From 5044a98e3b0c86902ca6e7e386fc0ec35d22377f Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 00:53:21 +0300 Subject: [PATCH 01/67] Octree fixes --- jsconfig.json | 1 - source/engine/server/Com.mjs | 2 +- source/shared/Octree.mjs | 90 ++++++++++++++++--------- source/shared/Q.mjs | 1 + test/common/octree.test.mjs | 125 +++++++++++++++++++++++++++++++++++ 5 files changed, 185 insertions(+), 34 deletions(-) create mode 100644 test/common/octree.test.mjs diff --git a/jsconfig.json b/jsconfig.json index 87b17ae0..3932737f 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -4,7 +4,6 @@ "moduleResolution": "bundler", "target": "es2024", "checkJs": true, - "baseUrl": ".", }, "exclude": ["node_modules"], "include": [ diff --git a/source/engine/server/Com.mjs b/source/engine/server/Com.mjs index 4eddc59a..b5dc2ddf 100644 --- a/source/engine/server/Com.mjs +++ b/source/engine/server/Com.mjs @@ -99,7 +99,7 @@ export default class NodeCOM extends COM { /** * Loads and parses a pack file. * @param {string} packfile - The path to the pack file. - * @returns {Promise | undefined>} - The parsed pack file entries or undefined if the file doesn't exist. + * @returns {Promise | null>} - The parsed pack file entries or null if the file doesn't exist. */ static async LoadPackFile(packfile) { if (!existsSync(`data/${packfile}`)) { // CR: wanna see something ugly? check out the async version of existsSync… diff --git a/source/shared/Octree.mjs b/source/shared/Octree.mjs index ca6d9bc6..b3ba494f 100644 --- a/source/shared/Octree.mjs +++ b/source/shared/Octree.mjs @@ -1,9 +1,9 @@ import Vector from './Vector.mjs'; -/** @typedef {{origin: Vector, absmin: Vector|null, absmax: Vector|null, octreeNode: OctreeNode|null}} OctreeItem */ +/** @typedef {{origin: Vector, absmin: Vector|null, absmax: Vector|null, octreeNode: OctreeNode|null}} OctreeItem */ /** - * Octree node holding an spatial indexed item. + * Octree node holding a spatially indexed item. * @template {OctreeItem} T */ export class OctreeNode { @@ -23,7 +23,7 @@ export class OctreeNode { this.totalCount = 0; /** @type {T[]} */ this.items = []; - /** @type {?OctreeNode[]} */ + /** @type {OctreeNode[]|null} */ this.children = null; } @@ -59,12 +59,13 @@ export class OctreeNode { /** * Subdivides this node into eight children. + * @returns {OctreeNode[]} created children */ #subdivide() { const hs = this.halfSize / 2; const offs = [-hs, hs]; - - this.children = []; + /** @type {OctreeNode[]} */ + const children = []; for (let ix = 0; ix < 2; ix++) { for (let iy = 0; iy < 2; iy++) { @@ -75,10 +76,30 @@ export class OctreeNode { this.center[2] + offs[iz], ); - this.children.push(new OctreeNode(c, hs, this.capacity, this.minSize, this)); + children.push(new OctreeNode(c, hs, this.capacity, this.minSize, this)); } } } + + this.children = children; + return children; + } + + /** + * Inserts an item into the first child that fully contains it. + * @param {T} item item to insert + * @param {OctreeNode[]} children child nodes + * @returns {OctreeNode|null} child node that accepted the item, if any + */ + #insertIntoChildren(item, children) { + for (const child of children) { + const node = child.insert(item); + if (node !== null) { + return node; + } + } + + return null; } /** @@ -98,7 +119,9 @@ export class OctreeNode { } } - if (this.children === null) { + let children = this.children; + + if (children === null) { // is there enough space? if so, add it here if (this.items.length < this.capacity || this.halfSize <= this.minSize) { this.items.push(obj); @@ -109,7 +132,7 @@ export class OctreeNode { // split // temporarily reduce count for items we are about to move this.#updateCount(-this.items.length); - this.#subdivide(); + children = this.#subdivide(); // move items into children const old = this.items; @@ -117,18 +140,14 @@ export class OctreeNode { // re-insert old items for (const item of old) { - let inserted = false; - for (const ch of this.children) { - const node = ch.insert(item); - if (node) { - if (item.octreeNode) { - item.octreeNode = node; - } - inserted = true; - break; + const node = this.#insertIntoChildren(item, children); + if (node !== null) { + if (item.octreeNode) { + item.octreeNode = node; } } - if (!inserted) { + + if (node === null) { this.items.push(item); // keep in parent if it doesn’t fit in any child if (item.octreeNode) { item.octreeNode = this; @@ -139,11 +158,9 @@ export class OctreeNode { } // insert into child - for (const ch of this.children) { - const node = ch.insert(obj); - if (node) { - return node; - } + const node = this.#insertIntoChildren(obj, children); + if (node !== null) { + return node; } // if it didn’t fit in any child (e.g. straddles boundary), keep it here @@ -215,11 +232,14 @@ export class OctreeNode { */ #getAllItems() { let items = [...this.items]; - if (this.children) { - for (const ch of this.children) { - items = items.concat(ch.#getAllItems()); + const children = this.children; + + if (children !== null) { + for (const child of children) { + items = items.concat(child.#getAllItems()); } } + return items; } @@ -228,6 +248,7 @@ export class OctreeNode { * @param {Vector} mins minimum bounds * @param {Vector} maxs maximum bounds * @yields {T} item + * @returns {IterableIterator} items inside AABB */ *queryAABB(mins, maxs) { // AABB-AABB intersection test @@ -268,9 +289,11 @@ export class OctreeNode { } // traverse children - if (this.children) { - for (const ch of this.children) { - yield* ch.queryAABB(mins, maxs); + const children = this.children; + + if (children !== null) { + for (const child of children) { + yield* child.queryAABB(mins, maxs); } } } @@ -280,6 +303,7 @@ export class OctreeNode { * @param {Vector} point position * @param {number} radius radius * @yields {[number, T]} distance and item + * @returns {IterableIterator<[number, T]>} items inside sphere */ *querySphere(point, radius) { // AABB-sphere intersection test @@ -304,9 +328,11 @@ export class OctreeNode { } // traverse children - if (this.children) { - for (const ch of this.children) { - yield* ch.querySphere(point, radius); + const children = this.children; + + if (children !== null) { + for (const child of children) { + yield* child.querySphere(point, radius); } } } diff --git a/source/shared/Q.mjs b/source/shared/Q.mjs index d0944970..78eabb69 100644 --- a/source/shared/Q.mjs +++ b/source/shared/Q.mjs @@ -156,6 +156,7 @@ export const enumHelpers = Object.freeze({ * @returns {string|number|null} enum value */ fromKey(name) { + // @ts-ignore return this[name] ?? null; }, }); diff --git a/test/common/octree.test.mjs b/test/common/octree.test.mjs new file mode 100644 index 00000000..907893dc --- /dev/null +++ b/test/common/octree.test.mjs @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { Octree } from '../../source/shared/Octree.mjs'; +import Vector from '../../source/shared/Vector.mjs'; + +/** @typedef {import('../../source/shared/Octree.mjs').OctreeNode} TestOctreeNode */ + +/** + * @typedef TestItem + * @property {string} name item identifier + * @property {Vector} origin item origin + * @property {Vector|null} absmin minimum bounds when the item has box extents + * @property {Vector|null} absmax maximum bounds when the item has box extents + * @property {TestOctreeNode|null} octreeNode node currently storing the item + */ + +/** + * @param {string} name item identifier + * @param {Vector} origin item origin + * @returns {TestItem} point item + */ +function createPointItem(name, origin) { + return { + name, + origin, + absmin: null, + absmax: null, + octreeNode: null, + }; +} + +/** + * @param {string} name item identifier + * @param {Vector} origin item origin + * @param {Vector} mins local minimum bounds + * @param {Vector} maxs local maximum bounds + * @returns {TestItem} bounded item + */ +function createBoxItem(name, origin, mins, maxs) { + return { + name, + origin, + absmin: origin.copy().add(mins), + absmax: origin.copy().add(maxs), + octreeNode: null, + }; +} + +/** + * @param {Octree} tree octree under test + * @param {TestItem} item item to insert and track + * @returns {TestOctreeNode} node that stored the item + */ +function insertTracked(tree, item) { + const node = tree.insert(item); + assert.notEqual(node, null); + item.octreeNode = node; + return node; +} + +/** + * @param {import('../../source/shared/Octree.mjs').OctreeNode} node node whose children must exist + * @returns {TestOctreeNode[]} node children + */ +function requireChildren(node) { + assert.notEqual(node.children, null); + return node.children; +} + +describe('Octree', () => { + test('splits into children once capacity is exceeded', () => { + const tree = new Octree(new Vector(0, 0, 0), 16, 1, 1); + const first = createPointItem('first', new Vector(-4, -4, -4)); + const second = createPointItem('second', new Vector(4, 4, 4)); + + insertTracked(tree, first); + insertTracked(tree, second); + + const children = requireChildren(tree.root); + + assert.equal(children.length, 8); + assert.equal(tree.root.items.length, 0); + assert.equal(tree.root.totalCount, 2); + assert.notEqual(first.octreeNode, tree.root); + assert.notEqual(second.octreeNode, tree.root); + assert.deepEqual( + [...tree.queryAABB(new Vector(-16, -16, -16), new Vector(16, 16, 16))].map((item) => item.name).sort(), + ['first', 'second'], + ); + }); + + test('keeps oversized bounds in the parent after a split', () => { + const tree = new Octree(new Vector(0, 0, 0), 16, 1, 1); + const anchor = createPointItem('anchor', new Vector(10, 10, 10)); + const straddling = createBoxItem('straddling', new Vector(0, 0, 0), new Vector(-2, -2, -2), new Vector(2, 2, 2)); + + insertTracked(tree, anchor); + insertTracked(tree, straddling); + + requireChildren(tree.root); + + assert.equal(tree.root.items.length, 1); + assert.equal(tree.root.items[0], straddling); + assert.equal(straddling.octreeNode, tree.root); + assert.notEqual(anchor.octreeNode, tree.root); + }); + + test('merges children back into the parent when removals drop below capacity', () => { + const tree = new Octree(new Vector(0, 0, 0), 16, 1, 1); + const first = createPointItem('first', new Vector(-4, -4, -4)); + const second = createPointItem('second', new Vector(4, 4, 4)); + + insertTracked(tree, first); + insertTracked(tree, second); + + assert.equal(tree.remove(second), true); + + assert.equal(tree.root.children, null); + assert.equal(tree.root.totalCount, 1); + assert.deepEqual(tree.root.items.map((item) => item.name), ['first']); + assert.equal(first.octreeNode, tree.root); + assert.equal(second.octreeNode, null); + }); +}); From 5821c7fa3abb7aa38181bf14ec50ebe7bb0d0387 Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 11:05:06 +0300 Subject: [PATCH 02/67] introducing TypeScript tooling --- eslint.config.mjs | 54 ++++++++++++++++++++++++++++------- jsconfig.json | 21 -------------- package.json | 3 ++ tsconfig.json | 60 +++++++++++++++++++++++++++++++++++++++ vite.config.dedicated.mjs | 5 +++- vite.config.mjs | 1 + 6 files changed, 112 insertions(+), 32 deletions(-) delete mode 100644 jsconfig.json create mode 100644 tsconfig.json diff --git a/eslint.config.mjs b/eslint.config.mjs index 24b8b520..8746b711 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,4 +1,6 @@ import { defineConfig } from 'eslint/config'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; import globals from 'globals'; import pluginJs from '@eslint/js'; import jsdoc from 'eslint-plugin-jsdoc'; @@ -6,6 +8,14 @@ import stylistic from '@stylistic/eslint-plugin'; import tseslint from '@typescript-eslint/eslint-plugin'; import tsparser from '@typescript-eslint/parser'; +const __dirname = dirname(fileURLToPath(import.meta.url)); +const typeAwareParserOptions = { + ecmaVersion: 'latest', + sourceType: 'module', + projectService: true, + tsconfigRootDir: __dirname, +}; + const jsdocPlugin = /** @type {import('eslint').ESLint.Plugin} */ (jsdoc); const stylisticPlugin = /** @type {import('eslint').ESLint.Plugin} */ (stylistic); const typeScriptEslintPlugin = /** @type {import('eslint').ESLint.Plugin} */ ( @@ -86,11 +96,7 @@ export default defineConfig([ languageOptions: { globals: globals.browser, parser: tsparser, - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - project: './jsconfig.json', - }, + parserOptions: typeAwareParserOptions, }, plugins: { '@stylistic': stylisticPlugin, @@ -99,29 +105,57 @@ export default defineConfig([ }, rules: commonRules, }, + { + files: ['**/*.{ts,mts,cts}'], + languageOptions: { + globals: globals.browser, + parser: tsparser, + parserOptions: typeAwareParserOptions, + }, + plugins: { + '@stylistic': stylisticPlugin, + '@typescript-eslint': typeScriptEslintPlugin, + jsdoc: jsdocPlugin, + }, + rules: { + ...commonRules, + 'no-undef': 'off', + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': ['error', { + argsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }], + }, + }, { files: [ 'dedicated.mjs', + 'dedicated.ts', 'eslint.config.mjs', + 'eslint.config.ts', 'vite.config.mjs', + 'vite.config.ts', 'vite.config.dedicated.mjs', + 'vite.config.dedicated.ts', 'source/engine/main-dedicated.mjs', - 'source/engine/server/**/*.mjs', - 'source/engine/common/**/*.mjs', - 'test/**/*.mjs', + 'source/engine/main-dedicated.ts', + 'source/engine/server/**/*.{mjs,ts,mts,cts}', + 'source/engine/common/**/*.{mjs,ts,mts,cts}', + 'test/**/*.{mjs,ts,mts,cts}', ], languageOptions: { globals: nodeGlobals, }, }, { - files: ['source/cloudflare/**/*.mjs'], + files: ['source/cloudflare/**/*.{mjs,ts,mts,cts}'], languageOptions: { globals: globals.serviceworker, }, }, { - files: ['**/*.cjs'], + files: ['**/*.{cjs,cts}'], languageOptions: { sourceType: 'commonjs', }, diff --git a/jsconfig.json b/jsconfig.json deleted file mode 100644 index 3932737f..00000000 --- a/jsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "module": "esnext", - "moduleResolution": "bundler", - "target": "es2024", - "checkJs": true, - }, - "exclude": ["node_modules"], - "include": [ - "./source/engine/**/*.mjs", - "./source/engine/**/*.d.ts", - "./source/game/**/*.mjs", - "./source/game/**/*.d.ts", - "./source/shared/**/*.mjs", - "./source/shared/**/*.d.ts", - "./source/cloudflare/**/*.mjs", - "./source/cloudflare/**/*.d.ts", - "./test/**/*.mjs", - "./*.mjs" - ] -} diff --git a/package.json b/package.json index ad9e0861..0dbc4a77 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "node": ">=24 <25" }, "devDependencies": { + "@types/node": "^24.6.1", "@eslint/js": "^9.17.0", "@stylistic/eslint-plugin": "^5.2.2", "@typescript-eslint/eslint-plugin": "^8.46.4", @@ -34,6 +35,8 @@ "test:common": "node --test test/common/**/*.test.mjs", "test:physics": "node --test test/physics/**/*.test.mjs", "test:renderer": "node --test test/renderer/**/*.test.mjs", + "typecheck": "tsc -p tsconfig.json", + "typecheck:watch": "tsc -p tsconfig.json --watch --preserveWatchOutput", "build:wrangler": "npm run test && npm run build:production", "dev": "vite build --watch --mode development", "build": "vite build", diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..f16999f2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,60 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["node"], + "allowJs": true, + "checkJs": true, + "noEmit": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "useDefineForClassFields": true, + "moduleDetection": "force", + "skipLibCheck": true, + "paths": { + "@/*": ["./source/*"] + } + }, + "exclude": ["node_modules", "dist"], + "include": [ + "./source/engine/**/*.mjs", + "./source/engine/**/*.cjs", + "./source/engine/**/*.ts", + "./source/engine/**/*.mts", + "./source/engine/**/*.cts", + "./source/engine/**/*.d.ts", + "./source/game/**/*.mjs", + "./source/game/**/*.cjs", + "./source/game/**/*.ts", + "./source/game/**/*.mts", + "./source/game/**/*.cts", + "./source/game/**/*.d.ts", + "./source/shared/**/*.mjs", + "./source/shared/**/*.cjs", + "./source/shared/**/*.ts", + "./source/shared/**/*.mts", + "./source/shared/**/*.cts", + "./source/shared/**/*.d.ts", + "./source/cloudflare/**/*.mjs", + "./source/cloudflare/**/*.cjs", + "./source/cloudflare/**/*.ts", + "./source/cloudflare/**/*.mts", + "./source/cloudflare/**/*.cts", + "./source/cloudflare/**/*.d.ts", + "./test/**/*.mjs", + "./test/**/*.cjs", + "./test/**/*.ts", + "./test/**/*.mts", + "./test/**/*.cts", + "./*.mjs", + "./*.cjs", + "./*.ts", + "./*.mts", + "./*.cts" + ] +} diff --git a/vite.config.dedicated.mjs b/vite.config.dedicated.mjs index fbd17cca..2621474a 100644 --- a/vite.config.dedicated.mjs +++ b/vite.config.dedicated.mjs @@ -118,7 +118,7 @@ function dedicatedWorkerBundlePlugin(mode) { // We undo that here so Rollup can resolve and bundle them. name: 'resolve-worker-dynamic-imports', transform(code, id) { - if (!id.includes('WorkerFramework')) return null; + if (!id.includes('WorkerFramework')) { return null; } // Replace the two-step variable + import() patterns with direct // literal import() calls so Rollup can statically resolve them. return { @@ -186,6 +186,8 @@ export default defineConfig(({ mode }) => ({ if (id.includes('/source/engine/') || id.includes('/source/shared/')) { return 'engine'; } + + return null; }, }, }, @@ -211,6 +213,7 @@ export default defineConfig(({ mode }) => ({ alias: { '@': resolve(__dirname, 'source'), }, + extensions: ['.ts', '.mts', '.mjs', '.js', '.json'], preserveSymlinks: process.env.VITE_PRESERVE_SYMLINKS === 'true', }, })); diff --git a/vite.config.mjs b/vite.config.mjs index b45870dd..6d8808dd 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -122,6 +122,7 @@ export default defineConfig(({ mode }) => ({ alias: { '@': resolve(__dirname, 'source'), }, + extensions: ['.ts', '.mts', '.mjs', '.js', '.json'], preserveSymlinks: process.env.VITE_PRESERVE_SYMLINKS === 'true', }, })); From e4c5888a8619e11a8873cd8fc7920689392fb972 Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 12:20:00 +0300 Subject: [PATCH 03/67] TS: Defs and Vector --- dedicated.mjs | 2 +- package-lock.json | 21 + package.json | 10 +- source/engine/client/CL.mjs | 2 +- source/engine/client/ClientEntities.mjs | 2 +- source/engine/client/ClientLegacy.mjs | 2 +- source/engine/client/ClientLifecycle.mjs | 2 +- source/engine/client/ClientMessages.mjs | 2 +- .../client/ClientServerCommandHandlers.mjs | 2 +- source/engine/client/R.mjs | 2 +- source/engine/client/SCR.mjs | 2 +- source/engine/client/V.mjs | 2 +- .../client/renderer/AliasModelRenderer.mjs | 2 +- source/engine/client/renderer/BloomEffect.mjs | 2 +- source/engine/client/renderer/Mesh.mjs | 2 +- source/engine/client/renderer/ShadowMap.mjs | 2 +- source/engine/common/Cmd.mjs | 10 +- source/engine/common/Console.mjs | 4 +- source/engine/common/Cvar.mjs | 2 +- source/engine/common/Def.mjs | 2 +- source/engine/common/GameAPIs.mjs | 212 ++++- source/engine/common/Host.mjs | 4 +- source/engine/common/Pmove.mjs | 4 +- source/engine/common/model/BSP.mjs | 2 +- .../common/model/loaders/BSP29Loader.mjs | 2 +- .../common/model/loaders/BSP38Loader.mjs | 2 +- source/engine/registry.d.ts | 44 +- source/engine/registry.mjs | 50 +- source/engine/server/Client.mjs | 2 +- source/engine/server/Edict.mjs | 2 +- source/engine/server/GameLoader.d.ts | 2 +- source/engine/server/Navigation.mjs | 2 +- source/engine/server/Progs.mjs | 2 +- source/engine/server/Server.mjs | 2 +- source/engine/server/ServerMessages.mjs | 2 +- source/engine/server/physics/ServerArea.mjs | 27 +- .../server/physics/ServerClientPhysics.mjs | 2 +- .../engine/server/physics/ServerCollision.mjs | 2 +- .../physics/ServerLegacyHullCollision.mjs | 2 +- .../engine/server/physics/ServerMovement.mjs | 2 +- .../engine/server/physics/ServerPhysics.mjs | 2 +- source/shared/Defs.mjs | 311 ------- source/shared/Defs.ts | 281 +++++++ source/shared/Octree.mjs | 2 +- source/shared/Q.mjs | 2 +- source/shared/Vector.mjs | 760 +----------------- source/shared/Vector.ts | 652 +++++++++++++++ source/shared/index.mjs | 4 +- test/common/game-apis.test.mjs | 161 ++++ test/common/vector-typescript-compat.test.mjs | 18 + test/physics/brushtrace.test.mjs | 2 +- test/physics/collision-regressions.test.mjs | 2 +- test/physics/fixtures.mjs | 2 +- test/physics/func-rotating.test.mjs | 2 +- test/physics/pmove.test.mjs | 2 +- test/physics/server-client-physics.test.mjs | 2 +- test/physics/server-collision.test.mjs | 2 +- test/physics/server-movement.test.mjs | 2 +- test/physics/server-physics.test.mjs | 2 +- test/renderer/bloom-effect.test.mjs | 2 +- vite.config.dedicated.mjs | 16 + 61 files changed, 1474 insertions(+), 1201 deletions(-) delete mode 100644 source/shared/Defs.mjs create mode 100644 source/shared/Defs.ts create mode 100644 source/shared/Vector.ts create mode 100644 test/common/game-apis.test.mjs create mode 100644 test/common/vector-typescript-compat.test.mjs diff --git a/dedicated.mjs b/dedicated.mjs index 69d0d5f0..791729ea 100755 --- a/dedicated.mjs +++ b/dedicated.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env node --experimental-transform-types import process from 'node:process'; import EngineLauncher from './source/engine/main-dedicated.mjs'; diff --git a/package-lock.json b/package-lock.json index a48bb776..484728ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "devDependencies": { "@eslint/js": "^9.17.0", "@stylistic/eslint-plugin": "^5.2.2", + "@types/node": "^24.6.1", "@typescript-eslint/eslint-plugin": "^8.46.4", "@typescript-eslint/parser": "^8.46.4", "eslint": "^9.19.0", @@ -27,6 +28,9 @@ "globals": "^15.14.0", "typescript": "^5.9.3", "vite": "^7.1.12" + }, + "engines": { + "node": ">=24 <25" } }, "node_modules/@es-joy/jsdoccomment": { @@ -1154,6 +1158,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", @@ -3473,6 +3487,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 0dbc4a77..b36669bf 100644 --- a/package.json +++ b/package.json @@ -31,10 +31,10 @@ "ws": "^8.18.0" }, "scripts": { - "test": "node --test test/**/*.test.mjs", - "test:common": "node --test test/common/**/*.test.mjs", - "test:physics": "node --test test/physics/**/*.test.mjs", - "test:renderer": "node --test test/renderer/**/*.test.mjs", + "test": "node --experimental-transform-types --test test/**/*.test.mjs", + "test:common": "node --experimental-transform-types --test test/common/**/*.test.mjs", + "test:physics": "node --experimental-transform-types --test test/physics/**/*.test.mjs", + "test:renderer": "node --experimental-transform-types --test test/renderer/**/*.test.mjs", "typecheck": "tsc -p tsconfig.json", "typecheck:watch": "tsc -p tsconfig.json --watch --preserveWatchOutput", "build:wrangler": "npm run test && npm run build:production", @@ -49,7 +49,7 @@ "dedicated:dev": "vite build --watch --mode development --config vite.config.dedicated.mjs", "dedicated:build": "vite build --config vite.config.dedicated.mjs", "dedicated:build:production": "vite build --config vite.config.dedicated.mjs --mode production", - "dedicated:start": "node ./dedicated.mjs +exec server.cfg", + "dedicated:start": "node --experimental-transform-types ./dedicated.mjs +exec server.cfg", "dedicated:start:production": "node ./dist/dedicated/dedicated.mjs +exec server.cfg" } } diff --git a/source/engine/client/CL.mjs b/source/engine/client/CL.mjs index 925707c3..7c6cc9bd 100644 --- a/source/engine/client/CL.mjs +++ b/source/engine/client/CL.mjs @@ -5,7 +5,7 @@ import Cmd, { ConsoleCommand } from '../common/Cmd.mjs'; import Cvar from '../common/Cvar.mjs'; import { Pmove, PmovePlayer } from '../common/Pmove.mjs'; import { eventBus, registry } from '../registry.mjs'; -import { gameCapabilities, solid } from '../../shared/Defs.mjs'; +import { gameCapabilities, solid } from '../../shared/Defs.ts'; import ClientDemos from './ClientDemos.mjs'; import { ClientPlayerState } from './ClientMessages.mjs'; import VID from './VID.mjs'; diff --git a/source/engine/client/ClientEntities.mjs b/source/engine/client/ClientEntities.mjs index ec61874f..03cfceb7 100644 --- a/source/engine/client/ClientEntities.mjs +++ b/source/engine/client/ClientEntities.mjs @@ -1,7 +1,7 @@ import Vector from '../../shared/Vector.mjs'; import { eventBus, registry } from '../registry.mjs'; import * as Def from '../common/Def.mjs'; -import { content, effect, solid } from '../../shared/Defs.mjs'; +import { content, effect, solid } from '../../shared/Defs.ts'; import Chase from './Chase.mjs'; import { DefaultClientEdictHandler } from './ClientLegacy.mjs'; import { BaseClientEdictHandler } from '../../shared/ClientEdict.mjs'; diff --git a/source/engine/client/ClientLegacy.mjs b/source/engine/client/ClientLegacy.mjs index 8e6c51d9..4f877112 100644 --- a/source/engine/client/ClientLegacy.mjs +++ b/source/engine/client/ClientLegacy.mjs @@ -4,7 +4,7 @@ */ import Vector from '../../shared/Vector.mjs'; -import { effect, modelFlags } from '../../shared/Defs.mjs'; +import { effect, modelFlags } from '../../shared/Defs.ts'; import { BaseClientEdictHandler } from '../../shared/ClientEdict.mjs'; import { registry, eventBus } from '../registry.mjs'; diff --git a/source/engine/client/ClientLifecycle.mjs b/source/engine/client/ClientLifecycle.mjs index b7291ea1..7fc8145a 100644 --- a/source/engine/client/ClientLifecycle.mjs +++ b/source/engine/client/ClientLifecycle.mjs @@ -1,7 +1,7 @@ import Cvar from '../common/Cvar.mjs'; import Cmd, { ConsoleCommand } from '../common/Cmd.mjs'; import * as Def from '../common/Def.mjs'; -import { gameCapabilities } from '../../shared/Defs.mjs'; +import { gameCapabilities } from '../../shared/Defs.ts'; import ClientInput from './ClientInput.mjs'; import CL from './CL.mjs'; import { clientRuntimeState } from './ClientState.mjs'; diff --git a/source/engine/client/ClientMessages.mjs b/source/engine/client/ClientMessages.mjs index d2305564..bd981f18 100644 --- a/source/engine/client/ClientMessages.mjs +++ b/source/engine/client/ClientMessages.mjs @@ -4,7 +4,7 @@ import { eventBus, registry } from '../registry.mjs'; import { HostError } from '../common/Errors.mjs'; import Vector from '../../shared/Vector.mjs'; import { PmovePlayer } from '../common/Pmove.mjs'; -import { gameCapabilities } from '../../shared/Defs.mjs'; +import { gameCapabilities } from '../../shared/Defs.ts'; import { ClientEdict } from './ClientEntities.mjs'; let { CL, COM, NET } = registry; diff --git a/source/engine/client/ClientServerCommandHandlers.mjs b/source/engine/client/ClientServerCommandHandlers.mjs index 1669de05..5b8da30d 100644 --- a/source/engine/client/ClientServerCommandHandlers.mjs +++ b/source/engine/client/ClientServerCommandHandlers.mjs @@ -2,7 +2,7 @@ import * as Protocol from '../network/Protocol.mjs'; import * as Def from '../common/Def.mjs'; import Cmd from '../common/Cmd.mjs'; import { HostError } from '../common/Errors.mjs'; -import { gameCapabilities } from '../../shared/Defs.mjs'; +import { gameCapabilities } from '../../shared/Defs.ts'; import Vector from '../../shared/Vector.mjs'; import { ClientEngineAPI } from '../common/GameAPIs.mjs'; import { sharedCollisionModelSource } from '../common/CollisionModelSource.mjs'; diff --git a/source/engine/client/R.mjs b/source/engine/client/R.mjs index 3b5d9318..a76808fd 100644 --- a/source/engine/client/R.mjs +++ b/source/engine/client/R.mjs @@ -8,7 +8,7 @@ import Chase from './Chase.mjs'; import W from '../common/W.mjs'; import VID from './VID.mjs'; import GL, { ATTRIB_LOCATIONS, GLTexture } from './GL.mjs'; -import { content, effect, gameCapabilities } from '../../shared/Defs.mjs'; +import { content, effect, gameCapabilities } from '../../shared/Defs.ts'; import { modelRendererRegistry } from './renderer/ModelRendererRegistry.mjs'; import { BrushModelRenderer, LIGHTMAP_BLOCK_HEIGHT, LIGHTMAP_BLOCK_SIZE } from './renderer/BrushModelRenderer.mjs'; import { AliasModelRenderer } from './renderer/AliasModelRenderer.mjs'; diff --git a/source/engine/client/SCR.mjs b/source/engine/client/SCR.mjs index aeb05c4f..6faecba3 100644 --- a/source/engine/client/SCR.mjs +++ b/source/engine/client/SCR.mjs @@ -1,6 +1,6 @@ /* global */ -import { gameCapabilities } from '../../shared/Defs.mjs'; +import { gameCapabilities } from '../../shared/Defs.ts'; import Cmd from '../common/Cmd.mjs'; import Cvar from '../common/Cvar.mjs'; import { clientConnectionState } from '../common/Def.mjs'; diff --git a/source/engine/client/V.mjs b/source/engine/client/V.mjs index d1638524..e5f48598 100644 --- a/source/engine/client/V.mjs +++ b/source/engine/client/V.mjs @@ -1,5 +1,5 @@ import Vector from '../../shared/Vector.mjs'; -import { content, gameCapabilities } from '../../shared/Defs.mjs'; +import { content, gameCapabilities } from '../../shared/Defs.ts'; import Cmd from '../common/Cmd.mjs'; import Cvar from '../common/Cvar.mjs'; import * as Def from '../common/Def.mjs'; diff --git a/source/engine/client/renderer/AliasModelRenderer.mjs b/source/engine/client/renderer/AliasModelRenderer.mjs index b33520fb..cfdb63bf 100644 --- a/source/engine/client/renderer/AliasModelRenderer.mjs +++ b/source/engine/client/renderer/AliasModelRenderer.mjs @@ -4,7 +4,7 @@ import { getEntityBloomEmissiveScale } from './BloomEffect.mjs'; import { eventBus, registry } from '../../registry.mjs'; import GL from '../GL.mjs'; import W from '../../common/W.mjs'; -import { effect } from '../../../shared/Defs.mjs'; +import { effect } from '../../../shared/Defs.ts'; let { CL, Host, R, Con } = registry; let gl = /** @type {WebGL2RenderingContext} */ (null); diff --git a/source/engine/client/renderer/BloomEffect.mjs b/source/engine/client/renderer/BloomEffect.mjs index 90cf07b9..477393a7 100644 --- a/source/engine/client/renderer/BloomEffect.mjs +++ b/source/engine/client/renderer/BloomEffect.mjs @@ -4,7 +4,7 @@ import VID from '../VID.mjs'; import PostProcessEffect from './PostProcessEffect.mjs'; import Vector from '../../../shared/Vector.mjs'; import { eventBus, registry } from '../../registry.mjs'; -import { effect } from '../../../shared/Defs.mjs'; +import { effect } from '../../../shared/Defs.ts'; let { Draw, R } = registry; diff --git a/source/engine/client/renderer/Mesh.mjs b/source/engine/client/renderer/Mesh.mjs index bc1485f7..0e987e0e 100644 --- a/source/engine/client/renderer/Mesh.mjs +++ b/source/engine/client/renderer/Mesh.mjs @@ -1,4 +1,4 @@ -import { EPSILON } from '../../../shared/Defs.mjs'; +import { EPSILON } from '../../../shared/Defs.ts'; /** * Mesh stuff. diff --git a/source/engine/client/renderer/ShadowMap.mjs b/source/engine/client/renderer/ShadowMap.mjs index a8c076b3..d551bf61 100644 --- a/source/engine/client/renderer/ShadowMap.mjs +++ b/source/engine/client/renderer/ShadowMap.mjs @@ -3,7 +3,7 @@ import Cvar from '../../common/Cvar.mjs'; import { limits } from '../../common/Def.mjs'; import { eventBus, registry } from '../../registry.mjs'; import { materialFlags } from './Materials.mjs'; -import { effect } from '../../../shared/Defs.mjs'; +import { effect } from '../../../shared/Defs.ts'; import Vector from '../../../shared/Vector.mjs'; import { AliasModelRenderer } from './AliasModelRenderer.mjs'; diff --git a/source/engine/common/Cmd.mjs b/source/engine/common/Cmd.mjs index d223b1eb..cb2633d7 100644 --- a/source/engine/common/Cmd.mjs +++ b/source/engine/common/Cmd.mjs @@ -19,9 +19,9 @@ eventBus.subscribe('registry.frozen', () => { export class ConsoleCommand { /** @type {?import('../server/Edict.mjs').ServerClient} Invoking server client. Unset, when called locally. */ client = null; - /** @type {string} The name that was used to execute this command. */ + /** @type {string?} The name that was used to execute this command. */ command = null; - /** @type {string} Full command line. */ + /** @type {string?} Full command line. */ args = null; /** @type {string[]} Arguments including the name. */ argv = []; @@ -52,10 +52,10 @@ export class ConsoleCommand { let command = this.command; if (command && command.toLowerCase() === 'cmd') { - command = argv.shift(); + command = argv.shift() || null; } - if (command === undefined) { + if (command === null) { Con.Print('Usage: cmd \n'); return true; } @@ -101,7 +101,7 @@ class ForwardCommand extends ConsoleCommand { class ExecSlot { constructor(/** @type {string} */ filename) { this.filename = filename; - this.content = null; + this.content = /** @type {string|null} */ (null); this.isReady = false; } }; diff --git a/source/engine/common/Console.mjs b/source/engine/common/Console.mjs index d3552b03..9962a9d8 100644 --- a/source/engine/common/Console.mjs +++ b/source/engine/common/Console.mjs @@ -22,9 +22,9 @@ eventBus.subscribe('registry.frozen', () => { export default class Con { static backscroll = 0; static current = 0; - static text = []; + static text = /** @type {{ text: string, time: number, color: Vector, doNotNotify: boolean }[]} */([]); static captureBuffer = null; - /** @type {Cvar} */ + /** @type {Cvar?} */ static notifytime = null; /** used by the client to force the console to be up */ diff --git a/source/engine/common/Cvar.mjs b/source/engine/common/Cvar.mjs index 34497672..ed9e3390 100644 --- a/source/engine/common/Cvar.mjs +++ b/source/engine/common/Cvar.mjs @@ -1,7 +1,7 @@ import { registry, eventBus } from '../registry.mjs'; import Cmd from './Cmd.mjs'; import Q from '../../shared/Q.mjs'; -import { cvarFlags } from '../../shared/Defs.mjs'; +import { cvarFlags } from '../../shared/Defs.ts'; let { CL, Con, SV } = registry; diff --git a/source/engine/common/Def.mjs b/source/engine/common/Def.mjs index c6be3f55..1bf79059 100644 --- a/source/engine/common/Def.mjs +++ b/source/engine/common/Def.mjs @@ -7,7 +7,7 @@ export const productName = 'The Quake Shack'; /** * Version string. */ -export const productVersion = '1.1.8'; +export const productVersion = '1.2.0'; /** * Default game directory. diff --git a/source/engine/common/GameAPIs.mjs b/source/engine/common/GameAPIs.mjs index 519e2309..a6ac457d 100644 --- a/source/engine/common/GameAPIs.mjs +++ b/source/engine/common/GameAPIs.mjs @@ -1,5 +1,6 @@ import { PmoveConfiguration } from '../../shared/Pmove.mjs'; import Vector from '../../shared/Vector.mjs'; +import { solid } from '../../shared/Defs.ts'; import Key from '../client/Key.mjs'; import { SFX } from '../client/Sound.mjs'; import VID from '../client/VID.mjs'; @@ -19,7 +20,22 @@ import W from './W.mjs'; /** @typedef {import('../server/Navigation.mjs').Navigation} Navigation */ /** @typedef {import('./model/parsers/ParsedQC.mjs').default} ParsedQC */ /** @typedef {import('./model/BaseModel.mjs').BaseModel} BaseModel */ -/** @typedef {import('../server/physics/ServerCollision.mjs').Trace} Trace */ +/** @typedef {import('../server/physics/ServerCollisionSupport.mjs').CollisionTrace} CollisionTrace */ +/** + * @typedef ClientTraceOptions + * @property {boolean} [includeEntities] include current client entities in addition to static world geometry + * @property {?number} [passEntityId] client entity number to skip during entity tracing + * @property {?((entity: ClientEdict) => boolean)} [filter] optional candidate filter for entity tracing + */ +/** + * @typedef GameTrace + * @property {{ all: boolean, start: boolean }} solid solid hit flags + * @property {number} fraction completed trace fraction + * @property {{ normal: Vector, distance: number }} plane impact plane + * @property {{ inOpen: boolean, inWater: boolean }} contents terminal contents flags + * @property {Vector} point final trace point + * @property {import('../../game/id1/entity/BaseEntity.mjs').default|ClientEdict|null} entity hit entity, if any + */ let { CL, Con, Draw, Host, R, S, SCR, SV, V} = registry; @@ -93,6 +109,164 @@ function internalTraceToGameTrace(trace) { }; } +/** + * @param {ClientEdict} entity client entity candidate + * @returns {boolean} true when the entity can be traced against + */ +function isTraceableClientSolid(entity) { + return entity.solid === solid.SOLID_BBOX + || entity.solid === solid.SOLID_SLIDEBOX + || entity.solid === solid.SOLID_BSP + || entity.solid === solid.SOLID_MESH; +} + +/** + * @param {ClientEdict} entity client entity candidate + * @returns {{ mins: Vector, maxs: Vector }} extents used for tracing this entity + */ +function getClientTraceExtents(entity) { + if (entity.model !== null && entity.mins.isOrigin() && entity.maxs.isOrigin()) { + return { + mins: entity.model.mins, + maxs: entity.model.maxs, + }; + } + + return { + mins: entity.mins, + maxs: entity.maxs, + }; +} + +/** + * @param {ClientEdict} entity client entity candidate + * @param {Vector} absmin output minimum bounds + * @param {Vector} absmax output maximum bounds + */ +function computeClientTraceBounds(entity, absmin, absmax) { + const { mins, maxs } = getClientTraceExtents(entity); + + if (!entity.angles.isOrigin()) { + const basis = entity.angles.toRotationMatrix(); + const forward = new Vector(basis[0], basis[1], basis[2]); + const right = new Vector(basis[3], basis[4], basis[5]); + const up = new Vector(basis[6], basis[7], basis[8]); + + const centerX = (mins[0] + maxs[0]) * 0.5; + const centerY = (mins[1] + maxs[1]) * 0.5; + const centerZ = (mins[2] + maxs[2]) * 0.5; + const extentsX = (maxs[0] - mins[0]) * 0.5; + const extentsY = (maxs[1] - mins[1]) * 0.5; + const extentsZ = (maxs[2] - mins[2]) * 0.5; + + const worldCenter = entity.origin.copy() + .add(forward.copy().multiply(centerX)) + .add(right.copy().multiply(centerY)) + .add(up.copy().multiply(centerZ)); + + const worldExtentX = Math.abs(forward[0]) * extentsX + Math.abs(right[0]) * extentsY + Math.abs(up[0]) * extentsZ; + const worldExtentY = Math.abs(forward[1]) * extentsX + Math.abs(right[1]) * extentsY + Math.abs(up[1]) * extentsZ; + const worldExtentZ = Math.abs(forward[2]) * extentsX + Math.abs(right[2]) * extentsY + Math.abs(up[2]) * extentsZ; + + absmin.setTo( + worldCenter[0] - worldExtentX, + worldCenter[1] - worldExtentY, + worldCenter[2] - worldExtentZ, + ); + absmax.setTo( + worldCenter[0] + worldExtentX, + worldCenter[1] + worldExtentY, + worldCenter[2] + worldExtentZ, + ); + return; + } + + absmin.set(entity.origin).add(mins); + absmax.set(entity.origin).add(maxs); +} + +/** + * @param {Vector} traceMins trace minimum bounds + * @param {Vector} traceMaxs trace maximum bounds + * @param {Vector} entityMins entity minimum bounds + * @param {Vector} entityMaxs entity maximum bounds + * @returns {boolean} true when the AABBs overlap + */ +function traceBoundsOverlap(traceMins, traceMaxs, entityMins, entityMaxs) { + return !( + traceMins[0] > entityMaxs[0] + || traceMins[1] > entityMaxs[1] + || traceMins[2] > entityMaxs[2] + || traceMaxs[0] < entityMins[0] + || traceMaxs[1] < entityMins[1] + || traceMaxs[2] < entityMins[2] + ); +} + +/** + * @param {Vector} start trace start + * @param {Vector} end trace end + * @param {CollisionTrace} worldTrace current static-world trace result + * @param {ClientTraceOptions} options client trace options + * @returns {CollisionTrace} best trace including eligible client entities + */ +function traceClientEntities(start, end, worldTrace, options) { + const traceMins = new Vector( + Math.min(start[0], worldTrace.endpos[0]), + Math.min(start[1], worldTrace.endpos[1]), + Math.min(start[2], worldTrace.endpos[2]), + ); + const traceMaxs = new Vector( + Math.max(start[0], worldTrace.endpos[0]), + Math.max(start[1], worldTrace.endpos[1]), + Math.max(start[2], worldTrace.endpos[2]), + ); + const entityMins = new Vector(); + const entityMaxs = new Vector(); + + /** @type {CollisionTrace} */ + let bestTrace = worldTrace; + + for (const entity of CL.state.clientEntities.getEntities()) { + if (entity.num === 0 || entity.free || entity.origin.isInfinite() || entity.model === null) { + continue; + } + + if (!isTraceableClientSolid(entity)) { + continue; + } + + if (options.passEntityId !== null && options.passEntityId !== undefined && entity.num === options.passEntityId) { + continue; + } + + if (options.filter !== null && options.filter !== undefined && !options.filter(entity)) { + continue; + } + + computeClientTraceBounds(entity, entityMins, entityMaxs); + + if (!traceBoundsOverlap(traceMins, traceMaxs, entityMins, entityMaxs)) { + continue; + } + + const trace = SV.collision.clipMoveToEntity({ + // @ts-ignore Client tracing reuses shared narrow-phase helpers with a lightweight ClientEdict adapter. + entity, + num: entity.num, + equals(other) { + return this === other; + }, + }, start, Vector.origin, Vector.origin, bestTrace.endpos); + + if (trace.allsolid || trace.startsolid || trace.fraction < bestTrace.fraction) { + bestTrace = trace; + } + } + + return bestTrace; +} + export class CommonEngineAPI { /** * Indicates whether the game is registered (not shareware). @@ -373,10 +547,11 @@ export class ServerEngineAPI extends CommonEngineAPI { } /** - * @param field - * @param value - * @param startEdictId + * @param {string} field field name to inspect on each entity + * @param {import('../../shared/GameInterfaces').EdictValueType} value target field value + * @param {number} startEdictId entity number to start searching from * @deprecated use FindAllByFieldAndValue instead + * @returns {ServerEdict|null} first matching edict, if any */ static FindByFieldAndValue(field, value, startEdictId = 0) { // FIXME: startEdictId should be edict? not 100% happy about this for (let i = startEdictId; i < SV.server.num_edicts; i++) { @@ -497,8 +672,8 @@ export class ServerEngineAPI extends CommonEngineAPI { } /** - * @param tempEntityId - * @param origin + * @param {number} tempEntityId temporary entity protocol id + * @param {Vector} origin event origin * @deprecated use client events instead */ static DispatchTempEntityEvent(tempEntityId, origin) { @@ -508,10 +683,10 @@ export class ServerEngineAPI extends CommonEngineAPI { } /** - * @param beamId - * @param edictId - * @param startOrigin - * @param endOrigin + * @param {number} beamId beam protocol id + * @param {number} edictId source entity id + * @param {Vector} startOrigin beam start position + * @param {Vector} endOrigin beam end position * @deprecated use client events instead */ static DispatchBeamEvent(beamId, edictId, startOrigin, endOrigin) { @@ -805,16 +980,23 @@ export class ClientEngineAPI extends CommonEngineAPI { /** * Performs a trace line in the client game world. - * Currently this traces static world geometry only. + * By default this traces static world geometry only. * Keep this legacy entry point aligned with the server-side Traceline name so * client tracing can grow into entity-aware behavior later without another API * rename. * @param {Vector} start start position * @param {Vector} end end position - * @returns {Trace} trace result + * @param {ClientTraceOptions} [options] optional entity-tracing options + * @returns {GameTrace} trace result */ - static Traceline(start, end) { - return internalTraceToGameTrace(SV.collision.traceWorldLine(start, end)); + static Traceline(start, end, options = null) { + const worldTrace = SV.collision.traceWorldLine(start, end); + + if (options === null || options.includeEntities !== true) { + return internalTraceToGameTrace(worldTrace); + } + + return internalTraceToGameTrace(traceClientEntities(start, end, worldTrace, options)); } /** @@ -919,7 +1101,7 @@ export class ClientEngineAPI extends CommonEngineAPI { return CL.state.levelname; }, get entityNum() { - return CL.state.viewent.num; + return CL.state.viewentity; }, /** * local time, not game time! If you are looking for SV.server.time, check gametime diff --git a/source/engine/common/Host.mjs b/source/engine/common/Host.mjs index 797dcf93..d06d6097 100644 --- a/source/engine/common/Host.mjs +++ b/source/engine/common/Host.mjs @@ -11,8 +11,8 @@ import Chase from '../client/Chase.mjs'; import VID from '../client/VID.mjs'; import { HostError } from './Errors.mjs'; import CDAudio from '../client/CDAudio.mjs'; -import * as Defs from '../../shared/Defs.mjs'; -import { content, gameCapabilities } from '../../shared/Defs.mjs'; +import * as Defs from '../../shared/Defs.ts'; +import { content, gameCapabilities } from '../../shared/Defs.ts'; import ClientLifecycle from '../client/ClientLifecycle.mjs'; import { Pmove } from './Pmove.mjs'; diff --git a/source/engine/common/Pmove.mjs b/source/engine/common/Pmove.mjs index 212b7699..78ed49ce 100644 --- a/source/engine/common/Pmove.mjs +++ b/source/engine/common/Pmove.mjs @@ -9,7 +9,7 @@ import Vector from '../../shared/Vector.mjs'; import * as Protocol from '../network/Protocol.mjs'; -import { content } from '../../shared/Defs.mjs'; +import { content } from '../../shared/Defs.ts'; import { BrushModel } from './Mod.mjs'; import Cvar from './Cvar.mjs'; import { PmoveConfiguration } from '../../shared/Pmove.mjs'; @@ -3749,7 +3749,7 @@ export class Pmove { // pmove_t /** * Adds an entity (client or server) to physents. * @param {import('../server/Edict.mjs').BaseEntity|import('../client/ClientEntities.mjs').ClientEdict} entity actual entity - * @param {BrushModel} model model must be provided when entity is SOLID_BSP + * @param {BrushModel|null} model model must be provided when entity is SOLID_BSP * @returns {Pmove} this */ addEntity(entity, model = null) { diff --git a/source/engine/common/model/BSP.mjs b/source/engine/common/model/BSP.mjs index 34024804..e7b9e423 100644 --- a/source/engine/common/model/BSP.mjs +++ b/source/engine/common/model/BSP.mjs @@ -1,5 +1,5 @@ import { BaseMaterial } from '../../client/renderer/Materials.mjs'; -import { content } from '../../../shared/Defs.mjs'; +import { content } from '../../../shared/Defs.ts'; import { BaseModel } from './BaseModel.mjs'; import { SkyRenderer } from '../../client/renderer/Sky.mjs'; import { AreaPortals } from './AreaPortals.mjs'; diff --git a/source/engine/common/model/loaders/BSP29Loader.mjs b/source/engine/common/model/loaders/BSP29Loader.mjs index ba97ce7c..f59d1da1 100644 --- a/source/engine/common/model/loaders/BSP29Loader.mjs +++ b/source/engine/common/model/loaders/BSP29Loader.mjs @@ -1,6 +1,6 @@ import Vector from '../../../../shared/Vector.mjs'; import Q from '../../../../shared/Q.mjs'; -import { content } from '../../../../shared/Defs.mjs'; +import { content } from '../../../../shared/Defs.ts'; import { GLTexture } from '../../../client/GL.mjs'; import W, { readWad3Texture, translateIndexToLuminanceRGBA, translateIndexToRGBA } from '../../W.mjs'; import { CRC16CCITT } from '../../CRC.mjs'; diff --git a/source/engine/common/model/loaders/BSP38Loader.mjs b/source/engine/common/model/loaders/BSP38Loader.mjs index d487f12b..1508b4b4 100644 --- a/source/engine/common/model/loaders/BSP38Loader.mjs +++ b/source/engine/common/model/loaders/BSP38Loader.mjs @@ -1,4 +1,4 @@ -import { content } from '../../../../shared/Defs.mjs'; +import { content } from '../../../../shared/Defs.ts'; import Q from '../../../../shared/Q.mjs'; import Vector from '../../../../shared/Vector.mjs'; import { CRC16CCITT } from '../../CRC.mjs'; diff --git a/source/engine/registry.d.ts b/source/engine/registry.d.ts index 5f729819..96e0ee86 100644 --- a/source/engine/registry.d.ts +++ b/source/engine/registry.d.ts @@ -40,32 +40,32 @@ type IN = typeof _IN; type WebSocket = typeof WebSocketClass; interface Registry { - isDedicatedServer: boolean | null; + isDedicatedServer?: boolean; isInsideWorker: boolean; - COM: Com | null; - Con: Con | null; - Host: Host | null; - Sys: Sys | null; - V: V | null; - SV: SV | null; - PR: PR | null; - NET: NET | null; - Mod: Mod | null; - CL: CL | null; - SCR: SCR | null; - R: R | null; - Draw: Draw | null; - Key: Key | null; - IN: IN | null; - Sbar: Sbar | null; - S: S | null; - M: M | null; + COM?: Com; + Con?: Con; + Host?: Host; + Sys?: Sys; + V?: V; + SV?: SV; + PR?: PR; + NET?: NET; + Mod?: Mod; + CL?: CL; + SCR?: SCR; + R?: R; + Draw?: Draw; + Key?: Key; + IN?: IN; + Sbar?: Sbar; + S?: S; + M?: M; - WebSocket: WebSocket | null; + WebSocket?: WebSocket; - urls: URLs | null; - buildConfig: BuildConfig | null; + urls?: URLs; + buildConfig?: BuildConfig; }; export const registry: Registry; diff --git a/source/engine/registry.mjs b/source/engine/registry.mjs index 1a6c04e5..5b4241e6 100644 --- a/source/engine/registry.mjs +++ b/source/engine/registry.mjs @@ -6,32 +6,32 @@ * @type {import('./registry').Registry} */ export const registry = { - COM: null, - Con: null, - Host: null, - NET: null, - Draw: null, - Sys: null, - V: null, - CL: null, - SV: null, - Mod: null, - PR: null, - R: null, - SCR: null, - Key: null, - IN: null, - Sbar: null, - S: null, - M: null, - - WebSocket: null, - - urls: null, - buildConfig: null, + COM: undefined, + Con: undefined, + Host: undefined, + NET: undefined, + Draw: undefined, + Sys: undefined, + V: undefined, + CL: undefined, + SV: undefined, + Mod: undefined, + PR: undefined, + R: undefined, + SCR: undefined, + Key: undefined, + IN: undefined, + Sbar: undefined, + S: undefined, + M: undefined, + + WebSocket: undefined, + + urls: undefined, + buildConfig: undefined, /** @type {boolean} true, when running in server mode */ - isDedicatedServer: null, + isDedicatedServer: false, isInsideWorker: false, }; @@ -45,7 +45,7 @@ export class EventBus { #listeners = new Map(); /** @type {string} */ - #name = null; + #name; /** * @param {string} name name diff --git a/source/engine/server/Client.mjs b/source/engine/server/Client.mjs index afc78259..3004b7e7 100644 --- a/source/engine/server/Client.mjs +++ b/source/engine/server/Client.mjs @@ -1,5 +1,5 @@ import { enumHelpers } from '../../shared/Q.mjs'; -import { gameCapabilities } from '../../shared/Defs.mjs'; +import { gameCapabilities } from '../../shared/Defs.ts'; import Vector from '../../shared/Vector.mjs'; import { SzBuffer } from '../network/MSG.mjs'; import { QSocket } from '../network/NetworkDrivers.mjs'; diff --git a/source/engine/server/Edict.mjs b/source/engine/server/Edict.mjs index 535c6386..3a4e06f7 100644 --- a/source/engine/server/Edict.mjs +++ b/source/engine/server/Edict.mjs @@ -2,7 +2,7 @@ import Vector from '../../shared/Vector.mjs'; import { SzBuffer, registerSerializableType } from '../network/MSG.mjs'; import * as Protocol from '../network/Protocol.mjs'; import * as Def from '../common/Def.mjs'; -import * as Defs from '../../shared/Defs.mjs'; +import * as Defs from '../../shared/Defs.ts'; import { eventBus, registry } from '../registry.mjs'; import Q from '../../shared/Q.mjs'; import { ConsoleCommand } from '../common/Cmd.mjs'; diff --git a/source/engine/server/GameLoader.d.ts b/source/engine/server/GameLoader.d.ts index 02503360..6ebcea25 100644 --- a/source/engine/server/GameLoader.d.ts +++ b/source/engine/server/GameLoader.d.ts @@ -1,5 +1,5 @@ import { ClientGameInterface, ServerGameInterface } from "../../shared/GameInterfaces"; -import { gameCapabilities } from "../../shared/Defs.mjs"; +import { gameCapabilities } from "../../shared/Defs.ts"; export interface GameModuleIdentification { name: string; diff --git a/source/engine/server/Navigation.mjs b/source/engine/server/Navigation.mjs index 056c906f..cb206ab2 100644 --- a/source/engine/server/Navigation.mjs +++ b/source/engine/server/Navigation.mjs @@ -1,5 +1,5 @@ // import sampleBSpline from '../../shared/BSpline.mjs'; -import * as Def from '../../shared/Defs.mjs'; +import * as Def from '../../shared/Defs.ts'; import { Octree } from '../../shared/Octree.mjs'; import Vector from '../../shared/Vector.mjs'; import Cmd from '../common/Cmd.mjs'; diff --git a/source/engine/server/Progs.mjs b/source/engine/server/Progs.mjs index 7b76a85d..f5bcf82e 100644 --- a/source/engine/server/Progs.mjs +++ b/source/engine/server/Progs.mjs @@ -8,7 +8,7 @@ import { eventBus, registry } from '../registry.mjs'; import { ED, ServerEdict } from './Edict.mjs'; import { ServerEngineAPI } from '../common/GameAPIs.mjs'; import PF, { etype, ofs } from './ProgsAPI.mjs'; -import { gameCapabilities } from '../../shared/Defs.mjs'; +import { gameCapabilities } from '../../shared/Defs.ts'; import { loadGameModule } from './GameLoader.mjs'; const PR = {}; diff --git a/source/engine/server/Server.mjs b/source/engine/server/Server.mjs index 2547faa1..ea72f2cd 100644 --- a/source/engine/server/Server.mjs +++ b/source/engine/server/Server.mjs @@ -8,7 +8,7 @@ import Cmd, { ConsoleCommand } from '../common/Cmd.mjs'; import { ED, ServerEdict } from './Edict.mjs'; import { EventBus, eventBus, registry } from '../registry.mjs'; import { ServerEngineAPI } from '../common/GameAPIs.mjs'; -import * as Defs from '../../shared/Defs.mjs'; +import * as Defs from '../../shared/Defs.ts'; import { Navigation } from './Navigation.mjs'; import { ServerPhysics } from './physics/ServerPhysics.mjs'; import { ServerClientPhysics } from './physics/ServerClientPhysics.mjs'; diff --git a/source/engine/server/ServerMessages.mjs b/source/engine/server/ServerMessages.mjs index 221d183f..681283e0 100644 --- a/source/engine/server/ServerMessages.mjs +++ b/source/engine/server/ServerMessages.mjs @@ -1,6 +1,6 @@ import { SzBuffer } from '../network/MSG.mjs'; import * as Protocol from '../network/Protocol.mjs'; -import * as Defs from '../../shared/Defs.mjs'; +import * as Defs from '../../shared/Defs.ts'; import Cvar from '../common/Cvar.mjs'; import { eventBus, registry } from '../registry.mjs'; import { ServerClient } from './Client.mjs'; diff --git a/source/engine/server/physics/ServerArea.mjs b/source/engine/server/physics/ServerArea.mjs index 5f02cd4d..9a86fa46 100644 --- a/source/engine/server/physics/ServerArea.mjs +++ b/source/engine/server/physics/ServerArea.mjs @@ -1,5 +1,5 @@ import Vector from '../../../shared/Vector.mjs'; -import * as Defs from '../../../shared/Defs.mjs'; +import * as Defs from '../../../shared/Defs.ts'; import { Octree } from '../../../shared/Octree.mjs'; import { eventBus, registry } from '../../registry.mjs'; import CollisionModelSource, { createRegistryCollisionModelSource } from '../../common/CollisionModelSource.mjs'; @@ -16,6 +16,9 @@ eventBus.subscribe('registry.frozen', () => { * Handles the area node BSP tree used for spatial queries. */ export class ServerArea { + /** @type {?Octree} */ + tree = null; + /** * @param {CollisionModelSource} [modelSource] runtime model resolver */ @@ -191,7 +194,7 @@ export class ServerArea { const halfSize = pow2 / 2; - this.tree = new Octree(center, halfSize, 16, 64); + this.tree = /** @type {Octree} */ (new Octree(center, halfSize, 16, 64)); } /** @@ -246,7 +249,10 @@ export class ServerArea { return; } - const sides = Vector.boxOnPlaneSide(ent.entity.absmin, ent.entity.absmax, node.plane); + const entity = /** @type {import('../Edict.mjs').BaseEntity} */ (ent.entity); + console.assert(entity !== null); + + const sides = Vector.boxOnPlaneSide(entity.absmin, entity.absmax, node.plane); if ((sides & 1) !== 0) { this.findTouchedLeafs(ent, node.children[0]); @@ -268,6 +274,9 @@ export class ServerArea { return; } + const entity = /** @type {import('../Edict.mjs').BaseEntity} */ (ent.entity); + console.assert(entity !== null); + SV.server.navigation.relinkEdict(ent); this.unlinkEdict(ent); @@ -280,28 +289,28 @@ export class ServerArea { absmin.add(new Vector(-1.0, -1.0, -1.0)); absmax.add(new Vector(1.0, 1.0, 1.0)); - if ((ent.entity.flags & Defs.flags.FL_ITEM) !== 0) { // TODO: should be a feature flag for the game + if ((entity.flags & Defs.flags.FL_ITEM) !== 0) { // TODO: should be a feature flag for the game absmin.add(new Vector(-14.0, -14.0, 1.0)); absmax.add(new Vector(14.0, 14.0, -1.0)); } } - ent.entity.absmin = ent.entity.absmin.set(absmin); - ent.entity.absmax = ent.entity.absmax.set(absmax); + entity.absmin = entity.absmin.set(absmin); + entity.absmax = entity.absmax.set(absmax); ent.leafnums = []; - if (ent.entity.modelindex !== 0) { + if (entity.modelindex !== 0) { this.findTouchedLeafs(ent, SV.server.worldmodel.nodes[0]); } - if (ent.entity.solid === Defs.solid.SOLID_NOT) { + if (entity.solid === Defs.solid.SOLID_NOT) { return; } const node = this.tree.insert(ent); ent.octreeNode = node; - if (ent.entity.movetype !== Defs.moveType.MOVETYPE_NOCLIP && touchTriggers) { + if (entity.movetype !== Defs.moveType.MOVETYPE_NOCLIP && touchTriggers) { this.touchLinks(ent); } } diff --git a/source/engine/server/physics/ServerClientPhysics.mjs b/source/engine/server/physics/ServerClientPhysics.mjs index 4f953e57..b7a4ec9d 100644 --- a/source/engine/server/physics/ServerClientPhysics.mjs +++ b/source/engine/server/physics/ServerClientPhysics.mjs @@ -1,5 +1,5 @@ import Vector from '../../../shared/Vector.mjs'; -import * as Defs from '../../../shared/Defs.mjs'; +import * as Defs from '../../../shared/Defs.ts'; import { eventBus, registry } from '../../registry.mjs'; import { VELOCITY_EPSILON, diff --git a/source/engine/server/physics/ServerCollision.mjs b/source/engine/server/physics/ServerCollision.mjs index b0b4a7c3..7cb6d995 100644 --- a/source/engine/server/physics/ServerCollision.mjs +++ b/source/engine/server/physics/ServerCollision.mjs @@ -1,5 +1,5 @@ import Vector from '../../../shared/Vector.mjs'; -import * as Defs from '../../../shared/Defs.mjs'; +import * as Defs from '../../../shared/Defs.ts'; import CollisionModelSource, { createRegistryCollisionModelSource } from '../../common/CollisionModelSource.mjs'; import Mod, { BrushModel } from '../../common/Mod.mjs'; import { BrushTrace, DIST_EPSILON, Trace as SharedTrace } from '../../common/Pmove.mjs'; diff --git a/source/engine/server/physics/ServerLegacyHullCollision.mjs b/source/engine/server/physics/ServerLegacyHullCollision.mjs index dbbd9c91..3b4af4f8 100644 --- a/source/engine/server/physics/ServerLegacyHullCollision.mjs +++ b/source/engine/server/physics/ServerLegacyHullCollision.mjs @@ -1,5 +1,5 @@ import Vector from '../../../shared/Vector.mjs'; -import * as Defs from '../../../shared/Defs.mjs'; +import * as Defs from '../../../shared/Defs.ts'; import { DIST_EPSILON } from '../../common/Pmove.mjs'; import { eventBus, registry } from '../../registry.mjs'; diff --git a/source/engine/server/physics/ServerMovement.mjs b/source/engine/server/physics/ServerMovement.mjs index 046c0f2a..74e3371a 100644 --- a/source/engine/server/physics/ServerMovement.mjs +++ b/source/engine/server/physics/ServerMovement.mjs @@ -1,5 +1,5 @@ import Vector from '../../../shared/Vector.mjs'; -import * as Defs from '../../../shared/Defs.mjs'; +import * as Defs from '../../../shared/Defs.ts'; import { STEPSIZE } from '../../common/Pmove.mjs'; import { ServerEdict } from '../Edict.mjs'; import { eventBus, registry } from '../../registry.mjs'; diff --git a/source/engine/server/physics/ServerPhysics.mjs b/source/engine/server/physics/ServerPhysics.mjs index c906a979..299c35ee 100644 --- a/source/engine/server/physics/ServerPhysics.mjs +++ b/source/engine/server/physics/ServerPhysics.mjs @@ -1,5 +1,5 @@ import Vector from '../../../shared/Vector.mjs'; -import * as Defs from '../../../shared/Defs.mjs'; +import * as Defs from '../../../shared/Defs.ts'; import Q from '../../../shared/Q.mjs'; import { eventBus, registry } from '../../registry.mjs'; import { diff --git a/source/shared/Defs.mjs b/source/shared/Defs.mjs deleted file mode 100644 index a11db9df..00000000 --- a/source/shared/Defs.mjs +++ /dev/null @@ -1,311 +0,0 @@ -/** - * Engine-game shared definitions. - */ - -import Vector from './Vector.mjs'; - -/** - * edict.solid values - * @readonly - * @enum {number} - */ -export const solid = Object.freeze({ - /** no interaction with other objects */ - SOLID_NOT: 0, - /** touch on edge, but not blocking */ - SOLID_TRIGGER: 1, - /** touch on edge, block */ - SOLID_BBOX: 2, - /** touch on edge, but not an onground */ - SOLID_SLIDEBOX: 3, - /** bsp clip, touch on edge, block */ - SOLID_BSP: 4, - /** mesh clip, touch on edge, block */ - SOLID_MESH: 5, -}); - -/** - * edict.movetype values - * @readonly - * @enum {number} - */ -export const moveType = Object.freeze({ - /** never moves */ - MOVETYPE_NONE: 0, - //float MOVETYPE_ANGLENOCLIP: 1, - //float MOVETYPE_ANGLECLIP: 2, - /** players only */ - MOVETYPE_WALK: 3, - /** discrete, not real time unless fall */ - MOVETYPE_STEP: 4, - MOVETYPE_FLY: 5, - /** gravity */ - MOVETYPE_TOSS: 6, - /** no clip to world, push and crush */ - MOVETYPE_PUSH: 7, - MOVETYPE_NOCLIP: 8, - /** fly with extra size against monsters */ - MOVETYPE_FLYMISSILE: 9, - MOVETYPE_BOUNCE: 10, - /** bounce with extra size */ - MOVETYPE_BOUNCEMISSILE: 11, -}); - -/** - * edict.flags - * @readonly - * @enum {number} - */ -export const flags = Object.freeze({ - FL_NONE: 0, // CR: used to mark something as “flags here” - FL_FLY: 1, - FL_SWIM: 2, - /** set for all client edicts */ - FL_CLIENT: 8, - /** for enter / leave water splash */ - FL_INWATER: 16, - FL_MONSTER: 32, - /** player cheat */ - FL_GODMODE: 64, - /** player cheat */ - FL_NOTARGET: 128, - /** extra wide size for bonus items */ - FL_ITEM: 256, - /** standing on something */ - FL_ONGROUND: 512, - /** not all corners are valid */ - FL_PARTIALGROUND: 1024, - /** player jumping out of water */ - FL_WATERJUMP: 2048, - /** for jump debouncing */ - FL_JUMPRELEASED: 4096, - /** entity can be used (interacted with) */ - FL_USEABLE: 8192, -}); - -/** - * damage types - * @readonly - * @enum {number} - */ -export const damage = Object.freeze({ - /** no damage */ - DAMAGE_NO: 0, - /** damage is applied */ - DAMAGE_YES: 1, - /** damage aims at entities */ - DAMAGE_AIM: 2, -}); - -/** - * collision trace move types - * @readonly - * @enum {number} - */ -export const moveTypes = Object.freeze({ - /** normal trace */ - MOVE_NORMAL: 0, - /** don't clip against monsters */ - MOVE_NOMONSTERS: 1, - /** expand for missile size */ - MOVE_MISSILE: 2, -}); - -/** - * entity effects - * NOTE: this is uint8 - * @readonly - * @enum {number} - */ -export const effect = Object.freeze({ - EF_NONE: 0, - EF_BRIGHTFIELD: 1, - EF_MUZZLEFLASH: 2, - EF_BRIGHTLIGHT: 4, - EF_DIMLIGHT: 8, - - /** makes sure that the model is always rendered fullbright */ - EF_FULLBRIGHT: 16, - - /** makes sure the model is never completely dark */ - EF_MINLIGHT: 32, - - /** make sure the model never casts a shadow */ - EF_NOSHADOW: 64, - - /** simply not being rendered */ - EF_NODRAW: 128, -}); - -/** - * model flags - * @readonly - * @enum {number} - */ -export const modelFlags = Object.freeze({ - MF_NONE: 0, - MF_ROCKET: 1, - MF_GRENADE: 2, - MF_GIB: 4, - MF_ROTATE: 8, - MF_TRACER: 16, - MF_ZOMGIB: 32, - MF_TRACER2: 64, - MF_TRACER3: 128, -}); - - -/** - * sound channels - * channel 0 never willingly overrides - * other channels (1-7) always override a playing sound on that channel - * @readonly - * @enum {number} - */ -export const channel = Object.freeze({ - CHAN_AUTO: 0, - CHAN_WEAPON: 1, - CHAN_VOICE: 2, - CHAN_ITEM: 3, - CHAN_BODY: 4, -}); - -/** - * attenuation - * @readonly - * @enum {number} - */ -export const attn = Object.freeze({ - /** whole map */ - ATTN_NONE: 0, - /** around 1,000 units */ - ATTN_NORM: 1, - /** around 500 units */ - ATTN_IDLE: 2, - /** around 300 units */ - ATTN_STATIC: 3, -}); - -/** - * Mins/max of available hulls. - * @readonly - */ -export const hull = Object.freeze([ - [new Vector(-16.0, -16.0, -24.0).freeze(), new Vector(16.0, 16.0, 32.0).freeze()], - [new Vector(-32.0, -32.0, -24.0).freeze(), new Vector(32.0, 32.0, 64.0).freeze()], -]); - -/** - * @readonly - * @enum {number} - * point content values - */ -export const content = Object.freeze({ - // for convenience: - CONTENT_NONE: 0, - - // for game play: - /** inside the world */ - CONTENT_EMPTY: -1, - /** outside the world */ - CONTENT_SOLID: -2, - CONTENT_WATER: -3, - CONTENT_SLIME: -4, - CONTENT_LAVA: -5, - /** behaves like CONTENT_SOLID (collisions and sealing), but renders sky and might affect game play */ - CONTENT_SKY: -6, - - // for build tools shenanigans: - CONTENT_ORIGIN: -7, - /** clip brush, does not affect PxS nor rendering, but collisions */ - CONTENT_CLIP: -8, - - // currents: - CONTENT_CURRENT_0: -9, - CONTENT_CURRENT_90: -10, - CONTENT_CURRENT_180: -11, - CONTENT_CURRENT_270: -12, - CONTENT_CURRENT_UP: -13, - CONTENT_CURRENT_DOWN: -14, -}); - -/** - * @readonly - * @enum {number} - * waterlevel values (0, 1, 2, 3) for .waterlevel - */ -export const waterlevel = Object.freeze({ - /** not in water */ - WATERLEVEL_NONE: 0, - /** feet in water (origin[2] + playerMins[2] + 1) */ - WATERLEVEL_FEET: 1, - /** waist in water (origin[2] + (playerMins[2] + playerMaxs[2]) / 2) */ - WATERLEVEL_WAIST: 2, - /** head in water (origin[2] + view_ofs[2]) */ - WATERLEVEL_HEAD: 3, -}); - -/** - * @readonly - * @enum {number} - * @deprecated I’m thinking of a more extensible way to handle this - * thin client information and legacy updatestat values - */ -export const clientStat = Object.freeze({ - STAT_HEALTH: 0, - STAT_WEAPON: 2, - STAT_WEAPONFRAME: 5, -}); - -/** - * @readonly - * @enum {string} - * feature flags for the game code (both server and client) - */ -export const gameCapabilities = Object.freeze({ - /** this will read total_secrets, total_monsters, found_secrets, killed_monsters being sent via updatestat and let the client write them to CL.state.stat */ - CAP_CLIENTDATA_UPDATESTAT: 'CAP_CLIENTDATA_UPDATESTAT', - /** this will add items and ammo information to clientdata messages */ - CAP_CLIENTDATA_LEGACY: 'CAP_CLIENTDATA_LEGACY', - /** this will transmit clientdataFields defined in the player entity to the client and automatically populate clientdata on the ClientGameAPI */ - CAP_CLIENTDATA_DYNAMIC: 'CAP_CLIENTDATA_DYNAMIC', - /** will allow updating certain fields from server to client */ - CAP_ENTITY_EXTENDED: 'CAP_ENTITY_EXTENDED', - /** the client game code brings its own status bar, in other words: no Sbar required! */ - CAP_HUD_INCLUDES_SBAR: 'CAP_HUD_INCLUDES_SBAR', - /** the client game code takes care of rendering crosshairs, in other words: V is not required to draw one! */ - CAP_HUD_INCLUDES_CROSSHAIR: 'CAP_HUD_INCLUDES_CROSSHAIR', - /** the client game manages the view model now, no longer the game code */ - CAP_VIEWMODEL_MANAGED: 'CAP_VIEWMODEL_MANAGED', - /** no longer using SetNewParms, SetSpawnParms, SetChangeParms, parm0..15, but the new interfaces allowing for more flexibility */ - CAP_SPAWNPARMS_DYNAMIC: 'CAP_SPAWNPARMS_DYNAMIC', - /** will use SetNewParms, SetSpawnParms, SetChangeParms, parm0..15, etc. */ - CAP_SPAWNPARMS_LEGACY: 'CAP_SPAWNPARMS_LEGACY', - /** prevents chat messages from being handled by the engine, client code will handle that */ - CAP_CHAT_MANAGED: 'CAP_CHAT_MANAGED', - /** adds additional units to the bounding box during entity linking (e.g. for items additional 28 units in total per x/y axis) */ - CAP_ENTITY_BBOX_ADJUSTMENTS_DURING_LINK: 'CAP_ENTITY_BBOX_ADJUSTMENTS_DURING_LINK', -}); - -export const cvarFlags = Object.freeze({ - NONE: 0, - /** archive will make the engine write the modified variable to local storage or file (dedicated only) */ - ARCHIVE: 1, - /** server will make changes be broadcast to all clients */ - SERVER: 2, - /** readonly cannot be changed by the user, only through the API */ - READONLY: 4, - /** value won’t be shown in broadcast message */ - SECRET: 8, - /** variable declared by the game code */ - GAME: 16, - /** variable will be changed upon next map */ - DEFERRED: 32, // TODO: implement - /** variable cannot be changed unless sv_cheats is set to 1 */ - CHEAT: 64, - /** variable has been registered from the client code */ - CLIENT: 128, -}); - -/** floating point epsilon to account for inexact comparisons */ -export const EPSILON = 1e-8; diff --git a/source/shared/Defs.ts b/source/shared/Defs.ts new file mode 100644 index 00000000..83df1ac2 --- /dev/null +++ b/source/shared/Defs.ts @@ -0,0 +1,281 @@ +/** + * Engine-game shared definitions. + */ + +import Vector from './Vector.ts'; + +/** + * `edict.solid` values. + */ +export enum solid { + /** no interaction with other objects */ + SOLID_NOT = 0, + /** touch on edge, but not blocking */ + SOLID_TRIGGER = 1, + /** touch on edge, block */ + SOLID_BBOX = 2, + /** touch on edge, but not an onground */ + SOLID_SLIDEBOX = 3, + /** bsp clip, touch on edge, block */ + SOLID_BSP = 4, + /** mesh clip, touch on edge, block */ + SOLID_MESH = 5, +} + +/** + * `edict.movetype` values. + */ +export enum moveType { + /** never moves */ + MOVETYPE_NONE = 0, + // float MOVETYPE_ANGLENOCLIP: 1, + // float MOVETYPE_ANGLECLIP: 2, + /** players only */ + MOVETYPE_WALK = 3, + /** discrete, not real time unless fall */ + MOVETYPE_STEP = 4, + MOVETYPE_FLY = 5, + /** gravity */ + MOVETYPE_TOSS = 6, + /** no clip to world, push and crush */ + MOVETYPE_PUSH = 7, + MOVETYPE_NOCLIP = 8, + /** fly with extra size against monsters */ + MOVETYPE_FLYMISSILE = 9, + MOVETYPE_BOUNCE = 10, + /** bounce with extra size */ + MOVETYPE_BOUNCEMISSILE = 11, +} + +/** + * `edict.flags` values. + */ +export enum flags { + FL_NONE = 0, + FL_FLY = 1, + FL_SWIM = 2, + /** set for all client edicts */ + FL_CLIENT = 8, + /** for enter / leave water splash */ + FL_INWATER = 16, + FL_MONSTER = 32, + /** player cheat */ + FL_GODMODE = 64, + /** player cheat */ + FL_NOTARGET = 128, + /** extra wide size for bonus items */ + FL_ITEM = 256, + /** standing on something */ + FL_ONGROUND = 512, + /** not all corners are valid */ + FL_PARTIALGROUND = 1024, + /** player jumping out of water */ + FL_WATERJUMP = 2048, + /** for jump debouncing */ + FL_JUMPRELEASED = 4096, + /** entity can be used (interacted with) */ + FL_USEABLE = 8192, +} + +/** + * Damage types. + */ +export enum damage { + /** no damage */ + DAMAGE_NO = 0, + /** damage is applied */ + DAMAGE_YES = 1, + /** damage aims at entities */ + DAMAGE_AIM = 2, +} + +/** + * Collision trace move types. + */ +export enum moveTypes { + /** normal trace */ + MOVE_NORMAL = 0, + /** don't clip against monsters */ + MOVE_NOMONSTERS = 1, + /** expand for missile size */ + MOVE_MISSILE = 2, +} + +/** + * Entity effects. + * This is a uint8. + */ +export enum effect { + EF_NONE = 0, + EF_BRIGHTFIELD = 1, + EF_MUZZLEFLASH = 2, + EF_BRIGHTLIGHT = 4, + EF_DIMLIGHT = 8, + /** makes sure that the model is always rendered fullbright */ + EF_FULLBRIGHT = 16, + /** makes sure the model is never completely dark */ + EF_MINLIGHT = 32, + /** make sure the model never casts a shadow */ + EF_NOSHADOW = 64, + /** simply not being rendered */ + EF_NODRAW = 128, +} + +/** + * Model flags. + */ +export enum modelFlags { + MF_NONE = 0, + MF_ROCKET = 1, + MF_GRENADE = 2, + MF_GIB = 4, + MF_ROTATE = 8, + MF_TRACER = 16, + MF_ZOMGIB = 32, + MF_TRACER2 = 64, + MF_TRACER3 = 128, +} + +/** + * Sound channels. + * Channel 0 never willingly overrides. + * Other channels (1-7) always override a playing sound on that channel. + */ +export enum channel { + CHAN_AUTO = 0, + CHAN_WEAPON = 1, + CHAN_VOICE = 2, + CHAN_ITEM = 3, + CHAN_BODY = 4, +} + +/** + * Sound attenuation. + */ +export enum attn { + /** whole map */ + ATTN_NONE = 0, + /** around 1,000 units */ + ATTN_NORM = 1, + /** around 500 units */ + ATTN_IDLE = 2, + /** around 300 units */ + ATTN_STATIC = 3, +} + +/** + * Mins/maxes of available hulls. + */ +export const hull = Object.freeze([ + [new Vector(-16.0, -16.0, -24.0).freeze(), new Vector(16.0, 16.0, 32.0).freeze()] as const, + [new Vector(-32.0, -32.0, -24.0).freeze(), new Vector(32.0, 32.0, 64.0).freeze()] as const, +] satisfies readonly [mins: Vector, maxs: Vector][]); + +/** + * Point content values. + */ +export enum content { + // for convenience: + CONTENT_NONE = 0, + + // for game play: + /** inside the world */ + CONTENT_EMPTY = -1, + /** outside the world */ + CONTENT_SOLID = -2, + CONTENT_WATER = -3, + CONTENT_SLIME = -4, + CONTENT_LAVA = -5, + /** behaves like CONTENT_SOLID (collisions and sealing), but renders sky and might affect game play */ + CONTENT_SKY = -6, + + // for build tools shenanigans: + CONTENT_ORIGIN = -7, + /** clip brush, does not affect PxS nor rendering, but collisions */ + CONTENT_CLIP = -8, + + // currents: + CONTENT_CURRENT_0 = -9, + CONTENT_CURRENT_90 = -10, + CONTENT_CURRENT_180 = -11, + CONTENT_CURRENT_270 = -12, + CONTENT_CURRENT_UP = -13, + CONTENT_CURRENT_DOWN = -14, +} + +/** + * `waterlevel` values for `.waterlevel`. + */ +export enum waterlevel { + /** not in water */ + WATERLEVEL_NONE = 0, + /** feet in water (`origin[2] + playerMins[2] + 1`) */ + WATERLEVEL_FEET = 1, + /** waist in water (`origin[2] + (playerMins[2] + playerMaxs[2]) / 2`) */ + WATERLEVEL_WAIST = 2, + /** head in water (`origin[2] + view_ofs[2]`) */ + WATERLEVEL_HEAD = 3, +} + +/** + * Thin client information and legacy `updatestat` values. + */ +export enum clientStat { + STAT_HEALTH = 0, + STAT_WEAPON = 2, + STAT_WEAPONFRAME = 5, +} + +/** + * Feature flags for the game code (both server and client). + */ +export enum gameCapabilities { + /** this will read total_secrets, total_monsters, found_secrets, killed_monsters being sent via updatestat and let the client write them to CL.state.stat */ + CAP_CLIENTDATA_UPDATESTAT = 'CAP_CLIENTDATA_UPDATESTAT', + /** this will add items and ammo information to clientdata messages */ + CAP_CLIENTDATA_LEGACY = 'CAP_CLIENTDATA_LEGACY', + /** this will transmit clientdataFields defined in the player entity to the client and automatically populate clientdata on the ClientGameAPI */ + CAP_CLIENTDATA_DYNAMIC = 'CAP_CLIENTDATA_DYNAMIC', + /** will allow updating certain fields from server to client */ + CAP_ENTITY_EXTENDED = 'CAP_ENTITY_EXTENDED', + /** the client game code brings its own status bar, in other words: no Sbar required! */ + CAP_HUD_INCLUDES_SBAR = 'CAP_HUD_INCLUDES_SBAR', + /** the client game code takes care of rendering crosshairs, in other words: V is not required to draw one! */ + CAP_HUD_INCLUDES_CROSSHAIR = 'CAP_HUD_INCLUDES_CROSSHAIR', + /** the client game manages the view model now, no longer the game code */ + CAP_VIEWMODEL_MANAGED = 'CAP_VIEWMODEL_MANAGED', + /** no longer using SetNewParms, SetSpawnParms, SetChangeParms, parm0..15, but the new interfaces allowing for more flexibility */ + CAP_SPAWNPARMS_DYNAMIC = 'CAP_SPAWNPARMS_DYNAMIC', + /** will use SetNewParms, SetSpawnParms, SetChangeParms, parm0..15, etc. */ + CAP_SPAWNPARMS_LEGACY = 'CAP_SPAWNPARMS_LEGACY', + /** prevents chat messages from being handled by the engine, client code will handle that */ + CAP_CHAT_MANAGED = 'CAP_CHAT_MANAGED', + /** adds additional units to the bounding box during entity linking (e.g. for items additional 28 units in total per x/y axis) */ + CAP_ENTITY_BBOX_ADJUSTMENTS_DURING_LINK = 'CAP_ENTITY_BBOX_ADJUSTMENTS_DURING_LINK', +} + +/** + * Cvar registration flags. + */ +export enum cvarFlags { + NONE = 0, + /** archive will make the engine write the modified variable to local storage or file (dedicated only) */ + ARCHIVE = 1, + /** server will make changes be broadcast to all clients */ + SERVER = 2, + /** readonly cannot be changed by the user, only through the API */ + READONLY = 4, + /** value won’t be shown in broadcast message */ + SECRET = 8, + /** variable declared by the game code */ + GAME = 16, + /** variable will be changed upon next map */ + DEFERRED = 32, + /** variable cannot be changed unless sv_cheats is set to 1 */ + CHEAT = 64, + /** variable has been registered from the client code */ + CLIENT = 128, +} + +/** floating point epsilon to account for inexact comparisons */ +export const EPSILON = 1e-8; diff --git a/source/shared/Octree.mjs b/source/shared/Octree.mjs index b3ba494f..e87942bf 100644 --- a/source/shared/Octree.mjs +++ b/source/shared/Octree.mjs @@ -1,6 +1,6 @@ import Vector from './Vector.mjs'; -/** @typedef {{origin: Vector, absmin: Vector|null, absmax: Vector|null, octreeNode: OctreeNode|null}} OctreeItem */ +/** @typedef {{origin: Vector|null, absmin: Vector|null, absmax: Vector|null, octreeNode: OctreeNode|null}} OctreeItem */ /** * Octree node holding a spatially indexed item. diff --git a/source/shared/Q.mjs b/source/shared/Q.mjs index 78eabb69..1c4ef014 100644 --- a/source/shared/Q.mjs +++ b/source/shared/Q.mjs @@ -1,4 +1,4 @@ -import { EPSILON } from './Defs.mjs'; +import { EPSILON } from './Defs.ts'; /** * Utility class for common engine functions. diff --git a/source/shared/Vector.mjs b/source/shared/Vector.mjs index af4e541f..85418889 100644 --- a/source/shared/Vector.mjs +++ b/source/shared/Vector.mjs @@ -1,759 +1,3 @@ -/** - * Directional vectors. - */ -export class DirectionalVectors { - /** - * @param {Vector} forward forward direction - * @param {Vector} right right direction - * @param {Vector} up up direction - */ - constructor(forward, right, up) { - this.forward = forward; - this.right = right; - this.up = up; - Object.freeze(this); - } -}; - -/** - * Quaternion. - */ -export class Quaternion extends Array { - constructor(x = 0.0, y = 0.0, z = 0.0, w = 0.0) { - super(4); - console.assert(typeof x === 'number' && typeof y === 'number' && typeof z === 'number' && typeof w === 'number', 'not a number'); - console.assert(!Number.isNaN(x) && !Number.isNaN(y) && !Number.isNaN(z) && !Number.isNaN(w), 'NaN component'); - this[0] = x; - this[1] = y; - this[2] = z; - this[3] = w; - } - - /** - * Creates Quaternion from Vector. - * @param {Vector} vector vector - * @returns {Quaternion} the resulting quaternion - */ - fromVector(vector) { - return vector.toQuaternion(); - } - - /** - * Compares this Quaternion to the other quaternion. - * @param {Quaternion} other other quaternion - * @returns {boolean} true, if equal - */ - equals(other) { - return this[0] === other[0] && this[1] === other[1] && this[2] === other[2] && this[3] === other[3]; - } - - /** - * Compares this Quaternion’s component to x, y, z, w. - * @param {number} x x - * @param {number} y y - * @param {number} z z - * @param {number} w omega - * @returns {boolean} true, if equal - */ - equalsTo(x, y, z, w) { - return this[0] === x && this[1] === y && this[2] === z && this[3] === w; - } - - /** - * Freezes this Quaternion. - * @returns {Quaternion} this - */ - freeze() { - Object.freeze(this); - return this; - } - - /** - * Quake-style string representation of a Quaternion - * @returns {string} Quake-style string of this quaternion - */ - toString() { - return `${this.map((/** @type {number} */ e) => e.toFixed(1)).join(' ')}`; - } -}; - -/** - * 3D vector. - * This is the most commonly used vector type in the engine, and is used for positions, directions, angles, etc. - * It is different from Quake’s Vector macros (it doesn’t even has classes or functions most of the time), - * almost all of which are now instance methods here. - * While most methods are mutating, some return new Vectors for convenience. - * Make sure to read the JSDoc carefully. - */ -export default class Vector extends Float32Array { // CR: we need 32 bit precision to simulate some Q1 issues - /** Vector origin constant */ - static origin = (new Vector()).freeze(); - - /** - * Construct a Vector (defaulting to [0.0, 0.0, 0.0]). - * Extending Array allows Vector to be used much like a numeric array - * but still benefit from instance methods here. - * @param {number} x X - * @param {number} y Y - * @param {number} z Z - */ - constructor(x = 0.0, y = 0.0, z = 0.0) { - console.assert(typeof x === 'number' && typeof y === 'number' && typeof z === 'number', 'not a number'); - console.assert(!Number.isNaN(x) && !Number.isNaN(y) && !Number.isNaN(z), 'NaN component'); - super(3); - this[0] = x; - this[1] = y; - this[2] = z; - } - - /** - * Return a perpendicular direction to `this`. - * (Equivalent to the old Vec.Perpendicular.) - * @returns {Vector} perpendicular vector - */ - perpendicular() { - let pos = 0; - let minelem = 1; - - // Find whichever component is the smallest in absolute value: - for (let i = 0; i < 3; i++) { - const absVal = Math.abs(this[i]); - if (absVal < minelem) { - pos = i; - minelem = absVal; - } - } - - // Construct a temporary vector with 1.0 in that dimension: - const temp = new Vector(); - temp[pos] = 1.0; - - // Compute the projection and subtract it: - const invDenom = 1.0 / (this[0] * this[0] + this[1] * this[1] + this[2] * this[2]); - const d = temp.dot(this) * invDenom; - const perpendicularVec = new Vector( - temp[0] - d * this[0] * invDenom, - temp[1] - d * this[1] * invDenom, - temp[2] - d * this[2] * invDenom, - ); - - // Normalize the result and return: - perpendicularVec.normalize(); - return perpendicularVec; - } - - /** - * Rotate a point around the direction `this`. - * (Equivalent to the old Vec.RotatePointAroundVector(dir, point, degrees).) - * @param {Vector} point point to rotate - * @param {number} degrees angle - * @returns {Vector} new point - */ - rotatePointAroundVector(point, degrees) { - const vectorR = this.perpendicular(); - const up = vectorR.cross(this); - - const m = [ - [vectorR[0], up[0], this[0]], - [vectorR[1], up[1], this[1]], - [vectorR[2], up[2], this[2]], - ]; - - const im = [ - [m[0][0], m[1][0], m[2][0]], - [m[0][1], m[1][1], m[2][1]], - [m[0][2], m[1][2], m[2][2]], - ]; - - const radians = (degrees * Math.PI) / 180.0; - const s = Math.sin(radians); - const c = Math.cos(radians); - - // Rotation about Z-axis by `degrees`: - const zrot = [ - [c, s, 0.0], - [-s, c, 0.0], - [0.0, 0.0, 1.0], - ]; - - // Combine the rotations: - const matrixRot = Vector.concatRotations(Vector.concatRotations(m, zrot), im); - - // Apply to point: - const x = matrixRot[0][0] * point[0] + matrixRot[0][1] * point[1] + matrixRot[0][2] * point[2]; - const y = matrixRot[1][0] * point[0] + matrixRot[1][1] * point[1] + matrixRot[1][2] * point[2]; - const z = matrixRot[2][0] * point[0] + matrixRot[2][1] * point[1] + matrixRot[2][2] * point[2]; - return new Vector(x, y, z); - } - - /** - * Modulo an angle into [0, 360). - * (Same as the old Vector.anglemod.) - * @param {number} angle angle - * @returns {number} angle in [0, 360) - */ - static anglemod(angle) { - return ((angle % 360.0) + 360.0) % 360.0; - } - - /** - * Equivalent to the old Vec.BoxOnPlaneSide(emins, emaxs, p). - * Kept as a static because it does not revolve around a single vector. - * @param {Vector} emins emins - * @param {Vector} emaxs emaxs - * @param {*} p plane - * @returns {number|null} which side, null on error - */ - static boxOnPlaneSide(emins, emaxs, p) { - if (p.type <= 2) { - if (p.dist <= emins[p.type]) { - return 1; - } - if (p.dist >= emaxs[p.type]) { - return 2; - } - return 3; - } - let dist1; let dist2; - console.assert(p.signbits >= 0 && p.signbits < 8, 'signbits must be [0, 8)', p.signbits); - switch (p.signbits) { - case 0: - dist1 = p.normal[0] * emaxs[0] + p.normal[1] * emaxs[1] + p.normal[2] * emaxs[2]; - dist2 = p.normal[0] * emins[0] + p.normal[1] * emins[1] + p.normal[2] * emins[2]; - break; - case 1: - dist1 = p.normal[0] * emins[0] + p.normal[1] * emaxs[1] + p.normal[2] * emaxs[2]; - dist2 = p.normal[0] * emaxs[0] + p.normal[1] * emins[1] + p.normal[2] * emins[2]; - break; - case 2: - dist1 = p.normal[0] * emaxs[0] + p.normal[1] * emins[1] + p.normal[2] * emaxs[2]; - dist2 = p.normal[0] * emins[0] + p.normal[1] * emaxs[1] + p.normal[2] * emins[2]; - break; - case 3: - dist1 = p.normal[0] * emins[0] + p.normal[1] * emins[1] + p.normal[2] * emaxs[2]; - dist2 = p.normal[0] * emaxs[0] + p.normal[1] * emaxs[1] + p.normal[2] * emins[2]; - break; - case 4: - dist1 = p.normal[0] * emaxs[0] + p.normal[1] * emaxs[1] + p.normal[2] * emins[2]; - dist2 = p.normal[0] * emins[0] + p.normal[1] * emins[1] + p.normal[2] * emaxs[2]; - break; - case 5: - dist1 = p.normal[0] * emins[0] + p.normal[1] * emaxs[1] + p.normal[2] * emins[2]; - dist2 = p.normal[0] * emaxs[0] + p.normal[1] * emins[1] + p.normal[2] * emaxs[2]; - break; - case 6: - dist1 = p.normal[0] * emaxs[0] + p.normal[1] * emins[1] + p.normal[2] * emins[2]; - dist2 = p.normal[0] * emins[0] + p.normal[1] * emaxs[1] + p.normal[2] * emaxs[2]; - break; - case 7: - dist1 = p.normal[0] * emins[0] + p.normal[1] * emins[1] + p.normal[2] * emins[2]; - dist2 = p.normal[0] * emaxs[0] + p.normal[1] * emaxs[1] + p.normal[2] * emaxs[2]; - break; - default: - return null; - } - let sides = 0; - if (dist1 >= p.dist) { - sides = 1; - } - if (dist2 < p.dist) { - sides += 2; - } - return sides; - } - - /** - * Equivalent to the old Vec.AngleVectors(angles, forward, right, up), - * but now it acts on `this` as the angles array. - * Returns an object containing forward, right, up as Vecs. - * @returns {DirectionalVectors} directional vectors - */ - angleVectors() { - console.assert(Number.isFinite(this[0]) && Number.isFinite(this[1]) && Number.isFinite(this[2]), 'angles must be finite numbers'); - - let angle = this[0] * Math.PI / 180.0; - const sp = Math.sin(angle); - const cp = Math.cos(angle); - - angle = this[1] * Math.PI / 180.0; - const sy = Math.sin(angle); - const cy = Math.cos(angle); - - angle = this[2] * Math.PI / 180.0; - const sr = Math.sin(angle); - const cr = Math.cos(angle); - - const forward = new Vector( - cp * cy, - cp * sy, - -sp, - ); - - const right = new Vector( - cr * sy - sr * sp * cy, - -sr * sp * sy - cr * cy, - -sr * cp, - ); - - const up = new Vector( - cr * sp * cy + sr * sy, - cr * sp * sy - sr * cy, - cr * cp, - ); - - return new DirectionalVectors(forward, right, up); - } - - toYaw() { - if (!this[0] && !this[1]) { - return 0.0; - } - - let yaw = (Math.atan2(this[1], this[0]) * 180.0 / Math.PI); - - if (yaw < 0.0) { - yaw += 360.0; - } - - return yaw; - } - - toPitch() { - let pitch = (Math.atan2(this[2], Math.hypot(this[0], this[1])) * 180.0 / Math.PI); - - if (pitch < 0.0) { - pitch += 360.0; - } - - return pitch; - } - - /** - * Convert this directional vector into pitch and yaw angles and returns them. - * @returns {Vector} Vector containing [pitch, yaw, 0] - */ - toAngles() { - const angles = new Vector(); - - if (this[0] === 0.0 && this[1] === 0.0) { - if (this[2] > 0.0) { - angles[0] = 90.0; - } else { - angles[0] = 270.0; - } - - return angles; - } - - angles.setTo(this.toPitch(), this.toYaw(), 0.0); - - return angles; - } - - /** - * Assumes in this Vector is [roll, pitch, yaw] and generates a 3x3 rotation matrix. - * @returns {number[]} 3x3 rotation matrix - */ - toRotationMatrix() { - let [pitch, yaw, roll] = this; - // FIXME: with infinite components, we need to err out here - console.assert(Number.isFinite(pitch), 'finite pitch'); - console.assert(Number.isFinite(yaw), 'finite yaw'); - console.assert(Number.isFinite(roll), 'finite roll'); - pitch *= Math.PI / -180.0; - yaw *= Math.PI / 180.0; - roll *= Math.PI / 180.0; - const sp = Math.sin(pitch); - const cp = Math.cos(pitch); - const sy = Math.sin(yaw); - const cy = Math.cos(yaw); - const sr = Math.sin(roll); - const cr = Math.cos(roll); - return [ - cy * cp, sy * cp, -sp, - -sy * cr + cy * sp * sr, cy * cr + sy * sp * sr, cp * sr, - -sy * -sr + cy * sp * cr, cy * -sr + sy * sp * cr, cp * cr, - ]; - } - - /** - * Dot product of this and other. - * @param {Vector} other other vector - * @returns {number} dot product of this and other - */ - dot(other) { - console.assert(other instanceof Vector, 'not a Vector'); - return this[0] * other[0] + this[1] * other[1] + this[2] * other[2]; - } - - /** - * Create a copy of this vector. - * (Equivalent to the old Vec.Copy, but now returns a fresh Vector.) - * @returns {Vector} copy of this - */ - copy() { - return new Vector(this[0], this[1], this[2]); - } - - /** - * Add other to this vector (component-wise). - * @param {Vector|number[]} other other vector (or vector alike) - * @returns {Vector} this - */ - add(other) { - console.assert(other instanceof Vector, 'not a Vector'); - this[0] += other[0]; - this[1] += other[1]; - this[2] += other[2]; - return this; - } - - /** - * Subtract other from this vector (component-wise). - * @param {Vector|number[]} other other vector (or vector alike) - * @returns {Vector} this - */ - subtract(other) { - console.assert(other instanceof Vector, 'not a Vector'); - this[0] -= other[0]; - this[1] -= other[1]; - this[2] -= other[2]; - return this; - } - - /** - * Multiply factor into this vector. - * @param {number} factor factor for multiplication - * @returns {Vector} this - */ - multiply(factor) { - console.assert(typeof factor === 'number', 'not a number'); - this[0] *= factor; - this[1] *= factor; - this[2] *= factor; - return this; - } - - /** - * Check if other equals this vector. - * @param {Vector|number[]} other other vector (or vector alike) - * @returns {boolean} true, if all components are equal - */ - equals(other) { - console.assert(other instanceof Vector, 'not a Vector'); - return this[0] === other[0] && this[1] === other[1] && this[2] === other[2]; - } - - /** - * Check if [x, y, z] equals this vector. - * @param {number} x X - * @param {number} y Y - * @param {number} z Z - * @returns {boolean} true, if all components are equal - */ - equalsTo(x, y, z) { - return this[0] === x && this[1] === y && this[2] === z; - } - - /** - * Check if this vector is greater than other. - * @param {Vector|number[]} other other vector (or vector alike) - * @returns {boolean} true, if all components of this vector are greater than the other vector - */ - gt(other) { - console.assert(other instanceof Vector, 'not a Vector'); - return this[0] > other[0] && this[1] > other[1] && this[2] > other[2]; - } - - /** - * Check if this vector is greater than or equal to other. - * @param {Vector|number[]} other other vector (or vector alike) - * @returns {boolean} true, if all components of this vector are greater than or equal to the other vector - */ - gte(other) { - console.assert(other instanceof Vector, 'not a Vector'); - return this[0] >= other[0] && this[1] >= other[1] && this[2] >= other[2]; - } - - /** - * Check if this vector is less than other. - * @param {Vector|number[]} other other vector (or vector alike) - * @returns {boolean} true, if all components of this vector are less than the other vector - */ - lt(other) { - console.assert(other instanceof Vector, 'not a Vector'); - return this[0] < other[0] && this[1] < other[1] && this[2] < other[2]; - } - - /** - * Check if this vector is less than or equal to other. - * @param {Vector|number[]} other other vector (or vector alike) - * @returns {boolean} true, if all components of this vector are less than or equal to the other vector - */ - lte(other) { - console.assert(other instanceof Vector, 'not a Vector'); - return this[0] <= other[0] && this[1] <= other[1] && this[2] <= other[2]; - } - - /** - * Overwrite this vector with values from other. - * @param {Vector|number[]} other other vector - * @returns {Vector} this - */ - set(other) { - console.assert(other instanceof Vector, 'not a Vector'); - console.assert(!Number.isNaN(other[0]), 'NaN component'); - console.assert(!Number.isNaN(other[1]), 'NaN component'); - console.assert(!Number.isNaN(other[2]), 'NaN component'); - this[0] = other[0]; - this[1] = other[1]; - this[2] = other[2]; - return this; - } - - /** - * Sets this vector to [x, y, z]. - * @param {number} x X - * @param {number} y Y - * @param {number} z Z - * @returns {Vector} this - */ - setTo(x, y, z) { - console.assert(typeof x === 'number' && typeof y === 'number' && typeof z === 'number', 'not a number'); - console.assert(!Number.isNaN(x), 'NaN component'); - console.assert(!Number.isNaN(y), 'NaN component'); - console.assert(!Number.isNaN(z), 'NaN component'); - this[0] = x; - this[1] = y; - this[2] = z; - return this; - } - - /** - * Clear this vector. - * @returns {Vector} this - */ - clear() { - this[0] = 0.0; - this[1] = 0.0; - this[2] = 0.0; - return this; - } - - /** - * Check if this vector is origin. - * @returns {boolean} true, if this is an origin vector - */ - isOrigin() { - return this[0] === 0.0 && this[1] === 0.0 && this[2] === 0.0; - } - - /** - * Check if this vector is infinite. - * @returns {boolean} true, if this is an infinite vector - */ - isInfinite() { - return this[0] === Infinity || this[1] === Infinity || this[2] === Infinity || - this[0] === -Infinity || this[1] === -Infinity || this[2] === -Infinity; - } - - /** - * Cross product of this x other, returns a new Vector. - * @param {Vector} other other vector - * @returns {Vector} cross product of this and the other vector as a new Vector - */ - cross(other) { - console.assert(other instanceof Vector, 'not a Vector'); - return new Vector( - this[1] * other[2] - this[2] * other[1], - this[2] * other[0] - this[0] * other[2], - this[0] * other[1] - this[1] * other[0], - ); - } - - /** - * Return the length (magnitude) of this vector. - * (Not using len, because Array.prototype.length exists.) - * @returns {number} the length of this vector - */ - len() { - return Math.hypot(this[0], this[1], this[2]); - } - - /** - * Returns the average of the components of this vector. - * @returns {number} the average of the components of this vector - */ - average() { - return (this[0] + this[1] + this[2]) / 3.0; - } - - /** - * Returns the greatest component of this vector. - * @returns {number} the greatest component of this vector - */ - greatest() { - return Math.max(this[0], this[1], this[2]); - } - - /** - * Determines the distance from this to other. - * @param {Vector} other other vector - * @returns {number} the distance between this and other - */ - distanceTo(other) { - const x = this[0] - other[0]; - const y = this[1] - other[1]; - const z = this[2] - other[2]; - return Math.hypot(x, y, z); - } - - /** - * Normalize this vector in place. Returns the original length. - * @returns {number} the original length of this vector - */ - normalize() { - const len = this.len(); - if (len === 0.0) { - this[0] = this[1] = this[2] = 0.0; - return 0.0; - } - this[0] /= len; - this[1] /= len; - this[2] /= len; - return len; - } - - /** - * Multiply two 3×3 rotation matrices (used by rotatePointAroundVector). - * This remains static because it’s operating on matrix arrays, not `this`. - * @param {number[][]} matrixA A - * @param {number[][]} matrixB B - * @returns {number[][]} matrix - */ - static concatRotations(matrixA, matrixB) { - return [ - [ - matrixA[0][0] * matrixB[0][0] + - matrixA[0][1] * matrixB[1][0] + - matrixA[0][2] * matrixB[2][0], - matrixA[0][0] * matrixB[0][1] + - matrixA[0][1] * matrixB[1][1] + - matrixA[0][2] * matrixB[2][1], - matrixA[0][0] * matrixB[0][2] + - matrixA[0][1] * matrixB[1][2] + - matrixA[0][2] * matrixB[2][2], - ], - [ - matrixA[1][0] * matrixB[0][0] + - matrixA[1][1] * matrixB[1][0] + - matrixA[1][2] * matrixB[2][0], - matrixA[1][0] * matrixB[0][1] + - matrixA[1][1] * matrixB[1][1] + - matrixA[1][2] * matrixB[2][1], - matrixA[1][0] * matrixB[0][2] + - matrixA[1][1] * matrixB[1][2] + - matrixA[1][2] * matrixB[2][2], - ], - [ - matrixA[2][0] * matrixB[0][0] + - matrixA[2][1] * matrixB[1][0] + - matrixA[2][2] * matrixB[2][0], - matrixA[2][0] * matrixB[0][1] + - matrixA[2][1] * matrixB[1][1] + - matrixA[2][2] * matrixB[2][1], - matrixA[2][0] * matrixB[0][2] + - matrixA[2][1] * matrixB[1][2] + - matrixA[2][2] * matrixB[2][2], - ], - ]; - } - - /** - * Set `this` from a quaternion, interpreting that quaternion as Euler angles. - * (Equivalent to the old Vec.SetQuaternion, but we store to `this`.) - * @param {Quaternion} quat quaternion - * @returns {Vector} this - */ - setQuaternion(quat) { - const [w, x, y, z] = quat; - - // Derived via standard quaternion->Euler formula - const yaw = Math.atan2( - 2 * (w * z + x * y), - 1 - 2 * (y * y + z * z), - ); - const pitch = Math.asin( - 2 * (w * y - z * x), - ); - const roll = Math.atan2( - 2 * (w * x + y * z), - 1 - 2 * (x * x + y * y), - ); - - // Store angles in this vector. (Roll, Pitch, Yaw) - this[0] = roll; - this[1] = pitch; - this[2] = yaw; - return this; - } - - /** - * Convert these Euler angles (this) into a quaternion [w, x, y, z]. - * @returns {Quaternion} quaternion - */ - toQuaternion() { - // Expecting [roll, pitch, yaw] in `this` - const [roll, pitch, yaw] = this; - - const halfRoll = roll / 2; - const halfPitch = pitch / 2; - const halfYaw = yaw / 2; - - const sinRoll = Math.sin(halfRoll); - const cosRoll = Math.cos(halfRoll); - - const sinPitch = Math.sin(halfPitch); - const cosPitch = Math.cos(halfPitch); - - const sinYaw = Math.sin(halfYaw); - const cosYaw = Math.cos(halfYaw); - - // w, x, y, z - const w = cosRoll * cosPitch * cosYaw + sinRoll * sinPitch * sinYaw; - const x = sinRoll * cosPitch * cosYaw - cosRoll * sinPitch * sinYaw; - const y = cosRoll * sinPitch * cosYaw + sinRoll * cosPitch * sinYaw; - const z = cosRoll * cosPitch * sinYaw - sinRoll * sinPitch * cosYaw; - - return new Quaternion(w, x, y, z); - } - - /** - * Create a Vector from a quaternion, converting that quaternion to Euler angles. - * @param {Quaternion} quat quaternion - * @returns {Vector} rotation vector - */ - static fromQuaternion(quat) { - const v = new Vector(); - v.setQuaternion(quat); - return v; - } - - /** - * Freezes this Vector. - * @returns {Vector} this - */ - freeze() { - // Object.freeze(this); - return this; - } - - /** - * Quake-style string representation of a Vector - * @returns {string} Quake-style string of this vector - */ - toString() { - return `${this.map((e) => +e.toFixed(1)).join(' ')}`; - } -}; +export { default } from './Vector.ts'; +export * from './Vector.ts'; diff --git a/source/shared/Vector.ts b/source/shared/Vector.ts new file mode 100644 index 00000000..0c794092 --- /dev/null +++ b/source/shared/Vector.ts @@ -0,0 +1,652 @@ +/* eslint jsdoc/require-param-type: "off", jsdoc/require-returns: "off" */ + +type VectorLike = ArrayLike; + +type RotationMatrix = [ + [number, number, number], + [number, number, number], + [number, number, number], +]; + +type PlaneLike = { + type: number; + dist: number; + signbits: number; + normal: VectorLike; +}; + +/** + * Directional vectors. + */ +export class DirectionalVectors { + readonly forward: Vector; + readonly right: Vector; + readonly up: Vector; + + constructor(forward: Vector, right: Vector, up: Vector) { + this.forward = forward; + this.right = right; + this.up = up; + Object.freeze(this); + } +} + +/** + * Quaternion. + */ +export class Quaternion extends Array { + constructor(x = 0.0, y = 0.0, z = 0.0, w = 0.0) { + super(4); + console.assert(typeof x === 'number' && typeof y === 'number' && typeof z === 'number' && typeof w === 'number', 'not a number'); + console.assert(!Number.isNaN(x) && !Number.isNaN(y) && !Number.isNaN(z) && !Number.isNaN(w), 'NaN component'); + this[0] = x; + this[1] = y; + this[2] = z; + this[3] = w; + } + + /** + * Creates Quaternion from Vector. + * @param vector + */ + fromVector(vector: Vector): Quaternion { + return vector.toQuaternion(); + } + + /** + * Compares this Quaternion to the other quaternion. + * @param other + */ + equals(other: Quaternion): boolean { + return this[0] === other[0] && this[1] === other[1] && this[2] === other[2] && this[3] === other[3]; + } + + /** + * Compares this Quaternion’s component to x, y, z, w. + * @param x + * @param y + * @param z + * @param w + */ + equalsTo(x: number, y: number, z: number, w: number): boolean { + return this[0] === x && this[1] === y && this[2] === z && this[3] === w; + } + + /** + * Freezes this Quaternion. + */ + freeze(): Quaternion { + Object.freeze(this); + return this; + } + + /** + * Quake-style string representation of a Quaternion. + */ + override toString(): string { + return `${this.map((element) => element.toFixed(1)).join(' ')}`; + } +} + +/** + * 3D vector. + * This is the most commonly used vector type in the engine, and is used for positions, directions, angles, etc. + * It is different from Quake’s Vector macros (it doesn’t even has classes or functions most of the time), + * almost all of which are now instance methods here. + * While most methods are mutating, some return new Vectors for convenience. + * Make sure to read the JSDoc carefully. + */ +export default class Vector extends Float32Array { + static origin: Readonly = (new Vector()).freeze(); + + constructor(x = 0.0, y = 0.0, z = 0.0) { + console.assert(typeof x === 'number' && typeof y === 'number' && typeof z === 'number', 'not a number'); + console.assert(!Number.isNaN(x) && !Number.isNaN(y) && !Number.isNaN(z), 'NaN component'); + super(3); + this[0] = x; + this[1] = y; + this[2] = z; + } + + /** + * Return a perpendicular direction to `this`. + */ + perpendicular(): Vector { + let pos = 0; + let minElement = 1; + + for (let index = 0; index < 3; index++) { + const absoluteValue = Math.abs(this[index]); + if (absoluteValue < minElement) { + pos = index; + minElement = absoluteValue; + } + } + + const temp = new Vector(); + temp[pos] = 1.0; + + const invDenominator = 1.0 / (this[0] * this[0] + this[1] * this[1] + this[2] * this[2]); + const dot = temp.dot(this) * invDenominator; + const perpendicularVector = new Vector( + temp[0] - dot * this[0] * invDenominator, + temp[1] - dot * this[1] * invDenominator, + temp[2] - dot * this[2] * invDenominator, + ); + + perpendicularVector.normalize(); + return perpendicularVector; + } + + /** + * Rotate a point around the direction `this`. + * @param point + * @param degrees + */ + rotatePointAroundVector(point: Vector, degrees: number): Vector { + const vectorRight = this.perpendicular(); + const up = vectorRight.cross(this); + + const matrix: RotationMatrix = [ + [vectorRight[0], up[0], this[0]], + [vectorRight[1], up[1], this[1]], + [vectorRight[2], up[2], this[2]], + ]; + + const inverseMatrix: RotationMatrix = [ + [matrix[0][0], matrix[1][0], matrix[2][0]], + [matrix[0][1], matrix[1][1], matrix[2][1]], + [matrix[0][2], matrix[1][2], matrix[2][2]], + ]; + + const radians = (degrees * Math.PI) / 180.0; + const sine = Math.sin(radians); + const cosine = Math.cos(radians); + + const zRotation: RotationMatrix = [ + [cosine, sine, 0.0], + [-sine, cosine, 0.0], + [0.0, 0.0, 1.0], + ]; + + const rotationMatrix = Vector.concatRotations(Vector.concatRotations(matrix, zRotation), inverseMatrix); + + const x = rotationMatrix[0][0] * point[0] + rotationMatrix[0][1] * point[1] + rotationMatrix[0][2] * point[2]; + const y = rotationMatrix[1][0] * point[0] + rotationMatrix[1][1] * point[1] + rotationMatrix[1][2] * point[2]; + const z = rotationMatrix[2][0] * point[0] + rotationMatrix[2][1] * point[1] + rotationMatrix[2][2] * point[2]; + return new Vector(x, y, z); + } + + /** + * Modulo an angle into [0, 360). + * @param angle + */ + static anglemod(angle: number): number { + return ((angle % 360.0) + 360.0) % 360.0; + } + + /** + * Equivalent to the old Vec.BoxOnPlaneSide(emins, emaxs, p). + * @param emins + * @param emaxs + * @param plane + */ + static boxOnPlaneSide(emins: Vector, emaxs: Vector, plane: PlaneLike): number { + if (plane.type <= 2) { + if (plane.dist <= emins[plane.type]) { + return 1; + } + if (plane.dist >= emaxs[plane.type]) { + return 2; + } + return 3; + } + + let dist1: number; + let dist2: number; + console.assert(plane.signbits >= 0 && plane.signbits < 8, 'signbits must be [0, 8)', plane.signbits); + + switch (plane.signbits) { + case 0: + dist1 = plane.normal[0] * emaxs[0] + plane.normal[1] * emaxs[1] + plane.normal[2] * emaxs[2]; + dist2 = plane.normal[0] * emins[0] + plane.normal[1] * emins[1] + plane.normal[2] * emins[2]; + break; + case 1: + dist1 = plane.normal[0] * emins[0] + plane.normal[1] * emaxs[1] + plane.normal[2] * emaxs[2]; + dist2 = plane.normal[0] * emaxs[0] + plane.normal[1] * emins[1] + plane.normal[2] * emins[2]; + break; + case 2: + dist1 = plane.normal[0] * emaxs[0] + plane.normal[1] * emins[1] + plane.normal[2] * emaxs[2]; + dist2 = plane.normal[0] * emins[0] + plane.normal[1] * emaxs[1] + plane.normal[2] * emins[2]; + break; + case 3: + dist1 = plane.normal[0] * emins[0] + plane.normal[1] * emins[1] + plane.normal[2] * emaxs[2]; + dist2 = plane.normal[0] * emaxs[0] + plane.normal[1] * emaxs[1] + plane.normal[2] * emins[2]; + break; + case 4: + dist1 = plane.normal[0] * emaxs[0] + plane.normal[1] * emaxs[1] + plane.normal[2] * emins[2]; + dist2 = plane.normal[0] * emins[0] + plane.normal[1] * emins[1] + plane.normal[2] * emaxs[2]; + break; + case 5: + dist1 = plane.normal[0] * emins[0] + plane.normal[1] * emaxs[1] + plane.normal[2] * emins[2]; + dist2 = plane.normal[0] * emaxs[0] + plane.normal[1] * emins[1] + plane.normal[2] * emaxs[2]; + break; + case 6: + dist1 = plane.normal[0] * emaxs[0] + plane.normal[1] * emins[1] + plane.normal[2] * emins[2]; + dist2 = plane.normal[0] * emins[0] + plane.normal[1] * emaxs[1] + plane.normal[2] * emaxs[2]; + break; + case 7: + dist1 = plane.normal[0] * emins[0] + plane.normal[1] * emins[1] + plane.normal[2] * emins[2]; + dist2 = plane.normal[0] * emaxs[0] + plane.normal[1] * emaxs[1] + plane.normal[2] * emaxs[2]; + break; + default: + return 0; + } + + let sides = 0; + if (dist1 >= plane.dist) { + sides = 1; + } + if (dist2 < plane.dist) { + sides += 2; + } + return sides; + } + + /** + * Returns an object containing forward, right, up as Vecs. + */ + angleVectors(): DirectionalVectors { + console.assert(Number.isFinite(this[0]) && Number.isFinite(this[1]) && Number.isFinite(this[2]), 'angles must be finite numbers'); + + let angle = this[0] * Math.PI / 180.0; + const sp = Math.sin(angle); + const cp = Math.cos(angle); + + angle = this[1] * Math.PI / 180.0; + const sy = Math.sin(angle); + const cy = Math.cos(angle); + + angle = this[2] * Math.PI / 180.0; + const sr = Math.sin(angle); + const cr = Math.cos(angle); + + const forward = new Vector(cp * cy, cp * sy, -sp); + const right = new Vector( + cr * sy - sr * sp * cy, + -sr * sp * sy - cr * cy, + -sr * cp, + ); + const up = new Vector( + cr * sp * cy + sr * sy, + cr * sp * sy - sr * cy, + cr * cp, + ); + + return new DirectionalVectors(forward, right, up); + } + + toYaw(): number { + if (!this[0] && !this[1]) { + return 0.0; + } + + let yaw = Math.atan2(this[1], this[0]) * 180.0 / Math.PI; + if (yaw < 0.0) { + yaw += 360.0; + } + + return yaw; + } + + toPitch(): number { + let pitch = Math.atan2(this[2], Math.hypot(this[0], this[1])) * 180.0 / Math.PI; + if (pitch < 0.0) { + pitch += 360.0; + } + + return pitch; + } + + /** + * Convert this directional vector into pitch and yaw angles and returns them. + */ + toAngles(): Vector { + const angles = new Vector(); + + if (this[0] === 0.0 && this[1] === 0.0) { + angles[0] = this[2] > 0.0 ? 90.0 : 270.0; + return angles; + } + + angles.setTo(this.toPitch(), this.toYaw(), 0.0); + return angles; + } + + /** + * Assumes this Vector is [roll, pitch, yaw] and generates a 3x3 rotation matrix. + */ + toRotationMatrix(): number[] { + let [pitch, yaw, roll] = this; + console.assert(Number.isFinite(pitch), 'finite pitch'); + console.assert(Number.isFinite(yaw), 'finite yaw'); + console.assert(Number.isFinite(roll), 'finite roll'); + pitch *= Math.PI / -180.0; + yaw *= Math.PI / 180.0; + roll *= Math.PI / 180.0; + const sp = Math.sin(pitch); + const cp = Math.cos(pitch); + const sy = Math.sin(yaw); + const cy = Math.cos(yaw); + const sr = Math.sin(roll); + const cr = Math.cos(roll); + return [ + cy * cp, sy * cp, -sp, + -sy * cr + cy * sp * sr, cy * cr + sy * sp * sr, cp * sr, + -sy * -sr + cy * sp * cr, cy * -sr + sy * sp * cr, cp * cr, + ]; + } + + /** + * Dot product of this and other. + * @param other + */ + dot(other: VectorLike): number { + return this[0] * other[0] + this[1] * other[1] + this[2] * other[2]; + } + + /** + * Create a copy of this vector. + */ + copy(): Vector { + return new Vector(this[0], this[1], this[2]); + } + + /** + * Add other to this vector (component-wise). + * @param other + */ + add(other: VectorLike): this { + this[0] += other[0]; + this[1] += other[1]; + this[2] += other[2]; + return this; + } + + /** + * Subtract other from this vector (component-wise). + * @param other + */ + subtract(other: VectorLike): this { + this[0] -= other[0]; + this[1] -= other[1]; + this[2] -= other[2]; + return this; + } + + /** + * Multiply factor into this vector. + * @param factor + */ + multiply(factor: number): this { + console.assert(typeof factor === 'number', 'not a number'); + this[0] *= factor; + this[1] *= factor; + this[2] *= factor; + return this; + } + + /** + * Check if other equals this vector. + * @param other + */ + equals(other: VectorLike): boolean { + return this[0] === other[0] && this[1] === other[1] && this[2] === other[2]; + } + + /** + * Check if [x, y, z] equals this vector. + * @param x + * @param y + * @param z + */ + equalsTo(x: number, y: number, z: number): boolean { + return this[0] === x && this[1] === y && this[2] === z; + } + + /** + * Check if this vector is greater than other. + * @param other + */ + gt(other: VectorLike): boolean { + return this[0] > other[0] && this[1] > other[1] && this[2] > other[2]; + } + + /** + * Check if this vector is greater than or equal to other. + * @param other + */ + gte(other: VectorLike): boolean { + return this[0] >= other[0] && this[1] >= other[1] && this[2] >= other[2]; + } + + /** + * Check if this vector is less than other. + * @param other + */ + lt(other: VectorLike): boolean { + return this[0] < other[0] && this[1] < other[1] && this[2] < other[2]; + } + + /** + * Check if this vector is less than or equal to other. + * @param other + */ + lte(other: VectorLike): boolean { + return this[0] <= other[0] && this[1] <= other[1] && this[2] <= other[2]; + } + + /** + * Overwrite this vector with values from other. + * @param other + * @param offset + */ + override set(other: VectorLike, offset = 0): this { + console.assert(offset === 0, 'Vector.set only supports a zero offset'); + console.assert(!Number.isNaN(other[0]), 'NaN component'); + console.assert(!Number.isNaN(other[1]), 'NaN component'); + console.assert(!Number.isNaN(other[2]), 'NaN component'); + this[0] = other[0]; + this[1] = other[1]; + this[2] = other[2]; + return this; + } + + /** + * Sets this vector to [x, y, z]. + * @param x + * @param y + * @param z + */ + setTo(x: number, y: number, z: number): this { + console.assert(typeof x === 'number' && typeof y === 'number' && typeof z === 'number', 'not a number'); + console.assert(!Number.isNaN(x), 'NaN component'); + console.assert(!Number.isNaN(y), 'NaN component'); + console.assert(!Number.isNaN(z), 'NaN component'); + this[0] = x; + this[1] = y; + this[2] = z; + return this; + } + + /** + * Clear this vector. + */ + clear(): this { + this[0] = 0.0; + this[1] = 0.0; + this[2] = 0.0; + return this; + } + + /** + * Check if this vector is origin. + */ + isOrigin(): boolean { + return this[0] === 0.0 && this[1] === 0.0 && this[2] === 0.0; + } + + /** + * Check if this vector is infinite. + */ + isInfinite(): boolean { + return this[0] === Infinity || this[1] === Infinity || this[2] === Infinity || + this[0] === -Infinity || this[1] === -Infinity || this[2] === -Infinity; + } + + /** + * Cross product of this x other, returns a new Vector. + * @param other + */ + cross(other: VectorLike): Vector { + return new Vector( + this[1] * other[2] - this[2] * other[1], + this[2] * other[0] - this[0] * other[2], + this[0] * other[1] - this[1] * other[0], + ); + } + + /** + * Return the length (magnitude) of this vector. + */ + len(): number { + return Math.hypot(this[0], this[1], this[2]); + } + + /** + * Returns the average of the components of this vector. + */ + average(): number { + return (this[0] + this[1] + this[2]) / 3.0; + } + + /** + * Returns the greatest component of this vector. + */ + greatest(): number { + return Math.max(this[0], this[1], this[2]); + } + + /** + * Determines the distance from this to other. + * @param other + */ + distanceTo(other: VectorLike): number { + const x = this[0] - other[0]; + const y = this[1] - other[1]; + const z = this[2] - other[2]; + return Math.hypot(x, y, z); + } + + /** + * Normalize this vector in place. Returns the original length. + */ + normalize(): number { + const length = this.len(); + if (length === 0.0) { + this[0] = this[1] = this[2] = 0.0; + return 0.0; + } + this[0] /= length; + this[1] /= length; + this[2] /= length; + return length; + } + + /** + * Multiply two 3x3 rotation matrices. + * @param matrixA + * @param matrixB + */ + static concatRotations(matrixA: RotationMatrix, matrixB: RotationMatrix): RotationMatrix { + return [ + [ + matrixA[0][0] * matrixB[0][0] + matrixA[0][1] * matrixB[1][0] + matrixA[0][2] * matrixB[2][0], + matrixA[0][0] * matrixB[0][1] + matrixA[0][1] * matrixB[1][1] + matrixA[0][2] * matrixB[2][1], + matrixA[0][0] * matrixB[0][2] + matrixA[0][1] * matrixB[1][2] + matrixA[0][2] * matrixB[2][2], + ], + [ + matrixA[1][0] * matrixB[0][0] + matrixA[1][1] * matrixB[1][0] + matrixA[1][2] * matrixB[2][0], + matrixA[1][0] * matrixB[0][1] + matrixA[1][1] * matrixB[1][1] + matrixA[1][2] * matrixB[2][1], + matrixA[1][0] * matrixB[0][2] + matrixA[1][1] * matrixB[1][2] + matrixA[1][2] * matrixB[2][2], + ], + [ + matrixA[2][0] * matrixB[0][0] + matrixA[2][1] * matrixB[1][0] + matrixA[2][2] * matrixB[2][0], + matrixA[2][0] * matrixB[0][1] + matrixA[2][1] * matrixB[1][1] + matrixA[2][2] * matrixB[2][1], + matrixA[2][0] * matrixB[0][2] + matrixA[2][1] * matrixB[1][2] + matrixA[2][2] * matrixB[2][2], + ], + ]; + } + + /** + * Set `this` from a quaternion, interpreting that quaternion as Euler angles. + * @param quaternion + */ + setQuaternion(quaternion: Quaternion): this { + const [w, x, y, z] = quaternion; + const yaw = Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)); + const pitch = Math.asin(2 * (w * y - z * x)); + const roll = Math.atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y)); + + this[0] = roll; + this[1] = pitch; + this[2] = yaw; + return this; + } + + /** + * Convert these Euler angles (this) into a quaternion [w, x, y, z]. + */ + toQuaternion(): Quaternion { + const [roll, pitch, yaw] = this; + const halfRoll = roll / 2; + const halfPitch = pitch / 2; + const halfYaw = yaw / 2; + const sinRoll = Math.sin(halfRoll); + const cosRoll = Math.cos(halfRoll); + const sinPitch = Math.sin(halfPitch); + const cosPitch = Math.cos(halfPitch); + const sinYaw = Math.sin(halfYaw); + const cosYaw = Math.cos(halfYaw); + const w = cosRoll * cosPitch * cosYaw + sinRoll * sinPitch * sinYaw; + const x = sinRoll * cosPitch * cosYaw - cosRoll * sinPitch * sinYaw; + const y = cosRoll * sinPitch * cosYaw + sinRoll * cosPitch * sinYaw; + const z = cosRoll * cosPitch * sinYaw - sinRoll * sinPitch * cosYaw; + + return new Quaternion(w, x, y, z); + } + + /** + * Create a Vector from a quaternion, converting that quaternion to Euler angles. + * @param quaternion + */ + static fromQuaternion(quaternion: Quaternion): Vector { + const vector = new Vector(); + vector.setQuaternion(quaternion); + return vector; + } + + /** + * Freezes this Vector. + */ + freeze(): Readonly { + return this; + } + + /** + * Quake-style string representation of a Vector. + */ + override toString(): string { + return `${this.map((element) => +element.toFixed(1)).join(' ')}`; + } +} diff --git a/source/shared/index.mjs b/source/shared/index.mjs index 5a57f74d..247ddfeb 100644 --- a/source/shared/index.mjs +++ b/source/shared/index.mjs @@ -1,7 +1,7 @@ export { default as sampleBSpline } from './BSpline.mjs'; export * from './ClientEdict.mjs'; -export * from './Defs.mjs'; +export * from './Defs.ts'; export * from './Keys.mjs'; export * from './Octree.mjs'; export { default as Q } from './Q.mjs'; -export * from './Vector.mjs'; +export * from './Vector.ts'; diff --git a/test/common/game-apis.test.mjs b/test/common/game-apis.test.mjs new file mode 100644 index 00000000..bafb3bb9 --- /dev/null +++ b/test/common/game-apis.test.mjs @@ -0,0 +1,161 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; + +import Vector from '../../source/shared/Vector.mjs'; +import { solid } from '../../source/shared/Defs.ts'; +import { ClientEdict } from '../../source/engine/client/ClientEntities.mjs'; +import { ClientEngineAPI } from '../../source/engine/common/GameAPIs.mjs'; +import { ServerArea } from '../../source/engine/server/physics/ServerArea.mjs'; +import { ServerCollision } from '../../source/engine/server/physics/ServerCollision.mjs'; +import { CollisionTrace } from '../../source/engine/server/physics/ServerCollisionSupport.mjs'; +import { defaultMockRegistry, withMockRegistry } from '../physics/fixtures.mjs'; + +/** + * + * @param num + * @param origin + * @param mins + * @param maxs + */ +/** + * @param {number} num entity number + * @param {Vector} origin entity origin + * @param {Vector} mins entity minimum bounds + * @param {Vector} maxs entity maximum bounds + * @returns {ClientEdict} configured client entity fixture + */ +function createClientTraceEntity(num, origin, mins, maxs) { + const entity = new ClientEdict(num); + entity.origin.set(origin); + entity.mins.set(mins); + entity.maxs.set(maxs); + entity.solid = solid.SOLID_BBOX; + entity.modelindex = 0; + entity.model = { + mins: mins.copy(), + maxs: maxs.copy(), + }; + return entity; +} + +describe('ClientEngineAPI.Traceline', () => { + test('keeps the default client trace static-world only', () => { + let clipMoveCalls = 0; + + withMockRegistry(defaultMockRegistry({ + collision: { + traceWorldLine(_start, end) { + return CollisionTrace.empty(end); + }, + clipMoveToEntity() { + clipMoveCalls += 1; + return CollisionTrace.empty(Vector.origin); + }, + }, + }, { + state: { + clientEntities: { + *getEntities() { + }, + }, + }, + }), () => { + const trace = ClientEngineAPI.Traceline(new Vector(), new Vector(128, 0, 0)); + + assert.equal(trace.fraction, 1.0); + assert.equal(trace.entity, null); + assert.equal(clipMoveCalls, 0); + }); + }); + + test('can trace current client entities on demand', () => { + const collision = new ServerCollision(); + const area = new ServerArea(); + area.initBoxHull(); + + const target = createClientTraceEntity( + 2, + new Vector(64, 0, 0), + new Vector(-16, -16, -24), + new Vector(16, 16, 32), + ); + + withMockRegistry(defaultMockRegistry({ + area, + collision: { + traceWorldLine(_start, end) { + return CollisionTrace.empty(end); + }, + clipMoveToEntity: collision.clipMoveToEntity.bind(collision), + }, + }, { + state: { + clientEntities: { + *getEntities() { + yield target; + }, + }, + }, + }), () => { + const trace = ClientEngineAPI.Traceline( + new Vector(0, 0, 0), + new Vector(128, 0, 0), + { includeEntities: true }, + ); + + assert.ok(trace.fraction < 1.0); + assert.equal(trace.entity, target); + }); + }); + + test('supports skipping and filtering client trace candidates', () => { + const collision = new ServerCollision(); + const area = new ServerArea(); + area.initBoxHull(); + + const skipped = createClientTraceEntity( + 1, + new Vector(48, 0, 0), + new Vector(-16, -16, -24), + new Vector(16, 16, 32), + ); + const filtered = createClientTraceEntity( + 2, + new Vector(80, 0, 0), + new Vector(-16, -16, -24), + new Vector(16, 16, 32), + ); + + withMockRegistry(defaultMockRegistry({ + area, + collision: { + traceWorldLine(_start, end) { + return CollisionTrace.empty(end); + }, + clipMoveToEntity: collision.clipMoveToEntity.bind(collision), + }, + }, { + state: { + clientEntities: { + *getEntities() { + yield skipped; + yield filtered; + }, + }, + }, + }), () => { + const trace = ClientEngineAPI.Traceline( + new Vector(0, 0, 0), + new Vector(128, 0, 0), + { + includeEntities: true, + passEntityId: 1, + filter: (entity) => entity.num === 2, + }, + ); + + assert.ok(trace.fraction < 1.0); + assert.equal(trace.entity, filtered); + }); + }); +}); diff --git a/test/common/vector-typescript-compat.test.mjs b/test/common/vector-typescript-compat.test.mjs new file mode 100644 index 00000000..4d0c5abc --- /dev/null +++ b/test/common/vector-typescript-compat.test.mjs @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import VectorFromMjs, { DirectionalVectors as DirectionalVectorsFromMjs, Quaternion as QuaternionFromMjs } from '../../source/shared/Vector.mjs'; +import VectorFromTs, { DirectionalVectors as DirectionalVectorsFromTs, Quaternion as QuaternionFromTs } from '../../source/shared/Vector.ts'; + +void describe('shared Vector TypeScript migration', () => { + void test('keeps the .mjs compatibility facade wired to the .ts implementation', () => { + assert.strictEqual(VectorFromMjs, VectorFromTs); + assert.strictEqual(DirectionalVectorsFromMjs, DirectionalVectorsFromTs); + assert.strictEqual(QuaternionFromMjs, QuaternionFromTs); + assert.strictEqual(VectorFromMjs.origin, VectorFromTs.origin); + + const vector = new VectorFromMjs(1, 2, 3); + assert.equal(vector.toString(), '1 2 3'); + assert.deepEqual(Array.from(vector.copy()), [1, 2, 3]); + }); +}); diff --git a/test/physics/brushtrace.test.mjs b/test/physics/brushtrace.test.mjs index 58c692b2..5bddbd8d 100644 --- a/test/physics/brushtrace.test.mjs +++ b/test/physics/brushtrace.test.mjs @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; import Vector from '../../source/shared/Vector.mjs'; import { BrushTrace, DIST_EPSILON, Pmove } from '../../source/engine/common/Pmove.mjs'; import { BrushSide } from '../../source/engine/common/model/BSP.mjs'; -import { content } from '../../source/shared/Defs.mjs'; +import { content } from '../../source/shared/Defs.ts'; import { assertNear, diff --git a/test/physics/collision-regressions.test.mjs b/test/physics/collision-regressions.test.mjs index 662fd6b7..b289ea16 100644 --- a/test/physics/collision-regressions.test.mjs +++ b/test/physics/collision-regressions.test.mjs @@ -2,7 +2,7 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import Vector from '../../source/shared/Vector.mjs'; -import { content, flags, moveType, moveTypes, solid } from '../../source/shared/Defs.mjs'; +import { content, flags, moveType, moveTypes, solid } from '../../source/shared/Defs.ts'; import { Brush, BrushModel, BrushSide } from '../../source/engine/common/model/BSP.mjs'; import { BrushTrace, Hull, PMF, Pmove, PmovePlayer, Trace } from '../../source/engine/common/Pmove.mjs'; import { BSP29Loader } from '../../source/engine/common/model/loaders/BSP29Loader.mjs'; diff --git a/test/physics/fixtures.mjs b/test/physics/fixtures.mjs index 31e869ba..1234a2b4 100644 --- a/test/physics/fixtures.mjs +++ b/test/physics/fixtures.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import Vector from '../../source/shared/Vector.mjs'; -import { content, flags, moveType, moveTypes, solid } from '../../source/shared/Defs.mjs'; +import { content, flags, moveType, moveTypes, solid } from '../../source/shared/Defs.ts'; import { Brush, BrushModel, BrushSide } from '../../source/engine/common/model/BSP.mjs'; import { Pmove } from '../../source/engine/common/Pmove.mjs'; import { eventBus, registry } from '../../source/engine/registry.mjs'; diff --git a/test/physics/func-rotating.test.mjs b/test/physics/func-rotating.test.mjs index a10c4180..51db69c9 100644 --- a/test/physics/func-rotating.test.mjs +++ b/test/physics/func-rotating.test.mjs @@ -2,7 +2,7 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import Vector from '../../source/shared/Vector.mjs'; -import { moveType, solid } from '../../source/shared/Defs.mjs'; +import { moveType, solid } from '../../source/shared/Defs.ts'; import { entityClasses } from '../../source/game/id1/GameAPI.mjs'; const RotatingEntity = entityClasses.find((entityClass) => entityClass.classname === 'func_rotating'); diff --git a/test/physics/pmove.test.mjs b/test/physics/pmove.test.mjs index ba22c188..6bb427bb 100644 --- a/test/physics/pmove.test.mjs +++ b/test/physics/pmove.test.mjs @@ -5,7 +5,7 @@ import assert from 'node:assert/strict'; import COMClass from '../../source/engine/common/Com.mjs'; import Mod from '../../source/engine/common/Mod.mjs'; import Vector from '../../source/shared/Vector.mjs'; -import { content } from '../../source/shared/Defs.mjs'; +import { content } from '../../source/shared/Defs.ts'; import { DIST_EPSILON, PM_TYPE, PMF, Pmove, PmovePlayer, Trace } from '../../source/engine/common/Pmove.mjs'; import { UserCmd } from '../../source/engine/network/Protocol.mjs'; import { eventBus, registry } from '../../source/engine/registry.mjs'; diff --git a/test/physics/server-client-physics.test.mjs b/test/physics/server-client-physics.test.mjs index 53556004..f6307215 100644 --- a/test/physics/server-client-physics.test.mjs +++ b/test/physics/server-client-physics.test.mjs @@ -2,7 +2,7 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import Vector from '../../source/shared/Vector.mjs'; -import { flags, moveType, solid } from '../../source/shared/Defs.mjs'; +import { flags, moveType, solid } from '../../source/shared/Defs.ts'; import { UserCmd } from '../../source/engine/network/Protocol.mjs'; import { eventBus, registry } from '../../source/engine/registry.mjs'; import { ServerClient } from '../../source/engine/server/Client.mjs'; diff --git a/test/physics/server-collision.test.mjs b/test/physics/server-collision.test.mjs index 9c5fe6b0..62e1e1a8 100644 --- a/test/physics/server-collision.test.mjs +++ b/test/physics/server-collision.test.mjs @@ -2,7 +2,7 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import Vector from '../../source/shared/Vector.mjs'; -import { content, flags, moveType, moveTypes, solid } from '../../source/shared/Defs.mjs'; +import { content, flags, moveType, moveTypes, solid } from '../../source/shared/Defs.ts'; import { BrushModel } from '../../source/engine/common/model/BSP.mjs'; import { BrushTrace, Pmove } from '../../source/engine/common/Pmove.mjs'; import { BSP29Loader } from '../../source/engine/common/model/loaders/BSP29Loader.mjs'; diff --git a/test/physics/server-movement.test.mjs b/test/physics/server-movement.test.mjs index d5e26494..9c4f9097 100644 --- a/test/physics/server-movement.test.mjs +++ b/test/physics/server-movement.test.mjs @@ -2,7 +2,7 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import Vector from '../../source/shared/Vector.mjs'; -import { content, flags, solid } from '../../source/shared/Defs.mjs'; +import { content, flags, solid } from '../../source/shared/Defs.ts'; import { ServerMovement } from '../../source/engine/server/physics/ServerMovement.mjs'; import { diff --git a/test/physics/server-physics.test.mjs b/test/physics/server-physics.test.mjs index 349010d8..fb48c616 100644 --- a/test/physics/server-physics.test.mjs +++ b/test/physics/server-physics.test.mjs @@ -2,7 +2,7 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import Vector from '../../source/shared/Vector.mjs'; -import { content, flags, gameCapabilities, moveType, moveTypes, solid } from '../../source/shared/Defs.mjs'; +import { content, flags, gameCapabilities, moveType, moveTypes, solid } from '../../source/shared/Defs.ts'; import { eventBus, registry } from '../../source/engine/registry.mjs'; import { ServerArea } from '../../source/engine/server/physics/ServerArea.mjs'; import { ServerCollision } from '../../source/engine/server/physics/ServerCollision.mjs'; diff --git a/test/renderer/bloom-effect.test.mjs b/test/renderer/bloom-effect.test.mjs index d0775c62..9665f83e 100644 --- a/test/renderer/bloom-effect.test.mjs +++ b/test/renderer/bloom-effect.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { describe, test } from 'node:test'; -import { effect } from '../../source/shared/Defs.mjs'; +import { effect } from '../../source/shared/Defs.ts'; import { getBloomBufferSize, getBloomDebugPreviewItems, getEntityBloomEmissiveScale, resolveBloomDebugMode, resolveBloomDownsample } from '../../source/engine/client/renderer/BloomEffect.mjs'; const bloomBlurShaderSource = readFileSync(new URL('../../source/engine/client/shaders/bloom-blur.frag', import.meta.url), 'utf8'); diff --git a/vite.config.dedicated.mjs b/vite.config.dedicated.mjs index 2621474a..7f31a3c0 100644 --- a/vite.config.dedicated.mjs +++ b/vite.config.dedicated.mjs @@ -93,6 +93,7 @@ function dedicatedWorkerBundlePlugin(mode) { async closeBundle() { const { rollup } = await import('rollup'); + const { transformWithEsbuild } = await import('vite'); const workerDir = resolve(__dirname, 'source/engine/server'); const outDir = resolve(__dirname, 'dist/dedicated/workers'); @@ -112,6 +113,21 @@ function dedicatedWorkerBundlePlugin(mode) { // Node built-ins stay external (both prefixed and bare); everything else is inlined external: [/^node:/, 'fs', 'path', 'os', 'url', 'util', 'crypto', 'stream', 'events', 'buffer', 'http', 'https', 'net', 'tls', 'child_process', 'worker_threads'], plugins: [ + { + name: 'transpile-typescript-modules', + async transform(code, id) { + if (!/\.(ts|mts|cts)$/.test(id)) { + return null; + } + + return await transformWithEsbuild(code, id, { + format: 'esm', + loader: 'ts', + sourcemap: mode !== 'production', + target: 'node24', + }); + }, + }, { // WorkerFramework.mjs constructs dynamic import paths at runtime // to evade Vite's static analysis (e.g. ['..','server','Com.mjs'].join('/')). From e295f91eacbbe29be6f2032f6b8eea993fd8135f Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 12:34:36 +0300 Subject: [PATCH 04/67] removing Vector.mjs --- source/engine/client/Chase.mjs | 2 +- source/engine/client/ClientEntities.mjs | 2 +- source/engine/client/ClientInput.mjs | 2 +- source/engine/client/ClientLegacy.mjs | 2 +- source/engine/client/ClientMessages.mjs | 2 +- .../client/ClientServerCommandHandlers.mjs | 2 +- source/engine/client/ClientState.mjs | 2 +- source/engine/client/Draw.mjs | 2 +- source/engine/client/Key.mjs | 2 +- source/engine/client/LegacyServerCommands.mjs | 2 +- source/engine/client/R.mjs | 2 +- source/engine/client/Sound.mjs | 2 +- source/engine/client/V.mjs | 2 +- .../client/renderer/AliasModelRenderer.mjs | 2 +- source/engine/client/renderer/BloomEffect.mjs | 2 +- .../client/renderer/BrushModelRenderer.mjs | 2 +- .../client/renderer/MeshModelRenderer.mjs | 2 +- source/engine/client/renderer/ShadowMap.mjs | 2 +- source/engine/common/Console.mjs | 2 +- source/engine/common/GameAPIs.mjs | 2 +- source/engine/common/Host.mjs | 2 +- source/engine/common/Pmove.mjs | 4 ++-- source/engine/common/model/AliasModel.mjs | 4 ++-- source/engine/common/model/BSP.mjs | 2 +- source/engine/common/model/BaseModel.mjs | 2 +- source/engine/common/model/MeshModel.mjs | 2 +- .../common/model/loaders/AliasMDLLoader.mjs | 2 +- .../common/model/loaders/BSP29Loader.mjs | 2 +- .../engine/common/model/loaders/BSP2Loader.mjs | 2 +- .../common/model/loaders/BSP38Loader.mjs | 2 +- .../common/model/loaders/SpriteSPRLoader.mjs | 2 +- .../model/loaders/WavefrontOBJLoader.mjs | 2 +- .../engine/common/model/parsers/ParsedQC.mjs | 2 +- source/engine/network/MSG.mjs | 2 +- source/engine/network/Protocol.mjs | 2 +- source/engine/server/Client.mjs | 2 +- source/engine/server/Edict.mjs | 2 +- source/engine/server/Navigation.mjs | 2 +- source/engine/server/NavigationWorker.mjs | 2 +- source/engine/server/Progs.mjs | 2 +- source/engine/server/ProgsAPI.mjs | 2 +- source/engine/server/Server.mjs | 2 +- source/engine/server/ServerEntityState.mjs | 2 +- source/engine/server/physics/ServerArea.mjs | 2 +- .../server/physics/ServerClientPhysics.mjs | 2 +- .../engine/server/physics/ServerCollision.mjs | 2 +- .../server/physics/ServerCollisionSupport.mjs | 2 +- .../physics/ServerLegacyHullCollision.mjs | 2 +- .../engine/server/physics/ServerMovement.mjs | 2 +- source/engine/server/physics/ServerPhysics.mjs | 2 +- source/shared/BSpline.mjs | 2 +- source/shared/GameInterfaces.d.ts | 2 +- source/shared/Octree.mjs | 2 +- source/shared/Vector.mjs | 3 --- test/common/game-apis.test.mjs | 2 +- test/common/model-cache.test.mjs | 2 +- test/common/octree.test.mjs | 2 +- test/common/vector-typescript-compat.test.mjs | 18 ------------------ test/physics/brushtrace.test.mjs | 2 +- test/physics/collision-regressions.test.mjs | 2 +- test/physics/fixtures.mjs | 3 ++- test/physics/func-rotating.test.mjs | 5 ++++- test/physics/func-train.test.mjs | 2 +- test/physics/map-pmove-harness.mjs | 2 +- test/physics/navigation.test.mjs | 2 +- test/physics/pmove.test.mjs | 2 +- test/physics/server-client-physics.test.mjs | 2 +- test/physics/server-collision.test.mjs | 2 +- test/physics/server-movement.test.mjs | 2 +- test/physics/server-physics.test.mjs | 2 +- test/physics/server.test.mjs | 2 +- test/renderer/brush-model-renderer.test.mjs | 2 +- test/renderer/r-sorting.test.mjs | 2 +- 73 files changed, 77 insertions(+), 94 deletions(-) delete mode 100644 source/shared/Vector.mjs delete mode 100644 test/common/vector-typescript-compat.test.mjs diff --git a/source/engine/client/Chase.mjs b/source/engine/client/Chase.mjs index 1ca6a51c..dcdeed65 100644 --- a/source/engine/client/Chase.mjs +++ b/source/engine/client/Chase.mjs @@ -1,4 +1,4 @@ -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import Cvar from '../common/Cvar.mjs'; import { eventBus, registry } from '../registry.mjs'; diff --git a/source/engine/client/ClientEntities.mjs b/source/engine/client/ClientEntities.mjs index 03cfceb7..5c93fa10 100644 --- a/source/engine/client/ClientEntities.mjs +++ b/source/engine/client/ClientEntities.mjs @@ -1,4 +1,4 @@ -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import { eventBus, registry } from '../registry.mjs'; import * as Def from '../common/Def.mjs'; import { content, effect, solid } from '../../shared/Defs.ts'; diff --git a/source/engine/client/ClientInput.mjs b/source/engine/client/ClientInput.mjs index e2641438..1e09cf5f 100644 --- a/source/engine/client/ClientInput.mjs +++ b/source/engine/client/ClientInput.mjs @@ -1,4 +1,4 @@ -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import * as Protocol from '../network/Protocol.mjs'; import Q from '../../shared/Q.mjs'; import { SzBuffer } from '../network/MSG.mjs'; diff --git a/source/engine/client/ClientLegacy.mjs b/source/engine/client/ClientLegacy.mjs index 4f877112..af1502e0 100644 --- a/source/engine/client/ClientLegacy.mjs +++ b/source/engine/client/ClientLegacy.mjs @@ -3,7 +3,7 @@ * Classes from this file are used by the client, when the game is not providing a ClientGameAPI implementation. */ -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import { effect, modelFlags } from '../../shared/Defs.ts'; import { BaseClientEdictHandler } from '../../shared/ClientEdict.mjs'; diff --git a/source/engine/client/ClientMessages.mjs b/source/engine/client/ClientMessages.mjs index bd981f18..c39d39c4 100644 --- a/source/engine/client/ClientMessages.mjs +++ b/source/engine/client/ClientMessages.mjs @@ -2,7 +2,7 @@ import * as Protocol from '../network/Protocol.mjs'; import * as Def from '../common/Def.mjs'; import { eventBus, registry } from '../registry.mjs'; import { HostError } from '../common/Errors.mjs'; -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import { PmovePlayer } from '../common/Pmove.mjs'; import { gameCapabilities } from '../../shared/Defs.ts'; import { ClientEdict } from './ClientEntities.mjs'; diff --git a/source/engine/client/ClientServerCommandHandlers.mjs b/source/engine/client/ClientServerCommandHandlers.mjs index 5b8da30d..537245db 100644 --- a/source/engine/client/ClientServerCommandHandlers.mjs +++ b/source/engine/client/ClientServerCommandHandlers.mjs @@ -3,7 +3,7 @@ import * as Def from '../common/Def.mjs'; import Cmd from '../common/Cmd.mjs'; import { HostError } from '../common/Errors.mjs'; import { gameCapabilities } from '../../shared/Defs.ts'; -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import { ClientEngineAPI } from '../common/GameAPIs.mjs'; import { sharedCollisionModelSource } from '../common/CollisionModelSource.mjs'; import { eventBus, registry } from '../registry.mjs'; diff --git a/source/engine/client/ClientState.mjs b/source/engine/client/ClientState.mjs index ac3c9f3a..4ec2b3be 100644 --- a/source/engine/client/ClientState.mjs +++ b/source/engine/client/ClientState.mjs @@ -2,7 +2,7 @@ import { SzBuffer } from '../network/MSG.mjs'; import { QSocket } from '../network/NetworkDrivers.mjs'; import * as Protocol from '../network/Protocol.mjs'; import * as Def from '../common/Def.mjs'; -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import { EventBus, eventBus, registry } from '../registry.mjs'; import ClientEntities, { ClientEdict } from './ClientEntities.mjs'; import { ClientMessages } from './ClientMessages.mjs'; diff --git a/source/engine/client/Draw.mjs b/source/engine/client/Draw.mjs index 31d5bd60..d39f542e 100644 --- a/source/engine/client/Draw.mjs +++ b/source/engine/client/Draw.mjs @@ -1,4 +1,4 @@ -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import { MissingResourceError } from '../common/Errors.mjs'; import VID from './VID.mjs'; diff --git a/source/engine/client/Key.mjs b/source/engine/client/Key.mjs index 2a55da24..514ef135 100644 --- a/source/engine/client/Key.mjs +++ b/source/engine/client/Key.mjs @@ -1,5 +1,5 @@ import { K } from '../../shared/Keys.mjs'; -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import Cmd from '../common/Cmd.mjs'; import Cvar from '../common/Cvar.mjs'; import { clientConnectionState } from '../common/Def.mjs'; diff --git a/source/engine/client/LegacyServerCommands.mjs b/source/engine/client/LegacyServerCommands.mjs index a75c6782..9302a344 100644 --- a/source/engine/client/LegacyServerCommands.mjs +++ b/source/engine/client/LegacyServerCommands.mjs @@ -3,7 +3,7 @@ import * as Def from '../common/Def.mjs'; import { HostError } from '../common/Errors.mjs'; import { eventBus, registry } from '../registry.mjs'; import { ScoreSlot } from './ClientState.mjs'; -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import { handleNop, handleTime, diff --git a/source/engine/client/R.mjs b/source/engine/client/R.mjs index a76808fd..0bade1ef 100644 --- a/source/engine/client/R.mjs +++ b/source/engine/client/R.mjs @@ -1,4 +1,4 @@ -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import Cvar from '../common/Cvar.mjs'; import Cmd from '../common/Cmd.mjs'; import * as Def from '../common/Def.mjs'; diff --git a/source/engine/client/Sound.mjs b/source/engine/client/Sound.mjs index 683faf7b..e0c2a91a 100644 --- a/source/engine/client/Sound.mjs +++ b/source/engine/client/Sound.mjs @@ -1,4 +1,4 @@ -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import Cmd from '../common/Cmd.mjs'; import Cvar from '../common/Cvar.mjs'; import Q from '../../shared/Q.mjs'; diff --git a/source/engine/client/V.mjs b/source/engine/client/V.mjs index e5f48598..ff87dfcb 100644 --- a/source/engine/client/V.mjs +++ b/source/engine/client/V.mjs @@ -1,4 +1,4 @@ -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import { content, gameCapabilities } from '../../shared/Defs.ts'; import Cmd from '../common/Cmd.mjs'; import Cvar from '../common/Cvar.mjs'; diff --git a/source/engine/client/renderer/AliasModelRenderer.mjs b/source/engine/client/renderer/AliasModelRenderer.mjs index cfdb63bf..ba5a01f2 100644 --- a/source/engine/client/renderer/AliasModelRenderer.mjs +++ b/source/engine/client/renderer/AliasModelRenderer.mjs @@ -1,4 +1,4 @@ -import Vector from '../../../shared/Vector.mjs'; +import Vector from '../../../shared/Vector.ts'; import { ModelRenderer } from './ModelRenderer.mjs'; import { getEntityBloomEmissiveScale } from './BloomEffect.mjs'; import { eventBus, registry } from '../../registry.mjs'; diff --git a/source/engine/client/renderer/BloomEffect.mjs b/source/engine/client/renderer/BloomEffect.mjs index 477393a7..905c1f4f 100644 --- a/source/engine/client/renderer/BloomEffect.mjs +++ b/source/engine/client/renderer/BloomEffect.mjs @@ -2,7 +2,7 @@ import GL from '../GL.mjs'; import PostProcess from './PostProcess.mjs'; import VID from '../VID.mjs'; import PostProcessEffect from './PostProcessEffect.mjs'; -import Vector from '../../../shared/Vector.mjs'; +import Vector from '../../../shared/Vector.ts'; import { eventBus, registry } from '../../registry.mjs'; import { effect } from '../../../shared/Defs.ts'; diff --git a/source/engine/client/renderer/BrushModelRenderer.mjs b/source/engine/client/renderer/BrushModelRenderer.mjs index b5eeb139..80cf1a51 100644 --- a/source/engine/client/renderer/BrushModelRenderer.mjs +++ b/source/engine/client/renderer/BrushModelRenderer.mjs @@ -1,4 +1,4 @@ -import Vector from '../../../shared/Vector.mjs'; +import Vector from '../../../shared/Vector.ts'; import { ModelRenderer } from './ModelRenderer.mjs'; import { eventBus, registry } from '../../registry.mjs'; import GL, { ATTRIB_LOCATIONS, BRUSH_VERTEX_STRIDE } from '../GL.mjs'; diff --git a/source/engine/client/renderer/MeshModelRenderer.mjs b/source/engine/client/renderer/MeshModelRenderer.mjs index 381fa997..96602d12 100644 --- a/source/engine/client/renderer/MeshModelRenderer.mjs +++ b/source/engine/client/renderer/MeshModelRenderer.mjs @@ -1,4 +1,4 @@ -import Vector from '../../../shared/Vector.mjs'; +import Vector from '../../../shared/Vector.ts'; import { ModelRenderer } from './ModelRenderer.mjs'; import { getEntityBloomEmissiveScale } from './BloomEffect.mjs'; import { eventBus, registry } from '../../registry.mjs'; diff --git a/source/engine/client/renderer/ShadowMap.mjs b/source/engine/client/renderer/ShadowMap.mjs index d551bf61..eb4059fd 100644 --- a/source/engine/client/renderer/ShadowMap.mjs +++ b/source/engine/client/renderer/ShadowMap.mjs @@ -4,7 +4,7 @@ import { limits } from '../../common/Def.mjs'; import { eventBus, registry } from '../../registry.mjs'; import { materialFlags } from './Materials.mjs'; import { effect } from '../../../shared/Defs.ts'; -import Vector from '../../../shared/Vector.mjs'; +import Vector from '../../../shared/Vector.ts'; import { AliasModelRenderer } from './AliasModelRenderer.mjs'; let { CL, COM, Mod, R, SV } = registry; diff --git a/source/engine/common/Console.mjs b/source/engine/common/Console.mjs index 9962a9d8..d92aab8e 100644 --- a/source/engine/common/Console.mjs +++ b/source/engine/common/Console.mjs @@ -2,7 +2,7 @@ import { eventBus, registry } from '../registry.mjs'; import Cvar from './Cvar.mjs'; -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import Cmd from './Cmd.mjs'; import VID from '../client/VID.mjs'; import { clientConnectionState } from './Def.mjs'; diff --git a/source/engine/common/GameAPIs.mjs b/source/engine/common/GameAPIs.mjs index a6ac457d..7fe9cafd 100644 --- a/source/engine/common/GameAPIs.mjs +++ b/source/engine/common/GameAPIs.mjs @@ -1,5 +1,5 @@ import { PmoveConfiguration } from '../../shared/Pmove.mjs'; -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import { solid } from '../../shared/Defs.ts'; import Key from '../client/Key.mjs'; import { SFX } from '../client/Sound.mjs'; diff --git a/source/engine/common/Host.mjs b/source/engine/common/Host.mjs index d06d6097..6741c4cc 100644 --- a/source/engine/common/Host.mjs +++ b/source/engine/common/Host.mjs @@ -3,7 +3,7 @@ import * as Protocol from '../network/Protocol.mjs'; import * as Def from './Def.mjs'; import Cmd, { ConsoleCommand } from './Cmd.mjs'; import { eventBus, registry } from '../registry.mjs'; -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import Q from '../../shared/Q.mjs'; import { ServerClient } from '../server/Client.mjs'; import { ServerEngineAPI } from './GameAPIs.mjs'; diff --git a/source/engine/common/Pmove.mjs b/source/engine/common/Pmove.mjs index 78ed49ce..75523440 100644 --- a/source/engine/common/Pmove.mjs +++ b/source/engine/common/Pmove.mjs @@ -7,14 +7,14 @@ * Original sources: pmove.c, pmovetst.c (Q2), pmove.c (Q1). */ -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import * as Protocol from '../network/Protocol.mjs'; import { content } from '../../shared/Defs.ts'; import { BrushModel } from './Mod.mjs'; import Cvar from './Cvar.mjs'; import { PmoveConfiguration } from '../../shared/Pmove.mjs'; -/** @typedef {import('../../shared/Vector.mjs').DirectionalVectors} DirectionalVectors */ +/** @typedef {import('../../shared/Vector.ts').DirectionalVectors} DirectionalVectors */ /** @typedef {{ normal: Vector, type: number }} BrushTracePlaneLike */ // --------------------------------------------------------------------------- diff --git a/source/engine/common/model/AliasModel.mjs b/source/engine/common/model/AliasModel.mjs index 2f6881b4..c479e5e9 100644 --- a/source/engine/common/model/AliasModel.mjs +++ b/source/engine/common/model/AliasModel.mjs @@ -15,10 +15,10 @@ export class AliasModel extends BaseModel { // Private model data (used during loading) - /** @type {import('../../../shared/Vector.mjs').default|null} Scale factors for vertices */ + /** @type {import('../../../shared/Vector.ts').default|null} Scale factors for vertices */ this._scale = null; - /** @type {import('../../../shared/Vector.mjs').default|null} Origin offset for vertices */ + /** @type {import('../../../shared/Vector.ts').default|null} Origin offset for vertices */ this._scale_origin = null; /** @type {number} Number of skins in file */ diff --git a/source/engine/common/model/BSP.mjs b/source/engine/common/model/BSP.mjs index e7b9e423..c8277f23 100644 --- a/source/engine/common/model/BSP.mjs +++ b/source/engine/common/model/BSP.mjs @@ -4,7 +4,7 @@ import { BaseModel } from './BaseModel.mjs'; import { SkyRenderer } from '../../client/renderer/Sky.mjs'; import { AreaPortals } from './AreaPortals.mjs'; -/** @typedef {import('../../../shared/Vector.mjs').default} Vector */ +/** @typedef {import('../../../shared/Vector.ts').default} Vector */ /** @typedef {import('./BaseModel.mjs').Face} Face */ /** @typedef {import('./BaseModel.mjs').Plane} Plane */ diff --git a/source/engine/common/model/BaseModel.mjs b/source/engine/common/model/BaseModel.mjs index 8e52dae5..f56c99f2 100644 --- a/source/engine/common/model/BaseModel.mjs +++ b/source/engine/common/model/BaseModel.mjs @@ -1,6 +1,6 @@ import GL from '../../client/GL.mjs'; import { eventBus } from '../../registry.mjs'; -import Vector from '../../../shared/Vector.mjs'; +import Vector from '../../../shared/Vector.ts'; let gl = /** @type {WebGL2RenderingContext|null} */ (null); diff --git a/source/engine/common/model/MeshModel.mjs b/source/engine/common/model/MeshModel.mjs index a1ef9e43..9c2978d0 100644 --- a/source/engine/common/model/MeshModel.mjs +++ b/source/engine/common/model/MeshModel.mjs @@ -1,5 +1,5 @@ import { BaseMaterial } from '../../client/renderer/Materials.mjs'; -import Vector from '../../../shared/Vector.mjs'; +import Vector from '../../../shared/Vector.ts'; import { BaseModel } from './BaseModel.mjs'; /** diff --git a/source/engine/common/model/loaders/AliasMDLLoader.mjs b/source/engine/common/model/loaders/AliasMDLLoader.mjs index 475f0205..4deaec11 100644 --- a/source/engine/common/model/loaders/AliasMDLLoader.mjs +++ b/source/engine/common/model/loaders/AliasMDLLoader.mjs @@ -1,4 +1,4 @@ -import Vector from '../../../../shared/Vector.mjs'; +import Vector from '../../../../shared/Vector.ts'; import Q from '../../../../shared/Q.mjs'; import GL, { GLTexture, resampleTexture8 } from '../../../client/GL.mjs'; import W, { translateIndexToLuminanceRGBA, translateIndexToRGBA } from '../../W.mjs'; diff --git a/source/engine/common/model/loaders/BSP29Loader.mjs b/source/engine/common/model/loaders/BSP29Loader.mjs index f59d1da1..80023498 100644 --- a/source/engine/common/model/loaders/BSP29Loader.mjs +++ b/source/engine/common/model/loaders/BSP29Loader.mjs @@ -1,4 +1,4 @@ -import Vector from '../../../../shared/Vector.mjs'; +import Vector from '../../../../shared/Vector.ts'; import Q from '../../../../shared/Q.mjs'; import { content } from '../../../../shared/Defs.ts'; import { GLTexture } from '../../../client/GL.mjs'; diff --git a/source/engine/common/model/loaders/BSP2Loader.mjs b/source/engine/common/model/loaders/BSP2Loader.mjs index 7b3dee8a..0c8e49cc 100644 --- a/source/engine/common/model/loaders/BSP2Loader.mjs +++ b/source/engine/common/model/loaders/BSP2Loader.mjs @@ -1,4 +1,4 @@ -import Vector from '../../../../shared/Vector.mjs'; +import Vector from '../../../../shared/Vector.ts'; import { CorruptedResourceError } from '../../Errors.mjs'; import { BSP29Loader } from './BSP29Loader.mjs'; import { Face } from '../BaseModel.mjs'; diff --git a/source/engine/common/model/loaders/BSP38Loader.mjs b/source/engine/common/model/loaders/BSP38Loader.mjs index 1508b4b4..e642d94d 100644 --- a/source/engine/common/model/loaders/BSP38Loader.mjs +++ b/source/engine/common/model/loaders/BSP38Loader.mjs @@ -1,6 +1,6 @@ import { content } from '../../../../shared/Defs.ts'; import Q from '../../../../shared/Q.mjs'; -import Vector from '../../../../shared/Vector.mjs'; +import Vector from '../../../../shared/Vector.ts'; import { CRC16CCITT } from '../../CRC.mjs'; import { Plane } from '../BaseModel.mjs'; import { Brush, BrushModel, BrushSide, Node } from '../BSP.mjs'; diff --git a/source/engine/common/model/loaders/SpriteSPRLoader.mjs b/source/engine/common/model/loaders/SpriteSPRLoader.mjs index 315a793d..116a6dcc 100644 --- a/source/engine/common/model/loaders/SpriteSPRLoader.mjs +++ b/source/engine/common/model/loaders/SpriteSPRLoader.mjs @@ -1,4 +1,4 @@ -import Vector from '../../../../shared/Vector.mjs'; +import Vector from '../../../../shared/Vector.ts'; import { GLTexture } from '../../../client/GL.mjs'; import W, { translateIndexToRGBA } from '../../W.mjs'; import { CRC16CCITT } from '../../CRC.mjs'; diff --git a/source/engine/common/model/loaders/WavefrontOBJLoader.mjs b/source/engine/common/model/loaders/WavefrontOBJLoader.mjs index 91b8ff3d..34fef9b0 100644 --- a/source/engine/common/model/loaders/WavefrontOBJLoader.mjs +++ b/source/engine/common/model/loaders/WavefrontOBJLoader.mjs @@ -1,6 +1,6 @@ import { registry } from '../../../registry.mjs'; -import Vector from '../../../../shared/Vector.mjs'; +import Vector from '../../../../shared/Vector.ts'; import { ModelLoader } from '../ModelLoader.mjs'; import { MeshModel } from '../MeshModel.mjs'; import { PBRMaterial } from '../../../client/renderer/Materials.mjs'; diff --git a/source/engine/common/model/parsers/ParsedQC.mjs b/source/engine/common/model/parsers/ParsedQC.mjs index 15f9b654..11f40488 100644 --- a/source/engine/common/model/parsers/ParsedQC.mjs +++ b/source/engine/common/model/parsers/ParsedQC.mjs @@ -1,5 +1,5 @@ import Q from '../../../../shared/Q.mjs'; -import Vector from '../../../../shared/Vector.mjs'; +import Vector from '../../../../shared/Vector.ts'; /** @typedef {import('../../../../shared/GameInterfaces.d.ts').ParsedQC} IParsedQC */ /** @augments IParsedQC */ diff --git a/source/engine/network/MSG.mjs b/source/engine/network/MSG.mjs index 43278f7c..4dd48795 100644 --- a/source/engine/network/MSG.mjs +++ b/source/engine/network/MSG.mjs @@ -1,5 +1,5 @@ import Q from '../../shared/Q.mjs'; -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import * as Protocol from '../network/Protocol.mjs'; import { eventBus, registry } from '../registry.mjs'; diff --git a/source/engine/network/Protocol.mjs b/source/engine/network/Protocol.mjs index 2aea6656..ee32b50c 100644 --- a/source/engine/network/Protocol.mjs +++ b/source/engine/network/Protocol.mjs @@ -1,4 +1,4 @@ -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; export const version = 42; // QuakeShack special version diff --git a/source/engine/server/Client.mjs b/source/engine/server/Client.mjs index 3004b7e7..05c00223 100644 --- a/source/engine/server/Client.mjs +++ b/source/engine/server/Client.mjs @@ -1,6 +1,6 @@ import { enumHelpers } from '../../shared/Q.mjs'; import { gameCapabilities } from '../../shared/Defs.ts'; -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import { SzBuffer } from '../network/MSG.mjs'; import { QSocket } from '../network/NetworkDrivers.mjs'; import * as Protocol from '../network/Protocol.mjs'; diff --git a/source/engine/server/Edict.mjs b/source/engine/server/Edict.mjs index 3a4e06f7..1f584899 100644 --- a/source/engine/server/Edict.mjs +++ b/source/engine/server/Edict.mjs @@ -1,4 +1,4 @@ -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import { SzBuffer, registerSerializableType } from '../network/MSG.mjs'; import * as Protocol from '../network/Protocol.mjs'; import * as Def from '../common/Def.mjs'; diff --git a/source/engine/server/Navigation.mjs b/source/engine/server/Navigation.mjs index cb206ab2..207d23cc 100644 --- a/source/engine/server/Navigation.mjs +++ b/source/engine/server/Navigation.mjs @@ -1,7 +1,7 @@ // import sampleBSpline from '../../shared/BSpline.mjs'; import * as Def from '../../shared/Defs.ts'; import { Octree } from '../../shared/Octree.mjs'; -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import Cmd from '../common/Cmd.mjs'; // import Cmd, { ConsoleCommand } from '../common/Cmd.mjs'; import Cvar from '../common/Cvar.mjs'; diff --git a/source/engine/server/NavigationWorker.mjs b/source/engine/server/NavigationWorker.mjs index 218447be..e7c036ec 100644 --- a/source/engine/server/NavigationWorker.mjs +++ b/source/engine/server/NavigationWorker.mjs @@ -2,7 +2,7 @@ import WorkerFramework from '../common/WorkerFramework.mjs'; import { eventBus, registry } from '../registry.mjs'; import { Navigation, NavMeshOutOfDateException } from './Navigation.mjs'; -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; await WorkerFramework.Init(); diff --git a/source/engine/server/Progs.mjs b/source/engine/server/Progs.mjs index f5bcf82e..947bc4fb 100644 --- a/source/engine/server/Progs.mjs +++ b/source/engine/server/Progs.mjs @@ -3,7 +3,7 @@ import { CRC16CCITT } from '../common/CRC.mjs'; import Cvar from '../common/Cvar.mjs'; import { HostError, MissingResourceError } from '../common/Errors.mjs'; import Q from '../../shared/Q.mjs'; -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import { eventBus, registry } from '../registry.mjs'; import { ED, ServerEdict } from './Edict.mjs'; import { ServerEngineAPI } from '../common/GameAPIs.mjs'; diff --git a/source/engine/server/ProgsAPI.mjs b/source/engine/server/ProgsAPI.mjs index 9b1f98e2..9449ee3d 100644 --- a/source/engine/server/ProgsAPI.mjs +++ b/source/engine/server/ProgsAPI.mjs @@ -1,4 +1,4 @@ -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import Cmd from '../common/Cmd.mjs'; import { HostError } from '../common/Errors.mjs'; import { ServerEngineAPI } from '../common/GameAPIs.mjs'; diff --git a/source/engine/server/Server.mjs b/source/engine/server/Server.mjs index ea72f2cd..cce1259a 100644 --- a/source/engine/server/Server.mjs +++ b/source/engine/server/Server.mjs @@ -1,6 +1,6 @@ import Cvar from '../common/Cvar.mjs'; import { MoveVars, Pmove } from '../common/Pmove.mjs'; -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; import { SzBuffer } from '../network/MSG.mjs'; import * as Protocol from '../network/Protocol.mjs'; import * as Def from './../common/Def.mjs'; diff --git a/source/engine/server/ServerEntityState.mjs b/source/engine/server/ServerEntityState.mjs index e87c563b..6879543a 100644 --- a/source/engine/server/ServerEntityState.mjs +++ b/source/engine/server/ServerEntityState.mjs @@ -1,4 +1,4 @@ -import Vector from '../../shared/Vector.mjs'; +import Vector from '../../shared/Vector.ts'; /** @typedef {import('../../shared/GameInterfaces').SerializableType} SerializableType */ diff --git a/source/engine/server/physics/ServerArea.mjs b/source/engine/server/physics/ServerArea.mjs index 9a86fa46..22e69f2c 100644 --- a/source/engine/server/physics/ServerArea.mjs +++ b/source/engine/server/physics/ServerArea.mjs @@ -1,4 +1,4 @@ -import Vector from '../../../shared/Vector.mjs'; +import Vector from '../../../shared/Vector.ts'; import * as Defs from '../../../shared/Defs.ts'; import { Octree } from '../../../shared/Octree.mjs'; import { eventBus, registry } from '../../registry.mjs'; diff --git a/source/engine/server/physics/ServerClientPhysics.mjs b/source/engine/server/physics/ServerClientPhysics.mjs index b7a4ec9d..41a90d9c 100644 --- a/source/engine/server/physics/ServerClientPhysics.mjs +++ b/source/engine/server/physics/ServerClientPhysics.mjs @@ -1,4 +1,4 @@ -import Vector from '../../../shared/Vector.mjs'; +import Vector from '../../../shared/Vector.ts'; import * as Defs from '../../../shared/Defs.ts'; import { eventBus, registry } from '../../registry.mjs'; import { diff --git a/source/engine/server/physics/ServerCollision.mjs b/source/engine/server/physics/ServerCollision.mjs index 7cb6d995..511c4b78 100644 --- a/source/engine/server/physics/ServerCollision.mjs +++ b/source/engine/server/physics/ServerCollision.mjs @@ -1,4 +1,4 @@ -import Vector from '../../../shared/Vector.mjs'; +import Vector from '../../../shared/Vector.ts'; import * as Defs from '../../../shared/Defs.ts'; import CollisionModelSource, { createRegistryCollisionModelSource } from '../../common/CollisionModelSource.mjs'; import Mod, { BrushModel } from '../../common/Mod.mjs'; diff --git a/source/engine/server/physics/ServerCollisionSupport.mjs b/source/engine/server/physics/ServerCollisionSupport.mjs index 9eda1661..6e073c70 100644 --- a/source/engine/server/physics/ServerCollisionSupport.mjs +++ b/source/engine/server/physics/ServerCollisionSupport.mjs @@ -1,4 +1,4 @@ -import Vector from '../../../shared/Vector.mjs'; +import Vector from '../../../shared/Vector.ts'; /** @typedef {import('../Client.mjs').ServerEdict} ServerEdict */ diff --git a/source/engine/server/physics/ServerLegacyHullCollision.mjs b/source/engine/server/physics/ServerLegacyHullCollision.mjs index 3b4af4f8..f66d9cd1 100644 --- a/source/engine/server/physics/ServerLegacyHullCollision.mjs +++ b/source/engine/server/physics/ServerLegacyHullCollision.mjs @@ -1,4 +1,4 @@ -import Vector from '../../../shared/Vector.mjs'; +import Vector from '../../../shared/Vector.ts'; import * as Defs from '../../../shared/Defs.ts'; import { DIST_EPSILON } from '../../common/Pmove.mjs'; import { eventBus, registry } from '../../registry.mjs'; diff --git a/source/engine/server/physics/ServerMovement.mjs b/source/engine/server/physics/ServerMovement.mjs index 74e3371a..e7c8763b 100644 --- a/source/engine/server/physics/ServerMovement.mjs +++ b/source/engine/server/physics/ServerMovement.mjs @@ -1,4 +1,4 @@ -import Vector from '../../../shared/Vector.mjs'; +import Vector from '../../../shared/Vector.ts'; import * as Defs from '../../../shared/Defs.ts'; import { STEPSIZE } from '../../common/Pmove.mjs'; import { ServerEdict } from '../Edict.mjs'; diff --git a/source/engine/server/physics/ServerPhysics.mjs b/source/engine/server/physics/ServerPhysics.mjs index 299c35ee..996f4a56 100644 --- a/source/engine/server/physics/ServerPhysics.mjs +++ b/source/engine/server/physics/ServerPhysics.mjs @@ -1,4 +1,4 @@ -import Vector from '../../../shared/Vector.mjs'; +import Vector from '../../../shared/Vector.ts'; import * as Defs from '../../../shared/Defs.ts'; import Q from '../../../shared/Q.mjs'; import { eventBus, registry } from '../../registry.mjs'; diff --git a/source/shared/BSpline.mjs b/source/shared/BSpline.mjs index e425e9bd..b93d63dc 100644 --- a/source/shared/BSpline.mjs +++ b/source/shared/BSpline.mjs @@ -1,4 +1,4 @@ -import Vector from './Vector.mjs'; +import Vector from './Vector.ts'; /** * Uniform clamped knot vector in [0,1] diff --git a/source/shared/GameInterfaces.d.ts b/source/shared/GameInterfaces.d.ts index 8399199e..fadee334 100644 --- a/source/shared/GameInterfaces.d.ts +++ b/source/shared/GameInterfaces.d.ts @@ -1,7 +1,7 @@ import { BaseClientEdictHandler } from "./ClientEdict.mjs"; import { ClientEngineAPI, ServerEngineAPI } from "../engine/common/GameAPIs.mjs"; import { ServerEdict } from "../engine/server/Edict.mjs"; -import Vector from "./Vector.mjs"; +import Vector from "./Vector.ts"; import { StartGameInterface } from "../engine/client/ClientLifecycle.mjs"; export type ClientEngineAPI = Readonly; diff --git a/source/shared/Octree.mjs b/source/shared/Octree.mjs index e87942bf..5bef48f7 100644 --- a/source/shared/Octree.mjs +++ b/source/shared/Octree.mjs @@ -1,4 +1,4 @@ -import Vector from './Vector.mjs'; +import Vector from './Vector.ts'; /** @typedef {{origin: Vector|null, absmin: Vector|null, absmax: Vector|null, octreeNode: OctreeNode|null}} OctreeItem */ diff --git a/source/shared/Vector.mjs b/source/shared/Vector.mjs deleted file mode 100644 index 85418889..00000000 --- a/source/shared/Vector.mjs +++ /dev/null @@ -1,3 +0,0 @@ - -export { default } from './Vector.ts'; -export * from './Vector.ts'; diff --git a/test/common/game-apis.test.mjs b/test/common/game-apis.test.mjs index bafb3bb9..89c0c20a 100644 --- a/test/common/game-apis.test.mjs +++ b/test/common/game-apis.test.mjs @@ -1,7 +1,7 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; -import Vector from '../../source/shared/Vector.mjs'; +import Vector from '../../source/shared/Vector.ts'; import { solid } from '../../source/shared/Defs.ts'; import { ClientEdict } from '../../source/engine/client/ClientEntities.mjs'; import { ClientEngineAPI } from '../../source/engine/common/GameAPIs.mjs'; diff --git a/test/common/model-cache.test.mjs b/test/common/model-cache.test.mjs index 5aa79cc8..95c79542 100644 --- a/test/common/model-cache.test.mjs +++ b/test/common/model-cache.test.mjs @@ -6,7 +6,7 @@ import { AliasModel } from '../../source/engine/common/model/AliasModel.mjs'; import { Face } from '../../source/engine/common/model/BaseModel.mjs'; import { BrushModel, Node } from '../../source/engine/common/model/BSP.mjs'; import { eventBus, registry } from '../../source/engine/registry.mjs'; -import Vector from '../../source/shared/Vector.mjs'; +import Vector from '../../source/shared/Vector.ts'; /** @typedef {import('../../source/engine/common/model/BaseModel.mjs').BaseModel} BaseModel */ diff --git a/test/common/octree.test.mjs b/test/common/octree.test.mjs index 907893dc..f0091886 100644 --- a/test/common/octree.test.mjs +++ b/test/common/octree.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import { Octree } from '../../source/shared/Octree.mjs'; -import Vector from '../../source/shared/Vector.mjs'; +import Vector from '../../source/shared/Vector.ts'; /** @typedef {import('../../source/shared/Octree.mjs').OctreeNode} TestOctreeNode */ diff --git a/test/common/vector-typescript-compat.test.mjs b/test/common/vector-typescript-compat.test.mjs deleted file mode 100644 index 4d0c5abc..00000000 --- a/test/common/vector-typescript-compat.test.mjs +++ /dev/null @@ -1,18 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; - -import VectorFromMjs, { DirectionalVectors as DirectionalVectorsFromMjs, Quaternion as QuaternionFromMjs } from '../../source/shared/Vector.mjs'; -import VectorFromTs, { DirectionalVectors as DirectionalVectorsFromTs, Quaternion as QuaternionFromTs } from '../../source/shared/Vector.ts'; - -void describe('shared Vector TypeScript migration', () => { - void test('keeps the .mjs compatibility facade wired to the .ts implementation', () => { - assert.strictEqual(VectorFromMjs, VectorFromTs); - assert.strictEqual(DirectionalVectorsFromMjs, DirectionalVectorsFromTs); - assert.strictEqual(QuaternionFromMjs, QuaternionFromTs); - assert.strictEqual(VectorFromMjs.origin, VectorFromTs.origin); - - const vector = new VectorFromMjs(1, 2, 3); - assert.equal(vector.toString(), '1 2 3'); - assert.deepEqual(Array.from(vector.copy()), [1, 2, 3]); - }); -}); diff --git a/test/physics/brushtrace.test.mjs b/test/physics/brushtrace.test.mjs index 5bddbd8d..eb5a026b 100644 --- a/test/physics/brushtrace.test.mjs +++ b/test/physics/brushtrace.test.mjs @@ -1,7 +1,7 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; -import Vector from '../../source/shared/Vector.mjs'; +import Vector from '../../source/shared/Vector.ts'; import { BrushTrace, DIST_EPSILON, Pmove } from '../../source/engine/common/Pmove.mjs'; import { BrushSide } from '../../source/engine/common/model/BSP.mjs'; import { content } from '../../source/shared/Defs.ts'; diff --git a/test/physics/collision-regressions.test.mjs b/test/physics/collision-regressions.test.mjs index b289ea16..28a8c859 100644 --- a/test/physics/collision-regressions.test.mjs +++ b/test/physics/collision-regressions.test.mjs @@ -1,7 +1,7 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; -import Vector from '../../source/shared/Vector.mjs'; +import Vector from '../../source/shared/Vector.ts'; import { content, flags, moveType, moveTypes, solid } from '../../source/shared/Defs.ts'; import { Brush, BrushModel, BrushSide } from '../../source/engine/common/model/BSP.mjs'; import { BrushTrace, Hull, PMF, Pmove, PmovePlayer, Trace } from '../../source/engine/common/Pmove.mjs'; diff --git a/test/physics/fixtures.mjs b/test/physics/fixtures.mjs index 1234a2b4..ab5b0b88 100644 --- a/test/physics/fixtures.mjs +++ b/test/physics/fixtures.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; -import Vector from '../../source/shared/Vector.mjs'; +import Vector from '../../source/shared/Vector.ts'; import { content, flags, moveType, moveTypes, solid } from '../../source/shared/Defs.ts'; import { Brush, BrushModel, BrushSide } from '../../source/engine/common/model/BSP.mjs'; import { Pmove } from '../../source/engine/common/Pmove.mjs'; @@ -294,6 +294,7 @@ export function createMockEdict(entity) { * Build a default mock registry config with silent console and standard frametime. * Supply SV overrides to configure server-side mocks. * @param {object} [sv] SV overrides + * @param cl * @returns {MockRegistryConfig} registry config */ export function defaultMockRegistry(sv = {}, cl = null) { diff --git a/test/physics/func-rotating.test.mjs b/test/physics/func-rotating.test.mjs index 51db69c9..0b4b4d4e 100644 --- a/test/physics/func-rotating.test.mjs +++ b/test/physics/func-rotating.test.mjs @@ -1,7 +1,7 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; -import Vector from '../../source/shared/Vector.mjs'; +import Vector from '../../source/shared/Vector.ts'; import { moveType, solid } from '../../source/shared/Defs.ts'; import { entityClasses } from '../../source/game/id1/GameAPI.mjs'; @@ -9,6 +9,9 @@ const RotatingEntity = entityClasses.find((entityClass) => entityClass.classname assert.ok(RotatingEntity, 'func_rotating must be registered in GameAPI'); +/** + * + */ function createRotatingEntityFixture() { const modelMins = new Vector(-16, -16, -16); const modelMaxs = new Vector(16, 16, 16); diff --git a/test/physics/func-train.test.mjs b/test/physics/func-train.test.mjs index a08b001c..d287680a 100644 --- a/test/physics/func-train.test.mjs +++ b/test/physics/func-train.test.mjs @@ -1,7 +1,7 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; -import Vector from '../../source/shared/Vector.mjs'; +import Vector from '../../source/shared/Vector.ts'; import { entityClasses } from '../../source/game/id1/GameAPI.mjs'; const TrainEntity = entityClasses.find((entityClass) => entityClass.classname === 'func_train'); diff --git a/test/physics/map-pmove-harness.mjs b/test/physics/map-pmove-harness.mjs index cb696e33..dde69312 100644 --- a/test/physics/map-pmove-harness.mjs +++ b/test/physics/map-pmove-harness.mjs @@ -6,7 +6,7 @@ import Mod from '../../source/engine/common/Mod.mjs'; import { PMF, Pmove } from '../../source/engine/common/Pmove.mjs'; import { UserCmd } from '../../source/engine/network/Protocol.mjs'; import { eventBus, registry } from '../../source/engine/registry.mjs'; -import Vector from '../../source/shared/Vector.mjs'; +import Vector from '../../source/shared/Vector.ts'; /** * @typedef {Record} EntityKV diff --git a/test/physics/navigation.test.mjs b/test/physics/navigation.test.mjs index ab796a1e..3fa18fad 100644 --- a/test/physics/navigation.test.mjs +++ b/test/physics/navigation.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import Vector from '../../source/shared/Vector.mjs'; +import Vector from '../../source/shared/Vector.ts'; import { eventBus, registry } from '../../source/engine/registry.mjs'; import { Navigation } from '../../source/engine/server/Navigation.mjs'; diff --git a/test/physics/pmove.test.mjs b/test/physics/pmove.test.mjs index 6bb427bb..407aeac3 100644 --- a/test/physics/pmove.test.mjs +++ b/test/physics/pmove.test.mjs @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; import COMClass from '../../source/engine/common/Com.mjs'; import Mod from '../../source/engine/common/Mod.mjs'; -import Vector from '../../source/shared/Vector.mjs'; +import Vector from '../../source/shared/Vector.ts'; import { content } from '../../source/shared/Defs.ts'; import { DIST_EPSILON, PM_TYPE, PMF, Pmove, PmovePlayer, Trace } from '../../source/engine/common/Pmove.mjs'; import { UserCmd } from '../../source/engine/network/Protocol.mjs'; diff --git a/test/physics/server-client-physics.test.mjs b/test/physics/server-client-physics.test.mjs index f6307215..1d549874 100644 --- a/test/physics/server-client-physics.test.mjs +++ b/test/physics/server-client-physics.test.mjs @@ -1,7 +1,7 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; -import Vector from '../../source/shared/Vector.mjs'; +import Vector from '../../source/shared/Vector.ts'; import { flags, moveType, solid } from '../../source/shared/Defs.ts'; import { UserCmd } from '../../source/engine/network/Protocol.mjs'; import { eventBus, registry } from '../../source/engine/registry.mjs'; diff --git a/test/physics/server-collision.test.mjs b/test/physics/server-collision.test.mjs index 62e1e1a8..edd17e4a 100644 --- a/test/physics/server-collision.test.mjs +++ b/test/physics/server-collision.test.mjs @@ -1,7 +1,7 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; -import Vector from '../../source/shared/Vector.mjs'; +import Vector from '../../source/shared/Vector.ts'; import { content, flags, moveType, moveTypes, solid } from '../../source/shared/Defs.ts'; import { BrushModel } from '../../source/engine/common/model/BSP.mjs'; import { BrushTrace, Pmove } from '../../source/engine/common/Pmove.mjs'; diff --git a/test/physics/server-movement.test.mjs b/test/physics/server-movement.test.mjs index 9c4f9097..96ba81b8 100644 --- a/test/physics/server-movement.test.mjs +++ b/test/physics/server-movement.test.mjs @@ -1,7 +1,7 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; -import Vector from '../../source/shared/Vector.mjs'; +import Vector from '../../source/shared/Vector.ts'; import { content, flags, solid } from '../../source/shared/Defs.ts'; import { ServerMovement } from '../../source/engine/server/physics/ServerMovement.mjs'; diff --git a/test/physics/server-physics.test.mjs b/test/physics/server-physics.test.mjs index fb48c616..f773d705 100644 --- a/test/physics/server-physics.test.mjs +++ b/test/physics/server-physics.test.mjs @@ -1,7 +1,7 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; -import Vector from '../../source/shared/Vector.mjs'; +import Vector from '../../source/shared/Vector.ts'; import { content, flags, gameCapabilities, moveType, moveTypes, solid } from '../../source/shared/Defs.ts'; import { eventBus, registry } from '../../source/engine/registry.mjs'; import { ServerArea } from '../../source/engine/server/physics/ServerArea.mjs'; diff --git a/test/physics/server.test.mjs b/test/physics/server.test.mjs index 9a14191b..8325a3c2 100644 --- a/test/physics/server.test.mjs +++ b/test/physics/server.test.mjs @@ -1,7 +1,7 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; -import Vector from '../../source/shared/Vector.mjs'; +import Vector from '../../source/shared/Vector.ts'; import * as Protocol from '../../source/engine/network/Protocol.mjs'; import { eventBus, registry } from '../../source/engine/registry.mjs'; import SV from '../../source/engine/server/Server.mjs'; diff --git a/test/renderer/brush-model-renderer.test.mjs b/test/renderer/brush-model-renderer.test.mjs index c408f34f..e15fa4ba 100644 --- a/test/renderer/brush-model-renderer.test.mjs +++ b/test/renderer/brush-model-renderer.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import Vector from '../../source/shared/Vector.mjs'; +import Vector from '../../source/shared/Vector.ts'; import { eventBus, registry } from '../../source/engine/registry.mjs'; import { BrushModelRenderer, resolveBrushBloomContributionStrength } from '../../source/engine/client/renderer/BrushModelRenderer.mjs'; import { SimpleSkyBox } from '../../source/engine/client/renderer/Sky.mjs'; diff --git a/test/renderer/r-sorting.test.mjs b/test/renderer/r-sorting.test.mjs index 20fad697..a66ca8bd 100644 --- a/test/renderer/r-sorting.test.mjs +++ b/test/renderer/r-sorting.test.mjs @@ -3,7 +3,7 @@ import { describe, test } from 'node:test'; import R, { compareFogAndTurbulentItems } from '../../source/engine/client/R.mjs'; import { eventBus, registry } from '../../source/engine/registry.mjs'; -import Vector from '../../source/shared/Vector.mjs'; +import Vector from '../../source/shared/Vector.ts'; describe('compareFogAndTurbulentItems', () => { test('sorts farther items first', () => { From 78c725af3f8bedf27f48e66a11474b91137ed946 Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 12:59:42 +0300 Subject: [PATCH 05/67] TS: Octree, BSpline --- eslint.config.mjs | 11 +- source/engine/server/Edict.mjs | 2 +- source/engine/server/Navigation.mjs | 4 +- source/engine/server/physics/ServerArea.mjs | 2 +- source/shared/{BSpline.mjs => BSpline.ts} | 57 +++-- source/shared/{Octree.mjs => Octree.ts} | 228 ++++++++++---------- source/shared/Vector.ts | 2 +- source/shared/index.mjs | 4 +- test/common/bspline.test.mjs | 31 +++ test/common/octree.test.mjs | 14 +- 10 files changed, 187 insertions(+), 168 deletions(-) rename source/shared/{BSpline.mjs => BSpline.ts} (57%) rename source/shared/{Octree.mjs => Octree.ts} (56%) create mode 100644 test/common/bspline.test.mjs diff --git a/eslint.config.mjs b/eslint.config.mjs index 8746b711..968d9920 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -21,11 +21,9 @@ const stylisticPlugin = /** @type {import('eslint').ESLint.Plugin} */ (stylistic const typeScriptEslintPlugin = /** @type {import('eslint').ESLint.Plugin} */ ( /** @type {unknown} */ (tseslint) ); -const nodeGlobals = { - ...globals.node, -}; - -delete nodeGlobals.Buffer; +const nodeGlobals = Object.fromEntries( + Object.entries(globals.node).filter(([name]) => name !== 'Buffer'), +); const commonRules = /** @type {import('eslint').Linter.RulesRecord} */ ({ 'max-len': 'off', @@ -119,6 +117,9 @@ export default defineConfig([ }, rules: { ...commonRules, + 'jsdoc/require-param-type': 'off', + 'jsdoc/require-property-type': 'off', + 'jsdoc/require-returns-type': 'off', 'no-undef': 'off', 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': ['error', { diff --git a/source/engine/server/Edict.mjs b/source/engine/server/Edict.mjs index 1f584899..459e7549 100644 --- a/source/engine/server/Edict.mjs +++ b/source/engine/server/Edict.mjs @@ -7,7 +7,7 @@ import { eventBus, registry } from '../registry.mjs'; import Q from '../../shared/Q.mjs'; import { ConsoleCommand } from '../common/Cmd.mjs'; import { ClientEdict } from '../client/ClientEntities.mjs'; -import { OctreeNode } from '../../shared/Octree.mjs'; +import { OctreeNode } from '../../shared/Octree.ts'; import { Visibility } from '../common/model/BSP.mjs'; /** @typedef {import('../../game/id1/entity/BaseEntity.mjs').default} BaseEntity */ diff --git a/source/engine/server/Navigation.mjs b/source/engine/server/Navigation.mjs index 207d23cc..29ab4fe1 100644 --- a/source/engine/server/Navigation.mjs +++ b/source/engine/server/Navigation.mjs @@ -1,6 +1,6 @@ -// import sampleBSpline from '../../shared/BSpline.mjs'; +// import sampleBSpline from '../../shared/BSpline.ts'; import * as Def from '../../shared/Defs.ts'; -import { Octree } from '../../shared/Octree.mjs'; +import { Octree } from '../../shared/Octree.ts'; import Vector from '../../shared/Vector.ts'; import Cmd from '../common/Cmd.mjs'; // import Cmd, { ConsoleCommand } from '../common/Cmd.mjs'; diff --git a/source/engine/server/physics/ServerArea.mjs b/source/engine/server/physics/ServerArea.mjs index 22e69f2c..b905eed1 100644 --- a/source/engine/server/physics/ServerArea.mjs +++ b/source/engine/server/physics/ServerArea.mjs @@ -1,6 +1,6 @@ import Vector from '../../../shared/Vector.ts'; import * as Defs from '../../../shared/Defs.ts'; -import { Octree } from '../../../shared/Octree.mjs'; +import { Octree } from '../../../shared/Octree.ts'; import { eventBus, registry } from '../../registry.mjs'; import CollisionModelSource, { createRegistryCollisionModelSource } from '../../common/CollisionModelSource.mjs'; import { BrushModel } from '../../../engine/common/Mod.mjs'; diff --git a/source/shared/BSpline.mjs b/source/shared/BSpline.ts similarity index 57% rename from source/shared/BSpline.mjs rename to source/shared/BSpline.ts index b93d63dc..bef593e3 100644 --- a/source/shared/BSpline.mjs +++ b/source/shared/BSpline.ts @@ -2,13 +2,13 @@ import Vector from './Vector.ts'; /** * Uniform clamped knot vector in [0,1] - * @param {number} nCtrl nCtrl - * @param {number} degree degree - * @returns {number[]} knots + * @param nCtrl nCtrl + * @param degree degree + * @returns knots */ -function makeClampedUniformKnots(nCtrl, degree) { +function makeClampedUniformKnots(nCtrl: number, degree: number): number[] { const m = nCtrl + degree + 1; - const knots = new Array(m).fill(0); + const knots = new Array(m).fill(0); const nInterior = m - 2 * (degree + 1); for (let i = 0; i < nInterior; i++) { @@ -23,13 +23,13 @@ function makeClampedUniformKnots(nCtrl, degree) { } /** - * @param {number} u u - * @param {number} degree degree - * @param {number[]} knots knots - * @returns {number} span index + * @param u u + * @param degree degree + * @param knots knots + * @returns span index */ -function findSpan(u, degree, knots) { - const n = knots.length - degree - 2; // last control index +function findSpan(u: number, degree: number, knots: number[]): number { + const n = knots.length - degree - 2; if (u >= knots[n + 1]) { return n; @@ -39,8 +39,9 @@ function findSpan(u, degree, knots) { return degree; } - // binary search - let low = degree, high = n + 1, mid = Math.floor((low + high) / 2); + let low = degree; + let high = n + 1; + let mid = Math.floor((low + high) / 2); while (!(u >= knots[mid] && u < knots[mid + 1])) { if (u < knots[mid]) { @@ -57,17 +58,16 @@ function findSpan(u, degree, knots) { /** * De Boor evaluation at parameter u in [0,1] - * @param {number} u u - * @param {number} degree degree - * @param {number[]} knots knots - * @param {Vector[]} ctrl ctrl - * @returns {Vector} point on the curve + * @param u u + * @param degree degree + * @param knots knots + * @param ctrl ctrl + * @returns point on the curve */ -function deBoor(u, degree, knots, ctrl) { +function deBoor(u: number, degree: number, knots: number[], ctrl: Vector[]): Vector { const k = findSpan(u, degree, knots); - const d = []; + const d: Vector[] = []; - // copy affected control points for (let j = 0; j <= degree; j++) { d[j] = ctrl[k - degree + j].copy(); } @@ -86,13 +86,13 @@ function deBoor(u, degree, knots, ctrl) { /** * Sample a cubic B-spline through given control points. - * @param {Vector[]} points control points (path you want to smooth) - * @param {number?} samples number of points to sample along the curve - * @returns {Vector[]} sampled points along the B-spline + * @param points control points (path you want to smooth) + * @param samples number of points to sample along the curve + * @returns sampled points along the B-spline */ -export default function sampleBSpline(points, samples = null) { +export default function sampleBSpline(points: Vector[], samples: number | null = null): Vector[] { if (points.length < 4) { - return points.slice(); // need at least 4 for cubic + return points.slice(); } if (samples === null) { @@ -101,11 +101,10 @@ export default function sampleBSpline(points, samples = null) { const degree = 3; const knots = makeClampedUniformKnots(points.length, degree); - - const out = []; + const out: Vector[] = []; for (let i = 0; i < samples; i++) { - const u = i / (samples - 1); // [0,1] + const u = i / (samples - 1); out.push(deBoor(u, degree, knots, points)); } diff --git a/source/shared/Octree.mjs b/source/shared/Octree.ts similarity index 56% rename from source/shared/Octree.mjs rename to source/shared/Octree.ts index 5bef48f7..b4a95906 100644 --- a/source/shared/Octree.mjs +++ b/source/shared/Octree.ts @@ -1,37 +1,52 @@ import Vector from './Vector.ts'; -/** @typedef {{origin: Vector|null, absmin: Vector|null, absmax: Vector|null, octreeNode: OctreeNode|null}} OctreeItem */ +/** + * Octree node holding a spatially indexed item. + */ +export interface OctreeItem> { + origin: Vector | null; + absmin: Vector | null; + absmax: Vector | null; + octreeNode: OctreeNode | null; +} /** * Octree node holding a spatially indexed item. - * @template {OctreeItem} T + * @template T */ -export class OctreeNode { +export class OctreeNode> { + center: Vector; + halfSize: number; + capacity: number; + minSize: number; + parent: OctreeNode | null; + totalCount: number; + items: T[]; + children: OctreeNode[] | null; + /** - * @param {Vector} center center point, e.g (mins + maxs) / 2 - * @param {number} halfSize half the size of the longest dimension, e.g. (Math.max of (maxs - mins)) / 2 +1 - * @param {number} capacity maximum items per node before splitting - * @param {number} minSize minimum halfSize to allow splitting - * @param {OctreeNode|null} parent parent node + * @param center center point, e.g (mins + maxs) / 2 + * @param halfSize half the size of the longest dimension, e.g. (Math.max of (maxs - mins)) / 2 +1 + * @param capacity maximum items per node before splitting + * @param minSize minimum halfSize to allow splitting + * @param parent parent node */ - constructor(center, halfSize, capacity = 8, minSize = 4, parent = null) { - this.center = center; // Vector - this.halfSize = halfSize; // number + constructor(center: Vector, halfSize: number, capacity = 8, minSize = 4, parent: OctreeNode | null = null) { + this.center = center; + this.halfSize = halfSize; this.capacity = capacity; this.minSize = minSize; this.parent = parent; this.totalCount = 0; - /** @type {T[]} */ this.items = []; - /** @type {OctreeNode[]|null} */ this.children = null; } /** - * @param {Vector} point position - * @returns {boolean} true if point is inside this node's box + * @param point position + * @returns true if point is inside this node's box */ - #isInBox(point) { + #isInBox(point: Vector): boolean { const dx = Math.abs(point[0] - this.center[0]); const dy = Math.abs(point[1] - this.center[1]); const dz = Math.abs(point[2] - this.center[2]); @@ -40,11 +55,11 @@ export class OctreeNode { } /** - * @param {Vector} mins minimum bounds - * @param {Vector} maxs maximum bounds - * @returns {boolean} true if box is fully inside this node's box + * @param mins minimum bounds + * @param maxs maximum bounds + * @returns true if box is fully inside this node's box */ - #isBoxInBox(mins, maxs) { + #isBoxInBox(mins: Vector, maxs: Vector): boolean { const nodeMinX = this.center[0] - this.halfSize; const nodeMaxX = this.center[0] + this.halfSize; const nodeMinY = this.center[1] - this.halfSize; @@ -59,13 +74,12 @@ export class OctreeNode { /** * Subdivides this node into eight children. - * @returns {OctreeNode[]} created children + * @returns created children */ - #subdivide() { + #subdivide(): OctreeNode[] { const hs = this.halfSize / 2; const offs = [-hs, hs]; - /** @type {OctreeNode[]} */ - const children = []; + const children: OctreeNode[] = []; for (let ix = 0; ix < 2; ix++) { for (let iy = 0; iy < 2; iy++) { @@ -87,11 +101,11 @@ export class OctreeNode { /** * Inserts an item into the first child that fully contains it. - * @param {T} item item to insert - * @param {OctreeNode[]} children child nodes - * @returns {OctreeNode|null} child node that accepted the item, if any + * @param item item to insert + * @param children child nodes + * @returns child node that accepted the item, if any */ - #insertIntoChildren(item, children) { + #insertIntoChildren(item: T, children: OctreeNode[]): OctreeNode | null { for (const child of children) { const node = child.insert(item); if (node !== null) { @@ -104,17 +118,16 @@ export class OctreeNode { /** * Inserts item. - * @param {T} obj item - * @returns {OctreeNode|null} node where item was inserted, or null + * @param obj item + * @returns node where item was inserted, or null */ - insert(obj) { - // if the object has bounds, check if it fits in this node - if (obj.absmin && obj.absmax) { + insert(obj: T): OctreeNode | null { + if (obj.absmin !== null && obj.absmax !== null) { if (!this.#isBoxInBox(obj.absmin, obj.absmax)) { return null; } } else { - if (!this.#isInBox(obj.origin)) { + if (obj.origin === null || !this.#isInBox(obj.origin)) { return null; } } @@ -122,48 +135,37 @@ export class OctreeNode { let children = this.children; if (children === null) { - // is there enough space? if so, add it here if (this.items.length < this.capacity || this.halfSize <= this.minSize) { this.items.push(obj); this.#updateCount(1); return this; } - // split - // temporarily reduce count for items we are about to move this.#updateCount(-this.items.length); children = this.#subdivide(); - // move items into children const old = this.items; this.items = []; - // re-insert old items for (const item of old) { const node = this.#insertIntoChildren(item, children); if (node !== null) { - if (item.octreeNode) { - item.octreeNode = node; - } + item.octreeNode = node; } if (node === null) { - this.items.push(item); // keep in parent if it doesn’t fit in any child - if (item.octreeNode) { - item.octreeNode = this; - } - this.#updateCount(1); // re-add count for the item kept in this node + this.items.push(item); + item.octreeNode = this; + this.#updateCount(1); } } } - // insert into child const node = this.#insertIntoChildren(obj, children); if (node !== null) { return node; } - // if it didn’t fit in any child (e.g. straddles boundary), keep it here this.items.push(obj); this.#updateCount(1); return this; @@ -171,12 +173,11 @@ export class OctreeNode { /** * Updates totalCount up the tree. - * @param {number} delta changed number of items + * @param delta changed number of items */ - #updateCount(delta) { - /** @type {OctreeNode|null} */ - let node = this; // eslint-disable-line consistent-this - while (node) { + #updateCount(delta: number): void { + let node: OctreeNode | null = this; // eslint-disable-line consistent-this + while (node !== null) { node.totalCount += delta; node = node.parent; } @@ -184,10 +185,10 @@ export class OctreeNode { /** * Removes item from this node. - * @param {T} obj item - * @returns {boolean} true if removed + * @param obj item + * @returns true if removed */ - remove(obj) { + remove(obj: T): boolean { const idx = this.items.indexOf(obj); if (idx !== -1) { this.items.splice(idx, 1); @@ -201,11 +202,10 @@ export class OctreeNode { /** * Checks if children can be merged. */ - #checkMerge() { - /** @type {OctreeNode|null} */ - let node = this; // eslint-disable-line consistent-this - while (node) { - if (node.children && node.totalCount <= node.capacity) { + #checkMerge(): void { + let node: OctreeNode | null = this; // eslint-disable-line consistent-this + while (node !== null) { + if (node.children !== null && node.totalCount <= node.capacity) { node.#merge(); } node = node.parent; @@ -215,22 +215,20 @@ export class OctreeNode { /** * Merges all children into this node. */ - #merge() { + #merge(): void { const items = this.#getAllItems(); this.items = items; this.children = null; for (const item of this.items) { - if (item.octreeNode) { - item.octreeNode = this; - } + item.octreeNode = this; } } /** * Returns all items in this node and its children. - * @returns {T[]} items + * @returns items */ - #getAllItems() { + #getAllItems(): T[] { let items = [...this.items]; const children = this.children; @@ -245,14 +243,12 @@ export class OctreeNode { /** * Collect candidates inside AABB. - * @param {Vector} mins minimum bounds - * @param {Vector} maxs maximum bounds - * @yields {T} item - * @returns {IterableIterator} items inside AABB + * @param mins minimum bounds + * @param maxs maximum bounds + * @yields item + * @returns items inside AABB */ - *queryAABB(mins, maxs) { - // AABB-AABB intersection test - // node bounds: + *queryAABB(mins: Vector, maxs: Vector): IterableIterator { const nodeMinX = this.center[0] - this.halfSize; const nodeMaxX = this.center[0] + this.halfSize; const nodeMinY = this.center[1] - this.halfSize; @@ -266,19 +262,15 @@ export class OctreeNode { return; } - // check items if (this.items.length > 0) { for (const p of this.items) { - // check if item is inside query AABB - if (p.absmin && p.absmax) { - // AABB-AABB overlap + if (p.absmin !== null && p.absmax !== null) { if (p.absmin[0] <= maxs[0] && p.absmax[0] >= mins[0] && p.absmin[1] <= maxs[1] && p.absmax[1] >= mins[1] && p.absmin[2] <= maxs[2] && p.absmax[2] >= mins[2]) { yield p; } - } else { - // item in AABB + } else if (p.origin !== null) { if (p.origin[0] >= mins[0] && p.origin[0] <= maxs[0] && p.origin[1] >= mins[1] && p.origin[1] <= maxs[1] && p.origin[2] >= mins[2] && p.origin[2] <= maxs[2]) { @@ -288,7 +280,6 @@ export class OctreeNode { } } - // traverse children const children = this.children; if (children !== null) { @@ -300,26 +291,27 @@ export class OctreeNode { /** * Collect candidates inside sphere centered at pos with radius r. - * @param {Vector} point position - * @param {number} radius radius - * @yields {[number, T]} distance and item - * @returns {IterableIterator<[number, T]>} items inside sphere + * @param point position + * @param radius radius + * @yields distance and item + * @returns items inside sphere */ - *querySphere(point, radius) { - // AABB-sphere intersection test + *querySphere(point: Vector, radius: number): IterableIterator<[number, T]> { const dx = Math.max(0, Math.abs(point[0] - this.center[0]) - this.halfSize); const dy = Math.max(0, Math.abs(point[1] - this.center[1]) - this.halfSize); const dz = Math.max(0, Math.abs(point[2] - this.center[2]) - this.halfSize); const dist2 = dx * dx + dy * dy + dz * dz; if (dist2 > radius * radius) { - // no intersection return; } - // check items if (this.items.length > 0) { for (const item of this.items) { + if (item.origin === null) { + continue; + } + const d = item.origin.copy().subtract(point).len(); if (d <= radius) { yield [d, item]; @@ -327,7 +319,6 @@ export class OctreeNode { } } - // traverse children const children = this.children; if (children !== null) { @@ -340,37 +331,37 @@ export class OctreeNode { /** * Simple Octree for spatial-indexing of anything. - * @template {OctreeItem} T + * @template T */ -export class Octree { +export class Octree> { + root: OctreeNode; + /** - * @param {Vector} center center point, e.g (mins + maxs) / 2 - * @param {number} halfSize half the size of the longest dimension, e.g. (Math.max of (maxs - mins)) / 2 +1 - * @param {number} capacity maximum items per node before splitting, default 8 - * @param {number} minSize minimum halfSize to allow splitting, default 4 + * @param center center point, e.g (mins + maxs) / 2 + * @param halfSize half the size of the longest dimension, e.g. (Math.max of (maxs - mins)) / 2 +1 + * @param capacity maximum items per node before splitting, default 8 + * @param minSize minimum halfSize to allow splitting, default 4 */ - constructor(center, halfSize, capacity = 8, minSize = 4) { - /** @type {OctreeNode} */ + constructor(center: Vector, halfSize: number, capacity = 8, minSize = 4) { this.root = new OctreeNode(center, halfSize, capacity, minSize); } /** * Inserts item. - * @param {T} item item to add - * @returns {OctreeNode|null} node where item was inserted + * @param item item to add + * @returns node where item was inserted */ - insert(item) { + insert(item: T): OctreeNode | null { return this.root.insert(item); } /** * Removes item. - * @param {T} item item to remove - * @returns {boolean} true if removed + * @param item item to remove + * @returns true if removed */ - remove(item) { - // if an item knows its node, use it - if (item.octreeNode) { + remove(item: T): boolean { + if (item.octreeNode !== null) { const removed = item.octreeNode.remove(item); if (removed) { item.octreeNode = null; @@ -378,30 +369,27 @@ export class Octree { return removed; } - // otherwise we can’t easily remove without searching, which is slow - // for now assume item has octreeNode if it was inserted - // TODO: fallback search removal return false; } /** * Collect candidates inside AABB. - * @param {Vector} mins minimum bounds - * @param {Vector} maxs maximum bounds - * @yields {T} item + * @param mins minimum bounds + * @param maxs maximum bounds + * @yields item */ - *queryAABB(mins, maxs) { + *queryAABB(mins: Vector, maxs: Vector): IterableIterator { yield* this.root.queryAABB(mins, maxs); } /** * Finds nearest item to point within maxDist. - * @param {Vector} point point in space to search nearest to - * @param {number} maxDist maximum distance to search, default unlimited - * @returns {T|null} nearest item whose origin is within maxDist, or null + * @param point point in space to search nearest to + * @param maxDist maximum distance to search, default unlimited + * @returns nearest item whose origin is within maxDist, or null */ - nearest(point, maxDist = Infinity) { - let best = null; + nearest(point: Vector, maxDist = Infinity): T | null { + let best: T | null = null; let bestDist = Infinity; for (const [d, item] of this.root.querySphere(point, maxDist)) { @@ -413,4 +401,4 @@ export class Octree { return best; } -}; +} diff --git a/source/shared/Vector.ts b/source/shared/Vector.ts index 0c794092..c8c085f3 100644 --- a/source/shared/Vector.ts +++ b/source/shared/Vector.ts @@ -1,4 +1,4 @@ -/* eslint jsdoc/require-param-type: "off", jsdoc/require-returns: "off" */ +/* eslint jsdoc/require-returns: "off" */ type VectorLike = ArrayLike; diff --git a/source/shared/index.mjs b/source/shared/index.mjs index 247ddfeb..9f65ea46 100644 --- a/source/shared/index.mjs +++ b/source/shared/index.mjs @@ -1,7 +1,7 @@ -export { default as sampleBSpline } from './BSpline.mjs'; +export { default as sampleBSpline } from './BSpline.ts'; export * from './ClientEdict.mjs'; export * from './Defs.ts'; export * from './Keys.mjs'; -export * from './Octree.mjs'; +export * from './Octree.ts'; export { default as Q } from './Q.mjs'; export * from './Vector.ts'; diff --git a/test/common/bspline.test.mjs b/test/common/bspline.test.mjs new file mode 100644 index 00000000..ae7fc1f9 --- /dev/null +++ b/test/common/bspline.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import sampleBSpline from '../../source/shared/BSpline.ts'; +import Vector from '../../source/shared/Vector.ts'; + +void describe('sampleBSpline', () => { + void test('returns a shallow copy when there are fewer than four control points', () => { + const points = [new Vector(0, 0, 0), new Vector(1, 1, 1), new Vector(2, 2, 2)]; + const sampled = sampleBSpline(points); + + assert.notStrictEqual(sampled, points); + assert.deepEqual(sampled.map((point) => Array.from(point)), points.map((point) => Array.from(point))); + }); + + void test('samples a clamped cubic curve that starts and ends on the control endpoints', () => { + const points = [ + new Vector(0, 0, 0), + new Vector(10, 0, 0), + new Vector(10, 10, 0), + new Vector(20, 10, 0), + ]; + + const sampled = sampleBSpline(points, 5); + + assert.equal(sampled.length, 5); + assert.deepEqual(Array.from(sampled[0]), Array.from(points[0])); + assert.deepEqual(Array.from(sampled.at(-1)), Array.from(points.at(-1))); + assert.ok(sampled.every((point) => point instanceof Vector)); + }); +}); diff --git a/test/common/octree.test.mjs b/test/common/octree.test.mjs index f0091886..45c89fde 100644 --- a/test/common/octree.test.mjs +++ b/test/common/octree.test.mjs @@ -1,10 +1,10 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import { Octree } from '../../source/shared/Octree.mjs'; +import { Octree } from '../../source/shared/Octree.ts'; import Vector from '../../source/shared/Vector.ts'; -/** @typedef {import('../../source/shared/Octree.mjs').OctreeNode} TestOctreeNode */ +/** @typedef {import('../../source/shared/Octree.ts').OctreeNode} TestOctreeNode */ /** * @typedef TestItem @@ -60,7 +60,7 @@ function insertTracked(tree, item) { } /** - * @param {import('../../source/shared/Octree.mjs').OctreeNode} node node whose children must exist + * @param {import('../../source/shared/Octree.ts').OctreeNode} node node whose children must exist * @returns {TestOctreeNode[]} node children */ function requireChildren(node) { @@ -68,8 +68,8 @@ function requireChildren(node) { return node.children; } -describe('Octree', () => { - test('splits into children once capacity is exceeded', () => { +void describe('Octree', () => { + void test('splits into children once capacity is exceeded', () => { const tree = new Octree(new Vector(0, 0, 0), 16, 1, 1); const first = createPointItem('first', new Vector(-4, -4, -4)); const second = createPointItem('second', new Vector(4, 4, 4)); @@ -90,7 +90,7 @@ describe('Octree', () => { ); }); - test('keeps oversized bounds in the parent after a split', () => { + void test('keeps oversized bounds in the parent after a split', () => { const tree = new Octree(new Vector(0, 0, 0), 16, 1, 1); const anchor = createPointItem('anchor', new Vector(10, 10, 10)); const straddling = createBoxItem('straddling', new Vector(0, 0, 0), new Vector(-2, -2, -2), new Vector(2, 2, 2)); @@ -106,7 +106,7 @@ describe('Octree', () => { assert.notEqual(anchor.octreeNode, tree.root); }); - test('merges children back into the parent when removals drop below capacity', () => { + void test('merges children back into the parent when removals drop below capacity', () => { const tree = new Octree(new Vector(0, 0, 0), 16, 1, 1); const first = createPointItem('first', new Vector(-4, -4, -4)); const second = createPointItem('second', new Vector(4, 4, 4)); From 887cf6765d8474c72d2699e9504c7f3af1a0cae6 Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 13:15:15 +0300 Subject: [PATCH 06/67] TS: Q and Keys --- source/engine/client/CDAudio.mjs | 2 +- source/engine/client/CL.mjs | 2 +- source/engine/client/ClientInput.mjs | 2 +- source/engine/client/IN.mjs | 2 +- source/engine/client/Key.mjs | 2 +- source/engine/client/Menu.mjs | 2 +- source/engine/client/Sound.mjs | 2 +- source/engine/client/Sys.mjs | 4 +- source/engine/client/V.mjs | 2 +- source/engine/client/menu/MenuItem.mjs | 4 +- source/engine/client/menu/MenuPage.mjs | 2 +- source/engine/client/menu/Multiplayer.mjs | 2 +- source/engine/common/Cmd.mjs | 2 +- source/engine/common/Com.mjs | 2 +- source/engine/common/Cvar.mjs | 2 +- source/engine/common/GameAPIs.mjs | 2 +- source/engine/common/Host.mjs | 2 +- source/engine/common/Pmove.mjs | 2 +- source/engine/common/W.mjs | 2 +- .../common/model/loaders/AliasMDLLoader.mjs | 2 +- .../common/model/loaders/BSP29Loader.mjs | 2 +- .../common/model/loaders/BSP38Loader.mjs | 2 +- .../engine/common/model/parsers/ParsedQC.mjs | 2 +- source/engine/network/MSG.mjs | 2 +- source/engine/network/Network.mjs | 2 +- source/engine/server/Client.mjs | 2 +- source/engine/server/Com.mjs | 2 +- source/engine/server/Edict.mjs | 2 +- source/engine/server/Progs.mjs | 2 +- source/engine/server/Sys.mjs | 2 +- .../engine/server/physics/ServerPhysics.mjs | 2 +- source/shared/GameInterfaces.d.ts | 4 +- source/shared/Keys.mjs | 45 --------- source/shared/Keys.ts | 44 +++++++++ source/shared/Pmove.mjs | 55 ----------- source/shared/Pmove.ts | 54 +++++++++++ source/shared/{Q.mjs => Q.ts} | 93 ++++++++++--------- source/shared/index.mjs | 5 +- test/common/keys.test.mjs | 13 +++ test/common/pmove-config.test.mjs | 25 +++++ test/common/q.test.mjs | 36 +++++++ 41 files changed, 258 insertions(+), 182 deletions(-) delete mode 100644 source/shared/Keys.mjs create mode 100644 source/shared/Keys.ts delete mode 100644 source/shared/Pmove.mjs create mode 100644 source/shared/Pmove.ts rename source/shared/{Q.mjs => Q.ts} (62%) create mode 100644 test/common/keys.test.mjs create mode 100644 test/common/pmove-config.test.mjs create mode 100644 test/common/q.test.mjs diff --git a/source/engine/client/CDAudio.mjs b/source/engine/client/CDAudio.mjs index 17518345..e9f32f97 100644 --- a/source/engine/client/CDAudio.mjs +++ b/source/engine/client/CDAudio.mjs @@ -1,6 +1,6 @@ import Cmd from '../common/Cmd.mjs'; import Cvar from '../common/Cvar.mjs'; -import Q from '../../shared/Q.mjs'; +import Q from '../../shared/Q.ts'; import { eventBus, registry } from '../registry.mjs'; let { COM, Con, S } = registry; diff --git a/source/engine/client/CL.mjs b/source/engine/client/CL.mjs index 7c6cc9bd..a936d61d 100644 --- a/source/engine/client/CL.mjs +++ b/source/engine/client/CL.mjs @@ -1,4 +1,4 @@ -import Q from '../../shared/Q.mjs'; +import Q from '../../shared/Q.ts'; import * as Def from '../common/Def.mjs'; import * as Protocol from '../network/Protocol.mjs'; import Cmd, { ConsoleCommand } from '../common/Cmd.mjs'; diff --git a/source/engine/client/ClientInput.mjs b/source/engine/client/ClientInput.mjs index 1e09cf5f..146fc324 100644 --- a/source/engine/client/ClientInput.mjs +++ b/source/engine/client/ClientInput.mjs @@ -1,6 +1,6 @@ import Vector from '../../shared/Vector.ts'; import * as Protocol from '../network/Protocol.mjs'; -import Q from '../../shared/Q.mjs'; +import Q from '../../shared/Q.ts'; import { SzBuffer } from '../network/MSG.mjs'; import Cmd from '../common/Cmd.mjs'; import { eventBus, registry } from '../registry.mjs'; diff --git a/source/engine/client/IN.mjs b/source/engine/client/IN.mjs index bce9f9e6..e82212f6 100644 --- a/source/engine/client/IN.mjs +++ b/source/engine/client/IN.mjs @@ -1,4 +1,4 @@ -import { K } from '../../shared/Keys.mjs'; +import { K } from '../../shared/Keys.ts'; import Cvar from '../common/Cvar.mjs'; import { eventBus, registry } from '../registry.mjs'; import { kbutton, kbuttons } from './ClientInput.mjs'; diff --git a/source/engine/client/Key.mjs b/source/engine/client/Key.mjs index 514ef135..3ef6efdf 100644 --- a/source/engine/client/Key.mjs +++ b/source/engine/client/Key.mjs @@ -1,4 +1,4 @@ -import { K } from '../../shared/Keys.mjs'; +import { K } from '../../shared/Keys.ts'; import Vector from '../../shared/Vector.ts'; import Cmd from '../common/Cmd.mjs'; import Cvar from '../common/Cvar.mjs'; diff --git a/source/engine/client/Menu.mjs b/source/engine/client/Menu.mjs index 41877236..3d9cc650 100644 --- a/source/engine/client/Menu.mjs +++ b/source/engine/client/Menu.mjs @@ -1,4 +1,4 @@ -import { K } from '../../shared/Keys.mjs'; +import { K } from '../../shared/Keys.ts'; import Cmd from '../common/Cmd.mjs'; import Cvar from '../common/Cvar.mjs'; import { clientConnectionState } from '../common/Def.mjs'; diff --git a/source/engine/client/Sound.mjs b/source/engine/client/Sound.mjs index e0c2a91a..80356041 100644 --- a/source/engine/client/Sound.mjs +++ b/source/engine/client/Sound.mjs @@ -1,7 +1,7 @@ import Vector from '../../shared/Vector.ts'; import Cmd from '../common/Cmd.mjs'; import Cvar from '../common/Cvar.mjs'; -import Q from '../../shared/Q.mjs'; +import Q from '../../shared/Q.ts'; import { eventBus, registry } from '../registry.mjs'; /** @typedef {import('../common/model/BSP.mjs').Node} BSPNode */ diff --git a/source/engine/client/Sys.mjs b/source/engine/client/Sys.mjs index ed82d31e..db012398 100644 --- a/source/engine/client/Sys.mjs +++ b/source/engine/client/Sys.mjs @@ -1,5 +1,5 @@ -import { K } from '../../shared/Keys.mjs'; -import Q from '../../shared/Q.mjs'; +import { K } from '../../shared/Keys.ts'; +import Q from '../../shared/Q.ts'; import { eventBus, registry } from '../registry.mjs'; import Tools from './Tools.mjs'; import WorkerManager from '../common/WorkerManager.mjs'; diff --git a/source/engine/client/V.mjs b/source/engine/client/V.mjs index ff87dfcb..6b50d56e 100644 --- a/source/engine/client/V.mjs +++ b/source/engine/client/V.mjs @@ -3,7 +3,7 @@ import { content, gameCapabilities } from '../../shared/Defs.ts'; import Cmd from '../common/Cmd.mjs'; import Cvar from '../common/Cvar.mjs'; import * as Def from '../common/Def.mjs'; -import Q from '../../shared/Q.mjs'; +import Q from '../../shared/Q.ts'; import { eventBus, registry } from '../registry.mjs'; import Chase from './Chase.mjs'; diff --git a/source/engine/client/menu/MenuItem.mjs b/source/engine/client/menu/MenuItem.mjs index 6441394d..582561cf 100644 --- a/source/engine/client/menu/MenuItem.mjs +++ b/source/engine/client/menu/MenuItem.mjs @@ -1,5 +1,5 @@ -import Q from '../../../shared/Q.mjs'; -import { K } from '../../../shared/Keys.mjs'; +import Q from '../../../shared/Q.ts'; +import { K } from '../../../shared/Keys.ts'; import Cvar from '../../common/Cvar.mjs'; import { eventBus, registry } from '../../registry.mjs'; diff --git a/source/engine/client/menu/MenuPage.mjs b/source/engine/client/menu/MenuPage.mjs index f29d1505..26913298 100644 --- a/source/engine/client/menu/MenuPage.mjs +++ b/source/engine/client/menu/MenuPage.mjs @@ -1,4 +1,4 @@ -import { K } from '../../../shared/Keys.mjs'; +import { K } from '../../../shared/Keys.ts'; import { eventBus, registry } from '../../registry.mjs'; // Destructure registry modules diff --git a/source/engine/client/menu/Multiplayer.mjs b/source/engine/client/menu/Multiplayer.mjs index 1687b060..3060d240 100644 --- a/source/engine/client/menu/Multiplayer.mjs +++ b/source/engine/client/menu/Multiplayer.mjs @@ -1,5 +1,5 @@ import PR from '../../server/Progs.mjs'; -import { K } from '../../../shared/Keys.mjs'; +import { K } from '../../../shared/Keys.ts'; import Cmd from '../../common/Cmd.mjs'; import { eventBus, registry } from '../../registry.mjs'; import { Action, Label, Spacer } from './MenuItem.mjs'; diff --git a/source/engine/common/Cmd.mjs b/source/engine/common/Cmd.mjs index cb2633d7..7122478c 100644 --- a/source/engine/common/Cmd.mjs +++ b/source/engine/common/Cmd.mjs @@ -1,4 +1,4 @@ -import { AsyncFunction } from '../../shared/Q.mjs'; +import { AsyncFunction } from '../../shared/Q.ts'; import * as Protocol from '../network/Protocol.mjs'; import { eventBus, registry } from '../registry.mjs'; import Cvar from './Cvar.mjs'; diff --git a/source/engine/common/Com.mjs b/source/engine/common/Com.mjs index 78de60c4..f1ba5ce5 100644 --- a/source/engine/common/Com.mjs +++ b/source/engine/common/Com.mjs @@ -1,7 +1,7 @@ import { registry, eventBus } from '../registry.mjs'; -import Q from '../../shared/Q.mjs'; +import Q from '../../shared/Q.ts'; import { CorruptedResourceError } from './Errors.mjs'; import Cvar from './Cvar.mjs'; diff --git a/source/engine/common/Cvar.mjs b/source/engine/common/Cvar.mjs index ed9e3390..aeece77c 100644 --- a/source/engine/common/Cvar.mjs +++ b/source/engine/common/Cvar.mjs @@ -1,6 +1,6 @@ import { registry, eventBus } from '../registry.mjs'; import Cmd from './Cmd.mjs'; -import Q from '../../shared/Q.mjs'; +import Q from '../../shared/Q.ts'; import { cvarFlags } from '../../shared/Defs.ts'; let { CL, Con, SV } = registry; diff --git a/source/engine/common/GameAPIs.mjs b/source/engine/common/GameAPIs.mjs index 7fe9cafd..9899f1a7 100644 --- a/source/engine/common/GameAPIs.mjs +++ b/source/engine/common/GameAPIs.mjs @@ -1,4 +1,4 @@ -import { PmoveConfiguration } from '../../shared/Pmove.mjs'; +import { PmoveConfiguration } from '../../shared/Pmove.ts'; import Vector from '../../shared/Vector.ts'; import { solid } from '../../shared/Defs.ts'; import Key from '../client/Key.mjs'; diff --git a/source/engine/common/Host.mjs b/source/engine/common/Host.mjs index 6741c4cc..46a64939 100644 --- a/source/engine/common/Host.mjs +++ b/source/engine/common/Host.mjs @@ -4,7 +4,7 @@ import * as Def from './Def.mjs'; import Cmd, { ConsoleCommand } from './Cmd.mjs'; import { eventBus, registry } from '../registry.mjs'; import Vector from '../../shared/Vector.ts'; -import Q from '../../shared/Q.mjs'; +import Q from '../../shared/Q.ts'; import { ServerClient } from '../server/Client.mjs'; import { ServerEngineAPI } from './GameAPIs.mjs'; import Chase from '../client/Chase.mjs'; diff --git a/source/engine/common/Pmove.mjs b/source/engine/common/Pmove.mjs index 75523440..5c63fb3a 100644 --- a/source/engine/common/Pmove.mjs +++ b/source/engine/common/Pmove.mjs @@ -12,7 +12,7 @@ import * as Protocol from '../network/Protocol.mjs'; import { content } from '../../shared/Defs.ts'; import { BrushModel } from './Mod.mjs'; import Cvar from './Cvar.mjs'; -import { PmoveConfiguration } from '../../shared/Pmove.mjs'; +import { PmoveConfiguration } from '../../shared/Pmove.ts'; /** @typedef {import('../../shared/Vector.ts').DirectionalVectors} DirectionalVectors */ /** @typedef {{ normal: Vector, type: number }} BrushTracePlaneLike */ diff --git a/source/engine/common/W.mjs b/source/engine/common/W.mjs index 7a486469..360e8b30 100644 --- a/source/engine/common/W.mjs +++ b/source/engine/common/W.mjs @@ -1,7 +1,7 @@ import { eventBus, registry } from '../registry.mjs'; import { CorruptedResourceError, MissingResourceError } from './Errors.mjs'; -import Q from '../../shared/Q.mjs'; +import Q from '../../shared/Q.ts'; let { COM } = registry; diff --git a/source/engine/common/model/loaders/AliasMDLLoader.mjs b/source/engine/common/model/loaders/AliasMDLLoader.mjs index 4deaec11..fbb41b5e 100644 --- a/source/engine/common/model/loaders/AliasMDLLoader.mjs +++ b/source/engine/common/model/loaders/AliasMDLLoader.mjs @@ -1,5 +1,5 @@ import Vector from '../../../../shared/Vector.ts'; -import Q from '../../../../shared/Q.mjs'; +import Q from '../../../../shared/Q.ts'; import GL, { GLTexture, resampleTexture8 } from '../../../client/GL.mjs'; import W, { translateIndexToLuminanceRGBA, translateIndexToRGBA } from '../../W.mjs'; import { CRC16CCITT } from '../../CRC.mjs'; diff --git a/source/engine/common/model/loaders/BSP29Loader.mjs b/source/engine/common/model/loaders/BSP29Loader.mjs index 80023498..3ba4be74 100644 --- a/source/engine/common/model/loaders/BSP29Loader.mjs +++ b/source/engine/common/model/loaders/BSP29Loader.mjs @@ -1,5 +1,5 @@ import Vector from '../../../../shared/Vector.ts'; -import Q from '../../../../shared/Q.mjs'; +import Q from '../../../../shared/Q.ts'; import { content } from '../../../../shared/Defs.ts'; import { GLTexture } from '../../../client/GL.mjs'; import W, { readWad3Texture, translateIndexToLuminanceRGBA, translateIndexToRGBA } from '../../W.mjs'; diff --git a/source/engine/common/model/loaders/BSP38Loader.mjs b/source/engine/common/model/loaders/BSP38Loader.mjs index e642d94d..06d0f9d0 100644 --- a/source/engine/common/model/loaders/BSP38Loader.mjs +++ b/source/engine/common/model/loaders/BSP38Loader.mjs @@ -1,5 +1,5 @@ import { content } from '../../../../shared/Defs.ts'; -import Q from '../../../../shared/Q.mjs'; +import Q from '../../../../shared/Q.ts'; import Vector from '../../../../shared/Vector.ts'; import { CRC16CCITT } from '../../CRC.mjs'; import { Plane } from '../BaseModel.mjs'; diff --git a/source/engine/common/model/parsers/ParsedQC.mjs b/source/engine/common/model/parsers/ParsedQC.mjs index 11f40488..fa3a5f4a 100644 --- a/source/engine/common/model/parsers/ParsedQC.mjs +++ b/source/engine/common/model/parsers/ParsedQC.mjs @@ -1,4 +1,4 @@ -import Q from '../../../../shared/Q.mjs'; +import Q from '../../../../shared/Q.ts'; import Vector from '../../../../shared/Vector.ts'; /** @typedef {import('../../../../shared/GameInterfaces.d.ts').ParsedQC} IParsedQC */ diff --git a/source/engine/network/MSG.mjs b/source/engine/network/MSG.mjs index 4dd48795..bd3b68e7 100644 --- a/source/engine/network/MSG.mjs +++ b/source/engine/network/MSG.mjs @@ -1,4 +1,4 @@ -import Q from '../../shared/Q.mjs'; +import Q from '../../shared/Q.ts'; import Vector from '../../shared/Vector.ts'; import * as Protocol from '../network/Protocol.mjs'; import { eventBus, registry } from '../registry.mjs'; diff --git a/source/engine/network/Network.mjs b/source/engine/network/Network.mjs index 41ba45f8..e56c3582 100644 --- a/source/engine/network/Network.mjs +++ b/source/engine/network/Network.mjs @@ -1,6 +1,6 @@ import Cmd from '../common/Cmd.mjs'; import Cvar from '../common/Cvar.mjs'; -import Q from '../../shared/Q.mjs'; +import Q from '../../shared/Q.ts'; import { eventBus, registry } from '../registry.mjs'; import { SzBuffer } from './MSG.mjs'; import { BaseDriver, LoopDriver, QSocket, WebRTCDriver, WebSocketDriver } from './NetworkDrivers.mjs'; diff --git a/source/engine/server/Client.mjs b/source/engine/server/Client.mjs index 05c00223..4fda9933 100644 --- a/source/engine/server/Client.mjs +++ b/source/engine/server/Client.mjs @@ -1,4 +1,4 @@ -import { enumHelpers } from '../../shared/Q.mjs'; +import { enumHelpers } from '../../shared/Q.ts'; import { gameCapabilities } from '../../shared/Defs.ts'; import Vector from '../../shared/Vector.ts'; import { SzBuffer } from '../network/MSG.mjs'; diff --git a/source/engine/server/Com.mjs b/source/engine/server/Com.mjs index b5dc2ddf..d5a21d28 100644 --- a/source/engine/server/Com.mjs +++ b/source/engine/server/Com.mjs @@ -2,7 +2,7 @@ import { promises as fsPromises, existsSync, writeFileSync, constants } from 'fs'; -import Q from '../../shared/Q.mjs'; +import Q from '../../shared/Q.ts'; import { CRC16CCITT as CRC } from '../common/CRC.mjs'; import COM from '../common/Com.mjs'; diff --git a/source/engine/server/Edict.mjs b/source/engine/server/Edict.mjs index 459e7549..a02f08a2 100644 --- a/source/engine/server/Edict.mjs +++ b/source/engine/server/Edict.mjs @@ -4,7 +4,7 @@ import * as Protocol from '../network/Protocol.mjs'; import * as Def from '../common/Def.mjs'; import * as Defs from '../../shared/Defs.ts'; import { eventBus, registry } from '../registry.mjs'; -import Q from '../../shared/Q.mjs'; +import Q from '../../shared/Q.ts'; import { ConsoleCommand } from '../common/Cmd.mjs'; import { ClientEdict } from '../client/ClientEntities.mjs'; import { OctreeNode } from '../../shared/Octree.ts'; diff --git a/source/engine/server/Progs.mjs b/source/engine/server/Progs.mjs index 947bc4fb..718bc5a3 100644 --- a/source/engine/server/Progs.mjs +++ b/source/engine/server/Progs.mjs @@ -2,7 +2,7 @@ import Cmd from '../common/Cmd.mjs'; import { CRC16CCITT } from '../common/CRC.mjs'; import Cvar from '../common/Cvar.mjs'; import { HostError, MissingResourceError } from '../common/Errors.mjs'; -import Q from '../../shared/Q.mjs'; +import Q from '../../shared/Q.ts'; import Vector from '../../shared/Vector.ts'; import { eventBus, registry } from '../registry.mjs'; import { ED, ServerEdict } from './Edict.mjs'; diff --git a/source/engine/server/Sys.mjs b/source/engine/server/Sys.mjs index 39f794fb..f9fe5421 100644 --- a/source/engine/server/Sys.mjs +++ b/source/engine/server/Sys.mjs @@ -11,7 +11,7 @@ import { registry, eventBus } from '../registry.mjs'; import Cvar from '../common/Cvar.mjs'; /** @typedef {import('node:repl').REPLServer} REPLServer */ import Cmd from '../common/Cmd.mjs'; -import Q from '../../shared/Q.mjs'; +import Q from '../../shared/Q.ts'; import WorkerManager from '../common/WorkerManager.mjs'; import workerFactories from '../common/WorkerFactories.mjs'; diff --git a/source/engine/server/physics/ServerPhysics.mjs b/source/engine/server/physics/ServerPhysics.mjs index 996f4a56..e8b51af7 100644 --- a/source/engine/server/physics/ServerPhysics.mjs +++ b/source/engine/server/physics/ServerPhysics.mjs @@ -1,6 +1,6 @@ import Vector from '../../../shared/Vector.ts'; import * as Defs from '../../../shared/Defs.ts'; -import Q from '../../../shared/Q.mjs'; +import Q from '../../../shared/Q.ts'; import { eventBus, registry } from '../../registry.mjs'; import { GROUND_ANGLE_THRESHOLD, diff --git a/source/shared/GameInterfaces.d.ts b/source/shared/GameInterfaces.d.ts index fadee334..036be442 100644 --- a/source/shared/GameInterfaces.d.ts +++ b/source/shared/GameInterfaces.d.ts @@ -12,8 +12,8 @@ export type ServerEdict = Readonly; export type GLTexture = import("../engine/client/GL.mjs").GLTexture; export type Cvar = Readonly; -export type PmoveConfiguration = Readonly; -export type PmoveQuake2Configuration = Readonly; +export type PmoveConfiguration = Readonly; +export type PmoveQuake2Configuration = Readonly; export type SerializableType = (string | number | boolean | Vector | ServerEdict | SerializableType[] | null); diff --git a/source/shared/Keys.mjs b/source/shared/Keys.mjs deleted file mode 100644 index 344ac7cb..00000000 --- a/source/shared/Keys.mjs +++ /dev/null @@ -1,45 +0,0 @@ - -/** Special keys */ -export const K = Object.freeze({ - TAB: 9, - ENTER: 13, - ESCAPE: 27, - SPACE: 32, - - BACKSPACE: 127, - UPARROW: 128, - DOWNARROW: 129, - LEFTARROW: 130, - RIGHTARROW: 131, - - ALT: 132, - CTRL: 133, - SHIFT: 134, - F1: 135, - F2: 136, - F3: 137, - F4: 138, - F5: 139, - F6: 140, - F7: 141, - F8: 142, - F9: 143, - F10: 144, - F11: 145, - F12: 146, - INS: 147, - DEL: 148, - PGDN: 149, - PGUP: 150, - HOME: 151, - END: 152, - - PAUSE: 255, - - MOUSE1: 200, - MOUSE2: 201, - MOUSE3: 202, - - MWHEELUP: 239, - MWHEELDOWN: 240, -}); diff --git a/source/shared/Keys.ts b/source/shared/Keys.ts new file mode 100644 index 00000000..27438193 --- /dev/null +++ b/source/shared/Keys.ts @@ -0,0 +1,44 @@ +/** Special keys */ +export enum K { + TAB = 9, + ENTER = 13, + ESCAPE = 27, + SPACE = 32, + + BACKSPACE = 127, + UPARROW = 128, + DOWNARROW = 129, + LEFTARROW = 130, + RIGHTARROW = 131, + + ALT = 132, + CTRL = 133, + SHIFT = 134, + F1 = 135, + F2 = 136, + F3 = 137, + F4 = 138, + F5 = 139, + F6 = 140, + F7 = 141, + F8 = 142, + F9 = 143, + F10 = 144, + F11 = 145, + F12 = 146, + INS = 147, + DEL = 148, + PGDN = 149, + PGUP = 150, + HOME = 151, + END = 152, + + PAUSE = 255, + + MOUSE1 = 200, + MOUSE2 = 201, + MOUSE3 = 202, + + MWHEELUP = 239, + MWHEELDOWN = 240, +} diff --git a/source/shared/Pmove.mjs b/source/shared/Pmove.mjs deleted file mode 100644 index 71b3ad73..00000000 --- a/source/shared/Pmove.mjs +++ /dev/null @@ -1,55 +0,0 @@ - -/** - * Pmove constant defaults. - * - * These will give player movement that feels more like Q1 (from QuakeWorld). - */ -export class PmoveConfiguration { - /** @type {number} distance to probe forward for water jump wall detection */ - forwardProbe = 24; - /** @type {number} Z offset for wall check in water jump detection */ - wallcheckZ = 8; - /** @type {number} Z offset for empty space check above wall in water jump */ - emptycheckZ = 24; - /** @type {number} upward velocity when exiting water via water jump */ - waterExitVelocity = 310; - /** @type {number} multiplier applied to wish speed when swimming */ - waterspeedMultiplier = 0.7; - /** @type {number} overbounce factor for velocity clipping (1.0 = QW, 1.01 = Q2) */ - overbounce = 1.0; - /** @type {number} distance below feet for ground detection trace */ - groundCheckDepth = 1.0; - /** @type {number} pitch divisor for ground angle vectors (0 = no scaling, 3 = Q2-style) */ - pitchDivisor = 0; - /** @type {boolean} clamp jump velocity to a minimum of 270 */ - jumpMinClamp = false; - /** @type {boolean} apply landing cooldown (PMF_TIME_LAND) preventing immediate re-jump */ - landingCooldown = false; - /** @type {boolean} prevent swimming jump when sinking faster than -300 */ - swimJumpGuard = false; - /** @type {boolean} fall back to regular accelerate when airaccelerate is 0 */ - airAccelFallback = false; - /** @type {boolean} apply edge friction when near dropoffs */ - edgeFriction = true; -}; - -/** - * Quake 2 defaults. - * - * This will give player movement that original Quake 2 feeling. - */ -export class PmoveQuake2Configuration extends PmoveConfiguration { - forwardProbe = 30; - wallcheckZ = 4; - emptycheckZ = 16; - waterExitVelocity = 350; - waterspeedMultiplier = 0.5; - overbounce = 1.01; - groundCheckDepth = 0.25; - pitchDivisor = 3; - jumpMinClamp = true; - landingCooldown = true; - swimJumpGuard = true; - airAccelFallback = true; - edgeFriction = false; -}; diff --git a/source/shared/Pmove.ts b/source/shared/Pmove.ts new file mode 100644 index 00000000..a1525828 --- /dev/null +++ b/source/shared/Pmove.ts @@ -0,0 +1,54 @@ +/** + * Pmove constant defaults. + * + * These will give player movement that feels more like Q1 (from QuakeWorld). + */ +export class PmoveConfiguration { + /** distance to probe forward for water jump wall detection */ + forwardProbe = 24; + /** Z offset for wall check in water jump detection */ + wallcheckZ = 8; + /** Z offset for empty space check above wall in water jump */ + emptycheckZ = 24; + /** upward velocity when exiting water via water jump */ + waterExitVelocity = 310; + /** multiplier applied to wish speed when swimming */ + waterspeedMultiplier = 0.7; + /** overbounce factor for velocity clipping (1.0 = QW, 1.01 = Q2) */ + overbounce = 1.0; + /** distance below feet for ground detection trace */ + groundCheckDepth = 1.0; + /** pitch divisor for ground angle vectors (0 = no scaling, 3 = Q2-style) */ + pitchDivisor = 0; + /** clamp jump velocity to a minimum of 270 */ + jumpMinClamp = false; + /** apply landing cooldown (PMF_TIME_LAND) preventing immediate re-jump */ + landingCooldown = false; + /** prevent swimming jump when sinking faster than -300 */ + swimJumpGuard = false; + /** fall back to regular accelerate when airaccelerate is 0 */ + airAccelFallback = false; + /** apply edge friction when near dropoffs */ + edgeFriction = true; +} + +/** + * Quake 2 defaults. + * + * This will give player movement that original Quake 2 feeling. + */ +export class PmoveQuake2Configuration extends PmoveConfiguration { + forwardProbe = 30; + wallcheckZ = 4; + emptycheckZ = 16; + waterExitVelocity = 350; + waterspeedMultiplier = 0.5; + overbounce = 1.01; + groundCheckDepth = 0.25; + pitchDivisor = 3; + jumpMinClamp = true; + landingCooldown = true; + swimJumpGuard = true; + airAccelFallback = true; + edgeFriction = false; +} diff --git a/source/shared/Q.mjs b/source/shared/Q.ts similarity index 62% rename from source/shared/Q.mjs rename to source/shared/Q.ts index 1c4ef014..e196b2da 100644 --- a/source/shared/Q.mjs +++ b/source/shared/Q.ts @@ -1,16 +1,20 @@ import { EPSILON } from './Defs.ts'; +type ByteArray = Uint8Array | number[]; +type EnumValue = string | number | null; +type EnumRecord = Record; + /** * Utility class for common engine functions. */ export default class Q { /** * Converts a Uint8Array or array of bytes to a string, stopping at the first zero byte. - * @param {Uint8Array|number[]} src - Source byte array. - * @returns {string} The resulting string. + * @param src source byte array + * @returns the resulting string */ - static memstr(src) { - const dest = []; + static memstr(src: ByteArray): string { + const dest: string[] = []; for (let i = 0; i < src.length; i++) { if (src[i] === 0) { break; @@ -22,10 +26,10 @@ export default class Q { /** * Converts a string to an ArrayBuffer of bytes (8-bit, zero-padded). - * @param {string} src - Source string. - * @returns {ArrayBuffer} The resulting ArrayBuffer. + * @param src source string + * @returns the resulting ArrayBuffer */ - static strmem(src) { + static strmem(src: string): ArrayBuffer { const buf = new ArrayBuffer(src.length); const dest = new Uint8Array(buf); for (let i = 0; i < src.length; i++) { @@ -36,43 +40,44 @@ export default class Q { /** * Checks if a value is NaN. - * @param {number} value - Value to check. - * @returns {boolean} True if value is NaN. + * @param value value to check + * @returns true if value is NaN */ - static isNaN(value) { + static isNaN(value: number): boolean { return Number.isNaN(value); } /** * Converts a string to an integer. * NOTE: Use `+value|0` during regular use in the main/rendering loop. - * @param {string} value - String to convert. - * @returns {number} The integer value. + * @param value string to convert + * @returns the integer value */ - static atoi(value) { + static atoi(value: string): number { return parseInt(value); } /** * Converts a string to a float. * NOTE: Use `+value` during regular use in the main/rendering loop. - * @param {string} value - String to convert. - * @returns {number} The float value. + * @param value string to convert + * @returns the float value */ - static atof(value) { + static atof(value: string): number { return parseFloat(value); } /** * Encodes a byte array to a base64 string. - * @param {Uint8Array|number[]} src - Source byte array. - * @returns {string} Base64-encoded string. + * @param src source byte array + * @returns base64-encoded string */ - static btoa(src) { + static btoa(src: ByteArray): string { const str = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; - const val = []; + const val: string[] = []; const len = src.length - (src.length % 3); - let c; let i; + let c: number; + let i: number; for (i = 0; i < len; i += 3) { c = (src[i] << 16) + (src[i + 1] << 8) + src[i + 2]; val[val.length] = str.charAt(c >> 18) + str.charAt((c >> 12) & 63) + str.charAt((c >> 6) & 63) + str.charAt(c & 63); @@ -89,11 +94,11 @@ export default class Q { /** * Turns seconds like 3692 into a string like "01:01:32". - * @param {number} secs seconds - * @returns {string} hours:mins:seconds + * @param secs seconds + * @returns hours:mins:seconds */ - static secsToTime(secs) { - let negative = secs < 0; + static secsToTime(secs: number): string { + const negative = secs < 0; let seconds = Math.floor(Math.abs(secs)); let minutes = Math.floor(seconds / 60); let hours = 0; @@ -110,32 +115,32 @@ export default class Q { /** * Yields execution to the event loop (async). - * @returns {Promise} Promise that resolves on next tick. + * @returns promise that resolves on next tick */ - static yield() { + static yield(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } /** * Sleeps for a given number of milliseconds (async). - * @param {number} msec - Milliseconds to sleep. - * @returns {Promise} Promise that resolves after the delay. + * @param msec milliseconds to sleep + * @returns promise that resolves after the delay */ - static sleep(msec) { + static sleep(msec: number): Promise { return new Promise((resolve) => setTimeout(resolve, msec)); } /** * Compares two floating point numbers for near-equality. - * @param {number} a - First number. - * @param {number} b - Second number. - * @param {number} epsilon Tolerance for comparison, optional. - * @returns {boolean} True if numbers are nearly equal. + * @param a first number + * @param b second number + * @param epsilon tolerance for comparison, optional + * @returns true if numbers are nearly equal */ - static compareFloat(a, b, epsilon = EPSILON) { + static compareFloat(a: number, b: number, epsilon = EPSILON): boolean { return Math.abs(a - b) < epsilon; } -}; +} /** * Helper functions for enums. @@ -143,20 +148,18 @@ export default class Q { */ export const enumHelpers = Object.freeze({ /** - * @param {string|number|null} val enum value - * @returns {string} enum key + * @param val enum value + * @returns enum key */ - toKey(val) { - // @ts-ignore - return /** @type {string} */ (Object.entries(this).find(([, v]) => v === val)?.[0] ?? `unknown (${val})`); + toKey(this: EnumRecord, val: EnumValue): string { + return Object.entries(this).find(([, value]) => value === val)?.[0] ?? `unknown (${val})`; }, /** - * @param {string} name enum key - * @returns {string|number|null} enum value + * @param name enum key + * @returns enum value */ - fromKey(name) { - // @ts-ignore + fromKey(this: EnumRecord, name: string): EnumValue { return this[name] ?? null; }, }); diff --git a/source/shared/index.mjs b/source/shared/index.mjs index 9f65ea46..564e26eb 100644 --- a/source/shared/index.mjs +++ b/source/shared/index.mjs @@ -1,7 +1,8 @@ export { default as sampleBSpline } from './BSpline.ts'; export * from './ClientEdict.mjs'; export * from './Defs.ts'; -export * from './Keys.mjs'; +export * from './Keys.ts'; export * from './Octree.ts'; -export { default as Q } from './Q.mjs'; +export * from './Pmove.ts'; +export { default as Q } from './Q.ts'; export * from './Vector.ts'; diff --git a/test/common/keys.test.mjs b/test/common/keys.test.mjs new file mode 100644 index 00000000..9eed200a --- /dev/null +++ b/test/common/keys.test.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { K } from '../../source/shared/Keys.ts'; + +void describe('K', () => { + void test('keeps the expected keyboard and mouse bindings', () => { + assert.equal(K.ENTER, 13); + assert.equal(K.ESCAPE, 27); + assert.equal(K.MOUSE1, 200); + assert.equal(K.MWHEELDOWN, 240); + }); +}); diff --git a/test/common/pmove-config.test.mjs b/test/common/pmove-config.test.mjs new file mode 100644 index 00000000..215de3ca --- /dev/null +++ b/test/common/pmove-config.test.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { PmoveConfiguration, PmoveQuake2Configuration } from '../../source/shared/Pmove.ts'; + +void describe('PmoveConfiguration', () => { + void test('keeps the QuakeWorld-style defaults', () => { + const config = new PmoveConfiguration(); + + assert.equal(config.forwardProbe, 24); + assert.equal(config.overbounce, 1.0); + assert.equal(config.pitchDivisor, 0); + assert.equal(config.edgeFriction, true); + }); + + void test('applies the Quake 2 overrides in the subclass', () => { + const config = new PmoveQuake2Configuration(); + + assert.equal(config.forwardProbe, 30); + assert.equal(config.overbounce, 1.01); + assert.equal(config.pitchDivisor, 3); + assert.equal(config.edgeFriction, false); + assert.equal(config.landingCooldown, true); + }); +}); diff --git a/test/common/q.test.mjs b/test/common/q.test.mjs new file mode 100644 index 00000000..459833e5 --- /dev/null +++ b/test/common/q.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import Q, { AsyncFunction, enumHelpers } from '../../source/shared/Q.ts'; + +void describe('Q', () => { + void test('round-trips strings through byte buffers', () => { + const bytes = new Uint8Array(Q.strmem('quake')); + + assert.equal(Q.memstr(bytes), 'quake'); + }); + + void test('compares floats with epsilon tolerance', () => { + assert.equal(Q.compareFloat(1.0, 1.0 + 1e-9), true); + assert.equal(Q.compareFloat(1.0, 1.1), false); + }); + + void test('provides enum helper lookups', () => { + const testEnum = Object.freeze({ + READY: 1, + DONE: 2, + ...enumHelpers, + }); + + assert.equal(testEnum.toKey(2), 'DONE'); + assert.equal(testEnum.fromKey('READY'), 1); + assert.equal(testEnum.fromKey('MISSING'), null); + }); + + void test('exposes the async function constructor', () => { + assert.equal(typeof AsyncFunction, 'function'); + const asyncFunction = new AsyncFunction('return 42;'); + + assert.equal(asyncFunction.constructor, AsyncFunction); + }); +}); From f82f9fc07314915dcfde544c3f81409fa6974299 Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 13:21:08 +0300 Subject: [PATCH 07/67] TS: ClientEdict --- source/engine/client/ClientEntities.mjs | 2 +- source/engine/client/ClientLegacy.mjs | 2 +- .../{ClientEdict.mjs => ClientEdict.ts} | 19 +++++++++------ source/shared/GameInterfaces.d.ts | 2 +- source/shared/index.mjs | 8 ------- test/common/client-edict.test.mjs | 23 +++++++++++++++++++ 6 files changed, 38 insertions(+), 18 deletions(-) rename source/shared/{ClientEdict.mjs => ClientEdict.ts} (63%) delete mode 100644 source/shared/index.mjs create mode 100644 test/common/client-edict.test.mjs diff --git a/source/engine/client/ClientEntities.mjs b/source/engine/client/ClientEntities.mjs index 5c93fa10..4320569a 100644 --- a/source/engine/client/ClientEntities.mjs +++ b/source/engine/client/ClientEntities.mjs @@ -4,7 +4,7 @@ import * as Def from '../common/Def.mjs'; import { content, effect, solid } from '../../shared/Defs.ts'; import Chase from './Chase.mjs'; import { DefaultClientEdictHandler } from './ClientLegacy.mjs'; -import { BaseClientEdictHandler } from '../../shared/ClientEdict.mjs'; +import { BaseClientEdictHandler } from '../../shared/ClientEdict.ts'; import { ClientEngineAPI } from '../common/GameAPIs.mjs'; import { SFX } from './Sound.mjs'; import { Node, revealedVisibility } from '../common/model/BSP.mjs'; diff --git a/source/engine/client/ClientLegacy.mjs b/source/engine/client/ClientLegacy.mjs index af1502e0..5332671a 100644 --- a/source/engine/client/ClientLegacy.mjs +++ b/source/engine/client/ClientLegacy.mjs @@ -5,7 +5,7 @@ import Vector from '../../shared/Vector.ts'; import { effect, modelFlags } from '../../shared/Defs.ts'; -import { BaseClientEdictHandler } from '../../shared/ClientEdict.mjs'; +import { BaseClientEdictHandler } from '../../shared/ClientEdict.ts'; import { registry, eventBus } from '../registry.mjs'; diff --git a/source/shared/ClientEdict.mjs b/source/shared/ClientEdict.ts similarity index 63% rename from source/shared/ClientEdict.mjs rename to source/shared/ClientEdict.ts index a50736a1..75225e7e 100644 --- a/source/shared/ClientEdict.mjs +++ b/source/shared/ClientEdict.ts @@ -1,14 +1,19 @@ +import type { ClientEdict } from '../engine/client/ClientEntities.mjs'; - -/** @typedef {import('../engine/client/ClientEntities.mjs').ClientEdict} ClientEdict */ -/** @typedef {typeof import('../engine/common/GameAPIs.mjs').ClientEngineAPI} ClientEngineAPI */ +type ClientEngineAPI = typeof import('../engine/common/GameAPIs.mjs').ClientEngineAPI; export class BaseClientEdictHandler { /** - * @param {ClientEdict} clientEdict client edict instance - * @param {ClientEngineAPI} engineAPI client engine API + * Client edict instance. */ - constructor(clientEdict, engineAPI) { + clientEdict: ClientEdict; + + /** + * Client engine API. + */ + engine: ClientEngineAPI; + + constructor(clientEdict: ClientEdict, engineAPI: ClientEngineAPI) { this.clientEdict = clientEdict; this.engine = engineAPI; } @@ -32,4 +37,4 @@ export class BaseClientEdictHandler { */ think() { } -}; +} diff --git a/source/shared/GameInterfaces.d.ts b/source/shared/GameInterfaces.d.ts index 036be442..4a12d784 100644 --- a/source/shared/GameInterfaces.d.ts +++ b/source/shared/GameInterfaces.d.ts @@ -1,4 +1,4 @@ -import { BaseClientEdictHandler } from "./ClientEdict.mjs"; +import { BaseClientEdictHandler } from "./ClientEdict.ts"; import { ClientEngineAPI, ServerEngineAPI } from "../engine/common/GameAPIs.mjs"; import { ServerEdict } from "../engine/server/Edict.mjs"; import Vector from "./Vector.ts"; diff --git a/source/shared/index.mjs b/source/shared/index.mjs deleted file mode 100644 index 564e26eb..00000000 --- a/source/shared/index.mjs +++ /dev/null @@ -1,8 +0,0 @@ -export { default as sampleBSpline } from './BSpline.ts'; -export * from './ClientEdict.mjs'; -export * from './Defs.ts'; -export * from './Keys.ts'; -export * from './Octree.ts'; -export * from './Pmove.ts'; -export { default as Q } from './Q.ts'; -export * from './Vector.ts'; diff --git a/test/common/client-edict.test.mjs b/test/common/client-edict.test.mjs new file mode 100644 index 00000000..74cfcac5 --- /dev/null +++ b/test/common/client-edict.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { BaseClientEdictHandler } from '../../source/shared/ClientEdict.ts'; + +void describe('BaseClientEdictHandler', () => { + void test('stores the provided client edict and engine API references', () => { + const clientEdict = { num: 7 }; + const engineAPI = { Draw: 'noop' }; + const handler = new BaseClientEdictHandler(clientEdict, engineAPI); + + assert.equal(handler.clientEdict, clientEdict); + assert.equal(handler.engine, engineAPI); + }); + + void test('keeps the base lifecycle hooks as no-ops', () => { + const handler = new BaseClientEdictHandler({}, {}); + + assert.equal(handler.spawn(), undefined); + assert.equal(handler.emit(), undefined); + assert.equal(handler.think(), undefined); + }); +}); From 39c7e1c6f14ceb71fe5ee2ef7fe7890b49f5d16e Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 13:26:46 +0300 Subject: [PATCH 08/67] removed d.ts for EventBus and registry --- source/engine/registry.d.ts | 71 ------------------------------------- source/engine/registry.mjs | 49 +++++++++++++++++++++++-- source/shared/EventBus.d.ts | 32 ----------------- 3 files changed, 46 insertions(+), 106 deletions(-) delete mode 100644 source/engine/registry.d.ts delete mode 100644 source/shared/EventBus.d.ts diff --git a/source/engine/registry.d.ts b/source/engine/registry.d.ts deleted file mode 100644 index 96e0ee86..00000000 --- a/source/engine/registry.d.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type WebSocketClass from 'ws'; -import type _Con from './common/Console.mjs'; -import type _Com from './common/Com.mjs'; -import type _Sys from './common/Sys.mjs'; -import type _Host from './common/Host.mjs'; -import type _V from './client/V.mjs'; -import type _NET from './network/Network.mjs'; -import type _SV from './server/Server.mjs'; -import type _PR from './server/Progs.mjs'; -import type _Mod from './common/Mod.mjs'; -import type _CL from './client/CL.mjs'; -import type _SCR from './client/SCR.mjs'; -import type _R from './client/R.mjs'; -import type _Draw from './client/Draw.mjs'; -import type _Key from './client/Key.mjs'; -import type _Sbar from './client/Sbar.mjs'; -import type _S from './client/Sound.mjs'; -import type _M from './client/Menu.mjs'; -import type _IN from './client/IN.mjs'; -import { BuildConfig, URLs } from './build-config'; - -type Con = typeof _Con; -type Com = typeof _Com; -type Sys = typeof _Sys; -type Host = typeof _Host; -type V = typeof _V; -type NET = typeof _NET; -type SV = typeof _SV; -type PR = typeof _PR; -type Mod = typeof _Mod; -type CL = typeof _CL; -type SCR = typeof _SCR; -type R = typeof _R; -type Draw = typeof _Draw; -type Key = typeof _Key; -type Sbar = typeof _Sbar; -type S = typeof _S; -type M = typeof _M; -type IN = typeof _IN; -type WebSocket = typeof WebSocketClass; - -interface Registry { - isDedicatedServer?: boolean; - isInsideWorker: boolean; - - COM?: Com; - Con?: Con; - Host?: Host; - Sys?: Sys; - V?: V; - SV?: SV; - PR?: PR; - NET?: NET; - Mod?: Mod; - CL?: CL; - SCR?: SCR; - R?: R; - Draw?: Draw; - Key?: Key; - IN?: IN; - Sbar?: Sbar; - S?: S; - M?: M; - - WebSocket?: WebSocket; - - urls?: URLs; - buildConfig?: BuildConfig; -}; - -export const registry: Registry; diff --git a/source/engine/registry.mjs b/source/engine/registry.mjs index 5b4241e6..00027310 100644 --- a/source/engine/registry.mjs +++ b/source/engine/registry.mjs @@ -1,10 +1,55 @@ +/** @typedef {typeof import('./common/Console.mjs').default} ConModule */ +/** @typedef {typeof import('./common/Com.mjs').default} ComModule */ +/** @typedef {typeof import('./common/Sys.mjs').default} SysModule */ +/** @typedef {typeof import('./common/Host.mjs').default} HostModule */ +/** @typedef {typeof import('./client/V.mjs').default} VModule */ +/** @typedef {typeof import('./network/Network.mjs').default} NetModule */ +/** @typedef {typeof import('./server/Server.mjs').default} ServerModule */ +/** @typedef {typeof import('./server/Progs.mjs').default} ProgsModule */ +/** @typedef {typeof import('./common/Mod.mjs').default} ModModule */ +/** @typedef {typeof import('./client/CL.mjs').default} ClientModule */ +/** @typedef {typeof import('./client/SCR.mjs').default} ScrModule */ +/** @typedef {typeof import('./client/R.mjs').default} RendererModule */ +/** @typedef {typeof import('./client/Draw.mjs').default} DrawModule */ +/** @typedef {typeof import('./client/Key.mjs').default} KeyModule */ +/** @typedef {typeof import('./client/Sbar.mjs').default} SbarModule */ +/** @typedef {typeof import('./client/Sound.mjs').default} SoundModule */ +/** @typedef {typeof import('./client/Menu.mjs').default} MenuModule */ +/** @typedef {typeof import('./client/IN.mjs').default} InputModule */ +/** @typedef {typeof import('ws').default} WebSocketClass */ +/** @typedef {import('./build-config').BuildConfig} BuildConfig */ +/** @typedef {import('./build-config').URLs} URLs */ /** * Registry for engine components. * Unfortunately, the engine components are too tightly coupled, that’s why we need a registry for the time being. * NOTE: Before adding more components here, consider refactoring the code to use ES6 modules and imports. - * @type {import('./registry').Registry} + * @typedef {object} Registry + * @property {ComModule | undefined} COM command and filesystem module + * @property {ConModule | undefined} Con console output module + * @property {HostModule | undefined} Host engine host lifecycle module + * @property {NetModule | undefined} NET networking module + * @property {DrawModule | undefined} Draw 2D drawing module + * @property {SysModule | undefined} Sys platform system module + * @property {VModule | undefined} V view and camera module + * @property {ClientModule | undefined} CL client runtime module + * @property {ServerModule | undefined} SV server runtime module + * @property {ModModule | undefined} Mod model loading and cache module + * @property {ProgsModule | undefined} PR game program interface module + * @property {RendererModule | undefined} R renderer module + * @property {ScrModule | undefined} SCR screen and HUD module + * @property {KeyModule | undefined} Key input binding module + * @property {InputModule | undefined} IN low-level input module + * @property {SbarModule | undefined} Sbar status bar module + * @property {SoundModule | undefined} S audio module + * @property {MenuModule | undefined} M menu module + * @property {WebSocketClass | undefined} WebSocket injected WebSocket constructor + * @property {URLs | undefined} urls runtime URL providers + * @property {BuildConfig | undefined} buildConfig build-time configuration snapshot + * @property {boolean} isDedicatedServer true when running in server mode + * @property {boolean} isInsideWorker true when running inside a worker */ +/** @type {Registry} */ export const registry = { COM: undefined, Con: undefined, @@ -38,8 +83,6 @@ export const registry = { // make sure the registry is not extensible beyond the defined properties Object.seal(registry); -/** @typedef {import('../shared/EventBus').EventBus} EventBusT */ -/** @augments EventBusT */ export class EventBus { /** @type {Map>} */ #listeners = new Map(); diff --git a/source/shared/EventBus.d.ts b/source/shared/EventBus.d.ts deleted file mode 100644 index 8e77bc7f..00000000 --- a/source/shared/EventBus.d.ts +++ /dev/null @@ -1,32 +0,0 @@ - -export interface EventBus { - /** - * Initializes the event bus with a name. - * @param {string} name The name of the event bus. - */ - constructor(name: string): void; - - /** - * Publishes an event, calling all registered listeners for that event type. - * NOTE: Make sure to use arguments that are serializable. Events might be sent over the network or/and to Web Workers. - * @param {string} eventName The event type to trigger. - * @param {...any} args The arguments to pass to the event listeners. - */ - publish(eventName: string, ...args: any): void; - - /** - * Registers an event listener for a specific event type. - * @param {string} eventName The event type to listen for. - * @param {Function} listener The function to call when the event is triggered. - * @returns {Function} A function to remove the listener. - */ - subscribe(eventName: string, listener: Function): Function; - - /** - * Unsubscribes from all events. - */ - unsubscribeAll(): void; - - /** All subscribed topics. */ - get topics(): string[]; -}; From 4872e7b736e623903f47387ccfd80e3bfe1b5421 Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 13:33:45 +0300 Subject: [PATCH 09/67] TS: GameInterfaces --- .../engine/common/model/parsers/ParsedQC.mjs | 2 +- source/engine/server/GameLoader.d.ts | 8 +- ...{GameInterfaces.d.ts => GameInterfaces.ts} | 100 +++++++++--------- 3 files changed, 56 insertions(+), 54 deletions(-) rename source/shared/{GameInterfaces.d.ts => GameInterfaces.ts} (50%) diff --git a/source/engine/common/model/parsers/ParsedQC.mjs b/source/engine/common/model/parsers/ParsedQC.mjs index fa3a5f4a..29207976 100644 --- a/source/engine/common/model/parsers/ParsedQC.mjs +++ b/source/engine/common/model/parsers/ParsedQC.mjs @@ -1,7 +1,7 @@ import Q from '../../../../shared/Q.ts'; import Vector from '../../../../shared/Vector.ts'; -/** @typedef {import('../../../../shared/GameInterfaces.d.ts').ParsedQC} IParsedQC */ +/** @typedef {import('../../../../shared/GameInterfaces').ParsedQC} IParsedQC */ /** @augments IParsedQC */ export default class ParsedQC { /** @type {string} */ diff --git a/source/engine/server/GameLoader.d.ts b/source/engine/server/GameLoader.d.ts index 6ebcea25..18c65642 100644 --- a/source/engine/server/GameLoader.d.ts +++ b/source/engine/server/GameLoader.d.ts @@ -1,5 +1,5 @@ -import { ClientGameInterface, ServerGameInterface } from "../../shared/GameInterfaces"; -import { gameCapabilities } from "../../shared/Defs.ts"; +import { ClientGameConstructor, ServerGameConstructor } from '../../shared/GameInterfaces.ts'; +import { gameCapabilities } from '../../shared/Defs.ts'; export interface GameModuleIdentification { name: string; @@ -10,8 +10,8 @@ export interface GameModuleIdentification { export interface GameModuleInterface { identification: GameModuleIdentification, - ServerGameAPI: ServerGameInterface; - ClientGameAPI: ClientGameInterface; + ServerGameAPI: ServerGameConstructor; + ClientGameAPI: ClientGameConstructor; }; export async function loadGameModule(gameDir: string): Promise; diff --git a/source/shared/GameInterfaces.d.ts b/source/shared/GameInterfaces.ts similarity index 50% rename from source/shared/GameInterfaces.d.ts rename to source/shared/GameInterfaces.ts index 4a12d784..0bf86820 100644 --- a/source/shared/GameInterfaces.d.ts +++ b/source/shared/GameInterfaces.ts @@ -1,28 +1,28 @@ -import { BaseClientEdictHandler } from "./ClientEdict.ts"; -import { ClientEngineAPI, ServerEngineAPI } from "../engine/common/GameAPIs.mjs"; -import { ServerEdict } from "../engine/server/Edict.mjs"; -import Vector from "./Vector.ts"; -import { StartGameInterface } from "../engine/client/ClientLifecycle.mjs"; +import type { BaseClientEdictHandler } from './ClientEdict.ts'; +import type { ClientEngineAPI as ClientEngineApiValue, ServerEngineAPI as ServerEngineApiValue } from '../engine/common/GameAPIs.mjs'; +import type { ServerEdict as ServerEdictValue } from '../engine/server/Edict.mjs'; +import type Vector from './Vector.ts'; +import type { StartGameInterface } from '../engine/client/ClientLifecycle.mjs'; +import type { BaseModel } from '../engine/common/model/BaseModel.mjs'; -export type ClientEngineAPI = Readonly; -export type ServerEngineAPI = Readonly; -export type ServerEdict = Readonly; +export type ClientEngineAPI = Readonly; +export type ServerEngineAPI = Readonly; +export type ServerEdict = Readonly; -//export type GLTexture = Readonly; -export type GLTexture = import("../engine/client/GL.mjs").GLTexture; -export type Cvar = Readonly; +export type GLTexture = import('../engine/client/GL.mjs').GLTexture; +export type Cvar = Readonly; -export type PmoveConfiguration = Readonly; -export type PmoveQuake2Configuration = Readonly; +export type PmoveConfiguration = Readonly; +export type PmoveQuake2Configuration = Readonly; -export type SerializableType = (string | number | boolean | Vector | ServerEdict | SerializableType[] | null); +export type SerializableType = string | number | boolean | Vector | ServerEdict | SerializableType[] | null; export type ClientdataMap = Record; -export type EdictValueType = (string | number | boolean | Vector | null); +export type EdictValueType = string | number | boolean | Vector | null; export type EdictData = Record; -export type SFX = Readonly; +export type SFX = Readonly; export type ViewmodelConfig = { visible: boolean; @@ -35,11 +35,10 @@ export type ViewportDimensions = { height: number; }; -export type RefDef = { // TODO: move to engine shared, it’s V’s refdef +export type RefDef = { vrect: ViewportDimensions; vieworg: Vector; viewangles: Vector; - // TODO: fov? }; export interface ParsedQC { @@ -50,76 +49,79 @@ export interface ParsedQC { frames: string[]; animations: Record; scale: number; -}; +} export type ViewportResizeEvent = ViewportDimensions; export type ClientDamageEvent = { - damageReceived: number, - armorLost: number, - attackOrigin: Vector, + damageReceived: number; + armorLost: number; + attackOrigin: Vector; }; -export interface ClientGameInterface { +export declare abstract class ClientGameInterface { clientdata: ClientdataMap | null; viewmodel: ViewmodelConfig | null; - // client initialization and shutdown methods init(): void; shutdown(): void; - // client main loop methods startFrame(): void; draw(): void; drawLoading(): void; - // serialization methods (for saving/loading games) saveGame(): string; - loadGame(data: string); + loadGame(data: string): void; - // client event handling methods and state change hooks handleClientEvent(code: number, ...args: SerializableType[]): void; updateRefDef(refdef: RefDef): void; - // optional interface for menu integration (NOTE: object to change) static GetStartGameInterface(engineAPI: ClientEngineAPI): StartGameInterface | null; - - // client edict handler retrieval static GetClientEdictHandler(classname: string): typeof BaseClientEdictHandler | null; - - // lifecycle methods static Init(engineAPI: ClientEngineAPI): void; static Shutdown(): void; - static IsServerCompatible(version: number[]): boolean; -}; +} + +export type ClientGameConstructor = typeof ClientGameInterface; export interface PlayerEntitySpawnParamsDynamic { saveSpawnParameters(): string; restoreSpawnParameters(data: string): void; -}; +} export interface ServerInfoField { name: string; label: string; - type: "string" | "number" | "boolean" | "maplist" | "enum"; + type: 'string' | 'number' | 'boolean' | 'maplist' | 'enum'; enumValues?: Record; -}; +} export interface MapDetails { name: string; label: string; maxplayers: number; pictures: string[]; -}; +} export interface StartServerListEntry { label: string; - callback: (ServerEngineAPI: ServerEngineAPI) => void; -}; - -export interface ServerGameInterface { - // only used with CAP_SPAWNPARMS_LEGACY flag + callback: (serverEngineAPI: ServerEngineAPI) => void; +} + +export type SerializedPrimitive = string | number | boolean | null; +export type SerializedVector = [number, number, number, number]; +export type SerializedSkipped = [number]; +export type SerializedInfinity = [number, number]; +export type SerializedPrimitiveValue = [number, SerializedPrimitive]; +export type SerializedFunction = [number, string]; +export type SerializedArray = [number, SerializedValue[]]; +export type SerializedEdictReference = [number, number]; +export type SerializedObject = [number, SerializedData]; +export type SerializedValue = SerializedSkipped | SerializedInfinity | SerializedPrimitiveValue | SerializedFunction | SerializedVector | SerializedArray | SerializedEdictReference | SerializedObject; +export type SerializedData = Record; + +export declare abstract class ServerGameInterface { SetNewParms?(): void; SetSpawnParms?(clientEdict: ServerEdict): void; SetChangeParms?(clientEdict: ServerEdict): void; @@ -144,14 +146,14 @@ export interface ServerGameInterface { prepareEntity(edict: ServerEdict, classname: string, initialData?: EdictData): boolean; spawnPreparedEntity(edict: ServerEdict): boolean; - serialize(): any; - deserialize(data: any): void; + serialize(): SerializedData; + deserialize(data: SerializedData): void; static GetServerInfoFields(): ServerInfoField[]; static GetMapList(): MapDetails[] | null; static GetStartServerList(): StartServerListEntry[] | null; - - static Init(ServerEngineAPI: ServerEngineAPI): void; + static Init(serverEngineAPI: ServerEngineAPI): void; static Shutdown(): void; -}; +} +export type ServerGameConstructor = typeof ServerGameInterface; From 814c231910e39dc2991ce760cf51351a232f96e3 Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 13:49:49 +0300 Subject: [PATCH 10/67] TS: network/Protocol --- source/engine/client/CL.mjs | 2 +- source/engine/client/ClientConnection.mjs | 2 +- source/engine/client/ClientDemos.mjs | 2 +- source/engine/client/ClientInput.mjs | 2 +- source/engine/client/ClientMessages.mjs | 2 +- .../client/ClientServerCommandHandlers.mjs | 2 +- source/engine/client/ClientState.mjs | 2 +- source/engine/client/LegacyServerCommands.mjs | 2 +- source/engine/common/Cmd.mjs | 2 +- source/engine/common/GameAPIs.mjs | 2 +- source/engine/common/Host.mjs | 2 +- source/engine/common/Pmove.mjs | 2 +- source/engine/network/MSG.mjs | 2 +- source/engine/network/Protocol.mjs | 310 ------------------ source/engine/network/Protocol.ts | 286 ++++++++++++++++ source/engine/server/Client.mjs | 2 +- source/engine/server/Edict.mjs | 2 +- source/engine/server/Server.mjs | 2 +- source/engine/server/ServerMessages.mjs | 2 +- test/common/protocol.test.mjs | 46 +++ test/physics/collision-regressions.test.mjs | 2 +- test/physics/map-pmove-harness.mjs | 2 +- test/physics/pmove.test.mjs | 2 +- test/physics/server-client-physics.test.mjs | 2 +- test/physics/server.test.mjs | 2 +- 25 files changed, 354 insertions(+), 332 deletions(-) delete mode 100644 source/engine/network/Protocol.mjs create mode 100644 source/engine/network/Protocol.ts create mode 100644 test/common/protocol.test.mjs diff --git a/source/engine/client/CL.mjs b/source/engine/client/CL.mjs index a936d61d..36846624 100644 --- a/source/engine/client/CL.mjs +++ b/source/engine/client/CL.mjs @@ -1,6 +1,6 @@ import Q from '../../shared/Q.ts'; import * as Def from '../common/Def.mjs'; -import * as Protocol from '../network/Protocol.mjs'; +import * as Protocol from '../network/Protocol.ts'; import Cmd, { ConsoleCommand } from '../common/Cmd.mjs'; import Cvar from '../common/Cvar.mjs'; import { Pmove, PmovePlayer } from '../common/Pmove.mjs'; diff --git a/source/engine/client/ClientConnection.mjs b/source/engine/client/ClientConnection.mjs index 6aea2bb5..17b63f5d 100644 --- a/source/engine/client/ClientConnection.mjs +++ b/source/engine/client/ClientConnection.mjs @@ -1,4 +1,4 @@ -import * as Protocol from '../network/Protocol.mjs'; +import * as Protocol from '../network/Protocol.ts'; import { HostError } from '../common/Errors.mjs'; import Cvar from '../common/Cvar.mjs'; import Cmd from '../common/Cmd.mjs'; diff --git a/source/engine/client/ClientDemos.mjs b/source/engine/client/ClientDemos.mjs index 0c8280cf..e23831c9 100644 --- a/source/engine/client/ClientDemos.mjs +++ b/source/engine/client/ClientDemos.mjs @@ -1,7 +1,7 @@ import { clientConnectionState } from '../common/Def.mjs'; import { eventBus, registry } from '../registry.mjs'; -import * as Protocol from '../network/Protocol.mjs'; +import * as Protocol from '../network/Protocol.ts'; import { HostError } from '../common/Errors.mjs'; let { CL, COM, Con, Host, NET } = registry; diff --git a/source/engine/client/ClientInput.mjs b/source/engine/client/ClientInput.mjs index 146fc324..31c585ac 100644 --- a/source/engine/client/ClientInput.mjs +++ b/source/engine/client/ClientInput.mjs @@ -1,5 +1,5 @@ import Vector from '../../shared/Vector.ts'; -import * as Protocol from '../network/Protocol.mjs'; +import * as Protocol from '../network/Protocol.ts'; import Q from '../../shared/Q.ts'; import { SzBuffer } from '../network/MSG.mjs'; import Cmd from '../common/Cmd.mjs'; diff --git a/source/engine/client/ClientMessages.mjs b/source/engine/client/ClientMessages.mjs index c39d39c4..76e8553e 100644 --- a/source/engine/client/ClientMessages.mjs +++ b/source/engine/client/ClientMessages.mjs @@ -1,4 +1,4 @@ -import * as Protocol from '../network/Protocol.mjs'; +import * as Protocol from '../network/Protocol.ts'; import * as Def from '../common/Def.mjs'; import { eventBus, registry } from '../registry.mjs'; import { HostError } from '../common/Errors.mjs'; diff --git a/source/engine/client/ClientServerCommandHandlers.mjs b/source/engine/client/ClientServerCommandHandlers.mjs index 537245db..4f0a4e50 100644 --- a/source/engine/client/ClientServerCommandHandlers.mjs +++ b/source/engine/client/ClientServerCommandHandlers.mjs @@ -1,4 +1,4 @@ -import * as Protocol from '../network/Protocol.mjs'; +import * as Protocol from '../network/Protocol.ts'; import * as Def from '../common/Def.mjs'; import Cmd from '../common/Cmd.mjs'; import { HostError } from '../common/Errors.mjs'; diff --git a/source/engine/client/ClientState.mjs b/source/engine/client/ClientState.mjs index 4ec2b3be..40c9297b 100644 --- a/source/engine/client/ClientState.mjs +++ b/source/engine/client/ClientState.mjs @@ -1,6 +1,6 @@ import { SzBuffer } from '../network/MSG.mjs'; import { QSocket } from '../network/NetworkDrivers.mjs'; -import * as Protocol from '../network/Protocol.mjs'; +import * as Protocol from '../network/Protocol.ts'; import * as Def from '../common/Def.mjs'; import Vector from '../../shared/Vector.ts'; import { EventBus, eventBus, registry } from '../registry.mjs'; diff --git a/source/engine/client/LegacyServerCommands.mjs b/source/engine/client/LegacyServerCommands.mjs index 9302a344..f2044fc1 100644 --- a/source/engine/client/LegacyServerCommands.mjs +++ b/source/engine/client/LegacyServerCommands.mjs @@ -1,4 +1,4 @@ -import * as Protocol from '../network/Protocol.mjs'; +import * as Protocol from '../network/Protocol.ts'; import * as Def from '../common/Def.mjs'; import { HostError } from '../common/Errors.mjs'; import { eventBus, registry } from '../registry.mjs'; diff --git a/source/engine/common/Cmd.mjs b/source/engine/common/Cmd.mjs index 7122478c..a2b8df24 100644 --- a/source/engine/common/Cmd.mjs +++ b/source/engine/common/Cmd.mjs @@ -1,5 +1,5 @@ import { AsyncFunction } from '../../shared/Q.ts'; -import * as Protocol from '../network/Protocol.mjs'; +import * as Protocol from '../network/Protocol.ts'; import { eventBus, registry } from '../registry.mjs'; import Cvar from './Cvar.mjs'; import { clientConnectionState } from './Def.mjs'; diff --git a/source/engine/common/GameAPIs.mjs b/source/engine/common/GameAPIs.mjs index 9899f1a7..c007b513 100644 --- a/source/engine/common/GameAPIs.mjs +++ b/source/engine/common/GameAPIs.mjs @@ -4,7 +4,7 @@ import { solid } from '../../shared/Defs.ts'; import Key from '../client/Key.mjs'; import { SFX } from '../client/Sound.mjs'; import VID from '../client/VID.mjs'; -import * as Protocol from '../network/Protocol.mjs'; +import * as Protocol from '../network/Protocol.ts'; import { EventBus, eventBus, registry } from '../registry.mjs'; import { ED, ServerEdict } from '../server/Edict.mjs'; import Cmd from './Cmd.mjs'; diff --git a/source/engine/common/Host.mjs b/source/engine/common/Host.mjs index 46a64939..e35d8e7a 100644 --- a/source/engine/common/Host.mjs +++ b/source/engine/common/Host.mjs @@ -1,5 +1,5 @@ import Cvar from './Cvar.mjs'; -import * as Protocol from '../network/Protocol.mjs'; +import * as Protocol from '../network/Protocol.ts'; import * as Def from './Def.mjs'; import Cmd, { ConsoleCommand } from './Cmd.mjs'; import { eventBus, registry } from '../registry.mjs'; diff --git a/source/engine/common/Pmove.mjs b/source/engine/common/Pmove.mjs index 5c63fb3a..cb8f35f3 100644 --- a/source/engine/common/Pmove.mjs +++ b/source/engine/common/Pmove.mjs @@ -8,7 +8,7 @@ */ import Vector from '../../shared/Vector.ts'; -import * as Protocol from '../network/Protocol.mjs'; +import * as Protocol from '../network/Protocol.ts'; import { content } from '../../shared/Defs.ts'; import { BrushModel } from './Mod.mjs'; import Cvar from './Cvar.mjs'; diff --git a/source/engine/network/MSG.mjs b/source/engine/network/MSG.mjs index bd3b68e7..367edbb4 100644 --- a/source/engine/network/MSG.mjs +++ b/source/engine/network/MSG.mjs @@ -1,6 +1,6 @@ import Q from '../../shared/Q.ts'; import Vector from '../../shared/Vector.ts'; -import * as Protocol from '../network/Protocol.mjs'; +import * as Protocol from '../network/Protocol.ts'; import { eventBus, registry } from '../registry.mjs'; let { Con } = registry; diff --git a/source/engine/network/Protocol.mjs b/source/engine/network/Protocol.mjs deleted file mode 100644 index ee32b50c..00000000 --- a/source/engine/network/Protocol.mjs +++ /dev/null @@ -1,310 +0,0 @@ -import Vector from '../../shared/Vector.ts'; - -export const version = 42; // QuakeShack special version - -export const update_backup = 64; // power of 2 -export const update_mask = update_backup - 1; - -export const u = Object.freeze({ - classname: 1 << 0, - - origin1: 1 << 1, - origin2: 1 << 2, - origin3: 1 << 3, - - /** used to smuggle velocity[0] */ - angle1: 1 << 4, - /** used to smuggle velocity[1] */ - angle2: 1 << 5, - /** used to smuggle velocity[2] */ - angle3: 1 << 6, - - nextthink: 1 << 7, - - frame: 1 << 8, - free: 1 << 9, - model: 1 << 10, - colormap: 1 << 11, - skin: 1 << 12, - /** used to smuggle alpha as well */ - effects: 1 << 13, - solid: 1 << 14, - size: 1 << 15, -}); - -export const su = Object.freeze({ - viewheight: 1, - idealpitch: 1 << 1, - punch1: 1 << 2, - punch2: 1 << 3, - punch3: 1 << 4, - velocity1: 1 << 5, - velocity2: 1 << 6, - velocity3: 1 << 7, - /** server acknowledges the last received move command sequence */ - moveack: 1 << 8, - items: 1 << 9, - onground: 1 << 10, - inwater: 1 << 11, - weaponframe: 1 << 12, - armor: 1 << 13, - weapon: 1 << 14, -}); - -/** number of user commands buffered for client-side prediction replay */ -export const CMD_BUFFER_SIZE = 64; -export const CMD_BUFFER_MASK = CMD_BUFFER_SIZE - 1; - -export const default_viewheight = 22; - -/** Server to Client */ -export const svc = Object.freeze({ - null: 0, - nop: 1, - disconnect: 2, - updatestat: 3, - version: 4, // WinQuake - setview: 5, - sound: 6, - time: 7, // WinQuake - print: 8, - stufftext: 9, - setangle: 10, - serverdata: 11, // QuakeWorld: serverdata - lightstyle: 12, - updatename: 13, // WinQuake - updatefrags: 14, - clientdata: 15, // WinQuake - stopsound: 16, - updatecolors: 17, // WinQuake - particle: 18, // WinQuake - damage: 19, - spawnstatic: 20, - spawnbinary: 21, // WinQuake - spawnbaseline: 22, - temp_entity: 23, // required by QC - setpause: 24, - signonnum: 25, // WinQuake - centerprint: 26, - killedmonster: 27, // required by QC - foundsecret: 28, // required by QC - spawnstaticsound: 29, - intermission: 30, // required by QC - finale: 31, // required by QC - cdtrack: 32, // required by QC - sellscreen: 33, // required by QC - cutscene: 34, // WinQuake - - // introduced in QuakeWorld: - smallkick: 34, // set client punchangle to 2 - bigkick: 35, // set client punchangle to 4 - updateping: 36, // [byte] [short] - updateentertime: 7, // [byte] [float] - updatestatlong: 8, // [byte] [long] - muzzleflash: 39, // [short] entity - updateuserinfo: 0, // [byte] slot [long] uid [string] userinfo - download: 41, // [short] size [size bytes] - playerinfo: 42, // variable - nails: 43, // [byte] num [48 bits] xyzpy 12 12 12 4 8 - chokecount: 44, // [byte] packets choked - modellist: 45, // [strings] - soundlist: 46, // [strings] - packetentities: 47, // [...] - deltapacketentities: 48, // [...] - maxspeed: 49, // maxspeed change, for prediction - entgravity: 50, // gravity change, for prediction - setinfo: 51, // setinfo on a client - serverinfo: 52, // serverinfo - updatepl: 53, // [byte] [byte] - - // QuakeShack-only: - updatepings: 101, - loadsound: 102, - chatmsg: 103, - obituary: 104, - pmovevars: 105, - cvar: 106, - changelevel: 107, - clientevent: 108, - gamestateupdate: 109, - clientstateupdate: 110, - setportalstate: 111, -}); - -/** Client to Server */ -export const clc = Object.freeze({ - nop: 1, - disconnect: 2, - move: 3, - stringcmd: 4, - rconcmd: 5, - delta: 6, - qwmove: 7, - sync: 8, -}); - -export const serializableTypes = Object.freeze({ - none: 0, - long: 1, - vector: 2, - string: 3, - true: 4, - false: 5, - null: 6, - array: 7, - float: 8, - short: 9, - byte: 10, - // anything else is a custom type and must be registered with registerSerializableType for more magic -}); - -export const te = Object.freeze({ - spike: 0, - superspike: 1, - gunshot: 2, - explosion: 3, - tarexplosion: 4, - lightning1: 5, - lightning2: 6, - wizspike: 7, - knightspike: 8, - lightning3: 9, - lavasplash: 10, - teleport: 11, - explosion2: 12, - beam: 13, -}); - -export const button = Object.freeze({ - attack: 1, - jump: 2, - use: 4, -}); - -/** - * Player flags - */ -export const pf = Object.freeze({ - PF_MSEC: (1 << 0), - PF_COMMAND: (1 << 1), - PF_VELOCITY1: (1 << 2), - PF_VELOCITY2: (1 << 3), - PF_VELOCITY3: (1 << 4), - PF_MODEL: (1 << 5), - PF_SKINNUM: (1 << 6), - PF_EFFECTS: (1 << 7), - /** only sent for view player */ - PF_WEAPONFRAME: (1 << 8), - /** don't block movement any more */ - PF_DEAD: (1 << 9), - /** offset the view height differently */ - PF_GIB: (1 << 10), - /** don't apply gravity for prediction */ - PF_NOGRAV: (1 << 11), - /** QS: complete Vector */ - PF_VELOCITY: (1 << 12), -}); - -export const cm = Object.freeze({ - CM_ANGLE1: (1<<0), - CM_ANGLE3: (1<<1), - CM_FORWARD: (1<<2), - CM_SIDE: (1<<3), - CM_UP: (1<<4), - CM_BUTTONS: (1<<5), - CM_IMPULSE: (1<<6), - CM_ANGLE2: (1<<7), -}); - -export class EntityState { // entity_state_t - constructor() { - /** @type {number} edict index */ - this.number = 0; - /** @type {number} nolerp, etc. */ - this.flags = 0; - this.frame = 0; - this.modelindex = 0; - this.colormap = 0; - this.skinnum = 0; - this.effects = 0; - this.alpha = 1.0; - - this.origin = new Vector(); - this.angles = new Vector(); - } -}; - -export class UserCmd { // usercmd_t - constructor() { - this.msec = 0; - this.forwardmove = 0; - this.sidemove = 0; - this.upmove = 0; - this.angles = new Vector(); - this.buttons = 0; - this.impulse = 0; - } - - /** - * Copies the usercmd. - * @returns {UserCmd} copied usercmd - */ - copy() { - const cmd = new UserCmd(); - cmd.msec = this.msec; - cmd.forwardmove = this.forwardmove; - cmd.sidemove = this.sidemove; - cmd.upmove = this.upmove; - cmd.angles.set(this.angles); - cmd.buttons = this.buttons; - cmd.impulse = this.impulse; - return cmd; - } - - /** - * Sets this to the value of other. - * @param {UserCmd} other usercmd - * @returns {UserCmd} this - */ - set(other) { - this.msec = other.msec; - this.forwardmove = other.forwardmove; - this.sidemove = other.sidemove; - this.upmove = other.upmove; - this.angles.set(other.angles); - this.buttons = other.buttons; - this.impulse = other.impulse; - return this; - } - - /** - * Reset command. - * @returns {UserCmd} this - */ - reset() { - /** @type {number} 0..255, how long the frame took to process on the client */ - this.msec = 0; - this.forwardmove = 0; - this.sidemove = 0; - this.upmove = 0; - this.angles.clear(); - this.buttons = 0; - this.impulse = 0; - return this; - } - - /** - * Tests for equality. - * @param {UserCmd} other other - * @returns {boolean} true, if equal - */ - equals(other) { - return this.msec === other.msec && - this.forwardmove === other.forwardmove && - this.sidemove === other.sidemove && - this.upmove === other.upmove && - this.angles.equals(other.angles) && // FIXME: use epsilon? - this.buttons === other.buttons && - this.impulse === other.impulse; - } -}; diff --git a/source/engine/network/Protocol.ts b/source/engine/network/Protocol.ts new file mode 100644 index 00000000..eb39dfb7 --- /dev/null +++ b/source/engine/network/Protocol.ts @@ -0,0 +1,286 @@ +import Vector from '../../shared/Vector.ts'; + +export const version = 42; + +export const update_backup = 64; +export const update_mask = update_backup - 1; + +export enum u { + classname = 1 << 0, + origin1 = 1 << 1, + origin2 = 1 << 2, + origin3 = 1 << 3, + angle1 = 1 << 4, + angle2 = 1 << 5, + angle3 = 1 << 6, + nextthink = 1 << 7, + frame = 1 << 8, + free = 1 << 9, + model = 1 << 10, + colormap = 1 << 11, + skin = 1 << 12, + effects = 1 << 13, + solid = 1 << 14, + size = 1 << 15, +} + +export enum su { + viewheight = 1, + idealpitch = 1 << 1, + punch1 = 1 << 2, + punch2 = 1 << 3, + punch3 = 1 << 4, + velocity1 = 1 << 5, + velocity2 = 1 << 6, + velocity3 = 1 << 7, + moveack = 1 << 8, + items = 1 << 9, + onground = 1 << 10, + inwater = 1 << 11, + weaponframe = 1 << 12, + armor = 1 << 13, + weapon = 1 << 14, +} + +export const CMD_BUFFER_SIZE = 64; +export const CMD_BUFFER_MASK = CMD_BUFFER_SIZE - 1; + +export const default_viewheight = 22; + +export const svc = Object.freeze({ + null: 0, + nop: 1, + disconnect: 2, + updatestat: 3, + version: 4, + setview: 5, + sound: 6, + time: 7, + print: 8, + stufftext: 9, + setangle: 10, + serverdata: 11, + lightstyle: 12, + updatename: 13, + updatefrags: 14, + clientdata: 15, + stopsound: 16, + updatecolors: 17, + particle: 18, + damage: 19, + spawnstatic: 20, + spawnbinary: 21, + spawnbaseline: 22, + temp_entity: 23, + setpause: 24, + signonnum: 25, + centerprint: 26, + killedmonster: 27, + foundsecret: 28, + spawnstaticsound: 29, + intermission: 30, + finale: 31, + cdtrack: 32, + sellscreen: 33, + cutscene: 34, + smallkick: 34, + bigkick: 35, + updateping: 36, + updateentertime: 7, + updatestatlong: 8, + muzzleflash: 39, + updateuserinfo: 0, + download: 41, + playerinfo: 42, + nails: 43, + chokecount: 44, + modellist: 45, + soundlist: 46, + packetentities: 47, + deltapacketentities: 48, + maxspeed: 49, + entgravity: 50, + setinfo: 51, + serverinfo: 52, + updatepl: 53, + updatepings: 101, + loadsound: 102, + chatmsg: 103, + obituary: 104, + pmovevars: 105, + cvar: 106, + changelevel: 107, + clientevent: 108, + gamestateupdate: 109, + clientstateupdate: 110, + setportalstate: 111, +} as const); + +export enum clc { + nop = 1, + disconnect = 2, + move = 3, + stringcmd = 4, + rconcmd = 5, + delta = 6, + qwmove = 7, + sync = 8, +} + +export const serializableTypes = Object.freeze({ + none: 0, + long: 1, + vector: 2, + string: 3, + true: 4, + false: 5, + null: 6, + array: 7, + float: 8, + short: 9, + byte: 10, +} as const); + +export enum te { + spike = 0, + superspike = 1, + gunshot = 2, + explosion = 3, + tarexplosion = 4, + lightning1 = 5, + lightning2 = 6, + wizspike = 7, + knightspike = 8, + lightning3 = 9, + lavasplash = 10, + teleport = 11, + explosion2 = 12, + beam = 13, +} + +export enum button { + attack = 1, + jump = 2, + use = 4, +} + +export enum pf { + PF_MSEC = 1 << 0, + PF_COMMAND = 1 << 1, + PF_VELOCITY1 = 1 << 2, + PF_VELOCITY2 = 1 << 3, + PF_VELOCITY3 = 1 << 4, + PF_MODEL = 1 << 5, + PF_SKINNUM = 1 << 6, + PF_EFFECTS = 1 << 7, + PF_WEAPONFRAME = 1 << 8, + PF_DEAD = 1 << 9, + PF_GIB = 1 << 10, + PF_NOGRAV = 1 << 11, + PF_VELOCITY = 1 << 12, +} + +export enum cm { + CM_ANGLE1 = 1 << 0, + CM_ANGLE3 = 1 << 1, + CM_FORWARD = 1 << 2, + CM_SIDE = 1 << 3, + CM_UP = 1 << 4, + CM_BUTTONS = 1 << 5, + CM_IMPULSE = 1 << 6, + CM_ANGLE2 = 1 << 7, +} + +export class EntityState { + number: number; + flags: number; + frame: number; + modelindex: number; + colormap: number; + skinnum: number; + effects: number; + alpha: number; + origin: Vector; + angles: Vector; + + constructor() { + this.number = 0; + this.flags = 0; + this.frame = 0; + this.modelindex = 0; + this.colormap = 0; + this.skinnum = 0; + this.effects = 0; + this.alpha = 1.0; + this.origin = new Vector(); + this.angles = new Vector(); + } +} + +export class UserCmd { + msec: number; + forwardmove: number; + sidemove: number; + upmove: number; + angles: Vector; + buttons: number; + impulse: number; + + constructor() { + this.msec = 0; + this.forwardmove = 0; + this.sidemove = 0; + this.upmove = 0; + this.angles = new Vector(); + this.buttons = 0; + this.impulse = 0; + } + + copy(): UserCmd { + const cmd = new UserCmd(); + + cmd.msec = this.msec; + cmd.forwardmove = this.forwardmove; + cmd.sidemove = this.sidemove; + cmd.upmove = this.upmove; + cmd.angles.set(this.angles); + cmd.buttons = this.buttons; + cmd.impulse = this.impulse; + + return cmd; + } + + set(other: UserCmd): UserCmd { + this.msec = other.msec; + this.forwardmove = other.forwardmove; + this.sidemove = other.sidemove; + this.upmove = other.upmove; + this.angles.set(other.angles); + this.buttons = other.buttons; + this.impulse = other.impulse; + + return this; + } + + reset(): UserCmd { + this.msec = 0; + this.forwardmove = 0; + this.sidemove = 0; + this.upmove = 0; + this.angles.clear(); + this.buttons = 0; + this.impulse = 0; + + return this; + } + + equals(other: UserCmd): boolean { + return this.msec === other.msec && + this.forwardmove === other.forwardmove && + this.sidemove === other.sidemove && + this.upmove === other.upmove && + this.angles.equals(other.angles) && + this.buttons === other.buttons && + this.impulse === other.impulse; + } +} diff --git a/source/engine/server/Client.mjs b/source/engine/server/Client.mjs index 4fda9933..4dd3c39d 100644 --- a/source/engine/server/Client.mjs +++ b/source/engine/server/Client.mjs @@ -3,7 +3,7 @@ import { gameCapabilities } from '../../shared/Defs.ts'; import Vector from '../../shared/Vector.ts'; import { SzBuffer } from '../network/MSG.mjs'; import { QSocket } from '../network/NetworkDrivers.mjs'; -import * as Protocol from '../network/Protocol.mjs'; +import * as Protocol from '../network/Protocol.ts'; import { eventBus, registry } from '../registry.mjs'; import { ServerEntityState } from './Server.mjs'; diff --git a/source/engine/server/Edict.mjs b/source/engine/server/Edict.mjs index a02f08a2..c903c0cb 100644 --- a/source/engine/server/Edict.mjs +++ b/source/engine/server/Edict.mjs @@ -1,6 +1,6 @@ import Vector from '../../shared/Vector.ts'; import { SzBuffer, registerSerializableType } from '../network/MSG.mjs'; -import * as Protocol from '../network/Protocol.mjs'; +import * as Protocol from '../network/Protocol.ts'; import * as Def from '../common/Def.mjs'; import * as Defs from '../../shared/Defs.ts'; import { eventBus, registry } from '../registry.mjs'; diff --git a/source/engine/server/Server.mjs b/source/engine/server/Server.mjs index cce1259a..ee1d6295 100644 --- a/source/engine/server/Server.mjs +++ b/source/engine/server/Server.mjs @@ -2,7 +2,7 @@ import Cvar from '../common/Cvar.mjs'; import { MoveVars, Pmove } from '../common/Pmove.mjs'; import Vector from '../../shared/Vector.ts'; import { SzBuffer } from '../network/MSG.mjs'; -import * as Protocol from '../network/Protocol.mjs'; +import * as Protocol from '../network/Protocol.ts'; import * as Def from './../common/Def.mjs'; import Cmd, { ConsoleCommand } from '../common/Cmd.mjs'; import { ED, ServerEdict } from './Edict.mjs'; diff --git a/source/engine/server/ServerMessages.mjs b/source/engine/server/ServerMessages.mjs index 681283e0..5a9d77d9 100644 --- a/source/engine/server/ServerMessages.mjs +++ b/source/engine/server/ServerMessages.mjs @@ -1,5 +1,5 @@ import { SzBuffer } from '../network/MSG.mjs'; -import * as Protocol from '../network/Protocol.mjs'; +import * as Protocol from '../network/Protocol.ts'; import * as Defs from '../../shared/Defs.ts'; import Cvar from '../common/Cvar.mjs'; import { eventBus, registry } from '../registry.mjs'; diff --git a/test/common/protocol.test.mjs b/test/common/protocol.test.mjs new file mode 100644 index 00000000..1d7765f5 --- /dev/null +++ b/test/common/protocol.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import * as Protocol from '../../source/engine/network/Protocol.ts'; + +void describe('Protocol', () => { + void test('keeps the expected command and flag bit values', () => { + assert.equal(Protocol.version, 42); + assert.equal(Protocol.u.classname, 1 << 0); + assert.equal(Protocol.su.moveack, 1 << 8); + assert.equal(Protocol.clc.stringcmd, 4); + assert.equal(Protocol.cm.CM_IMPULSE, 1 << 6); + assert.equal(Protocol.pf.PF_VELOCITY, 1 << 12); + assert.equal(Protocol.button.attack, 1); + assert.equal(Protocol.svc.clientevent, 108); + assert.equal(Protocol.serializableTypes.null, 6); + }); + + void test('copies and resets user commands without aliasing vectors', () => { + const command = new Protocol.UserCmd(); + + command.msec = 16; + command.forwardmove = 200; + command.sidemove = -50; + command.upmove = 10; + command.angles.set([1, 2, 3]); + command.buttons = Protocol.button.attack; + command.impulse = 7; + + const copy = command.copy(); + + assert.notStrictEqual(copy, command); + assert.notStrictEqual(copy.angles, command.angles); + assert.equal(copy.equals(command), true); + + command.reset(); + + assert.equal(command.msec, 0); + assert.equal(command.forwardmove, 0); + assert.equal(command.sidemove, 0); + assert.equal(command.upmove, 0); + assert.equal(command.buttons, 0); + assert.equal(command.impulse, 0); + assert.deepEqual(Array.from(command.angles), [0, 0, 0]); + }); +}); diff --git a/test/physics/collision-regressions.test.mjs b/test/physics/collision-regressions.test.mjs index 28a8c859..73af9877 100644 --- a/test/physics/collision-regressions.test.mjs +++ b/test/physics/collision-regressions.test.mjs @@ -7,7 +7,7 @@ import { Brush, BrushModel, BrushSide } from '../../source/engine/common/model/B import { BrushTrace, Hull, PMF, Pmove, PmovePlayer, Trace } from '../../source/engine/common/Pmove.mjs'; import { BSP29Loader } from '../../source/engine/common/model/loaders/BSP29Loader.mjs'; import { eventBus, registry } from '../../source/engine/registry.mjs'; -import { UserCmd } from '../../source/engine/network/Protocol.mjs'; +import { UserCmd } from '../../source/engine/network/Protocol.ts'; import { ClientEdict } from '../../source/engine/client/ClientEntities.mjs'; import { ServerCollision } from '../../source/engine/server/physics/ServerCollision.mjs'; import { ServerPhysics } from '../../source/engine/server/physics/ServerPhysics.mjs'; diff --git a/test/physics/map-pmove-harness.mjs b/test/physics/map-pmove-harness.mjs index dde69312..56d37e6b 100644 --- a/test/physics/map-pmove-harness.mjs +++ b/test/physics/map-pmove-harness.mjs @@ -4,7 +4,7 @@ import path from 'node:path'; import COMClass from '../../source/engine/common/Com.mjs'; import Mod from '../../source/engine/common/Mod.mjs'; import { PMF, Pmove } from '../../source/engine/common/Pmove.mjs'; -import { UserCmd } from '../../source/engine/network/Protocol.mjs'; +import { UserCmd } from '../../source/engine/network/Protocol.ts'; import { eventBus, registry } from '../../source/engine/registry.mjs'; import Vector from '../../source/shared/Vector.ts'; diff --git a/test/physics/pmove.test.mjs b/test/physics/pmove.test.mjs index 407aeac3..9603c218 100644 --- a/test/physics/pmove.test.mjs +++ b/test/physics/pmove.test.mjs @@ -7,7 +7,7 @@ import Mod from '../../source/engine/common/Mod.mjs'; import Vector from '../../source/shared/Vector.ts'; import { content } from '../../source/shared/Defs.ts'; import { DIST_EPSILON, PM_TYPE, PMF, Pmove, PmovePlayer, Trace } from '../../source/engine/common/Pmove.mjs'; -import { UserCmd } from '../../source/engine/network/Protocol.mjs'; +import { UserCmd } from '../../source/engine/network/Protocol.ts'; import { eventBus, registry } from '../../source/engine/registry.mjs'; import { diff --git a/test/physics/server-client-physics.test.mjs b/test/physics/server-client-physics.test.mjs index 1d549874..b4a8912a 100644 --- a/test/physics/server-client-physics.test.mjs +++ b/test/physics/server-client-physics.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import Vector from '../../source/shared/Vector.ts'; import { flags, moveType, solid } from '../../source/shared/Defs.ts'; -import { UserCmd } from '../../source/engine/network/Protocol.mjs'; +import { UserCmd } from '../../source/engine/network/Protocol.ts'; import { eventBus, registry } from '../../source/engine/registry.mjs'; import { ServerClient } from '../../source/engine/server/Client.mjs'; import { ServerClientPhysics } from '../../source/engine/server/physics/ServerClientPhysics.mjs'; diff --git a/test/physics/server.test.mjs b/test/physics/server.test.mjs index 8325a3c2..91319edd 100644 --- a/test/physics/server.test.mjs +++ b/test/physics/server.test.mjs @@ -2,7 +2,7 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import Vector from '../../source/shared/Vector.ts'; -import * as Protocol from '../../source/engine/network/Protocol.mjs'; +import * as Protocol from '../../source/engine/network/Protocol.ts'; import { eventBus, registry } from '../../source/engine/registry.mjs'; import SV from '../../source/engine/server/Server.mjs'; import { ServerClient } from '../../source/engine/server/Client.mjs'; From a207c2e09aecfd5e22952f5dd84d4722ef86df29 Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 14:11:40 +0300 Subject: [PATCH 11/67] TS: network/MSG --- source/engine/client/ClientInput.mjs | 2 +- source/engine/client/ClientState.mjs | 2 +- source/engine/common/GameAPIs.mjs | 2 +- source/engine/network/MSG.mjs | 580 ---------------------- source/engine/network/MSG.ts | 613 ++++++++++++++++++++++++ source/engine/network/Network.mjs | 2 +- source/engine/registry.mjs | 50 ++ source/engine/server/Client.mjs | 2 +- source/engine/server/Edict.mjs | 2 +- source/engine/server/Server.mjs | 2 +- source/engine/server/ServerMessages.mjs | 2 +- test/common/msg.test.mjs | 66 +++ test/common/registry.test.mjs | 11 + 13 files changed, 748 insertions(+), 588 deletions(-) delete mode 100644 source/engine/network/MSG.mjs create mode 100644 source/engine/network/MSG.ts create mode 100644 test/common/msg.test.mjs create mode 100644 test/common/registry.test.mjs diff --git a/source/engine/client/ClientInput.mjs b/source/engine/client/ClientInput.mjs index 31c585ac..0af70978 100644 --- a/source/engine/client/ClientInput.mjs +++ b/source/engine/client/ClientInput.mjs @@ -1,7 +1,7 @@ import Vector from '../../shared/Vector.ts'; import * as Protocol from '../network/Protocol.ts'; import Q from '../../shared/Q.ts'; -import { SzBuffer } from '../network/MSG.mjs'; +import { SzBuffer } from '../network/MSG.ts'; import Cmd from '../common/Cmd.mjs'; import { eventBus, registry } from '../registry.mjs'; import { HostError } from '../common/Errors.mjs'; diff --git a/source/engine/client/ClientState.mjs b/source/engine/client/ClientState.mjs index 40c9297b..8644e043 100644 --- a/source/engine/client/ClientState.mjs +++ b/source/engine/client/ClientState.mjs @@ -1,4 +1,4 @@ -import { SzBuffer } from '../network/MSG.mjs'; +import { SzBuffer } from '../network/MSG.ts'; import { QSocket } from '../network/NetworkDrivers.mjs'; import * as Protocol from '../network/Protocol.ts'; import * as Def from '../common/Def.mjs'; diff --git a/source/engine/common/GameAPIs.mjs b/source/engine/common/GameAPIs.mjs index c007b513..f97d2589 100644 --- a/source/engine/common/GameAPIs.mjs +++ b/source/engine/common/GameAPIs.mjs @@ -16,7 +16,7 @@ import W from './W.mjs'; /** @typedef {import('../client/ClientEntities.mjs').ClientEdict} ClientEdict */ /** @typedef {import('../client/ClientEntities.mjs').ClientDlight} ClientDlight */ /** @typedef {import('../client/GL.mjs').GLTexture} GLTexture */ -/** @typedef {import('../network/MSG.mjs').SzBuffer} SzBuffer */ +/** @typedef {import('../network/MSG.ts').SzBuffer} SzBuffer */ /** @typedef {import('../server/Navigation.mjs').Navigation} Navigation */ /** @typedef {import('./model/parsers/ParsedQC.mjs').default} ParsedQC */ /** @typedef {import('./model/BaseModel.mjs').BaseModel} BaseModel */ diff --git a/source/engine/network/MSG.mjs b/source/engine/network/MSG.mjs deleted file mode 100644 index 367edbb4..00000000 --- a/source/engine/network/MSG.mjs +++ /dev/null @@ -1,580 +0,0 @@ -import Q from '../../shared/Q.ts'; -import Vector from '../../shared/Vector.ts'; -import * as Protocol from '../network/Protocol.ts'; -import { eventBus, registry } from '../registry.mjs'; - -let { Con } = registry; - -eventBus.subscribe('registry.frozen', () => { - ({ Con } = registry); -}); - -/** @type {{id: number, constructor: Function, serialize: (sz: SzBuffer, object: object) => void, deserializeOnServer: (sz: SzBuffer) => object, deserializeOnClient: (sz: SzBuffer) => object}[]} */ -const serializableHandlers = []; - -/** - * Registers a custom serializable type for network transmission. - * @param {Function} constructor The constructor function of the type - * @param {{ serialize: (sz: SzBuffer, object: object) => void, deserializeOnServer: (sz: SzBuffer) => object, deserializeOnClient: (sz: SzBuffer) => object }} handlers serialization handlers - */ -export function registerSerializableType(constructor, { serialize, deserializeOnServer, deserializeOnClient }) { - serializableHandlers.push({ - constructor, - serialize, - deserializeOnServer, - deserializeOnClient, - id: Object.keys(Protocol.serializableTypes).length + serializableHandlers.length, - }); -} - -export class SzBuffer { - /** current read position in the buffer */ - readcount = 0; - - /** set to true when a read operation fails due to insufficient data */ - badread = false; - - /** - * @param {number} size maximum size of the buffer - * @param {string} name name for debugging purposes - */ - constructor(size, name = 'anonymous') { - this.name = name; - this.data = new ArrayBuffer(size); - this.cursize = 0; - /** if false, overflow will cause a crash */ - this.allowoverflow = false; - /** set to true, when an overflow has occurred */ - this.overflowed = false; - } - - get maxsize() { - return this.data.byteLength; - } - - clear() { - this.cursize = 0; - this.overflowed = false; - } - - copy() { - const copy = new SzBuffer(this.maxsize, this.name); - copy.cursize = this.cursize; - copy.overflowed = this.overflowed; - const u8 = new Uint8Array(this.data); - const u8copy = new Uint8Array(copy.data); - u8copy.set(u8); - return copy; - } - - set(other) { - this.name = other.name; - this.data = new ArrayBuffer(other.maxsize); - new Uint8Array(this.data).set(new Uint8Array(other.data)); - this.cursize = other.cursize; - this.allowoverflow = other.allowoverflow; - this.overflowed = other.overflowed; - return this; - } - - allocate(size) { - if (this.cursize + size > this.maxsize) { - if (this.allowoverflow !== true) { - throw RangeError('SzBuffer.allocate: overflow without allowoverflow set'); - } - - if (size > this.maxsize) { - throw RangeError('SzBuffer.allocate: ' + size + ' is > full buffer size'); - } - - this.overflowed = true; - this.cursize = 0; - - Con.Print('SzBuffer.allocate: overflow\n'); - // eslint-disable-next-line no-debugger - debugger; - } - - const cursize = this.cursize; - this.cursize += size; - return cursize; - } - - write(data, length) { - const u = new Uint8Array(this.data, this.allocate(length), length); - u.set(data.subarray(0, length)); - } - - print(data) { - const buf = new Uint8Array(this.data); - let dest; - if (this.cursize !== 0) { - if (buf[this.cursize - 1] === 0) { - dest = this.allocate(data.length - 1) - 1; - } else { - dest = this.allocate(data.length); - } - } else { - dest = this.allocate(data.length); - } - for (let i = 0; i < data.length; i++) { - buf[dest + i] = data.charCodeAt(i); - } - } - - toHexString() { - let output = ''; - const u8 = new Uint8Array(this.data, 0, this.cursize); - const lineBytes = 16; - for (let i = 0; i < u8.length; i += lineBytes) { - let line = ('00000000' + i.toString(16)).slice(-8) + ': '; - let hexPart = ''; - let asciiPart = ''; - for (let j = 0; j < lineBytes; j++) { - if (i + j < u8.length) { - const byte = u8[i + j]; - hexPart += ('0' + byte.toString(16)).slice(-2) + ' '; - asciiPart += (byte >= 32 && byte <= 126 ? String.fromCharCode(byte) : '.'); - } else { - hexPart += ' '; - asciiPart += ' '; - } - } - line += hexPart + ' ' + asciiPart; - output += line + '\n'; - } - return output; - } - - toString() { - return `SzBuffer: (${this.name}) ${this.cursize} bytes of ${this.maxsize} bytes used, overflowed? ${this.overflowed ? 'yes' : 'no'}`; - } - - writeChar(c) { - console.assert(c >= -128 && c <= 127, 'must be signed byte', c); - new DataView(this.data).setInt8(this.allocate(1), c); - } - - writeByte(c) { - console.assert(c >= 0 && c <= 255, 'must be unsigned byte', c); - new DataView(this.data).setUint8(this.allocate(1), c); - } - - writeShort(c) { - console.assert(c >= -32768 && c <= 32767, 'must be signed short', c); - new DataView(this.data).setInt16(this.allocate(2), c, true); - } - - writeUint16(c) { - console.assert(c >= 0 && c <= 65535, 'must be unsigned short', c); - new DataView(this.data).setUint16(this.allocate(2), c, true); - } - - writeLong(c) { - console.assert(c >= -2147483648 && c <= 2147483647, 'must be signed long', c); - new DataView(this.data).setInt32(this.allocate(4), c, true); - } - - writeFloat(f) { - console.assert(typeof f === 'number' && !Q.isNaN(f) && isFinite(f), 'must be a real number, not NaN or Infinity'); - new DataView(this.data).setFloat32(this.allocate(4), f, true); - } - - writeString(s) { - if (s) { - this.write(new Uint8Array(Q.strmem(s)), s.length); - } - this.writeChar(0); - } - - writeCoord(f) { - // NOTE: when adjusting quantization of coordinates, make sure to update the snap/nudge position logic in Pmove as well - this.writeLong(f * 8.0); - } - - writeCoordVector(vec) { - this.writeCoord(vec[0]); - this.writeCoord(vec[1]); - this.writeCoord(vec[2]); - } - - writeAngle(f) { - this.writeShort(Math.round((f / 360.0 * 32768.0)) % 32768); - } - - writeAngleVector(vec) { - this.writeAngle(vec[0]); - this.writeAngle(vec[1]); - this.writeAngle(vec[2]); - } - - writeRGB(color) { - this.writeByte(Math.round(color[0] * 255)); - this.writeByte(Math.round(color[1] * 255)); - this.writeByte(Math.round(color[2] * 255)); - } - - writeRGBA(color, alpha) { - this.writeRGB(color); - this.writeByte(Math.round(alpha * 255)); - } - - beginReading() { - this.readcount = 0; - this.badread = false; - } - - readChar() { - if (this.readcount >= this.cursize) { - this.badread = true; - // debugger; - return -1; - } - const c = new Int8Array(this.data, this.readcount, 1)[0]; - this.readcount++; - return c; - } - - readByte() { - if (this.readcount >= this.cursize) { - this.badread = true; - // debugger; - return -1; - } - const c = new Uint8Array(this.data, this.readcount, 1)[0]; - this.readcount++; - return c; - } - - readShort() { - if ((this.readcount + 2) > this.cursize) { - this.badread = true; - // debugger; - return -1; - } - const num = new DataView(this.data).getInt16(this.readcount, true); - this.readcount += 2; - return num; - } - - readUint16() { - if ((this.readcount + 2) > this.cursize) { - this.badread = true; - // debugger; - return -1; - } - const num = new DataView(this.data).getUint16(this.readcount, true); - this.readcount += 2; - return num; - } - - readLong() { - if ((this.readcount + 4) > this.cursize) { - this.badread = true; - // debugger; - return -1; - } - const num = new DataView(this.data).getInt32(this.readcount, true); - this.readcount += 4; - return num; - } - - readFloat() { - if ((this.readcount + 4) > this.cursize) { - this.badread = true; - // debugger; - return -1; - } - const num = new DataView(this.data).getFloat32(this.readcount, true); - this.readcount += 4; - return num; - } - - readString() { - const chars = []; - for (let i = 0; i < this.cursize; i++) { - const c = this.readByte(); - if (c <= 0) { - break; - } - chars.push(String.fromCharCode(c)); - } - return chars.join(''); - } - - readCoord() { - return this.readLong() * 0.125; - } - - readCoordVector() { - return new Vector(this.readCoord(), this.readCoord(), this.readCoord()); - } - - readAngle() { - return this.readShort() * (360.0 / 32768.0); - } - - readAngleVector() { - return new Vector(this.readAngle(), this.readAngle(), this.readAngle()); - } - - readRGB() { - return new Vector( - this.readByte() / 255, - this.readByte() / 255, - this.readByte() / 255, - ); - } - - readRGBA() { - return [this.readRGB(), this.readByte() / 255]; - } - - /** - * Write a delta usercmd to the message buffer. - * @param {Protocol.UserCmd} from previous usercmd - * @param {Protocol.UserCmd} to current usercmd - */ - writeDeltaUsercmd(from, to) { - let bits = 0; - - if (to.forwardmove !== from.forwardmove) { - bits |= Protocol.cm.CM_FORWARD; - } - - if (to.sidemove !== from.sidemove) { - bits |= Protocol.cm.CM_SIDE; - } - - if (to.upmove !== from.upmove) { - bits |= Protocol.cm.CM_UP; - } - - if (to.angles[0] !== from.angles[0]) { - bits |= Protocol.cm.CM_ANGLE1; - } - - if (to.angles[1] !== from.angles[1]) { - bits |= Protocol.cm.CM_ANGLE2; - } - - if (to.angles[2] !== from.angles[2]) { - bits |= Protocol.cm.CM_ANGLE3; - } - - if (to.buttons !== from.buttons) { - bits |= Protocol.cm.CM_BUTTONS; - } - - if (to.impulse !== from.impulse) { - bits |= Protocol.cm.CM_IMPULSE; - } - - this.writeByte(bits); - - if (bits & Protocol.cm.CM_FORWARD) { - this.writeShort(to.forwardmove); - } - - if (bits & Protocol.cm.CM_SIDE) { - this.writeShort(to.sidemove); - } - - if (bits & Protocol.cm.CM_UP) { - this.writeShort(to.upmove); - } - - if (bits & Protocol.cm.CM_ANGLE1) { - this.writeAngle(to.angles[0]); - } - - if (bits & Protocol.cm.CM_ANGLE2) { - this.writeAngle(to.angles[1]); - } - - if (bits & Protocol.cm.CM_ANGLE3) { - this.writeAngle(to.angles[2]); - } - - if (bits & Protocol.cm.CM_BUTTONS) { - this.writeByte(to.buttons); - } - - if (bits & Protocol.cm.CM_IMPULSE) { - this.writeByte(to.impulse); - } - - this.writeByte(to.msec); - } - - /** - * Read a delta usercmd from the message buffer. - * @param {Protocol.UserCmd} from previous usercmd - * @returns {Protocol.UserCmd} current usercmd - */ - readDeltaUsercmd(from) { - const to = new Protocol.UserCmd(); - - to.set(from); - - const bits = this.readByte(); - - if (bits & Protocol.cm.CM_FORWARD) { - to.forwardmove = this.readShort(); - } - - if (bits & Protocol.cm.CM_SIDE) { - to.sidemove = this.readShort(); - } - - if (bits & Protocol.cm.CM_UP) { - to.upmove = this.readShort(); - } - - if (bits & Protocol.cm.CM_ANGLE1) { - to.angles[0] = this.readAngle(); - } - - if (bits & Protocol.cm.CM_ANGLE2) { - to.angles[1] = this.readAngle(); - } - - if (bits & Protocol.cm.CM_ANGLE3) { - to.angles[2] = this.readAngle(); - } - - if (bits & Protocol.cm.CM_BUTTONS) { - to.buttons = this.readByte(); - } - - if (bits & Protocol.cm.CM_IMPULSE) { - to.impulse = this.readByte(); - } - - to.msec = this.readByte(); - - return to; - } - - /** - * Write an array of serializable values to the buffer. - * @param {Array} serializables array of values to serialize - */ - writeSerializables(serializables) { - for (const serializable of serializables) { - switch (true) { - case serializable === undefined: - console.assert(false, 'serializable must not be undefined'); - this.writeByte(Protocol.serializableTypes.null); - continue; - case serializable === null: - this.writeByte(Protocol.serializableTypes.null); - continue; - case typeof serializable === 'string': - this.writeByte(Protocol.serializableTypes.string); - this.writeString(serializable); - continue; - case typeof serializable === 'number': - if (Number.isInteger(serializable)) { - if (serializable >= 0 && serializable < 256) { - this.writeByte(Protocol.serializableTypes.byte); - this.writeByte(serializable); - } else if (serializable >= -32768 && serializable < 32768) { - this.writeByte(Protocol.serializableTypes.short); - this.writeShort(serializable); - } else { - this.writeByte(Protocol.serializableTypes.long); - this.writeLong(serializable); - } - } else { - this.writeByte(Protocol.serializableTypes.float); - this.writeFloat(serializable); - } - continue; - case typeof serializable === 'boolean': - this.writeByte(serializable ? Protocol.serializableTypes.true : Protocol.serializableTypes.false); - continue; - case serializable instanceof Vector: - this.writeByte(Protocol.serializableTypes.vector); - this.writeCoordVector(serializable); - continue; - case serializable instanceof Array: - this.writeByte(Protocol.serializableTypes.array); - this.writeSerializables(serializable); - continue; - } - - const handler = serializableHandlers.find((h) => serializable instanceof h.constructor); - - if (handler) { - this.writeByte(handler.id); - handler.serialize(this, serializable); - continue; - } - - throw new TypeError(`Unsupported argument type: ${typeof serializable}`); - } - - // end of event data - this.writeByte(Protocol.serializableTypes.none); - } - - /** - * Read an array of serializable values from the buffer (client-side). - * @returns {Array} array of deserialized values - */ - readSerializablesOnClient() { - const serializables = []; - - while (true) { - const type = this.readByte(); - if (type === Protocol.serializableTypes.none) { - break; // end of stream of serializables - } - - switch (type) { - case Protocol.serializableTypes.string: - serializables.push(this.readString()); - continue; - case Protocol.serializableTypes.long: - serializables.push(this.readLong()); - continue; - case Protocol.serializableTypes.short: - serializables.push(this.readShort()); - continue; - case Protocol.serializableTypes.byte: - serializables.push(this.readByte()); - continue; - case Protocol.serializableTypes.float: - serializables.push(this.readFloat()); - continue; - case Protocol.serializableTypes.true: - serializables.push(true); - continue; - case Protocol.serializableTypes.false: - serializables.push(false); - continue; - case Protocol.serializableTypes.null: - serializables.push(null); - continue; - case Protocol.serializableTypes.vector: - serializables.push(this.readCoordVector()); - continue; - case Protocol.serializableTypes.array: - serializables.push(this.readSerializablesOnClient()); - continue; - } - - const handler = serializableHandlers.find((h) => h.id === type); - - if (handler) { - serializables.push(handler.deserializeOnClient(this)); - continue; - } - - throw new TypeError(`Unsupported serializable type: ${type}`); - } - - return serializables; - } -}; diff --git a/source/engine/network/MSG.ts b/source/engine/network/MSG.ts new file mode 100644 index 00000000..75ccd626 --- /dev/null +++ b/source/engine/network/MSG.ts @@ -0,0 +1,613 @@ +import type { SerializableType } from '../../shared/GameInterfaces.ts'; +import Q from '../../shared/Q.ts'; +import Vector from '../../shared/Vector.ts'; +import * as Protocol from '../network/Protocol.ts'; +import { eventBus, getCommonRegistry } from '../registry.mjs'; + +type SerializableVectorLike = ArrayLike; +type SerializableValue = SerializableType | object; +type SerializableConstructor = abstract new (...args: never[]) => T; + +type SerializableHandlers = { + serialize: (sz: SzBuffer, object: T) => void; + deserializeOnServer: (sz: SzBuffer) => ServerValue; + deserializeOnClient: (sz: SzBuffer) => ClientValue; +}; + +type RegisteredSerializableHandler = { + id: number; + constructor: SerializableConstructor; + serialize: (sz: SzBuffer, object: object) => void; + deserializeOnServer: (sz: SzBuffer) => unknown; + deserializeOnClient: (sz: SzBuffer) => unknown; +}; + +let { Con } = getCommonRegistry(); + +eventBus.subscribe('registry.frozen', () => { + ({ Con } = getCommonRegistry()); +}); + +const serializableHandlers: RegisteredSerializableHandler[] = []; + +/** + * Registers a custom serializable type for network transmission. + * @param constructor constructor used to match values during serialization + * @param root0 serialization handlers + * @param root0.serialize writes the object into the size buffer + * @param root0.deserializeOnServer reads the object on the server side + * @param root0.deserializeOnClient reads the object on the client side + */ +export function registerSerializableType( + constructor: SerializableConstructor, + { serialize, deserializeOnServer, deserializeOnClient }: SerializableHandlers, +): void { + serializableHandlers.push({ + constructor, + serialize: serialize as (sz: SzBuffer, object: object) => void, + deserializeOnServer, + deserializeOnClient, + id: Object.keys(Protocol.serializableTypes).length + serializableHandlers.length, + }); +} + +export class SzBuffer { + readcount = 0; + badread = false; + name: string; + data: ArrayBuffer; + cursize: number; + allowoverflow: boolean; + overflowed: boolean; + + constructor(size: number, name = 'anonymous') { + this.name = name; + this.data = new ArrayBuffer(size); + this.cursize = 0; + this.allowoverflow = false; + this.overflowed = false; + } + + get maxsize(): number { + return this.data.byteLength; + } + + clear(): void { + this.cursize = 0; + this.overflowed = false; + } + + copy(): SzBuffer { + const copy = new SzBuffer(this.maxsize, this.name); + + copy.cursize = this.cursize; + copy.overflowed = this.overflowed; + + const source = new Uint8Array(this.data); + const destination = new Uint8Array(copy.data); + + destination.set(source); + + return copy; + } + + set(other: SzBuffer): SzBuffer { + this.name = other.name; + this.data = new ArrayBuffer(other.maxsize); + new Uint8Array(this.data).set(new Uint8Array(other.data)); + this.cursize = other.cursize; + this.allowoverflow = other.allowoverflow; + this.overflowed = other.overflowed; + + return this; + } + + allocate(size: number): number { + if (this.cursize + size > this.maxsize) { + if (this.allowoverflow !== true) { + throw new RangeError('SzBuffer.allocate: overflow without allowoverflow set'); + } + + if (size > this.maxsize) { + throw new RangeError(`SzBuffer.allocate: ${size} is > full buffer size`); + } + + this.overflowed = true; + this.cursize = 0; + + Con.Print('SzBuffer.allocate: overflow\n'); + // eslint-disable-next-line no-debugger + debugger; + } + + const cursorSize = this.cursize; + + this.cursize += size; + + return cursorSize; + } + + write(data: Uint8Array, length: number): void { + const view = new Uint8Array(this.data, this.allocate(length), length); + + view.set(data.subarray(0, length)); + } + + print(data: string): void { + const buffer = new Uint8Array(this.data); + let destination: number; + + if (this.cursize !== 0) { + if (buffer[this.cursize - 1] === 0) { + destination = this.allocate(data.length - 1) - 1; + } else { + destination = this.allocate(data.length); + } + } else { + destination = this.allocate(data.length); + } + + for (let i = 0; i < data.length; i++) { + buffer[destination + i] = data.charCodeAt(i); + } + } + + toHexString(): string { + let output = ''; + const bytes = new Uint8Array(this.data, 0, this.cursize); + const lineBytes = 16; + + for (let i = 0; i < bytes.length; i += lineBytes) { + let line = `00000000${i.toString(16)}`.slice(-8) + ': '; + let hexPart = ''; + let asciiPart = ''; + + for (let j = 0; j < lineBytes; j++) { + if (i + j < bytes.length) { + const byte = bytes[i + j]; + + hexPart += `0${byte.toString(16)}`.slice(-2) + ' '; + asciiPart += byte >= 32 && byte <= 126 ? String.fromCharCode(byte) : '.'; + } else { + hexPart += ' '; + asciiPart += ' '; + } + } + + line += `${hexPart} ${asciiPart}`; + output += `${line}\n`; + } + + return output; + } + + toString(): string { + return `SzBuffer: (${this.name}) ${this.cursize} bytes of ${this.maxsize} bytes used, overflowed? ${this.overflowed ? 'yes' : 'no'}`; + } + + writeChar(value: number): void { + console.assert(value >= -128 && value <= 127, 'must be signed byte', value); + new DataView(this.data).setInt8(this.allocate(1), value); + } + + writeByte(value: number): void { + console.assert(value >= 0 && value <= 255, 'must be unsigned byte', value); + new DataView(this.data).setUint8(this.allocate(1), value); + } + + writeShort(value: number): void { + console.assert(value >= -32768 && value <= 32767, 'must be signed short', value); + new DataView(this.data).setInt16(this.allocate(2), value, true); + } + + writeUint16(value: number): void { + console.assert(value >= 0 && value <= 65535, 'must be unsigned short', value); + new DataView(this.data).setUint16(this.allocate(2), value, true); + } + + writeLong(value: number): void { + console.assert(value >= -2147483648 && value <= 2147483647, 'must be signed long', value); + new DataView(this.data).setInt32(this.allocate(4), value, true); + } + + writeFloat(value: number): void { + console.assert(typeof value === 'number' && !Q.isNaN(value) && Number.isFinite(value), 'must be a real number, not NaN or Infinity'); + new DataView(this.data).setFloat32(this.allocate(4), value, true); + } + + writeString(value: string): void { + if (value) { + this.write(new Uint8Array(Q.strmem(value)), value.length); + } + + this.writeChar(0); + } + + writeCoord(value: number): void { + this.writeLong(value * 8.0); + } + + writeCoordVector(vector: SerializableVectorLike): void { + this.writeCoord(vector[0]); + this.writeCoord(vector[1]); + this.writeCoord(vector[2]); + } + + writeAngle(value: number): void { + this.writeShort(Math.round((value / 360.0 * 32768.0)) % 32768); + } + + writeAngleVector(vector: SerializableVectorLike): void { + this.writeAngle(vector[0]); + this.writeAngle(vector[1]); + this.writeAngle(vector[2]); + } + + writeRGB(color: SerializableVectorLike): void { + this.writeByte(Math.round(color[0] * 255)); + this.writeByte(Math.round(color[1] * 255)); + this.writeByte(Math.round(color[2] * 255)); + } + + writeRGBA(color: SerializableVectorLike, alpha: number): void { + this.writeRGB(color); + this.writeByte(Math.round(alpha * 255)); + } + + beginReading(): void { + this.readcount = 0; + this.badread = false; + } + + readChar(): number { + if (this.readcount >= this.cursize) { + this.badread = true; + return -1; + } + + const value = new Int8Array(this.data, this.readcount, 1)[0]; + + this.readcount++; + + return value; + } + + readByte(): number { + if (this.readcount >= this.cursize) { + this.badread = true; + return -1; + } + + const value = new Uint8Array(this.data, this.readcount, 1)[0]; + + this.readcount++; + + return value; + } + + readShort(): number { + if (this.readcount + 2 > this.cursize) { + this.badread = true; + return -1; + } + + const value = new DataView(this.data).getInt16(this.readcount, true); + + this.readcount += 2; + + return value; + } + + readUint16(): number { + if (this.readcount + 2 > this.cursize) { + this.badread = true; + return -1; + } + + const value = new DataView(this.data).getUint16(this.readcount, true); + + this.readcount += 2; + + return value; + } + + readLong(): number { + if (this.readcount + 4 > this.cursize) { + this.badread = true; + return -1; + } + + const value = new DataView(this.data).getInt32(this.readcount, true); + + this.readcount += 4; + + return value; + } + + readFloat(): number { + if (this.readcount + 4 > this.cursize) { + this.badread = true; + return -1; + } + + const value = new DataView(this.data).getFloat32(this.readcount, true); + + this.readcount += 4; + + return value; + } + + readString(): string { + const chars: string[] = []; + + for (let i = 0; i < this.cursize; i++) { + const character = this.readByte(); + + if (character <= 0) { + break; + } + + chars.push(String.fromCharCode(character)); + } + + return chars.join(''); + } + + readCoord(): number { + return this.readLong() * 0.125; + } + + readCoordVector(): Vector { + return new Vector(this.readCoord(), this.readCoord(), this.readCoord()); + } + + readAngle(): number { + return this.readShort() * (360.0 / 32768.0); + } + + readAngleVector(): Vector { + return new Vector(this.readAngle(), this.readAngle(), this.readAngle()); + } + + readRGB(): Vector { + return new Vector( + this.readByte() / 255, + this.readByte() / 255, + this.readByte() / 255, + ); + } + + readRGBA(): [Vector, number] { + return [this.readRGB(), this.readByte() / 255]; + } + + writeDeltaUsercmd(from: Protocol.UserCmd, to: Protocol.UserCmd): void { + let bits = 0; + + if (to.forwardmove !== from.forwardmove) { + bits |= Protocol.cm.CM_FORWARD; + } + + if (to.sidemove !== from.sidemove) { + bits |= Protocol.cm.CM_SIDE; + } + + if (to.upmove !== from.upmove) { + bits |= Protocol.cm.CM_UP; + } + + if (to.angles[0] !== from.angles[0]) { + bits |= Protocol.cm.CM_ANGLE1; + } + + if (to.angles[1] !== from.angles[1]) { + bits |= Protocol.cm.CM_ANGLE2; + } + + if (to.angles[2] !== from.angles[2]) { + bits |= Protocol.cm.CM_ANGLE3; + } + + if (to.buttons !== from.buttons) { + bits |= Protocol.cm.CM_BUTTONS; + } + + if (to.impulse !== from.impulse) { + bits |= Protocol.cm.CM_IMPULSE; + } + + this.writeByte(bits); + + if (bits & Protocol.cm.CM_FORWARD) { + this.writeShort(to.forwardmove); + } + + if (bits & Protocol.cm.CM_SIDE) { + this.writeShort(to.sidemove); + } + + if (bits & Protocol.cm.CM_UP) { + this.writeShort(to.upmove); + } + + if (bits & Protocol.cm.CM_ANGLE1) { + this.writeAngle(to.angles[0]); + } + + if (bits & Protocol.cm.CM_ANGLE2) { + this.writeAngle(to.angles[1]); + } + + if (bits & Protocol.cm.CM_ANGLE3) { + this.writeAngle(to.angles[2]); + } + + if (bits & Protocol.cm.CM_BUTTONS) { + this.writeByte(to.buttons); + } + + if (bits & Protocol.cm.CM_IMPULSE) { + this.writeByte(to.impulse); + } + + this.writeByte(to.msec); + } + + readDeltaUsercmd(from: Protocol.UserCmd): Protocol.UserCmd { + const to = new Protocol.UserCmd(); + + to.set(from); + + const bits = this.readByte(); + + if (bits & Protocol.cm.CM_FORWARD) { + to.forwardmove = this.readShort(); + } + + if (bits & Protocol.cm.CM_SIDE) { + to.sidemove = this.readShort(); + } + + if (bits & Protocol.cm.CM_UP) { + to.upmove = this.readShort(); + } + + if (bits & Protocol.cm.CM_ANGLE1) { + to.angles[0] = this.readAngle(); + } + + if (bits & Protocol.cm.CM_ANGLE2) { + to.angles[1] = this.readAngle(); + } + + if (bits & Protocol.cm.CM_ANGLE3) { + to.angles[2] = this.readAngle(); + } + + if (bits & Protocol.cm.CM_BUTTONS) { + to.buttons = this.readByte(); + } + + if (bits & Protocol.cm.CM_IMPULSE) { + to.impulse = this.readByte(); + } + + to.msec = this.readByte(); + + return to; + } + + writeSerializables(serializables: readonly SerializableValue[]): void { + for (const serializable of serializables) { + switch (true) { + case serializable === undefined: + console.assert(false, 'serializable must not be undefined'); + this.writeByte(Protocol.serializableTypes.null); + continue; + case serializable === null: + this.writeByte(Protocol.serializableTypes.null); + continue; + case typeof serializable === 'string': + this.writeByte(Protocol.serializableTypes.string); + this.writeString(serializable); + continue; + case typeof serializable === 'number': + if (Number.isInteger(serializable)) { + if (serializable >= 0 && serializable < 256) { + this.writeByte(Protocol.serializableTypes.byte); + this.writeByte(serializable); + } else if (serializable >= -32768 && serializable < 32768) { + this.writeByte(Protocol.serializableTypes.short); + this.writeShort(serializable); + } else { + this.writeByte(Protocol.serializableTypes.long); + this.writeLong(serializable); + } + } else { + this.writeByte(Protocol.serializableTypes.float); + this.writeFloat(serializable); + } + continue; + case typeof serializable === 'boolean': + this.writeByte(serializable ? Protocol.serializableTypes.true : Protocol.serializableTypes.false); + continue; + case serializable instanceof Vector: + this.writeByte(Protocol.serializableTypes.vector); + this.writeCoordVector(serializable); + continue; + case serializable instanceof Array: + this.writeByte(Protocol.serializableTypes.array); + this.writeSerializables(serializable); + continue; + } + + const handler = serializableHandlers.find((candidate) => serializable instanceof candidate.constructor); + + if (handler) { + this.writeByte(handler.id); + handler.serialize(this, serializable); + continue; + } + + throw new TypeError(`Unsupported argument type: ${typeof serializable}`); + } + + this.writeByte(Protocol.serializableTypes.none); + } + + readSerializablesOnClient(): SerializableValue[] { + const serializables: SerializableValue[] = []; + + while (true) { + const type = this.readByte(); + + if (type === Protocol.serializableTypes.none) { + break; + } + + switch (type) { + case Protocol.serializableTypes.string: + serializables.push(this.readString()); + continue; + case Protocol.serializableTypes.long: + serializables.push(this.readLong()); + continue; + case Protocol.serializableTypes.short: + serializables.push(this.readShort()); + continue; + case Protocol.serializableTypes.byte: + serializables.push(this.readByte()); + continue; + case Protocol.serializableTypes.float: + serializables.push(this.readFloat()); + continue; + case Protocol.serializableTypes.true: + serializables.push(true); + continue; + case Protocol.serializableTypes.false: + serializables.push(false); + continue; + case Protocol.serializableTypes.null: + serializables.push(null); + continue; + case Protocol.serializableTypes.vector: + serializables.push(this.readCoordVector()); + continue; + case Protocol.serializableTypes.array: + serializables.push(this.readSerializablesOnClient()); + continue; + } + + const handler = serializableHandlers.find((candidate) => candidate.id === type); + + if (handler) { + serializables.push(handler.deserializeOnClient(this) as SerializableValue); + continue; + } + + throw new TypeError(`Unsupported serializable type: ${type}`); + } + + return serializables; + } +} diff --git a/source/engine/network/Network.mjs b/source/engine/network/Network.mjs index e56c3582..0ddab36b 100644 --- a/source/engine/network/Network.mjs +++ b/source/engine/network/Network.mjs @@ -2,7 +2,7 @@ import Cmd from '../common/Cmd.mjs'; import Cvar from '../common/Cvar.mjs'; import Q from '../../shared/Q.ts'; import { eventBus, registry } from '../registry.mjs'; -import { SzBuffer } from './MSG.mjs'; +import { SzBuffer } from './MSG.ts'; import { BaseDriver, LoopDriver, QSocket, WebRTCDriver, WebSocketDriver } from './NetworkDrivers.mjs'; import { DriverRegistry } from './DriverRegistry.mjs'; import { InviteCommand } from './ConsoleCommands.mjs'; diff --git a/source/engine/registry.mjs b/source/engine/registry.mjs index 00027310..47b1723f 100644 --- a/source/engine/registry.mjs +++ b/source/engine/registry.mjs @@ -49,6 +49,38 @@ * @property {boolean} isDedicatedServer true when running in server mode * @property {boolean} isInsideWorker true when running inside a worker */ +/** + * Registry members guaranteed after both browser and dedicated launch. + * @typedef {Registry & { + * COM: ComModule, + * Con: ConModule, + * Host: HostModule, + * NET: NetModule, + * Sys: SysModule, + * V: VModule, + * SV: ServerModule, + * Mod: ModModule, + * PR: ProgsModule, + * WebSocket: WebSocketClass, + * }} CommonRegistry + */ +/** + * Registry members guaranteed only after browser launch. + * @typedef {CommonRegistry & { + * CL: ClientModule, + * Draw: DrawModule, + * Key: KeyModule, + * IN: InputModule, + * M: MenuModule, + * R: RendererModule, + * S: SoundModule, + * Sbar: SbarModule, + * SCR: ScrModule, + * urls: URLs, + * buildConfig: BuildConfig, + * isDedicatedServer: false, + * }} ClientRegistry + */ /** @type {Registry} */ export const registry = { COM: undefined, @@ -83,6 +115,24 @@ export const registry = { // make sure the registry is not extensible beyond the defined properties Object.seal(registry); +/** + * Returns the registry members guaranteed after both browser and dedicated launch. + * Use this from code that runs in either runtime. + * @returns {CommonRegistry} initialized common registry view + */ +export function getCommonRegistry() { + return /** @type {CommonRegistry} */ (registry); +} + +/** + * Returns the registry members guaranteed only after browser launch. + * Use this only from browser or client-only code paths. + * @returns {ClientRegistry} initialized client registry view + */ +export function getClientRegistry() { + return /** @type {ClientRegistry} */ (registry); +} + export class EventBus { /** @type {Map>} */ #listeners = new Map(); diff --git a/source/engine/server/Client.mjs b/source/engine/server/Client.mjs index 4dd3c39d..289a064e 100644 --- a/source/engine/server/Client.mjs +++ b/source/engine/server/Client.mjs @@ -1,7 +1,7 @@ import { enumHelpers } from '../../shared/Q.ts'; import { gameCapabilities } from '../../shared/Defs.ts'; import Vector from '../../shared/Vector.ts'; -import { SzBuffer } from '../network/MSG.mjs'; +import { SzBuffer } from '../network/MSG.ts'; import { QSocket } from '../network/NetworkDrivers.mjs'; import * as Protocol from '../network/Protocol.ts'; import { eventBus, registry } from '../registry.mjs'; diff --git a/source/engine/server/Edict.mjs b/source/engine/server/Edict.mjs index c903c0cb..9a63e050 100644 --- a/source/engine/server/Edict.mjs +++ b/source/engine/server/Edict.mjs @@ -1,5 +1,5 @@ import Vector from '../../shared/Vector.ts'; -import { SzBuffer, registerSerializableType } from '../network/MSG.mjs'; +import { SzBuffer, registerSerializableType } from '../network/MSG.ts'; import * as Protocol from '../network/Protocol.ts'; import * as Def from '../common/Def.mjs'; import * as Defs from '../../shared/Defs.ts'; diff --git a/source/engine/server/Server.mjs b/source/engine/server/Server.mjs index ee1d6295..9c2080da 100644 --- a/source/engine/server/Server.mjs +++ b/source/engine/server/Server.mjs @@ -1,7 +1,7 @@ import Cvar from '../common/Cvar.mjs'; import { MoveVars, Pmove } from '../common/Pmove.mjs'; import Vector from '../../shared/Vector.ts'; -import { SzBuffer } from '../network/MSG.mjs'; +import { SzBuffer } from '../network/MSG.ts'; import * as Protocol from '../network/Protocol.ts'; import * as Def from './../common/Def.mjs'; import Cmd, { ConsoleCommand } from '../common/Cmd.mjs'; diff --git a/source/engine/server/ServerMessages.mjs b/source/engine/server/ServerMessages.mjs index 5a9d77d9..026a77d9 100644 --- a/source/engine/server/ServerMessages.mjs +++ b/source/engine/server/ServerMessages.mjs @@ -1,4 +1,4 @@ -import { SzBuffer } from '../network/MSG.mjs'; +import { SzBuffer } from '../network/MSG.ts'; import * as Protocol from '../network/Protocol.ts'; import * as Defs from '../../shared/Defs.ts'; import Cvar from '../common/Cvar.mjs'; diff --git a/test/common/msg.test.mjs b/test/common/msg.test.mjs new file mode 100644 index 00000000..39508662 --- /dev/null +++ b/test/common/msg.test.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import Vector from '../../source/shared/Vector.ts'; +import { SzBuffer } from '../../source/engine/network/MSG.ts'; +import { UserCmd, button } from '../../source/engine/network/Protocol.ts'; + +void describe('SzBuffer', () => { + void test('round-trips delta user commands', () => { + const from = new UserCmd(); + const to = new UserCmd(); + + from.msec = 8; + from.forwardmove = 100; + from.angles.set([5, 10, 15]); + + to.set(from); + to.msec = 16; + to.forwardmove = 200; + to.sidemove = -40; + to.upmove = 12; + to.angles.set([45, 90, 135]); + to.buttons = button.attack; + to.impulse = 7; + + const buffer = new SzBuffer(128, 'delta-usercmd'); + + buffer.writeDeltaUsercmd(from, to); + buffer.beginReading(); + + const decoded = buffer.readDeltaUsercmd(from); + + assert.equal(decoded.equals(to), true); + }); + + void test('round-trips built-in serializable values', () => { + const buffer = new SzBuffer(256, 'serializables'); + const values = [ + 'quake', + 255, + -12, + 12.5, + true, + false, + null, + new Vector(1, 2, 3), + [1, 'two', null], + ]; + + buffer.writeSerializables(values); + buffer.beginReading(); + + const decoded = buffer.readSerializablesOnClient(); + + assert.equal(decoded[0], 'quake'); + assert.equal(decoded[1], 255); + assert.equal(decoded[2], -12); + assert.equal(decoded[3], 12.5); + assert.equal(decoded[4], true); + assert.equal(decoded[5], false); + assert.equal(decoded[6], null); + assert.ok(decoded[7] instanceof Vector); + assert.deepEqual(Array.from(decoded[7]), [1, 2, 3]); + assert.deepEqual(decoded[8], [1, 'two', null]); + }); +}); diff --git a/test/common/registry.test.mjs b/test/common/registry.test.mjs new file mode 100644 index 00000000..0fab9a9a --- /dev/null +++ b/test/common/registry.test.mjs @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { getClientRegistry, getCommonRegistry, registry } from '../../source/engine/registry.mjs'; + +void describe('registry views', () => { + void test('return the shared registry singleton', () => { + assert.equal(getCommonRegistry(), registry); + assert.equal(getClientRegistry(), registry); + }); +}); From b9fdb332cc8d2421c62a21e1ef7ed1574d8ae41b Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 15:26:34 +0300 Subject: [PATCH 12/67] TS: network/ --- .github/copilot-instructions.md | 4 + .../code-style-guide.instructions.md | 1 + eslint.config.mjs | 2 + source/engine/client/ClientConnection.mjs | 2 +- source/engine/client/ClientState.mjs | 2 +- source/engine/main-browser.mjs | 2 +- source/engine/main-dedicated.mjs | 2 +- ...ConsoleCommands.mjs => ConsoleCommands.ts} | 19 +- source/engine/network/DriverRegistry.mjs | 78 - source/engine/network/DriverRegistry.ts | 67 + source/engine/network/Misc.mjs | 8 - source/engine/network/Misc.ts | 9 + source/engine/network/Network.mjs | 316 --- source/engine/network/Network.ts | 303 +++ source/engine/network/NetworkDrivers.mjs | 1972 ---------------- source/engine/network/NetworkDrivers.ts | 2047 +++++++++++++++++ source/engine/registry.mjs | 2 +- source/engine/server/Client.mjs | 2 +- test/common/console-commands.test.mjs | 145 ++ test/common/driver-registry.test.mjs | 96 + test/common/network-drivers.test.mjs | 120 + test/common/network-misc.test.mjs | 14 + test/common/network.test.mjs | 93 + 23 files changed, 2917 insertions(+), 2389 deletions(-) rename source/engine/network/{ConsoleCommands.mjs => ConsoleCommands.ts} (72%) delete mode 100644 source/engine/network/DriverRegistry.mjs create mode 100644 source/engine/network/DriverRegistry.ts delete mode 100644 source/engine/network/Misc.mjs create mode 100644 source/engine/network/Misc.ts delete mode 100644 source/engine/network/Network.mjs create mode 100644 source/engine/network/Network.ts delete mode 100644 source/engine/network/NetworkDrivers.mjs create mode 100644 source/engine/network/NetworkDrivers.ts create mode 100644 test/common/console-commands.test.mjs create mode 100644 test/common/driver-registry.test.mjs create mode 100644 test/common/network-drivers.test.mjs create mode 100644 test/common/network-misc.test.mjs create mode 100644 test/common/network.test.mjs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b95c08a7..842322bc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -63,6 +63,10 @@ eventBus.subscribe("registry.frozen", () => { - **No `any`, `unknown`, or `*`**. Use specific types (e.g. `ArrayBuffer`). - **No `@returns {void}`**. +### Helper Functions +- **Prefer function declarations** for helper functions when lexical `this`, expression semantics, or very local inline usage are not needed. +- **Use `const foo = (...) => ...`** only when arrow-function behavior is actually relevant. + ## Specific Patterns to Observe - **Module System**: ES Modules exclusively (`type: "module"` in package.json). diff --git a/.github/instructions/code-style-guide.instructions.md b/.github/instructions/code-style-guide.instructions.md index f49f1642..efe7d945 100644 --- a/.github/instructions/code-style-guide.instructions.md +++ b/.github/instructions/code-style-guide.instructions.md @@ -64,6 +64,7 @@ Use `eventBus` for **business logic events and lifecycle hooks**. - **Use camelCase** for variables and functions, PascalCase for classes. - **Use descriptive names** for variables and functions. - **Keep functions small** and focused on a single task or a single responsibility. +- **Prefer function declarations** for helper functions when arrow-function semantics are not needed. - **Use early returns** to reduce nesting and improve readability. - **Avoid deep nesting**; refactor into helper functions if necessary. - **Never mutate function parameters**; create new variables instead. diff --git a/eslint.config.mjs b/eslint.config.mjs index 968d9920..25c3e939 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -117,6 +117,8 @@ export default defineConfig([ }, rules: { ...commonRules, + 'jsdoc/check-param-names': 'off', + 'jsdoc/require-param': 'off', 'jsdoc/require-param-type': 'off', 'jsdoc/require-property-type': 'off', 'jsdoc/require-returns-type': 'off', diff --git a/source/engine/client/ClientConnection.mjs b/source/engine/client/ClientConnection.mjs index 17b63f5d..d86869fc 100644 --- a/source/engine/client/ClientConnection.mjs +++ b/source/engine/client/ClientConnection.mjs @@ -6,7 +6,7 @@ import ClientInput from './ClientInput.mjs'; import { clientRuntimeState, clientStaticState } from './ClientState.mjs'; import { eventBus, registry } from '../registry.mjs'; import * as Def from '../common/Def.mjs'; -import { QSocket } from '../network/NetworkDrivers.mjs'; +import { QSocket } from '../network/NetworkDrivers.ts'; import { parseServerMessage as parseServerCommandMessage } from './ClientServerCommandHandlers.mjs'; let { Con, Host, IN, Mod, NET, SCR, S, SV } = registry; diff --git a/source/engine/client/ClientState.mjs b/source/engine/client/ClientState.mjs index 8644e043..b50e1048 100644 --- a/source/engine/client/ClientState.mjs +++ b/source/engine/client/ClientState.mjs @@ -1,5 +1,5 @@ import { SzBuffer } from '../network/MSG.ts'; -import { QSocket } from '../network/NetworkDrivers.mjs'; +import { QSocket } from '../network/NetworkDrivers.ts'; import * as Protocol from '../network/Protocol.ts'; import * as Def from '../common/Def.mjs'; import Vector from '../../shared/Vector.ts'; diff --git a/source/engine/main-browser.mjs b/source/engine/main-browser.mjs index d765f004..c941a76b 100644 --- a/source/engine/main-browser.mjs +++ b/source/engine/main-browser.mjs @@ -5,7 +5,7 @@ import COM from './common/Com.mjs'; import Con from './common/Console.mjs'; import Host from './common/Host.mjs'; import V from './client/V.mjs'; -import NET from './network/Network.mjs'; +import NET from './network/Network.ts'; import SV from './server/Server.mjs'; import PR from './server/Progs.mjs'; import Mod from './common/Mod.mjs'; diff --git a/source/engine/main-dedicated.mjs b/source/engine/main-dedicated.mjs index f79ebd9e..fbada24e 100644 --- a/source/engine/main-dedicated.mjs +++ b/source/engine/main-dedicated.mjs @@ -13,7 +13,7 @@ import NodeCOM from './server/Com.mjs'; import Con from './common/Console.mjs'; import Host from './common/Host.mjs'; import V from './client/V.mjs'; -import NET from './network/Network.mjs'; +import NET from './network/Network.ts'; import SV from './server/Server.mjs'; import PR from './server/Progs.mjs'; import Mod from './common/Mod.mjs'; diff --git a/source/engine/network/ConsoleCommands.mjs b/source/engine/network/ConsoleCommands.ts similarity index 72% rename from source/engine/network/ConsoleCommands.mjs rename to source/engine/network/ConsoleCommands.ts index 1a7cac6b..abc95943 100644 --- a/source/engine/network/ConsoleCommands.mjs +++ b/source/engine/network/ConsoleCommands.ts @@ -1,18 +1,20 @@ import { ConsoleCommand } from '../common/Cmd.mjs'; -import { eventBus, registry } from '../registry.mjs'; +import { eventBus, getCommonRegistry } from '../registry.mjs'; -let { NET, Con } = registry; +let { Con, NET } = getCommonRegistry(); eventBus.subscribe('registry.frozen', () => { - NET = registry.NET; - Con = registry.Con; + ({ Con, NET } = getCommonRegistry()); }); +/** + * Copy a join link for the currently hosted session. + */ export class InviteCommand extends ConsoleCommand { - async run() { + async run(): Promise { const listenAddress = NET.GetListenAddress(); - if (!listenAddress) { + if (listenAddress === null) { Con.PrintWarning('Cannot create invite link, not hosting.\n'); return; } @@ -25,9 +27,8 @@ export class InviteCommand extends ConsoleCommand { try { await navigator.clipboard.writeText(shareLink.toString()); Con.Print(`This link has been copied to your clipboard:\n${shareLink.toString()}\n`); - // eslint-disable-next-line no-unused-vars - } catch (err) { + } catch { prompt('Share this link to invite players:', shareLink.toString()); } } -}; +} diff --git a/source/engine/network/DriverRegistry.mjs b/source/engine/network/DriverRegistry.mjs deleted file mode 100644 index 818525af..00000000 --- a/source/engine/network/DriverRegistry.mjs +++ /dev/null @@ -1,78 +0,0 @@ -import { BaseDriver } from './NetworkDrivers.mjs'; - -/** - * DriverRegistry - Manages network drivers and handles driver selection - * - * Clean separation of concerns: this class owns driver lifecycle and selection logic, - * removing the need for global driverlevel state mutation. - */ -export class DriverRegistry { - constructor() { - /** @type {Record} */ - this.drivers = {}; - - /** @type {BaseDriver[]} */ - this.orderedDrivers = []; - } - - /** - * Register a network driver - * @param {string} name - Unique driver name - * @param {BaseDriver} driver - Driver instance - */ - register(name, driver) { - this.drivers[name] = driver; - this.orderedDrivers.push(driver); - } - - /** - * Get a driver by name - * @param {string} name - Driver name - * @returns {BaseDriver|null} driver or null if not found - */ - get(name) { - return this.drivers[name] || null; - } - - /** - * Select appropriate driver for a given address - * @param {string} address - address (e.g., "local", "wss://...", "webrtc://...") - * @returns {BaseDriver|null} suitable driver or null if none found - */ - getClientDriver(address) { - // Fallback: try each initialized driver in order - for (const driver of this.orderedDrivers) { - if (driver.initialized && driver.canHandle(address)) { - return driver; - } - } - - return null; - } - - /** - * Get all initialized drivers - * @returns {BaseDriver[]} list of initialized drivers - */ - getInitializedDrivers() { - return this.orderedDrivers.filter((d) => d.initialized); - } - - /** - * Initialize all registered drivers - */ - initialize() { - for (const driver of this.orderedDrivers) { - driver.Init(); - } - } - - /** - * Shutdown all drivers - */ - shutdown() { - for (const driver of this.orderedDrivers) { - driver.Shutdown(); - } - } -} diff --git a/source/engine/network/DriverRegistry.ts b/source/engine/network/DriverRegistry.ts new file mode 100644 index 00000000..63b7c518 --- /dev/null +++ b/source/engine/network/DriverRegistry.ts @@ -0,0 +1,67 @@ +import type { BaseDriver } from './NetworkDrivers.ts'; + +/** + * Manage network driver registration, lifecycle, and client-side selection. + */ +export class DriverRegistry { + /** Registered drivers by name. */ + drivers: Record = {}; + /** Drivers in registration order for deterministic selection. */ + orderedDrivers: BaseDriver[] = []; + + /** + * Register a network driver. + * @param name + * @param driver + */ + register(name: string, driver: BaseDriver): void { + this.drivers[name] = driver; + this.orderedDrivers.push(driver); + } + + /** + * Get a driver by name. + * @param name + * @returns Registered driver or null when absent. + */ + get(name: string): BaseDriver | null { + return this.drivers[name] ?? null; + } + + /** + * Select the first initialized driver that can handle the address. + * @param address + * @returns Suitable driver or null when no driver can handle the address. + */ + getClientDriver(address: string): BaseDriver | null { + for (const driver of this.orderedDrivers) { + if (driver.initialized && driver.canHandle(address)) { + return driver; + } + } + + return null; + } + + /** + * Return all initialized drivers in registration order. + * @returns Initialized drivers. + */ + getInitializedDrivers(): BaseDriver[] { + return this.orderedDrivers.filter((driver) => driver.initialized); + } + + /** Initialize all registered drivers. */ + initialize(): void { + for (const driver of this.orderedDrivers) { + driver.Init(); + } + } + + /** Shutdown all registered drivers. */ + shutdown(): void { + for (const driver of this.orderedDrivers) { + driver.Shutdown(); + } + } +} diff --git a/source/engine/network/Misc.mjs b/source/engine/network/Misc.mjs deleted file mode 100644 index 32136655..00000000 --- a/source/engine/network/Misc.mjs +++ /dev/null @@ -1,8 +0,0 @@ -/** - * @param {string} ip IP address - * @param {number} port port number - * @returns {string} formatted IP address with port - */ -export function formatIP(ip, port) { - return ip.includes(':') ? `[${ip}]:${port}` : `${ip}:${port}`; -}; diff --git a/source/engine/network/Misc.ts b/source/engine/network/Misc.ts new file mode 100644 index 00000000..50d7862a --- /dev/null +++ b/source/engine/network/Misc.ts @@ -0,0 +1,9 @@ +/** + * Format a host and port into a network address string. + * @param ip + * @param port + * @returns Formatted network address. + */ +export function formatIP(ip: string, port: number): string { + return ip.includes(':') ? `[${ip}]:${port}` : `${ip}:${port}`; +} diff --git a/source/engine/network/Network.mjs b/source/engine/network/Network.mjs deleted file mode 100644 index 0ddab36b..00000000 --- a/source/engine/network/Network.mjs +++ /dev/null @@ -1,316 +0,0 @@ -import Cmd from '../common/Cmd.mjs'; -import Cvar from '../common/Cvar.mjs'; -import Q from '../../shared/Q.ts'; -import { eventBus, registry } from '../registry.mjs'; -import { SzBuffer } from './MSG.ts'; -import { BaseDriver, LoopDriver, QSocket, WebRTCDriver, WebSocketDriver } from './NetworkDrivers.mjs'; -import { DriverRegistry } from './DriverRegistry.mjs'; -import { InviteCommand } from './ConsoleCommands.mjs'; -import { clientConnectionState } from '../common/Def.mjs'; - -const NET = {}; - -export default NET; - -let { CL, Con, SV, Sys } = registry; - -eventBus.subscribe('registry.frozen', () => { - CL = registry.CL; - Con = registry.Con; - SV = registry.SV; - Sys = registry.Sys; -}); - -NET.activeSockets = /** @type {QSocket[]} */ ([]); -NET.message = new SzBuffer(16384, 'NET.message'); -NET.activeconnections = 0; -NET.listening = false; -NET.driverRegistry = /** @type {DriverRegistry} */ (null); - -/** - * @param {BaseDriver} driver responsible driver - * @returns {QSocket} new QSocket - */ -NET.NewQSocket = function(driver) { - let i; - for (i = 0; i < NET.activeSockets.length; i++) { - if (NET.activeSockets[i].state === QSocket.STATE_DISCONNECTED) { - break; - } - } - NET.activeSockets[i] = new QSocket(driver, NET.time); - return NET.activeSockets[i]; -}; - -/** - * @param {string} address server address - * @returns {QSocket|null} socket or null on failure - */ -NET.Connect = function(address) { - NET.time = Sys.FloatTime(); - - const driver = NET.driverRegistry.getClientDriver(address); - - if (!driver) { - Con.PrintWarning(`No suitable network driver found for host: ${address}\n`); - return null; - } - - const ret = driver.Connect(address); - - if (ret === 0) { - CL.cls.state = clientConnectionState.connecting; - Con.Print('trying...\n'); - NET.start_time = NET.time; - NET.reps = 0; - } - - return ret; -}; - -/** - * Checks all initialized drivers for new connections to handle - * @returns {QSocket|null} new connection socket or null if none - */ -NET.CheckNewConnections = function() { - NET.time = Sys.FloatTime(); - - // Check all initialized drivers for new connections - for (const driver of NET.driverRegistry.getInitializedDrivers()) { - const ret = driver.CheckNewConnections(); - if (ret !== null) { - return ret; - } - } - - return null; -}; - -/** - * @param {QSocket} sock connection handle - */ -NET.Close = function(sock) { - if (!sock) { - return; - } - if (sock.state === QSocket.STATE_DISCONNECTED) { - return; - } - NET.time = Sys.FloatTime(); - sock.Close(); -}; - -/** - * @param {QSocket} sock connection handle - * @returns {number} channel number or -1 on failure - */ -NET.GetMessage = function(sock) { - if (sock === null) { - return -1; - } - if (sock.state === QSocket.STATE_DISCONNECTED) { - Con.DPrint('NET.GetMessage: disconnected socket\n'); - return -1; - } - NET.time = Sys.FloatTime(); - const ret = sock.GetMessage(); - if (sock.driver instanceof LoopDriver) { // FIXME: hardcoded check for loopback driver - if (ret === 0) { - if ((NET.time - sock.lastMessageTime) > NET.messagetimeout.value) { - Con.DPrint(`NET.GetMessage: message timeout for ${sock.address}\n`); - NET.Close(sock); - return -1; - } - } else if (ret > 0) { - sock.lastMessageTime = NET.time; - } - } - return ret; -}; - -NET.SendMessage = function(sock, data) { - if (!sock) { - return -1; - } - if (sock.state === QSocket.STATE_DISCONNECTED) { - Con.DPrint('NET.SendMessage: disconnected socket\n'); - return -1; - } - NET.time = Sys.FloatTime(); - sock.lastMessageTime = NET.time; - return sock.SendMessage(data); -}; - -/** - * @param {QSocket} sock socket - * @param {SzBuffer} data message - * @returns {number} -1 on failure, 1 on success - */ -NET.SendUnreliableMessage = function(sock, data) { - if (sock === null) { - return -1; - } - if (sock.state === QSocket.STATE_DISCONNECTED) { - Con.DPrint('NET.SendUnreliableMessage: disconnected socket\n'); - return -1; - } - // console.debug(`NET.SendUnreliableMessage: ${sock.address} ${data.cursize}`, data.toHexString()); - NET.time = Sys.FloatTime(); - sock.lastMessageTime = NET.time; - return sock.SendUnreliableMessage(data); -}; - -/** - * Check if a socket can send messages - * @param {QSocket} sock - The socket to check - * @returns {boolean} true if the socket can send messages, false otherwise - */ -NET.CanSendMessage = function(sock) { - if (!sock) { - return false; - } - if (sock.state === QSocket.STATE_DISCONNECTED) { - return false; - } - NET.time = Sys.FloatTime(); - return sock.CanSendMessage(); -}; - -NET.Init = function() { - NET.time = Sys.FloatTime(); - - NET.messagetimeout = new Cvar('net_messagetimeout', '60'); - NET.hostname = new Cvar('hostname', 'UNNAMED', Cvar.FLAG.SERVER, 'Descriptive name of the server.'); - - NET.delay_send = new Cvar('net_delay_send', '0', Cvar.FLAG.NONE, 'Delay sending messages to the network. Useful for debugging.'); - NET.delay_send_jitter = new Cvar('net_delay_send_jitter', '0', Cvar.FLAG.NONE, 'Jitter for the delay sending messages to the network. Useful for debugging.'); - - NET.delay_receive = new Cvar('net_delay_receive', '0', Cvar.FLAG.NONE, 'Delay receiving messages from the network. Useful for debugging.'); - NET.delay_receive_jitter = new Cvar('net_delay_receive_jitter', '0', Cvar.FLAG.NONE, 'Jitter for the delay receiving messages from the network. Useful for debugging.'); - - Cmd.AddCommand('maxplayers', NET.MaxPlayers_f); - Cmd.AddCommand('listen', NET.Listen_f); - - if (!registry.isDedicatedServer) { - Cmd.AddCommand('invite', InviteCommand); - } - - if (!registry.isDedicatedServer) { // TODO: move this to the client code path, nothing to do with networking - const Key = registry.Key; // client code path - - eventBus.subscribe('server.spawned', async () => { - await Q.sleep(5000); - - if (!NET.listening) { - return; - } - - Con.PrintSuccess('Online multiplayer game has been created!\n'); - }); - - eventBus.subscribe('client.signon', async (signon) => { - if (signon !== 4) { - return; - } - - await Q.sleep(5000); - - if (!NET.listening) { - return; - } - - const key = Key.BindingToString('invite'); - - if (key) { - Con.Print(`Press "${key}" to invite others.\n`); - } else { - Con.Print('Use "invite" command to print the invite message.\n'); - } - }); - } - - NET.driverRegistry = new DriverRegistry(); - NET.driverRegistry.register('loopback', new LoopDriver()); - NET.driverRegistry.register('websocket', new WebSocketDriver()); - NET.driverRegistry.register('webrtc', new WebRTCDriver()); - NET.driverRegistry.initialize(); -}; - -NET.Shutdown = function() { - NET.time = Sys.FloatTime(); - - for (let i = 0; i < NET.activeSockets.length; i++) { - NET.Close(NET.activeSockets[i]); - } - - NET.driverRegistry.shutdown(); -}; - -NET.Listen_f = function(isListening) { // TODO: turn into Cvar with hooks - if (isListening === undefined) { - Con.Print('"listen" is "' + (NET.listening ? 1 : 0) + '"\n'); - return; - } - - NET.listening = +isListening ? true : false; - - for (const driver of NET.driverRegistry.getInitializedDrivers()) { - if (driver.ShouldListen()) { - driver.Listen(NET.listening); - } - } -}; - -/** - * @returns {string|null} listen address or null if not listening - */ -NET.GetListenAddress = function() { - // Try to get listen address from any driver that's listening - for (const driver of NET.driverRegistry.getInitializedDrivers()) { - const addr = driver.GetListenAddress(); - if (addr) { - return addr; - } - } - - return null; -}; - -NET.MaxPlayers_f = function(maxplayers) { // TODO: turn into Cvar with hooks - if (maxplayers === undefined) { - Con.Print('"maxplayers" is "' + SV.svs.maxclients + '"\n'); - return; - } - - if (SV.server.active) { - Con.Print('maxplayers can not be changed while a server is running.\n'); - return; - } - - let n = Q.atoi(maxplayers); - if (n < 1) { - n = 1; - } - if (n > SV.svs.maxclientslimit) { - n = SV.svs.maxclientslimit; - Con.Print('"maxplayers" set to "' + n + '"\n'); - } - - SV.svs.maxclients = n; -}; - -eventBus.subscribe('server.spawned', () => { - if (SV.svs.maxclients === 1 && NET.listening) { - Cmd.ExecuteString('listen 0'); - } - - if (SV.svs.maxclients > 1 && !NET.listening) { - Cmd.ExecuteString('listen 1'); - } -}); - -eventBus.subscribe('server.shutdown', () => { - if (NET.listening) { - Cmd.ExecuteString('listen 0'); - } -}); diff --git a/source/engine/network/Network.ts b/source/engine/network/Network.ts new file mode 100644 index 00000000..765f3e32 --- /dev/null +++ b/source/engine/network/Network.ts @@ -0,0 +1,303 @@ +import type { Server as HttpServer } from 'node:http'; + +import Cmd from '../common/Cmd.mjs'; +import Cvar from '../common/Cvar.mjs'; +import { clientConnectionState } from '../common/Def.mjs'; +import Q from '../../shared/Q.ts'; +import { eventBus, getClientRegistry, getCommonRegistry, registry } from '../registry.mjs'; +import { SzBuffer } from './MSG.ts'; +import { InviteCommand } from './ConsoleCommands.ts'; +import { DriverRegistry } from './DriverRegistry.ts'; +import { BaseDriver, LoopDriver, QSocket, WebRTCDriver, WebSocketDriver } from './NetworkDrivers.ts'; + +type NetworkPayload = Pick; + +let { Con, SV, Sys } = getCommonRegistry(); + +eventBus.subscribe('registry.frozen', () => { + ({ Con, SV, Sys } = getCommonRegistry()); +}); + +export default class NET { + static activeSockets: QSocket[] = []; + static message = new SzBuffer(16384, 'NET.message'); + static activeconnections = 0; + static listening = false; + static driverRegistry = new DriverRegistry(); + static server: HttpServer | null = null; + static time = 0; + static start_time = 0; + static reps = 0; + static messagetimeout: Cvar; + static hostname: Cvar; + static delay_send: Cvar; + static delay_send_jitter: Cvar; + static delay_receive: Cvar; + static delay_receive_jitter: Cvar; + + static NewQSocket(driver: BaseDriver): QSocket { + let index = 0; + + for (; index < NET.activeSockets.length; index++) { + if (NET.activeSockets[index].state === QSocket.STATE_DISCONNECTED) { + break; + } + } + + NET.activeSockets[index] = new QSocket(driver, NET.time); + return NET.activeSockets[index]; + } + + static Connect(address: string): QSocket | null { + NET.time = Sys.FloatTime(); + + const driver = NET.driverRegistry.getClientDriver(address); + + if (driver === null) { + Con.PrintWarning(`No suitable network driver found for host: ${address}\n`); + return null; + } + + const sock = driver.Connect(address); + + if (sock !== null) { + const { CL } = getClientRegistry(); + + CL.cls.state = clientConnectionState.connecting; + Con.Print('trying...\n'); + NET.start_time = NET.time; + NET.reps = 0; + } + + return sock; + } + + static CheckNewConnections(): QSocket | null { + NET.time = Sys.FloatTime(); + + for (const driver of NET.driverRegistry.getInitializedDrivers()) { + const sock = driver.CheckNewConnections(); + + if (sock !== null) { + return sock; + } + } + + return null; + } + + static Close(sock: QSocket | null): void { + if (sock === null || sock.state === QSocket.STATE_DISCONNECTED) { + return; + } + + NET.time = Sys.FloatTime(); + sock.Close(); + } + + static GetMessage(sock: QSocket | null): number { + if (sock === null) { + return -1; + } + + if (sock.state === QSocket.STATE_DISCONNECTED) { + Con.DPrint('NET.GetMessage: disconnected socket\n'); + return -1; + } + + NET.time = Sys.FloatTime(); + const result = sock.GetMessage(); + + if (sock.driver instanceof LoopDriver) { + if (result === 0) { + if ((NET.time - sock.lastMessageTime) > NET.messagetimeout.value) { + Con.DPrint(`NET.GetMessage: message timeout for ${sock.address}\n`); + NET.Close(sock); + return -1; + } + } else if (result > 0) { + sock.lastMessageTime = NET.time; + } + } + + return result; + } + + static SendMessage(sock: QSocket | null, data: NetworkPayload): number { + if (sock === null) { + return -1; + } + + if (sock.state === QSocket.STATE_DISCONNECTED) { + Con.DPrint('NET.SendMessage: disconnected socket\n'); + return -1; + } + + NET.time = Sys.FloatTime(); + sock.lastMessageTime = NET.time; + return sock.SendMessage(data); + } + + static SendUnreliableMessage(sock: QSocket | null, data: SzBuffer): number { + if (sock === null) { + return -1; + } + + if (sock.state === QSocket.STATE_DISCONNECTED) { + Con.DPrint('NET.SendUnreliableMessage: disconnected socket\n'); + return -1; + } + + NET.time = Sys.FloatTime(); + sock.lastMessageTime = NET.time; + return sock.SendUnreliableMessage(data); + } + + static CanSendMessage(sock: QSocket | null): boolean { + if (sock === null || sock.state === QSocket.STATE_DISCONNECTED) { + return false; + } + + NET.time = Sys.FloatTime(); + return sock.CanSendMessage(); + } + + static Init(): void { + NET.time = Sys.FloatTime(); + + NET.messagetimeout = new Cvar('net_messagetimeout', '60'); + NET.hostname = new Cvar('hostname', 'UNNAMED', Cvar.FLAG.SERVER, 'Descriptive name of the server.'); + + NET.delay_send = new Cvar('net_delay_send', '0', Cvar.FLAG.NONE, 'Delay sending messages to the network. Useful for debugging.'); + NET.delay_send_jitter = new Cvar('net_delay_send_jitter', '0', Cvar.FLAG.NONE, 'Jitter for the delay sending messages to the network. Useful for debugging.'); + + NET.delay_receive = new Cvar('net_delay_receive', '0', Cvar.FLAG.NONE, 'Delay receiving messages from the network. Useful for debugging.'); + NET.delay_receive_jitter = new Cvar('net_delay_receive_jitter', '0', Cvar.FLAG.NONE, 'Jitter for the delay receiving messages from the network. Useful for debugging.'); + + Cmd.AddCommand('maxplayers', NET.MaxPlayers_f); + Cmd.AddCommand('listen', NET.Listen_f); + + if (!registry.isDedicatedServer) { + Cmd.AddCommand('invite', InviteCommand); + } + + if (!registry.isDedicatedServer) { + const { Key } = getClientRegistry(); + + eventBus.subscribe('server.spawned', async () => { + await Q.sleep(5000); + + if (!NET.listening) { + return; + } + + Con.PrintSuccess('Online multiplayer game has been created!\n'); + }); + + eventBus.subscribe('client.signon', async (signon: number) => { + if (signon !== 4) { + return; + } + + await Q.sleep(5000); + + if (!NET.listening) { + return; + } + + const key = Key.BindingToString('invite'); + + if (key) { + Con.Print(`Press "${key}" to invite others.\n`); + return; + } + + Con.Print('Use "invite" command to print the invite message.\n'); + }); + } + + NET.driverRegistry = new DriverRegistry(); + NET.driverRegistry.register('loopback', new LoopDriver()); + NET.driverRegistry.register('websocket', new WebSocketDriver()); + NET.driverRegistry.register('webrtc', new WebRTCDriver()); + NET.driverRegistry.initialize(); + } + + static Shutdown(): void { + NET.time = Sys.FloatTime(); + + for (const sock of NET.activeSockets) { + NET.Close(sock); + } + + NET.driverRegistry.shutdown(); + } + + static Listen_f(isListening?: string | number): void { + if (isListening === undefined) { + Con.Print(`"listen" is "${NET.listening ? 1 : 0}"\n`); + return; + } + + NET.listening = Number(isListening) !== 0; + + for (const driver of NET.driverRegistry.getInitializedDrivers()) { + if (driver.ShouldListen()) { + driver.Listen(NET.listening); + } + } + } + + static GetListenAddress(): string | null { + for (const driver of NET.driverRegistry.getInitializedDrivers()) { + const address = driver.GetListenAddress(); + + if (address !== null) { + return address; + } + } + + return null; + } + + static MaxPlayers_f(maxplayers?: string | number): void { + if (maxplayers === undefined) { + Con.Print(`"maxplayers" is "${SV.svs.maxclients}"\n`); + return; + } + + if (SV.server.active) { + Con.Print('maxplayers can not be changed while a server is running.\n'); + return; + } + + let value = Q.atoi(String(maxplayers)); + + if (value < 1) { + value = 1; + } + + if (value > SV.svs.maxclientslimit) { + value = SV.svs.maxclientslimit; + Con.Print(`"maxplayers" set to "${value}"\n`); + } + + SV.svs.maxclients = value; + } +} + +eventBus.subscribe('server.spawned', () => { + if (SV.svs.maxclients === 1 && NET.listening) { + Cmd.ExecuteString('listen 0'); + } + + if (SV.svs.maxclients > 1 && !NET.listening) { + Cmd.ExecuteString('listen 1'); + } +}); + +eventBus.subscribe('server.shutdown', () => { + if (NET.listening) { + Cmd.ExecuteString('listen 0'); + } +}); diff --git a/source/engine/network/NetworkDrivers.mjs b/source/engine/network/NetworkDrivers.mjs deleted file mode 100644 index 9008a20f..00000000 --- a/source/engine/network/NetworkDrivers.mjs +++ /dev/null @@ -1,1972 +0,0 @@ -import Cvar from '../common/Cvar.mjs'; -import { HostError } from '../common/Errors.mjs'; -import { eventBus, registry } from '../registry.mjs'; -import { formatIP } from './Misc.mjs'; - -let { COM, Con, NET, Sys, SV } = registry; - -eventBus.subscribe('registry.frozen', () => { - COM = registry.COM; - Con = registry.Con; - NET = registry.NET; - Sys = registry.Sys; - SV = registry.SV; -}); - -export class QSocket { - static STATE_NEW = 'new'; - static STATE_CONNECTING = 'connecting'; - static STATE_CONNECTED = 'connected'; - static STATE_DISCONNECTING = 'disconnecting'; - static STATE_DISCONNECTED = 'disconnected'; - - /** - * @param {BaseDriver} driver - The driver instance (direct reference, not index) - * @param {number} time - Connection time - */ - constructor(driver, time) { - this.driver = driver; // Direct reference to driver instance - this.connecttime = time; - this.lastMessageTime = time; - this.address = null; - this.state = QSocket.STATE_NEW; - - this.receiveMessage = new Uint8Array(new ArrayBuffer(8192)); - this.receiveMessageLength = 0; - - this.sendMessage = new Uint8Array(new ArrayBuffer(8192)); - this.sendMessageLength = 0; - - /** @type {any} driver might store some data here */ - this.driverdata = null; - } - - toString() { - return `QSocket(${this.address}, ${this.state})`; - } - - GetMessage() { - return this.driver.GetMessage(this); - } - - SendMessage(data) { - return this.driver.SendMessage(this, data); - } - - SendUnreliableMessage(data) { - return this.driver.SendUnreliableMessage(this, data); - } - - CanSendMessage() { - if (this.state !== QSocket.STATE_CONNECTED) { - return false; - } - - return this.driver.CanSendMessage(this); - } - - Close() { - return this.driver.Close(this); - } -}; - -export class BaseDriver { - /** - * @param {string} name - Unique driver name (e.g., 'loop', 'websocket', 'webrtc') - */ - constructor(name) { - this.name = name; - this.initialized = false; - } - - Init() { - return false; - } - - Shutdown() { - this.initialized = false; - } - - /** - * Check if this driver can handle the given host string - * @param {string} host - Host string to check - * @returns {boolean} true if this driver can handle the host - */ - // eslint-disable-next-line no-unused-vars - canHandle(host) { - return false; - } - - // eslint-disable-next-line no-unused-vars - Connect(host) { - return null; - } - - CheckNewConnections() { - return null; - } - - CheckForResend() { - return -1; - } - - // eslint-disable-next-line no-unused-vars - GetMessage(qsocket) { - return -1; - } - - // eslint-disable-next-line no-unused-vars - SendMessage(qsocket, data) { - return -1; - } - - // eslint-disable-next-line no-unused-vars - SendUnreliableMessage(qsocket, data) { - return -1; - } - - // eslint-disable-next-line no-unused-vars - CanSendMessage(qsocket) { - return false; - } - - Close(qsocket) { - qsocket.state = QSocket.STATE_DISCONNECTED; - } - - /** - * Determine if this driver should handle listening in the current environment - * @returns {boolean} true if this driver should listen - */ - ShouldListen() { - return true; // Default: always listen - } - - // eslint-disable-next-line no-unused-vars - Listen(shouldListen) { - } - - /** - * @returns {string|null} the address this driver is listening on, or null if not applicable - */ - GetListenAddress() { - return null; - } -}; - -export class LoopDriver extends BaseDriver { - constructor() { - super('loop'); - this._server = null; - this._client = null; - this.localconnectpending = false; - } - - Init() { - this._server = null; - this._client = null; - this.localconnectpending = false; - - this.initialized = true; - return true; - } - - canHandle(host) { - return host === 'local'; - } - - Connect(host) { - if (host !== 'local') { // Loop Driver only handles loopback/local connections - return null; - } - - // we will return only one new client ever - this.localconnectpending = true; - - if (this._server === null) { - this._server = NET.NewQSocket(this); - this._server.address = 'local server'; - } - - this._server.receiveMessageLength = 0; - this._server.canSend = true; - - if (this._client === null) { - this._client = NET.NewQSocket(this); - this._client.address = 'local client'; - } - - this._client.receiveMessageLength = 0; - this._client.canSend = true; - - this._server.driverdata = this._client; // client is directly feeding into the server - this._client.driverdata = this._server; // and vice-versa - - this._client.state = QSocket.STATE_CONNECTED; - this._server.state = QSocket.STATE_CONNECTED; - - return this._server; - } - - CheckNewConnections() { - if (!this.localconnectpending) { - return null; - } - - this.localconnectpending = false; - - this._client.receiveMessageLength = 0; - this._client.canSend = true; - this._client.state = QSocket.STATE_CONNECTED; - - this._server.receiveMessageLength = 0; - this._server.canSend = true; - this._server.state = QSocket.STATE_CONNECTED; - - return this._client; - } - - GetMessage(sock) { - if (sock.receiveMessageLength === 0) { - return 0; - } - const ret = sock.receiveMessage[0]; - const length = sock.receiveMessage[1] + (sock.receiveMessage[2] << 8); - if (length > NET.message.data.byteLength) { - throw new HostError('Loop.GetMessage: overflow'); - } - NET.message.cursize = length; - new Uint8Array(NET.message.data).set(sock.receiveMessage.subarray(3, length + 3)); - sock.receiveMessageLength -= length; - if (sock.receiveMessageLength >= 4) { - sock.receiveMessage.copyWithin(0, length + 3, length + 3 + sock.receiveMessageLength); - } - sock.receiveMessageLength -= 3; - if (sock.driverdata && ret === 1) { - sock.driverdata.canSend = true; - } - if (sock.state === QSocket.STATE_DISCONNECTED) { - return -1; - } - return ret; - } - - SendMessage(sock, data) { - if (!sock.driverdata) { - return -1; - } - const bufferLength = sock.driverdata.receiveMessageLength; - sock.driverdata.receiveMessageLength += data.cursize + 3; - if (sock.driverdata.receiveMessageLength > 8192) { - throw new HostError('LoopDriver.SendMessage: overflow'); - } - const buffer = sock.driverdata.receiveMessage; - buffer[bufferLength] = 1; - buffer[bufferLength + 1] = data.cursize & 0xff; - buffer[bufferLength + 2] = data.cursize >> 8; - buffer.set(new Uint8Array(data.data, 0, data.cursize), bufferLength + 3); - sock.canSend = false; - return 1; - } - - SendUnreliableMessage(sock, data) { - if (!sock.driverdata) { - return -1; - } - const bufferLength = sock.driverdata.receiveMessageLength; - sock.driverdata.receiveMessageLength += data.cursize + 3; - if (sock.driverdata.receiveMessageLength > 8192) { - throw new HostError('LoopDriver.SendUnreliableMessage: overflow'); - } - const buffer = sock.driverdata.receiveMessage; - buffer[bufferLength] = 2; - buffer[bufferLength + 1] = data.cursize & 0xff; - buffer[bufferLength + 2] = data.cursize >> 8; - buffer.set(new Uint8Array(data.data, 0, data.cursize), bufferLength + 3); - return 1; - } - - CanSendMessage(sock) { - return sock.driverdata ? sock.canSend : false; - } - - Close(sock) { - if (sock.driverdata) { - sock.driverdata.driverdata = null; - } - sock.receiveMessageLength = 0; - sock.canSend = false; - if (sock === this._server) { - this._server = null; - } else { - this._client = null; - } - sock.state = QSocket.STATE_DISCONNECTED; - } - - // eslint-disable-next-line no-unused-vars - Listen(shouldListen) { - } -}; - -export class WebSocketDriver extends BaseDriver { - constructor() { - super('websocket'); - this.newConnections = []; - this.wss = null; - } - - Init() { - this.initialized = true; - this.newConnections = []; - return true; - } - - canHandle(host) { - return /^wss?:\/\//i.test(host); - } - - Connect(host) { - // Only handle ws:// and wss:// URLs - if (!/^wss?:\/\//i.test(host)) { - return null; - } - - const url = new URL(host); - - // set a default port - if (!url.port) { - url.port = (new URL(location.href)).port; - } - - // we can open a QSocket - const sock = NET.NewQSocket(this); - - try { - sock.address = url.toString(); - sock.driverdata = new WebSocket(url, 'quake'); - sock.driverdata.binaryType = 'arraybuffer'; - } catch (e) { - Con.PrintError(`WebSocketDriver.Connect: failed to setup ${url}, ${e.message}\n`); - return null; - } - - // these event handlers will feed into the message buffer structures - sock.driverdata.onerror = this._OnErrorClient; - sock.driverdata.onmessage = this._OnMessageClient; - sock.driverdata.onopen = this._OnOpenClient; - sock.driverdata.onclose = this._OnCloseClient; - - // freeing up some QSocket structures - sock.receiveMessage = []; - sock.receiveMessageLength = null; - sock.sendMessage = []; - sock.sendMessageLength = null; - - sock.driverdata.qsocket = sock; - - sock.state = QSocket.STATE_CONNECTING; - - return sock; - } - - CanSendMessage(qsocket) { - return ![2, 3].includes(qsocket.driverdata.readyState); // FIXME: WebSocket declaration - // return ![WebSocket.CLOSING, WebSocket.CLOSED].includes(qsocket.driverdata.readyState); - } - - GetMessage(qsocket) { - // check if we have collected new data - if (qsocket.receiveMessage.length === 0) { - if (qsocket.state === QSocket.STATE_DISCONNECTED) { - return -1; - } - - // finished message buffer draining due to a disconnect - if (qsocket.state === QSocket.STATE_DISCONNECTING) { - qsocket.state = QSocket.STATE_DISCONNECTED; - } - - return 0; - } - - // fetch a message - const message = qsocket.receiveMessage.shift(); - - // parse header - const ret = message[0]; - const length = message[1] + (message[2] << 8); - - // copy over the payload to our NET.message buffer - new Uint8Array(NET.message.data).set(message.subarray(3, length + 3)); - NET.message.cursize = length; - - return ret; - } - - _FlushSendBuffer(qsocket) { - switch (qsocket.driverdata.readyState) { - case 2: - case 3: - // case WebSocket.CLOSING: // FIXME: WebSocket declaration - // case WebSocket.CLOSED: // FIXME: WebSocket declaration - Con.DPrint(`WebSocketDriver._FlushSendBuffer: connection already died (readyState = ${qsocket.driverdata.readyState})`); - return false; - - case 0: - // case WebSocket.CONNECTING: // still connecting // FIXME: WebSocket declaration - return true; - } - - while (qsocket.sendMessage.length > 0) { - const message = qsocket.sendMessage.shift(); - - if (NET.delay_send.value === 0) { - (qsocket.driverdata).send(message); - } else { - setTimeout(() => { - /** @type {WebSocket} */(qsocket.driverdata).send(message); - - // failed to send? immediately mark it as disconnected - if (qsocket.driverdata.readyState > 1) { - qsocket.state = QSocket.STATE_DISCONNECTED; - } - }, NET.delay_send.value + (Math.random() - 0.5) * NET.delay_send_jitter.value); - } - } - - return true; - } - - _SendRawMessage(qsocket, data) { - // push the message onto the sendMessage buffer - qsocket.sendMessage.push(data); - - // try sending all out, don’t wait for an immediate reaction - this._FlushSendBuffer(qsocket); - - // we always assume it worked - return qsocket.state !== QSocket.STATE_DISCONNECTED ? 1 : -1; - } - - SendMessage(qsocket, data) { - const buffer = new Uint8Array(data.cursize + 3); - let i = 0; - buffer[i++] = 1; - buffer[i++] = data.cursize & 0xff; - buffer[i++] = (data.cursize >> 8) & 0xff; - buffer.set(new Uint8Array(data.data, 0, data.cursize), i); - return this._SendRawMessage(qsocket, buffer); - } - - SendUnreliableMessage(qsocket, data) { - const buffer = new Uint8Array(data.cursize + 3); - let i = 0; - buffer[i++] = 2; - buffer[i++] = data.cursize & 0xff; - buffer[i++] = (data.cursize >> 8) & 0xff; - buffer.set(new Uint8Array(data.data, 0, data.cursize), i); - return this._SendRawMessage(qsocket, buffer); - } - - Close(qsocket) { - if (this.CanSendMessage(qsocket)) { - this._FlushSendBuffer(qsocket); // make sure to send everything queued up out - qsocket.driverdata.close(1000); - } - - qsocket.state = QSocket.STATE_DISCONNECTED; - } - - // eslint-disable-next-line no-unused-vars - _OnErrorClient(error) { - Con.PrintError(`WebSocketDriver._OnErrorClient: lost connection to ${this.qsocket.address}\n`); - this.qsocket.state = QSocket.STATE_DISCONNECTED; // instant disconnect - } - - _OnMessageClient(message) { - const data = message.data; - - if (typeof(data) === 'string') { - return; - } - - if (NET.delay_receive.value === 0) { - this.qsocket.receiveMessage.push(new Uint8Array(data)); - return; - } - - setTimeout(() => { - this.qsocket.receiveMessage.push(new Uint8Array(data)); - }, NET.delay_receive.value + (Math.random() - 0.5) * NET.delay_receive_jitter.value); - } - - _OnOpenClient() { - this.qsocket.state = QSocket.STATE_CONNECTED; - } - - _OnCloseClient() { - if (this.qsocket.state !== QSocket.STATE_CONNECTED) { - return; - } - - Con.DPrint('WebSocketDriver._OnCloseClient: connection closed.\n'); - this.qsocket.state = QSocket.STATE_DISCONNECTING; // mark it as disconnecting, so that we can peacefully process any buffered messages - } - - _OnConnectionServer(ws, req) { - Con.DPrint('WebSocketDriver._OnConnectionServer: received new connection\n'); - - const sock = NET.NewQSocket(this); - - if (!sock) { - Con.PrintError('WebSocketDriver._OnConnectionServer: failed to allocate new socket, dropping client\n'); - // TODO: send a proper good bye to the client? - ws.close(); - return; - } - - sock.driverdata = ws; - sock.address = formatIP((req.headers['x-forwarded-for'] || req.socket.remoteAddress), req.socket.remotePort); - - // these event handlers will feed into the message buffer structures - sock.receiveMessage = []; - sock.receiveMessageLength = null; - sock.sendMessage = []; - sock.sendMessageLength = null; - sock.state = QSocket.STATE_CONNECTED; - - // set the last message time to now - NET.time = Sys.FloatTime(); - sock.lastMessageTime = NET.time; - - ws.on('close', () => { - Con.DPrint('WebSocketDriver._OnConnectionServer.disconnect: client disconnected\n'); - sock.state = QSocket.STATE_DISCONNECTED; - - eventBus.publish('net.connection.close', sock); - }); - - ws.on('error', () => { - Con.DPrint('WebSocketDriver._OnConnectionServer.disconnect: client errored out\n'); - sock.state = QSocket.STATE_DISCONNECTED; - - eventBus.publish('net.connection.error', sock); - }); - - ws.on('message', (data) => { - sock.receiveMessage.push(new Uint8Array(data)); - }); - - this.newConnections.push(sock); - - eventBus.publish('net.connection.accepted', sock); - } - - CheckNewConnections() { - if (this.newConnections.length === 0) { - return null; - } - - return this.newConnections.shift(); - } - - /** - * WebSocketDriver only listens in dedicated server mode - * Browser environments cannot create WebSocket servers - * @returns {boolean} true if should listen and can listen - */ - ShouldListen() { - return registry.isDedicatedServer && NET.server; - } - - Listen(listening) { - if (this.wss) { - if (!listening) { - this.wss.close(); - this.wss = null; - } - - return; - } - - const { WebSocket } = registry; - - this.wss = new WebSocket.WebSocketServer({server: NET.server}); - this.wss.on('connection', this._OnConnectionServer.bind(this)); - this.newConnections = []; - } - - GetListenAddress() { - if (!this.wss) { - return null; - } - - const addr = this.wss.address(); - - if (typeof addr === 'string') { - return addr; - } - - return formatIP(addr.address, addr.port); - } -}; - -/** - * WebRTC Driver - * - * Peer-to-peer networking using WebRTC DataChannels. - * Uses a signaling server for initial connection setup. - */ -export class WebRTCDriver extends BaseDriver { - constructor() { - super('webrtc'); - this.signalingUrl = null; - this.signalingWs = null; - this.sessionId = null; - this.peerId = null; - this.hostToken = null; // Token to prove ownership of session for reconnect - this.isHost = false; - this.creatingSession = false; // Track if we're in the process of creating a session - this.pingInterval = null; // Timer for sending pings to signaling server - this.reconnectTimer = null; // Timer for reconnecting to signaling server - /** @type {Function[]} */ - this.serverEventSubscriptions = []; - this.newConnections = []; - this.pendingConnections = new Map(); // peerId -> { qsocket, peerConnection } - - // STUN/TURN servers configuration - this.iceServers = [ - { urls: 'stun:stun.l.google.com:19302' }, - { urls: 'stun:stun.cloudflare.com:3478' }, - { urls: 'stun:stun.nextcloud.com:443' }, - ]; - } - - Init() { - // WebRTC only makes sense in browser environment - if (registry.isDedicatedServer) { - // Don't initialize in dedicated server mode - this.initialized = false; - return false; - } - - // Determine signaling server URL - // For local development: ws://localhost:3001/signaling - // For production: could be wss://signaling.yourcdn.com - const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; - - // Try to connect to local signaling server first, fallback to same host - this.signalingUrl = `${protocol}//${location.hostname}:8787/signaling`; - - if (registry.urls?.signalingURL) { - this.signalingUrl = registry.urls.signalingURL; - } - - this.initialized = true; - Con.DPrint(`WebRTCDriver: Initialized with signaling at ${this.signalingUrl}\n`); - return true; - } - - canHandle(host) { - return /^webrtc:\/\//i.test(host) || host === 'host'; - } - - /** - * Connect to a WebRTC peer - * @param {string} host - Format: "webrtc://sessionId" or just "sessionId" to join - * Use "webrtc://host" or "host" to create a new session - */ - Connect(host) { - if (!/^webrtc:\/\//i.test(host)) { - return null; - } - - // Parse the host parameter - let sessionId = null; - let shouldCreateSession = false; - - if (host.startsWith('webrtc://')) { - host = host.substring(9); - } - - // "host" means create a new session, otherwise join existing - if (host === 'host' || host === '') { - shouldCreateSession = true; - } else { - sessionId = host; - } - - // Connect to signaling server - if (!this._ConnectSignaling()) { - Con.PrintError('WebRTCDriver.Connect: Failed to connect to signaling server\n'); - return null; - } - - // Create a QSocket for tracking this connection attempt - const sock = NET.NewQSocket(this); - sock.state = QSocket.STATE_CONNECTING; - sock.address = shouldCreateSession ? 'WebRTC Host' : `WebRTC Session ${sessionId}`; - - // Store connection state - sock.driverdata = { - sessionId: sessionId, - isHost: shouldCreateSession, - peerConnections: new Map(), // peerId -> RTCPeerConnection - dataChannels: new Map(), // peerId -> { reliable, unreliable } - signalingReady: false, - }; - - // Free up some QSocket structures (we'll use our own buffering) - sock.receiveMessage = []; - sock.sendMessage = []; - - // Wait for signaling to be ready, then create or join session - const onSignalingReady = () => { - if (shouldCreateSession) { - this._CreateSession(sock); - } else { - this._JoinSession(sock, sessionId); - } - }; - - if (this.signalingWs && this.signalingWs.readyState === 1) { - onSignalingReady(); - } else { - // Store callback for when signaling connects - sock.driverdata.onSignalingReady = onSignalingReady; - } - - return sock; - } - - /** - * Connect to the signaling server - * @returns {boolean} true if connected or connecting - */ - _ConnectSignaling() { - if (this.signalingWs) { - if (this.signalingWs.readyState === 1) { // OPEN - return true; - } - if (this.signalingWs.readyState === 0) { // CONNECTING - return true; - } - } - - // Clear any pending reconnect timer since we are connecting now - if (this.reconnectTimer) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - - try { - this.signalingWs = new WebSocket(this.signalingUrl); - - this.signalingWs.onopen = () => { - Con.DPrint(`WebRTCDriver: Connected to signaling server at ${this.signalingUrl}\n`); - - // Capture session state before processing pending requests - const previousSessionId = this.sessionId; - - this._ProcessPendingSignaling(); - - // If we were in a session AND the session ID hasn't changed (meaning no new session was started) - if (previousSessionId && previousSessionId === this.sessionId) { - this._RestoreSession(); - } - }; - - this.signalingWs.onmessage = async (event) => { - await this._OnSignalingMessage(JSON.parse(event.data)); - }; - - this.signalingWs.onerror = (errorEvent) => { - // CR: errorEvent is not very useful here, log it anyway - console.debug('WebRTCDriver: Signaling WebSocket error', errorEvent); - Con.DPrint(`WebRTCDriver: Signaling error: ${errorEvent}\n`); - - this._OnSignalingError({ error: 'Signaling connection error' }); - }; - - this.signalingWs.onclose = (closeEvent) => { - Con.DPrint('WebRTCDriver: Signaling connection closed\n'); - this.signalingWs = null; - - if (closeEvent.code !== 1000) { - Con.PrintError(`Signaling connection closed unexpectedly, ${closeEvent.reason || 'unknown reason'} (code: ${closeEvent.code})\n`); - Con.PrintWarning(`Signaling server at ${this.signalingUrl} might be unavailable.\n`); - } - - this._OnSignalingError({ error: 'Signaling connection closed' }); - - // Attempt to reconnect - this._ScheduleReconnect(); - }; - - return true; - } catch (error) { - Con.PrintError(`WebRTCDriver: Failed to connect to signaling at ${this.signalingUrl}:\n${error.message}\n`); - this._ScheduleReconnect(); - return false; - } - } - - /** - * Schedule a reconnection attempt - */ - _ScheduleReconnect() { - if (this.reconnectTimer) { - return; - } - - const delay = 5000; // 5 seconds - Con.DPrint(`WebRTCDriver: Scheduling reconnect in ${delay}ms...\n`); - - this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null; - Con.DPrint('WebRTCDriver: Attempting to reconnect...\n'); - this._ConnectSignaling(); - }, delay); - } - - /** - * Restore session after reconnection - */ - _RestoreSession() { - if (this.isHost) { - Con.DPrint('WebRTCDriver: Restoring host session...\n'); - - // Send create-session with existing sessionId and hostToken to attempt reconnect - this._SendSignaling({ - type: 'create-session', - sessionId: this.sessionId, - hostToken: this.hostToken, - serverInfo: this._GatherServerInfo(), - isPublic: this._IsSessionPublic(), - }); - } else { - Con.DPrint(`WebRTCDriver: Restoring client session ${this.sessionId}...\n`); - // Try to join the same session - this._SendSignaling({ - type: 'join-session', - sessionId: this.sessionId, - }); - } - } - - /** - * Process any pending signaling operations - */ - _ProcessPendingSignaling() { - // Call any pending onSignalingReady callbacks - for (let i = 0; i < NET.activeSockets.length; i++) { - const sock = NET.activeSockets[i]; - if (sock && sock.driver === this && sock.driverdata?.onSignalingReady) { - sock.driverdata.onSignalingReady(); - delete sock.driverdata.onSignalingReady; - } - } - } - - /** - * Send a message to the signaling server - * @param message - */ - _SendSignaling(message) { - if (this.signalingWs && this.signalingWs.readyState === 1) { - this.signalingWs.send(JSON.stringify(message)); - } - } - - /** - * Start sending periodic pings to keep the session alive (host only) - */ - _StartPingInterval() { - if (!this.isHost) { - return; // Only hosts need to ping - } - - // Clear any existing interval - this._StopPingInterval(); - - // Send ping every 30 seconds - this.pingInterval = setInterval(() => { - this._SendSignaling({ type: 'ping' }); - }, 30 * 1000); - - // Send initial ping immediately - this._SendSignaling({ type: 'ping' }); - } - - /** - * Stop sending pings - */ - _StopPingInterval() { - if (this.pingInterval) { - clearInterval(this.pingInterval); - this.pingInterval = null; - } - } - - /** - * Start sending periodic server info updates (host only) - */ - _StartServerInfoSubscriptions() { - if (!this.isHost) { - return; // Only hosts need to update server info - } - - // Clear any existing subscriptions - this._StopServerInfoSubscriptions(); - - this.serverEventSubscriptions.push(eventBus.subscribe('server.spawned', () => this._UpdateServerInfo())); - - this.serverEventSubscriptions.push(eventBus.subscribe('server.client.connected', () => this._UpdateServerInfo())); - this.serverEventSubscriptions.push(eventBus.subscribe('server.client.disconnected', () => this._UpdateServerInfo())); - - this.serverEventSubscriptions.push(eventBus.subscribe('cvar.changed', (/** @type {string} */ cvarName) => { - const cvar = Cvar.FindVar(cvarName); - - if (cvar && cvar.flags & Cvar.FLAG.SERVER) { - this._UpdateServerInfo(); - } - })); - - // Send initial update immediately - this._UpdateServerInfo(); - } - - /** - * Stop sending server info updates - */ - _StopServerInfoSubscriptions() { - while (this.serverEventSubscriptions.length > 0) { - const unsubscribe = this.serverEventSubscriptions.pop(); - unsubscribe(); - } - } - - /** - * Gather and send current server info to master server - */ - _UpdateServerInfo() { - if (!this.isHost || !this.sessionId) { - return; - } - - // Gather server info from game state - const serverInfo = this._GatherServerInfo(); - - this._SendSignaling({ - type: 'update-server-info', - serverInfo, - isPublic: this._IsSessionPublic(), - }); - } - - /** - * Gather current server information from game state - * Override this or hook into game state to provide actual values - */ - _GatherServerInfo() { - const serverInfo = { - hostname: Cvar.FindVar('hostname').string, - maxPlayers: SV.svs.maxclients, - currentPlayers: NET.activeconnections, - map: SV.server.mapname, - mod: COM.game, - /** @type {Record} */ - settings: {}, - }; - - for (const cvar of Cvar.Filter((/** @type {Cvar} */ cvar) => cvar.flags & Cvar.FLAG.SERVER)) { - serverInfo.settings[cvar.name] = cvar.string; - } - - return serverInfo; - } - - /** - * Determine if the session should be public - * Can be controlled by a cvar or game setting - * @returns {boolean} true if session is public - */ - _IsSessionPublic() { - // Check if there's a cvar controlling this - return Cvar.FindVar('sv_public').value !== 0; - } - - /** - * Create a new session (host) - * @param sock - */ - _CreateSession(sock) { - this._SendSignaling({ type: 'create-session' }); - sock.driverdata.isHost = true; - this.isHost = true; - } - - /** - * Join an existing session - * @param sock - * @param sessionId - */ - _JoinSession(sock, sessionId) { - this._SendSignaling({ - type: 'join-session', - sessionId: sessionId, - }); - sock.driverdata.sessionId = sessionId; - this.sessionId = sessionId; - } - - /** - * Handle messages from signaling server - * @param message - */ - async _OnSignalingMessage(message) { - switch (message.type) { - case 'session-created': - this._OnSessionCreated(message); - break; - - case 'session-joined': - this._OnSessionJoined(message); - break; - - case 'peer-joined': - this._OnPeerJoined(message); - break; - - case 'peer-left': - this._OnPeerLeft(message); - break; - - case 'offer': - await this._OnOffer(message); - break; - - case 'answer': - await this._OnAnswer(message); - break; - - case 'ice-candidate': - await this._OnIceCandidate(message); - break; - - case 'session-closed': - this._OnSessionClosed(message); - break; - - case 'pong': - // Pong response from server - session is alive - // Could track latency here if needed - break; - - case 'error': - Con.DPrint(`WebRTCDriver: Signaling error: ${message.error}\n`); - this._OnSignalingError(message); - break; - - default: - Con.DPrint(`WebRTCDriver: Unknown signaling message: ${message.type}\n`); - } - } - - /** - * Handle signaling errors (session not found, etc.) - * @param message - */ - _OnSignalingError(message) { - // Find the socket that's trying to connect - // Priority: look for sockets in CONNECTING state that match the error context - let failedSocket = null; - - for (let i = 0; i < NET.activeSockets.length; i++) { - const sock = NET.activeSockets[i]; - if (sock && sock.driver === this && sock.state === QSocket.STATE_CONNECTING) { - // If the error mentions a session ID, try to match it - if (sock.driverdata?.sessionId && message.error.includes(sock.driverdata.sessionId)) { - failedSocket = sock; - break; - } - // If we're creating a session and it fails - if (sock.driverdata?.isHost && message.error.includes('already exists')) { - failedSocket = sock; - break; - } - // Otherwise, just mark the first connecting socket as failed - if (!failedSocket) { - failedSocket = sock; - } - } - } - - if (failedSocket) { - Con.PrintError(`WebRTCDriver: Connection failed - ${message.error}\n`); - failedSocket.state = QSocket.STATE_DISCONNECTED; - - // Clean up the failed connection attempt if it was our current session - if (failedSocket.driverdata?.sessionId === this.sessionId) { - this.sessionId = null; - this.peerId = null; - this.hostToken = null; - this.isHost = false; - } - } else { - // No matching socket found, just log the error - Con.PrintWarning(`WebRTCDriver: Signaling error (no matching socket): ${message.error}\n`); - } - } - - /** - * Session was created successfully - * @param message - */ - _OnSessionCreated(message) { - this.sessionId = message.sessionId; - this.peerId = message.peerId; - this.isHost = message.isHost; - this.hostToken = message.hostToken; // Store host token - this.creatingSession = false; // Session creation complete - - Con.DPrint(`WebRTCDriver: Session created: ${this.sessionId}\n`); - Con.DPrint(`WebRTCDriver: Your peer ID: ${this.peerId}\n`); - - // Find the socket for this session and update it - // For host sessions created via Listen(), we need to find any socket with isHost=true - let sock = null; - for (let i = 0; i < NET.activeSockets.length; i++) { - const s = NET.activeSockets[i]; - if (s && s.driver === this && s.driverdata?.isHost) { - sock = s; - break; - } - } - - if (!sock) { - // Fallback: try to find by sessionId - sock = this._FindSocketBySession(this.sessionId); - } - - if (sock && sock.driverdata) { - sock.driverdata.sessionId = this.sessionId; - sock.state = QSocket.STATE_CONNECTED; - sock.address = `WebRTC Host (${this.sessionId})`; - // Don't add host socket to newConnections - it's not an incoming client connection - // Only peer connections should be added to newConnections when they join - Con.DPrint('WebRTCDriver: Host socket ready for accepting peers\n'); - - // Start sending periodic pings to keep session alive - this._StartPingInterval(); - - // Start sending periodic server info updates - this._StartServerInfoSubscriptions(); - - // Handle existing peers (reconnect scenario) - if (message.existingPeers && message.existingPeers.length > 0) { - Con.DPrint(`WebRTCDriver: Reconnecting to ${message.existingPeers.length} existing peers...\n`); - for (const peerId of message.existingPeers) { - this._OnPeerJoined({ peerId }); - } - } - } else { - Con.PrintWarning(`WebRTCDriver: No socket found for session ${this.sessionId}\n`); - } - } - - /** - * Successfully joined a session - * @param message - */ - _OnSessionJoined(message) { - this.sessionId = message.sessionId; - this.peerId = message.peerId; - this.isHost = message.isHost; - - Con.DPrint(`WebRTCDriver: Joined session: ${this.sessionId}\n`); - Con.DPrint(`WebRTCDriver: Your peer ID: ${this.peerId}\n`); - Con.DPrint(`WebRTCDriver: Peers in session: ${message.peerCount}\n`); - - // Find the socket for this session and mark it as connected - const sock = this._FindSocketBySession(this.sessionId); - if (sock) { - // Don't mark as fully connected yet - wait for data channels to open - // But update the address - sock.address = `WebRTC Peer (${this.sessionId})`; - Con.DPrint('WebRTCDriver: Socket found, waiting for P2P connection\n'); - } else { - Con.PrintWarning(`WebRTCDriver: No socket found for joined session ${this.sessionId}\n`); - } - } - - /** - * New peer joined the session - * @param message - */ - _OnPeerJoined(message) { - Con.DPrint(`WebRTCDriver: Peer ${message.peerId} joined\n`); - - // If we're the host, create a new socket for this peer and initiate connection - if (this.isHost) { - // Create a QSocket for this peer connection - const peerSock = NET.NewQSocket(this); - peerSock.state = QSocket.STATE_CONNECTING; - peerSock.address = `WebRTC Peer ${message.peerId}`; - - // Store peer-specific connection state - peerSock.driverdata = { - sessionId: this.sessionId, - isHost: false, - peerId: message.peerId, // This socket represents a connection to this specific peer - peerConnections: new Map(), - dataChannels: new Map(), - }; - - // Free up some QSocket structures - peerSock.receiveMessage = []; - peerSock.sendMessage = []; - - // Initiate P2P connection to this peer using the peer-specific socket - this._CreatePeerConnection(peerSock, message.peerId, true); - - // Add to new connections so server accepts it as a client - this.newConnections.push(peerSock); - Con.DPrint(`WebRTCDriver: Created socket for peer ${message.peerId}, added to new connections\n`); - } - } - - /** - * Peer left the session - * @param message - */ - _OnPeerLeft(message) { - Con.DPrint(`WebRTCDriver: Peer ${message.peerId} left\n`); - this._ClosePeerConnection(message.peerId); - } - - /** - * Received WebRTC offer from peer - * @param message - */ - async _OnOffer(message) { - Con.DPrint(`WebRTCDriver: Received offer from ${message.fromPeerId}\n`); - - // Find our socket (we're the joining peer) - const sock = this._FindSocketBySession(this.sessionId); - if (!sock) { - Con.PrintWarning('WebRTCDriver._OnOffer: No socket found for session\n'); - return; - } - - const pc = this._CreatePeerConnection(sock, message.fromPeerId, false); - - try { - await pc.setRemoteDescription(new RTCSessionDescription(message.offer)); - const answer = await pc.createAnswer(); - await pc.setLocalDescription(answer); - - this._SendSignaling({ - type: 'answer', - targetPeerId: message.fromPeerId, - answer: pc.localDescription, - }); - } catch (error) { - Con.PrintError(`WebRTCDriver: Error handling offer: ${error.message}\n`); - } - } - - /** - * Received WebRTC answer from peer - * @param message - */ - async _OnAnswer(message) { - Con.DPrint(`WebRTCDriver: Received answer from ${message.fromPeerId}\n`); - - // If we're the host, find the peer-specific socket - // If we're a peer, find our own socket - const sock = this.isHost ? this._FindSocketByPeerId(message.fromPeerId) : this._FindSocketBySession(this.sessionId); - - if (!sock || !sock.driverdata) { - Con.PrintWarning(`WebRTCDriver._OnAnswer: No socket found for ${message.fromPeerId}\n`); - return; - } - - const pc = sock.driverdata.peerConnections.get(message.fromPeerId); - if (!pc) { - Con.PrintWarning(`WebRTCDriver: No peer connection found for ${message.fromPeerId}\n`); - return; - } - - try { - await pc.setRemoteDescription(new RTCSessionDescription(message.answer)); - Con.DPrint(`WebRTCDriver: Answer processed for ${message.fromPeerId}\n`); - } catch (error) { - Con.PrintError(`WebRTCDriver: Error handling answer: ${error.message}\n`); - } - } - - /** - * Received ICE candidate from peer - * @param message - */ - async _OnIceCandidate(message) { - // If we're the host, find the peer-specific socket - // If we're a peer, find our own socket - const sock = this.isHost - ? this._FindSocketByPeerId(message.fromPeerId) - : this._FindSocketBySession(this.sessionId); - - if (!sock || !sock.driverdata) { - return; - } - - const pc = sock.driverdata.peerConnections.get(message.fromPeerId); - if (!pc) { - return; - } - - try { - if (message.candidate) { - await pc.addIceCandidate(new RTCIceCandidate(message.candidate)); - } - } catch (error) { - Con.DPrint(`WebRTCDriver: Error adding ICE candidate: ${error.message}\n`); - } - } - - /** - * Session was closed - * @param message - */ - _OnSessionClosed(message) { - Con.DPrint(`WebRTCDriver: Session closed: ${message.reason}\n`); - - const sock = this._FindSocketBySession(this.sessionId); - if (sock) { - sock.state = QSocket.STATE_DISCONNECTED; - } - - this.sessionId = null; - this.peerId = null; - this.isHost = false; - } - - /** - * Create a peer connection - * @param sock - The socket for this peer connection - * @param peerId - * @param initiator - */ - _CreatePeerConnection(sock, peerId, initiator) { - console.assert(sock && sock.driverdata, 'WebRTCDriver._CreatePeerConnection: Invalid socket'); - - if (!sock || !sock.driverdata) { - Con.PrintError('WebRTCDriver._CreatePeerConnection: No socket provided\n'); - return null; - } - - // Check if connection already exists - if (sock.driverdata.peerConnections.has(peerId)) { - return sock.driverdata.peerConnections.get(peerId); - } - - Con.DPrint(`WebRTCDriver: Creating peer connection to ${peerId} (initiator: ${initiator})\n`); - - const pc = new RTCPeerConnection({ iceServers: this.iceServers }); - sock.driverdata.peerConnections.set(peerId, pc); - - // Handle ICE candidates - pc.onicecandidate = (event) => { - if (event.candidate) { - Con.DPrint(`WebRTCDriver: Sending ICE candidate to ${peerId}\n`); - this._SendSignaling({ - type: 'ice-candidate', - targetPeerId: peerId, - candidate: event.candidate, - }); - } else { - Con.DPrint(`WebRTCDriver: ICE gathering complete for ${peerId}\n`); - } - }; - - // Handle ICE connection state changes (more detailed than connectionState) - pc.oniceconnectionstatechange = () => { - Con.DPrint(`WebRTCDriver: ICE state with ${peerId}: ${pc.iceConnectionState}\n`); - }; - - // Handle connection state changes - pc.onconnectionstatechange = () => { - Con.DPrint(`WebRTCDriver: Connection state with ${peerId}: ${pc.connectionState}\n`); - - if (pc.connectionState === 'connected') { - Con.DPrint(`WebRTCDriver: P2P connection established with ${peerId}\n`); - } else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') { - Con.DPrint(`WebRTCDriver: Connection ${pc.connectionState} with ${peerId}\n`); - this._ClosePeerConnection(peerId); - } - }; - - // Create data channels - if (initiator) { - // Reliable channel for important messages - const reliableChannel = pc.createDataChannel('reliable', { - ordered: true, - }); - - // Unreliable channel for position updates, etc. - const unreliableChannel = pc.createDataChannel('unreliable', { - ordered: false, - maxRetransmits: 10, - }); - - this._SetupDataChannel(sock, peerId, reliableChannel, unreliableChannel); - - // Create and send offer - pc.createOffer().then((offer) => pc.setLocalDescription(offer)).then(() => { - this._SendSignaling({ - type: 'offer', - targetPeerId: peerId, - offer: pc.localDescription, - }); - }).catch((error) => { - Con.PrintError(`WebRTCDriver: Error creating offer: ${error.message}\n`); - }); - } else { - // Wait for data channels from initiator - pc.ondatachannel = (event) => { - const channel = event.channel; - - if (!sock.driverdata.dataChannels.has(peerId)) { - sock.driverdata.dataChannels.set(peerId, {}); - } - - const channels = sock.driverdata.dataChannels.get(peerId); - - if (channel.label === 'reliable') { - channels.reliable = channel; - this._SetupDataChannelHandlers(sock, peerId, channel); - } else if (channel.label === 'unreliable') { - channels.unreliable = channel; - this._SetupDataChannelHandlers(sock, peerId, channel); - } - }; - } - - return pc; - } - - /** - * Setup data channels - * @param sock - * @param peerId - * @param reliableChannel - * @param unreliableChannel - */ - _SetupDataChannel(sock, peerId, reliableChannel, unreliableChannel) { - sock.driverdata.dataChannels.set(peerId, { - reliable: reliableChannel, - unreliable: unreliableChannel, - }); - - this._SetupDataChannelHandlers(sock, peerId, reliableChannel); - this._SetupDataChannelHandlers(sock, peerId, unreliableChannel); - } - - /** - * Setup data channel event handlers - * @param sock - * @param peerId - * @param channel - */ - _SetupDataChannelHandlers(sock, peerId, channel) { - channel.binaryType = 'arraybuffer'; - - channel.onopen = () => { - Con.DPrint(`WebRTCDriver: Data channel ${channel.label} opened with ${peerId}\n`); - - // Mark socket as connected when the reliable channel opens - if (channel.label === 'reliable' && sock.state !== QSocket.STATE_CONNECTED) { - sock.state = QSocket.STATE_CONNECTED; - Con.DPrint('WebRTCDriver: Socket now CONNECTED (can send/receive data)\n'); - } - - this._FlushSendBuffer(sock); - }; - - channel.onclose = () => { - Con.DPrint(`WebRTCDriver: Data channel ${channel.label} closed with ${peerId}\n`); - - sock.state = QSocket.STATE_DISCONNECTED; - }; - - channel.onerror = (error) => { - Con.PrintError(`WebRTCDriver: Data channel error with ${peerId}: ${error}\n`); - - sock.state = QSocket.STATE_DISCONNECTED; - }; - - channel.onmessage = (event) => { - const data = new Uint8Array(event.data); - sock.receiveMessage.push(data); - }; - } - - /** - * Close peer connection - * @param peerId - */ - _ClosePeerConnection(peerId) { - // If we're the host, find the peer-specific socket - // If we're a peer, find our own socket - const sock = this.isHost ? this._FindSocketByPeerId(peerId) : this._FindSocketBySession(this.sessionId); - - if (!sock || !sock.driverdata) { - Con.DPrint(`WebRTCDriver._ClosePeerConnection: No socket found for ${peerId}\n`); - return; - } - - sock.state = QSocket.STATE_DISCONNECTING; - - const pc = sock.driverdata.peerConnections.get(peerId); - if (pc) { - pc.close(); - sock.driverdata.peerConnections.delete(peerId); - } - - sock.driverdata.dataChannels.delete(peerId); - - sock.state = QSocket.STATE_DISCONNECTED; - } - - /** - * Find socket by session ID - * @param sessionId - */ - _FindSocketBySession(sessionId) { - for (let i = 0; i < NET.activeSockets.length; i++) { - const sock = NET.activeSockets[i]; - if (sock && sock.driver === this && sock.driverdata?.sessionId === sessionId) { - return sock; - } - } - return null; - } - - /** - * Find socket by peer ID (for host to find peer-specific sockets) - * @param peerId - */ - _FindSocketByPeerId(peerId) { - for (let i = 0; i < NET.activeSockets.length; i++) { - const sock = NET.activeSockets[i]; - if (sock && sock.driver === this && sock.driverdata?.peerId === peerId) { - return sock; - } - } - return null; - } - - CheckNewConnections() { - if (this.newConnections.length === 0) { - return null; - } - - const sock = this.newConnections.shift(); - Con.DPrint(`WebRTCDriver.CheckNewConnections: returning new connection ${sock.address}\n`); - return sock; - } - - _FlushSendBuffer(qsocket) { - if (!qsocket.driverdata || !qsocket.driverdata.dataChannels) { - return; - } - - while (qsocket.sendMessage.length > 0) { - const msg = qsocket.sendMessage[0]; - - // Check if we can send THIS message type to at least one peer - let canSendThis = false; - for (const channels of qsocket.driverdata.dataChannels.values()) { - const channel = msg.reliable ? channels.reliable : channels.unreliable; - if (channel && channel.readyState === 'open') { - canSendThis = true; - break; - } - } - - if (!canSendThis) { - // Can't send this message yet. Stop flushing. - break; - } - - const ret = this._SendToAllPeers(qsocket, msg.buffer, msg.reliable); - - if (ret > 0) { - qsocket.sendMessage.shift(); - } else { - break; - } - } - - if (qsocket.sendMessage.length === 0 && qsocket.state === QSocket.STATE_DISCONNECTING) { - Con.DPrint(`WebRTCDriver._FlushSendBuffer: buffer drained, closing ${qsocket.address}\n`); - this._ForceClose(qsocket); - } - } - - GetMessage(qsocket) { - // Check if we have collected new data - if (qsocket.receiveMessage.length === 0) { - if (qsocket.state === QSocket.STATE_DISCONNECTED) { - return -1; - } - - if (qsocket.state === QSocket.STATE_DISCONNECTING) { - qsocket.state = QSocket.STATE_DISCONNECTED; - return -1; - } - - return 0; - } - - // Fetch a message - const message = qsocket.receiveMessage.shift(); - - // Parse header - const ret = message[0]; - const length = message[1] + (message[2] << 8); - - // Con.DPrint(`WebRTCDriver.GetMessage: type=${ret}, length=${length}\n`); - - // Copy over the payload to our NET.message buffer - new Uint8Array(NET.message.data).set(message.subarray(3, length + 3)); - NET.message.cursize = length; - - return ret; - } - - SendMessage(qsocket, data) { - const buffer = new Uint8Array(data.cursize + 3); - buffer[0] = 1; // reliable message type - buffer[1] = data.cursize & 0xff; - buffer[2] = (data.cursize >> 8) & 0xff; - buffer.set(new Uint8Array(data.data, 0, data.cursize), 3); - - // Con.DPrint(`WebRTCDriver.SendMessage: sending ${data.cursize} bytes (reliable)\n`); - qsocket.sendMessage.push({ - buffer: buffer, - reliable: true, - }); - - this._FlushSendBuffer(qsocket); - return 1; - } - - SendUnreliableMessage(qsocket, data) { - const buffer = new Uint8Array(data.cursize + 3); - buffer[0] = 2; // unreliable message type - buffer[1] = data.cursize & 0xff; - buffer[2] = (data.cursize >> 8) & 0xff; - buffer.set(new Uint8Array(data.data, 0, data.cursize), 3); - - // Con.DPrint(`WebRTCDriver.SendUnreliableMessage: sending ${data.cursize} bytes (unreliable)\n`); - qsocket.sendMessage.push({ - buffer: buffer, - reliable: false, - }); - - this._FlushSendBuffer(qsocket); - return 1; - } - - /** - * Send data to all connected peers - * @param qsocket - * @param buffer - * @param reliable - */ - _SendToAllPeers(qsocket, buffer, reliable) { - console.assert(qsocket && qsocket.driverdata, 'WebRTCDriver._SendToAllPeers: Invalid socket'); - - if (!qsocket.driverdata || !qsocket.driverdata.dataChannels) { - Con.PrintError('WebRTCDriver._SendToAllPeers: no driverdata or channels\n'); - return -1; - } - - let sentCount = 0; - - for (const [peerId, channels] of qsocket.driverdata.dataChannels) { - const channel = reliable ? channels.reliable : channels.unreliable; - - if (!channel || channel.readyState !== 'open') { - Con.DPrint(`WebRTCDriver._SendToAllPeers: channel to ${peerId} not open (state=${channel?.readyState})\n`); - continue; - } - - try { - channel.send(buffer); - // Con.DPrint(`WebRTCDriver._SendToAllPeers: sent ${buffer.length} bytes to ${peerId} on ${channel.label}\n`); - sentCount++; - } catch (error) { - Con.DPrint(`WebRTCDriver: Error sending to ${peerId}: ${error.message}\n`); - } - } - - if (sentCount === 0) { - Con.DPrint('WebRTCDriver._SendToAllPeers: no peers available to send to\n'); - } - - return sentCount > 0 ? 1 : -1; - } - - CanSendMessage(qsocket) { - if (!qsocket.driverdata || !qsocket.driverdata.dataChannels) { - return false; - } - - // Can send if at least one reliable channel is open - for (const channels of qsocket.driverdata.dataChannels.values()) { - if (channels.reliable && channels.reliable.readyState === 'open') { - return true; - } - } - - return false; - } - - Close(qsocket) { - if (!qsocket.driverdata) { - qsocket.state = QSocket.STATE_DISCONNECTED; - return; - } - - // Try to flush any pending messages - this._FlushSendBuffer(qsocket); - - // If we have pending messages and we are in a state where we might send them, - // delay the actual closing. - if (qsocket.sendMessage.length > 0 && qsocket.state !== QSocket.STATE_DISCONNECTED) { - // Check if we have any channels that might open or are open - if (qsocket.driverdata.dataChannels && qsocket.driverdata.dataChannels.size > 0) { - Con.DPrint(`WebRTCDriver.Close: delaying close for ${qsocket.address} to flush buffer\n`); - qsocket.state = QSocket.STATE_DISCONNECTING; - - // Set a timeout to force close if it takes too long (e.g. 5 seconds) - setTimeout(() => { - if (qsocket.state === QSocket.STATE_DISCONNECTING) { - Con.DPrint(`WebRTCDriver.Close: timeout waiting for flush, forcing close for ${qsocket.address}\n`); - this._ForceClose(qsocket); - } - }, 5000); - - return; - } - } - - this._ForceClose(qsocket); - } - - _ForceClose(qsocket) { - if (!qsocket.driverdata) { - qsocket.state = QSocket.STATE_DISCONNECTED; - return; - } - - // Close all peer connections - if (qsocket.driverdata.peerConnections) { - for (const pc of qsocket.driverdata.peerConnections.values()) { - pc.close(); - } - qsocket.driverdata.peerConnections.clear(); - } - - // Clear data channels - if (qsocket.driverdata.dataChannels) { - qsocket.driverdata.dataChannels.clear(); - } - - // If this socket represents our current session (host or client), clear session state - const isSessionSocket = qsocket.driverdata.isHost || (!this.isHost && qsocket.driverdata.sessionId === this.sessionId); - - // Stop ping interval if this is the host socket - if (qsocket.driverdata.isHost) { - this._StopPingInterval(); - this._StopServerInfoSubscriptions(); - } - - // Notify signaling server if we are leaving the session - // This applies to both Host (destroying session) and Client (leaving session) - if (isSessionSocket && this.sessionId) { - this._SendSignaling({ type: 'leave-session' }); - } - - if (isSessionSocket) { - // Only clear if we are actually closing the session socket, not a peer socket on the host - // Host has: - // 1. Host socket (isHost=true, sessionId=...) - // 2. Peer sockets (isHost=false, sessionId=..., peerId=...) - - // If it's a peer socket on the host, we shouldn't clear global session state - if (this.isHost && !qsocket.driverdata.isHost) { - // This is a peer connection closing on the host side - // Do not clear session state - } else { - this.sessionId = null; - this.peerId = null; - this.hostToken = null; - this.isHost = false; - } - } - - qsocket.state = QSocket.STATE_DISCONNECTED; - } - - /** - * WebRTCDriver only listens in browser mode - * Dedicated servers use WebSocketDriver instead - * @returns {boolean} true if should listen - */ - ShouldListen() { - return !registry.isDedicatedServer; - } - - Listen(listening) { - // In browser environment, listening means hosting a WebRTC session - if (!this.ShouldListen()) { - // Dedicated servers don't use WebRTC - return; - } - - if (listening) { - // Check if we already have a session or are creating one - if (this.sessionId || this.creatingSession) { - Con.DPrint('WebRTCDriver: Already hosting or creating a session\n'); - return; - } - - // Auto-create a WebRTC host session for browser-based listen servers - Con.DPrint('WebRTCDriver: Starting WebRTC host session for listen server\n'); - this.creatingSession = true; - - // Connect to signaling and create a host session - if (!this._ConnectSignaling()) { - Con.PrintWarning('WebRTCDriver: Failed to connect to signaling server\n'); - this.creatingSession = false; - return; - } - - // Create a QSocket for tracking this host session - const sock = NET.NewQSocket(this); - sock.state = QSocket.STATE_CONNECTING; - sock.address = 'WebRTC Host'; - - // Store connection state - sock.driverdata = { - sessionId: null, // Will be set when session is created - isHost: true, - peerConnections: new Map(), - dataChannels: new Map(), - signalingReady: false, - }; - - // Free up some QSocket structures - sock.receiveMessage = []; - sock.sendMessage = []; - - // Wait for signaling to be ready, then create session - const createSessionWhenReady = () => { - this._SendSignaling({ - type: 'create-session', - serverInfo: this._GatherServerInfo(), - isPublic: this._IsSessionPublic(), - }); - Con.DPrint('WebRTCDriver: Session creation request sent\n'); - }; - - if (this.signalingWs && this.signalingWs.readyState === 1) { - // Already connected, send immediately - createSessionWhenReady(); - } else { - // Store callback for when signaling connects - sock.driverdata.onSignalingReady = createSessionWhenReady; - } - - this.isHost = true; - - Con.DPrint('WebRTCDriver: Waiting for signaling connection to create session...\n'); - } else { - // Stop listening - tear down the session completely - Con.DPrint('WebRTCDriver: Stopping listen server, tearing down session\n'); - - // Stop ping interval - this._StopPingInterval(); - this._StopServerInfoSubscriptions(); - - // Close all sockets (host and peers) that belong to this driver - for (let i = NET.activeSockets.length - 1; i >= 0; i--) { - const sock = NET.activeSockets[i]; - if (sock && sock.driver === this && sock.driverdata) { - // Close all peer connections - if (sock.driverdata.peerConnections) { - for (const pc of sock.driverdata.peerConnections.values()) { - pc.close(); - } - sock.driverdata.peerConnections.clear(); - } - - // Clear data channels - if (sock.driverdata.dataChannels) { - sock.driverdata.dataChannels.clear(); - } - - // Mark socket as disconnected - sock.state = QSocket.STATE_DISCONNECTED; - } - } - - // Leave session on signaling server - if (this.sessionId) { - this._SendSignaling({ type: 'leave-session' }); - } - - // Close signaling connection - if (this.signalingWs) { - // Remove listeners to prevent reconnect logic - this.signalingWs.onclose = null; - this.signalingWs.onerror = null; - this.signalingWs.onmessage = null; - this.signalingWs.onopen = null; - - this.signalingWs.close(); - this.signalingWs = null; - } - - // Clear any pending reconnect timer - if (this.reconnectTimer) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - - if (this.sessionId) { - Con.DPrint('WebRTCDriver: Session torn down, no longer accepting connections\n'); - } - - // Reset state - this.sessionId = null; - this.peerId = null; - this.isHost = false; - this.creatingSession = false; - } - } - - GetListenAddress() { - if (this.sessionId) { - return `webrtc://${this.sessionId}`; - } - - return null; - } -}; - diff --git a/source/engine/network/NetworkDrivers.ts b/source/engine/network/NetworkDrivers.ts new file mode 100644 index 00000000..e54a562a --- /dev/null +++ b/source/engine/network/NetworkDrivers.ts @@ -0,0 +1,2047 @@ +import Cvar from '../common/Cvar.mjs'; +import { HostError } from '../common/Errors.mjs'; +import type { SzBuffer } from './MSG.ts'; +import { eventBus, getCommonRegistry, registry } from '../registry.mjs'; +import { formatIP } from './Misc.ts'; + +type Throwable = Error | string | number | boolean | null | undefined | { message?: string }; +type NetworkPayload = Pick; +type QSocketState = 'new' | 'connecting' | 'connected' | 'disconnecting' | 'disconnected'; + +type ListenAddress = { + address: string; + port: number; +}; + +type NodeRawData = ArrayBuffer | Uint8Array | Uint8Array[]; + +type NodeIncomingMessageLike = { + headers: Record; + socket: { + remoteAddress?: string; + remotePort?: number; + }; +}; + +type NodeWebSocketLike = { + close: (code?: number) => void; + readyState: number; + send: (data: ArrayBuffer) => void; + on: { + (eventName: 'close', listener: () => void): void; + (eventName: 'error', listener: () => void): void; + (eventName: 'message', listener: (data: NodeRawData) => void): void; + }; +}; + +type NodeWebSocketServerLike = { + address: () => string | ListenAddress | null; + close: () => void; + on: (eventName: 'connection', listener: (ws: NodeWebSocketLike, req: NodeIncomingMessageLike) => void) => void; +}; + +type NodeWebSocketModuleLike = { + WebSocketServer: new (options: { server: typeof NET.server }) => NodeWebSocketServerLike; +}; + +type DataChannelPair = { + reliable?: RTCDataChannel; + unreliable?: RTCDataChannel; +}; + +type WebRTCQueuedMessage = { + buffer: Uint8Array; + reliable: boolean; +}; + +type ServerInfo = { + hostname: string; + maxPlayers: number; + currentPlayers: number; + map: string; + mod: string; + settings: Record; +}; + +type SignalingMessage = { + type: string; + answer?: RTCSessionDescription | RTCSessionDescriptionInit | null; + candidate?: RTCIceCandidate | RTCIceCandidateInit | null; + error?: string; + existingPeers?: string[]; + fromPeerId?: string; + hostToken?: string; + isHost?: boolean; + isPublic?: boolean; + offer?: RTCSessionDescription | RTCSessionDescriptionInit | null; + peerCount?: number; + peerId?: string; + reason?: string; + serverInfo?: ServerInfo; + sessionId?: string; + targetPeerId?: string; +}; + +type LoopbackSocketState = { + kind: 'loopback'; + peer: QSocket | null; + receiveBuffer: Uint8Array; + receiveLength: number; +}; + +type ClientWebSocketSocketState = { + kind: 'websocket'; + mode: 'client'; + receiveQueue: Uint8Array[]; + sendQueue: Uint8Array[]; + webSocket: BrowserWebSocketWithSocket; +}; + +type ServerWebSocketSocketState = { + kind: 'websocket'; + mode: 'server'; + receiveQueue: Uint8Array[]; + sendQueue: Uint8Array[]; + webSocket: NodeWebSocketLike; +}; + +type WebSocketSocketState = ClientWebSocketSocketState | ServerWebSocketSocketState; + +type WebRTCSocketState = { + kind: 'webrtc'; + dataChannels: Map; + isHost: boolean; + onSignalingReady?: () => void; + peerConnections: Map; + peerId?: string | null; + receiveQueue: Uint8Array[]; + sendQueue: WebRTCQueuedMessage[]; + sessionId: string | null; +}; + +type QSocketTransportState = LoopbackSocketState | WebSocketSocketState | WebRTCSocketState | null; + +type BrowserWebSocketWithSocket = WebSocket & { + qsocket?: QSocket; +}; + +let { COM, Con, NET, Sys, SV } = getCommonRegistry(); + +eventBus.subscribe('registry.frozen', () => { + ({ COM, Con, NET, Sys, SV } = getCommonRegistry()); +}); + +/** + * Normalize thrown values into a printable error message. + * @param error + * @returns Human-readable error text. + */ +function getErrorMessage(error: Throwable): string { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string') { + return error.message; + } + + return String(error); +} + +/** + * Create loopback-only runtime state for a qsocket. + * @param peer + * @returns Initialized loopback transport state. + */ +function createLoopbackSocketState(peer: QSocket | null): LoopbackSocketState { + return { + kind: 'loopback', + peer, + receiveBuffer: new Uint8Array(new ArrayBuffer(8192)), + receiveLength: 0, + }; +} + +/** + * Create WebRTC runtime state for a qsocket. + * @param options + * @returns Initialized WebRTC transport state. + */ +function createWebRTCSocketState(options: { + isHost: boolean; + peerId?: string | null; + sessionId: string | null; +}): WebRTCSocketState { + const { + isHost, + peerId = null, + sessionId, + } = options; + + return { + kind: 'webrtc', + dataChannels: new Map(), + isHost, + peerConnections: new Map(), + peerId, + receiveQueue: [], + sendQueue: [], + sessionId, + }; +} + +/** + * Return loopback transport state when present. + * @param sock + * @returns Loopback state for the socket when available. + */ +function getLoopbackState(sock: QSocket): LoopbackSocketState | null { + return sock.transportState?.kind === 'loopback' ? sock.transportState : null; +} + +/** + * Return websocket transport state when present. + * @param sock + * @returns WebSocket state for the socket when available. + */ +function getWebSocketState(sock: QSocket): WebSocketSocketState | null { + return sock.transportState?.kind === 'websocket' ? sock.transportState : null; +} + +/** + * Return WebRTC transport state when present. + * @param sock + * @returns WebRTC state for the socket when available. + */ +function getWebRTCSocketState(sock: QSocket): WebRTCSocketState | null { + return sock.transportState?.kind === 'webrtc' ? sock.transportState : null; +} + +/** + * Copy a view into a detached ArrayBuffer for transport APIs that require it. + * @param data + * @returns A standalone buffer containing the input bytes. + */ +function toArrayBuffer(data: Uint8Array): ArrayBuffer { + const buffer = new ArrayBuffer(data.byteLength); + new Uint8Array(buffer).set(new Uint8Array(data.buffer, data.byteOffset, data.byteLength)); + return buffer; +} + +/** + * Normalize node websocket message payloads into a standalone Uint8Array. + * @param data + * @returns A standalone byte view of the incoming payload. + */ +function rawDataToUint8Array(data: NodeRawData): Uint8Array { + if (Array.isArray(data)) { + const totalLength = data.reduce((length, chunk) => length + chunk.byteLength, 0); + const combined = new Uint8Array(totalLength); + let offset = 0; + + for (const chunk of data) { + combined.set(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength), offset); + offset += chunk.byteLength; + } + + return combined; + } + + if (data instanceof ArrayBuffer) { + return new Uint8Array(data); + } + + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice(); +} + +export class QSocket { + static readonly STATE_NEW = 'new'; + static readonly STATE_CONNECTING = 'connecting'; + static readonly STATE_CONNECTED = 'connected'; + static readonly STATE_DISCONNECTING = 'disconnecting'; + static readonly STATE_DISCONNECTED = 'disconnected'; + + address: string | null = null; + canSend = false; + connecttime: number; + driver: BaseDriver; + lastMessageTime: number; + state: QSocketState = QSocket.STATE_NEW; + transportState: QSocketTransportState = null; + + constructor(driver: BaseDriver, time: number) { + this.driver = driver; + this.connecttime = time; + this.lastMessageTime = time; + } + + toString(): string { + return `QSocket(${this.address}, ${this.state})`; + } + + GetMessage(): number { + return this.driver.GetMessage(this); + } + + SendMessage(data: NetworkPayload): number { + return this.driver.SendMessage(this, data); + } + + SendUnreliableMessage(data: NetworkPayload): number { + return this.driver.SendUnreliableMessage(this, data); + } + + CanSendMessage(): boolean { + if (this.state !== QSocket.STATE_CONNECTED) { + return false; + } + + return this.driver.CanSendMessage(this); + } + + Close(): void { + this.driver.Close(this); + } +} + +export class BaseDriver { + initialized = false; + name: string; + + constructor(name: string) { + this.name = name; + } + + Init(): boolean { + return false; + } + + Shutdown(): void { + this.initialized = false; + } + + canHandle(_host: string): boolean { + return false; + } + + Connect(_host: string): QSocket | null { + return null; + } + + CheckNewConnections(): QSocket | null { + return null; + } + + CheckForResend(): number { + return -1; + } + + GetMessage(_qsocket: QSocket): number { + return -1; + } + + SendMessage(_qsocket: QSocket, _data: NetworkPayload): number { + return -1; + } + + SendUnreliableMessage(_qsocket: QSocket, _data: NetworkPayload): number { + return -1; + } + + CanSendMessage(_qsocket: QSocket): boolean { + return false; + } + + Close(qsocket: QSocket): void { + qsocket.state = QSocket.STATE_DISCONNECTED; + } + + ShouldListen(): boolean { + return true; + } + + Listen(_shouldListen: boolean): void { + } + + GetListenAddress(): string | null { + return null; + } +} + +export class LoopDriver extends BaseDriver { + _client: QSocket | null = null; + _server: QSocket | null = null; + localconnectpending = false; + + constructor() { + super('loop'); + } + + Init(): boolean { + this._server = null; + this._client = null; + this.localconnectpending = false; + this.initialized = true; + return true; + } + + canHandle(host: string): boolean { + return host === 'local'; + } + + Connect(host: string): QSocket | null { + if (host !== 'local') { + return null; + } + + this.localconnectpending = true; + + if (this._server === null) { + this._server = NET.NewQSocket(this); + } + + if (this._client === null) { + this._client = NET.NewQSocket(this); + } + + const server = this._server; + const client = this._client; + + if (server === null || client === null) { + return null; + } + + server.address = 'local server'; + client.address = 'local client'; + server.canSend = true; + client.canSend = true; + + server.transportState = createLoopbackSocketState(client); + client.transportState = createLoopbackSocketState(server); + + client.state = QSocket.STATE_CONNECTED; + server.state = QSocket.STATE_CONNECTED; + + return server; + } + + CheckNewConnections(): QSocket | null { + if (!this.localconnectpending) { + return null; + } + + this.localconnectpending = false; + + if (this._client === null || this._server === null) { + return null; + } + + const clientState = getLoopbackState(this._client); + const serverState = getLoopbackState(this._server); + + if (clientState === null || serverState === null) { + return null; + } + + clientState.receiveLength = 0; + this._client.canSend = true; + this._client.state = QSocket.STATE_CONNECTED; + + serverState.receiveLength = 0; + this._server.canSend = true; + this._server.state = QSocket.STATE_CONNECTED; + + return this._client; + } + + GetMessage(sock: QSocket): number { + const loopbackState = getLoopbackState(sock); + + if (loopbackState === null || loopbackState.receiveLength === 0) { + return 0; + } + + const buffer = loopbackState.receiveBuffer; + let receiveLength = loopbackState.receiveLength; + + const type = buffer[0]; + const length = buffer[1] + (buffer[2] << 8); + + if (length > NET.message.data.byteLength) { + throw new HostError('Loop.GetMessage: overflow'); + } + + NET.message.cursize = length; + new Uint8Array(NET.message.data).set(buffer.subarray(3, length + 3)); + + receiveLength -= length; + + if (receiveLength >= 4) { + buffer.copyWithin(0, length + 3, length + 3 + receiveLength); + } + + loopbackState.receiveLength = receiveLength - 3; + + const { peer } = loopbackState; + + if (peer !== null && type === 1) { + peer.canSend = true; + } + + if (sock.state === QSocket.STATE_DISCONNECTED) { + return -1; + } + + return type; + } + + SendMessage(sock: QSocket, data: NetworkPayload): number { + const peer = getLoopbackState(sock)?.peer ?? null; + const peerState = peer === null ? null : getLoopbackState(peer); + + if (peer === null || peerState === null) { + return -1; + } + + const bufferLength = peerState.receiveLength; + peerState.receiveLength += data.cursize + 3; + + if (peerState.receiveLength > 8192) { + throw new HostError('LoopDriver.SendMessage: overflow'); + } + + const buffer = peerState.receiveBuffer; + buffer[bufferLength] = 1; + buffer[bufferLength + 1] = data.cursize & 0xff; + buffer[bufferLength + 2] = data.cursize >> 8; + buffer.set(new Uint8Array(data.data, 0, data.cursize), bufferLength + 3); + sock.canSend = false; + + return 1; + } + + SendUnreliableMessage(sock: QSocket, data: NetworkPayload): number { + const peer = getLoopbackState(sock)?.peer ?? null; + const peerState = peer === null ? null : getLoopbackState(peer); + + if (peer === null || peerState === null) { + return -1; + } + + const bufferLength = peerState.receiveLength; + peerState.receiveLength += data.cursize + 3; + + if (peerState.receiveLength > 8192) { + throw new HostError('LoopDriver.SendUnreliableMessage: overflow'); + } + + const buffer = peerState.receiveBuffer; + buffer[bufferLength] = 2; + buffer[bufferLength + 1] = data.cursize & 0xff; + buffer[bufferLength + 2] = data.cursize >> 8; + buffer.set(new Uint8Array(data.data, 0, data.cursize), bufferLength + 3); + + return 1; + } + + CanSendMessage(sock: QSocket): boolean { + return getLoopbackState(sock)?.peer !== null ? sock.canSend : false; + } + + Close(sock: QSocket): void { + const loopbackState = getLoopbackState(sock); + const peer = loopbackState?.peer ?? null; + + if (peer !== null) { + const peerState = getLoopbackState(peer); + + if (peerState !== null) { + peerState.peer = null; + } + } + + if (loopbackState !== null) { + loopbackState.peer = null; + loopbackState.receiveLength = 0; + } + + sock.canSend = false; + + if (sock === this._server) { + this._server = null; + } else { + this._client = null; + } + + sock.state = QSocket.STATE_DISCONNECTED; + } +} + +export class WebSocketDriver extends BaseDriver { + newConnections: QSocket[] = []; + wss: NodeWebSocketServerLike | null = null; + + constructor() { + super('websocket'); + } + + Init(): boolean { + this.initialized = true; + this.newConnections = []; + return true; + } + + canHandle(host: string): boolean { + return /^wss?:\/\//i.test(host); + } + + Connect(host: string): QSocket | null { + if (!/^wss?:\/\//i.test(host)) { + return null; + } + + const url = new URL(host); + + if (!url.port) { + url.port = new URL(location.href).port; + } + + const sock = NET.NewQSocket(this); + + try { + sock.address = url.toString(); + const browserSocket = new WebSocket(url, 'quake') as BrowserWebSocketWithSocket; + browserSocket.binaryType = 'arraybuffer'; + sock.transportState = { + kind: 'websocket', + mode: 'client', + receiveQueue: [], + sendQueue: [], + webSocket: browserSocket, + }; + } catch (error) { + Con.PrintError(`WebSocketDriver.Connect: failed to setup ${url}, ${getErrorMessage(error as Throwable)}\n`); + return null; + } + + const socketState = getWebSocketState(sock); + + if (socketState === null || socketState.mode !== 'client') { + return null; + } + + const { webSocket: browserSocket } = socketState; + browserSocket.onerror = this._OnErrorClient; + browserSocket.onmessage = this._OnMessageClient; + browserSocket.onopen = this._OnOpenClient; + browserSocket.onclose = this._OnCloseClient; + + browserSocket.qsocket = sock; + sock.state = QSocket.STATE_CONNECTING; + + return sock; + } + + CanSendMessage(qsocket: QSocket): boolean { + const socketState = getWebSocketState(qsocket); + return socketState !== null ? ![2, 3].includes(socketState.webSocket.readyState) : false; + } + + GetMessage(qsocket: QSocket): number { + const socketState = getWebSocketState(qsocket); + + if (socketState === null) { + return qsocket.state === QSocket.STATE_DISCONNECTED ? -1 : 0; + } + + const { receiveQueue } = socketState; + + if (receiveQueue.length === 0) { + if (qsocket.state === QSocket.STATE_DISCONNECTED) { + return -1; + } + + if (qsocket.state === QSocket.STATE_DISCONNECTING) { + qsocket.state = QSocket.STATE_DISCONNECTED; + } + + return 0; + } + + const message = receiveQueue.shift(); + + if (message === undefined) { + return 0; + } + + const type = message[0]; + const length = message[1] + (message[2] << 8); + new Uint8Array(NET.message.data).set(message.subarray(3, length + 3)); + NET.message.cursize = length; + + return type; + } + + _FlushSendBuffer(qsocket: QSocket): boolean { + const socketState = getWebSocketState(qsocket); + + if (socketState === null) { + return false; + } + + const { sendQueue, webSocket } = socketState; + + switch (webSocket.readyState) { + case 2: + case 3: + Con.DPrint(`WebSocketDriver._FlushSendBuffer: connection already died (readyState = ${webSocket.readyState})`); + return false; + + case 0: + return true; + } + + while (sendQueue.length > 0) { + const message = sendQueue.shift(); + + if (message === undefined) { + break; + } + + if (NET.delay_send.value === 0) { + webSocket.send(toArrayBuffer(message)); + } else { + setTimeout(() => { + webSocket.send(toArrayBuffer(message)); + + if (webSocket.readyState > 1) { + qsocket.state = QSocket.STATE_DISCONNECTED; + } + }, NET.delay_send.value + (Math.random() - 0.5) * NET.delay_send_jitter.value); + } + } + + return true; + } + + _SendRawMessage(qsocket: QSocket, data: Uint8Array): number { + const socketState = getWebSocketState(qsocket); + + if (socketState === null) { + return -1; + } + + socketState.sendQueue.push(data); + this._FlushSendBuffer(qsocket); + return qsocket.state !== QSocket.STATE_DISCONNECTED ? 1 : -1; + } + + SendMessage(qsocket: QSocket, data: NetworkPayload): number { + const buffer = new Uint8Array(data.cursize + 3); + let index = 0; + buffer[index++] = 1; + buffer[index++] = data.cursize & 0xff; + buffer[index++] = (data.cursize >> 8) & 0xff; + buffer.set(new Uint8Array(data.data, 0, data.cursize), index); + return this._SendRawMessage(qsocket, buffer); + } + + SendUnreliableMessage(qsocket: QSocket, data: NetworkPayload): number { + const buffer = new Uint8Array(data.cursize + 3); + let index = 0; + buffer[index++] = 2; + buffer[index++] = data.cursize & 0xff; + buffer[index++] = (data.cursize >> 8) & 0xff; + buffer.set(new Uint8Array(data.data, 0, data.cursize), index); + return this._SendRawMessage(qsocket, buffer); + } + + Close(qsocket: QSocket): void { + const socketState = getWebSocketState(qsocket); + + if (socketState !== null && this.CanSendMessage(qsocket)) { + this._FlushSendBuffer(qsocket); + socketState.webSocket.close(1000); + } + + qsocket.state = QSocket.STATE_DISCONNECTED; + } + + _OnErrorClient(this: BrowserWebSocketWithSocket, _error: Event): void { + if (this.qsocket === undefined) { + return; + } + + Con.PrintError(`WebSocketDriver._OnErrorClient: lost connection to ${this.qsocket.address}\n`); + this.qsocket.state = QSocket.STATE_DISCONNECTED; + } + + _OnMessageClient(this: BrowserWebSocketWithSocket, message: MessageEvent): void { + if (this.qsocket === undefined) { + return; + } + + const data = message.data; + + if (typeof data === 'string') { + return; + } + + const socketState = getWebSocketState(this.qsocket); + + if (socketState === null) { + return; + } + + if (NET.delay_receive.value === 0) { + socketState.receiveQueue.push(new Uint8Array(data)); + return; + } + + setTimeout(() => { + socketState.receiveQueue.push(new Uint8Array(data)); + }, NET.delay_receive.value + (Math.random() - 0.5) * NET.delay_receive_jitter.value); + } + + _OnOpenClient(this: BrowserWebSocketWithSocket): void { + if (this.qsocket !== undefined) { + this.qsocket.state = QSocket.STATE_CONNECTED; + } + } + + _OnCloseClient(this: BrowserWebSocketWithSocket): void { + if (this.qsocket === undefined || this.qsocket.state !== QSocket.STATE_CONNECTED) { + return; + } + + Con.DPrint('WebSocketDriver._OnCloseClient: connection closed.\n'); + this.qsocket.state = QSocket.STATE_DISCONNECTING; + } + + _OnConnectionServer(ws: NodeWebSocketLike, req: NodeIncomingMessageLike): void { + Con.DPrint('WebSocketDriver._OnConnectionServer: received new connection\n'); + + const sock = NET.NewQSocket(this); + sock.transportState = { + kind: 'websocket', + mode: 'server', + receiveQueue: [], + sendQueue: [], + webSocket: ws, + }; + + const forwardedFor = req.headers['x-forwarded-for']; + const address = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor ?? req.socket.remoteAddress ?? ''; + + sock.address = formatIP(address, req.socket.remotePort ?? 0); + sock.state = QSocket.STATE_CONNECTED; + + NET.time = Sys.FloatTime(); + sock.lastMessageTime = NET.time; + + ws.on('close', () => { + Con.DPrint('WebSocketDriver._OnConnectionServer.disconnect: client disconnected\n'); + sock.state = QSocket.STATE_DISCONNECTED; + eventBus.publish('net.connection.close', sock); + }); + + ws.on('error', () => { + Con.DPrint('WebSocketDriver._OnConnectionServer.disconnect: client errored out\n'); + sock.state = QSocket.STATE_DISCONNECTED; + eventBus.publish('net.connection.error', sock); + }); + + ws.on('message', (data: NodeRawData) => { + const socketState = getWebSocketState(sock); + + if (socketState !== null) { + socketState.receiveQueue.push(rawDataToUint8Array(data)); + } + }); + + this.newConnections.push(sock); + eventBus.publish('net.connection.accepted', sock); + } + + CheckNewConnections(): QSocket | null { + return this.newConnections.shift() ?? null; + } + + ShouldListen(): boolean { + return registry.isDedicatedServer && NET.server !== null; + } + + Listen(listening: boolean): void { + if (this.wss !== null) { + if (!listening) { + this.wss.close(); + this.wss = null; + } + + return; + } + + if (!listening || NET.server === null) { + return; + } + + const { WebSocket: WebSocketModule } = getCommonRegistry(); + const nodeWebSocketModule = WebSocketModule as NodeWebSocketModuleLike; + + this.wss = new nodeWebSocketModule.WebSocketServer({ server: NET.server }); + this.wss.on('connection', this._OnConnectionServer.bind(this)); + this.newConnections = []; + } + + GetListenAddress(): string | null { + if (this.wss === null) { + return null; + } + + const address = this.wss.address(); + + if (address === null || typeof address === 'string') { + return address; + } + + const socketAddress = address as ListenAddress; + return formatIP(socketAddress.address, socketAddress.port); + } +} + +export class WebRTCDriver extends BaseDriver { + creatingSession = false; + hostToken: string | null = null; + iceServers: RTCIceServer[] = [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun.cloudflare.com:3478' }, + { urls: 'stun:stun.nextcloud.com:443' }, + ]; + isHost = false; + newConnections: QSocket[] = []; + peerId: string | null = null; + pendingConnections = new Map(); + pingInterval: ReturnType | null = null; + reconnectTimer: ReturnType | null = null; + serverEventSubscriptions: Array<() => void> = []; + sessionId: string | null = null; + signalingUrl: string | null = null; + signalingWs: WebSocket | null = null; + + constructor() { + super('webrtc'); + } + + Init(): boolean { + if (registry.isDedicatedServer) { + this.initialized = false; + return false; + } + + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + this.signalingUrl = `${protocol}//${location.hostname}:8787/signaling`; + + if (registry.urls?.signalingURL) { + this.signalingUrl = registry.urls.signalingURL; + } + + this.initialized = true; + Con.DPrint(`WebRTCDriver: Initialized with signaling at ${this.signalingUrl}\n`); + return true; + } + + canHandle(host: string): boolean { + return /^webrtc:\/\//i.test(host) || host === 'host'; + } + + Connect(host: string): QSocket | null { + if (!/^webrtc:\/\//i.test(host)) { + return null; + } + + let sessionId: string | null = null; + let shouldCreateSession = false; + + if (host.startsWith('webrtc://')) { + host = host.substring(9); + } + + if (host === 'host' || host === '') { + shouldCreateSession = true; + } else { + sessionId = host; + } + + if (!this._ConnectSignaling()) { + Con.PrintError('WebRTCDriver.Connect: Failed to connect to signaling server\n'); + return null; + } + + const sock = NET.NewQSocket(this); + sock.state = QSocket.STATE_CONNECTING; + sock.address = shouldCreateSession ? 'WebRTC Host' : `WebRTC Session ${sessionId}`; + sock.transportState = createWebRTCSocketState({ sessionId, isHost: shouldCreateSession }); + + const onSignalingReady = () => { + if (shouldCreateSession) { + this._CreateSession(sock); + } else { + this._JoinSession(sock, sessionId); + } + }; + + if (this.signalingWs !== null && this.signalingWs.readyState === 1) { + onSignalingReady(); + } else { + const socketState = getWebRTCSocketState(sock); + + if (socketState !== null) { + socketState.onSignalingReady = onSignalingReady; + } + } + + return sock; + } + + _ConnectSignaling(): boolean { + if (this.signalingWs !== null) { + if (this.signalingWs.readyState === 1 || this.signalingWs.readyState === 0) { + return true; + } + } + + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + try { + this.signalingWs = new WebSocket(this.signalingUrl ?? ''); + + this.signalingWs.onopen = () => { + Con.DPrint(`WebRTCDriver: Connected to signaling server at ${this.signalingUrl}\n`); + const previousSessionId = this.sessionId; + this._ProcessPendingSignaling(); + + if (previousSessionId !== null && previousSessionId === this.sessionId) { + this._RestoreSession(); + } + }; + + this.signalingWs.onmessage = async (event: MessageEvent) => { + if (typeof event.data !== 'string') { + return; + } + + await this._OnSignalingMessage(JSON.parse(event.data) as SignalingMessage); + }; + + this.signalingWs.onerror = (errorEvent: Event) => { + console.debug('WebRTCDriver: Signaling WebSocket error', errorEvent); + Con.DPrint(`WebRTCDriver: Signaling error: ${errorEvent}\n`); + this._OnSignalingError({ error: 'Signaling connection error', type: 'error' }); + }; + + this.signalingWs.onclose = (closeEvent: CloseEvent) => { + Con.DPrint('WebRTCDriver: Signaling connection closed\n'); + this.signalingWs = null; + + if (closeEvent.code !== 1000) { + Con.PrintError(`Signaling connection closed unexpectedly, ${closeEvent.reason || 'unknown reason'} (code: ${closeEvent.code})\n`); + Con.PrintWarning(`Signaling server at ${this.signalingUrl} might be unavailable.\n`); + } + + this._OnSignalingError({ error: 'Signaling connection closed', type: 'error' }); + this._ScheduleReconnect(); + }; + + return true; + } catch (error) { + Con.PrintError(`WebRTCDriver: Failed to connect to signaling at ${this.signalingUrl}:\n${getErrorMessage(error as Throwable)}\n`); + this._ScheduleReconnect(); + return false; + } + } + + _ScheduleReconnect(): void { + if (this.reconnectTimer !== null) { + return; + } + + const delay = 5000; + Con.DPrint(`WebRTCDriver: Scheduling reconnect in ${delay}ms...\n`); + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + Con.DPrint('WebRTCDriver: Attempting to reconnect...\n'); + this._ConnectSignaling(); + }, delay); + } + + _RestoreSession(): void { + if (this.isHost) { + Con.DPrint('WebRTCDriver: Restoring host session...\n'); + this._SendSignaling({ + type: 'create-session', + sessionId: this.sessionId ?? undefined, + hostToken: this.hostToken ?? undefined, + serverInfo: this._GatherServerInfo(), + isPublic: this._IsSessionPublic(), + }); + return; + } + + Con.DPrint(`WebRTCDriver: Restoring client session ${this.sessionId}\n`); + this._SendSignaling({ + type: 'join-session', + sessionId: this.sessionId ?? undefined, + }); + } + + _ProcessPendingSignaling(): void { + for (const sock of NET.activeSockets) { + const socketData = sock === undefined ? null : getWebRTCSocketState(sock); + + if (sock !== undefined && sock.driver === this && socketData?.onSignalingReady !== undefined) { + socketData.onSignalingReady(); + socketData.onSignalingReady = undefined; + } + } + } + + _SendSignaling(message: SignalingMessage): void { + if (this.signalingWs !== null && this.signalingWs.readyState === 1) { + this.signalingWs.send(JSON.stringify(message)); + } + } + + _StartPingInterval(): void { + if (!this.isHost) { + return; + } + + this._StopPingInterval(); + this.pingInterval = setInterval(() => { + this._SendSignaling({ type: 'ping' }); + }, 30 * 1000); + this._SendSignaling({ type: 'ping' }); + } + + _StopPingInterval(): void { + if (this.pingInterval !== null) { + clearInterval(this.pingInterval); + this.pingInterval = null; + } + } + + _StartServerInfoSubscriptions(): void { + if (!this.isHost) { + return; + } + + this._StopServerInfoSubscriptions(); + this.serverEventSubscriptions.push(eventBus.subscribe('server.spawned', () => this._UpdateServerInfo())); + this.serverEventSubscriptions.push(eventBus.subscribe('server.client.connected', () => this._UpdateServerInfo())); + this.serverEventSubscriptions.push(eventBus.subscribe('server.client.disconnected', () => this._UpdateServerInfo())); + this.serverEventSubscriptions.push(eventBus.subscribe('cvar.changed', (cvarName: string) => { + const cvar = Cvar.FindVar(cvarName); + + if (cvar !== null && (cvar.flags & Cvar.FLAG.SERVER) !== 0) { + this._UpdateServerInfo(); + } + })); + this._UpdateServerInfo(); + } + + _StopServerInfoSubscriptions(): void { + while (this.serverEventSubscriptions.length > 0) { + const unsubscribe = this.serverEventSubscriptions.pop(); + + if (unsubscribe !== undefined) { + unsubscribe(); + } + } + } + + _UpdateServerInfo(): void { + if (!this.isHost || this.sessionId === null) { + return; + } + + this._SendSignaling({ + type: 'update-server-info', + serverInfo: this._GatherServerInfo(), + isPublic: this._IsSessionPublic(), + }); + } + + _GatherServerInfo(): ServerInfo { + const serverInfo: ServerInfo = { + hostname: Cvar.FindVar('hostname')?.string ?? 'UNNAMED', + maxPlayers: SV.svs.maxclients, + currentPlayers: NET.activeconnections, + map: SV.server.mapname, + mod: COM.game, + settings: {}, + }; + + for (const cvar of Cvar.Filter((cvar: Cvar) => (cvar.flags & Cvar.FLAG.SERVER) !== 0)) { + serverInfo.settings[cvar.name] = cvar.string; + } + + return serverInfo; + } + + _IsSessionPublic(): boolean { + return (Cvar.FindVar('sv_public')?.value ?? 0) !== 0; + } + + _CreateSession(sock: QSocket): void { + this._SendSignaling({ type: 'create-session' }); + const socketState = getWebRTCSocketState(sock); + + if (socketState !== null) { + socketState.isHost = true; + } + + this.isHost = true; + } + + _JoinSession(sock: QSocket, sessionId: string | null): void { + this._SendSignaling({ + type: 'join-session', + sessionId: sessionId ?? undefined, + }); + const socketState = getWebRTCSocketState(sock); + + if (socketState !== null) { + socketState.sessionId = sessionId; + } + + this.sessionId = sessionId; + } + + async _OnSignalingMessage(message: SignalingMessage): Promise { + switch (message.type) { + case 'session-created': + this._OnSessionCreated(message); + return; + case 'session-joined': + this._OnSessionJoined(message); + return; + case 'peer-joined': + this._OnPeerJoined(message); + return; + case 'peer-left': + this._OnPeerLeft(message); + return; + case 'offer': + await this._OnOffer(message); + return; + case 'answer': + await this._OnAnswer(message); + return; + case 'ice-candidate': + await this._OnIceCandidate(message); + return; + case 'session-closed': + this._OnSessionClosed(message); + return; + case 'pong': + return; + case 'error': + Con.DPrint(`WebRTCDriver: Signaling error: ${message.error}\n`); + this._OnSignalingError(message); + return; + default: + Con.DPrint(`WebRTCDriver: Unknown signaling message: ${message.type}\n`); + } + } + + _OnSignalingError(message: SignalingMessage): void { + let failedSocket: QSocket | null = null; + + for (const sock of NET.activeSockets) { + if (sock !== undefined && sock.driver === this && sock.state === QSocket.STATE_CONNECTING) { + const socketData = getWebRTCSocketState(sock); + + if (socketData !== null) { + if (socketData.sessionId && (message.error ?? '').includes(socketData.sessionId)) { + failedSocket = sock; + break; + } + + if (socketData.isHost && (message.error ?? '').includes('already exists')) { + failedSocket = sock; + break; + } + } + + if (failedSocket === null) { + failedSocket = sock; + } + } + } + + if (failedSocket !== null) { + Con.PrintError(`WebRTCDriver: Connection failed - ${message.error}\n`); + failedSocket.state = QSocket.STATE_DISCONNECTED; + + const webRtcData = getWebRTCSocketState(failedSocket); + + if (webRtcData !== null && webRtcData.sessionId === this.sessionId) { + this.sessionId = null; + this.peerId = null; + this.hostToken = null; + this.isHost = false; + } + + return; + } + + Con.PrintWarning(`WebRTCDriver: Signaling error (no matching socket): ${message.error}\n`); + } + + _OnSessionCreated(message: SignalingMessage): void { + this.sessionId = message.sessionId ?? null; + this.peerId = message.peerId ?? null; + this.isHost = message.isHost ?? false; + this.hostToken = message.hostToken ?? null; + this.creatingSession = false; + + Con.DPrint(`WebRTCDriver: Session created: ${this.sessionId}\n`); + Con.DPrint(`WebRTCDriver: Your peer ID: ${this.peerId}\n`); + + let sock: QSocket | null = null; + + for (const activeSocket of NET.activeSockets) { + const socketData = activeSocket === undefined ? null : getWebRTCSocketState(activeSocket); + + if (activeSocket !== undefined && activeSocket.driver === this && socketData !== null && socketData.isHost) { + sock = activeSocket; + break; + } + } + + if (sock === null) { + sock = this._FindSocketBySession(this.sessionId); + } + + const socketData = sock === null ? null : getWebRTCSocketState(sock); + + if (sock !== null && socketData !== null) { + socketData.sessionId = this.sessionId; + sock.state = QSocket.STATE_CONNECTED; + sock.address = `WebRTC Host (${this.sessionId})`; + Con.DPrint('WebRTCDriver: Host socket ready for accepting peers\n'); + this._StartPingInterval(); + this._StartServerInfoSubscriptions(); + + if (message.existingPeers !== undefined && message.existingPeers.length > 0) { + Con.DPrint(`WebRTCDriver: Reconnecting to ${message.existingPeers.length} existing peers...\n`); + + for (const peerId of message.existingPeers) { + this._OnPeerJoined({ type: 'peer-joined', peerId }); + } + } + + return; + } + + Con.PrintWarning(`WebRTCDriver: No socket found for session ${this.sessionId}\n`); + } + + _OnSessionJoined(message: SignalingMessage): void { + this.sessionId = message.sessionId ?? null; + this.peerId = message.peerId ?? null; + this.isHost = message.isHost ?? false; + + Con.DPrint(`WebRTCDriver: Joined session: ${this.sessionId}\n`); + Con.DPrint(`WebRTCDriver: Your peer ID: ${this.peerId}\n`); + Con.DPrint(`WebRTCDriver: Peers in session: ${message.peerCount}\n`); + + const sock = this._FindSocketBySession(this.sessionId); + + if (sock !== null) { + sock.address = `WebRTC Peer (${this.sessionId})`; + Con.DPrint('WebRTCDriver: Socket found, waiting for P2P connection\n'); + return; + } + + Con.PrintWarning(`WebRTCDriver: No socket found for joined session ${this.sessionId}\n`); + } + + _OnPeerJoined(message: SignalingMessage): void { + if (message.peerId === undefined) { + return; + } + + Con.DPrint(`WebRTCDriver: Peer ${message.peerId} joined\n`); + + if (this.isHost) { + const peerSock = NET.NewQSocket(this); + peerSock.state = QSocket.STATE_CONNECTING; + peerSock.address = `WebRTC Peer ${message.peerId}`; + peerSock.transportState = createWebRTCSocketState({ + sessionId: this.sessionId, + isHost: false, + peerId: message.peerId, + }); + + this._CreatePeerConnection(peerSock, message.peerId, true); + this.newConnections.push(peerSock); + Con.DPrint(`WebRTCDriver: Created socket for peer ${message.peerId}, added to new connections\n`); + } + } + + _OnPeerLeft(message: SignalingMessage): void { + if (message.peerId !== undefined) { + Con.DPrint(`WebRTCDriver: Peer ${message.peerId} left\n`); + this._ClosePeerConnection(message.peerId); + } + } + + async _OnOffer(message: SignalingMessage): Promise { + if (message.fromPeerId === undefined || message.offer === undefined || message.offer === null) { + return; + } + + Con.DPrint(`WebRTCDriver: Received offer from ${message.fromPeerId}\n`); + + const sock = this._FindSocketBySession(this.sessionId); + + if (sock === null) { + Con.PrintWarning('WebRTCDriver._OnOffer: No socket found for session\n'); + return; + } + + const peerConnection = this._CreatePeerConnection(sock, message.fromPeerId, false); + + if (peerConnection === null) { + return; + } + + try { + await peerConnection.setRemoteDescription(new RTCSessionDescription(message.offer)); + const answer = await peerConnection.createAnswer(); + await peerConnection.setLocalDescription(answer); + this._SendSignaling({ + type: 'answer', + targetPeerId: message.fromPeerId, + answer: peerConnection.localDescription, + }); + } catch (error) { + Con.PrintError(`WebRTCDriver: Error handling offer: ${getErrorMessage(error as Throwable)}\n`); + } + } + + async _OnAnswer(message: SignalingMessage): Promise { + if (message.fromPeerId === undefined || message.answer === undefined || message.answer === null) { + return; + } + + Con.DPrint(`WebRTCDriver: Received answer from ${message.fromPeerId}\n`); + + const sock = this.isHost ? this._FindSocketByPeerId(message.fromPeerId) : this._FindSocketBySession(this.sessionId); + + const socketData = sock === null ? null : getWebRTCSocketState(sock); + + if (sock === null || socketData === null) { + Con.PrintWarning(`WebRTCDriver._OnAnswer: No socket found for ${message.fromPeerId}\n`); + return; + } + + const peerConnection = socketData.peerConnections.get(message.fromPeerId); + + if (peerConnection === undefined) { + Con.PrintWarning(`WebRTCDriver: No peer connection found for ${message.fromPeerId}\n`); + return; + } + + try { + await peerConnection.setRemoteDescription(new RTCSessionDescription(message.answer)); + Con.DPrint(`WebRTCDriver: Answer processed for ${message.fromPeerId}\n`); + } catch (error) { + Con.PrintError(`WebRTCDriver: Error handling answer: ${getErrorMessage(error as Throwable)}\n`); + } + } + + async _OnIceCandidate(message: SignalingMessage): Promise { + if (message.fromPeerId === undefined) { + return; + } + + const sock = this.isHost ? this._FindSocketByPeerId(message.fromPeerId) : this._FindSocketBySession(this.sessionId); + + const socketData = sock === null ? null : getWebRTCSocketState(sock); + + if (sock === null || socketData === null) { + return; + } + + const peerConnection = socketData.peerConnections.get(message.fromPeerId); + + if (peerConnection === undefined) { + return; + } + + try { + if (message.candidate) { + await peerConnection.addIceCandidate(new RTCIceCandidate(message.candidate)); + } + } catch (error) { + Con.DPrint(`WebRTCDriver: Error adding ICE candidate: ${getErrorMessage(error as Throwable)}\n`); + } + } + + _OnSessionClosed(message: SignalingMessage): void { + Con.DPrint(`WebRTCDriver: Session closed: ${message.reason}\n`); + + const sock = this._FindSocketBySession(this.sessionId); + + if (sock !== null) { + sock.state = QSocket.STATE_DISCONNECTED; + } + + this.sessionId = null; + this.peerId = null; + this.isHost = false; + } + + _CreatePeerConnection(sock: QSocket, peerId: string, initiator: boolean): RTCPeerConnection | null { + console.assert(sock.transportState?.kind === 'webrtc', 'WebRTCDriver._CreatePeerConnection: Invalid socket'); + + const socketData = getWebRTCSocketState(sock); + + if (socketData === null) { + Con.PrintError('WebRTCDriver._CreatePeerConnection: No socket provided\n'); + return null; + } + + if (socketData.peerConnections.has(peerId)) { + return socketData.peerConnections.get(peerId) ?? null; + } + + Con.DPrint(`WebRTCDriver: Creating peer connection to ${peerId} (initiator: ${initiator})\n`); + + const peerConnection = new RTCPeerConnection({ iceServers: this.iceServers }); + socketData.peerConnections.set(peerId, peerConnection); + + peerConnection.onicecandidate = (event) => { + if (event.candidate) { + Con.DPrint(`WebRTCDriver: Sending ICE candidate to ${peerId}\n`); + this._SendSignaling({ + type: 'ice-candidate', + targetPeerId: peerId, + candidate: event.candidate, + }); + return; + } + + Con.DPrint(`WebRTCDriver: ICE gathering complete for ${peerId}\n`); + }; + + peerConnection.oniceconnectionstatechange = () => { + Con.DPrint(`WebRTCDriver: ICE state with ${peerId}: ${peerConnection.iceConnectionState}\n`); + }; + + peerConnection.onconnectionstatechange = () => { + Con.DPrint(`WebRTCDriver: Connection state with ${peerId}: ${peerConnection.connectionState}\n`); + + if (peerConnection.connectionState === 'connected') { + Con.DPrint(`WebRTCDriver: P2P connection established with ${peerId}\n`); + } else if (peerConnection.connectionState === 'failed' || peerConnection.connectionState === 'disconnected') { + Con.DPrint(`WebRTCDriver: Connection ${peerConnection.connectionState} with ${peerId}\n`); + this._ClosePeerConnection(peerId); + } + }; + + if (initiator) { + const reliableChannel = peerConnection.createDataChannel('reliable', { ordered: true }); + const unreliableChannel = peerConnection.createDataChannel('unreliable', { ordered: false, maxRetransmits: 10 }); + this._SetupDataChannel(sock, peerId, reliableChannel, unreliableChannel); + + void peerConnection.createOffer() + .then((offer) => peerConnection.setLocalDescription(offer)) + .then(() => { + this._SendSignaling({ + type: 'offer', + targetPeerId: peerId, + offer: peerConnection.localDescription, + }); + }) + .catch((error) => { + Con.PrintError(`WebRTCDriver: Error creating offer: ${getErrorMessage(error)}\n`); + }); + } else { + peerConnection.ondatachannel = (event) => { + const liveSocketData = getWebRTCSocketState(sock); + + if (liveSocketData === null) { + return; + } + + const channel = event.channel; + + if (!liveSocketData.dataChannels.has(peerId)) { + liveSocketData.dataChannels.set(peerId, {}); + } + + const channels = liveSocketData.dataChannels.get(peerId); + + if (channels === undefined) { + return; + } + + if (channel.label === 'reliable') { + channels.reliable = channel; + this._SetupDataChannelHandlers(sock, peerId, channel); + } else if (channel.label === 'unreliable') { + channels.unreliable = channel; + this._SetupDataChannelHandlers(sock, peerId, channel); + } + }; + } + + return peerConnection; + } + + _SetupDataChannel(sock: QSocket, peerId: string, reliableChannel: RTCDataChannel, unreliableChannel: RTCDataChannel): void { + const socketData = getWebRTCSocketState(sock); + + if (socketData === null) { + return; + } + + socketData.dataChannels.set(peerId, { + reliable: reliableChannel, + unreliable: unreliableChannel, + }); + + this._SetupDataChannelHandlers(sock, peerId, reliableChannel); + this._SetupDataChannelHandlers(sock, peerId, unreliableChannel); + } + + _SetupDataChannelHandlers(sock: QSocket, peerId: string, channel: RTCDataChannel): void { + channel.binaryType = 'arraybuffer'; + + channel.onopen = () => { + Con.DPrint(`WebRTCDriver: Data channel ${channel.label} opened with ${peerId}\n`); + + if (channel.label === 'reliable' && sock.state !== QSocket.STATE_CONNECTED) { + sock.state = QSocket.STATE_CONNECTED; + Con.DPrint('WebRTCDriver: Socket now CONNECTED (can send/receive data)\n'); + } + + this._FlushSendBuffer(sock); + }; + + channel.onclose = () => { + Con.DPrint(`WebRTCDriver: Data channel ${channel.label} closed with ${peerId}\n`); + sock.state = QSocket.STATE_DISCONNECTED; + }; + + channel.onerror = (error) => { + Con.PrintError(`WebRTCDriver: Data channel error with ${peerId}: ${error}\n`); + sock.state = QSocket.STATE_DISCONNECTED; + }; + + channel.onmessage = (event) => { + const socketState = getWebRTCSocketState(sock); + + if (socketState !== null) { + socketState.receiveQueue.push(new Uint8Array(event.data)); + } + }; + } + + _ClosePeerConnection(peerId: string): void { + const sock = this.isHost ? this._FindSocketByPeerId(peerId) : this._FindSocketBySession(this.sessionId); + + const socketData = sock === null ? null : getWebRTCSocketState(sock); + + if (sock === null || socketData === null) { + Con.DPrint(`WebRTCDriver._ClosePeerConnection: No socket found for ${peerId}\n`); + return; + } + + sock.state = QSocket.STATE_DISCONNECTING; + + const peerConnection = socketData.peerConnections.get(peerId); + + if (peerConnection !== undefined) { + peerConnection.close(); + socketData.peerConnections.delete(peerId); + } + + socketData.dataChannels.delete(peerId); + sock.state = QSocket.STATE_DISCONNECTED; + } + + _FindSocketBySession(sessionId: string | null): QSocket | null { + for (const sock of NET.activeSockets) { + const socketData = sock === undefined ? null : getWebRTCSocketState(sock); + + if (sock !== undefined && sock.driver === this && socketData !== null && socketData.sessionId === sessionId) { + return sock; + } + } + + return null; + } + + _FindSocketByPeerId(peerId: string): QSocket | null { + for (const sock of NET.activeSockets) { + const socketData = sock === undefined ? null : getWebRTCSocketState(sock); + + if (sock !== undefined && sock.driver === this && socketData !== null && socketData.peerId === peerId) { + return sock; + } + } + + return null; + } + + CheckNewConnections(): QSocket | null { + const sock = this.newConnections.shift() ?? null; + + if (sock !== null) { + Con.DPrint(`WebRTCDriver.CheckNewConnections: returning new connection ${sock.address}\n`); + } + + return sock; + } + + _FlushSendBuffer(qsocket: QSocket): void { + const webRtcData = getWebRTCSocketState(qsocket); + + if (webRtcData === null) { + return; + } + + const queue = webRtcData.sendQueue; + + while (queue.length > 0) { + const message = queue[0]; + let canSendThis = false; + + for (const channels of webRtcData.dataChannels.values()) { + const channel = message.reliable ? channels.reliable : channels.unreliable; + + if (channel !== undefined && channel.readyState === 'open') { + canSendThis = true; + break; + } + } + + if (!canSendThis) { + break; + } + + const result = this._SendToAllPeers(qsocket, message.buffer, message.reliable); + + if (result > 0) { + queue.shift(); + } else { + break; + } + } + + if (queue.length === 0 && qsocket.state === QSocket.STATE_DISCONNECTING) { + Con.DPrint(`WebRTCDriver._FlushSendBuffer: buffer drained, closing ${qsocket.address}\n`); + this._ForceClose(qsocket); + } + } + + GetMessage(qsocket: QSocket): number { + const socketData = getWebRTCSocketState(qsocket); + + if (socketData === null) { + return qsocket.state === QSocket.STATE_DISCONNECTED ? -1 : 0; + } + + const { receiveQueue } = socketData; + + if (receiveQueue.length === 0) { + if (qsocket.state === QSocket.STATE_DISCONNECTED) { + return -1; + } + + if (qsocket.state === QSocket.STATE_DISCONNECTING) { + qsocket.state = QSocket.STATE_DISCONNECTED; + return -1; + } + + return 0; + } + + const message = receiveQueue.shift(); + + if (message === undefined) { + return 0; + } + + const type = message[0]; + const length = message[1] + (message[2] << 8); + new Uint8Array(NET.message.data).set(message.subarray(3, length + 3)); + NET.message.cursize = length; + + return type; + } + + SendMessage(qsocket: QSocket, data: NetworkPayload): number { + const socketData = getWebRTCSocketState(qsocket); + + if (socketData === null) { + return -1; + } + + const buffer = new Uint8Array(data.cursize + 3); + buffer[0] = 1; + buffer[1] = data.cursize & 0xff; + buffer[2] = (data.cursize >> 8) & 0xff; + buffer.set(new Uint8Array(data.data, 0, data.cursize), 3); + socketData.sendQueue.push({ buffer, reliable: true }); + this._FlushSendBuffer(qsocket); + return 1; + } + + SendUnreliableMessage(qsocket: QSocket, data: NetworkPayload): number { + const socketData = getWebRTCSocketState(qsocket); + + if (socketData === null) { + return -1; + } + + const buffer = new Uint8Array(data.cursize + 3); + buffer[0] = 2; + buffer[1] = data.cursize & 0xff; + buffer[2] = (data.cursize >> 8) & 0xff; + buffer.set(new Uint8Array(data.data, 0, data.cursize), 3); + socketData.sendQueue.push({ buffer, reliable: false }); + this._FlushSendBuffer(qsocket); + return 1; + } + + _SendToAllPeers(qsocket: QSocket, buffer: Uint8Array, reliable: boolean): number { + console.assert(qsocket.transportState?.kind === 'webrtc', 'WebRTCDriver._SendToAllPeers: Invalid socket'); + + const socketData = getWebRTCSocketState(qsocket); + + if (socketData === null) { + Con.PrintError('WebRTCDriver._SendToAllPeers: missing WebRTC transport state\n'); + return -1; + } + + let sentCount = 0; + + for (const [peerId, channels] of socketData.dataChannels) { + const channel = reliable ? channels.reliable : channels.unreliable; + + if (channel === undefined || channel.readyState !== 'open') { + Con.DPrint(`WebRTCDriver._SendToAllPeers: channel to ${peerId} not open (state=${channel?.readyState})\n`); + continue; + } + + try { + channel.send(toArrayBuffer(buffer)); + sentCount++; + } catch (error) { + Con.DPrint(`WebRTCDriver: Error sending to ${peerId}: ${getErrorMessage(error as Throwable)}\n`); + } + } + + if (sentCount === 0) { + Con.DPrint('WebRTCDriver._SendToAllPeers: no peers available to send to\n'); + } + + return sentCount > 0 ? 1 : -1; + } + + CanSendMessage(qsocket: QSocket): boolean { + const socketData = getWebRTCSocketState(qsocket); + + if (socketData === null) { + return false; + } + + for (const channels of socketData.dataChannels.values()) { + if (channels.reliable !== undefined && channels.reliable.readyState === 'open') { + return true; + } + } + + return false; + } + + Close(qsocket: QSocket): void { + const socketData = getWebRTCSocketState(qsocket); + + if (socketData === null) { + qsocket.state = QSocket.STATE_DISCONNECTED; + return; + } + + this._FlushSendBuffer(qsocket); + + if (socketData.sendQueue.length > 0 && qsocket.state !== QSocket.STATE_DISCONNECTED) { + if (socketData.dataChannels.size > 0) { + Con.DPrint(`WebRTCDriver.Close: delaying close for ${qsocket.address} to flush buffer\n`); + qsocket.state = QSocket.STATE_DISCONNECTING; + + setTimeout(() => { + if (qsocket.state === QSocket.STATE_DISCONNECTING) { + Con.DPrint(`WebRTCDriver.Close: timeout waiting for flush, forcing close for ${qsocket.address}\n`); + this._ForceClose(qsocket); + } + }, 5000); + + return; + } + } + + this._ForceClose(qsocket); + } + + _ForceClose(qsocket: QSocket): void { + const socketData = getWebRTCSocketState(qsocket); + + if (socketData === null) { + qsocket.state = QSocket.STATE_DISCONNECTED; + return; + } + + for (const peerConnection of socketData.peerConnections.values()) { + peerConnection.close(); + } + + socketData.peerConnections.clear(); + socketData.dataChannels.clear(); + + const isSessionSocket = socketData.isHost || (!this.isHost && socketData.sessionId === this.sessionId); + + if (socketData.isHost) { + this._StopPingInterval(); + this._StopServerInfoSubscriptions(); + } + + if (isSessionSocket && this.sessionId !== null) { + this._SendSignaling({ type: 'leave-session' }); + } + + if (isSessionSocket) { + if (!(this.isHost && !socketData.isHost)) { + this.sessionId = null; + this.peerId = null; + this.hostToken = null; + this.isHost = false; + } + } + + qsocket.state = QSocket.STATE_DISCONNECTED; + } + + ShouldListen(): boolean { + return !registry.isDedicatedServer; + } + + Listen(listening: boolean): void { + if (!this.ShouldListen()) { + return; + } + + if (listening) { + if (this.sessionId !== null || this.creatingSession) { + Con.DPrint('WebRTCDriver: Already hosting or creating a session\n'); + return; + } + + Con.DPrint('WebRTCDriver: Starting WebRTC host session for listen server\n'); + this.creatingSession = true; + + if (!this._ConnectSignaling()) { + Con.PrintWarning('WebRTCDriver: Failed to connect to signaling server\n'); + this.creatingSession = false; + return; + } + + const sock = NET.NewQSocket(this); + sock.state = QSocket.STATE_CONNECTING; + sock.address = 'WebRTC Host'; + sock.transportState = createWebRTCSocketState({ sessionId: null, isHost: true }); + + const createSessionWhenReady = () => { + this._SendSignaling({ + type: 'create-session', + serverInfo: this._GatherServerInfo(), + isPublic: this._IsSessionPublic(), + }); + Con.DPrint('WebRTCDriver: Session creation request sent\n'); + }; + + if (this.signalingWs !== null && this.signalingWs.readyState === 1) { + createSessionWhenReady(); + } else { + const socketState = getWebRTCSocketState(sock); + + if (socketState !== null) { + socketState.onSignalingReady = createSessionWhenReady; + } + } + + this.isHost = true; + Con.DPrint('WebRTCDriver: Waiting for signaling connection to create session...\n'); + return; + } + + Con.DPrint('WebRTCDriver: Stopping listen server, tearing down session\n'); + this._StopPingInterval(); + this._StopServerInfoSubscriptions(); + + for (let index = NET.activeSockets.length - 1; index >= 0; index--) { + const sock = NET.activeSockets[index]; + const socketData = sock === undefined ? null : getWebRTCSocketState(sock); + + if (sock !== undefined && sock.driver === this && socketData !== null) { + for (const peerConnection of socketData.peerConnections.values()) { + peerConnection.close(); + } + + socketData.peerConnections.clear(); + socketData.dataChannels.clear(); + sock.state = QSocket.STATE_DISCONNECTED; + } + } + + if (this.sessionId !== null) { + this._SendSignaling({ type: 'leave-session' }); + } + + if (this.signalingWs !== null) { + this.signalingWs.onclose = null; + this.signalingWs.onerror = null; + this.signalingWs.onmessage = null; + this.signalingWs.onopen = null; + this.signalingWs.close(); + this.signalingWs = null; + } + + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + if (this.sessionId !== null) { + Con.DPrint('WebRTCDriver: Session torn down, no longer accepting connections\n'); + } + + this.sessionId = null; + this.peerId = null; + this.isHost = false; + this.creatingSession = false; + } + + GetListenAddress(): string | null { + return this.sessionId !== null ? `webrtc://${this.sessionId}` : null; + } +} diff --git a/source/engine/registry.mjs b/source/engine/registry.mjs index 47b1723f..bf41c6d2 100644 --- a/source/engine/registry.mjs +++ b/source/engine/registry.mjs @@ -4,7 +4,7 @@ /** @typedef {typeof import('./common/Sys.mjs').default} SysModule */ /** @typedef {typeof import('./common/Host.mjs').default} HostModule */ /** @typedef {typeof import('./client/V.mjs').default} VModule */ -/** @typedef {typeof import('./network/Network.mjs').default} NetModule */ +/** @typedef {typeof import('./network/Network').default} NetModule */ /** @typedef {typeof import('./server/Server.mjs').default} ServerModule */ /** @typedef {typeof import('./server/Progs.mjs').default} ProgsModule */ /** @typedef {typeof import('./common/Mod.mjs').default} ModModule */ diff --git a/source/engine/server/Client.mjs b/source/engine/server/Client.mjs index 289a064e..96d0abf0 100644 --- a/source/engine/server/Client.mjs +++ b/source/engine/server/Client.mjs @@ -2,7 +2,7 @@ import { enumHelpers } from '../../shared/Q.ts'; import { gameCapabilities } from '../../shared/Defs.ts'; import Vector from '../../shared/Vector.ts'; import { SzBuffer } from '../network/MSG.ts'; -import { QSocket } from '../network/NetworkDrivers.mjs'; +import { QSocket } from '../network/NetworkDrivers.ts'; import * as Protocol from '../network/Protocol.ts'; import { eventBus, registry } from '../registry.mjs'; import { ServerEntityState } from './Server.mjs'; diff --git a/test/common/console-commands.test.mjs b/test/common/console-commands.test.mjs new file mode 100644 index 00000000..6e18caf8 --- /dev/null +++ b/test/common/console-commands.test.mjs @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { InviteCommand } from '../../source/engine/network/ConsoleCommands.ts'; +import { eventBus, registry } from '../../source/engine/registry.mjs'; + +/** + * Temporarily install a global value for the duration of a callback. + * @param {string} name + * @param {unknown} value + * @param {() => Promise} callback + * @returns {Promise} Result of the callback. + */ +function withGlobalValue(name, value, callback) { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, name); + + Object.defineProperty(globalThis, name, { + configurable: true, + writable: true, + value, + }); + + try { + return Promise.resolve(callback()).finally(() => { + if (descriptor === undefined) { + delete globalThis[name]; + } else { + Object.defineProperty(globalThis, name, descriptor); + } + }); + } catch (error) { + if (descriptor === undefined) { + delete globalThis[name]; + } else { + Object.defineProperty(globalThis, name, descriptor); + } + + throw error; + } +} + +/** + * Temporarily install mocked networking registry singletons for invite command tests. + * @param {{ GetListenAddress: () => string | null }} mockedNet + * @param {{ Print: (message: string) => void, PrintWarning: (message: string) => void }} mockedCon + * @param {() => Promise} callback + * @returns {Promise} Result of the callback. + */ +function withInviteRegistry(mockedNet, mockedCon, callback) { + const previousCon = registry.Con; + const previousNET = registry.NET; + + registry.Con = mockedCon; + registry.NET = mockedNet; + eventBus.publish('registry.frozen'); + + return Promise.resolve(callback()).finally(() => { + registry.Con = previousCon; + registry.NET = previousNET; + eventBus.publish('registry.frozen'); + }); +} + +void describe('InviteCommand', () => { + void test('warns when no listen address is available', async () => { + const warnings = []; + const command = new InviteCommand(); + + await withInviteRegistry( + { GetListenAddress() { return null; } }, + { Print() {}, PrintWarning(message) { warnings.push(message); } }, + async () => { + await command.run(); + }, + ); + + assert.deepEqual(warnings, ['Cannot create invite link, not hosting.\n']); + }); + + void test('copies a sanitized invite link to the clipboard', async () => { + const prints = []; + const clipboardWrites = []; + const command = new InviteCommand(); + + await withInviteRegistry( + { GetListenAddress() { return 'webrtc://session-123'; } }, + { Print(message) { prints.push(message); }, PrintWarning() {} }, + async () => { + await withGlobalValue('location', new URL('https://quake.test/play?exec=autoexec.cfg&map=start&foo=bar'), async () => { + await withGlobalValue('navigator', { + clipboard: { + writeText(text) { + clipboardWrites.push(text); + return Promise.resolve(); + }, + }, + }, async () => { + await withGlobalValue('prompt', () => { + throw new Error('prompt should not be called'); + }, async () => { + await command.run(); + }); + }); + }); + }, + ); + + assert.deepEqual(clipboardWrites, ['https://quake.test/play?foo=bar&connect=webrtc%3A%2F%2Fsession-123']); + assert.deepEqual(prints, [ + 'This link has been copied to your clipboard:\nhttps://quake.test/play?foo=bar&connect=webrtc%3A%2F%2Fsession-123\n', + ]); + }); + + void test('falls back to prompt when clipboard write fails', async () => { + const prompts = []; + const command = new InviteCommand(); + + await withInviteRegistry( + { GetListenAddress() { return 'webrtc://session-456'; } }, + { Print() {}, PrintWarning() {} }, + async () => { + await withGlobalValue('location', new URL('https://quake.test/play?map=start'), async () => { + await withGlobalValue('navigator', { + clipboard: { + writeText() { + return Promise.reject(new Error('clipboard unavailable')); + }, + }, + }, async () => { + await withGlobalValue('prompt', (message, value) => { + prompts.push([message, value]); + }, async () => { + await command.run(); + }); + }); + }); + }, + ); + + assert.deepEqual(prompts, [[ + 'Share this link to invite players:', + 'https://quake.test/play?connect=webrtc%3A%2F%2Fsession-456', + ]]); + }); +}); diff --git a/test/common/driver-registry.test.mjs b/test/common/driver-registry.test.mjs new file mode 100644 index 00000000..82153f64 --- /dev/null +++ b/test/common/driver-registry.test.mjs @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { DriverRegistry } from '../../source/engine/network/DriverRegistry.ts'; +import { BaseDriver } from '../../source/engine/network/NetworkDrivers.ts'; + +/** + * @typedef {object} FakeDriverOptions + * @property {boolean} [initialized] Whether the fake driver starts initialized. + * @property {string[]} [handleHosts] Addresses the fake driver reports it can handle. + */ + +class FakeDriver extends BaseDriver { + /** @type {string[]} */ + handleHosts = []; + initCalls = 0; + shutdownCalls = 0; + + /** + * @param {string} name + * @param {FakeDriverOptions} [options] + */ + constructor(name, options = {}) { + super(name); + const { initialized = false, handleHosts = [] } = options; + this.initialized = initialized; + this.handleHosts = handleHosts; + } + + Init() { + this.initCalls += 1; + this.initialized = true; + return true; + } + + Shutdown() { + this.shutdownCalls += 1; + this.initialized = false; + } + + /** + * @param {string} host + * @returns {boolean} True when the fake driver accepts the host string. + */ + canHandle(host) { + return this.handleHosts.includes(host); + } +} + +void describe('DriverRegistry', () => { + void test('registers drivers by name and preserves order', () => { + const registry = new DriverRegistry(); + const loop = new FakeDriver('loop'); + const websocket = new FakeDriver('websocket'); + + registry.register('loop', loop); + registry.register('websocket', websocket); + + assert.equal(registry.get('loop'), loop); + assert.equal(registry.get('websocket'), websocket); + assert.deepEqual(registry.orderedDrivers, [loop, websocket]); + }); + + void test('selects the first initialized driver that can handle an address', () => { + const registry = new DriverRegistry(); + const uninitialized = new FakeDriver('websocket', { initialized: false, handleHosts: ['wss://quake.test'] }); + const initialized = new FakeDriver('webrtc', { initialized: true, handleHosts: ['wss://quake.test'] }); + + registry.register('websocket', uninitialized); + registry.register('webrtc', initialized); + + assert.equal(registry.getClientDriver('wss://quake.test'), initialized); + assert.equal(registry.getClientDriver('local'), null); + }); + + void test('initializes and shuts down registered drivers', () => { + const registry = new DriverRegistry(); + const loop = new FakeDriver('loop'); + const websocket = new FakeDriver('websocket'); + + registry.register('loop', loop); + registry.register('websocket', websocket); + + registry.initialize(); + + assert.equal(loop.initCalls, 1); + assert.equal(websocket.initCalls, 1); + assert.deepEqual(registry.getInitializedDrivers(), [loop, websocket]); + + registry.shutdown(); + + assert.equal(loop.shutdownCalls, 1); + assert.equal(websocket.shutdownCalls, 1); + assert.deepEqual(registry.getInitializedDrivers(), []); + }); +}); diff --git a/test/common/network-drivers.test.mjs b/test/common/network-drivers.test.mjs new file mode 100644 index 00000000..5a810921 --- /dev/null +++ b/test/common/network-drivers.test.mjs @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { SzBuffer } from '../../source/engine/network/MSG.ts'; +import NET from '../../source/engine/network/Network.ts'; +import { BaseDriver, LoopDriver, QSocket } from '../../source/engine/network/NetworkDrivers.ts'; +import { eventBus, registry } from '../../source/engine/registry.mjs'; + +class RecordingDriver extends BaseDriver { + calls = []; + + constructor() { + super('recording'); + this.initialized = true; + } + + GetMessage(qsocket) { + this.calls.push(['GetMessage', qsocket]); + return 7; + } + + SendMessage(qsocket, data) { + this.calls.push(['SendMessage', qsocket, data.cursize]); + return 1; + } + + SendUnreliableMessage(qsocket, data) { + this.calls.push(['SendUnreliableMessage', qsocket, data.cursize]); + return 1; + } + + CanSendMessage(qsocket) { + this.calls.push(['CanSendMessage', qsocket]); + return true; + } + + Close(qsocket) { + this.calls.push(['Close', qsocket]); + super.Close(qsocket); + } +} + +void describe('NetworkDrivers', () => { + void test('QSocket delegates to its driver', () => { + const driver = new RecordingDriver(); + const sock = new QSocket(driver, 5); + const message = new SzBuffer(16, 'socket-delegation'); + + sock.state = QSocket.STATE_CONNECTED; + message.writeByte(42); + + assert.equal(sock.GetMessage(), 7); + assert.equal(sock.SendMessage(message), 1); + assert.equal(sock.SendUnreliableMessage(message), 1); + assert.equal(sock.CanSendMessage(), true); + + sock.Close(); + + assert.equal(sock.state, QSocket.STATE_DISCONNECTED); + assert.deepEqual(driver.calls.map(([name]) => name), [ + 'GetMessage', + 'SendMessage', + 'SendUnreliableMessage', + 'CanSendMessage', + 'Close', + ]); + }); + + void test('LoopDriver round-trips a reliable local message', () => { + const previousCon = registry.Con; + const previousCOM = registry.COM; + const previousNET = registry.NET; + const previousSV = registry.SV; + const previousSys = registry.Sys; + const previousSockets = NET.activeSockets.slice(); + const previousTime = NET.time; + const previousMessage = NET.message; + + registry.Con = { DPrint() {}, Print() {}, PrintError() {}, PrintWarning() {} }; + registry.COM = { game: 'id1' }; + registry.NET = NET; + registry.SV = { server: { mapname: 'start' }, svs: { maxclients: 1 } }; + registry.Sys = { FloatTime() { return 1; } }; + eventBus.publish('registry.frozen'); + + try { + NET.activeSockets = []; + NET.time = 1; + NET.message = new SzBuffer(128, 'NET.message.test'); + + const driver = new LoopDriver(); + const serverSock = driver.Connect('local'); + const clientSock = driver.CheckNewConnections(); + + assert.ok(serverSock instanceof QSocket); + assert.ok(clientSock instanceof QSocket); + + const payload = new SzBuffer(16, 'loop-message'); + payload.writeByte(99); + + assert.equal(serverSock.SendMessage(payload), 1); + assert.equal(serverSock.CanSendMessage(), false); + assert.equal(clientSock.GetMessage(), 1); + assert.equal(new Uint8Array(NET.message.data)[0], 99); + assert.equal(serverSock.CanSendMessage(), true); + assert.equal(clientSock.transportState?.kind, 'loopback'); + assert.equal(clientSock.transportState?.peer, serverSock); + } finally { + registry.Con = previousCon; + registry.COM = previousCOM; + registry.NET = previousNET; + registry.SV = previousSV; + registry.Sys = previousSys; + NET.activeSockets = previousSockets; + NET.time = previousTime; + NET.message = previousMessage; + eventBus.publish('registry.frozen'); + } + }); +}); diff --git a/test/common/network-misc.test.mjs b/test/common/network-misc.test.mjs new file mode 100644 index 00000000..d0501af2 --- /dev/null +++ b/test/common/network-misc.test.mjs @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { formatIP } from '../../source/engine/network/Misc.ts'; + +void describe('formatIP', () => { + void test('formats ipv4 addresses without brackets', () => { + assert.equal(formatIP('127.0.0.1', 26000), '127.0.0.1:26000'); + }); + + void test('wraps ipv6 addresses in brackets', () => { + assert.equal(formatIP('2001:db8::1', 26000), '[2001:db8::1]:26000'); + }); +}); diff --git a/test/common/network.test.mjs b/test/common/network.test.mjs new file mode 100644 index 00000000..c5b67b64 --- /dev/null +++ b/test/common/network.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import NET from '../../source/engine/network/Network.ts'; +import { BaseDriver, QSocket } from '../../source/engine/network/NetworkDrivers.ts'; + +class FakeDriver extends BaseDriver { + constructor() { + super('fake'); + this.listenCalls = []; + this.listenAddress = null; + this.initialized = true; + } + + Listen(shouldListen) { + this.listenCalls.push(shouldListen); + } + + GetListenAddress() { + return this.listenAddress; + } +} + +void describe('NET', () => { + void test('reuses disconnected socket slots', () => { + const previousTime = NET.time; + const previousSockets = NET.activeSockets.slice(); + const driver = new FakeDriver(); + + try { + NET.time = 123; + NET.activeSockets = []; + + const first = NET.NewQSocket(driver); + + first.state = QSocket.STATE_DISCONNECTED; + + const second = NET.NewQSocket(driver); + + assert.notEqual(second, first); + assert.equal(NET.activeSockets[0], second); + assert.equal(NET.activeSockets.length, 1); + assert.equal(second.connecttime, 123); + } finally { + NET.time = previousTime; + NET.activeSockets = previousSockets; + } + }); + + void test('listens only on eligible initialized drivers', () => { + const previousListening = NET.listening; + const previousDriverRegistry = NET.driverRegistry; + const listeningDriver = new FakeDriver(); + const skippedDriver = new FakeDriver(); + skippedDriver.ShouldListen = () => false; + + NET.driverRegistry = { + getInitializedDrivers() { + return [listeningDriver, skippedDriver]; + }, + }; + + try { + NET.Listen_f(1); + + assert.equal(NET.listening, true); + assert.deepEqual(listeningDriver.listenCalls, [true]); + assert.deepEqual(skippedDriver.listenCalls, []); + } finally { + NET.listening = previousListening; + NET.driverRegistry = previousDriverRegistry; + } + }); + + void test('returns the first active listen address', () => { + const previousDriverRegistry = NET.driverRegistry; + const firstDriver = new FakeDriver(); + const secondDriver = new FakeDriver(); + secondDriver.listenAddress = 'ws://127.0.0.1:26000'; + + NET.driverRegistry = { + getInitializedDrivers() { + return [firstDriver, secondDriver]; + }, + }; + + try { + assert.equal(NET.GetListenAddress(), 'ws://127.0.0.1:26000'); + } finally { + NET.driverRegistry = previousDriverRegistry; + } + }); +}); From ab49cddbbf7a1a3ace827cac8c4508c7f2411299 Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 15:44:22 +0300 Subject: [PATCH 13/67] TS: common/CRC, common/Def, common/Errors --- source/engine/client/CL.mjs | 2 +- source/engine/client/ClientConnection.mjs | 4 +- source/engine/client/ClientDemos.mjs | 4 +- source/engine/client/ClientEntities.mjs | 2 +- source/engine/client/ClientInput.mjs | 2 +- source/engine/client/ClientLifecycle.mjs | 2 +- source/engine/client/ClientMessages.mjs | 4 +- .../client/ClientServerCommandHandlers.mjs | 4 +- source/engine/client/ClientState.mjs | 14 +- source/engine/client/Draw.mjs | 2 +- source/engine/client/GL.mjs | 2 +- source/engine/client/Key.mjs | 2 +- source/engine/client/LegacyServerCommands.mjs | 4 +- source/engine/client/Menu.mjs | 2 +- source/engine/client/R.mjs | 2 +- source/engine/client/SCR.mjs | 2 +- source/engine/client/Sbar.mjs | 2 +- source/engine/client/V.mjs | 2 +- .../client/renderer/BrushModelRenderer.mjs | 2 +- .../engine/client/renderer/ModelRenderer.mjs | 2 +- source/engine/client/renderer/ShadowMap.mjs | 2 +- source/engine/common/{CRC.mjs => CRC.ts} | 20 ++- source/engine/common/Cmd.mjs | 2 +- source/engine/common/Com.mjs | 6 +- source/engine/common/Console.mjs | 2 +- source/engine/common/Def.mjs | 162 ------------------ source/engine/common/Def.ts | 148 ++++++++++++++++ source/engine/common/Errors.mjs | 75 -------- source/engine/common/Errors.ts | 66 +++++++ source/engine/common/GameAPIs.mjs | 2 +- source/engine/common/Host.mjs | 4 +- source/engine/common/Mod.mjs | 2 +- source/engine/common/Sys.mjs | 2 +- source/engine/common/W.mjs | 2 +- source/engine/common/WorkerManager.mjs | 2 +- source/engine/common/model/ModelLoader.mjs | 2 +- .../common/model/ModelLoaderRegistry.mjs | 2 +- .../common/model/loaders/AliasMDLLoader.mjs | 2 +- .../common/model/loaders/BSP29Loader.mjs | 4 +- .../common/model/loaders/BSP2Loader.mjs | 2 +- .../common/model/loaders/BSP38Loader.mjs | 2 +- .../common/model/loaders/SpriteSPRLoader.mjs | 2 +- source/engine/network/Network.ts | 2 +- source/engine/network/NetworkDrivers.ts | 2 +- source/engine/server/Com.mjs | 4 +- source/engine/server/Edict.mjs | 2 +- source/engine/server/Navigation.mjs | 2 +- source/engine/server/Progs.mjs | 4 +- source/engine/server/ProgsAPI.mjs | 2 +- source/engine/server/Server.mjs | 2 +- test/common/crc.test.mjs | 16 ++ test/common/def.test.mjs | 26 +++ test/common/errors.test.mjs | 34 ++++ 53 files changed, 367 insertions(+), 304 deletions(-) rename source/engine/common/{CRC.mjs => CRC.ts} (87%) delete mode 100644 source/engine/common/Def.mjs create mode 100644 source/engine/common/Def.ts delete mode 100644 source/engine/common/Errors.mjs create mode 100644 source/engine/common/Errors.ts create mode 100644 test/common/crc.test.mjs create mode 100644 test/common/def.test.mjs create mode 100644 test/common/errors.test.mjs diff --git a/source/engine/client/CL.mjs b/source/engine/client/CL.mjs index 36846624..ca45f4cf 100644 --- a/source/engine/client/CL.mjs +++ b/source/engine/client/CL.mjs @@ -1,5 +1,5 @@ import Q from '../../shared/Q.ts'; -import * as Def from '../common/Def.mjs'; +import * as Def from '../common/Def.ts'; import * as Protocol from '../network/Protocol.ts'; import Cmd, { ConsoleCommand } from '../common/Cmd.mjs'; import Cvar from '../common/Cvar.mjs'; diff --git a/source/engine/client/ClientConnection.mjs b/source/engine/client/ClientConnection.mjs index d86869fc..1d949f46 100644 --- a/source/engine/client/ClientConnection.mjs +++ b/source/engine/client/ClientConnection.mjs @@ -1,11 +1,11 @@ import * as Protocol from '../network/Protocol.ts'; -import { HostError } from '../common/Errors.mjs'; +import { HostError } from '../common/Errors.ts'; import Cvar from '../common/Cvar.mjs'; import Cmd from '../common/Cmd.mjs'; import ClientInput from './ClientInput.mjs'; import { clientRuntimeState, clientStaticState } from './ClientState.mjs'; import { eventBus, registry } from '../registry.mjs'; -import * as Def from '../common/Def.mjs'; +import * as Def from '../common/Def.ts'; import { QSocket } from '../network/NetworkDrivers.ts'; import { parseServerMessage as parseServerCommandMessage } from './ClientServerCommandHandlers.mjs'; diff --git a/source/engine/client/ClientDemos.mjs b/source/engine/client/ClientDemos.mjs index e23831c9..7fe9a801 100644 --- a/source/engine/client/ClientDemos.mjs +++ b/source/engine/client/ClientDemos.mjs @@ -1,8 +1,8 @@ -import { clientConnectionState } from '../common/Def.mjs'; +import { clientConnectionState } from '../common/Def.ts'; import { eventBus, registry } from '../registry.mjs'; import * as Protocol from '../network/Protocol.ts'; -import { HostError } from '../common/Errors.mjs'; +import { HostError } from '../common/Errors.ts'; let { CL, COM, Con, Host, NET } = registry; diff --git a/source/engine/client/ClientEntities.mjs b/source/engine/client/ClientEntities.mjs index 4320569a..edc1b32c 100644 --- a/source/engine/client/ClientEntities.mjs +++ b/source/engine/client/ClientEntities.mjs @@ -1,6 +1,6 @@ import Vector from '../../shared/Vector.ts'; import { eventBus, registry } from '../registry.mjs'; -import * as Def from '../common/Def.mjs'; +import * as Def from '../common/Def.ts'; import { content, effect, solid } from '../../shared/Defs.ts'; import Chase from './Chase.mjs'; import { DefaultClientEdictHandler } from './ClientLegacy.mjs'; diff --git a/source/engine/client/ClientInput.mjs b/source/engine/client/ClientInput.mjs index 0af70978..ebe671d4 100644 --- a/source/engine/client/ClientInput.mjs +++ b/source/engine/client/ClientInput.mjs @@ -4,7 +4,7 @@ import Q from '../../shared/Q.ts'; import { SzBuffer } from '../network/MSG.ts'; import Cmd from '../common/Cmd.mjs'; import { eventBus, registry } from '../registry.mjs'; -import { HostError } from '../common/Errors.mjs'; +import { HostError } from '../common/Errors.ts'; let { Con, CL, Host, NET, V } = registry; diff --git a/source/engine/client/ClientLifecycle.mjs b/source/engine/client/ClientLifecycle.mjs index 7fc8145a..53b31cc2 100644 --- a/source/engine/client/ClientLifecycle.mjs +++ b/source/engine/client/ClientLifecycle.mjs @@ -1,6 +1,6 @@ import Cvar from '../common/Cvar.mjs'; import Cmd, { ConsoleCommand } from '../common/Cmd.mjs'; -import * as Def from '../common/Def.mjs'; +import * as Def from '../common/Def.ts'; import { gameCapabilities } from '../../shared/Defs.ts'; import ClientInput from './ClientInput.mjs'; import CL from './CL.mjs'; diff --git a/source/engine/client/ClientMessages.mjs b/source/engine/client/ClientMessages.mjs index 76e8553e..8321f182 100644 --- a/source/engine/client/ClientMessages.mjs +++ b/source/engine/client/ClientMessages.mjs @@ -1,7 +1,7 @@ import * as Protocol from '../network/Protocol.ts'; -import * as Def from '../common/Def.mjs'; +import * as Def from '../common/Def.ts'; import { eventBus, registry } from '../registry.mjs'; -import { HostError } from '../common/Errors.mjs'; +import { HostError } from '../common/Errors.ts'; import Vector from '../../shared/Vector.ts'; import { PmovePlayer } from '../common/Pmove.mjs'; import { gameCapabilities } from '../../shared/Defs.ts'; diff --git a/source/engine/client/ClientServerCommandHandlers.mjs b/source/engine/client/ClientServerCommandHandlers.mjs index 4f0a4e50..6c295778 100644 --- a/source/engine/client/ClientServerCommandHandlers.mjs +++ b/source/engine/client/ClientServerCommandHandlers.mjs @@ -1,7 +1,7 @@ import * as Protocol from '../network/Protocol.ts'; -import * as Def from '../common/Def.mjs'; +import * as Def from '../common/Def.ts'; import Cmd from '../common/Cmd.mjs'; -import { HostError } from '../common/Errors.mjs'; +import { HostError } from '../common/Errors.ts'; import { gameCapabilities } from '../../shared/Defs.ts'; import Vector from '../../shared/Vector.ts'; import { ClientEngineAPI } from '../common/GameAPIs.mjs'; diff --git a/source/engine/client/ClientState.mjs b/source/engine/client/ClientState.mjs index b50e1048..75f0a3dc 100644 --- a/source/engine/client/ClientState.mjs +++ b/source/engine/client/ClientState.mjs @@ -1,7 +1,7 @@ import { SzBuffer } from '../network/MSG.ts'; import { QSocket } from '../network/NetworkDrivers.ts'; import * as Protocol from '../network/Protocol.ts'; -import * as Def from '../common/Def.mjs'; +import * as Def from '../common/Def.ts'; import Vector from '../../shared/Vector.ts'; import { EventBus, eventBus, registry } from '../registry.mjs'; import ClientEntities, { ClientEdict } from './ClientEntities.mjs'; @@ -14,6 +14,14 @@ eventBus.subscribe('registry.frozen', () => { CL = registry.CL; }); +/** + * Create a stats array sized to the numeric legacy stat entries. + * @returns {number[]} Fresh zeroed stat slots. + */ +function createLegacyStatsArray() { + return Object.values(Def.stat).filter((value) => typeof value === 'number').map(() => 0); +} + const clientGameEvents = [ 'vid.resize', 'cvar.changed', @@ -157,7 +165,7 @@ class ClientRuntimeState { /** @type {number} server-acknowledged old button state */ ackedPmOldButtons = 0; - stats = Object.values(Def.stat).map(() => 0); + stats = createLegacyStatsArray(); items = 0; item_gettime = new Array(32).fill(0.0); faceanimtime = 0.0; @@ -247,7 +255,7 @@ class ClientRuntimeState { this.ackedPmFlags = 0; this.ackedPmTime = 0; this.ackedPmOldButtons = 0; - this.stats = Object.values(Def.stat).map(() => 0); + this.stats = createLegacyStatsArray(); this.items = 0; this.item_gettime.fill(0.0); this.faceanimtime = 0.0; diff --git a/source/engine/client/Draw.mjs b/source/engine/client/Draw.mjs index d39f542e..e3851d72 100644 --- a/source/engine/client/Draw.mjs +++ b/source/engine/client/Draw.mjs @@ -1,5 +1,5 @@ import Vector from '../../shared/Vector.ts'; -import { MissingResourceError } from '../common/Errors.mjs'; +import { MissingResourceError } from '../common/Errors.ts'; import VID from './VID.mjs'; import W, { WadFileInterface, WadLumpTexture } from '../common/W.mjs'; diff --git a/source/engine/client/GL.mjs b/source/engine/client/GL.mjs index 80c3bf2c..6fc4ebd8 100644 --- a/source/engine/client/GL.mjs +++ b/source/engine/client/GL.mjs @@ -1,6 +1,6 @@ import Cmd, { ConsoleCommand } from '../common/Cmd.mjs'; import Cvar from '../common/Cvar.mjs'; -import { MissingResourceError } from '../common/Errors.mjs'; +import { MissingResourceError } from '../common/Errors.ts'; import { WadLumpTexture } from '../common/W.mjs'; import { eventBus, registry } from '../registry.mjs'; import VID from './VID.mjs'; diff --git a/source/engine/client/Key.mjs b/source/engine/client/Key.mjs index 3ef6efdf..0b49fce5 100644 --- a/source/engine/client/Key.mjs +++ b/source/engine/client/Key.mjs @@ -2,7 +2,7 @@ import { K } from '../../shared/Keys.ts'; import Vector from '../../shared/Vector.ts'; import Cmd from '../common/Cmd.mjs'; import Cvar from '../common/Cvar.mjs'; -import { clientConnectionState } from '../common/Def.mjs'; +import { clientConnectionState } from '../common/Def.ts'; import { registry, eventBus } from '../registry.mjs'; const Key = {}; diff --git a/source/engine/client/LegacyServerCommands.mjs b/source/engine/client/LegacyServerCommands.mjs index f2044fc1..08e740dc 100644 --- a/source/engine/client/LegacyServerCommands.mjs +++ b/source/engine/client/LegacyServerCommands.mjs @@ -1,6 +1,6 @@ import * as Protocol from '../network/Protocol.ts'; -import * as Def from '../common/Def.mjs'; -import { HostError } from '../common/Errors.mjs'; +import * as Def from '../common/Def.ts'; +import { HostError } from '../common/Errors.ts'; import { eventBus, registry } from '../registry.mjs'; import { ScoreSlot } from './ClientState.mjs'; import Vector from '../../shared/Vector.ts'; diff --git a/source/engine/client/Menu.mjs b/source/engine/client/Menu.mjs index 3d9cc650..6a7bc2c0 100644 --- a/source/engine/client/Menu.mjs +++ b/source/engine/client/Menu.mjs @@ -1,7 +1,7 @@ import { K } from '../../shared/Keys.ts'; import Cmd from '../common/Cmd.mjs'; import Cvar from '../common/Cvar.mjs'; -import { clientConnectionState } from '../common/Def.mjs'; +import { clientConnectionState } from '../common/Def.ts'; import { eventBus, registry } from '../registry.mjs'; import ClientLifecycle from './ClientLifecycle.mjs'; import { GLTexture } from './GL.mjs'; diff --git a/source/engine/client/R.mjs b/source/engine/client/R.mjs index 0bade1ef..9a1b6140 100644 --- a/source/engine/client/R.mjs +++ b/source/engine/client/R.mjs @@ -1,7 +1,7 @@ import Vector from '../../shared/Vector.ts'; import Cvar from '../common/Cvar.mjs'; import Cmd from '../common/Cmd.mjs'; -import * as Def from '../common/Def.mjs'; +import * as Def from '../common/Def.ts'; import { eventBus, registry } from '../registry.mjs'; import Chase from './Chase.mjs'; diff --git a/source/engine/client/SCR.mjs b/source/engine/client/SCR.mjs index 6faecba3..8990b4de 100644 --- a/source/engine/client/SCR.mjs +++ b/source/engine/client/SCR.mjs @@ -3,7 +3,7 @@ import { gameCapabilities } from '../../shared/Defs.ts'; import Cmd from '../common/Cmd.mjs'; import Cvar from '../common/Cvar.mjs'; -import { clientConnectionState } from '../common/Def.mjs'; +import { clientConnectionState } from '../common/Def.ts'; import { eventBus, registry } from '../registry.mjs'; import GL from './GL.mjs'; import VID from './VID.mjs'; diff --git a/source/engine/client/Sbar.mjs b/source/engine/client/Sbar.mjs index de728dd4..2f1921dc 100644 --- a/source/engine/client/Sbar.mjs +++ b/source/engine/client/Sbar.mjs @@ -3,7 +3,7 @@ import Cmd from '../common/Cmd.mjs'; import { eventBus, registry } from '../registry.mjs'; import VID from './VID.mjs'; -import * as Def from '../common/Def.mjs'; +import * as Def from '../common/Def.ts'; import Cvar from '../common/Cvar.mjs'; const Sbar = {}; diff --git a/source/engine/client/V.mjs b/source/engine/client/V.mjs index 6b50d56e..6560ee4d 100644 --- a/source/engine/client/V.mjs +++ b/source/engine/client/V.mjs @@ -2,7 +2,7 @@ import Vector from '../../shared/Vector.ts'; import { content, gameCapabilities } from '../../shared/Defs.ts'; import Cmd from '../common/Cmd.mjs'; import Cvar from '../common/Cvar.mjs'; -import * as Def from '../common/Def.mjs'; +import * as Def from '../common/Def.ts'; import Q from '../../shared/Q.ts'; import { eventBus, registry } from '../registry.mjs'; import Chase from './Chase.mjs'; diff --git a/source/engine/client/renderer/BrushModelRenderer.mjs b/source/engine/client/renderer/BrushModelRenderer.mjs index 80cf1a51..49d0af67 100644 --- a/source/engine/client/renderer/BrushModelRenderer.mjs +++ b/source/engine/client/renderer/BrushModelRenderer.mjs @@ -8,7 +8,7 @@ import { BrushModel, Node } from '../../common/model/BSP.mjs'; import { ClientEdict } from '../ClientEntities.mjs'; import Mesh from './Mesh.mjs'; import PostProcess from './PostProcess.mjs'; -import * as Def from '../../common/Def.mjs'; +import * as Def from '../../common/Def.ts'; let { CL, Host, R } = registry; diff --git a/source/engine/client/renderer/ModelRenderer.mjs b/source/engine/client/renderer/ModelRenderer.mjs index 72b10aae..f8af53b5 100644 --- a/source/engine/client/renderer/ModelRenderer.mjs +++ b/source/engine/client/renderer/ModelRenderer.mjs @@ -1,4 +1,4 @@ -import { NotImplementedError } from '../../common/Errors.mjs'; +import { NotImplementedError } from '../../common/Errors.ts'; /** * Abstract base class for model renderers. diff --git a/source/engine/client/renderer/ShadowMap.mjs b/source/engine/client/renderer/ShadowMap.mjs index eb4059fd..6377792f 100644 --- a/source/engine/client/renderer/ShadowMap.mjs +++ b/source/engine/client/renderer/ShadowMap.mjs @@ -1,6 +1,6 @@ import GL from '../GL.mjs'; import Cvar from '../../common/Cvar.mjs'; -import { limits } from '../../common/Def.mjs'; +import { limits } from '../../common/Def.ts'; import { eventBus, registry } from '../../registry.mjs'; import { materialFlags } from './Materials.mjs'; import { effect } from '../../../shared/Defs.ts'; diff --git a/source/engine/common/CRC.mjs b/source/engine/common/CRC.ts similarity index 87% rename from source/engine/common/CRC.mjs rename to source/engine/common/CRC.ts index 29ec1bb9..ab3df759 100644 --- a/source/engine/common/CRC.mjs +++ b/source/engine/common/CRC.ts @@ -1,8 +1,8 @@ /** - * CRC class for calculating CRC-16-CCITT checksum. + * CRC-16-CCITT checksum helper. */ export class CRC16CCITT { - static #table = [ + static readonly #table = [ 0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50a5, 0x60c6, 0x70e7, 0x8108, 0x9129, 0xa14a, 0xb16b, 0xc18c, 0xd1ad, 0xe1ce, 0xf1ef, 0x1231, 0x0210, 0x3273, 0x2252, 0x52b5, 0x4294, 0x72f7, 0x62d6, @@ -38,15 +38,17 @@ export class CRC16CCITT { ]; /** - * Calculates the CRC-16-CCITT checksum for the given data block. - * @param {Uint8Array} start - The input data block. - * @returns {number} The calculated CRC-16-CCITT checksum. + * Calculate the checksum for a block of bytes. + * @param start + * @returns CRC-16-CCITT checksum. */ - static Block(start) { + static Block(start: Uint8Array): number { let crcvalue = 0xffff; - for (let i = 0; i < start.length; i++) { - crcvalue = ((crcvalue << 8) & 0xffff) ^ this.#table[(crcvalue >> 8) ^ start[i]]; + + for (const byte of start) { + crcvalue = ((crcvalue << 8) & 0xffff) ^ this.#table[(crcvalue >> 8) ^ byte]; } + return crcvalue; } -}; +} diff --git a/source/engine/common/Cmd.mjs b/source/engine/common/Cmd.mjs index a2b8df24..99e10dc0 100644 --- a/source/engine/common/Cmd.mjs +++ b/source/engine/common/Cmd.mjs @@ -2,7 +2,7 @@ import { AsyncFunction } from '../../shared/Q.ts'; import * as Protocol from '../network/Protocol.ts'; import { eventBus, registry } from '../registry.mjs'; import Cvar from './Cvar.mjs'; -import { clientConnectionState } from './Def.mjs'; +import { clientConnectionState } from './Def.ts'; let { CL, COM, Con, Host } = registry; diff --git a/source/engine/common/Com.mjs b/source/engine/common/Com.mjs index f1ba5ce5..2ab98018 100644 --- a/source/engine/common/Com.mjs +++ b/source/engine/common/Com.mjs @@ -2,13 +2,13 @@ import { registry, eventBus } from '../registry.mjs'; import Q from '../../shared/Q.ts'; -import { CorruptedResourceError } from './Errors.mjs'; +import { CorruptedResourceError } from './Errors.ts'; import Cvar from './Cvar.mjs'; import W from './W.mjs'; import Cmd from './Cmd.mjs'; -import { defaultBasedir, defaultGame } from './Def.mjs'; -import { CRC16CCITT } from './CRC.mjs'; +import { defaultBasedir, defaultGame } from './Def.ts'; +import { CRC16CCITT } from './CRC.ts'; let { Con, Sys } = registry; diff --git a/source/engine/common/Console.mjs b/source/engine/common/Console.mjs index d92aab8e..6ece7d4f 100644 --- a/source/engine/common/Console.mjs +++ b/source/engine/common/Console.mjs @@ -5,7 +5,7 @@ import Cvar from './Cvar.mjs'; import Vector from '../../shared/Vector.ts'; import Cmd from './Cmd.mjs'; import VID from '../client/VID.mjs'; -import { clientConnectionState } from './Def.mjs'; +import { clientConnectionState } from './Def.ts'; import { ClientEngineAPI } from './GameAPIs.mjs'; let { CL, Draw, Host, Key, M, SCR } = registry; diff --git a/source/engine/common/Def.mjs b/source/engine/common/Def.mjs deleted file mode 100644 index 1bf79059..00000000 --- a/source/engine/common/Def.mjs +++ /dev/null @@ -1,162 +0,0 @@ - -/** - * Name of this fun project. - */ -export const productName = 'The Quake Shack'; - -/** - * Version string. - */ -export const productVersion = '1.2.0'; - -/** - * Default game directory. - */ -export const defaultGame = 'id1'; - -/** - * Default base directory. - */ -export const defaultBasedir = 'id1'; - -/** - * Engine limitations. - * @enum {number} - * @readonly - */ -export const limits = Object.freeze({ - edicts: 64, - clients: 32, - dlights: 32, - lightstyles: 64, - beams: 24, - entities: 1024, -}); - -/** - * CL.state.state keys - * @enum {number} - * @readonly - * @deprecated TODO: move to CL or PR, it’s legacy code - */ -export const stat = Object.freeze({ - health: 0, - frags: 1, - weapon: 2, - ammo: 3, - armor: 4, - weaponframe: 5, - shells: 6, - nails: 7, - rockets: 8, - cells: 9, - activeweapon: 10, - totalsecrets: 11, - totalmonsters: 12, - secrets: 13, - monsters: 14, -}); - -/** - * @enum {number} - * @readonly - * @deprecated TODO: move to CL or PR, it’s legacy code - */ -export const it = Object.freeze({ - shotgun: 1, - super_shotgun: 2, - nailgun: 4, - super_nailgun: 8, - grenade_launcher: 16, - rocket_launcher: 32, - lightning: 64, - super_lightning: 128, - shells: 256, - nails: 512, - rockets: 1024, - cells: 2048, - axe: 4096, - armor1: 8192, - armor2: 16384, - armor3: 32768, - superhealth: 65536, - key1: 131072, - key2: 262144, - invisibility: 524288, - invulnerability: 1048576, - suit: 2097152, - quad: 4194304, -}); - -/** - * @enum {number} - * @readonly - * @deprecated TODO: move to CL or PR, it’s legacy code - */ -export const rit = Object.freeze({ - shells: 128, - nails: 256, - rockets: 512, - cells: 1024, - axe: 2048, - lava_nailgun: 4096, - lava_super_nailgun: 8192, - multi_grenade: 16384, - multi_rocket: 32768, - plasma_gun: 65536, - armor1: 8388608, - armor2: 16777216, - armor3: 33554432, - lava_nails: 67108864, - plasma_ammo: 134217728, - multi_rockets: 268435456, - shield: 536870912, - antigrav: 1073741824, - superhealth: 2147483648, -}); - -/** - * @enum {number} - * @readonly - * @deprecated TODO: move to CL or PR, it’s legacy code - */ -export const hit = Object.freeze({ - proximity_gun_bit: 16, - mjolnir_bit: 7, - laser_cannon_bit: 23, - proximity_gun: 65536, - mjolnir: 128, - laser_cannon: 8388608, - wetsuit: 33554432, - empathy_shields: 67108864, -}); - -/** - * @enum {number} - * @readonly - */ -export const contentShift = Object.freeze({ - contents: 0, - damage: 1, - bonus: 2, - powerup: 3, - user1: 4, - user2: 5, - user3: 6, - user4: 7, -}); - -/** - * @enum {number} - * @readonly - */ -export const clientConnectionState = { - disconnected: 0, - connecting: 1, - connected: 2, -}; - -/** - * Version used for savegames. - */ -export const gamestateVersion = 2; diff --git a/source/engine/common/Def.ts b/source/engine/common/Def.ts new file mode 100644 index 00000000..840feece --- /dev/null +++ b/source/engine/common/Def.ts @@ -0,0 +1,148 @@ +/** + * Name of this fun project. + */ +export const productName = 'The Quake Shack'; + +/** + * Version string. + */ +export const productVersion = '1.2.0'; + +/** + * Default game directory. + */ +export const defaultGame = 'id1'; + +/** + * Default base directory. + */ +export const defaultBasedir = 'id1'; + +/** + * Engine limitations. + */ +export enum limits { + edicts = 64, + clients = 32, + dlights = 32, + lightstyles = 64, + beams = 24, + entities = 1024, +} + +/** + * Legacy client stat indices. + */ +export enum stat { + health = 0, + frags = 1, + weapon = 2, + ammo = 3, + armor = 4, + weaponframe = 5, + shells = 6, + nails = 7, + rockets = 8, + cells = 9, + activeweapon = 10, + totalsecrets = 11, + totalmonsters = 12, + secrets = 13, + monsters = 14, +} + +/** + * Legacy item bit flags. + */ +export enum it { + shotgun = 1, + super_shotgun = 2, + nailgun = 4, + super_nailgun = 8, + grenade_launcher = 16, + rocket_launcher = 32, + lightning = 64, + super_lightning = 128, + shells = 256, + nails = 512, + rockets = 1024, + cells = 2048, + axe = 4096, + armor1 = 8192, + armor2 = 16384, + armor3 = 32768, + superhealth = 65536, + key1 = 131072, + key2 = 262144, + invisibility = 524288, + invulnerability = 1048576, + suit = 2097152, + quad = 4194304, +} + +/** + * Rogue item bit flags. + */ +export enum rit { + shells = 128, + nails = 256, + rockets = 512, + cells = 1024, + axe = 2048, + lava_nailgun = 4096, + lava_super_nailgun = 8192, + multi_grenade = 16384, + multi_rocket = 32768, + plasma_gun = 65536, + armor1 = 8388608, + armor2 = 16777216, + armor3 = 33554432, + lava_nails = 67108864, + plasma_ammo = 134217728, + multi_rockets = 268435456, + shield = 536870912, + antigrav = 1073741824, + superhealth = 2147483648, +} + +/** + * Hipnotic item bit flags. + */ +export enum hit { + proximity_gun_bit = 16, + mjolnir_bit = 7, + laser_cannon_bit = 23, + proximity_gun = 65536, + mjolnir = 128, + laser_cannon = 8388608, + wetsuit = 33554432, + empathy_shields = 67108864, +} + +/** + * Bit shifts used by legacy contents flags. + */ +export enum contentShift { + contents = 0, + damage = 1, + bonus = 2, + powerup = 3, + user1 = 4, + user2 = 5, + user3 = 6, + user4 = 7, +} + +/** + * High-level client connection states. + */ +export enum clientConnectionState { + disconnected = 0, + connecting = 1, + connected = 2, +} + +/** + * Version used for savegames. + */ +export const gamestateVersion = 2; diff --git a/source/engine/common/Errors.mjs b/source/engine/common/Errors.mjs deleted file mode 100644 index 97087e1d..00000000 --- a/source/engine/common/Errors.mjs +++ /dev/null @@ -1,75 +0,0 @@ -// When in doubt where to put an exception that many modules can use, put it here. - -/** - * Causes the engine to crash with an error message. - * Loosely based on Sys.Error. - */ -export class SysError extends Error { - constructor(message) { - super(message); - this.name = 'SysError'; - } -}; - -/** - * Causes a Host.Error. - * Breaks the current frame, instantly stops the game and displays the error message. - */ -export class HostError extends Error { - constructor(message) { - super(message); - this.name = 'HostError'; - } -}; - -/** - * NOTE: Use subclasses of SysError to provide more context about the error. - */ -export class ResourceError extends SysError { - resource = null; - error = null; -}; - -/** - * Use this error when a required resource could not be loaded. - * Replaces `Sys.Error('Couldn\'t load gfx/palette.lmp');` stanza. - */ -export class MissingResourceError extends ResourceError { - /** - * @param {string} resource filename - * @param {*} error optional error object, e.g. from a failed fetch - */ - constructor(resource, error = null) { - super(`Couldn't load ${resource}`); - this.resource = resource; - this.error = error; - this.name = 'MissingResourceError'; - } -}; - -export class CorruptedResourceError extends ResourceError { - /** - * @param {string} resource filename - * @param {string} reason optional reason why the resource is considered corrupted - */ - constructor(resource, reason) { - super(`${resource} is corrupted: ${reason}`); - this.resource = resource; - this.error = null; - this.reason = reason; - this.name = 'CorruptedResourceError'; - } -}; - -/** - * Use this error when a method is not implemented in a subclass. - */ -export class NotImplementedError extends SysError { - /** - * @param {string} message message what’s not implemented - */ - constructor(message) { - super(message); - this.name = 'NotImplementedError'; - } -}; diff --git a/source/engine/common/Errors.ts b/source/engine/common/Errors.ts new file mode 100644 index 00000000..f96ea1bc --- /dev/null +++ b/source/engine/common/Errors.ts @@ -0,0 +1,66 @@ +type ResourceLoadError = Error | null; + +/** + * Causes the engine to crash with an error message. + */ +export class SysError extends Error { + constructor(message: string) { + super(message); + this.name = 'SysError'; + } +} + +/** + * Break the current frame, stop the game, and display the error message. + */ +export class HostError extends Error { + constructor(message: string) { + super(message); + this.name = 'HostError'; + } +} + +/** + * Base resource failure carrying the resource name and an optional underlying error. + */ +export class ResourceError extends SysError { + resource: string | null = null; + error: ResourceLoadError = null; +} + +/** + * Use this when a required resource could not be loaded. + */ +export class MissingResourceError extends ResourceError { + constructor(resource: string, error: ResourceLoadError = null) { + super(`Couldn't load ${resource}`); + this.resource = resource; + this.error = error; + this.name = 'MissingResourceError'; + } +} + +/** + * Use this when a resource exists but fails validation. + */ +export class CorruptedResourceError extends ResourceError { + reason: string; + + constructor(resource: string, reason: string) { + super(`${resource} is corrupted: ${reason}`); + this.resource = resource; + this.error = null; + this.reason = reason; + this.name = 'CorruptedResourceError'; + } +} + +/** + * Use this when a subclass must provide an implementation. + */ +export class NotImplementedError extends SysError { + constructor(message: string) { + super(message); + this.name = 'NotImplementedError'; + } +} diff --git a/source/engine/common/GameAPIs.mjs b/source/engine/common/GameAPIs.mjs index f97d2589..f45ce4fc 100644 --- a/source/engine/common/GameAPIs.mjs +++ b/source/engine/common/GameAPIs.mjs @@ -9,7 +9,7 @@ import { EventBus, eventBus, registry } from '../registry.mjs'; import { ED, ServerEdict } from '../server/Edict.mjs'; import Cmd from './Cmd.mjs'; import Cvar from './Cvar.mjs'; -import { HostError } from './Errors.mjs'; +import { HostError } from './Errors.ts'; import Mod from './Mod.mjs'; import W from './W.mjs'; diff --git a/source/engine/common/Host.mjs b/source/engine/common/Host.mjs index e35d8e7a..b387c757 100644 --- a/source/engine/common/Host.mjs +++ b/source/engine/common/Host.mjs @@ -1,6 +1,6 @@ import Cvar from './Cvar.mjs'; import * as Protocol from '../network/Protocol.ts'; -import * as Def from './Def.mjs'; +import * as Def from './Def.ts'; import Cmd, { ConsoleCommand } from './Cmd.mjs'; import { eventBus, registry } from '../registry.mjs'; import Vector from '../../shared/Vector.ts'; @@ -9,7 +9,7 @@ import { ServerClient } from '../server/Client.mjs'; import { ServerEngineAPI } from './GameAPIs.mjs'; import Chase from '../client/Chase.mjs'; import VID from '../client/VID.mjs'; -import { HostError } from './Errors.mjs'; +import { HostError } from './Errors.ts'; import CDAudio from '../client/CDAudio.mjs'; import * as Defs from '../../shared/Defs.ts'; import { content, gameCapabilities } from '../../shared/Defs.ts'; diff --git a/source/engine/common/Mod.mjs b/source/engine/common/Mod.mjs index 5a429b5a..23729276 100644 --- a/source/engine/common/Mod.mjs +++ b/source/engine/common/Mod.mjs @@ -1,5 +1,5 @@ import { eventBus, registry } from '../registry.mjs'; -import { MissingResourceError } from './Errors.mjs'; +import { MissingResourceError } from './Errors.ts'; import { ModelLoaderRegistry } from './model/ModelLoaderRegistry.mjs'; import { AliasMDLLoader } from './model/loaders/AliasMDLLoader.mjs'; import { SpriteSPRLoader } from './model/loaders/SpriteSPRLoader.mjs'; diff --git a/source/engine/common/Sys.mjs b/source/engine/common/Sys.mjs index 0496c513..9b5206c8 100644 --- a/source/engine/common/Sys.mjs +++ b/source/engine/common/Sys.mjs @@ -1,4 +1,4 @@ -import { NotImplementedError } from './Errors.mjs'; +import { NotImplementedError } from './Errors.ts'; export class BaseWorker { /** @type {Function[]} @protected */ diff --git a/source/engine/common/W.mjs b/source/engine/common/W.mjs index 360e8b30..9fbcb4a3 100644 --- a/source/engine/common/W.mjs +++ b/source/engine/common/W.mjs @@ -1,6 +1,6 @@ import { eventBus, registry } from '../registry.mjs'; -import { CorruptedResourceError, MissingResourceError } from './Errors.mjs'; +import { CorruptedResourceError, MissingResourceError } from './Errors.ts'; import Q from '../../shared/Q.ts'; let { COM } = registry; diff --git a/source/engine/common/WorkerManager.mjs b/source/engine/common/WorkerManager.mjs index 03f7fcf6..9c060388 100644 --- a/source/engine/common/WorkerManager.mjs +++ b/source/engine/common/WorkerManager.mjs @@ -1,5 +1,5 @@ import { registry, eventBus } from '../registry.mjs'; -import { SysError } from './Errors.mjs'; +import { SysError } from './Errors.ts'; import PlatformWorker from './PlatformWorker.mjs'; let { Con, COM } = registry; diff --git a/source/engine/common/model/ModelLoader.mjs b/source/engine/common/model/ModelLoader.mjs index 13be2558..a468201b 100644 --- a/source/engine/common/model/ModelLoader.mjs +++ b/source/engine/common/model/ModelLoader.mjs @@ -1,4 +1,4 @@ -import { NotImplementedError } from '../Errors.mjs'; +import { NotImplementedError } from '../Errors.ts'; /** * Abstract base class for model format loaders. diff --git a/source/engine/common/model/ModelLoaderRegistry.mjs b/source/engine/common/model/ModelLoaderRegistry.mjs index 0ea5708c..68ee379e 100644 --- a/source/engine/common/model/ModelLoaderRegistry.mjs +++ b/source/engine/common/model/ModelLoaderRegistry.mjs @@ -1,4 +1,4 @@ -import { NotImplementedError } from '../Errors.mjs'; +import { NotImplementedError } from '../Errors.ts'; import { BaseModel } from './BaseModel.mjs'; import { ModelLoader } from './ModelLoader.mjs'; diff --git a/source/engine/common/model/loaders/AliasMDLLoader.mjs b/source/engine/common/model/loaders/AliasMDLLoader.mjs index fbb41b5e..a943bbdd 100644 --- a/source/engine/common/model/loaders/AliasMDLLoader.mjs +++ b/source/engine/common/model/loaders/AliasMDLLoader.mjs @@ -2,7 +2,7 @@ import Vector from '../../../../shared/Vector.ts'; import Q from '../../../../shared/Q.ts'; import GL, { GLTexture, resampleTexture8 } from '../../../client/GL.mjs'; import W, { translateIndexToLuminanceRGBA, translateIndexToRGBA } from '../../W.mjs'; -import { CRC16CCITT } from '../../CRC.mjs'; +import { CRC16CCITT } from '../../CRC.ts'; import { registry } from '../../../registry.mjs'; import { ModelLoader } from '../ModelLoader.mjs'; import { AliasModel } from '../AliasModel.mjs'; diff --git a/source/engine/common/model/loaders/BSP29Loader.mjs b/source/engine/common/model/loaders/BSP29Loader.mjs index 3ba4be74..aaa7ab59 100644 --- a/source/engine/common/model/loaders/BSP29Loader.mjs +++ b/source/engine/common/model/loaders/BSP29Loader.mjs @@ -3,8 +3,8 @@ import Q from '../../../../shared/Q.ts'; import { content } from '../../../../shared/Defs.ts'; import { GLTexture } from '../../../client/GL.mjs'; import W, { readWad3Texture, translateIndexToLuminanceRGBA, translateIndexToRGBA } from '../../W.mjs'; -import { CRC16CCITT } from '../../CRC.mjs'; -import { CorruptedResourceError } from '../../Errors.mjs'; +import { CRC16CCITT } from '../../CRC.ts'; +import { CorruptedResourceError } from '../../Errors.ts'; import { eventBus, registry } from '../../../registry.mjs'; import { ModelLoader } from '../ModelLoader.mjs'; import { Brush, BrushModel, BrushSide, Node } from '../BSP.mjs'; diff --git a/source/engine/common/model/loaders/BSP2Loader.mjs b/source/engine/common/model/loaders/BSP2Loader.mjs index 0c8e49cc..32d56c13 100644 --- a/source/engine/common/model/loaders/BSP2Loader.mjs +++ b/source/engine/common/model/loaders/BSP2Loader.mjs @@ -1,5 +1,5 @@ import Vector from '../../../../shared/Vector.ts'; -import { CorruptedResourceError } from '../../Errors.mjs'; +import { CorruptedResourceError } from '../../Errors.ts'; import { BSP29Loader } from './BSP29Loader.mjs'; import { Face } from '../BaseModel.mjs'; import { BrushModel, Node } from '../BSP.mjs'; diff --git a/source/engine/common/model/loaders/BSP38Loader.mjs b/source/engine/common/model/loaders/BSP38Loader.mjs index 06d0f9d0..118a335d 100644 --- a/source/engine/common/model/loaders/BSP38Loader.mjs +++ b/source/engine/common/model/loaders/BSP38Loader.mjs @@ -1,7 +1,7 @@ import { content } from '../../../../shared/Defs.ts'; import Q from '../../../../shared/Q.ts'; import Vector from '../../../../shared/Vector.ts'; -import { CRC16CCITT } from '../../CRC.mjs'; +import { CRC16CCITT } from '../../CRC.ts'; import { Plane } from '../BaseModel.mjs'; import { Brush, BrushModel, BrushSide, Node } from '../BSP.mjs'; import { ModelLoader } from '../ModelLoader.mjs'; diff --git a/source/engine/common/model/loaders/SpriteSPRLoader.mjs b/source/engine/common/model/loaders/SpriteSPRLoader.mjs index 116a6dcc..2406b26c 100644 --- a/source/engine/common/model/loaders/SpriteSPRLoader.mjs +++ b/source/engine/common/model/loaders/SpriteSPRLoader.mjs @@ -1,7 +1,7 @@ import Vector from '../../../../shared/Vector.ts'; import { GLTexture } from '../../../client/GL.mjs'; import W, { translateIndexToRGBA } from '../../W.mjs'; -import { CRC16CCITT } from '../../CRC.mjs'; +import { CRC16CCITT } from '../../CRC.ts'; import { registry } from '../../../registry.mjs'; import { ModelLoader } from '../ModelLoader.mjs'; import { SpriteModel } from '../SpriteModel.mjs'; diff --git a/source/engine/network/Network.ts b/source/engine/network/Network.ts index 765f3e32..ed2f4d3e 100644 --- a/source/engine/network/Network.ts +++ b/source/engine/network/Network.ts @@ -2,7 +2,7 @@ import type { Server as HttpServer } from 'node:http'; import Cmd from '../common/Cmd.mjs'; import Cvar from '../common/Cvar.mjs'; -import { clientConnectionState } from '../common/Def.mjs'; +import { clientConnectionState } from '../common/Def.ts'; import Q from '../../shared/Q.ts'; import { eventBus, getClientRegistry, getCommonRegistry, registry } from '../registry.mjs'; import { SzBuffer } from './MSG.ts'; diff --git a/source/engine/network/NetworkDrivers.ts b/source/engine/network/NetworkDrivers.ts index e54a562a..2ee266e6 100644 --- a/source/engine/network/NetworkDrivers.ts +++ b/source/engine/network/NetworkDrivers.ts @@ -1,5 +1,5 @@ import Cvar from '../common/Cvar.mjs'; -import { HostError } from '../common/Errors.mjs'; +import { HostError } from '../common/Errors.ts'; import type { SzBuffer } from './MSG.ts'; import { eventBus, getCommonRegistry, registry } from '../registry.mjs'; import { formatIP } from './Misc.ts'; diff --git a/source/engine/server/Com.mjs b/source/engine/server/Com.mjs index d5a21d28..cc271d47 100644 --- a/source/engine/server/Com.mjs +++ b/source/engine/server/Com.mjs @@ -3,10 +3,10 @@ import { promises as fsPromises, existsSync, writeFileSync, constants } from 'fs'; import Q from '../../shared/Q.ts'; -import { CRC16CCITT as CRC } from '../common/CRC.mjs'; +import { CRC16CCITT as CRC } from '../common/CRC.ts'; import COM from '../common/Com.mjs'; -import { CorruptedResourceError } from '../common/Errors.mjs'; +import { CorruptedResourceError } from '../common/Errors.ts'; import { registry, eventBus } from '../registry.mjs'; let { Con, Sys } = registry; diff --git a/source/engine/server/Edict.mjs b/source/engine/server/Edict.mjs index 9a63e050..7e57cf23 100644 --- a/source/engine/server/Edict.mjs +++ b/source/engine/server/Edict.mjs @@ -1,7 +1,7 @@ import Vector from '../../shared/Vector.ts'; import { SzBuffer, registerSerializableType } from '../network/MSG.ts'; import * as Protocol from '../network/Protocol.ts'; -import * as Def from '../common/Def.mjs'; +import * as Def from '../common/Def.ts'; import * as Defs from '../../shared/Defs.ts'; import { eventBus, registry } from '../registry.mjs'; import Q from '../../shared/Q.ts'; diff --git a/source/engine/server/Navigation.mjs b/source/engine/server/Navigation.mjs index 29ab4fe1..40191ce7 100644 --- a/source/engine/server/Navigation.mjs +++ b/source/engine/server/Navigation.mjs @@ -5,7 +5,7 @@ import Vector from '../../shared/Vector.ts'; import Cmd from '../common/Cmd.mjs'; // import Cmd, { ConsoleCommand } from '../common/Cmd.mjs'; import Cvar from '../common/Cvar.mjs'; -import { CorruptedResourceError, MissingResourceError } from '../common/Errors.mjs'; +import { CorruptedResourceError, MissingResourceError } from '../common/Errors.ts'; import { ServerEngineAPI } from '../common/GameAPIs.mjs'; import { BrushModel } from '../common/Mod.mjs'; import { MIN_STEP_NORMAL, STEPSIZE } from '../common/Pmove.mjs'; diff --git a/source/engine/server/Progs.mjs b/source/engine/server/Progs.mjs index 718bc5a3..f289e9c5 100644 --- a/source/engine/server/Progs.mjs +++ b/source/engine/server/Progs.mjs @@ -1,7 +1,7 @@ import Cmd from '../common/Cmd.mjs'; -import { CRC16CCITT } from '../common/CRC.mjs'; +import { CRC16CCITT } from '../common/CRC.ts'; import Cvar from '../common/Cvar.mjs'; -import { HostError, MissingResourceError } from '../common/Errors.mjs'; +import { HostError, MissingResourceError } from '../common/Errors.ts'; import Q from '../../shared/Q.ts'; import Vector from '../../shared/Vector.ts'; import { eventBus, registry } from '../registry.mjs'; diff --git a/source/engine/server/ProgsAPI.mjs b/source/engine/server/ProgsAPI.mjs index 9449ee3d..fc5ef06c 100644 --- a/source/engine/server/ProgsAPI.mjs +++ b/source/engine/server/ProgsAPI.mjs @@ -1,6 +1,6 @@ import Vector from '../../shared/Vector.ts'; import Cmd from '../common/Cmd.mjs'; -import { HostError } from '../common/Errors.mjs'; +import { HostError } from '../common/Errors.ts'; import { ServerEngineAPI } from '../common/GameAPIs.mjs'; import { eventBus, registry } from '../registry.mjs'; import { ED, ServerEdict } from './Edict.mjs'; diff --git a/source/engine/server/Server.mjs b/source/engine/server/Server.mjs index 9c2080da..2fe6658b 100644 --- a/source/engine/server/Server.mjs +++ b/source/engine/server/Server.mjs @@ -3,7 +3,7 @@ import { MoveVars, Pmove } from '../common/Pmove.mjs'; import Vector from '../../shared/Vector.ts'; import { SzBuffer } from '../network/MSG.ts'; import * as Protocol from '../network/Protocol.ts'; -import * as Def from './../common/Def.mjs'; +import * as Def from './../common/Def.ts'; import Cmd, { ConsoleCommand } from '../common/Cmd.mjs'; import { ED, ServerEdict } from './Edict.mjs'; import { EventBus, eventBus, registry } from '../registry.mjs'; diff --git a/test/common/crc.test.mjs b/test/common/crc.test.mjs new file mode 100644 index 00000000..d8177c94 --- /dev/null +++ b/test/common/crc.test.mjs @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { CRC16CCITT } from '../../source/engine/common/CRC.ts'; + +void describe('CRC16CCITT', () => { + void test('matches the standard CRC-16-CCITT checksum for 123456789', () => { + const input = new TextEncoder().encode('123456789'); + + assert.equal(CRC16CCITT.Block(input), 0x29b1); + }); + + void test('returns the initial seed when hashing an empty block', () => { + assert.equal(CRC16CCITT.Block(new Uint8Array(0)), 0xffff); + }); +}); diff --git a/test/common/def.test.mjs b/test/common/def.test.mjs new file mode 100644 index 00000000..d8755f9d --- /dev/null +++ b/test/common/def.test.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { clientConnectionState, defaultBasedir, defaultGame, gamestateVersion, limits, productName } from '../../source/engine/common/Def.ts'; + +void describe('common definitions', () => { + void test('exposes the expected engine identity and defaults', () => { + assert.equal(productName, 'The Quake Shack'); + assert.equal(defaultGame, 'id1'); + assert.equal(defaultBasedir, 'id1'); + assert.equal(gamestateVersion, 2); + }); + + void test('keeps stable numeric limits and connection state values', () => { + assert.equal(limits.edicts, 64); + assert.equal(limits.clients, 32); + assert.equal(limits.dlights, 32); + assert.equal(limits.lightstyles, 64); + assert.equal(limits.beams, 24); + assert.equal(limits.entities, 1024); + + assert.equal(clientConnectionState.disconnected, 0); + assert.equal(clientConnectionState.connecting, 1); + assert.equal(clientConnectionState.connected, 2); + }); +}); diff --git a/test/common/errors.test.mjs b/test/common/errors.test.mjs new file mode 100644 index 00000000..19e27435 --- /dev/null +++ b/test/common/errors.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { CorruptedResourceError, HostError, MissingResourceError, NotImplementedError, SysError } from '../../source/engine/common/Errors.ts'; + +void describe('common errors', () => { + void test('MissingResourceError stores the resource and optional cause', () => { + const cause = new Error('network failed'); + const error = new MissingResourceError('gfx/palette.lmp', cause); + + assert.equal(error.name, 'MissingResourceError'); + assert.equal(error.message, "Couldn't load gfx/palette.lmp"); + assert.equal(error.resource, 'gfx/palette.lmp'); + assert.equal(error.error, cause); + }); + + void test('CorruptedResourceError includes the corruption reason', () => { + const error = new CorruptedResourceError('maps/start.bsp', 'invalid binary magic'); + + assert.equal(error.name, 'CorruptedResourceError'); + assert.equal(error.resource, 'maps/start.bsp'); + assert.equal(error.reason, 'invalid binary magic'); + assert.equal(error.message, 'maps/start.bsp is corrupted: invalid binary magic'); + }); + + void test('HostError and NotImplementedError preserve their class hierarchy', () => { + const hostError = new HostError('host failure'); + const notImplementedError = new NotImplementedError('missing override'); + + assert.equal(hostError.name, 'HostError'); + assert.equal(notImplementedError.name, 'NotImplementedError'); + assert.equal(notImplementedError instanceof SysError, true); + }); +}); From 0b7b3233ddeb6b532bf254f63661da84db9d7117 Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 16:14:44 +0300 Subject: [PATCH 14/67] removed Host.client --- source/engine/common/Host.mjs | 120 ++++++++++++++++++---------------- 1 file changed, 64 insertions(+), 56 deletions(-) diff --git a/source/engine/common/Host.mjs b/source/engine/common/Host.mjs index b387c757..9e02de8a 100644 --- a/source/engine/common/Host.mjs +++ b/source/engine/common/Host.mjs @@ -121,9 +121,13 @@ Host.SendChatMessageToClient = function(client, name, message, direct = false) { client.message.writeByte(direct ? 1 : 0); }; -Host.ClientPrint = function(string) { // FIXME: Host.client - Host.client.message.writeByte(Protocol.svc.print); - Host.client.message.writeString(string); +/** + * @param {ServerClient} client recipient client + * @param {string} string text to send + */ +Host.ClientPrint = function(client, string) { + client.message.writeByte(Protocol.svc.print); + client.message.writeString(string); }; Host.BroadcastPrint = function(string) { @@ -204,16 +208,16 @@ Host.ShutdownServer = function(isCrashShutdown = false) { // TODO: SV duties do { count = 0; for (i = 0; i < SV.svs.maxclients; i++) { // FIXME: this 1is completely broken, it won’t properly close connections - Host.client = SV.svs.clients[i]; - if (Host.client.state < ServerClient.STATE.CONNECTED || Host.client.message.cursize === 0) { + const client = SV.svs.clients[i]; + if (client.state < ServerClient.STATE.CONNECTED || client.message.cursize === 0) { continue; } - if (NET.CanSendMessage(Host.client.netconnection)) { - NET.SendMessage(Host.client.netconnection, Host.client.message); - Host.client.message.clear(); + if (NET.CanSendMessage(client.netconnection)) { + NET.SendMessage(client.netconnection, client.message); + client.message.clear(); continue; } - NET.GetMessage(Host.client.netconnection); + NET.GetMessage(client.netconnection); count++; } if ((Sys.FloatTime() - start) > 3.0) { // this breaks a loop when the stuff on the top is stuck @@ -561,7 +565,10 @@ Host.Status_f = function() { } print = Con.Print; } else { - print = Host.ClientPrint; + const client = this.client; + print = (text) => { + Host.ClientPrint(client, text); + }; } print('hostname: ' + NET.hostname.string + '\n'); print('address : ' + NET.GetListenAddress() + '\n'); @@ -614,7 +621,7 @@ class HostConsoleCommand extends ConsoleCommand { */ cheat() { if (!SV.cheats.value) { - Host.ClientPrint('Cheats are not enabled on this server.\n'); + Host.ClientPrint(this.client, 'Cheats are not enabled on this server.\n'); return true; } @@ -633,9 +640,9 @@ Host.God_f = class extends HostConsoleCommand { const client = this.client; client.edict.entity.flags ^= Defs.flags.FL_GODMODE; if ((client.edict.entity.flags & Defs.flags.FL_GODMODE) === 0) { - Host.ClientPrint('godmode OFF\n'); + Host.ClientPrint(client, 'godmode OFF\n'); } else { - Host.ClientPrint('godmode ON\n'); + Host.ClientPrint(client, 'godmode ON\n'); } } }; @@ -651,9 +658,9 @@ Host.Notarget_f = class extends HostConsoleCommand { const client = this.client; client.edict.entity.flags ^= Defs.flags.FL_NOTARGET; if ((client.edict.entity.flags & Defs.flags.FL_NOTARGET) === 0) { - Host.ClientPrint('notarget OFF\n'); + Host.ClientPrint(client, 'notarget OFF\n'); } else { - Host.ClientPrint('notarget ON\n'); + Host.ClientPrint(client, 'notarget ON\n'); } } }; @@ -670,12 +677,12 @@ Host.Noclip_f = class extends HostConsoleCommand { if (client.edict.entity.movetype !== Defs.moveType.MOVETYPE_NOCLIP) { Host.noclip_anglehack = true; client.edict.entity.movetype = Defs.moveType.MOVETYPE_NOCLIP; - Host.ClientPrint('noclip ON\n'); + Host.ClientPrint(client, 'noclip ON\n'); return; } Host.noclip_anglehack = false; client.edict.entity.movetype = Defs.moveType.MOVETYPE_WALK; - Host.ClientPrint('noclip OFF\n'); + Host.ClientPrint(client, 'noclip OFF\n'); } }; @@ -690,11 +697,11 @@ Host.Fly_f = class extends HostConsoleCommand { const client = this.client; if (client.edict.entity.movetype !== Defs.moveType.MOVETYPE_FLY) { client.edict.entity.movetype = Defs.moveType.MOVETYPE_FLY; - Host.ClientPrint('flymode ON\n'); + Host.ClientPrint(client, 'flymode ON\n'); return; } client.edict.entity.movetype = Defs.moveType.MOVETYPE_WALK; - Host.ClientPrint('flymode OFF\n'); + Host.ClientPrint(client, 'flymode OFF\n'); } }; @@ -703,7 +710,9 @@ Host.Ping_f = function() { return; } - Host.ClientPrint('Client ping times:\n'); + const recipientClient = this.client; + + Host.ClientPrint(recipientClient, 'Client ping times:\n'); for (let i = 0; i < SV.svs.maxclients; i++) { /** @type {ServerClient} */ @@ -719,7 +728,7 @@ Host.Ping_f = function() { total += client.ping_times[j]; } - Host.ClientPrint((total * 62.5).toFixed(0).padStart(3) + ' ' + client.name + '\n'); + Host.ClientPrint(recipientClient, (total * 62.5).toFixed(0).padStart(3) + ' ' + client.name + '\n'); } }; @@ -822,7 +831,7 @@ Host.Changelevel_f = function(mapname) { Host.Restart_f = function() { if ((SV.server.active) && (registry.isDedicatedServer || !CL.cls.demoplayback && !this.client)) { - Cmd.ExecuteString(`map ${SV.server.mapname}`); + void Cmd.ExecuteString(`map ${SV.server.mapname}`); } }; @@ -1098,7 +1107,7 @@ Host.Say_f = function(teamonly, message) { return; } - const save = Host.client; + const sender = this.client; if (message.length > 140) { message = message.substring(0, 140) + '...'; @@ -1109,15 +1118,13 @@ Host.Say_f = function(teamonly, message) { if (client.state < ServerClient.STATE.CONNECTED) { continue; } - if ((Host.teamplay.value !== 0) && (teamonly === true) && (client.entity.team !== save.entity.team)) { // Legacy cvars + if ((Host.teamplay.value !== 0) && (teamonly === true) && (client.entity.team !== sender.entity.team)) { // Legacy cvars continue; } - Host.SendChatMessageToClient(client, save.name, message, false); + Host.SendChatMessageToClient(client, sender.name, message, false); } - Host.client = save; // unsure whether I removed it or not - - Con.Print(`${save.name}: ${message}\n`); + Con.Print(`${sender.name}: ${message}\n`); }; Host.Say_Team_f = function(message) { @@ -1148,7 +1155,7 @@ Host.Tell_f = function(recipient, message) { message = message.substring(0, 140) + '...'; } - const save = Host.client; + const sender = this.client; for (let i = 0; i < SV.svs.maxclients; i++) { const client = SV.svs.clients[i]; if (client.state < ServerClient.STATE.CONNECTED) { @@ -1157,14 +1164,13 @@ Host.Tell_f = function(recipient, message) { if (client.name.toLowerCase() !== recipient.toLowerCase()) { continue; } - Host.SendChatMessageToClient(client, save.name, message, true); - Host.SendChatMessageToClient(Host.client, save.name, message, true); + Host.SendChatMessageToClient(client, sender.name, message, true); + Host.SendChatMessageToClient(sender, sender.name, message, true); break; } - Host.client = save; }; -Host.Color_f = function(...argv) { // signon 2, step 2 // FIXME: Host.client +Host.Color_f = function(...argv) { // signon 2, step 2 Con.DPrint(`Host.Color_f: ${this.client}\n`); if (argv.length <= 1) { Con.Print('"color" is "' + (CL.color.value >> 4) + ' ' + (CL.color.value & 15) + '"\ncolor <0-13> [0-13]\n'); @@ -1213,7 +1219,7 @@ Host.Kill_f = function() { const client = this.client; if (client.edict.entity.health <= 0.0) { - Host.ClientPrint('Can\'t suicide -- already dead!\n'); + Host.ClientPrint(client, 'Can\'t suicide -- already dead!\n'); return; } @@ -1226,12 +1232,14 @@ Host.Pause_f = function() { return; } + const client = this.client; + if (Host.pausable.value === 0) { - Host.ClientPrint('Pause not allowed.\n'); + Host.ClientPrint(client, 'Pause not allowed.\n'); return; } SV.server.paused = !SV.server.paused; - Host.BroadcastPrint(Host.client.name + (SV.server.paused === true ? ' paused the game\n' : ' unpaused the game\n')); + Host.BroadcastPrint(client.name + (SV.server.paused === true ? ' paused the game\n' : ' unpaused the game\n')); SV.server.reliable_datagram.writeByte(Protocol.svc.setpause); SV.server.reliable_datagram.writeByte(SV.server.paused === true ? 1 : 0); }; @@ -1370,7 +1378,7 @@ Host.Begin_f = function() { // signon 3, step 1 } }; -Host.Kick_f = function() { // FIXME: Host.client +Host.Kick_f = function() { const argv = this.argv; if (!this.client) { if (!SV.server.active) { @@ -1381,9 +1389,11 @@ Host.Kick_f = function() { // FIXME: Host.client if (argv.length < 2) { return; } - const save = Host.client; const s = argv[1].toLowerCase(); + const invokingClient = this.client; let i; let byNumber = false; + let targetClient = /** @type {ServerClient | null} */ (null); + if ((argv.length >= 3) && (s === '#')) { i = Q.atoi(argv[2]) - 1; if ((i < 0) || (i >= SV.svs.maxclients)) { @@ -1392,38 +1402,35 @@ Host.Kick_f = function() { // FIXME: Host.client if (SV.svs.clients[i].state !== ServerClient.STATE.SPAWNED) { return; } - Host.client = SV.svs.clients[i]; + targetClient = SV.svs.clients[i]; byNumber = true; } else { for (i = 0; i < SV.svs.maxclients; i++) { - Host.client = SV.svs.clients[i]; - if (Host.client.state < ServerClient.STATE.CONNECTED) { + const client = SV.svs.clients[i]; + if (client.state < ServerClient.STATE.CONNECTED) { continue; } - if (Host.client.name.toLowerCase() === s) { + if (client.name.toLowerCase() === s) { + targetClient = client; break; } } } - if (i >= SV.svs.maxclients) { - Host.client = save; + if (targetClient === null) { return; } - if (Host.client === save) { + if (targetClient === invokingClient) { return; } let who; - if (!this.client) { + if (!invokingClient) { if (registry.isDedicatedServer) { who = NET.hostname.string; } else { who = CL.name.string; } } else { - if (Host.client === save) { - return; - } - who = save.name; + who = invokingClient.name; } let message; if (argv.length >= 3) { @@ -1448,8 +1455,7 @@ Host.Kick_f = function() { // FIXME: Host.client } dropReason = 'Kicked by ' + who + ': ' + message.data.substring(p); } - Host.DropClient(Host.client, false, dropReason); - Host.client = save; + Host.DropClient(targetClient, false, dropReason); }; Host.Give_f = class extends HostConsoleCommand { // TODO: move to game @@ -1466,15 +1472,17 @@ Host.Give_f = class extends HostConsoleCommand { // TODO: move to game return; } + const client = this.client; + if (!classname) { - Host.ClientPrint('give \n'); + Host.ClientPrint(client, 'give \n'); return; } - const player = this.client.edict; + const player = client.edict; if (!classname.startsWith('item_') && !classname.startsWith('weapon_')) { - Host.ClientPrint('Only entity classes item_* and weapon_* are allowed!\n'); + Host.ClientPrint(client, 'Only entity classes item_* and weapon_* are allowed!\n'); return; } @@ -1493,7 +1501,7 @@ Host.Give_f = class extends HostConsoleCommand { // TODO: move to game const origin = trace.point.subtract(forward.multiply(16.0)).add(new Vector(0.0, 0.0, 16.0)); if (![content.CONTENT_EMPTY, content.CONTENT_WATER].includes(ServerEngineAPI.DetermineStaticWorldContents(origin))) { - Host.ClientPrint('Item would spawn out of world!\n'); + Host.ClientPrint(client, 'Item would spawn out of world!\n'); return; } From 8d8fc82ac020a66da290ec6bf6c67f8d973bb173 Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 16:36:55 +0300 Subject: [PATCH 15/67] TS: common/Cmd, common/Cvar --- source/engine/client/CDAudio.mjs | 4 +- source/engine/client/CL.mjs | 4 +- source/engine/client/Chase.mjs | 2 +- source/engine/client/ClientConnection.mjs | 4 +- source/engine/client/ClientInput.mjs | 4 +- source/engine/client/ClientLifecycle.mjs | 4 +- .../client/ClientServerCommandHandlers.mjs | 2 +- source/engine/client/GL.mjs | 4 +- source/engine/client/IN.mjs | 2 +- source/engine/client/Key.mjs | 4 +- source/engine/client/Menu.mjs | 4 +- source/engine/client/R.mjs | 4 +- source/engine/client/SCR.mjs | 4 +- source/engine/client/Sbar.mjs | 4 +- source/engine/client/Sound.mjs | 4 +- source/engine/client/Tools.mjs | 2 +- source/engine/client/V.mjs | 4 +- source/engine/client/VID.mjs | 2 +- source/engine/client/menu/MenuItem.mjs | 2 +- source/engine/client/menu/Multiplayer.mjs | 2 +- source/engine/client/renderer/PostProcess.mjs | 2 +- source/engine/client/renderer/ShadowMap.mjs | 2 +- source/engine/common/Cmd.mjs | 398 ---------------- source/engine/common/Cmd.ts | 424 ++++++++++++++++++ source/engine/common/Com.mjs | 4 +- source/engine/common/Console.mjs | 4 +- source/engine/common/Cvar.mjs | 404 ----------------- source/engine/common/Cvar.ts | 326 ++++++++++++++ source/engine/common/GameAPIs.mjs | 4 +- source/engine/common/Host.mjs | 4 +- source/engine/common/Pmove.mjs | 2 +- source/engine/network/ConsoleCommands.ts | 2 +- source/engine/network/Network.ts | 4 +- source/engine/network/NetworkDrivers.ts | 2 +- source/engine/server/Edict.mjs | 2 +- source/engine/server/Navigation.mjs | 6 +- source/engine/server/Progs.mjs | 4 +- source/engine/server/ProgsAPI.mjs | 2 +- source/engine/server/Server.mjs | 4 +- source/engine/server/ServerMessages.mjs | 2 +- source/engine/server/Sys.mjs | 8 +- source/shared/GameInterfaces.ts | 2 +- test/common/cmd.test.mjs | 141 ++++++ test/common/cvar.test.mjs | 114 +++++ test/physics/fixtures.mjs | 29 +- 45 files changed, 1091 insertions(+), 871 deletions(-) delete mode 100644 source/engine/common/Cmd.mjs create mode 100644 source/engine/common/Cmd.ts delete mode 100644 source/engine/common/Cvar.mjs create mode 100644 source/engine/common/Cvar.ts create mode 100644 test/common/cmd.test.mjs create mode 100644 test/common/cvar.test.mjs diff --git a/source/engine/client/CDAudio.mjs b/source/engine/client/CDAudio.mjs index e9f32f97..51794b70 100644 --- a/source/engine/client/CDAudio.mjs +++ b/source/engine/client/CDAudio.mjs @@ -1,5 +1,5 @@ -import Cmd from '../common/Cmd.mjs'; -import Cvar from '../common/Cvar.mjs'; +import Cmd from '../common/Cmd.ts'; +import Cvar from '../common/Cvar.ts'; import Q from '../../shared/Q.ts'; import { eventBus, registry } from '../registry.mjs'; diff --git a/source/engine/client/CL.mjs b/source/engine/client/CL.mjs index ca45f4cf..87010c84 100644 --- a/source/engine/client/CL.mjs +++ b/source/engine/client/CL.mjs @@ -1,8 +1,8 @@ import Q from '../../shared/Q.ts'; import * as Def from '../common/Def.ts'; import * as Protocol from '../network/Protocol.ts'; -import Cmd, { ConsoleCommand } from '../common/Cmd.mjs'; -import Cvar from '../common/Cvar.mjs'; +import Cmd, { ConsoleCommand } from '../common/Cmd.ts'; +import Cvar from '../common/Cvar.ts'; import { Pmove, PmovePlayer } from '../common/Pmove.mjs'; import { eventBus, registry } from '../registry.mjs'; import { gameCapabilities, solid } from '../../shared/Defs.ts'; diff --git a/source/engine/client/Chase.mjs b/source/engine/client/Chase.mjs index dcdeed65..4c4c9ca2 100644 --- a/source/engine/client/Chase.mjs +++ b/source/engine/client/Chase.mjs @@ -1,5 +1,5 @@ import Vector from '../../shared/Vector.ts'; -import Cvar from '../common/Cvar.mjs'; +import Cvar from '../common/Cvar.ts'; import { eventBus, registry } from '../registry.mjs'; let { CL, R, SV } = registry; diff --git a/source/engine/client/ClientConnection.mjs b/source/engine/client/ClientConnection.mjs index 1d949f46..bf2a53b4 100644 --- a/source/engine/client/ClientConnection.mjs +++ b/source/engine/client/ClientConnection.mjs @@ -1,7 +1,7 @@ import * as Protocol from '../network/Protocol.ts'; import { HostError } from '../common/Errors.ts'; -import Cvar from '../common/Cvar.mjs'; -import Cmd from '../common/Cmd.mjs'; +import Cvar from '../common/Cvar.ts'; +import Cmd from '../common/Cmd.ts'; import ClientInput from './ClientInput.mjs'; import { clientRuntimeState, clientStaticState } from './ClientState.mjs'; import { eventBus, registry } from '../registry.mjs'; diff --git a/source/engine/client/ClientInput.mjs b/source/engine/client/ClientInput.mjs index ebe671d4..2f5afac6 100644 --- a/source/engine/client/ClientInput.mjs +++ b/source/engine/client/ClientInput.mjs @@ -2,7 +2,7 @@ import Vector from '../../shared/Vector.ts'; import * as Protocol from '../network/Protocol.ts'; import Q from '../../shared/Q.ts'; import { SzBuffer } from '../network/MSG.ts'; -import Cmd from '../common/Cmd.mjs'; +import Cmd from '../common/Cmd.ts'; import { eventBus, registry } from '../registry.mjs'; import { HostError } from '../common/Errors.ts'; @@ -46,6 +46,7 @@ export const kbuttons = new Array(Object.keys(kbutton).length); export default class ClientInput { static impulse = 0; + /** @this {import('../common/Cmd.ts').ConsoleCommand} */ static KeyDown_f(cmd) { // private let b = kbutton[this.command.substring(1)]; if (b === undefined) { @@ -78,6 +79,7 @@ export default class ClientInput { } } + /** @this {import('../common/Cmd.ts').ConsoleCommand} */ static KeyUp_f(cmd) { // private let b = kbutton[this.command.substring(1)]; diff --git a/source/engine/client/ClientLifecycle.mjs b/source/engine/client/ClientLifecycle.mjs index 53b31cc2..78324166 100644 --- a/source/engine/client/ClientLifecycle.mjs +++ b/source/engine/client/ClientLifecycle.mjs @@ -1,5 +1,5 @@ -import Cvar from '../common/Cvar.mjs'; -import Cmd, { ConsoleCommand } from '../common/Cmd.mjs'; +import Cvar from '../common/Cvar.ts'; +import Cmd, { ConsoleCommand } from '../common/Cmd.ts'; import * as Def from '../common/Def.ts'; import { gameCapabilities } from '../../shared/Defs.ts'; import ClientInput from './ClientInput.mjs'; diff --git a/source/engine/client/ClientServerCommandHandlers.mjs b/source/engine/client/ClientServerCommandHandlers.mjs index 6c295778..34b0c4ce 100644 --- a/source/engine/client/ClientServerCommandHandlers.mjs +++ b/source/engine/client/ClientServerCommandHandlers.mjs @@ -1,6 +1,6 @@ import * as Protocol from '../network/Protocol.ts'; import * as Def from '../common/Def.ts'; -import Cmd from '../common/Cmd.mjs'; +import Cmd from '../common/Cmd.ts'; import { HostError } from '../common/Errors.ts'; import { gameCapabilities } from '../../shared/Defs.ts'; import Vector from '../../shared/Vector.ts'; diff --git a/source/engine/client/GL.mjs b/source/engine/client/GL.mjs index 6fc4ebd8..dad99bfc 100644 --- a/source/engine/client/GL.mjs +++ b/source/engine/client/GL.mjs @@ -1,5 +1,5 @@ -import Cmd, { ConsoleCommand } from '../common/Cmd.mjs'; -import Cvar from '../common/Cvar.mjs'; +import Cmd, { ConsoleCommand } from '../common/Cmd.ts'; +import Cvar from '../common/Cvar.ts'; import { MissingResourceError } from '../common/Errors.ts'; import { WadLumpTexture } from '../common/W.mjs'; import { eventBus, registry } from '../registry.mjs'; diff --git a/source/engine/client/IN.mjs b/source/engine/client/IN.mjs index e82212f6..a24a4128 100644 --- a/source/engine/client/IN.mjs +++ b/source/engine/client/IN.mjs @@ -1,5 +1,5 @@ import { K } from '../../shared/Keys.ts'; -import Cvar from '../common/Cvar.mjs'; +import Cvar from '../common/Cvar.ts'; import { eventBus, registry } from '../registry.mjs'; import { kbutton, kbuttons } from './ClientInput.mjs'; import VID from './VID.mjs'; diff --git a/source/engine/client/Key.mjs b/source/engine/client/Key.mjs index 0b49fce5..908aebf9 100644 --- a/source/engine/client/Key.mjs +++ b/source/engine/client/Key.mjs @@ -1,7 +1,7 @@ import { K } from '../../shared/Keys.ts'; import Vector from '../../shared/Vector.ts'; -import Cmd from '../common/Cmd.mjs'; -import Cvar from '../common/Cvar.mjs'; +import Cmd from '../common/Cmd.ts'; +import Cvar from '../common/Cvar.ts'; import { clientConnectionState } from '../common/Def.ts'; import { registry, eventBus } from '../registry.mjs'; diff --git a/source/engine/client/Menu.mjs b/source/engine/client/Menu.mjs index 6a7bc2c0..39f00458 100644 --- a/source/engine/client/Menu.mjs +++ b/source/engine/client/Menu.mjs @@ -1,6 +1,6 @@ import { K } from '../../shared/Keys.ts'; -import Cmd from '../common/Cmd.mjs'; -import Cvar from '../common/Cvar.mjs'; +import Cmd from '../common/Cmd.ts'; +import Cvar from '../common/Cvar.ts'; import { clientConnectionState } from '../common/Def.ts'; import { eventBus, registry } from '../registry.mjs'; import ClientLifecycle from './ClientLifecycle.mjs'; diff --git a/source/engine/client/R.mjs b/source/engine/client/R.mjs index 9a1b6140..4ce00765 100644 --- a/source/engine/client/R.mjs +++ b/source/engine/client/R.mjs @@ -1,6 +1,6 @@ import Vector from '../../shared/Vector.ts'; -import Cvar from '../common/Cvar.mjs'; -import Cmd from '../common/Cmd.mjs'; +import Cvar from '../common/Cvar.ts'; +import Cmd from '../common/Cmd.ts'; import * as Def from '../common/Def.ts'; import { eventBus, registry } from '../registry.mjs'; diff --git a/source/engine/client/SCR.mjs b/source/engine/client/SCR.mjs index 8990b4de..bbbfc261 100644 --- a/source/engine/client/SCR.mjs +++ b/source/engine/client/SCR.mjs @@ -1,8 +1,8 @@ /* global */ import { gameCapabilities } from '../../shared/Defs.ts'; -import Cmd from '../common/Cmd.mjs'; -import Cvar from '../common/Cvar.mjs'; +import Cmd from '../common/Cmd.ts'; +import Cvar from '../common/Cvar.ts'; import { clientConnectionState } from '../common/Def.ts'; import { eventBus, registry } from '../registry.mjs'; import GL from './GL.mjs'; diff --git a/source/engine/client/Sbar.mjs b/source/engine/client/Sbar.mjs index 2f1921dc..f6edb24a 100644 --- a/source/engine/client/Sbar.mjs +++ b/source/engine/client/Sbar.mjs @@ -1,10 +1,10 @@ /* globalx Sbar Draw, COM, Host, CL, Cmd, SCR, Def, VID */ -import Cmd from '../common/Cmd.mjs'; +import Cmd from '../common/Cmd.ts'; import { eventBus, registry } from '../registry.mjs'; import VID from './VID.mjs'; import * as Def from '../common/Def.ts'; -import Cvar from '../common/Cvar.mjs'; +import Cvar from '../common/Cvar.ts'; const Sbar = {}; diff --git a/source/engine/client/Sound.mjs b/source/engine/client/Sound.mjs index 80356041..d8bd4097 100644 --- a/source/engine/client/Sound.mjs +++ b/source/engine/client/Sound.mjs @@ -1,6 +1,6 @@ import Vector from '../../shared/Vector.ts'; -import Cmd from '../common/Cmd.mjs'; -import Cvar from '../common/Cvar.mjs'; +import Cmd from '../common/Cmd.ts'; +import Cvar from '../common/Cvar.ts'; import Q from '../../shared/Q.ts'; import { eventBus, registry } from '../registry.mjs'; diff --git a/source/engine/client/Tools.mjs b/source/engine/client/Tools.mjs index b501461a..f3d1ec8e 100644 --- a/source/engine/client/Tools.mjs +++ b/source/engine/client/Tools.mjs @@ -1,4 +1,4 @@ -import Cmd, { ConsoleCommand } from '../common/Cmd.mjs'; +import Cmd, { ConsoleCommand } from '../common/Cmd.ts'; import W, { WadFileInterface } from '../common/W.mjs'; import { eventBus, registry } from '../registry.mjs'; diff --git a/source/engine/client/V.mjs b/source/engine/client/V.mjs index 6560ee4d..3f2b84af 100644 --- a/source/engine/client/V.mjs +++ b/source/engine/client/V.mjs @@ -1,7 +1,7 @@ import Vector from '../../shared/Vector.ts'; import { content, gameCapabilities } from '../../shared/Defs.ts'; -import Cmd from '../common/Cmd.mjs'; -import Cvar from '../common/Cvar.mjs'; +import Cmd from '../common/Cmd.ts'; +import Cvar from '../common/Cvar.ts'; import * as Def from '../common/Def.ts'; import Q from '../../shared/Q.ts'; import { eventBus, registry } from '../registry.mjs'; diff --git a/source/engine/client/VID.mjs b/source/engine/client/VID.mjs index 0774e3ee..af99768e 100644 --- a/source/engine/client/VID.mjs +++ b/source/engine/client/VID.mjs @@ -1,4 +1,4 @@ -import Cmd, { ConsoleCommand } from '../common/Cmd.mjs'; +import Cmd, { ConsoleCommand } from '../common/Cmd.ts'; import { eventBus } from '../registry.mjs'; class FullscreenCommand extends ConsoleCommand { diff --git a/source/engine/client/menu/MenuItem.mjs b/source/engine/client/menu/MenuItem.mjs index 582561cf..4e7df0a5 100644 --- a/source/engine/client/menu/MenuItem.mjs +++ b/source/engine/client/menu/MenuItem.mjs @@ -1,6 +1,6 @@ import Q from '../../../shared/Q.ts'; import { K } from '../../../shared/Keys.ts'; -import Cvar from '../../common/Cvar.mjs'; +import Cvar from '../../common/Cvar.ts'; import { eventBus, registry } from '../../registry.mjs'; let { S, M, Host } = registry; diff --git a/source/engine/client/menu/Multiplayer.mjs b/source/engine/client/menu/Multiplayer.mjs index 3060d240..96047af2 100644 --- a/source/engine/client/menu/Multiplayer.mjs +++ b/source/engine/client/menu/Multiplayer.mjs @@ -1,6 +1,6 @@ import PR from '../../server/Progs.mjs'; import { K } from '../../../shared/Keys.ts'; -import Cmd from '../../common/Cmd.mjs'; +import Cmd from '../../common/Cmd.ts'; import { eventBus, registry } from '../../registry.mjs'; import { Action, Label, Spacer } from './MenuItem.mjs'; import { MenuPage, VerticalLayout } from './MenuPage.mjs'; diff --git a/source/engine/client/renderer/PostProcess.mjs b/source/engine/client/renderer/PostProcess.mjs index af4aaa55..a933372c 100644 --- a/source/engine/client/renderer/PostProcess.mjs +++ b/source/engine/client/renderer/PostProcess.mjs @@ -1,5 +1,5 @@ import GL from '../GL.mjs'; -import Cvar from '../../common/Cvar.mjs'; +import Cvar from '../../common/Cvar.ts'; import VID from '../VID.mjs'; import PostProcessEffect from './PostProcessEffect.mjs'; import { eventBus } from '../../registry.mjs'; diff --git a/source/engine/client/renderer/ShadowMap.mjs b/source/engine/client/renderer/ShadowMap.mjs index 6377792f..7985a7b8 100644 --- a/source/engine/client/renderer/ShadowMap.mjs +++ b/source/engine/client/renderer/ShadowMap.mjs @@ -1,5 +1,5 @@ import GL from '../GL.mjs'; -import Cvar from '../../common/Cvar.mjs'; +import Cvar from '../../common/Cvar.ts'; import { limits } from '../../common/Def.ts'; import { eventBus, registry } from '../../registry.mjs'; import { materialFlags } from './Materials.mjs'; diff --git a/source/engine/common/Cmd.mjs b/source/engine/common/Cmd.mjs deleted file mode 100644 index 99e10dc0..00000000 --- a/source/engine/common/Cmd.mjs +++ /dev/null @@ -1,398 +0,0 @@ -import { AsyncFunction } from '../../shared/Q.ts'; -import * as Protocol from '../network/Protocol.ts'; -import { eventBus, registry } from '../registry.mjs'; -import Cvar from './Cvar.mjs'; -import { clientConnectionState } from './Def.ts'; - -let { CL, COM, Con, Host } = registry; - -eventBus.subscribe('registry.frozen', () => { - CL = registry.CL; - COM = registry.COM; - Con = registry.Con; - Host = registry.Host; -}); - -/** - * Console Command. - */ -export class ConsoleCommand { - /** @type {?import('../server/Edict.mjs').ServerClient} Invoking server client. Unset, when called locally. */ - client = null; - /** @type {string?} The name that was used to execute this command. */ - command = null; - /** @type {string?} Full command line. */ - args = null; - /** @type {string[]} Arguments including the name. */ - argv = []; - - /** @returns {void|Promise} Hint: function can be async */ - run() { - console.assert(false, 'ConsoleCommand.run() must be overridden'); - } - - /** - * Forwards a console command to the server. - * To forward a console command, use `this.forward();`. - * NOTE: Forwarded commands must be allowlisted in `SV.ReadClientMessage`. - * @returns {boolean} true, if forwarded - */ - forward() { - if (this.client !== null) { - return false; - } - - if (registry.isDedicatedServer) { - return true; - } - - console.assert(this.client === null, 'must be executed locally'); - - const argv = this.argv; - let command = this.command; - - if (command && command.toLowerCase() === 'cmd') { - command = argv.shift() || null; - } - - if (command === null) { - Con.Print('Usage: cmd \n'); - return true; - } - - console.assert(CL !== null, 'CL must be available'); - - if (CL.cls.state !== clientConnectionState.connected) { - Con.Print('Can\'t "' + command + '", not connected\n'); - return true; - } - - if (CL.cls.demoplayback === true) { - return true; - } - - // send command to the server in behalf of the client - CL.cls.message.writeByte(Protocol.clc.stringcmd); - CL.cls.message.writeString(this.args); - - return true; - } -}; - -/** - * Just the naked console command context. - */ -class AnonymousConsoleCommand extends ConsoleCommand { - run() { - console.assert(false, 'AnonymousConsoleCommand.run() cannot be used'); - } - - forward() { - return false; - } -}; - -class ForwardCommand extends ConsoleCommand { - run() { - this.forward(); - } -}; - -class ExecSlot { - constructor(/** @type {string} */ filename) { - this.filename = filename; - this.content = /** @type {string|null} */ (null); - this.isReady = false; - } -}; - -export default class Cmd { - static alias = /** @type {{ name: string, value: string }[]} */([]); - static functions = /** @type {{ name: string, command: typeof ConsoleCommand }[]} */([]); - static text = ''; - static wait = false; - - static #execSlots = /** @type {ExecSlot[]} */([]); - - static HasPendingCommands() { - return this.wait === true || this.text.length > 0 || this.#execSlots.length > 0; - } - - static Wait_f() { - Cmd.wait = true; - } - - static Execute() { - // go through all pending exec slots - while (this.#execSlots.length > 0) { - const slot = this.#execSlots[0]; - - if (!slot.isReady) { - // as long as the first exec slot is not ready, we - // cannot proceed with any command, we want to keep order - return; - } - - if (slot.content !== null) { - Con.DPrint('execing ' + slot.filename + '\n'); - Cmd.text += slot.content; - } else { - Con.PrintWarning('couldn\'t exec ' + slot.filename + '\n'); - } - - this.#execSlots.shift(); - - // if the exec caused a wait, we stop processing here - if (Cmd.wait) { - Cmd.wait = false; - return; - } - } - - let line = ''; let quotes = false; - while (Cmd.text.length !== 0) { - const c = Cmd.text[0]; - Cmd.text = Cmd.text.substring(1); - if (c === '"') { - quotes = !quotes; - line += '"'; - continue; - } - if (((quotes === false) && (c === ';')) || (c === '\n')) { - if (line.length === 0) { - continue; - } - Cmd.ExecuteString(line); - if (Cmd.wait) { - Cmd.wait = false; - return; - } - line = ''; - continue; - } - line += c; - } - Cmd.text = ''; - } - - /** - * Executes all console commands passed by the command line. - */ - static StuffCmds_f() { - let s = false; let build = ''; - for (let i = 0; i < COM.argv.length; i++) { - const c = COM.argv[i][0]; - if (s === true) { - if (c === '+') { - build += ('\n' + COM.argv[i].substring(1) + ' '); - continue; - } - if (c === '-') { - s = false; - build += '\n'; - continue; - } - build += (COM.argv[i] + ' '); - continue; - } - if (c === '+') { - s = true; - build += (COM.argv[i].substring(1) + ' '); - } - } - if (build.length !== 0) { - Cmd.text = build + '\n' + Cmd.text; - } - } - - static Exec_f = class ExecConsoleCommand extends ConsoleCommand { - async run(filename) { - if (!filename) { - Con.Print('exec : execute a script file\n'); - return; - } - const slot = new ExecSlot(filename); - Cmd.#execSlots.push(slot); - const f = await COM.LoadTextFile(filename); - slot.isReady = true; - slot.content = f; - } - }; - - static Echo_f = class EchoConsoleCommand extends ConsoleCommand { - run() { - Con.Print(`${this.args.substring(this.argv[0].length + 1)}\n`); - } - }; - - static Alias_f(...argv) { - if (argv.length <= 1) { - Con.Print('Current alias commands:\n'); - for (let i = 0; i < Cmd.alias.length; i++) { - Con.Print(Cmd.alias[i].name + ' : ' + Cmd.alias[i].value + '\n'); - } - } - let value = ''; - for (let i = 0; i < Cmd.alias.length; i++) { - if (Cmd.alias[i].name === argv[1]) { - break; - } - } - for (let j = 2; j < argv.length; j++) { - value += argv[j]; - if (j !== argv.length) { - value += ' '; - } - } - Cmd.alias.push({ name: argv[1], value: value + '\n' }); - } - - static Init() { - Cmd.functions.length = 0; - - Cmd.AddCommand('stuffcmds', Cmd.StuffCmds_f); - Cmd.AddCommand('exec', Cmd.Exec_f); - Cmd.AddCommand('echo', Cmd.Echo_f); - Cmd.AddCommand('alias', Cmd.Alias_f); - Cmd.AddCommand('cmd', ForwardCommand); - Cmd.AddCommand('wait', Cmd.Wait_f); - } - - static Shutdown() { - Cmd.functions.length = 0; - } - - static TokenizeString(text) { - const argv = []; - let i; let c; - while (true) { - for (i = 0; i < text.length; i++) { - c = text.charCodeAt(i); - if ((c > 32) || (c === 10)) { - break; - } - } - if ((text.charCodeAt(i) === 10) || (i >= text.length)) { - break; - } - const parsed = COM.Parse(text); - if (parsed.data === null) { - break; - } - text = parsed.data; - argv.push(parsed.token); - } - return argv; - } - - static HasCommand(name) { - for (let i = 0; i < Cmd.functions.length; i++) { - if (Cmd.functions[i].name === name) { - return true; - } - } - - return false; - } - - static AddCommand(name, command) { - console.assert(Cvar.FindVar(name) === null, 'command name must not be taken by a cvar', name); - - for (let i = 0; i < Cmd.functions.length; i++) { - if (Cmd.functions[i].name === name) { - Con.Print('Cmd.AddCommand: ' + name + ' already defined\n'); - return; - } - } - - if (command.prototype instanceof ConsoleCommand) { - Cmd.functions.push({ name: name, command: command }); - } else if (typeof command === 'function') { - // if the command is a function, wrap it into a ConsoleCommand - Cmd.functions.push({ name: name, command: class extends ConsoleCommand { - run(...args) { - command.apply(this, args); - } - }}); - } - } - - static CompleteCommand(partial) { - if (!partial) { - return null; - } - - for (let i = 0; i < Cmd.functions.length; i++) { - if (Cmd.functions[i].name.startsWith(partial)) { - return Cmd.functions[i].name; - } - } - - return null; - } - - static ExecuteString(text, client = null) { - const argv = Cmd.TokenizeString(text); - - if (argv.length === 0) { - return; - } - - const cmdname = argv[0].toLowerCase(); - const cmdargs = argv.slice(1); - - // check commands - for (let i = 0; i < Cmd.functions.length; i++) { - if (Cmd.functions[i].name === cmdname) { - /** @type {ConsoleCommand} */ - const handler = new Cmd.functions[i].command(); - handler.client = client; - handler.args = text; - handler.command = cmdname; - handler.argv = argv; - - if (handler.run instanceof AsyncFunction) { - handler.run.apply(handler, cmdargs).catch((err) => { - Con.PrintError(`Error executing command "${cmdname}":\n${err?.message || err}\n`); - }); - return; - } - - // Temporarily set Host.client for backward compatibility with commands that still use it - const savedHostClient = Host.client; - if (client) { - Host.client = client; - } - try { - handler.run.apply(handler, cmdargs); - } finally { - if (client) { - Host.client = savedHostClient; - } - } - return; - } - } - - // check aliases - for (let i = 0; i < Cmd.alias.length; i++) { - if (Cmd.alias[i].name === cmdname) { - Cmd.text = Cmd.alias[i].value + Cmd.text; - return; - } - } - - // ask Cvar, if it knows more - const ctx = new AnonymousConsoleCommand(); - ctx.client = client; - ctx.args = text; - ctx.command = cmdname; - ctx.argv = argv; - - if (Cvar.Command_f.call(ctx, argv[0], argv[1])) { - return; - } - - Con.Print('Unknown command "' + cmdname + '"\n'); - } -}; diff --git a/source/engine/common/Cmd.ts b/source/engine/common/Cmd.ts new file mode 100644 index 00000000..730b3080 --- /dev/null +++ b/source/engine/common/Cmd.ts @@ -0,0 +1,424 @@ +import type { ServerClient } from '../server/Client.mjs'; + +import * as Protocol from '../network/Protocol.ts'; +import { eventBus, getClientRegistry, getCommonRegistry, registry } from '../registry.mjs'; +import Cvar from './Cvar.ts'; +import { clientConnectionState } from './Def.ts'; + +type CommandFunction = (this: ConsoleCommand, ...args: string[]) => void | Promise; +type CommandConstructor = new () => ConsoleCommand; +type CommandRegistration = CommandConstructor | CommandFunction; +type CommandEntry = { + name: string; + command: CommandConstructor; +}; +type AliasEntry = { + name: string; + value: string; +}; + +let { COM, Con } = getCommonRegistry(); + +eventBus.subscribe('registry.frozen', () => { + ({ COM, Con } = getCommonRegistry()); +}); + +/** @returns {boolean} True when the registration is a ConsoleCommand subclass. */ +function isConsoleCommandClass(command: CommandRegistration): command is CommandConstructor { + return typeof command === 'function' && command.prototype instanceof ConsoleCommand; +} + +/** + * Console Command. + */ +export class ConsoleCommand { + client: ServerClient | null = null; + command: string | null = null; + args: string | null = null; + argv: string[] = []; + + run(..._args: string[]): void | Promise { + console.assert(false, 'ConsoleCommand.run() must be overridden'); + } + + // Forwards a console command to the server. + // To forward a console command, use `this.forward();`. + // NOTE: Forwarded commands must be allowlisted in `SV.ReadClientMessage`. + forward(): boolean { + if (this.client !== null) { + return false; + } + + if (registry.isDedicatedServer) { + return true; + } + + console.assert(this.client === null, 'must be executed locally'); + + const argv = [...this.argv]; + let command = this.command; + + if (command !== null && command.toLowerCase() === 'cmd') { + command = argv.shift() ?? null; + } + + if (command === null) { + Con.Print('Usage: cmd \n'); + return true; + } + + const { CL } = getClientRegistry(); + + if (CL.cls.state !== clientConnectionState.connected) { + Con.Print(`Can't "${command}", not connected\n`); + return true; + } + + if (CL.cls.demoplayback) { + return true; + } + + // send command to the server in behalf of the client + CL.cls.message.writeByte(Protocol.clc.stringcmd); + CL.cls.message.writeString(this.args ?? ''); + + return true; + } +} + +/** + * Just the naked console command context. + */ +class AnonymousConsoleCommand extends ConsoleCommand { + override run(): void { + console.assert(false, 'AnonymousConsoleCommand.run() cannot be used'); + } + + override forward(): boolean { + return false; + } +} + +class ForwardCommand extends ConsoleCommand { + override run(): void { + this.forward(); + } +} + +class ExecSlot { + filename: string; + content: string | null = null; + isReady = false; + + constructor(filename: string) { + this.filename = filename; + } +} + +export default class Cmd { + static alias: AliasEntry[] = []; + static functions: CommandEntry[] = []; + static text = ''; + static wait = false; + + static #execSlots: ExecSlot[] = []; + + static HasPendingCommands(): boolean { + return this.wait || this.text.length > 0 || this.#execSlots.length > 0; + } + + static Wait_f(): void { + Cmd.wait = true; + } + + static Execute(): void { + // go through all pending exec slots + while (this.#execSlots.length > 0) { + const slot = this.#execSlots[0]; + + if (!slot.isReady) { + // as long as the first exec slot is not ready, we + // cannot proceed with any command, we want to keep order + return; + } + + if (slot.content !== null) { + Con.DPrint(`execing ${slot.filename}\n`); + Cmd.text += slot.content; + } else { + Con.PrintWarning(`couldn't exec ${slot.filename}\n`); + } + + this.#execSlots.shift(); + + // if the exec caused a wait, we stop processing here + if (Cmd.wait) { + Cmd.wait = false; + return; + } + } + + let line = ''; + let quotes = false; + + while (Cmd.text.length !== 0) { + const character = Cmd.text[0]; + Cmd.text = Cmd.text.substring(1); + + if (character === '"') { + quotes = !quotes; + line += '"'; + continue; + } + + if ((!quotes && character === ';') || character === '\n') { + if (line.length === 0) { + continue; + } + + void Cmd.ExecuteString(line); + + if (Cmd.wait) { + Cmd.wait = false; + return; + } + + line = ''; + continue; + } + + line += character; + } + + Cmd.text = ''; + } + + /** + * Executes all console commands passed by the command line. + */ + static StuffCmds_f(): void { + let readingCommand = false; + let build = ''; + + for (let index = 0; index < COM.argv.length; index++) { + const firstCharacter = COM.argv[index][0]; + + if (readingCommand) { + if (firstCharacter === '+') { + build += `\n${COM.argv[index].substring(1)} `; + continue; + } + + if (firstCharacter === '-') { + readingCommand = false; + build += '\n'; + continue; + } + + build += `${COM.argv[index]} `; + continue; + } + + if (firstCharacter === '+') { + readingCommand = true; + build += `${COM.argv[index].substring(1)} `; + } + } + + if (build.length !== 0) { + Cmd.text = `${build}\n${Cmd.text}`; + } + } + + static Exec_f = class ExecConsoleCommand extends ConsoleCommand { + override async run(filename?: string): Promise { + if (!filename) { + Con.Print('exec : execute a script file\n'); + return; + } + + const slot = new ExecSlot(filename); + Cmd.#execSlots.push(slot); + slot.content = await COM.LoadTextFile(filename); + slot.isReady = true; + } + }; + + static Echo_f = class EchoConsoleCommand extends ConsoleCommand { + override run(): void { + const args = this.args ?? ''; + Con.Print(`${args.substring(this.argv[0].length + 1)}\n`); + } + }; + + static Alias_f(...argv: string[]): void { + if (argv.length === 0) { + Con.Print('Current alias commands:\n'); + + for (const alias of Cmd.alias) { + Con.Print(`${alias.name} : ${alias.value}\n`); + } + + return; + } + + const aliasName = argv[0].toLowerCase(); + const value = `${argv.slice(1).join(' ')}\n`; + + for (const alias of Cmd.alias) { + if (alias.name === aliasName) { + alias.value = value; + return; + } + } + + Cmd.alias.push({ name: aliasName, value }); + } + + static Init(): void { + Cmd.functions.length = 0; + + Cmd.AddCommand('stuffcmds', Cmd.StuffCmds_f); + Cmd.AddCommand('exec', Cmd.Exec_f); + Cmd.AddCommand('echo', Cmd.Echo_f); + Cmd.AddCommand('alias', Cmd.Alias_f); + Cmd.AddCommand('cmd', ForwardCommand); + Cmd.AddCommand('wait', Cmd.Wait_f); + } + + static Shutdown(): void { + Cmd.functions.length = 0; + } + + static TokenizeString(text: string): string[] { + const argv: string[] = []; + + while (true) { + let index = 0; + let character = 0; + + for (; index < text.length; index++) { + character = text.charCodeAt(index); + + if (character > 32 || character === 10) { + break; + } + } + + if (text.charCodeAt(index) === 10 || index >= text.length) { + break; + } + + const parsed = COM.Parse(text); + + if (parsed.data === null) { + break; + } + + text = parsed.data; + argv.push(parsed.token); + } + + return argv; + } + + static HasCommand(name: string): boolean { + return Cmd.GetCommandNames().includes(name); + } + + static GetCommandNames(): string[] { + return Cmd.functions.map((entry) => entry.name); + } + + static AddCommand(name: string, command: CommandRegistration): void { + console.assert(Cvar.FindVar(name) === null, 'command name must not be taken by a cvar', name); + + if (Cmd.HasCommand(name)) { + Con.Print(`Cmd.AddCommand: ${name} already defined\n`); + return; + } + + if (isConsoleCommandClass(command)) { + Cmd.functions.push({ name, command }); + return; + } + + Cmd.functions.push({ + name, + command: class extends ConsoleCommand { + // if the command is a function, wrap it into a ConsoleCommand + override run(...args: string[]): void | Promise { + return command.apply(this, args); + } + }, + }); + } + + static CompleteCommand(partial: string): string | null { + if (!partial) { + return null; + } + + return Cmd.GetCommandNames().find((name) => name.startsWith(partial)) ?? null; + } + + static ExecuteString(text: string, client: ServerClient | null = null): void | Promise { + const argv = Cmd.TokenizeString(text); + + if (argv.length === 0) { + return undefined; + } + + const commandName = argv[0].toLowerCase(); + const commandArgs = argv.slice(1); + + // check commands + for (const entry of Cmd.functions) { + if (entry.name !== commandName) { + continue; + } + + const handler = new entry.command(); + handler.client = client; + handler.args = text; + handler.command = commandName; + handler.argv = argv; + + const run = handler.run.bind(handler) as (...args: string[]) => void | Promise; + const result = run(...commandArgs); + + if (result instanceof Promise) { + return result.catch((error: Error | string | null | undefined) => { + const message = error instanceof Error ? error.message : error; + Con.PrintError(`Error executing command "${commandName}":\n${message}\n`); + }); + } + + return result; + } + + for (const alias of Cmd.alias) { + if (alias.name !== commandName) { + continue; + } + + Cmd.text = alias.value + Cmd.text; + return undefined; + } + + const context = new AnonymousConsoleCommand(); + context.client = client; + context.args = text; + context.command = commandName; + context.argv = argv; + + // ask Cvar, if it knows more + if (Cvar.Command_f.call(context, argv[0], argv[1])) { + return undefined; + } + + Con.Print(`Unknown command "${commandName}"\n`); + + return undefined; + } +} diff --git a/source/engine/common/Com.mjs b/source/engine/common/Com.mjs index 2ab98018..50e44370 100644 --- a/source/engine/common/Com.mjs +++ b/source/engine/common/Com.mjs @@ -4,9 +4,9 @@ import { registry, eventBus } from '../registry.mjs'; import Q from '../../shared/Q.ts'; import { CorruptedResourceError } from './Errors.ts'; -import Cvar from './Cvar.mjs'; +import Cvar from './Cvar.ts'; import W from './W.mjs'; -import Cmd from './Cmd.mjs'; +import Cmd from './Cmd.ts'; import { defaultBasedir, defaultGame } from './Def.ts'; import { CRC16CCITT } from './CRC.ts'; diff --git a/source/engine/common/Console.mjs b/source/engine/common/Console.mjs index 6ece7d4f..4290be14 100644 --- a/source/engine/common/Console.mjs +++ b/source/engine/common/Console.mjs @@ -1,9 +1,9 @@ import { eventBus, registry } from '../registry.mjs'; -import Cvar from './Cvar.mjs'; +import Cvar from './Cvar.ts'; import Vector from '../../shared/Vector.ts'; -import Cmd from './Cmd.mjs'; +import Cmd from './Cmd.ts'; import VID from '../client/VID.mjs'; import { clientConnectionState } from './Def.ts'; import { ClientEngineAPI } from './GameAPIs.mjs'; diff --git a/source/engine/common/Cvar.mjs b/source/engine/common/Cvar.mjs deleted file mode 100644 index aeece77c..00000000 --- a/source/engine/common/Cvar.mjs +++ /dev/null @@ -1,404 +0,0 @@ -import { registry, eventBus } from '../registry.mjs'; -import Cmd from './Cmd.mjs'; -import Q from '../../shared/Q.ts'; -import { cvarFlags } from '../../shared/Defs.ts'; - -let { CL, Con, SV } = registry; - -eventBus.subscribe('registry.frozen', () => { - CL = registry.CL; - Con = registry.Con; - SV = registry.SV; -}); - -/** - * Console Variable - */ -export default class Cvar { - /** @type {Record} @private */ - static _vars = {}; - - static FLAG = cvarFlags; - - // TODO: add things like onChange, onPreChange so that we can hook into changes of the variable - - /** @type {string} */ - #currentValue = null; - - /** @type {number} */ - #numberValue = null; - - /** @type {string} @readonly */ - #originalValue = null; - - /** @type {string} @readonly */ - #name = null; - - get name() { return this.#name; } - get string() { return this.#currentValue; } - get value() { return this.#numberValue; } - - /** - * @param {string} name name of the variable - * @param {string} value preset value of the variable - * @param {number} flags optional flags for the variable - * @param {?string} description optional description of the variable - */ - constructor(name, value, flags = Cvar.FLAG.NONE, description = null) { - // making sure these fields are out of reach - this.#name = name; - this.#currentValue = value; - this.#originalValue = value; - - /** @type {number} @see Cvar.FLAG */ - this.flags = flags; - /** @type {?string} @readonly */ - this.description = description; - - this.#numberValue = value === '' ? 0 : Q.atof(value); - - console.assert(name.length > 0, 'Cvar name must be at least 1 character long', name); - console.assert(!Cvar._vars[name], 'Cvar name must not be used already', name); - - Cvar._vars[name] = this; - } - - /** - * @deprecated use flags instead - * @returns {boolean} whether the variable is an archive variable - */ - get archive() { - return !!(this.flags & Cvar.FLAG.ARCHIVE); - } - - /** - * @deprecated use flags instead - * @returns {boolean} whether the variable is a server variable - */ - get server() { - return !!(this.flags & Cvar.FLAG.SERVER); - } - - /** - * Deletes the console variable from the list of variables. - */ - free() { - delete Cvar._vars[this.name]; - } - - /** - * Finds a console variable by name. - * @param {string} name console variable name - * @returns {Cvar|null} The console variable if found, otherwise null. - */ - static FindVar(name) { - return Cvar._vars[name] || null; - } - - /** - * Completes a variable name based on a partial string. - * @param {string} partial starting string of the variable name - * @returns {string|null} The name of the console variable if found, otherwise null. - */ - static CompleteVariable(partial) { - if (!partial.length) { - return null; - } - - return Object.keys(Cvar._vars).find((name) => name.startsWith(partial)) || null; - } - - /** - * Sets the value of the console variable. - * Setting a variable to a boolean will convert it to a string. - * READONLY variables can be changed through this. - * @param {number|string|boolean} value new value - * @returns {Cvar} this - */ - set(value) { - // turning everything into a string - switch (typeof value) { - case 'boolean': - value = value ? '1' : '0'; - break; - case 'string': - value = value.trim(); - break; - case 'number': - value = value.toString(); - break; - - default: - console.assert(false, 'invalid type of value', value); - value = ''; - } - - const changed = this.#currentValue !== value; - - // TODO: implement Cvar.FLAG.DEFERRED - - this.#currentValue = value; - this.#numberValue = value === '' ? 0 : Q.atof(value); - - if (changed) { - eventBus.publish('cvar.changed', this.name); - eventBus.publish(`cvar.changed.${this.name}`, this); - } - - return this; - } - - /** - * Resets the console variable to its original value. - * @returns {Cvar} this - */ - reset() { - this.set(this.#originalValue); - - return this; - } - - /** - * For easy embedding in strings. It will return the current value of the console variable. - * @returns {string} string representation of the console variable - */ - toString() { - return this.#currentValue; - } - - /** - * Sets the value of the console variable. - * @param {string} name name of the variable - * @param {number|string|boolean} value new value - * @returns {Cvar} variable - */ - static Set(name, value) { - const variable = Cvar._vars[name]; - - console.assert(variable !== undefined, 'variable must be registered', name); - - if (!variable) { - return null; - } - - variable.set(value); - - return variable; - } - - /** - * Command line interface for console variables. - * @param {string} name name of the variable - * @param {?string} value value to set - * @returns {boolean} true if the variable handling was executed successfully, false otherwise - */ - static Command_f(name, value) { - const v = Cvar.FindVar(name); - - if (!v) { - return false; - } - - if (value === undefined) { - Con.Print(`"${v.name}" is "${v.string}"\n`); - Con.DPrint(`... "${v.string}" is ${v.value} as a numeric value\n`); - - if (v.description) { - Con.Print(`> ${v.description}\n`); - } - - if (v.flags & Cvar.FLAG.READONLY) { - Con.Print('- Cannot be changed.\n'); - } - - if (v.flags & Cvar.FLAG.ARCHIVE) { - Con.Print('- Will be saved to the configuration file.\n'); - } - - if (v.flags & Cvar.FLAG.SERVER) { - Con.Print('- Is a server variable.\n'); - } - - if (v.flags & Cvar.FLAG.GAME) { - Con.Print('- Is a game variable.\n'); - } - - if (v.flags & Cvar.FLAG.DEFERRED) { - Con.Print('- New value will be applied on the next map.\n'); - } - - if (v.flags & Cvar.FLAG.CHEAT) { - Con.Print('- Cheat.\n'); - } - - if (v.flags & Cvar.FLAG.SECRET) { - if (v.flags & Cvar.FLAG.SERVER) { - Con.Print('- Changed value will not be broadcasted, sensitive information.\n'); - } - } - - return true; - } - - if (v.flags & Cvar.FLAG.READONLY) { - Con.PrintWarning(`"${v.name}" is read-only\n`); - return true; - } - - if ((v.flags & Cvar.FLAG.CHEAT) && SV.server.active && CL?.cls.serverInfo?.sv_cheats !== '1') { - Con.Print('Cheats are not enabled on this server.\n'); - return true; - } - - // TODO: check if there’s a min/max value and clamp accordingly - - v.set(value); - - return true; - } - - /** - * @returns {string} all variables that are marked as archive - */ - static WriteVariables() { - return Object.values(Cvar._vars) - .filter((v) => (v.flags & Cvar.FLAG.ARCHIVE) !== 0) - .map((v) => `seta "${v.name}" "${v.string}"\n`) // FIXME: escape quotes in value - .join(''); - } - - /** - * Filter all variables by a function. - * @param {Function} compareFn function to compare the variable, first argument will be a Cvar - * @yields {Cvar} variable - */ - static *Filter(compareFn) { - for (const variable of Object.values(Cvar._vars)) { - if (compareFn(variable)) { - yield variable; - } - } - } - - /** - * @param {string} name name of the variable - * @param {?string} value value to set - */ - static Set_f(name, value) { - if (name === undefined) { - Con.Print('Usage: set \n'); - return; - } - - if (!Cvar.Command_f.call(this, name, value)) { - Con.PrintWarning(`Unknown variable "${name}"\n`); - } - } - - /** - * @param {string} name name of the variable - * @param {?string} value value to set - */ - static Seta_f(name, value) { - if (name === undefined) { - Con.Print('Usage: seta \n'); - return; - } - - const variable = Cvar.FindVar(name); - - if (!variable) { - Con.PrintWarning(`Unknown variable "${name}"\n`); - return; - } - - if (!(variable.flags & (Cvar.FLAG.ARCHIVE | Cvar.FLAG.READONLY))) { - variable.flags |= Cvar.FLAG.ARCHIVE; - Con.DPrint(`"${name}" flagged as archive variable\n`); - } - - if (!Cvar.Command_f.call(this, name, value)) { - Con.PrintWarning(`Unknown variable "${name}"\n`); - } - } - - /** - * Toggles a variable between 0 and 1. - * @param {string} name name of the variable - */ - static Toggle_f(name) { - if (name === undefined) { - Con.Print('Usage: toggle \n'); - return; - } - - const variable = Cvar.FindVar(name); - - if (!variable) { - Con.PrintWarning(`Unknown variable "${name}"\n`); - return; - } - - if (variable.flags & Cvar.FLAG.READONLY) { - Con.PrintWarning(`"${name}" is read-only\n`); - return; - } - - variable.set(variable.value === 0 ? 1 : 0); - - Con.Print(`"${name}" toggled to "${variable.string}"\n`); - } - - static Cvarlist_f(start) { - const names = Object.keys(Cvar._vars).sort(); - - for (const name of names) { - const v = Cvar._vars[name]; - - if (start !== undefined && !name.startsWith(start)) { - continue; - } - - const flags = new Array(5).fill(' '); - - if (v.flags & Cvar.FLAG.ARCHIVE) { - flags[0] = 'A'; - } - - if (v.flags & Cvar.FLAG.GAME) { - flags[1] = 'G'; - } - - if (v.flags & Cvar.FLAG.SERVER) { - flags[2] = 'S'; - } - - if (v.flags & Cvar.FLAG.READONLY) { - flags[3] = 'R'; - } - - if (v.flags & Cvar.FLAG.CHEAT) { - flags[4] = 'C'; - } - - Con.Print(`${v.name.padEnd(24)} | ${flags.join('')} | ${v.string.padEnd(16)} | ${v.description || ''}\n`); - } - } - - /** - * Initializes the Cvar system. - */ - static Init() { - Cmd.AddCommand('set', Cvar.Set_f); - Cmd.AddCommand('seta', Cvar.Seta_f); - Cmd.AddCommand('toggle', Cvar.Toggle_f); - Cmd.AddCommand('cvarlist', Cvar.Cvarlist_f); - } - - /** - * Unregisters all variables. - */ - static Shutdown() { - Cvar._vars = {}; - } -}; diff --git a/source/engine/common/Cvar.ts b/source/engine/common/Cvar.ts new file mode 100644 index 00000000..7b48075d --- /dev/null +++ b/source/engine/common/Cvar.ts @@ -0,0 +1,326 @@ +import { eventBus, getCommonRegistry, registry } from '../registry.mjs'; +import Cmd from './Cmd.ts'; +import Q from '../../shared/Q.ts'; +import { cvarFlags } from '../../shared/Defs.ts'; + +type CvarValue = number | string | boolean; +type CvarFilter = (variable: Cvar) => boolean; + +let { Con, SV } = getCommonRegistry(); + +eventBus.subscribe('registry.frozen', () => { + ({ Con, SV } = getCommonRegistry()); +}); + +/** + * Console Variable. + */ +export default class Cvar { + static _vars: Record = {}; + + static FLAG = cvarFlags; + + // TODO: add things like onChange, onPreChange so that we can hook into changes of the variable + + #currentValue: string; + #numberValue: number; + #originalValue: string; + #name: string; + + flags: number; + readonly description: string | null; + + get name(): string { + return this.#name; + } + + get string(): string { + return this.#currentValue; + } + + get value(): number { + return this.#numberValue; + } + + constructor(name: string, value: string, flags: number = Cvar.FLAG.NONE, description: string | null = null) { + // making sure these fields are out of reach + this.#name = name; + this.#currentValue = value; + this.#originalValue = value; + this.flags = flags; + this.description = description; + this.#numberValue = value === '' ? 0 : Q.atof(value); + + console.assert(name.length > 0, 'Cvar name must be at least 1 character long', name); + console.assert(!Cvar._vars[name], 'Cvar name must not be used already', name); + + Cvar._vars[name] = this; + } + + get archive(): boolean { + return (this.flags & Cvar.FLAG.ARCHIVE) !== 0; + } + + get server(): boolean { + return (this.flags & Cvar.FLAG.SERVER) !== 0; + } + + free(): void { + delete Cvar._vars[this.name]; + } + + static FindVar(name: string): Cvar | null { + return Cvar._vars[name] ?? null; + } + + static GetVariableNames(): string[] { + return Object.keys(Cvar._vars); + } + + static CompleteVariable(partial: string): string | null { + if (!partial.length) { + return null; + } + + return Cvar.GetVariableNames().find((name) => name.startsWith(partial)) ?? null; + } + + set(value: CvarValue): Cvar { + let nextValue: string; + + // turning everything into a string + switch (typeof value) { + case 'boolean': + nextValue = value ? '1' : '0'; + break; + case 'string': + nextValue = value.trim(); + break; + case 'number': + nextValue = value.toString(); + break; + default: + console.assert(false, 'invalid type of value', value); + nextValue = ''; + break; + } + + const changed = this.#currentValue !== nextValue; + + // TODO: implement Cvar.FLAG.DEFERRED + + this.#currentValue = nextValue; + this.#numberValue = nextValue === '' ? 0 : Q.atof(nextValue); + + if (changed) { + eventBus.publish('cvar.changed', this.name); + eventBus.publish(`cvar.changed.${this.name}`, this); + } + + return this; + } + + reset(): Cvar { + this.set(this.#originalValue); + return this; + } + + toString(): string { + return this.#currentValue; + } + + static Set(name: string, value: CvarValue): Cvar | null { + const variable = Cvar._vars[name]; + + console.assert(variable !== undefined, 'variable must be registered', name); + + if (!variable) { + return null; + } + + variable.set(value); + return variable; + } + + static Command_f(name: string, value?: string): boolean { + const variable = Cvar.FindVar(name); + + if (variable === null) { + return false; + } + + if (value === undefined) { + Con.Print(`"${variable.name}" is "${variable.string}"\n`); + Con.DPrint(`... "${variable.string}" is ${variable.value} as a numeric value\n`); + + if (variable.description) { + Con.Print(`> ${variable.description}\n`); + } + + if (variable.flags & Cvar.FLAG.READONLY) { + Con.Print('- Cannot be changed.\n'); + } + + if (variable.flags & Cvar.FLAG.ARCHIVE) { + Con.Print('- Will be saved to the configuration file.\n'); + } + + if (variable.flags & Cvar.FLAG.SERVER) { + Con.Print('- Is a server variable.\n'); + } + + if (variable.flags & Cvar.FLAG.GAME) { + Con.Print('- Is a game variable.\n'); + } + + if (variable.flags & Cvar.FLAG.DEFERRED) { + Con.Print('- New value will be applied on the next map.\n'); + } + + if (variable.flags & Cvar.FLAG.CHEAT) { + Con.Print('- Cheat.\n'); + } + + if ((variable.flags & Cvar.FLAG.SECRET) && (variable.flags & Cvar.FLAG.SERVER)) { + Con.Print('- Changed value will not be broadcasted, sensitive information.\n'); + } + + return true; + } + + if (variable.flags & Cvar.FLAG.READONLY) { + Con.PrintWarning(`"${variable.name}" is read-only\n`); + return true; + } + + const clientSvCheats = registry.CL?.cls.serverInfo?.sv_cheats; + + if ((variable.flags & Cvar.FLAG.CHEAT) && SV.server.active && clientSvCheats !== '1') { + Con.Print('Cheats are not enabled on this server.\n'); + return true; + } + + // TODO: check if there’s a min/max value and clamp accordingly + + variable.set(value); + return true; + } + + static WriteVariables(): string { + return Object.values(Cvar._vars) + .filter((variable) => (variable.flags & Cvar.FLAG.ARCHIVE) !== 0) + .map((variable) => `seta "${variable.name}" "${variable.string}"\n`) + .join(''); + } + + static *Filter(compareFn: CvarFilter): Generator { + for (const variable of Object.values(Cvar._vars)) { + if (compareFn(variable)) { + yield variable; + } + } + } + + static Set_f(name?: string, value?: string): void { + if (name === undefined) { + Con.Print('Usage: set \n'); + return; + } + + if (!Cvar.Command_f.call(this, name, value)) { + Con.PrintWarning(`Unknown variable "${name}"\n`); + } + } + + static Seta_f(name?: string, value?: string): void { + if (name === undefined) { + Con.Print('Usage: seta \n'); + return; + } + + const variable = Cvar.FindVar(name); + + if (variable === null) { + Con.PrintWarning(`Unknown variable "${name}"\n`); + return; + } + + if ((variable.flags & (Cvar.FLAG.ARCHIVE | Cvar.FLAG.READONLY)) === 0) { + variable.flags |= Cvar.FLAG.ARCHIVE; + Con.DPrint(`"${name}" flagged as archive variable\n`); + } + + if (!Cvar.Command_f.call(this, name, value)) { + Con.PrintWarning(`Unknown variable "${name}"\n`); + } + } + + static Toggle_f(name?: string): void { + if (name === undefined) { + Con.Print('Usage: toggle \n'); + return; + } + + const variable = Cvar.FindVar(name); + + if (variable === null) { + Con.PrintWarning(`Unknown variable "${name}"\n`); + return; + } + + if (variable.flags & Cvar.FLAG.READONLY) { + Con.PrintWarning(`"${name}" is read-only\n`); + return; + } + + variable.set(variable.value === 0 ? 1 : 0); + Con.Print(`"${name}" toggled to "${variable.string}"\n`); + } + + static Cvarlist_f(start?: string): void { + const names = Cvar.GetVariableNames().sort(); + + for (const name of names) { + const variable = Cvar._vars[name]; + + if (start !== undefined && !name.startsWith(start)) { + continue; + } + + const flags = new Array(5).fill(' '); + + if (variable.flags & Cvar.FLAG.ARCHIVE) { + flags[0] = 'A'; + } + + if (variable.flags & Cvar.FLAG.GAME) { + flags[1] = 'G'; + } + + if (variable.flags & Cvar.FLAG.SERVER) { + flags[2] = 'S'; + } + + if (variable.flags & Cvar.FLAG.READONLY) { + flags[3] = 'R'; + } + + if (variable.flags & Cvar.FLAG.CHEAT) { + flags[4] = 'C'; + } + + Con.Print(`${variable.name.padEnd(24)} | ${flags.join('')} | ${variable.string.padEnd(16)} | ${variable.description ?? ''}\n`); + } + } + + static Init(): void { + Cmd.AddCommand('set', Cvar.Set_f); + Cmd.AddCommand('seta', Cvar.Seta_f); + Cmd.AddCommand('toggle', Cvar.Toggle_f); + Cmd.AddCommand('cvarlist', Cvar.Cvarlist_f); + } + + static Shutdown(): void { + Cvar._vars = {}; + } +} diff --git a/source/engine/common/GameAPIs.mjs b/source/engine/common/GameAPIs.mjs index f45ce4fc..7860a941 100644 --- a/source/engine/common/GameAPIs.mjs +++ b/source/engine/common/GameAPIs.mjs @@ -7,8 +7,8 @@ import VID from '../client/VID.mjs'; import * as Protocol from '../network/Protocol.ts'; import { EventBus, eventBus, registry } from '../registry.mjs'; import { ED, ServerEdict } from '../server/Edict.mjs'; -import Cmd from './Cmd.mjs'; -import Cvar from './Cvar.mjs'; +import Cmd from './Cmd.ts'; +import Cvar from './Cvar.ts'; import { HostError } from './Errors.ts'; import Mod from './Mod.mjs'; import W from './W.mjs'; diff --git a/source/engine/common/Host.mjs b/source/engine/common/Host.mjs index 9e02de8a..e1e76688 100644 --- a/source/engine/common/Host.mjs +++ b/source/engine/common/Host.mjs @@ -1,7 +1,7 @@ -import Cvar from './Cvar.mjs'; +import Cvar from './Cvar.ts'; import * as Protocol from '../network/Protocol.ts'; import * as Def from './Def.ts'; -import Cmd, { ConsoleCommand } from './Cmd.mjs'; +import Cmd, { ConsoleCommand } from './Cmd.ts'; import { eventBus, registry } from '../registry.mjs'; import Vector from '../../shared/Vector.ts'; import Q from '../../shared/Q.ts'; diff --git a/source/engine/common/Pmove.mjs b/source/engine/common/Pmove.mjs index cb8f35f3..d250890e 100644 --- a/source/engine/common/Pmove.mjs +++ b/source/engine/common/Pmove.mjs @@ -11,7 +11,7 @@ import Vector from '../../shared/Vector.ts'; import * as Protocol from '../network/Protocol.ts'; import { content } from '../../shared/Defs.ts'; import { BrushModel } from './Mod.mjs'; -import Cvar from './Cvar.mjs'; +import Cvar from './Cvar.ts'; import { PmoveConfiguration } from '../../shared/Pmove.ts'; /** @typedef {import('../../shared/Vector.ts').DirectionalVectors} DirectionalVectors */ diff --git a/source/engine/network/ConsoleCommands.ts b/source/engine/network/ConsoleCommands.ts index abc95943..55f8f5e7 100644 --- a/source/engine/network/ConsoleCommands.ts +++ b/source/engine/network/ConsoleCommands.ts @@ -1,4 +1,4 @@ -import { ConsoleCommand } from '../common/Cmd.mjs'; +import { ConsoleCommand } from '../common/Cmd.ts'; import { eventBus, getCommonRegistry } from '../registry.mjs'; let { Con, NET } = getCommonRegistry(); diff --git a/source/engine/network/Network.ts b/source/engine/network/Network.ts index ed2f4d3e..cd0bba3c 100644 --- a/source/engine/network/Network.ts +++ b/source/engine/network/Network.ts @@ -1,7 +1,7 @@ import type { Server as HttpServer } from 'node:http'; -import Cmd from '../common/Cmd.mjs'; -import Cvar from '../common/Cvar.mjs'; +import Cmd from '../common/Cmd.ts'; +import Cvar from '../common/Cvar.ts'; import { clientConnectionState } from '../common/Def.ts'; import Q from '../../shared/Q.ts'; import { eventBus, getClientRegistry, getCommonRegistry, registry } from '../registry.mjs'; diff --git a/source/engine/network/NetworkDrivers.ts b/source/engine/network/NetworkDrivers.ts index 2ee266e6..682cb57f 100644 --- a/source/engine/network/NetworkDrivers.ts +++ b/source/engine/network/NetworkDrivers.ts @@ -1,4 +1,4 @@ -import Cvar from '../common/Cvar.mjs'; +import Cvar from '../common/Cvar.ts'; import { HostError } from '../common/Errors.ts'; import type { SzBuffer } from './MSG.ts'; import { eventBus, getCommonRegistry, registry } from '../registry.mjs'; diff --git a/source/engine/server/Edict.mjs b/source/engine/server/Edict.mjs index 7e57cf23..8dfe6f30 100644 --- a/source/engine/server/Edict.mjs +++ b/source/engine/server/Edict.mjs @@ -5,7 +5,7 @@ import * as Def from '../common/Def.ts'; import * as Defs from '../../shared/Defs.ts'; import { eventBus, registry } from '../registry.mjs'; import Q from '../../shared/Q.ts'; -import { ConsoleCommand } from '../common/Cmd.mjs'; +import { ConsoleCommand } from '../common/Cmd.ts'; import { ClientEdict } from '../client/ClientEntities.mjs'; import { OctreeNode } from '../../shared/Octree.ts'; import { Visibility } from '../common/model/BSP.mjs'; diff --git a/source/engine/server/Navigation.mjs b/source/engine/server/Navigation.mjs index 40191ce7..d3d9af6d 100644 --- a/source/engine/server/Navigation.mjs +++ b/source/engine/server/Navigation.mjs @@ -2,9 +2,9 @@ import * as Def from '../../shared/Defs.ts'; import { Octree } from '../../shared/Octree.ts'; import Vector from '../../shared/Vector.ts'; -import Cmd from '../common/Cmd.mjs'; -// import Cmd, { ConsoleCommand } from '../common/Cmd.mjs'; -import Cvar from '../common/Cvar.mjs'; +import Cmd from '../common/Cmd.ts'; +// import Cmd, { ConsoleCommand } from '../common/Cmd.ts'; +import Cvar from '../common/Cvar.ts'; import { CorruptedResourceError, MissingResourceError } from '../common/Errors.ts'; import { ServerEngineAPI } from '../common/GameAPIs.mjs'; import { BrushModel } from '../common/Mod.mjs'; diff --git a/source/engine/server/Progs.mjs b/source/engine/server/Progs.mjs index f289e9c5..7e873a85 100644 --- a/source/engine/server/Progs.mjs +++ b/source/engine/server/Progs.mjs @@ -1,6 +1,6 @@ -import Cmd from '../common/Cmd.mjs'; +import Cmd from '../common/Cmd.ts'; import { CRC16CCITT } from '../common/CRC.ts'; -import Cvar from '../common/Cvar.mjs'; +import Cvar from '../common/Cvar.ts'; import { HostError, MissingResourceError } from '../common/Errors.ts'; import Q from '../../shared/Q.ts'; import Vector from '../../shared/Vector.ts'; diff --git a/source/engine/server/ProgsAPI.mjs b/source/engine/server/ProgsAPI.mjs index fc5ef06c..c5efc8bf 100644 --- a/source/engine/server/ProgsAPI.mjs +++ b/source/engine/server/ProgsAPI.mjs @@ -1,5 +1,5 @@ import Vector from '../../shared/Vector.ts'; -import Cmd from '../common/Cmd.mjs'; +import Cmd from '../common/Cmd.ts'; import { HostError } from '../common/Errors.ts'; import { ServerEngineAPI } from '../common/GameAPIs.mjs'; import { eventBus, registry } from '../registry.mjs'; diff --git a/source/engine/server/Server.mjs b/source/engine/server/Server.mjs index 2fe6658b..e5040adf 100644 --- a/source/engine/server/Server.mjs +++ b/source/engine/server/Server.mjs @@ -1,10 +1,10 @@ -import Cvar from '../common/Cvar.mjs'; +import Cvar from '../common/Cvar.ts'; import { MoveVars, Pmove } from '../common/Pmove.mjs'; import Vector from '../../shared/Vector.ts'; import { SzBuffer } from '../network/MSG.ts'; import * as Protocol from '../network/Protocol.ts'; import * as Def from './../common/Def.ts'; -import Cmd, { ConsoleCommand } from '../common/Cmd.mjs'; +import Cmd, { ConsoleCommand } from '../common/Cmd.ts'; import { ED, ServerEdict } from './Edict.mjs'; import { EventBus, eventBus, registry } from '../registry.mjs'; import { ServerEngineAPI } from '../common/GameAPIs.mjs'; diff --git a/source/engine/server/ServerMessages.mjs b/source/engine/server/ServerMessages.mjs index 026a77d9..ec1f22de 100644 --- a/source/engine/server/ServerMessages.mjs +++ b/source/engine/server/ServerMessages.mjs @@ -1,7 +1,7 @@ import { SzBuffer } from '../network/MSG.ts'; import * as Protocol from '../network/Protocol.ts'; import * as Defs from '../../shared/Defs.ts'; -import Cvar from '../common/Cvar.mjs'; +import Cvar from '../common/Cvar.ts'; import { eventBus, registry } from '../registry.mjs'; import { ServerClient } from './Client.mjs'; import { ServerEntityState } from './ServerEntityState.mjs'; diff --git a/source/engine/server/Sys.mjs b/source/engine/server/Sys.mjs index f9fe5421..5c4e1618 100644 --- a/source/engine/server/Sys.mjs +++ b/source/engine/server/Sys.mjs @@ -8,9 +8,9 @@ import { join } from 'path'; import { createServer } from 'http'; import { registry, eventBus } from '../registry.mjs'; -import Cvar from '../common/Cvar.mjs'; +import Cvar from '../common/Cvar.ts'; /** @typedef {import('node:repl').REPLServer} REPLServer */ -import Cmd from '../common/Cmd.mjs'; +import Cmd from '../common/Cmd.ts'; import Q from '../../shared/Q.ts'; import WorkerManager from '../common/WorkerManager.mjs'; import workerFactories from '../common/WorkerFactories.mjs'; @@ -92,8 +92,8 @@ export default class Sys { }, completer(line) { const completions = [ - ...Cmd.functions.map((fnc) => fnc.name), - ...Object.keys(Cvar._vars).map((cvar) => cvar), // FIXME: Cvar._vars is private, should not be accessed directly + ...Cmd.GetCommandNames(), + ...Cvar.GetVariableNames(), ]; const hits = completions.filter((c) => c.startsWith(line)); diff --git a/source/shared/GameInterfaces.ts b/source/shared/GameInterfaces.ts index 0bf86820..95921638 100644 --- a/source/shared/GameInterfaces.ts +++ b/source/shared/GameInterfaces.ts @@ -10,7 +10,7 @@ export type ServerEngineAPI = Readonly; export type ServerEdict = Readonly; export type GLTexture = import('../engine/client/GL.mjs').GLTexture; -export type Cvar = Readonly; +export type Cvar = Readonly; export type PmoveConfiguration = Readonly; export type PmoveQuake2Configuration = Readonly; diff --git a/test/common/cmd.test.mjs b/test/common/cmd.test.mjs new file mode 100644 index 00000000..0a118fbe --- /dev/null +++ b/test/common/cmd.test.mjs @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import Cmd from '../../source/engine/common/Cmd.ts'; +import COM from '../../source/engine/common/Com.mjs'; +import Cvar from '../../source/engine/common/Cvar.ts'; +import { registry } from '../../source/engine/registry.mjs'; +import { defaultMockRegistry, withMockRegistry } from '../physics/fixtures.mjs'; + +/** @typedef {{ prints: string[], warnings: string[], errors: string[], dprints: string[], Print: (message: string) => void, PrintWarning: (message: string) => void, PrintError: (message: string) => void, DPrint: (message: string) => void }} ConsoleCapture */ + +/** @returns {ConsoleCapture} captured console methods */ +function createConsoleCapture() { + return { + prints: [], + warnings: [], + errors: [], + dprints: [], + Print(message) { + this.prints.push(message); + }, + PrintWarning(message) { + this.warnings.push(message); + }, + PrintError(message) { + this.errors.push(message); + }, + DPrint(message) { + this.dprints.push(message); + }, + }; +} + +/** + * Reset Cmd and Cvar globals between tests. + */ +function resetCommandState() { + Cmd.Shutdown(); + Cmd.alias.length = 0; + Cmd.text = ''; + Cmd.wait = false; + Cvar.Shutdown(); +} + +/** + * @param {ConsoleCapture} consoleCapture captured console sinks + * @param {{ frametime?: number }} [hostOverrides] host registry overrides + * @returns {import('../physics/fixtures.mjs').MockRegistryConfig & { Host: { frametime: number } }} mock registry config + */ +function createMockRegistryConfig(consoleCapture, hostOverrides = {}) { + return { + ...defaultMockRegistry({}, null), + COM, + Con: consoleCapture, + Host: { + frametime: 0.1, + ...hostOverrides, + }, + }; +} + +void describe('Cmd', () => { + void test('lists aliases without creating a broken alias entry and overwrites existing aliases by name', async () => { + const consoleCapture = createConsoleCapture(); + + await withMockRegistry(createMockRegistryConfig(consoleCapture), async () => { + resetCommandState(); + Cmd.Init(); + + try { + await Cmd.ExecuteString('alias'); + assert.deepEqual(Cmd.alias, []); + assert.deepEqual(consoleCapture.prints, ['Current alias commands:\n']); + + await Cmd.ExecuteString('alias greet echo first'); + assert.deepEqual(Cmd.alias, [{ name: 'greet', value: 'echo first\n' }]); + + await Cmd.ExecuteString('alias GREET echo second'); + assert.deepEqual(Cmd.alias, [{ name: 'greet', value: 'echo second\n' }]); + + await Cmd.ExecuteString('greet'); + assert.equal(Cmd.text, 'echo second\n'); + } finally { + resetCommandState(); + } + }); + }); + + void test('exposes public command and variable name lists for completion', async () => { + const consoleCapture = createConsoleCapture(); + + await withMockRegistry(createMockRegistryConfig(consoleCapture), () => { + resetCommandState(); + Cmd.Init(); + + try { + Cmd.AddCommand('customcommand', () => {}); + new Cvar('skill', '1'); + new Cvar('deathmatch', '0'); + + assert.equal(Cmd.GetCommandNames().includes('alias'), true); + assert.equal(Cmd.GetCommandNames().includes('customcommand'), true); + assert.deepEqual(Cvar.GetVariableNames(), ['skill', 'deathmatch']); + } finally { + resetCommandState(); + } + }); + }); + + void test('captures rejected promises from wrapped async function commands without mutating Host', async () => { + const consoleCapture = createConsoleCapture(); + const invokingClient = { name: 'player' }; + + await withMockRegistry(createMockRegistryConfig(consoleCapture), async () => { + resetCommandState(); + Cmd.Init(); + + try { + Cmd.AddCommand('asyncplain', async function (arg) { + assert.equal(arg, 'payload'); + assert.equal(this.client, invokingClient); + assert.equal(this.command, 'asyncplain'); + assert.equal(this.args, 'asyncplain payload'); + assert.deepEqual(this.argv, ['asyncplain', 'payload']); + assert.equal(Object.hasOwn(registry.Host, 'client'), false); + + await Promise.resolve(); + + throw new Error('boom'); + }); + + await Cmd.ExecuteString('asyncplain payload', invokingClient); + + assert.equal(Object.hasOwn(registry.Host, 'client'), false); + assert.deepEqual(consoleCapture.errors, ['Error executing command "asyncplain":\nboom\n']); + } finally { + resetCommandState(); + } + }); + }); +}); diff --git a/test/common/cvar.test.mjs b/test/common/cvar.test.mjs new file mode 100644 index 00000000..0300f33a --- /dev/null +++ b/test/common/cvar.test.mjs @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import Cvar from '../../source/engine/common/Cvar.ts'; +import { defaultMockRegistry, withMockRegistry } from '../physics/fixtures.mjs'; + +/** @returns {{ prints: string[], warnings: string[], dprints: string[], Print: (message: string) => void, PrintWarning: (message: string) => void, DPrint: (message: string) => void }} captured console methods */ +function createConsoleCapture() { + return { + prints: [], + warnings: [], + dprints: [], + Print(message) { + this.prints.push(message); + }, + PrintWarning(message) { + this.warnings.push(message); + }, + DPrint(message) { + this.dprints.push(message); + }, + }; +} + +/** + * Reset the global Cvar registry between tests. + */ +function resetCvarState() { + Cvar.Shutdown(); +} + +void describe('Cvar', () => { + void test('coerces values and completes variable names', async () => { + const consoleCapture = createConsoleCapture(); + + await withMockRegistry({ + ...defaultMockRegistry({ server: { active: false } }, null), + Con: consoleCapture, + }, () => { + resetCvarState(); + + try { + const skill = new Cvar('skill', '1'); + new Cvar('deathmatch', '0'); + + skill.set(' 2 '); + assert.equal(skill.string, '2'); + assert.equal(skill.value, 2); + + skill.set(true); + assert.equal(skill.string, '1'); + assert.equal(skill.value, 1); + + assert.deepEqual(Cvar.GetVariableNames(), ['skill', 'deathmatch']); + assert.equal(Cvar.CompleteVariable('dea'), 'deathmatch'); + } finally { + resetCvarState(); + } + }); + }); + + void test('reports variable metadata and blocks readonly changes', async () => { + const consoleCapture = createConsoleCapture(); + + await withMockRegistry({ + ...defaultMockRegistry({ server: { active: false } }, null), + Con: consoleCapture, + }, () => { + resetCvarState(); + + try { + const registered = new Cvar('registered', '0', Cvar.FLAG.READONLY | Cvar.FLAG.ARCHIVE, 'Shareware marker.'); + + assert.equal(Cvar.Command_f('registered'), true); + assert.deepEqual(consoleCapture.prints, [ + '"registered" is "0"\n', + '> Shareware marker.\n', + '- Cannot be changed.\n', + '- Will be saved to the configuration file.\n', + ]); + + assert.equal(Cvar.Command_f('registered', '1'), true); + assert.equal(registered.string, '0'); + assert.deepEqual(consoleCapture.warnings, ['"registered" is read-only\n']); + } finally { + resetCvarState(); + } + }); + }); + + void test('marks archive variables and serializes them to config commands', async () => { + const consoleCapture = createConsoleCapture(); + + await withMockRegistry({ + ...defaultMockRegistry({ server: { active: false } }, null), + Con: consoleCapture, + }, () => { + resetCvarState(); + + try { + const crosshair = new Cvar('crosshair', '0'); + new Cvar('name', 'player'); + + Cvar.Seta_f('crosshair', '1'); + + assert.equal((crosshair.flags & Cvar.FLAG.ARCHIVE) !== 0, true); + assert.equal(crosshair.string, '1'); + assert.equal(Cvar.WriteVariables(), 'seta "crosshair" "1"\n'); + } finally { + resetCvarState(); + } + }); + }); +}); diff --git a/test/physics/fixtures.mjs b/test/physics/fixtures.mjs index ab5b0b88..cbf9059b 100644 --- a/test/physics/fixtures.mjs +++ b/test/physics/fixtures.mjs @@ -1,9 +1,8 @@ import assert from 'node:assert/strict'; import Vector from '../../source/shared/Vector.ts'; -import { content, flags, moveType, moveTypes, solid } from '../../source/shared/Defs.ts'; +import { content, flags, moveType, solid } from '../../source/shared/Defs.ts'; import { Brush, BrushModel, BrushSide } from '../../source/engine/common/model/BSP.mjs'; -import { Pmove } from '../../source/engine/common/Pmove.mjs'; import { eventBus, registry } from '../../source/engine/registry.mjs'; import { ClientEdict } from '../../source/engine/client/ClientEntities.mjs'; import { ServerPhysics } from '../../source/engine/server/physics/ServerPhysics.mjs'; @@ -47,6 +46,7 @@ import { ServerPhysics } from '../../source/engine/server/physics/ServerPhysics. /** * @typedef MockRegistryConfig + * @property {typeof import('../../source/engine/common/Com.mjs').default | null} [COM] * @property {object|null} [CL] * @property {{ Print: Function, DPrint: Function }} Con * @property {{ frametime: number }} Host @@ -309,28 +309,43 @@ export function defaultMockRegistry(sv = {}, cl = null) { /** * Run a callback with mocked registry values. * @param {MockRegistryConfig} mockedRegistry registry replacements - * @param {() => void} callback test callback + * @param {() => void | Promise} callback test callback */ export function withMockRegistry(mockedRegistry, callback) { + const previousCOM = registry.COM; const previousCL = registry.CL; const previousCon = registry.Con; const previousHost = registry.Host; const previousSV = registry.SV; + registry.COM = mockedRegistry.COM ?? previousCOM; registry.CL = mockedRegistry.CL ?? null; registry.Con = mockedRegistry.Con; registry.Host = mockedRegistry.Host; registry.SV = mockedRegistry.SV; eventBus.publish('registry.frozen'); - try { - callback(); - } finally { + const restore = () => { + registry.COM = previousCOM; registry.CL = previousCL; registry.Con = previousCon; registry.Host = previousHost; registry.SV = previousSV; eventBus.publish('registry.frozen'); + }; + + try { + const result = callback(); + + if (result !== null && result !== undefined && typeof result.then === 'function') { + return Promise.resolve(result).finally(restore); + } + + restore(); + return result; + } catch (error) { + restore(); + throw error; } } @@ -379,7 +394,7 @@ export function withMockServerPhysics(callback) { linkCalls.push(edict); }; - withMockRegistry({ + void withMockRegistry({ Con: { Print() {}, DPrint() {}, From 7e8618f28ab1868ca4a15f07933180bd69bcfd2a Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 16:50:44 +0300 Subject: [PATCH 16/67] TS: common/Console --- .../engine/common/{Console.mjs => Console.ts} | 60 +++--- source/engine/main-browser.mjs | 2 +- source/engine/main-dedicated.mjs | 2 +- source/engine/registry.mjs | 2 +- test/common/console.test.mjs | 189 ++++++++++++++++++ test/common/model-cache.test.mjs | 2 +- test/physics/pmove.test.mjs | 2 +- 7 files changed, 228 insertions(+), 31 deletions(-) rename source/engine/common/{Console.mjs => Console.ts} (81%) create mode 100644 test/common/console.test.mjs diff --git a/source/engine/common/Console.mjs b/source/engine/common/Console.ts similarity index 81% rename from source/engine/common/Console.mjs rename to source/engine/common/Console.ts index 4290be14..fb927ea5 100644 --- a/source/engine/common/Console.mjs +++ b/source/engine/common/Console.ts @@ -1,36 +1,44 @@ - - -import { eventBus, registry } from '../registry.mjs'; -import Cvar from './Cvar.ts'; import Vector from '../../shared/Vector.ts'; +import { eventBus, getClientRegistry, registry } from '../registry.mjs'; +import Cvar from './Cvar.ts'; import Cmd from './Cmd.ts'; import VID from '../client/VID.mjs'; import { clientConnectionState } from './Def.ts'; import { ClientEngineAPI } from './GameAPIs.mjs'; -let { CL, Draw, Host, Key, M, SCR } = registry; +let { CL, Draw, Host, Key, M, SCR } = getClientRegistry(); eventBus.subscribe('registry.frozen', () => { - CL = registry.CL; - Draw = registry.Draw; - Host = registry.Host; - Key = registry.Key; - M = registry.M; - SCR = registry.SCR; + ({ CL, Draw, Host, Key, M, SCR } = getClientRegistry()); }); +/** A single line entry in the console text buffer. */ +type ConsoleLine = { + text: string; + time: number; + color: Vector; + doNotNotify: boolean; +}; + +/** + * Console output and display. + * + * Handles printing, notification display, and the interactive drop-down + * console overlay used by both the browser client and dedicated server. + */ export default class Con { static backscroll = 0; static current = 0; - static text = /** @type {{ text: string, time: number, color: Vector, doNotNotify: boolean }[]} */([]); - static captureBuffer = null; - /** @type {Cvar?} */ - static notifytime = null; + static text: ConsoleLine[] = []; + static captureBuffer: string[] | null = null; + + /** Console notification display time. */ + static notifytime: Cvar | null = null; - /** used by the client to force the console to be up */ + /** Used by the client to force the console to be up. */ static forcedup = false; - /** used by the client to determine how many lines to draw */ + /** Used by the client to determine how many lines to draw. */ static vislines = 0; static ToggleConsole_f() { @@ -87,13 +95,13 @@ export default class Con { Con.captureBuffer = []; } - static StopCapturing() { - const data = Con.captureBuffer.join('\n') + '\n'; + static StopCapturing(): string { + const data = Con.captureBuffer!.join('\n') + '\n'; Con.captureBuffer = null; return data; } - static Print(msg, color = new Vector(1.0, 1.0, 1.0)) { + static Print(msg: string, color = new Vector(1.0, 1.0, 1.0)) { let doNotNotify = false; Con.backscroll = 0; @@ -135,7 +143,7 @@ export default class Con { } } - static DPrint(msg) { + static DPrint(msg: string) { if (!Host.developer?.value) { return; } @@ -143,15 +151,15 @@ export default class Con { Con.Print(msg, new Vector(0.7, 0.7, 1.0)); } - static PrintWarning(msg) { + static PrintWarning(msg: string) { Con.Print(msg, new Vector(1.0, 1.0, 0.3)); } - static PrintError(msg) { + static PrintError(msg: string) { Con.Print(msg, new Vector(1.0, 0.3, 0.3)); } - static PrintSuccess(msg) { + static PrintSuccess(msg: string) { Con.Print(msg, new Vector(0.3, 1.0, 0.3)); } @@ -177,7 +185,7 @@ export default class Con { } for (; i < Con.text.length; i++) { - if (Con.text[i].doNotNotify || (Host.realtime - Con.text[i].time) > Con.notifytime.value) { + if (Con.text[i].doNotNotify || (Host.realtime - Con.text[i].time) > Con.notifytime!.value) { continue; } @@ -192,7 +200,7 @@ export default class Con { } } - static DrawConsole(lines) { + static DrawConsole(lines: number) { if (lines <= 0) { return; } diff --git a/source/engine/main-browser.mjs b/source/engine/main-browser.mjs index c941a76b..7ed2ccd3 100644 --- a/source/engine/main-browser.mjs +++ b/source/engine/main-browser.mjs @@ -2,7 +2,7 @@ import { registry, freeze as registryFreeze } from './registry.mjs'; import Sys from './client/Sys.mjs'; import COM from './common/Com.mjs'; -import Con from './common/Console.mjs'; +import Con from './common/Console.ts'; import Host from './common/Host.mjs'; import V from './client/V.mjs'; import NET from './network/Network.ts'; diff --git a/source/engine/main-dedicated.mjs b/source/engine/main-dedicated.mjs index fbada24e..6d0b3ebf 100644 --- a/source/engine/main-dedicated.mjs +++ b/source/engine/main-dedicated.mjs @@ -10,7 +10,7 @@ globalThis.Worker = Worker; import Sys from './server/Sys.mjs'; import NodeCOM from './server/Com.mjs'; -import Con from './common/Console.mjs'; +import Con from './common/Console.ts'; import Host from './common/Host.mjs'; import V from './client/V.mjs'; import NET from './network/Network.ts'; diff --git a/source/engine/registry.mjs b/source/engine/registry.mjs index bf41c6d2..0aa7bd8f 100644 --- a/source/engine/registry.mjs +++ b/source/engine/registry.mjs @@ -1,5 +1,5 @@ -/** @typedef {typeof import('./common/Console.mjs').default} ConModule */ +/** @typedef {typeof import('./common/Console.ts').default} ConModule */ /** @typedef {typeof import('./common/Com.mjs').default} ComModule */ /** @typedef {typeof import('./common/Sys.mjs').default} SysModule */ /** @typedef {typeof import('./common/Host.mjs').default} HostModule */ diff --git a/test/common/console.test.mjs b/test/common/console.test.mjs new file mode 100644 index 00000000..fafd517e --- /dev/null +++ b/test/common/console.test.mjs @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import Con from '../../source/engine/common/Console.ts'; +import { eventBus, registry } from '../../source/engine/registry.mjs'; + +/** + * Install minimal registry stubs for Console and fire registry.frozen so + * the module picks up Host (the only registry member Con needs at print time). + * @param {{ realtime?: number, developer?: { value: number } }} [hostOverrides] + * @param {() => void} callback + */ +function withMinimalRegistry(hostOverrides = {}, callback) { + const previousHost = registry.Host; + registry.Host = /** @type {any} */ ({ + realtime: 0, + developer: { value: 0 }, + ...hostOverrides, + }); + eventBus.publish('registry.frozen'); + + try { + callback(); + } finally { + registry.Host = previousHost; + eventBus.publish('registry.frozen'); + } +} + +/** Reset Con state between tests. */ +function resetConState() { + Con.backscroll = 0; + Con.current = 0; + Con.text = []; + Con.captureBuffer = null; + Con.forcedup = false; + Con.vislines = 0; +} + +void describe('Con', () => { + void describe('Print', () => { + void test('appends text to the current line and advances on newline', () => { + withMinimalRegistry({ realtime: 1.5 }, () => { + resetConState(); + try { + Con.Print('hello\n'); + assert.equal(Con.text.length, 1); + assert.equal(Con.text[0].text, 'hello'); + assert.equal(Con.text[0].time, 1.5); + assert.equal(Con.current, 1); + } finally { + resetConState(); + } + }); + }); + + void test('handles multiple lines in a single Print call', () => { + withMinimalRegistry({}, () => { + resetConState(); + try { + Con.Print('line1\nline2\nline3\n'); + assert.equal(Con.text.length, 3); + assert.equal(Con.text[0].text, 'line1'); + assert.equal(Con.text[1].text, 'line2'); + assert.equal(Con.text[2].text, 'line3'); + assert.equal(Con.current, 3); + } finally { + resetConState(); + } + }); + }); + + void test('trims buffer when it exceeds 1024 lines', () => { + withMinimalRegistry({}, () => { + resetConState(); + try { + // fill up 1023 lines then add one more to trigger the trim + for (let i = 0; i < 1024; i++) { + Con.Print(`line${i}\n`); + } + // after crossing 1024, the buffer is sliced to the last 512 + assert.ok(Con.text.length <= 512 + 1, `expected <= 513, got ${Con.text.length}`); + } finally { + resetConState(); + } + }); + }); + + void test('legacy color code 3 sets doNotNotify', () => { + withMinimalRegistry({}, () => { + resetConState(); + try { + Con.Print('\x03silent\n'); + assert.equal(Con.text[0].doNotNotify, true); + assert.equal(Con.text[0].text, 'silent'); + } finally { + resetConState(); + } + }); + }); + }); + + void describe('DPrint', () => { + void test('suppresses output when developer is 0', () => { + withMinimalRegistry({ developer: { value: 0 } }, () => { + resetConState(); + try { + Con.DPrint('debug only\n'); + assert.equal(Con.text.length, 0); + } finally { + resetConState(); + } + }); + }); + + void test('prints when developer is non-zero', () => { + withMinimalRegistry({ developer: { value: 1 } }, () => { + resetConState(); + try { + Con.DPrint('debug msg\n'); + assert.equal(Con.text.length, 1); + assert.equal(Con.text[0].text, 'debug msg'); + } finally { + resetConState(); + } + }); + }); + }); + + void describe('capture', () => { + void test('captures printed lines between start and stop', () => { + withMinimalRegistry({}, () => { + resetConState(); + try { + Con.StartCapturing(); + Con.Print('captured1\n'); + Con.Print('captured2\n'); + const result = Con.StopCapturing(); + assert.equal(result, 'captured1\ncaptured2\n'); + assert.equal(Con.captureBuffer, null); + } finally { + resetConState(); + } + }); + }); + }); + + void describe('Clear_f', () => { + void test('resets text buffer and scroll position', () => { + withMinimalRegistry({}, () => { + resetConState(); + try { + Con.Print('something\n'); + Con.backscroll = 5; + Con.Clear_f(); + assert.equal(Con.text.length, 0); + assert.equal(Con.current, 0); + assert.equal(Con.backscroll, 0); + } finally { + resetConState(); + } + }); + }); + }); + + void describe('ClearNotify', () => { + void test('zeroes time on the last 4 lines', () => { + withMinimalRegistry({ realtime: 10 }, () => { + resetConState(); + try { + for (let i = 0; i < 6; i++) { + Con.Print(`line${i}\n`); + } + Con.ClearNotify(); + // first two lines should keep their time + assert.equal(Con.text[0].time, 10); + assert.equal(Con.text[1].time, 10); + // last four lines should have time = 0 + assert.equal(Con.text[2].time, 0); + assert.equal(Con.text[3].time, 0); + assert.equal(Con.text[4].time, 0); + assert.equal(Con.text[5].time, 0); + } finally { + resetConState(); + } + }); + }); + }); +}); diff --git a/test/common/model-cache.test.mjs b/test/common/model-cache.test.mjs index 95c79542..9a3616c9 100644 --- a/test/common/model-cache.test.mjs +++ b/test/common/model-cache.test.mjs @@ -41,7 +41,7 @@ async function withModelRegistry(callback) { const previousPendingLoads = { ...Mod.pendingLoads }; registry.isDedicatedServer = true; - registry.Con = /** @type {typeof import('../../source/engine/common/Console.mjs').default} */ ({ + registry.Con = /** @type {typeof import('../../source/engine/common/Console.ts').default} */ ({ Print() {}, DPrint() {}, PrintWarning() {}, diff --git a/test/physics/pmove.test.mjs b/test/physics/pmove.test.mjs index 9603c218..533f8a8d 100644 --- a/test/physics/pmove.test.mjs +++ b/test/physics/pmove.test.mjs @@ -208,7 +208,7 @@ async function runMapFrames({ const knownKeysBefore = new Set(Object.keys(Mod.known)); registry.isDedicatedServer = true; - registry.Con = /** @type {typeof import('../../source/engine/common/Console.mjs').default} */ ({ + registry.Con = /** @type {typeof import('../../source/engine/common/Console.ts').default} */ ({ Print() {}, DPrint() {}, PrintWarning() {}, From 4faad0b15329cd7ddf83c5407ea654099ed97bb2 Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 17:09:04 +0300 Subject: [PATCH 17/67] TS: common/Com --- source/engine/common/{Com.mjs => Com.ts} | 214 +++++++++++++---------- source/engine/common/WorkerFramework.mjs | 4 +- source/engine/main-browser.mjs | 2 +- source/engine/registry.mjs | 2 +- source/engine/server/Com.mjs | 2 +- test/common/cmd.test.mjs | 2 +- test/common/com.test.mjs | 110 ++++++++++++ test/physics/fixtures.mjs | 2 +- test/physics/map-pmove-harness.mjs | 2 +- test/physics/pmove.test.mjs | 4 +- 10 files changed, 241 insertions(+), 103 deletions(-) rename source/engine/common/{Com.mjs => Com.ts} (62%) create mode 100644 test/common/com.test.mjs diff --git a/source/engine/common/Com.mjs b/source/engine/common/Com.ts similarity index 62% rename from source/engine/common/Com.mjs rename to source/engine/common/Com.ts index 50e44370..f00f8301 100644 --- a/source/engine/common/Com.mjs +++ b/source/engine/common/Com.ts @@ -1,5 +1,4 @@ - -import { registry, eventBus } from '../registry.mjs'; +import { registry, eventBus, getCommonRegistry } from '../registry.mjs'; import Q from '../../shared/Q.ts'; import { CorruptedResourceError } from './Errors.ts'; @@ -10,50 +9,72 @@ import Cmd from './Cmd.ts'; import { defaultBasedir, defaultGame } from './Def.ts'; import { CRC16CCITT } from './CRC.ts'; -let { Con, Sys } = registry; +let { Con, Sys } = getCommonRegistry(); eventBus.subscribe('registry.frozen', () => { - Con = registry.Con; - Sys = registry.Sys; + ({ Con, Sys } = getCommonRegistry()); }); -/** @typedef {{ name: string; filepos: number; filelen: number;}[]} PackFile */ -/** @typedef {{filename: any; pack: PackFile[];}} SearchPath */ +/** A file entry inside a .pak archive. */ +export type PackFileEntry = { + name: string; + filepos: number; + filelen: number; +}; -export default class COM { - /** @type {string[]} */ - static argv = []; +/** A search path entry in the virtual filesystem. */ +export type SearchPath = { + filename: string; + pack: PackFileEntry[][]; +}; - /** @type {SearchPath[]} */ - static searchpaths = []; +/** Result of {@link COM.Parse}. */ +export type ParseResult = { + token: string; + data: string | null; +}; + +/** + * Common file system, command line, and string parsing utilities. + * + * This is the base class shared by both the browser client and the Node.js + * dedicated server (`server/Com.mjs` extends this as `NodeCOM`). + */ +export default class COM { + static argv: string[] = []; + static searchpaths: SearchPath[] = []; static hipnotic = false; static rogue = false; static standard_quake = true; static modified = false; - /** @type {Cvar} */ - static registered = null; + static registered: Cvar | null = null; - /** @type {Cvar|string} */ // FIXME: string turns into Cvar when jumping from InitArgv to Init - static cmdline = null; + /** + * Command line string — starts as a plain string from + * {@link COM.InitArgv}, then replaced with a Cvar in {@link COM.Init}. + */ + static cmdline: Cvar | string | null = null; - /** @type {?AbortController} */ - static abortController = null; + static abortController: AbortController | null = null; - /** @type {SearchPath[]} */ - static gamedir = null; + static gamedir: SearchPath[] | null = null; - /** @type {string} mod name */ - static game = defaultGame; + /** Active mod name. */ + static game: string = defaultGame; - static DefaultExtension(path, extension) { + /** + * Append a default file extension if none is present. + * @returns the path with extension appended when no extension was found + */ + static DefaultExtension(path: string, extension: string): string { for (let i = path.length - 1; i >= 0; i--) { const src = path.charCodeAt(i); - if (src === 47) { + if (src === 47) { // '/' break; } - if (src === 46) { + if (src === 46) { // '.' return path; } } @@ -61,20 +82,25 @@ export default class COM { } /** - * Quake style parser. - * @param {string} data string to parse - * @returns {{token: string, data: string|null}} parsed token and remaining data to parse + * Quake-style token parser. + * + * Splits `data` into the next whitespace-delimited token (respecting + * double-quote strings and `//` line comments) and returns the token + * together with the remaining unparsed data. + * @returns parsed token and remaining data */ - static Parse(data) { // FIXME: remove charCodeAt code + static Parse(data: string): ParseResult { let token = ''; - let i = 0; let c; + let i = 0; + let c = 0; if (data.length === 0) { return { token, data: null }; } + // skip whitespace and // comments let skipwhite = true; while (true) { - if (skipwhite !== true) { + if (!skipwhite) { break; } skipwhite = false; @@ -88,9 +114,10 @@ export default class COM { } i++; } - if ((c === 47) && (data.charCodeAt(i + 1) === 47)) { + // skip // comments + if (c === 47 && data.charCodeAt(i + 1) === 47) { // '//' while (true) { - if ((i >= data.length) || (data.charCodeAt(i) === 10)) { + if (i >= data.length || data.charCodeAt(i) === 10) { // '\n' break; } i++; @@ -99,20 +126,22 @@ export default class COM { } } - if (c === 34) { + // handle quoted strings + if (c === 34) { // '"' i++; while (true) { c = data.charCodeAt(i); i++; - if ((i >= data.length) || (c === 34)) { + if (i >= data.length || c === 34) { // '"' return { token, data: data.substring(i) }; } token += String.fromCharCode(c); } } + // regular token while (true) { - if ((i >= data.length) || (c <= 32)) { + if (i >= data.length || c <= 32) { // whitespace break; } token += String.fromCharCode(c); @@ -121,34 +150,35 @@ export default class COM { } return { token, data: data.substring(i) }; - }; + } - static CheckParm(parm) { + /** + * Check if a command-line parameter is present. + * @returns the argv index of the parameter, or null if not found + */ + static CheckParm(parm: string): number | null { for (let i = 1; i < this.argv.length; i++) { if (this.argv[i] === parm) { return i; } } - return null; - }; + } /** - * Gets parameter from command line. - * @param {string} parm parameter name - * @returns {string|null} value of the parameter or null if not found + * Get a command-line parameter value (the argument after the flag). + * @returns the value following `parm`, or null if not found */ - static GetParm(parm) { + static GetParm(parm: string): string | null { for (let i = 1; i < this.argv.length; i++) { if (this.argv[i] === parm) { return this.argv[i + 1] || null; } } - return null; - }; + } - static async CheckRegistered() { + static async CheckRegistered(): Promise { const filename = 'gfx/pop.lmp'; const h = await this.LoadFile(filename); @@ -158,17 +188,18 @@ export default class COM { return false; } - if (CRC16CCITT.Block(new Uint8Array(h)) !== 25990) { // CR: shouldn’t be that hard to generate a fake pop.lmp with the same checksum + // CR: shouldn't be that hard to generate a fake pop.lmp with the same checksum + if (CRC16CCITT.Block(new Uint8Array(h)) !== 25990) { throw new CorruptedResourceError(filename, 'not genuine registered version'); } - this.registered.set(true); + this.registered!.set(true); Con.PrintSuccess('Playing registered version.\n'); eventBus.publish('com.registered', true); return true; } - static InitArgv(argv) { + static InitArgv(argv: string[]) { this.cmdline = (argv.join(' ') + ' ').substring(0, 256); for (let i = 0; i < argv.length; i++) { this.argv[i] = argv[i]; @@ -193,8 +224,8 @@ export default class COM { this.abortController = new AbortController(); this.registered = new Cvar('registered', '0', Cvar.FLAG.READONLY, 'Set to 1, when not playing shareware.'); - // @ts-ignore: need to fix that later, this.cmdline is a string first, but then it’s turned into a Cvar. - this.cmdline = new Cvar('cmdline', this.cmdline, Cvar.FLAG.READONLY, 'Command line used to start the game.'); + // cmdline starts as a string from InitArgv, then becomes a Cvar here + this.cmdline = new Cvar('cmdline', this.cmdline as string, Cvar.FLAG.READONLY, 'Command line used to start the game.'); Cmd.AddCommand('path', this.Path_f); @@ -202,7 +233,7 @@ export default class COM { await Promise.all([ this.CheckRegistered(), - W.LoadPalette('gfx/palette.lmp'), // CR: we early load the palette here, it’s needed in both dedicated and browser processes + W.LoadPalette('gfx/palette.lmp'), // CR: we early load the palette here, it's needed in both dedicated and browser processes ]); Sys.Print('COM.Init: low-level initialization completed.\n'); @@ -212,8 +243,7 @@ export default class COM { static Shutdown() { Sys.Print('COM.Shutdown: signaling outstanding promises to abort\n'); - - this.abortController.abort('COM.Shutdown'); + this.abortController!.abort('COM.Shutdown'); } static Path_f() { @@ -221,40 +251,40 @@ export default class COM { } // eslint-disable-next-line @typescript-eslint/require-await - static async WriteFile(filename, data, len) { + static async WriteFile(filename: string, data: ArrayLike, len: number): Promise { if (registry.isInsideWorker) { Sys.Print('COM.WriteFile: not supported inside worker threads\n'); return false; } filename = filename.toLowerCase(); - const dest = []; + const dest: string[] = []; for (let i = 0; i < len; i++) { dest[i] = String.fromCharCode(data[i]); } try { localStorage.setItem('Quake.' + this.searchpaths[this.searchpaths.length - 1].filename + '/' + filename, dest.join('')); } catch (e) { - Sys.Print('COM.WriteFile: failed on ' + filename + ', ' + e.message + '\n'); + Sys.Print('COM.WriteFile: failed on ' + filename + ', ' + (e as Error).message + '\n'); return false; } Sys.Print('COM.WriteFile: ' + filename + '\n'); return true; - }; + } - static WriteTextFile(filename, data) { + static WriteTextFile(filename: string, data: string): boolean { filename = filename.toLowerCase(); try { localStorage.setItem('Quake.' + this.searchpaths[this.searchpaths.length - 1].filename + '/' + filename, data); } catch (e) { - Sys.Print('COM.WriteTextFile: failed on ' + filename + ', ' + e.message + '\n'); + Sys.Print('COM.WriteTextFile: failed on ' + filename + ', ' + (e as Error).message + '\n'); return false; } Sys.Print('COM.WriteTextFile: ' + filename + '\n'); return true; - }; + } - static GetNetpath(filename, gameDir = null) { + static GetNetpath(filename: string, gameDir: string | null = null): string { if (gameDir === null) { gameDir = this.GetGamedir(); } @@ -272,20 +302,21 @@ export default class COM { } /** - * Get the current game directory - * @returns {string} game name, e.g. 'id1' + * Get the current game directory. + * @returns game name, e.g. `'id1'` */ - static GetGamedir() { + static GetGamedir(): string { return this.searchpaths.length > 0 ? this.searchpaths[this.searchpaths.length - 1].filename : defaultGame; } /** - * @param {string} filename virtual filename - * @returns {Promise} binary content + * Load a file from the virtual filesystem. + * Searches localStorage first, then fetches from the CDN/server. + * @returns binary content, or null if not found */ - static async LoadFile(filename) { + static async LoadFile(filename: string): Promise { filename = filename.toLowerCase(); eventBus.publish('com.fs.being', filename); @@ -328,37 +359,34 @@ export default class COM { } /** - * Loads a text file. - * @param {string} filename filename - * @returns {Promise} content of the file as a string + * Load a text file, stripping carriage returns. + * @returns file content as a string, or null if not found */ - static async LoadTextFile(filename) { + static async LoadTextFile(filename: string): Promise { const buf = await this.LoadFile(filename); if (buf === null) { return null; } const bufview = new Uint8Array(buf); - const f = []; + const f: string[] = []; for (let i = 0; i < bufview.length; i++) { - if (bufview[i] !== 13) { + if (bufview[i] !== 13) { // skip CR f[f.length] = String.fromCharCode(bufview[i]); } } return f.join(''); - }; + } /** * Add a game directory to the search path. * Note: PAK files are pre-extracted at build time, so we only track the directory. - * @param {string} dir - directory name (e.g., 'id1') */ // eslint-disable-next-line @typescript-eslint/require-await - static async AddGameDirectory(dir) { - /** @type {SearchPath} */ - const search = { filename: dir, pack: [] }; - this.searchpaths[this.searchpaths.length] = search; + static async AddGameDirectory(dir: string) { + const search: SearchPath = { filename: dir, pack: [] }; + this.searchpaths.push(search); Con.DPrint(`Added game directory: ${dir}\n`); - }; + } static async InitFilesystem() { // Shortcut for specifying game directory at build time @@ -368,9 +396,9 @@ export default class COM { return; } - let search; + let search: string | undefined; - let i = this.CheckParm('-basedir'); + const i = this.CheckParm('-basedir'); if (i !== null) { search = this.argv[i + 1]; } @@ -380,19 +408,19 @@ export default class COM { await this.AddGameDirectory(defaultBasedir); } - if (this.rogue === true) { + if (this.rogue) { await this.AddGameDirectory('rogue'); - } else if (this.hipnotic === true) { + } else if (this.hipnotic) { await this.AddGameDirectory('hipnotic'); } - i = this.CheckParm('-game'); - if (i !== null) { - search = this.argv[i + 1]; - if (search !== undefined) { + const gameIdx = this.CheckParm('-game'); + if (gameIdx !== null) { + const gameArg = this.argv[gameIdx + 1]; + if (gameArg !== undefined) { this.modified = true; - this.game = search; - await this.AddGameDirectory(search); + this.game = gameArg; + await this.AddGameDirectory(gameArg); } } else if (defaultGame !== defaultBasedir) { this.game = defaultGame; @@ -402,4 +430,4 @@ export default class COM { this.gamedir = [this.searchpaths[this.searchpaths.length - 1]]; } -}; +} diff --git a/source/engine/common/WorkerFramework.mjs b/source/engine/common/WorkerFramework.mjs index a74e4fee..d49a22f7 100644 --- a/source/engine/common/WorkerFramework.mjs +++ b/source/engine/common/WorkerFramework.mjs @@ -1,7 +1,7 @@ import { eventBus, registry } from '../registry.mjs'; import Mod from './Mod.mjs'; import Sys from './Sys.mjs'; -import COM from './Com.mjs'; +import COM from './Com.ts'; class WorkerConsole { static Print(message) { @@ -71,7 +71,7 @@ export default class WorkerFramework { static async Init() { let COM; - + const isNode = typeof process !== 'undefined' && process.versions != null && process.versions.node != null; if (isNode) { diff --git a/source/engine/main-browser.mjs b/source/engine/main-browser.mjs index 7ed2ccd3..4c2364b4 100644 --- a/source/engine/main-browser.mjs +++ b/source/engine/main-browser.mjs @@ -1,7 +1,7 @@ import { registry, freeze as registryFreeze } from './registry.mjs'; import Sys from './client/Sys.mjs'; -import COM from './common/Com.mjs'; +import COM from './common/Com.ts'; import Con from './common/Console.ts'; import Host from './common/Host.mjs'; import V from './client/V.mjs'; diff --git a/source/engine/registry.mjs b/source/engine/registry.mjs index 0aa7bd8f..1d8e373f 100644 --- a/source/engine/registry.mjs +++ b/source/engine/registry.mjs @@ -1,6 +1,6 @@ /** @typedef {typeof import('./common/Console.ts').default} ConModule */ -/** @typedef {typeof import('./common/Com.mjs').default} ComModule */ +/** @typedef {typeof import('./common/Com.ts').default} ComModule */ /** @typedef {typeof import('./common/Sys.mjs').default} SysModule */ /** @typedef {typeof import('./common/Host.mjs').default} HostModule */ /** @typedef {typeof import('./client/V.mjs').default} VModule */ diff --git a/source/engine/server/Com.mjs b/source/engine/server/Com.mjs index cc271d47..7a0359b4 100644 --- a/source/engine/server/Com.mjs +++ b/source/engine/server/Com.mjs @@ -4,7 +4,7 @@ import { promises as fsPromises, existsSync, writeFileSync, constants } from 'fs import Q from '../../shared/Q.ts'; import { CRC16CCITT as CRC } from '../common/CRC.ts'; -import COM from '../common/Com.mjs'; +import COM from '../common/Com.ts'; import { CorruptedResourceError } from '../common/Errors.ts'; import { registry, eventBus } from '../registry.mjs'; diff --git a/test/common/cmd.test.mjs b/test/common/cmd.test.mjs index 0a118fbe..f40d09f0 100644 --- a/test/common/cmd.test.mjs +++ b/test/common/cmd.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import Cmd from '../../source/engine/common/Cmd.ts'; -import COM from '../../source/engine/common/Com.mjs'; +import COM from '../../source/engine/common/Com.ts'; import Cvar from '../../source/engine/common/Cvar.ts'; import { registry } from '../../source/engine/registry.mjs'; import { defaultMockRegistry, withMockRegistry } from '../physics/fixtures.mjs'; diff --git a/test/common/com.test.mjs b/test/common/com.test.mjs new file mode 100644 index 00000000..e60a0d79 --- /dev/null +++ b/test/common/com.test.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import COM from '../../source/engine/common/Com.ts'; + +void describe('COM', () => { + void describe('DefaultExtension', () => { + void test('appends extension when path has none', () => { + assert.equal(COM.DefaultExtension('maps/e1m1', '.bsp'), 'maps/e1m1.bsp'); + }); + + void test('does not append when path already has an extension', () => { + assert.equal(COM.DefaultExtension('maps/e1m1.bsp', '.lit'), 'maps/e1m1.bsp'); + }); + + void test('does not treat directory slashes as extensions', () => { + assert.equal(COM.DefaultExtension('gfx/env/sky', '.tga'), 'gfx/env/sky.tga'); + }); + }); + + void describe('Parse', () => { + void test('parses a simple token', () => { + const result = COM.Parse('hello world'); + assert.equal(result.token, 'hello'); + assert.equal(result.data?.trim(), 'world'); + }); + + void test('returns null data when input is exhausted', () => { + const result = COM.Parse(''); + assert.equal(result.token, ''); + assert.equal(result.data, null); + }); + + void test('parses a quoted string as a single token', () => { + const result = COM.Parse('"hello world" rest'); + assert.equal(result.token, 'hello world'); + assert.equal(result.data?.trim(), 'rest'); + }); + + void test('skips // line comments', () => { + const result = COM.Parse('// comment\ntoken'); + assert.equal(result.token, 'token'); + }); + + void test('skips leading whitespace', () => { + const result = COM.Parse(' spaced'); + assert.equal(result.token, 'spaced'); + }); + }); + + void describe('CheckParm / GetParm', () => { + const savedArgv = [...COM.argv]; + + void test('CheckParm returns index when parameter exists', () => { + COM.argv = ['quake', '-game', 'hipnotic', '-developer']; + try { + assert.equal(COM.CheckParm('-game'), 1); + assert.equal(COM.CheckParm('-developer'), 3); + assert.equal(COM.CheckParm('-missing'), null); + } finally { + COM.argv = savedArgv; + } + }); + + void test('GetParm returns the value following the flag', () => { + COM.argv = ['quake', '-game', 'hipnotic', '-developer']; + try { + assert.equal(COM.GetParm('-game'), 'hipnotic'); + assert.equal(COM.GetParm('-developer'), null); // no value after last flag + assert.equal(COM.GetParm('-missing'), null); + } finally { + COM.argv = savedArgv; + } + }); + }); + + void describe('InitArgv', () => { + void test('populates argv and detects -rogue flag', () => { + const savedArgv = [...COM.argv]; + const savedRogue = COM.rogue; + const savedStdQuake = COM.standard_quake; + try { + COM.argv = []; + COM.rogue = false; + COM.standard_quake = true; + COM.InitArgv(['quake', '-rogue']); + assert.equal(COM.rogue, true); + assert.equal(COM.standard_quake, false); + assert.equal(COM.argv[0], 'quake'); + } finally { + COM.argv = savedArgv; + COM.rogue = savedRogue; + COM.standard_quake = savedStdQuake; + } + }); + + void test('-safe appends disable flags', () => { + const savedArgv = [...COM.argv]; + try { + COM.argv = []; + COM.InitArgv(['quake', '-safe']); + assert.ok(COM.argv.includes('-nosound')); + assert.ok(COM.argv.includes('-nocdaudio')); + assert.ok(COM.argv.includes('-nomouse')); + } finally { + COM.argv = savedArgv; + } + }); + }); +}); diff --git a/test/physics/fixtures.mjs b/test/physics/fixtures.mjs index cbf9059b..39af2ed3 100644 --- a/test/physics/fixtures.mjs +++ b/test/physics/fixtures.mjs @@ -46,7 +46,7 @@ import { ServerPhysics } from '../../source/engine/server/physics/ServerPhysics. /** * @typedef MockRegistryConfig - * @property {typeof import('../../source/engine/common/Com.mjs').default | null} [COM] + * @property {typeof import('../../source/engine/common/Com.ts').default | null} [COM] * @property {object|null} [CL] * @property {{ Print: Function, DPrint: Function }} Con * @property {{ frametime: number }} Host diff --git a/test/physics/map-pmove-harness.mjs b/test/physics/map-pmove-harness.mjs index 56d37e6b..ac0a136e 100644 --- a/test/physics/map-pmove-harness.mjs +++ b/test/physics/map-pmove-harness.mjs @@ -1,7 +1,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import COMClass from '../../source/engine/common/Com.mjs'; +import COMClass from '../../source/engine/common/Com.ts'; import Mod from '../../source/engine/common/Mod.mjs'; import { PMF, Pmove } from '../../source/engine/common/Pmove.mjs'; import { UserCmd } from '../../source/engine/network/Protocol.ts'; diff --git a/test/physics/pmove.test.mjs b/test/physics/pmove.test.mjs index 533f8a8d..d7136497 100644 --- a/test/physics/pmove.test.mjs +++ b/test/physics/pmove.test.mjs @@ -2,7 +2,7 @@ import fs from 'node:fs/promises'; import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; -import COMClass from '../../source/engine/common/Com.mjs'; +import COMClass from '../../source/engine/common/Com.ts'; import Mod from '../../source/engine/common/Mod.mjs'; import Vector from '../../source/shared/Vector.ts'; import { content } from '../../source/shared/Defs.ts'; @@ -218,7 +218,7 @@ async function runMapFrames({ PrintSuccess() {}, }); registry.Mod = Mod; - registry.COM = /** @type {typeof import('../../source/engine/common/Com.mjs').default} */ ({ + registry.COM = /** @type {typeof import('../../source/engine/common/Com.ts').default} */ ({ Parse: COMClass.Parse, async LoadFile(name) { try { From 0e3fd19b235713e08ea7a694e36198bc4f4877f8 Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 17:09:35 +0300 Subject: [PATCH 18/67] TS: common/Com improvements --- source/engine/common/Com.ts | 61 ++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 35 deletions(-) diff --git a/source/engine/common/Com.ts b/source/engine/common/Com.ts index f00f8301..ad663688 100644 --- a/source/engine/common/Com.ts +++ b/source/engine/common/Com.ts @@ -16,23 +16,23 @@ eventBus.subscribe('registry.frozen', () => { }); /** A file entry inside a .pak archive. */ -export type PackFileEntry = { - name: string; - filepos: number; - filelen: number; -}; +export interface PackFileEntry { + readonly name: string; + readonly filepos: number; + readonly filelen: number; +} /** A search path entry in the virtual filesystem. */ -export type SearchPath = { - filename: string; +export interface SearchPath { + readonly filename: string; pack: PackFileEntry[][]; -}; +} /** Result of {@link COM.Parse}. */ -export type ParseResult = { - token: string; - data: string | null; -}; +export interface ParseResult { + readonly token: string; + readonly data: string | null; +} /** * Common file system, command line, and string parsing utilities. @@ -200,14 +200,10 @@ export default class COM { } static InitArgv(argv: string[]) { - this.cmdline = (argv.join(' ') + ' ').substring(0, 256); - for (let i = 0; i < argv.length; i++) { - this.argv[i] = argv[i]; - } + this.cmdline = `${argv.join(' ')} `.substring(0, 256); + this.argv = [...argv]; if (this.CheckParm('-safe')) { - this.argv[this.argv.length] = '-nosound'; - this.argv[this.argv.length] = '-nocdaudio'; - this.argv[this.argv.length] = '-nomouse'; + this.argv.push('-nosound', '-nocdaudio', '-nomouse'); } if (this.CheckParm('-rogue')) { this.rogue = true; @@ -258,29 +254,31 @@ export default class COM { } filename = filename.toLowerCase(); - const dest: string[] = []; + const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { - dest[i] = String.fromCharCode(data[i]); + bytes[i] = data[i]; } + const gameDir = this.searchpaths[this.searchpaths.length - 1].filename; try { - localStorage.setItem('Quake.' + this.searchpaths[this.searchpaths.length - 1].filename + '/' + filename, dest.join('')); + localStorage.setItem(`Quake.${gameDir}/${filename}`, new TextDecoder('iso-8859-1').decode(bytes)); } catch (e) { - Sys.Print('COM.WriteFile: failed on ' + filename + ', ' + (e as Error).message + '\n'); + Sys.Print(`COM.WriteFile: failed on ${filename}, ${(e as Error).message}\n`); return false; } - Sys.Print('COM.WriteFile: ' + filename + '\n'); + Sys.Print(`COM.WriteFile: ${filename}\n`); return true; } static WriteTextFile(filename: string, data: string): boolean { filename = filename.toLowerCase(); + const gameDir = this.searchpaths[this.searchpaths.length - 1].filename; try { - localStorage.setItem('Quake.' + this.searchpaths[this.searchpaths.length - 1].filename + '/' + filename, data); + localStorage.setItem(`Quake.${gameDir}/${filename}`, data); } catch (e) { - Sys.Print('COM.WriteTextFile: failed on ' + filename + ', ' + (e as Error).message + '\n'); + Sys.Print(`COM.WriteTextFile: failed on ${filename}, ${(e as Error).message}\n`); return false; } - Sys.Print('COM.WriteTextFile: ' + filename + '\n'); + Sys.Print(`COM.WriteTextFile: ${filename}\n`); return true; } @@ -367,14 +365,7 @@ export default class COM { if (buf === null) { return null; } - const bufview = new Uint8Array(buf); - const f: string[] = []; - for (let i = 0; i < bufview.length; i++) { - if (bufview[i] !== 13) { // skip CR - f[f.length] = String.fromCharCode(bufview[i]); - } - } - return f.join(''); + return new TextDecoder('iso-8859-1').decode(buf).replaceAll('\r', ''); } /** From 4d79df3901a0337fd2c7b8d49aff5be7da00ff0e Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 17:17:10 +0300 Subject: [PATCH 19/67] TS: common/Sys --- source/engine/common/PlatformWorker.mjs | 2 +- source/engine/common/{Sys.mjs => Sys.ts} | 58 ++++++++++++++---------- source/engine/common/WorkerFramework.mjs | 2 +- source/engine/registry.mjs | 2 +- test/common/sys.test.mjs | 40 ++++++++++++++++ 5 files changed, 76 insertions(+), 28 deletions(-) rename source/engine/common/{Sys.mjs => Sys.ts} (50%) create mode 100644 test/common/sys.test.mjs diff --git a/source/engine/common/PlatformWorker.mjs b/source/engine/common/PlatformWorker.mjs index cee3e24e..d09d2914 100644 --- a/source/engine/common/PlatformWorker.mjs +++ b/source/engine/common/PlatformWorker.mjs @@ -1,5 +1,5 @@ import { registry, eventBus } from '../registry.mjs'; -import { BaseWorker } from './Sys.mjs'; +import { BaseWorker } from './Sys.ts'; let { Host } = registry; diff --git a/source/engine/common/Sys.mjs b/source/engine/common/Sys.ts similarity index 50% rename from source/engine/common/Sys.mjs rename to source/engine/common/Sys.ts index 9b5206c8..ccc8f8ed 100644 --- a/source/engine/common/Sys.mjs +++ b/source/engine/common/Sys.ts @@ -1,27 +1,36 @@ import { NotImplementedError } from './Errors.ts'; +/** Message listener callback for worker communication. */ +type WorkerMessageListener = (message: unknown) => void; + +/** Shutdown listener callback. */ +type WorkerShutdownListener = () => void; + +/** + * Abstract base class for platform-specific worker implementations. + * + * Subclassed by `PlatformWorker` for both browser (Web Worker) and + * Node.js (worker_threads) environments. + */ export class BaseWorker { - /** @type {Function[]} @protected */ - _shutdownListeners = []; + protected _shutdownListeners: WorkerShutdownListener[] = []; + + /** Display name of this worker instance. */ + name: string; - /** - * @param {string} name name of the worker - */ - constructor(name) { + constructor(name: string) { this.name = name; } - // eslint-disable-next-line no-unused-vars - addOnMessageListener(listener) { + addOnMessageListener(_listener: WorkerMessageListener) { throw new NotImplementedError('Worker.addOnMessageListener must be implemented in a subclass'); } - addOnShutdownListener(listener) { + addOnShutdownListener(listener: WorkerShutdownListener) { this._shutdownListeners.push(listener); } - // eslint-disable-next-line no-unused-vars - postMessage(message) { + postMessage(_message: unknown) { throw new NotImplementedError('Worker.postMessage must be implemented in a subclass'); } @@ -29,35 +38,34 @@ export class BaseWorker { async shutdown() { throw new NotImplementedError('Worker.shutdown must be implemented in a subclass'); } -}; +} -/** Base class for Sys implementations. */ +/** + * Abstract base class for platform system services. + * + * Provides the contract for initialization, output, and timing that + * platform-specific implementations (`client/Sys`, `server/Sys`, + * `WorkerSys`) must fulfil. + */ export default class Sys { // eslint-disable-next-line @typescript-eslint/require-await static async Init() { throw new NotImplementedError('Sys.Init must be implemented in a subclass'); } - static Quit() { + static Quit(): never { throw new NotImplementedError('Sys.Quit must be implemented in a subclass'); } - // eslint-disable-next-line no-unused-vars - static Print(text) { + static Print(_text: string) { throw new NotImplementedError('Sys.Print must be implemented in a subclass'); } - /** @returns {number} uptime in seconds */ - static FloatTime() { + static FloatTime(): number { throw new NotImplementedError('Sys.GetTime must be implemented in a subclass'); - // eslint-disable-next-line no-unreachable - return 0; } - /** @returns {number} uptime in milliseconds, containing microseconds */ - static FloatMilliTime() { + static FloatMilliTime(): number { throw new NotImplementedError('Sys.FloatMilliTime must be implemented in a subclass'); - // eslint-disable-next-line no-unreachable - return 0; } -}; +} diff --git a/source/engine/common/WorkerFramework.mjs b/source/engine/common/WorkerFramework.mjs index d49a22f7..30e80c84 100644 --- a/source/engine/common/WorkerFramework.mjs +++ b/source/engine/common/WorkerFramework.mjs @@ -1,6 +1,6 @@ import { eventBus, registry } from '../registry.mjs'; import Mod from './Mod.mjs'; -import Sys from './Sys.mjs'; +import Sys from './Sys.ts'; import COM from './Com.ts'; class WorkerConsole { diff --git a/source/engine/registry.mjs b/source/engine/registry.mjs index 1d8e373f..6a965dc9 100644 --- a/source/engine/registry.mjs +++ b/source/engine/registry.mjs @@ -1,7 +1,7 @@ /** @typedef {typeof import('./common/Console.ts').default} ConModule */ /** @typedef {typeof import('./common/Com.ts').default} ComModule */ -/** @typedef {typeof import('./common/Sys.mjs').default} SysModule */ +/** @typedef {typeof import('./common/Sys.ts').default} SysModule */ /** @typedef {typeof import('./common/Host.mjs').default} HostModule */ /** @typedef {typeof import('./client/V.mjs').default} VModule */ /** @typedef {typeof import('./network/Network').default} NetModule */ diff --git a/test/common/sys.test.mjs b/test/common/sys.test.mjs new file mode 100644 index 00000000..c7d4db1e --- /dev/null +++ b/test/common/sys.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import Sys, { BaseWorker } from '../../source/engine/common/Sys.ts'; + +void describe('Sys (base class)', () => { + void test('all static methods throw NotImplementedError', () => { + assert.throws(() => Sys.Print('test'), { name: 'NotImplementedError' }); + assert.throws(() => Sys.Quit(), { name: 'NotImplementedError' }); + assert.throws(() => Sys.FloatTime(), { name: 'NotImplementedError' }); + assert.throws(() => Sys.FloatMilliTime(), { name: 'NotImplementedError' }); + }); + + void test('Init rejects with NotImplementedError', async () => { + await assert.rejects(() => Sys.Init(), { name: 'NotImplementedError' }); + }); +}); + +void describe('BaseWorker', () => { + void test('abstract methods throw NotImplementedError', () => { + const worker = new BaseWorker('test-worker'); + assert.equal(worker.name, 'test-worker'); + assert.throws(() => worker.addOnMessageListener(() => {}), { name: 'NotImplementedError' }); + assert.throws(() => worker.postMessage({}), { name: 'NotImplementedError' }); + }); + + void test('shutdown rejects with NotImplementedError', async () => { + const worker = new BaseWorker('test-worker'); + await assert.rejects(() => worker.shutdown(), { name: 'NotImplementedError' }); + }); + + void test('addOnShutdownListener accumulates listeners', () => { + const worker = new BaseWorker('test-worker'); + const fn1 = () => {}; + const fn2 = () => {}; + worker.addOnShutdownListener(fn1); + worker.addOnShutdownListener(fn2); + assert.equal(worker._shutdownListeners.length, 2); + }); +}); From 27777770881e637e2b73cc90872338c3466e0c06 Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 17:33:29 +0300 Subject: [PATCH 20/67] TS: common/W --- source/engine/client/Draw.mjs | 2 +- source/engine/client/GL.mjs | 2 +- source/engine/client/R.mjs | 2 +- source/engine/client/Tools.mjs | 2 +- .../client/renderer/AliasModelRenderer.mjs | 2 +- source/engine/client/renderer/Sky.mjs | 2 +- source/engine/common/Com.ts | 2 +- source/engine/common/GameAPIs.mjs | 2 +- source/engine/common/PlatformWorker.mjs | 2 +- source/engine/common/{W.mjs => W.ts} | 256 +++++++++--------- .../common/model/loaders/AliasMDLLoader.mjs | 2 +- .../common/model/loaders/BSP29Loader.mjs | 2 +- .../common/model/loaders/SpriteSPRLoader.mjs | 2 +- test/common/w-textures.test.mjs | 55 +++- 14 files changed, 185 insertions(+), 150 deletions(-) rename source/engine/common/{W.mjs => W.ts} (64%) diff --git a/source/engine/client/Draw.mjs b/source/engine/client/Draw.mjs index e3851d72..2157f21a 100644 --- a/source/engine/client/Draw.mjs +++ b/source/engine/client/Draw.mjs @@ -2,7 +2,7 @@ import Vector from '../../shared/Vector.ts'; import { MissingResourceError } from '../common/Errors.ts'; import VID from './VID.mjs'; -import W, { WadFileInterface, WadLumpTexture } from '../common/W.mjs'; +import W, { WadFileInterface, WadLumpTexture } from '../common/W.ts'; import { eventBus, registry } from '../registry.mjs'; import GL, { GLTexture } from './GL.mjs'; diff --git a/source/engine/client/GL.mjs b/source/engine/client/GL.mjs index dad99bfc..44ae10ef 100644 --- a/source/engine/client/GL.mjs +++ b/source/engine/client/GL.mjs @@ -1,7 +1,7 @@ import Cmd, { ConsoleCommand } from '../common/Cmd.ts'; import Cvar from '../common/Cvar.ts'; import { MissingResourceError } from '../common/Errors.ts'; -import { WadLumpTexture } from '../common/W.mjs'; +import { WadLumpTexture } from '../common/W.ts'; import { eventBus, registry } from '../registry.mjs'; import VID from './VID.mjs'; diff --git a/source/engine/client/R.mjs b/source/engine/client/R.mjs index 4ce00765..f8836b97 100644 --- a/source/engine/client/R.mjs +++ b/source/engine/client/R.mjs @@ -5,7 +5,7 @@ import * as Def from '../common/Def.ts'; import { eventBus, registry } from '../registry.mjs'; import Chase from './Chase.mjs'; -import W from '../common/W.mjs'; +import W from '../common/W.ts'; import VID from './VID.mjs'; import GL, { ATTRIB_LOCATIONS, GLTexture } from './GL.mjs'; import { content, effect, gameCapabilities } from '../../shared/Defs.ts'; diff --git a/source/engine/client/Tools.mjs b/source/engine/client/Tools.mjs index f3d1ec8e..636dd5cc 100644 --- a/source/engine/client/Tools.mjs +++ b/source/engine/client/Tools.mjs @@ -1,5 +1,5 @@ import Cmd, { ConsoleCommand } from '../common/Cmd.ts'; -import W, { WadFileInterface } from '../common/W.mjs'; +import W, { WadFileInterface } from '../common/W.ts'; import { eventBus, registry } from '../registry.mjs'; let { Con } = registry; diff --git a/source/engine/client/renderer/AliasModelRenderer.mjs b/source/engine/client/renderer/AliasModelRenderer.mjs index ba5a01f2..4716503d 100644 --- a/source/engine/client/renderer/AliasModelRenderer.mjs +++ b/source/engine/client/renderer/AliasModelRenderer.mjs @@ -3,7 +3,7 @@ import { ModelRenderer } from './ModelRenderer.mjs'; import { getEntityBloomEmissiveScale } from './BloomEffect.mjs'; import { eventBus, registry } from '../../registry.mjs'; import GL from '../GL.mjs'; -import W from '../../common/W.mjs'; +import W from '../../common/W.ts'; import { effect } from '../../../shared/Defs.ts'; let { CL, Host, R, Con } = registry; diff --git a/source/engine/client/renderer/Sky.mjs b/source/engine/client/renderer/Sky.mjs index f71f14af..49187a1c 100644 --- a/source/engine/client/renderer/Sky.mjs +++ b/source/engine/client/renderer/Sky.mjs @@ -1,4 +1,4 @@ -import W from '../../common/W.mjs'; +import W from '../../common/W.ts'; import { BrushModel } from '../../common/Mod.mjs'; import { eventBus, registry } from '../../registry.mjs'; diff --git a/source/engine/common/Com.ts b/source/engine/common/Com.ts index ad663688..cac7ae54 100644 --- a/source/engine/common/Com.ts +++ b/source/engine/common/Com.ts @@ -4,7 +4,7 @@ import Q from '../../shared/Q.ts'; import { CorruptedResourceError } from './Errors.ts'; import Cvar from './Cvar.ts'; -import W from './W.mjs'; +import W from './W.ts'; import Cmd from './Cmd.ts'; import { defaultBasedir, defaultGame } from './Def.ts'; import { CRC16CCITT } from './CRC.ts'; diff --git a/source/engine/common/GameAPIs.mjs b/source/engine/common/GameAPIs.mjs index 7860a941..fc51b63c 100644 --- a/source/engine/common/GameAPIs.mjs +++ b/source/engine/common/GameAPIs.mjs @@ -11,7 +11,7 @@ import Cmd from './Cmd.ts'; import Cvar from './Cvar.ts'; import { HostError } from './Errors.ts'; import Mod from './Mod.mjs'; -import W from './W.mjs'; +import W from './W.ts'; /** @typedef {import('../client/ClientEntities.mjs').ClientEdict} ClientEdict */ /** @typedef {import('../client/ClientEntities.mjs').ClientDlight} ClientDlight */ diff --git a/source/engine/common/PlatformWorker.mjs b/source/engine/common/PlatformWorker.mjs index d09d2914..3ff4e8e0 100644 --- a/source/engine/common/PlatformWorker.mjs +++ b/source/engine/common/PlatformWorker.mjs @@ -8,7 +8,7 @@ eventBus.subscribe('registry.frozen', () => { }); /** @type {boolean} */ -const isNode = typeof process !== 'undefined' && process.versions?.node !== null; +const isNode = typeof process !== 'undefined' && process.versions?.node !== null; /** * Unified worker wrapper that works on both Node.js and browser environments. diff --git a/source/engine/common/W.mjs b/source/engine/common/W.ts similarity index 64% rename from source/engine/common/W.mjs rename to source/engine/common/W.ts index 9fbcb4a3..6c7f1508 100644 --- a/source/engine/common/W.mjs +++ b/source/engine/common/W.ts @@ -1,108 +1,100 @@ - -import { eventBus, registry } from '../registry.mjs'; +import { eventBus, getCommonRegistry } from '../registry.mjs'; import { CorruptedResourceError, MissingResourceError } from './Errors.ts'; import Q from '../../shared/Q.ts'; -let { COM } = registry; +let { COM } = getCommonRegistry(); eventBus.subscribe('registry.frozen', () => { - COM = registry.COM; + ({ COM } = getCommonRegistry()); }); +export interface WadLumpRecord { + readonly data: ArrayBuffer; + readonly type: number; + readonly size: number; + readonly name: string; +} + /** * WAD lump texture representation. * Contains only data, not uploaded to the GPU or anything. */ export class WadLumpTexture { - /** - * @param {string} name internal texture name - * @param {number} width width - * @param {number} height height - * @param {Uint8Array} data RGBA texture data - */ - constructor(name, width, height, data) { - this.name = name; // lump name - this.width = width; // texture width - this.height = height; // texture height - this.data = data; // texture data (Uint8Array) - + constructor( + readonly name: string, + readonly width: number, + readonly height: number, + readonly data: Uint8Array, + ) { Object.freeze(this); } - toDataURL() { + toDataURL(): string { const canvas = document.createElement('canvas'); canvas.width = this.width; canvas.height = this.height; const ctx = canvas.getContext('2d'); + if (ctx === null) { + throw new Error('WadLumpTexture.toDataURL: 2D canvas context unavailable'); + } const data = ctx.createImageData(canvas.width, canvas.height); data.data.set(new Uint8Array(this.data)); ctx.putImageData(data, 0, 0); return canvas.toDataURL(); } - toString() { + toString(): string { return `WadLumpTexture(${this.name}, ${this.width} x ${this.height} pixels, ${this.data.length} bytes)`; } -}; +} -export class WadFileInterface { - static MAGIC = 0; // magic number, to be defined in subclasses +export abstract class WadFileInterface { + static MAGIC = 0; - /** @protected */ - _lumps = {}; + protected _lumps: Record = {}; - getLumpNames() { + getLumpNames(): string[] { return Object.keys(this._lumps); } - // eslint-disable-next-line no-unused-vars - load(view) { - console.assert(null, 'WadFileInterface.load: not implemented'); - } + abstract load(base: ArrayBuffer): void; /** * This will return the raw data for the given name. - * @param {string} name identifer of the lump to retrieve - * @returns {ArrayBuffer} the lump data + * @returns the lump data */ - // eslint-disable-next-line no-unused-vars - getLump(name) { - console.assert(null, 'WadFileInterface.getLump: not implemented'); - return new ArrayBuffer(0); - } + abstract getLump(name: string): ArrayBuffer | WadLumpRecord; /** * This will return the palette translated data for the given name. - * @param {string} name identifer of the lump to retrieve - * @param {?number} mipmapLevel mipmap level to retrieve, will always take the most available mipmap level - * @returns {WadLumpTexture} the decoded texture data + * @returns the decoded texture data */ - // eslint-disable-next-line no-unused-vars - getLumpMipmap(name, mipmapLevel) { - console.assert(null, 'WadFileInterface.getLumpMipmap: not implemented'); - return null; - } -}; + abstract getLumpMipmap(name: string, mipmapLevel?: number): WadLumpTexture | null; +} + +/** A concrete WAD handler constructor with a static MAGIC identifier. */ +interface WadHandlerConstructor { + readonly MAGIC: number; + new(): WadFileInterface; +} export default class W { - /** @type {Array} */ - static _handlers = []; + static _handlers: WadHandlerConstructor[] = []; /** Current palette in 32 bit words. */ - static d_8to24table = new Uint32Array(new ArrayBuffer(1024)); + static d_8to24table = new Uint32Array(256); /** Current palette in 256 8 bit tuples for RGB. */ static d_8to24table_u8 = new Uint8Array(768); - /** @type {number} Fill color index */ - static filledColor = null; + /** Fill color index. */ + static filledColor: number | null = null; /** * Loads given WAD file. Supports multiple WAD formats (WAD2, WAD3). - * @param {string} filename wad file path - * @returns {Promise} the loaded WAD file or null if not found + * @returns the loaded WAD file */ - static async LoadFile(filename) { + static async LoadFile(filename: string): Promise { const base = await COM.LoadFile(filename); if (!base) { @@ -111,23 +103,22 @@ export default class W { const view = new DataView(base); const magic = view.getUint32(0, true); - const handler = W._handlers.find((h) => h.MAGIC === magic); + const handler = W._handlers.find((wadHandler) => wadHandler.MAGIC === magic); - if (!handler) { + if (handler === undefined) { throw new CorruptedResourceError(filename, 'not a valid WAD file'); } const wadFile = new handler(); wadFile.load(base); return wadFile; - }; + } /** * Loads the default palette from the given file. Used for all Quake resources. * A palette is a 256 color palette, each color is 3 bytes (RGB). 768 bytes in total. - * @param {string} filename palette file path, e.g. 'gfx/palette.lmp' */ - static async LoadPalette(filename) { + static async LoadPalette(filename: string) { const palette = await COM.LoadFile(filename); if (palette === null) { @@ -135,6 +126,7 @@ export default class W { } W.d_8to24table_u8 = new Uint8Array(palette); + W.filledColor = null; for (let i = 0, src = 0; i < 256; i++) { const pal = W.d_8to24table_u8; @@ -147,14 +139,13 @@ export default class W { } eventBus.publish('wad.palette.loaded'); - }; + } /** * Loads a lump from the filesystem as texture. - * @param {string} filename lump file path - * @returns {Promise} the loaded lump texture + * @returns the loaded lump texture */ - static async LoadLump(filename) { // TODO: this should take a type parameter to specify the type of the lump + static async LoadLump(filename: string) { // TODO: this should take a type parameter to specify the type of the lump const buf = await COM.LoadFile(filename); if (buf === null) { @@ -168,20 +159,23 @@ export default class W { return new WadLumpTexture(filename, width, height, translateIndexToRGBA(data, width, height, W.d_8to24table_u8, 255)); } -}; +} /** * Quake 1 WAD file format handler. */ class Wad2File extends WadFileInterface { - static MAGIC = 0x32444157; // 'WAD2' + static override MAGIC = 0x32444157; // 'WAD2' + + /** Active palette, sourced from {@link W.d_8to24table_u8}. */ + readonly palette: Uint8Array; constructor() { super(); - this.palette = W.d_8to24table_u8; // use the palette from VID + this.palette = W.d_8to24table_u8; } - load(base) { + override load(base: ArrayBuffer) { const view = new DataView(base); console.assert(view.getUint32(0, true) === Wad2File.MAGIC, 'magic number'); const numlumps = view.getUint32(4, true); @@ -191,12 +185,12 @@ class Wad2File extends WadFileInterface { const type = view.getUint8(infotableofs + 12); const lump = new ArrayBuffer(size); const name = Q.memstr(new Uint8Array(base, infotableofs + 16, 16)); - (new Uint8Array(lump)).set(new Uint8Array(base, view.getUint32(infotableofs, true), size)); + new Uint8Array(lump).set(new Uint8Array(base, view.getUint32(infotableofs, true), size)); this._lumps[name.toUpperCase()] = { data: lump, - type: type, // lump type - size: size, // uncompressed size - name: name, + type, // lump type + size, // uncompressed size + name, }; infotableofs += 32; } @@ -204,10 +198,9 @@ class Wad2File extends WadFileInterface { /** * This will return the raw data for the given name. - * @param {string} name identifer of the lump to retrieve - * @returns {ArrayBuffer} the lump data + * @returns the lump data */ - getLump(name) { + override getLump(name: string): ArrayBuffer { const lump = this._lumps[name.toUpperCase()]; if (!lump) { @@ -219,12 +212,9 @@ class Wad2File extends WadFileInterface { /** * This will return the palette translated data for the given name. - * @param {string} name identifer of the lump to retrieve - * @param {?number} mipmapLevel always 0, WAD2 does not support mipmaps - * @returns {WadLumpTexture} the decoded texture data + * @returns the decoded texture data */ - // eslint-disable-next-line no-unused-vars - getLumpMipmap(name, mipmapLevel = 0) { + override getLumpMipmap(name: string, _mipmapLevel = 0): WadLumpTexture { const data = this.getLump(name); const view = new DataView(data); @@ -245,7 +235,7 @@ class Wad2File extends WadFileInterface { return new WadLumpTexture(name, width, height, rgba); } -}; +} W._handlers.push(Wad2File); @@ -253,9 +243,9 @@ W._handlers.push(Wad2File); * GoldSrc WAD3 file format handler. */ class Wad3File extends WadFileInterface { - static MAGIC = 0x33444157; // 'WAD3' + static override MAGIC = 0x33444157; // 'WAD3' - load(base) { + override load(base: ArrayBuffer) { const view = new DataView(base); console.assert(view.getUint32(0, true) === Wad3File.MAGIC, 'magic number'); const numlumps = view.getUint32(4, true); @@ -271,26 +261,25 @@ class Wad3File extends WadFileInterface { const lump = new ArrayBuffer(size); if (!compression) { // Uncompressed - (new Uint8Array(lump)).set(new Uint8Array(base, filepos, disksize)); + new Uint8Array(lump).set(new Uint8Array(base, filepos, disksize)); } else { // Compressed const compressedData = new Uint8Array(base, filepos, disksize); const decompressed = Wad3File._decompressLZ(compressedData, size); - (new Uint8Array(lump)).set(decompressed); + new Uint8Array(lump).set(decompressed); } this._lumps[name.toUpperCase()] = { data: lump, - type: type, - size: size, - name: name, + type, + size, + name, }; infotableofs += 32; } } - // eslint-disable-next-line no-unused-vars - _parseQPicLump(name, data, mipmapLevel) { + _parseQPicLump(name: string, data: ArrayBuffer, _mipmapLevel: number): WadLumpTexture { const view = new DataView(data); const width = view.getUint32(0, true); const height = view.getUint32(4, true); @@ -308,16 +297,15 @@ class Wad3File extends WadFileInterface { return new WadLumpTexture(name, width, height, rgba); } - _parseMiptexLump(name, data, mipmapLevel) { + _parseMiptexLump(name: string, data: ArrayBuffer, mipmapLevel: number): WadLumpTexture { return readWad3Texture(data, name, mipmapLevel); } /** * This will return the raw data for the given name. - * @param {string} name identifer of the lump to retrieve - * @returns {ArrayBuffer} the lump data + * @returns the lump data */ - getLump(name) { + override getLump(name: string): WadLumpRecord { const lump = this._lumps[name.toUpperCase()]; if (!lump) { @@ -329,11 +317,9 @@ class Wad3File extends WadFileInterface { /** * This will return the palette translated data for the given name. - * @param {string} name name of the lump to retrieve - * @param {number} mipmapLevel 0..3, 0 is the base level - * @returns {WadLumpTexture} the decoded texture data + * @returns the decoded texture data */ - getLumpMipmap(name, mipmapLevel = 0) { + override getLumpMipmap(name: string, mipmapLevel = 0): WadLumpTexture | null { const lumpInfo = this._lumps[name.toUpperCase()]; if (!lumpInfo) { @@ -353,16 +339,14 @@ class Wad3File extends WadFileInterface { return null; // TODO: implement font handling } - throw new CorruptedResourceError(name, 'not a valid lump type (' + lumpInfo.type + ')'); + throw new CorruptedResourceError(name, `not a valid lump type (${lumpInfo.type})`); } /** * Decompress LZ-compressed data from GoldSrc WAD3 files - * @param {Uint8Array} compressed - The compressed data - * @param {number} uncompressedSize - Expected size of uncompressed data - * @returns {Uint8Array} - The decompressed data + * @returns the decompressed data */ - static _decompressLZ(compressed, uncompressedSize) { + static _decompressLZ(compressed: Uint8Array, uncompressedSize: number): Uint8Array { const output = new Uint8Array(uncompressedSize); let inPos = 0; let outPos = 0; @@ -407,23 +391,25 @@ class Wad3File extends WadFileInterface { } return output; - }; -}; + } +} W._handlers.push(Wad3File); /** * Helper function to convert indexed 8-bit data to RGBA format. * It has options for transparency and fullbright colors. - * @param {Uint8Array} uint8data indexed 8-bit data, each byte is an index to the palette - * @param {number} width width - * @param {number} height height - * @param {?Uint8Array} palette palette data, 256 colors, each color is 3 bytes (RGB), default is W.d_8to24table_u8 - * @param {?number} transparentColor optional color index to treat as transparent (default is null, no transparency) - * @param {?number} fullbrightColorStart optional color index where fullbright colors start (default is null, no fullbright) - * @returns {Uint8Array} RGBA data, each pixel is 4 bytes (R, G, B, A) + * @returns RGBA data, each pixel is 4 bytes (R, G, B, A) */ -export function translateIndexToRGBA(uint8data, width, height, palette = W.d_8to24table_u8, transparentColor = null, fullbrightColorStart = null) { +export function translateIndexToRGBA( + uint8data: Uint8Array, + width: number, + height: number, + palette: Uint8Array | null = W.d_8to24table_u8, + transparentColor: number | null = null, + fullbrightColorStart: number | null = null, +): Uint8Array { + const resolvedPalette = palette ?? W.d_8to24table_u8; const rgba = new Uint8Array(width * height * 4); for (let i = 0; i < width * height; i++) { @@ -437,29 +423,31 @@ export function translateIndexToRGBA(uint8data, width, height, palette = W.d_8to } // lookup the color in the palette - rgba[i * 4 + 0] = palette[colorIndex * 3]; - rgba[i * 4 + 1] = palette[colorIndex * 3 + 1]; - rgba[i * 4 + 2] = palette[colorIndex * 3 + 2]; + rgba[i * 4 + 0] = resolvedPalette[colorIndex * 3]; + rgba[i * 4 + 1] = resolvedPalette[colorIndex * 3 + 1]; + rgba[i * 4 + 2] = resolvedPalette[colorIndex * 3 + 2]; // our pixel shader is considering the alpha channel whether to use the lightmap or not rgba[i * 4 + 3] = fullbrightColorStart !== null && colorIndex >= fullbrightColorStart ? 0 : 255; } return rgba; -}; +} /** * Convert indexed 8-bit data into an RGBA emissive texture containing only * Quake fullbright pixels. - * @param {Uint8Array} uint8data Indexed 8-bit texture data. - * @param {number} width Texture width. - * @param {number} height Texture height. - * @param {?Uint8Array} palette Palette data, 256 colors x 3 bytes. - * @param {?number} transparentColor Optional transparent palette index. - * @param {?number} fullbrightColorStart Palette index where fullbright colors begin. - * @returns {Uint8Array} RGBA data containing only fullbright pixels. + * @returns RGBA data containing only fullbright pixels */ -export function translateIndexToLuminanceRGBA(uint8data, width, height, palette = W.d_8to24table_u8, transparentColor = null, fullbrightColorStart = 240) { +export function translateIndexToLuminanceRGBA( + uint8data: Uint8Array, + width: number, + height: number, + palette: Uint8Array | null = W.d_8to24table_u8, + transparentColor: number | null = null, + fullbrightColorStart: number | null = 240, +): Uint8Array { + const resolvedPalette = palette ?? W.d_8to24table_u8; const rgba = new Uint8Array(width * height * 4); for (let i = 0; i < width * height; i++) { @@ -473,9 +461,9 @@ export function translateIndexToLuminanceRGBA(uint8data, width, height, palette continue; } - rgba[i * 4 + 0] = palette[colorIndex * 3]; - rgba[i * 4 + 1] = palette[colorIndex * 3 + 1]; - rgba[i * 4 + 2] = palette[colorIndex * 3 + 2]; + rgba[i * 4 + 0] = resolvedPalette[colorIndex * 3]; + rgba[i * 4 + 1] = resolvedPalette[colorIndex * 3 + 1]; + rgba[i * 4 + 2] = resolvedPalette[colorIndex * 3 + 2]; rgba[i * 4 + 3] = 255; } @@ -484,12 +472,9 @@ export function translateIndexToLuminanceRGBA(uint8data, width, height, palette /** * Reads a WAD3 texture from the given data. - * @param {ArrayBuffer} data WAD3 texture data - * @param {string} name texture name, used if the texture name in the data is empty - * @param {number} mipmapLevel 0..3, 0 is the base level - * @returns {WadLumpTexture} the decoded texture data + * @returns the decoded texture data */ -export function readWad3Texture(data, name, mipmapLevel = 0) { +export function readWad3Texture(data: ArrayBuffer, name: string, mipmapLevel = 0): WadLumpTexture { const view = new DataView(data); const width = view.getUint32(16, true); const height = view.getUint32(20, true); @@ -523,7 +508,14 @@ export function readWad3Texture(data, name, mipmapLevel = 0) { ); // Textures with a name starting with '{' are transparent, so we set the transparent color to 255 - const rgba = translateIndexToRGBA(uint8data, swidth, sheight, palette, texName[0] === '{' ? 255 : null, (texName[0] === '~' || texName[2] === '~') ? 240 : null); + const rgba = translateIndexToRGBA( + uint8data, + swidth, + sheight, + palette, + texName[0] === '{' ? 255 : null, + (texName[0] === '~' || texName[2] === '~') ? 240 : null, + ); return new WadLumpTexture(texName, swidth, sheight, rgba); -}; +} diff --git a/source/engine/common/model/loaders/AliasMDLLoader.mjs b/source/engine/common/model/loaders/AliasMDLLoader.mjs index a943bbdd..50b389d3 100644 --- a/source/engine/common/model/loaders/AliasMDLLoader.mjs +++ b/source/engine/common/model/loaders/AliasMDLLoader.mjs @@ -1,7 +1,7 @@ import Vector from '../../../../shared/Vector.ts'; import Q from '../../../../shared/Q.ts'; import GL, { GLTexture, resampleTexture8 } from '../../../client/GL.mjs'; -import W, { translateIndexToLuminanceRGBA, translateIndexToRGBA } from '../../W.mjs'; +import W, { translateIndexToLuminanceRGBA, translateIndexToRGBA } from '../../W.ts'; import { CRC16CCITT } from '../../CRC.ts'; import { registry } from '../../../registry.mjs'; import { ModelLoader } from '../ModelLoader.mjs'; diff --git a/source/engine/common/model/loaders/BSP29Loader.mjs b/source/engine/common/model/loaders/BSP29Loader.mjs index aaa7ab59..4ed11168 100644 --- a/source/engine/common/model/loaders/BSP29Loader.mjs +++ b/source/engine/common/model/loaders/BSP29Loader.mjs @@ -2,7 +2,7 @@ import Vector from '../../../../shared/Vector.ts'; import Q from '../../../../shared/Q.ts'; import { content } from '../../../../shared/Defs.ts'; import { GLTexture } from '../../../client/GL.mjs'; -import W, { readWad3Texture, translateIndexToLuminanceRGBA, translateIndexToRGBA } from '../../W.mjs'; +import W, { readWad3Texture, translateIndexToLuminanceRGBA, translateIndexToRGBA } from '../../W.ts'; import { CRC16CCITT } from '../../CRC.ts'; import { CorruptedResourceError } from '../../Errors.ts'; import { eventBus, registry } from '../../../registry.mjs'; diff --git a/source/engine/common/model/loaders/SpriteSPRLoader.mjs b/source/engine/common/model/loaders/SpriteSPRLoader.mjs index 2406b26c..d915bb0d 100644 --- a/source/engine/common/model/loaders/SpriteSPRLoader.mjs +++ b/source/engine/common/model/loaders/SpriteSPRLoader.mjs @@ -1,6 +1,6 @@ import Vector from '../../../../shared/Vector.ts'; import { GLTexture } from '../../../client/GL.mjs'; -import W, { translateIndexToRGBA } from '../../W.mjs'; +import W, { translateIndexToRGBA } from '../../W.ts'; import { CRC16CCITT } from '../../CRC.ts'; import { registry } from '../../../registry.mjs'; import { ModelLoader } from '../ModelLoader.mjs'; diff --git a/test/common/w-textures.test.mjs b/test/common/w-textures.test.mjs index 24bdc474..b43a6c93 100644 --- a/test/common/w-textures.test.mjs +++ b/test/common/w-textures.test.mjs @@ -1,10 +1,10 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import { translateIndexToLuminanceRGBA, translateIndexToRGBA } from '../../source/engine/common/W.mjs'; +import { readWad3Texture, translateIndexToLuminanceRGBA, translateIndexToRGBA } from '../../source/engine/common/W.ts'; -describe('translateIndexToRGBA', () => { - test('treats palette index 240 as fullbright for legacy Quake textures', () => { +void describe('translateIndexToRGBA', () => { + void test('treats palette index 240 as fullbright for legacy Quake textures', () => { const palette = new Uint8Array(256 * 3); palette[240 * 3] = 10; palette[240 * 3 + 1] = 20; @@ -19,8 +19,8 @@ describe('translateIndexToRGBA', () => { }); }); -describe('translateIndexToLuminanceRGBA', () => { - test('keeps only fullbright palette entries in the luminance texture', () => { +void describe('translateIndexToLuminanceRGBA', () => { + void test('keeps only fullbright palette entries in the luminance texture', () => { const palette = new Uint8Array(256 * 3); palette[239 * 3] = 1; palette[239 * 3 + 1] = 2; @@ -41,7 +41,7 @@ describe('translateIndexToLuminanceRGBA', () => { ]); }); - test('skips transparent indexed pixels even when their palette index is fullbright', () => { + void test('skips transparent indexed pixels even when their palette index is fullbright', () => { const palette = new Uint8Array(256 * 3); palette[255 * 3] = 70; palette[255 * 3 + 1] = 80; @@ -52,3 +52,46 @@ describe('translateIndexToLuminanceRGBA', () => { assert.deepEqual(Array.from(rgba), [0, 0, 0, 0]); }); }); + +void describe('readWad3Texture', () => { + void test('applies transparent and fullbright rules from the miptex name markers', () => { + const width = 8; + const height = 8; + const headerSize = 40; + const mip0Size = width * height; + const mip1Size = (width / 2) * (height / 2); + const mip2Size = (width / 4) * (height / 4); + const mip3Size = (width / 8) * (height / 8); + const paletteOffset = headerSize + mip0Size + mip1Size + mip2Size + mip3Size + 2; + const buffer = new ArrayBuffer(paletteOffset + 768); + const view = new DataView(buffer); + const bytes = new Uint8Array(buffer); + + bytes.set(new TextEncoder().encode('{A~'), 0); + view.setUint32(16, width, true); + view.setUint32(20, height, true); + view.setUint32(24, headerSize, true); + view.setUint32(28, headerSize + mip0Size, true); + view.setUint32(32, headerSize + mip0Size + mip1Size, true); + view.setUint32(36, headerSize + mip0Size + mip1Size + mip2Size, true); + + bytes[headerSize] = 255; + bytes[headerSize + 1] = 240; + + view.setUint16(headerSize + mip0Size + mip1Size + mip2Size + mip3Size, 256, true); + + bytes[paletteOffset + 240 * 3] = 10; + bytes[paletteOffset + 240 * 3 + 1] = 20; + bytes[paletteOffset + 240 * 3 + 2] = 30; + + const texture = readWad3Texture(buffer, 'fallback', 0); + + assert.equal(texture.name, '{A~'); + assert.equal(texture.width, 8); + assert.equal(texture.height, 8); + assert.deepEqual(Array.from(texture.data.slice(0, 8)), [ + 0, 0, 0, 0, + 10, 20, 30, 0, + ]); + }); +}); From b187d137f703586c8263c29500b770e3d70a8cf3 Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 17:51:02 +0300 Subject: [PATCH 21/67] TS: common/ anything Worker --- source/engine/client/Sys.mjs | 4 +- source/engine/common/PlatformWorker.mjs | 99 --------- source/engine/common/PlatformWorker.ts | 126 ++++++++++++ source/engine/common/Sys.ts | 4 +- ...WorkerFactories.mjs => WorkerFactories.ts} | 5 +- source/engine/common/WorkerFramework.mjs | 118 ----------- source/engine/common/WorkerFramework.ts | 140 +++++++++++++ .../{WorkerManager.mjs => WorkerManager.ts} | 85 ++++---- source/engine/main-dedicated.mjs | 2 +- source/engine/server/DummyWorker.mjs | 2 +- source/engine/server/Navigation.mjs | 4 +- source/engine/server/NavigationWorker.mjs | 2 +- source/engine/server/Sys.mjs | 4 +- test/common/workers.test.mjs | 194 ++++++++++++++++++ 14 files changed, 523 insertions(+), 266 deletions(-) delete mode 100644 source/engine/common/PlatformWorker.mjs create mode 100644 source/engine/common/PlatformWorker.ts rename source/engine/common/{WorkerFactories.mjs => WorkerFactories.ts} (76%) delete mode 100644 source/engine/common/WorkerFramework.mjs create mode 100644 source/engine/common/WorkerFramework.ts rename source/engine/common/{WorkerManager.mjs => WorkerManager.ts} (58%) create mode 100644 test/common/workers.test.mjs diff --git a/source/engine/client/Sys.mjs b/source/engine/client/Sys.mjs index db012398..b90100f6 100644 --- a/source/engine/client/Sys.mjs +++ b/source/engine/client/Sys.mjs @@ -2,8 +2,8 @@ import { K } from '../../shared/Keys.ts'; import Q from '../../shared/Q.ts'; import { eventBus, registry } from '../registry.mjs'; import Tools from './Tools.mjs'; -import WorkerManager from '../common/WorkerManager.mjs'; -import workerFactories from '../common/WorkerFactories.mjs'; +import WorkerManager from '../common/WorkerManager.ts'; +import workerFactories from '../common/WorkerFactories.ts'; let { COM, Host, Key } = registry; diff --git a/source/engine/common/PlatformWorker.mjs b/source/engine/common/PlatformWorker.mjs deleted file mode 100644 index 3ff4e8e0..00000000 --- a/source/engine/common/PlatformWorker.mjs +++ /dev/null @@ -1,99 +0,0 @@ -import { registry, eventBus } from '../registry.mjs'; -import { BaseWorker } from './Sys.ts'; - -let { Host } = registry; - -eventBus.subscribe('registry.frozen', () => { - ({ Host } = registry); -}); - -/** @type {boolean} */ -const isNode = typeof process !== 'undefined' && process.versions?.node !== null; - -/** - * Unified worker wrapper that works on both Node.js and browser environments. - * - * In Node.js, it wraps a `worker_threads.Worker` (event emitter API). - * In the browser, it wraps a Web Worker (DOM event target API). - * - * Platform differences (message unwrapping, error subscription, terminate - * semantics) are detected once at module load via `isNode` and handled - * transparently. - * - * Receives an already-constructed Worker instance to avoid importing - * WorkerFactories (which would create a circular dependency through - * worker scripts that transitively import this module). - */ -export default class PlatformWorker extends BaseWorker { - /** - * @type {Worker} - * @description In Node.js this is a `worker_threads.Worker` (EventEmitter API); - * in the browser it is a standard Web Worker (EventTarget API). - */ - #worker = null; - - /** - * @param {string} name worker script name - * @param {Worker} worker pre-constructed Worker instance - */ - constructor(name, worker) { - super(name); - - this.#worker = worker; - this.#setupErrorHandler(); - } - - #setupErrorHandler() { - if (isNode) { - // @ts-ignore — Node.js Worker uses EventEmitter API (.on), not EventTarget - this.#worker.on('error', (e) => { - console.error(`PlatformWorker ${this.name} error: ${e.message}`); - - void this.shutdown(); - - Host.HandleCrash(e); - }); - } else { - this.#worker.addEventListener('error', (e) => { - const detail = e?.message || e?.filename || '(no details)'; - - console.error(`PlatformWorker ${this.name} error: ${detail}`, e); - - void this.shutdown(); - - Host.HandleCrash(e); - }); - } - } - - addOnMessageListener(listener) { - if (isNode) { - // @ts-ignore — Node.js Worker uses EventEmitter API (.on), not EventTarget - this.#worker.on('message', (data) => { - listener(data); - }); - } else { - this.#worker.addEventListener('message', (e) => { - listener(e.data); - }); - } - } - - postMessage(message) { - this.#worker.postMessage(message); - } - - async shutdown() { - for (const listener of this._shutdownListeners) { - listener(); - } - - this._shutdownListeners.length = 0; - - // Node.js Worker.terminate() returns a Promise; browser Worker.terminate() does not. - // eslint-disable-next-line @typescript-eslint/await-thenable - await this.#worker.terminate(); - - this.#worker = null; - } -} diff --git a/source/engine/common/PlatformWorker.ts b/source/engine/common/PlatformWorker.ts new file mode 100644 index 00000000..8c79e3ef --- /dev/null +++ b/source/engine/common/PlatformWorker.ts @@ -0,0 +1,126 @@ +import { registry, eventBus } from '../registry.mjs'; +import { BaseWorker, type WorkerMessageListener } from './Sys.ts'; + +let { Host } = registry; + +eventBus.subscribe('registry.frozen', () => { + ({ Host } = registry); +}); + +const isNode = typeof process !== 'undefined' && process.versions?.node !== undefined; + +export interface WorkerMessageEnvelope { + readonly event: string; + readonly data?: unknown[]; + readonly args?: unknown[]; +} + +type NodeLikeWorker = { + on(event: 'message', listener: (data: unknown) => void): void; + on(event: 'error', listener: (error: Error) => void): void; + postMessage(message: unknown): void; + terminate(): Promise; +}; + +type BrowserLikeWorker = { + addEventListener(event: 'message', listener: (event: MessageEvent) => void): void; + addEventListener(event: 'error', listener: (event: ErrorEvent) => void): void; + postMessage(message: unknown): void; + terminate(): void; +}; + +export type PlatformWorkerHandle = NodeLikeWorker | BrowserLikeWorker; +export type WorkerFactory = (name: string) => PlatformWorkerHandle; +export type WorkerFactoryRegistry = Record; + +/** + * Unified worker wrapper that works on both Node.js and browser environments. + * + * In Node.js, it wraps a `worker_threads.Worker` (event emitter API). + * In the browser, it wraps a Web Worker (DOM event target API). + * + * Platform differences (message unwrapping, error subscription, terminate + * semantics) are detected once at module load via `isNode` and handled + * transparently. + * + * Receives an already-constructed Worker instance to avoid importing + * WorkerFactories (which would create a circular dependency through + * worker scripts that transitively import this module). + */ +export default class PlatformWorker extends BaseWorker { + #worker: PlatformWorkerHandle | null = null; + + constructor(name: string, worker: PlatformWorkerHandle) { + super(name); + + this.#worker = worker; + this.#setupErrorHandler(); + } + + #setupErrorHandler() { + const worker = this.#worker; + + if (worker === null) { + return; + } + + if (isNode) { + (worker as NodeLikeWorker).on('error', (error) => { + console.error(`PlatformWorker ${this.name} error: ${error.message}`); + + void this.shutdown(); + + Host.HandleCrash(error); + }); + } else { + (worker as BrowserLikeWorker).addEventListener('error', (error) => { + const detail = error.message || error.filename || '(no details)'; + + console.error(`PlatformWorker ${this.name} error: ${detail}`, error); + + void this.shutdown(); + + Host.HandleCrash(error); + }); + } + } + + addOnMessageListener(listener: WorkerMessageListener) { + const worker = this.#worker; + + if (worker === null) { + return; + } + + if (isNode) { + (worker as NodeLikeWorker).on('message', (data) => { + listener(data); + }); + } else { + (worker as BrowserLikeWorker).addEventListener('message', (event) => { + listener(event.data); + }); + } + } + + postMessage(message: unknown) { + this.#worker?.postMessage(message); + } + + async shutdown() { + for (const listener of this._shutdownListeners) { + listener(); + } + + this._shutdownListeners.length = 0; + + const worker = this.#worker; + this.#worker = null; + + if (worker === null) { + return; + } + + await worker.terminate(); + } +} diff --git a/source/engine/common/Sys.ts b/source/engine/common/Sys.ts index ccc8f8ed..7c02abaf 100644 --- a/source/engine/common/Sys.ts +++ b/source/engine/common/Sys.ts @@ -1,10 +1,10 @@ import { NotImplementedError } from './Errors.ts'; /** Message listener callback for worker communication. */ -type WorkerMessageListener = (message: unknown) => void; +export type WorkerMessageListener = (message: unknown) => void; /** Shutdown listener callback. */ -type WorkerShutdownListener = () => void; +export type WorkerShutdownListener = () => void; /** * Abstract base class for platform-specific worker implementations. diff --git a/source/engine/common/WorkerFactories.mjs b/source/engine/common/WorkerFactories.ts similarity index 76% rename from source/engine/common/WorkerFactories.mjs rename to source/engine/common/WorkerFactories.ts index e9656fb7..645f1ade 100644 --- a/source/engine/common/WorkerFactories.mjs +++ b/source/engine/common/WorkerFactories.ts @@ -1,8 +1,9 @@ +import type { WorkerFactoryRegistry } from './PlatformWorker.ts'; + /** * Add new worker scripts here when creating additional workers. - * @type {Record Worker>} */ -const workerFactories = { +const workerFactories: WorkerFactoryRegistry = { 'server/DummyWorker.mjs': (name) => new Worker(new URL('../server/DummyWorker.mjs', import.meta.url), { name, type: 'module' }), 'server/NavigationWorker.mjs': (name) => new Worker(new URL('../server/NavigationWorker.mjs', import.meta.url), { name, type: 'module' }), }; diff --git a/source/engine/common/WorkerFramework.mjs b/source/engine/common/WorkerFramework.mjs deleted file mode 100644 index 30e80c84..00000000 --- a/source/engine/common/WorkerFramework.mjs +++ /dev/null @@ -1,118 +0,0 @@ -import { eventBus, registry } from '../registry.mjs'; -import Mod from './Mod.mjs'; -import Sys from './Sys.ts'; -import COM from './Com.ts'; - -class WorkerConsole { - static Print(message) { - WorkerFramework.Publish('worker.con.print', message); - } - - static PrintError(message) { - WorkerFramework.Publish('worker.con.print.error', message); - } - - static PrintWarning(message) { - WorkerFramework.Publish('worker.con.print.warning', message); - } - - static PrintSuccess(message) { - WorkerFramework.Publish('worker.con.print.success', message); - } - - static DPrint(message) { - WorkerFramework.Publish('worker.con.dprint', message); - } -} - -class WorkerSys extends Sys { - static Print(message) { - console.info(message); - } - - static FloatTime() { - return Date.now() / 1000; - } -} - -class WorkerCOM extends COM { - // TODO: implement the COM stuff here for workers to share files etc. -} - -/** - * Worker Framework - * - * Initializes the worker framework, setting up the registry and event bus. - * Listens for messages from the parent thread and publishes them to the event bus. - * - * Also prepares lean versions of Con, Sys, and COM for use within the worker. - * - * Usage: `await WorkerFramework.Init();` at the top of the worker script. - */ -export default class WorkerFramework { - static port = null; - - static #InitRegistry(COM) { - registry.isDedicatedServer = true; - registry.isInsideWorker = true; - registry.Con = WorkerConsole; - registry.Sys = WorkerSys; - registry.COM = COM; - registry.Mod = Mod; - - registry.urls = {}; // will be set later - - eventBus.publish('registry.frozen'); - } - - static #InitModules() { - Mod.Init(); - } - - static async Init() { - let COM; - - const isNode = typeof process !== 'undefined' && process.versions != null && process.versions.node != null; - - if (isNode) { - // Paths constructed at runtime so Vite's worker bundler cannot - // statically resolve them (it ignores @vite-ignore in its - // separate Rollup pass). These modules are Node.js-only. - const workerThreadsId = ['node', 'worker_threads'].join(':'); - const { parentPort } = await import(/* @vite-ignore */ workerThreadsId); - this.port = parentPort; - const serverComId = ['..', 'server', 'Com.mjs'].join('/'); - const comModule = await import(/* @vite-ignore */ serverComId); - COM = comModule.default; - - this.port.on('message', ({ event, args }) => { - eventBus.publish(event, ...args); - }); - } else { - this.port = self; - COM = WorkerCOM; - - this.port.addEventListener('message', (e) => { - const { event, args } = e.data; - eventBus.publish(event, ...args); - }); - } - - this.#InitRegistry(COM); - this.#InitModules(); - - eventBus.subscribe('worker.framework.init', (comParams, urls) => { - COM.searchpaths = comParams[0]; - COM.gamedir = comParams[1]; - COM.game = comParams[2]; - - Object.assign(registry.urls, urls); - }); - - console.debug('Worker Framework initialized.'); - } - - static Publish(event, ...data) { - this.port.postMessage({ event, data }); - } -}; diff --git a/source/engine/common/WorkerFramework.ts b/source/engine/common/WorkerFramework.ts new file mode 100644 index 00000000..e945b742 --- /dev/null +++ b/source/engine/common/WorkerFramework.ts @@ -0,0 +1,140 @@ +import type { URLs } from '../build-config'; + +import { eventBus, registry } from '../registry.mjs'; +import Mod from './Mod.mjs'; +import Sys from './Sys.ts'; +import COM, { type SearchPath } from './Com.ts'; + +type WorkerConsoleMessage = string; + +type WorkerPortMessage = { + readonly event: string; + readonly args: unknown[]; +}; + +type WorkerPublishMessage = { + readonly event: string; + readonly data: unknown[]; +}; + +type WorkerFrameworkPort = { + postMessage(message: WorkerPublishMessage): void; + addEventListener?(event: 'message', listener: (event: MessageEvent) => void): void; + on?(event: 'message', listener: (message: WorkerPortMessage) => void): void; +}; + +type WorkerFrameworkInitPayload = [SearchPath[], SearchPath[] | null, string]; + +class WorkerConsole { + static Print(message: WorkerConsoleMessage) { + WorkerFramework.Publish('worker.con.print', message); + } + + static PrintError(message: WorkerConsoleMessage) { + WorkerFramework.Publish('worker.con.print.error', message); + } + + static PrintWarning(message: WorkerConsoleMessage) { + WorkerFramework.Publish('worker.con.print.warning', message); + } + + static PrintSuccess(message: WorkerConsoleMessage) { + WorkerFramework.Publish('worker.con.print.success', message); + } + + static DPrint(message: WorkerConsoleMessage) { + WorkerFramework.Publish('worker.con.dprint', message); + } +} + +class WorkerSys extends Sys { + static Print(message: string) { + console.info(message); + } + + static FloatTime(): number { + return Date.now() / 1000; + } +} + +class WorkerCOM extends COM { + // TODO: implement the COM stuff here for workers to share files etc. +} + +/** + * Worker Framework + * + * Initializes the worker framework, setting up the registry and event bus. + * Listens for messages from the parent thread and publishes them to the event bus. + * + * Also prepares lean versions of Con, Sys, and COM for use within the worker. + * + * Usage: `await WorkerFramework.Init();` at the top of the worker script. + */ +export default class WorkerFramework { + static port: WorkerFrameworkPort | null = null; + + static #InitRegistry(workerCom: typeof COM) { + registry.isDedicatedServer = true; + registry.isInsideWorker = true; + registry.Con = WorkerConsole as typeof registry.Con; + registry.Sys = WorkerSys; + registry.COM = workerCom; + registry.Mod = Mod; + + registry.urls = {} as URLs; // will be set later + + eventBus.publish('registry.frozen'); + } + + static #InitModules() { + Mod.Init(); + } + + static async Init() { + let workerCom: typeof COM; + + const isNode = typeof process !== 'undefined' && process.versions !== undefined && process.versions.node !== undefined; + + if (isNode) { + // Paths constructed at runtime so Vite's worker bundler cannot + // statically resolve them (it ignores @vite-ignore in its + // separate Rollup pass). These modules are Node.js-only. + const workerThreadsId = ['node', 'worker_threads'].join(':'); + const { parentPort } = await import(/* @vite-ignore */ workerThreadsId); + this.port = parentPort as WorkerFrameworkPort; + const serverComId = ['..', 'server', 'Com.mjs'].join('/'); + const comModule = await import(/* @vite-ignore */ serverComId); + workerCom = comModule.default as typeof COM; + + this.port.on?.('message', ({ event, args }) => { + eventBus.publish(event, ...args); + }); + } else { + this.port = self as unknown as WorkerFrameworkPort; + workerCom = WorkerCOM; + + this.port.addEventListener?.('message', (event) => { + const { event: eventName, args } = event.data; + eventBus.publish(eventName, ...args); + }); + } + + this.#InitRegistry(workerCom); + this.#InitModules(); + + eventBus.subscribe('worker.framework.init', (comParams: WorkerFrameworkInitPayload, urls: URLs | undefined) => { + workerCom.searchpaths = comParams[0]; + workerCom.gamedir = comParams[1]; + workerCom.game = comParams[2]; + + Object.assign(registry.urls ?? (registry.urls = {} as URLs), urls ?? {}); + }); + + console.debug('Worker Framework initialized.'); + } + + static Publish(event: string, ...data: unknown[]) { + this.port?.postMessage({ event, data }); + } +} diff --git a/source/engine/common/WorkerManager.mjs b/source/engine/common/WorkerManager.ts similarity index 58% rename from source/engine/common/WorkerManager.mjs rename to source/engine/common/WorkerManager.ts index 9c060388..768e9c3b 100644 --- a/source/engine/common/WorkerManager.mjs +++ b/source/engine/common/WorkerManager.ts @@ -1,28 +1,35 @@ -import { registry, eventBus } from '../registry.mjs'; +import { registry, eventBus, getCommonRegistry } from '../registry.mjs'; import { SysError } from './Errors.ts'; -import PlatformWorker from './PlatformWorker.mjs'; +import PlatformWorker, { type WorkerFactoryRegistry, type WorkerMessageEnvelope } from './PlatformWorker.ts'; -let { Con, COM } = registry; +let { Con, COM } = getCommonRegistry(); eventBus.subscribe('registry.frozen', () => { - COM = registry.COM; - Con = registry.Con; + ({ COM, Con } = getCommonRegistry()); }); +type WorkerFrameworkInitArgs = [ + [typeof COM.searchpaths, typeof COM.gamedir, typeof COM.game], + typeof registry.urls, +]; + +type WorkerOutboundEnvelope = { + readonly event: string; + readonly args: unknown[]; +}; + export default class WorkerManager { - /** @type {Record Worker>} */ - static #factories = null; + static #factories: WorkerFactoryRegistry | null = null; /** * Initializes the worker manager with the worker factory registry. * * Factories are passed in at runtime (rather than statically imported) * to avoid a circular module dependency: worker scripts transitively - * import WorkerManager via Navigation.mjs, and WorkerFactories.mjs + * import WorkerManager via Navigation.mjs, and WorkerFactories.ts * references those same worker scripts. - * @param {Record Worker>} factories worker factory map from WorkerFactories.mjs */ - static Init(factories) { + static Init(factories: WorkerFactoryRegistry) { WorkerManager.#factories = factories; // eventBus.subscribe('com.ready', () => { // console.info('WorkerManager: Spawning dummy worker for initialization test.'); @@ -43,47 +50,52 @@ export default class WorkerManager { /** * Spawns a worker thread and sets up event forwarding. - * @param {string} script Path to worker script (must be registered in WorkerFactories.mjs) - * @param {string[]} events list of events the worker wants to subscribe to - * @returns {PlatformWorker} worker thread wrapper + * @returns worker thread wrapper */ - static SpawnWorker(script, events) { - const factory = WorkerManager.#factories[script]; + static SpawnWorker(script: string, events: string[]): PlatformWorker { + const factory = WorkerManager.#factories?.[script]; - console.assert(factory, `No worker factory found for script "${script}". Make sure it's registered in WorkerFactories.mjs.`); + console.assert(factory, `No worker factory found for script "${script}". Make sure it's registered in WorkerFactories.ts.`); + + if (factory === undefined) { + throw new SysError(`Worker ${script}: no registered factory`); + } let rawWorker; try { rawWorker = factory(script); - } catch (e) { - console.error(`WorkerManager: failed to create worker "${script}":`, e); - throw new SysError(`Worker ${script}: failed to construct: ${e.message}`); + } catch (error) { + console.error(`WorkerManager: failed to create worker "${script}":`, error); + const message = error instanceof Error ? error.message : String(error); + throw new SysError(`Worker ${script}: failed to construct: ${message}`); } const worker = new PlatformWorker(script, rawWorker); // worker thread --> main thread - worker.addOnMessageListener(({ event, data }) => { + worker.addOnMessageListener((message: unknown) => { + const { event, data = [] } = message as WorkerMessageEnvelope; + // Handle special events directly, otherwise publish to event bus switch (event) { case 'worker.con.print': - Con.Print(data[0]); + Con.Print(String(data[0] ?? '')); break; case 'worker.con.print.success': - Con.PrintSuccess(data[0]); + Con.PrintSuccess(String(data[0] ?? '')); break; case 'worker.con.print.warning': - Con.PrintWarning(data[0]); + Con.PrintWarning(String(data[0] ?? '')); break; case 'worker.con.print.error': - Con.PrintError(data[0]); + Con.PrintError(String(data[0] ?? '')); break; case 'worker.con.dprint': - Con.DPrint(data[0]); + Con.DPrint(String(data[0] ?? '')); break; default: @@ -92,8 +104,7 @@ export default class WorkerManager { } }); - /** @type {Function[]} all subscribed events need to be unsubscribed once the worker finished */ - const unsubscribeFunctions = []; + const unsubscribeFunctions: Array<() => void> = []; // make sure all subscriptions are removed on shutdown worker.addOnShutdownListener(() => { @@ -106,24 +117,26 @@ export default class WorkerManager { // main thread --> worker thread for (const event of events) { - unsubscribeFunctions.push(eventBus.subscribe(event, (...args) => { - worker.postMessage({ + unsubscribeFunctions.push(eventBus.subscribe(event, (...args: unknown[]) => { + const payload: WorkerOutboundEnvelope = { event, args, - }); + }; + worker.postMessage(payload); })); } + const initArgs: WorkerFrameworkInitArgs = [ + [COM.searchpaths, COM.gamedir, COM.game], // COM + registry.urls, // urls + ]; + // tell the worker that it can initialize now worker.postMessage({ event: 'worker.framework.init', - args: [ - [COM.searchpaths, COM.gamedir, COM.game], // COM - registry.urls, // urls - ], + args: initArgs, }); return worker; } -}; - +} diff --git a/source/engine/main-dedicated.mjs b/source/engine/main-dedicated.mjs index 6d0b3ebf..924fc3bf 100644 --- a/source/engine/main-dedicated.mjs +++ b/source/engine/main-dedicated.mjs @@ -2,7 +2,7 @@ import { Worker } from 'node:worker_threads'; import { registry, freeze as registryFreeze } from './registry.mjs'; -// Polyfill Worker global for Node.js so that WorkerFactories.mjs +// Polyfill Worker global for Node.js so that WorkerFactories.ts // (which uses the browser-compatible `new Worker(url)` pattern for // Vite static analysis) works identically in unbundled Node.js. // @ts-ignore — Node.js worker_threads.Worker is API-compatible at runtime diff --git a/source/engine/server/DummyWorker.mjs b/source/engine/server/DummyWorker.mjs index 8ac222d0..4c90b5a0 100644 --- a/source/engine/server/DummyWorker.mjs +++ b/source/engine/server/DummyWorker.mjs @@ -1,4 +1,4 @@ -import WorkerFramework from '../common/WorkerFramework.mjs'; +import WorkerFramework from '../common/WorkerFramework.ts'; import { eventBus, registry } from '../registry.mjs'; await WorkerFramework.Init(); diff --git a/source/engine/server/Navigation.mjs b/source/engine/server/Navigation.mjs index d3d9af6d..4adf5f4a 100644 --- a/source/engine/server/Navigation.mjs +++ b/source/engine/server/Navigation.mjs @@ -10,8 +10,8 @@ import { ServerEngineAPI } from '../common/GameAPIs.mjs'; import { BrushModel } from '../common/Mod.mjs'; import { MIN_STEP_NORMAL, STEPSIZE } from '../common/Pmove.mjs'; import { Face } from '../common/model/BaseModel.mjs'; -import PlatformWorker from '../common/PlatformWorker.mjs'; -import WorkerManager from '../common/WorkerManager.mjs'; +import PlatformWorker from '../common/PlatformWorker.ts'; +import WorkerManager from '../common/WorkerManager.ts'; import { eventBus, registry } from '../registry.mjs'; import { ServerEdict } from './Edict.mjs'; diff --git a/source/engine/server/NavigationWorker.mjs b/source/engine/server/NavigationWorker.mjs index e7c036ec..d169d678 100644 --- a/source/engine/server/NavigationWorker.mjs +++ b/source/engine/server/NavigationWorker.mjs @@ -1,4 +1,4 @@ -import WorkerFramework from '../common/WorkerFramework.mjs'; +import WorkerFramework from '../common/WorkerFramework.ts'; import { eventBus, registry } from '../registry.mjs'; import { Navigation, NavMeshOutOfDateException } from './Navigation.mjs'; diff --git a/source/engine/server/Sys.mjs b/source/engine/server/Sys.mjs index 5c4e1618..36cdf2fa 100644 --- a/source/engine/server/Sys.mjs +++ b/source/engine/server/Sys.mjs @@ -12,8 +12,8 @@ import Cvar from '../common/Cvar.ts'; /** @typedef {import('node:repl').REPLServer} REPLServer */ import Cmd from '../common/Cmd.ts'; import Q from '../../shared/Q.ts'; -import WorkerManager from '../common/WorkerManager.mjs'; -import workerFactories from '../common/WorkerFactories.mjs'; +import WorkerManager from '../common/WorkerManager.ts'; +import workerFactories from '../common/WorkerFactories.ts'; let { COM, Host, NET } = registry; diff --git a/test/common/workers.test.mjs b/test/common/workers.test.mjs new file mode 100644 index 00000000..176ce25b --- /dev/null +++ b/test/common/workers.test.mjs @@ -0,0 +1,194 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import PlatformWorker from '../../source/engine/common/PlatformWorker.ts'; +import WorkerManager from '../../source/engine/common/WorkerManager.ts'; +import { eventBus, registry } from '../../source/engine/registry.mjs'; + +class FakeNodeWorker { + constructor() { + this.listeners = { + error: [], + message: [], + }; + this.messages = []; + this.terminated = 0; + } + + on(event, listener) { + this.listeners[event].push(listener); + } + + postMessage(message) { + this.messages.push(message); + } + + terminate() { + this.terminated += 1; + return Promise.resolve(0); + } + + emit(event, payload) { + for (const listener of this.listeners[event]) { + listener(payload); + } + } +} + +/** @returns {{ prints: string[], successes: string[], warnings: string[], errors: string[], dprints: string[], Print: (message: string) => void, PrintSuccess: (message: string) => void, PrintWarning: (message: string) => void, PrintError: (message: string) => void, DPrint: (message: string) => void }} console capture */ +function createConsoleCapture() { + return { + prints: [], + successes: [], + warnings: [], + errors: [], + dprints: [], + Print(message) { + this.prints.push(message); + }, + PrintSuccess(message) { + this.successes.push(message); + }, + PrintWarning(message) { + this.warnings.push(message); + }, + PrintError(message) { + this.errors.push(message); + }, + DPrint(message) { + this.dprints.push(message); + }, + }; +} + +/** + * Run with a minimal worker-capable registry. + * @param {ReturnType} consoleCapture + * @param {() => void | Promise} callback + */ +async function withWorkerRegistry(consoleCapture, callback) { + const previousCon = registry.Con; + const previousCom = registry.COM; + const previousHost = registry.Host; + const previousUrls = registry.urls; + const crashes = []; + + Object.assign(registry, { + Con: /** @type {typeof import('../../source/engine/common/Console.ts').default} */ (/** @type {unknown} */ (consoleCapture)), + COM: /** @type {typeof import('../../source/engine/common/Com.ts').default} */ ({ + searchpaths: [{ filename: 'id1', pack: [] }], + gamedir: [{ filename: 'id1', pack: [] }], + game: 'id1', + }), + Host: /** @type {typeof import('../../source/engine/common/Host.mjs').default} */ (/** @type {unknown} */ ({ + crashes, + HandleCrash(error) { + crashes.push(error); + }, + })), + urls: /** @type {import('../../source/engine/build-config').URLs} */ ({ cdnURL: 'https://cdn.example/{gameDir}/{filename}' }), + }); + + eventBus.publish('registry.frozen'); + + try { + await callback(); + } finally { + Object.assign(registry, { + Con: previousCon, + COM: previousCom, + Host: previousHost, + urls: previousUrls, + }); + eventBus.publish('registry.frozen'); + } +} + +void describe('PlatformWorker', () => { + void test('forwards messages and runs shutdown listeners once on terminate', async () => { + const consoleCapture = createConsoleCapture(); + + await withWorkerRegistry(consoleCapture, async () => { + const rawWorker = new FakeNodeWorker(); + const worker = new PlatformWorker('server/DummyWorker.mjs', rawWorker); + const messages = []; + let shutdownCalls = 0; + + worker.addOnMessageListener((message) => { + messages.push(message); + }); + worker.addOnShutdownListener(() => { + shutdownCalls += 1; + }); + + rawWorker.emit('message', { event: 'nav.build', args: [] }); + worker.postMessage({ event: 'nav.load', args: ['e1m1'] }); + await worker.shutdown(); + + assert.deepEqual(messages, [{ event: 'nav.build', args: [] }]); + assert.deepEqual(rawWorker.messages, [{ event: 'nav.load', args: ['e1m1'] }]); + assert.equal(shutdownCalls, 1); + assert.equal(rawWorker.terminated, 1); + }); + }); + + void test('routes worker errors through Host.HandleCrash', async () => { + const consoleCapture = createConsoleCapture(); + + await withWorkerRegistry(consoleCapture, async () => { + const rawWorker = new FakeNodeWorker(); + const worker = new PlatformWorker('server/DummyWorker.mjs', rawWorker); + const error = new Error('worker boom'); + + rawWorker.emit('error', error); + + const mockedHost = /** @type {{ crashes: Error[] }} */ (/** @type {unknown} */ (registry.Host)); + assert.deepEqual(mockedHost.crashes, [error]); + await worker.shutdown(); + }); + }); +}); + +void describe('WorkerManager', () => { + void test('subscribes worker events, forwards console messages, and sends framework init payload', async () => { + const consoleCapture = createConsoleCapture(); + + await withWorkerRegistry(consoleCapture, async () => { + const rawWorker = new FakeNodeWorker(); + + WorkerManager.Init({ + 'server/DummyWorker.mjs': () => rawWorker, + }); + + const worker = WorkerManager.SpawnWorker('server/DummyWorker.mjs', ['nav.build']); + + assert.deepEqual(rawWorker.messages[0], { + event: 'worker.framework.init', + args: [ + [registry.COM.searchpaths, registry.COM.gamedir, registry.COM.game], + registry.urls, + ], + }); + + eventBus.publish('nav.build', 'arg1', 2); + assert.deepEqual(rawWorker.messages[1], { + event: 'nav.build', + args: ['arg1', 2], + }); + + const navResponses = []; + const unsubscribe = eventBus.subscribe('nav.path.response', (...args) => { + navResponses.push(args); + }); + + rawWorker.emit('message', { event: 'worker.con.print.warning', data: ['from worker\n'] }); + rawWorker.emit('message', { event: 'nav.path.response', data: [8, ['c']] }); + + unsubscribe(); + await worker.shutdown(); + + assert.deepEqual(consoleCapture.warnings, ['from worker\n']); + assert.deepEqual(navResponses, [[8, ['c']]]); + }); + }); +}); From 15c6831cf881c346af88cce0e4acaf5ee8fa1593 Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 19:01:13 +0300 Subject: [PATCH 22/67] TS: common/Pmove --- source/engine/client/CL.mjs | 4 +- source/engine/client/ClientLifecycle.mjs | 6 +- source/engine/client/ClientMessages.mjs | 2 +- source/engine/common/Host.mjs | 2 +- source/engine/common/{Pmove.mjs => Pmove.ts} | 1034 ++++++++--------- source/engine/server/Navigation.mjs | 6 +- source/engine/server/Server.mjs | 6 +- .../server/physics/ServerClientPhysics.mjs | 2 +- .../engine/server/physics/ServerCollision.mjs | 6 +- .../server/physics/ServerCollisionSupport.mjs | 2 +- .../physics/ServerLegacyHullCollision.mjs | 2 +- .../engine/server/physics/ServerMovement.mjs | 2 +- test/physics/brushtrace.test.mjs | 2 +- test/physics/collision-regressions.test.mjs | 2 +- test/physics/map-pmove-harness.mjs | 2 +- test/physics/pmove.test.mjs | 2 +- test/physics/server-collision.test.mjs | 2 +- 17 files changed, 537 insertions(+), 547 deletions(-) rename source/engine/common/{Pmove.mjs => Pmove.ts} (81%) diff --git a/source/engine/client/CL.mjs b/source/engine/client/CL.mjs index 87010c84..517e3ec8 100644 --- a/source/engine/client/CL.mjs +++ b/source/engine/client/CL.mjs @@ -3,7 +3,7 @@ import * as Def from '../common/Def.ts'; import * as Protocol from '../network/Protocol.ts'; import Cmd, { ConsoleCommand } from '../common/Cmd.ts'; import Cvar from '../common/Cvar.ts'; -import { Pmove, PmovePlayer } from '../common/Pmove.mjs'; +import { Pmove, PmovePlayer } from '../common/Pmove.ts'; import { eventBus, registry } from '../registry.mjs'; import { gameCapabilities, solid } from '../../shared/Defs.ts'; import ClientDemos from './ClientDemos.mjs'; @@ -209,7 +209,7 @@ export default class CL { return; } - Cmd.ExecuteString('map ' + map); + void Cmd.ExecuteString('map ' + map); CL.StartRecording(demoname, Q.atoi(track)); } diff --git a/source/engine/client/ClientLifecycle.mjs b/source/engine/client/ClientLifecycle.mjs index 78324166..b68fa5a9 100644 --- a/source/engine/client/ClientLifecycle.mjs +++ b/source/engine/client/ClientLifecycle.mjs @@ -5,7 +5,7 @@ import { gameCapabilities } from '../../shared/Defs.ts'; import ClientInput from './ClientInput.mjs'; import CL from './CL.mjs'; import { clientRuntimeState } from './ClientState.mjs'; -import { MoveVars, Pmove } from '../common/Pmove.mjs'; +import { MoveVars, Pmove } from '../common/Pmove.ts'; import { ClientEngineAPI } from '../common/GameAPIs.mjs'; import { eventBus, registry } from '../registry.mjs'; @@ -30,11 +30,11 @@ export class StartGameInterface { /** Quake 1 default start game entries */ export class DefaultStartGameFunctions extends StartGameInterface { startSingleplayerGame() { - Cmd.ExecuteString('map start'); + void Cmd.ExecuteString('map start'); } startMultiplayerGame(/** @type {string} */ mapname) { - Cmd.ExecuteString(`map ${mapname}`); + void Cmd.ExecuteString(`map ${mapname}`); } }; diff --git a/source/engine/client/ClientMessages.mjs b/source/engine/client/ClientMessages.mjs index 8321f182..0811a0e8 100644 --- a/source/engine/client/ClientMessages.mjs +++ b/source/engine/client/ClientMessages.mjs @@ -3,7 +3,7 @@ import * as Def from '../common/Def.ts'; import { eventBus, registry } from '../registry.mjs'; import { HostError } from '../common/Errors.ts'; import Vector from '../../shared/Vector.ts'; -import { PmovePlayer } from '../common/Pmove.mjs'; +import { PmovePlayer } from '../common/Pmove.ts'; import { gameCapabilities } from '../../shared/Defs.ts'; import { ClientEdict } from './ClientEntities.mjs'; diff --git a/source/engine/common/Host.mjs b/source/engine/common/Host.mjs index e1e76688..8c2da13c 100644 --- a/source/engine/common/Host.mjs +++ b/source/engine/common/Host.mjs @@ -14,7 +14,7 @@ import CDAudio from '../client/CDAudio.mjs'; import * as Defs from '../../shared/Defs.ts'; import { content, gameCapabilities } from '../../shared/Defs.ts'; import ClientLifecycle from '../client/ClientLifecycle.mjs'; -import { Pmove } from './Pmove.mjs'; +import { Pmove } from './Pmove.ts'; const Host = {}; diff --git a/source/engine/common/Pmove.mjs b/source/engine/common/Pmove.ts similarity index 81% rename from source/engine/common/Pmove.mjs rename to source/engine/common/Pmove.ts index d250890e..ab3ccc24 100644 --- a/source/engine/common/Pmove.mjs +++ b/source/engine/common/Pmove.ts @@ -7,6 +7,8 @@ * Original sources: pmove.c, pmovetst.c (Q2), pmove.c (Q1). */ +/* eslint-disable jsdoc/require-returns */ + import Vector from '../../shared/Vector.ts'; import * as Protocol from '../network/Protocol.ts'; import { content } from '../../shared/Defs.ts'; @@ -14,12 +16,26 @@ import { BrushModel } from './Mod.mjs'; import Cvar from './Cvar.ts'; import { PmoveConfiguration } from '../../shared/Pmove.ts'; -/** @typedef {import('../../shared/Vector.ts').DirectionalVectors} DirectionalVectors */ -/** @typedef {{ normal: Vector, type: number }} BrushTracePlaneLike */ - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- +type DirectionalVectors = import('../../shared/Vector.ts').DirectionalVectors; +interface BrushTracePlaneLike { + readonly normal: Vector; + readonly type: number; +} +type BrushTraceTransform = { readonly origin: Vector; readonly basis: number[] | null } | null; +interface NearbyBrushSideSummary { + readonly distance: number; + readonly summary: string; +} +interface NearbyBrushCandidate { + readonly index: number; + readonly contents: number; + readonly numsides: number; + readonly nearestPlaneDistance: number; + touchingPlanes: number; + readonly mins: Vector; + readonly maxs: Vector; + sideSummaries: string[]; +} export const DIST_EPSILON = 0.03125; export const STOP_EPSILON = 0.1; @@ -37,39 +53,35 @@ export const ZERO_PROGRESS_DUPLICATE_DOT = 0.985; /** * Player movement flags (pmove-specific, separate from entity flags). * These travel with the player state and are used for prediction. - * @readonly - * @enum {number} */ -export const PMF = Object.freeze({ +export enum PMF { /** Player is ducked */ - DUCKED: (1 << 0), + DUCKED = (1 << 0), /** Player has jump button held (prevent re-jump) */ - JUMP_HELD: (1 << 1), + JUMP_HELD = (1 << 1), /** Player is on the ground */ - ON_GROUND: (1 << 2), + ON_GROUND = (1 << 2), /** Timing: landing cooldown (prevents immediate re-jump after hard landing) */ - TIME_LAND: (1 << 3), + TIME_LAND = (1 << 3), /** Timing: water jump is active */ - TIME_WATERJUMP: (1 << 4), + TIME_WATERJUMP = (1 << 4), /** Timing: teleport freeze */ - TIME_TELEPORT: (1 << 5), -}); + TIME_TELEPORT = (1 << 5), +} /** * Player movement types. - * @readonly - * @enum {number} */ -export const PM_TYPE = Object.freeze({ +export enum PM_TYPE { /** Normal movement */ - NORMAL: 0, + NORMAL = 0, /** Spectator - noclip flight */ - SPECTATOR: 1, + SPECTATOR = 1, /** Dead – reduced input, extra friction */ - DEAD: 2, + DEAD = 2, /** Frozen – no movement at all */ - FREEZE: 3, -}); + FREEZE = 3, +} // --------------------------------------------------------------------------- // MoveVars: shared physics tuning knobs @@ -81,77 +93,107 @@ export const PM_TYPE = Object.freeze({ * Physics tuning knobs shared between client and server. */ export class MoveVars { // movevars_t + /** World gravity strength. */ + gravity: number; + /** Minimum speed preserved before friction stops the player. */ + stopspeed: number; + /** Default maximum ground speed. */ + maxspeed: number; + /** Maximum speed while in spectator flight. */ + spectatormaxspeed: number; + /** Maximum speed while ducked. */ + duckspeed: number; + /** Ground acceleration factor. */ + accelerate: number; + /** Air acceleration factor. */ + airaccelerate: number; + /** Water acceleration factor. */ + wateraccelerate: number; + /** Ground friction multiplier. */ + friction: number; + /** Water friction multiplier. */ + waterfriction: number; + /** Base swimming speed. */ + waterspeed: number; + /** Per-entity gravity scale. */ + entgravity: number; + /** Extra friction applied near ledges. */ + edgefriction: number; + constructor() { - /** @type {number} world gravity (units/sec²) */ this.gravity = 800; - /** @type {number} speed below which friction acts at full strength */ this.stopspeed = 100; - /** @type {number} maximum walking speed */ this.maxspeed = 320; // Q2: 300 - /** @type {number} maximum spectator speed */ this.spectatormaxspeed = 500; - /** @type {number} duck speed cap */ this.duckspeed = 100; - /** @type {number} ground acceleration factor */ this.accelerate = 10; - /** @type {number} air acceleration factor */ this.airaccelerate = 0.7; - /** @type {number} water acceleration factor */ this.wateraccelerate = 10; - /** @type {number} ground friction factor */ this.friction = 6; - /** @type {number} water friction factor */ this.waterfriction = 1; - /** @type {number} maximum water speed */ this.waterspeed = 400; - /** @type {number} per-entity gravity multiplier (1.0 = normal) */ this.entgravity = 1.0; - /** @type {number} edge friction multiplier */ this.edgefriction = 2; } -}; +} // --------------------------------------------------------------------------- // Geometry primitives: Plane, Trace, ClipNode, Hull, BoxHull // --------------------------------------------------------------------------- export class Plane { // mplane_t + /** Plane normal. */ + normal: Vector; + /** Signed distance from the origin. */ + dist: number; + /** Plane axis classification used by BSP tracing. */ + type: number; + /** Cached sign bits for fast box-plane tests. */ + signBits: number; + constructor() { this.normal = new Vector(); this.dist = 0; - /** @type {number} for texture axis selection and fast side tests */ this.type = 0; - /** @type {number} signx + signy<<1 + signz<<1 */ this.signBits = 0; } -}; +} export class Trace { // pmtrace_t + /** Whether the entire trace volume stayed inside solid. */ + allsolid: boolean; + /** Whether the start point began inside solid. */ + startsolid: boolean; + /** Completed fraction of the attempted move. */ + fraction: number; + /** Final position after clipping. */ + endpos: Vector; + /** Impact plane for the first blocking hit. */ + plane: Plane; + /** Physent index hit by the trace, or null when nothing was hit. */ + ent: number | null; + /** Whether the trace entered open space. */ + inopen: boolean; + /** Whether the trace entered water. */ + inwater: boolean; + constructor() { - /** if true, plane is not valid */ this.allsolid = true; - /** if true, the initial point was in a solid area */ this.startsolid = false; - /** moving along the vector completed, 1.0 = didn’t hit anything */ this.fraction = 1.0; - /** final position */ this.endpos = new Vector(); - /** surface normal at impact */ this.plane = new Plane(); - /** @type {?number} edict number the surface is on, if applicable */ this.ent = null; - /** true if the surface is in a open area */ this.inopen = false; - /** true if the surface is in water */ this.inwater = false; } /** * Sets this trace to the other trace. - * @param {Trace} other other trace - * @returns {Trace} this + * @param other - source trace to copy from + * @returns this trace after copying the values */ - set(other) { + set(other: Trace): Trace { console.assert(other instanceof Trace, 'other must be a Trace'); this.allsolid = other.allsolid; @@ -169,36 +211,58 @@ export class Trace { // pmtrace_t /** * Creates a copy. - * @returns {Trace} copy of this trace + * @returns duplicated trace state */ - copy() { + copy(): Trace { const trace = new Trace(); trace.set(this); return trace; } -}; +} export class ClipNode { // dclipnode_t + /** Plane index used by this BSP clip node. */ + readonly planeNum: number; + /** Child node indices or negative contents values. */ + readonly children: [number, number]; + constructor(planeNum = 0) { this.planeNum = planeNum; this.children = [0, 0]; } -}; +} export class Hull { // hull_t + /** Hull-space minimum bounds for this collision hull. */ + clipMins: Vector; + /** Hull-space maximum bounds for this collision hull. */ + clipMaxs: Vector; + /** First valid BSP clip node index. */ + firstClipNode: number; + /** Last valid BSP clip node index. */ + lastClipNode: number; + /** Optional mask restricting which clip nodes may be traversed. */ + allowedClipNodes: Uint8Array | null; + /** BSP clip nodes used for hull traversal. */ + clipNodes: ClipNode[]; + /** Plane list referenced by the clip nodes. */ + planes: Plane[]; + constructor() { this.clipMins = new Vector(); this.clipMaxs = new Vector(); this.firstClipNode = 0; this.lastClipNode = 0; - /** @type {Uint8Array|null} */ this.allowedClipNodes = null; - /** @type {ClipNode[]} */ this.clipNodes = []; - /** @type {Plane[]} */ this.planes = []; } + /** + * Clone collision data from a model hull into runtime Hull objects. + * @param hull - source hull from model loading + * @returns copied runtime hull + */ static fromModelHull(hull) { const newHull = new Hull(); newHull.clipMins = hull.clip_mins.copy(); @@ -226,11 +290,11 @@ export class Hull { // hull_t /** * Determine if a point is inside the hull and if so, return the content type. - * @param {Vector} point point to test - * @param {number} num clip node to start - * @returns {number} content type + * @param point - point to classify in hull space + * @param num - starting clip node index + * @returns negative Quake contents value for the containing leaf */ - pointContents(point, num = this.firstClipNode) { + pointContents(point: Vector, num: number = this.firstClipNode): number { // as long as num is a valid node, keep going down the tree while (num >= 0) { if (this.allowedClipNodes !== null && this.allowedClipNodes[num] !== 1) { @@ -262,21 +326,20 @@ export class Hull { // hull_t return num; } - /** @type {Vector[]} */ - static _midPool = Array.from({ length: 64 }, () => new Vector()); + static readonly _midPool: Vector[] = Array.from({ length: 64 }, () => new Vector()); /** * Check against hull. - * @param {number} p1f fraction at p1 (usually 0.0) - * @param {number} p2f fraction at p2 (usually 1.0) - * @param {Vector} p1 start point - * @param {Vector} p2 end point - * @param {Trace} trace object to store trace results - * @param {number} num starting clipnode number (typically hull.firstclipnode) - * @param {number} depth recursion depth - * @returns {boolean} true means going down, false means going up - */ - check(p1f, p2f, p1, p2, trace, num = this.firstClipNode, depth = 0) { + * @param p1f - starting fraction along the trace segment + * @param p2f - ending fraction along the trace segment + * @param p1 - start point in hull space + * @param p2 - end point in hull space + * @param trace - trace result being updated in place + * @param num - current clip node index + * @param depth - recursion depth for pooled midpoint scratch vectors + * @returns true while traversal should continue, false after a blocking hit + */ + check(p1f: number, p2f: number, p1: Vector, p2: Vector, trace: Trace, num: number = this.firstClipNode, depth: number = 0): boolean { // check for empty if (num < 0) { if (num !== content.CONTENT_SOLID && num !== content.CONTENT_SKY) { @@ -426,11 +489,12 @@ export class BoxHull extends Hull { } /** - * @param {Vector} mins mins - * @param {Vector} maxs maxs - * @returns {BoxHull} this + * Configure the reusable box hull to match an entity bounding box. + * @param mins - local minimum corner + * @param maxs - local maximum corner + * @returns this box hull for chaining */ - setSize(mins, maxs) { + setSize(mins: Vector, maxs: Vector): BoxHull { console.assert(mins instanceof Vector, 'mins must be a Vector'); console.assert(maxs instanceof Vector, 'maxs must be a Vector'); @@ -442,7 +506,7 @@ export class BoxHull extends Hull { return this; } -}; +} // --------------------------------------------------------------------------- // BrushTrace: Q2-style brush-based collision tracing @@ -464,31 +528,20 @@ export class BoxHull extends Hull { * traces for maps that include brush data. */ export class BrushTrace { - /** @type {number} monotonically increasing counter to avoid testing the same brush twice */ - static _checkCount = 0; + static _checkCount: number = 0; /** * Test whether two axis-aligned bounding boxes overlap. - * @param {Vector} mins1 first box minimum - * @param {Vector} maxs1 first box maximum - * @param {Vector} mins2 second box minimum - * @param {Vector} maxs2 second box maximum - * @returns {boolean} true when the boxes overlap or touch */ - static _boundsOverlap(mins1, maxs1, mins2, maxs2) { + static _boundsOverlap(mins1: Vector, maxs1: Vector, mins2: Vector, maxs2: Vector): boolean { return mins1[0] <= maxs2[0] && mins1[1] <= maxs2[1] && mins1[2] <= maxs2[2] && maxs1[0] >= mins2[0] && maxs1[1] >= mins2[1] && maxs1[2] >= mins2[2]; } /** * Compute the swept world-space bounds of a point or box move. - * @param {Vector} start trace start - * @param {Vector} end trace end - * @param {Vector} mins box mins - * @param {Vector} maxs box maxs - * @returns {{mins: Vector, maxs: Vector}} swept bounds */ - static _computeSweepBounds(start, end, mins, maxs) { + static _computeSweepBounds(start: Vector, end: Vector, mins: Vector, maxs: Vector): { mins: Vector; maxs: Vector } { return { mins: new Vector( Math.min(start[0] + mins[0], end[0] + mins[0]), @@ -505,12 +558,8 @@ export class BrushTrace { /** * Compute the world-space bounds of a point or box at a fixed position. - * @param {Vector} position position to test - * @param {Vector} mins box mins - * @param {Vector} maxs box maxs - * @returns {{mins: Vector, maxs: Vector}} world-space bounds */ - static _computePositionBounds(position, mins, maxs) { + static _computePositionBounds(position: Vector, mins: Vector, maxs: Vector): { mins: Vector; maxs: Vector } { return { mins: new Vector(position[0] + mins[0], position[1] + mins[1], position[2] + mins[2]), maxs: new Vector(position[0] + maxs[0], position[1] + maxs[1], position[2] + maxs[2]), @@ -519,11 +568,8 @@ export class BrushTrace { /** * Check whether a brush AABB can possibly overlap the current swept move. - * @param {BrushTraceContext} ctx trace context - * @param {import('./model/BSP.mjs').Brush} brush brush candidate - * @returns {boolean} true when the brush could affect the move */ - static _brushMayAffectTrace(ctx, brush) { + static _brushMayAffectTrace(ctx: BrushTraceContext, brush: import('./model/BSP.mjs').Brush): boolean { if (brush.mins === null || brush.mins === undefined || brush.maxs === null || brush.maxs === undefined) { return true; } @@ -533,12 +579,8 @@ export class BrushTrace { /** * Check whether a brush AABB can possibly overlap the current position test. - * @param {import('./model/BSP.mjs').Brush} brush brush candidate - * @param {Vector} boundsMins position-test bounds minimum - * @param {Vector} boundsMaxs position-test bounds maximum - * @returns {boolean} true when the brush could affect the test */ - static _brushMayAffectPosition(brush, boundsMins, boundsMaxs) { + static _brushMayAffectPosition(brush: import('./model/BSP.mjs').Brush, boundsMins: Vector, boundsMaxs: Vector): boolean { if (brush.mins === null || brush.mins === undefined || brush.maxs === null || brush.maxs === undefined) { return true; } @@ -550,11 +592,8 @@ export class BrushTrace { * Estimate the earliest global trace fraction where a swept point/box can * enter a node's bounds. Used only for pruning; false negatives are avoided * by falling back when bounds are missing. - * @param {BrushTraceContext} ctx trace context - * @param {import('./model/BSP.mjs').Node} node BSP node or leaf - * @returns {number} earliest possible entry fraction, or Infinity when unreachable */ - static _estimateNodeEntryFraction(ctx, node) { + static _estimateNodeEntryFraction(ctx: BrushTraceContext, node: import('./model/BSP.mjs').Node): number { if (node.mins === null || node.mins === undefined || node.maxs === null || node.maxs === undefined) { return 0; } @@ -597,11 +636,8 @@ export class BrushTrace { /** * Check whether a node can still affect the current trace. - * @param {BrushTraceContext} ctx trace context - * @param {import('./model/BSP.mjs').Node} node BSP node or leaf - * @returns {boolean} true when traversal should continue into the node */ - static _nodeMayAffectTrace(ctx, node) { + static _nodeMayAffectTrace(ctx: BrushTraceContext, node: import('./model/BSP.mjs').Node): boolean { if (node.mins === null || node.mins === undefined || node.maxs === null || node.maxs === undefined) { return true; } @@ -618,14 +654,8 @@ export class BrushTrace { * Walkable ramps need this to avoid horizontal climb stalls, and corner * slides need it so a real diagonal face can beat an inferred axial wall * from the same brush when both land within epsilon. - * @param {BrushTracePlaneLike|null} currentPlane currently selected clip plane - * @param {number} currentFraction currently selected enter fraction - * @param {BrushTracePlaneLike} candidatePlane newly intersected plane - * @param {number} candidateFraction newly intersected enter fraction - * @param {number} fractionEpsilon move-distance-scaled tie threshold - * @returns {boolean} true when the candidate plane should replace the current one - */ - static _shouldPreferClipPlane(currentPlane, currentFraction, candidatePlane, candidateFraction, fractionEpsilon) { + */ + static _shouldPreferClipPlane(currentPlane: BrushTracePlaneLike | null, currentFraction: number, candidatePlane: BrushTracePlaneLike, candidateFraction: number, fractionEpsilon: number): boolean { if (currentPlane === null) { return true; } @@ -648,14 +678,8 @@ export class BrushTrace { * Earlier trace hits win globally, but nearly simultaneous hits can still * benefit from plane preference. This lets a real non-axial face replace an * exact-tangent axial wall from another brush in the same leaf. - * @param {BrushTracePlaneLike|null} currentPlane currently selected trace plane - * @param {number} currentFraction currently selected trace fraction - * @param {BrushTracePlaneLike} candidatePlane newly intersected trace plane - * @param {number} candidateFraction newly intersected trace fraction - * @param {number} fractionEpsilon move-distance-scaled tie threshold - * @returns {boolean} true when the candidate trace hit should replace the current hit - */ - static _shouldPreferTraceHit(currentPlane, currentFraction, candidatePlane, candidateFraction, fractionEpsilon) { + */ + static _shouldPreferTraceHit(currentPlane: BrushTracePlaneLike | null, currentFraction: number, candidatePlane: BrushTracePlaneLike, candidateFraction: number, fractionEpsilon: number): boolean { if (currentPlane === null) { return true; } @@ -677,23 +701,21 @@ export class BrushTrace { /** * Resolve the head node for world-model BSP traversal. - * @param {BrushModel} model - brush model to inspect - * @returns {number} clipnode index used as the trace root */ - static _getHeadNode(model) { + static _getHeadNode(model: BrushModel): number { return model.hulls[0]?.firstclipnode ?? 0; } /** * Dispatch a brush trace against either a submodel brush range or a world BSP. - * @param {BrushModel} model - world model or submodel owning the brush data - * @param {Vector} start - local-space trace start - * @param {Vector} end - local-space trace end - * @param {Vector} mins - box mins - * @param {Vector} maxs - box maxs - * @returns {Trace} trace result - */ - static _traceModel(model, start, end, mins, maxs) { + * @param model - model to trace against + * @param start - world-space start point + * @param end - world-space end point + * @param mins - box minimum corner relative to the origin + * @param maxs - box maximum corner relative to the origin + * @returns trace result in model space or world space as appropriate + */ + static _traceModel(model: BrushModel, start: Vector, end: Vector, mins: Vector, maxs: Vector): Trace { return model.submodel ? BrushTrace.boxTraceModel(model, start, end, mins, maxs) : BrushTrace.boxTrace(model, BrushTrace._getHeadNode(model), start, end, mins, maxs); @@ -701,13 +723,13 @@ export class BrushTrace { /** * Dispatch a position test against either a submodel brush range or a world BSP. - * @param {BrushModel} model - world model or submodel owning the brush data - * @param {Vector} position - local-space position to test - * @param {Vector} mins - box mins - * @param {Vector} maxs - box maxs - * @returns {boolean} true if position is valid (not in solid) + * @param model - model to test against + * @param position - world-space position to test + * @param mins - box minimum corner relative to the origin + * @param maxs - box maximum corner relative to the origin + * @returns true when the position does not overlap solid brush geometry */ - static _testModelPosition(model, position, mins, maxs) { + static _testModelPosition(model: BrushModel, position: Vector, mins: Vector, maxs: Vector): boolean { return model.submodel ? BrushTrace.testPositionModel(model, position, mins, maxs) : BrushTrace.testPosition(model, BrushTrace._getHeadNode(model), position, mins, maxs); @@ -715,11 +737,11 @@ export class BrushTrace { /** * Resolve the entity transform used by shared brush collision helpers. - * @param {Vector} origin - entity origin - * @param {Vector} angles - entity angles - * @returns {{origin: Vector, basis: number[]|null}|null} transform context, or null when identity + * @param origin - entity origin in world space + * @param angles - entity rotation angles + * @returns cached transform data, or null when no transform is needed */ - static _getTransformContext(origin, angles) { + static _getTransformContext(origin: Vector, angles: Vector): BrushTraceTransform { const basis = angles.isOrigin() ? null : angles.toRotationMatrix(); if (basis === null && origin.isOrigin()) { @@ -731,11 +753,11 @@ export class BrushTrace { /** * Convert a world-space point into local model space for an entity transform. - * @param {Vector} point - world-space point - * @param {{origin: Vector, basis: number[]|null}|null} transform - entity transform context - * @returns {Vector} transformed local-space point + * @param point - point in world space + * @param transform - entity transform context + * @returns point in local model space */ - static _toLocalPoint(point, transform) { + static _toLocalPoint(point: Vector, transform: BrushTraceTransform): Vector { if (transform === null) { return point; } @@ -750,16 +772,16 @@ export class BrushTrace { /** * Trace a box against a brush model with entity transform applied. * Equivalent to Quake 2's transformed box trace helpers. - * @param {BrushModel} model - world model or submodel owning the brush data - * @param {Vector} start - world-space trace start - * @param {Vector} end - world-space trace end - * @param {Vector} mins - box mins - * @param {Vector} maxs - box maxs - * @param {Vector} origin - entity origin - * @param {Vector} angles - entity angles - * @returns {Trace} world-space trace result - */ - static transformedBoxTrace(model, start, end, mins, maxs, origin = Vector.origin, angles = Vector.origin) { + * @param model - brush model to trace against + * @param start - world-space start point + * @param end - world-space end point + * @param mins - box minimum corner relative to the origin + * @param maxs - box maximum corner relative to the origin + * @param origin - entity origin applied before tracing + * @param angles - entity rotation applied before tracing + * @returns world-space trace result + */ + static transformedBoxTrace(model: BrushModel, start: Vector, end: Vector, mins: Vector, maxs: Vector, origin: Vector = Vector.origin, angles: Vector = Vector.origin): Trace { const transform = BrushTrace._getTransformContext(origin, angles); if (transform === null) { @@ -777,15 +799,15 @@ export class BrushTrace { * Test if a box at the given world-space position overlaps a brush model * after applying entity translation and rotation. * Equivalent to Quake 2's transformed position test helpers. - * @param {BrushModel} model - world model or submodel owning the brush data - * @param {Vector} position - world-space position to test - * @param {Vector} mins - box mins - * @param {Vector} maxs - box maxs - * @param {Vector} origin - entity origin - * @param {Vector} angles - entity angles - * @returns {boolean} true if position is valid (not in solid) - */ - static transformedTestPosition(model, position, mins, maxs, origin = Vector.origin, angles = Vector.origin) { + * @param model - brush model to test against + * @param position - world-space position to test + * @param mins - box minimum corner relative to the origin + * @param maxs - box maximum corner relative to the origin + * @param origin - entity origin applied before testing + * @param angles - entity rotation applied before testing + * @returns true when the transformed box does not overlap solid brush geometry + */ + static transformedTestPosition(model: BrushModel, position: Vector, mins: Vector, maxs: Vector, origin: Vector = Vector.origin, angles: Vector = Vector.origin): boolean { const transform = BrushTrace._getTransformContext(origin, angles); if (transform === null) { @@ -800,15 +822,15 @@ export class BrushTrace { /** * Trace a box from start to end through the BSP tree, testing individual brushes. * Equivalent to Q2’s CM_BoxTrace. - * @param {BrushModel} worldModel - world model owning the BSP data - * @param {number} headNode - BSP node index to start traversal from - * @param {Vector} start - start position - * @param {Vector} end - end position - * @param {Vector} mins - box mins (typically PLAYER_MINS) - * @param {Vector} maxs - box maxs (typically PLAYER_MAXS) - * @returns {Trace} trace result - */ - static boxTrace(worldModel, headNode, start, end, mins, maxs) { + * @param worldModel - world brush model with BSP data + * @param headNode - BSP node index to start traversal from + * @param start - world-space start point + * @param end - world-space end point + * @param mins - box minimum corner relative to the origin + * @param maxs - box maximum corner relative to the origin + * @returns trace result against BSP brushes + */ + static boxTrace(worldModel: BrushModel, headNode: number, start: Vector, end: Vector, mins: Vector, maxs: Vector): Trace { const trace = new Trace(); // Brush traces must derive allsolid from brush overlap, not from the @@ -839,8 +861,7 @@ export class BrushTrace { const totalMove = end.copy().subtract(start); const sweepBounds = BrushTrace._computeSweepBounds(start, end, mins, maxs); - /** @type {BrushTraceContext} */ - const ctx = { + const ctx: BrushTraceContext = { worldModel, trace, mins, @@ -884,14 +905,14 @@ export class BrushTrace { /** * Test if a player-sized box at the given position overlaps any solid brush. * Equivalent to Q2’s CM_BoxTrace position-test special case. - * @param {BrushModel} worldModel - world model owning the BSP data - * @param {number} headNode - BSP node index to start traversal from - * @param {Vector} position - position to test - * @param {Vector} mins - box mins (typically PLAYER_MINS) - * @param {Vector} maxs - box maxs (typically PLAYER_MAXS) - * @returns {boolean} true if position is valid (not stuck in solid) - */ - static testPosition(worldModel, headNode, position, mins, maxs) { + * @param worldModel - world brush model with BSP data + * @param headNode - BSP node index to start traversal from + * @param position - world-space position to test + * @param mins - box minimum corner relative to the origin + * @param maxs - box maximum corner relative to the origin + * @returns true when the position is clear of solid brushes + */ + static testPosition(worldModel: BrushModel, headNode: number, position: Vector, mins: Vector, maxs: Vector): boolean { if (!worldModel.nodes || worldModel.nodes.length === 0) { return true; } @@ -925,19 +946,8 @@ export class BrushTrace { /** * Recursively walk the BSP tree for position testing, expanding by box * extents to visit all leaves the player box overlaps. - * @param {BrushModel} worldModel - world model - * @param {import('./model/BSP.mjs').Node} node - current node - * @param {Vector} position - test position - * @param {Vector} mins - box mins - * @param {Vector} maxs - box maxs - * @param {Vector} boundsMins - world-space test bounds minimum - * @param {Vector} boundsMaxs - world-space test bounds maximum - * @param {Vector} extents - absolute half-extents - * @param {boolean} isPoint - true if point trace - * @param {number} checkCount - dedup counter - * @returns {boolean} true if solid overlap found - */ - static _testPositionRecursive(worldModel, node, position, mins, maxs, boundsMins, boundsMaxs, extents, isPoint, checkCount) { + */ + static _testPositionRecursive(worldModel: BrushModel, node: import('./model/BSP.mjs').Node, position: Vector, mins: Vector, maxs: Vector, boundsMins: Vector, boundsMaxs: Vector, extents: Vector, isPoint: boolean, checkCount: number): boolean { if (node.mins !== null && node.mins !== undefined && node.maxs !== null && node.maxs !== undefined && !BrushTrace._boundsOverlap(boundsMins, boundsMaxs, node.mins, node.maxs)) { return false; @@ -997,14 +1007,8 @@ export class BrushTrace { * Unlike boxTrace which walks the BSP tree, this tests every brush in the * submodel's range directly. Used for submodel entities (doors, plats, etc.) * whose brushes are NOT inserted into the world BSP leaf-brush index. - * @param {BrushModel} model - submodel with brush data (shared arrays from world) - * @param {Vector} start - start position (local to entity) - * @param {Vector} end - end position (local to entity) - * @param {Vector} mins - box mins - * @param {Vector} maxs - box maxs - * @returns {Trace} trace result - */ - static boxTraceModel(model, start, end, mins, maxs) { + */ + static boxTraceModel(model: BrushModel, start: Vector, end: Vector, mins: Vector, maxs: Vector): Trace { const trace = new Trace(); // Brute-force path: no BSP tree walk, so no leaf visits to clear allsolid. @@ -1032,8 +1036,7 @@ export class BrushTrace { const totalMove = end.copy().subtract(start); const sweepBounds = BrushTrace._computeSweepBounds(start, end, mins, maxs); - /** @type {BrushTraceContext} */ - const ctx = { + const ctx: BrushTraceContext = { worldModel: model, trace, mins, @@ -1094,13 +1097,8 @@ export class BrushTrace { * Test if a player-sized box at position overlaps any solid brush in * a submodel's brush range (brute-force). Used for submodel entities * whose brushes are NOT in the BSP leaf-brush index. - * @param {BrushModel} model - submodel with brush data - * @param {Vector} position - position to test (local to entity) - * @param {Vector} mins - box mins - * @param {Vector} maxs - box maxs - * @returns {boolean} true if position is valid (not in solid) */ - static testPositionModel(model, position, mins, maxs) { + static testPositionModel(model: BrushModel, position: Vector, mins: Vector, maxs: Vector): boolean { if (!model.brushes || model.numBrushes === 0) { return true; } @@ -1138,17 +1136,8 @@ export class BrushTrace { /** * Test if a player-sized box overlaps any solid brush in a leaf. - * @param {BrushModel} worldModel - world model - * @param {import('./model/BSP.mjs').Node} leaf - leaf node - * @param {Vector} position - position to test - * @param {Vector} mins - box mins - * @param {Vector} maxs - box maxs - * @param {Vector} boundsMins - world-space test bounds minimum - * @param {Vector} boundsMaxs - world-space test bounds maximum - * @param {number} checkCount - dedup counter - * @returns {boolean} true if solid overlap found - */ - static _testLeafSolid(worldModel, leaf, position, mins, maxs, boundsMins, boundsMaxs, checkCount) { + */ + static _testLeafSolid(worldModel: BrushModel, leaf: import('./model/BSP.mjs').Node, position: Vector, mins: Vector, maxs: Vector, boundsMins: Vector, boundsMaxs: Vector, checkCount: number): boolean { const brushes = worldModel.brushes; const leafbrushes = worldModel.leafbrushes; @@ -1192,14 +1181,8 @@ export class BrushTrace { /** * Test if a box at origin is inside a brush. Equivalent to Q2’s CM_TestBoxInBrush. - * @param {BrushModel} worldModel - world model - * @param {import('./model/BSP.mjs').Brush} brush - brush to test - * @param {Vector} position - box center position - * @param {Vector} mins - box mins - * @param {Vector} maxs - box maxs - * @returns {boolean} true if the box is inside the brush - */ - static _testBoxInBrush(worldModel, brush, position, mins, maxs) { + */ + static _testBoxInBrush(worldModel: BrushModel, brush: import('./model/BSP.mjs').Brush, position: Vector, mins: Vector, maxs: Vector): boolean { const brushsides = worldModel.brushsides; const planes = worldModel.planes; @@ -1234,23 +1217,14 @@ export class BrushTrace { return true; } - /** @type {Vector[]} */ - static _midPool = Array.from({ length: 96 }, () => new Vector()); - /** @type {Vector[]} */ - static _mid2Pool = Array.from({ length: 96 }, () => new Vector()); + static readonly _midPool: Vector[] = Array.from({ length: 96 }, () => new Vector()); + static readonly _mid2Pool: Vector[] = Array.from({ length: 96 }, () => new Vector()); /** * Recursively traverse the BSP node tree, expanding by trace extents. * At leaf nodes, test all brushes. Equivalent to Q2’s CM_RecursiveHullCheck. - * @param {BrushTraceContext} ctx - trace context - * @param {import('./model/BSP.mjs').Node} node - current BSP node (or leaf) - * @param {number} p1f - fraction at p1 - * @param {number} p2f - fraction at p2 - * @param {Vector} p1 - start of segment - * @param {Vector} p2 - end of segment - * @param {number} depth - recursion depth - */ - static _recursiveHullCheck(ctx, node, p1f, p2f, p1, p2, depth = 0) { + */ + static _recursiveHullCheck(ctx: BrushTraceContext, node: import('./model/BSP.mjs').Node, p1f: number, p2f: number, p1: Vector, p2: Vector, depth: number = 0) { if (!BrushTrace._nodeMayAffectTrace(ctx, node)) { return; } @@ -1346,10 +1320,8 @@ export class BrushTrace { /** * Test all brushes in a leaf against the current trace. * Equivalent to Q2’s CM_TraceToLeaf. - * @param {BrushTraceContext} ctx - trace context - * @param {import('./model/BSP.mjs').Node} leaf - leaf node */ - static _traceToLeaf(ctx, leaf) { + static _traceToLeaf(ctx: BrushTraceContext, leaf: import('./model/BSP.mjs').Node) { // Q1 content classification for trace flags if (leaf.contents !== content.CONTENT_SOLID && leaf.contents !== content.CONTENT_SKY) { ctx.trace.allsolid = false; @@ -1400,10 +1372,8 @@ export class BrushTrace { /** * Clip the trace against a single brush’s planes. * Equivalent to Q2’s CM_ClipBoxToBrush. - * @param {BrushTraceContext} ctx - trace context - * @param {import('./model/BSP.mjs').Brush} brush - brush to clip against */ - static _clipBoxToBrush(ctx, brush) { + static _clipBoxToBrush(ctx: BrushTraceContext, brush: import('./model/BSP.mjs').Brush) { const brushsides = ctx.worldModel.brushsides; const planes = ctx.worldModel.planes; const moveDeltaX = ctx.end[0] - ctx.start[0]; @@ -1414,10 +1384,8 @@ export class BrushTrace { let enterfrac = -1; let leavefrac = 1; - /** @type {import('./model/BaseModel.mjs').Plane|null} */ - let clipplane = null; - /** @type {import('./model/BaseModel.mjs').Plane|null} */ - let tangentAxialPlane = null; + let clipplane: import('./model/BaseModel.mjs').Plane | null = null; + let tangentAxialPlane: import('./model/BaseModel.mjs').Plane | null = null; let tangentAxialMovesDeeper = false; let getout = false; @@ -1538,12 +1506,12 @@ export class BrushTrace { /** * Convert a world-space point into local model space using the inverse of a * rigid transform represented by origin plus orthonormal basis rows. - * @param {Vector} point - world-space point - * @param {Vector} origin - transform origin - * @param {number[]} basis - 3x3 rotation matrix from Vector.toRotationMatrix() - * @returns {Vector} point in local space + * @param point - world-space point + * @param origin - transform origin + * @param basis - 3x3 rotation matrix from Vector.toRotationMatrix() + * @returns point in local model space */ - static _transformPointToLocal(point, origin, basis) { + static _transformPointToLocal(point: Vector, origin: Vector, basis: number[]): Vector { const delta = point.copy().subtract(origin); const forward = new Vector(basis[0], basis[1], basis[2]); const right = new Vector(basis[3], basis[4], basis[5]); @@ -1558,12 +1526,12 @@ export class BrushTrace { /** * Convert a local-space point into world space using origin plus basis rows. - * @param {Vector} point - local-space point - * @param {Vector} origin - transform origin - * @param {number[]} basis - 3x3 rotation matrix from Vector.toRotationMatrix() - * @returns {Vector} point in world space + * @param point - local-space point + * @param origin - transform origin + * @param basis - 3x3 rotation matrix from Vector.toRotationMatrix() + * @returns point in world space */ - static _transformPointToWorld(point, origin, basis) { + static _transformPointToWorld(point: Vector, origin: Vector, basis: number[]): Vector { const forward = new Vector(basis[0], basis[1], basis[2]); const right = new Vector(basis[3], basis[4], basis[5]); const up = new Vector(basis[6], basis[7], basis[8]); @@ -1576,11 +1544,11 @@ export class BrushTrace { /** * Rotate a local-space plane normal into world space. - * @param {Vector} normal - local-space normal - * @param {number[]} basis - 3x3 rotation matrix from Vector.toRotationMatrix() - * @returns {Vector} world-space normal + * @param normal - local-space normal + * @param basis - 3x3 rotation matrix from Vector.toRotationMatrix() + * @returns world-space normal */ - static _transformNormalToWorld(normal, basis) { + static _transformNormalToWorld(normal: Vector, basis: number[]): Vector { const forward = new Vector(basis[0], basis[1], basis[2]); const right = new Vector(basis[3], basis[4], basis[5]); const up = new Vector(basis[6], basis[7], basis[8]); @@ -1592,11 +1560,11 @@ export class BrushTrace { /** * Convert a local-space trace result back into world space. - * @param {Trace} localTrace - local-space trace result - * @param {{origin: Vector, basis: number[]|null}|null} transform - entity transform context - * @returns {Trace} world-space trace result + * @param localTrace - local-space trace result + * @param transform - entity transform context + * @returns world-space trace result */ - static _transformTraceToWorld(localTrace, transform) { + static _transformTraceToWorld(localTrace: Trace, transform: BrushTraceTransform): Trace { if (transform === null) { return localTrace; } @@ -1616,102 +1584,101 @@ export class BrushTrace { } } -/** - * @typedef {object} BrushTraceContext - * @property {BrushModel} worldModel - world model with BSP data - * @property {Trace} trace - trace result being accumulated - * @property {Vector} mins - player box mins - * @property {Vector} maxs - player box maxs - * @property {boolean} isPoint - true if mins/maxs are zero (point trace) - * @property {Vector} extents - absolute half-extents of the player box - * @property {Vector} start - trace start position - * @property {Vector} end - trace end position - * @property {Vector} totalMove - end minus start - * @property {Vector} sweepMins - swept move bounding box minimum - * @property {Vector} sweepMaxs - swept move bounding box maximum - * @property {number} checkCount - dedup counter for brush testing - */ +interface BrushTraceContext { + readonly worldModel: BrushModel; + readonly trace: Trace; + readonly mins: Vector; + readonly maxs: Vector; + readonly isPoint: boolean; + readonly extents: Vector; + readonly start: Vector; + readonly end: Vector; + readonly totalMove: Vector; + readonly sweepMins: Vector; + readonly sweepMaxs: Vector; + readonly checkCount: number; +} + // --------------------------------------------------------------------------- // PhysEnt: a physics entity stored in the Pmove world // --------------------------------------------------------------------------- export class PhysEnt { // physent_t - /** - * @param {Pmove} pmove parent pmove instance - */ - constructor(pmove) { - /** only for bsp models (legacy Q1 hull-based collision) @type {Hull[]} */ + /** Legacy Q1 hulls used when brush tracing is unavailable. */ + hulls: Hull[]; + /** Entity origin in world space. */ + origin: Vector; + /** Entity rotation used for transformed brush traces. */ + angles: Vector; + /** Local bounding-box minimums for non-BSP entities. */ + mins: Vector; + /** Local bounding-box maximums for non-BSP entities. */ + maxs: Vector; + /** Owning edict index when this physent maps back to game state. */ + edictId: number | null; + /** Shared world brush model used for Q2-style brush tracing. */ + brushWorldModel: BrushModel | null; + /** BSP node root used when tracing against the world brush model. */ + brushHeadNode: number; + /** Submodel brush range used for brute-force submodel tracing. */ + brushModel: BrushModel | null; + readonly #pmoveRef: WeakRef; + + /** + * @param pmove - parent pmove instance + */ + constructor(pmove: Pmove) { this.hulls = []; - /** origin */ this.origin = new Vector(); - /** angles for transformed brush collision */ this.angles = new Vector(); - /** only for non-bsp models */ this.mins = new Vector(); - /** only for non-bsp models */ this.maxs = new Vector(); - /** actual edict index, used to map back to edicts @type {?number} */ this.edictId = null; - /** - * Reference to the world model for brush-based collision. - * The world model owns nodes, leafs, planes, brushes, brushsides, leafbrushes. - * Submodels reference the same world model (shared BSP tree). - * @type {BrushModel|null} - */ this.brushWorldModel = null; - /** - * BSP node index for brush collision traversal root. - * For the world entity this is the world headnode. - * Not used for submodel entities (they use brute-force brush testing). - * @type {number} - */ this.brushHeadNode = -1; - /** - * Submodel reference for brute-force brush collision. - * Set for submodel entities (doors, plats, etc.) whose brushes are - * NOT in the BSP leaf-brush index. When set, tracing iterates this - * model's firstBrush..firstBrush+numBrushes directly. - * Null for the world entity (which uses BSP tree walk via brushHeadNode). - * @type {BrushModel|null} - */ this.brushModel = null; - /** @type {WeakRef} @private */ - this._pmove_wf = new WeakRef(pmove); + this.#pmoveRef = new WeakRef(pmove); } - /** @returns {Pmove} pmove @private */ - get _pmove() { - return this._pmove_wf.deref(); + /** Parent Pmove instance. */ + get _pmove(): Pmove { + const pmove = this.#pmoveRef.deref(); + + if (pmove === undefined) { + throw new Error('PhysEnt parent Pmove was released'); + } + + return pmove; } /** * Whether this entity uses brush-based collision (Q2-style). * When false, falls back to legacy hull-based collision (Q1-style). - * @returns {boolean} true if brush-based collision is available + * @returns true when brush-based collision is available */ - get usesBrushTracing() { + get usesBrushTracing(): boolean { return this.brushWorldModel !== null && this.brushWorldModel.hasBrushData; } /** * Active brush collision model: submodels use their own brush range, world uses the world model. - * @returns {BrushModel} brush model to trace against + * @returns brush model to trace against */ - get brushCollisionModel() { + get brushCollisionModel(): BrushModel | null { return this.brushModel ?? this.brushWorldModel; } /** * Emit nearby blocking brushes around a debug position when brush and hull comparisons disagree. - * @param {Vector} position world-space position to inspect - * @param {string} label debug label for the sampled position + * @param position - world-space position to inspect + * @param label - debug label for the sampled position */ - _debugLogNearbyBlockingBrushes(position, label) { + _debugLogNearbyBlockingBrushes(position: Vector, label: string) { const model = this.brushCollisionModel; const brushes = model?.brushes; const planes = model?.planes; @@ -1723,8 +1690,7 @@ export class PhysEnt { // physent_t const firstBrush = model.firstBrush ?? 0; const lastBrush = firstBrush + (model.numBrushes ?? brushes.length); - /** @type {{ index: number, contents: number, numsides: number, nearestPlaneDistance: number, touchingPlanes: number, mins: Vector, maxs: Vector, sideSummaries: string[] }[]} */ - const candidates = []; + const candidates: NearbyBrushCandidate[] = []; for (let brushIndex = firstBrush; brushIndex < lastBrush; brushIndex++) { const brush = brushes[brushIndex]; @@ -1752,8 +1718,7 @@ export class PhysEnt { // physent_t let nearestPlaneDistance = Number.POSITIVE_INFINITY; let touchingPlanes = 0; - /** @type {{ distance: number, summary: string }[]} */ - const sideSummaries = []; + const sideSummaries: NearbyBrushSideSummary[] = []; for (let sideIndex = 0; sideIndex < brush.numsides; sideIndex++) { const side = brushsides[brush.firstside + sideIndex]; @@ -1816,10 +1781,10 @@ export class PhysEnt { // physent_t * they sit exactly tangent to an axial wall plane. Legacy BSP hull point * contents treats that pose as solid, which causes the tiny separating nudge * seen in hull-backed maps before movement begins. - * @param {Vector} position world-space position to inspect - * @returns {boolean} true when the position would become solid if axial wall tangency counted as overlap + * @param position - world-space position to inspect + * @returns true when hull-style tangency should still count as blocked */ - _brushPositionNeedsHullTangentFallback(position) { + _brushPositionNeedsHullTangentFallback(position: Vector): boolean { const model = this.brushCollisionModel; const brushes = model?.brushes; const planes = model?.planes; @@ -1901,18 +1866,18 @@ export class PhysEnt { // physent_t /** * Legacy hull comparisons are only meaningful for axis-aligned brush traces. - * @returns {boolean} true if brush-vs-hull debug comparison is valid + * @returns true when brush-vs-hull debug comparisons are valid */ - get canCompareBrushAgainstHull() { + get canCompareBrushAgainstHull(): boolean { return this.hulls.length > 0 && this.angles.isOrigin(); } /** * Legacy hull comparisons are only meaningful for axial contact planes. - * @param {Vector} normal candidate contact normal - * @returns {boolean} true when the normal is axis-aligned + * @param normal - candidate contact normal + * @returns true when the normal is axis-aligned */ - static _isAxialNormal(normal) { + static _isAxialNormal(normal: Vector): boolean { const ax = Math.abs(normal[0]); const ay = Math.abs(normal[1]); const az = Math.abs(normal[2]); @@ -1924,11 +1889,11 @@ export class PhysEnt { // physent_t /** * Convert a point into this physent's legacy hull space. - * @param {Vector} point point in world space - * @param {Vector|null} scratch scratch vector to reuse, or null to allocate - * @returns {Vector} point in local hull space + * @param point - point in world space + * @param scratch - optional scratch vector to reuse + * @returns point in local hull space */ - toHullSpace(point, scratch = null) { + toHullSpace(point: Vector, scratch: Vector | null = null): Vector { const localPoint = scratch ?? point.copy(); return localPoint.set(point).subtract(this.origin); } @@ -1936,11 +1901,11 @@ export class PhysEnt { // physent_t /** * Convert a point into the collision space expected by this physent. * Brush traces operate in world space; legacy hull traces use local space. - * @param {Vector} point point in world space - * @param {Vector|null} scratch scratch vector to reuse for hull traces - * @returns {Vector} point in the collision space expected by the active path + * @param point - point in world space + * @param scratch - optional scratch vector for hull conversion + * @returns point in the active collision space */ - toCollisionSpace(point, scratch = null) { + toCollisionSpace(point: Vector, scratch: Vector | null = null): Vector { if (this.usesBrushTracing) { return point; } @@ -1950,11 +1915,11 @@ export class PhysEnt { // physent_t /** * Convert a point from this physent's collision space back to world space. - * @param {Vector} point point in collision space - * @param {Vector|null} scratch scratch vector to reuse for hull traces - * @returns {Vector} point in world space + * @param point - point in collision space + * @param scratch - optional scratch vector for hull conversion + * @returns point in world space */ - toWorldSpace(point, scratch = null) { + toWorldSpace(point: Vector, scratch: Vector | null = null): Vector { if (this.usesBrushTracing) { return point; } @@ -1963,15 +1928,15 @@ export class PhysEnt { // physent_t return worldPoint.set(point).add(this.origin); } - #hullMinsScratch = new Vector(); - #hullMaxsScratch = new Vector(); + readonly #hullMinsScratch = new Vector(); + readonly #hullMaxsScratch = new Vector(); /** * Returns clipping hull for this entity (legacy Q1 hull-based path). * NOTE: This is not async/wait safe, since it will modify pmove’s boxHull in-place. - * @returns {Hull} hull + * @returns hull used for legacy player traces */ - getClippingHull() { + getClippingHull(): Hull { if (this.hulls.length > 0) { return this.hulls[1]; // player hull } @@ -1986,11 +1951,11 @@ export class PhysEnt { // physent_t * Trace a player-sized box from start to end using the appropriate collision method. * For brush-based entities, uses Q2-style brush tracing. * For hull-based entities, uses Q1-style hull tracing. - * @param {Vector} start world-space start position - * @param {Vector} end world-space end position - * @returns {Trace} trace result + * @param start - world-space start position + * @param end - world-space end position + * @returns trace result for this physent */ - tracePlayerMove(start, end) { + tracePlayerMove(start: Vector, end: Vector): Trace { const traceStart = this.toCollisionSpace(start); const traceEnd = this.toCollisionSpace(end); @@ -2068,10 +2033,10 @@ export class PhysEnt { // physent_t /** * Test if a player-sized box at position is inside solid. - * @param {Vector} position world-space position to test - * @returns {boolean} true if position is valid (not in solid) + * @param position - world-space position to test + * @returns true when the position is valid and not in solid */ - testPlayerPosition(position) { + testPlayerPosition(position: Vector): boolean { if (this.usesBrushTracing) { const brushResult = BrushTrace.transformedTestPosition( this.brushCollisionModel, @@ -2177,7 +2142,7 @@ export class PhysEnt { // physent_t } // CR: we can add getClippingHullCrouch() for BSP30 hulls here later -}; +} // --------------------------------------------------------------------------- // PmovePlayer: the core player movement simulation @@ -2207,74 +2172,100 @@ export class PhysEnt { // physent_t * it back after. */ export class PmovePlayer { // pmove_t (player state only) - /** @type {boolean} enables verbose movement debugging */ - static get DEBUG() { + /** Frame time derived from the current user command. */ + frametime: number; + /** Water depth from 0 to 3. */ + waterlevel: number; + /** Current water contents value. */ + watertype: number; + /** Ground physent index, or null while airborne. */ + onground: number | null; + /** Player origin in world space. */ + origin: Vector; + /** Player velocity in world units per second. */ + velocity: Vector; + /** Resolved view angles used for movement. */ + angles: Vector; + /** Current movement mode. */ + pmType: PM_TYPE; + /** Player movement flags bitmask. */ + pmFlags: number; + /** Timing counter for landing, teleport, and water-jump states. */ + pmTime: number; + /** View height offset from the origin. */ + viewheight: number; + /** Previous frame button state for edge-triggered input. */ + oldbuttons: number; + /** Quake 1 water-jump timer compatibility field. */ + waterjumptime: number; + /** Backwards-compatible spectator flag. */ + spectator: boolean; + /** Backwards-compatible dead-player flag. */ + dead: boolean; + /** Current input command being simulated. */ + cmd: Protocol.UserCmd; + /** Physent indices touched during this frame. */ + touchindices: number[]; + /** Whether the player is on a ladder this frame. */ + _ladder: boolean; + /** Cached angle vectors for the current view angles. */ + _angleVectors: DirectionalVectors | null; + readonly #pmoveRef: WeakRef; + + /** Enables verbose movement debugging. */ + static get DEBUG(): boolean { return (Pmove.debug?.value ?? 0) !== 0; } /** - * @param {Pmove} pmove pmove instance (world + physents) + * @param pmove - pmove instance containing world collision state */ - constructor(pmove) { + constructor(pmove: Pmove) { // --- Public state (read/write by caller) --- - /** @type {number} computed from cmd.msec */ this.frametime = 0; - /** @type {number} 0-3 water depth */ this.waterlevel = 0; - /** @type {number} content type of water */ this.watertype = 0; - /** @type {?number} ground edict number; null if airborne */ this.onground = null; - /** @type {Vector} player position (full float precision) */ this.origin = new Vector(); - /** @type {Vector} player velocity (full float precision) */ this.velocity = new Vector(); - /** @type {Vector} resolved view angles */ this.angles = new Vector(); - /** @type {number} movement type (PM_TYPE enum) */ this.pmType = PM_TYPE.NORMAL; - /** @type {number} PM flag bitmask (PMF enum) */ this.pmFlags = 0; - /** @type {number} timing counter for special states (in msec/8 units) */ this.pmTime = 0; - /** @type {number} view height offset from origin */ this.viewheight = 22; - /** @type {number} remembered old buttons for edge detection */ this.oldbuttons = 0; - /** @type {number} Q1 compat, waterjump time remaining */ this.waterjumptime = 0.0; - /** @type {boolean} backwards compat flag */ this.spectator = false; - /** @type {boolean} backwards compat flag */ this.dead = false; - /** @type {Protocol.UserCmd} input command */ this.cmd = new Protocol.UserCmd(); - /** @type {number[]} list of touched edict numbers */ this.touchindices = []; // --- Private --- - /** @type {boolean} whether we are on a ladder this frame */ this._ladder = false; - /** @type {DirectionalVectors} cached angle vectors @private */ this._angleVectors = null; - /** @type {WeakRef} @private */ - this._pmove_wf = new WeakRef(pmove); + this.#pmoveRef = new WeakRef(pmove); } - /** @returns {Pmove} parent Pmove instance @private */ - get _pmove() { - return this._pmove_wf.deref(); + /** Parent Pmove instance. */ + get _pmove(): Pmove { + const pmove = this.#pmoveRef.deref(); + + if (pmove === undefined) { + throw new Error('PmovePlayer parent Pmove was released'); + } + + return pmove; } // ========================================================================= @@ -2761,12 +2752,12 @@ export class PmovePlayer { // pmove_t (player state only) /** * Slide off of the impacting surface. - * @param {Vector} veloIn input velocity - * @param {Vector} normal surface normal - * @param {Vector} veloOut output velocity (may alias veloIn) - * @param {number} overbounce overbounce factor (Q1: 1.0, Q2: 1.01) + * @param veloIn - input velocity + * @param normal - surface normal + * @param veloOut - output velocity, which may alias the input + * @param overbounce - overbounce factor used by Quake movement */ - _clipVelocity(veloIn, normal, veloOut, overbounce) { // Q2: PM_ClipVelocity + _clipVelocity(veloIn: Vector, normal: Vector, veloOut: Vector, overbounce: number) { // Q2: PM_ClipVelocity const backoff = veloIn.dot(normal) * overbounce; for (let i = 0; i < 3; i++) { @@ -2784,11 +2775,11 @@ export class PmovePlayer { // pmove_t (player state only) /** * Ground/water acceleration. - * @param {Vector} wishdir desired direction (unit vector) - * @param {number} wishspeed desired speed - * @param {number} accel acceleration factor + * @param wishdir - desired movement direction as a unit vector + * @param wishspeed - desired movement speed + * @param accel - acceleration factor to apply this frame */ - _accelerate(wishdir, wishspeed, accel) { // Q2: PM_Accelerate + _accelerate(wishdir: Vector, wishspeed: number, accel: number) { // Q2: PM_Accelerate const currentspeed = this.velocity.dot(wishdir); let addspeed = wishspeed - currentspeed; if (addspeed <= 0) { @@ -2809,11 +2800,11 @@ export class PmovePlayer { // pmove_t (player state only) * Air acceleration, preserves the Q1/Q2 air-strafe mechanic. * wishspeed is capped at 30 for the addspeed check, but the uncapped * value is used for accelspeed. This allows bunny-hopping. - * @param {Vector} wishdir desired direction (unit vector) - * @param {number} wishspeed desired speed (uncapped) - * @param {number} accel acceleration factor + * @param wishdir - desired movement direction as a unit vector + * @param wishspeed - uncapped desired movement speed + * @param accel - acceleration factor to apply this frame */ - _airAccelerate(wishdir, wishspeed, accel) { // Q2: PM_AirAccelerate + _airAccelerate(wishdir: Vector, wishspeed: number, accel: number) { // Q2: PM_AirAccelerate let wishspd = wishspeed; if (wishspd > 30) { wishspd = 30; @@ -2841,24 +2832,24 @@ export class PmovePlayer { // pmove_t (player state only) // ========================================================================= // --- Scratch Vectors for _slideMove --- - #slideOriginalVelocity = new Vector(); - #slidePrimalVelocity = new Vector(); - #slideEnd = new Vector(); - #slideClipVelocity = new Vector(); - #slideCreaseDir = new Vector(); - #slidePlanes = Array.from({ length: MAX_CLIP_PLANES }, () => new Vector()); + readonly #slideOriginalVelocity = new Vector(); + readonly #slidePrimalVelocity = new Vector(); + readonly #slideEnd = new Vector(); + readonly #slideClipVelocity = new Vector(); + readonly #slideCreaseDir = new Vector(); + readonly #slidePlanes = Array.from({ length: MAX_CLIP_PLANES }, () => new Vector()); /** * Brush bevels can report a second zero-progress hit whose normal only * differs slightly from the wall we already clipped against. Treat that as a * duplicate plane so we keep sliding instead of manufacturing a bogus crease. - * @param {Vector} normal candidate collision plane normal - * @param {number} planeCount number of existing clip planes - * @param {Vector[]} planes existing clip planes - * @param {number} fraction trace fraction for the candidate hit - * @returns {boolean} true when the candidate plane should be collapsed into an existing one + * @param normal - candidate collision plane normal + * @param planeCount - number of existing slide planes + * @param planes - accumulated slide plane normals + * @param fraction - trace fraction for the candidate collision + * @returns true when the candidate plane should be merged into an existing one */ - _isDuplicateSlidePlane(normal, planeCount, planes, fraction) { + _isDuplicateSlidePlane(normal: Vector, planeCount: number, planes: Vector[], fraction: number): boolean { const duplicateDot = fraction === 0.0 ? ZERO_PROGRESS_DUPLICATE_DOT : 0.99; for (let i = 0; i < planeCount; i++) { @@ -2873,9 +2864,8 @@ export class PmovePlayer { // pmove_t (player state only) /** * The basic solid body movement clip that slides along multiple planes. * This is the inner loop, it does NOT attempt step-up. - * @returns {boolean} True when movement hit a blocking plane and required clipping. */ - _slideMove() { // Q1: SV_FlyMove / Q2: PM_StepSlideMove_ + _slideMove(): boolean { // Q1: SV_FlyMove / Q2: PM_StepSlideMove_ const _dbg = PmovePlayer.DEBUG; const _dbgStartOrigin = _dbg ? this.origin.copy() : null; const _dbgStartVelocity = _dbg ? this.velocity.copy() : null; @@ -2887,8 +2877,7 @@ export class PmovePlayer { // pmove_t (player state only) // against the same BSP hull plane with the 1.01 overbounce factor. const originalVelocity = this.#slideOriginalVelocity.set(this.velocity); let numplanes = 0; - /** @type {Vector[]} */ - const planes = this.#slidePlanes; + const planes: Vector[] = this.#slidePlanes; let timeLeft = this.frametime; const end = this.#slideEnd; const clipVelocity = this.#slideClipVelocity; @@ -3030,20 +3019,20 @@ export class PmovePlayer { // pmove_t (player state only) // ========================================================================= // --- Scratch Vectors for _stepSlideMove --- - #stepStartOrigin = new Vector(); - #stepStartVelocity = new Vector(); - #stepDownOrigin = new Vector(); - #stepDownVelocity = new Vector(); - #stepUpOrigin = new Vector(); - #stepStickTarget = new Vector(); - #stepUp = new Vector(); - #stepDown = new Vector(); + readonly #stepStartOrigin = new Vector(); + readonly #stepStartVelocity = new Vector(); + readonly #stepDownOrigin = new Vector(); + readonly #stepDownVelocity = new Vector(); + readonly #stepUpOrigin = new Vector(); + readonly #stepStickTarget = new Vector(); + readonly #stepUp = new Vector(); + readonly #stepDown = new Vector(); - /** - * Each intersection will try to step over the obstruction instead of - * sliding along it. This calls _slideMove twice: once without step-up, - * once with step-up, and picks whichever went farther horizontally. - */ +/** + * Each intersection will try to step over the obstruction instead of + * sliding along it. This calls _slideMove twice: once without step-up, + * once with step-up, and picks whichever went farther horizontally. + */ _stepSlideMove() { // Q2: PM_StepSlideMove const startOrigin = this.#stepStartOrigin.set(this.origin); const startVelocity = this.#stepStartVelocity.set(this.velocity); @@ -3300,10 +3289,10 @@ export class PmovePlayer { // pmove_t (player state only) this._slideMove(); } - /** - * Fly/spectator movement - noclip with friction. - * Can be called by spectators or noclip modes. - */ +/** + * Fly/spectator movement - noclip with friction. + * Can be called by spectators or noclip modes. + */ _flyMove() { // Q2: PM_FlyMove this.viewheight = 22; // TODO: config @@ -3376,10 +3365,10 @@ export class PmovePlayer { // pmove_t (player state only) // Position snapping / nudging // ========================================================================= - /** - * Quantize position to 1/8 unit precision for network transmission - * and nudge into a valid position. - */ +/** + * Quantize position to 1/8 unit precision for network transmission + * and nudge into a valid position. + */ _snapPosition() { // Q2: PM_SnapPosition // snap velocity to 1/8 unit precision (see SzBuffer) for (let i = 0; i < 3; i++) { @@ -3421,11 +3410,11 @@ export class PmovePlayer { // pmove_t (player state only) this.origin.set(base); } - /** - * If pmove.origin is in a solid position, - * try nudging slightly on all axes to - * allow for the cut precision of the net coordinates. - */ +/** + * If pmove.origin is in a solid position, + * try nudging slightly on all axes to + * allow for the cut precision of the net coordinates. + */ _nudgePosition() { // Q2: PM_InitialSnapPosition / QW: NudgePosition const offsets = [0, -1, 1]; const base = this.origin.copy(); @@ -3449,7 +3438,7 @@ export class PmovePlayer { // pmove_t (player state only) } this.origin.set(base); } -}; +} // --------------------------------------------------------------------------- // Pmove: the world container (physents, collision infrastructure) @@ -3470,13 +3459,21 @@ export class Pmove { // pmove_t static MAX_CLIP_PLANES = MAX_CLIP_PLANES; - static PLAYER_MINS = new Vector(-16.0, -16.0, -24.0); - static PLAYER_MAXS = new Vector(16.0, 16.0, 32.0); + static readonly PLAYER_MINS = new Vector(-16.0, -16.0, -24.0); + static readonly PLAYER_MAXS = new Vector(16.0, 16.0, 32.0); - static MAX_PHYSENTS = 32; + static readonly MAX_PHYSENTS = 32; - /** @type {Cvar} */ - static debug = null; + static debug: Cvar | null = null; + + /** Runtime configuration shared by client and server movement. */ + configuration = new PmoveConfiguration(); + /** Physics entities, with index 0 reserved for the static world. */ + physents: PhysEnt[] = []; + /** Reusable box hull for non-BSP entity collision. */ + boxHull = new BoxHull(); + /** Movement tuning variables shared by simulated players. */ + movevars = new MoveVars(); static Init() { Pmove.debug = new Cvar('pm_debug', '0', Cvar.FLAG.NONE, 'pmove debug output'); @@ -3487,23 +3484,15 @@ export class Pmove { // pmove_t Pmove.debug = null; } - /** @type {PmoveConfiguration} parameters for certain checks */ - configuration = new PmoveConfiguration(); - - /** @type {PhysEnt[]} 0 - world */ - physents = []; - boxHull = new BoxHull(); - movevars = new MoveVars(); - - /** @type {Map} cache for pm hulls from mod hulls */ - #modelHullsCache = new Map(); + /** Cache for cloned model hulls keyed by model name. */ + readonly #modelHullsCache = new Map(); /** * Normalize static-world contents values so current volumes behave like water. - * @param {number} contents raw contents value - * @returns {number} normalized static-world contents value + * @param contents - raw contents value from the collision backend + * @returns normalized static-world contents value */ - _normalizeStaticWorldContents(contents) { + _normalizeStaticWorldContents(contents: number): number { if ((contents <= content.CONTENT_CURRENT_0) && (contents >= content.CONTENT_CURRENT_DOWN)) { return content.CONTENT_WATER; } @@ -3513,11 +3502,11 @@ export class Pmove { // pmove_t /** * Sample brush-backed static-world contents without exposing BSP details to callers. - * @param {PhysEnt} worldPhysEnt world physent - * @param {Vector} point position to sample - * @returns {number} static-world contents value + * @param worldPhysEnt - world physent holding the active brush model + * @param point - world-space position to sample + * @returns static-world contents value */ - _pointContentsBrushStaticWorld(worldPhysEnt, point) { + _pointContentsBrushStaticWorld(worldPhysEnt: PhysEnt, point: Vector): number { console.assert(worldPhysEnt.brushWorldModel instanceof BrushModel, 'world brush model'); if (!BrushTrace.transformedTestPosition( @@ -3538,10 +3527,10 @@ export class Pmove { // pmove_t * Sample static-world contents using the active world collision backend. * This queries the world physent only; dynamic entities and BSP submodels are * not included here. - * @param {Vector} point position to sample - * @returns {number} static-world contents value + * @param point - world-space position to sample + * @returns static-world contents value */ - staticWorldContents(point) { + staticWorldContents(point: Vector): number { console.assert(this.physents[0] instanceof PhysEnt, 'world physent'); const worldPhysEnt = this.physents[0]; @@ -3558,31 +3547,31 @@ export class Pmove { // pmove_t /** * Compatibility alias for staticWorldContents. - * @param {Vector} point position to sample - * @returns {number} static-world contents value + * @param point - world-space position to sample + * @returns static-world contents value */ - worldContents(point) { + worldContents(point: Vector): number { return this.staticWorldContents(point); } /** * Compatibility alias for staticWorldContents. - * @param {Vector} point position to sample - * @returns {number} static-world contents value + * @param point - world-space position to sample + * @returns static-world contents value */ - pointContents(point) { + pointContents(point: Vector): number { return this.staticWorldContents(point); } /** * Normalize a player-move trace so startsolid results stop at the start point. - * @param {Trace} trace trace to normalize - * @param {Vector} start trace start position - * @param {number} physEntIndex physent index for debug logging - * @param {PhysEnt} physEnt physent that produced the trace - * @returns {Trace} normalized trace + * @param trace - trace to normalize in place + * @param start - original trace start position + * @param physEntIndex - physent index used for debug logging + * @param physEnt - physent that produced the trace + * @returns normalized trace */ - _finalizePlayerMoveTrace(trace, start, physEntIndex, physEnt) { + _finalizePlayerMoveTrace(trace: Trace, start: Vector, physEntIndex: number, physEnt: PhysEnt): Trace { if (trace.allsolid) { trace.startsolid = true; } @@ -3602,11 +3591,11 @@ export class Pmove { // pmove_t * Trace a player-sized move against the static world only. * Dynamic entities and BSP submodels owned by separate physents are not * included here. - * @param {Vector} start starting point - * @param {Vector} end end point (e.g. start + velocity * frametime) - * @returns {Trace} trace object against the world physent only + * @param start - starting point + * @param end - end point, usually start plus velocity times frame time + * @returns trace against the world physent only */ - traceStaticWorldPlayerMove(start, end) { + traceStaticWorldPlayerMove(start: Vector, end: Vector): Trace { console.assert(!Number.isNaN(start[0]) && !Number.isNaN(start[1]) && !Number.isNaN(start[2]), 'NaN start'); console.assert(!Number.isNaN(end[0]) && !Number.isNaN(end[1]) && !Number.isNaN(end[2]), 'NaN end'); console.assert(this.physents[0] instanceof PhysEnt, 'world physent'); @@ -3621,13 +3610,14 @@ export class Pmove { // pmove_t return this._finalizePlayerMoveTrace(trace, start, 0, worldPhysEnt); } - #validPosTestScratch = new Vector(); + readonly #validPosTestScratch = new Vector(); /** - * @param {Vector} position player’s origin - * @returns {boolean} Returns false if the given player position is not valid (in solid) + * Check whether a player-sized box can occupy the given world-space position. + * @param position - player origin to validate + * @returns true when no physent blocks the position */ - isValidPlayerPosition(position) { + isValidPlayerPosition(position: Vector): boolean { for (let i = 0; i < this.physents.length; i++) { const pe = this.physents[i]; @@ -3653,11 +3643,11 @@ export class Pmove { // pmove_t /** * Attempts to move the player from start to end. - * @param {Vector} start starting point - * @param {Vector} end end point (e.g. start + velocity * frametime) - * @returns {Trace} trace object + * @param start - starting point + * @param end - end point, usually start plus velocity times frame time + * @returns earliest blocking trace across all physents */ - clipPlayerMove(start, end) { + clipPlayerMove(start: Vector, end: Vector): Trace { console.assert(!Number.isNaN(start[0]) && !Number.isNaN(start[1]) && !Number.isNaN(start[2]), 'NaN start'); console.assert(!Number.isNaN(end[0]) && !Number.isNaN(end[1]) && !Number.isNaN(end[2]), 'NaN end'); @@ -3710,10 +3700,10 @@ export class Pmove { // pmove_t /** * Sets worldmodel. * This will automatically reset all physents. - * @param {BrushModel} model worldmodel - * @returns {Pmove} this + * @param model - world brush model to use as physent zero + * @returns this pmove instance */ - setWorldmodel(model) { + setWorldmodel(model: BrushModel): Pmove { console.assert(model instanceof BrushModel, 'model'); this.physents.length = 0; @@ -3739,20 +3729,20 @@ export class Pmove { // pmove_t /** * Clears all entities. - * @returns {Pmove} this + * @returns this pmove instance */ - clearEntities() { + clearEntities(): Pmove { this.physents.length = 1; return this; } /** * Adds an entity (client or server) to physents. - * @param {import('../server/Edict.mjs').BaseEntity|import('../client/ClientEntities.mjs').ClientEdict} entity actual entity - * @param {BrushModel|null} model model must be provided when entity is SOLID_BSP - * @returns {Pmove} this + * @param entity - entity state to mirror into the physent list + * @param model - brush model to use for SOLID_BSP-style entities + * @returns this pmove instance */ - addEntity(entity, model = null) { + addEntity(entity: import('../server/Edict.mjs').BaseEntity | import('../client/ClientEntities.mjs').ClientEdict, model: BrushModel | null = null): Pmove { const pe = new PhysEnt(this); console.assert(model === null || model instanceof BrushModel, 'no model or brush model required'); @@ -3812,9 +3802,9 @@ export class Pmove { // pmove_t /** * Returns a new player move engine. - * @returns {PmovePlayer} player move engine + * @returns fresh per-player movement state bound to this pmove world */ - newPlayerMove() { + newPlayerMove(): PmovePlayer { return new PmovePlayer(this); } -}; +} diff --git a/source/engine/server/Navigation.mjs b/source/engine/server/Navigation.mjs index 4adf5f4a..538d9a75 100644 --- a/source/engine/server/Navigation.mjs +++ b/source/engine/server/Navigation.mjs @@ -8,7 +8,7 @@ import Cvar from '../common/Cvar.ts'; import { CorruptedResourceError, MissingResourceError } from '../common/Errors.ts'; import { ServerEngineAPI } from '../common/GameAPIs.mjs'; import { BrushModel } from '../common/Mod.mjs'; -import { MIN_STEP_NORMAL, STEPSIZE } from '../common/Pmove.mjs'; +import { MIN_STEP_NORMAL, STEPSIZE } from '../common/Pmove.ts'; import { Face } from '../common/model/BaseModel.mjs'; import PlatformWorker from '../common/PlatformWorker.ts'; import WorkerManager from '../common/WorkerManager.ts'; @@ -1803,11 +1803,11 @@ export class Navigation { Con.DPrint('Navigation: node graph built with ' + this.graph.nodes.length + ' nodes.\n'); - this.save() + void this.save() .then(() => { Con.PrintSuccess('Navigation: navigation graph saved!\n'); if (Navigation.nav_build_process?.value) { - Cmd.ExecuteString('quit'); + void Cmd.ExecuteString('quit'); } }) .catch((err) => Con.PrintError('Navigation: failed to save navigation graph: ' + err + '\n')); diff --git a/source/engine/server/Server.mjs b/source/engine/server/Server.mjs index e5040adf..307cee0d 100644 --- a/source/engine/server/Server.mjs +++ b/source/engine/server/Server.mjs @@ -1,5 +1,5 @@ import Cvar from '../common/Cvar.ts'; -import { MoveVars, Pmove } from '../common/Pmove.mjs'; +import { MoveVars, Pmove } from '../common/Pmove.ts'; import Vector from '../../shared/Vector.ts'; import { SzBuffer } from '../network/MSG.ts'; import * as Protocol from '../network/Protocol.ts'; @@ -686,7 +686,7 @@ export default class SV { Con.Print(`[${client.name}@${client.netconnection.address}] ${cmd}\n`); Con.StartCapturing(); - Cmd.ExecuteString(cmd); + void Cmd.ExecuteString(cmd); const response = Con.StopCapturing(); message.writeByte(Protocol.svc.print); @@ -1056,7 +1056,7 @@ export default class SV { ); if (matchedCommand) { - Cmd.ExecuteString(input, client); + void Cmd.ExecuteString(input, client); } else { Con.Print(`${client.name} tried to ${input}!\n`); } diff --git a/source/engine/server/physics/ServerClientPhysics.mjs b/source/engine/server/physics/ServerClientPhysics.mjs index 41a90d9c..4f9e2c02 100644 --- a/source/engine/server/physics/ServerClientPhysics.mjs +++ b/source/engine/server/physics/ServerClientPhysics.mjs @@ -5,7 +5,7 @@ import { VELOCITY_EPSILON, } from './Defs.mjs'; import { ServerClient } from '../Client.mjs'; -import { PM_TYPE } from '../../common/Pmove.mjs'; +import { PM_TYPE } from '../../common/Pmove.ts'; import { BrushModel } from '../../common/Mod.mjs'; let { Host, SV, V } = registry; diff --git a/source/engine/server/physics/ServerCollision.mjs b/source/engine/server/physics/ServerCollision.mjs index 511c4b78..5f39f8a7 100644 --- a/source/engine/server/physics/ServerCollision.mjs +++ b/source/engine/server/physics/ServerCollision.mjs @@ -2,7 +2,7 @@ import Vector from '../../../shared/Vector.ts'; import * as Defs from '../../../shared/Defs.ts'; import CollisionModelSource, { createRegistryCollisionModelSource } from '../../common/CollisionModelSource.mjs'; import Mod, { BrushModel } from '../../common/Mod.mjs'; -import { BrushTrace, DIST_EPSILON, Trace as SharedTrace } from '../../common/Pmove.mjs'; +import { BrushTrace, DIST_EPSILON, Trace as SharedTrace } from '../../common/Pmove.ts'; import { eventBus, registry } from '../../registry.mjs'; import { BrushCollisionState, @@ -24,7 +24,7 @@ let { Con, SV } = registry; /** @typedef {import('../Client.mjs').ServerEdict} ServerEdict */ -/** @typedef {import('../../common/Pmove.mjs').Trace} SharedBrushTrace */ +/** @typedef {import('../../common/Pmove.ts').Trace} SharedBrushTrace */ eventBus.subscribe('registry.frozen', () => { Con = registry.Con; @@ -119,7 +119,7 @@ export class ServerCollision { /** * Convert a shared brush trace result into the server collision trace shape. - * @param {import('../../common/Pmove.mjs').Trace} brushTrace shared brush trace result + * @param {import('../../common/Pmove.ts').Trace} brushTrace shared brush trace result * @param {ServerEdict} ent entity that owns the brush model * @returns {CollisionTrace} server collision trace */ diff --git a/source/engine/server/physics/ServerCollisionSupport.mjs b/source/engine/server/physics/ServerCollisionSupport.mjs index 6e073c70..3e07f4f9 100644 --- a/source/engine/server/physics/ServerCollisionSupport.mjs +++ b/source/engine/server/physics/ServerCollisionSupport.mjs @@ -119,7 +119,7 @@ export class CollisionTrace { } /** - * @param {import('../../common/Pmove.mjs').Trace} brushTrace shared brush trace result + * @param {import('../../common/Pmove.ts').Trace} brushTrace shared brush trace result * @param {ServerEdict} ent entity that owns the brush model * @returns {CollisionTrace} server collision trace */ diff --git a/source/engine/server/physics/ServerLegacyHullCollision.mjs b/source/engine/server/physics/ServerLegacyHullCollision.mjs index f66d9cd1..e54e7285 100644 --- a/source/engine/server/physics/ServerLegacyHullCollision.mjs +++ b/source/engine/server/physics/ServerLegacyHullCollision.mjs @@ -1,6 +1,6 @@ import Vector from '../../../shared/Vector.ts'; import * as Defs from '../../../shared/Defs.ts'; -import { DIST_EPSILON } from '../../common/Pmove.mjs'; +import { DIST_EPSILON } from '../../common/Pmove.ts'; import { eventBus, registry } from '../../registry.mjs'; let { Con } = registry; diff --git a/source/engine/server/physics/ServerMovement.mjs b/source/engine/server/physics/ServerMovement.mjs index e7c8763b..3521c517 100644 --- a/source/engine/server/physics/ServerMovement.mjs +++ b/source/engine/server/physics/ServerMovement.mjs @@ -1,6 +1,6 @@ import Vector from '../../../shared/Vector.ts'; import * as Defs from '../../../shared/Defs.ts'; -import { STEPSIZE } from '../../common/Pmove.mjs'; +import { STEPSIZE } from '../../common/Pmove.ts'; import { ServerEdict } from '../Edict.mjs'; import { eventBus, registry } from '../../registry.mjs'; diff --git a/test/physics/brushtrace.test.mjs b/test/physics/brushtrace.test.mjs index eb5a026b..7de530dc 100644 --- a/test/physics/brushtrace.test.mjs +++ b/test/physics/brushtrace.test.mjs @@ -2,7 +2,7 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import Vector from '../../source/shared/Vector.ts'; -import { BrushTrace, DIST_EPSILON, Pmove } from '../../source/engine/common/Pmove.mjs'; +import { BrushTrace, DIST_EPSILON, Pmove } from '../../source/engine/common/Pmove.ts'; import { BrushSide } from '../../source/engine/common/model/BSP.mjs'; import { content } from '../../source/shared/Defs.ts'; diff --git a/test/physics/collision-regressions.test.mjs b/test/physics/collision-regressions.test.mjs index 73af9877..9423f2bd 100644 --- a/test/physics/collision-regressions.test.mjs +++ b/test/physics/collision-regressions.test.mjs @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; import Vector from '../../source/shared/Vector.ts'; import { content, flags, moveType, moveTypes, solid } from '../../source/shared/Defs.ts'; import { Brush, BrushModel, BrushSide } from '../../source/engine/common/model/BSP.mjs'; -import { BrushTrace, Hull, PMF, Pmove, PmovePlayer, Trace } from '../../source/engine/common/Pmove.mjs'; +import { BrushTrace, Hull, PMF, Pmove, PmovePlayer, Trace } from '../../source/engine/common/Pmove.ts'; import { BSP29Loader } from '../../source/engine/common/model/loaders/BSP29Loader.mjs'; import { eventBus, registry } from '../../source/engine/registry.mjs'; import { UserCmd } from '../../source/engine/network/Protocol.ts'; diff --git a/test/physics/map-pmove-harness.mjs b/test/physics/map-pmove-harness.mjs index ac0a136e..93b1cf11 100644 --- a/test/physics/map-pmove-harness.mjs +++ b/test/physics/map-pmove-harness.mjs @@ -3,7 +3,7 @@ import path from 'node:path'; import COMClass from '../../source/engine/common/Com.ts'; import Mod from '../../source/engine/common/Mod.mjs'; -import { PMF, Pmove } from '../../source/engine/common/Pmove.mjs'; +import { PMF, Pmove } from '../../source/engine/common/Pmove.ts'; import { UserCmd } from '../../source/engine/network/Protocol.ts'; import { eventBus, registry } from '../../source/engine/registry.mjs'; import Vector from '../../source/shared/Vector.ts'; diff --git a/test/physics/pmove.test.mjs b/test/physics/pmove.test.mjs index d7136497..8e31a9e9 100644 --- a/test/physics/pmove.test.mjs +++ b/test/physics/pmove.test.mjs @@ -6,7 +6,7 @@ import COMClass from '../../source/engine/common/Com.ts'; import Mod from '../../source/engine/common/Mod.mjs'; import Vector from '../../source/shared/Vector.ts'; import { content } from '../../source/shared/Defs.ts'; -import { DIST_EPSILON, PM_TYPE, PMF, Pmove, PmovePlayer, Trace } from '../../source/engine/common/Pmove.mjs'; +import { DIST_EPSILON, PM_TYPE, PMF, Pmove, PmovePlayer, Trace } from '../../source/engine/common/Pmove.ts'; import { UserCmd } from '../../source/engine/network/Protocol.ts'; import { eventBus, registry } from '../../source/engine/registry.mjs'; diff --git a/test/physics/server-collision.test.mjs b/test/physics/server-collision.test.mjs index edd17e4a..594b19b6 100644 --- a/test/physics/server-collision.test.mjs +++ b/test/physics/server-collision.test.mjs @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; import Vector from '../../source/shared/Vector.ts'; import { content, flags, moveType, moveTypes, solid } from '../../source/shared/Defs.ts'; import { BrushModel } from '../../source/engine/common/model/BSP.mjs'; -import { BrushTrace, Pmove } from '../../source/engine/common/Pmove.mjs'; +import { BrushTrace, Pmove } from '../../source/engine/common/Pmove.ts'; import { BSP29Loader } from '../../source/engine/common/model/loaders/BSP29Loader.mjs'; import { ServerCollision } from '../../source/engine/server/physics/ServerCollision.mjs'; import { ServerArea } from '../../source/engine/server/physics/ServerArea.mjs'; From aba084049913b20ad587a128aa6fd61ef42b4e33 Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 20:49:32 +0300 Subject: [PATCH 23/67] clean up of Host.mjs --- source/engine/common/Host.mjs | 2729 +++++++++++++++++---------------- 1 file changed, 1372 insertions(+), 1357 deletions(-) diff --git a/source/engine/common/Host.mjs b/source/engine/common/Host.mjs index 8c2da13c..a881584c 100644 --- a/source/engine/common/Host.mjs +++ b/source/engine/common/Host.mjs @@ -16,9 +16,6 @@ import { content, gameCapabilities } from '../../shared/Defs.ts'; import ClientLifecycle from '../client/ClientLifecycle.mjs'; import { Pmove } from './Pmove.ts'; -const Host = {}; - -export default Host; let { CL, COM, Con, Draw, IN, Key, M, Mod, NET, PR, R, S, SCR, SV, Sbar, Sys, V } = registry; @@ -42,1634 +39,1652 @@ eventBus.subscribe('registry.frozen', () => { V = registry.V; }); -Host.framecount = 0; - -Host.EndGame = function(message) { - Con.PrintSuccess('Host.EndGame: ' + message + '\n'); - if (CL.cls.demonum !== -1) { - CL.NextDemo(); - } else { - CL.Disconnect(); - M.Alert('Host.EndGame', message); - } -}; +class HostConsoleCommand extends ConsoleCommand { + /** + * @protected + * @returns {boolean} true, if it’s a cheat and cannot be invoked + */ + cheat() { + if (!SV.cheats.value) { + Host.ClientPrint(this.client, 'Cheats are not enabled on this server.\n'); + return true; + } -Host.Error = function(error) { - if (Host.inerror === true) { - throw new Error('throw new HostError: recursively entered'); - } - Host.inerror = true; - if (!registry.isDedicatedServer) { - SCR.EndLoadingPlaque(); - } - Con.PrintError('Host Error: ' + error + '\n'); - if (SV.server.active === true) { - Host.ShutdownServer(); - } - CL.Disconnect(); - CL.cls.demonum = -1; - Host.inerror = false; - M.Alert('Host Error', error); -}; - -Host.FindMaxClients = function() { - SV.svs.maxclients = 1; - SV.svs.maxclientslimit = Def.limits.clients; - SV.svs.clients.length = 0; - if (!registry.isDedicatedServer) { - CL.cls.state = Def.clientConnectionState.disconnected; - } - for (let i = 0; i < SV.svs.maxclientslimit; i++) { - SV.svs.clients.push(new ServerClient(i)); - } -}; - -Host.InitLocal = function() { - const commitHash = registry.buildConfig?.commitHash; - const version = commitHash ? `${Def.productVersion}+${commitHash}` : Def.productVersion; - - Host.version = new Cvar('version', version, Cvar.FLAG.READONLY); - - Host.InitCommands(); - Host.refreshrate = new Cvar('host_refreshrate', '0', Cvar.FLAG.ARCHIVE, 'Affects main loop sleep time, keep it at 0 for vsync-based timing. Vanilla recommendation is 60.'); - Host.framerate = new Cvar('host_framerate', '0'); - Host.speeds = new Cvar('host_speeds', '0'); - Host.ticrate = new Cvar('sys_ticrate', '0.05'); - Host.developer = new Cvar('developer', '0'); - Host.pausable = new Cvar('pausable', '1', Cvar.FLAG.SERVER); - Host.teamplay = new Cvar('teamplay', '0', Cvar.FLAG.SERVER); // actually a game cvar, but we need it here, since a bunch of server code is using it - - /** @deprecated use registry.isDedicatedServer instead, this is only made available to the game code */ - Host.dedicated = new Cvar('dedicated', registry.isDedicatedServer ? '1' : '0', Cvar.FLAG.READONLY, 'Set to 1, if running in dedicated server mode.'); - - eventBus.subscribe('cvar.changed', (name) => { - const cvar = Cvar.FindVar(name); - - // Automatically save when an archive Cvar changed - if ((cvar.flags & Cvar.FLAG.ARCHIVE) && Host.initialized) { - Host.WriteConfiguration(); - } - }); - - Host.FindMaxClients(); -}; - -Host.SendChatMessageToClient = function(client, name, message, direct = false) { - client.message.writeByte(Protocol.svc.chatmsg); - client.message.writeString(name); - client.message.writeString(message); - client.message.writeByte(direct ? 1 : 0); -}; - -/** - * @param {ServerClient} client recipient client - * @param {string} string text to send - */ -Host.ClientPrint = function(client, string) { - client.message.writeByte(Protocol.svc.print); - client.message.writeString(string); -}; - -Host.BroadcastPrint = function(string) { - for (const client of SV.svs.spawnedClients()) { - client.message.writeByte(Protocol.svc.print); - client.message.writeString(string); - } -}; - -/** - * - * @param {ServerClient} client - * @param {boolean} crash - * @param {string} reason - */ -Host.DropClient = function(client, crash, reason) { // TODO: refactor into ServerClient - if (NET.CanSendMessage(client.netconnection)) { - client.message.writeByte(Protocol.svc.disconnect); - client.message.writeString(reason); - NET.SendMessage(client.netconnection, client.message); + return false; } +} - if (!crash) { - if (client.edict && client.state === ServerClient.STATE.SPAWNED) { - const saveSelf = SV.server.gameAPI.self; - SV.server.gameAPI.ClientDisconnect(client.edict); - if (saveSelf !== undefined) { - SV.server.gameAPI.self = saveSelf; - } +export default class Host { + static developer = null; + static dedicated = null; + static framecount = 0; + static framerate = null; + static frametime = 0.0; + static initialized = false; + static inerror = false; + static isdown = false; + static noclip_anglehack = false; + static oldrealtime = 0.0; + static pausable = null; + static realtime = 0.0; + static refreshrate = null; + static speeds = null; + static teamplay = null; + static ticrate = null; + static version = null; + + static EndGame(message) { + Con.PrintSuccess('Host.EndGame: ' + message + '\n'); + if (CL.cls.demonum !== -1) { + CL.NextDemo(); + } else { + CL.Disconnect(); + M.Alert('Host.EndGame', message); } - Sys.Print('Client ' + client.name + ' removed\n'); - } else { - client.state = ServerClient.STATE.DROPASAP; - Sys.Print('Client ' + client.name + ' dropped\n'); - } - - NET.Close(client.netconnection); - - const { name, num } = client; - - client.clear(); - - NET.activeconnections--; - - eventBus.publish('server.client.disconnected', num, name); + }; - for (let i = 0; i < SV.svs.maxclients; i++) { - const client = SV.svs.clients[i]; - if (client.state <= ServerClient.STATE.CONNECTED) { - continue; + static Error(error) { + if (Host.inerror === true) { + throw new Error('throw new HostError: recursively entered'); } - // FIXME: consolidate into a single message - client.message.writeByte(Protocol.svc.updatename); - client.message.writeByte(num); - client.message.writeByte(0); - client.message.writeByte(Protocol.svc.updatefrags); - client.message.writeByte(num); - client.message.writeShort(0); - client.message.writeByte(Protocol.svc.updatecolors); - client.message.writeByte(num); - client.message.writeByte(0); - client.message.writeByte(Protocol.svc.updatepings); - client.message.writeByte(num); - client.message.writeShort(0); - } -}; - -Host.ShutdownServer = function(isCrashShutdown = false) { // TODO: SV duties - if (SV.server.active !== true) { - return; - } - eventBus.publish('server.shutting-down'); - SV.server.active = false; - if (!registry.isDedicatedServer && CL.cls.state === Def.clientConnectionState.connected) { - CL.Disconnect(); - } - const start = Sys.FloatTime(); let count; let i; - do { - count = 0; - for (i = 0; i < SV.svs.maxclients; i++) { // FIXME: this 1is completely broken, it won’t properly close connections - const client = SV.svs.clients[i]; - if (client.state < ServerClient.STATE.CONNECTED || client.message.cursize === 0) { - continue; - } - if (NET.CanSendMessage(client.netconnection)) { - NET.SendMessage(client.netconnection, client.message); - client.message.clear(); - continue; - } - NET.GetMessage(client.netconnection); - count++; + Host.inerror = true; + if (!registry.isDedicatedServer) { + SCR.EndLoadingPlaque(); } - if ((Sys.FloatTime() - start) > 3.0) { // this breaks a loop when the stuff on the top is stuck - break; + Con.PrintError('Host Error: ' + error + '\n'); + if (SV.server.active === true) { + Host.ShutdownServer(); } - } while (count !== 0); - for (i = 0; i < SV.svs.maxclients; i++) { - const client = SV.svs.clients[i]; - if (client.state >= ServerClient.STATE.CONNECTED) { - Host.DropClient(client, isCrashShutdown, 'Server shutting down'); + CL.Disconnect(); + CL.cls.demonum = -1; + Host.inerror = false; + M.Alert('Host Error', error); + }; + + static FindMaxClients() { + SV.svs.maxclients = 1; + SV.svs.maxclientslimit = Def.limits.clients; + SV.svs.clients.length = 0; + if (!registry.isDedicatedServer) { + CL.cls.state = Def.clientConnectionState.disconnected; } - } - SV.ShutdownServer(isCrashShutdown); - eventBus.publish('server.shutdown'); -}; - -Host.ConfigReady_f = function() { - eventBus.publish('host.config.loaded'); - Con.DPrint('Loaded configuration\n'); -}; - -Host.WriteConfiguration = function() { - Host.ScheduleInFuture('Host.WriteConfiguration', () => { - // never save a config during pending commands - if (Cmd.HasPendingCommands()) { - Con.PrintWarning('Writing configuration dismissed, pending commands outstanding. Try again later.\n'); - return; + for (let i = 0; i < SV.svs.maxclientslimit; i++) { + SV.svs.clients.push(new ServerClient(i)); } + }; - const config = ` -${(!registry.isDedicatedServer ? Key.WriteBindings() + '\n\n\n': '')} + static InitLocal() { + const commitHash = registry.buildConfig?.commitHash; + const version = commitHash ? `${Def.productVersion}+${commitHash}` : Def.productVersion; -${Cvar.WriteVariables()} + Host.version = new Cvar('version', version, Cvar.FLAG.READONLY); -configready -`; + Host.InitCommands(); + Host.refreshrate = new Cvar('host_refreshrate', '0', Cvar.FLAG.ARCHIVE, 'Affects main loop sleep time, keep it at 0 for vsync-based timing. Vanilla recommendation is 60.'); + Host.framerate = new Cvar('host_framerate', '0'); + Host.speeds = new Cvar('host_speeds', '0'); + Host.ticrate = new Cvar('sys_ticrate', '0.05'); + Host.developer = new Cvar('developer', '0'); + Host.pausable = new Cvar('pausable', '1', Cvar.FLAG.SERVER); + Host.teamplay = new Cvar('teamplay', '0', Cvar.FLAG.SERVER); // actually a game cvar, but we need it here, since a bunch of server code is using it - COM.WriteTextFile('config.cfg', config); - Con.DPrint('Wrote configuration\n'); - }, 5.000); -}; + /** @deprecated use registry.isDedicatedServer instead, this is only made available to the game code */ + Host.dedicated = new Cvar('dedicated', registry.isDedicatedServer ? '1' : '0', Cvar.FLAG.READONLY, 'Set to 1, if running in dedicated server mode.'); -Host.WriteConfiguration_f = function() { - Con.Print('Writing configuration\n'); - Host.WriteConfiguration(); -}; + eventBus.subscribe('cvar.changed', (name) => { + const cvar = Cvar.FindVar(name); -Host.ServerFrame = function() { // TODO: move to SV.ServerFrame - SV.server.gameAPI.frametime = Host.frametime; - SV.server.datagram.clear(); - SV.server.expedited_datagram.clear(); - SV.CheckForNewClients(); - SV.RunClients(); - if ((SV.server.paused !== true) && ((SV.svs.maxclients >= 2) || (!registry.isDedicatedServer && Key.dest.value === Key.dest.game))) { - SV.physics.physics(); - } - SV.RunScheduledGameCommands(); - SV.messages.sendClientMessages(); -}; - -Host._scheduledForNextFrame = []; -Host.ScheduleForNextFrame = function(callback) { - Host._scheduledForNextFrame.push(callback); -}; - -Host._scheduleInFuture = new Map(); -Host.ScheduleInFuture = function(name, callback, whenInSeconds) { - if (Host.isdown) { - // there’s no future when shutting down - callback(); - return; - } + // Automatically save when an archive Cvar changed + if ((cvar.flags & Cvar.FLAG.ARCHIVE) && Host.initialized) { + Host.WriteConfiguration(); + } + }); - if (Host._scheduleInFuture.has(name)) { - return; - } + Host.FindMaxClients(); + }; - Host._scheduleInFuture.set(name, { - time: Host.realtime + whenInSeconds, - callback, - }); -}; - -Host._Frame = async function() { - Host.realtime = Sys.FloatTime(); - Host.frametime = Host.realtime - Host.oldrealtime; - Host.oldrealtime = Host.realtime; - if (Host.framerate.value > 0) { - Host.frametime = Host.framerate.value; - } else { - if (Host.frametime > 0.1) { - Host.frametime = 0.1; - } else if (Host.frametime < 0.001) { - Host.frametime = 0.001; - } - } + static SendChatMessageToClient(client, name, message, direct = false) { + client.message.writeByte(Protocol.svc.chatmsg); + client.message.writeString(name); + client.message.writeString(message); + client.message.writeByte(direct ? 1 : 0); + }; - // check all scheduled things for the next frame - while (Host._scheduledForNextFrame.length > 0) { - const callback = Host._scheduledForNextFrame.shift(); - await callback(); - } + /** + * @param {ServerClient} client recipient client + * @param {string} string text to send + */ + static ClientPrint(client, string) { + client.message.writeByte(Protocol.svc.print); + client.message.writeString(string); + }; - // check what’s scheduled in future - for (const [name, { time, callback }] of Host._scheduleInFuture.entries()) { - if (time > Host.realtime) { - continue; + static BroadcastPrint(string) { + for (const client of SV.svs.spawnedClients()) { + client.message.writeByte(Protocol.svc.print); + client.message.writeString(string); } + }; - await callback(); - Host._scheduleInFuture.delete(name); - } - - if (registry.isDedicatedServer) { - Cmd.Execute(); - - if (SV.server.active === true) { - if (Host.speeds.value !== 0) { - console.profile('Host.ServerFrame'); - } - - Host.ServerFrame(); - - if (Host.speeds.value !== 0) { - console.profileEnd('Host.ServerFrame'); + /** + * + * @param {ServerClient} client + * @param {boolean} crash + * @param {string} reason + */ + static DropClient(client, crash, reason) { // TODO: refactor into ServerClient + if (NET.CanSendMessage(client.netconnection)) { + client.message.writeByte(Protocol.svc.disconnect); + client.message.writeString(reason); + NET.SendMessage(client.netconnection, client.message); + } + + if (!crash) { + if (client.edict && client.state === ServerClient.STATE.SPAWNED) { + const saveSelf = SV.server.gameAPI.self; + SV.server.gameAPI.ClientDisconnect(client.edict); + if (saveSelf !== undefined) { + SV.server.gameAPI.self = saveSelf; + } } + Sys.Print('Client ' + client.name + ' removed\n'); + } else { + client.state = ServerClient.STATE.DROPASAP; + Sys.Print('Client ' + client.name + ' dropped\n'); } - // TODO: add times - - Host.framecount++; - - return; - } - - if (CL.cls.state === Def.clientConnectionState.connecting) { - CL.CheckConnectingState(); - SCR.UpdateScreen(); - return; - } + NET.Close(client.netconnection); - Cmd.Execute(); + const { name, num } = client; - if (CL.cls.state === Def.clientConnectionState.connected) { - CL.ReadFromServer(); - } + client.clear(); - if (Host.speeds.value !== 0) { - console.profile('CL.ClientFrame'); - } - CL.ClientFrame(); - if (Host.speeds.value !== 0) { - console.profileEnd('CL.ClientFrame'); - } + NET.activeconnections--; - CL.SendCmd(); + eventBus.publish('server.client.disconnected', num, name); - if (SV.server.active && !SV.svs.changelevelIssued) { - if (Host.speeds.value !== 0) { - console.profile('Host.ServerFrame'); + for (let i = 0; i < SV.svs.maxclients; i++) { + const client = SV.svs.clients[i]; + if (client.state <= ServerClient.STATE.CONNECTED) { + continue; + } + // FIXME: consolidate into a single message + client.message.writeByte(Protocol.svc.updatename); + client.message.writeByte(num); + client.message.writeByte(0); + client.message.writeByte(Protocol.svc.updatefrags); + client.message.writeByte(num); + client.message.writeShort(0); + client.message.writeByte(Protocol.svc.updatecolors); + client.message.writeByte(num); + client.message.writeByte(0); + client.message.writeByte(Protocol.svc.updatepings); + client.message.writeByte(num); + client.message.writeShort(0); } + }; - Host.ServerFrame(); - - if (Host.speeds.value !== 0) { - console.profileEnd('Host.ServerFrame'); + static ShutdownServer(isCrashShutdown = false) { // TODO: SV duties + if (SV.server.active !== true) { + return; } - } + eventBus.publish('server.shutting-down'); + SV.server.active = false; + if (!registry.isDedicatedServer && CL.cls.state === Def.clientConnectionState.connected) { + CL.Disconnect(); + } + const start = Sys.FloatTime(); let count; let i; + do { + count = 0; + for (i = 0; i < SV.svs.maxclients; i++) { // FIXME: this 1is completely broken, it won’t properly close connections + const client = SV.svs.clients[i]; + if (client.state < ServerClient.STATE.CONNECTED || client.message.cursize === 0) { + continue; + } + if (NET.CanSendMessage(client.netconnection)) { + NET.SendMessage(client.netconnection, client.message); + client.message.clear(); + continue; + } + NET.GetMessage(client.netconnection); + count++; + } + if ((Sys.FloatTime() - start) > 3.0) { // this breaks a loop when the stuff on the top is stuck + break; + } + } while (count !== 0); + for (i = 0; i < SV.svs.maxclients; i++) { + const client = SV.svs.clients[i]; + if (client.state >= ServerClient.STATE.CONNECTED) { + Host.DropClient(client, isCrashShutdown, 'Server shutting down'); + } + } + SV.ShutdownServer(isCrashShutdown); + eventBus.publish('server.shutdown'); + }; - // Set up prediction for other players - CL.SetUpPlayerPrediction(false); + static ConfigReady_f() { + eventBus.publish('host.config.loaded'); + Con.DPrint('Loaded configuration\n'); + }; - if (Host.speeds.value !== 0) { - console.profile('CL.PredictMove'); - } + static WriteConfiguration() { + Host.ScheduleInFuture('Host.WriteConfiguration', () => { + // never save a config during pending commands + if (Cmd.HasPendingCommands()) { + Con.PrintWarning('Writing configuration dismissed, pending commands outstanding. Try again later.\n'); + return; + } - // do client side motion prediction - CL.PredictMove(); + const config = ` + ${(!registry.isDedicatedServer ? Key.WriteBindings() + '\n\n\n': '')} - if (Host.speeds.value !== 0) { - console.profileEnd('CL.PredictMove'); - } + ${Cvar.WriteVariables()} - // Set up prediction for other players - CL.SetUpPlayerPrediction(true); + configready + `; - // build a refresh entity list - CL.state.clientEntities.emit(); + COM.WriteTextFile('config.cfg', config); + Con.DPrint('Wrote configuration\n'); + }, 5.000); + }; - SCR.UpdateScreen(); + static WriteConfiguration_f() { + Con.Print('Writing configuration\n'); + Host.WriteConfiguration(); + }; - if (Host.speeds.value !== 0) { - console.profile('S.Update'); - } + static ServerFrame() { // TODO: move to SV.ServerFrame + SV.server.gameAPI.frametime = Host.frametime; + SV.server.datagram.clear(); + SV.server.expedited_datagram.clear(); + SV.CheckForNewClients(); + SV.RunClients(); + if ((SV.server.paused !== true) && ((SV.svs.maxclients >= 2) || (!registry.isDedicatedServer && Key.dest.value === Key.dest.game))) { + SV.physics.physics(); + } + SV.RunScheduledGameCommands(); + SV.messages.sendClientMessages(); + }; - if (CL.cls.signon === 4) { - S.Update(R.refdef.vieworg, R.vpn, R.vright, R.vup, R.viewleaf ? R.viewleaf.contents <= content.CONTENT_WATER : false); - } else { - S.Update(Vector.origin, Vector.origin, Vector.origin, Vector.origin, false); - } - CDAudio.Update(); + static _scheduledForNextFrame = []; + static ScheduleForNextFrame(callback) { + Host._scheduledForNextFrame.push(callback); + }; - if (Host.speeds.value !== 0) { - console.profileEnd('S.Update'); - } + static _scheduleInFuture = new Map(); + static ScheduleInFuture(name, callback, whenInSeconds) { + if (Host.isdown) { + // there’s no future when shutting down + callback(); + return; + } - Host.framecount++; -}; + if (Host._scheduleInFuture.has(name)) { + return; + } -let inHandleCrash = false; + Host._scheduleInFuture.set(name, { + time: Host.realtime + whenInSeconds, + callback, + }); + }; -// TODO: Sys.Init can handle a crash now since we are main looping without setInterval -Host.HandleCrash = function(e) { - if (e instanceof HostError) { - Host.Error(e.message); - return; - } - if (inHandleCrash) { - console.error(e); - // eslint-disable-next-line no-debugger - debugger; - return; - } - inHandleCrash = true; - Con.PrintError((e.name ?? e.constructor.name) + ': ' + e.message + '\n'); - eventBus.publish('host.crash', e); - Sys.Quit(); -}; - -Host.Frame = async function() { - if (inHandleCrash) { - return; - } + static async _Frame() { + Host.realtime = Sys.FloatTime(); + Host.frametime = Host.realtime - Host.oldrealtime; + Host.oldrealtime = Host.realtime; + if (Host.framerate.value > 0) { + Host.frametime = Host.framerate.value; + } else { + if (Host.frametime > 0.1) { + Host.frametime = 0.1; + } else if (Host.frametime < 0.001) { + Host.frametime = 0.001; + } + } - try { - await Host._Frame(); - } catch (e) { - Host.HandleCrash(e); - } -}; + // check all scheduled things for the next frame + while (Host._scheduledForNextFrame.length > 0) { + const callback = Host._scheduledForNextFrame.shift(); + await callback(); + } -Host.Init = async function() { - Host.oldrealtime = Sys.FloatTime(); - Cmd.Init(); - Cvar.Init(); + // check what’s scheduled in future + for (const [name, { time, callback }] of Host._scheduleInFuture.entries()) { + if (time > Host.realtime) { + continue; + } - V.Init(); // required for V.CalcRoll + await callback(); + Host._scheduleInFuture.delete(name); + } - if (!registry.isDedicatedServer) { - Chase.Init(); - } + if (registry.isDedicatedServer) { + Cmd.Execute(); + + if (SV.server.active === true) { + if (Host.speeds.value !== 0) { + console.profile('Host.ServerFrame'); + } - await COM.Init(); - Host.InitLocal(); + Host.ServerFrame(); - if (!registry.isDedicatedServer) { - Key.Init(); - } + if (Host.speeds.value !== 0) { + console.profileEnd('Host.ServerFrame'); + } + } - Con.Init(); - await PR.Init(); - Mod.Init(); - NET.Init(); - Pmove.Init(); - SV.Init(); + // TODO: add times - if (!registry.isDedicatedServer) { - S.Init(); - VID.Init(); - await Draw.Init(); - await R.Init(); - await M.Init(); - await CL.Init(); - SCR.Init(); - CDAudio.Init(); + Host.framecount++; - if (!CL.gameCapabilities.includes(gameCapabilities.CAP_HUD_INCLUDES_SBAR)) { - await Sbar.Init(); + return; } - IN.Init(); - } - - Cmd.text = 'exec better-quake.rc\n' + Cmd.text; + if (CL.cls.state === Def.clientConnectionState.connecting) { + CL.CheckConnectingState(); + SCR.UpdateScreen(); + return; + } - // eslint-disable-next-line require-atomic-updates - Host.initialized = true; - Sys.Print('========Host Initialized=========\n'); + Cmd.Execute(); - eventBus.publish('host.ready'); -}; + if (CL.cls.state === Def.clientConnectionState.connected) { + CL.ReadFromServer(); + } -Host.Shutdown = function() { - if (Host.isdown === true) { - Sys.Print('recursive shutdown\n'); - return; - } - eventBus.publish('host.shutting-down'); - Host.isdown = true; - Host.WriteConfiguration(); - if (!registry.isDedicatedServer) { - S.Shutdown(); - CDAudio.Shutdown(); - } - NET.Shutdown(); - if (!registry.isDedicatedServer) { - IN.Shutdown(); - VID.Shutdown(); - } - Pmove.Shutdown(); - Cmd.Shutdown(); - Cvar.Shutdown(); - eventBus.publish('host.shutdown'); -}; - -// Commands - -Host.Quit_f = function() { - if (!registry.isDedicatedServer) { - if (Key.dest.value !== Key.dest.console) { - M.Menu_Quit_f(); - return; + if (Host.speeds.value !== 0) { + console.profile('CL.ClientFrame'); + } + CL.ClientFrame(); + if (Host.speeds.value !== 0) { + console.profileEnd('CL.ClientFrame'); } - } - if (SV.server.active === true) { - Host.ShutdownServer(); - } + CL.SendCmd(); - COM.Shutdown(); - Sys.Quit(); -}; + if (SV.server.active && !SV.svs.changelevelIssued) { + if (Host.speeds.value !== 0) { + console.profile('Host.ServerFrame'); + } -Host.Status_f = function() { - /** @type {Function} */ - let print; - if (!this.client) { - if (!SV.server.active) { - if (registry.isDedicatedServer) { - Con.Print('No active server\n'); - return; + Host.ServerFrame(); + + if (Host.speeds.value !== 0) { + console.profileEnd('Host.ServerFrame'); } - this.forward(); - return; } - print = Con.Print; - } else { - const client = this.client; - print = (text) => { - Host.ClientPrint(client, text); - }; - } - print('hostname: ' + NET.hostname.string + '\n'); - print('address : ' + NET.GetListenAddress() + '\n'); - print('version : ' + Host.version.string + ' (' + SV.server.gameVersion + ')\n'); - print('map : ' + SV.server.mapname + '\n'); - print('game : ' + SV.server.gameName + '\n'); - print('edicts : ' + SV.server.num_edicts + ' used of ' + SV.server.edicts.length + ' allocated\n'); - print('players : ' + NET.activeconnections + ' active (' + SV.svs.maxclients + ' max)\n\n'); - - const lines = []; - - for (let i = 0; i < SV.svs.maxclients; i++) { - /** @type {ServerClient} */ - const client = SV.svs.clients[i]; - - if (client.state < ServerClient.STATE.CONNECTED) { - continue; - } - - const parts = [ - client.num.toString().padStart(3), - client.name.substring(0, 19).padEnd(19), - client.uniqueId.substring(0, 19).padEnd(19), - Q.secsToTime(NET.time - client.netconnection.connecttime).padEnd(9), - client.ping.toFixed(0).padStart(4), - new Number(0).toFixed(0).padStart(4), // TODO: add loss - ServerClient.STATE.toKey(client.state).padEnd(10), - client.netconnection.address, - ]; - - lines.push(parts.join(' | ') + '\n'); - } - if (lines.length === 0) { - return; - } + // Set up prediction for other players + CL.SetUpPlayerPrediction(false); - print('id | name | unique id | play time | ping | loss | state | adr\n'); - print('----|---------------------|---------------------|-----------|------|------|------------|-----\n'); + if (Host.speeds.value !== 0) { + console.profile('CL.PredictMove'); + } - for (const line of lines) { - print(line); - } -}; + // do client side motion prediction + CL.PredictMove(); -class HostConsoleCommand extends ConsoleCommand { - /** - * @protected - * @returns {boolean} true, if it’s a cheat and cannot be invoked - */ - cheat() { - if (!SV.cheats.value) { - Host.ClientPrint(this.client, 'Cheats are not enabled on this server.\n'); - return true; + if (Host.speeds.value !== 0) { + console.profileEnd('CL.PredictMove'); } - return false; - } -} + // Set up prediction for other players + CL.SetUpPlayerPrediction(true); -Host.God_f = class extends HostConsoleCommand { - run() { - if (this.forward()) { - return; - } - if (this.cheat()) { - return; - } - const client = this.client; - client.edict.entity.flags ^= Defs.flags.FL_GODMODE; - if ((client.edict.entity.flags & Defs.flags.FL_GODMODE) === 0) { - Host.ClientPrint(client, 'godmode OFF\n'); - } else { - Host.ClientPrint(client, 'godmode ON\n'); - } - } -}; + // build a refresh entity list + CL.state.clientEntities.emit(); -Host.Notarget_f = class extends HostConsoleCommand { - run() { - if (this.forward()) { - return; - } - if (this.cheat()) { - return; + SCR.UpdateScreen(); + + if (Host.speeds.value !== 0) { + console.profile('S.Update'); } - const client = this.client; - client.edict.entity.flags ^= Defs.flags.FL_NOTARGET; - if ((client.edict.entity.flags & Defs.flags.FL_NOTARGET) === 0) { - Host.ClientPrint(client, 'notarget OFF\n'); + + if (CL.cls.signon === 4) { + S.Update(R.refdef.vieworg, R.vpn, R.vright, R.vup, R.viewleaf ? R.viewleaf.contents <= content.CONTENT_WATER : false); } else { - Host.ClientPrint(client, 'notarget ON\n'); + S.Update(Vector.origin, Vector.origin, Vector.origin, Vector.origin, false); } - } -}; + CDAudio.Update(); -Host.Noclip_f = class extends HostConsoleCommand { - run() { - if (this.forward()) { - return; + if (Host.speeds.value !== 0) { + console.profileEnd('S.Update'); } - if (this.cheat()) { + + Host.framecount++; + }; + + static #inHandleCrash = false; + + // TODO: Sys.Init can handle a crash now since we are main looping without setInterval + static HandleCrash(e) { + if (e instanceof HostError) { + Host.Error(e.message); return; } - const client = this.client; - if (client.edict.entity.movetype !== Defs.moveType.MOVETYPE_NOCLIP) { - Host.noclip_anglehack = true; - client.edict.entity.movetype = Defs.moveType.MOVETYPE_NOCLIP; - Host.ClientPrint(client, 'noclip ON\n'); + if (Host.#inHandleCrash) { + console.error(e); + // eslint-disable-next-line no-debugger + debugger; return; } - Host.noclip_anglehack = false; - client.edict.entity.movetype = Defs.moveType.MOVETYPE_WALK; - Host.ClientPrint(client, 'noclip OFF\n'); - } -}; + Host.#inHandleCrash = true; + Con.PrintError((e.name ?? e.constructor.name) + ': ' + e.message + '\n'); + eventBus.publish('host.crash', e); + Sys.Quit(); + }; -Host.Fly_f = class extends HostConsoleCommand { - run() { - if (this.forward()) { - return; - } - if (this.cheat()) { + static async Frame() { + if (Host.#inHandleCrash) { return; } - const client = this.client; - if (client.edict.entity.movetype !== Defs.moveType.MOVETYPE_FLY) { - client.edict.entity.movetype = Defs.moveType.MOVETYPE_FLY; - Host.ClientPrint(client, 'flymode ON\n'); - return; + + try { + await Host._Frame(); + } catch (e) { + Host.HandleCrash(e); } - client.edict.entity.movetype = Defs.moveType.MOVETYPE_WALK; - Host.ClientPrint(client, 'flymode OFF\n'); - } -}; + }; -Host.Ping_f = function() { - if (this.forward()) { - return; - } + static async Init() { + Host.oldrealtime = Sys.FloatTime(); + Cmd.Init(); + Cvar.Init(); - const recipientClient = this.client; + V.Init(); // required for V.CalcRoll - Host.ClientPrint(recipientClient, 'Client ping times:\n'); + if (!registry.isDedicatedServer) { + Chase.Init(); + } - for (let i = 0; i < SV.svs.maxclients; i++) { - /** @type {ServerClient} */ - const client = SV.svs.clients[i]; + await COM.Init(); + Host.InitLocal(); - if (client.state < ServerClient.STATE.CONNECTED) { - continue; + if (!registry.isDedicatedServer) { + Key.Init(); } - let total = 0; + Con.Init(); + await PR.Init(); + Mod.Init(); + NET.Init(); + Pmove.Init(); + SV.Init(); + + if (!registry.isDedicatedServer) { + S.Init(); + VID.Init(); + await Draw.Init(); + await R.Init(); + await M.Init(); + await CL.Init(); + SCR.Init(); + CDAudio.Init(); + + if (!CL.gameCapabilities.includes(gameCapabilities.CAP_HUD_INCLUDES_SBAR)) { + await Sbar.Init(); + } - for (let j = 0; j < client.ping_times.length; j++) { - total += client.ping_times[j]; + IN.Init(); } - Host.ClientPrint(recipientClient, (total * 62.5).toFixed(0).padStart(3) + ' ' + client.name + '\n'); - } -}; + Cmd.text = 'exec better-quake.rc\n' + Cmd.text; -Host.Map_f = function(mapname, ...spawnparms) { - if (mapname === undefined) { - Con.Print('Usage: map \n'); - return; - } - if (this.client) { - return; - } - // if (!SV.HasMap(mapname)) { - // Con.Print(`No such map: ${mapname}\n`); - // return; - // } - if (!registry.isDedicatedServer) { - CL.cls.demonum = -1; - CL.Disconnect(); - } - Host.ShutdownServer(); // CR: this is the reason why you would need to use changelevel on Counter-Strike 1.6 etc. - if (!registry.isDedicatedServer) { - Key.dest.value = Key.dest.game; - SCR.BeginLoadingPlaque(); - } - SV.svs.serverflags = 0; + // eslint-disable-next-line require-atomic-updates + Host.initialized = true; + Sys.Print('========Host Initialized=========\n'); - if (!registry.isDedicatedServer) { - CL.SetConnectingStep(5, 'Spawning server'); - } - - if (!registry.isDedicatedServer) { - CL.cls.spawnparms = spawnparms.join(' '); - } + eventBus.publish('host.ready'); + }; - Host.ScheduleForNextFrame(async () => { - if (!await SV.SpawnServer(mapname)) { - SV.ShutdownServer(false); - throw new HostError('Could not spawn server with map ' + mapname); + static Shutdown() { + if (Host.isdown === true) { + Sys.Print('recursive shutdown\n'); + return; } - + eventBus.publish('host.shutting-down'); + Host.isdown = true; + Host.WriteConfiguration(); if (!registry.isDedicatedServer) { - CL.SetConnectingStep(null, null); + S.Shutdown(); + CDAudio.Shutdown(); } + NET.Shutdown(); + if (!registry.isDedicatedServer) { + IN.Shutdown(); + VID.Shutdown(); + } + Pmove.Shutdown(); + Cmd.Shutdown(); + Cvar.Shutdown(); + eventBus.publish('host.shutdown'); + }; + // Commands + + static Quit_f() { if (!registry.isDedicatedServer) { - CL.Connect('local'); + if (Key.dest.value !== Key.dest.console) { + M.Menu_Quit_f(); + return; + } } - }); -}; -Host.Changelevel_f = function(mapname) { - if (mapname === undefined) { - Con.Print('Usage: changelevel \n'); - return; - } + if (SV.server.active === true) { + Host.ShutdownServer(); + } - if (!SV.server.active || (!registry.isDedicatedServer && CL.cls.demoplayback)) { - Con.Print('Only the server may changelevel\n'); - return; - } + COM.Shutdown(); + Sys.Quit(); + }; - // if (!SV.HasMap(mapname)) { - // throw new HostError(`No such map: ${mapname}`); - // } + static Status_f() { + /** @type {Function} */ + let print; + if (!this.client) { + if (!SV.server.active) { + if (registry.isDedicatedServer) { + Con.Print('No active server\n'); + return; + } + this.forward(); + return; + } + print = Con.Print; + } else { + const client = this.client; + print = (text) => { + Host.ClientPrint(client, text); + }; + } + print('hostname: ' + NET.hostname.string + '\n'); + print('address : ' + NET.GetListenAddress() + '\n'); + print('version : ' + Host.version.string + ' (' + SV.server.gameVersion + ')\n'); + print('map : ' + SV.server.mapname + '\n'); + print('game : ' + SV.server.gameName + '\n'); + print('edicts : ' + SV.server.num_edicts + ' used of ' + SV.server.edicts.length + ' allocated\n'); + print('players : ' + NET.activeconnections + ' active (' + SV.svs.maxclients + ' max)\n\n'); + + const lines = []; + + for (let i = 0; i < SV.svs.maxclients; i++) { + /** @type {ServerClient} */ + const client = SV.svs.clients[i]; - SV.svs.changelevelIssued = true; + if (client.state < ServerClient.STATE.CONNECTED) { + continue; + } - for (let i = 0; i < SV.svs.maxclients; i++) { - const client = SV.svs.clients[i]; - if (client.state < ServerClient.STATE.CONNECTED) { - continue; - } - client.message.writeByte(Protocol.svc.changelevel); - client.message.writeString(mapname); - } + const parts = [ + client.num.toString().padStart(3), + client.name.substring(0, 19).padEnd(19), + client.uniqueId.substring(0, 19).padEnd(19), + Q.secsToTime(NET.time - client.netconnection.connecttime).padEnd(9), + client.ping.toFixed(0).padStart(4), + new Number(0).toFixed(0).padStart(4), // TODO: add loss + ServerClient.STATE.toKey(client.state).padEnd(10), + client.netconnection.address, + ]; - if (!registry.isDedicatedServer) { - // this hack allows us to show the loading plaque and resetting the client renderer - CL.cls.changelevel = true; - CL.cls.signon = 0; - } + lines.push(parts.join(' | ') + '\n'); + } - Host.ScheduleForNextFrame(async () => { - SV.SaveSpawnparms(); + if (lines.length === 0) { + return; + } - Con.DPrint('Host.Changelevel_f: changing level to ' + mapname + '\n'); + print('id | name | unique id | play time | ping | loss | state | adr\n'); + print('----|---------------------|---------------------|-----------|------|------|------------|-----\n'); - if (!await SV.SpawnServer(mapname)) { - SV.ShutdownServer(false); - throw new HostError('Could not spawn server for changelevel to ' + mapname); + for (const line of lines) { + print(line); } + }; - Con.DPrint('Host.Changelevel_f: spawned server for changelevel to ' + mapname + '\n'); + static God_f = class extends HostConsoleCommand { + run() { + if (this.forward()) { + return; + } + if (this.cheat()) { + return; + } + const client = this.client; + client.edict.entity.flags ^= Defs.flags.FL_GODMODE; + if ((client.edict.entity.flags & Defs.flags.FL_GODMODE) === 0) { + Host.ClientPrint(client, 'godmode OFF\n'); + } else { + Host.ClientPrint(client, 'godmode ON\n'); + } + } + }; - if (!registry.isDedicatedServer) { - CL.SetConnectingStep(null, null); + static Notarget_f = class extends HostConsoleCommand { + run() { + if (this.forward()) { + return; + } + if (this.cheat()) { + return; + } + const client = this.client; + client.edict.entity.flags ^= Defs.flags.FL_NOTARGET; + if ((client.edict.entity.flags & Defs.flags.FL_NOTARGET) === 0) { + Host.ClientPrint(client, 'notarget OFF\n'); + } else { + Host.ClientPrint(client, 'notarget ON\n'); + } } - }); -}; + }; -Host.Restart_f = function() { - if ((SV.server.active) && (registry.isDedicatedServer || !CL.cls.demoplayback && !this.client)) { - void Cmd.ExecuteString(`map ${SV.server.mapname}`); - } -}; + static Noclip_f = class extends HostConsoleCommand { + run() { + if (this.forward()) { + return; + } + if (this.cheat()) { + return; + } + const client = this.client; + if (client.edict.entity.movetype !== Defs.moveType.MOVETYPE_NOCLIP) { + Host.noclip_anglehack = true; + client.edict.entity.movetype = Defs.moveType.MOVETYPE_NOCLIP; + Host.ClientPrint(client, 'noclip ON\n'); + return; + } + Host.noclip_anglehack = false; + client.edict.entity.movetype = Defs.moveType.MOVETYPE_WALK; + Host.ClientPrint(client, 'noclip OFF\n'); + } + }; -// NOTE: this is the dedicated server version of disconnect -Host.Disconnect_f = function() { - if (!SV.server.active) { - Con.Print('No active server\n'); - return; - } + static Fly_f = class extends HostConsoleCommand { + run() { + if (this.forward()) { + return; + } + if (this.cheat()) { + return; + } + const client = this.client; + if (client.edict.entity.movetype !== Defs.moveType.MOVETYPE_FLY) { + client.edict.entity.movetype = Defs.moveType.MOVETYPE_FLY; + Host.ClientPrint(client, 'flymode ON\n'); + return; + } + client.edict.entity.movetype = Defs.moveType.MOVETYPE_WALK; + Host.ClientPrint(client, 'flymode OFF\n'); + } + }; - Host.ShutdownServer(); -}; + static Ping_f() { + if (this.forward()) { + return; + } -Host.Reconnect_f = function() { - if (registry.isDedicatedServer) { - Con.Print('cannot reconnect in dedicated server mode\n'); - return; - } + const recipientClient = this.client; - Con.PrintWarning('NOT IMPLEMENTED: reconnect\n'); // TODO: reimplement reconnect here -}; + Host.ClientPrint(recipientClient, 'Client ping times:\n'); -Host.Connect_f = function(address) { - if (address === undefined) { - Con.Print('Usage: connect
\n'); - Con.Print(' -
can be "self", connecting to the current domain name\n'); - return; - } + for (let i = 0; i < SV.svs.maxclients; i++) { + /** @type {ServerClient} */ + const client = SV.svs.clients[i]; - if (registry.isDedicatedServer) { - Con.Print('cannot connect to another server in dedicated server mode\n'); - return; - } + if (client.state < ServerClient.STATE.CONNECTED) { + continue; + } - CL.cls.demonum = -1; - if (CL.cls.demoplayback === true) { - CL.StopPlayback(); - CL.Disconnect(); - } + let total = 0; - if (address === 'self') { - const url = new URL(location.href); - CL.Connect((url.protocol === 'https:' ? 'wss' : 'ws') + '://' + url.host + url.pathname + (!url.pathname.endsWith('/') ? '/' : '') + 'api/'); - } else { - CL.Connect(address); - } + for (let j = 0; j < client.ping_times.length; j++) { + total += client.ping_times[j]; + } - CL.cls.signon = 0; -}; + Host.ClientPrint(recipientClient, (total * 62.5).toFixed(0).padStart(3) + ' ' + client.name + '\n'); + } + }; -Host.Savegame_f = function(savename) { - if (this.client) { - return; - } - if (savename === undefined) { - Con.Print('Usage: save \n'); - return; - } - if (SV.server.active !== true) { - Con.PrintWarning('Not playing a local game.\n'); - return; - } - if (CL.state.intermission !== 0) { - Con.PrintWarning('Can\'t save in intermission.\n'); - return; - } - if (SV.svs.maxclients !== 1) { - Con.PrintWarning('Can\'t save multiplayer games.\n'); - return; - } - if (savename.indexOf('..') !== -1) { - Con.PrintWarning('Relative pathnames are not allowed.\n'); - return; - } - const client = SV.svs.clients[0]; - if (client.state >= ServerClient.STATE.CONNECTED) { - if (client.edict.entity.health <= 0.0) { - Con.PrintWarning('Can\'t savegame with a dead player\n'); + static Map_f(mapname, ...spawnparms) { + if (mapname === undefined) { + Con.Print('Usage: map \n'); return; } - } - - const gamestate = { - version: Def.gamestateVersion, - gameversion: SV.server.gameVersion, - comment: CL.state.levelname, // TODO: ask the game for a comment - spawn_parms: client.spawn_parms, - mapname: SV.server.mapname, - time: SV.server.time, - lightstyles: SV.server.lightstyles, - globals: null, - cvars: [...Cvar.Filter((cvar) => cvar.flags & (Cvar.FLAG.SERVER | Cvar.FLAG.GAME))].map((cvar) => [cvar.name, cvar.string]), - clientdata: null, - edicts: [], - num_edicts: SV.server.num_edicts, - // TODO: client entities - particles: R.SerializeParticles(), - }; + if (this.client) { + return; + } + // if (!SV.HasMap(mapname)) { + // Con.Print(`No such map: ${mapname}\n`); + // return; + // } + if (!registry.isDedicatedServer) { + CL.cls.demonum = -1; + CL.Disconnect(); + } + Host.ShutdownServer(); // CR: this is the reason why you would need to use changelevel on Counter-Strike 1.6 etc. + if (!registry.isDedicatedServer) { + Key.dest.value = Key.dest.game; + SCR.BeginLoadingPlaque(); + } + SV.svs.serverflags = 0; - if (CL.state.gameAPI) { - gamestate.clientdata = CL.state.gameAPI.saveGame(); - } + if (!registry.isDedicatedServer) { + CL.SetConnectingStep(5, 'Spawning server'); + } - // IDEA: we could actually compress this by using a list of common fields - for (const edict of SV.server.edicts) { - if (edict.isFree()) { - gamestate.edicts.push(null); - continue; + if (!registry.isDedicatedServer) { + CL.cls.spawnparms = spawnparms.join(' '); } - gamestate.edicts.push([edict.entity.classname, edict.entity.serialize()]); - } + Host.ScheduleForNextFrame(async () => { + if (!await SV.SpawnServer(mapname)) { + SV.ShutdownServer(false); + throw new HostError('Could not spawn server with map ' + mapname); + } - gamestate.globals = SV.server.gameAPI.serialize(); + if (!registry.isDedicatedServer) { + CL.SetConnectingStep(null, null); + } - const name = COM.DefaultExtension(savename, '.json'); - Con.Print('Saving game to ' + name + '...\n'); - if (COM.WriteTextFile(name, JSON.stringify(gamestate))) { - Con.PrintSuccess('done.\n'); - } else { - Con.PrintError('ERROR: couldn\'t open.\n'); - } -}; + if (!registry.isDedicatedServer) { + CL.Connect('local'); + } + }); + }; -Host.Loadgame_f = async function (savename) { - if (this.client) { - return; - } - if (savename === undefined) { - Con.Print('Usage: load \n'); - return; - } - if (savename.indexOf('..') !== -1) { - Con.PrintWarning('Relative pathnames are not allowed.\n'); - return; - } - CL.cls.demonum = -1; - const name = COM.DefaultExtension(savename, '.json'); - Con.Print('Loading game from ' + name + '...\n'); - const data = await COM.LoadTextFile(name); - if (data === null) { - Con.PrintError('ERROR: couldn\'t open.\n'); - return; - } + static Changelevel_f(mapname) { + if (mapname === undefined) { + Con.Print('Usage: changelevel \n'); + return; + } - const gamestate = JSON.parse(data); + if (!SV.server.active || (!registry.isDedicatedServer && CL.cls.demoplayback)) { + Con.Print('Only the server may changelevel\n'); + return; + } - if (gamestate.version !== Def.gamestateVersion) { - throw new HostError(`Savegame is version ${gamestate.version}, not ${Def.gamestateVersion}\n`); - } + // if (!SV.HasMap(mapname)) { + // throw new HostError(`No such map: ${mapname}`); + // } - CL.Disconnect(); + SV.svs.changelevelIssued = true; - // restore all server/game cvars - for (const [name, value] of gamestate.cvars) { - const cvar = Cvar.FindVar(name); - if (cvar) { - cvar.set(value); - } else { - Con.PrintWarning(`Saved cvar ${name} not found, skipping\n`); + for (let i = 0; i < SV.svs.maxclients; i++) { + const client = SV.svs.clients[i]; + if (client.state < ServerClient.STATE.CONNECTED) { + continue; + } + client.message.writeByte(Protocol.svc.changelevel); + client.message.writeString(mapname); } - } - if (!await SV.SpawnServer(gamestate.mapname)) { if (!registry.isDedicatedServer) { - CL.SetConnectingStep(null, null); + // this hack allows us to show the loading plaque and resetting the client renderer + CL.cls.changelevel = true; + CL.cls.signon = 0; } - SV.ShutdownServer(false); - throw new HostError(`Couldn't load map ${gamestate.mapname} for save game ${name}\n`); - } + Host.ScheduleForNextFrame(async () => { + SV.SaveSpawnparms(); - if (gamestate.gameversion !== SV.server.gameVersion) { - SV.ShutdownServer(false); - throw new HostError(`Game is version ${gamestate.gameversion}, not ${SV.server.gameVersion}\n`); - } - - SV.server.paused = true; - SV.server.loadgame = true; + Con.DPrint('Host.Changelevel_f: changing level to ' + mapname + '\n'); - SV.server.lightstyles = gamestate.lightstyles; - SV.server.gameAPI.deserialize(gamestate.globals); + if (!await SV.SpawnServer(mapname)) { + SV.ShutdownServer(false); + throw new HostError('Could not spawn server for changelevel to ' + mapname); + } - SV.server.num_edicts = gamestate.num_edicts; - console.assert(SV.server.num_edicts <= SV.server.edicts.length, 'resizing edicts not supported yet'); // TODO: alloc more edicts + Con.DPrint('Host.Changelevel_f: spawned server for changelevel to ' + mapname + '\n'); - // first run through all edicts to make sure the entity structures get initialized - for (let i = 0; i < SV.server.edicts.length; i++) { - const edict = SV.server.edicts[i]; + if (!registry.isDedicatedServer) { + CL.SetConnectingStep(null, null); + } + }); + }; - if (!gamestate.edicts[i]) { // freed edict - // FIXME: QuakeC doesn’t like it at all when edicts suddenly disappear, we should offload this code to the GameAPI - edict.freeEdict(); - continue; + static Restart_f() { + if ((SV.server.active) && (registry.isDedicatedServer || !CL.cls.demoplayback && !this.client)) { + void Cmd.ExecuteString(`map ${SV.server.mapname}`); } + }; - const [classname] = gamestate.edicts[i]; - console.assert(SV.server.gameAPI.prepareEntity(edict, classname), 'no entity for classname'); - } + // NOTE: this is the dedicated server version of disconnect + static Disconnect_f() { + if (!SV.server.active) { + Con.Print('No active server\n'); + return; + } - // second run we can start deserializing - for (let i = 0; i < SV.server.edicts.length; i++) { - const edict = SV.server.edicts[i]; + Host.ShutdownServer(); + }; - if (edict.isFree()) { // freed edict - continue; + static Reconnect_f() { + if (registry.isDedicatedServer) { + Con.Print('cannot reconnect in dedicated server mode\n'); + return; } - const [, data] = gamestate.edicts[i]; - edict.entity.deserialize(data); - edict.linkEdict(); - } - - SV.server.time = gamestate.time; + Con.PrintWarning('NOT IMPLEMENTED: reconnect\n'); // TODO: reimplement reconnect here + }; - const client = SV.svs.clients[0]; - client.spawn_parms = gamestate.spawn_parms; + static Connect_f(address) { + if (address === undefined) { + Con.Print('Usage: connect
\n'); + Con.Print(' -
can be "self", connecting to the current domain name\n'); + return; + } - ClientLifecycle.resumeGame(gamestate.clientdata, gamestate.particles); -}; + if (registry.isDedicatedServer) { + Con.Print('cannot connect to another server in dedicated server mode\n'); + return; + } -Host.Name_f = function(...names) { // signon 2, step 1 - Con.DPrint(`Host.Name_f: ${this.client}\n`); - if (names.length < 1) { - Con.Print('"name" is "' + CL.name.string + '"\n'); - return; - } + CL.cls.demonum = -1; + if (CL.cls.demoplayback === true) { + CL.StopPlayback(); + CL.Disconnect(); + } - if (!SV.server.active) { // ??? - return; - } + if (address === 'self') { + const url = new URL(location.href); + CL.Connect((url.protocol === 'https:' ? 'wss' : 'ws') + '://' + url.host + url.pathname + (!url.pathname.endsWith('/') ? '/' : '') + 'api/'); + } else { + CL.Connect(address); + } - let newName = names.join(' ').trim().substring(0, 15); + CL.cls.signon = 0; + }; - if (!registry.isDedicatedServer && !this.client) { - Cvar.Set('_cl_name', newName); - if (CL.cls.state === Def.clientConnectionState.connected) { - this.forward(); + static Savegame_f(savename) { + if (this.client) { + return; + } + if (savename === undefined) { + Con.Print('Usage: save \n'); + return; + } + if (SV.server.active !== true) { + Con.PrintWarning('Not playing a local game.\n'); + return; + } + if (CL.state.intermission !== 0) { + Con.PrintWarning('Can\'t save in intermission.\n'); + return; + } + if (SV.svs.maxclients !== 1) { + Con.PrintWarning('Can\'t save multiplayer games.\n'); + return; + } + if (savename.indexOf('..') !== -1) { + Con.PrintWarning('Relative pathnames are not allowed.\n'); + return; + } + const client = SV.svs.clients[0]; + if (client.state >= ServerClient.STATE.CONNECTED) { + if (client.edict.entity.health <= 0.0) { + Con.PrintWarning('Can\'t savegame with a dead player\n'); + return; + } } - return; - } - - if (!this.client) { - return; - } - const initialNewName = newName; - let newNameCounter = 2; + const gamestate = { + version: Def.gamestateVersion, + gameversion: SV.server.gameVersion, + comment: CL.state.levelname, // TODO: ask the game for a comment + spawn_parms: client.spawn_parms, + mapname: SV.server.mapname, + time: SV.server.time, + lightstyles: SV.server.lightstyles, + globals: null, + cvars: [...Cvar.Filter((cvar) => cvar.flags & (Cvar.FLAG.SERVER | Cvar.FLAG.GAME))].map((cvar) => [cvar.name, cvar.string]), + clientdata: null, + edicts: [], + num_edicts: SV.server.num_edicts, + // TODO: client entities + particles: R.SerializeParticles(), + }; - // make sure we have a somewhat unique name - while (SV.FindClientByName(newName)) { - newName = `${initialNewName}${newNameCounter++}`; - } + if (CL.state.gameAPI) { + gamestate.clientdata = CL.state.gameAPI.saveGame(); + } - const name = this.client.name; - if (registry.isDedicatedServer && name && (name.length !== 0) && (name !== 'unconnected') && (name !== newName)) { - Con.Print(name + ' renamed to ' + newName + '\n'); - } + // IDEA: we could actually compress this by using a list of common fields + for (const edict of SV.server.edicts) { + if (edict.isFree()) { + gamestate.edicts.push(null); + continue; + } - this.client.name = newName; - const msg = SV.server.reliable_datagram; - msg.writeByte(Protocol.svc.updatename); - msg.writeByte(this.client.num); - msg.writeString(newName); -}; + gamestate.edicts.push([edict.entity.classname, edict.entity.serialize()]); + } -Host.Say_f = function(teamonly, message) { - if (this.forward()) { - return; - } + gamestate.globals = SV.server.gameAPI.serialize(); - if (!message) { - return; - } + const name = COM.DefaultExtension(savename, '.json'); + Con.Print('Saving game to ' + name + '...\n'); + if (COM.WriteTextFile(name, JSON.stringify(gamestate))) { + Con.PrintSuccess('done.\n'); + } else { + Con.PrintError('ERROR: couldn\'t open.\n'); + } + }; - const sender = this.client; + static async Loadgame_f(savename) { + if (this.client) { + return; + } + if (savename === undefined) { + Con.Print('Usage: load \n'); + return; + } + if (savename.indexOf('..') !== -1) { + Con.PrintWarning('Relative pathnames are not allowed.\n'); + return; + } + CL.cls.demonum = -1; + const name = COM.DefaultExtension(savename, '.json'); + Con.Print('Loading game from ' + name + '...\n'); + const data = await COM.LoadTextFile(name); + if (data === null) { + Con.PrintError('ERROR: couldn\'t open.\n'); + return; + } - if (message.length > 140) { - message = message.substring(0, 140) + '...'; - } + const gamestate = JSON.parse(data); - for (let i = 0; i < SV.svs.maxclients; i++) { - const client = SV.svs.clients[i]; - if (client.state < ServerClient.STATE.CONNECTED) { - continue; - } - if ((Host.teamplay.value !== 0) && (teamonly === true) && (client.entity.team !== sender.entity.team)) { // Legacy cvars - continue; + if (gamestate.version !== Def.gamestateVersion) { + throw new HostError(`Savegame is version ${gamestate.version}, not ${Def.gamestateVersion}\n`); } - Host.SendChatMessageToClient(client, sender.name, message, false); - } - Con.Print(`${sender.name}: ${message}\n`); -}; + CL.Disconnect(); -Host.Say_Team_f = function(message) { - Host.Say_f.call(this, true, message); -}; + // restore all server/game cvars + for (const [name, value] of gamestate.cvars) { + const cvar = Cvar.FindVar(name); + if (cvar) { + cvar.set(value); + } else { + Con.PrintWarning(`Saved cvar ${name} not found, skipping\n`); + } + } -Host.Say_All_f = function(message) { - Host.Say_f.call(this, false, message); -}; + if (!await SV.SpawnServer(gamestate.mapname)) { + if (!registry.isDedicatedServer) { + CL.SetConnectingStep(null, null); + } -Host.Tell_f = function(recipient, message) { - if (this.forward()) { - return; - } + SV.ShutdownServer(false); + throw new HostError(`Couldn't load map ${gamestate.mapname} for save game ${name}\n`); + } - if (!recipient || !message) { - Con.Print('Usage: tell \n'); - return; - } + if (gamestate.gameversion !== SV.server.gameVersion) { + SV.ShutdownServer(false); + throw new HostError(`Game is version ${gamestate.gameversion}, not ${SV.server.gameVersion}\n`); + } - message = message.trim(); + SV.server.paused = true; + SV.server.loadgame = true; - // Remove surrounding double quotes if present - if (message.startsWith('"')) { - message = message.slice(1, -1); - } - if (message.length > 140) { - message = message.substring(0, 140) + '...'; - } + SV.server.lightstyles = gamestate.lightstyles; + SV.server.gameAPI.deserialize(gamestate.globals); - const sender = this.client; - for (let i = 0; i < SV.svs.maxclients; i++) { - const client = SV.svs.clients[i]; - if (client.state < ServerClient.STATE.CONNECTED) { - continue; - } - if (client.name.toLowerCase() !== recipient.toLowerCase()) { - continue; - } - Host.SendChatMessageToClient(client, sender.name, message, true); - Host.SendChatMessageToClient(sender, sender.name, message, true); - break; - } -}; + SV.server.num_edicts = gamestate.num_edicts; + console.assert(SV.server.num_edicts <= SV.server.edicts.length, 'resizing edicts not supported yet'); // TODO: alloc more edicts -Host.Color_f = function(...argv) { // signon 2, step 2 - Con.DPrint(`Host.Color_f: ${this.client}\n`); - if (argv.length <= 1) { - Con.Print('"color" is "' + (CL.color.value >> 4) + ' ' + (CL.color.value & 15) + '"\ncolor <0-13> [0-13]\n'); - return; - } + // first run through all edicts to make sure the entity structures get initialized + for (let i = 0; i < SV.server.edicts.length; i++) { + const edict = SV.server.edicts[i]; - let top; let bottom; - if (argv.length === 2) { - top = bottom = (Q.atoi(argv[1]) & 15) >>> 0; - } else { - top = (Q.atoi(argv[1]) & 15) >>> 0; - bottom = (Q.atoi(argv[2]) & 15) >>> 0; - } - if (top >= 14) { - top = 13; - } - if (bottom >= 14) { - bottom = 13; - } - const playercolor = (top << 4) + bottom; + if (!gamestate.edicts[i]) { // freed edict + // FIXME: QuakeC doesn’t like it at all when edicts suddenly disappear, we should offload this code to the GameAPI + edict.freeEdict(); + continue; + } - if (!registry.isDedicatedServer && !this.client) { - Cvar.Set('_cl_color', playercolor); - if (CL.cls.state === Def.clientConnectionState.connected) { - this.forward(); + const [classname] = gamestate.edicts[i]; + console.assert(SV.server.gameAPI.prepareEntity(edict, classname), 'no entity for classname'); } - return; - } - if (!this.client) { - return; - } + // second run we can start deserializing + for (let i = 0; i < SV.server.edicts.length; i++) { + const edict = SV.server.edicts[i]; - this.client.colors = playercolor; - this.client.edict.entity.team = bottom + 1; - const msg = SV.server.reliable_datagram; - msg.writeByte(Protocol.svc.updatecolors); - msg.writeByte(this.client.num); - msg.writeByte(playercolor); -}; - -Host.Kill_f = function() { - if (this.forward()) { - return; - } + if (edict.isFree()) { // freed edict + continue; + } - const client = this.client; - if (client.edict.entity.health <= 0.0) { - Host.ClientPrint(client, 'Can\'t suicide -- already dead!\n'); - return; - } + const [, data] = gamestate.edicts[i]; + edict.entity.deserialize(data); + edict.linkEdict(); + } - SV.server.gameAPI.time = SV.server.time; - SV.server.gameAPI.ClientKill(client.edict); -}; + SV.server.time = gamestate.time; -Host.Pause_f = function() { - if (this.forward()) { - return; - } + const client = SV.svs.clients[0]; + client.spawn_parms = gamestate.spawn_parms; - const client = this.client; + ClientLifecycle.resumeGame(gamestate.clientdata, gamestate.particles); + }; - if (Host.pausable.value === 0) { - Host.ClientPrint(client, 'Pause not allowed.\n'); - return; - } - SV.server.paused = !SV.server.paused; - Host.BroadcastPrint(client.name + (SV.server.paused === true ? ' paused the game\n' : ' unpaused the game\n')); - SV.server.reliable_datagram.writeByte(Protocol.svc.setpause); - SV.server.reliable_datagram.writeByte(SV.server.paused === true ? 1 : 0); -}; - -Host.PreSpawn_f = function() { // signon 1, step 1 - if (!this.client) { - Con.Print('prespawn is not valid from the console\n'); - return; - } - Con.DPrint(`Host.PreSpawn_f: ${this.client}\n`); - const client = this.client; - if (client.state === ServerClient.STATE.SPAWNED) { - Con.Print('prespawn not valid -- already spawned\n'); - return; - } - // CR: SV.server.signon is a special buffer that is used to send the signon messages (make static as well as baseline information) - client.message.write(new Uint8Array(SV.server.signon.data), SV.server.signon.cursize); - client.message.writeByte(Protocol.svc.signonnum); - client.message.writeByte(2); -}; - -Host.Spawn_f = function() { // signon 2, step 3 - Con.DPrint(`Host.Spawn_f: ${this.client}\n`); - if (!this.client) { - Con.Print('spawn is not valid from the console\n'); - return; - } - let client = this.client; - if (client.state === ServerClient.STATE.SPAWNED) { - Con.Print('Spawn not valid -- already spawned\n'); - return; - } + static Name_f(...names) { // signon 2, step 1 + Con.DPrint(`Host.Name_f: ${this.client}\n`); + if (names.length < 1) { + Con.Print('"name" is "' + CL.name.string + '"\n'); + return; + } - const message = client.message; - message.clear(); - - message.writeByte(Protocol.svc.time); - message.writeFloat(SV.server.time); - - const ent = client.edict; - if (SV.server.loadgame === true) { - SV.server.paused = false; - } else { - // ent.clear(); // FIXME: there’s a weird edge case - SV.server.gameAPI.prepareEntity(ent, 'player', { - netname: client.name, - colormap: ent.num, // the num, not the entity - team: (client.colors & 15) + 1, - }); + if (!SV.server.active) { // ??? + return; + } - // load in spawn parameters (legacy) - if (SV.server.gameCapabilities.includes(gameCapabilities.CAP_SPAWNPARMS_LEGACY)) { - for (let i = 0; i <= 15; i++) { - SV.server.gameAPI[`parm${i + 1}`] = client.spawn_parms[i]; + let newName = names.join(' ').trim().substring(0, 15); + + if (!registry.isDedicatedServer && !this.client) { + Cvar.Set('_cl_name', newName); + if (CL.cls.state === Def.clientConnectionState.connected) { + this.forward(); } + return; } - // load in spawn parameters - if (SV.server.gameCapabilities.includes(gameCapabilities.CAP_SPAWNPARMS_DYNAMIC)) { - ent.entity.restoreSpawnParameters(client.spawn_parms); + if (!this.client) { + return; } - // call the spawn function - SV.server.gameAPI.time = SV.server.time; - SV.server.gameAPI.ClientConnect(ent); - - // actually spawn the player - SV.server.gameAPI.time = SV.server.time; - SV.server.gameAPI.PutClientInServer(ent); - } + const initialNewName = newName; + let newNameCounter = 2; - for (let i = 0; i < SV.svs.maxclients; i++) { - client = SV.svs.clients[i]; - message.writeByte(Protocol.svc.updatename); - message.writeByte(i); - message.writeString(client.name); - message.writeByte(Protocol.svc.updatefrags); - message.writeByte(i); - message.writeShort(client.old_frags); - message.writeByte(Protocol.svc.updatecolors); - message.writeByte(i); - message.writeByte(client.colors); - } + // make sure we have a somewhat unique name + while (SV.FindClientByName(newName)) { + newName = `${initialNewName}${newNameCounter++}`; + } - for (let i = 0; i < Def.limits.lightstyles; i++) { - message.writeByte(Protocol.svc.lightstyle); - message.writeByte(i); - message.writeString(SV.server.lightstyles[i]); - } + const name = this.client.name; + if (registry.isDedicatedServer && name && (name.length !== 0) && (name !== 'unconnected') && (name !== newName)) { + Con.Print(name + ' renamed to ' + newName + '\n'); + } - if (SV.server.gameCapabilities.includes(gameCapabilities.CAP_CLIENTDATA_UPDATESTAT)) { - message.writeByte(Protocol.svc.updatestat); - message.writeByte(Def.stat.totalsecrets); - message.writeLong(SV.server.gameAPI.total_secrets); - message.writeByte(Protocol.svc.updatestat); - message.writeByte(Def.stat.totalmonsters); - message.writeLong(SV.server.gameAPI.total_monsters); - message.writeByte(Protocol.svc.updatestat); - message.writeByte(Def.stat.secrets); - message.writeLong(SV.server.gameAPI.found_secrets); - message.writeByte(Protocol.svc.updatestat); - message.writeByte(Def.stat.monsters); - message.writeLong(SV.server.gameAPI.killed_monsters); - } + this.client.name = newName; + const msg = SV.server.reliable_datagram; + msg.writeByte(Protocol.svc.updatename); + msg.writeByte(this.client.num); + msg.writeString(newName); + }; - message.writeByte(Protocol.svc.setangle); - message.writeAngleVector(ent.entity.angles); + static Say_f(teamonly, message) { + if (this.forward()) { + return; + } - SV.messages.writeClientdataToMessage(client, message); + if (!message) { + return; + } - message.writeByte(Protocol.svc.signonnum); - message.writeByte(3); -}; + const sender = this.client; -Host.Begin_f = function() { // signon 3, step 1 - Con.DPrint(`Host.Begin_f: ${this.client}\n`); - if (!this.client) { - Con.Print('begin is not valid from the console\n'); - return; - } + if (message.length > 140) { + message = message.substring(0, 140) + '...'; + } - // Send all portal states before the client is offically spawned and gets updates incrementally - const areaPortals = SV.server.worldmodel.areaPortals; + for (let i = 0; i < SV.svs.maxclients; i++) { + const client = SV.svs.clients[i]; + if (client.state < ServerClient.STATE.CONNECTED) { + continue; + } + if ((Host.teamplay.value !== 0) && (teamonly === true) && (client.entity.team !== sender.entity.team)) { // Legacy cvars + continue; + } + Host.SendChatMessageToClient(client, sender.name, message, false); + } - for (let p = 0; p < areaPortals.numPortals; p++) { - this.client.message.writeByte(Protocol.svc.setportalstate); - this.client.message.writeShort(p); - this.client.message.writeByte(areaPortals.isPortalOpen(p) ? 1 : 0); - } + Con.Print(`${sender.name}: ${message}\n`); + }; - this.client.state = ServerClient.STATE.SPAWNED; + static Say_Team_f(message) { + Host.Say_f.call(this, true, message); + }; - if (SV.server.gameAPI.ClientBegin) { - SV.server.gameAPI.time = SV.server.time; - SV.server.gameAPI.ClientBegin(this.client.edict); - } -}; + static Say_All_f(message) { + Host.Say_f.call(this, false, message); + }; -Host.Kick_f = function() { - const argv = this.argv; - if (!this.client) { - if (!SV.server.active) { - this.forward(); + static Tell_f(recipient, message) { + if (this.forward()) { return; } - } - if (argv.length < 2) { - return; - } - const s = argv[1].toLowerCase(); - const invokingClient = this.client; - let i; let byNumber = false; - let targetClient = /** @type {ServerClient | null} */ (null); - - if ((argv.length >= 3) && (s === '#')) { - i = Q.atoi(argv[2]) - 1; - if ((i < 0) || (i >= SV.svs.maxclients)) { + + if (!recipient || !message) { + Con.Print('Usage: tell \n'); return; } - if (SV.svs.clients[i].state !== ServerClient.STATE.SPAWNED) { - return; + + message = message.trim(); + + // Remove surrounding double quotes if present + if (message.startsWith('"')) { + message = message.slice(1, -1); } - targetClient = SV.svs.clients[i]; - byNumber = true; - } else { - for (i = 0; i < SV.svs.maxclients; i++) { + if (message.length > 140) { + message = message.substring(0, 140) + '...'; + } + + const sender = this.client; + for (let i = 0; i < SV.svs.maxclients; i++) { const client = SV.svs.clients[i]; if (client.state < ServerClient.STATE.CONNECTED) { continue; } - if (client.name.toLowerCase() === s) { - targetClient = client; - break; + if (client.name.toLowerCase() !== recipient.toLowerCase()) { + continue; } + Host.SendChatMessageToClient(client, sender.name, message, true); + Host.SendChatMessageToClient(sender, sender.name, message, true); + break; } - } - if (targetClient === null) { - return; - } - if (targetClient === invokingClient) { - return; - } - let who; - if (!invokingClient) { - if (registry.isDedicatedServer) { - who = NET.hostname.string; + }; + + static Color_f(...argv) { // signon 2, step 2 + Con.DPrint(`Host.Color_f: ${this.client}\n`); + if (argv.length <= 1) { + Con.Print('"color" is "' + (CL.color.value >> 4) + ' ' + (CL.color.value & 15) + '"\ncolor <0-13> [0-13]\n'); + return; + } + + let top; let bottom; + if (argv.length === 2) { + top = bottom = (Q.atoi(argv[1]) & 15) >>> 0; } else { - who = CL.name.string; + top = (Q.atoi(argv[1]) & 15) >>> 0; + bottom = (Q.atoi(argv[2]) & 15) >>> 0; } - } else { - who = invokingClient.name; - } - let message; - if (argv.length >= 3) { - message = COM.Parse(this.args); - } - let dropReason = 'Kicked by ' + who; - if (message.data !== null) { - let p = 0; - if (byNumber) { - p++; - for (; p < message.data.length; p++) { - if (message.data.charCodeAt(p) !== 32) { - break; - } - } - p += argv[2].length; + if (top >= 14) { + top = 13; } - for (; p < message.data.length; p++) { - if (message.data.charCodeAt(p) !== 32) { - break; + if (bottom >= 14) { + bottom = 13; + } + const playercolor = (top << 4) + bottom; + + if (!registry.isDedicatedServer && !this.client) { + Cvar.Set('_cl_color', playercolor); + if (CL.cls.state === Def.clientConnectionState.connected) { + this.forward(); } + return; + } + + if (!this.client) { + return; } - dropReason = 'Kicked by ' + who + ': ' + message.data.substring(p); - } - Host.DropClient(targetClient, false, dropReason); -}; -Host.Give_f = class extends HostConsoleCommand { // TODO: move to game - run(classname) { - // CR: unsure if I want a “give item_shells” approach or - // if I want to push this piece of code into PR/PF and let - // the game handle this instead + this.client.colors = playercolor; + this.client.edict.entity.team = bottom + 1; + const msg = SV.server.reliable_datagram; + msg.writeByte(Protocol.svc.updatecolors); + msg.writeByte(this.client.num); + msg.writeByte(playercolor); + }; + static Kill_f() { if (this.forward()) { return; } - if (this.cheat()) { + const client = this.client; + if (client.edict.entity.health <= 0.0) { + Host.ClientPrint(client, 'Can\'t suicide -- already dead!\n'); return; } - const client = this.client; + SV.server.gameAPI.time = SV.server.time; + SV.server.gameAPI.ClientKill(client.edict); + }; - if (!classname) { - Host.ClientPrint(client, 'give \n'); + static Pause_f() { + if (this.forward()) { return; } - const player = client.edict; + const client = this.client; - if (!classname.startsWith('item_') && !classname.startsWith('weapon_')) { - Host.ClientPrint(client, 'Only entity classes item_* and weapon_* are allowed!\n'); + if (Host.pausable.value === 0) { + Host.ClientPrint(client, 'Pause not allowed.\n'); return; } + SV.server.paused = !SV.server.paused; + Host.BroadcastPrint(client.name + (SV.server.paused === true ? ' paused the game\n' : ' unpaused the game\n')); + SV.server.reliable_datagram.writeByte(Protocol.svc.setpause); + SV.server.reliable_datagram.writeByte(SV.server.paused === true ? 1 : 0); + }; - // wait for the next server frame - SV.ScheduleGameCommand(() => { - const { forward } = player.entity.v_angle.angleVectors(); + static PreSpawn_f() { // signon 1, step 1 + if (!this.client) { + Con.Print('prespawn is not valid from the console\n'); + return; + } + Con.DPrint(`Host.PreSpawn_f: ${this.client}\n`); + const client = this.client; + if (client.state === ServerClient.STATE.SPAWNED) { + Con.Print('prespawn not valid -- already spawned\n'); + return; + } + // CR: SV.server.signon is a special buffer that is used to send the signon messages (make static as well as baseline information) + client.message.write(new Uint8Array(SV.server.signon.data), SV.server.signon.cursize); + client.message.writeByte(Protocol.svc.signonnum); + client.message.writeByte(2); + }; - const start = player.entity.origin; - const end = forward.copy().multiply(64.0).add(start); + static Spawn_f() { // signon 2, step 3 + Con.DPrint(`Host.Spawn_f: ${this.client}\n`); + if (!this.client) { + Con.Print('spawn is not valid from the console\n'); + return; + } + let client = this.client; + if (client.state === ServerClient.STATE.SPAWNED) { + Con.Print('Spawn not valid -- already spawned\n'); + return; + } - const mins = new Vector(-16.0, -16.0, -24.0); - const maxs = new Vector(16.0, 16.0, 32.0); + const message = client.message; + message.clear(); - const trace = ServerEngineAPI.Traceline(start, end, false, player, mins, maxs); + message.writeByte(Protocol.svc.time); + message.writeFloat(SV.server.time); - const origin = trace.point.subtract(forward.multiply(16.0)).add(new Vector(0.0, 0.0, 16.0)); + const ent = client.edict; + if (SV.server.loadgame === true) { + SV.server.paused = false; + } else { + // ent.clear(); // FIXME: there’s a weird edge case + SV.server.gameAPI.prepareEntity(ent, 'player', { + netname: client.name, + colormap: ent.num, // the num, not the entity + team: (client.colors & 15) + 1, + }); - if (![content.CONTENT_EMPTY, content.CONTENT_WATER].includes(ServerEngineAPI.DetermineStaticWorldContents(origin))) { - Host.ClientPrint(client, 'Item would spawn out of world!\n'); - return; + // load in spawn parameters (legacy) + if (SV.server.gameCapabilities.includes(gameCapabilities.CAP_SPAWNPARMS_LEGACY)) { + for (let i = 0; i <= 15; i++) { + SV.server.gameAPI[`parm${i + 1}`] = client.spawn_parms[i]; + } } - ServerEngineAPI.SpawnEntity(classname, { - origin, - }); - }); - } -}; - -Host.FindViewthing = function() { - if (SV.server.active) { - for (let i = 0; i < SV.server.num_edicts; i++) { - const e = SV.server.edicts[i]; - if (!e.isFree() && e.entity.classname === 'viewthing') { - return e; + // load in spawn parameters + if (SV.server.gameCapabilities.includes(gameCapabilities.CAP_SPAWNPARMS_DYNAMIC)) { + ent.entity.restoreSpawnParameters(client.spawn_parms); } + + // call the spawn function + SV.server.gameAPI.time = SV.server.time; + SV.server.gameAPI.ClientConnect(ent); + + // actually spawn the player + SV.server.gameAPI.time = SV.server.time; + SV.server.gameAPI.PutClientInServer(ent); } - } - Con.Print('No viewthing on map\n'); - return null; -}; - -Host.Viewmodel_f = async function(model) { - if (model === undefined) { - Con.Print('Usage: viewmodel \n'); - return; - } - const ent = Host.FindViewthing(); - if (ent) { - return; - } - const m = await Mod.ForNameAsync(model, false, Mod.scope.client); - if (!m) { - Con.Print('Can\'t load ' + model + '\n'); - return; - } - ent.entity.frame = 0; - CL.state.model_precache[ent.entity.modelindex] = m; -}; - -Host.Viewframe_f = function(frame) { - if (frame === undefined) { - Con.Print('Usage: viewframe \n'); - return; - } - const ent = Host.FindViewthing(); - if (!ent) { - return; - } - const m = CL.state.model_precache[ent.entity.modelindex >> 0]; - let f = Q.atoi(frame); - if (f >= m.frames.length) { - f = m.frames.length - 1; - } - ent.entity.frame = f; -}; -Host.Viewnext_f = function() { - const ent = Host.FindViewthing(); - if (!ent) { - return; - } - const m = CL.state.model_precache[ent.entity.modelindex >> 0]; - let f = (ent.entity.frame >> 0) + 1; - if (f >= m.frames.length) { - f = m.frames.length - 1; - } - ent.entity.frame = f; - Con.Print('frame ' + f + ': ' + m.frames[f].name + '\n'); -}; - -Host.Viewprev_f = function() { - const ent = Host.FindViewthing(); - if (!ent) { - return; - } - const m = CL.state.model_precache[ent.entity.modelindex >> 0]; - let f = (ent.entity.frame >> 0) - 1; - if (f < 0) { - f = 0; - } - ent.entity.frame = f; - Con.Print('frame ' + f + ': ' + m.frames[f].name + '\n'); -}; + for (let i = 0; i < SV.svs.maxclients; i++) { + client = SV.svs.clients[i]; + message.writeByte(Protocol.svc.updatename); + message.writeByte(i); + message.writeString(client.name); + message.writeByte(Protocol.svc.updatefrags); + message.writeByte(i); + message.writeShort(client.old_frags); + message.writeByte(Protocol.svc.updatecolors); + message.writeByte(i); + message.writeByte(client.colors); + } -Host.InitCommands = function() { - if (registry.isDedicatedServer) { // TODO: move this to a dedicated stub for IN - Cmd.AddCommand('bind', () => {}); - Cmd.AddCommand('unbind', () => {}); - Cmd.AddCommand('unbindall', () => {}); + for (let i = 0; i < Def.limits.lightstyles; i++) { + message.writeByte(Protocol.svc.lightstyle); + message.writeByte(i); + message.writeString(SV.server.lightstyles[i]); + } - Cmd.AddCommand('disconnect', Host.Disconnect_f); - } + if (SV.server.gameCapabilities.includes(gameCapabilities.CAP_CLIENTDATA_UPDATESTAT)) { + message.writeByte(Protocol.svc.updatestat); + message.writeByte(Def.stat.totalsecrets); + message.writeLong(SV.server.gameAPI.total_secrets); + message.writeByte(Protocol.svc.updatestat); + message.writeByte(Def.stat.totalmonsters); + message.writeLong(SV.server.gameAPI.total_monsters); + message.writeByte(Protocol.svc.updatestat); + message.writeByte(Def.stat.secrets); + message.writeLong(SV.server.gameAPI.found_secrets); + message.writeByte(Protocol.svc.updatestat); + message.writeByte(Def.stat.monsters); + message.writeLong(SV.server.gameAPI.killed_monsters); + } - Cmd.AddCommand('status', Host.Status_f); - Cmd.AddCommand('quit', Host.Quit_f); - Cmd.AddCommand('god', Host.God_f); - Cmd.AddCommand('notarget', Host.Notarget_f); - Cmd.AddCommand('fly', Host.Fly_f); - Cmd.AddCommand('map', Host.Map_f); - Cmd.AddCommand('restart', Host.Restart_f); - Cmd.AddCommand('changelevel', Host.Changelevel_f); - Cmd.AddCommand('connect', Host.Connect_f); - Cmd.AddCommand('reconnect', Host.Reconnect_f); - Cmd.AddCommand('name', Host.Name_f); - Cmd.AddCommand('noclip', Host.Noclip_f); - Cmd.AddCommand('say', Host.Say_All_f); - Cmd.AddCommand('say_team', Host.Say_Team_f); - Cmd.AddCommand('tell', Host.Tell_f); - Cmd.AddCommand('color', Host.Color_f); - Cmd.AddCommand('kill', Host.Kill_f); - Cmd.AddCommand('pause', Host.Pause_f); - Cmd.AddCommand('spawn', Host.Spawn_f); - Cmd.AddCommand('begin', Host.Begin_f); - Cmd.AddCommand('prespawn', Host.PreSpawn_f); - Cmd.AddCommand('kick', Host.Kick_f); - Cmd.AddCommand('ping', Host.Ping_f); - if (!registry.isDedicatedServer) { - Cmd.AddCommand('load', Host.Loadgame_f); - Cmd.AddCommand('save', Host.Savegame_f); - } - Cmd.AddCommand('give', Host.Give_f); - Cmd.AddCommand('viewmodel', Host.Viewmodel_f); - Cmd.AddCommand('viewframe', Host.Viewframe_f); - Cmd.AddCommand('viewnext', Host.Viewnext_f); - Cmd.AddCommand('viewprev', Host.Viewprev_f); - // Cmd.AddCommand('mcache', Mod.Print); - Cmd.AddCommand('writeconfig', Host.WriteConfiguration_f); - Cmd.AddCommand('configready', Host.ConfigReady_f); + message.writeByte(Protocol.svc.setangle); + message.writeAngleVector(ent.entity.angles); + + SV.messages.writeClientdataToMessage(client, message); + + message.writeByte(Protocol.svc.signonnum); + message.writeByte(3); + }; - Cmd.AddCommand('error', class extends ConsoleCommand { - run(message) { - throw new HostError(message); + static Begin_f() { // signon 3, step 1 + Con.DPrint(`Host.Begin_f: ${this.client}\n`); + if (!this.client) { + Con.Print('begin is not valid from the console\n'); + return; } - }); - Cmd.AddCommand('fatalerror', class extends ConsoleCommand { - run(message) { - throw new Error(message); + // Send all portal states before the client is offically spawned and gets updates incrementally + const areaPortals = SV.server.worldmodel.areaPortals; + + for (let p = 0; p < areaPortals.numPortals; p++) { + this.client.message.writeByte(Protocol.svc.setportalstate); + this.client.message.writeShort(p); + this.client.message.writeByte(areaPortals.isPortalOpen(p) ? 1 : 0); } - }); - Cmd.AddCommand('eb_topics', class extends ConsoleCommand { - run() { - // TODO: do not allow this command when server is having cheats disabled + this.client.state = ServerClient.STATE.SPAWNED; - for (const topic of eventBus.topics.sort()) { - Con.Print(topic + '\n'); + if (SV.server.gameAPI.ClientBegin) { + SV.server.gameAPI.time = SV.server.time; + SV.server.gameAPI.ClientBegin(this.client.edict); + } + }; + + static Kick_f() { + const argv = this.argv; + if (!this.client) { + if (!SV.server.active) { + this.forward(); + return; } } - }); + if (argv.length < 2) { + return; + } + const s = argv[1].toLowerCase(); + const invokingClient = this.client; + let i; let byNumber = false; + let targetClient = /** @type {ServerClient | null} */ (null); - Cmd.AddCommand('eb_publish', class extends ConsoleCommand { - run(eventName, ...args) { - // TODO: do not allow this command when server is having cheats disabled + if ((argv.length >= 3) && (s === '#')) { + i = Q.atoi(argv[2]) - 1; + if ((i < 0) || (i >= SV.svs.maxclients)) { + return; + } + if (SV.svs.clients[i].state !== ServerClient.STATE.SPAWNED) { + return; + } + targetClient = SV.svs.clients[i]; + byNumber = true; + } else { + for (i = 0; i < SV.svs.maxclients; i++) { + const client = SV.svs.clients[i]; + if (client.state < ServerClient.STATE.CONNECTED) { + continue; + } + if (client.name.toLowerCase() === s) { + targetClient = client; + break; + } + } + } + if (targetClient === null) { + return; + } + if (targetClient === invokingClient) { + return; + } + let who; + if (!invokingClient) { + if (registry.isDedicatedServer) { + who = NET.hostname.string; + } else { + who = CL.name.string; + } + } else { + who = invokingClient.name; + } + let message; + if (argv.length >= 3) { + message = COM.Parse(this.args); + } + let dropReason = 'Kicked by ' + who; + if (message.data !== null) { + let p = 0; + if (byNumber) { + p++; + for (; p < message.data.length; p++) { + if (message.data.charCodeAt(p) !== 32) { + break; + } + } + p += argv[2].length; + } + for (; p < message.data.length; p++) { + if (message.data.charCodeAt(p) !== 32) { + break; + } + } + dropReason = 'Kicked by ' + who + ': ' + message.data.substring(p); + } + Host.DropClient(targetClient, false, dropReason); + }; + + static Give_f = class extends HostConsoleCommand { // TODO: move to game + run(classname) { + // CR: unsure if I want a “give item_shells” approach or + // if I want to push this piece of code into PR/PF and let + // the game handle this instead + + if (this.forward()) { + return; + } + + if (this.cheat()) { + return; + } - if (!eventName) { - Con.Print(`Usage: ${this.command} [args...]\n`); + const client = this.client; + + if (!classname) { + Host.ClientPrint(client, 'give \n'); return; } - if (!eventBus.topics.includes(eventName)) { - Con.PrintError(`No such event topic: ${eventName}\n`); + const player = client.edict; + + if (!classname.startsWith('item_') && !classname.startsWith('weapon_')) { + Host.ClientPrint(client, 'Only entity classes item_* and weapon_* are allowed!\n'); return; } - eventBus.publish(eventName, ...args); + // wait for the next server frame + SV.ScheduleGameCommand(() => { + const { forward } = player.entity.v_angle.angleVectors(); + + const start = player.entity.origin; + const end = forward.copy().multiply(64.0).add(start); + + const mins = new Vector(-16.0, -16.0, -24.0); + const maxs = new Vector(16.0, 16.0, 32.0); + + const trace = ServerEngineAPI.Traceline(start, end, false, player, mins, maxs); + + const origin = trace.point.subtract(forward.multiply(16.0)).add(new Vector(0.0, 0.0, 16.0)); + + if (![content.CONTENT_EMPTY, content.CONTENT_WATER].includes(ServerEngineAPI.DetermineStaticWorldContents(origin))) { + Host.ClientPrint(client, 'Item would spawn out of world!\n'); + return; + } + + ServerEngineAPI.SpawnEntity(classname, { + origin, + }); + }); + } + }; + + static FindViewthing() { + if (SV.server.active) { + for (let i = 0; i < SV.server.num_edicts; i++) { + const e = SV.server.edicts[i]; + if (!e.isFree() && e.entity.classname === 'viewthing') { + return e; + } + } + } + Con.Print('No viewthing on map\n'); + return null; + }; + + static async Viewmodel_f(model) { + if (model === undefined) { + Con.Print('Usage: viewmodel \n'); + return; + } + const ent = Host.FindViewthing(); + if (ent) { + return; + } + const m = await Mod.ForNameAsync(model, false, Mod.scope.client); + if (!m) { + Con.Print('Can\'t load ' + model + '\n'); + return; + } + ent.entity.frame = 0; + CL.state.model_precache[ent.entity.modelindex] = m; + }; + + static Viewframe_f(frame) { + if (frame === undefined) { + Con.Print('Usage: viewframe \n'); + return; + } + const ent = Host.FindViewthing(); + if (!ent) { + return; + } + const m = CL.state.model_precache[ent.entity.modelindex >> 0]; + let f = Q.atoi(frame); + if (f >= m.frames.length) { + f = m.frames.length - 1; + } + ent.entity.frame = f; + }; + + static Viewnext_f() { + const ent = Host.FindViewthing(); + if (!ent) { + return; } - }); -}; + const m = CL.state.model_precache[ent.entity.modelindex >> 0]; + let f = (ent.entity.frame >> 0) + 1; + if (f >= m.frames.length) { + f = m.frames.length - 1; + } + ent.entity.frame = f; + Con.Print('frame ' + f + ': ' + m.frames[f].name + '\n'); + }; + + static Viewprev_f() { + const ent = Host.FindViewthing(); + if (!ent) { + return; + } + const m = CL.state.model_precache[ent.entity.modelindex >> 0]; + let f = (ent.entity.frame >> 0) - 1; + if (f < 0) { + f = 0; + } + ent.entity.frame = f; + Con.Print('frame ' + f + ': ' + m.frames[f].name + '\n'); + }; + + static InitCommands() { + if (registry.isDedicatedServer) { // TODO: move this to a dedicated stub for IN + Cmd.AddCommand('bind', () => {}); + Cmd.AddCommand('unbind', () => {}); + Cmd.AddCommand('unbindall', () => {}); + + Cmd.AddCommand('disconnect', Host.Disconnect_f); + } + + Cmd.AddCommand('status', Host.Status_f); + Cmd.AddCommand('quit', Host.Quit_f); + Cmd.AddCommand('god', Host.God_f); + Cmd.AddCommand('notarget', Host.Notarget_f); + Cmd.AddCommand('fly', Host.Fly_f); + Cmd.AddCommand('map', Host.Map_f); + Cmd.AddCommand('restart', Host.Restart_f); + Cmd.AddCommand('changelevel', Host.Changelevel_f); + Cmd.AddCommand('connect', Host.Connect_f); + Cmd.AddCommand('reconnect', Host.Reconnect_f); + Cmd.AddCommand('name', Host.Name_f); + Cmd.AddCommand('noclip', Host.Noclip_f); + Cmd.AddCommand('say', Host.Say_All_f); + Cmd.AddCommand('say_team', Host.Say_Team_f); + Cmd.AddCommand('tell', Host.Tell_f); + Cmd.AddCommand('color', Host.Color_f); + Cmd.AddCommand('kill', Host.Kill_f); + Cmd.AddCommand('pause', Host.Pause_f); + Cmd.AddCommand('spawn', Host.Spawn_f); + Cmd.AddCommand('begin', Host.Begin_f); + Cmd.AddCommand('prespawn', Host.PreSpawn_f); + Cmd.AddCommand('kick', Host.Kick_f); + Cmd.AddCommand('ping', Host.Ping_f); + if (!registry.isDedicatedServer) { + Cmd.AddCommand('load', Host.Loadgame_f); + Cmd.AddCommand('save', Host.Savegame_f); + } + Cmd.AddCommand('give', Host.Give_f); + Cmd.AddCommand('viewmodel', Host.Viewmodel_f); + Cmd.AddCommand('viewframe', Host.Viewframe_f); + Cmd.AddCommand('viewnext', Host.Viewnext_f); + Cmd.AddCommand('viewprev', Host.Viewprev_f); + // Cmd.AddCommand('mcache', Mod.Print); + Cmd.AddCommand('writeconfig', Host.WriteConfiguration_f); + Cmd.AddCommand('configready', Host.ConfigReady_f); + + Cmd.AddCommand('error', class extends ConsoleCommand { + run(message) { + throw new HostError(message); + } + }); + + Cmd.AddCommand('fatalerror', class extends ConsoleCommand { + run(message) { + throw new Error(message); + } + }); + + Cmd.AddCommand('eb_topics', class extends ConsoleCommand { + run() { + // TODO: do not allow this command when server is having cheats disabled + + for (const topic of eventBus.topics.sort()) { + Con.Print(topic + '\n'); + } + } + }); + + Cmd.AddCommand('eb_publish', class extends ConsoleCommand { + run(eventName, ...args) { + // TODO: do not allow this command when server is having cheats disabled + + if (!eventName) { + Con.Print(`Usage: ${this.command} [args...]\n`); + return; + } + + if (!eventBus.topics.includes(eventName)) { + Con.PrintError(`No such event topic: ${eventName}\n`); + return; + } + + eventBus.publish(eventName, ...args); + } + }); + }; +} From a2fd54b6d07e93903ec9772ae9270abf3c35356a Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 21:21:37 +0300 Subject: [PATCH 24/67] TS: common/Host --- source/engine/common/{Host.mjs => Host.ts} | 1319 +++++++++++--------- source/engine/main-browser.mjs | 2 +- source/engine/main-dedicated.mjs | 2 +- source/engine/registry.mjs | 2 +- test/common/workers.test.mjs | 2 +- 5 files changed, 760 insertions(+), 567 deletions(-) rename source/engine/common/{Host.mjs => Host.ts} (54%) diff --git a/source/engine/common/Host.mjs b/source/engine/common/Host.ts similarity index 54% rename from source/engine/common/Host.mjs rename to source/engine/common/Host.ts index a881584c..b3717966 100644 --- a/source/engine/common/Host.mjs +++ b/source/engine/common/Host.ts @@ -1,8 +1,20 @@ +/* + * Host: shared engine lifecycle coordinator for both browser and dedicated + * runtimes. + * + * Owns startup and shutdown sequencing, the main frame loop, server lifecycle + * transitions, and the classic host and gameplay console commands. + */ + +/* eslint-disable jsdoc/require-returns */ + +import type { ServerEdict } from '../server/Edict.mjs'; + import Cvar from './Cvar.ts'; import * as Protocol from '../network/Protocol.ts'; import * as Def from './Def.ts'; import Cmd, { ConsoleCommand } from './Cmd.ts'; -import { eventBus, registry } from '../registry.mjs'; +import { eventBus, getClientRegistry, getCommonRegistry, registry } from '../registry.mjs'; import Vector from '../../shared/Vector.ts'; import Q from '../../shared/Q.ts'; import { ServerClient } from '../server/Client.mjs'; @@ -16,104 +28,179 @@ import { content, gameCapabilities } from '../../shared/Defs.ts'; import ClientLifecycle from '../client/ClientLifecycle.mjs'; import { Pmove } from './Pmove.ts'; - -let { CL, COM, Con, Draw, IN, Key, M, Mod, NET, PR, R, S, SCR, SV, Sbar, Sys, V } = registry; +let { COM, Con, Mod, NET, PR, SV, Sys, V } = getCommonRegistry(); +let { CL, Draw, IN, Key, M, R, S, SCR, Sbar } = getClientRegistry(); eventBus.subscribe('registry.frozen', () => { - CL = registry.CL; - COM = registry.COM; - Con = registry.Con; - Draw = registry.Draw; - IN = registry.IN; - Key = registry.Key; - M = registry.M; - Mod = registry.Mod; - NET = registry.NET; - PR = registry.PR; - R = registry.R; - S = registry.S; - SCR = registry.SCR; - SV = registry.SV; - Sbar = registry.Sbar; - Sys = registry.Sys; - V = registry.V; + ({ COM, Con, Mod, NET, PR, SV, Sys, V } = getCommonRegistry()); + ({ CL, Draw, IN, Key, M, R, S, SCR, Sbar } = getClientRegistry()); }); +type DeferredCallback = () => void | Promise; +interface ScheduledFutureEntry { + readonly time: number; + readonly callback: DeferredCallback; +} +type PrintFunction = (text: string) => void; +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; +type SavegameEdictEntry = [classname: string, data: JsonValue] | null; +type SpawnParameters = ServerClient['spawn_parms']; +type CrashLike = + | Error + | string + | null + | undefined + | { + readonly name?: string; + readonly message?: string; + readonly constructor?: { readonly name?: string }; + }; + +interface SavegameState { + readonly version: number; + readonly gameversion: string; + readonly comment: string | null; + readonly spawn_parms: SpawnParameters; + readonly mapname: string; + readonly time: number; + readonly lightstyles: string[]; + readonly globals: JsonValue; + readonly cvars: Array<[name: string, value: string]>; + readonly clientdata: JsonValue | null; + readonly edicts: SavegameEdictEntry[]; + readonly num_edicts: number; + readonly particles: JsonValue; +} + +/** Extracts a display name from a CrashLike value. */ +function crashName(error: CrashLike): string { + if (error instanceof Error) { + return error.name; + } + + if (typeof error === 'string') { + return 'Error'; + } + + return error?.name ?? error?.constructor?.name ?? 'Error'; +} + +/** Extracts a human-readable message from a CrashLike value. */ +function crashMessage(error: CrashLike): string { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === 'string') { + return error; + } + + return error?.message ?? 'Unknown error'; +} + class HostConsoleCommand extends ConsoleCommand { /** - * @protected - * @returns {boolean} true, if it’s a cheat and cannot be invoked + * Returns true when the command must abort because cheats are disabled. */ - cheat() { - if (!SV.cheats.value) { - Host.ClientPrint(this.client, 'Cheats are not enabled on this server.\n'); - return true; + cheat(): boolean { + if (SV.cheats.value) { + return false; + } + + const client = this.client; + + if (client !== null) { + Host.ClientPrint(client, 'Cheats are not enabled on this server.\n'); } - return false; + return true; } } +/** + * Host lifecycle singleton. + * + * Historically this module was a mutable namespace object. It now uses a real + * class with static state and methods so the engine can keep the existing + * `Host.X` call sites while gaining native TypeScript typing. + */ export default class Host { - static developer = null; - static dedicated = null; + static developer: Cvar | null = null; + static dedicated: Cvar | null = null; static framecount = 0; - static framerate = null; + static framerate: Cvar | null = null; static frametime = 0.0; static initialized = false; static inerror = false; static isdown = false; static noclip_anglehack = false; static oldrealtime = 0.0; - static pausable = null; + static pausable: Cvar | null = null; static realtime = 0.0; - static refreshrate = null; - static speeds = null; - static teamplay = null; - static ticrate = null; - static version = null; - - static EndGame(message) { - Con.PrintSuccess('Host.EndGame: ' + message + '\n'); + static refreshrate: Cvar | null = null; + static speeds: Cvar | null = null; + static teamplay: Cvar | null = null; + static ticrate: Cvar | null = null; + static version: Cvar | null = null; + + /** Callbacks that must run before the next frame body starts. */ + static readonly _scheduledForNextFrame: DeferredCallback[] = []; + + /** Named deferred tasks used to coalesce repeated requests. */ + static readonly _scheduleInFuture = new Map(); + + static #inHandleCrash = false; + + static EndGame(message: string): void { + Con.PrintSuccess(`Host.EndGame: ${message}\n`); + if (CL.cls.demonum !== -1) { CL.NextDemo(); - } else { - CL.Disconnect(); - M.Alert('Host.EndGame', message); + return; } - }; - static Error(error) { - if (Host.inerror === true) { + CL.Disconnect(); + M.Alert('Host.EndGame', message); + } + + static Error(error: string): never | void { + if (Host.inerror) { throw new Error('throw new HostError: recursively entered'); } + Host.inerror = true; + if (!registry.isDedicatedServer) { SCR.EndLoadingPlaque(); } - Con.PrintError('Host Error: ' + error + '\n'); - if (SV.server.active === true) { + + Con.PrintError(`Host Error: ${error}\n`); + + if (SV.server.active) { Host.ShutdownServer(); } + CL.Disconnect(); CL.cls.demonum = -1; Host.inerror = false; M.Alert('Host Error', error); - }; + } - static FindMaxClients() { + static FindMaxClients(): void { SV.svs.maxclients = 1; SV.svs.maxclientslimit = Def.limits.clients; SV.svs.clients.length = 0; + if (!registry.isDedicatedServer) { CL.cls.state = Def.clientConnectionState.disconnected; } - for (let i = 0; i < SV.svs.maxclientslimit; i++) { - SV.svs.clients.push(new ServerClient(i)); + + for (let index = 0; index < SV.svs.maxclientslimit; index++) { + SV.svs.clients.push(new ServerClient(index)); } - }; + } - static InitLocal() { + static InitLocal(): void { const commitHash = registry.buildConfig?.commitHash; const version = commitHash ? `${Def.productVersion}+${commitHash}` : Def.productVersion; @@ -131,48 +218,44 @@ export default class Host { /** @deprecated use registry.isDedicatedServer instead, this is only made available to the game code */ Host.dedicated = new Cvar('dedicated', registry.isDedicatedServer ? '1' : '0', Cvar.FLAG.READONLY, 'Set to 1, if running in dedicated server mode.'); - eventBus.subscribe('cvar.changed', (name) => { + eventBus.subscribe('cvar.changed', (name: string) => { const cvar = Cvar.FindVar(name); - // Automatically save when an archive Cvar changed + if (cvar === null) { + return; + } + + // Automatically save when an archive Cvar changed. if ((cvar.flags & Cvar.FLAG.ARCHIVE) && Host.initialized) { Host.WriteConfiguration(); } }); Host.FindMaxClients(); - }; + } - static SendChatMessageToClient(client, name, message, direct = false) { + /** Sends a chat message packet to a single client. */ + static SendChatMessageToClient(client: ServerClient, name: string, message: string, direct = false): void { client.message.writeByte(Protocol.svc.chatmsg); client.message.writeString(name); client.message.writeString(message); client.message.writeByte(direct ? 1 : 0); - }; + } - /** - * @param {ServerClient} client recipient client - * @param {string} string text to send - */ - static ClientPrint(client, string) { + /** Sends a plain print message to a single client. */ + static ClientPrint(client: ServerClient, text: string): void { client.message.writeByte(Protocol.svc.print); - client.message.writeString(string); - }; + client.message.writeString(text); + } - static BroadcastPrint(string) { + static BroadcastPrint(text: string): void { for (const client of SV.svs.spawnedClients()) { client.message.writeByte(Protocol.svc.print); - client.message.writeString(string); + client.message.writeString(text); } - }; + } - /** - * - * @param {ServerClient} client - * @param {boolean} crash - * @param {string} reason - */ - static DropClient(client, crash, reason) { // TODO: refactor into ServerClient + static DropClient(client: ServerClient, crash: boolean, reason: string): void { // TODO: refactor into ServerClient if (NET.CanSendMessage(client.netconnection)) { client.message.writeByte(Protocol.svc.disconnect); client.message.writeString(reason); @@ -181,16 +264,20 @@ export default class Host { if (!crash) { if (client.edict && client.state === ServerClient.STATE.SPAWNED) { - const saveSelf = SV.server.gameAPI.self; - SV.server.gameAPI.ClientDisconnect(client.edict); - if (saveSelf !== undefined) { - SV.server.gameAPI.self = saveSelf; + const gameAPI = SV.server.gameAPI as typeof SV.server.gameAPI & { self?: ServerClient['edict'] }; + const savedSelf = gameAPI.self; + + gameAPI.ClientDisconnect(client.edict); + + if (savedSelf !== undefined) { + gameAPI.self = savedSelf; } } - Sys.Print('Client ' + client.name + ' removed\n'); + + Sys.Print(`Client ${client.name} removed\n`); } else { client.state = ServerClient.STATE.DROPASAP; - Sys.Print('Client ' + client.name + ' dropped\n'); + Sys.Print(`Client ${client.name} dropped\n`); } NET.Close(client.netconnection); @@ -198,86 +285,100 @@ export default class Host { const { name, num } = client; client.clear(); - NET.activeconnections--; eventBus.publish('server.client.disconnected', num, name); - for (let i = 0; i < SV.svs.maxclients; i++) { - const client = SV.svs.clients[i]; - if (client.state <= ServerClient.STATE.CONNECTED) { + for (let index = 0; index < SV.svs.maxclients; index++) { + const spawnedClient = SV.svs.clients[index]; + + if (spawnedClient.state <= ServerClient.STATE.CONNECTED) { continue; } - // FIXME: consolidate into a single message - client.message.writeByte(Protocol.svc.updatename); - client.message.writeByte(num); - client.message.writeByte(0); - client.message.writeByte(Protocol.svc.updatefrags); - client.message.writeByte(num); - client.message.writeShort(0); - client.message.writeByte(Protocol.svc.updatecolors); - client.message.writeByte(num); - client.message.writeByte(0); - client.message.writeByte(Protocol.svc.updatepings); - client.message.writeByte(num); - client.message.writeShort(0); + + // FIXME: consolidate into a single message. + spawnedClient.message.writeByte(Protocol.svc.updatename); + spawnedClient.message.writeByte(num); + spawnedClient.message.writeByte(0); + spawnedClient.message.writeByte(Protocol.svc.updatefrags); + spawnedClient.message.writeByte(num); + spawnedClient.message.writeShort(0); + spawnedClient.message.writeByte(Protocol.svc.updatecolors); + spawnedClient.message.writeByte(num); + spawnedClient.message.writeByte(0); + spawnedClient.message.writeByte(Protocol.svc.updatepings); + spawnedClient.message.writeByte(num); + spawnedClient.message.writeShort(0); } - }; + } - static ShutdownServer(isCrashShutdown = false) { // TODO: SV duties - if (SV.server.active !== true) { + static ShutdownServer(isCrashShutdown = false): void { // TODO: SV duties + if (!SV.server.active) { return; } + eventBus.publish('server.shutting-down'); SV.server.active = false; + if (!registry.isDedicatedServer && CL.cls.state === Def.clientConnectionState.connected) { CL.Disconnect(); } - const start = Sys.FloatTime(); let count; let i; + + const start = Sys.FloatTime(); + let count = 0; + do { count = 0; - for (i = 0; i < SV.svs.maxclients; i++) { // FIXME: this 1is completely broken, it won’t properly close connections - const client = SV.svs.clients[i]; + + for (let index = 0; index < SV.svs.maxclients; index++) { // FIXME: this is completely broken, it won’t properly close connections + const client = SV.svs.clients[index]; + if (client.state < ServerClient.STATE.CONNECTED || client.message.cursize === 0) { continue; } + if (NET.CanSendMessage(client.netconnection)) { NET.SendMessage(client.netconnection, client.message); client.message.clear(); continue; } + NET.GetMessage(client.netconnection); count++; } - if ((Sys.FloatTime() - start) > 3.0) { // this breaks a loop when the stuff on the top is stuck + + if ((Sys.FloatTime() - start) > 3.0) { break; } } while (count !== 0); - for (i = 0; i < SV.svs.maxclients; i++) { - const client = SV.svs.clients[i]; + + for (let index = 0; index < SV.svs.maxclients; index++) { + const client = SV.svs.clients[index]; + if (client.state >= ServerClient.STATE.CONNECTED) { Host.DropClient(client, isCrashShutdown, 'Server shutting down'); } } + SV.ShutdownServer(isCrashShutdown); eventBus.publish('server.shutdown'); - }; + } - static ConfigReady_f() { + static ConfigReady_f(): void { eventBus.publish('host.config.loaded'); Con.DPrint('Loaded configuration\n'); - }; + } - static WriteConfiguration() { + static WriteConfiguration(): void { Host.ScheduleInFuture('Host.WriteConfiguration', () => { - // never save a config during pending commands + // Never save a config during pending commands. if (Cmd.HasPendingCommands()) { Con.PrintWarning('Writing configuration dismissed, pending commands outstanding. Try again later.\n'); return; } const config = ` - ${(!registry.isDedicatedServer ? Key.WriteBindings() + '\n\n\n': '')} + ${!registry.isDedicatedServer ? `${Key.WriteBindings()}\n\n\n` : ''} ${Cvar.WriteVariables()} @@ -286,37 +387,37 @@ export default class Host { COM.WriteTextFile('config.cfg', config); Con.DPrint('Wrote configuration\n'); - }, 5.000); - }; + }, 5.0); + } - static WriteConfiguration_f() { + static WriteConfiguration_f(): void { Con.Print('Writing configuration\n'); Host.WriteConfiguration(); - }; + } - static ServerFrame() { // TODO: move to SV.ServerFrame + static ServerFrame(): void { // TODO: move to SV.ServerFrame SV.server.gameAPI.frametime = Host.frametime; SV.server.datagram.clear(); SV.server.expedited_datagram.clear(); SV.CheckForNewClients(); SV.RunClients(); - if ((SV.server.paused !== true) && ((SV.svs.maxclients >= 2) || (!registry.isDedicatedServer && Key.dest.value === Key.dest.game))) { + + if (SV.server.paused !== true && (SV.svs.maxclients >= 2 || (!registry.isDedicatedServer && Key.dest.value === Key.dest.game))) { SV.physics.physics(); } + SV.RunScheduledGameCommands(); SV.messages.sendClientMessages(); - }; + } - static _scheduledForNextFrame = []; - static ScheduleForNextFrame(callback) { + static ScheduleForNextFrame(callback: DeferredCallback): void { Host._scheduledForNextFrame.push(callback); - }; + } - static _scheduleInFuture = new Map(); - static ScheduleInFuture(name, callback, whenInSeconds) { + static ScheduleInFuture(name: string, callback: DeferredCallback, whenInSeconds: number): void { if (Host.isdown) { - // there’s no future when shutting down - callback(); + // There’s no future when shutting down. + void callback(); return; } @@ -328,29 +429,33 @@ export default class Host { time: Host.realtime + whenInSeconds, callback, }); - }; + } - static async _Frame() { + static async _Frame(): Promise { Host.realtime = Sys.FloatTime(); Host.frametime = Host.realtime - Host.oldrealtime; Host.oldrealtime = Host.realtime; - if (Host.framerate.value > 0) { + + if (Host.framerate !== null && Host.framerate.value > 0) { Host.frametime = Host.framerate.value; - } else { - if (Host.frametime > 0.1) { - Host.frametime = 0.1; - } else if (Host.frametime < 0.001) { - Host.frametime = 0.001; - } + } else if (Host.frametime > 0.1) { + Host.frametime = 0.1; + } else if (Host.frametime < 0.001) { + Host.frametime = 0.001; } - // check all scheduled things for the next frame + // Check all scheduled things for the next frame. while (Host._scheduledForNextFrame.length > 0) { const callback = Host._scheduledForNextFrame.shift(); + + if (callback === undefined) { + break; + } + await callback(); } - // check what’s scheduled in future + // Check what’s scheduled in the future. for (const [name, { time, callback }] of Host._scheduleInFuture.entries()) { if (time > Host.realtime) { continue; @@ -363,22 +468,19 @@ export default class Host { if (registry.isDedicatedServer) { Cmd.Execute(); - if (SV.server.active === true) { - if (Host.speeds.value !== 0) { + if (SV.server.active) { + if (Host.speeds !== null && Host.speeds.value !== 0) { console.profile('Host.ServerFrame'); } Host.ServerFrame(); - if (Host.speeds.value !== 0) { + if (Host.speeds !== null && Host.speeds.value !== 0) { console.profileEnd('Host.ServerFrame'); } } - // TODO: add times - Host.framecount++; - return; } @@ -394,51 +496,53 @@ export default class Host { CL.ReadFromServer(); } - if (Host.speeds.value !== 0) { + if (Host.speeds !== null && Host.speeds.value !== 0) { console.profile('CL.ClientFrame'); } + CL.ClientFrame(); - if (Host.speeds.value !== 0) { + + if (Host.speeds !== null && Host.speeds.value !== 0) { console.profileEnd('CL.ClientFrame'); } CL.SendCmd(); if (SV.server.active && !SV.svs.changelevelIssued) { - if (Host.speeds.value !== 0) { + if (Host.speeds !== null && Host.speeds.value !== 0) { console.profile('Host.ServerFrame'); } Host.ServerFrame(); - if (Host.speeds.value !== 0) { + if (Host.speeds !== null && Host.speeds.value !== 0) { console.profileEnd('Host.ServerFrame'); } } - // Set up prediction for other players + // Set up prediction for other players. CL.SetUpPlayerPrediction(false); - if (Host.speeds.value !== 0) { + if (Host.speeds !== null && Host.speeds.value !== 0) { console.profile('CL.PredictMove'); } - // do client side motion prediction + // Do client-side motion prediction. CL.PredictMove(); - if (Host.speeds.value !== 0) { + if (Host.speeds !== null && Host.speeds.value !== 0) { console.profileEnd('CL.PredictMove'); } - // Set up prediction for other players + // Set up prediction for other players. CL.SetUpPlayerPrediction(true); - // build a refresh entity list + // Build a refresh entity list. CL.state.clientEntities.emit(); SCR.UpdateScreen(); - if (Host.speeds.value !== 0) { + if (Host.speeds !== null && Host.speeds.value !== 0) { console.profile('S.Update'); } @@ -447,48 +551,49 @@ export default class Host { } else { S.Update(Vector.origin, Vector.origin, Vector.origin, Vector.origin, false); } + CDAudio.Update(); - if (Host.speeds.value !== 0) { + if (Host.speeds !== null && Host.speeds.value !== 0) { console.profileEnd('S.Update'); } Host.framecount++; - }; - - static #inHandleCrash = false; + } - // TODO: Sys.Init can handle a crash now since we are main looping without setInterval - static HandleCrash(e) { - if (e instanceof HostError) { - Host.Error(e.message); + // TODO: Sys.Init can handle a crash now since we are main looping without setInterval. + static HandleCrash(error: CrashLike): void { + if (error instanceof HostError) { + Host.Error(error.message); return; } + if (Host.#inHandleCrash) { - console.error(e); + console.error(error); // eslint-disable-next-line no-debugger debugger; return; } + Host.#inHandleCrash = true; - Con.PrintError((e.name ?? e.constructor.name) + ': ' + e.message + '\n'); - eventBus.publish('host.crash', e); + Con.PrintError(`${crashName(error)}: ${crashMessage(error)}\n`); + eventBus.publish('host.crash', error); Sys.Quit(); - }; + } - static async Frame() { + static async Frame(): Promise { if (Host.#inHandleCrash) { return; } try { await Host._Frame(); - } catch (e) { - Host.HandleCrash(e); + } catch (error) { + Host.HandleCrash(error as CrashLike); } - }; + } - static async Init() { + static async Init(): Promise { Host.oldrealtime = Sys.FloatTime(); Cmd.Init(); Cvar.Init(); @@ -530,90 +635,94 @@ export default class Host { IN.Init(); } - Cmd.text = 'exec better-quake.rc\n' + Cmd.text; - + Cmd.text = `exec better-quake.rc\n${Cmd.text}`; // eslint-disable-next-line require-atomic-updates Host.initialized = true; Sys.Print('========Host Initialized=========\n'); eventBus.publish('host.ready'); - }; + } - static Shutdown() { - if (Host.isdown === true) { + static Shutdown(): void { + if (Host.isdown) { Sys.Print('recursive shutdown\n'); return; } + eventBus.publish('host.shutting-down'); Host.isdown = true; Host.WriteConfiguration(); + if (!registry.isDedicatedServer) { S.Shutdown(); CDAudio.Shutdown(); } + NET.Shutdown(); + if (!registry.isDedicatedServer) { IN.Shutdown(); VID.Shutdown(); } + Pmove.Shutdown(); Cmd.Shutdown(); Cvar.Shutdown(); eventBus.publish('host.shutdown'); - }; + } // Commands - static Quit_f() { - if (!registry.isDedicatedServer) { - if (Key.dest.value !== Key.dest.console) { - M.Menu_Quit_f(); - return; - } + static Quit_f(): void { + if (!registry.isDedicatedServer && Key.dest.value !== Key.dest.console) { + M.Menu_Quit_f(); + return; } - if (SV.server.active === true) { + if (SV.server.active) { Host.ShutdownServer(); } COM.Shutdown(); Sys.Quit(); - }; + } - static Status_f() { - /** @type {Function} */ - let print; - if (!this.client) { + static Status_f(this: ConsoleCommand): void { + let print: PrintFunction; + + if (this.client === null) { if (!SV.server.active) { if (registry.isDedicatedServer) { Con.Print('No active server\n'); return; } + this.forward(); return; } + print = Con.Print; } else { const client = this.client; - print = (text) => { + print = (text: string) => { Host.ClientPrint(client, text); }; } - print('hostname: ' + NET.hostname.string + '\n'); - print('address : ' + NET.GetListenAddress() + '\n'); - print('version : ' + Host.version.string + ' (' + SV.server.gameVersion + ')\n'); - print('map : ' + SV.server.mapname + '\n'); - print('game : ' + SV.server.gameName + '\n'); - print('edicts : ' + SV.server.num_edicts + ' used of ' + SV.server.edicts.length + ' allocated\n'); - print('players : ' + NET.activeconnections + ' active (' + SV.svs.maxclients + ' max)\n\n'); - const lines = []; + print(`hostname: ${NET.hostname.string}\n`); + print(`address : ${NET.GetListenAddress()}\n`); + print(`version : ${Host.version!.string} (${SV.server.gameVersion})\n`); + print(`map : ${SV.server.mapname}\n`); + print(`game : ${SV.server.gameName}\n`); + print(`edicts : ${SV.server.num_edicts} used of ${SV.server.edicts.length} allocated\n`); + print(`players : ${NET.activeconnections} active (${SV.svs.maxclients} max)\n\n`); - for (let i = 0; i < SV.svs.maxclients; i++) { - /** @type {ServerClient} */ - const client = SV.svs.clients[i]; + const lines: string[] = []; - if (client.state < ServerClient.STATE.CONNECTED) { + for (let index = 0; index < SV.svs.maxclients; index++) { + const client = SV.svs.clients[index]; + + if (client.state < ServerClient.STATE.CONNECTED || client.netconnection === null) { continue; } @@ -623,12 +732,12 @@ export default class Host { client.uniqueId.substring(0, 19).padEnd(19), Q.secsToTime(NET.time - client.netconnection.connecttime).padEnd(9), client.ping.toFixed(0).padStart(4), - new Number(0).toFixed(0).padStart(4), // TODO: add loss + Number(0).toFixed(0).padStart(4), // TODO: add loss ServerClient.STATE.toKey(client.state).padEnd(10), client.netconnection.address, ]; - lines.push(parts.join(' | ') + '\n'); + lines.push(`${parts.join(' | ')}\n`); } if (lines.length === 0) { @@ -641,59 +750,73 @@ export default class Host { for (const line of lines) { print(line); } - }; + } static God_f = class extends HostConsoleCommand { - run() { - if (this.forward()) { + override run(): void { + if (this.forward() || this.cheat()) { return; } - if (this.cheat()) { + + const client = this.client; + + if (client === null) { return; } - const client = this.client; + client.edict.entity.flags ^= Defs.flags.FL_GODMODE; + if ((client.edict.entity.flags & Defs.flags.FL_GODMODE) === 0) { Host.ClientPrint(client, 'godmode OFF\n'); - } else { - Host.ClientPrint(client, 'godmode ON\n'); + return; } + + Host.ClientPrint(client, 'godmode ON\n'); } }; static Notarget_f = class extends HostConsoleCommand { - run() { - if (this.forward()) { + override run(): void { + if (this.forward() || this.cheat()) { return; } - if (this.cheat()) { + + const client = this.client; + + if (client === null) { return; } - const client = this.client; + client.edict.entity.flags ^= Defs.flags.FL_NOTARGET; + if ((client.edict.entity.flags & Defs.flags.FL_NOTARGET) === 0) { Host.ClientPrint(client, 'notarget OFF\n'); - } else { - Host.ClientPrint(client, 'notarget ON\n'); + return; } + + Host.ClientPrint(client, 'notarget ON\n'); } }; static Noclip_f = class extends HostConsoleCommand { - run() { - if (this.forward()) { + override run(): void { + if (this.forward() || this.cheat()) { return; } - if (this.cheat()) { + + const client = this.client; + + if (client === null) { return; } - const client = this.client; + if (client.edict.entity.movetype !== Defs.moveType.MOVETYPE_NOCLIP) { Host.noclip_anglehack = true; client.edict.entity.movetype = Defs.moveType.MOVETYPE_NOCLIP; Host.ClientPrint(client, 'noclip ON\n'); return; } + Host.noclip_anglehack = false; client.edict.entity.movetype = Defs.moveType.MOVETYPE_WALK; Host.ClientPrint(client, 'noclip OFF\n'); @@ -701,36 +824,43 @@ export default class Host { }; static Fly_f = class extends HostConsoleCommand { - run() { - if (this.forward()) { + override run(): void { + if (this.forward() || this.cheat()) { return; } - if (this.cheat()) { + + const client = this.client; + + if (client === null) { return; } - const client = this.client; + if (client.edict.entity.movetype !== Defs.moveType.MOVETYPE_FLY) { client.edict.entity.movetype = Defs.moveType.MOVETYPE_FLY; Host.ClientPrint(client, 'flymode ON\n'); return; } + client.edict.entity.movetype = Defs.moveType.MOVETYPE_WALK; Host.ClientPrint(client, 'flymode OFF\n'); } }; - static Ping_f() { + static Ping_f(this: ConsoleCommand): void { if (this.forward()) { return; } const recipientClient = this.client; + if (recipientClient === null) { + return; + } + Host.ClientPrint(recipientClient, 'Client ping times:\n'); - for (let i = 0; i < SV.svs.maxclients; i++) { - /** @type {ServerClient} */ - const client = SV.svs.clients[i]; + for (let index = 0; index < SV.svs.maxclients; index++) { + const client = SV.svs.clients[index]; if (client.state < ServerClient.STATE.CONNECTED) { continue; @@ -738,62 +868,59 @@ export default class Host { let total = 0; - for (let j = 0; j < client.ping_times.length; j++) { - total += client.ping_times[j]; + for (let pingIndex = 0; pingIndex < client.ping_times.length; pingIndex++) { + total += client.ping_times[pingIndex]; } - Host.ClientPrint(recipientClient, (total * 62.5).toFixed(0).padStart(3) + ' ' + client.name + '\n'); + Host.ClientPrint(recipientClient, `${(total * 62.5).toFixed(0).padStart(3)} ${client.name}\n`); } - }; + } - static Map_f(mapname, ...spawnparms) { + static Map_f(this: ConsoleCommand, mapname?: string, ...spawnparms: string[]): void { if (mapname === undefined) { Con.Print('Usage: map \n'); return; } - if (this.client) { + + if (this.client !== null) { return; } + // if (!SV.HasMap(mapname)) { // Con.Print(`No such map: ${mapname}\n`); // return; // } + if (!registry.isDedicatedServer) { CL.cls.demonum = -1; CL.Disconnect(); } + Host.ShutdownServer(); // CR: this is the reason why you would need to use changelevel on Counter-Strike 1.6 etc. + if (!registry.isDedicatedServer) { Key.dest.value = Key.dest.game; SCR.BeginLoadingPlaque(); - } - SV.svs.serverflags = 0; - - if (!registry.isDedicatedServer) { CL.SetConnectingStep(5, 'Spawning server'); - } - - if (!registry.isDedicatedServer) { CL.cls.spawnparms = spawnparms.join(' '); } + SV.svs.serverflags = 0; + Host.ScheduleForNextFrame(async () => { if (!await SV.SpawnServer(mapname)) { SV.ShutdownServer(false); - throw new HostError('Could not spawn server with map ' + mapname); + throw new HostError(`Could not spawn server with map ${mapname}`); } if (!registry.isDedicatedServer) { CL.SetConnectingStep(null, null); - } - - if (!registry.isDedicatedServer) { CL.Connect('local'); } }); - }; + } - static Changelevel_f(mapname) { + static Changelevel_f(mapname?: string): void { if (mapname === undefined) { Con.Print('Usage: changelevel \n'); return; @@ -810,65 +937,66 @@ export default class Host { SV.svs.changelevelIssued = true; - for (let i = 0; i < SV.svs.maxclients; i++) { - const client = SV.svs.clients[i]; + for (let index = 0; index < SV.svs.maxclients; index++) { + const client = SV.svs.clients[index]; + if (client.state < ServerClient.STATE.CONNECTED) { continue; } + client.message.writeByte(Protocol.svc.changelevel); client.message.writeString(mapname); } if (!registry.isDedicatedServer) { - // this hack allows us to show the loading plaque and resetting the client renderer + // This hack allows us to show the loading plaque while resetting the client renderer. CL.cls.changelevel = true; CL.cls.signon = 0; } Host.ScheduleForNextFrame(async () => { SV.SaveSpawnparms(); - - Con.DPrint('Host.Changelevel_f: changing level to ' + mapname + '\n'); + Con.DPrint(`Host.Changelevel_f: changing level to ${mapname}\n`); if (!await SV.SpawnServer(mapname)) { SV.ShutdownServer(false); - throw new HostError('Could not spawn server for changelevel to ' + mapname); + throw new HostError(`Could not spawn server for changelevel to ${mapname}`); } - Con.DPrint('Host.Changelevel_f: spawned server for changelevel to ' + mapname + '\n'); + Con.DPrint(`Host.Changelevel_f: spawned server for changelevel to ${mapname}\n`); if (!registry.isDedicatedServer) { CL.SetConnectingStep(null, null); } }); - }; + } - static Restart_f() { - if ((SV.server.active) && (registry.isDedicatedServer || !CL.cls.demoplayback && !this.client)) { + static Restart_f(this: ConsoleCommand): void { + if (SV.server.active && (registry.isDedicatedServer || (!CL.cls.demoplayback && this.client === null))) { void Cmd.ExecuteString(`map ${SV.server.mapname}`); } - }; + } - // NOTE: this is the dedicated server version of disconnect - static Disconnect_f() { + // NOTE: this is the dedicated server version of disconnect. + static Disconnect_f(): void { if (!SV.server.active) { Con.Print('No active server\n'); return; } Host.ShutdownServer(); - }; + } - static Reconnect_f() { + static Reconnect_f(): void { if (registry.isDedicatedServer) { Con.Print('cannot reconnect in dedicated server mode\n'); return; } Con.PrintWarning('NOT IMPLEMENTED: reconnect\n'); // TODO: reimplement reconnect here - }; + } - static Connect_f(address) { + static Connect_f(address?: string): void { if (address === undefined) { Con.Print('Usage: connect
\n'); Con.Print(' -
can be "self", connecting to the current domain name\n'); @@ -881,117 +1009,128 @@ export default class Host { } CL.cls.demonum = -1; - if (CL.cls.demoplayback === true) { + + if (CL.cls.demoplayback) { CL.StopPlayback(); CL.Disconnect(); } if (address === 'self') { const url = new URL(location.href); - CL.Connect((url.protocol === 'https:' ? 'wss' : 'ws') + '://' + url.host + url.pathname + (!url.pathname.endsWith('/') ? '/' : '') + 'api/'); + const path = !url.pathname.endsWith('/') ? `${url.pathname}/` : url.pathname; + CL.Connect(`${url.protocol === 'https:' ? 'wss' : 'ws'}://${url.host}${path}api/`); } else { CL.Connect(address); } CL.cls.signon = 0; - }; + } - static Savegame_f(savename) { - if (this.client) { + static Savegame_f(this: ConsoleCommand, savename?: string): void { + if (this.client !== null) { return; } + if (savename === undefined) { Con.Print('Usage: save \n'); return; } - if (SV.server.active !== true) { + + if (!SV.server.active) { Con.PrintWarning('Not playing a local game.\n'); return; } + if (CL.state.intermission !== 0) { Con.PrintWarning('Can\'t save in intermission.\n'); return; } + if (SV.svs.maxclients !== 1) { Con.PrintWarning('Can\'t save multiplayer games.\n'); return; } - if (savename.indexOf('..') !== -1) { + + if (savename.includes('..')) { Con.PrintWarning('Relative pathnames are not allowed.\n'); return; } + const client = SV.svs.clients[0]; - if (client.state >= ServerClient.STATE.CONNECTED) { - if (client.edict.entity.health <= 0.0) { - Con.PrintWarning('Can\'t savegame with a dead player\n'); - return; - } + + if (client.state >= ServerClient.STATE.CONNECTED && client.edict.entity.health <= 0.0) { + Con.PrintWarning('Can\'t savegame with a dead player\n'); + return; } - const gamestate = { + const clientdata = CL.state.gameAPI ? CL.state.gameAPI.saveGame() as JsonValue : null; + + // IDEA: we could actually compress this by using a list of common fields. + const edicts: SavegameEdictEntry[] = []; + + for (const edict of SV.server.edicts) { + edicts.push(edict.isFree() ? null : [edict.entity.classname, edict.entity.serialize() as JsonValue]); + } + + const globals = SV.server.gameAPI.serialize() as JsonValue; + + const gamestate: SavegameState = { version: Def.gamestateVersion, gameversion: SV.server.gameVersion, - comment: CL.state.levelname, // TODO: ask the game for a comment + comment: CL.state.levelname, spawn_parms: client.spawn_parms, mapname: SV.server.mapname, time: SV.server.time, lightstyles: SV.server.lightstyles, - globals: null, - cvars: [...Cvar.Filter((cvar) => cvar.flags & (Cvar.FLAG.SERVER | Cvar.FLAG.GAME))].map((cvar) => [cvar.name, cvar.string]), - clientdata: null, - edicts: [], + globals, + cvars: [...Cvar.Filter((cvar) => (cvar.flags & (Cvar.FLAG.SERVER | Cvar.FLAG.GAME)) !== 0)].map((cvar) => [cvar.name, cvar.string]), + clientdata, + edicts, num_edicts: SV.server.num_edicts, - // TODO: client entities - particles: R.SerializeParticles(), + particles: R.SerializeParticles() as JsonValue, }; - if (CL.state.gameAPI) { - gamestate.clientdata = CL.state.gameAPI.saveGame(); - } - - // IDEA: we could actually compress this by using a list of common fields - for (const edict of SV.server.edicts) { - if (edict.isFree()) { - gamestate.edicts.push(null); - continue; - } - - gamestate.edicts.push([edict.entity.classname, edict.entity.serialize()]); - } + const filename = COM.DefaultExtension(savename, '.json'); - gamestate.globals = SV.server.gameAPI.serialize(); + Con.Print(`Saving game to ${filename}...\n`); - const name = COM.DefaultExtension(savename, '.json'); - Con.Print('Saving game to ' + name + '...\n'); - if (COM.WriteTextFile(name, JSON.stringify(gamestate))) { + if (COM.WriteTextFile(filename, JSON.stringify(gamestate))) { Con.PrintSuccess('done.\n'); - } else { - Con.PrintError('ERROR: couldn\'t open.\n'); + return; } - }; - static async Loadgame_f(savename) { - if (this.client) { + Con.PrintError('ERROR: couldn\'t open.\n'); + } + + static async Loadgame_f(this: ConsoleCommand, savename?: string): Promise { + if (this.client !== null) { return; } + if (savename === undefined) { Con.Print('Usage: load \n'); return; } - if (savename.indexOf('..') !== -1) { + + if (savename.includes('..')) { Con.PrintWarning('Relative pathnames are not allowed.\n'); return; } + CL.cls.demonum = -1; - const name = COM.DefaultExtension(savename, '.json'); - Con.Print('Loading game from ' + name + '...\n'); - const data = await COM.LoadTextFile(name); + + const filename = COM.DefaultExtension(savename, '.json'); + + Con.Print(`Loading game from ${filename}...\n`); + + const data = await COM.LoadTextFile(filename); + if (data === null) { Con.PrintError('ERROR: couldn\'t open.\n'); return; } - const gamestate = JSON.parse(data); + const gamestate = JSON.parse(data) as SavegameState; if (gamestate.version !== Def.gamestateVersion) { throw new HostError(`Savegame is version ${gamestate.version}, not ${Def.gamestateVersion}\n`); @@ -999,14 +1138,16 @@ export default class Host { CL.Disconnect(); - // restore all server/game cvars + // Restore all server and game cvars. for (const [name, value] of gamestate.cvars) { const cvar = Cvar.FindVar(name); - if (cvar) { + + if (cvar !== null) { cvar.set(value); - } else { - Con.PrintWarning(`Saved cvar ${name} not found, skipping\n`); + continue; } + + Con.PrintWarning(`Saved cvar ${name} not found, skipping\n`); } if (!await SV.SpawnServer(gamestate.mapname)) { @@ -1015,7 +1156,7 @@ export default class Host { } SV.ShutdownServer(false); - throw new HostError(`Couldn't load map ${gamestate.mapname} for save game ${name}\n`); + throw new HostError(`Couldn't load map ${gamestate.mapname} for save game ${filename}\n`); } if (gamestate.gameversion !== SV.server.gameVersion) { @@ -1032,30 +1173,32 @@ export default class Host { SV.server.num_edicts = gamestate.num_edicts; console.assert(SV.server.num_edicts <= SV.server.edicts.length, 'resizing edicts not supported yet'); // TODO: alloc more edicts - // first run through all edicts to make sure the entity structures get initialized - for (let i = 0; i < SV.server.edicts.length; i++) { - const edict = SV.server.edicts[i]; + // First run through all edicts to make sure the entity structures get initialized. + for (let index = 0; index < SV.server.edicts.length; index++) { + const edict = SV.server.edicts[index]; + const serializedEdict = gamestate.edicts[index]; - if (!gamestate.edicts[i]) { // freed edict - // FIXME: QuakeC doesn’t like it at all when edicts suddenly disappear, we should offload this code to the GameAPI + if (serializedEdict === undefined || serializedEdict === null) { + // FIXME: QuakeC doesn’t like it at all when edicts suddenly disappear, we should offload this code to the GameAPI. edict.freeEdict(); continue; } - const [classname] = gamestate.edicts[i]; + const [classname] = serializedEdict; console.assert(SV.server.gameAPI.prepareEntity(edict, classname), 'no entity for classname'); } - // second run we can start deserializing - for (let i = 0; i < SV.server.edicts.length; i++) { - const edict = SV.server.edicts[i]; + // Second run: we can start deserializing now that entity classes exist. + for (let index = 0; index < SV.server.edicts.length; index++) { + const edict = SV.server.edicts[index]; + const serializedEdict = gamestate.edicts[index]; - if (edict.isFree()) { // freed edict + if (edict.isFree() || serializedEdict === undefined || serializedEdict === null) { continue; } - const [, data] = gamestate.edicts[i]; - edict.entity.deserialize(data); + const [, entityData] = serializedEdict; + edict.entity.deserialize(entityData); edict.linkEdict(); } @@ -1065,173 +1208,188 @@ export default class Host { client.spawn_parms = gamestate.spawn_parms; ClientLifecycle.resumeGame(gamestate.clientdata, gamestate.particles); - }; + } - static Name_f(...names) { // signon 2, step 1 + static Name_f(this: ConsoleCommand, ...names: string[]): void { // signon 2, step 1 Con.DPrint(`Host.Name_f: ${this.client}\n`); + if (names.length < 1) { - Con.Print('"name" is "' + CL.name.string + '"\n'); + Con.Print(`"name" is "${CL.name.string}"\n`); return; } - if (!SV.server.active) { // ??? + if (!SV.server.active) { return; } let newName = names.join(' ').trim().substring(0, 15); - if (!registry.isDedicatedServer && !this.client) { + if (!registry.isDedicatedServer && this.client === null) { Cvar.Set('_cl_name', newName); + if (CL.cls.state === Def.clientConnectionState.connected) { this.forward(); } + return; } - if (!this.client) { + if (this.client === null) { return; } const initialNewName = newName; let newNameCounter = 2; - // make sure we have a somewhat unique name + // Make sure we have a somewhat unique name. while (SV.FindClientByName(newName)) { newName = `${initialNewName}${newNameCounter++}`; } - const name = this.client.name; - if (registry.isDedicatedServer && name && (name.length !== 0) && (name !== 'unconnected') && (name !== newName)) { - Con.Print(name + ' renamed to ' + newName + '\n'); + const previousName = this.client.name; + + if (registry.isDedicatedServer && previousName.length !== 0 && previousName !== 'unconnected' && previousName !== newName) { + Con.Print(`${previousName} renamed to ${newName}\n`); } this.client.name = newName; - const msg = SV.server.reliable_datagram; - msg.writeByte(Protocol.svc.updatename); - msg.writeByte(this.client.num); - msg.writeString(newName); - }; - static Say_f(teamonly, message) { - if (this.forward()) { - return; - } + const message = SV.server.reliable_datagram; + message.writeByte(Protocol.svc.updatename); + message.writeByte(this.client.num); + message.writeString(newName); + } - if (!message) { + static Say_f(this: ConsoleCommand, teamonly: boolean, message?: string): void { + if (this.forward() || !message || this.client === null) { return; } const sender = this.client; + const formattedMessage = message.length > 140 ? `${message.substring(0, 140)}...` : message; - if (message.length > 140) { - message = message.substring(0, 140) + '...'; - } + for (let index = 0; index < SV.svs.maxclients; index++) { + const client = SV.svs.clients[index]; - for (let i = 0; i < SV.svs.maxclients; i++) { - const client = SV.svs.clients[i]; if (client.state < ServerClient.STATE.CONNECTED) { continue; } - if ((Host.teamplay.value !== 0) && (teamonly === true) && (client.entity.team !== sender.entity.team)) { // Legacy cvars + + if (Host.teamplay !== null && Host.teamplay.value !== 0 && teamonly && client.entity.team !== sender.entity.team) { continue; } - Host.SendChatMessageToClient(client, sender.name, message, false); + + Host.SendChatMessageToClient(client, sender.name, formattedMessage, false); } - Con.Print(`${sender.name}: ${message}\n`); - }; + Con.Print(`${sender.name}: ${formattedMessage}\n`); + } - static Say_Team_f(message) { + static Say_Team_f(this: ConsoleCommand, message?: string): void { Host.Say_f.call(this, true, message); - }; + } - static Say_All_f(message) { + static Say_All_f(this: ConsoleCommand, message?: string): void { Host.Say_f.call(this, false, message); - }; + } - static Tell_f(recipient, message) { - if (this.forward()) { - return; - } + static Tell_f(this: ConsoleCommand, recipient?: string, message?: string): void { + if (this.forward() || !recipient || !message || this.client === null) { + if (!recipient || !message) { + Con.Print('Usage: tell \n'); + } - if (!recipient || !message) { - Con.Print('Usage: tell \n'); return; } - message = message.trim(); + let formattedMessage = message.trim(); - // Remove surrounding double quotes if present - if (message.startsWith('"')) { - message = message.slice(1, -1); + // Remove surrounding double quotes if present. + if (formattedMessage.startsWith('"')) { + formattedMessage = formattedMessage.slice(1, -1); } - if (message.length > 140) { - message = message.substring(0, 140) + '...'; + + if (formattedMessage.length > 140) { + formattedMessage = `${formattedMessage.substring(0, 140)}...`; } const sender = this.client; - for (let i = 0; i < SV.svs.maxclients; i++) { - const client = SV.svs.clients[i]; + + for (let index = 0; index < SV.svs.maxclients; index++) { + const client = SV.svs.clients[index]; + if (client.state < ServerClient.STATE.CONNECTED) { continue; } + if (client.name.toLowerCase() !== recipient.toLowerCase()) { continue; } - Host.SendChatMessageToClient(client, sender.name, message, true); - Host.SendChatMessageToClient(sender, sender.name, message, true); + + Host.SendChatMessageToClient(client, sender.name, formattedMessage, true); + Host.SendChatMessageToClient(sender, sender.name, formattedMessage, true); break; } - }; + } - static Color_f(...argv) { // signon 2, step 2 + static Color_f(this: ConsoleCommand, ...argv: string[]): void { // signon 2, step 2 Con.DPrint(`Host.Color_f: ${this.client}\n`); + if (argv.length <= 1) { - Con.Print('"color" is "' + (CL.color.value >> 4) + ' ' + (CL.color.value & 15) + '"\ncolor <0-13> [0-13]\n'); + Con.Print(`"color" is "${CL.color.value >> 4} ${CL.color.value & 15}"\ncolor <0-13> [0-13]\n`); return; } - let top; let bottom; + let top: number; + let bottom: number; + if (argv.length === 2) { top = bottom = (Q.atoi(argv[1]) & 15) >>> 0; } else { top = (Q.atoi(argv[1]) & 15) >>> 0; bottom = (Q.atoi(argv[2]) & 15) >>> 0; } + if (top >= 14) { top = 13; } + if (bottom >= 14) { bottom = 13; } + const playercolor = (top << 4) + bottom; - if (!registry.isDedicatedServer && !this.client) { + if (!registry.isDedicatedServer && this.client === null) { Cvar.Set('_cl_color', playercolor); + if (CL.cls.state === Def.clientConnectionState.connected) { this.forward(); } + return; } - if (!this.client) { + if (this.client === null) { return; } this.client.colors = playercolor; this.client.edict.entity.team = bottom + 1; - const msg = SV.server.reliable_datagram; - msg.writeByte(Protocol.svc.updatecolors); - msg.writeByte(this.client.num); - msg.writeByte(playercolor); - }; - static Kill_f() { - if (this.forward()) { + const message = SV.server.reliable_datagram; + message.writeByte(Protocol.svc.updatecolors); + message.writeByte(this.client.num); + message.writeByte(playercolor); + } + + static Kill_f(this: ConsoleCommand): void { + if (this.forward() || this.client === null) { return; } const client = this.client; + if (client.edict.entity.health <= 0.0) { Host.ClientPrint(client, 'Can\'t suicide -- already dead!\n'); return; @@ -1239,49 +1397,57 @@ export default class Host { SV.server.gameAPI.time = SV.server.time; SV.server.gameAPI.ClientKill(client.edict); - }; + } - static Pause_f() { - if (this.forward()) { + static Pause_f(this: ConsoleCommand): void { + if (this.forward() || this.client === null) { return; } const client = this.client; - if (Host.pausable.value === 0) { + if (Host.pausable === null || Host.pausable.value === 0) { Host.ClientPrint(client, 'Pause not allowed.\n'); return; } + SV.server.paused = !SV.server.paused; - Host.BroadcastPrint(client.name + (SV.server.paused === true ? ' paused the game\n' : ' unpaused the game\n')); + Host.BroadcastPrint(`${client.name}${SV.server.paused === true ? ' paused the game\n' : ' unpaused the game\n'}`); SV.server.reliable_datagram.writeByte(Protocol.svc.setpause); SV.server.reliable_datagram.writeByte(SV.server.paused === true ? 1 : 0); - }; + } - static PreSpawn_f() { // signon 1, step 1 - if (!this.client) { + static PreSpawn_f(this: ConsoleCommand): void { // signon 1, step 1 + if (this.client === null) { Con.Print('prespawn is not valid from the console\n'); return; } + Con.DPrint(`Host.PreSpawn_f: ${this.client}\n`); + const client = this.client; + if (client.state === ServerClient.STATE.SPAWNED) { Con.Print('prespawn not valid -- already spawned\n'); return; } - // CR: SV.server.signon is a special buffer that is used to send the signon messages (make static as well as baseline information) + + // CR: SV.server.signon is a special buffer that is used to send the signon messages. client.message.write(new Uint8Array(SV.server.signon.data), SV.server.signon.cursize); client.message.writeByte(Protocol.svc.signonnum); client.message.writeByte(2); - }; + } - static Spawn_f() { // signon 2, step 3 + static Spawn_f(this: ConsoleCommand): void { // signon 2, step 3 Con.DPrint(`Host.Spawn_f: ${this.client}\n`); - if (!this.client) { + + if (this.client === null) { Con.Print('spawn is not valid from the console\n'); return; } - let client = this.client; + + const client = this.client; + if (client.state === ServerClient.STATE.SPAWNED) { Con.Print('Spawn not valid -- already spawned\n'); return; @@ -1293,55 +1459,52 @@ export default class Host { message.writeByte(Protocol.svc.time); message.writeFloat(SV.server.time); - const ent = client.edict; - if (SV.server.loadgame === true) { + const entity = client.edict; + + if (SV.server.loadgame) { SV.server.paused = false; } else { - // ent.clear(); // FIXME: there’s a weird edge case - SV.server.gameAPI.prepareEntity(ent, 'player', { + SV.server.gameAPI.prepareEntity(entity, 'player', { netname: client.name, - colormap: ent.num, // the num, not the entity + colormap: entity.num, // the num, not the entity team: (client.colors & 15) + 1, }); - // load in spawn parameters (legacy) - if (SV.server.gameCapabilities.includes(gameCapabilities.CAP_SPAWNPARMS_LEGACY)) { - for (let i = 0; i <= 15; i++) { - SV.server.gameAPI[`parm${i + 1}`] = client.spawn_parms[i]; + // Load legacy spawn parameters. + if (SV.server.gameCapabilities.includes(gameCapabilities.CAP_SPAWNPARMS_LEGACY) && Array.isArray(client.spawn_parms)) { + for (let index = 0; index <= 15; index++) { + SV.server.gameAPI[`parm${index + 1}`] = client.spawn_parms[index]; } } - // load in spawn parameters + // Load dynamic spawn parameters. if (SV.server.gameCapabilities.includes(gameCapabilities.CAP_SPAWNPARMS_DYNAMIC)) { - ent.entity.restoreSpawnParameters(client.spawn_parms); + entity.entity.restoreSpawnParameters(client.spawn_parms); } - // call the spawn function SV.server.gameAPI.time = SV.server.time; - SV.server.gameAPI.ClientConnect(ent); - - // actually spawn the player + SV.server.gameAPI.ClientConnect(entity); SV.server.gameAPI.time = SV.server.time; - SV.server.gameAPI.PutClientInServer(ent); + SV.server.gameAPI.PutClientInServer(entity); } - for (let i = 0; i < SV.svs.maxclients; i++) { - client = SV.svs.clients[i]; + for (let index = 0; index < SV.svs.maxclients; index++) { + const otherClient = SV.svs.clients[index]; message.writeByte(Protocol.svc.updatename); - message.writeByte(i); - message.writeString(client.name); + message.writeByte(index); + message.writeString(otherClient.name); message.writeByte(Protocol.svc.updatefrags); - message.writeByte(i); - message.writeShort(client.old_frags); + message.writeByte(index); + message.writeShort(otherClient.old_frags); message.writeByte(Protocol.svc.updatecolors); - message.writeByte(i); - message.writeByte(client.colors); + message.writeByte(index); + message.writeByte(otherClient.colors); } - for (let i = 0; i < Def.limits.lightstyles; i++) { + for (let index = 0; index < Def.limits.lightstyles; index++) { message.writeByte(Protocol.svc.lightstyle); - message.writeByte(i); - message.writeString(SV.server.lightstyles[i]); + message.writeByte(index); + message.writeString(SV.server.lightstyles[index]); } if (SV.server.gameCapabilities.includes(gameCapabilities.CAP_CLIENTDATA_UPDATESTAT)) { @@ -1360,28 +1523,27 @@ export default class Host { } message.writeByte(Protocol.svc.setangle); - message.writeAngleVector(ent.entity.angles); - + message.writeAngleVector(entity.entity.angles); SV.messages.writeClientdataToMessage(client, message); - message.writeByte(Protocol.svc.signonnum); message.writeByte(3); - }; + } - static Begin_f() { // signon 3, step 1 + static Begin_f(this: ConsoleCommand): void { // signon 3, step 1 Con.DPrint(`Host.Begin_f: ${this.client}\n`); - if (!this.client) { + + if (this.client === null) { Con.Print('begin is not valid from the console\n'); return; } - // Send all portal states before the client is offically spawned and gets updates incrementally + // Send all portal states before the client is officially spawned and gets updates incrementally. const areaPortals = SV.server.worldmodel.areaPortals; - for (let p = 0; p < areaPortals.numPortals; p++) { + for (let portalIndex = 0; portalIndex < areaPortals.numPortals; portalIndex++) { this.client.message.writeByte(Protocol.svc.setportalstate); - this.client.message.writeShort(p); - this.client.message.writeByte(areaPortals.isPortalOpen(p) ? 1 : 0); + this.client.message.writeShort(portalIndex); + this.client.message.writeByte(areaPortals.isPortalOpen(portalIndex) ? 1 : 0); } this.client.state = ServerClient.STATE.SPAWNED; @@ -1390,104 +1552,106 @@ export default class Host { SV.server.gameAPI.time = SV.server.time; SV.server.gameAPI.ClientBegin(this.client.edict); } - }; + } - static Kick_f() { + static Kick_f(this: ConsoleCommand): void { const argv = this.argv; - if (!this.client) { - if (!SV.server.active) { - this.forward(); - return; - } + + if (this.client === null && !SV.server.active) { + this.forward(); + return; } + if (argv.length < 2) { return; } - const s = argv[1].toLowerCase(); + + const selection = argv[1].toLowerCase(); const invokingClient = this.client; - let i; let byNumber = false; - let targetClient = /** @type {ServerClient | null} */ (null); + let clientIndex = 0; + let byNumber = false; + let targetClient: ServerClient | null = null; - if ((argv.length >= 3) && (s === '#')) { - i = Q.atoi(argv[2]) - 1; - if ((i < 0) || (i >= SV.svs.maxclients)) { + if (argv.length >= 3 && selection === '#') { + clientIndex = Q.atoi(argv[2]) - 1; + + if (clientIndex < 0 || clientIndex >= SV.svs.maxclients) { return; } - if (SV.svs.clients[i].state !== ServerClient.STATE.SPAWNED) { + + if (SV.svs.clients[clientIndex].state !== ServerClient.STATE.SPAWNED) { return; } - targetClient = SV.svs.clients[i]; + + targetClient = SV.svs.clients[clientIndex]; byNumber = true; } else { - for (i = 0; i < SV.svs.maxclients; i++) { - const client = SV.svs.clients[i]; + for (clientIndex = 0; clientIndex < SV.svs.maxclients; clientIndex++) { + const client = SV.svs.clients[clientIndex]; + if (client.state < ServerClient.STATE.CONNECTED) { continue; } - if (client.name.toLowerCase() === s) { + + if (client.name.toLowerCase() === selection) { targetClient = client; break; } } } - if (targetClient === null) { - return; - } - if (targetClient === invokingClient) { + + if (targetClient === null || targetClient === invokingClient) { return; } - let who; - if (!invokingClient) { - if (registry.isDedicatedServer) { - who = NET.hostname.string; - } else { - who = CL.name.string; - } - } else { - who = invokingClient.name; - } - let message; - if (argv.length >= 3) { - message = COM.Parse(this.args); - } - let dropReason = 'Kicked by ' + who; - if (message.data !== null) { - let p = 0; + + const who = invokingClient === null + ? (registry.isDedicatedServer ? NET.hostname.string : CL.name.string) + : invokingClient.name; + const parsedMessage = argv.length >= 3 && this.args !== null ? COM.Parse(this.args) : null; + let dropReason = `Kicked by ${who}`; + + if (parsedMessage !== null && parsedMessage.data !== null) { + let offset = 0; + if (byNumber) { - p++; - for (; p < message.data.length; p++) { - if (message.data.charCodeAt(p) !== 32) { + offset++; + + for (; offset < parsedMessage.data.length; offset++) { + if (parsedMessage.data.charCodeAt(offset) !== 32) { break; } } - p += argv[2].length; + + offset += argv[2].length; } - for (; p < message.data.length; p++) { - if (message.data.charCodeAt(p) !== 32) { + + for (; offset < parsedMessage.data.length; offset++) { + if (parsedMessage.data.charCodeAt(offset) !== 32) { break; } } - dropReason = 'Kicked by ' + who + ': ' + message.data.substring(p); + + dropReason = `Kicked by ${who}: ${parsedMessage.data.substring(offset)}`; } + Host.DropClient(targetClient, false, dropReason); - }; + } static Give_f = class extends HostConsoleCommand { // TODO: move to game - run(classname) { - // CR: unsure if I want a “give item_shells” approach or - // if I want to push this piece of code into PR/PF and let - // the game handle this instead + override run(classname?: string): void { + // CR: unsure if I want a “give item_shells” approach or if I want to push + // this piece of code into PR/PF and let the game handle this instead. - if (this.forward()) { + if (this.forward() || this.cheat()) { return; } - if (this.cheat()) { + const client = this.client; + + if (client === null) { return; } - const client = this.client; - if (!classname) { Host.ClientPrint(client, 'give \n'); return; @@ -1500,18 +1664,14 @@ export default class Host { return; } - // wait for the next server frame + // Wait for the next server frame. SV.ScheduleGameCommand(() => { const { forward } = player.entity.v_angle.angleVectors(); - const start = player.entity.origin; const end = forward.copy().multiply(64.0).add(start); - const mins = new Vector(-16.0, -16.0, -24.0); const maxs = new Vector(16.0, 16.0, 32.0); - const trace = ServerEngineAPI.Traceline(start, end, false, player, mins, maxs); - const origin = trace.point.subtract(forward.multiply(16.0)).add(new Vector(0.0, 0.0, 16.0)); if (![content.CONTENT_EMPTY, content.CONTENT_WATER].includes(ServerEngineAPI.DetermineStaticWorldContents(origin))) { @@ -1526,88 +1686,122 @@ export default class Host { } }; - static FindViewthing() { + static FindViewthing(): ServerEdict | null { if (SV.server.active) { - for (let i = 0; i < SV.server.num_edicts; i++) { - const e = SV.server.edicts[i]; - if (!e.isFree() && e.entity.classname === 'viewthing') { - return e; + for (let index = 0; index < SV.server.num_edicts; index++) { + const edict = SV.server.edicts[index]; + + if (!edict.isFree() && edict.entity.classname === 'viewthing') { + return edict; } } } + Con.Print('No viewthing on map\n'); return null; - }; + } - static async Viewmodel_f(model) { + static async Viewmodel_f(model?: string): Promise { if (model === undefined) { Con.Print('Usage: viewmodel \n'); return; } - const ent = Host.FindViewthing(); - if (ent) { + + const entity = Host.FindViewthing(); + + if (entity === null) { return; } - const m = await Mod.ForNameAsync(model, false, Mod.scope.client); - if (!m) { - Con.Print('Can\'t load ' + model + '\n'); + + const loadedModel = await Mod.ForNameAsync(model, false, Mod.scope.client); + + if (!loadedModel) { + Con.Print(`Can't load ${model}\n`); return; } - ent.entity.frame = 0; - CL.state.model_precache[ent.entity.modelindex] = m; - }; - static Viewframe_f(frame) { + entity.entity.frame = 0; + CL.state.model_precache[entity.entity.modelindex] = loadedModel; + } + + static Viewframe_f(frame?: string): void { if (frame === undefined) { Con.Print('Usage: viewframe \n'); return; } - const ent = Host.FindViewthing(); - if (!ent) { + + const entity = Host.FindViewthing(); + + if (entity === null) { return; } - const m = CL.state.model_precache[ent.entity.modelindex >> 0]; - let f = Q.atoi(frame); - if (f >= m.frames.length) { - f = m.frames.length - 1; + + const model = CL.state.model_precache[entity.entity.modelindex >> 0]; + + if (!model) { + return; } - ent.entity.frame = f; - }; - static Viewnext_f() { - const ent = Host.FindViewthing(); - if (!ent) { + let nextFrame = Q.atoi(frame); + + if (nextFrame >= model.frames.length) { + nextFrame = model.frames.length - 1; + } + + entity.entity.frame = nextFrame; + } + + static Viewnext_f(): void { + const entity = Host.FindViewthing(); + + if (entity === null) { return; } - const m = CL.state.model_precache[ent.entity.modelindex >> 0]; - let f = (ent.entity.frame >> 0) + 1; - if (f >= m.frames.length) { - f = m.frames.length - 1; + + const model = CL.state.model_precache[entity.entity.modelindex >> 0]; + + if (!model) { + return; } - ent.entity.frame = f; - Con.Print('frame ' + f + ': ' + m.frames[f].name + '\n'); - }; - static Viewprev_f() { - const ent = Host.FindViewthing(); - if (!ent) { + let nextFrame = (entity.entity.frame >> 0) + 1; + + if (nextFrame >= model.frames.length) { + nextFrame = model.frames.length - 1; + } + + entity.entity.frame = nextFrame; + Con.Print(`frame ${nextFrame}: ${model.frames[nextFrame].name}\n`); + } + + static Viewprev_f(): void { + const entity = Host.FindViewthing(); + + if (entity === null) { return; } - const m = CL.state.model_precache[ent.entity.modelindex >> 0]; - let f = (ent.entity.frame >> 0) - 1; - if (f < 0) { - f = 0; + + const model = CL.state.model_precache[entity.entity.modelindex >> 0]; + + if (!model) { + return; } - ent.entity.frame = f; - Con.Print('frame ' + f + ': ' + m.frames[f].name + '\n'); - }; - static InitCommands() { + let nextFrame = (entity.entity.frame >> 0) - 1; + + if (nextFrame < 0) { + nextFrame = 0; + } + + entity.entity.frame = nextFrame; + Con.Print(`frame ${nextFrame}: ${model.frames[nextFrame].name}\n`); + } + + static InitCommands(): void { if (registry.isDedicatedServer) { // TODO: move this to a dedicated stub for IN Cmd.AddCommand('bind', () => {}); Cmd.AddCommand('unbind', () => {}); Cmd.AddCommand('unbindall', () => {}); - Cmd.AddCommand('disconnect', Host.Disconnect_f); } @@ -1634,45 +1828,44 @@ export default class Host { Cmd.AddCommand('prespawn', Host.PreSpawn_f); Cmd.AddCommand('kick', Host.Kick_f); Cmd.AddCommand('ping', Host.Ping_f); + if (!registry.isDedicatedServer) { Cmd.AddCommand('load', Host.Loadgame_f); Cmd.AddCommand('save', Host.Savegame_f); } + Cmd.AddCommand('give', Host.Give_f); Cmd.AddCommand('viewmodel', Host.Viewmodel_f); Cmd.AddCommand('viewframe', Host.Viewframe_f); Cmd.AddCommand('viewnext', Host.Viewnext_f); Cmd.AddCommand('viewprev', Host.Viewprev_f); - // Cmd.AddCommand('mcache', Mod.Print); Cmd.AddCommand('writeconfig', Host.WriteConfiguration_f); Cmd.AddCommand('configready', Host.ConfigReady_f); - Cmd.AddCommand('error', class extends ConsoleCommand { - run(message) { + Cmd.AddCommand('error', class HostErrorCommand extends ConsoleCommand { + override run(message = ''): void { throw new HostError(message); } }); - Cmd.AddCommand('fatalerror', class extends ConsoleCommand { - run(message) { + Cmd.AddCommand('fatalerror', class HostFatalErrorCommand extends ConsoleCommand { + override run(message = ''): void { throw new Error(message); } }); - Cmd.AddCommand('eb_topics', class extends ConsoleCommand { - run() { - // TODO: do not allow this command when server is having cheats disabled - + Cmd.AddCommand('eb_topics', class HostEventBusTopicsCommand extends ConsoleCommand { + override run(): void { + // TODO: do not allow this command when server is having cheats disabled. for (const topic of eventBus.topics.sort()) { - Con.Print(topic + '\n'); + Con.Print(`${topic}\n`); } } }); - Cmd.AddCommand('eb_publish', class extends ConsoleCommand { - run(eventName, ...args) { - // TODO: do not allow this command when server is having cheats disabled - + Cmd.AddCommand('eb_publish', class HostEventBusPublishCommand extends ConsoleCommand { + override run(eventName?: string, ...args: string[]): void { + // TODO: do not allow this command when server is having cheats disabled. if (!eventName) { Con.Print(`Usage: ${this.command} [args...]\n`); return; @@ -1686,5 +1879,5 @@ export default class Host { eventBus.publish(eventName, ...args); } }); - }; + } } diff --git a/source/engine/main-browser.mjs b/source/engine/main-browser.mjs index 4c2364b4..876d5ee7 100644 --- a/source/engine/main-browser.mjs +++ b/source/engine/main-browser.mjs @@ -3,7 +3,7 @@ import { registry, freeze as registryFreeze } from './registry.mjs'; import Sys from './client/Sys.mjs'; import COM from './common/Com.ts'; import Con from './common/Console.ts'; -import Host from './common/Host.mjs'; +import Host from './common/Host.ts'; import V from './client/V.mjs'; import NET from './network/Network.ts'; import SV from './server/Server.mjs'; diff --git a/source/engine/main-dedicated.mjs b/source/engine/main-dedicated.mjs index 924fc3bf..75d2abfd 100644 --- a/source/engine/main-dedicated.mjs +++ b/source/engine/main-dedicated.mjs @@ -11,7 +11,7 @@ globalThis.Worker = Worker; import Sys from './server/Sys.mjs'; import NodeCOM from './server/Com.mjs'; import Con from './common/Console.ts'; -import Host from './common/Host.mjs'; +import Host from './common/Host.ts'; import V from './client/V.mjs'; import NET from './network/Network.ts'; import SV from './server/Server.mjs'; diff --git a/source/engine/registry.mjs b/source/engine/registry.mjs index 6a965dc9..024ed0b0 100644 --- a/source/engine/registry.mjs +++ b/source/engine/registry.mjs @@ -2,7 +2,7 @@ /** @typedef {typeof import('./common/Console.ts').default} ConModule */ /** @typedef {typeof import('./common/Com.ts').default} ComModule */ /** @typedef {typeof import('./common/Sys.ts').default} SysModule */ -/** @typedef {typeof import('./common/Host.mjs').default} HostModule */ +/** @typedef {typeof import('./common/Host.ts').default} HostModule */ /** @typedef {typeof import('./client/V.mjs').default} VModule */ /** @typedef {typeof import('./network/Network').default} NetModule */ /** @typedef {typeof import('./server/Server.mjs').default} ServerModule */ diff --git a/test/common/workers.test.mjs b/test/common/workers.test.mjs index 176ce25b..f43baec4 100644 --- a/test/common/workers.test.mjs +++ b/test/common/workers.test.mjs @@ -80,7 +80,7 @@ async function withWorkerRegistry(consoleCapture, callback) { gamedir: [{ filename: 'id1', pack: [] }], game: 'id1', }), - Host: /** @type {typeof import('../../source/engine/common/Host.mjs').default} */ (/** @type {unknown} */ ({ + Host: /** @type {typeof import('../../source/engine/common/Host.ts').default} */ (/** @type {unknown} */ ({ crashes, HandleCrash(error) { crashes.push(error); From f0d00cca6abed8c8b1036fd6d2b26a68828dd344 Mon Sep 17 00:00:00 2001 From: Christian R Date: Thu, 2 Apr 2026 22:27:04 +0300 Subject: [PATCH 25/67] TS: common/model/* and common/Mod --- source/engine/client/CL.mjs | 2 +- source/engine/client/ClientEntities.mjs | 6 +- .../client/ClientServerCommandHandlers.mjs | 4 +- source/engine/client/ClientState.mjs | 2 +- source/engine/client/LegacyServerCommands.mjs | 2 +- source/engine/client/R.mjs | 6 +- .../client/renderer/AliasModelRenderer.mjs | 16 +- .../client/renderer/BrushModelRenderer.mjs | 16 +- .../client/renderer/MeshModelRenderer.mjs | 10 +- .../engine/client/renderer/ModelRenderer.mjs | 10 +- source/engine/client/renderer/ShadowMap.mjs | 4 +- source/engine/client/renderer/Sky.mjs | 2 +- .../client/renderer/SpriteModelRenderer.mjs | 10 +- source/engine/common/CollisionModelSource.mjs | 16 +- source/engine/common/GameAPIs.mjs | 4 +- source/engine/common/{Mod.mjs => Mod.ts} | 182 ++-- source/engine/common/Pmove.ts | 6 +- source/engine/common/WorkerFramework.ts | 2 +- source/engine/common/model/AliasModel.mjs | 70 -- source/engine/common/model/AliasModel.ts | 146 ++++ source/engine/common/model/AreaPortals.mjs | 294 ------- source/engine/common/model/AreaPortals.ts | 251 ++++++ source/engine/common/model/BSP.mjs | 715 +-------------- source/engine/common/model/BSP.ts | 821 ++++++++++++++++++ source/engine/common/model/BaseModel.mjs | 151 ---- source/engine/common/model/BaseModel.ts | 207 +++++ source/engine/common/model/MeshModel.mjs | 86 -- source/engine/common/model/MeshModel.ts | 132 +++ source/engine/common/model/ModelLoader.mjs | 67 -- source/engine/common/model/ModelLoader.ts | 44 + .../common/model/ModelLoaderRegistry.mjs | 62 -- .../common/model/ModelLoaderRegistry.ts | 53 ++ source/engine/common/model/SpriteModel.mjs | 40 - source/engine/common/model/SpriteModel.ts | 75 ++ .../common/model/loaders/AliasMDLLoader.mjs | 18 +- .../common/model/loaders/BSP29Loader.mjs | 4 +- .../common/model/loaders/BSP2Loader.mjs | 2 +- .../common/model/loaders/BSP38Loader.mjs | 13 +- .../common/model/loaders/SpriteSPRLoader.mjs | 4 +- .../model/loaders/WavefrontOBJLoader.mjs | 8 +- source/engine/main-browser.mjs | 2 +- source/engine/main-dedicated.mjs | 2 +- source/engine/registry.mjs | 2 +- source/engine/server/Navigation.mjs | 4 +- source/engine/server/Server.mjs | 4 +- source/engine/server/physics/ServerArea.mjs | 2 +- .../server/physics/ServerClientPhysics.mjs | 2 +- .../engine/server/physics/ServerCollision.mjs | 4 +- .../server/physics/ServerCollisionSupport.mjs | 6 +- .../physics/ServerLegacyHullCollision.mjs | 2 +- source/shared/GameInterfaces.ts | 2 +- test/common/model-cache.test.mjs | 20 +- test/physics/map-pmove-harness.mjs | 18 +- test/physics/pmove.test.mjs | 80 +- 54 files changed, 1974 insertions(+), 1739 deletions(-) rename source/engine/common/{Mod.mjs => Mod.ts} (57%) delete mode 100644 source/engine/common/model/AliasModel.mjs create mode 100644 source/engine/common/model/AliasModel.ts delete mode 100644 source/engine/common/model/AreaPortals.mjs create mode 100644 source/engine/common/model/AreaPortals.ts create mode 100644 source/engine/common/model/BSP.ts delete mode 100644 source/engine/common/model/BaseModel.mjs create mode 100644 source/engine/common/model/BaseModel.ts delete mode 100644 source/engine/common/model/MeshModel.mjs create mode 100644 source/engine/common/model/MeshModel.ts delete mode 100644 source/engine/common/model/ModelLoader.mjs create mode 100644 source/engine/common/model/ModelLoader.ts delete mode 100644 source/engine/common/model/ModelLoaderRegistry.mjs create mode 100644 source/engine/common/model/ModelLoaderRegistry.ts delete mode 100644 source/engine/common/model/SpriteModel.mjs create mode 100644 source/engine/common/model/SpriteModel.ts diff --git a/source/engine/client/CL.mjs b/source/engine/client/CL.mjs index 517e3ec8..9fffd086 100644 --- a/source/engine/client/CL.mjs +++ b/source/engine/client/CL.mjs @@ -12,7 +12,7 @@ import VID from './VID.mjs'; import { clientRuntimeState, clientStaticState } from './ClientState.mjs'; import ClientConnection from './ClientConnection.mjs'; import ClientLifecycle from './ClientLifecycle.mjs'; -import { BrushModel } from '../common/Mod.mjs'; +import { BrushModel } from '../common/Mod.ts'; // import { materialFlags, PBRMaterial, QuakeMaterial } from './renderer/Materials.mjs'; /** @typedef {import('./Sound.mjs').SFX} SFX */ diff --git a/source/engine/client/ClientEntities.mjs b/source/engine/client/ClientEntities.mjs index edc1b32c..4f8fe12e 100644 --- a/source/engine/client/ClientEntities.mjs +++ b/source/engine/client/ClientEntities.mjs @@ -8,7 +8,7 @@ import { BaseClientEdictHandler } from '../../shared/ClientEdict.ts'; import { ClientEngineAPI } from '../common/GameAPIs.mjs'; import { SFX } from './Sound.mjs'; import { Node, revealedVisibility } from '../common/model/BSP.mjs'; -import { BaseModel } from '../common/model/BaseModel.mjs'; +import { BaseModel } from '../common/model/BaseModel.ts'; let { CL, Con, Mod, PR, R, S } = registry; @@ -70,7 +70,7 @@ export class ClientBeam { start = new Vector(); end = new Vector(); - /** @type {import('../common/model/BaseModel.mjs').BaseModel} what model to use to draw the beam */ + /** @type {import('../common/model/BaseModel.ts').BaseModel} what model to use to draw the beam */ model = null; /** @type {number} */ @@ -425,7 +425,7 @@ export default class ClientEntities { explosion: null, }; - /** @type {Record} available tent models, initialized in initTempEntities */ + /** @type {Record} available tent models, initialized in initTempEntities */ tempEntityModels = {}; constructor() { diff --git a/source/engine/client/ClientServerCommandHandlers.mjs b/source/engine/client/ClientServerCommandHandlers.mjs index 34b0c4ce..4af8f9e7 100644 --- a/source/engine/client/ClientServerCommandHandlers.mjs +++ b/source/engine/client/ClientServerCommandHandlers.mjs @@ -332,7 +332,7 @@ function parseServerCvars() { /** * Parses beam-style temporary entities. - * @param {import('../common/model/BaseModel.mjs').BaseModel} model Model to attach to the beam. + * @param {import('../common/model/BaseModel.ts').BaseModel} model Model to attach to the beam. */ function parseBeam(model) { const ent = NET.message.readShort(); @@ -930,7 +930,7 @@ function handleCutscene() { * Calls the help command when the server requests the sell screen. */ function handleSellScreen() { - Cmd.ExecuteString('help'); + void Cmd.ExecuteString('help'); } /** diff --git a/source/engine/client/ClientState.mjs b/source/engine/client/ClientState.mjs index 75f0a3dc..2e23a681 100644 --- a/source/engine/client/ClientState.mjs +++ b/source/engine/client/ClientState.mjs @@ -6,7 +6,7 @@ import Vector from '../../shared/Vector.ts'; import { EventBus, eventBus, registry } from '../registry.mjs'; import ClientEntities, { ClientEdict } from './ClientEntities.mjs'; import { ClientMessages } from './ClientMessages.mjs'; -import { BrushModel } from '../common/Mod.mjs'; +import { BrushModel } from '../common/Mod.ts'; let { CL } = registry; diff --git a/source/engine/client/LegacyServerCommands.mjs b/source/engine/client/LegacyServerCommands.mjs index 08e740dc..75037c22 100644 --- a/source/engine/client/LegacyServerCommands.mjs +++ b/source/engine/client/LegacyServerCommands.mjs @@ -581,7 +581,7 @@ function handleLegacyParticle() { /** * Parses a beam entity from the message buffer. - * @param {import('../common/model/BaseModel.mjs').BaseModel} model beam model to use + * @param {import('../common/model/BaseModel.ts').BaseModel} model beam model to use */ function parseLegacyBeam(model) { const ent = NET.message.readShort(); diff --git a/source/engine/client/R.mjs b/source/engine/client/R.mjs index f8836b97..10ed32b1 100644 --- a/source/engine/client/R.mjs +++ b/source/engine/client/R.mjs @@ -146,7 +146,7 @@ R.RenderDlights = function() { }; /** - * @param {import('../common/model/BaseModel.mjs').Face} surf Surface to sample. + * @param {import('../common/model/BaseModel.ts').Face} surf Surface to sample. * @returns {Vector} A known point on the surface plane. */ R.GetDynamicLightSurfacePoint = function(surf) { @@ -161,7 +161,7 @@ R.GetDynamicLightSurfacePoint = function(surf) { /** * @param {ClientDlight} light Dynamic light being evaluated. - * @param {import('../common/model/BaseModel.mjs').Face} surf Surface being tested. + * @param {import('../common/model/BaseModel.ts').Face} surf Surface being tested. * @returns {{distanceToPlane: number, impact: Vector}|null} Surface-plane hit information when the light is in front of the face. */ R.GetDynamicLightSurfaceImpact = function(light, surf) { @@ -180,7 +180,7 @@ R.GetDynamicLightSurfaceImpact = function(light, surf) { /** * @param {ClientDlight} light Dynamic light being evaluated. - * @param {import('../common/model/BaseModel.mjs').Face} surf Surface being tested. + * @param {import('../common/model/BaseModel.ts').Face} surf Surface being tested. * @param {Vector} impact Projected point on the face plane. * @returns {boolean} True when the light has line of sight to the surface. */ diff --git a/source/engine/client/renderer/AliasModelRenderer.mjs b/source/engine/client/renderer/AliasModelRenderer.mjs index 4716503d..16311f87 100644 --- a/source/engine/client/renderer/AliasModelRenderer.mjs +++ b/source/engine/client/renderer/AliasModelRenderer.mjs @@ -45,7 +45,7 @@ export class AliasModelRenderer extends ModelRenderer { } /** - * @param {import('../../common/model/AliasModel.mjs').AliasModel} _model The alias model + * @param {import('../../common/model/AliasModel.ts').AliasModel} _model The alias model * @param {import('../ClientEntities.mjs').ClientEdict} entity The entity being rendered * @returns {boolean} True when this alias model should render in the opaque pass */ @@ -54,7 +54,7 @@ export class AliasModelRenderer extends ModelRenderer { } /** - * @param {import('../../common/model/AliasModel.mjs').AliasModel} _model The alias model + * @param {import('../../common/model/AliasModel.ts').AliasModel} _model The alias model * @param {import('../ClientEntities.mjs').ClientEdict} entity The entity being rendered * @returns {boolean} True when this alias model should render in the sorted transparent pass */ @@ -65,7 +65,7 @@ export class AliasModelRenderer extends ModelRenderer { /** * Render a single alias model entity. * Handles frustum culling, frame interpolation, skinning, and player color translation. - * @param {import('../../common/model/AliasModel.mjs').AliasModel} model The alias model to render + * @param {import('../../common/model/AliasModel.ts').AliasModel} model The alias model to render * @param {import('../ClientEntities.mjs').ClientEdict} entity The entity being rendered * @param {number} pass Rendering pass (0=opaque, 1=transparent) */ @@ -182,7 +182,7 @@ export class AliasModelRenderer extends ModelRenderer { /** * Select animation frames for rendering with interpolation - * @param {import('../../common/model/AliasModel.mjs').AliasModel} clmodel The alias model + * @param {import('../../common/model/AliasModel.ts').AliasModel} clmodel The alias model * @param {import('../ClientEntities.mjs').ClientEdict} e The entity * @returns {{frameA: object, frameB: object, targettime: number}} Selected frames and interpolation factor */ @@ -230,7 +230,7 @@ export class AliasModelRenderer extends ModelRenderer { /** * Select skin texture for rendering (handles skin groups and animation) * @private - * @param {import('../../common/model/AliasModel.mjs').AliasModel} clmodel The alias model + * @param {import('../../common/model/AliasModel.ts').AliasModel} clmodel The alias model * @param {import('../ClientEntities.mjs').ClientEdict} e The entity * @returns {object} Selected skin texture */ @@ -276,19 +276,19 @@ export class AliasModelRenderer extends ModelRenderer { /** * Prepare alias model for rendering (build vertex buffers from triangle data). - * @param {import('../../common/model/AliasModel.mjs').AliasModel} model The alias model to prepare + * @param {import('../../common/model/AliasModel.ts').AliasModel} model The alias model to prepare * @param {boolean} isWorldModel Whether this model is the world model */ // eslint-disable-next-line no-unused-vars prepareModel(model, isWorldModel = false) { // This will be implemented in a later task - // For now, vertex buffer building is still done in Mod.mjs + // For now, vertex buffer building is still done in Mod.ts Con.DPrint(`AliasModelRenderer.prepareModel: TODO - implement for ${model.name}\n`); } /** * Free GPU resources for this alias model. - * @param {import('../../common/model/AliasModel.mjs').AliasModel} model The alias model to cleanup + * @param {import('../../common/model/AliasModel.ts').AliasModel} model The alias model to cleanup */ cleanupModel(model) { if (model.cmds) { diff --git a/source/engine/client/renderer/BrushModelRenderer.mjs b/source/engine/client/renderer/BrushModelRenderer.mjs index 49d0af67..957ac0dd 100644 --- a/source/engine/client/renderer/BrushModelRenderer.mjs +++ b/source/engine/client/renderer/BrushModelRenderer.mjs @@ -113,7 +113,7 @@ export class BrushModelRenderer extends ModelRenderer { /** * @private * @param {BrushModel} model Model containing the face. - * @param {import('../../common/model/BaseModel.mjs').Face} face Turbulent face being packed. + * @param {import('../../common/model/BaseModel.ts').Face} face Turbulent face being packed. * @returns {{tangent: Vector, bitangent: Vector}} Face-plane sampling basis. */ static _getTurbulentFallbackBasis(model, face) { @@ -134,7 +134,7 @@ export class BrushModelRenderer extends ModelRenderer { ? BrushModelRenderer._projectTurbulentFallbackAxis(new Vector(texinfo.vecs[1][0], texinfo.vecs[1][1], texinfo.vecs[1][2]), normal) : null; - const faceWithVerts = /** @type {import('../../common/model/BaseModel.mjs').Face & { verts?: number[][] }} */ (face); + const faceWithVerts = /** @type {import('../../common/model/BaseModel.ts').Face & { verts?: number[][] }} */ (face); if (tangent === null && faceWithVerts.verts && faceWithVerts.verts.length >= 2) { const edge = new Vector( @@ -161,7 +161,7 @@ export class BrushModelRenderer extends ModelRenderer { /** * @param {BrushModel} model Model containing the face. - * @param {import('../../common/model/BaseModel.mjs').Face} face Turbulent face being packed. + * @param {import('../../common/model/BaseModel.ts').Face} face Turbulent face being packed. * @param {Vector} worldPos Vertex position used for the fallback sample. * @param {(position: Vector) => [Vector, Vector]} sampleLightPoint Light sampler callback. * @returns {number[]} Vertex-level fallback light color. @@ -1986,7 +1986,7 @@ export class BrushModelRenderer extends ModelRenderer { /** * @private * @param {BrushModel} model Model containing the face. - * @param {import('../../common/model/BaseModel.mjs').Face} face Turbulent face being packed. + * @param {import('../../common/model/BaseModel.ts').Face} face Turbulent face being packed. * @returns {boolean} True when the face has usable baked lightmap samples. */ _surfaceHasTurbulentLightmap(model, face) { @@ -2003,7 +2003,7 @@ export class BrushModelRenderer extends ModelRenderer { /** * @private - * @param {import('../../common/model/BaseModel.mjs').Face} face Turbulent face being packed. + * @param {import('../../common/model/BaseModel.ts').Face} face Turbulent face being packed. * @param {Vector} worldPos Vertex position used for the fallback sample. * @returns {string} Cache key shared by coplanar turbulent edges. */ @@ -2022,7 +2022,7 @@ export class BrushModelRenderer extends ModelRenderer { /** * @private * @param {BrushModel} model Model containing the face. - * @param {import('../../common/model/BaseModel.mjs').Face} face Turbulent face being packed. + * @param {import('../../common/model/BaseModel.ts').Face} face Turbulent face being packed. * @returns {Vector} Approximate center used for face-level fallback smoothing. */ _getTurbulentFallbackFaceCenter(model, face) { @@ -2043,7 +2043,7 @@ export class BrushModelRenderer extends ModelRenderer { /** * @private * @param {BrushModel} model Model containing the face. - * @param {import('../../common/model/BaseModel.mjs').Face} face Turbulent face being packed. + * @param {import('../../common/model/BaseModel.ts').Face} face Turbulent face being packed. * @param {Map} cache Shared per-model fallback cache. * @returns {number[]} Face-level fallback color. */ @@ -2055,7 +2055,7 @@ export class BrushModelRenderer extends ModelRenderer { /** * @private * @param {BrushModel} model Model containing the face. - * @param {import('../../common/model/BaseModel.mjs').Face} face Turbulent face being packed. + * @param {import('../../common/model/BaseModel.ts').Face} face Turbulent face being packed. * @param {Vector} worldPos Vertex position used for the fallback sample. * @param {Map} [cache] Shared per-model fallback cache. * @returns {number[]} Vertex-level fallback light color. diff --git a/source/engine/client/renderer/MeshModelRenderer.mjs b/source/engine/client/renderer/MeshModelRenderer.mjs index 96602d12..ba3f73d4 100644 --- a/source/engine/client/renderer/MeshModelRenderer.mjs +++ b/source/engine/client/renderer/MeshModelRenderer.mjs @@ -44,7 +44,7 @@ export class MeshModelRenderer extends ModelRenderer { } /** - * @param {import('../../common/model/MeshModel.mjs').MeshModel} _model The mesh model + * @param {import('../../common/model/MeshModel.ts').MeshModel} _model The mesh model * @param {import('../ClientEntities.mjs').ClientEdict} _entity The entity being rendered * @returns {boolean} Mesh transparency is not implemented, so meshes stay in the opaque pass */ @@ -54,7 +54,7 @@ export class MeshModelRenderer extends ModelRenderer { } /** - * @param {import('../../common/model/MeshModel.mjs').MeshModel} _model The mesh model + * @param {import('../../common/model/MeshModel.ts').MeshModel} _model The mesh model * @param {import('../ClientEntities.mjs').ClientEdict} _entity The entity being rendered * @returns {boolean} False because sorted transparent mesh rendering is not implemented yet */ @@ -74,7 +74,7 @@ export class MeshModelRenderer extends ModelRenderer { /** * Render a single mesh model entity. - * @param {import('../../common/model/MeshModel.mjs').MeshModel} model The mesh model to render + * @param {import('../../common/model/MeshModel.ts').MeshModel} model The mesh model to render * @param {import('../ClientEntities.mjs').ClientEdict} entity The entity being rendered * @param {number} pass Rendering pass (0=opaque, 1=transparent) */ @@ -163,7 +163,7 @@ export class MeshModelRenderer extends ModelRenderer { /** * Prepare mesh model for rendering (build display lists, upload to GPU). - * @param {import('../../common/model/MeshModel.mjs').MeshModel} model The mesh model to prepare + * @param {import('../../common/model/MeshModel.ts').MeshModel} model The mesh model to prepare * @param {boolean} isWorldModel Whether this model is the world model */ // eslint-disable-next-line no-unused-vars @@ -281,7 +281,7 @@ export class MeshModelRenderer extends ModelRenderer { /** * Load texture for mesh model * @private - * @param {import('../../common/model/MeshModel.mjs').MeshModel} model The mesh model + * @param {import('../../common/model/MeshModel.ts').MeshModel} model The mesh model */ _loadTexture(model) { // Try to load texture using the texture name diff --git a/source/engine/client/renderer/ModelRenderer.mjs b/source/engine/client/renderer/ModelRenderer.mjs index f8af53b5..8854b8bd 100644 --- a/source/engine/client/renderer/ModelRenderer.mjs +++ b/source/engine/client/renderer/ModelRenderer.mjs @@ -31,7 +31,7 @@ export class ModelRenderer { /** * Whether this model/entity pair should contribute to the opaque pass. * Renderers can override this when transparency is entity- or material-driven. - * @param {import('../../common/model/BaseModel.mjs').BaseModel} _model The model to evaluate + * @param {import('../../common/model/BaseModel.ts').BaseModel} _model The model to evaluate * @param {import('../ClientEntities.mjs').ClientEdict} _entity The entity to evaluate * @returns {boolean} True when the pair should render during the opaque pass */ @@ -43,7 +43,7 @@ export class ModelRenderer { /** * Whether this model/entity pair should contribute to the sorted transparent pass. * Sprites are handled by their dedicated pass and should normally return false here. - * @param {import('../../common/model/BaseModel.mjs').BaseModel} _model The model to evaluate + * @param {import('../../common/model/BaseModel.ts').BaseModel} _model The model to evaluate * @param {import('../ClientEntities.mjs').ClientEdict} _entity The entity to evaluate * @returns {boolean} True when the pair should render during the sorted transparent pass */ @@ -54,7 +54,7 @@ export class ModelRenderer { /** * Render a single entity with this model type. - * @param {import('../../common/model/BaseModel.mjs').BaseModel} _model The model to render + * @param {import('../../common/model/BaseModel.ts').BaseModel} _model The model to render * @param {import('../ClientEntities.mjs').ClientEdict} _entity The entity being rendered * @param {number} [_pass] Rendering pass (0=opaque, 1=transparent, etc.) */ @@ -77,7 +77,7 @@ export class ModelRenderer { * Prepare model for rendering (build display lists, upload to GPU, etc.). * Called when model is first loaded or needs rebuilding. * Uses global `gl` from registry. - * @param {import('../../common/model/BaseModel.mjs').BaseModel} _model The model to prepare + * @param {import('../../common/model/BaseModel.ts').BaseModel} _model The model to prepare * @param {boolean} isWorldModel Whether this model is the world model */ // eslint-disable-next-line no-unused-vars @@ -89,7 +89,7 @@ export class ModelRenderer { * Free GPU resources for this model. * Called when model is unloaded or needs cleanup. * Uses global `gl` from registry. - * @param {import('../../common/model/BaseModel.mjs').BaseModel} _model The model to cleanup + * @param {import('../../common/model/BaseModel.ts').BaseModel} _model The model to cleanup */ // eslint-disable-next-line no-unused-vars cleanupModel(_model) { diff --git a/source/engine/client/renderer/ShadowMap.mjs b/source/engine/client/renderer/ShadowMap.mjs index 7985a7b8..33ad08e5 100644 --- a/source/engine/client/renderer/ShadowMap.mjs +++ b/source/engine/client/renderer/ShadowMap.mjs @@ -584,7 +584,7 @@ export default class ShadowMap { /** * Render an alias model entity (monster, item, weapon) into a shadow map. * Handles frame interpolation identically to AliasModelRenderer. - * @param {import('../../common/model/AliasModel.mjs').AliasModel} model + * @param {import('../../common/model/AliasModel.ts').AliasModel} model * @param {import('../ClientEntities.mjs').ClientEdict} entity * @param {Float64Array} lightSpaceMatrix * @param {string} programName @@ -632,7 +632,7 @@ export default class ShadowMap { /** * Render a mesh model entity (glTF) into a shadow map. - * @param {import('../../common/model/MeshModel.mjs').MeshModel} model + * @param {import('../../common/model/MeshModel.ts').MeshModel} model * @param {import('../ClientEntities.mjs').ClientEdict} entity * @param {Float64Array} lightSpaceMatrix * @param {string} programName diff --git a/source/engine/client/renderer/Sky.mjs b/source/engine/client/renderer/Sky.mjs index 49187a1c..6e0ef02b 100644 --- a/source/engine/client/renderer/Sky.mjs +++ b/source/engine/client/renderer/Sky.mjs @@ -1,5 +1,5 @@ import W from '../../common/W.ts'; -import { BrushModel } from '../../common/Mod.mjs'; +import { BrushModel } from '../../common/Mod.ts'; import { eventBus, registry } from '../../registry.mjs'; import GL, { ATTRIB_LOCATIONS, GLTexture } from '../GL.mjs'; diff --git a/source/engine/client/renderer/SpriteModelRenderer.mjs b/source/engine/client/renderer/SpriteModelRenderer.mjs index 665a4581..8c1c5519 100644 --- a/source/engine/client/renderer/SpriteModelRenderer.mjs +++ b/source/engine/client/renderer/SpriteModelRenderer.mjs @@ -48,7 +48,7 @@ export class SpriteModelRenderer extends ModelRenderer { } /** - * @param {import('../../common/model/SpriteModel.mjs').SpriteModel} _model The sprite model + * @param {import('../../common/model/SpriteModel.ts').SpriteModel} _model The sprite model * @param {import('../ClientEntities.mjs').ClientEdict} _entity The entity being rendered * @returns {boolean} Sprites are never drawn in the opaque pass */ @@ -58,7 +58,7 @@ export class SpriteModelRenderer extends ModelRenderer { } /** - * @param {import('../../common/model/SpriteModel.mjs').SpriteModel} _model The sprite model + * @param {import('../../common/model/SpriteModel.ts').SpriteModel} _model The sprite model * @param {import('../ClientEntities.mjs').ClientEdict} _entity The entity being rendered * @returns {boolean} Sprites use their dedicated sprite pass rather than the sorted transparent pass */ @@ -70,7 +70,7 @@ export class SpriteModelRenderer extends ModelRenderer { /** * Render a single sprite model entity. * Generates billboard geometry dynamically based on camera orientation. - * @param {import('../../common/model/SpriteModel.mjs').SpriteModel} model The sprite model to render + * @param {import('../../common/model/SpriteModel.ts').SpriteModel} model The sprite model to render * @param {import('../ClientEntities.mjs').ClientEdict} entity The entity being rendered * @param {number} pass Rendering pass (0=opaque, 1=transparent) */ @@ -195,7 +195,7 @@ export class SpriteModelRenderer extends ModelRenderer { /** * Prepare sprite model for rendering. * Sprites use dynamic geometry, so no GPU resources to prepare. - * @param {import('../../common/model/SpriteModel.mjs').SpriteModel} _model The sprite model to prepare + * @param {import('../../common/model/SpriteModel.ts').SpriteModel} _model The sprite model to prepare * @param {boolean} isWorldModel Whether this model is the world model */ // eslint-disable-next-line no-unused-vars @@ -206,7 +206,7 @@ export class SpriteModelRenderer extends ModelRenderer { /** * Free GPU resources for this sprite model. * Sprites don't allocate GPU resources. - * @param {import('../../common/model/SpriteModel.mjs').SpriteModel} _model The sprite model to cleanup + * @param {import('../../common/model/SpriteModel.ts').SpriteModel} _model The sprite model to cleanup */ // eslint-disable-next-line no-unused-vars cleanupModel(_model) { diff --git a/source/engine/common/CollisionModelSource.mjs b/source/engine/common/CollisionModelSource.mjs index 79b106a8..969ecbec 100644 --- a/source/engine/common/CollisionModelSource.mjs +++ b/source/engine/common/CollisionModelSource.mjs @@ -11,21 +11,21 @@ export class CollisionModelSource { /** @type {() => ServerEdict|null} */ #getServerWorldEntity = () => null; - /** @type {() => import('./Mod.mjs').BrushModel|null} */ + /** @type {() => import('./Mod.ts').BrushModel|null} */ #getServerWorldModel = () => null; - /** @type {() => Array|null} */ + /** @type {() => Array|null} */ #getServerModels = () => null; - /** @type {() => import('./Mod.mjs').BrushModel|null} */ + /** @type {() => import('./Mod.ts').BrushModel|null} */ #getClientWorldModel = () => null; - /** @type {() => Array|null} */ + /** @type {() => Array|null} */ #getClientModels = () => null; /** * Install live server accessors. - * @param {{getWorldEntity?: () => ServerEdict|null, getWorldModel?: () => import('./Mod.mjs').BrushModel|null, getModels?: () => Array|null}} accessors server accessors + * @param {{getWorldEntity?: () => ServerEdict|null, getWorldModel?: () => import('./Mod.ts').BrushModel|null, getModels?: () => Array|null}} accessors server accessors */ configureServer(accessors = {}) { this.#getServerWorldEntity = accessors.getWorldEntity ?? (() => null); @@ -35,7 +35,7 @@ export class CollisionModelSource { /** * Install live client accessors. - * @param {{getWorldModel?: () => import('./Mod.mjs').BrushModel|null, getModels?: () => Array|null}} accessors client accessors + * @param {{getWorldModel?: () => import('./Mod.ts').BrushModel|null, getModels?: () => Array|null}} accessors client accessors */ configureClient(accessors = {}) { this.#getClientWorldModel = accessors.getWorldModel ?? (() => null); @@ -47,7 +47,7 @@ export class CollisionModelSource { return this.#getServerWorldEntity(); } - /** @returns {import('./Mod.mjs').BrushModel|null} active static-world model */ + /** @returns {import('./Mod.ts').BrushModel|null} active static-world model */ getWorldModel() { return this.#getServerWorldModel() ?? this.#getClientWorldModel() @@ -58,7 +58,7 @@ export class CollisionModelSource { /** * Resolve a model from the active runtime's model cache. * @param {number} modelIndex precached model index - * @returns {import('./Mod.mjs').BrushModel|object|null} resolved model, if any + * @returns {import('./Mod.ts').BrushModel|object|null} resolved model, if any */ getModelByIndex(modelIndex) { return this.#getServerModels()?.[modelIndex] diff --git a/source/engine/common/GameAPIs.mjs b/source/engine/common/GameAPIs.mjs index fc51b63c..179a85c9 100644 --- a/source/engine/common/GameAPIs.mjs +++ b/source/engine/common/GameAPIs.mjs @@ -10,7 +10,7 @@ import { ED, ServerEdict } from '../server/Edict.mjs'; import Cmd from './Cmd.ts'; import Cvar from './Cvar.ts'; import { HostError } from './Errors.ts'; -import Mod from './Mod.mjs'; +import Mod from './Mod.ts'; import W from './W.ts'; /** @typedef {import('../client/ClientEntities.mjs').ClientEdict} ClientEdict */ @@ -19,7 +19,7 @@ import W from './W.ts'; /** @typedef {import('../network/MSG.ts').SzBuffer} SzBuffer */ /** @typedef {import('../server/Navigation.mjs').Navigation} Navigation */ /** @typedef {import('./model/parsers/ParsedQC.mjs').default} ParsedQC */ -/** @typedef {import('./model/BaseModel.mjs').BaseModel} BaseModel */ +/** @typedef {import('./model/BaseModel.ts').BaseModel} BaseModel */ /** @typedef {import('../server/physics/ServerCollisionSupport.mjs').CollisionTrace} CollisionTrace */ /** * @typedef ClientTraceOptions diff --git a/source/engine/common/Mod.mjs b/source/engine/common/Mod.ts similarity index 57% rename from source/engine/common/Mod.mjs rename to source/engine/common/Mod.ts index 23729276..537a83f6 100644 --- a/source/engine/common/Mod.mjs +++ b/source/engine/common/Mod.ts @@ -1,6 +1,6 @@ -import { eventBus, registry } from '../registry.mjs'; +import { eventBus, getClientRegistry, getCommonRegistry, registry } from '../registry.mjs'; import { MissingResourceError } from './Errors.ts'; -import { ModelLoaderRegistry } from './model/ModelLoaderRegistry.mjs'; +import { ModelLoaderRegistry } from './model/ModelLoaderRegistry.ts'; import { AliasMDLLoader } from './model/loaders/AliasMDLLoader.mjs'; import { SpriteSPRLoader } from './model/loaders/SpriteSPRLoader.mjs'; import { BSP29Loader } from './model/loaders/BSP29Loader.mjs'; @@ -8,62 +8,66 @@ import { BSP2Loader } from './model/loaders/BSP2Loader.mjs'; import { WavefrontOBJLoader } from './model/loaders/WavefrontOBJLoader.mjs'; import ParsedQC from './model/parsers/ParsedQC.mjs'; import { BSP38Loader } from './model/loaders/BSP38Loader.mjs'; +import type { BaseModel } from './model/BaseModel.ts'; -/** @typedef {import('./model/BaseModel.mjs').BaseModel} BaseModel */ -/** @typedef {'shared' | 'client' | 'server'} ModelScope */ - -let { CL, COM } = registry; +let { COM } = getCommonRegistry(); +let { CL } = getClientRegistry(); eventBus.subscribe('registry.frozen', () => { - CL = registry.CL; - COM = registry.COM; + ({ COM } = getCommonRegistry()); + ({ CL } = getClientRegistry()); }); -// Re-export model classes for backward compatibility -export { AliasModel } from './model/AliasModel.mjs'; -export { BrushModel } from './model/BSP.mjs'; -export { SpriteModel } from './model/SpriteModel.mjs'; -export { MeshModel } from './model/MeshModel.mjs'; - -export default class Mod { - static type = { brush: 0, sprite: 1, alias: 2, mesh: 3 }; - - static scope = Object.freeze({ - shared: 'shared', - client: 'client', - server: 'server', - }); - - static hull = { - /** hull0, point intersection */ - normal: 0, - /** hull1, testing for player (32, 32, 56) */ - player: 1, - /** hull2, testing for large objects (64, 64, 88) */ - big: 2, - /** hull3, only used by BSP30 for crouching etc. (32, 32, 36) */ - crouch: 3, - }; - - static known = /** @type {Record} */ ({}); +export enum ModelType { + brush = 0, + sprite = 1, + alias = 2, + mesh = 3, +} - static clientKnown = /** @type {Record} */ ({}); +export enum ModelScope { + shared = 'shared', + client = 'client', + server = 'server', +} - static serverKnown = /** @type {Record} */ ({}); +export enum ModelHull { + normal = 0, + player = 1, + big = 2, + crouch = 3, +} - static pendingLoads = /** @type {Record>} */ ({}); +type ModelCache = Record; - static modelLoaderRegistry = new ModelLoaderRegistry(); +// Re-export model classes for backward compatibility. +export { AliasModel } from './model/AliasModel.ts'; +export { BrushModel } from './model/BSP.ts'; +export { SpriteModel } from './model/SpriteModel.ts'; +export { MeshModel } from './model/MeshModel.ts'; - static IsSubmodelName(name) { +/** + * Shared model cache and loading entry point. + */ +export default class Mod { + static type = ModelType; + static scope = ModelScope; + static hull = ModelHull; + static known: ModelCache = {}; + static clientKnown: ModelCache = {}; + static serverKnown: ModelCache = {}; + static readonly pendingLoads: Record> = {}; + static readonly modelLoaderRegistry = new ModelLoaderRegistry(); + + static IsSubmodelName(name: string): boolean { return name[0] === '*'; } /** - * @param {BaseModel} sharedModel shared cached model - * @returns {boolean} true when the model is a world brush model with inline submodels + * Returns true when the shared model is a world brush model with inline submodels. + * @returns True when the model is a world brush model with inline submodels. */ - static IsBrushWorldModel(sharedModel) { + static IsBrushWorldModel(sharedModel: BaseModel): boolean { return sharedModel.type === Mod.type.brush && sharedModel.submodel !== true && Array.isArray(sharedModel.submodels) @@ -71,11 +75,9 @@ export default class Mod { } /** - * @param {BaseModel} sharedWorld shared world model - * @param {BaseModel} scopedWorld scoped world model - * @param {ModelScope} scope requested scope + * Rebuilds scoped inline submodels against a scoped world view. */ - static RegisterScopedSubmodels(sharedWorld, scopedWorld, scope) { + static RegisterScopedSubmodels(sharedWorld: BaseModel, scopedWorld: BaseModel, scope: ModelScope): void { if (!Mod.IsBrushWorldModel(sharedWorld)) { return; } @@ -83,15 +85,15 @@ export default class Mod { const scopedCache = Mod.GetScopeCache(scope); scopedWorld.submodels = []; - for (let i = 0; i < sharedWorld.submodels.length; i++) { - const submodelName = `*${i + 1}`; + for (let index = 0; index < sharedWorld.submodels.length; index++) { + const submodelName = `*${index + 1}`; const existingScopedSubmodel = scopedCache[submodelName]; if (existingScopedSubmodel) { existingScopedSubmodel.cleanupScopedView(); } - const sharedSubmodel = sharedWorld.submodels[i]; + const sharedSubmodel = sharedWorld.submodels[index]; const scopedSubmodel = sharedSubmodel.createScopedView(); scopedSubmodel.vertexes = scopedWorld.vertexes; @@ -113,15 +115,15 @@ export default class Mod { scopedSubmodel.clusterPhsOffsets = scopedWorld.clusterPhsOffsets; scopedSubmodel.worldspawnInfo = scopedWorld.worldspawnInfo; - scopedWorld.submodels[i] = scopedSubmodel; + scopedWorld.submodels[index] = scopedSubmodel; scopedCache[submodelName] = scopedSubmodel; } } - static Init() { + static Init(): void { Mod.modelLoaderRegistry.clear(); Mod.modelLoaderRegistry.register(new BSP38Loader()); - Mod.modelLoaderRegistry.register(new BSP2Loader()); // Register BSP2 before BSP29 so it’s checked first (more specific format) + Mod.modelLoaderRegistry.register(new BSP2Loader()); // Register BSP2 before BSP29 so the more specific format wins. Mod.modelLoaderRegistry.register(new BSP29Loader()); Mod.modelLoaderRegistry.register(new AliasMDLLoader()); Mod.modelLoaderRegistry.register(new SpriteSPRLoader()); @@ -129,10 +131,10 @@ export default class Mod { } /** - * @param {ModelScope} scope requested model scope - * @returns {Record} cache for the requested scope + * Returns the model cache for the requested scope. + * @returns The cache object for the requested scope. */ - static GetScopeCache(scope) { + static GetScopeCache(scope: ModelScope): ModelCache { switch (scope) { case Mod.scope.client: return Mod.clientKnown; @@ -144,13 +146,13 @@ export default class Mod { } /** - * @param {string} name model name - * @param {ModelScope} scope requested scope - * @returns {BaseModel|null} scoped model instance or null when unavailable + * Resolves the cached model instance for a scope, creating a scoped runtime + * view when needed. + * @returns The scoped model instance, or `null` when unavailable. */ - static ResolveScopedModel(name, scope) { + static ResolveScopedModel(name: string, scope: ModelScope): BaseModel | null { if (scope === Mod.scope.shared) { - return Mod.known[name] || null; + return Mod.known[name] ?? null; } const scopedCache = Mod.GetScopeCache(scope); @@ -175,7 +177,7 @@ export default class Mod { return scopedModel; } - static PruneSharedCache() { + static PruneSharedCache(): void { for (const name of Object.keys(Mod.known)) { if (Mod.clientKnown[name] || Mod.serverKnown[name]) { continue; @@ -186,9 +188,9 @@ export default class Mod { } /** - * @param {ModelScope} [scope] scope to clear + * Clears cached models for a scope. */ - static ClearAll(scope = Mod.scope.shared) { + static ClearAll(scope: ModelScope = Mod.scope.shared): void { if (scope === Mod.scope.shared) { for (const scopedScope of [Mod.scope.client, Mod.scope.server]) { Mod.ClearAll(scopedScope); @@ -203,7 +205,7 @@ export default class Mod { const tempEnts = (() => { if (scope !== Mod.scope.client || registry.isDedicatedServer) { - return []; + return [] as string[]; } return Object.keys(CL.state.clientEntities.tempEntityModels); @@ -212,40 +214,34 @@ export default class Mod { const scopedCache = Mod.GetScopeCache(scope); for (const name of Object.keys(scopedCache)) { - const mod = scopedCache[name]; + const model = scopedCache[name]; if (tempEnts.includes(name)) { continue; } - mod.cleanupScopedView(); + model.cleanupScopedView(); delete scopedCache[name]; } Mod.PruneSharedCache(); } - static async LoadModelFromBuffer(name, buffer) { - // FIXME: maybe catch at least NotImplementedError here and give a better - // error message, right now it will simply crash the whole engine + static async LoadModelFromBuffer(name: string, buffer: ArrayBuffer): Promise { const model = await Mod.modelLoaderRegistry.load(buffer, name); - Mod.RegisterModel(model); - return model; } - static RegisterModel(model) { + static RegisterModel(model: BaseModel): void { Mod.known[model.name] = model; } /** - * @param {string} name model to load - * @param {boolean} crash whether to throw an error if the model is not found - * @param {ModelScope} scope requested cache scope - * @returns {Promise} the loaded model or null if not found + * Loads a named model into the shared cache and returns the scoped instance. + * @returns The scoped model instance, or `null` when the load fails without crashing. */ - static async LoadModelAsync(name, crash, scope = Mod.scope.shared) { // private method + static async LoadModelAsync(name: string, crash: boolean, scope: ModelScope = Mod.scope.shared): Promise { const scopedModel = Mod.ResolveScopedModel(name, scope); if (scopedModel !== null) { @@ -254,15 +250,17 @@ export default class Mod { if (Mod.pendingLoads[name] === undefined) { Mod.pendingLoads[name] = (async () => { - const buf = await COM.LoadFile(name); - if (buf === null) { - if (crash === true) { + const buffer = await COM.LoadFile(name); + + if (buffer === null) { + if (crash) { throw new MissingResourceError(name); } + return null; } - return await Mod.LoadModelFromBuffer(name, buf); + return await Mod.LoadModelFromBuffer(name, buffer); })().finally(() => { delete Mod.pendingLoads[name]; }); @@ -278,24 +276,19 @@ export default class Mod { } /** - * Load submodels. For anything else, use Mod.ForNameAsync instead. - * @param {string} name filename - * @param {ModelScope} [scope] requested cache scope - * @returns {BaseModel|null} the loaded model or null if not found + * Resolves an inline submodel from the already loaded world model cache. + * @returns The scoped inline submodel, or `null` when it is unavailable. */ - static ForName(name, scope = Mod.scope.shared) { // public method + static ForName(name: string, scope: ModelScope = Mod.scope.shared): BaseModel | null { console.assert(name[0] === '*', 'only submodels supported in Mod.ForName'); - return Mod.ResolveScopedModel(name, scope); } /** - * @param {string} name filename - * @param {boolean} crash whether to throw an error if the model is not found - * @param {ModelScope} [scope] requested cache scope - * @returns {Promise} the loaded model or null if not found + * Returns the requested model, loading it first when necessary. + * @returns The requested model, or `null` when it cannot be loaded. */ - static async ForNameAsync(name, crash = false, scope = Mod.scope.shared) { // public method + static async ForNameAsync(name: string, crash = false, scope: ModelScope = Mod.scope.shared): Promise { if (name[0] === '*') { return Mod.ForName(name, scope); } @@ -303,9 +296,8 @@ export default class Mod { return await Mod.LoadModelAsync(name, crash, scope); } - static ParseQC(qcContent) { + static ParseQC(qcContent: string) { const data = new ParsedQC(); - return data.parseQC(qcContent); } } diff --git a/source/engine/common/Pmove.ts b/source/engine/common/Pmove.ts index ab3ccc24..c425e094 100644 --- a/source/engine/common/Pmove.ts +++ b/source/engine/common/Pmove.ts @@ -12,7 +12,7 @@ import Vector from '../../shared/Vector.ts'; import * as Protocol from '../network/Protocol.ts'; import { content } from '../../shared/Defs.ts'; -import { BrushModel } from './Mod.mjs'; +import { BrushModel } from './Mod.ts'; import Cvar from './Cvar.ts'; import { PmoveConfiguration } from '../../shared/Pmove.ts'; @@ -1384,8 +1384,8 @@ export class BrushTrace { let enterfrac = -1; let leavefrac = 1; - let clipplane: import('./model/BaseModel.mjs').Plane | null = null; - let tangentAxialPlane: import('./model/BaseModel.mjs').Plane | null = null; + let clipplane: import('./model/BaseModel.ts').Plane | null = null; + let tangentAxialPlane: import('./model/BaseModel.ts').Plane | null = null; let tangentAxialMovesDeeper = false; let getout = false; diff --git a/source/engine/common/WorkerFramework.ts b/source/engine/common/WorkerFramework.ts index e945b742..3d3fc654 100644 --- a/source/engine/common/WorkerFramework.ts +++ b/source/engine/common/WorkerFramework.ts @@ -1,7 +1,7 @@ import type { URLs } from '../build-config'; import { eventBus, registry } from '../registry.mjs'; -import Mod from './Mod.mjs'; +import Mod from './Mod.ts'; import Sys from './Sys.ts'; import COM, { type SearchPath } from './Com.ts'; diff --git a/source/engine/common/model/AliasModel.mjs b/source/engine/common/model/AliasModel.mjs deleted file mode 100644 index c479e5e9..00000000 --- a/source/engine/common/model/AliasModel.mjs +++ /dev/null @@ -1,70 +0,0 @@ -import { BaseModel } from './BaseModel.mjs'; - -/** - * Alias model (.mdl) - Quake's animated mesh format. - * Used for characters, monsters, weapons, and other animated models. - */ -export class AliasModel extends BaseModel { - constructor(name) { - super(name); - this.type = 2; // Mod.type.alias - } - - reset() { - super.reset(); - - // Private model data (used during loading) - - /** @type {import('../../../shared/Vector.ts').default|null} Scale factors for vertices */ - this._scale = null; - - /** @type {import('../../../shared/Vector.ts').default|null} Origin offset for vertices */ - this._scale_origin = null; - - /** @type {number} Number of skins in file */ - this._num_skins = 0; - - /** @type {number} Skin texture width */ - this._skin_width = 0; - - /** @type {number} Skin texture height */ - this._skin_height = 0; - - /** @type {number} Number of vertices */ - this._num_verts = 0; - - /** @type {number} Number of triangles */ - this._num_tris = 0; - - /** @type {number} Number of frames in file */ - this._frames = 0; - - /** @type {Array<{facesfront: boolean, vertindex: number[]}>} Triangle definitions */ - this._triangles = []; - - /** @type {Array<{onseam: boolean, s: number, t: number}>} Texture coordinate vertices */ - this._stverts = []; - - // Public variables - - /** @type {number} Model flags (CL requires that together with Mod.flags) */ - this.flags = 0; - - /** @type {boolean} Random frame selection */ - this.random = false; - - /** @type {Array} Animation frames (required by R, Host for name and interval) */ - this.frames = []; - - // Public variables just for rendering purposes (IDEA: refactor into ModelRenderer classes) - - /** @type {Array} Skin textures (R requires that to pick the right texture) */ - this.skins = []; - - /** @type {number} Bounding radius (R requires that) */ - this.boundingradius = 0; - - /** @type {boolean} Is this a player model (R requires that to change colors) */ - this.player = false; - } -} diff --git a/source/engine/common/model/AliasModel.ts b/source/engine/common/model/AliasModel.ts new file mode 100644 index 00000000..01412b5b --- /dev/null +++ b/source/engine/common/model/AliasModel.ts @@ -0,0 +1,146 @@ +import type { GLTexture } from '../../client/GL.mjs'; + +import Vector from '../../../shared/Vector.ts'; +import { BaseModel } from './BaseModel.ts'; + +interface AliasStVertex { + readonly onseam: boolean; + readonly s: number; + readonly t: number; +} + +interface AliasTriangle { + readonly facesfront: boolean; + readonly vertindex: number[]; +} + +interface AliasPoseVertex { + readonly v: Vector; + readonly lightnormalindex: number; +} + +interface AliasSingleFrame { + readonly group: false; + readonly bboxmin: Vector; + readonly bboxmax: Vector; + readonly name: string; + readonly v: AliasPoseVertex[]; +} + +interface AliasGroupedFrameEntry { + readonly interval: number; + readonly bboxmin: Vector; + readonly bboxmax: Vector; + readonly name: string; + readonly v: AliasPoseVertex[]; +} + +interface AliasGroupedFrame { + readonly group: true; + readonly bboxmin: Vector; + readonly bboxmax: Vector; + readonly frames: AliasGroupedFrameEntry[]; +} + +interface AliasSingleSkin { + readonly group: false; + readonly texturenum: GLTexture | null; + readonly luminanceTexture: GLTexture | null; + readonly translated?: Uint8Array; + readonly playertexture?: GLTexture | null; +} + +interface AliasGroupedSkinEntry { + readonly interval: number; + readonly texturenum?: GLTexture | null; + readonly luminanceTexture?: GLTexture | null; + readonly translated?: Uint8Array; + readonly playertexture?: GLTexture | null; +} + +interface AliasGroupedSkin { + readonly group: true; + readonly skins: AliasGroupedSkinEntry[]; +} + +export type AliasFrame = AliasSingleFrame | AliasGroupedFrame; +export type AliasSkin = AliasSingleSkin | AliasGroupedSkin; + +/** + * Alias model (.mdl), Quake's animated triangle mesh format. + * Used for characters, monsters, weapons, and other animated models. + */ +export class AliasModel extends BaseModel { + /** Scale factors for vertices. */ + override _scale: Vector | null = null; + + /** Origin offset for vertices. */ + override _scale_origin: Vector | null = null; + + /** Number of skins in file. */ + override _num_skins = 0; + + /** Skin texture width. */ + _skin_width = 0; + + /** Skin texture height. */ + _skin_height = 0; + + /** Number of vertices. */ + override _num_verts = 0; + + /** Number of triangles. */ + override _num_tris = 0; + + /** Number of frames in file. */ + _frames = 0; + + /** Triangle definitions. */ + _triangles: AliasTriangle[] = []; + + /** Texture coordinate vertices. */ + _stverts: AliasStVertex[] = []; + + /** Model flags, used together with `Mod.flags` on the client. */ + flags = 0; + + /** True when animation frames should be selected randomly. */ + random = false; + + /** Animation frames used by rendering and host-side metadata lookups. */ + frames: AliasFrame[] = []; + + /** Skin textures used by the renderer. */ + skins: AliasSkin[] = []; + + /** Bounding radius consumed by the renderer. */ + boundingradius = 0; + + /** True when this is a player model that supports color translation. */ + player = false; + + constructor(name: string) { + super(name); + this.type = 2; + } + + override reset(): void { + super.reset(); + this._scale = null; + this._scale_origin = null; + this._num_skins = 0; + this._skin_width = 0; + this._skin_height = 0; + this._num_verts = 0; + this._num_tris = 0; + this._frames = 0; + this._triangles = []; + this._stverts = []; + this.flags = 0; + this.random = false; + this.frames = []; + this.skins = []; + this.boundingradius = 0; + this.player = false; + } +} diff --git a/source/engine/common/model/AreaPortals.mjs b/source/engine/common/model/AreaPortals.mjs deleted file mode 100644 index ca0e22a8..00000000 --- a/source/engine/common/model/AreaPortals.mjs +++ /dev/null @@ -1,294 +0,0 @@ -import { eventBus } from '../../registry.mjs'; - -/** - * Manages area portal state and area connectivity for a BrushModel. - * - * Q2-style area portals: each leaf belongs to an area, and portals - * connect pairs of areas. When a portal is closed (e.g. a door shuts), - * the two areas become disconnected, blocking sound propagation and - * optionally visibility even if PVS/PHS say otherwise. - * - * For BSP29/BSP2 maps (which lack area data), all leafs are in area 0 - * and everything is trivially connected. - * - * Portal groups: a single physical portal (e.g. a door) may separate - * multiple area pairs when the map has more than one portal. Each - * connection stores a `group` number identifying its physical portal. - * The open/close state is tracked per group, so opening one physical - * portal opens all its connections at once. - * - * Usage: - * if (phs.isRevealed(leafIndex) && areaPortals.areasConnected(srcArea, dstArea)) { ... } - */ -export class AreaPortals { - /** @type {number} total number of areas in the map */ - #numAreas = 0; - - /** - * Per-group open reference count. A group is open when its count > 0. - * Index = group number (physical portal number). - * Multiple entities can hold the same portal open. - * @type {number[]} - */ - #portalOpen = []; - - /** @type {number} number of groups (physical portals) */ - #numPortals = 0; - - /** - * Portal connections: each entry connects two areas and belongs to a group. - * Multiple connections can share the same group (physical portal). - * @type {{ area0: number, area1: number, group: number }[]} - */ - #connections = []; - - /** - * Flood-fill reachability: floodnum[area] after flood. - * Two areas are connected iff they have the same floodnum and it is > 0. - * @type {number[]} - */ - #floodNum = []; - - /** @type {number} current flood generation counter */ - #floodGeneration = 0; - - /** - * Initialize the area portal system for a given map. - * - * Each portal entry may include an optional `group` field identifying - * the physical portal it belongs to. When omitted, each entry is - * treated as its own group (backward compatible with BSP38). - * @param {number} numAreas number of areas (from BSP areas lump or 1 for Q1) - * @param {{ area0: number, area1: number, group?: number }[]} portals portal definitions - * @param {number} [numGroups] number of physical portal groups (defaults to portals.length) - */ - /** - * Adjacency list for fast graph traversal. - * Index = area number. - * Value = array of edges { target: number, group: number }. - * @type {Array<{ target: number, group: number }[]>} - */ - #adjacency = []; - - /** - * Initialize the area portal system for a given map. - * - * Each portal entry may include an optional `group` field identifying - * the physical portal it belongs to. When omitted, each entry is - * treated as its own group (backward compatible with BSP38). - * @param {number} numAreas number of areas (from BSP areas lump or 1 for Q1) - * @param {{ area0: number, area1: number, group?: number }[]} portals portal definitions - * @param {number} [numGroups] number of physical portal groups (defaults to portals.length) - */ - init(numAreas, portals, numGroups) { - this.#numAreas = numAreas; - - // Normalize connections and determine max group - this.#connections = []; - let calculatedMaxGroup = -1; - - // Build adjacency list immediately for O(1) edge lookups - this.#adjacency = Array.from({ length: numAreas }, () => []); - - for (let i = 0; i < portals.length; i++) { - const p = portals[i]; - const area0 = p.area0; - const area1 = p.area1; - const group = p.group !== undefined ? p.group : i; // Default to unique group - - // Store normalized connection (optional, purely for debug/serialization if needed) - this.#connections.push({ area0, area1, group }); - - // Populate adjacency graph (undirected) - if (area0 >= 0 && area0 < numAreas && area1 >= 0 && area1 < numAreas) { - this.#adjacency[area0].push({ target: area1, group }); - this.#adjacency[area1].push({ target: area0, group }); - } - - if (group > calculatedMaxGroup) { - calculatedMaxGroup = group; - } - } - - // Determine number of physical portal groups - this.#numPortals = numGroups !== undefined ? numGroups : (calculatedMaxGroup + 1); - - this.#portalOpen = new Array(this.#numPortals).fill(0); - this.#floodNum = new Array(numAreas).fill(0); - this.#floodGeneration = 0; - - // Initially all portals are closed (doors start closed). - // The server will send the correct state to clients during signon. - this.closeAll(); - } - - /** - * Open all portals (reset to fully connected state). - * This is the default after map load. - */ - openAll() { - this.#portalOpen.fill(1); - this.#floodAreas(); - } - - /** - * Close all portals (fully disconnected). - */ - closeAll() { - this.#portalOpen.fill(0); - this.#floodAreas(); - } - - /** - * Set a portal's open state. Uses reference counting so multiple - * entities can hold the same portal open. - * @param {number} portalNum portal index - * @param {boolean} open true to increment open count, false to decrement - */ - setPortalState(portalNum, open) { - if (portalNum < 0 || portalNum >= this.#numPortals) { - return; - } - - const oldState = this.#portalOpen[portalNum] > 0; - - if (open) { - this.#portalOpen[portalNum]++; - } else { - this.#portalOpen[portalNum] = Math.max(0, this.#portalOpen[portalNum] - 1); - } - - const newState = this.#portalOpen[portalNum] > 0; - - // Only reflood if the effective open/closed state actually changed - if (oldState !== newState) { - this.#floodAreas(); - } - } - - /** - * Check whether two areas are connected through open portals. - * @param {number} area0 first area index - * @param {number} area1 second area index - * @returns {boolean} true if the areas are connected - */ - areasConnected(area0, area1) { - // Same area is always connected - if (area0 === area1) { - return true; - } - - // Area 0 (outside/solid) is considered connected to everything to avoid - // culling bugs when camera/entities clip into void. - // Also bounds check handles invalid areas gracefully. - if (area0 <= 0 || area0 >= this.#numAreas || area1 <= 0 || area1 >= this.#numAreas) { - return true; - } - - // Connected if they share the same non-zero flood signature - const f0 = this.#floodNum[area0]; - const f1 = this.#floodNum[area1]; - return f0 > 0 && f1 > 0 && f0 === f1; - } - - /** - * Check whether two leafs are connected through area portals. - * Convenience method that takes leaf nodes directly. - * @param {import('./BSP.mjs').Node} leaf0 first leaf - * @param {import('./BSP.mjs').Node} leaf1 second leaf - * @returns {boolean} true if the leafs' areas are connected - */ - leafsConnected(leaf0, leaf1) { - return this.areasConnected(leaf0.area, leaf1.area); - } - - /** - * Check whether a specific portal is currently open. - * @param {number} portalNum portal index - * @returns {boolean} true if the portal's open count is > 0 - */ - isPortalOpen(portalNum) { - if (portalNum < 0 || portalNum >= this.#numPortals) { - return false; - } - - return this.#portalOpen[portalNum] > 0; - } - - /** - * Get the number of areas. - * @returns {number} area count - */ - get numAreas() { - return this.#numAreas; - } - - /** - * Get the number of portals. - * @returns {number} portal count - */ - get numPortals() { - return this.#numPortals; - } - - /** - * Flood-fill areas through open portals to compute reachability. - * Two areas with the same non-zero floodNum are mutually reachable. - * Uses BFS to avoid recursion depth limits. - */ - #floodAreas() { - this.#floodGeneration = 0; - this.#floodNum.fill(0); - - // Reusable queue to avoid allocation in loop - // Note: In extremely large maps, a dedicated Queue class might be faster than Array.push/shift - // but for typical area counts (hundreds), array is fine. - const queue = []; - - for (let startArea = 1; startArea < this.#numAreas; startArea++) { - // If area already visited by a previous flood, skip it - if (this.#floodNum[startArea] !== 0) { - continue; - } - - this.#floodGeneration++; - const currentFloodId = this.#floodGeneration; - - // Start BFS - this.#floodNum[startArea] = currentFloodId; - queue.push(startArea); - - while (queue.length > 0) { - const u = queue.shift(); - const neighbors = this.#adjacency[u]; - - // neighbors might be undefined if area index is weird, but we init all valid areas - if (!neighbors) { - continue; - } - - for (const edge of neighbors) { - // Check if portal is blocked (group >= 0 means it's a switchable door) - // If group is out of bounds, treat it as a closed door - const isOpen = !(edge.group >= 0 && (edge.group >= this.#numPortals || this.#portalOpen[edge.group] <= 0)); - - if (!isOpen) { - continue; // Door is effectively closed - } - - const v = edge.target; - if (this.#floodNum[v] === 0) { - this.#floodNum[v] = currentFloodId; - queue.push(v); - } - } - } - } - - this.#emitChangeEvent(); - } - - #emitChangeEvent() { - eventBus.publish('areaportals.changed'); - } -} diff --git a/source/engine/common/model/AreaPortals.ts b/source/engine/common/model/AreaPortals.ts new file mode 100644 index 00000000..22275ea7 --- /dev/null +++ b/source/engine/common/model/AreaPortals.ts @@ -0,0 +1,251 @@ +import type { Node } from './BSP.ts'; + +import { eventBus } from '../../registry.mjs'; + +export interface PortalDefinition { + readonly area0: number; + readonly area1: number; + readonly group?: number; +} + +interface PortalConnection { + readonly area0: number; + readonly area1: number; + readonly group: number; +} + +interface AreaAdjacencyEdge { + readonly target: number; + readonly group: number; +} + +/** + * Tracks area portal connectivity for brush worlds. + * + * Q2-style area portals assign each leaf to an area and use portals to + * connect pairs of areas. Closing a portal, such as when a door shuts, + * disconnects the affected areas and blocks sound propagation even when + * PVS or PHS alone would still relate them. + * + * For BSP29 and BSP2 maps that lack explicit area data, every leaf falls + * into area 0 and connectivity becomes trivially open. + * + * A portal can connect one or more area pairs through a shared group id so a + * single physical door can open or close multiple logical connections. + */ +export class AreaPortals { + /** Total number of areas in the loaded map. */ + #numAreas = 0; + + /** + * Per-group open reference count. + * A group is effectively open whenever its count is greater than zero. + */ + #portalOpen: number[] = []; + + /** Number of physical portal groups. */ + #numPortals = 0; + + /** Portal connections between area pairs. */ + #connections: PortalConnection[] = []; + + /** Flood-fill reachability signature for each area. */ + #floodNum: number[] = []; + + /** Current flood generation counter. */ + #floodGeneration = 0; + + /** Adjacency list used for flood-fill traversal. */ + #adjacency: AreaAdjacencyEdge[][] = []; + + /** + * Initializes the area portal graph for a map. + * + * Each portal entry can optionally provide a `group` field identifying the + * physical portal it belongs to. When omitted, the entry gets its own group, + * matching the older BSP38 behavior. + */ + init(numAreas: number, portals: PortalDefinition[], numGroups?: number): void { + this.#numAreas = numAreas; + this.#connections = []; + this.#adjacency = Array.from({ length: numAreas }, () => []); + + let calculatedMaxGroup = -1; + + for (let index = 0; index < portals.length; index++) { + const portal = portals[index]; + const group = portal.group !== undefined ? portal.group : index; + + this.#connections.push({ + area0: portal.area0, + area1: portal.area1, + group, + }); + + if (portal.area0 >= 0 && portal.area0 < numAreas && portal.area1 >= 0 && portal.area1 < numAreas) { + this.#adjacency[portal.area0].push({ target: portal.area1, group }); + this.#adjacency[portal.area1].push({ target: portal.area0, group }); + } + + if (group > calculatedMaxGroup) { + calculatedMaxGroup = group; + } + } + + this.#numPortals = numGroups !== undefined ? numGroups : calculatedMaxGroup + 1; + this.#portalOpen = new Array(this.#numPortals).fill(0); + this.#floodNum = new Array(numAreas).fill(0); + this.#floodGeneration = 0; + + // The server will publish the real state during signon. + this.closeAll(); + } + + /** + * Opens every physical portal group. + */ + openAll(): void { + this.#portalOpen.fill(1); + this.#floodAreas(); + } + + /** + * Closes every physical portal group. + */ + closeAll(): void { + this.#portalOpen.fill(0); + this.#floodAreas(); + } + + /** + * Updates the open state of a portal group using reference counting. + * Multiple entities can hold the same physical portal open at once. + */ + setPortalState(portalNum: number, open: boolean): void { + if (portalNum < 0 || portalNum >= this.#numPortals) { + return; + } + + const wasOpen = this.#portalOpen[portalNum] > 0; + + if (open) { + this.#portalOpen[portalNum]++; + } else { + this.#portalOpen[portalNum] = Math.max(0, this.#portalOpen[portalNum] - 1); + } + + const isOpen = this.#portalOpen[portalNum] > 0; + + if (wasOpen !== isOpen) { + this.#floodAreas(); + } + } + + /** + * Returns true when two areas are connected through open portals. + * + * Area 0 is treated as connected to everything to avoid culling bugs when + * the camera or entities clip into invalid space. + * @returns True when the two areas are mutually reachable. + */ + areasConnected(area0: number, area1: number): boolean { + if (area0 === area1) { + return true; + } + + // Area 0 (outside/solid) remains connected to everything to avoid culling + // glitches when the camera or entities clip into invalid space. + if (area0 <= 0 || area0 >= this.#numAreas || area1 <= 0 || area1 >= this.#numAreas) { + return true; + } + + const flood0 = this.#floodNum[area0]; + const flood1 = this.#floodNum[area1]; + return flood0 > 0 && flood1 > 0 && flood0 === flood1; + } + + /** + * Convenience wrapper that checks connectivity by leaf area ids. + * @returns True when the two leaf areas are mutually reachable. + */ + leafsConnected(leaf0: Node, leaf1: Node): boolean { + return this.areasConnected(leaf0.area, leaf1.area); + } + + /** + * Returns true when the portal group is currently open. + * @returns True when the portal group's open count is above zero. + */ + isPortalOpen(portalNum: number): boolean { + if (portalNum < 0 || portalNum >= this.#numPortals) { + return false; + } + + return this.#portalOpen[portalNum] > 0; + } + + get numAreas(): number { + return this.#numAreas; + } + + get numPortals(): number { + return this.#numPortals; + } + + /** + * Recomputes reachability by flooding through currently open portal groups. + * Uses breadth-first traversal to avoid recursion depth issues. + */ + #floodAreas(): void { + this.#floodGeneration = 0; + this.#floodNum.fill(0); + + const queue: number[] = []; + + for (let startArea = 1; startArea < this.#numAreas; startArea++) { + if (this.#floodNum[startArea] !== 0) { + continue; + } + + this.#floodGeneration++; + const currentFloodId = this.#floodGeneration; + this.#floodNum[startArea] = currentFloodId; + queue.push(startArea); + + while (queue.length > 0) { + const area = queue.shift(); + + if (area === undefined) { + break; + } + + const neighbors = this.#adjacency[area]; + + if (!neighbors) { + continue; + } + + for (const edge of neighbors) { + const isOpen = !(edge.group >= 0 && (edge.group >= this.#numPortals || this.#portalOpen[edge.group] <= 0)); + + if (!isOpen) { + continue; + } + + if (this.#floodNum[edge.target] !== 0) { + continue; + } + + this.#floodNum[edge.target] = currentFloodId; + queue.push(edge.target); + } + } + } + + this.#emitChangeEvent(); + } + + #emitChangeEvent(): void { + eventBus.publish('areaportals.changed'); + } +} diff --git a/source/engine/common/model/BSP.mjs b/source/engine/common/model/BSP.mjs index c8277f23..224f42b0 100644 --- a/source/engine/common/model/BSP.mjs +++ b/source/engine/common/model/BSP.mjs @@ -1,714 +1 @@ -import { BaseMaterial } from '../../client/renderer/Materials.mjs'; -import { content } from '../../../shared/Defs.ts'; -import { BaseModel } from './BaseModel.mjs'; -import { SkyRenderer } from '../../client/renderer/Sky.mjs'; -import { AreaPortals } from './AreaPortals.mjs'; - -/** @typedef {import('../../../shared/Vector.ts').default} Vector */ -/** @typedef {import('./BaseModel.mjs').Face} Face */ -/** @typedef {import('./BaseModel.mjs').Plane} Plane */ - -/** - * @typedef {object} Clipnode - * @property {number} planenum - Index into planes array - * @property {number[]} children - Child node indices [front, back] - */ - -/** - * @typedef {object} Hull - * @property {Clipnode[]} clipnodes - Clipnodes for this hull - * @property {Plane[]} planes - Planes for collision detection - * @property {number} [firstclipnode] - Index of first clipnode (optional) - * @property {number} lastclipnode - Index of last clipnode - * @property {Vector} clip_mins - Minimum bounding box for this hull - * @property {Vector} clip_maxs - Maximum bounding box for this hull - */ - -/** - * @typedef {Record} WorldspawnInfo - * Parsed worldspawn entity key-value pairs - */ - -/** - * @typedef {object} FogVolumeInfo - * @property {number} modelIndex - The inline brush model index (from *N notation), 0 for world water - * @property {number[]} color - Fog color as [r, g, b] in 0-255 range - * @property {number} density - Fog density for exponential falloff - * @property {number} maxOpacity - Maximum fog opacity (0-1 clamped) - * @property {number[]} mins - AABB minimum corner [x, y, z] - * @property {number[]} maxs - AABB maximum corner [x, y, z] - */ - -/** - * @typedef {object} WorldTurbulentChainInfo - * @property {number} texture Texture index used by the draw batch - * @property {number} firstVertex First vertex in the turbulent VBO region - * @property {number} vertexCount Number of vertices in the draw batch - * @property {Vector} mins Tight world-space bounds minimum - * @property {Vector} maxs Tight world-space bounds maximum - */ - -/** - * @typedef {Record} BSPXLumps - * BSPX extended lump data (RGBLIGHTING, LIGHTINGDIR, etc.) - */ - -const VISDATA_SIZE = 1024; // fallback for singleton Visibility instances (no model) - -/** - * Visibility data for PVS/PHS. - * Stored as cluster-indexed bits. Each bit corresponds to a cluster; - * leaf → cluster mapping is resolved via the owning BrushModel. - */ -export class Visibility { - #data = new Uint8Array(VISDATA_SIZE); - - #model = /** @type {BrushModel} */ (null); - - /** @type {boolean} when set, isRevealed/areRevealed always return true */ - #unconditionalReveal = false; - - constructor(model = null) { - this.#model = model; - - if (model !== null) { - const clusterBytes = Math.max((model.numclusters + 7) >> 3, 1); - this.#data = new Uint8Array(clusterBytes); - - if (model.visdata === null) { - this.revealAll(); - } - } - } - - /** - * Create a Visibility instance from RLE-compressed cluster PVS data. - * @param {BrushModel} model map model - * @param {number} visofs byte offset into sourceData - * @param {Uint8Array} sourceData compressed vis data (defaults to model.visdata) - * @returns {Visibility} visibility instance - */ - static fromBrushModel(model, visofs, sourceData = model.visdata) { - console.assert(model instanceof BrushModel); - - const modelVisSize = (model.numclusters + 7) >> 3; - const visibility = new Visibility(model); - - if (sourceData !== null && visofs >= 0) { - for (let _out = 0, _in = visofs; _out < modelVisSize;) { - // Bounds check to prevent reading past end of sourceData - // Note: It's normal for visibility data to end before modelVisSize is filled; - // remaining bytes stay zero, which is correct for unvisible clusters. - if (_in >= sourceData.length) { - break; - } - - if (sourceData[_in] !== 0) { - visibility.#data[_out++] = sourceData[_in++]; - continue; - } - - // RLE: 0 byte followed by count of zeros - if (_in + 1 >= sourceData.length) { - // End of RLE data; remaining output stays zero (unvisible) - break; - } - - for (let c = sourceData[_in + 1]; c > 0; c--) { - visibility.#data[_out++] = 0x00; - } - - _in += 2; - } - } - - return visibility; - } - - /** - * Will reveal all clusters. - * @returns {Visibility} this - */ - revealAll() { - this.#data.fill(0xff); - this.#unconditionalReveal = true; - - return this; - } - - /** - * Will hide all clusters. - * @returns {Visibility} this - */ - hideAll() { - this.#data.fill(0x00); - - return this; - } - - /** - * Recursive helper for addFatPoint. - * @param {Vector} p point in world - * @param {Node} node current BSP node - */ - #addToFatPoint(p, node) { - while (true) { - if (node.contents < 0) { - if (node.contents !== content.CONTENT_SOLID && node.cluster >= 0) { - const visofs = this.#model.clusterPvsOffsets[node.cluster]; - const vis = Visibility.fromBrushModel(this.#model, visofs); - - for (let i = 0; i < this.#data.length; i++) { // merge visibility from node to ours - this.#data[i] |= vis.#data[i]; - } - } - return; - } - - const normal = node.plane.normal; - const d = p.dot(normal) - node.plane.dist; - - if (d > 8.0) { - node = node.children[0]; - continue; - } - - if (d < -8.0) { - node = node.children[1]; - continue; - } - - this.#addToFatPoint(p, node.children[0]); - node = node.children[1]; - } - } - - /** - * Adds a point to the visibility, merging visibility from all leafs connected. - * @param {Vector} p point in world - * @returns {Visibility} this - */ - addFatPoint(p) { - this.#addToFatPoint(p, this.#model.nodes[0]); - - return this; - } - - /** - * Check if any of the given leaf indices have visible clusters. - * @param {number[]} leafIndices leaf array indices (Node.num values) - * @returns {boolean} whether any of the given leafs are revealed - */ - areRevealed(leafIndices) { - if (this.#unconditionalReveal) { - return leafIndices.length > 0; - } - - if (this.#model === null) { - // Sentinel fallback (hiddenVisibility) - for (let i = 0; i < leafIndices.length; i++) { - if ((this.#data[leafIndices[i] >> 3] & (1 << (leafIndices[i] & 7))) !== 0) { - return true; - } - } - return false; - } - - for (let i = 0; i < leafIndices.length; i++) { - const cluster = this.#model.leafs[leafIndices[i]].cluster; - - if (cluster < 0) { - continue; - } - - if ((this.#data[cluster >> 3] & (1 << (cluster & 7))) !== 0) { - return true; - } - } - - return false; - } - - /** - * Check if a given leaf is revealed via its cluster. - * @param {number} leafIndex leaf array index (Node.num) - * @returns {boolean} whether the given leaf is revealed - */ - isRevealed(leafIndex) { - if (this.#unconditionalReveal) { - return true; - } - - if (this.#model === null) { - // Sentinel fallback (hiddenVisibility) - return (this.#data[leafIndex >> 3] & (1 << (leafIndex & 7))) !== 0; - } - - const cluster = this.#model.leafs[leafIndex].cluster; - - if (cluster < 0) { - return false; - } - - return (this.#data[cluster >> 3] & (1 << (cluster & 7))) !== 0; - } -}; - -/** @type {Visibility} @readonly */ -export const revealedVisibility = (new Visibility()).revealAll(); - -/** @type {Visibility} @readonly */ -export const hiddenVisibility = (new Visibility()).hideAll(); - -export class BrushModelComponent { - /** - * @param {BrushModel} brushmodel parent brush model - */ - constructor(brushmodel) { - /** @type {BrushModel} owning brush model @protected */ - this._brushmodel = brushmodel; - } -}; - -/** - * BSP tree node aka. BSP leaf - */ -export class Node extends BrushModelComponent { - /** @type {number} node index in the nodes array */ - num = 0; - - /** @type {number} */ - contents = 0; - /** @type {number} index into planes array */ - planenum = 0; - /** @type {Plane} splitting plane */ - plane = null; - /** @type {Node|null} parent node */ - parent = null; - /** @type {Node[]} frontside, backside - numbers during loading, Node refs after */ - children = [null, null]; - /** @type {number} visibility offset for PVS */ - visofs = 0; - /** @type {Vector|null} minimum bounding box */ - mins = null; - /** @type {Vector|null} maximum bounding box */ - maxs = null; - /** @type {Vector|null} immutable bounds loaded from BSP data */ - baseMins = null; - /** @type {Vector|null} immutable bounds loaded from BSP data */ - baseMaxs = null; - /** @type {number} first marksurface index (for leafs), aka firstleafface */ - firstmarksurface = 0; - /** @type {number} number of marksurfaces (for leafs), aka numleaffaces */ - nummarksurfaces = 0; - /** @type {number} first face index (for nodes) */ - firstface = 0; - /** @type {number} number of faces (for nodes) */ - numfaces = 0; - /** @type {number[]} ambient sound levels [water, sky, slime, lava] */ - ambient_level = [0, 0, 0, 0]; - - // === Renderer related === - /** @type {number} used by the renderer to determine what to draw */ - markvisframe = 0; - /** @type {number} used by the renderer to determine what to draw */ - visframe = 0; - /** @type {number} index into skychain list */ - skychain = 0; - /** @type {number} index into waterchain list */ - waterchain = 0; - /** @type {number[]} render command list */ - cmds = []; - /** @type {WorldTurbulentChainInfo[]} tight turbulent draw batches for sorted world rendering */ - turbulentChains = []; - - // === Quake 2 based features === - /** @type {number} cluster for PVS */ - cluster = 0; - /** @type {number} area for area portals */ - area = 0; - /** @type {number} first leaf brush index */ - firstleafbrush = 0; - /** @type {number} number of leaf brushes */ - numleafbrushes = 0; - - *facesIter() { - for (let i = 0; i < this.numfaces; i++) { - yield this._brushmodel.faces[this.firstface + i]; - } - } - - /** - * Reset renderer-owned runtime state on this BSP node/leaf. - * Leaf bounds may be expanded during display-list packing, so restore them - * from the immutable BSP bounds before rebuilding world render chains. - */ - resetRenderState() { - this.markvisframe = 0; - this.visframe = 0; - this.skychain = 0; - this.waterchain = 0; - this.cmds.length = 0; - this.turbulentChains.length = 0; - - if (this.mins !== null && this.baseMins !== null) { - this.mins.set(this.baseMins); - } - - if (this.maxs !== null && this.baseMaxs !== null) { - this.maxs.set(this.baseMaxs); - } - } -}; - -export class BrushSide extends BrushModelComponent { - /** @type {number} plane index, facing leaf outwards */ - planenum = 0; - /** @type {number} texture info index */ - texinfo = 0; -}; - -export class Brush extends BrushModelComponent { - /** @type {number} first brush side index */ - firstside = 0; - /** @type {number} number of brush sides */ - numsides = 0; - /** @type {number} contents flags, see Def.contents */ - contents = 0; - /** @type {Vector|null} axis-aligned bounding box minimum */ - mins = null; - /** @type {Vector|null} axis-aligned bounding box maximum */ - maxs = null; - /** @type {number} BrushTrace dedup counter to avoid testing the same brush twice */ - _brushTraceCheck = 0; - - *sidesIter() { - for (let i = 0; i < this.numsides; i++) { - yield this._brushmodel.brushsides[this.firstside + i]; - } - } -}; - -/** - * Base class for brush-based models (BSP maps) - * All loading is handled by BSP29Loader.mjs - */ -export class BrushModel extends BaseModel { - /** @type {number} BSP format version */ - version = null; - - /** @type {number} Bounding radius for culling */ - radius = 0; - - /** @type {Plane[]} All planes in the BSP tree */ - planes = []; - - /** @type {Face[]} All visible faces/surfaces */ - faces = []; - - /** @type {Vector[]} All vertex positions */ - vertexes = []; - - /** @type {number[][]} Edge vertex indices [v1, v2] */ - edges = []; - - /** @type {number[]} Surface edge list (index into edges, negative = reverse) */ - surfedges = []; - - /** @type {Node[]} BSP tree nodes */ - nodes = []; - - /** @type {Node[]} BSP leaf nodes */ - leafs = []; - - /** @type {BaseMaterial[]} Texture information */ - textures = []; - - /** @type {any[]} Texture coordinate info per face */ - texinfo = []; - - /** @type {number[]} Face indices visible from each leaf */ - marksurfaces = []; - - /** @type {Uint8Array} Lightmap data (grayscale) */ - lightdata = null; - - /** @type {Uint8Array} Lightmap data (RGB) */ - lightdata_rgb = null; - - /** @type {Uint8Array} Deluxemap data (normals) */ - deluxemap = null; - - /** @type {object|null} Lightgrid octree data */ - lightgrid = null; - - /** @type {Uint8Array} Visibility data for PVS */ - visdata = null; - - /** @type {Clipnode[]} Clipnodes for collision detection */ - clipnodes = []; - - /** @type {Hull[]} Collision hulls for physics (hull0, hull1, hull2) */ - hulls = []; - - /** @type {BrushModel[]} Submodels (brush entities) */ - submodels = []; - - /** @type {number} First face index for this submodel */ - firstface = 0; - - /** @type {number} Number of faces in this submodel */ - numfaces = 0; - - /** @type {string} Entity lump as string */ - entities = null; - - /** @type {WorldspawnInfo} Parsed worldspawn entity properties */ - worldspawnInfo = {}; - - /** @type {number} Offset for BSPX extended data */ - bspxoffset = 0; - - /** @type {BSPXLumps} BSPX extended lumps */ - bspxlumps = null; - - /** @type {boolean} Whether this is an inline submodel (brush entity) vs the world */ - submodel = false; - - /** @type {Array} Rendering chains (texture batches) for optimized drawing */ - chains = []; - - /** @type {number} Offset into vertex buffer for turbulent surfaces (water, slime, lava) */ - waterchain = 0; - - /** @type {number} Offset into vertex buffer for sky surfaces */ - skychain = 0; - - /** @type {boolean} Whether RGB lighting is used */ - coloredlights = false; - - /** @type {number} Number of visibility clusters */ - numclusters = 0; - - /** @type {number[]|null} PVS byte offset per cluster into visdata */ - clusterPvsOffsets = null; - - /** @type {Uint8Array|null} PHS (Potentially Hearable Set) data, cluster-indexed RLE */ - phsdata = null; - - /** @type {number[]|null} PHS byte offset per cluster into phsdata */ - clusterPhsOffsets = null; - - /** @type {number} Number of areas for area portals */ - numAreas = 0; - - /** @type {{ area0: number, area1: number, group?: number }[]} Area portal definitions */ - portalDefs = []; - - /** @type {AreaPortals} Area portal connectivity manager */ - areaPortals = new AreaPortals(); - - /** @type {Record} Maps brush model names (e.g. "*1") to auto-assigned portal numbers */ - modelPortalMap = {}; - - /** @type {FogVolumeInfo[]} Fog volume brush entities parsed from the BSP entity lump */ - fogVolumes = []; - - /** @type {number[]|null} Leaf brushes, useful for PHS/PVS (optional) */ - leafbrushes = null; - - /** @type {BrushSide[]|null} Brush sides (optional) */ - brushsides = null; - - /** @type {Brush[]|null} Brushes (optional) */ - brushes = null; - - /** @type {number} First brush index in the shared brushes array for this model */ - firstBrush = 0; - - /** @type {number} Number of brushes belonging to this model */ - numBrushes = 0; - - /** - * Whether this model has complete brush-based collision data. - * When true, Q2-style brush tracing can be used instead of Q1-style hull tracing. - * Requires brushes, brushsides, leafbrushes arrays plus nodes with leaf brush references. - * @returns {boolean} true if brush data is available for Q2-style tracing - */ - get hasBrushData() { - return this.brushes !== null - && this.brushsides !== null - && this.leafbrushes !== null - && this.brushes.length > 0; - } - - type = 0; // Mod.type.brush; - - *facesIter() { - for (let i = 0; i < this.numfaces; i++) { - yield this.faces[this.firstface + i]; - } - } - - /** - * Find the leaf node for a given point in 3D space - * @param {Vector} p position - * @returns {Node} leaf node containing the point - */ - getLeafForPoint(p) { - let node = this.nodes[0]; - - while (true) { - if (node.contents < 0) { - // reached a leaf - return node; - } - - /** @type {Vector} */ - const normal = node.plane.normal; - - if (p.dot(normal) - node.plane.dist > 0) { - node = node.children[0]; - } else { - node = node.children[1]; - } - } - } - - /** - * @param {Vector} point point in world - * @returns {Visibility} visibility data for the leaf containing the point - */ - getPvsByPoint(point) { - return this.getPvsByLeaf(this.getLeafForPoint(point)); - } - - /** - * @param {Node} leaf leaf node - * @returns {Visibility} visibility data for the given leaf - */ - getPvsByLeaf(leaf) { - if (leaf === this.leafs[0] || leaf.cluster < 0 || this.clusterPvsOffsets === null) { - return hiddenVisibility; - } - - return Visibility.fromBrushModel(this, this.clusterPvsOffsets[leaf.cluster]); - } - - /** - * This will merge visibility from all leafs from a given starting point. - * @param {Vector} point point in world - * @returns {Visibility} visibility data for the leaf containing the point - */ - getFatPvsByPoint(point) { - const vis = new Visibility(this); - - return vis.addFatPoint(point); - } - - /** - * Get PHS (Potentially Hearable Set) for a point in the world. - * @param {Vector} point point in world - * @returns {Visibility} PHS data for the leaf containing the point - */ - getPhsByPoint(point) { - return this.getPhsByLeaf(this.getLeafForPoint(point)); - } - - /** - * Get PHS (Potentially Hearable Set) for a given leaf. - * Returns a Visibility where isRevealed/areRevealed check hearability. - * @param {Node} leaf leaf node - * @returns {Visibility} PHS data for the given leaf - */ - getPhsByLeaf(leaf) { - if (this.phsdata === null || leaf === this.leafs[0] || leaf.cluster < 0 || this.clusterPhsOffsets === null) { - return hiddenVisibility; - } - - return Visibility.fromBrushModel(this, this.clusterPhsOffsets[leaf.cluster], this.phsdata); - } - - /** - * Will create a new sky renderer for this brush model, if supported. - * @returns {SkyRenderer|null} desired sky renderer - */ - newSkyRenderer() { - return null; - } - - /** - * Reset runtime-only face state for a scoped brush model view. - * Dynamic light bookkeeping is rebuilt per client frame and must not be - * inherited from an earlier scoped instance of the same cached map. - * @private - */ - _resetScopedFaces() { - for (let i = 0; i < this.faces.length; i++) { - this.faces[i].dlightbits = 0; - this.faces[i].dlightframe = -1; - } - } - - /** - * Reset renderer-owned world BSP runtime state before rebuilding display lists. - * This clears stale per-leaf command chains and restores original BSP bounds - * without cloning the full node graph per scoped model view. - */ - resetWorldRenderState() { - for (let i = 0; i < this.nodes.length; i++) { - this.nodes[i].resetRenderState(); - } - - for (let i = 0; i < this.leafs.length; i++) { - this.leafs[i].resetRenderState(); - } - - this._resetScopedFaces(); - } - - /** - * @returns {BrushModel} scoped runtime view - */ - createScopedView() { - const scopedView = /** @type {BrushModel} */ (super.createScopedView()); - - scopedView.cmds = null; - scopedView.chains = []; - scopedView.waterchain = 0; - scopedView.skychain = 0; - scopedView.opaqueVAO = null; - scopedView.turbulentVAO = null; - - scopedView._resetScopedFaces(); - - return scopedView; - } - - /** - */ - cleanupScopedView() { - super.cleanupScopedView(); - - const gl = this._getGLContext(); - - if (gl === null) { - return; - } - - if (this.cmds !== null) { - gl.deleteBuffer(this.cmds); - this.cmds = null; - } - - if (this.opaqueVAO) { - gl.deleteVertexArray(this.opaqueVAO); - this.opaqueVAO = null; - } - - if (this.turbulentVAO) { - gl.deleteVertexArray(this.turbulentVAO); - this.turbulentVAO = null; - } - } -}; +export * from './BSP.ts'; diff --git a/source/engine/common/model/BSP.ts b/source/engine/common/model/BSP.ts new file mode 100644 index 00000000..9f197e3f --- /dev/null +++ b/source/engine/common/model/BSP.ts @@ -0,0 +1,821 @@ +import type { BaseMaterial } from '../../client/renderer/Materials.mjs'; +import type Vector from '../../../shared/Vector.ts'; + +import { content } from '../../../shared/Defs.ts'; +import { BaseModel, type Face, type Plane } from './BaseModel.ts'; +import { SkyRenderer } from '../../client/renderer/Sky.mjs'; +import { AreaPortals, type PortalDefinition } from './AreaPortals.ts'; + +export interface Clipnode { + /** Index into planes array. */ + readonly planenum: number; + + /** Child node indices `[front, back]`. */ + readonly children: [number, number]; +} + +export interface Hull { + /** Clipnodes for this hull. */ + readonly clipnodes: Clipnode[]; + + /** Planes for collision detection. */ + readonly planes: Plane[]; + + /** Index of the first clipnode, when the hull is a subrange. */ + readonly firstclipnode?: number; + + /** Index of the last clipnode. */ + readonly lastclipnode: number; + + /** Minimum bounding box for this hull. */ + readonly clip_mins: Vector; + + /** Maximum bounding box for this hull. */ + readonly clip_maxs: Vector; +} + +export type WorldspawnInfo = Record; + +export interface FogVolumeInfo { + /** The inline brush model index from `*N` notation, or `0` for world water. */ + readonly modelIndex: number; + + /** Fog color as `[r, g, b]` in 0-255 range. */ + readonly color: [number, number, number]; + + /** Fog density for exponential falloff. */ + readonly density: number; + + /** Maximum fog opacity, clamped to `0..1`. */ + readonly maxOpacity: number; + + /** AABB minimum corner. */ + readonly mins: [number, number, number]; + + /** AABB maximum corner. */ + readonly maxs: [number, number, number]; +} + +export interface WorldTurbulentChainInfo { + /** Texture index used by the draw batch. */ + readonly texture: number; + + /** First vertex in the turbulent VBO region. */ + readonly firstVertex: number; + + /** Number of vertices in the draw batch. */ + readonly vertexCount: number; + + /** Tight world-space bounds minimum. */ + readonly mins: Vector; + + /** Tight world-space bounds maximum. */ + readonly maxs: Vector; +} + +export type BSPXLumps = Record; + +export type BrushTexVec = readonly [number, number, number, number]; + +export interface BrushTexInfo { + /** Texture projection vectors with offsets. */ + readonly vecs: [BrushTexVec, BrushTexVec]; + + /** Texture index or name, depending on the BSP format. */ + readonly texture: number | string; + + /** Material and surface flags. */ + readonly flags: number; + + /** Optional Quake 2 texture value field. */ + readonly value?: number; + + /** Optional Quake 2 linked texture-info index. */ + readonly nexttexinfo?: number; +} + +export interface LightgridStyleSample { + readonly stylenum: number; + readonly rgb: [number, number, number]; +} + +export interface LightgridPointSample { + readonly stylecount: number; + readonly styles: LightgridStyleSample[]; +} + +export interface LightgridLeaf { + readonly mins: [number, number, number]; + readonly size: [number, number, number]; + readonly points: LightgridPointSample[]; +} + +export interface LightgridNode { + readonly mid: [number, number, number]; + readonly child: number[]; +} + +export interface LightgridOctree { + readonly step: [number, number, number]; + readonly size: [number, number, number]; + readonly mins: Vector; + readonly numstyles: number; + readonly rootnode: number; + readonly nodes: LightgridNode[]; + readonly leafs: LightgridLeaf[]; +} + +type NodeChild = Node | number | null; + +const VISDATA_SIZE = 1024; + +/** + * Visibility data for PVS/PHS. + * Stored as cluster-indexed bits. Each bit corresponds to a cluster; + * leaf to cluster mapping is resolved via the owning BrushModel. + */ +export class Visibility { + #data = new Uint8Array(VISDATA_SIZE); + #model: BrushModel | null = null; + + /** When set, `isRevealed` and `areRevealed` always return true. */ + #unconditionalReveal = false; + + constructor(model: BrushModel | null = null) { + this.#model = model; + + if (model !== null) { + const clusterBytes = Math.max((model.numclusters + 7) >> 3, 1); + this.#data = new Uint8Array(clusterBytes); + + if (model.visdata === null) { + this.revealAll(); + } + } + } + + /** + * Create a Visibility instance from RLE-compressed cluster PVS data. + * @param model Map model. + * @param visofs Byte offset into `sourceData`. + * @param sourceData Compressed visibility data, defaulting to `model.visdata`. + * @returns Visibility instance. + */ + static fromBrushModel(model: BrushModel, visofs: number, sourceData: Uint8Array | null = model.visdata): Visibility { + console.assert(model instanceof BrushModel); + + const modelVisSize = (model.numclusters + 7) >> 3; + const visibility = new Visibility(model); + + if (sourceData !== null && visofs >= 0) { + for (let outIndex = 0, inIndex = visofs; outIndex < modelVisSize;) { + if (inIndex >= sourceData.length) { + break; + } + + if (sourceData[inIndex] !== 0) { + visibility.#data[outIndex++] = sourceData[inIndex++]; + continue; + } + + if (inIndex + 1 >= sourceData.length) { + break; + } + + for (let count = sourceData[inIndex + 1]; count > 0; count--) { + visibility.#data[outIndex++] = 0x00; + } + + inIndex += 2; + } + } + + return visibility; + } + + /** + * Reveal all clusters. + * @returns This visibility object. + */ + revealAll(): Visibility { + this.#data.fill(0xff); + this.#unconditionalReveal = true; + + return this; + } + + /** + * Hide all clusters. + * @returns This visibility object. + */ + hideAll(): Visibility { + this.#data.fill(0x00); + + return this; + } + + /** + * Recursive helper for `addFatPoint`. + * @param p Point in world space. + * @param node Current BSP node. + */ + #addToFatPoint(p: Vector, node: Node): void { + const model = this.#model; + + if (model === null) { + return; + } + + while (true) { + if (node.contents < 0) { + if (node.contents !== content.CONTENT_SOLID && node.cluster >= 0 && model.clusterPvsOffsets !== null) { + const visofs = model.clusterPvsOffsets[node.cluster]; + const vis = Visibility.fromBrushModel(model, visofs); + + for (let index = 0; index < this.#data.length; index++) { + this.#data[index] |= vis.#data[index]; + } + } + + return; + } + + const plane = node.plane as Plane; + const d = p.dot(plane.normal) - plane.dist; + + if (d > 8.0) { + node = node.children[0] as Node; + continue; + } + + if (d < -8.0) { + node = node.children[1] as Node; + continue; + } + + this.#addToFatPoint(p, node.children[0] as Node); + node = node.children[1] as Node; + } + } + + /** + * Merge visibility from all leafs connected to the point. + * @param p Point in world space. + * @returns This visibility object. + */ + addFatPoint(p: Vector): Visibility { + const model = this.#model; + + if (model !== null) { + this.#addToFatPoint(p, model.nodes[0] as Node); + } + + return this; + } + + /** + * Check whether any of the given leaf indices have visible clusters. + * @param leafIndices Leaf array indices (`Node.num` values). + * @returns True when any of the given leafs are revealed. + */ + areRevealed(leafIndices: number[]): boolean { + if (this.#unconditionalReveal) { + return leafIndices.length > 0; + } + + const model = this.#model; + + if (model === null) { + for (let index = 0; index < leafIndices.length; index++) { + if ((this.#data[leafIndices[index] >> 3] & (1 << (leafIndices[index] & 7))) !== 0) { + return true; + } + } + + return false; + } + + for (let index = 0; index < leafIndices.length; index++) { + const cluster = model.leafs[leafIndices[index]]?.cluster ?? -1; + + if (cluster < 0) { + continue; + } + + if ((this.#data[cluster >> 3] & (1 << (cluster & 7))) !== 0) { + return true; + } + } + + return false; + } + + /** + * Check whether a given leaf is revealed via its cluster. + * @param leafIndex Leaf array index (`Node.num`). + * @returns True when the given leaf is revealed. + */ + isRevealed(leafIndex: number): boolean { + if (this.#unconditionalReveal) { + return true; + } + + const model = this.#model; + + if (model === null) { + return (this.#data[leafIndex >> 3] & (1 << (leafIndex & 7))) !== 0; + } + + const cluster = model.leafs[leafIndex]?.cluster ?? -1; + + if (cluster < 0) { + return false; + } + + return (this.#data[cluster >> 3] & (1 << (cluster & 7))) !== 0; + } +} + +export const revealedVisibility = new Visibility().revealAll(); +export const hiddenVisibility = new Visibility().hideAll(); + +export class BrushModelComponent { + /** Owning brush model. */ + protected _brushmodel: BrushModel; + + constructor(brushmodel: BrushModel) { + this._brushmodel = brushmodel; + } +} + +/** + * BSP tree node, also reused for BSP leafs. + */ +export class Node extends BrushModelComponent { + /** Node index in the nodes array. */ + num = 0; + + contents = 0; + + /** Index into planes array. */ + planenum = 0; + + /** Splitting plane. */ + plane: Plane | null = null; + + /** Parent node. */ + parent: Node | null = null; + + /** Frontside/backside, numbers during loading and `Node` refs after linking. */ + children: [NodeChild, NodeChild] = [null, null]; + + /** Visibility offset for PVS. */ + visofs = 0; + + /** Minimum bounding box. */ + mins: Vector | null = null; + + /** Maximum bounding box. */ + maxs: Vector | null = null; + + /** Immutable bounds loaded from BSP data. */ + baseMins: Vector | null = null; + + /** Immutable bounds loaded from BSP data. */ + baseMaxs: Vector | null = null; + + /** First marksurface index for leafs, aka `firstleafface`. */ + firstmarksurface = 0; + + /** Number of marksurfaces for leafs, aka `numleaffaces`. */ + nummarksurfaces = 0; + + /** First face index for nodes. */ + firstface = 0; + + /** Number of faces for nodes. */ + numfaces = 0; + + /** Ambient sound levels `[water, sky, slime, lava]`. */ + ambient_level: [number, number, number, number] = [0, 0, 0, 0]; + + /** Used by the renderer to determine what to draw. */ + markvisframe = 0; + + /** Used by the renderer to determine what to draw. */ + visframe = 0; + + /** Index into skychain list. */ + skychain = 0; + + /** Index into waterchain list. */ + waterchain = 0; + + /** Render command list. */ + cmds: number[][] = []; + + /** Tight turbulent draw batches for sorted world rendering. */ + turbulentChains: WorldTurbulentChainInfo[] = []; + + /** Cluster for PVS. */ + cluster = 0; + + /** Area id for area portals. */ + area = 0; + + /** First leaf brush index. */ + firstleafbrush = 0; + + /** Number of leaf brushes. */ + numleafbrushes = 0; + + *facesIter(): Generator { + for (let index = 0; index < this.numfaces; index++) { + yield this._brushmodel.faces[this.firstface + index] as Face; + } + } + + /** + * Reset renderer-owned runtime state on this BSP node or leaf. + * Leaf bounds may be expanded during display-list packing, so restore them + * from the immutable BSP bounds before rebuilding world render chains. + */ + resetRenderState(): void { + this.markvisframe = 0; + this.visframe = 0; + this.skychain = 0; + this.waterchain = 0; + this.cmds.length = 0; + this.turbulentChains.length = 0; + + if (this.mins !== null && this.baseMins !== null) { + this.mins.set(this.baseMins); + } + + if (this.maxs !== null && this.baseMaxs !== null) { + this.maxs.set(this.baseMaxs); + } + } +} + +export class BrushSide extends BrushModelComponent { + /** Plane index, facing leaf outwards. */ + planenum = 0; + + /** Texture info index. */ + texinfo = 0; +} + +export class Brush extends BrushModelComponent { + /** First brush side index. */ + firstside = 0; + + /** Number of brush sides. */ + numsides = 0; + + /** Contents flags, see `Defs.content`. */ + contents = 0; + + /** Axis-aligned bounding box minimum. */ + mins: Vector | null = null; + + /** Axis-aligned bounding box maximum. */ + maxs: Vector | null = null; + + /** BrushTrace dedup counter to avoid testing the same brush twice. */ + _brushTraceCheck = 0; + + *sidesIter(): Generator { + for (let index = 0; index < this.numsides; index++) { + yield this._brushmodel.brushsides![this.firstside + index] as BrushSide; + } + } +} + +/** + * Base class for brush-based models (BSP maps). + * All loading is handled by `BSP29Loader.mjs`. + */ +export class BrushModel extends BaseModel { + /** BSP format version. */ + version: number | null = null; + + /** Bounding radius for culling. */ + radius = 0; + + /** All planes in the BSP tree. */ + planes: Plane[] = []; + + /** All visible faces and surfaces. */ + faces: Face[] = []; + + /** All vertex positions. */ + vertexes: Vector[] = []; + + /** Edge vertex indices `[v1, v2]`. */ + edges: number[][] = []; + + /** Surface edge list, negative indices mean reversed winding. */ + surfedges: number[] = []; + + /** BSP tree nodes. */ + nodes: Node[] = []; + + /** BSP leaf nodes. */ + leafs: Node[] = []; + + /** Texture materials. */ + textures: BaseMaterial[] = []; + + /** Texture coordinate info per face. */ + texinfo: BrushTexInfo[] = []; + + /** Face indices visible from each leaf. */ + marksurfaces: number[] = []; + + /** Grayscale lightmap data. */ + lightdata: Uint8Array | null = null; + + /** RGB lightmap data. */ + lightdata_rgb: Uint8Array | null = null; + + /** Deluxemap data storing dominant light directions. */ + deluxemap: Uint8Array | null = null; + + /** Lightgrid octree data. */ + lightgrid: LightgridOctree | null = null; + + /** Visibility data for PVS. */ + visdata: Uint8Array | null = null; + + /** Clipnodes for collision detection. */ + clipnodes: Clipnode[] = []; + + /** Collision hulls for physics. */ + hulls: Hull[] = []; + + /** Inline brush submodels. */ + submodels: BrushModel[] = []; + + /** First face index for this submodel. */ + firstface = 0; + + /** Number of faces in this submodel. */ + numfaces = 0; + + /** Entity lump as a string. */ + entities: string | null = null; + + /** Parsed worldspawn entity properties. */ + worldspawnInfo: WorldspawnInfo = {}; + + /** Offset for BSPX extended data. */ + bspxoffset = 0; + + /** BSPX extended lumps. */ + bspxlumps: BSPXLumps | null = null; + + /** True when this is an inline submodel rather than the main world. */ + submodel = false; + + /** Rendering chains for optimized texture batching. */ + chains: number[][] = []; + + /** Offset into the vertex buffer for turbulent surfaces. */ + waterchain = 0; + + /** Offset into the vertex buffer for sky surfaces. */ + skychain = 0; + + /** True when RGB lighting is available. */ + coloredlights = false; + + /** Number of visibility clusters. */ + numclusters = 0; + + /** PVS byte offset per cluster into `visdata`. */ + clusterPvsOffsets: number[] | null = null; + + /** PHS data, cluster-indexed and RLE-compressed. */ + phsdata: Uint8Array | null = null; + + /** PHS byte offset per cluster into `phsdata`. */ + clusterPhsOffsets: number[] | null = null; + + /** Number of areas for area portals. */ + numAreas = 0; + + /** Area portal definitions. */ + portalDefs: PortalDefinition[] = []; + + /** Area portal connectivity manager. */ + areaPortals = new AreaPortals(); + + /** Maps brush model names such as `*1` to auto-assigned portal numbers. */ + modelPortalMap: Record = {}; + + /** Fog volume brush entities parsed from the BSP entity lump. */ + fogVolumes: FogVolumeInfo[] = []; + + /** Leaf brushes, when present. */ + leafbrushes: number[] | null = null; + + /** Brush sides, when present. */ + brushsides: BrushSide[] | null = null; + + /** Brushes, when present. */ + brushes: Brush[] | null = null; + + /** First brush index in the shared brushes array for this model. */ + firstBrush = 0; + + /** Number of brushes belonging to this model. */ + numBrushes = 0; + + /** Opaque world VAO created by the brush renderer. */ + opaqueVAO: WebGLVertexArrayObject | null = null; + + /** Turbulent world VAO created by the brush renderer. */ + turbulentVAO: WebGLVertexArrayObject | null = null; + + override type = 0; + + /** + * Whether this model has complete brush-based collision data. + * When true, Q2-style brush tracing can be used instead of Q1-style hull tracing. + * Requires brushes, brushsides, leafbrushes arrays plus nodes with leaf brush references. + * @returns True if brush data is available for Q2-style tracing. + */ + get hasBrushData(): boolean { + return this.brushes !== null + && this.brushsides !== null + && this.leafbrushes !== null + && this.brushes.length > 0; + } + + *facesIter(): Generator { + for (let index = 0; index < this.numfaces; index++) { + yield this.faces[this.firstface + index] as Face; + } + } + + /** + * Find the leaf node for a given point in 3D space. + * @param p Position. + * @returns The leaf node containing the point. + */ + getLeafForPoint(p: Vector): Node { + let node = this.nodes[0] as Node; + + while (true) { + if (node.contents < 0) { + return node; + } + + const plane = node.plane as Plane; + + if (p.dot(plane.normal) - plane.dist > 0) { + node = node.children[0] as Node; + } else { + node = node.children[1] as Node; + } + } + } + + /** + * @param point Point in world space. + * @returns Visibility data for the leaf containing the point. + */ + getPvsByPoint(point: Vector): Visibility { + return this.getPvsByLeaf(this.getLeafForPoint(point)); + } + + /** + * @param leaf Leaf node. + * @returns Visibility data for the given leaf. + */ + getPvsByLeaf(leaf: Node): Visibility { + if (leaf === this.leafs[0] || leaf.cluster < 0 || this.clusterPvsOffsets === null) { + return hiddenVisibility; + } + + return Visibility.fromBrushModel(this, this.clusterPvsOffsets[leaf.cluster]); + } + + /** + * Merge visibility from all leafs near the given starting point. + * @param point Point in world space. + * @returns Visibility data for the leaf containing the point. + */ + getFatPvsByPoint(point: Vector): Visibility { + const vis = new Visibility(this); + + return vis.addFatPoint(point); + } + + /** + * Get PHS for a point in the world. + * @param point Point in world space. + * @returns PHS data for the leaf containing the point. + */ + getPhsByPoint(point: Vector): Visibility { + return this.getPhsByLeaf(this.getLeafForPoint(point)); + } + + /** + * Get PHS for a given leaf. + * Returns a `Visibility` where `isRevealed` and `areRevealed` check hearability. + * @param leaf Leaf node. + * @returns PHS data for the given leaf. + */ + getPhsByLeaf(leaf: Node): Visibility { + if (this.phsdata === null || leaf === this.leafs[0] || leaf.cluster < 0 || this.clusterPhsOffsets === null) { + return hiddenVisibility; + } + + return Visibility.fromBrushModel(this, this.clusterPhsOffsets[leaf.cluster], this.phsdata); + } + + /** + * Create a new sky renderer for this brush model, if supported. + * @returns Desired sky renderer. + */ + newSkyRenderer(): SkyRenderer | null { + return null; + } + + /** + * Reset runtime-only face state for a scoped brush model view. + * Dynamic light bookkeeping is rebuilt per client frame and must not be + * inherited from an earlier scoped instance of the same cached map. + */ + _resetScopedFaces(): void { + for (let index = 0; index < this.faces.length; index++) { + this.faces[index].dlightbits = 0; + this.faces[index].dlightframe = -1; + } + } + + /** + * Reset renderer-owned world BSP runtime state before rebuilding display lists. + * This clears stale per-leaf command chains and restores original BSP bounds + * without cloning the full node graph per scoped model view. + */ + resetWorldRenderState(): void { + for (let index = 0; index < this.nodes.length; index++) { + this.nodes[index].resetRenderState(); + } + + for (let index = 0; index < this.leafs.length; index++) { + this.leafs[index].resetRenderState(); + } + + this._resetScopedFaces(); + } + + /** + * @returns Scoped runtime view. + */ + override createScopedView(): BrushModel { + const scopedView = super.createScopedView() as BrushModel; + + scopedView.cmds = null; + scopedView.chains = []; + scopedView.waterchain = 0; + scopedView.skychain = 0; + scopedView.opaqueVAO = null; + scopedView.turbulentVAO = null; + + scopedView._resetScopedFaces(); + + return scopedView; + } + + /** + * Release scoped GPU buffers and VAOs owned by this brush-model view. + */ + override cleanupScopedView(): void { + super.cleanupScopedView(); + + const gl = this._getGLContext(); + + if (gl === null) { + return; + } + + if (this.cmds !== null) { + gl.deleteBuffer(this.cmds); + this.cmds = null; + } + + if (this.opaqueVAO !== null) { + gl.deleteVertexArray(this.opaqueVAO); + this.opaqueVAO = null; + } + + if (this.turbulentVAO !== null) { + gl.deleteVertexArray(this.turbulentVAO); + this.turbulentVAO = null; + } + } +} diff --git a/source/engine/common/model/BaseModel.mjs b/source/engine/common/model/BaseModel.mjs deleted file mode 100644 index f56c99f2..00000000 --- a/source/engine/common/model/BaseModel.mjs +++ /dev/null @@ -1,151 +0,0 @@ -import GL from '../../client/GL.mjs'; -import { eventBus } from '../../registry.mjs'; -import Vector from '../../../shared/Vector.ts'; - -let gl = /** @type {WebGL2RenderingContext|null} */ (null); - -eventBus.subscribe('gl.ready', () => { - gl = GL.gl; -}); - -eventBus.subscribe('gl.shutdown', () => { - gl = null; -}); - -export class Plane { // TODO: move to shared - type = 0; - - /** @type {0|1|2|3|4|5|6|7} bits 1, 2 and 3 represent the normal’s components signess */ - signbits = 0; - - /** - * @param {Vector} normal normal vector - * @param {number} dist distance from origin - */ - constructor(normal, dist) { - /** @type {Vector} plane’s normal vector, on n-sided polygons it might not be facing correctly, better use Face’s normal instead. */ - this.normal = normal; - - /** @type {number} distance from origin along normal (in direction of normal) */ - this.dist = dist; - } -}; - -export class Face { - submodel = false; - plane = /** @type {Plane} */(null); // will be linked during loading - - /** @type {boolean} True when the face uses the back side of its BSP plane. */ - planeBack = false; - firstedge = 0; - numedges = 0; - texinfo = 0; - styles = /** @type {number[]} */([]); - lightofs = 0; - texture = 0; - texturemins = [0, 0]; - extents = [0, 0]; - - // lightmap scaling - lmshift = null; - - // TODO: refactor these fields into a flags bitmap, there’s more to come - turbulent = false; - sky = false; - - /** @type {Vector} Face normal oriented to the BSP face side. */ - normal = new Vector(); - - dlightbits = 0; - dlightframe = -1; -}; - -export class BaseModel { - static STATE = { - NOT_READY: 'not-ready', - LOADING: 'loading', - READY: 'ready', - FAILED: 'failed', - }; - - constructor(name) { - this.name = name; - this.type = null; - this.reset(); - } - - reset() { - // Private variables (used during loading) - - /** @type {number} Number of frames in file */ - this._num_frames = 0; - - /** @type {number} Number of skins in file */ - this._num_skins = 0; - - /** @type {number} Number of triangles (R requires that) */ - this._num_tris = 0; - - /** @type {number} Number of vertices */ - this._num_verts = 0; - - /** @type {Vector} Scale factors for vertices */ - this._scale = new Vector(1.0, 1.0, 1.0); - - /** @type {Vector} Origin offset for vertices */ - this._scale_origin = new Vector(); - - /** @type {boolean} FIXME: read but unused */ - this._random = false; - - // Public variables - - /** @type {boolean} Whether the file still needs loading */ - this.needload = true; - - /** @type {number} Simple CRC checksum to check if things are still the same */ - this.checksum = 0; - - /** @type {Vector} Bounding box minimum (required by PF, R, CL, SV on worldmodel) */ - this.mins = new Vector(); - - /** @type {Vector} Bounding box maximum (required by PF, R, CL, SV on worldmodel) */ - this.maxs = new Vector(); - - /** @type {Vector} Origin offset for vertices */ - this.origin = new Vector(); - - // Public variables just for rendering purposes (IDEA: refactor into ModelRenderer classes) - - /** @type {WebGLBuffer|null} WebGLBuffer for alias models, or null for brush/sprite models */ - this.cmds = null; - } - - /** - * Create a per-scope runtime view that reuses immutable model data. - * @returns {BaseModel} scoped runtime view - */ - createScopedView() { - const scopedView = /** @type {BaseModel} */ (Object.assign( - Object.create(Object.getPrototypeOf(this)), - this, - )); - - return scopedView; - } - - /** - * @protected - * @returns {WebGL2RenderingContext|null} current GL context, if available - */ - _getGLContext() { - return gl; - } - - /** - * Release runtime-only resources owned by a scoped model view. - */ - cleanupScopedView() { - // Base models do not assume ownership of shared GPU resources. - } -}; diff --git a/source/engine/common/model/BaseModel.ts b/source/engine/common/model/BaseModel.ts new file mode 100644 index 00000000..d6058ef4 --- /dev/null +++ b/source/engine/common/model/BaseModel.ts @@ -0,0 +1,207 @@ +import GL from '../../client/GL.mjs'; +import { eventBus } from '../../registry.mjs'; +import Vector from '../../../shared/Vector.ts'; + +let gl: WebGL2RenderingContext | null = null; + +eventBus.subscribe('gl.ready', () => { + gl = GL.gl; +}); + +eventBus.subscribe('gl.shutdown', () => { + gl = null; +}); + +export enum ModelState { + NOT_READY = 'not-ready', + LOADING = 'loading', + READY = 'ready', + FAILED = 'failed', +} + +export type PlaneSignBits = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; + +/** + * Shared plane representation used by BSP rendering and collision code. + */ +export class Plane { + type = 0; + + /** Bits 1, 2, and 3 encode the signs of the normal components. */ + signbits: PlaneSignBits = 0; + + /** Plane normal. Face normals are usually more reliable for winding-facing work. */ + normal: Vector; + + /** Distance from the origin measured along the plane normal. */ + dist: number; + + constructor(normal: Vector, dist: number) { + this.normal = normal; + this.dist = dist; + } +} + +/** + * Shared face metadata used by brush models and lightmap generation. + */ +export class Face { + /** True when this face belongs to an inline submodel. */ + submodel = false; + + /** BSP plane linked during loading. */ + plane: Plane | null = null; + + /** True when the face uses the back side of its BSP plane. */ + planeBack = false; + + /** First surfedge index for this face. */ + firstedge = 0; + + /** Number of surfedges referenced by this face. */ + numedges = 0; + + /** Texture info index. */ + texinfo = 0; + + /** Lightstyle ids used by this face. */ + styles: number[] = []; + + /** Offset into the baked light data. */ + lightofs = 0; + + /** Texture index used by the renderer. */ + texture = 0; + + /** Texture-space minimum extents. */ + texturemins: [number, number] = [0, 0]; + + /** Texture-space size extents. */ + extents: [number, number] = [0, 0]; + + /** Optional per-face lightmap downscale shift. */ + lmshift: number | null = null; + + /** True when this face uses turbulent warping. */ + turbulent = false; + + /** True when this face renders as sky. */ + sky = false; + + /** Face normal oriented to the BSP face side. */ + normal = new Vector(); + + /** Dynamic light bitmask affecting this face. */ + dlightbits = 0; + + /** Last frame index that updated the dynamic light bits. */ + dlightframe = -1; +} + +/** + * Base class for all model types. + * + * Holds common bounds, loading state, and runtime-only rendering resources + * shared by brush, sprite, alias, and mesh models. + */ +export class BaseModel { + static STATE = ModelState; + + /** Model name or load path. */ + name: string; + + /** Concrete model type, assigned by subclasses. */ + type: number | null; + + /** Number of frames in file. */ + _num_frames = 0; + + /** Number of skins in file. */ + _num_skins = 0; + + /** Number of triangles, also consumed by the renderer. */ + _num_tris = 0; + + /** Number of vertices in the loaded model. */ + _num_verts = 0; + + /** Scale factors applied to model vertices. */ + _scale: Vector | null = new Vector(1.0, 1.0, 1.0); + + /** Origin offset applied to model vertices. */ + _scale_origin: Vector | null = new Vector(); + + /** Randomization flag retained from legacy formats. */ + _random = false; + + /** True while the file still needs loading. */ + needload = true; + + /** Simple CRC checksum used to detect content changes. */ + checksum = 0; + + /** Bounding box minimum, required by gameplay and rendering code. */ + mins = new Vector(); + + /** Bounding box maximum, required by gameplay and rendering code. */ + maxs = new Vector(); + + /** Model-space origin offset. */ + origin = new Vector(); + + /** Shared alias-model command buffer, when applicable. */ + cmds: WebGLBuffer | null = null; + + constructor(name: string) { + this.name = name; + this.type = null; + this.reset(); + } + + reset(): void { + // Private variables used while loading. + this._num_frames = 0; + this._num_skins = 0; + this._num_tris = 0; + this._num_verts = 0; + this._scale = new Vector(1.0, 1.0, 1.0); + this._scale_origin = new Vector(); + this._random = false; + + // Public variables shared across model users. + this.needload = true; + this.checksum = 0; + this.mins = new Vector(); + this.maxs = new Vector(); + this.origin = new Vector(); + + // Runtime-only rendering state. + this.cmds = null; + } + + /** + * Creates a per-scope runtime view that reuses immutable model data. + * @returns A scoped model view that shares immutable backing data. + */ + createScopedView(): this { + return Object.assign( + Object.create(Object.getPrototypeOf(this)) as this, + this, + ); + } + + /** + * Returns the active GL context, if a client renderer is currently running. + * @returns The active WebGL context, or `null` when rendering is unavailable. + */ + protected _getGLContext(): WebGL2RenderingContext | null { + return gl; + } + + /** + * Releases runtime-only resources owned by a scoped model view. + */ + cleanupScopedView(): void { + // Base models do not assume ownership of shared GPU resources. + } +} diff --git a/source/engine/common/model/MeshModel.mjs b/source/engine/common/model/MeshModel.mjs deleted file mode 100644 index 9c2978d0..00000000 --- a/source/engine/common/model/MeshModel.mjs +++ /dev/null @@ -1,86 +0,0 @@ -import { BaseMaterial } from '../../client/renderer/Materials.mjs'; -import Vector from '../../../shared/Vector.ts'; -import { BaseModel } from './BaseModel.mjs'; - -/** - * Mesh model - Generic polygon mesh format for OBJ, IQM, GLTF, etc. - * Used for static and animated meshes with modern vertex attributes. - */ -export class MeshModel extends BaseModel { - constructor(name) { - super(name); - this.type = 3; // Mod.type.mesh - } - - // Vertex data (flat arrays ready for WebGL) - /** @type {Float32Array|null} */ vertices = null; // Positions (x,y,z triplets) - /** @type {Float32Array|null} */ normals = null; // Normals (x,y,z triplets) - /** @type {Float32Array|null} */ texcoords = null; // UVs (u,v pairs) - /** @type {Float32Array|null} */ tangents = null; // Tangents for normal mapping (x,y,z triplets) - /** @type {Float32Array|null} */ bitangents = null; // Bitangents for normal mapping (x,y,z triplets) - - // Geometry - /** @type {Uint16Array|Uint32Array|null} */ indices = null; // Triangle indices - /** @type {number} */ numVertices = 0; - /** @type {number} */ numTriangles = 0; - - // Materials & textures - /** @type {string} */ materialName = ''; // Material library reference - /** @type {string} */ textureName = ''; // Diffuse texture path - /** @type {BaseMaterial|null} */ texture = null; // Loaded texture object - - // GPU buffers (created by renderer) - /** @type {WebGLBuffer|null} */ vbo = null; // Vertex buffer object - /** @type {WebGLBuffer|null} */ ibo = null; // Index buffer object - - // Bounding volume - /** @type {Vector} */ mins = new Vector(-16, -16, -16); - /** @type {Vector} */ maxs = new Vector(16, 16, 16); - /** @type {number} */ boundingradius = 16.0; - - // Animation support (for future IQM/GLTF) - /** @type {boolean} */ animated = false; - /** @type {Array} */ animations = []; - /** @type {Array} */ bones = []; - - // Submesh support (multiple materials) - /** @type {Array} */ submeshes = []; - - /** - * @returns {MeshModel} scoped runtime view - */ - createScopedView() { - const scopedView = /** @type {MeshModel} */ (super.createScopedView()); - - scopedView.vbo = null; - scopedView.ibo = null; - scopedView.vao = null; - - return scopedView; - } - - cleanupScopedView() { - super.cleanupScopedView(); - - const gl = this._getGLContext(); - - if (gl === null) { - return; - } - - if (this.vao) { - gl.deleteVertexArray(this.vao); - this.vao = null; - } - - if (this.vbo !== null) { - gl.deleteBuffer(this.vbo); - this.vbo = null; - } - - if (this.ibo !== null) { - gl.deleteBuffer(this.ibo); - this.ibo = null; - } - } -} diff --git a/source/engine/common/model/MeshModel.ts b/source/engine/common/model/MeshModel.ts new file mode 100644 index 00000000..6fb58cb3 --- /dev/null +++ b/source/engine/common/model/MeshModel.ts @@ -0,0 +1,132 @@ +import type { BaseMaterial } from '../../client/renderer/Materials.mjs'; + +import Vector from '../../../shared/Vector.ts'; +import { BaseModel } from './BaseModel.ts'; + +interface MeshAnimation { + readonly name?: string; + readonly firstFrame?: number; + readonly frameCount?: number; + readonly fps?: number; +} + +interface MeshBone { + readonly name?: string; + readonly parentIndex?: number; +} + +interface MeshSubmesh { + readonly startIndex?: number; + readonly indexCount?: number; + readonly materialName?: string; +} + +/** + * Mesh model, a generic polygon mesh format for OBJ, IQM, glTF, and similar assets. + * Used for static and animated meshes with modern vertex attributes. + */ +export class MeshModel extends BaseModel { + /** Position buffer stored as flat `x, y, z` triplets. */ + vertices: Float32Array | null = null; + + /** Normal buffer stored as flat `x, y, z` triplets. */ + normals: Float32Array | null = null; + + /** Texture coordinate buffer stored as flat `u, v` pairs. */ + texcoords: Float32Array | null = null; + + /** Tangent buffer used for normal mapping. */ + tangents: Float32Array | null = null; + + /** Bitangent buffer used for normal mapping. */ + bitangents: Float32Array | null = null; + + /** Triangle index buffer. */ + indices: Uint16Array | Uint32Array | null = null; + + /** Number of vertices in the mesh. */ + numVertices = 0; + + /** Number of triangles in the mesh. */ + numTriangles = 0; + + /** Material library reference. */ + materialName = ''; + + /** Diffuse texture path or asset name. */ + textureName = ''; + + /** Loaded material instance used by the renderer. */ + texture: BaseMaterial | null = null; + + /** Vertex buffer object created by the renderer. */ + vbo: WebGLBuffer | null = null; + + /** Index buffer object created by the renderer. */ + ibo: WebGLBuffer | null = null; + + /** Vertex array object created by the renderer. */ + vao: WebGLVertexArrayObject | null = null; + + /** Conservative bounding box minimum. */ + override mins = new Vector(-16, -16, -16); + + /** Conservative bounding box maximum. */ + override maxs = new Vector(16, 16, 16); + + /** Bounding sphere radius. */ + boundingradius = 16.0; + + /** True when the mesh carries skeletal or keyframed animation data. */ + animated = false; + + /** Animation clips for future IQM or glTF support. */ + animations: MeshAnimation[] = []; + + /** Skeleton or bind-pose bone metadata. */ + bones: MeshBone[] = []; + + /** Optional submesh partitions for multi-material meshes. */ + submeshes: MeshSubmesh[] = []; + + constructor(name: string) { + super(name); + this.type = 3; + } + + override createScopedView(): MeshModel { + const scopedView = super.createScopedView() as MeshModel; + + // Scoped views must not own shared GPU objects from the source model. + scopedView.vbo = null; + scopedView.ibo = null; + scopedView.vao = null; + + return scopedView; + } + + override cleanupScopedView(): void { + super.cleanupScopedView(); + + const gl = this._getGLContext(); + + if (gl === null) { + return; + } + + if (this.vao !== null) { + gl.deleteVertexArray(this.vao); + this.vao = null; + } + + if (this.vbo !== null) { + gl.deleteBuffer(this.vbo); + this.vbo = null; + } + + if (this.ibo !== null) { + gl.deleteBuffer(this.ibo); + this.ibo = null; + } + } +} diff --git a/source/engine/common/model/ModelLoader.mjs b/source/engine/common/model/ModelLoader.mjs deleted file mode 100644 index a468201b..00000000 --- a/source/engine/common/model/ModelLoader.mjs +++ /dev/null @@ -1,67 +0,0 @@ -import { NotImplementedError } from '../Errors.ts'; - -/** - * Abstract base class for model format loaders. - * Each format (BSP, MDL, SPR, OBJ, IQM, etc.) should implement this interface. - */ -export class ModelLoader { - // eslint-disable-next-line jsdoc/require-returns-check - /** - * Get the magic number(s) that identify this format. - * Magic numbers are read from the first 4 bytes of the file. - * @returns {number[]} Array of magic numbers (uint32, little-endian) - */ - getMagicNumbers() { - throw new NotImplementedError('ModelLoader.getMagicNumbers must be implemented'); - } - - // eslint-disable-next-line jsdoc/require-returns-check - /** - * Get the file extension(s) this loader supports. - * Used as a fallback when magic number detection isn't conclusive. - * @returns {string[]} Array of file extensions (e.g., ['.bsp', '.mdl']) - */ - getExtensions() { - throw new NotImplementedError('ModelLoader.getExtensions must be implemented'); - } - - /** - * Check if this loader can handle the given buffer/filename. - * Default implementation checks magic number and extension. - * @param {ArrayBuffer} buffer The file buffer to check - * @param {string} filename The filename (for extension checking) - * @returns {boolean} True if this loader can handle the file - */ - canLoad(buffer, filename) { - const view = new DataView(buffer); - const magic = view.getUint32(0, true); // little-endian - const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase(); - - // Check magic number - if (this.getExtensions().includes(ext) && this.getMagicNumbers().includes(magic)) { - return true; - } - - return false; - } - - /** - * Load the model from the buffer. - * @param {ArrayBuffer} buffer The file buffer - * @param {string} name The model name/path - * @returns {Promise} The loaded model - */ - // eslint-disable-next-line @typescript-eslint/require-await - async load(buffer, name) { // eslint-disable-line no-unused-vars - throw new NotImplementedError('ModelLoader.load must be implemented'); - } - - // eslint-disable-next-line jsdoc/require-returns-check - /** - * Get a human-readable name for this loader. - * @returns {string} Loader name (e.g., "BSP29", "Quake MDL", "Sprite") - */ - getName() { - throw new NotImplementedError('ModelLoader.getName must be implemented'); - } -} diff --git a/source/engine/common/model/ModelLoader.ts b/source/engine/common/model/ModelLoader.ts new file mode 100644 index 00000000..9029d7f1 --- /dev/null +++ b/source/engine/common/model/ModelLoader.ts @@ -0,0 +1,44 @@ +import type { BaseModel } from './BaseModel.ts'; + +/** + * Abstract base class for model format loaders. + * + * Each model format provides magic-number detection and a loader that returns + * a populated model instance. + */ +export abstract class ModelLoader { + /** + * Returns the magic numbers that identify this format. + * Magic numbers are read from the first four bytes of the file. + */ + abstract getMagicNumbers(): number[]; + + /** + * Returns the file extensions supported by this loader. + */ + abstract getExtensions(): string[]; + + /** + * Returns a human-readable loader name. + */ + abstract getName(): string; + + /** + * Checks whether this loader can handle the given file. + * + * The default implementation requires both a matching extension and a + * matching magic number. + */ + canLoad(buffer: ArrayBuffer, filename: string): boolean { + const view = new DataView(buffer); + const magic = view.getUint32(0, true); + const extension = filename.substring(filename.lastIndexOf('.')).toLowerCase(); + + return this.getExtensions().includes(extension) && this.getMagicNumbers().includes(magic); + } + + /** + * Loads a model from the supplied file buffer. + */ + abstract load(buffer: ArrayBuffer, name: string): Promise; +} diff --git a/source/engine/common/model/ModelLoaderRegistry.mjs b/source/engine/common/model/ModelLoaderRegistry.mjs deleted file mode 100644 index 68ee379e..00000000 --- a/source/engine/common/model/ModelLoaderRegistry.mjs +++ /dev/null @@ -1,62 +0,0 @@ -import { NotImplementedError } from '../Errors.ts'; -import { BaseModel } from './BaseModel.mjs'; -import { ModelLoader } from './ModelLoader.mjs'; - -/** - * Registry for managing model format loaders. - * Provides automatic format detection and routing to the appropriate loader. - */ -export class ModelLoaderRegistry { - constructor() { - /** @type {ModelLoader[]} */ - this.loaders = []; - } - - /** - * Register a model loader. - * Loaders are checked in the order they are registered. - * @param {ModelLoader} loader The loader to register - */ - register(loader) { - this.loaders.push(loader); - } - - /** - * Find a loader that can handle the given buffer/filename. - * @param {ArrayBuffer} buffer The file buffer - * @param {string} filename The filename - * @returns {ModelLoader|null} The loader, or null if none found - */ - findLoader(buffer, filename) { - for (const loader of this.loaders) { - if (loader.canLoad(buffer, filename)) { - return loader; - } - } - return null; - } - - /** - * Load a model using the appropriate loader. - * @param {ArrayBuffer} buffer The file buffer - * @param {string} name The model name/path - * @returns {Promise} The loaded model - * @throws {NotImplementedError} If no suitable loader is found - */ - async load(buffer, name) { - const loader = this.findLoader(buffer, name); - - if (!loader) { - throw new NotImplementedError(`No loader found for model format: ${name}`); - } - - return await loader.load(buffer, name); - } - - /** - * Clear all registered loaders. - */ - clear() { - this.loaders.length = 0; - } -}; diff --git a/source/engine/common/model/ModelLoaderRegistry.ts b/source/engine/common/model/ModelLoaderRegistry.ts new file mode 100644 index 00000000..c47f98fb --- /dev/null +++ b/source/engine/common/model/ModelLoaderRegistry.ts @@ -0,0 +1,53 @@ +import { NotImplementedError } from '../Errors.ts'; +import type { BaseModel } from './BaseModel.ts'; +import type { ModelLoader } from './ModelLoader.ts'; + +/** + * Registry for managing model format loaders. + * + * Loaders are checked in registration order so more specific formats can win + * before broader fallbacks. + */ +export class ModelLoaderRegistry { + readonly loaders: ModelLoader[] = []; + + /** + * Registers a model loader. + */ + register(loader: ModelLoader): void { + this.loaders.push(loader); + } + + /** + * Finds a loader that can handle the given file. + */ + findLoader(buffer: ArrayBuffer, filename: string): ModelLoader | null { + for (const loader of this.loaders) { + if (loader.canLoad(buffer, filename)) { + return loader; + } + } + + return null; + } + + /** + * Loads a model using the first compatible registered loader. + */ + async load(buffer: ArrayBuffer, name: string): Promise { + const loader = this.findLoader(buffer, name); + + if (loader === null) { + throw new NotImplementedError(`No loader found for model format: ${name}`); + } + + return await loader.load(buffer, name); + } + + /** + * Clears the registry. + */ + clear(): void { + this.loaders.length = 0; + } +} diff --git a/source/engine/common/model/SpriteModel.mjs b/source/engine/common/model/SpriteModel.mjs deleted file mode 100644 index 28d1d970..00000000 --- a/source/engine/common/model/SpriteModel.mjs +++ /dev/null @@ -1,40 +0,0 @@ -import { BaseModel } from './BaseModel.mjs'; - -/** - * Sprite model (.spr) - Quake's 2D billboard sprite format. - * Used for explosions, particles, and other effects that always face the camera. - */ -export class SpriteModel extends BaseModel { - constructor(name) { - super(name); - this.type = 1; // Mod.type.sprite - } - - reset() { - super.reset(); - - /** @type {boolean} Whether sprite orientation is fixed or faces camera */ - this.oriented = false; - - /** @type {number} Bounding sphere radius */ - this.boundingradius = 0; - - /** @type {number} Sprite width */ - this.width = 0; - - /** @type {number} Sprite height */ - this.height = 0; - - /** @type {number} Number of frames in file (used during loading) */ - this._frames = 0; - - /** @type {Array} Sprite frames (single or groups) */ - this.frames = []; - - /** @type {boolean} Random frame selection */ - this.random = false; - - /** @type {number} Total number of frames */ - this.numframes = 0; - } -} diff --git a/source/engine/common/model/SpriteModel.ts b/source/engine/common/model/SpriteModel.ts new file mode 100644 index 00000000..3552ad66 --- /dev/null +++ b/source/engine/common/model/SpriteModel.ts @@ -0,0 +1,75 @@ +import type { GLTexture } from '../../client/GL.mjs'; + +import { BaseModel } from './BaseModel.ts'; + +interface SpriteFrameImage { + readonly interval?: number; + readonly origin: [number, number]; + readonly width: number; + readonly height: number; + readonly glt: GLTexture; + readonly texturenum: number; +} + +interface SpriteSingleFrame { + readonly group: false; + readonly origin: [number, number]; + readonly width: number; + readonly height: number; + readonly glt: GLTexture; + readonly texturenum: number; +} + +interface SpriteFrameGroup { + readonly group: true; + readonly frames: SpriteFrameImage[]; +} + +export type SpriteFrame = SpriteSingleFrame | SpriteFrameGroup; + +/** + * Sprite model (.spr), Quake's 2D billboard sprite format. + * Used for explosions, particles, and other effects that always face the camera. + */ +export class SpriteModel extends BaseModel { + /** Whether sprite orientation is fixed or faces the camera. */ + oriented = false; + + /** Bounding sphere radius. */ + boundingradius = 0; + + /** Sprite width. */ + width = 0; + + /** Sprite height. */ + height = 0; + + /** Number of frames in file, used during loading. */ + _frames = 0; + + /** Sprite frames, stored as single images or grouped animations. */ + frames: SpriteFrame[] = []; + + /** True when frame selection should be randomized. */ + random = false; + + /** Total number of frames. */ + numframes = 0; + + constructor(name: string) { + super(name); + this.type = 1; + } + + override reset(): void { + super.reset(); + this.oriented = false; + this.boundingradius = 0; + this.width = 0; + this.height = 0; + this._frames = 0; + this.frames = []; + this.random = false; + this.numframes = 0; + } +} diff --git a/source/engine/common/model/loaders/AliasMDLLoader.mjs b/source/engine/common/model/loaders/AliasMDLLoader.mjs index 50b389d3..b37dcc5e 100644 --- a/source/engine/common/model/loaders/AliasMDLLoader.mjs +++ b/source/engine/common/model/loaders/AliasMDLLoader.mjs @@ -4,8 +4,8 @@ import GL, { GLTexture, resampleTexture8 } from '../../../client/GL.mjs'; import W, { translateIndexToLuminanceRGBA, translateIndexToRGBA } from '../../W.ts'; import { CRC16CCITT } from '../../CRC.ts'; import { registry } from '../../../registry.mjs'; -import { ModelLoader } from '../ModelLoader.mjs'; -import { AliasModel } from '../AliasModel.mjs'; +import { ModelLoader } from '../ModelLoader.ts'; +import { AliasModel } from '../AliasModel.ts'; /** * Builds the diffuse and luminance skin layers for a legacy alias model skin. @@ -324,7 +324,7 @@ export class AliasMDLLoader extends ModelLoader { /** * Load ST (texture coordinate) vertices - * @param {import('../AliasModel.mjs').AliasModel} loadmodel - The model being loaded + * @param {import('../AliasModel.ts').AliasModel} loadmodel - The model being loaded * @param {ArrayBuffer} buffer - The model file data * @param {number} inmodel - Current offset in buffer * @returns {number} New offset after reading vertices @@ -348,7 +348,7 @@ export class AliasMDLLoader extends ModelLoader { /** * Load triangles - * @param {import('../AliasModel.mjs').AliasModel} loadmodel - The model being loaded + * @param {import('../AliasModel.ts').AliasModel} loadmodel - The model being loaded * @param {ArrayBuffer} buffer - The model file data * @param {number} inmodel - Current offset in buffer * @returns {number} New offset after reading triangles @@ -375,7 +375,7 @@ export class AliasMDLLoader extends ModelLoader { /** * Flood fill skin to handle transparent areas - * @param {import('../AliasModel.mjs').AliasModel} loadmodel - The model being loaded + * @param {import('../AliasModel.ts').AliasModel} loadmodel - The model being loaded * @param {Uint8Array} skin - The skin pixel data * @private */ @@ -415,7 +415,7 @@ export class AliasMDLLoader extends ModelLoader { /** * Translate player skin for color customization - * @param {import('../AliasModel.mjs').AliasModel} loadmodel - The model being loaded + * @param {import('../AliasModel.ts').AliasModel} loadmodel - The model being loaded * @param {Uint8Array} data - The original skin data * @param {*} skin - The skin object to store the result * @private @@ -447,7 +447,7 @@ export class AliasMDLLoader extends ModelLoader { /** * Load all skins (textures) for the model - * @param {import('../AliasModel.mjs').AliasModel} loadmodel - The model being loaded + * @param {import('../AliasModel.ts').AliasModel} loadmodel - The model being loaded * @param {ArrayBuffer} buffer - The model file data * @param {number} inmodel - Current offset in buffer * @returns {number} New offset after reading skins @@ -527,7 +527,7 @@ export class AliasMDLLoader extends ModelLoader { /** * Load all animation frames - * @param {import('../AliasModel.mjs').AliasModel} loadmodel - The model being loaded + * @param {import('../AliasModel.ts').AliasModel} loadmodel - The model being loaded * @param {ArrayBuffer} buffer - The model file data * @param {number} inmodel - Current offset in buffer * @private @@ -602,7 +602,7 @@ export class AliasMDLLoader extends ModelLoader { /** * Build rendering commands (WebGL buffers) for efficient rendering - * @param {import('../AliasModel.mjs').AliasModel} loadmodel - The model being loaded + * @param {import('../AliasModel.ts').AliasModel} loadmodel - The model being loaded * @private */ _buildRenderCommands(loadmodel) { diff --git a/source/engine/common/model/loaders/BSP29Loader.mjs b/source/engine/common/model/loaders/BSP29Loader.mjs index 4ed11168..b097c0f2 100644 --- a/source/engine/common/model/loaders/BSP29Loader.mjs +++ b/source/engine/common/model/loaders/BSP29Loader.mjs @@ -6,9 +6,9 @@ import W, { readWad3Texture, translateIndexToLuminanceRGBA, translateIndexToRGBA import { CRC16CCITT } from '../../CRC.ts'; import { CorruptedResourceError } from '../../Errors.ts'; import { eventBus, registry } from '../../../registry.mjs'; -import { ModelLoader } from '../ModelLoader.mjs'; +import { ModelLoader } from '../ModelLoader.ts'; import { Brush, BrushModel, BrushSide, Node } from '../BSP.mjs'; -import { Face, Plane } from '../BaseModel.mjs'; +import { Face, Plane } from '../BaseModel.ts'; import { materialFlags, noTextureMaterial, PBRMaterial, QuakeMaterial } from '../../../client/renderer/Materials.mjs'; import { Quake1Sky, SimpleSkyBox } from '../../../client/renderer/Sky.mjs'; diff --git a/source/engine/common/model/loaders/BSP2Loader.mjs b/source/engine/common/model/loaders/BSP2Loader.mjs index 32d56c13..c8357b2a 100644 --- a/source/engine/common/model/loaders/BSP2Loader.mjs +++ b/source/engine/common/model/loaders/BSP2Loader.mjs @@ -1,7 +1,7 @@ import Vector from '../../../../shared/Vector.ts'; import { CorruptedResourceError } from '../../Errors.ts'; import { BSP29Loader } from './BSP29Loader.mjs'; -import { Face } from '../BaseModel.mjs'; +import { Face } from '../BaseModel.ts'; import { BrushModel, Node } from '../BSP.mjs'; import { materialFlags } from '../../../client/renderer/Materials.mjs'; diff --git a/source/engine/common/model/loaders/BSP38Loader.mjs b/source/engine/common/model/loaders/BSP38Loader.mjs index 118a335d..2917307e 100644 --- a/source/engine/common/model/loaders/BSP38Loader.mjs +++ b/source/engine/common/model/loaders/BSP38Loader.mjs @@ -2,9 +2,9 @@ import { content } from '../../../../shared/Defs.ts'; import Q from '../../../../shared/Q.ts'; import Vector from '../../../../shared/Vector.ts'; import { CRC16CCITT } from '../../CRC.ts'; -import { Plane } from '../BaseModel.mjs'; +import { Plane } from '../BaseModel.ts'; import { Brush, BrushModel, BrushSide, Node } from '../BSP.mjs'; -import { ModelLoader } from '../ModelLoader.mjs'; +import { ModelLoader } from '../ModelLoader.ts'; /** @typedef {Record} LumpViews */ @@ -300,14 +300,13 @@ export class BSP38Loader extends ModelLoader { // int32 firstface, numfaces; // submodels just draw faces // // without walking the bsp tree - const stride = 48; - const length = modelsLump.byteLength / stride; + void modelsLump; loadmodel.submodels.length = 0; } - async load(buffer, name) { + load(buffer, name) { const loadmodel = new BrushModel(name); loadmodel.version = BSP_VERSION; @@ -325,9 +324,7 @@ export class BSP38Loader extends ModelLoader { loadmodel.needload = false; loadmodel.checksum = CRC16CCITT.Block(new Uint8Array(buffer)); - debugger; - - return loadmodel; + return Promise.resolve(loadmodel); } }; diff --git a/source/engine/common/model/loaders/SpriteSPRLoader.mjs b/source/engine/common/model/loaders/SpriteSPRLoader.mjs index d915bb0d..b7d58e44 100644 --- a/source/engine/common/model/loaders/SpriteSPRLoader.mjs +++ b/source/engine/common/model/loaders/SpriteSPRLoader.mjs @@ -3,8 +3,8 @@ import { GLTexture } from '../../../client/GL.mjs'; import W, { translateIndexToRGBA } from '../../W.ts'; import { CRC16CCITT } from '../../CRC.ts'; import { registry } from '../../../registry.mjs'; -import { ModelLoader } from '../ModelLoader.mjs'; -import { SpriteModel } from '../SpriteModel.mjs'; +import { ModelLoader } from '../ModelLoader.ts'; +import { SpriteModel } from '../SpriteModel.ts'; /** * Loader for Quake Sprite format (.spr) diff --git a/source/engine/common/model/loaders/WavefrontOBJLoader.mjs b/source/engine/common/model/loaders/WavefrontOBJLoader.mjs index 34fef9b0..d6841a98 100644 --- a/source/engine/common/model/loaders/WavefrontOBJLoader.mjs +++ b/source/engine/common/model/loaders/WavefrontOBJLoader.mjs @@ -1,8 +1,8 @@ import { registry } from '../../../registry.mjs'; import Vector from '../../../../shared/Vector.ts'; -import { ModelLoader } from '../ModelLoader.mjs'; -import { MeshModel } from '../MeshModel.mjs'; +import { ModelLoader } from '../ModelLoader.ts'; +import { MeshModel } from '../MeshModel.ts'; import { PBRMaterial } from '../../../client/renderer/Materials.mjs'; import { GLTexture } from '../../../client/GL.mjs'; @@ -330,7 +330,7 @@ export class WavefrontOBJLoader extends ModelLoader { /** * Calculate bounding box for the model * @protected - * @param {import('../MeshModel.mjs').MeshModel} model The model + * @param {import('../MeshModel.ts').MeshModel} model The model */ _calculateBounds(model) { if (!model.vertices || model.vertices.length === 0) { @@ -371,7 +371,7 @@ export class WavefrontOBJLoader extends ModelLoader { * Generate tangent and bitangent vectors for normal mapping * Based on "Lengyel's Method" for computing tangent space basis * @protected - * @param {import('../MeshModel.mjs').MeshModel} model The model + * @param {import('../MeshModel.ts').MeshModel} model The model */ _generateTangentSpace(model) { const numVerts = model.numVertices; diff --git a/source/engine/main-browser.mjs b/source/engine/main-browser.mjs index 876d5ee7..335318ce 100644 --- a/source/engine/main-browser.mjs +++ b/source/engine/main-browser.mjs @@ -8,7 +8,7 @@ import V from './client/V.mjs'; import NET from './network/Network.ts'; import SV from './server/Server.mjs'; import PR from './server/Progs.mjs'; -import Mod from './common/Mod.mjs'; +import Mod from './common/Mod.ts'; import Key from './client/Key.mjs'; import CL from './client/CL.mjs'; import S from './client/Sound.mjs'; diff --git a/source/engine/main-dedicated.mjs b/source/engine/main-dedicated.mjs index 75d2abfd..19be2500 100644 --- a/source/engine/main-dedicated.mjs +++ b/source/engine/main-dedicated.mjs @@ -16,7 +16,7 @@ import V from './client/V.mjs'; import NET from './network/Network.ts'; import SV from './server/Server.mjs'; import PR from './server/Progs.mjs'; -import Mod from './common/Mod.mjs'; +import Mod from './common/Mod.ts'; import * as WebSocket from 'ws'; export default class EngineLauncher { diff --git a/source/engine/registry.mjs b/source/engine/registry.mjs index 024ed0b0..1ccb4961 100644 --- a/source/engine/registry.mjs +++ b/source/engine/registry.mjs @@ -7,7 +7,7 @@ /** @typedef {typeof import('./network/Network').default} NetModule */ /** @typedef {typeof import('./server/Server.mjs').default} ServerModule */ /** @typedef {typeof import('./server/Progs.mjs').default} ProgsModule */ -/** @typedef {typeof import('./common/Mod.mjs').default} ModModule */ +/** @typedef {typeof import('./common/Mod.ts').default} ModModule */ /** @typedef {typeof import('./client/CL.mjs').default} ClientModule */ /** @typedef {typeof import('./client/SCR.mjs').default} ScrModule */ /** @typedef {typeof import('./client/R.mjs').default} RendererModule */ diff --git a/source/engine/server/Navigation.mjs b/source/engine/server/Navigation.mjs index 538d9a75..da222d81 100644 --- a/source/engine/server/Navigation.mjs +++ b/source/engine/server/Navigation.mjs @@ -7,9 +7,9 @@ import Cmd from '../common/Cmd.ts'; import Cvar from '../common/Cvar.ts'; import { CorruptedResourceError, MissingResourceError } from '../common/Errors.ts'; import { ServerEngineAPI } from '../common/GameAPIs.mjs'; -import { BrushModel } from '../common/Mod.mjs'; +import { BrushModel } from '../common/Mod.ts'; import { MIN_STEP_NORMAL, STEPSIZE } from '../common/Pmove.ts'; -import { Face } from '../common/model/BaseModel.mjs'; +import { Face } from '../common/model/BaseModel.ts'; import PlatformWorker from '../common/PlatformWorker.ts'; import WorkerManager from '../common/WorkerManager.ts'; import { eventBus, registry } from '../registry.mjs'; diff --git a/source/engine/server/Server.mjs b/source/engine/server/Server.mjs index 307cee0d..f342c912 100644 --- a/source/engine/server/Server.mjs +++ b/source/engine/server/Server.mjs @@ -17,7 +17,7 @@ import { ServerMovement } from './physics/ServerMovement.mjs'; import { ServerArea } from './physics/ServerArea.mjs'; import { ServerCollision } from './physics/ServerCollision.mjs'; import { sharedCollisionModelSource } from '../common/CollisionModelSource.mjs'; -import { BrushModel } from '../common/Mod.mjs'; +import { BrushModel } from '../common/Mod.ts'; import { ServerClient } from './Client.mjs'; let { COM, Con, Host, Mod, NET, PR } = registry; @@ -171,7 +171,7 @@ export default class SV { clientdataFieldsBitsWriter: null, /** @type {Record} maps classname to its fields and the apropriate bits writer */ clientEntityFields: {}, - /** @type {import('../common/Mod.mjs').BaseModel[]} */ + /** @type {import('../common/Mod.ts').BaseModel[]} */ models: [], /** @type {string[]} */ soundPrecache: [], diff --git a/source/engine/server/physics/ServerArea.mjs b/source/engine/server/physics/ServerArea.mjs index b905eed1..25e6d4eb 100644 --- a/source/engine/server/physics/ServerArea.mjs +++ b/source/engine/server/physics/ServerArea.mjs @@ -3,7 +3,7 @@ import * as Defs from '../../../shared/Defs.ts'; import { Octree } from '../../../shared/Octree.ts'; import { eventBus, registry } from '../../registry.mjs'; import CollisionModelSource, { createRegistryCollisionModelSource } from '../../common/CollisionModelSource.mjs'; -import { BrushModel } from '../../../engine/common/Mod.mjs'; +import { BrushModel } from '../../../engine/common/Mod.ts'; let { SV } = registry; diff --git a/source/engine/server/physics/ServerClientPhysics.mjs b/source/engine/server/physics/ServerClientPhysics.mjs index 4f9e2c02..f994716f 100644 --- a/source/engine/server/physics/ServerClientPhysics.mjs +++ b/source/engine/server/physics/ServerClientPhysics.mjs @@ -6,7 +6,7 @@ import { } from './Defs.mjs'; import { ServerClient } from '../Client.mjs'; import { PM_TYPE } from '../../common/Pmove.ts'; -import { BrushModel } from '../../common/Mod.mjs'; +import { BrushModel } from '../../common/Mod.ts'; let { Host, SV, V } = registry; diff --git a/source/engine/server/physics/ServerCollision.mjs b/source/engine/server/physics/ServerCollision.mjs index 5f39f8a7..29032c48 100644 --- a/source/engine/server/physics/ServerCollision.mjs +++ b/source/engine/server/physics/ServerCollision.mjs @@ -1,7 +1,7 @@ import Vector from '../../../shared/Vector.ts'; import * as Defs from '../../../shared/Defs.ts'; import CollisionModelSource, { createRegistryCollisionModelSource } from '../../common/CollisionModelSource.mjs'; -import Mod, { BrushModel } from '../../common/Mod.mjs'; +import Mod, { BrushModel } from '../../common/Mod.ts'; import { BrushTrace, DIST_EPSILON, Trace as SharedTrace } from '../../common/Pmove.ts'; import { eventBus, registry } from '../../registry.mjs'; import { @@ -628,7 +628,7 @@ export class ServerCollision { return null; } - const meshModel = /** @type {import('../../common/model/MeshModel.mjs').MeshModel} */ (model); + const meshModel = /** @type {import('../../common/model/MeshModel.ts').MeshModel} */ (model); if (!meshModel.indices || !meshModel.vertices || meshModel.numTriangles === 0) { return null; } diff --git a/source/engine/server/physics/ServerCollisionSupport.mjs b/source/engine/server/physics/ServerCollisionSupport.mjs index 3e07f4f9..f85704de 100644 --- a/source/engine/server/physics/ServerCollisionSupport.mjs +++ b/source/engine/server/physics/ServerCollisionSupport.mjs @@ -25,7 +25,7 @@ export class MeshCollisionState extends CollisionState { export class BrushCollisionState extends CollisionState { /** * @param {ServerEdict} ent entity being traced against - * @param {import('../../common/Mod.mjs').BrushModel} model brush collision model + * @param {import('../../common/Mod.ts').BrushModel} model brush collision model * @param {Vector} origin brush-model origin * @param {Vector} angles brush-model angles */ @@ -148,7 +148,7 @@ export class CollisionTrace { export class MeshTraceContext { /** @type {ServerEdict} */ ent; - /** @type {import('../../common/model/MeshModel.mjs').MeshModel} */ + /** @type {import('../../common/model/MeshModel.ts').MeshModel} */ model; /** @type {Vector} */ start; @@ -171,7 +171,7 @@ export class MeshTraceContext { /** * @param {ServerEdict} ent entity being traced against - * @param {import('../../common/model/MeshModel.mjs').MeshModel} model mesh collision model + * @param {import('../../common/model/MeshModel.ts').MeshModel} model mesh collision model * @param {Vector} start start position * @param {Vector} mins minimum extents of the moving box * @param {Vector} maxs maximum extents of the moving box diff --git a/source/engine/server/physics/ServerLegacyHullCollision.mjs b/source/engine/server/physics/ServerLegacyHullCollision.mjs index e54e7285..cb5d178f 100644 --- a/source/engine/server/physics/ServerLegacyHullCollision.mjs +++ b/source/engine/server/physics/ServerLegacyHullCollision.mjs @@ -10,7 +10,7 @@ eventBus.subscribe('registry.frozen', () => { }); /** @typedef {import('./ServerCollisionSupport.mjs').CollisionTrace} CollisionTrace */ -/** @typedef {import('../../common/Mod.mjs').BrushModel} BrushModel */ +/** @typedef {import('../../common/Mod.ts').BrushModel} BrushModel */ /** * Check whether a clipnode belongs to the owning legacy hull subtree. diff --git a/source/shared/GameInterfaces.ts b/source/shared/GameInterfaces.ts index 95921638..0dffafab 100644 --- a/source/shared/GameInterfaces.ts +++ b/source/shared/GameInterfaces.ts @@ -3,7 +3,7 @@ import type { ClientEngineAPI as ClientEngineApiValue, ServerEngineAPI as Server import type { ServerEdict as ServerEdictValue } from '../engine/server/Edict.mjs'; import type Vector from './Vector.ts'; import type { StartGameInterface } from '../engine/client/ClientLifecycle.mjs'; -import type { BaseModel } from '../engine/common/model/BaseModel.mjs'; +import type { BaseModel } from '../engine/common/model/BaseModel.ts'; export type ClientEngineAPI = Readonly; export type ServerEngineAPI = Readonly; diff --git a/test/common/model-cache.test.mjs b/test/common/model-cache.test.mjs index 9a3616c9..dcfd7672 100644 --- a/test/common/model-cache.test.mjs +++ b/test/common/model-cache.test.mjs @@ -1,14 +1,14 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import Mod from '../../source/engine/common/Mod.mjs'; -import { AliasModel } from '../../source/engine/common/model/AliasModel.mjs'; -import { Face } from '../../source/engine/common/model/BaseModel.mjs'; +import Mod from '../../source/engine/common/Mod.ts'; +import { AliasModel } from '../../source/engine/common/model/AliasModel.ts'; +import { Face } from '../../source/engine/common/model/BaseModel.ts'; import { BrushModel, Node } from '../../source/engine/common/model/BSP.mjs'; import { eventBus, registry } from '../../source/engine/registry.mjs'; import Vector from '../../source/shared/Vector.ts'; -/** @typedef {import('../../source/engine/common/model/BaseModel.mjs').BaseModel} BaseModel */ +/** @typedef {import('../../source/engine/common/model/BaseModel.ts').BaseModel} BaseModel */ /** * @param {BaseModel|null} model model to narrow @@ -180,8 +180,8 @@ function createSharedAliasModel() { return aliasModel; } -describe('Mod scoped model cache', () => { - test('separates client and server submodel instances while reusing shared BSP data', async () => { +void describe('Mod scoped model cache', () => { + void test('separates client and server submodel instances while reusing shared BSP data', async () => { await withModelRegistry(async () => { const { worldModel, submodel } = createSharedBrushModels(); @@ -225,7 +225,7 @@ describe('Mod scoped model cache', () => { }); }); - test('keeps bare submodel names scoped to their owning world per side', async () => { + void test('keeps bare submodel names scoped to their owning world per side', async () => { await withModelRegistry(async () => { const { worldModel: serverWorldShared } = createSharedBrushModelsForWorld('maps/server-test.bsp', 64); const { worldModel: clientWorldShared } = createSharedBrushModelsForWorld('maps/client-test.bsp', 256); @@ -244,7 +244,7 @@ describe('Mod scoped model cache', () => { }); }); - test('keeps shared alias vertex buffers visible to scoped views', async () => { + void test('keeps shared alias vertex buffers visible to scoped views', async () => { await withModelRegistry(async () => { const sharedAliasModel = createSharedAliasModel(); @@ -265,7 +265,7 @@ describe('Mod scoped model cache', () => { }); }); - test('resets shared brush leaf runtime state before rebuilding a scoped client view', async () => { + void test('resets shared brush leaf runtime state before rebuilding a scoped client view', async () => { await withModelRegistry(async () => { const { worldModel } = createSharedBrushModels(); @@ -317,7 +317,7 @@ describe('Mod scoped model cache', () => { }); }); - test('clears shared and scoped caches together when clearing shared scope', async () => { + void test('clears shared and scoped caches together when clearing shared scope', async () => { await withModelRegistry(async () => { const { worldModel } = createSharedBrushModels(); const sharedAliasModel = createSharedAliasModel(); diff --git a/test/physics/map-pmove-harness.mjs b/test/physics/map-pmove-harness.mjs index 93b1cf11..af7f2c26 100644 --- a/test/physics/map-pmove-harness.mjs +++ b/test/physics/map-pmove-harness.mjs @@ -2,7 +2,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import COMClass from '../../source/engine/common/Com.ts'; -import Mod from '../../source/engine/common/Mod.mjs'; +import Mod from '../../source/engine/common/Mod.ts'; import { PMF, Pmove } from '../../source/engine/common/Pmove.ts'; import { UserCmd } from '../../source/engine/network/Protocol.ts'; import { eventBus, registry } from '../../source/engine/registry.mjs'; @@ -14,14 +14,14 @@ import Vector from '../../source/shared/Vector.ts'; /** * @typedef {object} HarnessOptions - * @property {string} mapName - * @property {string} gameDir - * @property {string} spawnClassname - * @property {string} orientationTargetname - * @property {number} frames - * @property {number} msec - * @property {number} forwardmove - * @property {number} probeDistance + * @property {string} mapName BSP path relative to the selected game directory. + * @property {string} gameDir Game directory used to resolve the BSP and assets. + * @property {string} spawnClassname Spawn classname used to place the probe entity. + * @property {string} orientationTargetname Optional targetname used to derive the probe yaw. + * @property {number} frames Number of movement frames to simulate. + * @property {number} msec Frame duration in milliseconds for each simulated command. + * @property {number} forwardmove Forward movement command applied each frame. + * @property {number} probeDistance Distance used for optional forward probe traces. */ /** diff --git a/test/physics/pmove.test.mjs b/test/physics/pmove.test.mjs index 8e31a9e9..318b6070 100644 --- a/test/physics/pmove.test.mjs +++ b/test/physics/pmove.test.mjs @@ -3,7 +3,7 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import COMClass from '../../source/engine/common/Com.ts'; -import Mod from '../../source/engine/common/Mod.mjs'; +import Mod from '../../source/engine/common/Mod.ts'; import Vector from '../../source/shared/Vector.ts'; import { content } from '../../source/shared/Defs.ts'; import { DIST_EPSILON, PM_TYPE, PMF, Pmove, PmovePlayer, Trace } from '../../source/engine/common/Pmove.ts'; @@ -304,12 +304,12 @@ async function runMapForwardFrames(mapName, frames) { return runMapFrames({ mapName, frames }); } -describe('PmovePlayer', () => { - test('DEBUG is disabled before Pmove.Init()', () => { +void describe('PmovePlayer', () => { + void test('DEBUG is disabled before Pmove.Init()', () => { assert.equal(PmovePlayer.DEBUG, false); }); - test('move integrates one grounded movement frame against a world model', () => { + void test('move integrates one grounded movement frame against a world model', () => { const worldModel = createBrushWorldModel({ axis: 2, center: [0, 0, -40], halfExtents: [512, 512, 16] }); const pmove = new Pmove(); const player = pmove.newPlayerMove(); @@ -332,7 +332,7 @@ describe('PmovePlayer', () => { assert.ok(player.velocity[0] > 0); }); - test('move uses noclip-style spectator movement without collision traces', () => { + void test('move uses noclip-style spectator movement without collision traces', () => { const pmove = new Pmove(); const player = pmove.newPlayerMove(); @@ -363,7 +363,7 @@ describe('PmovePlayer', () => { assertNear(player.velocity[2], 41.5, 0.001); }); - test('keeps decisive uphill progress on the brush-backed slope regression map', async () => { + void test('keeps decisive uphill progress on the brush-backed slope regression map', async () => { const brushFrames = await runMapForwardFrames('maps/test_slope.bsp', 24); const firstRampFrame = 13; @@ -378,7 +378,7 @@ describe('PmovePlayer', () => { } }); - test('tracks the hull slope climb on the brush-backed slope regression map', async () => { + void test('tracks the hull slope climb on the brush-backed slope regression map', async () => { const brushFrames = await runMapForwardFrames('maps/test_slope.bsp', 24); const hullFrames = await runMapForwardFrames('maps/test_slope_hull.bsp', 24); @@ -390,7 +390,7 @@ describe('PmovePlayer', () => { } }); - test('matches the hull corner slide timing on the brush-backed clip_2 regression map', async () => { + void test('matches the hull corner slide timing on the brush-backed clip_2 regression map', async () => { const brushFrames = await runMapForwardFrames('maps/test_clip_2.bsp', 12); const hullFrames = await runMapForwardFrames('maps/test_clip_2_hull.bsp', 12); @@ -407,7 +407,7 @@ describe('PmovePlayer', () => { assert.ok(hullFrames[7].moved[1] < -1.0); }); - test('does not wedge on the hw_doom corridor sidestep repro', async () => { + void test('does not wedge on the hw_doom corridor sidestep repro', async () => { const frames = await runMapFrames({ mapName: 'maps/test_hw_doom.bsp', frames: 8, @@ -427,7 +427,7 @@ describe('PmovePlayer', () => { assert.ok(frames[7].moved[0] > 4.0); }); - test('tracks the hull slope climb on the brush-backed slope_2 regression map', async () => { + void test('tracks the hull slope climb on the brush-backed slope_2 regression map', async () => { const brushFrames = await runMapForwardFrames('maps/test_slope_2.bsp', 24); const hullFrames = await runMapForwardFrames('maps/test_slope_2_hull.bsp', 24); @@ -439,8 +439,8 @@ describe('PmovePlayer', () => { } }); - describe('_checkDuck', () => { - test('enters ducked state on grounded crouch input and stands when space is clear', () => { + void describe('_checkDuck', () => { + void test('enters ducked state on grounded crouch input and stands when space is clear', () => { const pmove = new Pmove(); const player = pmove.newPlayerMove(); @@ -464,8 +464,8 @@ describe('PmovePlayer', () => { }); }); - describe('_checkSpecialMovement', () => { - test('starts a waterjump when water waist depth meets a solid lip with empty space above', () => { + void describe('_checkSpecialMovement', () => { + void test('starts a waterjump when water waist depth meets a solid lip with empty space above', () => { const pmove = new Pmove(); const player = pmove.newPlayerMove(); @@ -503,8 +503,8 @@ describe('PmovePlayer', () => { }); }); - describe('_categorizePosition', () => { - test('keeps grounded state on a walkable slope while climbing quickly', () => { + void describe('_categorizePosition', () => { + void test('keeps grounded state on a walkable slope while climbing quickly', () => { const pmove = new Pmove(); const player = pmove.newPlayerMove(); @@ -530,7 +530,7 @@ describe('PmovePlayer', () => { assert.deepEqual(player.touchindices, [0]); }); - test('still drops ground while moving upward fast after leaving the floor', () => { + void test('still drops ground while moving upward fast after leaving the floor', () => { const pmove = new Pmove(); const player = pmove.newPlayerMove(); let traceCalls = 0; @@ -553,7 +553,7 @@ describe('PmovePlayer', () => { assert.equal(traceCalls, 0); }); - test('ignores a stale grounded flag while moving upward fast', () => { + void test('ignores a stale grounded flag while moving upward fast', () => { const pmove = new Pmove(); const player = pmove.newPlayerMove(); let traceCalls = 0; @@ -577,8 +577,8 @@ describe('PmovePlayer', () => { }); }); - describe('waterjump movement', () => { - test('applies gravity and clears waterjump once the upward boost turns downward', () => { + void describe('waterjump movement', () => { + void test('applies gravity and clears waterjump once the upward boost turns downward', () => { const pmove = new Pmove(); const player = pmove.newPlayerMove(); let stepSlideCalls = 0; @@ -610,8 +610,8 @@ describe('PmovePlayer', () => { }); }); - describe('_stepSlideMove', () => { - test('snaps down to a walkable slope after an unblocked slide', () => { + void describe('_stepSlideMove', () => { + void test('snaps down to a walkable slope after an unblocked slide', () => { const pmove = new Pmove(); const player = pmove.newPlayerMove(); let slideCalls = 0; @@ -650,7 +650,7 @@ describe('PmovePlayer', () => { assert.deepEqual(player.touchindices, [5]); }); - test('still evaluates the stepped retry after a blocked slide move', () => { + void test('still evaluates the stepped retry after a blocked slide move', () => { const pmove = new Pmove(); const player = pmove.newPlayerMove(); let slideCalls = 0; @@ -709,8 +709,8 @@ describe('PmovePlayer', () => { }); }); - describe('_slideMove', () => { - test('slides past a wall-to-clip seam and snap keeps the seam walkable', () => { + void describe('_slideMove', () => { + void test('slides past a wall-to-clip seam and snap keeps the seam walkable', () => { const pmove = new Pmove(); const player = pmove.newPlayerMove(); @@ -746,7 +746,7 @@ describe('PmovePlayer', () => { assert.deepEqual([...continuedTrace.endpos], [0, 144, 0]); }); - test('treats zero-progress near-parallel brush re-clips as a single slide plane', () => { + void test('treats zero-progress near-parallel brush re-clips as a single slide plane', () => { const pmove = new Pmove(); const player = pmove.newPlayerMove(); let traceCalls = 0; @@ -796,9 +796,9 @@ describe('PmovePlayer', () => { }); }); -describe('Pmove', () => { - describe('clipPlayerMove', () => { - test('keeps startsolid end positions in world space', () => { +void describe('Pmove', () => { + void describe('clipPlayerMove', () => { + void test('keeps startsolid end positions in world space', () => { const pmove = new Pmove(); pmove.addEntity(createPmoveBoxEntity({ @@ -816,7 +816,7 @@ describe('Pmove', () => { assert.deepEqual([...trace.endpos], [...start]); }); - test('reports hull hits in world coordinates', () => { + void test('reports hull hits in world coordinates', () => { const pmove = new Pmove(); pmove.addEntity(createPmoveBoxEntity({ @@ -834,7 +834,7 @@ describe('Pmove', () => { assertNear(trace.endpos[2], 0); }); - test('preserves later startsolid when an earlier equal-fraction clip already won', () => { + void test('preserves later startsolid when an earlier equal-fraction clip already won', () => { const pmove = new Pmove(); const start = new Vector(0, 0, 0); const end = new Vector(100, 0, 0); @@ -870,7 +870,7 @@ describe('Pmove', () => { assert.deepEqual([...trace.endpos], [...start]); }); - test('ands allsolid across equal-fraction startsolid physents', () => { + void test('ands allsolid across equal-fraction startsolid physents', () => { const pmove = new Pmove(); const start = new Vector(0, 0, 0); const end = new Vector(100, 0, 0); @@ -907,7 +907,7 @@ describe('Pmove', () => { }); }); - test('server-style smoke setup mirrors TestServerside assertions', () => { + void test('server-style smoke setup mirrors TestServerside assertions', () => { const worldModel = createLegacyWorldModel( new Vector(-256, -256, -128), new Vector(256, 256, 128), @@ -945,7 +945,7 @@ describe('Pmove', () => { assert.equal(playerMoveTraceHigher.fraction, 1.0); }); - test('traceStaticWorldPlayerMove traces world only and ignores dynamic physents', () => { + void test('traceStaticWorldPlayerMove traces world only and ignores dynamic physents', () => { const worldModel = createLegacyWorldModel( new Vector(-256, -256, -128), new Vector(256, 256, 128), @@ -969,7 +969,7 @@ describe('Pmove', () => { assertNear(aggregateTrace.endpos[0], 31.96875, 0.001); }); - test('brush-list world path supports server-style vertical smoke checks', () => { + void test('brush-list world path supports server-style vertical smoke checks', () => { const worldModel = createBrushWorldModel({ axis: 2, center: [0, 0, 144], halfExtents: [512, 512, 16] }); const pmove = new Pmove(); const entity = createPmoveBoxEntity({ @@ -997,8 +997,8 @@ describe('Pmove', () => { assert.equal(playerMoveTraceHigher.fraction, 1.0); }); - describe('staticWorldContents', () => { - test('uses brush-backed world solids before leaf contents', () => { + void describe('staticWorldContents', () => { + void test('uses brush-backed world solids before leaf contents', () => { const worldModel = createBrushWorldModel({ center: [64, 0, 0], halfExtents: [16, 16, 16] }); const pmove = new Pmove(); @@ -1010,7 +1010,7 @@ describe('Pmove', () => { assert.equal(pmove.staticWorldContents(new Vector(8, 0, 0)), content.CONTENT_WATER); }); - test('normalizes brush-backed current leaves to water', () => { + void test('normalizes brush-backed current leaves to water', () => { const worldModel = createBrushWorldModel({ center: [64, 0, 0], halfExtents: [16, 16, 16] }); const pmove = new Pmove(); @@ -1022,8 +1022,8 @@ describe('Pmove', () => { }); }); - describe('crouching movement', () => { - test('uses duckspeed as the movement cap while ducked on the ground', () => { + void describe('crouching movement', () => { + void test('uses duckspeed as the movement cap while ducked on the ground', () => { const pmove = new Pmove(); const player = pmove.newPlayerMove(); const recordedWishspeeds = []; From e2cfd787148a79b357e1d3ce9700bb4d5170f1bf Mon Sep 17 00:00:00 2001 From: Christian R Date: Fri, 3 Apr 2026 00:43:06 +0300 Subject: [PATCH 26/67] TS: common/model/loaders/* and more --- .../code-style-guide.instructions.md | 11 +- .../typescript-port.instructions.md | 232 ++ docs/code-style-guide.md | 6 +- source/engine/client/ClientEntities.mjs | 2 +- source/engine/client/R.mjs | 8 +- source/engine/client/Sound.mjs | 2 +- .../client/renderer/BrushModelRenderer.mjs | 22 +- source/engine/client/renderer/ShadowMap.mjs | 8 +- source/engine/common/Mod.ts | 2 +- source/engine/common/Pmove.ts | 20 +- source/engine/common/model/BSP.mjs | 1 - source/engine/common/model/BSP.ts | 2 +- .../common/model/loaders/BSP29Loader.mjs | 2724 +---------------- .../common/model/loaders/BSP29Loader.ts | 2563 ++++++++++++++++ .../common/model/loaders/BSP2Loader.mjs | 4 +- .../common/model/loaders/BSP38Loader.mjs | 2 +- source/engine/server/Edict.mjs | 2 +- test/common/model-cache.test.mjs | 2 +- test/physics/brushtrace.test.mjs | 24 +- test/physics/collision-regressions.test.mjs | 172 +- test/physics/fixtures.mjs | 10 +- test/physics/pmove.test.mjs | 4 +- test/physics/server-collision.test.mjs | 94 +- 23 files changed, 2996 insertions(+), 2921 deletions(-) create mode 100644 .github/instructions/typescript-port.instructions.md delete mode 100644 source/engine/common/model/BSP.mjs create mode 100644 source/engine/common/model/loaders/BSP29Loader.ts diff --git a/.github/instructions/code-style-guide.instructions.md b/.github/instructions/code-style-guide.instructions.md index efe7d945..a71f4c99 100644 --- a/.github/instructions/code-style-guide.instructions.md +++ b/.github/instructions/code-style-guide.instructions.md @@ -154,13 +154,15 @@ class GL { ### `null` initializations -- **Explicitly initialize variables to `null`** when they will later hold an object reference and provide JSDoc type annotations either as cast or an inline comment. - - Example: `let model = /** @type {BaseModel} */ (null);` +- **Explicitly initialize variables to `null`** when they will later hold an object reference. + - In `.ts` files: `let model: BaseModel | null = null;` + - In `.mjs` files: `let model = /** @type {BaseModel} */ (null);` ### Empty Arrays -- **Initialize empty arrays with `[]`** instead of `new Array()` and provide JSDoc type annotations. - - Example: `let vertices = /** @type {number[]} */ ([]);` +- **Initialize empty arrays with `[]`** instead of `new Array()`. + - In `.ts` files: `const vertices: number[] = [];` + - In `.mjs` files: `let vertices = /** @type {number[]} */ ([]);` ## Class and Interface Design @@ -173,6 +175,7 @@ class GL { - **Use `_` prefix** for protected methods. Add `@protected` JSDoc tag. - **Use `#` prefix** for private methods. +- In `.ts` files, prefer native `protected` / `private` keywords. See `typescript-port.instructions.md`. ### Respect boundaries of abstraction diff --git a/.github/instructions/typescript-port.instructions.md b/.github/instructions/typescript-port.instructions.md new file mode 100644 index 00000000..c28bd16d --- /dev/null +++ b/.github/instructions/typescript-port.instructions.md @@ -0,0 +1,232 @@ + +## TypeScript Porting Guide + +When porting `.mjs` files to `.ts` (or polishing an earlier verbatim JS→TS port), apply every applicable rule below. The goal is idiomatic, type-safe TypeScript that relies on the compiler rather than JSDoc for type information. + +### Interfaces over Type Aliases + +- **Prefer `interface`** for object shapes. Only use `type` for unions, intersections, tuples, or mapped types. +- **Mark every field `readonly`** unless the field is genuinely mutated after construction. + +```typescript +// ✅ Good +interface Hull { + readonly clipnodes: Clipnode[]; + readonly planes: Plane[]; + readonly clip_mins: Vector; + readonly clip_maxs: Vector; +} + +// ❌ Bad — type alias for a plain object shape +type Hull = { + clipnodes: Clipnode[]; + planes: Plane[]; +}; +``` + +### Typed Declarations — No JSDoc Type Casts + +Replace every `/** @type {X} */` cast with a native TS annotation or `as` cast. + +```typescript +// ❌ JSDoc cast +const materials = /** @type {Record} */ ({}); +loadmodel.version = /** @type {29|844124994} */ (dv.getUint32(0, true)); +const node = /** @type {Node} */ (stack.pop()); + +// ✅ TS annotation or `as` cast +const materials: Record = {}; +loadmodel.version = dv.getUint32(0, true) as 29 | 844124994; +const node = stack.pop()!; // when non-null is guaranteed +const node = stack.pop() as Node; // when a type narrowing is needed +``` + +Apply the same rule to local variables that used a JSDoc `@type` on the preceding line: + +```typescript +// ❌ JSDoc-typed local +/** @type {Map} */ +const leafsByType = new Map(); + +// ✅ TS generic +const leafsByType = new Map(); +``` + +### Method and Function Signatures + +- **Always provide explicit parameter types and return types** on every class method (public, protected, and private). +- For inner arrow / local functions, add parameter types; the return type may be inferred unless it improves clarity. + +```typescript +// ❌ Untyped method — leftover from JS +_loadVertexes(loadmodel, buf) { + +// ✅ Fully typed +_loadVertexes(loadmodel: BrushModel, buf: ArrayBuffer): void { +``` + +### JSDoc Cleanup + +When a method has TS parameter/return types, remove the redundant JSDoc `@param {Type}` and `@returns {Type}` annotations. **Keep the description text.** + +```typescript +// ❌ Redundant JSDoc types +/** + * Load vertices from BSP lump. + * @param {BrushModel} loadmodel - The model being loaded + * @param {ArrayBuffer} buf - The BSP file buffer + * @returns {void} + */ +_loadVertexes(loadmodel: BrushModel, buf: ArrayBuffer): void { + +// ✅ Description only +/** + * Load vertices from BSP lump. + * @protected + */ +_loadVertexes(loadmodel: BrushModel, buf: ArrayBuffer): void { +``` + +- **Never leave an empty JSDoc block** — always add a description sentence. +- **End JSDoc description sentences with a period.** +- **Keep `@protected` and `@private` tags** only during the transition period while the codebase still has `.mjs` callers that rely on them. Once all callers are `.ts`, prefer TS native access modifiers (see below). + +### Access Modifiers — `protected` / `private` + +Convert JSDoc access annotations to native TS modifiers: + +| JSDoc pattern | TS replacement | +|---|---| +| `@protected` + `_` prefix | `protected _methodName(…)` | +| `@private` + `_` prefix | `#methodName(…)` | +| `#methodName` (already private) | keep `#methodName(…)` | + +- **Use `protected`** for methods overridden by subclasses (e.g., `_loadFaces` in BSP29Loader → BSP2Loader). +- **Use `#` (hard private)** for methods that are truly internal and never accessed outside the class. +- **Keep the `_` prefix** on protected members for visual consistency during the migration. Once the full codebase is TS, the prefix may be dropped. + +### `override` Keyword + +Add `override` to every method that overrides a base class method. This catches accidental signature mismatches at compile time. + +```typescript +// ✅ Signals this overrides ModelLoader.getMagicNumbers() +override getMagicNumbers(): number[] { + return [29]; +} +``` + +### `readonly` and `static readonly` + +- Mark class fields and static fields `readonly` when they are assigned once (at declaration or in the constructor) and never reassigned. +- Applies to `static` lookup tables, frozen objects, configuration sets, etc. + +```typescript +static readonly #lump = Object.freeze({ entities: 0, planes: 1, /* … */ }); +static readonly doorClassnames = new Set(['func_door', 'func_door_secret']); +``` + +### Enums + +Port obvious enumeration-like patterns to native TS `enum` (or `const enum` for zero-runtime overhead when all consumers are TS): + +```typescript +// ❌ JS-era frozen object enum +const materialFlags = Object.freeze({ + MF_SKY: 1, + MF_TURBULENT: 2, + MF_TRANSPARENT: 4, +}); + +// ✅ TS enum +export enum MaterialFlags { + MF_SKY = 1, + MF_TURBULENT = 2, + MF_TRANSPARENT = 4, +} +``` + +**When to use `const enum`:** only when every consumer is TypeScript and no runtime object is needed (e.g., internal flags bitfields). Prefer a regular `enum` when the values may be iterated at runtime or exposed to `.mjs` callers. + +### Redundant Constructor Removal + +Remove empty constructors that only call `super()` with no additional logic — TypeScript (and JavaScript) does this implicitly. + +```typescript +// ❌ Redundant +constructor() { + super(); +} + +// ✅ Just omit it +``` + +### Template Literals + +Replace string concatenation with template literals for readability. + +```typescript +// ❌ Concatenation +throw new Error('Bad lump size in ' + loadmodel.name); + +// ✅ Template literal +throw new Error(`Bad lump size in ${loadmodel.name}`); +``` + +### Null Initialization and Empty Arrays + +When porting `null`-initialized or empty-array variables, use TS annotations directly instead of JSDoc casts: + +```typescript +// ❌ JSDoc +let model = /** @type {BaseModel} */ (null); +let vertices = /** @type {number[]} */ ([]); + +// ✅ TS +let model: BaseModel | null = null; +const vertices: number[] = []; +``` + +### Checklist (per file) + +Use this checklist when polishing a ported `.ts` file: + +1. [ ] All `/** @type {X} */` casts → TS annotations or `as` casts. +2. [ ] All method parameters and return types explicitly typed. +3. [ ] JSDoc `@param {Type}` / `@returns {Type}` removed (descriptions kept). +4. [ ] `interface` + `readonly` for all object shape types. +5. [ ] `override` on every overriding method. +6. [ ] `static readonly` on immutable class fields. +7. [ ] `protected` / `private` / `#` replacing JSDoc `@protected` / `@private`. +8. [ ] Obvious enums ported to TS `enum`. +9. [ ] Redundant empty constructors removed. +10. [ ] String concatenation → template literals. +11. [ ] No empty JSDoc blocks — every block has a description ending with a period. +12. [ ] ESLint clean (`npx eslint `). +13. [ ] All tests pass (`npm run test`). + +### Avoid inline import type annotations + +When importing types, prefer file-level imports over inline `import('…').Type` annotations for better readability and maintainability. + +```typescript + +// ❌ Inline import type +static _brushMayAffectTrace(ctx: BrushTraceContext, brush: import('./model/BSP.ts').Brush): boolean { +… + +// ✅ File-level import +import { Brush } from './model/BSP.ts'; +… +static _brushMayAffectTrace(ctx: BrushTraceContext, brush: Brush): boolean { +… +``` + +Exception: When used in dynamically to load modules like so + +```typescript +const comModule = await import(/* @vite-ignore */ serverComId); +const COM = comModule.COM as typeof import('../common/COM.ts'); +``` + +But this is a special case and should not be used as a general pattern for type imports! diff --git a/docs/code-style-guide.md b/docs/code-style-guide.md index 4b8b51d8..d43b5104 100644 --- a/docs/code-style-guide.md +++ b/docs/code-style-guide.md @@ -64,7 +64,7 @@ This document outlines the coding conventions and style rules for the Quakeshack /** @type {import('./ClientEntities.mjs').ClientEdict} */ // ✅ GOOD for model types - /** @type {import('../../common/model/BSP.mjs').BrushModel} */ + /** @type {import('../../common/model/BSP.ts').BrushModel} */ ``` ## Registry and Global Variables @@ -231,10 +231,10 @@ Always verify import paths are correct: ```javascript // ❌ BAD - Wrong relative path -/** @param {import('../../../common/model/BSP.mjs').BrushModel} model */ +/** @param {import('../../../common/model/BSP.ts').BrushModel} model */ // ✅ GOOD - Correct relative path from current file -/** @param {import('../../common/model/BSP.mjs').BrushModel} model */ +/** @param {import('../../common/model/BSP.ts').BrushModel} model */ ``` ### Return Types from Library Functions diff --git a/source/engine/client/ClientEntities.mjs b/source/engine/client/ClientEntities.mjs index 4f8fe12e..2379731f 100644 --- a/source/engine/client/ClientEntities.mjs +++ b/source/engine/client/ClientEntities.mjs @@ -7,7 +7,7 @@ import { DefaultClientEdictHandler } from './ClientLegacy.mjs'; import { BaseClientEdictHandler } from '../../shared/ClientEdict.ts'; import { ClientEngineAPI } from '../common/GameAPIs.mjs'; import { SFX } from './Sound.mjs'; -import { Node, revealedVisibility } from '../common/model/BSP.mjs'; +import { Node, revealedVisibility } from '../common/model/BSP.ts'; import { BaseModel } from '../common/model/BaseModel.ts'; let { CL, Con, Mod, PR, R, S } = registry; diff --git a/source/engine/client/R.mjs b/source/engine/client/R.mjs index 10ed32b1..599913c8 100644 --- a/source/engine/client/R.mjs +++ b/source/engine/client/R.mjs @@ -15,7 +15,7 @@ import { AliasModelRenderer } from './renderer/AliasModelRenderer.mjs'; import { SpriteModelRenderer } from './renderer/SpriteModelRenderer.mjs'; import { MeshModelRenderer } from './renderer/MeshModelRenderer.mjs'; import Draw from './Draw.mjs'; -import { BrushModel, Node, revealedVisibility } from '../common/model/BSP.mjs'; +import { BrushModel, Node, revealedVisibility } from '../common/model/BSP.ts'; import PostProcess from './renderer/PostProcess.mjs'; import BloomEffect from './renderer/BloomEffect.mjs'; import WarpEffect from './renderer/WarpEffect.mjs'; @@ -822,7 +822,7 @@ R._renderFogAndTurbulentsSorted = function(worldEntity) { } const vieworg = R.refdef.vieworg; - /** @type {Array<{dist: number, kind: number, data: import('../common/model/BSP.mjs').WorldTurbulentChainInfo|import('../common/model/BSP.mjs').FogVolumeInfo}>} */ + /** @type {Array<{dist: number, kind: number, data: import('../common/model/BSP.ts').WorldTurbulentChainInfo|import('../common/model/BSP.ts').FogVolumeInfo}>} */ const items = []; const turbulentChains = brushRenderer.getWorldTurbulentChains(worldmodel, vieworg); @@ -864,11 +864,11 @@ R._renderFogAndTurbulentsSorted = function(worldEntity) { } if (item.kind === 0) { - brushRenderer.renderWorldTurbulentChain(worldmodel, /** @type {import('../common/model/BSP.mjs').WorldTurbulentChainInfo} */ (item.data)); + brushRenderer.renderWorldTurbulentChain(worldmodel, /** @type {import('../common/model/BSP.ts').WorldTurbulentChainInfo} */ (item.data)); continue; } - brushRenderer.renderSingleFogVolume(worldmodel, /** @type {import('../common/model/BSP.mjs').FogVolumeInfo} */ (item.data)); + brushRenderer.renderSingleFogVolume(worldmodel, /** @type {import('../common/model/BSP.ts').FogVolumeInfo} */ (item.data)); } if (activePass === 0) { diff --git a/source/engine/client/Sound.mjs b/source/engine/client/Sound.mjs index d8bd4097..a195a0ac 100644 --- a/source/engine/client/Sound.mjs +++ b/source/engine/client/Sound.mjs @@ -4,7 +4,7 @@ import Cvar from '../common/Cvar.ts'; import Q from '../../shared/Q.ts'; import { eventBus, registry } from '../registry.mjs'; -/** @typedef {import('../common/model/BSP.mjs').Node} BSPNode */ +/** @typedef {import('../common/model/BSP.ts').Node} BSPNode */ let { CL, COM, Con, Host } = registry; diff --git a/source/engine/client/renderer/BrushModelRenderer.mjs b/source/engine/client/renderer/BrushModelRenderer.mjs index 957ac0dd..17782912 100644 --- a/source/engine/client/renderer/BrushModelRenderer.mjs +++ b/source/engine/client/renderer/BrushModelRenderer.mjs @@ -4,7 +4,7 @@ import { eventBus, registry } from '../../registry.mjs'; import GL, { ATTRIB_LOCATIONS, BRUSH_VERTEX_STRIDE } from '../GL.mjs'; import { getEntityBloomEmissiveScale } from './BloomEffect.mjs'; import { materialFlags } from './Materials.mjs'; -import { BrushModel, Node } from '../../common/model/BSP.mjs'; +import { BrushModel, Node } from '../../common/model/BSP.ts'; import { ClientEdict } from '../ClientEntities.mjs'; import Mesh from './Mesh.mjs'; import PostProcess from './PostProcess.mjs'; @@ -662,7 +662,7 @@ export class BrushModelRenderer extends ModelRenderer { * semi-transparent liquids can share the same sorted space. * @param {BrushModel} clmodel The world model * @param {Float32Array|number[]} vieworg Camera position [x, y, z] - * @returns {{chain: import('../../common/model/BSP.mjs').WorldTurbulentChainInfo, dist: number}[]} Turbulent draw items with distance metadata + * @returns {{chain: import('../../common/model/BSP.ts').WorldTurbulentChainInfo, dist: number}[]} Turbulent draw items with distance metadata */ getWorldTurbulentChains(clmodel, vieworg) { const items = []; @@ -754,7 +754,7 @@ export class BrushModelRenderer extends ModelRenderer { * Render a single pre-sorted world turbulent draw batch. * Must be called between `beginWorldTurbulentPass` and `endWorldTurbulentPass`. * @param {BrushModel} clmodel The world model - * @param {import('../../common/model/BSP.mjs').WorldTurbulentChainInfo} chain The turbulent draw batch to render + * @param {import('../../common/model/BSP.ts').WorldTurbulentChainInfo} chain The turbulent draw batch to render */ renderWorldTurbulentChain(clmodel, chain) { this._renderWorldTurbulentBatch(clmodel, chain.texture, chain.firstVertex, chain.vertexCount); @@ -1119,7 +1119,7 @@ export class BrushModelRenderer extends ModelRenderer { * Light probe textures for fog volumes, keyed by the fog volume object. * Each entry holds a raw WebGL texture, the grid resolution, and a reusable * pixel buffer to avoid reallocating on every lightstyle update. - * @type {Map} + * @type {Map} */ #fogLightProbes = new Map(); @@ -1174,7 +1174,7 @@ export class BrushModelRenderer extends ModelRenderer { * Sample R.LightPoint into a pixel buffer for a fog volume's light probe grid. * Data is laid out for TEXTURE_3D upload: Z slices are contiguous in memory. * Texel at grid (ix, iy, iz) is at index (iz * resY * resX + iy * resX + ix). - * @param {import('../../common/model/BSP.mjs').FogVolumeInfo} fogVolume The fog volume + * @param {import('../../common/model/BSP.ts').FogVolumeInfo} fogVolume The fog volume * @param {Uint8Array} data Pixel buffer to fill (resX * resY * resZ * 4) * @param {number} resX Grid resolution X * @param {number} resY Grid resolution Y @@ -1229,7 +1229,7 @@ export class BrushModelRenderer extends ModelRenderer { * Create or update the light probe texture for a fog volume. * If the probe already exists, re-samples into the existing pixel buffer * and re-uploads via texSubImage2D (avoids GPU allocation). - * @param {import('../../common/model/BSP.mjs').FogVolumeInfo} fogVolume The fog volume + * @param {import('../../common/model/BSP.ts').FogVolumeInfo} fogVolume The fog volume * @returns {{texture: WebGLTexture, resX: number, resY: number, resZ: number, data: Uint8Array}} The probe data */ _createOrUpdateFogLightProbe(fogVolume) { @@ -1269,7 +1269,7 @@ export class BrushModelRenderer extends ModelRenderer { * Get the light probe texture for a fog volume, creating or updating it * when lightstyle animations tick. Lightstyles animate at 10 Hz, so the * probes are re-sampled at most 10 times per second. - * @param {import('../../common/model/BSP.mjs').FogVolumeInfo} fogVolume The fog volume + * @param {import('../../common/model/BSP.ts').FogVolumeInfo} fogVolume The fog volume * @returns {{texture: WebGLTexture, resX: number, resY: number, resZ: number, data: Uint8Array}|null} Probe data, or null if light data is unavailable */ _getFogLightProbe(fogVolume) { @@ -1310,7 +1310,7 @@ export class BrushModelRenderer extends ModelRenderer { /** * Collect active dynamic lights that overlap a fog volume's AABB. * Returns up to MAX_FOG_DLIGHTS lights sorted by contribution (closest first). - * @param {import('../../common/model/BSP.mjs').FogVolumeInfo} fogVolume The fog volume + * @param {import('../../common/model/BSP.ts').FogVolumeInfo} fogVolume The fog volume * @returns {{origin: Vector, radius: number, color: Vector}[]} Overlapping dlights */ _collectFogDlights(fogVolume) { @@ -1350,7 +1350,7 @@ export class BrushModelRenderer extends ModelRenderer { /** * Upload dynamic light uniforms for the current fog volume. - * @param {import('../../common/model/BSP.mjs').FogVolumeInfo} fogVolume The fog volume + * @param {import('../../common/model/BSP.ts').FogVolumeInfo} fogVolume The fog volume */ _uploadFogDlights(fogVolume) { const program = this._fogVolumeProgram; @@ -1431,7 +1431,7 @@ export class BrushModelRenderer extends ModelRenderer { * Collect fog volumes with distance from the given viewpoint for back-to-front sorting. * @param {BrushModel} worldmodel The world model containing fog volume definitions * @param {Float32Array|number[]} vieworg Camera position [x, y, z] - * @returns {{fogVolume: import('../../common/model/BSP.mjs').FogVolumeInfo, dist: number}[]} Fog volume items with distance + * @returns {{fogVolume: import('../../common/model/BSP.ts').FogVolumeInfo, dist: number}[]} Fog volume items with distance */ getFogVolumeItems(worldmodel, vieworg) { if (!worldmodel.fogVolumes || worldmodel.fogVolumes.length === 0) { @@ -1488,7 +1488,7 @@ export class BrushModelRenderer extends ModelRenderer { * Render a single fog volume. * Must be called between `beginFogVolumePass` and `endFogVolumePass`. * @param {BrushModel} worldmodel The world model - * @param {import('../../common/model/BSP.mjs').FogVolumeInfo} fogVolume The fog volume to render + * @param {import('../../common/model/BSP.ts').FogVolumeInfo} fogVolume The fog volume to render */ renderSingleFogVolume(worldmodel, fogVolume) { const program = this._fogVolumeProgram; diff --git a/source/engine/client/renderer/ShadowMap.mjs b/source/engine/client/renderer/ShadowMap.mjs index 33ad08e5..efcc50f9 100644 --- a/source/engine/client/renderer/ShadowMap.mjs +++ b/source/engine/client/renderer/ShadowMap.mjs @@ -131,7 +131,7 @@ export default class ShadowMap { /** * Reference to the worldmodel whose entities were last parsed. * Used to detect map changes and re-parse lazily. - * @type {import('../../common/model/BSP.mjs').BrushModel|null} + * @type {import('../../common/model/BSP.ts').BrushModel|null} */ static _parsedWorldmodel = null; @@ -431,7 +431,7 @@ export default class ShadowMap { * viewpoint differs from the camera's. */ static renderWorldShadow() { - const worldmodel = /** @type {import('../../common/model/BSP.mjs').BrushModel} */ (CL.state.worldmodel); + const worldmodel = /** @type {import('../../common/model/BSP.ts').BrushModel} */ (CL.state.worldmodel); if (!worldmodel) { return; } @@ -554,7 +554,7 @@ export default class ShadowMap { /** * Render a brush submodel entity (door, platform, etc.) into a shadow map. - * @param {import('../../common/model/BSP.mjs').BrushModel} model + * @param {import('../../common/model/BSP.ts').BrushModel} model * @param {import('../ClientEntities.mjs').ClientEdict} entity * @param {Float64Array} lightSpaceMatrix * @param {string} programName @@ -1004,7 +1004,7 @@ export default class ShadowMap { * Call after selectPointLight() returns true. */ static renderPointLightShadow() { - const worldmodel = /** @type {import('../../common/model/BSP.mjs').BrushModel} */ (CL.state.worldmodel); + const worldmodel = /** @type {import('../../common/model/BSP.ts').BrushModel} */ (CL.state.worldmodel); if (!worldmodel) { return; } diff --git a/source/engine/common/Mod.ts b/source/engine/common/Mod.ts index 537a83f6..d151beb0 100644 --- a/source/engine/common/Mod.ts +++ b/source/engine/common/Mod.ts @@ -3,7 +3,7 @@ import { MissingResourceError } from './Errors.ts'; import { ModelLoaderRegistry } from './model/ModelLoaderRegistry.ts'; import { AliasMDLLoader } from './model/loaders/AliasMDLLoader.mjs'; import { SpriteSPRLoader } from './model/loaders/SpriteSPRLoader.mjs'; -import { BSP29Loader } from './model/loaders/BSP29Loader.mjs'; +import { BSP29Loader } from './model/loaders/BSP29Loader.ts'; import { BSP2Loader } from './model/loaders/BSP2Loader.mjs'; import { WavefrontOBJLoader } from './model/loaders/WavefrontOBJLoader.mjs'; import ParsedQC from './model/parsers/ParsedQC.mjs'; diff --git a/source/engine/common/Pmove.ts b/source/engine/common/Pmove.ts index c425e094..d533b7f4 100644 --- a/source/engine/common/Pmove.ts +++ b/source/engine/common/Pmove.ts @@ -569,7 +569,7 @@ export class BrushTrace { /** * Check whether a brush AABB can possibly overlap the current swept move. */ - static _brushMayAffectTrace(ctx: BrushTraceContext, brush: import('./model/BSP.mjs').Brush): boolean { + static _brushMayAffectTrace(ctx: BrushTraceContext, brush: import('./model/BSP.ts').Brush): boolean { if (brush.mins === null || brush.mins === undefined || brush.maxs === null || brush.maxs === undefined) { return true; } @@ -580,7 +580,7 @@ export class BrushTrace { /** * Check whether a brush AABB can possibly overlap the current position test. */ - static _brushMayAffectPosition(brush: import('./model/BSP.mjs').Brush, boundsMins: Vector, boundsMaxs: Vector): boolean { + static _brushMayAffectPosition(brush: import('./model/BSP.ts').Brush, boundsMins: Vector, boundsMaxs: Vector): boolean { if (brush.mins === null || brush.mins === undefined || brush.maxs === null || brush.maxs === undefined) { return true; } @@ -593,7 +593,7 @@ export class BrushTrace { * enter a node's bounds. Used only for pruning; false negatives are avoided * by falling back when bounds are missing. */ - static _estimateNodeEntryFraction(ctx: BrushTraceContext, node: import('./model/BSP.mjs').Node): number { + static _estimateNodeEntryFraction(ctx: BrushTraceContext, node: import('./model/BSP.ts').Node): number { if (node.mins === null || node.mins === undefined || node.maxs === null || node.maxs === undefined) { return 0; } @@ -637,7 +637,7 @@ export class BrushTrace { /** * Check whether a node can still affect the current trace. */ - static _nodeMayAffectTrace(ctx: BrushTraceContext, node: import('./model/BSP.mjs').Node): boolean { + static _nodeMayAffectTrace(ctx: BrushTraceContext, node: import('./model/BSP.ts').Node): boolean { if (node.mins === null || node.mins === undefined || node.maxs === null || node.maxs === undefined) { return true; } @@ -947,7 +947,7 @@ export class BrushTrace { * Recursively walk the BSP tree for position testing, expanding by box * extents to visit all leaves the player box overlaps. */ - static _testPositionRecursive(worldModel: BrushModel, node: import('./model/BSP.mjs').Node, position: Vector, mins: Vector, maxs: Vector, boundsMins: Vector, boundsMaxs: Vector, extents: Vector, isPoint: boolean, checkCount: number): boolean { + static _testPositionRecursive(worldModel: BrushModel, node: import('./model/BSP.ts').Node, position: Vector, mins: Vector, maxs: Vector, boundsMins: Vector, boundsMaxs: Vector, extents: Vector, isPoint: boolean, checkCount: number): boolean { if (node.mins !== null && node.mins !== undefined && node.maxs !== null && node.maxs !== undefined && !BrushTrace._boundsOverlap(boundsMins, boundsMaxs, node.mins, node.maxs)) { return false; @@ -1137,7 +1137,7 @@ export class BrushTrace { /** * Test if a player-sized box overlaps any solid brush in a leaf. */ - static _testLeafSolid(worldModel: BrushModel, leaf: import('./model/BSP.mjs').Node, position: Vector, mins: Vector, maxs: Vector, boundsMins: Vector, boundsMaxs: Vector, checkCount: number): boolean { + static _testLeafSolid(worldModel: BrushModel, leaf: import('./model/BSP.ts').Node, position: Vector, mins: Vector, maxs: Vector, boundsMins: Vector, boundsMaxs: Vector, checkCount: number): boolean { const brushes = worldModel.brushes; const leafbrushes = worldModel.leafbrushes; @@ -1182,7 +1182,7 @@ export class BrushTrace { /** * Test if a box at origin is inside a brush. Equivalent to Q2’s CM_TestBoxInBrush. */ - static _testBoxInBrush(worldModel: BrushModel, brush: import('./model/BSP.mjs').Brush, position: Vector, mins: Vector, maxs: Vector): boolean { + static _testBoxInBrush(worldModel: BrushModel, brush: import('./model/BSP.ts').Brush, position: Vector, mins: Vector, maxs: Vector): boolean { const brushsides = worldModel.brushsides; const planes = worldModel.planes; @@ -1224,7 +1224,7 @@ export class BrushTrace { * Recursively traverse the BSP node tree, expanding by trace extents. * At leaf nodes, test all brushes. Equivalent to Q2’s CM_RecursiveHullCheck. */ - static _recursiveHullCheck(ctx: BrushTraceContext, node: import('./model/BSP.mjs').Node, p1f: number, p2f: number, p1: Vector, p2: Vector, depth: number = 0) { + static _recursiveHullCheck(ctx: BrushTraceContext, node: import('./model/BSP.ts').Node, p1f: number, p2f: number, p1: Vector, p2: Vector, depth: number = 0) { if (!BrushTrace._nodeMayAffectTrace(ctx, node)) { return; } @@ -1321,7 +1321,7 @@ export class BrushTrace { * Test all brushes in a leaf against the current trace. * Equivalent to Q2’s CM_TraceToLeaf. */ - static _traceToLeaf(ctx: BrushTraceContext, leaf: import('./model/BSP.mjs').Node) { + static _traceToLeaf(ctx: BrushTraceContext, leaf: import('./model/BSP.ts').Node) { // Q1 content classification for trace flags if (leaf.contents !== content.CONTENT_SOLID && leaf.contents !== content.CONTENT_SKY) { ctx.trace.allsolid = false; @@ -1373,7 +1373,7 @@ export class BrushTrace { * Clip the trace against a single brush’s planes. * Equivalent to Q2’s CM_ClipBoxToBrush. */ - static _clipBoxToBrush(ctx: BrushTraceContext, brush: import('./model/BSP.mjs').Brush) { + static _clipBoxToBrush(ctx: BrushTraceContext, brush: import('./model/BSP.ts').Brush) { const brushsides = ctx.worldModel.brushsides; const planes = ctx.worldModel.planes; const moveDeltaX = ctx.end[0] - ctx.start[0]; diff --git a/source/engine/common/model/BSP.mjs b/source/engine/common/model/BSP.mjs deleted file mode 100644 index 224f42b0..00000000 --- a/source/engine/common/model/BSP.mjs +++ /dev/null @@ -1 +0,0 @@ -export * from './BSP.ts'; diff --git a/source/engine/common/model/BSP.ts b/source/engine/common/model/BSP.ts index 9f197e3f..43c751cc 100644 --- a/source/engine/common/model/BSP.ts +++ b/source/engine/common/model/BSP.ts @@ -494,7 +494,7 @@ export class Brush extends BrushModelComponent { /** * Base class for brush-based models (BSP maps). - * All loading is handled by `BSP29Loader.mjs`. + * All loading is handled by `BSP29Loader`. */ export class BrushModel extends BaseModel { /** BSP format version. */ diff --git a/source/engine/common/model/loaders/BSP29Loader.mjs b/source/engine/common/model/loaders/BSP29Loader.mjs index b097c0f2..d9fb6e2a 100644 --- a/source/engine/common/model/loaders/BSP29Loader.mjs +++ b/source/engine/common/model/loaders/BSP29Loader.mjs @@ -1,2723 +1 @@ -import Vector from '../../../../shared/Vector.ts'; -import Q from '../../../../shared/Q.ts'; -import { content } from '../../../../shared/Defs.ts'; -import { GLTexture } from '../../../client/GL.mjs'; -import W, { readWad3Texture, translateIndexToLuminanceRGBA, translateIndexToRGBA } from '../../W.ts'; -import { CRC16CCITT } from '../../CRC.ts'; -import { CorruptedResourceError } from '../../Errors.ts'; -import { eventBus, registry } from '../../../registry.mjs'; -import { ModelLoader } from '../ModelLoader.ts'; -import { Brush, BrushModel, BrushSide, Node } from '../BSP.mjs'; -import { Face, Plane } from '../BaseModel.ts'; -import { materialFlags, noTextureMaterial, PBRMaterial, QuakeMaterial } from '../../../client/renderer/Materials.mjs'; -import { Quake1Sky, SimpleSkyBox } from '../../../client/renderer/Sky.mjs'; - -// Get registry references (will be set by eventBus) -let { COM, Con } = registry; - -eventBus.subscribe('registry.frozen', () => { - ({ COM, Con } = registry); -}); - -/** - * Loader for Quake BSP29 format (.bsp) - * It supports vanilla BSP29 and a few BSPX extensions (such as lightgrid, RGB lighting). - */ -export class BSP29Loader extends ModelLoader { - /** BSP29 lump indices */ - static #lump = Object.freeze({ - entities: 0, - planes: 1, - textures: 2, - vertexes: 3, - visibility: 4, - nodes: 5, - texinfo: 6, - faces: 7, - lighting: 8, - clipnodes: 9, - leafs: 10, - marksurfaces: 11, - edges: 12, - surfedges: 13, - models: 14, - }); - - constructor() { - super(); - } - - /** - * Get magic numbers that identify this format - * @returns {number[]} Array of magic numbers - */ - getMagicNumbers() { - return [29]; // BSP version 29 - } - - /** - * Get file extensions for this format - * @returns {string[]} Array of file extensions - */ - getExtensions() { - return ['.bsp']; - } - - /** - * Get human-readable name of this loader - * @returns {string} Loader name - */ - getName() { - return 'Quake BSP29'; - } - - /** - * Load a BSP29 map model from buffer - * @param {ArrayBuffer} buffer - The BSP file data - * @param {string} name - The model name/path - * @returns {Promise} The loaded model - */ - async load(buffer, name) { - const loadmodel = new BrushModel(name); - - loadmodel.version = /** @type {29|844124994} */ ((new DataView(buffer)).getUint32(0, true)); - loadmodel.bspxoffset = 0; - - // Load all BSP lumps - this._loadEntities(loadmodel, buffer); - this._loadVertexes(loadmodel, buffer); - this._loadEdges(loadmodel, buffer); - this._loadSurfedges(loadmodel, buffer); - this._loadTextures(loadmodel, buffer); - await this._loadMaterials(loadmodel); - this._loadLighting(loadmodel, buffer); - this._loadPlanes(loadmodel, buffer); - this._loadTexinfo(loadmodel, buffer); - this._loadFaces(loadmodel, buffer); - this._loadMarksurfaces(loadmodel, buffer); - this._loadVisibility(loadmodel, buffer); - this._loadLeafs(loadmodel, buffer); - this._buildClusterData(loadmodel); - this._loadNodes(loadmodel, buffer); - this._loadClipnodes(loadmodel, buffer); - this._makeHull0(loadmodel); - this._loadBSPX(loadmodel, buffer); - this._loadBrushList(loadmodel, buffer); - this._loadLightingRGB(loadmodel, buffer); - this._loadDeluxeMap(loadmodel, buffer); - this._loadLightgridOctree(loadmodel, buffer); - this._loadSubmodels(loadmodel, buffer); // CR: must be last, since it’s creating additional models based on this one - this._parseFogVolumes(loadmodel); // must be after _loadSubmodels so we can scan submodel faces - this._computeAreas(loadmodel); - - if (loadmodel.coloredlights && !loadmodel.lightdata_rgb) { - await this._loadExternalLighting(loadmodel, name); - } - - await this._loadSkybox(loadmodel); - - // Calculate bounding radius - this._calculateRadius(loadmodel); - - loadmodel.needload = false; - loadmodel.checksum = CRC16CCITT.Block(new Uint8Array(buffer)); - - return loadmodel; - } - - async _loadSkybox(loadmodel) { - if (registry.isDedicatedServer) { - return; - } - - const skyname = loadmodel.worldspawnInfo.skyname; - - if (!skyname) { - return; - } - - const [front, back, left, right, up, down] = await Promise.all([ - GLTexture.FromImageFile(`gfx/env/${skyname}ft.png`), - GLTexture.FromImageFile(`gfx/env/${skyname}bk.png`), - GLTexture.FromImageFile(`gfx/env/${skyname}lf.png`), - GLTexture.FromImageFile(`gfx/env/${skyname}rt.png`), - GLTexture.FromImageFile(`gfx/env/${skyname}up.png`), - GLTexture.FromImageFile(`gfx/env/${skyname}dn.png`), - ]); - - // CR: unholy yet convenient hack to pass sky texture data to SkyRenderer - loadmodel.newSkyRenderer = function () { - const skyrenderer = new SimpleSkyBox(this); - skyrenderer.setSkyTextures(front, back, left, right, up, down); - return skyrenderer; - }; - } - - /** - * Calculate the bounding radius of the model from its vertices - * @protected - * @param {BrushModel} loadmodel - The model being loaded - */ - _calculateRadius(loadmodel) { - const mins = new Vector(); - const maxs = new Vector(); - - for (let i = 0; i < loadmodel.vertexes.length; i++) { - const vert = loadmodel.vertexes[i]; - - if (vert[0] < mins[0]) { - mins[0] = vert[0]; - } else if (vert[0] > maxs[0]) { - maxs[0] = vert[0]; - } - - if (vert[1] < mins[1]) { - mins[1] = vert[1]; - } else if (vert[1] > maxs[1]) { - maxs[1] = vert[1]; - } - - if (vert[2] < mins[2]) { - mins[2] = vert[2]; - } else if (vert[2] > maxs[2]) { - maxs[2] = vert[2]; - } - } - - loadmodel.radius = (new Vector( - Math.abs(mins[0]) > Math.abs(maxs[0]) ? Math.abs(mins[0]) : Math.abs(maxs[0]), - Math.abs(mins[1]) > Math.abs(maxs[1]) ? Math.abs(mins[1]) : Math.abs(maxs[1]), - Math.abs(mins[2]) > Math.abs(maxs[2]) ? Math.abs(mins[2]) : Math.abs(maxs[2]), - )).len(); - } - - /** - * Load texture information and create GL textures from BSP texture lump - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - */ - _loadTextures(loadmodel, buf) { - const view = new DataView(buf); - const lump = BSP29Loader.#lump; - const fileofs = view.getUint32((lump.textures << 3) + 4, true); - const filelen = view.getUint32((lump.textures << 3) + 8, true); - loadmodel.textures.length = 0; - const nummiptex = view.getUint32(fileofs, true); - let dataofs = fileofs + 4; - - // const textures = /** @type {Record} */ ({}); // list of textures - const materials = /** @type {Record} */ ({}); // list of materials - - for (let i = 0; i < nummiptex; i++) { - const miptexofs = view.getInt32(dataofs, true); - dataofs += 4; - if (miptexofs === -1) { - loadmodel.textures[i] = noTextureMaterial; - continue; - } - const absofs = miptexofs + fileofs; - - const name = Q.memstr(new Uint8Array(buf, absofs, 16)); - const cleanName = name.replace(/^\+[0-9a-j]/, ''); // no anim prefix - - if (!materials[cleanName]) { - materials[cleanName] = new QuakeMaterial(name, view.getUint32(absofs + 16, true), view.getUint32(absofs + 20, true)); - } - - const tx = materials[cleanName]; - - let glt = null; - let luminanceTexture = null; - - // Load texture data (skip for dedicated server) - if (!registry.isDedicatedServer) { - if (tx.name.substring(0, 3).toLowerCase() === 'sky') { - const skyTexture = new Uint8Array(buf, absofs + view.getUint32(absofs + 24, true), 32768); - - // CR: unholy yet convenient hack to pass sky texture data to SkyRenderer - loadmodel.newSkyRenderer = function () { - const skyrenderer = new Quake1Sky(this); - skyrenderer.setSkyTexture(skyTexture); - return skyrenderer; - }; - - tx.flags |= materialFlags.MF_SKY; - } else { - // Try loading WAD3 texture - const len = 40 + tx.width * tx.height * (1 + 0.25 + 0.0625 + 0.015625) + 2 + 768; - if (absofs + len - 2 - 768 < buf.byteLength) { - const magic = view.getInt16(absofs + len - 2 - 768, true); - if (magic === 256) { - const data = new ArrayBuffer(len); - new Uint8Array(data).set(new Uint8Array(buf, absofs, len)); - const wtex = readWad3Texture(data, tx.name, 0); - glt = GLTexture.FromLumpTexture(wtex); - const wadLuminance = BSP29Loader._createWadLuminanceTexture(data, tx.name, tx.width, tx.height); - if (wadLuminance !== null) { - luminanceTexture = wadLuminance; - } - tx.averageColor = BSP29Loader._computeAverageColor(wtex.data); - } - } - } - - if (!glt) { - const pixelData = new Uint8Array(buf, absofs + view.getUint32(absofs + 24, true), tx.width * tx.height); - const rgba = translateIndexToRGBA(pixelData, tx.width, tx.height, W.d_8to24table_u8, tx.name[0] === '{' ? 255 : null, 240); - const textureId = `${tx.name}/${CRC16CCITT.Block(pixelData)}`; // CR: unique texture ID to avoid conflicts across maps - glt = GLTexture.Allocate(textureId, tx.width, tx.height, rgba); - const luminanceRGBA = translateIndexToLuminanceRGBA(pixelData, tx.width, tx.height, W.d_8to24table_u8, tx.name[0] === '{' ? 255 : null, 240); - if (BSP29Loader._hasVisiblePixels(luminanceRGBA)) { - luminanceTexture = GLTexture.Allocate(`${textureId}:luminance`, tx.width, tx.height, luminanceRGBA); - } - tx.averageColor = BSP29Loader._computeAverageColor(rgba); - } - - if (tx.name[0] === '*' || tx.name[0] === '!') { - tx.flags |= materialFlags.MF_TURBULENT; - } - - // Mark textures with '{' prefix as transparent (for alpha blending) - if (tx.name[0] === '{') { - tx.flags |= materialFlags.MF_TRANSPARENT; - } - - if (tx.name.toLowerCase().startsWith('*lava')) { - tx.flags |= materialFlags.MF_FULLBRIGHT; - } - } - - if (name[0] === '+') { // animation prefix - const frame = name.toUpperCase().charCodeAt(1); - - if (frame >= 48 && frame <= 57) { // '0'-'9' - const frameIndex = frame - 48; - tx.addAnimationFrame(frameIndex, glt, luminanceTexture); - } else if (frame >= 65 && frame <= 74) { // 'A'-'J' - const frameIndex = frame - 65; - tx.addAlternateFrame(frameIndex, glt, luminanceTexture); - } - } else { - tx.texture = glt; - tx.luminanceTexture = luminanceTexture; - } - - loadmodel.textures[i] = tx; - } - - loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs + filelen); - } - - /** - * @param {ArrayBuffer} wadTextureData Copied WAD3 texture data. - * @param {string} textureName Texture name for cache IDs. - * @param {number} width Texture width. - * @param {number} height Texture height. - * @returns {GLTexture|null} Uploaded luminance texture when the source has fullbright pixels. - */ - static _createWadLuminanceTexture(wadTextureData, textureName, width, height) { - const indexedData = new Uint8Array(wadTextureData, 40, width * height); - const palette = new Uint8Array(wadTextureData, - 40 + - width * height + - width / 2 * height / 2 + - width / 4 * height / 4 + - width / 8 * height / 8 + - 2, - 768); - const luminanceRGBA = translateIndexToLuminanceRGBA(indexedData, width, height, palette, textureName[0] === '{' ? 255 : null, 240); - - if (!BSP29Loader._hasVisiblePixels(luminanceRGBA)) { - return null; - } - - return GLTexture.Allocate(`${textureName}/${CRC16CCITT.Block(indexedData)}:luminance`, width, height, luminanceRGBA); - } - - /** - * @param {Uint8Array} rgba RGBA texture data. - * @returns {boolean} True when any pixel contributes visible color. - */ - static _hasVisiblePixels(rgba) { - for (let i = 3; i < rgba.length; i += 4) { - if (rgba[i] !== 0) { - return true; - } - } - - return false; - } - - /** - * Load material definitions from .qsmat.json file if available - * @protected - * @param {BrushModel} loadmodel - The model being loaded - */ - async _loadMaterials(loadmodel) { - if (registry.isDedicatedServer) { - return; - } - - const matfile = await COM.LoadTextFile(loadmodel.name.replace(/\.bsp$/i, '.qsmat.json')); - - if (!matfile) { - return; - } - - Con.DPrint(`BSP29Loader: found materials file for ${loadmodel.name}\n`); - const materialData = JSON.parse(matfile); - console.assert(materialData.version === 1); - - for (const [txName, textures] of Object.entries(materialData.materials)) { - const textureEntry = Array.from(loadmodel.textures.entries()).find(([, t]) => t.name === txName); - - if (!textureEntry) { - Con.DPrint(`BSP29Loader: referenced material (${txName}) is not used\n`); - continue; - } - - const [txIndex, texture] = textureEntry; - const pbr = new PBRMaterial(texture.name, texture.width, texture.height); - - for (const category of ['luminance', 'diffuse', 'specular', 'normal']) { - if (textures[category]) { - try { - pbr[category] = await GLTexture.FromImageFile(textures[category]); - Con.DPrint(`BSP29Loader: loaded ${category} texture for ${texture.name} from ${textures[category]}\n`); - } catch (e) { - Con.PrintError(`BSP29Loader: failed to load ${textures[category]}: ${e.message}\n`); - } - } - } - - if (textures.flags) { - for (const flagName of textures.flags) { - const flagValue = materialFlags[flagName]; - console.assert(typeof flagValue === 'number', `BSP29Loader: unknown material flag ${flagName} in ${loadmodel.name}`); - pbr.flags |= flagValue; - } - } - - if (!textures.diffuse && (texture instanceof QuakeMaterial)) { - pbr.diffuse = texture.texture; // keep original diffuse as base - } - - loadmodel.textures[txIndex] = pbr; // replace with PBR material - } - } - - /** - * Load lighting data from BSP lump - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - */ - _loadLighting(loadmodel, buf) { - loadmodel.lightdata_rgb = null; - loadmodel.lightdata = null; - - const view = new DataView(buf); - const lump = BSP29Loader.#lump; - const fileofs = view.getUint32((lump.lighting << 3) + 4, true); - const filelen = view.getUint32((lump.lighting << 3) + 8, true); - - if (filelen === 0) { - return; - } - - loadmodel.lightdata = new Uint8Array(new ArrayBuffer(filelen)); - loadmodel.lightdata.set(new Uint8Array(buf, fileofs, filelen)); - loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs + filelen); - } - - /** - * Load visibility data for potentially visible set (PVS) calculations - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - */ - _loadVisibility(loadmodel, buf) { - const view = new DataView(buf); - const lump = BSP29Loader.#lump; - const fileofs = view.getUint32((lump.visibility << 3) + 4, true); - const filelen = view.getUint32((lump.visibility << 3) + 8, true); - - if (filelen === 0) { - return; - } - - loadmodel.visdata = new Uint8Array(new ArrayBuffer(filelen)); - loadmodel.visdata.set(new Uint8Array(buf, fileofs, filelen)); - loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs + filelen); - } - - /** - * Build cluster-native visibility data structures after vis + leafs are loaded. - * Sets numclusters, builds clusterPvsOffsets from leaf visofs values, and - * computes PHS (Potentially Hearable Set) via transitive closure of PVS. - * @protected - * @param {BrushModel} loadmodel - The model being loaded - */ - _buildClusterData(loadmodel) { - const numclusters = loadmodel.leafs.length - 1; // leaf 0 is outside sentinel - loadmodel.numclusters = numclusters; - - // BSP29/BSP2 maps have no area data — single area, no portals - loadmodel.numAreas = 1; - loadmodel.areaPortals.init(1, []); - - if (loadmodel.visdata === null || numclusters <= 0) { - return; - } - - // Build clusterPvsOffsets from leaf visofs values - // For BSP29: cluster c = leaf c+1, so clusterPvsOffsets[c] = leafs[c+1].visofs - const clusterPvsOffsets = new Array(numclusters); - - for (let c = 0; c < numclusters; c++) { - clusterPvsOffsets[c] = loadmodel.leafs[c + 1].visofs; - } - - loadmodel.clusterPvsOffsets = clusterPvsOffsets; - - // Compute PHS via transitive closure of PVS - this._computePHS(loadmodel); - } - - /** - * Compute PHS (Potentially Hearable Set) data by transitive closure of PVS. - * For each cluster, the PHS includes all clusters visible from any cluster - * that is itself visible from the source cluster (one-hop expansion). - * @protected - * @param {BrushModel} loadmodel - The model being loaded - */ - _computePHS(loadmodel) { - const numclusters = loadmodel.numclusters; - const clusterBytes = (numclusters + 7) >> 3; - const visdata = loadmodel.visdata; - const offsets = loadmodel.clusterPvsOffsets; - - if (visdata === null || offsets === null || numclusters <= 0) { - return; - } - - // Decompress all PVS rows into a flat buffer for fast OR operations - const pvsRows = new Uint8Array(numclusters * clusterBytes); - for (let c = 0; c < numclusters; c++) { - const rowStart = c * clusterBytes; - - if (offsets[c] < 0) { - continue; // no vis for this cluster; row stays zero - } - - for (let _out = 0, _in = offsets[c]; _out < clusterBytes;) { - // Bounds check to prevent reading past end of visdata - // Note: It's normal for visibility data to end before clusterBytes is filled; - // remaining bytes stay zero, which is correct for unvisible clusters. - if (_in >= visdata.length) { - break; - } - - if (visdata[_in] !== 0) { - pvsRows[rowStart + _out++] = visdata[_in++]; - continue; - } - - // RLE: 0 byte followed by count of zeros - if (_in + 1 >= visdata.length) { - // End of RLE data; remaining output stays zero (unvisible) - break; - } - - for (let skip = visdata[_in + 1]; skip > 0; skip--) { - pvsRows[rowStart + _out++] = 0x00; - } - - _in += 2; - } - } - - // Transitive closure: PHS[src] = OR of PVS[c] for all c where PVS[src] has bit c set - const phsRows = new Uint8Array(numclusters * clusterBytes); - for (let src = 0; src < numclusters; src++) { - const srcPvsStart = src * clusterBytes; - const dstPhsStart = src * clusterBytes; - - // Start with the PVS itself - for (let b = 0; b < clusterBytes; b++) { - phsRows[dstPhsStart + b] = pvsRows[srcPvsStart + b]; - } - - // OR in PVS of every cluster visible from src - for (let c = 0; c < numclusters; c++) { - if ((pvsRows[srcPvsStart + (c >> 3)] & (1 << (c & 7))) === 0) { - continue; - } - - const neighborPvsStart = c * clusterBytes; - - for (let b = 0; b < clusterBytes; b++) { - phsRows[dstPhsStart + b] |= pvsRows[neighborPvsStart + b]; - } - } - } - - // RLE-compress PHS rows into phsdata + build clusterPhsOffsets - // Worst case: each row expands to clusterBytes (no compression) - // Typical case: significant compression due to zero runs - // Format: Non-zero bytes are literal, [0, count] represents count zero bytes - const phsBuffer = []; - const clusterPhsOffsets = new Array(numclusters); - - for (let c = 0; c < numclusters; c++) { - clusterPhsOffsets[c] = phsBuffer.length; - const rowStart = c * clusterBytes; - let i = 0; - - while (i < clusterBytes) { - if (phsRows[rowStart + i] !== 0) { - // Literal non-zero byte - phsBuffer.push(phsRows[rowStart + i]); - i++; - } else { - // Count zero run (up to 255 bytes) - let zeroCount = 0; - - while (i < clusterBytes && phsRows[rowStart + i] === 0 && zeroCount < 255) { - i++; - zeroCount++; - } - - // Encode as [0, count] - phsBuffer.push(0); - phsBuffer.push(zeroCount); - } - } - } - - loadmodel.phsdata = new Uint8Array(phsBuffer); - loadmodel.clusterPhsOffsets = clusterPhsOffsets; - } - - /** - * Compute visibility areas and portals based on door entities and PHS. - * This traverses the BSP tree to classify leaves against door planes, - * then clusters them based on PVS connectivity. - * @protected - * @param {BrushModel} loadmodel - The model being loaded - */ - _computeAreas(loadmodel) { - // 1. Identify Portal Definitions (Doors) - const portalDefs = this.#parsePortalEntities(loadmodel); - - // 2. Build Portal Planes from Door BBoxes - // Merge bboxes for shared portalNums (e.g. double doors) - const mergedBboxes = new Map(); - - for (const def of portalDefs) { - const submodel = loadmodel.submodels[def.modelIndex - 1]; // *N -> submodels[N-1] - - if (!submodel) { - Con.PrintWarning(`BSP29Loader._computeAreas: portal ${def.portalNum} references invalid model *${def.modelIndex}\n`); - continue; - } - - const existing = mergedBboxes.get(def.portalNum); - - if (existing) { - for (let k = 0; k < 3; k++) { - existing.mins[k] = Math.min(existing.mins[k], submodel.mins[k]); - existing.maxs[k] = Math.max(existing.maxs[k], submodel.maxs[k]); - } - } else { - mergedBboxes.set(def.portalNum, { - mins: [submodel.mins[0], submodel.mins[1], submodel.mins[2]], - maxs: [submodel.maxs[0], submodel.maxs[1], submodel.maxs[2]], - }); - } - } - - const portals = []; - - for (const [portalNum, bbox] of mergedBboxes) { - // Determine split plane (thinnest axis) - const size = [bbox.maxs[0] - bbox.mins[0], bbox.maxs[1] - bbox.mins[1], bbox.maxs[2] - bbox.mins[2]]; - let axis = 0; - - if (size[1] < size[0]) { - axis = 1; - } - - if (size[2] < size[axis]) { - axis = 2; - } - - const dist = (bbox.mins[axis] + bbox.maxs[axis]) * 0.5; - const thickness = (bbox.maxs[axis] - bbox.mins[axis]) * 0.5; - const offset = Math.max(24.0, thickness + 16.0); // Offset for PHS sampling - - // Sample PHS points - const center = [(bbox.mins[0] + bbox.maxs[0]) * 0.5, (bbox.mins[1] + bbox.maxs[1]) * 0.5, (bbox.mins[2] + bbox.maxs[2]) * 0.5]; - const backPt = [...center]; - backPt[axis] -= offset; - const frontPt = [...center]; - frontPt[axis] += offset; - - portals.push({ - portalNum, - axis, - dist, - backVis: loadmodel.getPhsByPoint(new Vector(...backPt)), - frontVis: loadmodel.getPhsByPoint(new Vector(...frontPt)), - }); - } - - // 3. Traverse BSP Nodes to assign "Side" Signatures - // Optimization: If a node is fully on one side of a portal plane, all its children are too. - const leafSignatures = new Map(); // leafIndex -> "signature string" - - /** - * Recursively classify nodes against portal planes - * @param {import('../BSP.mjs').Node} node Current BSP node being classified. - * @param {number[]} states - Array of 0 (Back), 1 (Front), or -1 (Unknown) - */ - const classifyRecursive = (node, states) => { - // Check unknown portals against node bounds - const nextStates = states.slice(); - - for (let i = 0; i < portals.length; i++) { - if (nextStates[i] !== -1) { - continue; - } - - const p = portals[i]; - - if (node.maxs[p.axis] < p.dist) { - nextStates[i] = 0; - } else if (node.mins[p.axis] > p.dist) { - nextStates[i] = 1; - } - } - - if (node.contents < 0) { - // Leaf Node - if (node.contents === content.CONTENT_SOLID) { - node.area = 0; - return; - } - - const cx = (node.mins[0] + node.maxs[0]) * 0.5; - const cy = (node.mins[1] + node.maxs[1]) * 0.5; - const cz = (node.mins[2] + node.maxs[2]) * 0.5; - const center = [cx, cy, cz]; - - let sig = ''; - - for (let i = 0; i < portals.length; i++) { - let side = nextStates[i]; - - if (side === -1) { - side = center[portals[i].axis] >= portals[i].dist ? 1 : 0; - } - - // Nearness check via PHS - const p = portals[i]; - const isNear = side === 0 ? p.backVis.isRevealed(node.num) : p.frontVis.isRevealed(node.num); - - // Sig Codes: 0=BackFar, 1=BackNear, 2=FrontNear, 3=FrontFar - let code = 0; - - if (side === 0) { - code = isNear ? 1 : 0; - } else { - code = isNear ? 2 : 3; - } - - sig += code.toString(); - } - - leafSignatures.set(node.num, sig); - return; - } - - // Inner Node - if (node.children[0]) { - classifyRecursive(node.children[0], nextStates); - } - if (node.children[1]) { - classifyRecursive(node.children[1], nextStates); - } - }; - - // Start traversal from root - const initialStates = new Array(portals.length).fill(-1); - classifyRecursive(loadmodel.nodes[0], initialStates); - - // 4. Cluster Signatures into Areas (PVS connectivity) - const sigGroups = new Map(); - - for (const [leafNum, sig] of leafSignatures) { - if (!sigGroups.has(sig)) { - sigGroups.set(sig, []); - } - sigGroups.get(sig).push(leafNum); - } - - let nextArea = 1; - /** @type {{ sig: string, area: number }[]} */ - const areasList = []; - /** @type {Map} areaID -> leafNum[] */ - const areaLeafsMap = new Map(); - - for (const [sig, leafIndices] of sigGroups) { - const visited = new Set(); - - for (const startLeaf of leafIndices) { - if (visited.has(startLeaf)) { - continue; - } - - const area = nextArea++; - areasList.push({ sig, area }); - areaLeafsMap.set(area, []); - - // BFS within this signature group using PVS - const queue = [startLeaf]; - visited.add(startLeaf); - loadmodel.leafs[startLeaf].area = area; - areaLeafsMap.get(area).push(startLeaf); - - while (queue.length > 0) { - const current = queue.shift(); - const pvs = loadmodel.getPvsByLeaf(loadmodel.leafs[current]); - - // We only need to check leaves in the same signature group - for (const candidate of leafIndices) { - if (!visited.has(candidate) && pvs.isRevealed(candidate)) { - visited.add(candidate); - loadmodel.leafs[candidate].area = area; - areaLeafsMap.get(area).push(candidate); - queue.push(candidate); - } - } - } - } - } - - loadmodel.numAreas = nextArea; - - // 5. Connect Areas - /** @type {{ area0: number, area1: number, group: number }[]} */ - const allConnections = []; - - for (let i = 0; i < areasList.length; i++) { - for (let j = i + 1; j < areasList.length; j++) { - const { sig: sigA, area: areaA } = areasList[i]; - const { sig: sigB, area: areaB } = areasList[j]; - - // Differences logic: - // 1 -> 2 (BackNear <-> FrontNear) : Door Crossing (Gated) - // Others: Open connection (just movement) - let diffCount = 0; - let doorCount = 0; - let doorGroup = -1; - - for (let k = 0; k < sigA.length; k++) { - if (sigA[k] !== sigB[k]) { - diffCount++; - const s1 = parseInt(sigA[k]); - const s2 = parseInt(sigB[k]); - - // Check if transition is BackNear(1) <-> FrontNear(2) - if ((s1 === 1 && s2 === 2) || (s1 === 2 && s2 === 1)) { - doorCount++; - doorGroup = portals[k].portalNum; - } - } - } - - if (diffCount === 0) { - continue; - } - - // PVS Adjacency Check - const leafsA = areaLeafsMap.get(areaA); - const leafsB = areaLeafsMap.get(areaB); - let pvsAdjacent = false; - - // Check A seeing B (optimization: only check first few or scan until hit) - for (let li = 0; li < leafsA.length && !pvsAdjacent; li++) { - const pvs = loadmodel.getPvsByLeaf(loadmodel.leafs[leafsA[li]]); - for (let lj = 0; lj < leafsB.length; lj++) { - if (pvs.isRevealed(leafsB[lj])) { - pvsAdjacent = true; - break; - } - } - } - - if (!pvsAdjacent) { - continue; - } - - if (doorCount === 1) { - allConnections.push({ area0: areaA, area1: areaB, group: doorGroup }); - } else if (doorCount === 0) { - allConnections.push({ area0: areaA, area1: areaB, group: -1 }); - } - } - } - - let maxGroup = 0; - for (const c of allConnections) { - if (c.group > maxGroup) { - maxGroup = c.group; - } - } - const numGroups = maxGroup + 1; - - loadmodel.portalDefs = allConnections; - loadmodel.areaPortals.init(loadmodel.numAreas, allConnections, numGroups); - - Con.DPrint(`_computeAreas: Computed ${loadmodel.numAreas} areas, ${allConnections.length} connections, ${numGroups} groups\n`); - } - - /** @type {Set} Classnames that are auto-assigned portal numbers */ - static doorClassnames = new Set(['func_door', 'func_door_secret', 'func_buyzone_shutters']); - - /** - * Parse the entity lump for portal entity definitions. - * Entities with an explicit "portal" key are used directly. Door entities - * (func_door, func_door_secret) with brush models are auto-assigned portal - * numbers if they don't have an explicit one. The mapping from model name - * to portal number is stored in loadmodel.modelPortalMap. - * @param {BrushModel} loadmodel - The model being loaded - * @returns {{ portalNum: number, modelIndex: number }[]} portal definitions - */ - #parsePortalEntities(loadmodel) { - /** @type {{ portalNum: number, modelIndex: number }[]} */ - const portals = []; - - // First pass: collect explicit portals and door entities needing auto-assignment - /** @type {{ modelIndex: number, model: string }[]} */ - const autoAssignDoors = []; - let maxExplicitPortal = -1; - - let data = loadmodel.entities; - - Con.DPrint('BSP29Loader.#parsePortalEntities: looking for portals in entity lump...\n'); - - while (data) { - const parsed = COM.Parse(data); - data = parsed.data; - - if (!data) { - break; - } - - // Parse one entity block - /** @type {Record} */ - const ent = {}; - - while (data) { - const parsedKey = COM.Parse(data); - data = parsedKey.data; - - if (!data || parsedKey.token === '}') { - break; - } - - const parsedValue = COM.Parse(data); - data = parsedValue.data; - - if (!data || parsedValue.token === '}') { - break; - } - - ent[parsedKey.token] = parsedValue.token; - } - - if (!ent.model || !ent.model.startsWith('*')) { - continue; - } - - const modelIndex = parseInt(ent.model.substring(1), 10); - - if (isNaN(modelIndex) || modelIndex <= 0) { - continue; - } - - // Explicit portal key takes priority - if (ent.portal !== undefined) { - const portalNum = parseInt(ent.portal, 10); - - if (!isNaN(portalNum) && portalNum >= 0) { - portals.push({ portalNum, modelIndex }); - loadmodel.modelPortalMap[ent.model] = portalNum; - - if (portalNum > maxExplicitPortal) { - maxExplicitPortal = portalNum; - } - } - - continue; - } - - // CR: temporarily disabled due to funny bugs - // // Auto-assign portal numbers to door entities - // if (ent.classname && BSP29Loader.doorClassnames.has(ent.classname)) { - // Con.DPrint(`...detected portal ${ent.classname} with model ${ent.model}\n`); - // autoAssignDoors.push({ modelIndex, model: ent.model }); - // } - } - - // Auto-assign portal numbers starting after the highest explicit one. - // Double doors (two touching door halves) must share a single portal - // number, otherwise each half generates its own splitting plane and - // the area assignment breaks. - let nextPortal = maxExplicitPortal + 1; - - // Group touching doors using union-find so linked door pairs share - // a portal. This mirrors the game-side _linkDoors() touching test. - const doorCount = autoAssignDoors.length; - - /** @type {number[]} union-find parent array */ - const parent = autoAssignDoors.map((_, i) => i); - - /** - * @param {number} i - element index - * @returns {number} root of the set containing i - */ - const find = (i) => { - while (parent[i] !== i) { - parent[i] = parent[parent[i]]; // path compression - i = parent[i]; - } - - return i; - }; - - /** - * @param {number} a - first element - * @param {number} b - second element - */ - const union = (a, b) => { - parent[find(a)] = find(b); - }; - - for (let i = 0; i < doorCount; i++) { - const smA = loadmodel.submodels[autoAssignDoors[i].modelIndex - 1]; - - if (!smA) { - continue; - } - - for (let j = i + 1; j < doorCount; j++) { - const smB = loadmodel.submodels[autoAssignDoors[j].modelIndex - 1]; - - if (!smB) { - continue; - } - - // Same touching test as BaseEntity.isTouching / QuakeC EntitiesTouching - if (smA.mins[0] <= smB.maxs[0] && smA.maxs[0] >= smB.mins[0] - && smA.mins[1] <= smB.maxs[1] && smA.maxs[1] >= smB.mins[1] - && smA.mins[2] <= smB.maxs[2] && smA.maxs[2] >= smB.mins[2]) { - union(i, j); - } - } - } - - // Assign one portal number per group - /** @type {Map} group root → portal number */ - const groupPortal = new Map(); - - for (let i = 0; i < doorCount; i++) { - const door = autoAssignDoors[i]; - - if (loadmodel.modelPortalMap[door.model]) { - continue; - } - - const root = find(i); - let portalNum = groupPortal.get(root); - - if (portalNum === undefined) { - portalNum = nextPortal++; - groupPortal.set(root, portalNum); - } - - portals.push({ portalNum, modelIndex: door.modelIndex }); - loadmodel.modelPortalMap[door.model] = portalNum; - } - - if (autoAssignDoors.length > 0) { - Con.DPrint(`BSP29Loader.#parsePortalEntities: auto-assigned ${autoAssignDoors.length} door portals (${groupPortal.size} groups)\n`); - } - - return portals; - } - - /** - * Load entities from BSP lump and parse worldspawn properties. - * Also this tries to parse light entities to determine if RGB lighting is used and whether we need to load the .lit file. - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - */ - _loadEntities(loadmodel, buf) { - const view = new DataView(buf); - const lump = BSP29Loader.#lump; - const fileofs = view.getUint32((lump.entities << 3) + 4, true); - const filelen = view.getUint32((lump.entities << 3) + 8, true); - loadmodel.entities = Q.memstr(new Uint8Array(buf, fileofs, filelen)); - loadmodel.worldspawnInfo = {}; - - let data = loadmodel.entities; - - // going for worldspawn and light - let stillLooking = 2; - while (stillLooking > 0) { - const parsed = COM.Parse(data); - data = parsed.data; - - if (!data) { - break; - } - - const currentEntity = {}; - while (data) { - const parsedKey = COM.Parse(data); - data = parsedKey.data; - - if (!data || parsedKey.token === '}') { - break; - } - - const parsedValue = COM.Parse(data); - data = parsedValue.data; - - if (!data || parsedKey.token === '}') { - break; - } - - currentEntity[parsedKey.token] = parsedValue.token; - } - - if (!currentEntity.classname) { - break; - } - - switch (currentEntity.classname) { - case 'worldspawn': - Object.assign(loadmodel.worldspawnInfo, currentEntity); - stillLooking--; - break; - - case 'light': - if (currentEntity._color) { - loadmodel.coloredlights = true; - stillLooking--; - } - break; - } - } - - // Second pass: parse func_fog entities from the entity lump - // (moved to load() after _loadSubmodels so we can also scan submodel faces) - - loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs + filelen); - } - - /** - * Parse func_fog entities from the BSP entity lump and store - * them as fog volume descriptors on the model. - * @protected - * @param {BrushModel} loadmodel - The model being loaded - */ - _parseFogVolumes(loadmodel) { - loadmodel.fogVolumes.length = 0; - - let data = loadmodel.entities; - if (!data) { - return; - } - - while (data) { - const parsed = COM.Parse(data); - data = parsed.data; - - if (!data) { - break; - } - - const entity = {}; - - while (data) { - const parsedKey = COM.Parse(data); - data = parsedKey.data; - - if (!data || parsedKey.token === '}') { - break; - } - - const parsedValue = COM.Parse(data); - data = parsedValue.data; - - if (!data || parsedValue.token === '}') { - break; - } - - entity[parsedKey.token] = parsedValue.token; - } - - if (entity.classname !== 'func_fog' || !entity.model) { - continue; - } - - const modelIndex = parseInt(entity.model.substring(1), 10); - - if (isNaN(modelIndex) || modelIndex <= 0) { - Con.PrintWarning(`func_fog has invalid model '${entity.model}'\n`); - continue; - } - - const colorParts = (entity.fog_color || '128 128 128').split(/\s+/).map(Number); - const submodel = loadmodel.submodels[modelIndex - 1]; - - loadmodel.fogVolumes.push({ - modelIndex, - color: [colorParts[0] || 128, colorParts[1] || 128, colorParts[2] || 128], - density: parseFloat(entity.fog_density || '0.01'), - maxOpacity: Math.min(1.0, Math.max(0.0, parseFloat(entity.fog_max_opacity || '0.8'))), - mins: submodel ? [submodel.mins[0], submodel.mins[1], submodel.mins[2]] : [0, 0, 0], - maxs: submodel ? [submodel.maxs[0], submodel.maxs[1], submodel.maxs[2]] : [0, 0, 0], - }); - - Con.DPrint(`Found func_fog: model *${modelIndex}, color ${colorParts}, density ${entity.fog_density || '0.01'}\n`); - } - - if (loadmodel.worldspawnInfo._qs_autogen_fog !== '1') { - Con.DPrint('Auto-generation of fog volumes is disabled.\n'); - return; - } - - // Auto-generate fog volumes for turbulent submodels (water, slime, lava) - // that weren't already claimed by a func_fog entity - const claimedModels = new Set(loadmodel.fogVolumes.map((fv) => fv.modelIndex)); - - for (let i = 0; i < loadmodel.submodels.length; i++) { - const modelIndex = i + 1; // submodels[0] = *1, submodels[1] = *2, etc. - - if (claimedModels.has(modelIndex)) { - continue; - } - - const submodel = loadmodel.submodels[i]; - - // Check if ALL faces in this submodel are turbulent - let allTurbulent = submodel.numfaces > 0; - /** @type {number} */ - let turbulentTextureIndex = -1; - - for (let j = 0; j < submodel.numfaces; j++) { - const face = submodel.faces[submodel.firstface + j]; - const material = loadmodel.textures[face.texture]; - - if (!(material.flags & materialFlags.MF_TURBULENT)) { - allTurbulent = false; - break; - } - - if (turbulentTextureIndex === -1) { - turbulentTextureIndex = face.texture; - } - } - - if (!allTurbulent || turbulentTextureIndex === -1) { - continue; - } - - // Derive fog color from the texture's average color, modulated by ambient light - const material = loadmodel.textures[turbulentTextureIndex]; - const baseColor = material.averageColor || [128, 128, 128]; - const volMins = [submodel.mins[0], submodel.mins[1], submodel.mins[2]]; - const volMaxs = [submodel.maxs[0], submodel.maxs[1], submodel.maxs[2]]; - const lightFactor = this._sampleAmbientLightForVolume(loadmodel, volMins, volMaxs); - const color = [ - Math.round(baseColor[0] * lightFactor), - Math.round(baseColor[1] * lightFactor), - Math.round(baseColor[2] * lightFactor), - ]; - - loadmodel.fogVolumes.push({ - modelIndex, - color, - density: 0.02, - maxOpacity: 0.85, - mins: volMins, - maxs: volMaxs, - }); - - Con.DPrint(`Auto-fog for turbulent *${modelIndex} (${material.name}): color [${color}], light=${lightFactor.toFixed(2)}\n`); - } - - // Phase 3: Auto-generate fog volumes for world-level turbulent surfaces - // (water/slime/lava that belong to the worldspawn, not a brush entity). - // These exist as BSP leafs with CONTENT_WATER/SLIME/LAVA contents. - // We cluster adjacent leafs of the same type and create fog volumes from their merged AABBs. - this._parseFogVolumesFromWorldLeafs(loadmodel); - } - - /** - * Create fog volumes for world-level water/slime/lava directly from BSP leafs. - * Each liquid leaf gets its own fog volume with exact bounds from the BSP compiler, - * avoiding imprecision from merging AABBs across multiple leafs. - * @private - * @param {BrushModel} loadmodel - The model being loaded - */ - _parseFogVolumesFromWorldLeafs(loadmodel) { - if (!loadmodel.leafs || loadmodel.leafs.length === 0) { - return; - } - - /** @type {Map} content type -> leaf indices (for color lookup) */ - const liquidLeafsByType = new Map(); - - for (let i = 0; i < loadmodel.leafs.length; i++) { - const leaf = loadmodel.leafs[i]; - const c = leaf.contents; - - if (c !== content.CONTENT_WATER && c !== content.CONTENT_SLIME && c !== content.CONTENT_LAVA) { - continue; - } - - if (!liquidLeafsByType.has(c)) { - liquidLeafsByType.set(c, []); - } - liquidLeafsByType.get(c).push(i); - } - - if (liquidLeafsByType.size === 0) { - return; - } - - for (const [contentType, leafIndices] of liquidLeafsByType) { - // Find dominant turbulent texture color across all leafs of this type - const color = this._findClusterTurbulentColor(loadmodel, leafIndices) || [128, 128, 128]; - - const contentName = contentType === content.CONTENT_WATER ? 'water' - : contentType === content.CONTENT_SLIME ? 'slime' : 'lava'; - - const density = contentType === content.CONTENT_LAVA ? 0.05 - : contentType === content.CONTENT_SLIME ? 0.01 : 0.005; - - // Create one fog volume per leaf with exact BSP bounds, modulated by ambient light - for (const leafIdx of leafIndices) { - const leaf = loadmodel.leafs[leafIdx]; - const volMins = [leaf.mins[0], leaf.mins[1], leaf.mins[2]]; - const volMaxs = [leaf.maxs[0], leaf.maxs[1], leaf.maxs[2]]; - const lightFactor = this._sampleAmbientLightForVolume(loadmodel, volMins, volMaxs); - const dimmedColor = [ - Math.round(color[0] * lightFactor), - Math.round(color[1] * lightFactor), - Math.round(color[2] * lightFactor), - ]; - - loadmodel.fogVolumes.push({ - modelIndex: 0, - color: dimmedColor, - density, - maxOpacity: 0.85, - mins: volMins, - maxs: volMaxs, - }); - - Con.DPrint(`Auto-fog: ${leafIdx} ${contentName} leaf volume, base color [${color}], lightFactor = ${lightFactor.toFixed(2)}\n`); - } - } - } - - /** - * Find the average color of the dominant turbulent texture in a set of leafs. - * Scans marksurfaces for turbulent faces and returns the most common texture's color. - * @param {BrushModel} loadmodel - The model with leaf/face/texture data - * @param {number[]} leafIndices - Leaf indices to scan - * @returns {number[]|null} Average color as [r, g, b] or null if no turbulent face found - */ - _findClusterTurbulentColor(loadmodel, leafIndices) { - /** @type {Map} texture index -> face count */ - const textureCounts = new Map(); - - for (const leafIdx of leafIndices) { - const leaf = loadmodel.leafs[leafIdx]; - - for (let k = 0; k < leaf.nummarksurfaces; k++) { - const faceIdx = loadmodel.marksurfaces[leaf.firstmarksurface + k]; - const face = loadmodel.faces[faceIdx]; - - if (!face.turbulent) { - continue; - } - - textureCounts.set(face.texture, (textureCounts.get(face.texture) || 0) + 1); - } - } - - if (textureCounts.size === 0) { - return null; - } - - // Find the most common turbulent texture - let bestTexture = -1; - let bestCount = 0; - - for (const [texIdx, count] of textureCounts) { - if (count > bestCount) { - bestCount = count; - bestTexture = texIdx; - } - } - - const material = loadmodel.textures[bestTexture]; - return material?.averageColor || null; - } - - /** - * Sample the average ambient light intensity near a fog volume's bounding box. - * Scans BSP leafs that overlap the expanded AABB and samples lightmap data - * from non-turbulent, non-sky faces to estimate the local light level. - * @param {BrushModel} loadmodel - The model with leaf/face/lighting data - * @param {number[]} mins - Volume AABB minimum [x, y, z] - * @param {number[]} maxs - Volume AABB maximum [x, y, z] - * @returns {number} Normalized intensity factor in [0.15, 1.0] range - */ - _sampleAmbientLightForVolume(loadmodel, mins, maxs) { - if ((loadmodel.lightdata_rgb === null && loadmodel.lightdata === null) || !loadmodel.leafs || loadmodel.leafs.length === 0) { - return 1.0; - } - - // Expand AABB by 64 units to catch nearby lit surfaces - const expand = 64; - const eMins = [mins[0] - expand, mins[1] - expand, mins[2] - expand]; - const eMaxs = [maxs[0] + expand, maxs[1] + expand, maxs[2] + expand]; - - let totalIntensity = 0; - let sampleCount = 0; - - const hasRGB = loadmodel.lightdata_rgb !== null; - - for (let i = 0; i < loadmodel.leafs.length; i++) { - const leaf = loadmodel.leafs[i]; - - // Skip liquid leafs — we want light from surrounding solid geometry - if (leaf.contents === content.CONTENT_WATER - || leaf.contents === content.CONTENT_SLIME - || leaf.contents === content.CONTENT_LAVA) { - continue; - } - - // AABB overlap test - if (leaf.mins[0] > eMaxs[0] || leaf.maxs[0] < eMins[0] - || leaf.mins[1] > eMaxs[1] || leaf.maxs[1] < eMins[1] - || leaf.mins[2] > eMaxs[2] || leaf.maxs[2] < eMins[2]) { - continue; - } - - // Scan marksurfaces in this leaf - for (let k = 0; k < leaf.nummarksurfaces; k++) { - const faceIdx = loadmodel.marksurfaces[leaf.firstmarksurface + k]; - const face = loadmodel.faces[faceIdx]; - - if (face.turbulent || face.sky || face.lightofs < 0 || face.styles.length === 0) { - continue; - } - - // Compute lightmap dimensions for this face - const smax = (face.extents[0] >> face.lmshift) + 1; - const tmax = (face.extents[1] >> face.lmshift) + 1; - const size = smax * tmax; - - if (size <= 0 || size > 4096) { - continue; - } - - // Sample only the first light style (style 0 = static light) - if (hasRGB) { - const offset = face.lightofs * 3; - - if (offset + size * 3 > loadmodel.lightdata_rgb.length) { - continue; - } - - for (let s = 0; s < size * 3; s += 3) { - // Perceptual luminance - totalIntensity += loadmodel.lightdata_rgb[offset + s] * 0.299 - + loadmodel.lightdata_rgb[offset + s + 1] * 0.587 - + loadmodel.lightdata_rgb[offset + s + 2] * 0.114; - sampleCount++; - } - } else { - const offset = face.lightofs; - - if (offset + size > loadmodel.lightdata.length) { - continue; - } - - for (let s = 0; s < size; s++) { - totalIntensity += loadmodel.lightdata[offset + s]; - sampleCount++; - } - } - } - } - - if (sampleCount === 0) { - return 1.0; - } - - // Average intensity in 0-255 range, normalize to a 0-1 scale. - // Quake lighting with value ~200 is considered well-lit; we use 200 as reference - // so well-lit areas keep the fog color roughly unchanged. - const avgIntensity = totalIntensity / sampleCount; - const factor = Math.min(1.0, Math.max(0.15, avgIntensity / 200.0)); - return factor; - } - - /** - * Compute the average color of an RGBA texture buffer. - * Skips fully transparent pixels so alpha-masked areas don't dilute the color. - * @param {Uint8Array} rgba - RGBA pixel data - * @returns {number[]} Average color as [r, g, b] in 0-255 range - */ - static _computeAverageColor(rgba) { - let r = 0; - let g = 0; - let b = 0; - let count = 0; - - for (let i = 0; i < rgba.length; i += 4) { - if (rgba[i + 3] === 0) { - continue; // skip fully transparent pixels - } - r += rgba[i]; - g += rgba[i + 1]; - b += rgba[i + 2]; - count++; - } - - if (count === 0) { - return [128, 128, 128]; - } - - return [ - Math.round(r / count), - Math.round(g / count), - Math.round(b / count), - ]; - } - - /** - * Load vertices from BSP lump - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - * @throws {Error} If vertex lump size is not a multiple of 12 - */ - _loadVertexes(loadmodel, buf) { - const view = new DataView(buf); - const lump = BSP29Loader.#lump; - let fileofs = view.getUint32((lump.vertexes << 3) + 4, true); - const filelen = view.getUint32((lump.vertexes << 3) + 8, true); - if ((filelen % 12) !== 0) { - throw new Error('BSP29Loader: vertexes lump size is not a multiple of 12 in ' + loadmodel.name); - } - const count = filelen / 12; - loadmodel.vertexes.length = 0; - for (let i = 0; i < count; i++) { - loadmodel.vertexes[i] = new Vector( - view.getFloat32(fileofs, true), - view.getFloat32(fileofs + 4, true), - view.getFloat32(fileofs + 8, true), - ); - fileofs += 12; - } - loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); - } - - /** - * Load edges from BSP lump - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - * @throws {CorruptedResourceError} If edge lump size is not a multiple of 4 - */ - _loadEdges(loadmodel, buf) { - const view = new DataView(buf); - const lump = BSP29Loader.#lump; - let fileofs = view.getUint32((lump.edges << 3) + 4, true); - const filelen = view.getUint32((lump.edges << 3) + 8, true); - if ((filelen & 3) !== 0) { - throw new CorruptedResourceError(loadmodel.name, 'BSP29Loader: edges lump size is not a multiple of 4'); - } - const count = filelen >> 2; - loadmodel.edges.length = 0; - for (let i = 0; i < count; i++) { - loadmodel.edges[i] = [view.getUint16(fileofs, true), view.getUint16(fileofs + 2, true)]; - fileofs += 4; - } - loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); - } - - /** - * Load surface edges from BSP lump (indices into edge array, negative = reversed) - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - */ - _loadSurfedges(loadmodel, buf) { - const view = new DataView(buf); - const lump = BSP29Loader.#lump; - const fileofs = view.getUint32((lump.surfedges << 3) + 4, true); - const filelen = view.getUint32((lump.surfedges << 3) + 8, true); - const count = filelen >> 2; - loadmodel.surfedges.length = 0; - for (let i = 0; i < count; i++) { - loadmodel.surfedges[i] = view.getInt32(fileofs + (i << 2), true); - } - loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs + filelen); - } - - /** - * Load planes from BSP lump - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - * @throws {Error} If planes lump size is not a multiple of 20 - */ - _loadPlanes(loadmodel, buf) { - const view = new DataView(buf); - const lump = BSP29Loader.#lump; - let fileofs = view.getUint32((lump.planes << 3) + 4, true); - const filelen = view.getUint32((lump.planes << 3) + 8, true); - if ((filelen % 20) !== 0) { - throw new Error('BSP29Loader: planes lump size is not a multiple of 20 in ' + loadmodel.name); - } - const count = filelen / 20; - loadmodel.planes.length = 0; - for (let i = 0; i < count; i++) { - const normal = new Vector( - view.getFloat32(fileofs, true), - view.getFloat32(fileofs + 4, true), - view.getFloat32(fileofs + 8, true), - ); - const dist = view.getFloat32(fileofs + 12, true); - const out = new Plane(normal, dist); - out.type = view.getUint32(fileofs + 16, true); - if (out.normal[0] < 0) { out.signbits |= 1; } - if (out.normal[1] < 0) { out.signbits |= 2; } - if (out.normal[2] < 0) { out.signbits |= 4; } - loadmodel.planes[i] = out; - fileofs += 20; - } - loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); - } - - /** - * Load texture coordinate information from BSP lump - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - * @throws {Error} If texinfo lump size is not a multiple of 40 - */ - _loadTexinfo(loadmodel, buf) { - const view = new DataView(buf); - const lump = BSP29Loader.#lump; - let fileofs = view.getUint32((lump.texinfo << 3) + 4, true); - const filelen = view.getUint32((lump.texinfo << 3) + 8, true); - if ((filelen % 40) !== 0) { - throw new Error('BSP29Loader: texinfo lump size is not a multiple of 40 in ' + loadmodel.name); - } - const count = filelen / 40; - loadmodel.texinfo.length = 0; - for (let i = 0; i < count; i++) { - const out = { - vecs: [ - [view.getFloat32(fileofs, true), view.getFloat32(fileofs + 4, true), view.getFloat32(fileofs + 8, true), view.getFloat32(fileofs + 12, true)], - [view.getFloat32(fileofs + 16, true), view.getFloat32(fileofs + 20, true), view.getFloat32(fileofs + 24, true), view.getFloat32(fileofs + 28, true)], - ], - texture: view.getUint32(fileofs + 32, true), - flags: view.getUint32(fileofs + 36, true), - }; - if (out.texture >= loadmodel.textures.length) { - out.texture = loadmodel.textures.length - 1; - out.flags = 0; - } - loadmodel.texinfo[i] = out; - fileofs += 40; - } - loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); - } - - /** - * Load faces (surfaces) from BSP lump and derive oriented face normals - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - * @throws {CorruptedResourceError} If faces lump size is not a multiple of 20 - */ - _loadFaces(loadmodel, buf) { - const view = new DataView(buf); - const lump = BSP29Loader.#lump; - let fileofs = view.getUint32((lump.faces << 3) + 4, true); - const filelen = view.getUint32((lump.faces << 3) + 8, true); - if ((filelen % 20) !== 0) { - throw new CorruptedResourceError(loadmodel.name, 'BSP29Loader: faces lump size is not a multiple of 20'); - } - - const lmshift = loadmodel.worldspawnInfo._lightmap_scale ? Math.log2(parseInt(loadmodel.worldspawnInfo._lightmap_scale)) : 4; - const count = filelen / 20; - loadmodel.firstface = 0; - loadmodel.numfaces = count; - loadmodel.faces.length = 0; - - for (let i = 0; i < count; i++) { - const styles = new Uint8Array(buf, fileofs + 12, 4); - const out = Object.assign(new Face(), { - plane: loadmodel.planes[view.getUint16(fileofs, true)], - planeBack: view.getInt16(fileofs + 2, true) !== 0, - firstedge: view.getInt32(fileofs + 4, true), - numedges: view.getUint16(fileofs + 8, true), - texinfo: view.getUint16(fileofs + 10, true), - styles: [], - lightofs: view.getInt32(fileofs + 16, true), - lmshift, - }); - - for (let j = 0; j < 4; j++) { - if (styles[j] !== 255) { - out.styles[j] = styles[j]; - } - } - - const mins = [Infinity, Infinity]; - const maxs = [-Infinity, -Infinity]; - const tex = loadmodel.texinfo[out.texinfo]; - out.texture = tex.texture; - - for (let j = 0; j < out.numedges; j++) { - const e = loadmodel.surfedges[out.firstedge + j]; - const v = e >= 0 - ? loadmodel.vertexes[loadmodel.edges[e][0]] - : loadmodel.vertexes[loadmodel.edges[-e][1]]; - - const val0 = v.dot(new Vector(...tex.vecs[0])) + tex.vecs[0][3]; - const val1 = v.dot(new Vector(...tex.vecs[1])) + tex.vecs[1][3]; - - if (val0 < mins[0]) { - mins[0] = val0; - } - - if (val0 > maxs[0]) { - maxs[0] = val0; - } - - if (val1 < mins[1]) { - mins[1] = val1; - } - - if (val1 > maxs[1]) { - maxs[1] = val1; - } - } - - const lmscale = 1 << out.lmshift; - out.texturemins = [Math.floor(mins[0] / lmscale) * lmscale, Math.floor(mins[1] / lmscale) * lmscale]; - out.extents = [Math.ceil(maxs[0] / lmscale) * lmscale - out.texturemins[0], Math.ceil(maxs[1] / lmscale) * lmscale - out.texturemins[1]]; - - if (loadmodel.textures[tex.texture].flags & materialFlags.MF_TURBULENT) { - out.turbulent = true; - } else if (loadmodel.textures[tex.texture].flags & materialFlags.MF_SKY) { - out.sky = true; - } - - out.normal.set(out.plane.normal); - if (out.planeBack) { - out.normal.multiply(-1.0); - } - - loadmodel.faces[i] = out; - fileofs += 20; - } - loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); - } - - /** - * Recursively set parent references for BSP tree nodes - * @protected - * @param {import('../BSP.mjs').Node} node - The node to set parent for - * @param {import('../BSP.mjs').Node|null} parent - The parent node - */ - _setParent(node, parent) { - node.parent = parent; - - if (node.contents < 0) { - return; - } - - this._setParent(/** @type {import('../BSP.mjs').Node} */(node.children[0]), node); - this._setParent(/** @type {import('../BSP.mjs').Node} */(node.children[1]), node); - } - - /** - * Load BSP tree nodes from BSP lump - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - * @throws {CorruptedResourceError} If nodes lump is empty or incorrectly sized - */ - _loadNodes(loadmodel, buf) { - const view = new DataView(buf); - const lump = BSP29Loader.#lump; - let fileofs = view.getUint32((lump.nodes << 3) + 4, true); - const filelen = view.getUint32((lump.nodes << 3) + 8, true); - - if ((filelen === 0) || ((filelen % 24) !== 0)) { - throw new Error('BSP29Loader: nodes lump size is invalid in ' + loadmodel.name); - } - - const count = filelen / 24; - loadmodel.nodes.length = 0; - - for (let i = 0; i < count; i++) { - loadmodel.nodes[i] = Object.assign(new Node(loadmodel), { - num: i, - planenum: view.getUint32(fileofs, true), - children: [view.getInt16(fileofs + 4, true), view.getInt16(fileofs + 6, true)], - mins: new Vector(view.getInt16(fileofs + 8, true), view.getInt16(fileofs + 10, true), view.getInt16(fileofs + 12, true)), - maxs: new Vector(view.getInt16(fileofs + 14, true), view.getInt16(fileofs + 16, true), view.getInt16(fileofs + 18, true)), - firstface: view.getUint16(fileofs + 20, true), - numfaces: view.getUint16(fileofs + 22, true), - }); - loadmodel.nodes[i].baseMins = loadmodel.nodes[i].mins.copy(); - loadmodel.nodes[i].baseMaxs = loadmodel.nodes[i].maxs.copy(); - fileofs += 24; - } - - for (let i = 0; i < count; i++) { - const out = loadmodel.nodes[i]; - out.plane = loadmodel.planes[out.planenum]; - // At this point children contain indices, we convert them to Node references - const child0Idx = /** @type {number} */ (out.children[0]); - const child1Idx = /** @type {number} */ (out.children[1]); - out.children[0] = child0Idx >= 0 - ? loadmodel.nodes[child0Idx] - : loadmodel.leafs[-1 - child0Idx]; - out.children[1] = child1Idx >= 0 - ? loadmodel.nodes[child1Idx] - : loadmodel.leafs[-1 - child1Idx]; - } - - this._setParent(loadmodel.nodes[0], null); - loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); - } - - /** - * Load BSP leaf nodes from BSP lump - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - * @throws {Error} If leafs lump size is not a multiple of 28 - */ - _loadLeafs(loadmodel, buf) { - const view = new DataView(buf); - const lump = BSP29Loader.#lump; - let fileofs = view.getUint32((lump.leafs << 3) + 4, true); - const filelen = view.getUint32((lump.leafs << 3) + 8, true); - if ((filelen % 28) !== 0) { - throw new Error('BSP29Loader: leafs lump size is not a multiple of 28 in ' + loadmodel.name); - } - const count = filelen / 28; - loadmodel.leafs.length = count; - - for (let i = 0; i < count; i++) { - loadmodel.leafs[i] = /** @type {Node} */ (Object.assign(new Node(loadmodel), { - num: i, - contents: view.getInt32(fileofs, true), - visofs: view.getInt32(fileofs + 4, true), - cluster: i > 0 ? i - 1 : -1, - mins: new Vector(view.getInt16(fileofs + 8, true), view.getInt16(fileofs + 10, true), view.getInt16(fileofs + 12, true)), - maxs: new Vector(view.getInt16(fileofs + 14, true), view.getInt16(fileofs + 16, true), view.getInt16(fileofs + 18, true)), - firstmarksurface: view.getUint16(fileofs + 20, true), - nummarksurfaces: view.getUint16(fileofs + 22, true), - ambient_level: [ - view.getUint8(fileofs + 24), - view.getUint8(fileofs + 25), - view.getUint8(fileofs + 26), - view.getUint8(fileofs + 27), - ], - })); - loadmodel.leafs[i].baseMins = loadmodel.leafs[i].mins.copy(); - loadmodel.leafs[i].baseMaxs = loadmodel.leafs[i].maxs.copy(); - fileofs += 28; - } - loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); - } - - /** - * Load collision clipnodes and initialize physics hulls - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - */ - _loadClipnodes(loadmodel, buf) { - const view = new DataView(buf); - const lump = BSP29Loader.#lump; - let fileofs = view.getUint32((lump.clipnodes << 3) + 4, true); - const filelen = view.getUint32((lump.clipnodes << 3) + 8, true); - const count = filelen >> 3; - loadmodel.clipnodes.length = 0; - - loadmodel.hulls.length = 0; - loadmodel.hulls[1] = { - clipnodes: loadmodel.clipnodes, - firstclipnode: 0, - lastclipnode: count - 1, - planes: loadmodel.planes, - clip_mins: new Vector(-16.0, -16.0, -24.0), - clip_maxs: new Vector(16.0, 16.0, 32.0), - }; - loadmodel.hulls[2] = { - clipnodes: loadmodel.clipnodes, - firstclipnode: 0, - lastclipnode: count - 1, - planes: loadmodel.planes, - clip_mins: new Vector(-32.0, -32.0, -24.0), - clip_maxs: new Vector(32.0, 32.0, 64.0), - }; - - for (let i = 0; i < count; i++) { - loadmodel.clipnodes[i] = { - planenum: view.getUint32(fileofs, true), - children: [view.getInt16(fileofs + 4, true), view.getInt16(fileofs + 6, true)], - }; - fileofs += 8; - } - loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); - } - - /** - * Create hull0 (point hull) from BSP nodes for collision detection - * @protected - * @param {BrushModel} loadmodel - The model being loaded - */ - _makeHull0(loadmodel) { - const clipnodes = []; - const hull = { - clipnodes: clipnodes, - lastclipnode: loadmodel.nodes.length - 1, - planes: loadmodel.planes, - clip_mins: new Vector(), - clip_maxs: new Vector(), - }; - - for (let i = 0; i < loadmodel.nodes.length; i++) { - const node = loadmodel.nodes[i]; - const out = { planenum: node.planenum, children: [] }; - const child0 = /** @type {import('../BSP.mjs').Node} */ (node.children[0]); - const child1 = /** @type {import('../BSP.mjs').Node} */ (node.children[1]); - out.children[0] = child0.contents < 0 ? child0.contents : child0.num; - out.children[1] = child1.contents < 0 ? child1.contents : child1.num; - clipnodes[i] = out; - } - loadmodel.hulls[0] = hull; - } - - /** - * Build a reachability mask for the clipnodes owned by a model headnode. - * Legacy BSP29 clipnode arrays are shared across worldspawn and inline - * submodels, so traces must stay within the owning subtree. - * @protected - * @param {import('../BSP.mjs').Clipnode[]} clipnodes clipnode array backing the hull - * @param {number} firstclipnode root clipnode for the owning model - * @returns {Uint8Array|null} mask with 1 for reachable clipnodes, or null when unavailable - */ - _buildAllowedClipnodeMask(clipnodes, firstclipnode) { - if (!clipnodes || clipnodes.length === 0 || firstclipnode < 0 || firstclipnode >= clipnodes.length) { - return null; - } - - const allowedClipNodes = new Uint8Array(clipnodes.length); - const stack = [firstclipnode]; - - while (stack.length > 0) { - const nodeIndex = /** @type {number} */ (stack.pop()); - - if (nodeIndex < 0 || nodeIndex >= clipnodes.length || allowedClipNodes[nodeIndex] === 1) { - continue; - } - - allowedClipNodes[nodeIndex] = 1; - - const node = clipnodes[nodeIndex]; - if (!node) { - continue; - } - - for (const childIndex of node.children) { - if (childIndex >= 0) { - stack.push(childIndex); - } - } - } - - return allowedClipNodes; - } - - /** - * Attach the owning subtree mask to a legacy hull. - * @protected - * @param {{clipnodes: import('../BSP.mjs').Clipnode[], firstclipnode: number, allowedClipNodes?: Uint8Array|null}|undefined} hull legacy hull descriptor - */ - _assignAllowedClipnodeMask(hull) { - if (!hull) { - return; - } - - hull.allowedClipNodes = this._buildAllowedClipnodeMask(hull.clipnodes, hull.firstclipnode); - } - - /** - * Load marksurfaces (face indices visible from each leaf) - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - * @throws {Error} If marksurface index is out of bounds - */ - _loadMarksurfaces(loadmodel, buf) { - const view = new DataView(buf); - const lump = BSP29Loader.#lump; - const fileofs = view.getUint32((lump.marksurfaces << 3) + 4, true); - const filelen = view.getUint32((lump.marksurfaces << 3) + 8, true); - const count = filelen >> 1; - loadmodel.marksurfaces.length = 0; - - for (let i = 0; i < count; i++) { - const j = view.getUint16(fileofs + (i << 1), true); - if (j > loadmodel.faces.length) { - throw new Error('BSP29Loader: bad surface number in marksurfaces'); - } - loadmodel.marksurfaces[i] = j; - } - loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs + filelen); - } - - /** - * Load submodels (brush models for doors, lifts, etc.) - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - * @throws {Error} If no submodels found - */ - _loadSubmodels(loadmodel, buf) { - const view = new DataView(buf); - const lump = BSP29Loader.#lump; - let fileofs = view.getUint32((lump.models << 3) + 4, true); - const filelen = view.getUint32((lump.models << 3) + 8, true); - const count = filelen >> 6; - if (count === 0) { - throw new Error('BSP29Loader: no submodels in ' + loadmodel.name); - } - loadmodel.submodels.length = 0; - - loadmodel.mins.setTo(view.getFloat32(fileofs, true) - 1.0, view.getFloat32(fileofs + 4, true) - 1.0, view.getFloat32(fileofs + 8, true) - 1.0); - loadmodel.maxs.setTo(view.getFloat32(fileofs + 12, true) + 1.0, view.getFloat32(fileofs + 16, true) + 1.0, view.getFloat32(fileofs + 20, true) + 1.0); - loadmodel.hulls[0].firstclipnode = view.getUint32(fileofs + 36, true); - loadmodel.hulls[1].firstclipnode = view.getUint32(fileofs + 40, true); - loadmodel.hulls[2].firstclipnode = view.getUint32(fileofs + 44, true); - this._assignAllowedClipnodeMask(loadmodel.hulls[0]); - this._assignAllowedClipnodeMask(loadmodel.hulls[1]); - this._assignAllowedClipnodeMask(loadmodel.hulls[2]); - fileofs += 64; - - const clipnodes = loadmodel.hulls[0].clipnodes; - for (let i = 1; i < count; i++) { - const out = new BrushModel('*' + i); - out.submodel = true; - out.mins.setTo(view.getFloat32(fileofs, true) - 1.0, view.getFloat32(fileofs + 4, true) - 1.0, view.getFloat32(fileofs + 8, true) - 1.0); - out.maxs.setTo(view.getFloat32(fileofs + 12, true) + 1.0, view.getFloat32(fileofs + 16, true) + 1.0, view.getFloat32(fileofs + 20, true) + 1.0); - out.origin.setTo(view.getFloat32(fileofs + 24, true), view.getFloat32(fileofs + 28, true), view.getFloat32(fileofs + 32, true)); - out.hulls = [ - { - clipnodes: clipnodes, - firstclipnode: view.getUint32(fileofs + 36, true), - lastclipnode: loadmodel.nodes.length - 1, - planes: loadmodel.planes, - clip_mins: new Vector(), - clip_maxs: new Vector(), - }, - { - clipnodes: loadmodel.clipnodes, - firstclipnode: view.getUint32(fileofs + 40, true), - lastclipnode: loadmodel.clipnodes.length - 1, - planes: loadmodel.planes, - clip_mins: new Vector(-16.0, -16.0, -24.0), - clip_maxs: new Vector(16.0, 16.0, 32.0), - }, - { - clipnodes: loadmodel.clipnodes, - firstclipnode: view.getUint32(fileofs + 44, true), - lastclipnode: loadmodel.clipnodes.length - 1, - planes: loadmodel.planes, - clip_mins: new Vector(-32.0, -32.0, -24.0), - clip_maxs: new Vector(32.0, 32.0, 64.0), - }, - ]; - this._assignAllowedClipnodeMask(out.hulls[0]); - this._assignAllowedClipnodeMask(out.hulls[1]); - this._assignAllowedClipnodeMask(out.hulls[2]); - out.vertexes = loadmodel.vertexes; - out.edges = loadmodel.edges; - out.surfedges = loadmodel.surfedges; - out.nodes = loadmodel.nodes; - out.leafs = loadmodel.leafs; - out.texinfo = loadmodel.texinfo; - out.textures = loadmodel.textures; - out.marksurfaces = loadmodel.marksurfaces; - out.lightdata = loadmodel.lightdata; - out.lightdata_rgb = loadmodel.lightdata_rgb; - out.deluxemap = loadmodel.deluxemap; - out.faces = loadmodel.faces; - out.visdata = loadmodel.visdata; - out.numclusters = loadmodel.numclusters; - out.clusterPvsOffsets = loadmodel.clusterPvsOffsets; - out.phsdata = loadmodel.phsdata; - out.clusterPhsOffsets = loadmodel.clusterPhsOffsets; - out.firstface = view.getUint32(fileofs + 56, true); - out.numfaces = view.getUint32(fileofs + 60, true); - - // Propagate brush data from world model to submodels (shared arrays) - if (loadmodel.hasBrushData) { - out.brushes = loadmodel.brushes; - out.brushsides = loadmodel.brushsides; - out.leafbrushes = loadmodel.leafbrushes; - out.planes = loadmodel.planes; - - // Set per-submodel brush range from BRUSHLIST data - const brushRange = loadmodel._brushRanges?.get(i); - if (brushRange) { - out.firstBrush = brushRange.firstBrush; - out.numBrushes = brushRange.numBrushes; - } - } - - out.worldspawnInfo = loadmodel.worldspawnInfo; - - loadmodel.submodels[i - 1] = out; - fileofs += 64; - - for (let j = 0; j < out.numfaces; j++) { - out.faces[out.firstface + j].submodel = true; - } - } - loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); - } - - /** - * Load BSPX extended format data (optional extra lumps) - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buffer - The BSP file buffer - */ - _loadBSPX(loadmodel, buffer) { - loadmodel.bspxoffset = (loadmodel.bspxoffset + 3) & ~3; - if (loadmodel.bspxoffset >= buffer.byteLength) { - Con.DPrint('BSP29Loader: no BSPX data found\n'); - return; - } - - const view = new DataView(buffer); - const magic = view.getUint32(loadmodel.bspxoffset, true); - console.assert(magic === 0x58505342, 'BSP29Loader: bad BSPX magic'); - - const numlumps = view.getUint32(loadmodel.bspxoffset + 4, true); - Con.DPrint('BSP29Loader: found BSPX data with ' + numlumps + ' lumps\n'); - - /** @type {import('../BSP.mjs').BSPXLumps} */ - const bspxLumps = {}; - for (let i = 0, pointer = loadmodel.bspxoffset + 8; i < numlumps; i++, pointer += 32) { - const name = Q.memstr(new Uint8Array(buffer, pointer, 24)); - const fileofs = view.getUint32(pointer + 24, true); - const filelen = view.getUint32(pointer + 28, true); - bspxLumps[name] = { fileofs, filelen }; - } - loadmodel.bspxlumps = bspxLumps; - } - - /** - * Load BSPX BRUSHLIST lump if available. - * Parses per-model brush data from the BSPX extension, creates Brush and - * BrushSide objects, generates the 6 axial planes that the spec says must - * be inferred from each brush's mins/maxs, and inserts brushes into BSP - * leaf nodes so that BrushTrace can find them during collision testing. - * @protected - * @param {BrushModel} loadmodel - The world model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - */ - _loadBrushList(loadmodel, buf) { - if (!loadmodel.bspxlumps || !loadmodel.bspxlumps['BRUSHLIST']) { - return; - } - - const { fileofs, filelen } = loadmodel.bspxlumps['BRUSHLIST']; - - if (filelen === 0) { - return; - } - - const view = new DataView(buf); - let offset = fileofs; - const endOffset = fileofs + filelen; - - /** @type {Brush[]} all brushes across all models */ - const allBrushes = []; - /** @type {BrushSide[]} all brush sides across all models */ - const allBrushSides = []; - /** @type {Plane[]} planes generated for brush collision (separate from BSP planes) */ - const allBrushPlanes = []; - - /** - * Create a Plane with type and signbits set. - * type: 0=X, 1=Y, 2=Z for axial, 3/4/5 for non-axial dominant axis. - * @param {Vector} normal plane normal - * @param {number} dist plane distance - * @returns {Plane} plane with type and signbits computed - */ - const makePlane = (normal, dist) => { - const p = new Plane(normal, dist); - const ax = Math.abs(normal[0]); - const ay = Math.abs(normal[1]); - const az = Math.abs(normal[2]); - if (ax === 1 && ay === 0 && az === 0) { - p.type = 0; - } else if (ax === 0 && ay === 1 && az === 0) { - p.type = 1; - } else if (ax === 0 && ay === 0 && az === 1) { - p.type = 2; - } else if (ax >= ay && ax >= az) { - p.type = 3; - } else if (ay >= ax && ay >= az) { - p.type = 4; - } else { - p.type = 5; - } - if (normal[0] < 0) { p.signbits |= 1; } - if (normal[1] < 0) { p.signbits |= 2; } - if (normal[2] < 0) { p.signbits |= 4; } - return p; - }; - - // Track per-model brush ranges for submodel assignment - /** @type {Map} */ - const modelBrushRanges = new Map(); - - while (offset + 16 <= endOffset) { - const ver = view.getUint32(offset, true); - offset += 4; - - if (ver !== 1) { - Con.Print(`BSP29Loader: unsupported BRUSHLIST version ${ver}\n`); - return; - } - - const modelnum = view.getUint32(offset, true); - offset += 4; - const numbrushes = view.getUint32(offset, true); - offset += 4; - const numplanes = view.getUint32(offset, true); - offset += 4; - - const firstBrush = allBrushes.length; - - let planesRead = 0; - - for (let b = 0; b < numbrushes; b++) { - if (offset + 28 > endOffset) { - Con.Print('BSP29Loader: BRUSHLIST lump truncated at brush header\n'); - return; - } - - // Parse brush header: vec3 mins (12) + vec3 maxs (12) + short contents (2) + ushort numplanes (2) = 28 bytes - // Actually: vec_t mins is 3 floats, vec_t maxs is 3 floats - const bmins = new Vector( - view.getFloat32(offset, true), - view.getFloat32(offset + 4, true), - view.getFloat32(offset + 8, true), - ); - offset += 12; - - const bmaxs = new Vector( - view.getFloat32(offset, true), - view.getFloat32(offset + 4, true), - view.getFloat32(offset + 8, true), - ); - offset += 12; - - const brushContents = view.getInt16(offset, true); - offset += 2; - const brushNumPlanes = view.getUint16(offset, true); - offset += 2; - - const firstside = allBrushSides.length; - - // Generate the 6 axial planes inferred from mins/maxs. - // Per the BSPX spec: "Axial planes MUST NOT be written - they will be - // inferred from the brush's mins+maxs." - // +X, -X, +Y, -Y, +Z, -Z - /** @type {[Vector, number][]} */ - const axialDefs = [ - [new Vector(1, 0, 0), bmaxs[0]], - [new Vector(-1, 0, 0), -bmins[0]], - [new Vector(0, 1, 0), bmaxs[1]], - [new Vector(0, -1, 0), -bmins[1]], - [new Vector(0, 0, 1), bmaxs[2]], - [new Vector(0, 0, -1), -bmins[2]], - ]; - - for (const [normal, dist] of axialDefs) { - const planeIdx = allBrushPlanes.length; - allBrushPlanes.push(makePlane(normal, dist)); - - const side = new BrushSide(loadmodel); - side.planenum = planeIdx; - allBrushSides.push(side); - } - - // Parse the non-axial planes from the lump - if (offset + brushNumPlanes * 16 > endOffset) { - Con.Print('BSP29Loader: BRUSHLIST lump truncated at brush planes\n'); - return; - } - - for (let p = 0; p < brushNumPlanes; p++) { - const normal = new Vector( - view.getFloat32(offset, true), - view.getFloat32(offset + 4, true), - view.getFloat32(offset + 8, true), - ); - const dist = view.getFloat32(offset + 12, true); - offset += 16; - - const planeIdx = allBrushPlanes.length; - allBrushPlanes.push(makePlane(normal, dist)); - - const side = new BrushSide(loadmodel); - side.planenum = planeIdx; - allBrushSides.push(side); - } - - planesRead += brushNumPlanes; - - const brush = new Brush(loadmodel); - brush.firstside = firstside; - brush.numsides = 6 + brushNumPlanes; // axial + explicit - brush.contents = brushContents; - brush.mins = bmins; - brush.maxs = bmaxs; - allBrushes.push(brush); - } - - if (planesRead !== numplanes) { - Con.Print(`BSP29Loader: BRUSHLIST plane count mismatch for model ${modelnum}: expected ${numplanes}, got ${planesRead}\n`); - } - - modelBrushRanges.set(modelnum, { firstBrush, numBrushes: numbrushes }); - } - - if (allBrushes.length === 0) { - return; - } - - // Assign brush data to the world model - loadmodel.brushes = allBrushes; - loadmodel.brushsides = allBrushSides; - - // Use allBrushPlanes as a separate plane array for brush collision. - // These are stored alongside BSP planes but indexed separately by brushsides. - // We store them on the world model where BrushTrace can reference them. - loadmodel.planes = loadmodel.planes.concat(allBrushPlanes); - - // Remap brushside plane indices to account for the offset into the combined array - const planeOffset = loadmodel.planes.length - allBrushPlanes.length; - for (const side of allBrushSides) { - side.planenum += planeOffset; - } - - // Insert brushes into BSP leaf nodes by walking the node tree. - // Each model's brushes are only inserted under that model's headnode, - // so that world traces don't collide with submodel brushes (func_plat, - // trigger_*, func_door, etc.) and vice versa. - - /** @type {Map} leaf-to-index lookup for fast insertion */ - const leafIndexMap = new Map(); - for (let i = 0; i < loadmodel.leafs.length; i++) { - leafIndexMap.set(loadmodel.leafs[i], i); - } - - /** @type {number[][]} per-leaf brush index lists */ - const leafBrushLists = new Array(loadmodel.leafs.length); - for (let i = 0; i < loadmodel.leafs.length; i++) { - leafBrushLists[i] = []; - } - - /** - * Recursively insert a brush into BSP leaf nodes whose bounds overlap. - * @param {Node} node - current BSP node - * @param {number} brushIdx - index into allBrushes - * @param {Brush} brush - the brush being inserted - */ - const insertBrushRecursive = (node, brushIdx, brush) => { - // Leaf node: add the brush here - if (node.contents < 0) { - const leafIndex = leafIndexMap.get(node); - if (leafIndex !== undefined) { - leafBrushLists[leafIndex].push(brushIdx); - } - return; - } - - // Internal node: test brush AABB against splitting plane - const plane = node.plane; - let d1, d2; - - if (plane.type < 3) { - // Axial plane: fast path - d1 = brush.maxs[plane.type] - plane.dist; - d2 = brush.mins[plane.type] - plane.dist; - } else { - // General plane: compute support points using worst-case AABB corners - const nx = plane.normal[0]; - const ny = plane.normal[1]; - const nz = plane.normal[2]; - - d1 = nx * (nx >= 0 ? brush.maxs[0] : brush.mins[0]) - + ny * (ny >= 0 ? brush.maxs[1] : brush.mins[1]) - + nz * (nz >= 0 ? brush.maxs[2] : brush.mins[2]) - - plane.dist; - d2 = nx * (nx >= 0 ? brush.mins[0] : brush.maxs[0]) - + ny * (ny >= 0 ? brush.mins[1] : brush.maxs[1]) - + nz * (nz >= 0 ? brush.mins[2] : brush.maxs[2]) - - plane.dist; - } - - if (d1 >= 0) { - insertBrushRecursive(node.children[0], brushIdx, brush); - } - // Brushes that touch the split plane on their back-most extent must be - // inserted into both leaves. Otherwise exact-boundary player positions - // can miss a clip brush in one leaf and hit it only after a tiny move - // into the adjacent leaf, producing false stuck/allsolid behavior. - if (d2 <= 0) { - insertBrushRecursive(node.children[1], brushIdx, brush); - } - }; - - // Only insert world (model 0) brushes into leaf nodes. - // Submodel brushes must NOT be inserted into the BSP leaf-brush index - // because Q1 BSP leaf nodes are shared between the world tree and - // submodel subtrees — inserting submodel brushes would make them - // appear in world traces, causing phantom collisions with triggers, - // removed entities (func_fog), etc. - // Submodel collision is handled by brute-force testing against the - // submodel's brush range (see BrushTrace.boxTraceModel). - const worldRange = modelBrushRanges.get(0); - if (worldRange) { - const rootNode = loadmodel.nodes[0]; - if (rootNode) { - for (let brushIdx = worldRange.firstBrush; brushIdx < worldRange.firstBrush + worldRange.numBrushes; brushIdx++) { - insertBrushRecursive(rootNode, brushIdx, allBrushes[brushIdx]); - } - } - } - - // Store brush ranges on the world model for submodel propagation - loadmodel._brushRanges = modelBrushRanges; - if (worldRange) { - loadmodel.firstBrush = worldRange.firstBrush; - loadmodel.numBrushes = worldRange.numBrushes; - } - - // Build the flat leafbrushes array from per-leaf lists - /** @type {number[]} */ - const leafbrushes = []; - - for (let i = 0; i < loadmodel.leafs.length; i++) { - const leaf = loadmodel.leafs[i]; - const list = leafBrushLists[i]; - leaf.firstleafbrush = leafbrushes.length; - leaf.numleafbrushes = list.length; - for (const brushIdx of list) { - leafbrushes.push(brushIdx); - } - } - - loadmodel.leafbrushes = leafbrushes; - - Con.DPrint(`BSP29Loader: loaded BRUSHLIST with ${allBrushes.length} brushes, ${allBrushSides.length} sides, ${leafbrushes.length} leaf-brush refs\n`); - } - - /** - * Load RGB colored lighting from BSPX lump if available - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - */ - _loadLightingRGB(loadmodel, buf) { - loadmodel.lightdata_rgb = null; - - if (!loadmodel.bspxlumps || !loadmodel.bspxlumps['RGBLIGHTING']) { - return; - } - - const { fileofs, filelen } = loadmodel.bspxlumps['RGBLIGHTING']; - - if (filelen === 0) { - return; - } - - loadmodel.lightdata_rgb = new Uint8Array(buf.slice(fileofs, fileofs + filelen)); - } - - /** - * Load external RGB lighting from .lit file if available - * @param {BrushModel} loadmodel - The model being loaded - * @param {string} filename - The original BSP filename - */ - async _loadExternalLighting(loadmodel, filename) { - const rgbFilename = filename.replace(/\.bsp$/i, '.lit'); - - const data = await COM.LoadFile(rgbFilename); - - if (!data) { - Con.DPrint(`BSP29Loader: no external RGB lighting file found: ${rgbFilename}\n`); - return; - } - - const dv = new DataView(data); - - console.assert(dv.getUint32(0, true) === 0x54494C51, 'QLIT header'); - console.assert(dv.getUint32(4, true) === 0x00000001, 'QLIT version 1'); - - loadmodel.lightdata_rgb = new Uint8Array(data.slice(8)); // Skip header - } - - /** - * Load deluxemap (directional lighting normals) from BSPX lump if available - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - */ - _loadDeluxeMap(loadmodel, buf) { - loadmodel.deluxemap = null; - - if (!loadmodel.bspxlumps || !loadmodel.bspxlumps['LIGHTINGDIR']) { - return; - } - - const { fileofs, filelen } = loadmodel.bspxlumps['LIGHTINGDIR']; - - if (filelen === 0) { - return; - } - - loadmodel.deluxemap = new Uint8Array(buf.slice(fileofs, fileofs + filelen)); - } - - /** - * Load lightgrid octree from BSPX lump if available - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - */ - _loadLightgridOctree(loadmodel, buf) { - loadmodel.lightgrid = null; - - if (!loadmodel.bspxlumps || !loadmodel.bspxlumps['LIGHTGRID_OCTREE']) { - return; - } - - const { fileofs, filelen } = loadmodel.bspxlumps['LIGHTGRID_OCTREE']; - - if (filelen === 0) { - return; - } - - try { - const view = new DataView(buf); - let offset = fileofs; - const endOffset = fileofs + filelen; - - // Minimum size check: vec3_t step (12) + ivec3_t size (12) + vec3_t mins (12) + byte numstyles (1) + uint32_t rootnode (4) + uint32_t numnodes (4) + uint32_t numleafs (4) = 49 bytes - if (filelen < 49) { - Con.DPrint('BSP29Loader: LIGHTGRID_OCTREE lump too small\n'); - return; - } - - // vec3_t step - const step = new Vector( - view.getFloat32(offset, true), - view.getFloat32(offset + 4, true), - view.getFloat32(offset + 8, true), - ); - offset += 12; - - // ivec3_t size - const size = [ - view.getInt32(offset, true), - view.getInt32(offset + 4, true), - view.getInt32(offset + 8, true), - ]; - offset += 12; - - // vec3_t mins - const mins = new Vector( - view.getFloat32(offset, true), - view.getFloat32(offset + 4, true), - view.getFloat32(offset + 8, true), - ); - offset += 12; - - // byte numstyles (WARNING: misaligns the rest of the data) - const numstyles = view.getUint8(offset); - offset += 1; - - // uint32_t rootnode - const rootnode = view.getUint32(offset, true); - offset += 4; - - // uint32_t numnodes - const numnodes = view.getUint32(offset, true); - offset += 4; - - // Check if we have enough data for nodes (each node is 44 bytes: 3*4 for mid + 8*4 for children) - if (offset + (numnodes * 44) > endOffset) { - Con.DPrint('BSP29Loader: LIGHTGRID_OCTREE nodes data truncated\n'); - return; - } - - // Parse nodes - const nodes = []; - for (let i = 0; i < numnodes; i++) { - const mid = [ - view.getUint32(offset, true), - view.getUint32(offset + 4, true), - view.getUint32(offset + 8, true), - ]; - offset += 12; - - const child = []; - for (let j = 0; j < 8; j++) { - child[j] = view.getUint32(offset, true); - offset += 4; - } - - nodes[i] = { mid, child }; - } - - // uint32_t numleafs - if (offset + 4 > endOffset) { - Con.DPrint('BSP29Loader: LIGHTGRID_OCTREE numleafs missing\n'); - return; - } - const numleafs = view.getUint32(offset, true); - offset += 4; - - // Parse leafs - const leafs = []; - for (let i = 0; i < numleafs; i++) { - // Check bounds for leaf header (mins + size = 24 bytes) - if (offset + 24 > endOffset) { - Con.DPrint(`BSP29Loader: LIGHTGRID_OCTREE leaf ${i} header truncated\n`); - return; - } - - const leafMins = [ - view.getInt32(offset, true), - view.getInt32(offset + 4, true), - view.getInt32(offset + 8, true), - ]; - offset += 12; - - const leafSize = [ - view.getInt32(offset, true), - view.getInt32(offset + 4, true), - view.getInt32(offset + 8, true), - ]; - offset += 12; - - // Parse per-point data - const totalPoints = leafSize[0] * leafSize[1] * leafSize[2]; - const points = []; - - for (let p = 0; p < totalPoints; p++) { - // Check bounds for stylecount byte - if (offset >= endOffset) { - Con.DPrint(`BSP29Loader: LIGHTGRID_OCTREE leaf ${i} point ${p} truncated\n`); - return; - } - - const stylecount = view.getUint8(offset); - offset += 1; - - // Skip points with no data (stylecount = 0xff means missing) - if (stylecount === 0xff) { - points.push({ stylecount, styles: [] }); - continue; - } - - const styles = []; - for (let s = 0; s < stylecount; s++) { - // Check bounds for style data (1 byte stylenum + 3 bytes rgb = 4 bytes) - if (offset + 3 >= endOffset) { - Con.DPrint(`BSP29Loader: LIGHTGRID_OCTREE leaf ${i} point ${p} style ${s} truncated\n`); - return; - } - - const stylenum = view.getUint8(offset); - - offset += 1; - - const rgb = [ - view.getUint8(offset), - view.getUint8(offset + 1), - view.getUint8(offset + 2), - ]; - offset += 3; - - styles.push({ stylenum, rgb }); - } - - points.push({ stylecount, styles }); - } - - leafs.push({ mins: leafMins, size: leafSize, points }); - } - - loadmodel.lightgrid = { - step, - size, - mins, - numstyles, - rootnode, - nodes, - leafs, - }; - - Con.DPrint(`BSP29Loader: loaded LIGHTGRID_OCTREE with ${numnodes} nodes and ${numleafs} leafs\n`); - } catch (error) { - Con.DPrint(`BSP29Loader: error loading LIGHTGRID_OCTREE: ${error.message}\n`); - loadmodel.lightgrid = null; - } - } -} +export * from './BSP29Loader.ts'; diff --git a/source/engine/common/model/loaders/BSP29Loader.ts b/source/engine/common/model/loaders/BSP29Loader.ts new file mode 100644 index 00000000..569511eb --- /dev/null +++ b/source/engine/common/model/loaders/BSP29Loader.ts @@ -0,0 +1,2563 @@ +import Vector from '../../../../shared/Vector.ts'; +import Q from '../../../../shared/Q.ts'; +import { content } from '../../../../shared/Defs.ts'; +import { GLTexture } from '../../../client/GL.mjs'; +import W, { readWad3Texture, translateIndexToLuminanceRGBA, translateIndexToRGBA } from '../../W.ts'; +import { CRC16CCITT } from '../../CRC.ts'; +import { CorruptedResourceError } from '../../Errors.ts'; +import { eventBus, registry } from '../../../registry.mjs'; +import { ModelLoader } from '../ModelLoader.ts'; +import { Brush, BrushModel, BrushSide, Node, type BSPXLumps, type Clipnode, type Hull } from '../BSP.ts'; +import { Face, Plane } from '../BaseModel.ts'; +import { materialFlags, noTextureMaterial, PBRMaterial, QuakeMaterial } from '../../../client/renderer/Materials.mjs'; +import { Quake1Sky, SimpleSkyBox } from '../../../client/renderer/Sky.mjs'; + +// Get registry references (will be set by eventBus) +let { COM, Con } = registry; + +eventBus.subscribe('registry.frozen', () => { + ({ COM, Con } = registry); +}); + +interface AllowedClipnodeHull extends Hull { + readonly firstclipnode: number; + allowedClipNodes?: Uint8Array | null; +} + +/** + * Loader for Quake BSP29 format (.bsp) + * It supports vanilla BSP29 and a few BSPX extensions (such as lightgrid, RGB lighting). + */ +export class BSP29Loader extends ModelLoader { + /** BSP29 lump indices. */ + static readonly #lump = Object.freeze({ + entities: 0, + planes: 1, + textures: 2, + vertexes: 3, + visibility: 4, + nodes: 5, + texinfo: 6, + faces: 7, + lighting: 8, + clipnodes: 9, + leafs: 10, + marksurfaces: 11, + edges: 12, + surfedges: 13, + models: 14, + }); + + override getMagicNumbers(): number[] { + return [29]; // BSP version 29 + } + + override getExtensions(): string[] { + return ['.bsp']; + } + + override getName(): string { + return 'Quake BSP29'; + } + + /** + * Load a BSP29 map model from buffer. + * @returns The loaded brush model. + */ + override async load(buffer: ArrayBuffer, name: string): Promise { + const loadmodel = new BrushModel(name); + + loadmodel.version = (new DataView(buffer)).getUint32(0, true) as 29 | 844124994; + loadmodel.bspxoffset = 0; + + // Load all BSP lumps + this.#loadEntities(loadmodel, buffer); + this.#loadVertexes(loadmodel, buffer); + this.#loadEdges(loadmodel, buffer); + this.#loadSurfedges(loadmodel, buffer); + this.#loadTextures(loadmodel, buffer); + await this.#loadMaterials(loadmodel); + this.#loadLighting(loadmodel, buffer); + this.#loadPlanes(loadmodel, buffer); + this.#loadTexinfo(loadmodel, buffer); + this._loadFaces(loadmodel, buffer); + this._loadMarksurfaces(loadmodel, buffer); + this.#loadVisibility(loadmodel, buffer); + this._loadLeafs(loadmodel, buffer); + this.#buildClusterData(loadmodel); + this._loadNodes(loadmodel, buffer); + this._loadClipnodes(loadmodel, buffer); + this.#makeHull0(loadmodel); + this.#loadBSPX(loadmodel, buffer); + this._loadBrushList(loadmodel, buffer); + this.#loadLightingRGB(loadmodel, buffer); + this.#loadDeluxeMap(loadmodel, buffer); + this.#loadLightgridOctree(loadmodel, buffer); + this.#loadSubmodels(loadmodel, buffer); // CR: must be last, since it creates additional models based on this one + this.#parseFogVolumes(loadmodel); // must be after submodels load so we can scan submodel faces + this.#computeAreas(loadmodel); + + if (loadmodel.coloredlights && !loadmodel.lightdata_rgb) { + await this.#loadExternalLighting(loadmodel, name); + } + + await this.#loadSkybox(loadmodel); + + // Calculate bounding radius + this.#calculateRadius(loadmodel); + + loadmodel.needload = false; + loadmodel.checksum = CRC16CCITT.Block(new Uint8Array(buffer)); + + return loadmodel; + } + + async #loadSkybox(loadmodel: BrushModel): Promise { + if (registry.isDedicatedServer) { + return; + } + + const skyname = loadmodel.worldspawnInfo.skyname; + + if (!skyname) { + return; + } + + const [front, back, left, right, up, down] = await Promise.all([ + GLTexture.FromImageFile(`gfx/env/${skyname}ft.png`), + GLTexture.FromImageFile(`gfx/env/${skyname}bk.png`), + GLTexture.FromImageFile(`gfx/env/${skyname}lf.png`), + GLTexture.FromImageFile(`gfx/env/${skyname}rt.png`), + GLTexture.FromImageFile(`gfx/env/${skyname}up.png`), + GLTexture.FromImageFile(`gfx/env/${skyname}dn.png`), + ]); + + // CR: unholy yet convenient hack to pass sky texture data to SkyRenderer + loadmodel.newSkyRenderer = function () { + const skyrenderer = new SimpleSkyBox(this); + skyrenderer.setSkyTextures(front, back, left, right, up, down); + return skyrenderer; + }; + } + + /** + * Calculate the bounding radius of the model from its vertices. + */ + #calculateRadius(loadmodel: BrushModel): void { + const mins = new Vector(); + const maxs = new Vector(); + + for (let i = 0; i < loadmodel.vertexes.length; i++) { + const vert = loadmodel.vertexes[i]; + + if (vert[0] < mins[0]) { + mins[0] = vert[0]; + } else if (vert[0] > maxs[0]) { + maxs[0] = vert[0]; + } + + if (vert[1] < mins[1]) { + mins[1] = vert[1]; + } else if (vert[1] > maxs[1]) { + maxs[1] = vert[1]; + } + + if (vert[2] < mins[2]) { + mins[2] = vert[2]; + } else if (vert[2] > maxs[2]) { + maxs[2] = vert[2]; + } + } + + loadmodel.radius = (new Vector( + Math.abs(mins[0]) > Math.abs(maxs[0]) ? Math.abs(mins[0]) : Math.abs(maxs[0]), + Math.abs(mins[1]) > Math.abs(maxs[1]) ? Math.abs(mins[1]) : Math.abs(maxs[1]), + Math.abs(mins[2]) > Math.abs(maxs[2]) ? Math.abs(mins[2]) : Math.abs(maxs[2]), + )).len(); + } + + /** + * Load texture information and create GL textures from BSP texture lump. + */ + #loadTextures(loadmodel: BrushModel, buf: ArrayBuffer): void { + const view = new DataView(buf); + const lump = BSP29Loader.#lump; + const fileofs = view.getUint32((lump.textures << 3) + 4, true); + const filelen = view.getUint32((lump.textures << 3) + 8, true); + loadmodel.textures.length = 0; + const nummiptex = view.getUint32(fileofs, true); + let dataofs = fileofs + 4; + + const materials: Record = {}; + + for (let i = 0; i < nummiptex; i++) { + const miptexofs = view.getInt32(dataofs, true); + dataofs += 4; + if (miptexofs === -1) { + loadmodel.textures[i] = noTextureMaterial; + continue; + } + const absofs = miptexofs + fileofs; + + const name = Q.memstr(new Uint8Array(buf, absofs, 16)); + const cleanName = name.replace(/^\+[0-9a-j]/, ''); // no anim prefix + + if (!materials[cleanName]) { + materials[cleanName] = new QuakeMaterial(name, view.getUint32(absofs + 16, true), view.getUint32(absofs + 20, true)); + } + + const tx = materials[cleanName]; + + let glt = null; + let luminanceTexture = null; + + // Load texture data (skip for dedicated server) + if (!registry.isDedicatedServer) { + if (tx.name.substring(0, 3).toLowerCase() === 'sky') { + const skyTexture = new Uint8Array(buf, absofs + view.getUint32(absofs + 24, true), 32768); + + // CR: unholy yet convenient hack to pass sky texture data to SkyRenderer + loadmodel.newSkyRenderer = function () { + const skyrenderer = new Quake1Sky(this); + skyrenderer.setSkyTexture(skyTexture); + return skyrenderer; + }; + + tx.flags |= materialFlags.MF_SKY; + } else { + // Try loading WAD3 texture + const len = 40 + tx.width * tx.height * (1 + 0.25 + 0.0625 + 0.015625) + 2 + 768; + if (absofs + len - 2 - 768 < buf.byteLength) { + const magic = view.getInt16(absofs + len - 2 - 768, true); + if (magic === 256) { + const data = new ArrayBuffer(len); + new Uint8Array(data).set(new Uint8Array(buf, absofs, len)); + const wtex = readWad3Texture(data, tx.name, 0); + glt = GLTexture.FromLumpTexture(wtex); + const wadLuminance = BSP29Loader.#createWadLuminanceTexture(data, tx.name, tx.width, tx.height); + if (wadLuminance !== null) { + luminanceTexture = wadLuminance; + } + tx.averageColor = BSP29Loader.#computeAverageColor(wtex.data); + } + } + } + + if (!glt) { + const pixelData = new Uint8Array(buf, absofs + view.getUint32(absofs + 24, true), tx.width * tx.height); + const rgba = translateIndexToRGBA(pixelData, tx.width, tx.height, W.d_8to24table_u8, tx.name[0] === '{' ? 255 : null, 240); + const textureId = `${tx.name}/${CRC16CCITT.Block(pixelData)}`; // CR: unique texture ID to avoid conflicts across maps + glt = GLTexture.Allocate(textureId, tx.width, tx.height, rgba); + const luminanceRGBA = translateIndexToLuminanceRGBA(pixelData, tx.width, tx.height, W.d_8to24table_u8, tx.name[0] === '{' ? 255 : null, 240); + if (BSP29Loader.#hasVisiblePixels(luminanceRGBA)) { + luminanceTexture = GLTexture.Allocate(`${textureId}:luminance`, tx.width, tx.height, luminanceRGBA); + } + tx.averageColor = BSP29Loader.#computeAverageColor(rgba); + } + + if (tx.name[0] === '*' || tx.name[0] === '!') { + tx.flags |= materialFlags.MF_TURBULENT; + } + + // Mark textures with '{' prefix as transparent (for alpha blending) + if (tx.name[0] === '{') { + tx.flags |= materialFlags.MF_TRANSPARENT; + } + + if (tx.name.toLowerCase().startsWith('*lava')) { + tx.flags |= materialFlags.MF_FULLBRIGHT; + } + } + + if (name[0] === '+') { // animation prefix + const frame = name.toUpperCase().charCodeAt(1); + + if (frame >= 48 && frame <= 57) { // '0'-'9' + const frameIndex = frame - 48; + tx.addAnimationFrame(frameIndex, glt, luminanceTexture); + } else if (frame >= 65 && frame <= 74) { // 'A'-'J' + const frameIndex = frame - 65; + tx.addAlternateFrame(frameIndex, glt, luminanceTexture); + } + } else { + tx.texture = glt; + tx.luminanceTexture = luminanceTexture; + } + + loadmodel.textures[i] = tx; + } + + loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs + filelen); + } + + /** + * Upload a luminance texture when the source WAD3 texture has fullbright pixels. + * @returns The uploaded luminance texture, or null when no visible fullbright pixels exist. + */ + static #createWadLuminanceTexture(wadTextureData: ArrayBuffer, textureName: string, width: number, height: number): GLTexture | null { + const indexedData = new Uint8Array(wadTextureData, 40, width * height); + const palette = new Uint8Array(wadTextureData, + 40 + + width * height + + width / 2 * height / 2 + + width / 4 * height / 4 + + width / 8 * height / 8 + + 2, + 768); + const luminanceRGBA = translateIndexToLuminanceRGBA(indexedData, width, height, palette, textureName[0] === '{' ? 255 : null, 240); + + if (!BSP29Loader.#hasVisiblePixels(luminanceRGBA)) { + return null; + } + + return GLTexture.Allocate(`${textureName}/${CRC16CCITT.Block(indexedData)}:luminance`, width, height, luminanceRGBA); + } + + /** + * Check whether any pixel in the RGBA data contributes visible color. + * @returns True when any pixel is visible. + */ + static #hasVisiblePixels(rgba: Uint8Array): boolean { + for (let i = 3; i < rgba.length; i += 4) { + if (rgba[i] !== 0) { + return true; + } + } + + return false; + } + + /** + * Load material definitions from .qsmat.json file if available. + */ + async #loadMaterials(loadmodel: BrushModel): Promise { + if (registry.isDedicatedServer) { + return; + } + + const matfile = await COM.LoadTextFile(loadmodel.name.replace(/\.bsp$/i, '.qsmat.json')); + + if (!matfile) { + return; + } + + Con.DPrint(`BSP29Loader: found materials file for ${loadmodel.name}\n`); + const materialData = JSON.parse(matfile); + console.assert(materialData.version === 1); + + for (const [txName, textures] of Object.entries(materialData.materials)) { + const textureEntry = Array.from(loadmodel.textures.entries()).find(([, t]) => t.name === txName); + + if (!textureEntry) { + Con.DPrint(`BSP29Loader: referenced material (${txName}) is not used\n`); + continue; + } + + const [txIndex, texture] = textureEntry; + const pbr = new PBRMaterial(texture.name, texture.width, texture.height); + + for (const category of ['luminance', 'diffuse', 'specular', 'normal']) { + if (textures[category]) { + try { + pbr[category] = await GLTexture.FromImageFile(textures[category]); + Con.DPrint(`BSP29Loader: loaded ${category} texture for ${texture.name} from ${textures[category]}\n`); + } catch (e) { + Con.PrintError(`BSP29Loader: failed to load ${textures[category]}: ${e.message}\n`); + } + } + } + + if (textures.flags) { + for (const flagName of textures.flags) { + const flagValue = materialFlags[flagName]; + console.assert(typeof flagValue === 'number', `BSP29Loader: unknown material flag ${flagName} in ${loadmodel.name}`); + pbr.flags |= flagValue; + } + } + + if (!textures.diffuse && (texture instanceof QuakeMaterial)) { + pbr.diffuse = texture.texture; // keep original diffuse as base + } + + loadmodel.textures[txIndex] = pbr; // replace with PBR material + } + } + + /** + * Load lighting data from BSP lump. + */ + #loadLighting(loadmodel: BrushModel, buf: ArrayBuffer): void { + loadmodel.lightdata_rgb = null; + loadmodel.lightdata = null; + + const view = new DataView(buf); + const lump = BSP29Loader.#lump; + const fileofs = view.getUint32((lump.lighting << 3) + 4, true); + const filelen = view.getUint32((lump.lighting << 3) + 8, true); + + if (filelen === 0) { + return; + } + + loadmodel.lightdata = new Uint8Array(new ArrayBuffer(filelen)); + loadmodel.lightdata.set(new Uint8Array(buf, fileofs, filelen)); + loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs + filelen); + } + + /** + * Load visibility data for potentially visible set (PVS) calculations. + */ + #loadVisibility(loadmodel: BrushModel, buf: ArrayBuffer): void { + const view = new DataView(buf); + const lump = BSP29Loader.#lump; + const fileofs = view.getUint32((lump.visibility << 3) + 4, true); + const filelen = view.getUint32((lump.visibility << 3) + 8, true); + + if (filelen === 0) { + return; + } + + loadmodel.visdata = new Uint8Array(new ArrayBuffer(filelen)); + loadmodel.visdata.set(new Uint8Array(buf, fileofs, filelen)); + loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs + filelen); + } + + /** + * Build cluster-native visibility data structures after vis + leafs are loaded. + * Sets numclusters, builds clusterPvsOffsets from leaf visofs values, and + * computes PHS (Potentially Hearable Set) via transitive closure of PVS. + */ + #buildClusterData(loadmodel: BrushModel): void { + const numclusters = loadmodel.leafs.length - 1; // leaf 0 is outside sentinel + loadmodel.numclusters = numclusters; + + // BSP29/BSP2 maps have no area data — single area, no portals + loadmodel.numAreas = 1; + loadmodel.areaPortals.init(1, []); + + if (loadmodel.visdata === null || numclusters <= 0) { + return; + } + + // Build clusterPvsOffsets from leaf visofs values + // For BSP29: cluster c = leaf c+1, so clusterPvsOffsets[c] = leafs[c+1].visofs + const clusterPvsOffsets = new Array(numclusters); + + for (let c = 0; c < numclusters; c++) { + clusterPvsOffsets[c] = loadmodel.leafs[c + 1].visofs; + } + + loadmodel.clusterPvsOffsets = clusterPvsOffsets; + + // Compute PHS via transitive closure of PVS + this.#computePHS(loadmodel); + } + + /** + * Compute PHS (Potentially Hearable Set) data by transitive closure of PVS. + * For each cluster, the PHS includes all clusters visible from any cluster + * that is itself visible from the source cluster (one-hop expansion). + */ + #computePHS(loadmodel: BrushModel): void { + const numclusters = loadmodel.numclusters; + const clusterBytes = (numclusters + 7) >> 3; + const visdata = loadmodel.visdata; + const offsets = loadmodel.clusterPvsOffsets; + + if (visdata === null || offsets === null || numclusters <= 0) { + return; + } + + // Decompress all PVS rows into a flat buffer for fast OR operations + const pvsRows = new Uint8Array(numclusters * clusterBytes); + for (let c = 0; c < numclusters; c++) { + const rowStart = c * clusterBytes; + + if (offsets[c] < 0) { + continue; // no vis for this cluster; row stays zero + } + + for (let _out = 0, _in = offsets[c]; _out < clusterBytes;) { + // Bounds check to prevent reading past end of visdata + // Note: It's normal for visibility data to end before clusterBytes is filled; + // remaining bytes stay zero, which is correct for unvisible clusters. + if (_in >= visdata.length) { + break; + } + + if (visdata[_in] !== 0) { + pvsRows[rowStart + _out++] = visdata[_in++]; + continue; + } + + // RLE: 0 byte followed by count of zeros + if (_in + 1 >= visdata.length) { + // End of RLE data; remaining output stays zero (unvisible) + break; + } + + for (let skip = visdata[_in + 1]; skip > 0; skip--) { + pvsRows[rowStart + _out++] = 0x00; + } + + _in += 2; + } + } + + // Transitive closure: PHS[src] = OR of PVS[c] for all c where PVS[src] has bit c set + const phsRows = new Uint8Array(numclusters * clusterBytes); + for (let src = 0; src < numclusters; src++) { + const srcPvsStart = src * clusterBytes; + const dstPhsStart = src * clusterBytes; + + // Start with the PVS itself + for (let b = 0; b < clusterBytes; b++) { + phsRows[dstPhsStart + b] = pvsRows[srcPvsStart + b]; + } + + // OR in PVS of every cluster visible from src + for (let c = 0; c < numclusters; c++) { + if ((pvsRows[srcPvsStart + (c >> 3)] & (1 << (c & 7))) === 0) { + continue; + } + + const neighborPvsStart = c * clusterBytes; + + for (let b = 0; b < clusterBytes; b++) { + phsRows[dstPhsStart + b] |= pvsRows[neighborPvsStart + b]; + } + } + } + + // RLE-compress PHS rows into phsdata + build clusterPhsOffsets + // Worst case: each row expands to clusterBytes (no compression) + // Typical case: significant compression due to zero runs + // Format: Non-zero bytes are literal, [0, count] represents count zero bytes + const phsBuffer = []; + const clusterPhsOffsets = new Array(numclusters); + + for (let c = 0; c < numclusters; c++) { + clusterPhsOffsets[c] = phsBuffer.length; + const rowStart = c * clusterBytes; + let i = 0; + + while (i < clusterBytes) { + if (phsRows[rowStart + i] !== 0) { + // Literal non-zero byte + phsBuffer.push(phsRows[rowStart + i]); + i++; + } else { + // Count zero run (up to 255 bytes) + let zeroCount = 0; + + while (i < clusterBytes && phsRows[rowStart + i] === 0 && zeroCount < 255) { + i++; + zeroCount++; + } + + // Encode as [0, count] + phsBuffer.push(0); + phsBuffer.push(zeroCount); + } + } + } + + loadmodel.phsdata = new Uint8Array(phsBuffer); + loadmodel.clusterPhsOffsets = clusterPhsOffsets; + } + + /** + * Compute visibility areas and portals based on door entities and PHS. + * This traverses the BSP tree to classify leaves against door planes, + * then clusters them based on PVS connectivity. + */ + #computeAreas(loadmodel: BrushModel): void { + // 1. Identify Portal Definitions (Doors) + const portalDefs = this.#parsePortalEntities(loadmodel); + + // 2. Build Portal Planes from Door BBoxes + // Merge bboxes for shared portalNums (e.g. double doors) + const mergedBboxes = new Map(); + + for (const def of portalDefs) { + const submodel = loadmodel.submodels[def.modelIndex - 1]; // *N -> submodels[N-1] + + if (!submodel) { + Con.PrintWarning(`BSP29Loader.computeAreas: portal ${def.portalNum} references invalid model *${def.modelIndex}\n`); + continue; + } + + const existing = mergedBboxes.get(def.portalNum); + + if (existing) { + for (let k = 0; k < 3; k++) { + existing.mins[k] = Math.min(existing.mins[k], submodel.mins[k]); + existing.maxs[k] = Math.max(existing.maxs[k], submodel.maxs[k]); + } + } else { + mergedBboxes.set(def.portalNum, { + mins: [submodel.mins[0], submodel.mins[1], submodel.mins[2]], + maxs: [submodel.maxs[0], submodel.maxs[1], submodel.maxs[2]], + }); + } + } + + const portals = []; + + for (const [portalNum, bbox] of mergedBboxes) { + // Determine split plane (thinnest axis) + const size = [bbox.maxs[0] - bbox.mins[0], bbox.maxs[1] - bbox.mins[1], bbox.maxs[2] - bbox.mins[2]]; + let axis = 0; + + if (size[1] < size[0]) { + axis = 1; + } + + if (size[2] < size[axis]) { + axis = 2; + } + + const dist = (bbox.mins[axis] + bbox.maxs[axis]) * 0.5; + const thickness = (bbox.maxs[axis] - bbox.mins[axis]) * 0.5; + const offset = Math.max(24.0, thickness + 16.0); // Offset for PHS sampling + + // Sample PHS points + const center = [(bbox.mins[0] + bbox.maxs[0]) * 0.5, (bbox.mins[1] + bbox.maxs[1]) * 0.5, (bbox.mins[2] + bbox.maxs[2]) * 0.5]; + const backPt = [...center]; + backPt[axis] -= offset; + const frontPt = [...center]; + frontPt[axis] += offset; + + portals.push({ + portalNum, + axis, + dist, + backVis: loadmodel.getPhsByPoint(new Vector(...backPt)), + frontVis: loadmodel.getPhsByPoint(new Vector(...frontPt)), + }); + } + + // 3. Traverse BSP Nodes to assign "Side" Signatures + // Optimization: If a node is fully on one side of a portal plane, all its children are too. + const leafSignatures = new Map(); + + /** Recursively classify nodes against portal planes. */ + const classifyRecursive = (node: Node, states: number[]): void => { + // Check unknown portals against node bounds + const nextStates = states.slice(); + + for (let i = 0; i < portals.length; i++) { + if (nextStates[i] !== -1) { + continue; + } + + const p = portals[i]; + + if (node.maxs[p.axis] < p.dist) { + nextStates[i] = 0; + } else if (node.mins[p.axis] > p.dist) { + nextStates[i] = 1; + } + } + + if (node.contents < 0) { + // Leaf Node + if (node.contents === content.CONTENT_SOLID) { + node.area = 0; + return; + } + + const cx = (node.mins[0] + node.maxs[0]) * 0.5; + const cy = (node.mins[1] + node.maxs[1]) * 0.5; + const cz = (node.mins[2] + node.maxs[2]) * 0.5; + const center = [cx, cy, cz]; + + let sig = ''; + + for (let i = 0; i < portals.length; i++) { + let side = nextStates[i]; + + if (side === -1) { + side = center[portals[i].axis] >= portals[i].dist ? 1 : 0; + } + + // Nearness check via PHS + const p = portals[i]; + const isNear = side === 0 ? p.backVis.isRevealed(node.num) : p.frontVis.isRevealed(node.num); + + // Sig Codes: 0=BackFar, 1=BackNear, 2=FrontNear, 3=FrontFar + let code = 0; + + if (side === 0) { + code = isNear ? 1 : 0; + } else { + code = isNear ? 2 : 3; + } + + sig += code.toString(); + } + + leafSignatures.set(node.num, sig); + return; + } + + // Inner Node + if (node.children[0]) { + classifyRecursive(node.children[0], nextStates); + } + if (node.children[1]) { + classifyRecursive(node.children[1], nextStates); + } + }; + + // Start traversal from root + const initialStates = new Array(portals.length).fill(-1); + classifyRecursive(loadmodel.nodes[0], initialStates); + + // 4. Cluster Signatures into Areas (PVS connectivity) + const sigGroups = new Map(); + + for (const [leafNum, sig] of leafSignatures) { + if (!sigGroups.has(sig)) { + sigGroups.set(sig, []); + } + sigGroups.get(sig).push(leafNum); + } + + let nextArea = 1; + const areasList: { sig: string; area: number }[] = []; + const areaLeafsMap = new Map(); + + for (const [sig, leafIndices] of sigGroups) { + const visited = new Set(); + + for (const startLeaf of leafIndices) { + if (visited.has(startLeaf)) { + continue; + } + + const area = nextArea++; + areasList.push({ sig, area }); + areaLeafsMap.set(area, []); + + // BFS within this signature group using PVS + const queue = [startLeaf]; + visited.add(startLeaf); + loadmodel.leafs[startLeaf].area = area; + areaLeafsMap.get(area).push(startLeaf); + + while (queue.length > 0) { + const current = queue.shift(); + const pvs = loadmodel.getPvsByLeaf(loadmodel.leafs[current]); + + // We only need to check leaves in the same signature group + for (const candidate of leafIndices) { + if (!visited.has(candidate) && pvs.isRevealed(candidate)) { + visited.add(candidate); + loadmodel.leafs[candidate].area = area; + areaLeafsMap.get(area).push(candidate); + queue.push(candidate); + } + } + } + } + } + + loadmodel.numAreas = nextArea; + + // 5. Connect Areas + const allConnections: { area0: number; area1: number; group: number }[] = []; + + for (let i = 0; i < areasList.length; i++) { + for (let j = i + 1; j < areasList.length; j++) { + const { sig: sigA, area: areaA } = areasList[i]; + const { sig: sigB, area: areaB } = areasList[j]; + + // Differences logic: + // 1 -> 2 (BackNear <-> FrontNear) : Door Crossing (Gated) + // Others: Open connection (just movement) + let diffCount = 0; + let doorCount = 0; + let doorGroup = -1; + + for (let k = 0; k < sigA.length; k++) { + if (sigA[k] !== sigB[k]) { + diffCount++; + const s1 = parseInt(sigA[k]); + const s2 = parseInt(sigB[k]); + + // Check if transition is BackNear(1) <-> FrontNear(2) + if ((s1 === 1 && s2 === 2) || (s1 === 2 && s2 === 1)) { + doorCount++; + doorGroup = portals[k].portalNum; + } + } + } + + if (diffCount === 0) { + continue; + } + + // PVS Adjacency Check + const leafsA = areaLeafsMap.get(areaA); + const leafsB = areaLeafsMap.get(areaB); + let pvsAdjacent = false; + + // Check A seeing B (optimization: only check first few or scan until hit) + for (let li = 0; li < leafsA.length && !pvsAdjacent; li++) { + const pvs = loadmodel.getPvsByLeaf(loadmodel.leafs[leafsA[li]]); + for (let lj = 0; lj < leafsB.length; lj++) { + if (pvs.isRevealed(leafsB[lj])) { + pvsAdjacent = true; + break; + } + } + } + + if (!pvsAdjacent) { + continue; + } + + if (doorCount === 1) { + allConnections.push({ area0: areaA, area1: areaB, group: doorGroup }); + } else if (doorCount === 0) { + allConnections.push({ area0: areaA, area1: areaB, group: -1 }); + } + } + } + + let maxGroup = 0; + for (const c of allConnections) { + if (c.group > maxGroup) { + maxGroup = c.group; + } + } + const numGroups = maxGroup + 1; + + loadmodel.portalDefs = allConnections; + loadmodel.areaPortals.init(loadmodel.numAreas, allConnections, numGroups); + + Con.DPrint(`BSP29Loader.computeAreas: computed ${loadmodel.numAreas} areas, ${allConnections.length} connections, ${numGroups} groups\n`); + } + + /** + * Parse the entity lump for portal entity definitions. + * Entities with an explicit "portal" key are used directly. Door entities + * (func_door, func_door_secret) with brush models are auto-assigned portal + * numbers if they don't have an explicit one. The mapping from model name + * to portal number is stored in loadmodel.modelPortalMap. + * @returns Portal definitions keyed by submodel index. + */ + #parsePortalEntities(loadmodel: BrushModel): { portalNum: number; modelIndex: number }[] { + const portals: { portalNum: number; modelIndex: number }[] = []; + + // First pass: collect explicit portals and door entities needing auto-assignment + const autoAssignDoors: { modelIndex: number; model: string }[] = []; + let maxExplicitPortal = -1; + + let data = loadmodel.entities; + + Con.DPrint('BSP29Loader.#parsePortalEntities: looking for portals in entity lump...\n'); + + while (data) { + const parsed = COM.Parse(data); + data = parsed.data; + + if (!data) { + break; + } + + // Parse one entity block + const ent: Record = {}; + + while (data) { + const parsedKey = COM.Parse(data); + data = parsedKey.data; + + if (!data || parsedKey.token === '}') { + break; + } + + const parsedValue = COM.Parse(data); + data = parsedValue.data; + + if (!data || parsedValue.token === '}') { + break; + } + + ent[parsedKey.token] = parsedValue.token; + } + + if (!ent.model || !ent.model.startsWith('*')) { + continue; + } + + const modelIndex = parseInt(ent.model.substring(1), 10); + + if (isNaN(modelIndex) || modelIndex <= 0) { + continue; + } + + // Explicit portal key takes priority + if (ent.portal !== undefined) { + const portalNum = parseInt(ent.portal, 10); + + if (!isNaN(portalNum) && portalNum >= 0) { + portals.push({ portalNum, modelIndex }); + loadmodel.modelPortalMap[ent.model] = portalNum; + + if (portalNum > maxExplicitPortal) { + maxExplicitPortal = portalNum; + } + } + + continue; + } + + // CR: temporarily disabled due to funny bugs + // // Auto-assign portal numbers to door entities + // if (ent.classname is an auto-assigned door classname) { + // Con.DPrint(`...detected portal ${ent.classname} with model ${ent.model}\n`); + // autoAssignDoors.push({ modelIndex, model: ent.model }); + // } + } + + // Auto-assign portal numbers starting after the highest explicit one. + // Double doors (two touching door halves) must share a single portal + // number, otherwise each half generates its own splitting plane and + // the area assignment breaks. + let nextPortal = maxExplicitPortal + 1; + + // Group touching doors using union-find so linked door pairs share + // a portal. This mirrors the game-side _linkDoors() touching test. + const doorCount = autoAssignDoors.length; + + const parent: number[] = autoAssignDoors.map((_, i) => i); + + const find = (i: number): number => { + while (parent[i] !== i) { + parent[i] = parent[parent[i]]; // path compression + i = parent[i]; + } + + return i; + }; + + const union = (a: number, b: number): void => { + parent[find(a)] = find(b); + }; + + for (let i = 0; i < doorCount; i++) { + const smA = loadmodel.submodels[autoAssignDoors[i].modelIndex - 1]; + + if (!smA) { + continue; + } + + for (let j = i + 1; j < doorCount; j++) { + const smB = loadmodel.submodels[autoAssignDoors[j].modelIndex - 1]; + + if (!smB) { + continue; + } + + // Same touching test as BaseEntity.isTouching / QuakeC EntitiesTouching + if (smA.mins[0] <= smB.maxs[0] && smA.maxs[0] >= smB.mins[0] + && smA.mins[1] <= smB.maxs[1] && smA.maxs[1] >= smB.mins[1] + && smA.mins[2] <= smB.maxs[2] && smA.maxs[2] >= smB.mins[2]) { + union(i, j); + } + } + } + + // Assign one portal number per group + const groupPortal = new Map(); + + for (let i = 0; i < doorCount; i++) { + const door = autoAssignDoors[i]; + + if (loadmodel.modelPortalMap[door.model]) { + continue; + } + + const root = find(i); + let portalNum = groupPortal.get(root); + + if (portalNum === undefined) { + portalNum = nextPortal++; + groupPortal.set(root, portalNum); + } + + portals.push({ portalNum, modelIndex: door.modelIndex }); + loadmodel.modelPortalMap[door.model] = portalNum; + } + + if (autoAssignDoors.length > 0) { + Con.DPrint(`BSP29Loader.#parsePortalEntities: auto-assigned ${autoAssignDoors.length} door portals (${groupPortal.size} groups)\n`); + } + + return portals; + } + + /** + * Load entities from BSP lump and parse worldspawn properties. + * Also tries to parse light entities to determine if RGB lighting is used + * and whether we need to load the .lit file. + */ + #loadEntities(loadmodel: BrushModel, buf: ArrayBuffer): void { + const view = new DataView(buf); + const lump = BSP29Loader.#lump; + const fileofs = view.getUint32((lump.entities << 3) + 4, true); + const filelen = view.getUint32((lump.entities << 3) + 8, true); + loadmodel.entities = Q.memstr(new Uint8Array(buf, fileofs, filelen)); + loadmodel.worldspawnInfo = {}; + + let data = loadmodel.entities; + + // going for worldspawn and light + let stillLooking = 2; + while (stillLooking > 0) { + const parsed = COM.Parse(data); + data = parsed.data; + + if (!data) { + break; + } + + const currentEntity: Record = {}; + while (data) { + const parsedKey = COM.Parse(data); + data = parsedKey.data; + + if (!data || parsedKey.token === '}') { + break; + } + + const parsedValue = COM.Parse(data); + data = parsedValue.data; + + if (!data || parsedKey.token === '}') { + break; + } + + currentEntity[parsedKey.token] = parsedValue.token; + } + + if (!currentEntity.classname) { + break; + } + + switch (currentEntity.classname) { + case 'worldspawn': + Object.assign(loadmodel.worldspawnInfo, currentEntity); + stillLooking--; + break; + + case 'light': + if (currentEntity._color) { + loadmodel.coloredlights = true; + stillLooking--; + } + break; + } + } + + // Second pass: parse func_fog entities from the entity lump + // (moved to load() after submodels load so we can also scan submodel faces) + + loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs + filelen); + } + + /** + * Parse func_fog entities from the BSP entity lump and store + * them as fog volume descriptors on the model. + */ + #parseFogVolumes(loadmodel: BrushModel): void { + loadmodel.fogVolumes.length = 0; + + let data = loadmodel.entities; + if (!data) { + return; + } + + while (data) { + const parsed = COM.Parse(data); + data = parsed.data; + + if (!data) { + break; + } + + const entity: Record = {}; + + while (data) { + const parsedKey = COM.Parse(data); + data = parsedKey.data; + + if (!data || parsedKey.token === '}') { + break; + } + + const parsedValue = COM.Parse(data); + data = parsedValue.data; + + if (!data || parsedValue.token === '}') { + break; + } + + entity[parsedKey.token] = parsedValue.token; + } + + if (entity.classname !== 'func_fog' || !entity.model) { + continue; + } + + const modelIndex = parseInt(entity.model.substring(1), 10); + + if (isNaN(modelIndex) || modelIndex <= 0) { + Con.PrintWarning(`func_fog has invalid model '${entity.model}'\n`); + continue; + } + + const colorParts = (entity.fog_color || '128 128 128').split(/\s+/).map(Number); + const submodel = loadmodel.submodels[modelIndex - 1]; + + loadmodel.fogVolumes.push({ + modelIndex, + color: [colorParts[0] || 128, colorParts[1] || 128, colorParts[2] || 128], + density: parseFloat(entity.fog_density || '0.01'), + maxOpacity: Math.min(1.0, Math.max(0.0, parseFloat(entity.fog_max_opacity || '0.8'))), + mins: submodel ? [submodel.mins[0], submodel.mins[1], submodel.mins[2]] : [0, 0, 0], + maxs: submodel ? [submodel.maxs[0], submodel.maxs[1], submodel.maxs[2]] : [0, 0, 0], + }); + + Con.DPrint(`Found func_fog: model *${modelIndex}, color ${colorParts}, density ${entity.fog_density || '0.01'}\n`); + } + + if (loadmodel.worldspawnInfo._qs_autogen_fog !== '1') { + Con.DPrint('Auto-generation of fog volumes is disabled.\n'); + return; + } + + // Auto-generate fog volumes for turbulent submodels (water, slime, lava) + // that weren't already claimed by a func_fog entity + const claimedModels = new Set(loadmodel.fogVolumes.map((fv) => fv.modelIndex)); + + for (let i = 0; i < loadmodel.submodels.length; i++) { + const modelIndex = i + 1; // submodels[0] = *1, submodels[1] = *2, etc. + + if (claimedModels.has(modelIndex)) { + continue; + } + + const submodel = loadmodel.submodels[i]; + + // Check if ALL faces in this submodel are turbulent + let allTurbulent = submodel.numfaces > 0; + let turbulentTextureIndex = -1; + + for (let j = 0; j < submodel.numfaces; j++) { + const face = submodel.faces[submodel.firstface + j]; + const material = loadmodel.textures[face.texture]; + + if (!(material.flags & materialFlags.MF_TURBULENT)) { + allTurbulent = false; + break; + } + + if (turbulentTextureIndex === -1) { + turbulentTextureIndex = face.texture; + } + } + + if (!allTurbulent || turbulentTextureIndex === -1) { + continue; + } + + // Derive fog color from the texture's average color, modulated by ambient light + const material = loadmodel.textures[turbulentTextureIndex]; + const baseColor = material.averageColor || [128, 128, 128]; + const volMins = [submodel.mins[0], submodel.mins[1], submodel.mins[2]]; + const volMaxs = [submodel.maxs[0], submodel.maxs[1], submodel.maxs[2]]; + const lightFactor = this.#sampleAmbientLightForVolume(loadmodel, volMins, volMaxs); + const color = [ + Math.round(baseColor[0] * lightFactor), + Math.round(baseColor[1] * lightFactor), + Math.round(baseColor[2] * lightFactor), + ]; + + loadmodel.fogVolumes.push({ + modelIndex, + color, + density: 0.02, + maxOpacity: 0.85, + mins: volMins, + maxs: volMaxs, + }); + + Con.DPrint(`Auto-fog for turbulent *${modelIndex} (${material.name}): color [${color}], light=${lightFactor.toFixed(2)}\n`); + } + + // Phase 3: Auto-generate fog volumes for world-level turbulent surfaces + // (water/slime/lava that belong to the worldspawn, not a brush entity). + // These exist as BSP leafs with CONTENT_WATER/SLIME/LAVA contents. + // We cluster adjacent leafs of the same type and create fog volumes from their merged AABBs. + this.#parseFogVolumesFromWorldLeafs(loadmodel); + } + + /** + * Create fog volumes for world-level water/slime/lava directly from BSP leafs. + * Each liquid leaf gets its own fog volume with exact bounds from the BSP compiler, + * avoiding imprecision from merging AABBs across multiple leafs. + */ + #parseFogVolumesFromWorldLeafs(loadmodel: BrushModel): void { + if (!loadmodel.leafs || loadmodel.leafs.length === 0) { + return; + } + + const liquidLeafsByType = new Map(); + + for (let i = 0; i < loadmodel.leafs.length; i++) { + const leaf = loadmodel.leafs[i]; + const c = leaf.contents; + + if (c !== content.CONTENT_WATER && c !== content.CONTENT_SLIME && c !== content.CONTENT_LAVA) { + continue; + } + + if (!liquidLeafsByType.has(c)) { + liquidLeafsByType.set(c, []); + } + liquidLeafsByType.get(c).push(i); + } + + if (liquidLeafsByType.size === 0) { + return; + } + + for (const [contentType, leafIndices] of liquidLeafsByType) { + // Find dominant turbulent texture color across all leafs of this type + const color = this.#findClusterTurbulentColor(loadmodel, leafIndices) || [128, 128, 128]; + + const contentName = contentType === content.CONTENT_WATER ? 'water' + : contentType === content.CONTENT_SLIME ? 'slime' : 'lava'; + + const density = contentType === content.CONTENT_LAVA ? 0.05 + : contentType === content.CONTENT_SLIME ? 0.01 : 0.005; + + // Create one fog volume per leaf with exact BSP bounds, modulated by ambient light + for (const leafIdx of leafIndices) { + const leaf = loadmodel.leafs[leafIdx]; + const volMins = [leaf.mins[0], leaf.mins[1], leaf.mins[2]]; + const volMaxs = [leaf.maxs[0], leaf.maxs[1], leaf.maxs[2]]; + const lightFactor = this.#sampleAmbientLightForVolume(loadmodel, volMins, volMaxs); + const dimmedColor = [ + Math.round(color[0] * lightFactor), + Math.round(color[1] * lightFactor), + Math.round(color[2] * lightFactor), + ]; + + loadmodel.fogVolumes.push({ + modelIndex: 0, + color: dimmedColor, + density, + maxOpacity: 0.85, + mins: volMins, + maxs: volMaxs, + }); + + Con.DPrint(`Auto-fog: ${leafIdx} ${contentName} leaf volume, base color [${color}], lightFactor = ${lightFactor.toFixed(2)}\n`); + } + } + } + + /** + * Find the average color of the dominant turbulent texture in a set of leafs. + * Scans marksurfaces for turbulent faces and returns the most common texture's color. + * @returns The dominant turbulent color, or null when no turbulent face is present. + */ + #findClusterTurbulentColor(loadmodel: BrushModel, leafIndices: number[]): number[] | null { + const textureCounts = new Map(); + + for (const leafIdx of leafIndices) { + const leaf = loadmodel.leafs[leafIdx]; + + for (let k = 0; k < leaf.nummarksurfaces; k++) { + const faceIdx = loadmodel.marksurfaces[leaf.firstmarksurface + k]; + const face = loadmodel.faces[faceIdx]; + + if (!face.turbulent) { + continue; + } + + textureCounts.set(face.texture, (textureCounts.get(face.texture) || 0) + 1); + } + } + + if (textureCounts.size === 0) { + return null; + } + + // Find the most common turbulent texture + let bestTexture = -1; + let bestCount = 0; + + for (const [texIdx, count] of textureCounts) { + if (count > bestCount) { + bestCount = count; + bestTexture = texIdx; + } + } + + const material = loadmodel.textures[bestTexture]; + return material?.averageColor || null; + } + + /** + * Sample the average ambient light intensity near a fog volume's bounding box. + * Scans BSP leafs that overlap the expanded AABB and samples lightmap data + * from non-turbulent, non-sky faces to estimate the local light level. + * @returns A normalized lighting factor in the range used for fog modulation. + */ + #sampleAmbientLightForVolume(loadmodel: BrushModel, mins: number[], maxs: number[]): number { + if ((loadmodel.lightdata_rgb === null && loadmodel.lightdata === null) || !loadmodel.leafs || loadmodel.leafs.length === 0) { + return 1.0; + } + + // Expand AABB by 64 units to catch nearby lit surfaces + const expand = 64; + const eMins = [mins[0] - expand, mins[1] - expand, mins[2] - expand]; + const eMaxs = [maxs[0] + expand, maxs[1] + expand, maxs[2] + expand]; + + let totalIntensity = 0; + let sampleCount = 0; + + const hasRGB = loadmodel.lightdata_rgb !== null; + + for (let i = 0; i < loadmodel.leafs.length; i++) { + const leaf = loadmodel.leafs[i]; + + // Skip liquid leafs — we want light from surrounding solid geometry + if (leaf.contents === content.CONTENT_WATER + || leaf.contents === content.CONTENT_SLIME + || leaf.contents === content.CONTENT_LAVA) { + continue; + } + + // AABB overlap test + if (leaf.mins[0] > eMaxs[0] || leaf.maxs[0] < eMins[0] + || leaf.mins[1] > eMaxs[1] || leaf.maxs[1] < eMins[1] + || leaf.mins[2] > eMaxs[2] || leaf.maxs[2] < eMins[2]) { + continue; + } + + // Scan marksurfaces in this leaf + for (let k = 0; k < leaf.nummarksurfaces; k++) { + const faceIdx = loadmodel.marksurfaces[leaf.firstmarksurface + k]; + const face = loadmodel.faces[faceIdx]; + + if (face.turbulent || face.sky || face.lightofs < 0 || face.styles.length === 0) { + continue; + } + + // Compute lightmap dimensions for this face + const smax = (face.extents[0] >> face.lmshift) + 1; + const tmax = (face.extents[1] >> face.lmshift) + 1; + const size = smax * tmax; + + if (size <= 0 || size > 4096) { + continue; + } + + // Sample only the first light style (style 0 = static light) + if (hasRGB) { + const offset = face.lightofs * 3; + + if (offset + size * 3 > loadmodel.lightdata_rgb.length) { + continue; + } + + for (let s = 0; s < size * 3; s += 3) { + // Perceptual luminance + totalIntensity += loadmodel.lightdata_rgb[offset + s] * 0.299 + + loadmodel.lightdata_rgb[offset + s + 1] * 0.587 + + loadmodel.lightdata_rgb[offset + s + 2] * 0.114; + sampleCount++; + } + } else { + const offset = face.lightofs; + + if (offset + size > loadmodel.lightdata.length) { + continue; + } + + for (let s = 0; s < size; s++) { + totalIntensity += loadmodel.lightdata[offset + s]; + sampleCount++; + } + } + } + } + + if (sampleCount === 0) { + return 1.0; + } + + // Average intensity in 0-255 range, normalize to a 0-1 scale. + // Quake lighting with value ~200 is considered well-lit; we use 200 as reference + // so well-lit areas keep the fog color roughly unchanged. + const avgIntensity = totalIntensity / sampleCount; + const factor = Math.min(1.0, Math.max(0.15, avgIntensity / 200.0)); + return factor; + } + + /** + * Compute the average color of an RGBA texture buffer. + * Skips fully transparent pixels so alpha-masked areas don't dilute the color. + * @returns The average RGB color. + */ + static #computeAverageColor(rgba: Uint8Array): number[] { + let r = 0; + let g = 0; + let b = 0; + let count = 0; + + for (let i = 0; i < rgba.length; i += 4) { + if (rgba[i + 3] === 0) { + continue; // skip fully transparent pixels + } + r += rgba[i]; + g += rgba[i + 1]; + b += rgba[i + 2]; + count++; + } + + if (count === 0) { + return [128, 128, 128]; + } + + return [ + Math.round(r / count), + Math.round(g / count), + Math.round(b / count), + ]; + } + + /** + * Load vertices from BSP lump. + */ + #loadVertexes(loadmodel: BrushModel, buf: ArrayBuffer): void { + const view = new DataView(buf); + const lump = BSP29Loader.#lump; + let fileofs = view.getUint32((lump.vertexes << 3) + 4, true); + const filelen = view.getUint32((lump.vertexes << 3) + 8, true); + if ((filelen % 12) !== 0) { + throw new Error(`BSP29Loader: vertexes lump size is not a multiple of 12 in ${loadmodel.name}`); + } + const count = filelen / 12; + loadmodel.vertexes.length = 0; + for (let i = 0; i < count; i++) { + loadmodel.vertexes[i] = new Vector( + view.getFloat32(fileofs, true), + view.getFloat32(fileofs + 4, true), + view.getFloat32(fileofs + 8, true), + ); + fileofs += 12; + } + loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); + } + + /** + * Load edges from BSP lump. + */ + #loadEdges(loadmodel: BrushModel, buf: ArrayBuffer): void { + const view = new DataView(buf); + const lump = BSP29Loader.#lump; + let fileofs = view.getUint32((lump.edges << 3) + 4, true); + const filelen = view.getUint32((lump.edges << 3) + 8, true); + if ((filelen & 3) !== 0) { + throw new CorruptedResourceError(loadmodel.name, 'BSP29Loader: edges lump size is not a multiple of 4'); + } + const count = filelen >> 2; + loadmodel.edges.length = 0; + for (let i = 0; i < count; i++) { + loadmodel.edges[i] = [view.getUint16(fileofs, true), view.getUint16(fileofs + 2, true)]; + fileofs += 4; + } + loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); + } + + /** + * Load surface edges from BSP lump (indices into edge array, negative = reversed). + */ + #loadSurfedges(loadmodel: BrushModel, buf: ArrayBuffer): void { + const view = new DataView(buf); + const lump = BSP29Loader.#lump; + const fileofs = view.getUint32((lump.surfedges << 3) + 4, true); + const filelen = view.getUint32((lump.surfedges << 3) + 8, true); + const count = filelen >> 2; + loadmodel.surfedges.length = 0; + for (let i = 0; i < count; i++) { + loadmodel.surfedges[i] = view.getInt32(fileofs + (i << 2), true); + } + loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs + filelen); + } + + /** + * Load planes from BSP lump. + */ + #loadPlanes(loadmodel: BrushModel, buf: ArrayBuffer): void { + const view = new DataView(buf); + const lump = BSP29Loader.#lump; + let fileofs = view.getUint32((lump.planes << 3) + 4, true); + const filelen = view.getUint32((lump.planes << 3) + 8, true); + if ((filelen % 20) !== 0) { + throw new Error(`BSP29Loader: planes lump size is not a multiple of 20 in ${loadmodel.name}`); + } + const count = filelen / 20; + loadmodel.planes.length = 0; + for (let i = 0; i < count; i++) { + const normal = new Vector( + view.getFloat32(fileofs, true), + view.getFloat32(fileofs + 4, true), + view.getFloat32(fileofs + 8, true), + ); + const dist = view.getFloat32(fileofs + 12, true); + const out = new Plane(normal, dist); + out.type = view.getUint32(fileofs + 16, true); + if (out.normal[0] < 0) { out.signbits |= 1; } + if (out.normal[1] < 0) { out.signbits |= 2; } + if (out.normal[2] < 0) { out.signbits |= 4; } + loadmodel.planes[i] = out; + fileofs += 20; + } + loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); + } + + /** + * Load texture coordinate information from BSP lump. + */ + #loadTexinfo(loadmodel: BrushModel, buf: ArrayBuffer): void { + const view = new DataView(buf); + const lump = BSP29Loader.#lump; + let fileofs = view.getUint32((lump.texinfo << 3) + 4, true); + const filelen = view.getUint32((lump.texinfo << 3) + 8, true); + if ((filelen % 40) !== 0) { + throw new Error(`BSP29Loader: texinfo lump size is not a multiple of 40 in ${loadmodel.name}`); + } + const count = filelen / 40; + loadmodel.texinfo.length = 0; + for (let i = 0; i < count; i++) { + const out = { + vecs: [ + [view.getFloat32(fileofs, true), view.getFloat32(fileofs + 4, true), view.getFloat32(fileofs + 8, true), view.getFloat32(fileofs + 12, true)], + [view.getFloat32(fileofs + 16, true), view.getFloat32(fileofs + 20, true), view.getFloat32(fileofs + 24, true), view.getFloat32(fileofs + 28, true)], + ], + texture: view.getUint32(fileofs + 32, true), + flags: view.getUint32(fileofs + 36, true), + }; + if (out.texture >= loadmodel.textures.length) { + out.texture = loadmodel.textures.length - 1; + out.flags = 0; + } + loadmodel.texinfo[i] = out; + fileofs += 40; + } + loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); + } + + /** + * Load faces (surfaces) from BSP lump and derive oriented face normals. + */ + protected _loadFaces(loadmodel: BrushModel, buf: ArrayBuffer): void { + const view = new DataView(buf); + const lump = BSP29Loader.#lump; + let fileofs = view.getUint32((lump.faces << 3) + 4, true); + const filelen = view.getUint32((lump.faces << 3) + 8, true); + if ((filelen % 20) !== 0) { + throw new CorruptedResourceError(loadmodel.name, 'BSP29Loader: faces lump size is not a multiple of 20'); + } + + const lmshift = loadmodel.worldspawnInfo._lightmap_scale ? Math.log2(parseInt(loadmodel.worldspawnInfo._lightmap_scale)) : 4; + const count = filelen / 20; + loadmodel.firstface = 0; + loadmodel.numfaces = count; + loadmodel.faces.length = 0; + + for (let i = 0; i < count; i++) { + const styles = new Uint8Array(buf, fileofs + 12, 4); + const face = Object.assign(new Face(), { + plane: loadmodel.planes[view.getUint16(fileofs, true)], + planeBack: view.getInt16(fileofs + 2, true) !== 0, + firstedge: view.getInt32(fileofs + 4, true), + numedges: view.getUint16(fileofs + 8, true), + texinfo: view.getUint16(fileofs + 10, true), + styles: [], + lightofs: view.getInt32(fileofs + 16, true), + lmshift, + }); + + for (let j = 0; j < 4; j++) { + if (styles[j] !== 255) { + face.styles[j] = styles[j]; + } + } + + const mins = [Infinity, Infinity]; + const maxs = [-Infinity, -Infinity]; + const tex = loadmodel.texinfo[face.texinfo]; + face.texture = tex.texture; + + for (let j = 0; j < face.numedges; j++) { + const e = loadmodel.surfedges[face.firstedge + j]; + const v = e >= 0 + ? loadmodel.vertexes[loadmodel.edges[e][0]] + : loadmodel.vertexes[loadmodel.edges[-e][1]]; + + const val0 = v.dot(new Vector(...tex.vecs[0])) + tex.vecs[0][3]; + const val1 = v.dot(new Vector(...tex.vecs[1])) + tex.vecs[1][3]; + + if (val0 < mins[0]) { + mins[0] = val0; + } + + if (val0 > maxs[0]) { + maxs[0] = val0; + } + + if (val1 < mins[1]) { + mins[1] = val1; + } + + if (val1 > maxs[1]) { + maxs[1] = val1; + } + } + + const lmscale = 1 << face.lmshift; + face.texturemins = [Math.floor(mins[0] / lmscale) * lmscale, Math.floor(mins[1] / lmscale) * lmscale]; + face.extents = [Math.ceil(maxs[0] / lmscale) * lmscale - face.texturemins[0], Math.ceil(maxs[1] / lmscale) * lmscale - face.texturemins[1]]; + + if (loadmodel.textures[tex.texture].flags & materialFlags.MF_TURBULENT) { + face.turbulent = true; + } else if (loadmodel.textures[tex.texture].flags & materialFlags.MF_SKY) { + face.sky = true; + } + + face.normal.set(face.plane.normal); + if (face.planeBack) { + face.normal.multiply(-1.0); + } + + loadmodel.faces[i] = face; + fileofs += 20; + } + loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); + } + + /** + * Recursively set parent references for BSP tree nodes. + */ + #setParent(node: Node, parent: Node | null): void { + node.parent = parent; + + if (node.contents < 0) { + return; + } + + this.#setParent(node.children[0] as Node, node); + this.#setParent(node.children[1] as Node, node); + } + + /** + * Load BSP tree nodes from BSP lump. + */ + protected _loadNodes(loadmodel: BrushModel, buf: ArrayBuffer): void { + const view = new DataView(buf); + const lump = BSP29Loader.#lump; + let fileofs = view.getUint32((lump.nodes << 3) + 4, true); + const filelen = view.getUint32((lump.nodes << 3) + 8, true); + + if ((filelen === 0) || ((filelen % 24) !== 0)) { + throw new Error(`BSP29Loader: nodes lump size is invalid in ${loadmodel.name}`); + } + + const count = filelen / 24; + loadmodel.nodes.length = 0; + + for (let i = 0; i < count; i++) { + loadmodel.nodes[i] = Object.assign(new Node(loadmodel), { + num: i, + planenum: view.getUint32(fileofs, true), + children: [view.getInt16(fileofs + 4, true), view.getInt16(fileofs + 6, true)], + mins: new Vector(view.getInt16(fileofs + 8, true), view.getInt16(fileofs + 10, true), view.getInt16(fileofs + 12, true)), + maxs: new Vector(view.getInt16(fileofs + 14, true), view.getInt16(fileofs + 16, true), view.getInt16(fileofs + 18, true)), + firstface: view.getUint16(fileofs + 20, true), + numfaces: view.getUint16(fileofs + 22, true), + }); + loadmodel.nodes[i].baseMins = loadmodel.nodes[i].mins.copy(); + loadmodel.nodes[i].baseMaxs = loadmodel.nodes[i].maxs.copy(); + fileofs += 24; + } + + for (let i = 0; i < count; i++) { + const out = loadmodel.nodes[i]; + out.plane = loadmodel.planes[out.planenum]; + // At this point children contain indices, we convert them to Node references + const child0Idx = out.children[0] as number; + const child1Idx = out.children[1] as number; + out.children[0] = child0Idx >= 0 + ? loadmodel.nodes[child0Idx] + : loadmodel.leafs[-1 - child0Idx]; + out.children[1] = child1Idx >= 0 + ? loadmodel.nodes[child1Idx] + : loadmodel.leafs[-1 - child1Idx]; + } + + this.#setParent(loadmodel.nodes[0], null); + loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); + } + + /** + * Load BSP leaf nodes from BSP lump. + */ + protected _loadLeafs(loadmodel: BrushModel, buf: ArrayBuffer): void { + const view = new DataView(buf); + const lump = BSP29Loader.#lump; + let fileofs = view.getUint32((lump.leafs << 3) + 4, true); + const filelen = view.getUint32((lump.leafs << 3) + 8, true); + if ((filelen % 28) !== 0) { + throw new Error(`BSP29Loader: leafs lump size is not a multiple of 28 in ${loadmodel.name}`); + } + const count = filelen / 28; + loadmodel.leafs.length = count; + + for (let i = 0; i < count; i++) { + loadmodel.leafs[i] = Object.assign(new Node(loadmodel), { + num: i, + contents: view.getInt32(fileofs, true), + visofs: view.getInt32(fileofs + 4, true), + cluster: i > 0 ? i - 1 : -1, + mins: new Vector(view.getInt16(fileofs + 8, true), view.getInt16(fileofs + 10, true), view.getInt16(fileofs + 12, true)), + maxs: new Vector(view.getInt16(fileofs + 14, true), view.getInt16(fileofs + 16, true), view.getInt16(fileofs + 18, true)), + firstmarksurface: view.getUint16(fileofs + 20, true), + nummarksurfaces: view.getUint16(fileofs + 22, true), + ambient_level: [ + view.getUint8(fileofs + 24), + view.getUint8(fileofs + 25), + view.getUint8(fileofs + 26), + view.getUint8(fileofs + 27), + ], + }); + loadmodel.leafs[i].baseMins = loadmodel.leafs[i].mins.copy(); + loadmodel.leafs[i].baseMaxs = loadmodel.leafs[i].maxs.copy(); + fileofs += 28; + } + loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); + } + + /** + * Load collision clipnodes and initialize physics hulls. + */ + protected _loadClipnodes(loadmodel: BrushModel, buf: ArrayBuffer): void { + const view = new DataView(buf); + const lump = BSP29Loader.#lump; + let fileofs = view.getUint32((lump.clipnodes << 3) + 4, true); + const filelen = view.getUint32((lump.clipnodes << 3) + 8, true); + const count = filelen >> 3; + loadmodel.clipnodes.length = 0; + + loadmodel.hulls.length = 0; + loadmodel.hulls[1] = { + clipnodes: loadmodel.clipnodes, + firstclipnode: 0, + lastclipnode: count - 1, + planes: loadmodel.planes, + clip_mins: new Vector(-16.0, -16.0, -24.0), + clip_maxs: new Vector(16.0, 16.0, 32.0), + }; + loadmodel.hulls[2] = { + clipnodes: loadmodel.clipnodes, + firstclipnode: 0, + lastclipnode: count - 1, + planes: loadmodel.planes, + clip_mins: new Vector(-32.0, -32.0, -24.0), + clip_maxs: new Vector(32.0, 32.0, 64.0), + }; + + for (let i = 0; i < count; i++) { + loadmodel.clipnodes[i] = { + planenum: view.getUint32(fileofs, true), + children: [view.getInt16(fileofs + 4, true), view.getInt16(fileofs + 6, true)], + }; + fileofs += 8; + } + loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); + } + + /** + * Create hull0 (point hull) from BSP nodes for collision detection. + */ + #makeHull0(loadmodel: BrushModel): void { + const clipnodes = []; + const hull = { + clipnodes: clipnodes, + lastclipnode: loadmodel.nodes.length - 1, + planes: loadmodel.planes, + clip_mins: new Vector(), + clip_maxs: new Vector(), + }; + + for (let i = 0; i < loadmodel.nodes.length; i++) { + const node = loadmodel.nodes[i]; + const out = { planenum: node.planenum, children: [] }; + const child0 = node.children[0] as Node; + const child1 = node.children[1] as Node; + out.children[0] = child0.contents < 0 ? child0.contents : child0.num; + out.children[1] = child1.contents < 0 ? child1.contents : child1.num; + clipnodes[i] = out; + } + loadmodel.hulls[0] = hull; + } + + /** + * Build a reachability mask for the clipnodes owned by a model headnode. + * Legacy BSP29 clipnode arrays are shared across worldspawn and inline + * submodels, so traces must stay within the owning subtree. + * @returns A mask of reachable clipnodes, or null when the requested root is invalid. + */ + _buildAllowedClipnodeMask(clipnodes: Clipnode[], firstclipnode: number): Uint8Array | null { + if (!clipnodes || clipnodes.length === 0 || firstclipnode < 0 || firstclipnode >= clipnodes.length) { + return null; + } + + const allowedClipNodes = new Uint8Array(clipnodes.length); + const stack = [firstclipnode]; + + while (stack.length > 0) { + const nodeIndex = stack.pop()!; + + if (nodeIndex < 0 || nodeIndex >= clipnodes.length || allowedClipNodes[nodeIndex] === 1) { + continue; + } + + allowedClipNodes[nodeIndex] = 1; + + const node = clipnodes[nodeIndex]; + if (!node) { + continue; + } + + for (const childIndex of node.children) { + if (childIndex >= 0) { + stack.push(childIndex); + } + } + } + + return allowedClipNodes; + } + + /** + * Attach the owning subtree mask to a legacy hull. + */ + #assignAllowedClipnodeMask(hull?: AllowedClipnodeHull): void { + if (!hull) { + return; + } + + hull.allowedClipNodes = this._buildAllowedClipnodeMask(hull.clipnodes, hull.firstclipnode); + } + + /** + * Load marksurfaces (face indices visible from each leaf). + */ + protected _loadMarksurfaces(loadmodel: BrushModel, buf: ArrayBuffer): void { + const view = new DataView(buf); + const lump = BSP29Loader.#lump; + const fileofs = view.getUint32((lump.marksurfaces << 3) + 4, true); + const filelen = view.getUint32((lump.marksurfaces << 3) + 8, true); + const count = filelen >> 1; + loadmodel.marksurfaces.length = 0; + + for (let i = 0; i < count; i++) { + const j = view.getUint16(fileofs + (i << 1), true); + if (j > loadmodel.faces.length) { + throw new Error('BSP29Loader: bad surface number in marksurfaces'); + } + loadmodel.marksurfaces[i] = j; + } + loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs + filelen); + } + + /** + * Load submodels (brush models for doors, lifts, etc.). + */ + #loadSubmodels(loadmodel: BrushModel, buf: ArrayBuffer): void { + const view = new DataView(buf); + const lump = BSP29Loader.#lump; + let fileofs = view.getUint32((lump.models << 3) + 4, true); + const filelen = view.getUint32((lump.models << 3) + 8, true); + const count = filelen >> 6; + if (count === 0) { + throw new Error(`BSP29Loader: no submodels in ${loadmodel.name}`); + } + loadmodel.submodels.length = 0; + + loadmodel.mins.setTo(view.getFloat32(fileofs, true) - 1.0, view.getFloat32(fileofs + 4, true) - 1.0, view.getFloat32(fileofs + 8, true) - 1.0); + loadmodel.maxs.setTo(view.getFloat32(fileofs + 12, true) + 1.0, view.getFloat32(fileofs + 16, true) + 1.0, view.getFloat32(fileofs + 20, true) + 1.0); + loadmodel.hulls[0].firstclipnode = view.getUint32(fileofs + 36, true); + loadmodel.hulls[1].firstclipnode = view.getUint32(fileofs + 40, true); + loadmodel.hulls[2].firstclipnode = view.getUint32(fileofs + 44, true); + this.#assignAllowedClipnodeMask(loadmodel.hulls[0]); + this.#assignAllowedClipnodeMask(loadmodel.hulls[1]); + this.#assignAllowedClipnodeMask(loadmodel.hulls[2]); + fileofs += 64; + + const clipnodes = loadmodel.hulls[0].clipnodes; + for (let i = 1; i < count; i++) { + const out = new BrushModel('*' + i); + out.submodel = true; + out.mins.setTo(view.getFloat32(fileofs, true) - 1.0, view.getFloat32(fileofs + 4, true) - 1.0, view.getFloat32(fileofs + 8, true) - 1.0); + out.maxs.setTo(view.getFloat32(fileofs + 12, true) + 1.0, view.getFloat32(fileofs + 16, true) + 1.0, view.getFloat32(fileofs + 20, true) + 1.0); + out.origin.setTo(view.getFloat32(fileofs + 24, true), view.getFloat32(fileofs + 28, true), view.getFloat32(fileofs + 32, true)); + out.hulls = [ + { + clipnodes: clipnodes, + firstclipnode: view.getUint32(fileofs + 36, true), + lastclipnode: loadmodel.nodes.length - 1, + planes: loadmodel.planes, + clip_mins: new Vector(), + clip_maxs: new Vector(), + }, + { + clipnodes: loadmodel.clipnodes, + firstclipnode: view.getUint32(fileofs + 40, true), + lastclipnode: loadmodel.clipnodes.length - 1, + planes: loadmodel.planes, + clip_mins: new Vector(-16.0, -16.0, -24.0), + clip_maxs: new Vector(16.0, 16.0, 32.0), + }, + { + clipnodes: loadmodel.clipnodes, + firstclipnode: view.getUint32(fileofs + 44, true), + lastclipnode: loadmodel.clipnodes.length - 1, + planes: loadmodel.planes, + clip_mins: new Vector(-32.0, -32.0, -24.0), + clip_maxs: new Vector(32.0, 32.0, 64.0), + }, + ]; + this.#assignAllowedClipnodeMask(out.hulls[0]); + this.#assignAllowedClipnodeMask(out.hulls[1]); + this.#assignAllowedClipnodeMask(out.hulls[2]); + out.vertexes = loadmodel.vertexes; + out.edges = loadmodel.edges; + out.surfedges = loadmodel.surfedges; + out.nodes = loadmodel.nodes; + out.leafs = loadmodel.leafs; + out.texinfo = loadmodel.texinfo; + out.textures = loadmodel.textures; + out.marksurfaces = loadmodel.marksurfaces; + out.lightdata = loadmodel.lightdata; + out.lightdata_rgb = loadmodel.lightdata_rgb; + out.deluxemap = loadmodel.deluxemap; + out.faces = loadmodel.faces; + out.visdata = loadmodel.visdata; + out.numclusters = loadmodel.numclusters; + out.clusterPvsOffsets = loadmodel.clusterPvsOffsets; + out.phsdata = loadmodel.phsdata; + out.clusterPhsOffsets = loadmodel.clusterPhsOffsets; + out.firstface = view.getUint32(fileofs + 56, true); + out.numfaces = view.getUint32(fileofs + 60, true); + + // Propagate brush data from world model to submodels (shared arrays) + if (loadmodel.hasBrushData) { + out.brushes = loadmodel.brushes; + out.brushsides = loadmodel.brushsides; + out.leafbrushes = loadmodel.leafbrushes; + out.planes = loadmodel.planes; + + // Set per-submodel brush range from BRUSHLIST data + const brushRange = loadmodel._brushRanges?.get(i); + if (brushRange) { + out.firstBrush = brushRange.firstBrush; + out.numBrushes = brushRange.numBrushes; + } + } + + out.worldspawnInfo = loadmodel.worldspawnInfo; + + loadmodel.submodels[i - 1] = out; + fileofs += 64; + + for (let j = 0; j < out.numfaces; j++) { + out.faces[out.firstface + j].submodel = true; + } + } + loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); + } + + /** + * Load BSPX extended format data (optional extra lumps). + */ + #loadBSPX(loadmodel: BrushModel, buffer: ArrayBuffer): void { + loadmodel.bspxoffset = (loadmodel.bspxoffset + 3) & ~3; + if (loadmodel.bspxoffset >= buffer.byteLength) { + Con.DPrint('BSP29Loader: no BSPX data found\n'); + return; + } + + const view = new DataView(buffer); + const magic = view.getUint32(loadmodel.bspxoffset, true); + console.assert(magic === 0x58505342, 'BSP29Loader: bad BSPX magic'); + + const numlumps = view.getUint32(loadmodel.bspxoffset + 4, true); + Con.DPrint(`BSP29Loader: found BSPX data with ${numlumps} lumps\n`); + + const bspxLumps: BSPXLumps = {}; + for (let i = 0, pointer = loadmodel.bspxoffset + 8; i < numlumps; i++, pointer += 32) { + const name = Q.memstr(new Uint8Array(buffer, pointer, 24)); + const fileofs = view.getUint32(pointer + 24, true); + const filelen = view.getUint32(pointer + 28, true); + bspxLumps[name] = { fileofs, filelen }; + } + loadmodel.bspxlumps = bspxLumps; + } + + /** + * Load BSPX BRUSHLIST lump if available. + * Parses per-model brush data from the BSPX extension, creates Brush and + * BrushSide objects, generates the 6 axial planes that the spec says must + * be inferred from each brush's mins/maxs, and inserts brushes into BSP + * leaf nodes so that BrushTrace can find them during collision testing. + */ + _loadBrushList(loadmodel: BrushModel, buf: ArrayBuffer): void { + if (!loadmodel.bspxlumps || !loadmodel.bspxlumps['BRUSHLIST']) { + return; + } + + const { fileofs, filelen } = loadmodel.bspxlumps['BRUSHLIST']; + + if (filelen === 0) { + return; + } + + const view = new DataView(buf); + let offset = fileofs; + const endOffset = fileofs + filelen; + + const allBrushes: Brush[] = []; + const allBrushSides: BrushSide[] = []; + const allBrushPlanes: Plane[] = []; + + /** + * Create a Plane with type and signbits set. + * type: 0=X, 1=Y, 2=Z for axial, 3/4/5 for non-axial dominant axis. + * @returns A plane with precomputed type and sign bits. + */ + const makePlane = (normal: Vector, dist: number): Plane => { + const p = new Plane(normal, dist); + const ax = Math.abs(normal[0]); + const ay = Math.abs(normal[1]); + const az = Math.abs(normal[2]); + if (ax === 1 && ay === 0 && az === 0) { + p.type = 0; + } else if (ax === 0 && ay === 1 && az === 0) { + p.type = 1; + } else if (ax === 0 && ay === 0 && az === 1) { + p.type = 2; + } else if (ax >= ay && ax >= az) { + p.type = 3; + } else if (ay >= ax && ay >= az) { + p.type = 4; + } else { + p.type = 5; + } + if (normal[0] < 0) { p.signbits |= 1; } + if (normal[1] < 0) { p.signbits |= 2; } + if (normal[2] < 0) { p.signbits |= 4; } + return p; + }; + + // Track per-model brush ranges for submodel assignment + const modelBrushRanges = new Map(); + + while (offset + 16 <= endOffset) { + const ver = view.getUint32(offset, true); + offset += 4; + + if (ver !== 1) { + Con.Print(`BSP29Loader: unsupported BRUSHLIST version ${ver}\n`); + return; + } + + const modelnum = view.getUint32(offset, true); + offset += 4; + const numbrushes = view.getUint32(offset, true); + offset += 4; + const numplanes = view.getUint32(offset, true); + offset += 4; + + const firstBrush = allBrushes.length; + + let planesRead = 0; + + for (let b = 0; b < numbrushes; b++) { + if (offset + 28 > endOffset) { + Con.Print('BSP29Loader: BRUSHLIST lump truncated at brush header\n'); + return; + } + + // Parse brush header: vec3 mins (12) + vec3 maxs (12) + short contents (2) + ushort numplanes (2) = 28 bytes + // Actually: vec_t mins is 3 floats, vec_t maxs is 3 floats + const bmins = new Vector( + view.getFloat32(offset, true), + view.getFloat32(offset + 4, true), + view.getFloat32(offset + 8, true), + ); + offset += 12; + + const bmaxs = new Vector( + view.getFloat32(offset, true), + view.getFloat32(offset + 4, true), + view.getFloat32(offset + 8, true), + ); + offset += 12; + + const brushContents = view.getInt16(offset, true); + offset += 2; + const brushNumPlanes = view.getUint16(offset, true); + offset += 2; + + const firstside = allBrushSides.length; + + // Generate the 6 axial planes inferred from mins/maxs. + // Per the BSPX spec: "Axial planes MUST NOT be written - they will be + // inferred from the brush's mins+maxs." + // +X, -X, +Y, -Y, +Z, -Z + const axialDefs: [Vector, number][] = [ + [new Vector(1, 0, 0), bmaxs[0]], + [new Vector(-1, 0, 0), -bmins[0]], + [new Vector(0, 1, 0), bmaxs[1]], + [new Vector(0, -1, 0), -bmins[1]], + [new Vector(0, 0, 1), bmaxs[2]], + [new Vector(0, 0, -1), -bmins[2]], + ]; + + for (const [normal, dist] of axialDefs) { + const planeIdx = allBrushPlanes.length; + allBrushPlanes.push(makePlane(normal, dist)); + + const side = new BrushSide(loadmodel); + side.planenum = planeIdx; + allBrushSides.push(side); + } + + // Parse the non-axial planes from the lump + if (offset + brushNumPlanes * 16 > endOffset) { + Con.Print('BSP29Loader: BRUSHLIST lump truncated at brush planes\n'); + return; + } + + for (let p = 0; p < brushNumPlanes; p++) { + const normal = new Vector( + view.getFloat32(offset, true), + view.getFloat32(offset + 4, true), + view.getFloat32(offset + 8, true), + ); + const dist = view.getFloat32(offset + 12, true); + offset += 16; + + const planeIdx = allBrushPlanes.length; + allBrushPlanes.push(makePlane(normal, dist)); + + const side = new BrushSide(loadmodel); + side.planenum = planeIdx; + allBrushSides.push(side); + } + + planesRead += brushNumPlanes; + + const brush = new Brush(loadmodel); + brush.firstside = firstside; + brush.numsides = 6 + brushNumPlanes; // axial + explicit + brush.contents = brushContents; + brush.mins = bmins; + brush.maxs = bmaxs; + allBrushes.push(brush); + } + + if (planesRead !== numplanes) { + Con.Print(`BSP29Loader: BRUSHLIST plane count mismatch for model ${modelnum}: expected ${numplanes}, got ${planesRead}\n`); + } + + modelBrushRanges.set(modelnum, { firstBrush, numBrushes: numbrushes }); + } + + if (allBrushes.length === 0) { + return; + } + + // Assign brush data to the world model + loadmodel.brushes = allBrushes; + loadmodel.brushsides = allBrushSides; + + // Use allBrushPlanes as a separate plane array for brush collision. + // These are stored alongside BSP planes but indexed separately by brushsides. + // We store them on the world model where BrushTrace can reference them. + loadmodel.planes = loadmodel.planes.concat(allBrushPlanes); + + // Remap brushside plane indices to account for the offset into the combined array + const planeOffset = loadmodel.planes.length - allBrushPlanes.length; + for (const side of allBrushSides) { + side.planenum += planeOffset; + } + + // Insert brushes into BSP leaf nodes by walking the node tree. + // Each model's brushes are only inserted under that model's headnode, + // so that world traces don't collide with submodel brushes (func_plat, + // trigger_*, func_door, etc.) and vice versa. + + const leafIndexMap = new Map(); + for (let i = 0; i < loadmodel.leafs.length; i++) { + leafIndexMap.set(loadmodel.leafs[i], i); + } + + const leafBrushLists: number[][] = new Array(loadmodel.leafs.length); + for (let i = 0; i < loadmodel.leafs.length; i++) { + leafBrushLists[i] = []; + } + + /** Recursively insert a brush into BSP leaf nodes whose bounds overlap. */ + const insertBrushRecursive = (node: Node, brushIdx: number, brush: Brush): void => { + // Leaf node: add the brush here + if (node.contents < 0) { + const leafIndex = leafIndexMap.get(node); + if (leafIndex !== undefined) { + leafBrushLists[leafIndex].push(brushIdx); + } + return; + } + + // Internal node: test brush AABB against splitting plane + const plane = node.plane; + let d1, d2; + + if (plane.type < 3) { + // Axial plane: fast path + d1 = brush.maxs[plane.type] - plane.dist; + d2 = brush.mins[plane.type] - plane.dist; + } else { + // General plane: compute support points using worst-case AABB corners + const nx = plane.normal[0]; + const ny = plane.normal[1]; + const nz = plane.normal[2]; + + d1 = nx * (nx >= 0 ? brush.maxs[0] : brush.mins[0]) + + ny * (ny >= 0 ? brush.maxs[1] : brush.mins[1]) + + nz * (nz >= 0 ? brush.maxs[2] : brush.mins[2]) + - plane.dist; + d2 = nx * (nx >= 0 ? brush.mins[0] : brush.maxs[0]) + + ny * (ny >= 0 ? brush.mins[1] : brush.maxs[1]) + + nz * (nz >= 0 ? brush.mins[2] : brush.maxs[2]) + - plane.dist; + } + + if (d1 >= 0) { + insertBrushRecursive(node.children[0], brushIdx, brush); + } + // Brushes that touch the split plane on their back-most extent must be + // inserted into both leaves. Otherwise exact-boundary player positions + // can miss a clip brush in one leaf and hit it only after a tiny move + // into the adjacent leaf, producing false stuck/allsolid behavior. + if (d2 <= 0) { + insertBrushRecursive(node.children[1], brushIdx, brush); + } + }; + + // Only insert world (model 0) brushes into leaf nodes. + // Submodel brushes must NOT be inserted into the BSP leaf-brush index + // because Q1 BSP leaf nodes are shared between the world tree and + // submodel subtrees — inserting submodel brushes would make them + // appear in world traces, causing phantom collisions with triggers, + // removed entities (func_fog), etc. + // Submodel collision is handled by brute-force testing against the + // submodel's brush range (see BrushTrace.boxTraceModel). + const worldRange = modelBrushRanges.get(0); + if (worldRange) { + const rootNode = loadmodel.nodes[0]; + if (rootNode) { + for (let brushIdx = worldRange.firstBrush; brushIdx < worldRange.firstBrush + worldRange.numBrushes; brushIdx++) { + insertBrushRecursive(rootNode, brushIdx, allBrushes[brushIdx]); + } + } + } + + // Store brush ranges on the world model for submodel propagation + loadmodel._brushRanges = modelBrushRanges; + if (worldRange) { + loadmodel.firstBrush = worldRange.firstBrush; + loadmodel.numBrushes = worldRange.numBrushes; + } + + // Build the flat leafbrushes array from per-leaf lists + const leafbrushes: number[] = []; + + for (let i = 0; i < loadmodel.leafs.length; i++) { + const leaf = loadmodel.leafs[i]; + const list = leafBrushLists[i]; + leaf.firstleafbrush = leafbrushes.length; + leaf.numleafbrushes = list.length; + for (const brushIdx of list) { + leafbrushes.push(brushIdx); + } + } + + loadmodel.leafbrushes = leafbrushes; + + Con.DPrint(`BSP29Loader: loaded BRUSHLIST with ${allBrushes.length} brushes, ${allBrushSides.length} sides, ${leafbrushes.length} leaf-brush refs\n`); + } + + /** + * Load RGB colored lighting from BSPX lump if available. + */ + #loadLightingRGB(loadmodel: BrushModel, buf: ArrayBuffer): void { + loadmodel.lightdata_rgb = null; + + if (!loadmodel.bspxlumps || !loadmodel.bspxlumps['RGBLIGHTING']) { + return; + } + + const { fileofs, filelen } = loadmodel.bspxlumps['RGBLIGHTING']; + + if (filelen === 0) { + return; + } + + loadmodel.lightdata_rgb = new Uint8Array(buf.slice(fileofs, fileofs + filelen)); + } + + /** + * Load external RGB lighting from .lit file if available. + */ + async #loadExternalLighting(loadmodel: BrushModel, filename: string): Promise { + const rgbFilename = filename.replace(/\.bsp$/i, '.lit'); + + const data = await COM.LoadFile(rgbFilename); + + if (!data) { + Con.DPrint(`BSP29Loader: no external RGB lighting file found: ${rgbFilename}\n`); + return; + } + + const dv = new DataView(data); + + console.assert(dv.getUint32(0, true) === 0x54494C51, 'QLIT header'); + console.assert(dv.getUint32(4, true) === 0x00000001, 'QLIT version 1'); + + loadmodel.lightdata_rgb = new Uint8Array(data.slice(8)); // Skip header + } + + /** + * Load deluxemap (directional lighting normals) from BSPX lump if available. + */ + #loadDeluxeMap(loadmodel: BrushModel, buf: ArrayBuffer): void { + loadmodel.deluxemap = null; + + if (!loadmodel.bspxlumps || !loadmodel.bspxlumps['LIGHTINGDIR']) { + return; + } + + const { fileofs, filelen } = loadmodel.bspxlumps['LIGHTINGDIR']; + + if (filelen === 0) { + return; + } + + loadmodel.deluxemap = new Uint8Array(buf.slice(fileofs, fileofs + filelen)); + } + + /** + * Load lightgrid octree from BSPX lump if available. + */ + #loadLightgridOctree(loadmodel: BrushModel, buf: ArrayBuffer): void { + loadmodel.lightgrid = null; + + if (!loadmodel.bspxlumps || !loadmodel.bspxlumps['LIGHTGRID_OCTREE']) { + return; + } + + const { fileofs, filelen } = loadmodel.bspxlumps['LIGHTGRID_OCTREE']; + + if (filelen === 0) { + return; + } + + try { + const view = new DataView(buf); + let offset = fileofs; + const endOffset = fileofs + filelen; + + // Minimum size check: vec3_t step (12) + ivec3_t size (12) + vec3_t mins (12) + byte numstyles (1) + uint32_t rootnode (4) + uint32_t numnodes (4) + uint32_t numleafs (4) = 49 bytes + if (filelen < 49) { + Con.DPrint('BSP29Loader: LIGHTGRID_OCTREE lump too small\n'); + return; + } + + // vec3_t step + const step = new Vector( + view.getFloat32(offset, true), + view.getFloat32(offset + 4, true), + view.getFloat32(offset + 8, true), + ); + offset += 12; + + // ivec3_t size + const size = [ + view.getInt32(offset, true), + view.getInt32(offset + 4, true), + view.getInt32(offset + 8, true), + ]; + offset += 12; + + // vec3_t mins + const mins = new Vector( + view.getFloat32(offset, true), + view.getFloat32(offset + 4, true), + view.getFloat32(offset + 8, true), + ); + offset += 12; + + // byte numstyles (WARNING: misaligns the rest of the data) + const numstyles = view.getUint8(offset); + offset += 1; + + // uint32_t rootnode + const rootnode = view.getUint32(offset, true); + offset += 4; + + // uint32_t numnodes + const numnodes = view.getUint32(offset, true); + offset += 4; + + // Check if we have enough data for nodes (each node is 44 bytes: 3*4 for mid + 8*4 for children) + if (offset + (numnodes * 44) > endOffset) { + Con.DPrint('BSP29Loader: LIGHTGRID_OCTREE nodes data truncated\n'); + return; + } + + // Parse nodes + const nodes = []; + for (let i = 0; i < numnodes; i++) { + const mid = [ + view.getUint32(offset, true), + view.getUint32(offset + 4, true), + view.getUint32(offset + 8, true), + ]; + offset += 12; + + const child = []; + for (let j = 0; j < 8; j++) { + child[j] = view.getUint32(offset, true); + offset += 4; + } + + nodes[i] = { mid, child }; + } + + // uint32_t numleafs + if (offset + 4 > endOffset) { + Con.DPrint('BSP29Loader: LIGHTGRID_OCTREE numleafs missing\n'); + return; + } + const numleafs = view.getUint32(offset, true); + offset += 4; + + // Parse leafs + const leafs = []; + for (let i = 0; i < numleafs; i++) { + // Check bounds for leaf header (mins + size = 24 bytes) + if (offset + 24 > endOffset) { + Con.DPrint(`BSP29Loader: LIGHTGRID_OCTREE leaf ${i} header truncated\n`); + return; + } + + const leafMins = [ + view.getInt32(offset, true), + view.getInt32(offset + 4, true), + view.getInt32(offset + 8, true), + ]; + offset += 12; + + const leafSize = [ + view.getInt32(offset, true), + view.getInt32(offset + 4, true), + view.getInt32(offset + 8, true), + ]; + offset += 12; + + // Parse per-point data + const totalPoints = leafSize[0] * leafSize[1] * leafSize[2]; + const points = []; + + for (let p = 0; p < totalPoints; p++) { + // Check bounds for stylecount byte + if (offset >= endOffset) { + Con.DPrint(`BSP29Loader: LIGHTGRID_OCTREE leaf ${i} point ${p} truncated\n`); + return; + } + + const stylecount = view.getUint8(offset); + offset += 1; + + // Skip points with no data (stylecount = 0xff means missing) + if (stylecount === 0xff) { + points.push({ stylecount, styles: [] }); + continue; + } + + const styles = []; + for (let s = 0; s < stylecount; s++) { + // Check bounds for style data (1 byte stylenum + 3 bytes rgb = 4 bytes) + if (offset + 3 >= endOffset) { + Con.DPrint(`BSP29Loader: LIGHTGRID_OCTREE leaf ${i} point ${p} style ${s} truncated\n`); + return; + } + + const stylenum = view.getUint8(offset); + + offset += 1; + + const rgb = [ + view.getUint8(offset), + view.getUint8(offset + 1), + view.getUint8(offset + 2), + ]; + offset += 3; + + styles.push({ stylenum, rgb }); + } + + points.push({ stylecount, styles }); + } + + leafs.push({ mins: leafMins, size: leafSize, points }); + } + + loadmodel.lightgrid = { + step, + size, + mins, + numstyles, + rootnode, + nodes, + leafs, + }; + + Con.DPrint(`BSP29Loader: loaded LIGHTGRID_OCTREE with ${numnodes} nodes and ${numleafs} leafs\n`); + } catch (error) { + Con.DPrint(`BSP29Loader: error loading LIGHTGRID_OCTREE: ${error.message}\n`); + loadmodel.lightgrid = null; + } + } +} diff --git a/source/engine/common/model/loaders/BSP2Loader.mjs b/source/engine/common/model/loaders/BSP2Loader.mjs index c8357b2a..ca543e10 100644 --- a/source/engine/common/model/loaders/BSP2Loader.mjs +++ b/source/engine/common/model/loaders/BSP2Loader.mjs @@ -1,8 +1,8 @@ import Vector from '../../../../shared/Vector.ts'; import { CorruptedResourceError } from '../../Errors.ts'; -import { BSP29Loader } from './BSP29Loader.mjs'; +import { BSP29Loader } from './BSP29Loader.ts'; import { Face } from '../BaseModel.ts'; -import { BrushModel, Node } from '../BSP.mjs'; +import { BrushModel, Node } from '../BSP.ts'; import { materialFlags } from '../../../client/renderer/Materials.mjs'; /** diff --git a/source/engine/common/model/loaders/BSP38Loader.mjs b/source/engine/common/model/loaders/BSP38Loader.mjs index 2917307e..30089cb0 100644 --- a/source/engine/common/model/loaders/BSP38Loader.mjs +++ b/source/engine/common/model/loaders/BSP38Loader.mjs @@ -3,7 +3,7 @@ import Q from '../../../../shared/Q.ts'; import Vector from '../../../../shared/Vector.ts'; import { CRC16CCITT } from '../../CRC.ts'; import { Plane } from '../BaseModel.ts'; -import { Brush, BrushModel, BrushSide, Node } from '../BSP.mjs'; +import { Brush, BrushModel, BrushSide, Node } from '../BSP.ts'; import { ModelLoader } from '../ModelLoader.ts'; /** @typedef {Record} LumpViews */ diff --git a/source/engine/server/Edict.mjs b/source/engine/server/Edict.mjs index 8dfe6f30..23db94c3 100644 --- a/source/engine/server/Edict.mjs +++ b/source/engine/server/Edict.mjs @@ -8,7 +8,7 @@ import Q from '../../shared/Q.ts'; import { ConsoleCommand } from '../common/Cmd.ts'; import { ClientEdict } from '../client/ClientEntities.mjs'; import { OctreeNode } from '../../shared/Octree.ts'; -import { Visibility } from '../common/model/BSP.mjs'; +import { Visibility } from '../common/model/BSP.ts'; /** @typedef {import('../../game/id1/entity/BaseEntity.mjs').default} BaseEntity */ /** @typedef {import('../../game/id1/entity/Worldspawn.mjs').WorldspawnEntity} WorldspawnEntity */ diff --git a/test/common/model-cache.test.mjs b/test/common/model-cache.test.mjs index dcfd7672..393199c7 100644 --- a/test/common/model-cache.test.mjs +++ b/test/common/model-cache.test.mjs @@ -4,7 +4,7 @@ import { describe, test } from 'node:test'; import Mod from '../../source/engine/common/Mod.ts'; import { AliasModel } from '../../source/engine/common/model/AliasModel.ts'; import { Face } from '../../source/engine/common/model/BaseModel.ts'; -import { BrushModel, Node } from '../../source/engine/common/model/BSP.mjs'; +import { BrushModel, Node } from '../../source/engine/common/model/BSP.ts'; import { eventBus, registry } from '../../source/engine/registry.mjs'; import Vector from '../../source/shared/Vector.ts'; diff --git a/test/physics/brushtrace.test.mjs b/test/physics/brushtrace.test.mjs index 7de530dc..db74e345 100644 --- a/test/physics/brushtrace.test.mjs +++ b/test/physics/brushtrace.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import Vector from '../../source/shared/Vector.ts'; import { BrushTrace, DIST_EPSILON, Pmove } from '../../source/engine/common/Pmove.ts'; -import { BrushSide } from '../../source/engine/common/model/BSP.mjs'; +import { BrushSide } from '../../source/engine/common/model/BSP.ts'; import { content } from '../../source/shared/Defs.ts'; import { @@ -15,7 +15,7 @@ import { /** * Build a minimal two-brush submodel fixture with a gap between brushes. - * @returns {import('../../source/engine/common/model/BSP.mjs').BrushModel} brush model fixture + * @returns {import('../../source/engine/common/model/BSP.ts').BrushModel} brush model fixture */ function createTwoBoxBrushModel() { const model = createBoxBrushModel({ center: [-32, 0, 0], halfExtents: [16, 16, 16] }); @@ -43,7 +43,7 @@ function createTwoBoxBrushModel() { /** * Build a wedge brush whose inferred axial bounds bevel competes with a * walkable ramp face at almost the same enter fraction. - * @returns {import('../../source/engine/common/model/BSP.mjs').BrushModel} brush model fixture + * @returns {import('../../source/engine/common/model/BSP.ts').BrushModel} brush model fixture */ function createRampBevelBrushModel() { const model = createBoxBrushModel({ center: [0, 0, 0], halfExtents: [32, 16, 32] }); @@ -68,7 +68,7 @@ function createRampBevelBrushModel() { /** * Build a world model with one nearby brush and one distant child leaf whose * bounds do not intersect the current sweep. - * @returns {import('../../source/engine/common/model/BSP.mjs').BrushModel} world model fixture + * @returns {import('../../source/engine/common/model/BSP.ts').BrushModel} world model fixture */ function createPrunedLeafWorldModel() { const model = createBoxBrushModel({ center: [64, -16, 0], halfExtents: [16, 16, 16], name: 'test-pruned-world', submodel: false }); @@ -90,14 +90,14 @@ function createPrunedLeafWorldModel() { model.brushes.push(distantBrush); model.numBrushes = model.brushes.length; - const frontLeaf = /** @type {import('../../source/engine/common/model/BSP.mjs').Node} */ ({ + const frontLeaf = /** @type {import('../../source/engine/common/model/BSP.ts').Node} */ ({ contents: content.CONTENT_EMPTY, firstleafbrush: 0, numleafbrushes: 1, mins: new Vector(192, 0, -32), maxs: new Vector(256, 32, 32), }); - const backLeaf = /** @type {import('../../source/engine/common/model/BSP.mjs').Node} */ ({ + const backLeaf = /** @type {import('../../source/engine/common/model/BSP.ts').Node} */ ({ contents: content.CONTENT_EMPTY, firstleafbrush: 1, numleafbrushes: 1, @@ -105,14 +105,14 @@ function createPrunedLeafWorldModel() { maxs: new Vector(96, 0, 32), }); - model.nodes = /** @type {import('../../source/engine/common/model/BSP.mjs').Node[]} */ ([{ + model.nodes = /** @type {import('../../source/engine/common/model/BSP.ts').Node[]} */ ([{ contents: 0, plane: createAxisPlane([0, 1, 0], 0, 1), children: [frontLeaf, backLeaf], mins: new Vector(32, -32, -32), maxs: new Vector(256, 32, 32), }]); - model.leafs = /** @type {import('../../source/engine/common/model/BSP.mjs').Node[]} */ ([frontLeaf, backLeaf]); + model.leafs = /** @type {import('../../source/engine/common/model/BSP.ts').Node[]} */ ([frontLeaf, backLeaf]); model.leafbrushes = [1, 0]; return model; @@ -469,14 +469,14 @@ describe('BrushTrace', () => { test('does not inherit allsolid from solid BSP leaves on tangent brush clips', () => { const worldModel = createBoxBrushModel({ center: [0, 0, 0], halfExtents: [16, 16, 16], submodel: false }); - const solidLeaf = /** @type {import('../../source/engine/common/model/BSP.mjs').Node} */ ({ + const solidLeaf = /** @type {import('../../source/engine/common/model/BSP.ts').Node} */ ({ contents: content.CONTENT_SOLID, firstleafbrush: 0, numleafbrushes: 1, }); - worldModel.nodes = /** @type {import('../../source/engine/common/model/BSP.mjs').Node[]} */ ([solidLeaf]); - worldModel.leafs = /** @type {import('../../source/engine/common/model/BSP.mjs').Node[]} */ ([solidLeaf]); + worldModel.nodes = /** @type {import('../../source/engine/common/model/BSP.ts').Node[]} */ ([solidLeaf]); + worldModel.leafs = /** @type {import('../../source/engine/common/model/BSP.ts').Node[]} */ ([solidLeaf]); worldModel.leafbrushes = [0]; worldModel.hulls = /** @type {typeof worldModel.hulls} */ ([{ firstclipnode: 0 }]); @@ -501,7 +501,7 @@ describe('BrushTrace', () => { test('prunes BSP child bounds that miss a straddled world sweep', () => { const worldModel = createPrunedLeafWorldModel(); const originalTraceToLeaf = BrushTrace._traceToLeaf; - /** @type {import('../../source/engine/common/model/BSP.mjs').Node[]} */ + /** @type {import('../../source/engine/common/model/BSP.ts').Node[]} */ const visitedLeafs = []; BrushTrace._traceToLeaf = (ctx, leaf) => { diff --git a/test/physics/collision-regressions.test.mjs b/test/physics/collision-regressions.test.mjs index 9423f2bd..c473ccde 100644 --- a/test/physics/collision-regressions.test.mjs +++ b/test/physics/collision-regressions.test.mjs @@ -3,9 +3,9 @@ import assert from 'node:assert/strict'; import Vector from '../../source/shared/Vector.ts'; import { content, flags, moveType, moveTypes, solid } from '../../source/shared/Defs.ts'; -import { Brush, BrushModel, BrushSide } from '../../source/engine/common/model/BSP.mjs'; +import { Brush, BrushModel, BrushSide } from '../../source/engine/common/model/BSP.ts'; import { BrushTrace, Hull, PMF, Pmove, PmovePlayer, Trace } from '../../source/engine/common/Pmove.ts'; -import { BSP29Loader } from '../../source/engine/common/model/loaders/BSP29Loader.mjs'; +import { BSP29Loader } from '../../source/engine/common/model/loaders/BSP29Loader.ts'; import { eventBus, registry } from '../../source/engine/registry.mjs'; import { UserCmd } from '../../source/engine/network/Protocol.ts'; import { ClientEdict } from '../../source/engine/client/ClientEntities.mjs'; @@ -14,7 +14,7 @@ import { ServerPhysics } from '../../source/engine/server/physics/ServerPhysics. import { ServerMovement } from '../../source/engine/server/physics/ServerMovement.mjs'; import { BlockedFlags, MAX_BUMP_COUNT } from '../../source/engine/server/physics/Defs.mjs'; -test('PmovePlayer.DEBUG is disabled before Pmove.Init()', () => { +void test('PmovePlayer.DEBUG is disabled before Pmove.Init()', () => { assert.equal(PmovePlayer.DEBUG, false); }); @@ -89,23 +89,23 @@ function createBrushWorldModel({ axis = 0, center = [64, 0, 0], halfExtents }) { axisNormal[axis] = 1; const roomMins = new Vector(-2048, -2048, -2048); const roomMaxs = new Vector(2048, 2048, 2048); - const frontLeaf = /** @type {import('../../source/engine/common/model/BSP.mjs').Node} */ ({ + const frontLeaf = /** @type {import('../../source/engine/common/model/BSP.ts').Node} */ ({ contents: content.CONTENT_EMPTY, firstleafbrush: 0, numleafbrushes: 1, }); - const backLeaf = /** @type {import('../../source/engine/common/model/BSP.mjs').Node} */ ({ + const backLeaf = /** @type {import('../../source/engine/common/model/BSP.ts').Node} */ ({ contents: content.CONTENT_EMPTY, firstleafbrush: 1, numleafbrushes: 0, }); - model.nodes = /** @type {import('../../source/engine/common/model/BSP.mjs').Node[]} */ ([{ + model.nodes = /** @type {import('../../source/engine/common/model/BSP.ts').Node[]} */ ([{ contents: 0, plane: createAxisPlane(axisNormal, 0, axis), children: [frontLeaf, backLeaf], }]); - model.leafs = /** @type {import('../../source/engine/common/model/BSP.mjs').Node[]} */ ([frontLeaf, backLeaf]); + model.leafs = /** @type {import('../../source/engine/common/model/BSP.ts').Node[]} */ ([frontLeaf, backLeaf]); model.leafbrushes = [0]; model.hulls = /** @type {BrushModel['hulls']} */ ([ createRoomHullFromBounds(roomMins, roomMaxs), @@ -377,7 +377,7 @@ function withMockServerPhysics(callback) { }); } -test('BrushTrace.transformedTestPosition keeps exact face contact walkable', () => { +void test('BrushTrace.transformedTestPosition keeps exact face contact walkable', () => { const model = createBoxBrushModel({ halfExtents: [16, 16, 16] }); const origin = new Vector(100, 0, 0); const tangentPosition = new Vector(100, 0, 40); @@ -408,7 +408,7 @@ test('BrushTrace.transformedTestPosition keeps exact face contact walkable', () ); }); -test('BrushTrace.transformedTestPosition keeps non-axial clip-brush edge contact walkable for player boxes', () => { +void test('BrushTrace.transformedTestPosition keeps non-axial clip-brush edge contact walkable for player boxes', () => { const model = createBoxBrushModel({ center: [304, -124, 36], halfExtents: [8, 4, 36] }); const slopedPlaneIndex = model.planes.length; @@ -440,7 +440,7 @@ test('BrushTrace.transformedTestPosition keeps non-axial clip-brush edge contact ); }); -test('BrushTrace.transformedTestPosition keeps single non-axial clip-brush face contact walkable for player boxes', () => { +void test('BrushTrace.transformedTestPosition keeps single non-axial clip-brush face contact walkable for player boxes', () => { const model = createBoxBrushModel({ center: [336, -124, 36], halfExtents: [8, 4, 36] }); const slopedPlaneIndex = model.planes.length; @@ -472,7 +472,7 @@ test('BrushTrace.transformedTestPosition keeps single non-axial clip-brush face ); }); -test('BrushTrace.transformedBoxTrace clips tangent sloped clip-brush starts without startsolid', () => { +void test('BrushTrace.transformedBoxTrace clips tangent sloped clip-brush starts without startsolid', () => { const model = createBoxBrushModel({ center: [336, -124, 36], halfExtents: [8, 4, 36] }); const slopedPlaneIndex = model.planes.length; @@ -510,7 +510,7 @@ test('BrushTrace.transformedBoxTrace clips tangent sloped clip-brush starts with assert.deepEqual([...trace.endpos], [353.5, -108.80000305175781, 25]); }); -test('BrushTrace.transformedBoxTrace returns world-space impact points', () => { +void test('BrushTrace.transformedBoxTrace returns world-space impact points', () => { const model = createBoxBrushModel({ halfExtents: [16, 16, 16] }); const trace = BrushTrace.transformedBoxTrace( model, @@ -529,7 +529,7 @@ test('BrushTrace.transformedBoxTrace returns world-space impact points', () => { assertNear(trace.endpos[2], 0); }); -test('BrushTrace.transformedBoxTrace keeps exact floor contact out of startsolid', () => { +void test('BrushTrace.transformedBoxTrace keeps exact floor contact out of startsolid', () => { const model = createBoxBrushModel({ halfExtents: [16, 16, 16] }); const origin = new Vector(100, 0, 0); const start = new Vector(100, 0, 40); @@ -554,7 +554,7 @@ test('BrushTrace.transformedBoxTrace keeps exact floor contact out of startsolid assert.deepEqual([...trace.endpos], [...start]); }); -test('BrushTrace transformed tests honor rotated entity angles', () => { +void test('BrushTrace transformed tests honor rotated entity angles', () => { const model = createBoxBrushModel({ halfExtents: [8, 32, 16] }); const point = new Vector(20, 0, 0); @@ -590,7 +590,7 @@ test('BrushTrace transformed tests honor rotated entity angles', () => { assert.ok(rotatedTrace.endpos[0] < unrotatedTrace.endpos[0] - 20); }); -test('BrushTrace.boxTrace traverses world brush lists through BSP nodes', () => { +void test('BrushTrace.boxTrace traverses world brush lists through BSP nodes', () => { const worldModel = createBrushWorldModel({ halfExtents: [16, 16, 16] }); const trace = BrushTrace.boxTrace( worldModel, @@ -608,7 +608,7 @@ test('BrushTrace.boxTrace traverses world brush lists through BSP nodes', () => assertNear(trace.endpos[2], 0); }); -test('BrushTrace.boxTrace returns a clean miss for empty world models', () => { +void test('BrushTrace.boxTrace returns a clean miss for empty world models', () => { const worldModel = new BrushModel(); worldModel.name = 'empty-world'; worldModel.nodes = []; @@ -623,7 +623,7 @@ test('BrushTrace.boxTrace returns a clean miss for empty world models', () => { assert.deepEqual([...trace.endpos], [...end]); }); -test('BrushTrace.transformedBoxTrace returns a clean miss for empty submodels', () => { +void test('BrushTrace.transformedBoxTrace returns a clean miss for empty submodels', () => { const model = new BrushModel(); model.name = '*empty'; model.submodel = true; @@ -649,7 +649,7 @@ test('BrushTrace.transformedBoxTrace returns a clean miss for empty submodels', assert.deepEqual([...trace.endpos], [...end]); }); -test('BrushTrace.testPosition traverses world brush lists through BSP nodes', () => { +void test('BrushTrace.testPosition traverses world brush lists through BSP nodes', () => { const worldModel = createBrushWorldModel({ halfExtents: [16, 16, 16] }); assert.equal( @@ -675,7 +675,7 @@ test('BrushTrace.testPosition traverses world brush lists through BSP nodes', () ); }); -test('Pmove.clipPlayerMove keeps startsolid end positions in world space', () => { +void test('Pmove.clipPlayerMove keeps startsolid end positions in world space', () => { const pmove = new Pmove(); pmove.addEntity(createPmoveBoxEntity({ @@ -693,7 +693,7 @@ test('Pmove.clipPlayerMove keeps startsolid end positions in world space', () => assert.deepEqual([...trace.endpos], [...start]); }); -test('Pmove.clipPlayerMove reports hull hits in world coordinates', () => { +void test('Pmove.clipPlayerMove reports hull hits in world coordinates', () => { const pmove = new Pmove(); pmove.addEntity(createPmoveBoxEntity({ @@ -711,7 +711,7 @@ test('Pmove.clipPlayerMove reports hull hits in world coordinates', () => { assertNear(trace.endpos[2], 0); }); -test('Pmove server-style smoke setup mirrors TestServerside assertions', () => { +void test('Pmove server-style smoke setup mirrors TestServerside assertions', () => { const worldModel = createLegacyWorldModel( new Vector(-256, -256, -128), new Vector(256, 256, 128), @@ -749,7 +749,7 @@ test('Pmove server-style smoke setup mirrors TestServerside assertions', () => { assert.equal(playerMoveTraceHigher.fraction, 1.0); }); -test('Pmove.traceStaticWorldPlayerMove traces world only and ignores dynamic physents', () => { +void test('Pmove.traceStaticWorldPlayerMove traces world only and ignores dynamic physents', () => { const worldModel = createLegacyWorldModel( new Vector(-256, -256, -128), new Vector(256, 256, 128), @@ -773,7 +773,7 @@ test('Pmove.traceStaticWorldPlayerMove traces world only and ignores dynamic phy assertNear(aggregateTrace.endpos[0], 31.96875, 0.001); }); -test('Pmove brush-list world path supports server-style vertical smoke checks', () => { +void test('Pmove brush-list world path supports server-style vertical smoke checks', () => { const worldModel = createBrushWorldModel({ axis: 2, center: [0, 0, 144], halfExtents: [512, 512, 16] }); const pmove = new Pmove(); const entity = createPmoveBoxEntity({ @@ -801,7 +801,7 @@ test('Pmove brush-list world path supports server-style vertical smoke checks', assert.equal(playerMoveTraceHigher.fraction, 1.0); }); -test('Pmove.staticWorldContents uses brush-backed world solids before leaf contents', () => { +void test('Pmove.staticWorldContents uses brush-backed world solids before leaf contents', () => { const worldModel = createBrushWorldModel({ center: [64, 0, 0], halfExtents: [16, 16, 16] }); const pmove = new Pmove(); @@ -813,7 +813,7 @@ test('Pmove.staticWorldContents uses brush-backed world solids before leaf conte assert.equal(pmove.staticWorldContents(new Vector(8, 0, 0)), content.CONTENT_WATER); }); -test('Pmove.staticWorldContents normalizes brush-backed current leaves to water', () => { +void test('Pmove.staticWorldContents normalizes brush-backed current leaves to water', () => { const worldModel = createBrushWorldModel({ center: [64, 0, 0], halfExtents: [16, 16, 16] }); const pmove = new Pmove(); @@ -824,7 +824,7 @@ test('Pmove.staticWorldContents normalizes brush-backed current leaves to water' assert.equal(pmove.staticWorldContents(new Vector(8, 0, 0)), content.CONTENT_WATER); }); -test('PmovePlayer.move integrates one grounded movement frame against a world model', () => { +void test('PmovePlayer.move integrates one grounded movement frame against a world model', () => { const worldModel = createBrushWorldModel({ axis: 2, center: [0, 0, -40], halfExtents: [512, 512, 16] }); const pmove = new Pmove(); const player = pmove.newPlayerMove(); @@ -847,7 +847,7 @@ test('PmovePlayer.move integrates one grounded movement frame against a world mo assert.ok(player.velocity[0] > 0); }); -test('ServerCollision stationary brush tests preserve exact resting contact', () => { +void test('ServerCollision stationary brush tests preserve exact resting contact', () => { const collision = new ServerCollision(); const model = createBoxBrushModel({ halfExtents: [16, 16, 16] }); const position = new Vector(100, 0, 40); @@ -868,7 +868,7 @@ test('ServerCollision stationary brush tests preserve exact resting contact', () assert.deepEqual([...trace.endpos], [...position]); }); -test('ServerCollision.move traces world brush sweeps through shared brush state', () => { +void test('ServerCollision.move traces world brush sweeps through shared brush state', () => { const collision = new ServerCollision(); const worldModel = createBrushWorldModel({ halfExtents: [16, 16, 16] }); const worldEntity = createMockEntity({ @@ -921,7 +921,7 @@ test('ServerCollision.move traces world brush sweeps through shared brush state' }); }); -test('ServerCollision.move prefers a later legacy hull hit over an earlier world brush point hit', () => { +void test('ServerCollision.move prefers a later legacy hull hit over an earlier world brush point hit', () => { const collision = new ServerCollision(); const worldModel = createBoxBrushModel({ halfExtents: [16, 16, 16], name: 'world-brush' }); const worldEntity = createMockEntity({ @@ -1003,7 +1003,7 @@ test('ServerCollision.move prefers a later legacy hull hit over an earlier world }); }); -test('BSP29Loader builds legacy clipnode masks from a model headnode subtree', () => { +void test('BSP29Loader builds legacy clipnode masks from a model headnode subtree', () => { const loader = new BSP29Loader(); const clipnodes = [ { planenum: 0, children: [1, 2] }, @@ -1022,27 +1022,27 @@ test('BSP29Loader builds legacy clipnode masks from a model headnode subtree', ( assert.equal(loader._buildAllowedClipnodeMask(clipnodes, 99), null); }); -test('BSP29Loader inserts BRUSHLIST brushes into both leaves when they touch a BSP split plane', () => { +void test('BSP29Loader inserts BRUSHLIST brushes into both leaves when they touch a BSP split plane', () => { const loader = new BSP29Loader(); const loadmodel = new BrushModel(); - const frontLeaf = /** @type {import('../../source/engine/common/model/BSP.mjs').Node} */ ({ + const frontLeaf = /** @type {import('../../source/engine/common/model/BSP.ts').Node} */ ({ contents: content.CONTENT_EMPTY, firstleafbrush: 0, numleafbrushes: 0, }); - const backLeaf = /** @type {import('../../source/engine/common/model/BSP.mjs').Node} */ ({ + const backLeaf = /** @type {import('../../source/engine/common/model/BSP.ts').Node} */ ({ contents: content.CONTENT_EMPTY, firstleafbrush: 0, numleafbrushes: 0, }); loadmodel.planes = [createAxisPlane([1, 0, 0], 0, 0)]; - loadmodel.nodes = /** @type {import('../../source/engine/common/model/BSP.mjs').Node[]} */ ([{ + loadmodel.nodes = /** @type {import('../../source/engine/common/model/BSP.ts').Node[]} */ ([{ contents: 0, plane: loadmodel.planes[0], children: [frontLeaf, backLeaf], }]); - loadmodel.leafs = /** @type {import('../../source/engine/common/model/BSP.mjs').Node[]} */ ([frontLeaf, backLeaf]); + loadmodel.leafs = /** @type {import('../../source/engine/common/model/BSP.ts').Node[]} */ ([frontLeaf, backLeaf]); loadmodel.bspxlumps = { BRUSHLIST: { fileofs: 0, @@ -1078,7 +1078,7 @@ test('BSP29Loader inserts BRUSHLIST brushes into both leaves when they touch a B assert.deepEqual(loadmodel.leafbrushes, [0, 0]); }); -test('ServerCollision.hullPointContents treats masked foreign clipnodes as empty space', () => { +void test('ServerCollision.hullPointContents treats masked foreign clipnodes as empty space', () => { const collision = new ServerCollision(); const hull = { clip_mins: new Vector(), @@ -1102,7 +1102,7 @@ test('ServerCollision.hullPointContents treats masked foreign clipnodes as empty ); }); -test('Hull respects allowed clipnode masks in point and sweep tests', () => { +void test('Hull respects allowed clipnode masks in point and sweep tests', () => { const hull = Hull.fromModelHull({ clip_mins: new Vector(), clip_maxs: new Vector(), @@ -1132,7 +1132,7 @@ test('Hull respects allowed clipnode masks in point and sweep tests', () => { assert.deepEqual([...trace.endpos], [...end]); }); -test('ServerCollision.pointContents respects world hull ownership masks', () => { +void test('ServerCollision.pointContents respects world hull ownership masks', () => { const collision = new ServerCollision(); const worldHull = { clip_mins: new Vector(), @@ -1175,7 +1175,7 @@ test('ServerCollision.pointContents respects world hull ownership masks', () => }); }); -test('ServerCollision.staticWorldContents uses brush-backed world solids before leaf contents', () => { +void test('ServerCollision.staticWorldContents uses brush-backed world solids before leaf contents', () => { const collision = new ServerCollision(); const worldModel = createBrushWorldModel({ halfExtents: [16, 16, 16] }); const worldEdict = createMockEdict(createMockEntity({ solidType: solid.SOLID_BSP })); @@ -1205,7 +1205,7 @@ test('ServerCollision.staticWorldContents uses brush-backed world solids before }); }); -test('ServerCollision.staticWorldContents normalizes brush-backed current leaves to water', () => { +void test('ServerCollision.staticWorldContents normalizes brush-backed current leaves to water', () => { const collision = new ServerCollision(); const worldModel = createBrushWorldModel({ halfExtents: [16, 16, 16] }); worldModel.leafs[1].contents = content.CONTENT_CURRENT_DOWN; @@ -1235,7 +1235,7 @@ test('ServerCollision.staticWorldContents normalizes brush-backed current leaves }); }); -test('ServerCollision.traceStaticWorldLine uses brush tracing for brush-backed world hull 0', () => { +void test('ServerCollision.traceStaticWorldLine uses brush tracing for brush-backed world hull 0', () => { const collision = new ServerCollision(); const worldModel = createBrushWorldModel({ halfExtents: [16, 16, 16] }); const worldEdict = createMockEdict(createMockEntity({ @@ -1273,7 +1273,7 @@ test('ServerCollision.traceStaticWorldLine uses brush tracing for brush-backed w }); }); -test('ServerCollision.move keeps legacy world hull traces out of foreign clipnode subtrees', () => { +void test('ServerCollision.move keeps legacy world hull traces out of foreign clipnode subtrees', () => { const collision = new ServerCollision(); const worldHull = { clip_mins: new Vector(), @@ -1341,7 +1341,7 @@ test('ServerCollision.move keeps legacy world hull traces out of foreign clipnod }); }); -test('ServerCollision.traceWorldLine keeps legacy world hull traces out of foreign clipnode subtrees', () => { +void test('ServerCollision.traceWorldLine keeps legacy world hull traces out of foreign clipnode subtrees', () => { const collision = new ServerCollision(); const worldHull = { clip_mins: new Vector(), @@ -1402,8 +1402,8 @@ test('ServerCollision.traceWorldLine keeps legacy world hull traces out of forei }); }); -describe('ServerCollision.move legacy hull recursion regressions', () => { - test('keeps outer legacy hull split points stable across deeper recursion', () => { +void describe('ServerCollision.move legacy hull recursion regressions', () => { + void test('keeps outer legacy hull split points stable across deeper recursion', () => { const collision = new ServerCollision(); const worldHull = { clip_mins: new Vector(), @@ -1474,7 +1474,7 @@ describe('ServerCollision.move legacy hull recursion regressions', () => { }); }); - test('ServerCollision.move ignores zero-volume touched entities for boxed movers', () => { + void test('ServerCollision.move ignores zero-volume touched entities for boxed movers', () => { const collision = new ServerCollision(); const worldModel = createBrushWorldModel({ center: [1024, 0, 0], halfExtents: [16, 16, 16] }); const worldEdict = createMockEdict(createMockEntity({ @@ -1537,7 +1537,7 @@ describe('ServerCollision.move legacy hull recursion regressions', () => { }); }); - test('ServerCollision.move asserts when a touched entity returns a malformed trace', () => { + void test('ServerCollision.move asserts when a touched entity returns a malformed trace', () => { const collision = new ServerCollision(); const worldModel = createBrushWorldModel({ center: [1024, 0, 0], halfExtents: [16, 16, 16] }); const worldEdict = createMockEdict(createMockEntity({ @@ -1632,7 +1632,7 @@ describe('ServerCollision.move legacy hull recursion regressions', () => { }); }); -test('ServerCollision.move prefers a later legacy hull hit over an earlier unrotated BSP entity brush point hit', () => { +void test('ServerCollision.move prefers a later legacy hull hit over an earlier unrotated BSP entity brush point hit', () => { const collision = new ServerCollision(); const worldModel = createBoxBrushModel({ halfExtents: [16, 16, 16], name: 'world-brush', submodel: false }); const entityModel = createBoxBrushModel({ halfExtents: [8, 8, 8], name: '*clip-brush' }); @@ -1732,7 +1732,7 @@ test('ServerCollision.move prefers a later legacy hull hit over an earlier unrot }); }); -test('ServerCollision.clipMoveToEntity keeps rotated BSP point traces on the brush path', () => { +void test('ServerCollision.clipMoveToEntity keeps rotated BSP point traces on the brush path', () => { const collision = new ServerCollision(); const entityModel = createBoxBrushModel({ halfExtents: [8, 8, 8], name: '*rotating-brush' }); const bspEntity = createMockEntity({ @@ -1793,7 +1793,7 @@ test('ServerCollision.clipMoveToEntity keeps rotated BSP point traces on the bru }); }); -test('ServerCollision.move expands missile traces for monster broadphase and narrowphase', () => { +void test('ServerCollision.move expands missile traces for monster broadphase and narrowphase', () => { const collision = new ServerCollision(); const worldEdict = createMockEdict(createMockEntity({ solidType: solid.SOLID_BSP })); const monsterEntity = createMockEntity({ @@ -1897,7 +1897,7 @@ test('ServerCollision.move expands missile traces for monster broadphase and nar }); }); -test('ServerPhysics.checkVelocity clears NaNs and clamps to maxvelocity', () => { +void test('ServerPhysics.checkVelocity clears NaNs and clamps to maxvelocity', () => { const serverPhysics = new ServerPhysics(); const prints = []; const entity = createMockEntity({ @@ -1931,7 +1931,7 @@ test('ServerPhysics.checkVelocity clears NaNs and clamps to maxvelocity', () => assert.equal(prints[1], 'Got a NaN velocity on test_entity\n'); }); -test('ServerPhysics.pushEntity uses MOVE_MISSILE and preserves origin on allsolid', () => { +void test('ServerPhysics.pushEntity uses MOVE_MISSILE and preserves origin on allsolid', () => { const serverPhysics = new ServerPhysics(); const linkCalls = []; const moveCalls = []; @@ -1993,7 +1993,7 @@ test('ServerPhysics.pushEntity uses MOVE_MISSILE and preserves origin on allsoli assert.equal(linkCalls[0], edict); }); -test('ServerMovement.checkBottom returns early when all four corners are solid', () => { +void test('ServerMovement.checkBottom returns early when all four corners are solid', () => { const movement = new ServerMovement(); const moveCalls = []; const cornerChecks = []; @@ -2030,7 +2030,7 @@ test('ServerMovement.checkBottom returns early when all four corners are solid', assert.equal(moveCalls.length, 0); }); -test('ServerMovement.movestep preserves horizontal progress on partial ground fallback', () => { +void test('ServerMovement.movestep preserves horizontal progress on partial ground fallback', () => { const movement = new ServerMovement(); const linkCalls = []; const moveCalls = []; @@ -2081,7 +2081,7 @@ test('ServerMovement.movestep preserves horizontal progress on partial ground fa assert.equal(linkCalls[0].touchTriggers, true); }); -test('ServerPhysics.flyMove clips against a wall and records steptrace', () => { +void test('ServerPhysics.flyMove clips against a wall and records steptrace', () => { const serverPhysics = new ServerPhysics(); const moveCalls = []; const impacts = []; @@ -2137,7 +2137,7 @@ test('ServerPhysics.flyMove clips against a wall and records steptrace', () => { assert.deepEqual([...impacts[0].pushVector], [10, 0, 0]); }); -test('ServerPhysics.flyMove stops in a two-plane crease', () => { +void test('ServerPhysics.flyMove stops in a two-plane crease', () => { const serverPhysics = new ServerPhysics(); let moveCallCount = 0; const blockerA = createMockEdict(createMockEntity({ solidType: solid.SOLID_BBOX })); @@ -2201,7 +2201,7 @@ test('ServerPhysics.flyMove stops in a two-plane crease', () => { assert.deepEqual([...edict.entity.velocity], [0, 0, 0]); }); -test('ServerPhysics.flyMove dead-stops when clipped by three non-coplanar planes', () => { +void test('ServerPhysics.flyMove dead-stops when clipped by three non-coplanar planes', () => { const serverPhysics = new ServerPhysics(); let moveCallCount = 0; const blockerA = createMockEdict(createMockEntity({ solidType: solid.SOLID_BBOX })); @@ -2277,7 +2277,7 @@ test('ServerPhysics.flyMove dead-stops when clipped by three non-coplanar planes assert.deepEqual([...edict.entity.velocity], [0, 0, 0]); }); -test('ServerPhysics.flyMove keeps state finite when a degenerate wall normal repeats', () => { +void test('ServerPhysics.flyMove keeps state finite when a degenerate wall normal repeats', () => { const serverPhysics = new ServerPhysics(); let moveCallCount = 0; const impacts = []; @@ -2336,7 +2336,7 @@ test('ServerPhysics.flyMove keeps state finite when a degenerate wall normal rep } }); -test('ServerMovement.stepDirection restores origin when yaw delta stays too large', () => { +void test('ServerMovement.stepDirection restores origin when yaw delta stays too large', () => { const movement = new ServerMovement(); const linkCalls = []; const entity = createMockEntity({ @@ -2377,7 +2377,7 @@ test('ServerMovement.stepDirection restores origin when yaw delta stays too larg assert.equal(linkCalls[0].touchTriggers, true); }); -test('ServerPhysics.pushEntity uses MOVE_NOMONSTERS for trigger and non-solid entities', () => { +void test('ServerPhysics.pushEntity uses MOVE_NOMONSTERS for trigger and non-solid entities', () => { const serverPhysics = new ServerPhysics(); const moveCalls = []; const touchCalls = []; @@ -2445,7 +2445,7 @@ test('ServerPhysics.pushEntity uses MOVE_NOMONSTERS for trigger and non-solid en assert.equal(touchCalls[2][0], 'target'); }); -test('ServerPhysics.checkAllEnts skips static entities and reports invalid dynamic positions', () => { +void test('ServerPhysics.checkAllEnts skips static entities and reports invalid dynamic positions', () => { const serverPhysics = new ServerPhysics(); const prints = []; const tested = []; @@ -2488,7 +2488,7 @@ test('ServerPhysics.checkAllEnts skips static entities and reports invalid dynam assert.deepEqual(prints, ['entity in invalid position\n']); }); -test('ServerMovement.moveToGoal returns false when already close enough to a non-world enemy goal', () => { +void test('ServerMovement.moveToGoal returns false when already close enough to a non-world enemy goal', () => { const movement = new ServerMovement(); const actor = createMockEdict(createMockEntity({ flagsValue: flags.FL_ONGROUND })); const goal = createMockEdict(createMockEntity()); @@ -2509,7 +2509,7 @@ test('ServerMovement.moveToGoal returns false when already close enough to a non assert.equal(movement.moveToGoal(actor, 16), false); }); -test('ServerMovement.moveToGoal falls back to newChaseDir when stepDirection fails', () => { +void test('ServerMovement.moveToGoal falls back to newChaseDir when stepDirection fails', () => { const movement = new ServerMovement(); const actor = createMockEdict(createMockEntity({ flagsValue: flags.FL_ONGROUND })); const goal = createMockEdict(createMockEntity({ origin: new Vector(100, 50, 0) })); @@ -2545,7 +2545,7 @@ test('ServerMovement.moveToGoal falls back to newChaseDir when stepDirection fai assert.equal(calls[1].dist, 24); }); -test('ServerMovement.newChaseDir restores old yaw and marks partial ground when every direction fails', () => { +void test('ServerMovement.newChaseDir restores old yaw and marks partial ground when every direction fails', () => { const movement = new ServerMovement(); const actor = createMockEdict(createMockEntity({ origin: new Vector(0, 0, 0), @@ -2579,7 +2579,7 @@ test('ServerMovement.newChaseDir restores old yaw and marks partial ground when assert.deepEqual(attemptedDirs, [45, 0, 90, 90, 315, 225, 180, 135, 90, 45, 0, 270]); }); -test('ServerMovement.walkMove returns false when entity is not grounded, flying, or swimming', () => { +void test('ServerMovement.walkMove returns false when entity is not grounded, flying, or swimming', () => { const movement = new ServerMovement(); const actor = createMockEdict(createMockEntity({ flagsValue: 0 })); @@ -2590,7 +2590,7 @@ test('ServerMovement.walkMove returns false when entity is not grounded, flying, assert.equal(movement.walkMove(actor, 90, 16), false); }); -test('ServerMovement.changeYaw wraps and clamps using the shortest turn direction', () => { +void test('ServerMovement.changeYaw wraps and clamps using the shortest turn direction', () => { const movement = new ServerMovement(); const actor = createMockEdict(createMockEntity({ angles: new Vector(0, 350, 0) })); actor.entity.yaw_speed = 5; @@ -2604,7 +2604,7 @@ test('ServerMovement.changeYaw wraps and clamps using the shortest turn directio assert.equal(movement.changeYaw(actor), 5); }); -test('ServerPhysics.runThink returns false when the entity frees itself during think', () => { +void test('ServerPhysics.runThink returns false when the entity frees itself during think', () => { const serverPhysics = new ServerPhysics(); let freed = false; let thinkCalls = 0; @@ -2640,7 +2640,7 @@ test('ServerPhysics.runThink returns false when the entity frees itself during t assert.equal(entity.nextthink, 0.0); }); -test('ServerPhysics.runThink executes multiple thinks that become due within one frame', () => { +void test('ServerPhysics.runThink executes multiple thinks that become due within one frame', () => { const serverPhysics = new ServerPhysics(); const thinkTimes = []; const entity = createMockEntity(); @@ -2674,7 +2674,7 @@ test('ServerPhysics.runThink executes multiple thinks that become due within one assert.equal(entity.nextthink, 0.0); }); -test('ServerMovement.checkBottom rejects support when a corner drops more than step size', () => { +void test('ServerMovement.checkBottom rejects support when a corner drops more than step size', () => { const movement = new ServerMovement(); const moveCalls = []; let pointContentCalls = 0; @@ -2722,7 +2722,7 @@ test('ServerMovement.checkBottom rejects support when a corner drops more than s assert.equal(moveCalls.length, 2); }); -test('ServerMovement.movestep returns false when both the raised trace and retry stay startsolid', () => { +void test('ServerMovement.movestep returns false when both the raised trace and retry stay startsolid', () => { const movement = new ServerMovement(); const linkCalls = []; const moveCalls = []; @@ -2769,7 +2769,7 @@ test('ServerMovement.movestep returns false when both the raised trace and retry assert.equal(linkCalls.length, 0); }); -test('ServerPhysics.pushMove carries a grounded rider upward without blocked()', () => { +void test('ServerPhysics.pushMove carries a grounded rider upward without blocked()', () => { withMockServerPhysics(({ serverPhysics, pusherEdict, riderEdict, moveCalls, testCalls, blockedCalls }) => { serverPhysics.pushMove(pusherEdict, 0.1); @@ -2784,7 +2784,7 @@ test('ServerPhysics.pushMove carries a grounded rider upward without blocked()', }); }); -test('ServerPhysics.pushMove rolls back and calls blocked() when rider remains stuck', () => { +void test('ServerPhysics.pushMove rolls back and calls blocked() when rider remains stuck', () => { withMockServerPhysics(({ serverPhysics, pusherEdict, riderEdict, blockedCalls }) => { let testCount = 0; registry.SV.collision.testEntityPosition = (edict) => { @@ -2804,7 +2804,7 @@ test('ServerPhysics.pushMove rolls back and calls blocked() when rider remains s }); }); -test('ServerPhysics.pushMove restores earlier riders when a later rider blocks the push', () => { +void test('ServerPhysics.pushMove restores earlier riders when a later rider blocks the push', () => { const linkCalls = []; const blockedCalls = []; @@ -2896,7 +2896,7 @@ test('ServerPhysics.pushMove restores earlier riders when a later rider blocks t assert.ok(linkCalls.length >= 5); }); -test('ServerPhysics.pushMove collapses trigger bounds instead of rolling back the pusher', () => { +void test('ServerPhysics.pushMove collapses trigger bounds instead of rolling back the pusher', () => { const blockedCalls = []; const pusherEntity = createMockEntity({ @@ -2974,7 +2974,7 @@ test('ServerPhysics.pushMove collapses trigger bounds instead of rolling back th assert.equal(pusherEdict.entity.ltime, 0.1); }); -test('ServerPhysics.pushMove rotates grounded riders around the pusher yaw axis', () => { +void test('ServerPhysics.pushMove rotates grounded riders around the pusher yaw axis', () => { withMockServerPhysics(({ serverPhysics, pusherEdict, riderEdict, moveCalls, testCalls, blockedCalls }) => { pusherEdict.entity.velocity.clear(); pusherEdict.entity.avelocity = new Vector(0, 900, 0); @@ -3003,7 +3003,7 @@ test('ServerPhysics.pushMove rotates grounded riders around the pusher yaw axis' }); }); -test('ServerPhysics.physicsPusher limits movement to nextthink and then runs think', () => { +void test('ServerPhysics.physicsPusher limits movement to nextthink and then runs think', () => { const serverPhysics = new ServerPhysics(); const moveTimes = []; let observedGameTime = -1; @@ -3050,7 +3050,7 @@ test('ServerPhysics.physicsPusher limits movement to nextthink and then runs thi assert.equal(observedGameTime, 7.0); }); -test('ServerPhysics.physicsPusher keeps think deferred when nextthink is beyond this frame', () => { +void test('ServerPhysics.physicsPusher keeps think deferred when nextthink is beyond this frame', () => { const serverPhysics = new ServerPhysics(); const moveTimes = []; let observedGameTime = -1; @@ -3097,7 +3097,7 @@ test('ServerPhysics.physicsPusher keeps think deferred when nextthink is beyond assert.equal(observedGameTime, 0); }); -test('ServerPhysics.checkStuck restores oldorigin when the saved position is clear', () => { +void test('ServerPhysics.checkStuck restores oldorigin when the saved position is clear', () => { const serverPhysics = new ServerPhysics(); const prints = []; const linkCalls = []; @@ -3143,7 +3143,7 @@ test('ServerPhysics.checkStuck restores oldorigin when the saved position is cle assert.equal(linkCalls[0].touchTriggers, true); }); -test('ServerPhysics.checkStuck reports failure after exhausting all nudges', () => { +void test('ServerPhysics.checkStuck reports failure after exhausting all nudges', () => { const serverPhysics = new ServerPhysics(); const prints = []; const linkCalls = []; @@ -3187,7 +3187,7 @@ test('ServerPhysics.checkStuck reports failure after exhausting all nudges', () assert.deepEqual([...edict.entity.oldorigin], [1, 2, 3]); }); -test('ServerPhysics.checkWater leaves entities dry when feet probe is not water', () => { +void test('ServerPhysics.checkWater leaves entities dry when feet probe is not water', () => { const serverPhysics = new ServerPhysics(); const probes = []; const entity = createMockEntity({ @@ -3222,7 +3222,7 @@ test('ServerPhysics.checkWater leaves entities dry when feet probe is not water' assert.deepEqual([...probes[0]], [10, 20, 7]); }); -test('ServerPhysics.checkWater distinguishes feet waist and head submersion', () => { +void test('ServerPhysics.checkWater distinguishes feet waist and head submersion', () => { const serverPhysics = new ServerPhysics(); const feetEntity = createMockEntity({ origin: new Vector(0, 0, 40), @@ -3300,7 +3300,7 @@ test('ServerPhysics.checkWater distinguishes feet waist and head submersion', () assert.equal(headResult, true); }); -test('ServerPhysics.addGravity and addBoyancy accumulate using entity gravity and frametime', () => { +void test('ServerPhysics.addGravity and addBoyancy accumulate using entity gravity and frametime', () => { const serverPhysics = new ServerPhysics(); const entity = createMockEntity({ velocity: new Vector(0, 0, 10), @@ -3325,7 +3325,7 @@ test('ServerPhysics.addGravity and addBoyancy accumulate using entity gravity an assert.deepEqual([...entity.velocity], [0, 0, -88]); }); -test('ServerPhysics.clipVelocity zeroes tiny residuals after clipping against an angled plane', () => { +void test('ServerPhysics.clipVelocity zeroes tiny residuals after clipping against an angled plane', () => { const serverPhysics = new ServerPhysics(); const out = new Vector(); @@ -3341,7 +3341,7 @@ test('ServerPhysics.clipVelocity zeroes tiny residuals after clipping against an assert.equal(out[2], 0.0); }); -test('ServerPhysics.physicsToss keeps a bounce entity moving after a hard floor impact', () => { +void test('ServerPhysics.physicsToss keeps a bounce entity moving after a hard floor impact', () => { const serverPhysics = new ServerPhysics(); const entity = createMockEntity({ origin: new Vector(0, 0, 64), @@ -3407,7 +3407,7 @@ test('ServerPhysics.physicsToss keeps a bounce entity moving after a hard floor assert.deepEqual([...entity.angles], [0, 0, 9]); }); -test('ServerPhysics.physicsToss settles non-bounce tosses on walkable ground', () => { +void test('ServerPhysics.physicsToss settles non-bounce tosses on walkable ground', () => { const serverPhysics = new ServerPhysics(); const entity = createMockEntity({ origin: new Vector(0, 0, 64), @@ -3473,7 +3473,7 @@ test('ServerPhysics.physicsToss settles non-bounce tosses on walkable ground', ( assert.deepEqual([...entity.angles], [0, 1, 0]); }); -test('ServerPhysics.physics applies gravity and toss movement for one frame', () => { +void test('ServerPhysics.physics applies gravity and toss movement for one frame', () => { const linkCalls = []; const moveCalls = []; let startFrameCount = 0; diff --git a/test/physics/fixtures.mjs b/test/physics/fixtures.mjs index 39af2ed3..b8273752 100644 --- a/test/physics/fixtures.mjs +++ b/test/physics/fixtures.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import Vector from '../../source/shared/Vector.ts'; import { content, flags, moveType, solid } from '../../source/shared/Defs.ts'; -import { Brush, BrushModel, BrushSide } from '../../source/engine/common/model/BSP.mjs'; +import { Brush, BrushModel, BrushSide } from '../../source/engine/common/model/BSP.ts'; import { eventBus, registry } from '../../source/engine/registry.mjs'; import { ClientEdict } from '../../source/engine/client/ClientEntities.mjs'; import { ServerPhysics } from '../../source/engine/server/physics/ServerPhysics.mjs'; @@ -159,23 +159,23 @@ export function createBrushWorldModel({ axis = 0, center = [64, 0, 0], halfExten axisNormal[axis] = 1; const roomMins = new Vector(-2048, -2048, -2048); const roomMaxs = new Vector(2048, 2048, 2048); - const frontLeaf = /** @type {import('../../source/engine/common/model/BSP.mjs').Node} */ ({ + const frontLeaf = /** @type {import('../../source/engine/common/model/BSP.ts').Node} */ ({ contents: content.CONTENT_EMPTY, firstleafbrush: 0, numleafbrushes: 1, }); - const backLeaf = /** @type {import('../../source/engine/common/model/BSP.mjs').Node} */ ({ + const backLeaf = /** @type {import('../../source/engine/common/model/BSP.ts').Node} */ ({ contents: content.CONTENT_EMPTY, firstleafbrush: 1, numleafbrushes: 0, }); - model.nodes = /** @type {import('../../source/engine/common/model/BSP.mjs').Node[]} */ ([{ + model.nodes = /** @type {import('../../source/engine/common/model/BSP.ts').Node[]} */ ([{ contents: 0, plane: createAxisPlane(axisNormal, 0, axis), children: [frontLeaf, backLeaf], }]); - model.leafs = /** @type {import('../../source/engine/common/model/BSP.mjs').Node[]} */ ([frontLeaf, backLeaf]); + model.leafs = /** @type {import('../../source/engine/common/model/BSP.ts').Node[]} */ ([frontLeaf, backLeaf]); model.leafbrushes = [0]; model.hulls = /** @type {BrushModel['hulls']} */ ([ createRoomHullFromBounds(roomMins, roomMaxs), diff --git a/test/physics/pmove.test.mjs b/test/physics/pmove.test.mjs index 318b6070..9bc99a6e 100644 --- a/test/physics/pmove.test.mjs +++ b/test/physics/pmove.test.mjs @@ -22,7 +22,7 @@ import { * Build a brush-backed world where a solid wall continues as a clip brush. * The seam at y=0 should remain slideable when the player is already tangent * to the shared x face of both brushes. - * @returns {import('../../source/engine/common/model/BSP.mjs').BrushModel} world model fixture + * @returns {import('../../source/engine/common/model/BSP.ts').BrushModel} world model fixture */ function createWallClipSeamWorldModel() { const model = createBrushWorldModel({ axis: 0, center: [24, -32, 0], halfExtents: [8, 32, 64] }); @@ -240,7 +240,7 @@ async function runMapFrames({ Mod.Init(); try { - const model = /** @type {import('../../source/engine/common/model/BSP.mjs').BrushModel} */ (await Mod.ForNameAsync(mapName, true)); + const model = /** @type {import('../../source/engine/common/model/BSP.ts').BrushModel} */ (await Mod.ForNameAsync(mapName, true)); const entities = parseMapEntities(model.entities); const spawn = requireMapEntity(entities, (entity) => entity.classname === 'info_player_start', 'spawn entity classname=info_player_start'); diff --git a/test/physics/server-collision.test.mjs b/test/physics/server-collision.test.mjs index 594b19b6..dd54367f 100644 --- a/test/physics/server-collision.test.mjs +++ b/test/physics/server-collision.test.mjs @@ -3,9 +3,9 @@ import assert from 'node:assert/strict'; import Vector from '../../source/shared/Vector.ts'; import { content, flags, moveType, moveTypes, solid } from '../../source/shared/Defs.ts'; -import { BrushModel } from '../../source/engine/common/model/BSP.mjs'; -import { BrushTrace, Pmove } from '../../source/engine/common/Pmove.ts'; -import { BSP29Loader } from '../../source/engine/common/model/loaders/BSP29Loader.mjs'; +import { BrushModel } from '../../source/engine/common/model/BSP.ts'; +import { Pmove } from '../../source/engine/common/Pmove.ts'; +import { BSP29Loader } from '../../source/engine/common/model/loaders/BSP29Loader.ts'; import { ServerCollision } from '../../source/engine/server/physics/ServerCollision.mjs'; import { ServerArea } from '../../source/engine/server/physics/ServerArea.mjs'; @@ -20,8 +20,8 @@ import { withMockRegistry, } from './fixtures.mjs'; -describe('ServerCollision', () => { - test('stationary brush tests preserve exact resting contact', () => { +void describe('ServerCollision', () => { + void test('stationary brush tests preserve exact resting contact', () => { const collision = new ServerCollision(); const model = createBoxBrushModel({ halfExtents: [16, 16, 16] }); const position = new Vector(100, 0, 40); @@ -42,8 +42,8 @@ describe('ServerCollision', () => { assert.deepEqual([...trace.endpos], [...position]); }); - describe('move', () => { - test('traces world brush sweeps through shared brush state', () => { + void describe('move', () => { + void test('traces world brush sweeps through shared brush state', () => { const collision = new ServerCollision(); const worldModel = createBrushWorldModel({ halfExtents: [16, 16, 16] }); const worldEntity = createMockEntity({ @@ -54,7 +54,7 @@ describe('ServerCollision', () => { }); const worldEdict = createMockEdict(worldEntity); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area: { hullForEntity(_ent, _mins, _maxs, offset) { offset.clear(); @@ -89,7 +89,7 @@ describe('ServerCollision', () => { }); }); - test('prefers a later legacy hull hit over an earlier world brush point hit', () => { + void test('prefers a later legacy hull hit over an earlier world brush point hit', () => { const collision = new ServerCollision(); const worldModel = createBoxBrushModel({ halfExtents: [16, 16, 16], name: 'world-brush' }); const worldEntity = createMockEntity({ @@ -100,7 +100,7 @@ describe('ServerCollision', () => { }); const worldEdict = createMockEdict(worldEntity); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area: { tree: { queryAABB() { @@ -164,7 +164,7 @@ describe('ServerCollision', () => { }); }); - test('keeps legacy world hull traces out of foreign clipnode subtrees', () => { + void test('keeps legacy world hull traces out of foreign clipnode subtrees', () => { const collision = new ServerCollision(); const worldHull = { clip_mins: new Vector(), @@ -193,7 +193,7 @@ describe('ServerCollision', () => { }); const worldEdict = createMockEdict(worldEntity); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area: { hullForEntity() { return worldHull; @@ -225,7 +225,7 @@ describe('ServerCollision', () => { }); }); - test('keeps outer legacy hull split points stable across deeper recursion', () => { + void test('keeps outer legacy hull split points stable across deeper recursion', () => { const collision = new ServerCollision(); const worldHull = { clip_mins: new Vector(), @@ -255,7 +255,7 @@ describe('ServerCollision', () => { }); const worldEdict = createMockEdict(worldEntity); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area: { hullForEntity() { return worldHull; @@ -289,7 +289,7 @@ describe('ServerCollision', () => { }); }); - test('prefers a later legacy hull hit over an earlier unrotated BSP entity brush point hit', () => { + void test('prefers a later legacy hull hit over an earlier unrotated BSP entity brush point hit', () => { const collision = new ServerCollision(); const worldModel = createBoxBrushModel({ halfExtents: [16, 16, 16], name: 'world-brush', submodel: false }); const entityModel = createBoxBrushModel({ halfExtents: [8, 8, 8], name: '*clip-brush' }); @@ -303,7 +303,7 @@ describe('ServerCollision', () => { bspEntity.modelindex = 1; const bspEdict = createMockEdict(bspEntity); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area: { tree: { queryAABB() { @@ -382,7 +382,7 @@ describe('ServerCollision', () => { }); }); - test('expands missile traces for monster broadphase and narrowphase', () => { + void test('expands missile traces for monster broadphase and narrowphase', () => { const collision = new ServerCollision(); const worldEdict = createMockEdict(createMockEntity({ solidType: solid.SOLID_BSP })); const monsterEntity = createMockEntity({ @@ -403,7 +403,7 @@ describe('ServerCollision', () => { /** @type {{ ent: object, mins: Vector, maxs: Vector, end: Vector }[]} */ const traceCalls = []; - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area: { tree: { queryAABB(boxmins, boxmaxs) { @@ -479,7 +479,7 @@ describe('ServerCollision', () => { }); }); - test('queries area-linked entities and filters skipped or out-of-bounds touches', () => { + void test('queries area-linked entities and filters skipped or out-of-bounds touches', () => { const collision = new ServerCollision(); const worldEdict = createMockEdict(createMockEntity({ solidType: solid.SOLID_BSP })); const passedict = createMockEdict(createMockEntity({ @@ -511,7 +511,7 @@ describe('ServerCollision', () => { /** @type {object[]} */ const traceCalls = []; - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area: { tree: { queryAABB(boxmins, boxmaxs) { @@ -582,7 +582,7 @@ describe('ServerCollision', () => { }); }); - test('keeps the nearest hit across multi-entity clip chains', () => { + void test('keeps the nearest hit across multi-entity clip chains', () => { const collision = new ServerCollision(); const worldEdict = createMockEdict(createMockEntity({ solidType: solid.SOLID_BSP })); const farEdict = createMockEdict(createMockEntity({ @@ -601,7 +601,7 @@ describe('ServerCollision', () => { /** @type {object[]} */ const traceCalls = []; - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area: { tree: { queryAABB() { @@ -670,8 +670,8 @@ describe('ServerCollision', () => { }); }); - describe('clipMoveToEntity', () => { - test('keeps rotated BSP point traces on the brush path', () => { + void describe('clipMoveToEntity', () => { + void test('keeps rotated BSP point traces on the brush path', () => { const collision = new ServerCollision(); const entityModel = createBoxBrushModel({ halfExtents: [8, 8, 8], name: '*rotating-brush' }); const bspEntity = createMockEntity({ @@ -683,7 +683,7 @@ describe('ServerCollision', () => { bspEntity.modelindex = 1; const bspEdict = createMockEdict(bspEntity); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area: { tree: { queryAABB() { @@ -726,8 +726,8 @@ describe('ServerCollision', () => { }); }); - describe('hullPointContents', () => { - test('treats masked foreign clipnodes as empty space', () => { + void describe('hullPointContents', () => { + void test('treats masked foreign clipnodes as empty space', () => { const collision = new ServerCollision(); const hull = { clip_mins: new Vector(), @@ -752,8 +752,8 @@ describe('ServerCollision', () => { }); }); - describe('pointContents', () => { - test('respects world hull ownership masks', () => { + void describe('pointContents', () => { + void test('respects world hull ownership masks', () => { const collision = new ServerCollision(); const worldHull = { clip_mins: new Vector(), @@ -771,7 +771,7 @@ describe('ServerCollision', () => { ], }; - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area: { tree: { queryAABB() { @@ -790,13 +790,13 @@ describe('ServerCollision', () => { }); }); - describe('staticWorldContents', () => { - test('uses brush-backed world solids before leaf contents', () => { + void describe('staticWorldContents', () => { + void test('uses brush-backed world solids before leaf contents', () => { const collision = new ServerCollision(); const worldModel = createBrushWorldModel({ halfExtents: [16, 16, 16] }); const worldEdict = createMockEdict(createMockEntity({ solidType: solid.SOLID_BSP })); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area: { tree: { queryAABB() { @@ -814,13 +814,13 @@ describe('ServerCollision', () => { }); }); - test('normalizes brush-backed current leaves to water', () => { + void test('normalizes brush-backed current leaves to water', () => { const collision = new ServerCollision(); const worldModel = createBrushWorldModel({ halfExtents: [16, 16, 16] }); worldModel.leafs[1].contents = content.CONTENT_CURRENT_DOWN; const worldEdict = createMockEdict(createMockEntity({ solidType: solid.SOLID_BSP })); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area: { tree: { queryAABB() { @@ -838,8 +838,8 @@ describe('ServerCollision', () => { }); }); - describe('traceStaticWorldLine', () => { - test('uses brush tracing for brush-backed world hull 0', () => { + void describe('traceStaticWorldLine', () => { + void test('uses brush tracing for brush-backed world hull 0', () => { const collision = new ServerCollision(); const worldModel = createBrushWorldModel({ halfExtents: [16, 16, 16] }); const worldEdict = createMockEdict(createMockEntity({ @@ -848,7 +848,7 @@ describe('ServerCollision', () => { solidType: solid.SOLID_BSP, })); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area: { tree: { queryAABB() { @@ -870,11 +870,11 @@ describe('ServerCollision', () => { }); }); - test('uses the client worldmodel when no local server worldspawn exists', () => { + void test('uses the client worldmodel when no local server worldspawn exists', () => { const collision = new ServerCollision(); const worldModel = createBrushWorldModel({ halfExtents: [16, 16, 16] }); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area: { tree: { queryAABB() { @@ -900,7 +900,7 @@ describe('ServerCollision', () => { }); }); - test('keeps legacy world hull traces out of foreign clipnode subtrees', () => { + void test('keeps legacy world hull traces out of foreign clipnode subtrees', () => { const collision = new ServerCollision(); const worldHull = { clip_mins: new Vector(), @@ -929,7 +929,7 @@ describe('ServerCollision', () => { }); const worldEdict = createMockEdict(worldEntity); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area: { hullForEntity() { return worldHull; @@ -956,8 +956,8 @@ describe('ServerCollision', () => { }); }); -describe('ServerArea', () => { - test('resolves BSP hulls from the client model precache when the local server model table is empty', () => { +void describe('ServerArea', () => { + void test('resolves BSP hulls from the client model precache when the local server model table is empty', () => { const area = new ServerArea(); area.initBoxHull(); @@ -972,7 +972,7 @@ describe('ServerArea', () => { const movingEdict = createMockEdict(movingEntity); const offset = new Vector(1, 1, 1); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ server: { edicts: [], models: [], @@ -992,8 +992,8 @@ describe('ServerArea', () => { }); }); -describe('BSP29Loader', () => { - test('builds legacy clipnode masks from a model headnode subtree', () => { +void describe('BSP29Loader', () => { + void test('builds legacy clipnode masks from a model headnode subtree', () => { const loader = new BSP29Loader(); const clipnodes = [ { planenum: 0, children: [1, 2] }, From 61b7c7f3fb93fa9ca70b78d0e9f975a106964416 Mon Sep 17 00:00:00 2001 From: Christian R Date: Fri, 3 Apr 2026 00:50:12 +0300 Subject: [PATCH 27/67] TS: adopt the stricter guidelines --- source/engine/common/Pmove.ts | 34 ++-- source/engine/common/W.ts | 12 +- source/engine/network/NetworkDrivers.ts | 254 ++++++++++++------------ source/shared/ClientEdict.ts | 3 +- source/shared/GameInterfaces.ts | 14 +- 5 files changed, 162 insertions(+), 155 deletions(-) diff --git a/source/engine/common/Pmove.ts b/source/engine/common/Pmove.ts index d533b7f4..87812b4f 100644 --- a/source/engine/common/Pmove.ts +++ b/source/engine/common/Pmove.ts @@ -9,14 +9,16 @@ /* eslint-disable jsdoc/require-returns */ -import Vector from '../../shared/Vector.ts'; +import Vector, { type DirectionalVectors } from '../../shared/Vector.ts'; import * as Protocol from '../network/Protocol.ts'; import { content } from '../../shared/Defs.ts'; import { BrushModel } from './Mod.ts'; import Cvar from './Cvar.ts'; import { PmoveConfiguration } from '../../shared/Pmove.ts'; - -type DirectionalVectors = import('../../shared/Vector.ts').DirectionalVectors; +import type { ClientEdict } from '../client/ClientEntities.mjs'; +import type { BaseEntity } from '../server/Edict.mjs'; +import type { Plane as BaseModelPlane } from './model/BaseModel.ts'; +import type { Brush, Node } from './model/BSP.ts'; interface BrushTracePlaneLike { readonly normal: Vector; readonly type: number; @@ -569,7 +571,7 @@ export class BrushTrace { /** * Check whether a brush AABB can possibly overlap the current swept move. */ - static _brushMayAffectTrace(ctx: BrushTraceContext, brush: import('./model/BSP.ts').Brush): boolean { + static _brushMayAffectTrace(ctx: BrushTraceContext, brush: Brush): boolean { if (brush.mins === null || brush.mins === undefined || brush.maxs === null || brush.maxs === undefined) { return true; } @@ -580,7 +582,7 @@ export class BrushTrace { /** * Check whether a brush AABB can possibly overlap the current position test. */ - static _brushMayAffectPosition(brush: import('./model/BSP.ts').Brush, boundsMins: Vector, boundsMaxs: Vector): boolean { + static _brushMayAffectPosition(brush: Brush, boundsMins: Vector, boundsMaxs: Vector): boolean { if (brush.mins === null || brush.mins === undefined || brush.maxs === null || brush.maxs === undefined) { return true; } @@ -593,7 +595,7 @@ export class BrushTrace { * enter a node's bounds. Used only for pruning; false negatives are avoided * by falling back when bounds are missing. */ - static _estimateNodeEntryFraction(ctx: BrushTraceContext, node: import('./model/BSP.ts').Node): number { + static _estimateNodeEntryFraction(ctx: BrushTraceContext, node: Node): number { if (node.mins === null || node.mins === undefined || node.maxs === null || node.maxs === undefined) { return 0; } @@ -637,7 +639,7 @@ export class BrushTrace { /** * Check whether a node can still affect the current trace. */ - static _nodeMayAffectTrace(ctx: BrushTraceContext, node: import('./model/BSP.ts').Node): boolean { + static _nodeMayAffectTrace(ctx: BrushTraceContext, node: Node): boolean { if (node.mins === null || node.mins === undefined || node.maxs === null || node.maxs === undefined) { return true; } @@ -947,7 +949,7 @@ export class BrushTrace { * Recursively walk the BSP tree for position testing, expanding by box * extents to visit all leaves the player box overlaps. */ - static _testPositionRecursive(worldModel: BrushModel, node: import('./model/BSP.ts').Node, position: Vector, mins: Vector, maxs: Vector, boundsMins: Vector, boundsMaxs: Vector, extents: Vector, isPoint: boolean, checkCount: number): boolean { + static _testPositionRecursive(worldModel: BrushModel, node: Node, position: Vector, mins: Vector, maxs: Vector, boundsMins: Vector, boundsMaxs: Vector, extents: Vector, isPoint: boolean, checkCount: number): boolean { if (node.mins !== null && node.mins !== undefined && node.maxs !== null && node.maxs !== undefined && !BrushTrace._boundsOverlap(boundsMins, boundsMaxs, node.mins, node.maxs)) { return false; @@ -1137,7 +1139,7 @@ export class BrushTrace { /** * Test if a player-sized box overlaps any solid brush in a leaf. */ - static _testLeafSolid(worldModel: BrushModel, leaf: import('./model/BSP.ts').Node, position: Vector, mins: Vector, maxs: Vector, boundsMins: Vector, boundsMaxs: Vector, checkCount: number): boolean { + static _testLeafSolid(worldModel: BrushModel, leaf: Node, position: Vector, mins: Vector, maxs: Vector, boundsMins: Vector, boundsMaxs: Vector, checkCount: number): boolean { const brushes = worldModel.brushes; const leafbrushes = worldModel.leafbrushes; @@ -1182,7 +1184,7 @@ export class BrushTrace { /** * Test if a box at origin is inside a brush. Equivalent to Q2’s CM_TestBoxInBrush. */ - static _testBoxInBrush(worldModel: BrushModel, brush: import('./model/BSP.ts').Brush, position: Vector, mins: Vector, maxs: Vector): boolean { + static _testBoxInBrush(worldModel: BrushModel, brush: Brush, position: Vector, mins: Vector, maxs: Vector): boolean { const brushsides = worldModel.brushsides; const planes = worldModel.planes; @@ -1224,7 +1226,7 @@ export class BrushTrace { * Recursively traverse the BSP node tree, expanding by trace extents. * At leaf nodes, test all brushes. Equivalent to Q2’s CM_RecursiveHullCheck. */ - static _recursiveHullCheck(ctx: BrushTraceContext, node: import('./model/BSP.ts').Node, p1f: number, p2f: number, p1: Vector, p2: Vector, depth: number = 0) { + static _recursiveHullCheck(ctx: BrushTraceContext, node: Node, p1f: number, p2f: number, p1: Vector, p2: Vector, depth: number = 0) { if (!BrushTrace._nodeMayAffectTrace(ctx, node)) { return; } @@ -1321,7 +1323,7 @@ export class BrushTrace { * Test all brushes in a leaf against the current trace. * Equivalent to Q2’s CM_TraceToLeaf. */ - static _traceToLeaf(ctx: BrushTraceContext, leaf: import('./model/BSP.ts').Node) { + static _traceToLeaf(ctx: BrushTraceContext, leaf: Node) { // Q1 content classification for trace flags if (leaf.contents !== content.CONTENT_SOLID && leaf.contents !== content.CONTENT_SKY) { ctx.trace.allsolid = false; @@ -1373,7 +1375,7 @@ export class BrushTrace { * Clip the trace against a single brush’s planes. * Equivalent to Q2’s CM_ClipBoxToBrush. */ - static _clipBoxToBrush(ctx: BrushTraceContext, brush: import('./model/BSP.ts').Brush) { + static _clipBoxToBrush(ctx: BrushTraceContext, brush: Brush) { const brushsides = ctx.worldModel.brushsides; const planes = ctx.worldModel.planes; const moveDeltaX = ctx.end[0] - ctx.start[0]; @@ -1384,8 +1386,8 @@ export class BrushTrace { let enterfrac = -1; let leavefrac = 1; - let clipplane: import('./model/BaseModel.ts').Plane | null = null; - let tangentAxialPlane: import('./model/BaseModel.ts').Plane | null = null; + let clipplane: BaseModelPlane | null = null; + let tangentAxialPlane: BaseModelPlane | null = null; let tangentAxialMovesDeeper = false; let getout = false; @@ -3742,7 +3744,7 @@ export class Pmove { // pmove_t * @param model - brush model to use for SOLID_BSP-style entities * @returns this pmove instance */ - addEntity(entity: import('../server/Edict.mjs').BaseEntity | import('../client/ClientEntities.mjs').ClientEdict, model: BrushModel | null = null): Pmove { + addEntity(entity: BaseEntity | ClientEdict, model: BrushModel | null = null): Pmove { const pe = new PhysEnt(this); console.assert(model === null || model instanceof BrushModel, 'no model or brush model required'); diff --git a/source/engine/common/W.ts b/source/engine/common/W.ts index 6c7f1508..96cae40a 100644 --- a/source/engine/common/W.ts +++ b/source/engine/common/W.ts @@ -264,7 +264,7 @@ class Wad3File extends WadFileInterface { new Uint8Array(lump).set(new Uint8Array(base, filepos, disksize)); } else { // Compressed const compressedData = new Uint8Array(base, filepos, disksize); - const decompressed = Wad3File._decompressLZ(compressedData, size); + const decompressed = Wad3File.#decompressLZ(compressedData, size); new Uint8Array(lump).set(decompressed); } @@ -279,7 +279,7 @@ class Wad3File extends WadFileInterface { } } - _parseQPicLump(name: string, data: ArrayBuffer, _mipmapLevel: number): WadLumpTexture { + #parseQPicLump(name: string, data: ArrayBuffer, _mipmapLevel: number): WadLumpTexture { const view = new DataView(data); const width = view.getUint32(0, true); const height = view.getUint32(4, true); @@ -297,7 +297,7 @@ class Wad3File extends WadFileInterface { return new WadLumpTexture(name, width, height, rgba); } - _parseMiptexLump(name: string, data: ArrayBuffer, mipmapLevel: number): WadLumpTexture { + #parseMiptexLump(name: string, data: ArrayBuffer, mipmapLevel: number): WadLumpTexture { return readWad3Texture(data, name, mipmapLevel); } @@ -329,10 +329,10 @@ class Wad3File extends WadFileInterface { switch (lumpInfo.type) { case 0x43: // miptex case 0x40: // spraydecal - return this._parseMiptexLump(lumpInfo.name, lumpInfo.data, mipmapLevel); + return this.#parseMiptexLump(lumpInfo.name, lumpInfo.data, mipmapLevel); case 0x42: // QPic - return this._parseQPicLump(lumpInfo.name, lumpInfo.data, mipmapLevel); + return this.#parseQPicLump(lumpInfo.name, lumpInfo.data, mipmapLevel); case 0x46: // font console.assert(false, 'Wad3File.getLumpMipmap: font handling not implemented'); @@ -346,7 +346,7 @@ class Wad3File extends WadFileInterface { * Decompress LZ-compressed data from GoldSrc WAD3 files * @returns the decompressed data */ - static _decompressLZ(compressed: Uint8Array, uncompressedSize: number): Uint8Array { + static #decompressLZ(compressed: Uint8Array, uncompressedSize: number): Uint8Array { const output = new Uint8Array(uncompressedSize); let inPos = 0; let outPos = 0; diff --git a/source/engine/network/NetworkDrivers.ts b/source/engine/network/NetworkDrivers.ts index 682cb57f..da7c1712 100644 --- a/source/engine/network/NetworkDrivers.ts +++ b/source/engine/network/NetworkDrivers.ts @@ -631,10 +631,10 @@ export class WebSocketDriver extends BaseDriver { } const { webSocket: browserSocket } = socketState; - browserSocket.onerror = this._OnErrorClient; - browserSocket.onmessage = this._OnMessageClient; - browserSocket.onopen = this._OnOpenClient; - browserSocket.onclose = this._OnCloseClient; + browserSocket.onerror = this.#OnErrorClient; + browserSocket.onmessage = this.#OnMessageClient; + browserSocket.onopen = this.#OnOpenClient; + browserSocket.onclose = this.#OnCloseClient; browserSocket.qsocket = sock; sock.state = QSocket.STATE_CONNECTING; @@ -682,7 +682,7 @@ export class WebSocketDriver extends BaseDriver { return type; } - _FlushSendBuffer(qsocket: QSocket): boolean { + #FlushSendBuffer(qsocket: QSocket): boolean { const socketState = getWebSocketState(qsocket); if (socketState === null) { @@ -724,7 +724,7 @@ export class WebSocketDriver extends BaseDriver { return true; } - _SendRawMessage(qsocket: QSocket, data: Uint8Array): number { + #SendRawMessage(qsocket: QSocket, data: Uint8Array): number { const socketState = getWebSocketState(qsocket); if (socketState === null) { @@ -732,7 +732,7 @@ export class WebSocketDriver extends BaseDriver { } socketState.sendQueue.push(data); - this._FlushSendBuffer(qsocket); + this.#FlushSendBuffer(qsocket); return qsocket.state !== QSocket.STATE_DISCONNECTED ? 1 : -1; } @@ -743,7 +743,7 @@ export class WebSocketDriver extends BaseDriver { buffer[index++] = data.cursize & 0xff; buffer[index++] = (data.cursize >> 8) & 0xff; buffer.set(new Uint8Array(data.data, 0, data.cursize), index); - return this._SendRawMessage(qsocket, buffer); + return this.#SendRawMessage(qsocket, buffer); } SendUnreliableMessage(qsocket: QSocket, data: NetworkPayload): number { @@ -753,21 +753,21 @@ export class WebSocketDriver extends BaseDriver { buffer[index++] = data.cursize & 0xff; buffer[index++] = (data.cursize >> 8) & 0xff; buffer.set(new Uint8Array(data.data, 0, data.cursize), index); - return this._SendRawMessage(qsocket, buffer); + return this.#SendRawMessage(qsocket, buffer); } Close(qsocket: QSocket): void { const socketState = getWebSocketState(qsocket); if (socketState !== null && this.CanSendMessage(qsocket)) { - this._FlushSendBuffer(qsocket); + this.#FlushSendBuffer(qsocket); socketState.webSocket.close(1000); } qsocket.state = QSocket.STATE_DISCONNECTED; } - _OnErrorClient(this: BrowserWebSocketWithSocket, _error: Event): void { + #OnErrorClient(this: BrowserWebSocketWithSocket, _error: Event): void { if (this.qsocket === undefined) { return; } @@ -776,7 +776,7 @@ export class WebSocketDriver extends BaseDriver { this.qsocket.state = QSocket.STATE_DISCONNECTED; } - _OnMessageClient(this: BrowserWebSocketWithSocket, message: MessageEvent): void { + #OnMessageClient(this: BrowserWebSocketWithSocket, message: MessageEvent): void { if (this.qsocket === undefined) { return; } @@ -803,13 +803,13 @@ export class WebSocketDriver extends BaseDriver { }, NET.delay_receive.value + (Math.random() - 0.5) * NET.delay_receive_jitter.value); } - _OnOpenClient(this: BrowserWebSocketWithSocket): void { + #OnOpenClient(this: BrowserWebSocketWithSocket): void { if (this.qsocket !== undefined) { this.qsocket.state = QSocket.STATE_CONNECTED; } } - _OnCloseClient(this: BrowserWebSocketWithSocket): void { + #OnCloseClient(this: BrowserWebSocketWithSocket): void { if (this.qsocket === undefined || this.qsocket.state !== QSocket.STATE_CONNECTED) { return; } @@ -818,7 +818,7 @@ export class WebSocketDriver extends BaseDriver { this.qsocket.state = QSocket.STATE_DISCONNECTING; } - _OnConnectionServer(ws: NodeWebSocketLike, req: NodeIncomingMessageLike): void { + #OnConnectionServer(ws: NodeWebSocketLike, req: NodeIncomingMessageLike): void { Con.DPrint('WebSocketDriver._OnConnectionServer: received new connection\n'); const sock = NET.NewQSocket(this); @@ -889,7 +889,7 @@ export class WebSocketDriver extends BaseDriver { const nodeWebSocketModule = WebSocketModule as NodeWebSocketModuleLike; this.wss = new nodeWebSocketModule.WebSocketServer({ server: NET.server }); - this.wss.on('connection', this._OnConnectionServer.bind(this)); + this.wss.on('connection', this.#OnConnectionServer.bind(this)); this.newConnections = []; } @@ -972,7 +972,7 @@ export class WebRTCDriver extends BaseDriver { sessionId = host; } - if (!this._ConnectSignaling()) { + if (!this.#ConnectSignaling()) { Con.PrintError('WebRTCDriver.Connect: Failed to connect to signaling server\n'); return null; } @@ -984,9 +984,9 @@ export class WebRTCDriver extends BaseDriver { const onSignalingReady = () => { if (shouldCreateSession) { - this._CreateSession(sock); + this.#CreateSession(sock); } else { - this._JoinSession(sock, sessionId); + this.#JoinSession(sock, sessionId); } }; @@ -1003,7 +1003,7 @@ export class WebRTCDriver extends BaseDriver { return sock; } - _ConnectSignaling(): boolean { + #ConnectSignaling(): boolean { if (this.signalingWs !== null) { if (this.signalingWs.readyState === 1 || this.signalingWs.readyState === 0) { return true; @@ -1021,10 +1021,10 @@ export class WebRTCDriver extends BaseDriver { this.signalingWs.onopen = () => { Con.DPrint(`WebRTCDriver: Connected to signaling server at ${this.signalingUrl}\n`); const previousSessionId = this.sessionId; - this._ProcessPendingSignaling(); + this.#ProcessPendingSignaling(); if (previousSessionId !== null && previousSessionId === this.sessionId) { - this._RestoreSession(); + this.#RestoreSession(); } }; @@ -1033,13 +1033,13 @@ export class WebRTCDriver extends BaseDriver { return; } - await this._OnSignalingMessage(JSON.parse(event.data) as SignalingMessage); + await this.#OnSignalingMessage(JSON.parse(event.data) as SignalingMessage); }; this.signalingWs.onerror = (errorEvent: Event) => { console.debug('WebRTCDriver: Signaling WebSocket error', errorEvent); Con.DPrint(`WebRTCDriver: Signaling error: ${errorEvent}\n`); - this._OnSignalingError({ error: 'Signaling connection error', type: 'error' }); + this.#OnSignalingError({ error: 'Signaling connection error', type: 'error' }); }; this.signalingWs.onclose = (closeEvent: CloseEvent) => { @@ -1051,19 +1051,19 @@ export class WebRTCDriver extends BaseDriver { Con.PrintWarning(`Signaling server at ${this.signalingUrl} might be unavailable.\n`); } - this._OnSignalingError({ error: 'Signaling connection closed', type: 'error' }); - this._ScheduleReconnect(); + this.#OnSignalingError({ error: 'Signaling connection closed', type: 'error' }); + this.#ScheduleReconnect(); }; return true; } catch (error) { Con.PrintError(`WebRTCDriver: Failed to connect to signaling at ${this.signalingUrl}:\n${getErrorMessage(error as Throwable)}\n`); - this._ScheduleReconnect(); + this.#ScheduleReconnect(); return false; } } - _ScheduleReconnect(): void { + #ScheduleReconnect(): void { if (this.reconnectTimer !== null) { return; } @@ -1074,31 +1074,31 @@ export class WebRTCDriver extends BaseDriver { this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; Con.DPrint('WebRTCDriver: Attempting to reconnect...\n'); - this._ConnectSignaling(); + this.#ConnectSignaling(); }, delay); } - _RestoreSession(): void { + #RestoreSession(): void { if (this.isHost) { Con.DPrint('WebRTCDriver: Restoring host session...\n'); - this._SendSignaling({ + this.#SendSignaling({ type: 'create-session', sessionId: this.sessionId ?? undefined, hostToken: this.hostToken ?? undefined, - serverInfo: this._GatherServerInfo(), - isPublic: this._IsSessionPublic(), + serverInfo: this.#GatherServerInfo(), + isPublic: this.#IsSessionPublic(), }); return; } Con.DPrint(`WebRTCDriver: Restoring client session ${this.sessionId}\n`); - this._SendSignaling({ + this.#SendSignaling({ type: 'join-session', sessionId: this.sessionId ?? undefined, }); } - _ProcessPendingSignaling(): void { + #ProcessPendingSignaling(): void { for (const sock of NET.activeSockets) { const socketData = sock === undefined ? null : getWebRTCSocketState(sock); @@ -1109,51 +1109,51 @@ export class WebRTCDriver extends BaseDriver { } } - _SendSignaling(message: SignalingMessage): void { + #SendSignaling(message: SignalingMessage): void { if (this.signalingWs !== null && this.signalingWs.readyState === 1) { this.signalingWs.send(JSON.stringify(message)); } } - _StartPingInterval(): void { + #StartPingInterval(): void { if (!this.isHost) { return; } - this._StopPingInterval(); + this.#StopPingInterval(); this.pingInterval = setInterval(() => { - this._SendSignaling({ type: 'ping' }); + this.#SendSignaling({ type: 'ping' }); }, 30 * 1000); - this._SendSignaling({ type: 'ping' }); + this.#SendSignaling({ type: 'ping' }); } - _StopPingInterval(): void { + #StopPingInterval(): void { if (this.pingInterval !== null) { clearInterval(this.pingInterval); this.pingInterval = null; } } - _StartServerInfoSubscriptions(): void { + #StartServerInfoSubscriptions(): void { if (!this.isHost) { return; } - this._StopServerInfoSubscriptions(); - this.serverEventSubscriptions.push(eventBus.subscribe('server.spawned', () => this._UpdateServerInfo())); - this.serverEventSubscriptions.push(eventBus.subscribe('server.client.connected', () => this._UpdateServerInfo())); - this.serverEventSubscriptions.push(eventBus.subscribe('server.client.disconnected', () => this._UpdateServerInfo())); + this.#StopServerInfoSubscriptions(); + this.serverEventSubscriptions.push(eventBus.subscribe('server.spawned', () => this.#UpdateServerInfo())); + this.serverEventSubscriptions.push(eventBus.subscribe('server.client.connected', () => this.#UpdateServerInfo())); + this.serverEventSubscriptions.push(eventBus.subscribe('server.client.disconnected', () => this.#UpdateServerInfo())); this.serverEventSubscriptions.push(eventBus.subscribe('cvar.changed', (cvarName: string) => { const cvar = Cvar.FindVar(cvarName); if (cvar !== null && (cvar.flags & Cvar.FLAG.SERVER) !== 0) { - this._UpdateServerInfo(); + this.#UpdateServerInfo(); } })); - this._UpdateServerInfo(); + this.#UpdateServerInfo(); } - _StopServerInfoSubscriptions(): void { + #StopServerInfoSubscriptions(): void { while (this.serverEventSubscriptions.length > 0) { const unsubscribe = this.serverEventSubscriptions.pop(); @@ -1163,19 +1163,19 @@ export class WebRTCDriver extends BaseDriver { } } - _UpdateServerInfo(): void { + #UpdateServerInfo(): void { if (!this.isHost || this.sessionId === null) { return; } - this._SendSignaling({ + this.#SendSignaling({ type: 'update-server-info', - serverInfo: this._GatherServerInfo(), - isPublic: this._IsSessionPublic(), + serverInfo: this.#GatherServerInfo(), + isPublic: this.#IsSessionPublic(), }); } - _GatherServerInfo(): ServerInfo { + #GatherServerInfo(): ServerInfo { const serverInfo: ServerInfo = { hostname: Cvar.FindVar('hostname')?.string ?? 'UNNAMED', maxPlayers: SV.svs.maxclients, @@ -1192,12 +1192,12 @@ export class WebRTCDriver extends BaseDriver { return serverInfo; } - _IsSessionPublic(): boolean { + #IsSessionPublic(): boolean { return (Cvar.FindVar('sv_public')?.value ?? 0) !== 0; } - _CreateSession(sock: QSocket): void { - this._SendSignaling({ type: 'create-session' }); + #CreateSession(sock: QSocket): void { + this.#SendSignaling({ type: 'create-session' }); const socketState = getWebRTCSocketState(sock); if (socketState !== null) { @@ -1207,8 +1207,8 @@ export class WebRTCDriver extends BaseDriver { this.isHost = true; } - _JoinSession(sock: QSocket, sessionId: string | null): void { - this._SendSignaling({ + #JoinSession(sock: QSocket, sessionId: string | null): void { + this.#SendSignaling({ type: 'join-session', sessionId: sessionId ?? undefined, }); @@ -1221,44 +1221,44 @@ export class WebRTCDriver extends BaseDriver { this.sessionId = sessionId; } - async _OnSignalingMessage(message: SignalingMessage): Promise { + async #OnSignalingMessage(message: SignalingMessage): Promise { switch (message.type) { case 'session-created': - this._OnSessionCreated(message); + this.#OnSessionCreated(message); return; case 'session-joined': - this._OnSessionJoined(message); + this.#OnSessionJoined(message); return; case 'peer-joined': - this._OnPeerJoined(message); + this.#OnPeerJoined(message); return; case 'peer-left': - this._OnPeerLeft(message); + this.#OnPeerLeft(message); return; case 'offer': - await this._OnOffer(message); + await this.#OnOffer(message); return; case 'answer': - await this._OnAnswer(message); + await this.#OnAnswer(message); return; case 'ice-candidate': - await this._OnIceCandidate(message); + await this.#OnIceCandidate(message); return; case 'session-closed': - this._OnSessionClosed(message); + this.#OnSessionClosed(message); return; case 'pong': return; case 'error': Con.DPrint(`WebRTCDriver: Signaling error: ${message.error}\n`); - this._OnSignalingError(message); + this.#OnSignalingError(message); return; default: Con.DPrint(`WebRTCDriver: Unknown signaling message: ${message.type}\n`); } } - _OnSignalingError(message: SignalingMessage): void { + #OnSignalingError(message: SignalingMessage): void { let failedSocket: QSocket | null = null; for (const sock of NET.activeSockets) { @@ -1302,7 +1302,7 @@ export class WebRTCDriver extends BaseDriver { Con.PrintWarning(`WebRTCDriver: Signaling error (no matching socket): ${message.error}\n`); } - _OnSessionCreated(message: SignalingMessage): void { + #OnSessionCreated(message: SignalingMessage): void { this.sessionId = message.sessionId ?? null; this.peerId = message.peerId ?? null; this.isHost = message.isHost ?? false; @@ -1324,7 +1324,7 @@ export class WebRTCDriver extends BaseDriver { } if (sock === null) { - sock = this._FindSocketBySession(this.sessionId); + sock = this.#FindSocketBySession(this.sessionId); } const socketData = sock === null ? null : getWebRTCSocketState(sock); @@ -1334,14 +1334,14 @@ export class WebRTCDriver extends BaseDriver { sock.state = QSocket.STATE_CONNECTED; sock.address = `WebRTC Host (${this.sessionId})`; Con.DPrint('WebRTCDriver: Host socket ready for accepting peers\n'); - this._StartPingInterval(); - this._StartServerInfoSubscriptions(); + this.#StartPingInterval(); + this.#StartServerInfoSubscriptions(); if (message.existingPeers !== undefined && message.existingPeers.length > 0) { Con.DPrint(`WebRTCDriver: Reconnecting to ${message.existingPeers.length} existing peers...\n`); for (const peerId of message.existingPeers) { - this._OnPeerJoined({ type: 'peer-joined', peerId }); + this.#OnPeerJoined({ type: 'peer-joined', peerId }); } } @@ -1351,7 +1351,7 @@ export class WebRTCDriver extends BaseDriver { Con.PrintWarning(`WebRTCDriver: No socket found for session ${this.sessionId}\n`); } - _OnSessionJoined(message: SignalingMessage): void { + #OnSessionJoined(message: SignalingMessage): void { this.sessionId = message.sessionId ?? null; this.peerId = message.peerId ?? null; this.isHost = message.isHost ?? false; @@ -1360,7 +1360,7 @@ export class WebRTCDriver extends BaseDriver { Con.DPrint(`WebRTCDriver: Your peer ID: ${this.peerId}\n`); Con.DPrint(`WebRTCDriver: Peers in session: ${message.peerCount}\n`); - const sock = this._FindSocketBySession(this.sessionId); + const sock = this.#FindSocketBySession(this.sessionId); if (sock !== null) { sock.address = `WebRTC Peer (${this.sessionId})`; @@ -1371,7 +1371,7 @@ export class WebRTCDriver extends BaseDriver { Con.PrintWarning(`WebRTCDriver: No socket found for joined session ${this.sessionId}\n`); } - _OnPeerJoined(message: SignalingMessage): void { + #OnPeerJoined(message: SignalingMessage): void { if (message.peerId === undefined) { return; } @@ -1388,34 +1388,34 @@ export class WebRTCDriver extends BaseDriver { peerId: message.peerId, }); - this._CreatePeerConnection(peerSock, message.peerId, true); + this.#CreatePeerConnection(peerSock, message.peerId, true); this.newConnections.push(peerSock); Con.DPrint(`WebRTCDriver: Created socket for peer ${message.peerId}, added to new connections\n`); } } - _OnPeerLeft(message: SignalingMessage): void { + #OnPeerLeft(message: SignalingMessage): void { if (message.peerId !== undefined) { Con.DPrint(`WebRTCDriver: Peer ${message.peerId} left\n`); - this._ClosePeerConnection(message.peerId); + this.#ClosePeerConnection(message.peerId); } } - async _OnOffer(message: SignalingMessage): Promise { + async #OnOffer(message: SignalingMessage): Promise { if (message.fromPeerId === undefined || message.offer === undefined || message.offer === null) { return; } Con.DPrint(`WebRTCDriver: Received offer from ${message.fromPeerId}\n`); - const sock = this._FindSocketBySession(this.sessionId); + const sock = this.#FindSocketBySession(this.sessionId); if (sock === null) { Con.PrintWarning('WebRTCDriver._OnOffer: No socket found for session\n'); return; } - const peerConnection = this._CreatePeerConnection(sock, message.fromPeerId, false); + const peerConnection = this.#CreatePeerConnection(sock, message.fromPeerId, false); if (peerConnection === null) { return; @@ -1425,7 +1425,7 @@ export class WebRTCDriver extends BaseDriver { await peerConnection.setRemoteDescription(new RTCSessionDescription(message.offer)); const answer = await peerConnection.createAnswer(); await peerConnection.setLocalDescription(answer); - this._SendSignaling({ + this.#SendSignaling({ type: 'answer', targetPeerId: message.fromPeerId, answer: peerConnection.localDescription, @@ -1435,14 +1435,14 @@ export class WebRTCDriver extends BaseDriver { } } - async _OnAnswer(message: SignalingMessage): Promise { + async #OnAnswer(message: SignalingMessage): Promise { if (message.fromPeerId === undefined || message.answer === undefined || message.answer === null) { return; } Con.DPrint(`WebRTCDriver: Received answer from ${message.fromPeerId}\n`); - const sock = this.isHost ? this._FindSocketByPeerId(message.fromPeerId) : this._FindSocketBySession(this.sessionId); + const sock = this.isHost ? this.#FindSocketByPeerId(message.fromPeerId) : this.#FindSocketBySession(this.sessionId); const socketData = sock === null ? null : getWebRTCSocketState(sock); @@ -1466,12 +1466,12 @@ export class WebRTCDriver extends BaseDriver { } } - async _OnIceCandidate(message: SignalingMessage): Promise { + async #OnIceCandidate(message: SignalingMessage): Promise { if (message.fromPeerId === undefined) { return; } - const sock = this.isHost ? this._FindSocketByPeerId(message.fromPeerId) : this._FindSocketBySession(this.sessionId); + const sock = this.isHost ? this.#FindSocketByPeerId(message.fromPeerId) : this.#FindSocketBySession(this.sessionId); const socketData = sock === null ? null : getWebRTCSocketState(sock); @@ -1494,10 +1494,10 @@ export class WebRTCDriver extends BaseDriver { } } - _OnSessionClosed(message: SignalingMessage): void { + #OnSessionClosed(message: SignalingMessage): void { Con.DPrint(`WebRTCDriver: Session closed: ${message.reason}\n`); - const sock = this._FindSocketBySession(this.sessionId); + const sock = this.#FindSocketBySession(this.sessionId); if (sock !== null) { sock.state = QSocket.STATE_DISCONNECTED; @@ -1508,7 +1508,7 @@ export class WebRTCDriver extends BaseDriver { this.isHost = false; } - _CreatePeerConnection(sock: QSocket, peerId: string, initiator: boolean): RTCPeerConnection | null { + #CreatePeerConnection(sock: QSocket, peerId: string, initiator: boolean): RTCPeerConnection | null { console.assert(sock.transportState?.kind === 'webrtc', 'WebRTCDriver._CreatePeerConnection: Invalid socket'); const socketData = getWebRTCSocketState(sock); @@ -1530,7 +1530,7 @@ export class WebRTCDriver extends BaseDriver { peerConnection.onicecandidate = (event) => { if (event.candidate) { Con.DPrint(`WebRTCDriver: Sending ICE candidate to ${peerId}\n`); - this._SendSignaling({ + this.#SendSignaling({ type: 'ice-candidate', targetPeerId: peerId, candidate: event.candidate, @@ -1552,19 +1552,19 @@ export class WebRTCDriver extends BaseDriver { Con.DPrint(`WebRTCDriver: P2P connection established with ${peerId}\n`); } else if (peerConnection.connectionState === 'failed' || peerConnection.connectionState === 'disconnected') { Con.DPrint(`WebRTCDriver: Connection ${peerConnection.connectionState} with ${peerId}\n`); - this._ClosePeerConnection(peerId); + this.#ClosePeerConnection(peerId); } }; if (initiator) { const reliableChannel = peerConnection.createDataChannel('reliable', { ordered: true }); const unreliableChannel = peerConnection.createDataChannel('unreliable', { ordered: false, maxRetransmits: 10 }); - this._SetupDataChannel(sock, peerId, reliableChannel, unreliableChannel); + this.#SetupDataChannel(sock, peerId, reliableChannel, unreliableChannel); void peerConnection.createOffer() .then((offer) => peerConnection.setLocalDescription(offer)) .then(() => { - this._SendSignaling({ + this.#SendSignaling({ type: 'offer', targetPeerId: peerId, offer: peerConnection.localDescription, @@ -1595,10 +1595,10 @@ export class WebRTCDriver extends BaseDriver { if (channel.label === 'reliable') { channels.reliable = channel; - this._SetupDataChannelHandlers(sock, peerId, channel); + this.#SetupDataChannelHandlers(sock, peerId, channel); } else if (channel.label === 'unreliable') { channels.unreliable = channel; - this._SetupDataChannelHandlers(sock, peerId, channel); + this.#SetupDataChannelHandlers(sock, peerId, channel); } }; } @@ -1606,7 +1606,7 @@ export class WebRTCDriver extends BaseDriver { return peerConnection; } - _SetupDataChannel(sock: QSocket, peerId: string, reliableChannel: RTCDataChannel, unreliableChannel: RTCDataChannel): void { + #SetupDataChannel(sock: QSocket, peerId: string, reliableChannel: RTCDataChannel, unreliableChannel: RTCDataChannel): void { const socketData = getWebRTCSocketState(sock); if (socketData === null) { @@ -1618,11 +1618,11 @@ export class WebRTCDriver extends BaseDriver { unreliable: unreliableChannel, }); - this._SetupDataChannelHandlers(sock, peerId, reliableChannel); - this._SetupDataChannelHandlers(sock, peerId, unreliableChannel); + this.#SetupDataChannelHandlers(sock, peerId, reliableChannel); + this.#SetupDataChannelHandlers(sock, peerId, unreliableChannel); } - _SetupDataChannelHandlers(sock: QSocket, peerId: string, channel: RTCDataChannel): void { + #SetupDataChannelHandlers(sock: QSocket, peerId: string, channel: RTCDataChannel): void { channel.binaryType = 'arraybuffer'; channel.onopen = () => { @@ -1633,7 +1633,7 @@ export class WebRTCDriver extends BaseDriver { Con.DPrint('WebRTCDriver: Socket now CONNECTED (can send/receive data)\n'); } - this._FlushSendBuffer(sock); + this.#FlushSendBuffer(sock); }; channel.onclose = () => { @@ -1655,8 +1655,8 @@ export class WebRTCDriver extends BaseDriver { }; } - _ClosePeerConnection(peerId: string): void { - const sock = this.isHost ? this._FindSocketByPeerId(peerId) : this._FindSocketBySession(this.sessionId); + #ClosePeerConnection(peerId: string): void { + const sock = this.isHost ? this.#FindSocketByPeerId(peerId) : this.#FindSocketBySession(this.sessionId); const socketData = sock === null ? null : getWebRTCSocketState(sock); @@ -1678,7 +1678,7 @@ export class WebRTCDriver extends BaseDriver { sock.state = QSocket.STATE_DISCONNECTED; } - _FindSocketBySession(sessionId: string | null): QSocket | null { + #FindSocketBySession(sessionId: string | null): QSocket | null { for (const sock of NET.activeSockets) { const socketData = sock === undefined ? null : getWebRTCSocketState(sock); @@ -1690,7 +1690,7 @@ export class WebRTCDriver extends BaseDriver { return null; } - _FindSocketByPeerId(peerId: string): QSocket | null { + #FindSocketByPeerId(peerId: string): QSocket | null { for (const sock of NET.activeSockets) { const socketData = sock === undefined ? null : getWebRTCSocketState(sock); @@ -1712,7 +1712,7 @@ export class WebRTCDriver extends BaseDriver { return sock; } - _FlushSendBuffer(qsocket: QSocket): void { + #FlushSendBuffer(qsocket: QSocket): void { const webRtcData = getWebRTCSocketState(qsocket); if (webRtcData === null) { @@ -1738,7 +1738,7 @@ export class WebRTCDriver extends BaseDriver { break; } - const result = this._SendToAllPeers(qsocket, message.buffer, message.reliable); + const result = this.#SendToAllPeers(qsocket, message.buffer, message.reliable); if (result > 0) { queue.shift(); @@ -1749,7 +1749,7 @@ export class WebRTCDriver extends BaseDriver { if (queue.length === 0 && qsocket.state === QSocket.STATE_DISCONNECTING) { Con.DPrint(`WebRTCDriver._FlushSendBuffer: buffer drained, closing ${qsocket.address}\n`); - this._ForceClose(qsocket); + this.#ForceClose(qsocket); } } @@ -1802,7 +1802,7 @@ export class WebRTCDriver extends BaseDriver { buffer[2] = (data.cursize >> 8) & 0xff; buffer.set(new Uint8Array(data.data, 0, data.cursize), 3); socketData.sendQueue.push({ buffer, reliable: true }); - this._FlushSendBuffer(qsocket); + this.#FlushSendBuffer(qsocket); return 1; } @@ -1819,11 +1819,11 @@ export class WebRTCDriver extends BaseDriver { buffer[2] = (data.cursize >> 8) & 0xff; buffer.set(new Uint8Array(data.data, 0, data.cursize), 3); socketData.sendQueue.push({ buffer, reliable: false }); - this._FlushSendBuffer(qsocket); + this.#FlushSendBuffer(qsocket); return 1; } - _SendToAllPeers(qsocket: QSocket, buffer: Uint8Array, reliable: boolean): number { + #SendToAllPeers(qsocket: QSocket, buffer: Uint8Array, reliable: boolean): number { console.assert(qsocket.transportState?.kind === 'webrtc', 'WebRTCDriver._SendToAllPeers: Invalid socket'); const socketData = getWebRTCSocketState(qsocket); @@ -1882,7 +1882,7 @@ export class WebRTCDriver extends BaseDriver { return; } - this._FlushSendBuffer(qsocket); + this.#FlushSendBuffer(qsocket); if (socketData.sendQueue.length > 0 && qsocket.state !== QSocket.STATE_DISCONNECTED) { if (socketData.dataChannels.size > 0) { @@ -1892,7 +1892,7 @@ export class WebRTCDriver extends BaseDriver { setTimeout(() => { if (qsocket.state === QSocket.STATE_DISCONNECTING) { Con.DPrint(`WebRTCDriver.Close: timeout waiting for flush, forcing close for ${qsocket.address}\n`); - this._ForceClose(qsocket); + this.#ForceClose(qsocket); } }, 5000); @@ -1900,10 +1900,10 @@ export class WebRTCDriver extends BaseDriver { } } - this._ForceClose(qsocket); + this.#ForceClose(qsocket); } - _ForceClose(qsocket: QSocket): void { + #ForceClose(qsocket: QSocket): void { const socketData = getWebRTCSocketState(qsocket); if (socketData === null) { @@ -1921,12 +1921,12 @@ export class WebRTCDriver extends BaseDriver { const isSessionSocket = socketData.isHost || (!this.isHost && socketData.sessionId === this.sessionId); if (socketData.isHost) { - this._StopPingInterval(); - this._StopServerInfoSubscriptions(); + this.#StopPingInterval(); + this.#StopServerInfoSubscriptions(); } if (isSessionSocket && this.sessionId !== null) { - this._SendSignaling({ type: 'leave-session' }); + this.#SendSignaling({ type: 'leave-session' }); } if (isSessionSocket) { @@ -1959,7 +1959,7 @@ export class WebRTCDriver extends BaseDriver { Con.DPrint('WebRTCDriver: Starting WebRTC host session for listen server\n'); this.creatingSession = true; - if (!this._ConnectSignaling()) { + if (!this.#ConnectSignaling()) { Con.PrintWarning('WebRTCDriver: Failed to connect to signaling server\n'); this.creatingSession = false; return; @@ -1971,10 +1971,10 @@ export class WebRTCDriver extends BaseDriver { sock.transportState = createWebRTCSocketState({ sessionId: null, isHost: true }); const createSessionWhenReady = () => { - this._SendSignaling({ + this.#SendSignaling({ type: 'create-session', - serverInfo: this._GatherServerInfo(), - isPublic: this._IsSessionPublic(), + serverInfo: this.#GatherServerInfo(), + isPublic: this.#IsSessionPublic(), }); Con.DPrint('WebRTCDriver: Session creation request sent\n'); }; @@ -1995,8 +1995,8 @@ export class WebRTCDriver extends BaseDriver { } Con.DPrint('WebRTCDriver: Stopping listen server, tearing down session\n'); - this._StopPingInterval(); - this._StopServerInfoSubscriptions(); + this.#StopPingInterval(); + this.#StopServerInfoSubscriptions(); for (let index = NET.activeSockets.length - 1; index >= 0; index--) { const sock = NET.activeSockets[index]; @@ -2014,7 +2014,7 @@ export class WebRTCDriver extends BaseDriver { } if (this.sessionId !== null) { - this._SendSignaling({ type: 'leave-session' }); + this.#SendSignaling({ type: 'leave-session' }); } if (this.signalingWs !== null) { diff --git a/source/shared/ClientEdict.ts b/source/shared/ClientEdict.ts index 75225e7e..4d1cc16e 100644 --- a/source/shared/ClientEdict.ts +++ b/source/shared/ClientEdict.ts @@ -1,6 +1,7 @@ import type { ClientEdict } from '../engine/client/ClientEntities.mjs'; +import type { ClientEngineAPI as ClientEngineApiValue } from '../engine/common/GameAPIs.mjs'; -type ClientEngineAPI = typeof import('../engine/common/GameAPIs.mjs').ClientEngineAPI; +type ClientEngineAPI = typeof ClientEngineApiValue; export class BaseClientEdictHandler { /** diff --git a/source/shared/GameInterfaces.ts b/source/shared/GameInterfaces.ts index 0dffafab..9a37b56a 100644 --- a/source/shared/GameInterfaces.ts +++ b/source/shared/GameInterfaces.ts @@ -1,7 +1,11 @@ import type { BaseClientEdictHandler } from './ClientEdict.ts'; import type { ClientEngineAPI as ClientEngineApiValue, ServerEngineAPI as ServerEngineApiValue } from '../engine/common/GameAPIs.mjs'; import type { ServerEdict as ServerEdictValue } from '../engine/server/Edict.mjs'; +import type { GLTexture as GLTextureValue } from '../engine/client/GL.mjs'; +import type { SFX as SFXValue } from '../engine/client/Sound.mjs'; +import type CvarValue from '../engine/common/Cvar.ts'; import type Vector from './Vector.ts'; +import type { PmoveConfiguration as PmoveConfigurationValue, PmoveQuake2Configuration as PmoveQuake2ConfigurationValue } from '../shared/Pmove.ts'; import type { StartGameInterface } from '../engine/client/ClientLifecycle.mjs'; import type { BaseModel } from '../engine/common/model/BaseModel.ts'; @@ -9,11 +13,11 @@ export type ClientEngineAPI = Readonly; export type ServerEngineAPI = Readonly; export type ServerEdict = Readonly; -export type GLTexture = import('../engine/client/GL.mjs').GLTexture; -export type Cvar = Readonly; +export type GLTexture = GLTextureValue; +export type Cvar = Readonly; -export type PmoveConfiguration = Readonly; -export type PmoveQuake2Configuration = Readonly; +export type PmoveConfiguration = Readonly; +export type PmoveQuake2Configuration = Readonly; export type SerializableType = string | number | boolean | Vector | ServerEdict | SerializableType[] | null; @@ -22,7 +26,7 @@ export type ClientdataMap = Record; export type EdictValueType = string | number | boolean | Vector | null; export type EdictData = Record; -export type SFX = Readonly; +export type SFX = Readonly; export type ViewmodelConfig = { visible: boolean; From 61b68a5684e255ac4ecfe2c8956cff61ab8432b4 Mon Sep 17 00:00:00 2001 From: Christian R Date: Fri, 3 Apr 2026 01:26:43 +0300 Subject: [PATCH 28/67] TS: common/model/loaders/* and more --- .../typescript-port.instructions.md | 72 +++ source/engine/client/R.mjs | 2 +- source/engine/common/GameAPIs.mjs | 2 +- source/engine/common/Mod.ts | 16 +- source/engine/common/model/AliasModel.ts | 2 + .../{AliasMDLLoader.mjs => AliasMDLLoader.ts} | 361 +++++++------ .../common/model/loaders/BSP29Loader.mjs | 1 - .../common/model/loaders/BSP29Loader.ts | 4 +- .../common/model/loaders/BSP2Loader.mjs | 348 ------------- .../engine/common/model/loaders/BSP2Loader.ts | 310 +++++++++++ .../common/model/loaders/BSP38Loader.mjs | 331 ------------ .../common/model/loaders/BSP38Loader.ts | 297 +++++++++++ .../common/model/loaders/SpriteSPRLoader.mjs | 151 ------ .../common/model/loaders/SpriteSPRLoader.ts | 138 +++++ .../model/loaders/WavefrontOBJLoader.mjs | 487 ------------------ .../model/loaders/WavefrontOBJLoader.ts | 469 +++++++++++++++++ .../parsers/{ParsedQC.mjs => ParsedQC.ts} | 38 +- test/common/alias-mdl-loader.test.mjs | 8 +- 18 files changed, 1517 insertions(+), 1520 deletions(-) rename source/engine/common/model/loaders/{AliasMDLLoader.mjs => AliasMDLLoader.ts} (64%) delete mode 100644 source/engine/common/model/loaders/BSP29Loader.mjs delete mode 100644 source/engine/common/model/loaders/BSP2Loader.mjs create mode 100644 source/engine/common/model/loaders/BSP2Loader.ts delete mode 100644 source/engine/common/model/loaders/BSP38Loader.mjs create mode 100644 source/engine/common/model/loaders/BSP38Loader.ts delete mode 100644 source/engine/common/model/loaders/SpriteSPRLoader.mjs create mode 100644 source/engine/common/model/loaders/SpriteSPRLoader.ts delete mode 100644 source/engine/common/model/loaders/WavefrontOBJLoader.mjs create mode 100644 source/engine/common/model/loaders/WavefrontOBJLoader.ts rename source/engine/common/model/parsers/{ParsedQC.mjs => ParsedQC.ts} (67%) diff --git a/.github/instructions/typescript-port.instructions.md b/.github/instructions/typescript-port.instructions.md index c28bd16d..cc107a4a 100644 --- a/.github/instructions/typescript-port.instructions.md +++ b/.github/instructions/typescript-port.instructions.md @@ -3,6 +3,8 @@ When porting `.mjs` files to `.ts` (or polishing an earlier verbatim JS→TS port), apply every applicable rule below. The goal is idiomatic, type-safe TypeScript that relies on the compiler rather than JSDoc for type information. +Files have to end with an empty line. + ### Interfaces over Type Aliases - **Prefer `interface`** for object shapes. Only use `type` for unions, intersections, tuples, or mapped types. @@ -204,6 +206,9 @@ Use this checklist when polishing a ported `.ts` file: 11. [ ] No empty JSDoc blocks — every block has a description ending with a period. 12. [ ] ESLint clean (`npx eslint `). 13. [ ] All tests pass (`npm run test`). +14. [ ] All original comments preserved, especially TODOs and complex logic explanations. +15. [ ] File ends with an empty line. +16. [ ] If there is some important logic that is not covered by tests yet, add tests for it. ### Avoid inline import type annotations @@ -230,3 +235,70 @@ const COM = comModule.COM as typeof import('../common/COM.ts'); ``` But this is a special case and should not be used as a general pattern for type imports! + +### Porting over comments + +Make sure to **always** carry over comments from the original `.mjs` file, especially those that explain complex logic or important context. + +However, **do not carry over comments that only describe types** (e.g., "Bounding radius for culling") since the TS types should be self-explanatory. Instead, add a JSDoc comment with a description if needed. + +For example, consider this original code snippet with helpful comments: + +```javascript +… + + // Calculate bounding box + this._calculateBounds(loadmodel); + + // Generate tangents and bitangents for normal mapping + if (loadmodel.normals && loadmodel.texcoords) { + this._generateTangentSpace(loadmodel); + } + + // Set texture name (convention: same as model name without .obj) + const baseName = name.replace(/\.obj$/i, '.png').replace(/^models\//i, 'textures/'); + loadmodel.textureName = baseName; + +… +``` + +❌ Omitting comments: + +```typescript + + this.#calculateBounds(loadmodel); + + if (loadmodel.normals !== null && loadmodel.texcoords !== null) { + this.#generateTangentSpace(loadmodel); + } + + const baseName = name.replace(/\.obj$/i, '.png').replace(/^models\//i, 'textures/'); + loadmodel.textureName = baseName; + +``` + +✅ With comments preserved and updated: + +```typescript + + // Calculate bounding box for frustum culling + this.#calculateBounds(loadmodel); + + // Generate tangents/bitangents needed for normal mapping + if (loadmodel.normals !== null && loadmodel.texcoords !== null) { + this.#generateTangentSpace(loadmodel); + } + + // Derive texture name from model name (e.g. models/foo.obj → textures/foo.png) + const baseName = name.replace(/\.obj$/i, '.png').replace(/^models\//i, 'textures/'); + loadmodel.textureName = baseName; + +``` + +**Never ever delete TODO or FIXME unless the underlying issue has been fully resolved.** If the comment is no longer relevant, update it instead of deleting. + +### Adding missing tests + +If you encounter important logic that is not covered by tests, add new tests to cover it. This is especially critical for complex algorithms, edge cases, or any code that has caused bugs in the past. + +If code looks risky or has had bugs before, but there are no tests for it, that's a strong signal that tests should be added. Don't skip this step just to get the TS port done faster — the goal is not just to convert to TypeScript, but to improve code quality and maintainability overall. diff --git a/source/engine/client/R.mjs b/source/engine/client/R.mjs index 599913c8..4acf96a9 100644 --- a/source/engine/client/R.mjs +++ b/source/engine/client/R.mjs @@ -21,7 +21,7 @@ import BloomEffect from './renderer/BloomEffect.mjs'; import WarpEffect from './renderer/WarpEffect.mjs'; import ShadowMap from './renderer/ShadowMap.mjs'; import { ClientDlight, ClientEdict } from './ClientEntities.mjs'; -import { avertexnormals } from '../common/model/loaders/AliasMDLLoader.mjs'; +import { avertexnormals } from '../common/model/loaders/AliasMDLLoader.ts'; import { SkyRenderer } from './renderer/Sky.mjs'; let { CL, Host, Mod, SCR, SV, Sys, V } = registry; diff --git a/source/engine/common/GameAPIs.mjs b/source/engine/common/GameAPIs.mjs index 179a85c9..9b6968c3 100644 --- a/source/engine/common/GameAPIs.mjs +++ b/source/engine/common/GameAPIs.mjs @@ -18,7 +18,7 @@ import W from './W.ts'; /** @typedef {import('../client/GL.mjs').GLTexture} GLTexture */ /** @typedef {import('../network/MSG.ts').SzBuffer} SzBuffer */ /** @typedef {import('../server/Navigation.mjs').Navigation} Navigation */ -/** @typedef {import('./model/parsers/ParsedQC.mjs').default} ParsedQC */ +/** @typedef {import('./model/parsers/ParsedQC.ts').default} ParsedQC */ /** @typedef {import('./model/BaseModel.ts').BaseModel} BaseModel */ /** @typedef {import('../server/physics/ServerCollisionSupport.mjs').CollisionTrace} CollisionTrace */ /** diff --git a/source/engine/common/Mod.ts b/source/engine/common/Mod.ts index d151beb0..e7785dfe 100644 --- a/source/engine/common/Mod.ts +++ b/source/engine/common/Mod.ts @@ -1,13 +1,13 @@ import { eventBus, getClientRegistry, getCommonRegistry, registry } from '../registry.mjs'; import { MissingResourceError } from './Errors.ts'; import { ModelLoaderRegistry } from './model/ModelLoaderRegistry.ts'; -import { AliasMDLLoader } from './model/loaders/AliasMDLLoader.mjs'; -import { SpriteSPRLoader } from './model/loaders/SpriteSPRLoader.mjs'; +import { AliasMDLLoader } from './model/loaders/AliasMDLLoader.ts'; +import { SpriteSPRLoader } from './model/loaders/SpriteSPRLoader.ts'; import { BSP29Loader } from './model/loaders/BSP29Loader.ts'; -import { BSP2Loader } from './model/loaders/BSP2Loader.mjs'; -import { WavefrontOBJLoader } from './model/loaders/WavefrontOBJLoader.mjs'; -import ParsedQC from './model/parsers/ParsedQC.mjs'; -import { BSP38Loader } from './model/loaders/BSP38Loader.mjs'; +import { BSP2Loader } from './model/loaders/BSP2Loader.ts'; +import { WavefrontOBJLoader } from './model/loaders/WavefrontOBJLoader.ts'; +import ParsedQC from './model/parsers/ParsedQC.ts'; +import { BSP38Loader } from './model/loaders/BSP38Loader.ts'; import type { BaseModel } from './model/BaseModel.ts'; let { COM } = getCommonRegistry(); @@ -41,6 +41,7 @@ export enum ModelHull { type ModelCache = Record; // Re-export model classes for backward compatibility. +// TODO: remove these! export { AliasModel } from './model/AliasModel.ts'; export { BrushModel } from './model/BSP.ts'; export { SpriteModel } from './model/SpriteModel.ts'; @@ -50,8 +51,11 @@ export { MeshModel } from './model/MeshModel.ts'; * Shared model cache and loading entry point. */ export default class Mod { + /** @deprecated use ModelType instead */ static type = ModelType; + /** @deprecated use ModelScope instead */ static scope = ModelScope; + /** @deprecated use ModelHull instead */ static hull = ModelHull; static known: ModelCache = {}; static clientKnown: ModelCache = {}; diff --git a/source/engine/common/model/AliasModel.ts b/source/engine/common/model/AliasModel.ts index 01412b5b..6144c7ab 100644 --- a/source/engine/common/model/AliasModel.ts +++ b/source/engine/common/model/AliasModel.ts @@ -25,6 +25,7 @@ interface AliasSingleFrame { readonly bboxmax: Vector; readonly name: string; readonly v: AliasPoseVertex[]; + readonly cmdofs?: number; } interface AliasGroupedFrameEntry { @@ -33,6 +34,7 @@ interface AliasGroupedFrameEntry { readonly bboxmax: Vector; readonly name: string; readonly v: AliasPoseVertex[]; + readonly cmdofs?: number; } interface AliasGroupedFrame { diff --git a/source/engine/common/model/loaders/AliasMDLLoader.mjs b/source/engine/common/model/loaders/AliasMDLLoader.ts similarity index 64% rename from source/engine/common/model/loaders/AliasMDLLoader.mjs rename to source/engine/common/model/loaders/AliasMDLLoader.ts index b37dcc5e..03255da6 100644 --- a/source/engine/common/model/loaders/AliasMDLLoader.mjs +++ b/source/engine/common/model/loaders/AliasMDLLoader.ts @@ -1,31 +1,85 @@ import Vector from '../../../../shared/Vector.ts'; import Q from '../../../../shared/Q.ts'; import GL, { GLTexture, resampleTexture8 } from '../../../client/GL.mjs'; -import W, { translateIndexToLuminanceRGBA, translateIndexToRGBA } from '../../W.ts'; -import { CRC16CCITT } from '../../CRC.ts'; import { registry } from '../../../registry.mjs'; +import { CRC16CCITT } from '../../CRC.ts'; +import W, { translateIndexToLuminanceRGBA, translateIndexToRGBA } from '../../W.ts'; +import { AliasModel, type AliasFrame, type AliasSkin } from '../AliasModel.ts'; import { ModelLoader } from '../ModelLoader.ts'; -import { AliasModel } from '../AliasModel.ts'; + +interface AliasSkinLayers { + readonly diffuse: Uint8Array; + readonly luminance: Uint8Array; +} + + +interface MutableAliasSingleSkin { + group: false; + texturenum: GLTexture | null; + luminanceTexture: GLTexture | null; + translated?: Uint8Array; + playertexture?: GLTexture | null; +} + +interface MutableAliasGroupedSkinEntry { + interval: number; + texturenum?: GLTexture | null; + luminanceTexture?: GLTexture | null; + translated?: Uint8Array; + playertexture?: GLTexture | null; +} + +interface MutableAliasGroupedSkin { + group: true; + skins: MutableAliasGroupedSkinEntry[]; +} + +interface MutableAliasPoseVertex { + v: Vector; + lightnormalindex: number; +} + +interface MutableAliasSingleFrame { + group: false; + bboxmin: Vector; + bboxmax: Vector; + name: string; + v: MutableAliasPoseVertex[]; + cmdofs?: number; +} + +interface MutableAliasGroupedFrameEntry { + interval: number; + bboxmin: Vector; + bboxmax: Vector; + name: string; + v: MutableAliasPoseVertex[]; + cmdofs?: number; +} + +interface MutableAliasGroupedFrame { + group: true; + bboxmin: Vector; + bboxmax: Vector; + frames: MutableAliasGroupedFrameEntry[]; +} + +type MutableAliasFrame = MutableAliasSingleFrame | MutableAliasGroupedFrame; +type MutableAliasSkin = MutableAliasSingleSkin | MutableAliasGroupedSkin; /** * Builds the diffuse and luminance skin layers for a legacy alias model skin. * Fullbright indexed colors stay emissive-only in the luminance layer. - * @param {Uint8Array} skin indexed Quake skin pixels - * @param {number} width skin width - * @param {number} height skin height - * @param {Uint8Array} [palette] RGB palette data - * @param {?number} [transparentColor] optional transparent palette index - * @param {number} [fullbrightColorStart] first fullbright palette index - * @returns {{diffuse: Uint8Array, luminance: Uint8Array}} translated texture layers + * @returns The translated diffuse and luminance texture layers. */ export function buildAliasSkinLayers( - skin, - width, - height, - palette = W.d_8to24table_u8, - transparentColor = null, + skin: Uint8Array, + width: number, + height: number, + palette: Uint8Array = W.d_8to24table_u8, + transparentColor: number | null = null, fullbrightColorStart = 240, -) { +): AliasSkinLayers { return { diffuse: translateIndexToRGBA(skin, width, height, palette, transparentColor, fullbrightColorStart), luminance: translateIndexToLuminanceRGBA(skin, width, height, palette, transparentColor, fullbrightColorStart), @@ -213,43 +267,28 @@ export const avertexnormals = new Float32Array([ ]); /** - * Loader for Quake Alias Model format (.mdl) + * Loader for Quake Alias Model format (.mdl). * Magic: 0x4f504449 ("IDPO") * Version: 6 */ export class AliasMDLLoader extends ModelLoader { - /** - * Get magic numbers that identify this format - * @returns {number[]} Array of magic numbers - */ - getMagicNumbers() { + override getMagicNumbers(): number[] { return [0x4f504449]; // "IDPO" } - /** - * Get file extensions for this format - * @returns {string[]} Array of file extensions - */ - getExtensions() { + override getExtensions(): string[] { return ['.mdl']; } - /** - * Get human-readable name of this loader - * @returns {string} Loader name - */ - getName() { + override getName(): string { return 'Quake Alias'; } /** - * Load an Alias MDL model from buffer - * @param {ArrayBuffer} buffer - The model file data - * @param {string} name - The model name/path - * @returns {Promise} The loaded model + * Load an Alias MDL model from buffer. + * @returns The loaded alias model. */ - // eslint-disable-next-line @typescript-eslint/require-await - async load(buffer, name) { + override load(buffer: ArrayBuffer, name: string): Promise { const loadmodel = new AliasModel(name); loadmodel.type = 2; // Mod.type.alias @@ -306,36 +345,32 @@ export class AliasMDLLoader extends ModelLoader { loadmodel.maxs = new Vector(16.0, 16.0, 16.0); // Load model data - let inmodel = this._loadAllSkins(loadmodel, buffer, 84); - inmodel = this._loadSTVerts(loadmodel, buffer, inmodel); - inmodel = this._loadTriangles(loadmodel, buffer, inmodel); - this._loadAllFrames(loadmodel, buffer, inmodel); + let inmodel = this.#loadAllSkins(loadmodel, buffer, 84); + inmodel = this.#loadSTVerts(loadmodel, buffer, inmodel); + inmodel = this.#loadTriangles(loadmodel, buffer, inmodel); + this.#loadAllFrames(loadmodel, buffer, inmodel); // Prepare rendering data (if not dedicated server) if (!registry.isDedicatedServer) { - this._buildRenderCommands(loadmodel); + this.#buildRenderCommands(loadmodel); } loadmodel.needload = false; loadmodel.checksum = CRC16CCITT.Block(new Uint8Array(buffer)); - return loadmodel; + return Promise.resolve(loadmodel); } /** - * Load ST (texture coordinate) vertices - * @param {import('../AliasModel.ts').AliasModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buffer - The model file data - * @param {number} inmodel - Current offset in buffer - * @returns {number} New offset after reading vertices - * @private + * Load ST (texture coordinate) vertices. + * @returns The next byte offset after the ST vertex block. */ - _loadSTVerts(loadmodel, buffer, inmodel) { + #loadSTVerts(loadmodel: AliasModel, buffer: ArrayBuffer, inmodel: number): number { const view = new DataView(buffer); loadmodel._stverts.length = loadmodel._num_verts; - for (let i = 0; i < loadmodel._num_verts; i++) { - loadmodel._stverts[i] = { + for (let index = 0; index < loadmodel._num_verts; index++) { + loadmodel._stverts[index] = { onseam: view.getUint32(inmodel, true) !== 0, s: view.getUint32(inmodel + 4, true), t: view.getUint32(inmodel + 8, true), @@ -347,19 +382,15 @@ export class AliasMDLLoader extends ModelLoader { } /** - * Load triangles - * @param {import('../AliasModel.ts').AliasModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buffer - The model file data - * @param {number} inmodel - Current offset in buffer - * @returns {number} New offset after reading triangles - * @private + * Load triangles. + * @returns The next byte offset after the triangle block. */ - _loadTriangles(loadmodel, buffer, inmodel) { + #loadTriangles(loadmodel: AliasModel, buffer: ArrayBuffer, inmodel: number): number { const view = new DataView(buffer); loadmodel._triangles.length = loadmodel._num_tris; - for (let i = 0; i < loadmodel._num_tris; i++) { - loadmodel._triangles[i] = { + for (let index = 0; index < loadmodel._num_tris; index++) { + loadmodel._triangles[index] = { facesfront: view.getUint32(inmodel, true) !== 0, vertindex: [ view.getUint32(inmodel + 4, true), @@ -374,12 +405,9 @@ export class AliasMDLLoader extends ModelLoader { } /** - * Flood fill skin to handle transparent areas - * @param {import('../AliasModel.ts').AliasModel} loadmodel - The model being loaded - * @param {Uint8Array} skin - The skin pixel data - * @private + * Flood fill skin to handle transparent areas. */ - _floodFillSkin(loadmodel, skin) { + #floodFillSkin(loadmodel: AliasModel, skin: Uint8Array): void { const fillcolor = skin[0]; const filledcolor = W.filledColor; @@ -389,136 +417,126 @@ export class AliasMDLLoader extends ModelLoader { const width = loadmodel._skin_width; const height = loadmodel._skin_height; + const lifo: Array<[number, number]> = [[0, 0]]; - const lifo = [[0, 0]]; - - for (let sp = 1; sp > 0;) { - const cur = lifo[--sp]; - const x = cur[0]; - const y = cur[1]; + for (let stackPointer = 1; stackPointer > 0;) { + const [x, y] = lifo[--stackPointer]; skin[y * width + x] = filledcolor; if (x > 0 && skin[y * width + x - 1] === fillcolor) { - lifo[sp++] = [x - 1, y]; + lifo[stackPointer++] = [x - 1, y]; } if (x < (width - 1) && skin[y * width + x + 1] === fillcolor) { - lifo[sp++] = [x + 1, y]; + lifo[stackPointer++] = [x + 1, y]; } if (y > 0 && skin[(y - 1) * width + x] === fillcolor) { - lifo[sp++] = [x, y - 1]; + lifo[stackPointer++] = [x, y - 1]; } if (y < (height - 1) && skin[(y + 1) * width + x] === fillcolor) { - lifo[sp++] = [x, y + 1]; + lifo[stackPointer++] = [x, y + 1]; } } } /** - * Translate player skin for color customization - * @param {import('../AliasModel.ts').AliasModel} loadmodel - The model being loaded - * @param {Uint8Array} data - The original skin data - * @param {*} skin - The skin object to store the result - * @private + * Translate player skin for color customization. */ - _translatePlayerSkin(loadmodel, data, skin) { + #translatePlayerSkin(loadmodel: AliasModel, data: Uint8Array, skin: MutableAliasSkin): void { if (registry.isDedicatedServer) { return; } - if ((loadmodel._skin_width !== 512) || (loadmodel._skin_height !== 256)) { + if (loadmodel._skin_width !== 512 || loadmodel._skin_height !== 256) { data = resampleTexture8(data, loadmodel._skin_width, loadmodel._skin_height, 512, 256); } - const out = new Uint8Array(new ArrayBuffer(524288)); + const out = new Uint8Array(524288); - for (let i = 0; i < 131072; i++) { - const original = data[i]; + for (let index = 0; index < 131072; index++) { + const original = data[index]; if ((original >> 4) === 1) { - out[i << 2] = (original & 15) * 17; - out[(i << 2) + 1] = 255; + out[index << 2] = (original & 15) * 17; + out[(index << 2) + 1] = 255; } else if ((original >> 4) === 6) { - out[(i << 2) + 2] = (original & 15) * 17; - out[(i << 2) + 3] = 255; + out[(index << 2) + 2] = (original & 15) * 17; + out[(index << 2) + 3] = 255; } } - skin.playertexture = GLTexture.Allocate(loadmodel.name + '_playerskin', 512, 256, out); + skin.playertexture = GLTexture.Allocate(`${loadmodel.name}_playerskin`, 512, 256, out); } /** - * Load all skins (textures) for the model - * @param {import('../AliasModel.ts').AliasModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buffer - The model file data - * @param {number} inmodel - Current offset in buffer - * @returns {number} New offset after reading skins - * @private + * Load all skins (textures) for the model. + * @returns The next byte offset after the skin data. */ - _loadAllSkins(loadmodel, buffer, inmodel) { + #loadAllSkins(loadmodel: AliasModel, buffer: ArrayBuffer, inmodel: number): number { loadmodel.skins.length = loadmodel._num_skins; const view = new DataView(buffer); const skinsize = loadmodel._skin_width * loadmodel._skin_height; - for (let i = 0; i < loadmodel._num_skins; i++) { + for (let skinIndex = 0; skinIndex < loadmodel._num_skins; skinIndex++) { inmodel += 4; if (view.getUint32(inmodel - 4, true) === 0) { // Single skin const skin = new Uint8Array(buffer, inmodel, skinsize); - this._floodFillSkin(loadmodel, skin); + this.#floodFillSkin(loadmodel, skin); const { diffuse, luminance } = buildAliasSkinLayers(skin, loadmodel._skin_width, loadmodel._skin_height); - - loadmodel.skins[i] = { + const singleSkin: MutableAliasSingleSkin = { group: false, texturenum: !registry.isDedicatedServer - ? GLTexture.Allocate(loadmodel.name + '_' + i, loadmodel._skin_width, loadmodel._skin_height, diffuse) + ? GLTexture.Allocate(`${loadmodel.name}_${skinIndex}`, loadmodel._skin_width, loadmodel._skin_height, diffuse) : null, luminanceTexture: !registry.isDedicatedServer - ? GLTexture.Allocate(loadmodel.name + '_' + i + '_luma', loadmodel._skin_width, loadmodel._skin_height, luminance) + ? GLTexture.Allocate(`${loadmodel.name}_${skinIndex}_luma`, loadmodel._skin_width, loadmodel._skin_height, luminance) : null, }; + loadmodel.skins[skinIndex] = singleSkin as AliasSkin; + if (loadmodel.player === true) { - this._translatePlayerSkin(loadmodel, new Uint8Array(buffer, inmodel, skinsize), loadmodel.skins[i]); + this.#translatePlayerSkin(loadmodel, new Uint8Array(buffer, inmodel, skinsize), singleSkin); } inmodel += skinsize; } else { // Skin group (animated skins) - const group = { + const group: MutableAliasGroupedSkin = { group: true, skins: [], }; const numskins = view.getUint32(inmodel, true); inmodel += 4; - for (let j = 0; j < numskins; j++) { - group.skins[j] = { interval: view.getFloat32(inmodel, true) }; - if (group.skins[j].interval <= 0.0) { + for (let groupIndex = 0; groupIndex < numskins; groupIndex++) { + group.skins[groupIndex] = { interval: view.getFloat32(inmodel, true) }; + if (group.skins[groupIndex].interval <= 0.0) { throw new Error('AliasMDLLoader: skin interval <= 0'); } inmodel += 4; } - for (let j = 0; j < numskins; j++) { + for (let groupIndex = 0; groupIndex < numskins; groupIndex++) { const skin = new Uint8Array(buffer, inmodel, skinsize); - this._floodFillSkin(loadmodel, skin); + this.#floodFillSkin(loadmodel, skin); const { diffuse, luminance } = buildAliasSkinLayers(skin, loadmodel._skin_width, loadmodel._skin_height); - group.skins[j].texturenum = !registry.isDedicatedServer - ? GLTexture.Allocate(loadmodel.name + '_' + i + '_' + j, loadmodel._skin_width, loadmodel._skin_height, diffuse) + group.skins[groupIndex].texturenum = !registry.isDedicatedServer + ? GLTexture.Allocate(`${loadmodel.name}_${skinIndex}_${groupIndex}`, loadmodel._skin_width, loadmodel._skin_height, diffuse) : null; - group.skins[j].luminanceTexture = !registry.isDedicatedServer - ? GLTexture.Allocate(loadmodel.name + '_' + i + '_' + j + '_luma', loadmodel._skin_width, loadmodel._skin_height, luminance) + group.skins[groupIndex].luminanceTexture = !registry.isDedicatedServer + ? GLTexture.Allocate(`${loadmodel.name}_${skinIndex}_${groupIndex}_luma`, loadmodel._skin_width, loadmodel._skin_height, luminance) : null; if (loadmodel.player === true) { - this._translatePlayerSkin(loadmodel, new Uint8Array(buffer, inmodel, skinsize), group.skins[j]); + this.#translatePlayerSkin(loadmodel, new Uint8Array(buffer, inmodel, skinsize), group.skins[groupIndex]); } inmodel += skinsize; } - loadmodel.skins[i] = group; + loadmodel.skins[skinIndex] = group as AliasSkin; } } @@ -526,22 +544,18 @@ export class AliasMDLLoader extends ModelLoader { } /** - * Load all animation frames - * @param {import('../AliasModel.ts').AliasModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buffer - The model file data - * @param {number} inmodel - Current offset in buffer - * @private + * Load all animation frames. */ - _loadAllFrames(loadmodel, buffer, inmodel) { + #loadAllFrames(loadmodel: AliasModel, buffer: ArrayBuffer, inmodel: number): void { loadmodel.frames = []; const view = new DataView(buffer); - for (let i = 0; i < loadmodel._frames; i++) { + for (let frameIndex = 0; frameIndex < loadmodel._frames; frameIndex++) { inmodel += 4; if (view.getUint32(inmodel - 4, true) === 0) { // Single frame - const frame = { + const frame: MutableAliasSingleFrame = { group: false, bboxmin: new Vector(view.getUint8(inmodel), view.getUint8(inmodel + 1), view.getUint8(inmodel + 2)), bboxmax: new Vector(view.getUint8(inmodel + 4), view.getUint8(inmodel + 5), view.getUint8(inmodel + 6)), @@ -550,18 +564,18 @@ export class AliasMDLLoader extends ModelLoader { }; inmodel += 24; - for (let j = 0; j < loadmodel._num_verts; j++) { - frame.v[j] = { + for (let vertexIndex = 0; vertexIndex < loadmodel._num_verts; vertexIndex++) { + frame.v[vertexIndex] = { v: new Vector(view.getUint8(inmodel), view.getUint8(inmodel + 1), view.getUint8(inmodel + 2)), lightnormalindex: view.getUint8(inmodel + 3), }; inmodel += 4; } - loadmodel.frames[i] = frame; + loadmodel.frames[frameIndex] = frame as AliasFrame; } else { // Frame group (animated frames) - const group = { + const group: MutableAliasGroupedFrame = { group: true, bboxmin: new Vector(view.getUint8(inmodel + 4), view.getUint8(inmodel + 5), view.getUint8(inmodel + 6)), bboxmax: new Vector(view.getUint8(inmodel + 8), view.getUint8(inmodel + 9), view.getUint8(inmodel + 10)), @@ -570,24 +584,30 @@ export class AliasMDLLoader extends ModelLoader { const numframes = view.getUint32(inmodel, true); inmodel += 12; - for (let j = 0; j < numframes; j++) { - group.frames[j] = { interval: view.getFloat32(inmodel, true) }; - if (group.frames[j].interval <= 0.0) { + for (let groupIndex = 0; groupIndex < numframes; groupIndex++) { + group.frames[groupIndex] = { + interval: view.getFloat32(inmodel, true), + bboxmin: new Vector(), + bboxmax: new Vector(), + name: '', + v: [], + }; + if (group.frames[groupIndex].interval <= 0.0) { throw new Error('AliasMDLLoader: frame interval <= 0'); } inmodel += 4; } - for (let j = 0; j < numframes; j++) { - const frame = group.frames[j]; + for (let groupIndex = 0; groupIndex < numframes; groupIndex++) { + const frame = group.frames[groupIndex]; frame.bboxmin = new Vector(view.getUint8(inmodel), view.getUint8(inmodel + 1), view.getUint8(inmodel + 2)); frame.bboxmax = new Vector(view.getUint8(inmodel + 4), view.getUint8(inmodel + 5), view.getUint8(inmodel + 6)); frame.name = Q.memstr(new Uint8Array(buffer, inmodel + 8, 16)); frame.v = []; inmodel += 24; - for (let k = 0; k < loadmodel._num_verts; k++) { - frame.v[k] = { + for (let vertexIndex = 0; vertexIndex < loadmodel._num_verts; vertexIndex++) { + frame.v[vertexIndex] = { v: new Vector(view.getUint8(inmodel), view.getUint8(inmodel + 1), view.getUint8(inmodel + 2)), lightnormalindex: view.getUint8(inmodel + 3), }; @@ -595,24 +615,29 @@ export class AliasMDLLoader extends ModelLoader { } } - loadmodel.frames[i] = group; + loadmodel.frames[frameIndex] = group as AliasFrame; } } } /** - * Build rendering commands (WebGL buffers) for efficient rendering - * @param {import('../AliasModel.ts').AliasModel} loadmodel - The model being loaded - * @private + * Build rendering commands (WebGL buffers) for efficient rendering. */ - _buildRenderCommands(loadmodel) { + #buildRenderCommands(loadmodel: AliasModel): void { const gl = GL.gl; - const cmds = []; + const scale = loadmodel._scale; + const scaleOrigin = loadmodel._scale_origin; - // Build texture coordinates + console.assert(scale !== null && scaleOrigin !== null); + if (scale === null || scaleOrigin === null) { + return; + } - for (let i = 0; i < loadmodel._num_tris; i++) { - const triangle = loadmodel._triangles[i]; + const cmds: number[] = []; + + // Build texture coordinates + for (let triangleIndex = 0; triangleIndex < loadmodel._num_tris; triangleIndex++) { + const triangle = loadmodel._triangles[triangleIndex]; if (triangle.facesfront === true) { const vert0 = loadmodel._stverts[triangle.vertindex[0]]; @@ -629,8 +654,8 @@ export class AliasMDLLoader extends ModelLoader { continue; } - for (let j = 0; j < 3; j++) { - const vert = loadmodel._stverts[triangle.vertindex[j]]; + for (let vertexOffset = 0; vertexOffset < 3; vertexOffset++) { + const vert = loadmodel._stverts[triangle.vertindex[vertexOffset]]; if (vert.onseam === true) { cmds.push((vert.s + loadmodel._skin_width / 2 + 0.5) / loadmodel._skin_width); } else { @@ -641,24 +666,24 @@ export class AliasMDLLoader extends ModelLoader { } // Build vertex data for each frame - for (let i = 0; i < loadmodel.frames.length; i++) { - const group = loadmodel.frames[i]; + for (let frameIndex = 0; frameIndex < loadmodel.frames.length; frameIndex++) { + const group = loadmodel.frames[frameIndex] as MutableAliasFrame; if (group.group === true) { - for (let j = 0; j < group.frames.length; j++) { - const frame = group.frames[j]; + for (let groupIndex = 0; groupIndex < group.frames.length; groupIndex++) { + const frame = group.frames[groupIndex]; frame.cmdofs = cmds.length * 4; - for (let k = 0; k < loadmodel._num_tris; k++) { - const triangle = loadmodel._triangles[k]; + for (let triangleIndex = 0; triangleIndex < loadmodel._num_tris; triangleIndex++) { + const triangle = loadmodel._triangles[triangleIndex]; - for (let l = 0; l < 3; l++) { - const vert = frame.v[triangle.vertindex[l]]; + for (let vertexOffset = 0; vertexOffset < 3; vertexOffset++) { + const vert = frame.v[triangle.vertindex[vertexOffset]]; console.assert(vert.lightnormalindex < avertexnormals.length / 3); - cmds.push(vert.v[0] * loadmodel._scale[0] + loadmodel._scale_origin[0]); - cmds.push(vert.v[1] * loadmodel._scale[1] + loadmodel._scale_origin[1]); - cmds.push(vert.v[2] * loadmodel._scale[2] + loadmodel._scale_origin[2]); - cmds.push(avertexnormals[vert.lightnormalindex * 3 + 0]); + cmds.push(vert.v[0] * scale[0] + scaleOrigin[0]); + cmds.push(vert.v[1] * scale[1] + scaleOrigin[1]); + cmds.push(vert.v[2] * scale[2] + scaleOrigin[2]); + cmds.push(avertexnormals[vert.lightnormalindex * 3]); cmds.push(avertexnormals[vert.lightnormalindex * 3 + 1]); cmds.push(avertexnormals[vert.lightnormalindex * 3 + 2]); } @@ -670,16 +695,16 @@ export class AliasMDLLoader extends ModelLoader { const frame = group; frame.cmdofs = cmds.length * 4; - for (let j = 0; j < loadmodel._num_tris; j++) { - const triangle = loadmodel._triangles[j]; + for (let triangleIndex = 0; triangleIndex < loadmodel._num_tris; triangleIndex++) { + const triangle = loadmodel._triangles[triangleIndex]; - for (let k = 0; k < 3; k++) { - const vert = frame.v[triangle.vertindex[k]]; + for (let vertexOffset = 0; vertexOffset < 3; vertexOffset++) { + const vert = frame.v[triangle.vertindex[vertexOffset]]; console.assert(vert.lightnormalindex < avertexnormals.length / 3); - cmds.push(vert.v[0] * loadmodel._scale[0] + loadmodel._scale_origin[0]); - cmds.push(vert.v[1] * loadmodel._scale[1] + loadmodel._scale_origin[1]); - cmds.push(vert.v[2] * loadmodel._scale[2] + loadmodel._scale_origin[2]); - cmds.push(avertexnormals[vert.lightnormalindex * 3 + 0]); + cmds.push(vert.v[0] * scale[0] + scaleOrigin[0]); + cmds.push(vert.v[1] * scale[1] + scaleOrigin[1]); + cmds.push(vert.v[2] * scale[2] + scaleOrigin[2]); + cmds.push(avertexnormals[vert.lightnormalindex * 3]); cmds.push(avertexnormals[vert.lightnormalindex * 3 + 1]); cmds.push(avertexnormals[vert.lightnormalindex * 3 + 2]); } diff --git a/source/engine/common/model/loaders/BSP29Loader.mjs b/source/engine/common/model/loaders/BSP29Loader.mjs deleted file mode 100644 index d9fb6e2a..00000000 --- a/source/engine/common/model/loaders/BSP29Loader.mjs +++ /dev/null @@ -1 +0,0 @@ -export * from './BSP29Loader.ts'; diff --git a/source/engine/common/model/loaders/BSP29Loader.ts b/source/engine/common/model/loaders/BSP29Loader.ts index 569511eb..34b310c2 100644 --- a/source/engine/common/model/loaders/BSP29Loader.ts +++ b/source/engine/common/model/loaders/BSP29Loader.ts @@ -73,7 +73,7 @@ export class BSP29Loader extends ModelLoader { // Load all BSP lumps this.#loadEntities(loadmodel, buffer); this.#loadVertexes(loadmodel, buffer); - this.#loadEdges(loadmodel, buffer); + this._loadEdges(loadmodel, buffer); this.#loadSurfedges(loadmodel, buffer); this.#loadTextures(loadmodel, buffer); await this.#loadMaterials(loadmodel); @@ -1470,7 +1470,7 @@ export class BSP29Loader extends ModelLoader { /** * Load edges from BSP lump. */ - #loadEdges(loadmodel: BrushModel, buf: ArrayBuffer): void { + protected _loadEdges(loadmodel: BrushModel, buf: ArrayBuffer): void { const view = new DataView(buf); const lump = BSP29Loader.#lump; let fileofs = view.getUint32((lump.edges << 3) + 4, true); diff --git a/source/engine/common/model/loaders/BSP2Loader.mjs b/source/engine/common/model/loaders/BSP2Loader.mjs deleted file mode 100644 index ca543e10..00000000 --- a/source/engine/common/model/loaders/BSP2Loader.mjs +++ /dev/null @@ -1,348 +0,0 @@ -import Vector from '../../../../shared/Vector.ts'; -import { CorruptedResourceError } from '../../Errors.ts'; -import { BSP29Loader } from './BSP29Loader.ts'; -import { Face } from '../BaseModel.ts'; -import { BrushModel, Node } from '../BSP.ts'; -import { materialFlags } from '../../../client/renderer/Materials.mjs'; - -/** - * Loader for BSP2 format (.bsp) - * Magic: 'BSP2' (0x32425350 / 844124994 in little-endian) - * - * BSP2 is an extended version of BSP29 that uses 32-bit indices instead of 16-bit - * to support larger maps with more vertices, edges, faces, etc. - * - * Key differences from BSP29: - * - Faces: uint32 for planenum/numedges, int32 for side (28 bytes vs 20) - * - Nodes: float for mins/maxs, int32 for children, uint32 for faces (44 bytes vs 24) - * - Leafs: float for mins/maxs, uint32 for marksurfaces (44 bytes vs 28) - * - Marksurfaces: uint32 indices instead of uint16 - * - Surfedges: same as BSP29 (int32) - * - Clipnodes: int32 for child indices instead of int16 - * @augments BSP29Loader - */ -// @ts-ignore - Method override is intentional for BSP2 format differences -export class BSP2Loader extends BSP29Loader { - /** - * Get magic numbers that identify this format - * @returns {number[]} Array of magic numbers - */ - getMagicNumbers() { - // 'BSP2' in little-endian = 0x32425350 = 844124994 - return [844124994]; - } - - /** - * Get human-readable name of this loader - * @returns {string} Loader name - */ - getName() { - return 'BSP2'; - } - - /** - * Load faces from BSP lump (BSP2 version with 32-bit indices) - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - * @throws {CorruptedResourceError} If faces lump size is not a multiple of 28 - */ - _loadFaces(loadmodel, buf) { - const view = new DataView(buf); - // Use parent class lump enum (it's private, so we calculate offset same way) - const lumpIndex = 7; // faces lump - let fileofs = view.getUint32((lumpIndex << 3) + 4, true); - const filelen = view.getUint32((lumpIndex << 3) + 8, true); - - // BSP2 faces are 28 bytes - if ((filelen % 28) !== 0) { - throw new CorruptedResourceError(loadmodel.name, 'BSP2Loader: faces lump size is not a multiple of 28'); - } - - const lmshift = loadmodel.worldspawnInfo._lightmap_scale ? Math.log2(parseInt(loadmodel.worldspawnInfo._lightmap_scale)) : 4; - const count = filelen / 28; - loadmodel.firstface = 0; - loadmodel.numfaces = count; - loadmodel.faces.length = count; - - for (let i = 0; i < count; i++) { - const styles = new Uint8Array(buf, fileofs + 20, 4); - const out = Object.assign(new Face(), { - plane: loadmodel.planes[view.getUint32(fileofs, true)], // int planenum (offset 0) - planeBack: view.getInt32(fileofs + 4, true) !== 0, // int side (offset 4) - firstedge: view.getUint32(fileofs + 8, true), // int firstedge (offset 8) - numedges: view.getUint32(fileofs + 12, true), // int numedges (offset 12) - texinfo: view.getUint32(fileofs + 16, true), // int texinfo (offset 16) - lightofs: view.getInt32(fileofs + 24, true), // int lightofs (offset 24) - lmshift, - }); - - for (let j = 0; j < 4; j++) { - if (styles[j] !== 255) { - out.styles[j] = styles[j]; - } - } - - const mins = [Infinity, Infinity]; - const maxs = [-Infinity, -Infinity]; - const tex = loadmodel.texinfo[out.texinfo]; - out.texture = tex.texture; - - for (let j = 0; j < out.numedges; j++) { - const e = loadmodel.surfedges[out.firstedge + j]; - const v = e >= 0 - ? loadmodel.vertexes[loadmodel.edges[e][0]] - : loadmodel.vertexes[loadmodel.edges[-e][1]]; - - const val0 = v.dot(new Vector(...tex.vecs[0])) + tex.vecs[0][3]; - const val1 = v.dot(new Vector(...tex.vecs[1])) + tex.vecs[1][3]; - - if (val0 < mins[0]) { - mins[0] = val0; - } - - if (val0 > maxs[0]) { - maxs[0] = val0; - } - - if (val1 < mins[1]) { - mins[1] = val1; - } - - if (val1 > maxs[1]) { - maxs[1] = val1; - } - } - - const lmscale = 1 << out.lmshift; - out.texturemins = [Math.floor(mins[0] / lmscale) * lmscale, Math.floor(mins[1] / lmscale) * lmscale]; - out.extents = [Math.ceil(maxs[0] / lmscale) * lmscale - out.texturemins[0], Math.ceil(maxs[1] / lmscale) * lmscale - out.texturemins[1]]; - - if (loadmodel.textures[tex.texture].flags & materialFlags.MF_TURBULENT) { - out.turbulent = true; - } else if (loadmodel.textures[tex.texture].flags & materialFlags.MF_SKY) { - out.sky = true; - } - - out.normal.set(out.plane.normal); - if (out.planeBack) { - out.normal.multiply(-1.0); - } - - loadmodel.faces[i] = out; - fileofs += 28; - } - loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); - } - - /** - * Load BSP tree nodes from BSP lump (BSP2 version with 32-bit children indices) - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - * @throws {CorruptedResourceError} If nodes lump is empty or incorrectly sized - */ - _loadNodes(loadmodel, buf) { - const view = new DataView(buf); - const lumpIndex = 5; // nodes lump - let fileofs = view.getUint32((lumpIndex << 3) + 4, true); - const filelen = view.getUint32((lumpIndex << 3) + 8, true); - - // BSP2 nodes are 44 bytes (vs 24 in BSP29): - // uint32 planenum, int32 children[2], float mins[3], float maxs[3], - // uint32 firstface, uint32 numfaces - if ((filelen === 0) || ((filelen % 44) !== 0)) { - throw new Error('BSP2Loader: nodes lump size is invalid in ' + loadmodel.name); - } - const count = filelen / 44; - loadmodel.nodes.length = count; - - for (let i = 0; i < count; i++) { - loadmodel.nodes[i] = Object.assign(new Node(loadmodel), { - num: i, - planenum: view.getUint32(fileofs, true), - children: [view.getInt32(fileofs + 4, true), view.getInt32(fileofs + 8, true)], // int32 instead of int16 - mins: new Vector(view.getFloat32(fileofs + 12, true), view.getFloat32(fileofs + 16, true), view.getFloat32(fileofs + 20, true)), // float instead of int16 - maxs: new Vector(view.getFloat32(fileofs + 24, true), view.getFloat32(fileofs + 28, true), view.getFloat32(fileofs + 32, true)), // float instead of int16 - firstface: view.getUint32(fileofs + 36, true), // uint32 instead of uint16 - numfaces: view.getUint32(fileofs + 40, true), // uint32 instead of uint16 - }); - loadmodel.nodes[i].baseMins = loadmodel.nodes[i].mins.copy(); - loadmodel.nodes[i].baseMaxs = loadmodel.nodes[i].maxs.copy(); - fileofs += 44; - } - - for (let i = 0; i < count; i++) { - const out = loadmodel.nodes[i]; - out.plane = loadmodel.planes[out.planenum]; - // At this point children contain indices, we convert them to Node references - const child0Idx = /** @type {number} */ (out.children[0]); - const child1Idx = /** @type {number} */ (out.children[1]); - out.children[0] = child0Idx >= 0 - ? loadmodel.nodes[child0Idx] - : loadmodel.leafs[-1 - child0Idx]; - out.children[1] = child1Idx >= 0 - ? loadmodel.nodes[child1Idx] - : loadmodel.leafs[-1 - child1Idx]; - } - - // Set parent references recursively - const setParent = (node, parent) => { - node.parent = parent; - // Stop recursion at leaf nodes (contents < 0 or null children) - if (node.contents < 0 || !node.children[0] || !node.children[1]) { - return; - } - setParent(/** @type {Node} */(node.children[0]), node); - setParent(/** @type {Node} */(node.children[1]), node); - }; - setParent(loadmodel.nodes[0], null); - loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); - } - - /** - * Load BSP leaf nodes from BSP lump (BSP2 version with 32-bit indices) - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - * @throws {Error} If leafs lump size is not a multiple of 44 - */ - _loadLeafs(loadmodel, buf) { - const view = new DataView(buf); - const lumpIndex = 10; // leafs lump - let fileofs = view.getUint32((lumpIndex << 3) + 4, true); - const filelen = view.getUint32((lumpIndex << 3) + 8, true); - - // BSP2 leafs are 44 bytes (vs 28 in BSP29): - // int32 contents, int32 visofs, float mins[3], float maxs[3], - // uint32 firstmarksurface, uint32 nummarksurfaces, uint8 ambient_level[4] - if ((filelen % 44) !== 0) { - throw new Error('BSP2Loader: leafs lump size is not a multiple of 44 in ' + loadmodel.name); - } - const count = filelen / 44; - loadmodel.leafs.length = count; - - for (let i = 0; i < count; i++) { - loadmodel.leafs[i] = Object.assign(new Node(loadmodel), { - num: i, - contents: view.getInt32(fileofs, true), - visofs: view.getInt32(fileofs + 4, true), - cluster: i > 0 ? i - 1 : -1, - mins: new Vector(view.getFloat32(fileofs + 8, true), view.getFloat32(fileofs + 12, true), view.getFloat32(fileofs + 16, true)), // float instead of int16 - maxs: new Vector(view.getFloat32(fileofs + 20, true), view.getFloat32(fileofs + 24, true), view.getFloat32(fileofs + 28, true)), // float instead of int16 - firstmarksurface: view.getUint32(fileofs + 32, true), // uint32 instead of uint16 - nummarksurfaces: view.getUint32(fileofs + 36, true), // uint32 instead of uint16 - ambient_level: [ - view.getUint8(fileofs + 40), - view.getUint8(fileofs + 41), - view.getUint8(fileofs + 42), - view.getUint8(fileofs + 43), - ], - }); - loadmodel.leafs[i].baseMins = loadmodel.leafs[i].mins.copy(); - loadmodel.leafs[i].baseMaxs = loadmodel.leafs[i].maxs.copy(); - fileofs += 44; - } - loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); - } - - /** - * Load marksurfaces from BSP lump (BSP2 version with 32-bit indices) - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - * @throws {CorruptedResourceError} If marksurfaces lump size is not a multiple of 4 - */ - _loadMarksurfaces(loadmodel, buf) { - const view = new DataView(buf); - const lumpIndex = 11; // marksurfaces lump - let fileofs = view.getUint32((lumpIndex << 3) + 4, true); - const filelen = view.getUint32((lumpIndex << 3) + 8, true); - - // BSP2 uses uint32 for marksurfaces (vs uint16 in BSP29) - if ((filelen & 3) !== 0) { - throw new CorruptedResourceError(loadmodel.name, 'BSP2Loader: marksurfaces lump size is not a multiple of 4'); - } - const count = filelen >> 2; - loadmodel.marksurfaces.length = count; - - for (let i = 0; i < count; i++) { - loadmodel.marksurfaces[i] = view.getUint32(fileofs, true); // uint32 instead of uint16 - fileofs += 4; - } - loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); - } - - /** - * Load collision clipnodes and initialize physics hulls (BSP2 version with 32-bit children) - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - */ - _loadClipnodes(loadmodel, buf) { - const view = new DataView(buf); - const lumpIndex = 9; // clipnodes lump - let fileofs = view.getUint32((lumpIndex << 3) + 4, true); - const filelen = view.getUint32((lumpIndex << 3) + 8, true); - - // BSP2 clipnodes are 12 bytes (vs 8 in BSP29): - // int32 planenum, int32 children[2] - if ((filelen % 12) !== 0) { - throw new Error('BSP2Loader: clipnodes lump size is not a multiple of 12 in ' + loadmodel.name); - } - const count = filelen / 12; - loadmodel.clipnodes.length = count; - - // Initialize hulls (same as BSP29) - loadmodel.hulls.length = 3; - loadmodel.hulls[1] = { - clipnodes: loadmodel.clipnodes, - firstclipnode: 0, - lastclipnode: count - 1, - planes: loadmodel.planes, - clip_mins: new Vector(-16.0, -16.0, -24.0), - clip_maxs: new Vector(16.0, 16.0, 32.0), - }; - loadmodel.hulls[2] = { - clipnodes: loadmodel.clipnodes, - firstclipnode: 0, - lastclipnode: count - 1, - planes: loadmodel.planes, - clip_mins: new Vector(-32.0, -32.0, -24.0), - clip_maxs: new Vector(32.0, 32.0, 64.0), - }; - - for (let i = 0; i < count; i++) { - loadmodel.clipnodes[i] = { - planenum: view.getInt32(fileofs, true), - children: [view.getInt32(fileofs + 4, true), view.getInt32(fileofs + 8, true)], // int32 instead of int16 - }; - fileofs += 12; - } - loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); - } - - /** - * Load edges from BSP lump - * @protected - * @param {BrushModel} loadmodel - The model being loaded - * @param {ArrayBuffer} buf - The BSP file buffer - * @throws {CorruptedResourceError} If edge lump size is not a multiple of 8 - */ - _loadEdges(loadmodel, buf) { - const view = new DataView(buf); - const lump = 12; // edges lump - let fileofs = view.getUint32((lump << 3) + 4, true); - const filelen = view.getUint32((lump << 3) + 8, true); - if ((filelen % 8) !== 0) { - throw new CorruptedResourceError(loadmodel.name, 'BSP2Loader: edges lump size is not a multiple of 8'); - } - const count = filelen >> 3; - loadmodel.edges.length = count; - for (let i = 0; i < count; i++) { - loadmodel.edges[i] = [view.getUint32(fileofs, true), view.getUint32(fileofs + 4, true)]; - fileofs += 8; - } - loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); - } -} diff --git a/source/engine/common/model/loaders/BSP2Loader.ts b/source/engine/common/model/loaders/BSP2Loader.ts new file mode 100644 index 00000000..ff2ad4d9 --- /dev/null +++ b/source/engine/common/model/loaders/BSP2Loader.ts @@ -0,0 +1,310 @@ +import Vector from '../../../../shared/Vector.ts'; +import { CorruptedResourceError } from '../../Errors.ts'; +import { Face } from '../BaseModel.ts'; +import { BrushModel, Node } from '../BSP.ts'; +import { materialFlags } from '../../../client/renderer/Materials.mjs'; +import { BSP29Loader } from './BSP29Loader.ts'; + +/** + * Loader for BSP2 format (.bsp). + * + * BSP2 is an extended version of BSP29 that uses 32-bit indices instead of + * 16-bit indices for larger maps. + */ +export class BSP2Loader extends BSP29Loader { + /** BSP2 lump indices used by the overridden readers below. */ + static readonly #lump = Object.freeze({ + faces: 7, + nodes: 5, + clipnodes: 9, + leafs: 10, + marksurfaces: 11, + edges: 12, + }); + + override getMagicNumbers(): number[] { + return [844124994]; + } + + override getName(): string { + return 'BSP2'; + } + + /** + * Load faces from the BSP2 faces lump. + */ + protected override _loadFaces(loadmodel: BrushModel, buf: ArrayBuffer): void { + const view = new DataView(buf); + const lump = BSP2Loader.#lump; + let fileofs = view.getUint32((lump.faces << 3) + 4, true); + const filelen = view.getUint32((lump.faces << 3) + 8, true); + + if ((filelen % 28) !== 0) { + throw new CorruptedResourceError(loadmodel.name, 'BSP2Loader: faces lump size is not a multiple of 28'); + } + + const lmshift = loadmodel.worldspawnInfo._lightmap_scale ? Math.log2(parseInt(loadmodel.worldspawnInfo._lightmap_scale, 10)) : 4; + const count = filelen / 28; + loadmodel.firstface = 0; + loadmodel.numfaces = count; + loadmodel.faces.length = count; + + for (let i = 0; i < count; i++) { + const styles = new Uint8Array(buf, fileofs + 20, 4); + const face = Object.assign(new Face(), { + plane: loadmodel.planes[view.getUint32(fileofs, true)], + planeBack: view.getInt32(fileofs + 4, true) !== 0, + firstedge: view.getUint32(fileofs + 8, true), + numedges: view.getUint32(fileofs + 12, true), + texinfo: view.getUint32(fileofs + 16, true), + lightofs: view.getInt32(fileofs + 24, true), + lmshift, + }); + + for (let j = 0; j < 4; j++) { + if (styles[j] !== 255) { + face.styles[j] = styles[j]; + } + } + + const mins = [Infinity, Infinity]; + const maxs = [-Infinity, -Infinity]; + const tex = loadmodel.texinfo[face.texinfo]; + face.texture = tex.texture; + + for (let j = 0; j < face.numedges; j++) { + const edgeIndex = loadmodel.surfedges[face.firstedge + j]; + const vertex = edgeIndex >= 0 + ? loadmodel.vertexes[loadmodel.edges[edgeIndex][0]] + : loadmodel.vertexes[loadmodel.edges[-edgeIndex][1]]; + + const val0 = vertex.dot(new Vector(...tex.vecs[0])) + tex.vecs[0][3]; + const val1 = vertex.dot(new Vector(...tex.vecs[1])) + tex.vecs[1][3]; + + if (val0 < mins[0]) { + mins[0] = val0; + } + if (val0 > maxs[0]) { + maxs[0] = val0; + } + if (val1 < mins[1]) { + mins[1] = val1; + } + if (val1 > maxs[1]) { + maxs[1] = val1; + } + } + + const lmscale = 1 << face.lmshift; + face.texturemins = [Math.floor(mins[0] / lmscale) * lmscale, Math.floor(mins[1] / lmscale) * lmscale]; + face.extents = [Math.ceil(maxs[0] / lmscale) * lmscale - face.texturemins[0], Math.ceil(maxs[1] / lmscale) * lmscale - face.texturemins[1]]; + + if ((loadmodel.textures[tex.texture].flags & materialFlags.MF_TURBULENT) !== 0) { + face.turbulent = true; + } else if ((loadmodel.textures[tex.texture].flags & materialFlags.MF_SKY) !== 0) { + face.sky = true; + } + + face.normal.set(face.plane.normal); + if (face.planeBack) { + face.normal.multiply(-1.0); + } + + loadmodel.faces[i] = face; + fileofs += 28; + } + + loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); + } + + /** + * Load BSP tree nodes from the BSP2 nodes lump. + */ + protected override _loadNodes(loadmodel: BrushModel, buf: ArrayBuffer): void { + const view = new DataView(buf); + const lump = BSP2Loader.#lump; + let fileofs = view.getUint32((lump.nodes << 3) + 4, true); + const filelen = view.getUint32((lump.nodes << 3) + 8, true); + + if ((filelen === 0) || ((filelen % 44) !== 0)) { + throw new Error(`BSP2Loader: nodes lump size is invalid in ${loadmodel.name}`); + } + + const count = filelen / 44; + loadmodel.nodes.length = count; + + for (let i = 0; i < count; i++) { + loadmodel.nodes[i] = Object.assign(new Node(loadmodel), { + num: i, + planenum: view.getUint32(fileofs, true), + children: [view.getInt32(fileofs + 4, true), view.getInt32(fileofs + 8, true)], + mins: new Vector(view.getFloat32(fileofs + 12, true), view.getFloat32(fileofs + 16, true), view.getFloat32(fileofs + 20, true)), + maxs: new Vector(view.getFloat32(fileofs + 24, true), view.getFloat32(fileofs + 28, true), view.getFloat32(fileofs + 32, true)), + firstface: view.getUint32(fileofs + 36, true), + numfaces: view.getUint32(fileofs + 40, true), + }); + loadmodel.nodes[i].baseMins = loadmodel.nodes[i].mins.copy(); + loadmodel.nodes[i].baseMaxs = loadmodel.nodes[i].maxs.copy(); + fileofs += 44; + } + + for (let i = 0; i < count; i++) { + const node = loadmodel.nodes[i]; + node.plane = loadmodel.planes[node.planenum]; + const child0Idx = node.children[0] as number; + const child1Idx = node.children[1] as number; + node.children[0] = child0Idx >= 0 ? loadmodel.nodes[child0Idx] : loadmodel.leafs[-1 - child0Idx]; + node.children[1] = child1Idx >= 0 ? loadmodel.nodes[child1Idx] : loadmodel.leafs[-1 - child1Idx]; + } + + /** + * Rebuild parent links after BSP2 child indices are resolved. + */ + function setParent(node: Node, parent: Node | null): void { + node.parent = parent; + + if (node.contents < 0 || !node.children[0] || !node.children[1]) { + return; + } + + setParent(node.children[0] as Node, node); + setParent(node.children[1] as Node, node); + } + + setParent(loadmodel.nodes[0], null); + loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); + } + + /** + * Load BSP leaf nodes from the BSP2 leafs lump. + */ + protected override _loadLeafs(loadmodel: BrushModel, buf: ArrayBuffer): void { + const view = new DataView(buf); + const lump = BSP2Loader.#lump; + let fileofs = view.getUint32((lump.leafs << 3) + 4, true); + const filelen = view.getUint32((lump.leafs << 3) + 8, true); + + if ((filelen % 44) !== 0) { + throw new Error(`BSP2Loader: leafs lump size is not a multiple of 44 in ${loadmodel.name}`); + } + + const count = filelen / 44; + loadmodel.leafs.length = count; + + for (let i = 0; i < count; i++) { + loadmodel.leafs[i] = Object.assign(new Node(loadmodel), { + num: i, + contents: view.getInt32(fileofs, true), + visofs: view.getInt32(fileofs + 4, true), + cluster: i > 0 ? i - 1 : -1, + mins: new Vector(view.getFloat32(fileofs + 8, true), view.getFloat32(fileofs + 12, true), view.getFloat32(fileofs + 16, true)), + maxs: new Vector(view.getFloat32(fileofs + 20, true), view.getFloat32(fileofs + 24, true), view.getFloat32(fileofs + 28, true)), + firstmarksurface: view.getUint32(fileofs + 32, true), + nummarksurfaces: view.getUint32(fileofs + 36, true), + ambient_level: [ + view.getUint8(fileofs + 40), + view.getUint8(fileofs + 41), + view.getUint8(fileofs + 42), + view.getUint8(fileofs + 43), + ], + }); + loadmodel.leafs[i].baseMins = loadmodel.leafs[i].mins.copy(); + loadmodel.leafs[i].baseMaxs = loadmodel.leafs[i].maxs.copy(); + fileofs += 44; + } + + loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); + } + + /** + * Load marksurfaces from the BSP2 marksurfaces lump. + */ + protected override _loadMarksurfaces(loadmodel: BrushModel, buf: ArrayBuffer): void { + const view = new DataView(buf); + const lump = BSP2Loader.#lump; + let fileofs = view.getUint32((lump.marksurfaces << 3) + 4, true); + const filelen = view.getUint32((lump.marksurfaces << 3) + 8, true); + + if ((filelen & 3) !== 0) { + throw new CorruptedResourceError(loadmodel.name, 'BSP2Loader: marksurfaces lump size is not a multiple of 4'); + } + + const count = filelen >> 2; + loadmodel.marksurfaces.length = count; + + for (let i = 0; i < count; i++) { + loadmodel.marksurfaces[i] = view.getUint32(fileofs, true); + fileofs += 4; + } + + loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); + } + + /** + * Load clipnodes from the BSP2 clipnodes lump. + */ + protected override _loadClipnodes(loadmodel: BrushModel, buf: ArrayBuffer): void { + const view = new DataView(buf); + const lump = BSP2Loader.#lump; + let fileofs = view.getUint32((lump.clipnodes << 3) + 4, true); + const filelen = view.getUint32((lump.clipnodes << 3) + 8, true); + + if ((filelen % 12) !== 0) { + throw new Error(`BSP2Loader: clipnodes lump size is not a multiple of 12 in ${loadmodel.name}`); + } + + const count = filelen / 12; + loadmodel.clipnodes.length = count; + loadmodel.hulls.length = 3; + loadmodel.hulls[1] = { + clipnodes: loadmodel.clipnodes, + firstclipnode: 0, + lastclipnode: count - 1, + planes: loadmodel.planes, + clip_mins: new Vector(-16.0, -16.0, -24.0), + clip_maxs: new Vector(16.0, 16.0, 32.0), + }; + loadmodel.hulls[2] = { + clipnodes: loadmodel.clipnodes, + firstclipnode: 0, + lastclipnode: count - 1, + planes: loadmodel.planes, + clip_mins: new Vector(-32.0, -32.0, -24.0), + clip_maxs: new Vector(32.0, 32.0, 64.0), + }; + + for (let i = 0; i < count; i++) { + loadmodel.clipnodes[i] = { + planenum: view.getInt32(fileofs, true), + children: [view.getInt32(fileofs + 4, true), view.getInt32(fileofs + 8, true)], + }; + fileofs += 12; + } + + loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); + } + + /** + * Load edges from the BSP2 edges lump. + */ + protected override _loadEdges(loadmodel: BrushModel, buf: ArrayBuffer): void { + const view = new DataView(buf); + const lump = BSP2Loader.#lump; + let fileofs = view.getUint32((lump.edges << 3) + 4, true); + const filelen = view.getUint32((lump.edges << 3) + 8, true); + + if ((filelen % 8) !== 0) { + throw new CorruptedResourceError(loadmodel.name, 'BSP2Loader: edges lump size is not a multiple of 8'); + } + + const count = filelen >> 3; + loadmodel.edges.length = count; + + for (let i = 0; i < count; i++) { + loadmodel.edges[i] = [view.getUint32(fileofs, true), view.getUint32(fileofs + 4, true)]; + fileofs += 8; + } + + loadmodel.bspxoffset = Math.max(loadmodel.bspxoffset, fileofs); + } +} diff --git a/source/engine/common/model/loaders/BSP38Loader.mjs b/source/engine/common/model/loaders/BSP38Loader.mjs deleted file mode 100644 index 30089cb0..00000000 --- a/source/engine/common/model/loaders/BSP38Loader.mjs +++ /dev/null @@ -1,331 +0,0 @@ -import { content } from '../../../../shared/Defs.ts'; -import Q from '../../../../shared/Q.ts'; -import Vector from '../../../../shared/Vector.ts'; -import { CRC16CCITT } from '../../CRC.ts'; -import { Plane } from '../BaseModel.ts'; -import { Brush, BrushModel, BrushSide, Node } from '../BSP.ts'; -import { ModelLoader } from '../ModelLoader.ts'; - -/** @typedef {Record} LumpViews */ - -const BSP_MAGIC = 1347633737; // 'IBSP' little-endian -const BSP_VERSION = 38; - -const lumps = Object.freeze({ - LUMP_ENTITIES: 0, - LUMP_PLANES: 1, - LUMP_VERTEXES: 2, - LUMP_VISIBILITY: 3, - LUMP_NODES: 4, - LUMP_TEXINFO: 5, - LUMP_FACES: 6, - LUMP_LIGHTING: 7, - LUMP_LEAFS: 8, - LUMP_LEAFFACES: 9, - LUMP_LEAFBRUSHES: 10, - LUMP_EDGES: 11, - LUMP_SURFEDGES: 12, - LUMP_MODELS: 13, - LUMP_BRUSHES: 14, - LUMP_BRUSHSIDES: 15, - LUMP_POP: 16, - LUMP_AREAS: 17, - LUMP_AREAPORTALS: 18, -}); - -export class BSP38Loader extends ModelLoader { - static #contentsMap = Object.freeze({ - 0: content.CONTENT_EMPTY, - 1: content.CONTENT_SOLID, - 2: content.CONTENT_SOLID, // window - 4: content.CONTENT_EMPTY, // aux - 8: content.CONTENT_LAVA, - 16: content.CONTENT_SLIME, - 32: content.CONTENT_WATER, - 64: content.CONTENT_EMPTY, // fog - - 0x08000: content.CONTENT_EMPTY, // area portal - 0x10000: content.CONTENT_EMPTY, // player clip - 0x20000: content.CONTENT_EMPTY, // monster clip - - 0x040000: content.CONTENT_CURRENT_0, - 0x080000: content.CONTENT_CURRENT_90, - 0x100000: content.CONTENT_CURRENT_180, - 0x200000: content.CONTENT_CURRENT_270, - 0x400000: content.CONTENT_CURRENT_UP, - 0x800000: content.CONTENT_CURRENT_DOWN, - - 0x10000000: content.CONTENT_EMPTY, // translucent - 0x20000000: content.CONTENT_EMPTY, // ladder - }); - - getMagicNumbers() { - return [BSP_MAGIC]; - } - - getExtensions() { - return ['.bsp']; - } - - getName() { - return 'Quake 2 BSP38'; - } - - canLoad(buffer, filename) { - const view = new DataView(buffer); - - return super.canLoad(buffer, filename) && view.getUint32(4, true) === BSP_VERSION; - } - - _loadLumps(buffer) { - /** @type {LumpViews} */ - const lumpViews = {}; - - const dv = new DataView(buffer); - - for (let i = 0; i < Object.keys(lumps).length; i++) { - const offset = dv.getUint32(8 + i * 8, true); - const length = dv.getUint32(8 + i * 8 + 4, true); - - lumpViews[i] = new DataView(buffer, offset, length); - } - - return lumpViews; - } - - /** - * Reads a null-terminated string from a DataView. - * @param {DataView} dataView data view to read from - * @param {number} offset optional, offset to start reading - * @param {number} length optional, length of the string to read - * @returns {string} string, null-terminated or up to length - */ - _readString(dataView, offset = 0, length = dataView.byteLength - offset) { - return Q.memstr(new Uint8Array(dataView.buffer, dataView.byteOffset + offset, length)); - } - - /** - * @param {DataView} entitiesLump data view of entities lump - * @param {BrushModel} loadmodel brush model - */ - _loadEntities(entitiesLump, loadmodel) { - loadmodel.entities = this._readString(entitiesLump, 0, entitiesLump.byteLength); - } - - /** - * @param {DataView} texinfoLump data view of texinfo lump - * @param {BrushModel} loadmodel brush model - */ - _loadSurfaces(texinfoLump, loadmodel) { - loadmodel.texinfo.length = 0; - - // float vecs[2][4]; // [s/t][xyz offset] - // int32 flags; // miptex flags + overrides - // int32 value; // light emission, etc - // char texture[32]; // texture name (textures/*.wal) - // int32 nexttexinfo; // for animations, -1 = end of chain - - const stride = 76; - const length = texinfoLump.byteLength / stride; - for (let i = 0; i < length; i++) { - const offset = i * stride; - - // TODO: class - loadmodel.texinfo.push({ - vecs: [ - [ - texinfoLump.getFloat32(offset + 0, true), - texinfoLump.getFloat32(offset + 4, true), - texinfoLump.getFloat32(offset + 8, true), - texinfoLump.getFloat32(offset + 12, true), - ], - [ - texinfoLump.getFloat32(offset + 16, true), - texinfoLump.getFloat32(offset + 20, true), - texinfoLump.getFloat32(offset + 24, true), - texinfoLump.getFloat32(offset + 28, true), - ], - ], - flags: texinfoLump.getInt32(offset + 32, true), - value: texinfoLump.getInt32(offset + 36, true), - texture: this._readString(texinfoLump, offset + 40, 32), - nexttexinfo: texinfoLump.getInt32(offset + 72, true), - }); - } - } - - /** - * @param {number} contents Quake2 contents - * @returns {number} translated contents - */ - _translateQ2Contents(contents) { - console.assert(contents in BSP38Loader.#contentsMap); - return BSP38Loader.#contentsMap[contents]; - } - - /** - * @param {DataView} leafsLump data view of leafs lump - * @param {BrushModel} loadmodel brush model - */ - _loadLeafs(leafsLump, loadmodel) { - loadmodel.leafs.length = 0; - - // int32 contents; // OR of all brushes (not needed?) - // int16 cluster; - // int16 area; - // int16 mins[3]; // for frustum culling - // int16 maxs[3]; - // uint16 firstleafface; - // uint16 numleaffaces; - // uint16 firstleafbrush; - // uint16 numleafbrushes; - - const stride = 28; - const length = leafsLump.byteLength / stride; - for (let i = 0; i < length; i++) { - const offset = i * stride; - - loadmodel.leafs.push(/** @type {Node} */(Object.assign(new Node(loadmodel), { - num: i, - contents: this._translateQ2Contents(leafsLump.getInt32(offset + 0, true)), - cluster: leafsLump.getInt16(offset + 4, true), - area: leafsLump.getInt16(offset + 6, true), - mins: new Vector( - leafsLump.getInt16(offset + 8, true), - leafsLump.getInt16(offset + 10, true), - leafsLump.getInt16(offset + 12, true), - ), - maxs: new Vector( - leafsLump.getInt16(offset + 14, true), - leafsLump.getInt16(offset + 16, true), - leafsLump.getInt16(offset + 18, true), - ), - firstmarksurface: leafsLump.getUint16(offset + 20, true), - nummarksurfaces: leafsLump.getUint16(offset + 22, true), - firstleafbrush: leafsLump.getUint16(offset + 24, true), - numleafbrushes: leafsLump.getUint16(offset + 26, true), - }))); - } - } - - /** - * @param {DataView} leafbrushesLump data view of leafbrushes lump - * @param {BrushModel} loadmodel brush model - */ - _loadLeafBrushes(leafbrushesLump, loadmodel) { - const count = leafbrushesLump.byteLength / 2; // all uint16 - loadmodel.leafbrushes = new Array(count); - - for (let i = 0; i < count; i++) { - loadmodel.leafbrushes[i] = leafbrushesLump.getUint16(i * 2, true); - } - } - - /** - * @param {DataView} planesLump data view of planes lump - * @param {BrushModel} loadmodel brush model - */ - _loadPlanes(planesLump, loadmodel) { - loadmodel.planes.length = 0; - - // float normal[3]; - // float dist; - // int32 type; // PLANE_X - PLANE_ANYZ ?remove? trivial to regenerate - - const stride = 20; - const length = planesLump.byteLength / stride; - for (let i = 0; i < length; i++) { - const offset = i * stride; - - loadmodel.planes.push(new Plane( - new Vector( - planesLump.getFloat32(offset + 0, true), - planesLump.getFloat32(offset + 4, true), - planesLump.getFloat32(offset + 8, true), - ), - planesLump.getFloat32(offset + 12, true), - )); - - // CR: not reading in type - } - } - - /** - * @param {DataView} brushesLump data view of brushes lump - * @param {BrushModel} loadmodel brush model - */ - _loadBrushes(brushesLump, loadmodel) { - // int32 firstside; - // int32 numsides; - // int32 contents; - - const stride = 12; - const length = brushesLump.byteLength / stride; - - loadmodel.brushes = new Array(length); - - for (let i = 0; i < length; i++) { - const offset = i * stride; - - loadmodel.brushes[i] = Object.assign(new Brush(loadmodel), { - firstside: brushesLump.getInt32(offset + 0, true), - numsides: brushesLump.getInt32(offset + 4, true), - contents: this._translateQ2Contents(brushesLump.getInt32(offset + 8, true)), - }); - } - } - - _loadBrushSides(brushsidesLump, loadmodel) { - // uint16 planenum; // facing out of the leaf - // int16 texinfo; - - const stride = 4; - const length = brushsidesLump.byteLength / stride; - loadmodel.brushsides = new Array(length); - - for (let i = 0; i < length; i++) { - const offset = i * stride; - - loadmodel.brushsides[i] = /** @type {BrushSide} */ (Object.assign(new BrushSide(loadmodel), { - planenum: brushsidesLump.getUint16(offset + 0, true), - texinfo: brushsidesLump.getInt16(offset + 2, true), - })); - } - } - - _loadSubmodels(modelsLump, loadmodel) { - // float mins[3], maxs[3]; - // float origin[3]; // for sounds or lights - // int32 headnode; - // int32 firstface, numfaces; // submodels just draw faces - // // without walking the bsp tree - - void modelsLump; - loadmodel.submodels.length = 0; - - - } - - load(buffer, name) { - const loadmodel = new BrushModel(name); - - loadmodel.version = BSP_VERSION; - - const lviews = this._loadLumps(buffer); - this._loadEntities(lviews[lumps.LUMP_ENTITIES], loadmodel); - this._loadSurfaces(lviews[lumps.LUMP_TEXINFO], loadmodel); - this._loadLeafs(lviews[lumps.LUMP_LEAFS], loadmodel); - this._loadLeafBrushes(lviews[lumps.LUMP_LEAFBRUSHES], loadmodel); - this._loadPlanes(lviews[lumps.LUMP_PLANES], loadmodel); - this._loadBrushes(lviews[lumps.LUMP_BRUSHES], loadmodel); - this._loadBrushSides(lviews[lumps.LUMP_BRUSHSIDES], loadmodel); - this._loadSubmodels(lviews[lumps.LUMP_MODELS], loadmodel); - - loadmodel.needload = false; - loadmodel.checksum = CRC16CCITT.Block(new Uint8Array(buffer)); - - return Promise.resolve(loadmodel); - } -}; - - diff --git a/source/engine/common/model/loaders/BSP38Loader.ts b/source/engine/common/model/loaders/BSP38Loader.ts new file mode 100644 index 00000000..c5481e75 --- /dev/null +++ b/source/engine/common/model/loaders/BSP38Loader.ts @@ -0,0 +1,297 @@ +import { content } from '../../../../shared/Defs.ts'; +import Q from '../../../../shared/Q.ts'; +import Vector from '../../../../shared/Vector.ts'; +import { CRC16CCITT } from '../../CRC.ts'; +import { Plane } from '../BaseModel.ts'; +import { Brush, BrushModel, BrushSide, Node } from '../BSP.ts'; +import { ModelLoader } from '../ModelLoader.ts'; + +interface LumpViews { + [index: number]: DataView; +} + +enum BSP38Lump { + ENTITIES = 0, + PLANES = 1, + VERTEXES = 2, + VISIBILITY = 3, + NODES = 4, + TEXINFO = 5, + FACES = 6, + LIGHTING = 7, + LEAFS = 8, + LEAFFACES = 9, + LEAFBRUSHES = 10, + EDGES = 11, + SURFEDGES = 12, + MODELS = 13, + BRUSHES = 14, + BRUSHSIDES = 15, + POP = 16, + AREAS = 17, + AREAPORTALS = 18, +} + +const BSP_MAGIC = 1347633737; +const BSP_VERSION = 38; + +/** + * Loader for Quake 2 BSP38 format (.bsp). + */ +export class BSP38Loader extends ModelLoader { + static readonly #contentsMap: Record = Object.freeze({ + 0: content.CONTENT_EMPTY, + 1: content.CONTENT_SOLID, + 2: content.CONTENT_SOLID, + 4: content.CONTENT_EMPTY, + 8: content.CONTENT_LAVA, + 16: content.CONTENT_SLIME, + 32: content.CONTENT_WATER, + 64: content.CONTENT_EMPTY, + + 0x08000: content.CONTENT_EMPTY, + 0x10000: content.CONTENT_EMPTY, + 0x20000: content.CONTENT_EMPTY, + + 0x040000: content.CONTENT_CURRENT_0, + 0x080000: content.CONTENT_CURRENT_90, + 0x100000: content.CONTENT_CURRENT_180, + 0x200000: content.CONTENT_CURRENT_270, + 0x400000: content.CONTENT_CURRENT_UP, + 0x800000: content.CONTENT_CURRENT_DOWN, + + 0x10000000: content.CONTENT_EMPTY, + 0x20000000: content.CONTENT_EMPTY, + }); + + override getMagicNumbers(): number[] { + return [BSP_MAGIC]; + } + + override getExtensions(): string[] { + return ['.bsp']; + } + + override getName(): string { + return 'Quake 2 BSP38'; + } + + override canLoad(buffer: ArrayBuffer, filename: string): boolean { + const view = new DataView(buffer); + + return super.canLoad(buffer, filename) && view.getUint32(4, true) === BSP_VERSION; + } + + override load(buffer: ArrayBuffer, name: string): Promise { + const loadmodel = new BrushModel(name); + + loadmodel.version = BSP_VERSION; + + const lumpViews = this.#loadLumps(buffer); + this.#loadEntities(lumpViews[BSP38Lump.ENTITIES], loadmodel); + this.#loadSurfaces(lumpViews[BSP38Lump.TEXINFO], loadmodel); + this.#loadLeafs(lumpViews[BSP38Lump.LEAFS], loadmodel); + this.#loadLeafBrushes(lumpViews[BSP38Lump.LEAFBRUSHES], loadmodel); + this.#loadPlanes(lumpViews[BSP38Lump.PLANES], loadmodel); + this.#loadBrushes(lumpViews[BSP38Lump.BRUSHES], loadmodel); + this.#loadBrushSides(lumpViews[BSP38Lump.BRUSHSIDES], loadmodel); + this.#loadSubmodels(lumpViews[BSP38Lump.MODELS], loadmodel); + + loadmodel.needload = false; + loadmodel.checksum = CRC16CCITT.Block(new Uint8Array(buffer)); + + return Promise.resolve(loadmodel); + } + + /** + * Slice all BSP38 lumps into DataViews. + * @returns Per-lump DataViews indexed by BSP38 lump number. + */ + #loadLumps(buffer: ArrayBuffer): LumpViews { + const lumpViews: LumpViews = {}; + const view = new DataView(buffer); + + for (let lumpIndex = BSP38Lump.ENTITIES; lumpIndex <= BSP38Lump.AREAPORTALS; lumpIndex++) { + const offset = view.getUint32(8 + lumpIndex * 8, true); + const length = view.getUint32(8 + lumpIndex * 8 + 4, true); + lumpViews[lumpIndex] = new DataView(buffer, offset, length); + } + + return lumpViews; + } + + /** + * Read a null-terminated string from a lump view. + * @returns The decoded lump string. + */ + #readString(dataView: DataView, offset = 0, length = dataView.byteLength - offset): string { + return Q.memstr(new Uint8Array(dataView.buffer, dataView.byteOffset + offset, length)); + } + + /** + * Load entity text from the BSP38 entities lump. + */ + #loadEntities(entitiesLump: DataView, loadmodel: BrushModel): void { + loadmodel.entities = this.#readString(entitiesLump, 0, entitiesLump.byteLength); + } + + /** + * Load BSP38 texinfo entries. + */ + #loadSurfaces(texinfoLump: DataView, loadmodel: BrushModel): void { + loadmodel.texinfo.length = 0; + + const stride = 76; + const length = texinfoLump.byteLength / stride; + + for (let index = 0; index < length; index++) { + const offset = index * stride; + + loadmodel.texinfo.push({ + vecs: [ + [ + texinfoLump.getFloat32(offset + 0, true), + texinfoLump.getFloat32(offset + 4, true), + texinfoLump.getFloat32(offset + 8, true), + texinfoLump.getFloat32(offset + 12, true), + ], + [ + texinfoLump.getFloat32(offset + 16, true), + texinfoLump.getFloat32(offset + 20, true), + texinfoLump.getFloat32(offset + 24, true), + texinfoLump.getFloat32(offset + 28, true), + ], + ], + flags: texinfoLump.getInt32(offset + 32, true), + value: texinfoLump.getInt32(offset + 36, true), + texture: this.#readString(texinfoLump, offset + 40, 32), + nexttexinfo: texinfoLump.getInt32(offset + 72, true), + }); + } + } + + /** + * Translate Quake 2 brush contents to Quake contents constants. + * @returns The translated Quake contents constant. + */ + #translateQ2Contents(q2Contents: number): number { + console.assert(q2Contents in BSP38Loader.#contentsMap); + return BSP38Loader.#contentsMap[q2Contents]; + } + + /** + * Load BSP38 leafs. + */ + #loadLeafs(leafsLump: DataView, loadmodel: BrushModel): void { + loadmodel.leafs.length = 0; + + const stride = 28; + const length = leafsLump.byteLength / stride; + + for (let index = 0; index < length; index++) { + const offset = index * stride; + + loadmodel.leafs.push(Object.assign(new Node(loadmodel), { + num: index, + contents: this.#translateQ2Contents(leafsLump.getInt32(offset + 0, true)), + cluster: leafsLump.getInt16(offset + 4, true), + area: leafsLump.getInt16(offset + 6, true), + mins: new Vector( + leafsLump.getInt16(offset + 8, true), + leafsLump.getInt16(offset + 10, true), + leafsLump.getInt16(offset + 12, true), + ), + maxs: new Vector( + leafsLump.getInt16(offset + 14, true), + leafsLump.getInt16(offset + 16, true), + leafsLump.getInt16(offset + 18, true), + ), + firstmarksurface: leafsLump.getUint16(offset + 20, true), + nummarksurfaces: leafsLump.getUint16(offset + 22, true), + firstleafbrush: leafsLump.getUint16(offset + 24, true), + numleafbrushes: leafsLump.getUint16(offset + 26, true), + })); + } + } + + /** + * Load BSP38 leafbrush links. + */ + #loadLeafBrushes(leafbrushesLump: DataView, loadmodel: BrushModel): void { + const count = leafbrushesLump.byteLength / 2; + loadmodel.leafbrushes = new Array(count); + + for (let index = 0; index < count; index++) { + loadmodel.leafbrushes[index] = leafbrushesLump.getUint16(index * 2, true); + } + } + + /** + * Load BSP38 planes. + */ + #loadPlanes(planesLump: DataView, loadmodel: BrushModel): void { + loadmodel.planes.length = 0; + + const stride = 20; + const length = planesLump.byteLength / stride; + + for (let index = 0; index < length; index++) { + const offset = index * stride; + + loadmodel.planes.push(new Plane( + new Vector( + planesLump.getFloat32(offset + 0, true), + planesLump.getFloat32(offset + 4, true), + planesLump.getFloat32(offset + 8, true), + ), + planesLump.getFloat32(offset + 12, true), + )); + } + } + + /** + * Load BSP38 brushes. + */ + #loadBrushes(brushesLump: DataView, loadmodel: BrushModel): void { + const stride = 12; + const length = brushesLump.byteLength / stride; + + loadmodel.brushes = new Array(length); + + for (let index = 0; index < length; index++) { + const offset = index * stride; + + loadmodel.brushes[index] = Object.assign(new Brush(loadmodel), { + firstside: brushesLump.getInt32(offset + 0, true), + numsides: brushesLump.getInt32(offset + 4, true), + contents: this.#translateQ2Contents(brushesLump.getInt32(offset + 8, true)), + }); + } + } + + /** + * Load BSP38 brush sides. + */ + #loadBrushSides(brushsidesLump: DataView, loadmodel: BrushModel): void { + const stride = 4; + const length = brushsidesLump.byteLength / stride; + loadmodel.brushsides = new Array(length); + + for (let index = 0; index < length; index++) { + const offset = index * stride; + + loadmodel.brushsides[index] = Object.assign(new BrushSide(loadmodel), { + planenum: brushsidesLump.getUint16(offset + 0, true), + texinfo: brushsidesLump.getInt16(offset + 2, true), + }); + } + } + + /** + * Load BSP38 submodels. + */ + #loadSubmodels(modelsLump: DataView, loadmodel: BrushModel): void { + void modelsLump; + loadmodel.submodels.length = 0; + } +} diff --git a/source/engine/common/model/loaders/SpriteSPRLoader.mjs b/source/engine/common/model/loaders/SpriteSPRLoader.mjs deleted file mode 100644 index b7d58e44..00000000 --- a/source/engine/common/model/loaders/SpriteSPRLoader.mjs +++ /dev/null @@ -1,151 +0,0 @@ -import Vector from '../../../../shared/Vector.ts'; -import { GLTexture } from '../../../client/GL.mjs'; -import W, { translateIndexToRGBA } from '../../W.ts'; -import { CRC16CCITT } from '../../CRC.ts'; -import { registry } from '../../../registry.mjs'; -import { ModelLoader } from '../ModelLoader.ts'; -import { SpriteModel } from '../SpriteModel.ts'; - -/** - * Loader for Quake Sprite format (.spr) - * Magic: 0x50534449 ("IDSP") - * Version: 1 - */ -export class SpriteSPRLoader extends ModelLoader { - /** - * Get magic numbers that identify this format - * @returns {number[]} Array of magic numbers - */ - getMagicNumbers() { - return [0x50534449]; // "IDSP" - } - - /** - * Get file extensions for this format - * @returns {string[]} Array of file extensions - */ - getExtensions() { - return ['.spr']; - } - - /** - * Get human-readable name of this loader - * @returns {string} Loader name - */ - getName() { - return 'Quake Sprite'; - } - - /** - * Load a Sprite SPR model from buffer - * @param {ArrayBuffer} buffer - The model file data - * @param {string} name - The model name/path - * @returns {Promise} The loaded model - */ - // eslint-disable-next-line @typescript-eslint/require-await - async load(buffer, name) { - const loadmodel = new SpriteModel(name); - - loadmodel.type = 1; // Mod.type.sprite - - const view = new DataView(buffer); - const version = view.getUint32(4, true); - - if (version !== 1) { - throw new Error(`${name} has wrong version number (${version} should be 1)`); - } - - loadmodel.oriented = view.getUint32(8, true) === 3; - loadmodel.boundingradius = view.getFloat32(12, true); - loadmodel.width = view.getUint32(16, true); - loadmodel.height = view.getUint32(20, true); - loadmodel._frames = view.getUint32(24, true); - - if (loadmodel._frames === 0) { - throw new Error(`model ${name} has no frames`); - } - - loadmodel.random = view.getUint32(32, true) === 1; - loadmodel.numframes = loadmodel._frames; - loadmodel.mins = new Vector( - loadmodel.width * -0.5, - loadmodel.width * -0.5, - loadmodel.height * -0.5, - ); - loadmodel.maxs = new Vector( - loadmodel.width * 0.5, - loadmodel.width * 0.5, - loadmodel.height * 0.5, - ); - - loadmodel.frames.length = loadmodel._frames; - let inframe = 36; - - for (let i = 0; i < loadmodel._frames; i++) { - inframe += 4; - - if (view.getUint32(inframe - 4, true) === 0) { - // Single frame - const frame = { group: false }; - loadmodel.frames[i] = frame; - inframe = this._loadSpriteFrame(name + '_' + i, buffer, inframe, frame); - } else { - // Frame group (animated frames) - const group = { - group: true, - frames: [], - }; - loadmodel.frames[i] = group; - const numframes = view.getUint32(inframe, true); - inframe += 4; - - for (let j = 0; j < numframes; j++) { - group.frames[j] = { interval: view.getFloat32(inframe, true) }; - if (group.frames[j].interval <= 0.0) { - throw new Error('SpriteSPRLoader: interval <= 0'); - } - inframe += 4; - } - - for (let j = 0; j < numframes; j++) { - inframe = this._loadSpriteFrame(name + '_' + i + '_' + j, buffer, inframe, group.frames[j]); - } - } - } - - loadmodel.needload = false; - loadmodel.checksum = CRC16CCITT.Block(new Uint8Array(buffer)); - - return loadmodel; - } - - /** - * Load a single sprite frame from buffer - * @protected - * @param {string} identifier - Frame texture identifier - * @param {ArrayBuffer} buffer - The model file data - * @param {number} inframe - Current offset in buffer - * @param {object} frame - Frame object to populate with texture data - * @returns {number|null} New offset after reading frame, or null if dedicated server - */ - _loadSpriteFrame(identifier, buffer, inframe, frame) { - if (registry.isDedicatedServer) { - return null; - } - - const view = new DataView(buffer); - frame.origin = [view.getInt32(inframe, true), -view.getInt32(inframe + 4, true)]; - frame.width = view.getUint32(inframe + 8, true); - frame.height = view.getUint32(inframe + 12, true); - - const data = new Uint8Array(buffer, inframe + 16, frame.width * frame.height); - - const rgba = translateIndexToRGBA(data, frame.width, frame.height, W.d_8to24table_u8, 255); - const glt = GLTexture.Allocate(identifier, frame.width, frame.height, rgba); - - frame.glt = glt; - frame.texturenum = glt.texnum; - - return inframe + 16 + frame.width * frame.height; - } -} diff --git a/source/engine/common/model/loaders/SpriteSPRLoader.ts b/source/engine/common/model/loaders/SpriteSPRLoader.ts new file mode 100644 index 00000000..e479cf05 --- /dev/null +++ b/source/engine/common/model/loaders/SpriteSPRLoader.ts @@ -0,0 +1,138 @@ +import Vector from '../../../../shared/Vector.ts'; +import { GLTexture } from '../../../client/GL.mjs'; +import { registry } from '../../../registry.mjs'; +import { CRC16CCITT } from '../../CRC.ts'; +import W, { translateIndexToRGBA } from '../../W.ts'; +import { ModelLoader } from '../ModelLoader.ts'; +import { SpriteModel, type SpriteFrame } from '../SpriteModel.ts'; + +interface MutableSpriteFrameImage { + interval?: number; + origin: [number, number]; + width: number; + height: number; + glt: GLTexture; + texturenum: number; +} + +interface MutableSpriteSingleFrame extends MutableSpriteFrameImage { + group: false; +} + +interface MutableSpriteFrameGroup { + group: true; + frames: MutableSpriteFrameImage[]; +} + +/** + * Loader for Quake Sprite format (.spr). + */ +export class SpriteSPRLoader extends ModelLoader { + override getMagicNumbers(): number[] { + return [0x50534449]; + } + + override getExtensions(): string[] { + return ['.spr']; + } + + override getName(): string { + return 'Quake Sprite'; + } + + override load(buffer: ArrayBuffer, name: string): Promise { + const loadmodel = new SpriteModel(name); + const view = new DataView(buffer); + const version = view.getUint32(4, true); + + if (version !== 1) { + throw new Error(`${name} has wrong version number (${version} should be 1)`); + } + + loadmodel.oriented = view.getUint32(8, true) === 3; + loadmodel.boundingradius = view.getFloat32(12, true); + loadmodel.width = view.getUint32(16, true); + loadmodel.height = view.getUint32(20, true); + loadmodel._frames = view.getUint32(24, true); + + if (loadmodel._frames === 0) { + throw new Error(`model ${name} has no frames`); + } + + loadmodel.random = view.getUint32(32, true) === 1; + loadmodel.numframes = loadmodel._frames; + loadmodel.mins = new Vector( + loadmodel.width * -0.5, + loadmodel.width * -0.5, + loadmodel.height * -0.5, + ); + loadmodel.maxs = new Vector( + loadmodel.width * 0.5, + loadmodel.width * 0.5, + loadmodel.height * 0.5, + ); + + loadmodel.frames.length = loadmodel._frames; + let inframe = 36; + + for (let i = 0; i < loadmodel._frames; i++) { + inframe += 4; + + if (view.getUint32(inframe - 4, true) === 0) { + const frame = { group: false } as MutableSpriteSingleFrame; + loadmodel.frames[i] = frame as SpriteFrame; + inframe = this.#loadSpriteFrame(`${name}_${i}`, buffer, inframe, frame)!; + continue; + } + + const group: MutableSpriteFrameGroup = { + group: true, + frames: [], + }; + loadmodel.frames[i] = group as SpriteFrame; + const numframes = view.getUint32(inframe, true); + inframe += 4; + + for (let j = 0; j < numframes; j++) { + group.frames[j] = { interval: view.getFloat32(inframe, true) } as MutableSpriteFrameImage; + if ((group.frames[j].interval ?? 0) <= 0.0) { + throw new Error('SpriteSPRLoader: interval <= 0'); + } + inframe += 4; + } + + for (let j = 0; j < numframes; j++) { + inframe = this.#loadSpriteFrame(`${name}_${i}_${j}`, buffer, inframe, group.frames[j])!; + } + } + + loadmodel.needload = false; + loadmodel.checksum = CRC16CCITT.Block(new Uint8Array(buffer)); + + return Promise.resolve(loadmodel); + } + + /** + * Load a single sprite frame from the SPR data. + * @returns The next byte offset after the frame, or null on dedicated server. + */ + #loadSpriteFrame(identifier: string, buffer: ArrayBuffer, inframe: number, frame: MutableSpriteFrameImage): number | null { + if (registry.isDedicatedServer) { + return null; + } + + const view = new DataView(buffer); + frame.origin = [view.getInt32(inframe, true), -view.getInt32(inframe + 4, true)]; + frame.width = view.getUint32(inframe + 8, true); + frame.height = view.getUint32(inframe + 12, true); + + const data = new Uint8Array(buffer, inframe + 16, frame.width * frame.height); + const rgba = translateIndexToRGBA(data, frame.width, frame.height, W.d_8to24table_u8, 255); + const texture = GLTexture.Allocate(identifier, frame.width, frame.height, rgba); + + frame.glt = texture; + frame.texturenum = texture.texnum; + + return inframe + 16 + frame.width * frame.height; + } +} diff --git a/source/engine/common/model/loaders/WavefrontOBJLoader.mjs b/source/engine/common/model/loaders/WavefrontOBJLoader.mjs deleted file mode 100644 index d6841a98..00000000 --- a/source/engine/common/model/loaders/WavefrontOBJLoader.mjs +++ /dev/null @@ -1,487 +0,0 @@ -import { registry } from '../../../registry.mjs'; - -import Vector from '../../../../shared/Vector.ts'; -import { ModelLoader } from '../ModelLoader.ts'; -import { MeshModel } from '../MeshModel.ts'; -import { PBRMaterial } from '../../../client/renderer/Materials.mjs'; -import { GLTexture } from '../../../client/GL.mjs'; - -/** - * Loader for Wavefront OBJ format (.obj) - * Supports vertices, normals, texture coordinates, and triangulated faces. - * Does not yet support materials (.mtl), groups, or advanced features. - */ -export class WavefrontOBJLoader extends ModelLoader { - /** - * Get magic numbers that identify this format - * OBJ is text-based, so no magic number - * @returns {number[]} Empty array - */ - getMagicNumbers() { - return []; - } - - /** - * Get file extensions for this format - * @returns {string[]} Array of file extensions - */ - getExtensions() { - return ['.obj']; - } - - /** - * Get human-readable name of this loader - * @returns {string} Loader name - */ - getName() { - return 'Wavefront .obj'; - } - - /** - * Check if this loader can handle the given file - * @param {ArrayBuffer} buffer The file buffer - * @param {string} filename The filename - * @returns {boolean} True if this loader can handle the file - */ - canLoad(buffer, filename) { - // Check file extension - if (filename.toLowerCase().endsWith('.obj')) { - return true; - } - - // Could also check for OBJ text markers in the buffer - // But extension check is sufficient for now - return false; - } - - /** - * Load a Wavefront OBJ model from buffer - * @param {ArrayBuffer} buffer The model file data - * @param {string} name The model name/path - * @returns {Promise} The loaded model - */ - async load(buffer, name) { - const loadmodel = new MeshModel(name); - - // Convert ArrayBuffer to text - const decoder = new TextDecoder('utf-8'); - const text = decoder.decode(buffer); - - // Parse OBJ format - const objData = this._parseOBJ(text); - - // Build vertex arrays for WebGL (expand indexed format to flat arrays) - const meshData = this._buildMeshData(objData); - - // Store in model - loadmodel.vertices = meshData.vertices; - loadmodel.normals = meshData.normals; - loadmodel.texcoords = meshData.texcoords; - loadmodel.indices = meshData.indices; - loadmodel.numVertices = meshData.vertices.length / 3; - loadmodel.numTriangles = meshData.indices.length / 3; - - // Calculate bounding box - this._calculateBounds(loadmodel); - - // Generate tangents and bitangents for normal mapping - if (loadmodel.normals && loadmodel.texcoords) { - this._generateTangentSpace(loadmodel); - } - - // Set texture name (convention: same as model name without .obj) - const baseName = name.replace(/\.obj$/i, '.png').replace(/^models\//i, 'textures/'); - loadmodel.textureName = baseName; - - if (!registry.isDedicatedServer) { - const mat = new PBRMaterial(baseName, 256, 256); // Placeholder material - mat.diffuse = await GLTexture.FromImageFile(baseName); - mat.width = mat.diffuse.width; - mat.height = mat.diffuse.height; - loadmodel.texture = mat; - } - - loadmodel.needload = false; - - return loadmodel; - } - - /** - * Parse OBJ text format into indexed data - * @protected - * @param {string} text OBJ file content - * @returns {object} Parsed OBJ data with positions, texcoords, normals, faces - */ - _parseOBJ(text) { - const positions = []; // v entries - const texcoords = []; // vt entries - const normals = []; // vn entries - const faces = []; // f entries - - const lines = text.split('\n'); - - for (let line of lines) { - // Remove comments and trim whitespace - const commentIndex = line.indexOf('#'); - if (commentIndex >= 0) { - line = line.substring(0, commentIndex); - } - line = line.trim(); - - if (line.length === 0) { - continue; - } - - const parts = line.split(/\s+/); - const type = parts[0]; - - switch (type) { - case 'v': // Vertex position - if (parts.length >= 4) { - // Convert from OBJ convention to Quake's coordinate system - // OBJ: X=right, Y=up, Z=back - // Quake: X=forward, Y=left, Z=up - // Transformation: Quake(x,y,z) = OBJ(x, -z, y) - const x = parseFloat(parts[1]); - const y = parseFloat(parts[2]); - const z = parseFloat(parts[3]); - positions.push( - x, // OBJ X -> Quake X (forward) - -z, // OBJ -Z -> Quake Y (left) - y, // OBJ Y -> Quake Z (up) - ); - } - break; - - case 'vt': // Texture coordinate - if (parts.length >= 3) { - texcoords.push( - parseFloat(parts[1]), - parseFloat(parts[2]), - ); - } - break; - - case 'vn': // Normal - if (parts.length >= 4) { - // Convert normals from OBJ to Quake coordinate system - // Same transformation as positions: Quake(x,y,z) = OBJ(x, -z, y) - const nx = parseFloat(parts[1]); - const ny = parseFloat(parts[2]); - const nz = parseFloat(parts[3]); - normals.push( - nx, // OBJ X -> Quake X - -nz, // OBJ -Z -> Quake Y - ny, // OBJ Y -> Quake Z - ); - } - break; - - case 'f': // Face - if (parts.length >= 4) { - // Parse face vertices - const faceVertices = []; - for (let i = 1; i < parts.length; i++) { - faceVertices.push(this._parseFaceVertex(parts[i])); - } - - // Triangulate if needed (quad or n-gon) - if (faceVertices.length === 3) { - // Already a triangle - faces.push(faceVertices); - } else if (faceVertices.length === 4) { - // Quad - split into two triangles - faces.push([faceVertices[0], faceVertices[1], faceVertices[2]]); - faces.push([faceVertices[0], faceVertices[2], faceVertices[3]]); - } else if (faceVertices.length > 4) { - // N-gon - fan triangulation - for (let i = 1; i < faceVertices.length - 1; i++) { - faces.push([faceVertices[0], faceVertices[i], faceVertices[i + 1]]); - } - } - } - break; - - // Ignore other types (g, o, mtllib, usemtl, s, etc.) - default: - break; - } - } - - return { positions, texcoords, normals, faces }; - } - - /** - * Parse a face vertex specification (v, v/vt, v/vt/vn, v//vn) - * @protected - * @param {string} spec Face vertex specification - * @returns {object} Object with v, vt, vn indices (1-based, can be negative) - */ - _parseFaceVertex(spec) { - const parts = spec.split('/'); - return { - v: parts[0] ? parseInt(parts[0], 10) : 0, - vt: parts[1] ? parseInt(parts[1], 10) : 0, - vn: parts[2] ? parseInt(parts[2], 10) : 0, - }; - } - - /** - * Build mesh data from parsed OBJ (expand indices to flat arrays) - * @protected - * @param {object} objData Parsed OBJ data - * @returns {object} Mesh data with flat vertices, normals, texcoords, indices - */ - _buildMeshData(objData) { - const vertices = []; - const normals = []; - const texcoords = []; - const indices = []; - - const hasNormals = objData.normals.length > 0; - const hasTexcoords = objData.texcoords.length > 0; - - // Vertex deduplication using a map - const vertexMap = new Map(); - let nextIndex = 0; - - for (const face of objData.faces) { - for (const fv of face) { - // Convert OBJ 1-based indices to 0-based - // Handle negative indices (count from end) - const vIdx = this._resolveIndex(fv.v, objData.positions.length / 3); - const vtIdx = hasTexcoords ? this._resolveIndex(fv.vt, objData.texcoords.length / 2) : -1; - const vnIdx = hasNormals ? this._resolveIndex(fv.vn, objData.normals.length / 3) : -1; - - // Create unique key for this vertex combination - const key = `${vIdx}/${vtIdx}/${vnIdx}`; - - if (vertexMap.has(key)) { - // Reuse existing vertex - indices.push(vertexMap.get(key)); - } else { - // Add new vertex - const index = nextIndex++; - vertexMap.set(key, index); - indices.push(index); - - // Add position - if (vIdx >= 0) { - vertices.push( - objData.positions[vIdx * 3], - objData.positions[vIdx * 3 + 1], - objData.positions[vIdx * 3 + 2], - ); - } else { - vertices.push(0, 0, 0); - } - - // Add texcoord - if (vtIdx >= 0) { - texcoords.push( - objData.texcoords[vtIdx * 2], - objData.texcoords[vtIdx * 2 + 1], - ); - } else { - texcoords.push(0, 0); - } - - // Add normal - if (vnIdx >= 0) { - normals.push( - objData.normals[vnIdx * 3], - objData.normals[vnIdx * 3 + 1], - objData.normals[vnIdx * 3 + 2], - ); - } else { - normals.push(0, 0, 1); // Default up normal - } - } - } - } - - // Convert to typed arrays - return { - vertices: new Float32Array(vertices), - normals: hasNormals ? new Float32Array(normals) : null, - texcoords: hasTexcoords ? new Float32Array(texcoords) : null, - indices: nextIndex < 65536 ? new Uint16Array(indices) : new Uint32Array(indices), - }; - } - - /** - * Resolve OBJ index (1-based, negative) to 0-based array index - * @protected - * @param {number} index OBJ index - * @param {number} arrayLength Length of the array being indexed - * @returns {number} 0-based array index, or -1 if invalid - */ - _resolveIndex(index, arrayLength) { - if (index === 0) { - return -1; // Invalid - } - if (index > 0) { - return index - 1; // Convert 1-based to 0-based - } - // Negative index: count from end - return arrayLength + index; - } - - /** - * Calculate bounding box for the model - * @protected - * @param {import('../MeshModel.ts').MeshModel} model The model - */ - _calculateBounds(model) { - if (!model.vertices || model.vertices.length === 0) { - return; - } - - let minX = Infinity, minY = Infinity, minZ = Infinity; - let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; - - for (let i = 0; i < model.vertices.length; i += 3) { - const x = model.vertices[i]; - const y = model.vertices[i + 1]; - const z = model.vertices[i + 2]; - - minX = Math.min(minX, x); - minY = Math.min(minY, y); - minZ = Math.min(minZ, z); - maxX = Math.max(maxX, x); - maxY = Math.max(maxY, y); - maxZ = Math.max(maxZ, z); - } - - model.mins = new Vector(minX, minY, minZ); - model.maxs = new Vector(maxX, maxY, maxZ); - - // Calculate bounding radius (distance from origin to furthest point) - let maxDist = 0; - for (let i = 0; i < model.vertices.length; i += 3) { - const x = model.vertices[i]; - const y = model.vertices[i + 1]; - const z = model.vertices[i + 2]; - maxDist = Math.max(maxDist, Math.hypot(x, y, z)); - } - model.boundingradius = maxDist; - } - - /** - * Generate tangent and bitangent vectors for normal mapping - * Based on "Lengyel's Method" for computing tangent space basis - * @protected - * @param {import('../MeshModel.ts').MeshModel} model The model - */ - _generateTangentSpace(model) { - const numVerts = model.numVertices; - const tangents = new Float32Array(numVerts * 3); - const bitangents = new Float32Array(numVerts * 3); - - // Accumulate tangents and bitangents for each vertex - const tan1 = new Float32Array(numVerts * 3); - const tan2 = new Float32Array(numVerts * 3); - - // Calculate tangent and bitangent for each triangle - for (let i = 0; i < model.indices.length; i += 3) { - const i1 = model.indices[i]; - const i2 = model.indices[i + 1]; - const i3 = model.indices[i + 2]; - - const v1 = [model.vertices[i1 * 3], model.vertices[i1 * 3 + 1], model.vertices[i1 * 3 + 2]]; - const v2 = [model.vertices[i2 * 3], model.vertices[i2 * 3 + 1], model.vertices[i2 * 3 + 2]]; - const v3 = [model.vertices[i3 * 3], model.vertices[i3 * 3 + 1], model.vertices[i3 * 3 + 2]]; - - const w1 = [model.texcoords[i1 * 2], model.texcoords[i1 * 2 + 1]]; - const w2 = [model.texcoords[i2 * 2], model.texcoords[i2 * 2 + 1]]; - const w3 = [model.texcoords[i3 * 2], model.texcoords[i3 * 2 + 1]]; - - const x1 = v2[0] - v1[0]; - const x2 = v3[0] - v1[0]; - const y1 = v2[1] - v1[1]; - const y2 = v3[1] - v1[1]; - const z1 = v2[2] - v1[2]; - const z2 = v3[2] - v1[2]; - - const s1 = w2[0] - w1[0]; - const s2 = w3[0] - w1[0]; - const t1 = w2[1] - w1[1]; - const t2 = w3[1] - w1[1]; - - const denom = (s1 * t2 - s2 * t1); - const r = denom !== 0 ? 1.0 / denom : 0; - - const sdir = [ - (t2 * x1 - t1 * x2) * r, - (t2 * y1 - t1 * y2) * r, - (t2 * z1 - t1 * z2) * r, - ]; - - const tdir = [ - (s1 * x2 - s2 * x1) * r, - (s1 * y2 - s2 * y1) * r, - (s1 * z2 - s2 * z1) * r, - ]; - - // Accumulate - for (const idx of [i1, i2, i3]) { - tan1[idx * 3] += sdir[0]; - tan1[idx * 3 + 1] += sdir[1]; - tan1[idx * 3 + 2] += sdir[2]; - - tan2[idx * 3] += tdir[0]; - tan2[idx * 3 + 1] += tdir[1]; - tan2[idx * 3 + 2] += tdir[2]; - } - } - - // Orthogonalize and normalize - for (let i = 0; i < numVerts; i++) { - const n = [ - model.normals[i * 3], - model.normals[i * 3 + 1], - model.normals[i * 3 + 2], - ]; - - const t = [ - tan1[i * 3], - tan1[i * 3 + 1], - tan1[i * 3 + 2], - ]; - - // Gram-Schmidt orthogonalize - const dot = n[0] * t[0] + n[1] * t[1] + n[2] * t[2]; - const tangent = [ - t[0] - n[0] * dot, - t[1] - n[1] * dot, - t[2] - n[2] * dot, - ]; - - // Normalize tangent - const tLen = Math.hypot(tangent[0], tangent[1], tangent[2]); - if (tLen > 0) { - tangent[0] /= tLen; - tangent[1] /= tLen; - tangent[2] /= tLen; - } - - tangents[i * 3] = tangent[0]; - tangents[i * 3 + 1] = tangent[1]; - tangents[i * 3 + 2] = tangent[2]; - - // Calculate bitangent (cross product of normal and tangent) - const bitangent = [ - n[1] * tangent[2] - n[2] * tangent[1], - n[2] * tangent[0] - n[0] * tangent[2], - n[0] * tangent[1] - n[1] * tangent[0], - ]; - - bitangents[i * 3] = bitangent[0]; - bitangents[i * 3 + 1] = bitangent[1]; - bitangents[i * 3 + 2] = bitangent[2]; - } - - model.tangents = tangents; - model.bitangents = bitangents; - } -} diff --git a/source/engine/common/model/loaders/WavefrontOBJLoader.ts b/source/engine/common/model/loaders/WavefrontOBJLoader.ts new file mode 100644 index 00000000..22ba99f3 --- /dev/null +++ b/source/engine/common/model/loaders/WavefrontOBJLoader.ts @@ -0,0 +1,469 @@ +import { registry } from '../../../registry.mjs'; + +import Vector from '../../../../shared/Vector.ts'; +import { GLTexture } from '../../../client/GL.mjs'; +import { PBRMaterial } from '../../../client/renderer/Materials.mjs'; +import { MeshModel } from '../MeshModel.ts'; +import { ModelLoader } from '../ModelLoader.ts'; + +type TriangleVertexIndices = [number, number, number]; +type TextureCoordinate = [number, number]; + +interface FaceVertex { + readonly v: number; + readonly vt: number; + readonly vn: number; +} + +interface ParsedOBJData { + readonly positions: number[]; + readonly texcoords: number[]; + readonly normals: number[]; + readonly faces: FaceVertex[][]; +} + +interface MeshBuildData { + readonly vertices: Float32Array; + readonly normals: Float32Array | null; + readonly texcoords: Float32Array | null; + readonly indices: Uint16Array | Uint32Array; +} + +/** + * Loader for Wavefront OBJ format (.obj). + * Supports vertices, normals, texture coordinates, and triangulated faces. + * Does not yet support materials (.mtl), groups, or advanced features. + */ +export class WavefrontOBJLoader extends ModelLoader { + override getMagicNumbers(): number[] { + return []; + } + + override getExtensions(): string[] { + return ['.obj']; + } + + override getName(): string { + return 'Wavefront .obj'; + } + + override canLoad(buffer: ArrayBuffer, filename: string): boolean { + if (filename.toLowerCase().endsWith('.obj')) { + return true; + } + + return super.canLoad(buffer, filename); + } + + override async load(buffer: ArrayBuffer, name: string): Promise { + const loadmodel = new MeshModel(name); + const decoder = new TextDecoder('utf-8'); + const text = decoder.decode(buffer); + const objData = this.#parseOBJ(text); + const meshData = this.#buildMeshData(objData); + + loadmodel.vertices = meshData.vertices; + loadmodel.normals = meshData.normals; + loadmodel.texcoords = meshData.texcoords; + loadmodel.indices = meshData.indices; + loadmodel.numVertices = meshData.vertices.length / 3; + loadmodel.numTriangles = meshData.indices.length / 3; + + this.#calculateBounds(loadmodel); + + if (loadmodel.normals !== null && loadmodel.texcoords !== null) { + this.#generateTangentSpace(loadmodel); + } + + const baseName = name.replace(/\.obj$/i, '.png').replace(/^models\//i, 'textures/'); + loadmodel.textureName = baseName; + + if (!registry.isDedicatedServer) { + const material = new PBRMaterial(baseName, 256, 256); + material.diffuse = await GLTexture.FromImageFile(baseName); + material.width = material.diffuse.width; + material.height = material.diffuse.height; + loadmodel.texture = material; + } + + loadmodel.needload = false; + + return loadmodel; + } + + /** + * Parse OBJ text format into indexed mesh data. + * @returns Parsed OBJ positions, texture coordinates, normals, and faces. + */ + #parseOBJ(text: string): ParsedOBJData { + const positions: number[] = []; + const texcoords: number[] = []; + const normals: number[] = []; + const faces: FaceVertex[][] = []; + const lines = text.split('\n'); + + for (let line of lines) { + const commentIndex = line.indexOf('#'); + if (commentIndex >= 0) { + line = line.substring(0, commentIndex); + } + line = line.trim(); + + if (line.length === 0) { + continue; + } + + const parts = line.split(/\s+/); + const type = parts[0]; + + switch (type) { + case 'v': + if (parts.length >= 4) { + const x = parseFloat(parts[1]); + const y = parseFloat(parts[2]); + const z = parseFloat(parts[3]); + positions.push(x, -z, y); + } + break; + + case 'vt': + if (parts.length >= 3) { + texcoords.push(parseFloat(parts[1]), parseFloat(parts[2])); + } + break; + + case 'vn': + if (parts.length >= 4) { + const nx = parseFloat(parts[1]); + const ny = parseFloat(parts[2]); + const nz = parseFloat(parts[3]); + normals.push(nx, -nz, ny); + } + break; + + case 'f': + if (parts.length >= 4) { + this.#parseFace(parts, faces); + } + break; + + default: + break; + } + } + + return { positions, texcoords, normals, faces }; + } + + /** + * Parse a face record and append triangulated faces. + */ + #parseFace(parts: string[], faces: FaceVertex[][]): void { + const faceVertices: FaceVertex[] = []; + + for (let index = 1; index < parts.length; index++) { + faceVertices.push(this.#parseFaceVertex(parts[index])); + } + + if (faceVertices.length === 3) { + faces.push(faceVertices); + return; + } + + if (faceVertices.length === 4) { + faces.push([faceVertices[0], faceVertices[1], faceVertices[2]]); + faces.push([faceVertices[0], faceVertices[2], faceVertices[3]]); + return; + } + + for (let index = 1; index < faceVertices.length - 1; index++) { + faces.push([faceVertices[0], faceVertices[index], faceVertices[index + 1]]); + } + } + + /** + * Parse a face vertex specification (`v`, `v/vt`, `v/vt/vn`, `v//vn`). + * @returns Parsed OBJ face indices. + */ + #parseFaceVertex(spec: string): FaceVertex { + const parts = spec.split('/'); + + return { + v: parts[0] ? parseInt(parts[0], 10) : 0, + vt: parts[1] ? parseInt(parts[1], 10) : 0, + vn: parts[2] ? parseInt(parts[2], 10) : 0, + }; + } + + /** + * Build flat mesh buffers from parsed indexed OBJ data. + * @returns Flat mesh buffers ready for upload to WebGL. + */ + #buildMeshData(objData: ParsedOBJData): MeshBuildData { + const vertices: number[] = []; + const normals: number[] = []; + const texcoords: number[] = []; + const indices: number[] = []; + const hasNormals = objData.normals.length > 0; + const hasTexcoords = objData.texcoords.length > 0; + const vertexMap = new Map(); + let nextIndex = 0; + + for (const face of objData.faces) { + for (const faceVertex of face) { + const vIdx = this.#resolveIndex(faceVertex.v, objData.positions.length / 3); + const vtIdx = hasTexcoords ? this.#resolveIndex(faceVertex.vt, objData.texcoords.length / 2) : -1; + const vnIdx = hasNormals ? this.#resolveIndex(faceVertex.vn, objData.normals.length / 3) : -1; + const key = `${vIdx}/${vtIdx}/${vnIdx}`; + const existingIndex = vertexMap.get(key); + + if (existingIndex !== undefined) { + indices.push(existingIndex); + continue; + } + + const index = nextIndex++; + vertexMap.set(key, index); + indices.push(index); + + this.#appendPosition(vertices, objData.positions, vIdx); + this.#appendTexcoord(texcoords, objData.texcoords, vtIdx); + this.#appendNormal(normals, objData.normals, vnIdx); + } + } + + return { + vertices: new Float32Array(vertices), + normals: hasNormals ? new Float32Array(normals) : null, + texcoords: hasTexcoords ? new Float32Array(texcoords) : null, + indices: nextIndex < 65536 ? new Uint16Array(indices) : new Uint32Array(indices), + }; + } + + /** + * Resolve an OBJ index to a zero-based array index. + * @returns The zero-based index, or `-1` when the OBJ index is invalid. + */ + #resolveIndex(index: number, arrayLength: number): number { + if (index === 0) { + return -1; + } + + if (index > 0) { + return index - 1; + } + + return arrayLength + index; + } + + /** + * Append a position triplet or a default origin. + */ + #appendPosition(vertices: number[], positions: number[], vertexIndex: number): void { + if (vertexIndex < 0) { + vertices.push(0, 0, 0); + return; + } + + vertices.push( + positions[vertexIndex * 3], + positions[vertexIndex * 3 + 1], + positions[vertexIndex * 3 + 2], + ); + } + + /** + * Append a texture coordinate pair or a default zero coordinate. + */ + #appendTexcoord(texcoords: number[], sourceTexcoords: number[], texcoordIndex: number): void { + if (texcoordIndex < 0) { + texcoords.push(0, 0); + return; + } + + texcoords.push( + sourceTexcoords[texcoordIndex * 2], + sourceTexcoords[texcoordIndex * 2 + 1], + ); + } + + /** + * Append a normal triplet or a default up normal. + */ + #appendNormal(normals: number[], sourceNormals: number[], normalIndex: number): void { + if (normalIndex < 0) { + normals.push(0, 0, 1); + return; + } + + normals.push( + sourceNormals[normalIndex * 3], + sourceNormals[normalIndex * 3 + 1], + sourceNormals[normalIndex * 3 + 2], + ); + } + + /** + * Calculate the mesh bounding box and radius. + */ + #calculateBounds(model: MeshModel): void { + if (model.vertices === null || model.vertices.length === 0) { + return; + } + + let minX = Infinity; + let minY = Infinity; + let minZ = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let maxZ = -Infinity; + + for (let index = 0; index < model.vertices.length; index += 3) { + const x = model.vertices[index]; + const y = model.vertices[index + 1]; + const z = model.vertices[index + 2]; + + minX = Math.min(minX, x); + minY = Math.min(minY, y); + minZ = Math.min(minZ, z); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + maxZ = Math.max(maxZ, z); + } + + model.mins = new Vector(minX, minY, minZ); + model.maxs = new Vector(maxX, maxY, maxZ); + + let maxDistance = 0; + for (let index = 0; index < model.vertices.length; index += 3) { + const x = model.vertices[index]; + const y = model.vertices[index + 1]; + const z = model.vertices[index + 2]; + maxDistance = Math.max(maxDistance, Math.hypot(x, y, z)); + } + + model.boundingradius = maxDistance; + } + + /** + * Generate tangent and bitangent buffers for normal mapping. + */ + #generateTangentSpace(model: MeshModel): void { + if (model.vertices === null || model.normals === null || model.texcoords === null || model.indices === null) { + return; + } + + const numVerts = model.numVertices; + const tangents = new Float32Array(numVerts * 3); + const bitangents = new Float32Array(numVerts * 3); + const tan1 = new Float32Array(numVerts * 3); + const tan2 = new Float32Array(numVerts * 3); + + for (let index = 0; index < model.indices.length; index += 3) { + const i1 = model.indices[index]; + const i2 = model.indices[index + 1]; + const i3 = model.indices[index + 2]; + + const v1 = this.#readTriangleVertex(model.vertices, i1); + const v2 = this.#readTriangleVertex(model.vertices, i2); + const v3 = this.#readTriangleVertex(model.vertices, i3); + const w1 = this.#readTextureCoordinate(model.texcoords, i1); + const w2 = this.#readTextureCoordinate(model.texcoords, i2); + const w3 = this.#readTextureCoordinate(model.texcoords, i3); + + const x1 = v2[0] - v1[0]; + const x2 = v3[0] - v1[0]; + const y1 = v2[1] - v1[1]; + const y2 = v3[1] - v1[1]; + const z1 = v2[2] - v1[2]; + const z2 = v3[2] - v1[2]; + const s1 = w2[0] - w1[0]; + const s2 = w3[0] - w1[0]; + const t1 = w2[1] - w1[1]; + const t2 = w3[1] - w1[1]; + const denom = s1 * t2 - s2 * t1; + const reciprocal = denom !== 0 ? 1 / denom : 0; + + const sdir: TriangleVertexIndices = [ + (t2 * x1 - t1 * x2) * reciprocal, + (t2 * y1 - t1 * y2) * reciprocal, + (t2 * z1 - t1 * z2) * reciprocal, + ]; + const tdir: TriangleVertexIndices = [ + (s1 * x2 - s2 * x1) * reciprocal, + (s1 * y2 - s2 * y1) * reciprocal, + (s1 * z2 - s2 * z1) * reciprocal, + ]; + + for (const vertexIndex of [i1, i2, i3]) { + tan1[vertexIndex * 3] += sdir[0]; + tan1[vertexIndex * 3 + 1] += sdir[1]; + tan1[vertexIndex * 3 + 2] += sdir[2]; + + tan2[vertexIndex * 3] += tdir[0]; + tan2[vertexIndex * 3 + 1] += tdir[1]; + tan2[vertexIndex * 3 + 2] += tdir[2]; + } + } + + for (let index = 0; index < numVerts; index++) { + const normal = this.#readTriangleVertex(model.normals, index); + const accumulatedTangent = this.#readTriangleVertex(tan1, index); + const dot = normal[0] * accumulatedTangent[0] + + normal[1] * accumulatedTangent[1] + + normal[2] * accumulatedTangent[2]; + const tangent: TriangleVertexIndices = [ + accumulatedTangent[0] - normal[0] * dot, + accumulatedTangent[1] - normal[1] * dot, + accumulatedTangent[2] - normal[2] * dot, + ]; + const tangentLength = Math.hypot(tangent[0], tangent[1], tangent[2]); + + if (tangentLength > 0) { + tangent[0] /= tangentLength; + tangent[1] /= tangentLength; + tangent[2] /= tangentLength; + } + + tangents[index * 3] = tangent[0]; + tangents[index * 3 + 1] = tangent[1]; + tangents[index * 3 + 2] = tangent[2]; + + const bitangent: TriangleVertexIndices = [ + normal[1] * tangent[2] - normal[2] * tangent[1], + normal[2] * tangent[0] - normal[0] * tangent[2], + normal[0] * tangent[1] - normal[1] * tangent[0], + ]; + + bitangents[index * 3] = bitangent[0]; + bitangents[index * 3 + 1] = bitangent[1]; + bitangents[index * 3 + 2] = bitangent[2]; + } + + model.tangents = tangents; + model.bitangents = bitangents; + } + + /** + * Read a packed `x, y, z` triplet from a flat float buffer. + * @returns The vertex triplet at the requested index. + */ + #readTriangleVertex(values: Float32Array, vertexIndex: number): TriangleVertexIndices { + return [ + values[vertexIndex * 3], + values[vertexIndex * 3 + 1], + values[vertexIndex * 3 + 2], + ]; + } + + /** + * Read a packed `u, v` pair from a flat float buffer. + * @returns The texture coordinate pair at the requested index. + */ + #readTextureCoordinate(values: Float32Array, vertexIndex: number): TextureCoordinate { + return [ + values[vertexIndex * 2], + values[vertexIndex * 2 + 1], + ]; + } +} diff --git a/source/engine/common/model/parsers/ParsedQC.mjs b/source/engine/common/model/parsers/ParsedQC.ts similarity index 67% rename from source/engine/common/model/parsers/ParsedQC.mjs rename to source/engine/common/model/parsers/ParsedQC.ts index 29207976..6a5aa0a5 100644 --- a/source/engine/common/model/parsers/ParsedQC.mjs +++ b/source/engine/common/model/parsers/ParsedQC.ts @@ -1,28 +1,25 @@ +import type { ParsedQC as ParsedQCShape } from '../../../../shared/GameInterfaces.ts'; + import Q from '../../../../shared/Q.ts'; import Vector from '../../../../shared/Vector.ts'; -/** @typedef {import('../../../../shared/GameInterfaces').ParsedQC} IParsedQC */ -/** @augments IParsedQC */ -export default class ParsedQC { - /** @type {string} */ - cd = null; +/** + * Parsed representation of a QuakeC model `.qc` source file. + */ +export default class ParsedQC implements ParsedQCShape { + cd = ''; origin = new Vector(); - /** @type {string} */ - base = null; - /** @type {string} */ - skin = null; - /** @type {string[]} */ - frames = []; - /** @type {Record} */ - animations = {}; - /** @type {number} */ + base: string | null = null; + skin: string | null = null; + frames: string[] = []; + animations: Record = {}; scale = 1.0; /** - * @param {string} qcContent qc model source - * @returns {this} this + * Parse QC model source into a structured representation. + * @returns This parsed QC instance. */ - parseQC(qcContent) { + parseQC(qcContent: string): this { console.assert(typeof qcContent === 'string', 'qcContent must be a string'); const lines = qcContent.trim().split('\n'); @@ -33,7 +30,8 @@ export default class ParsedQC { } const parts = line.split(/\s+/); - const [key, value] = [parts.shift(), parts.join(' ')]; + const key = parts.shift(); + const value = parts.join(' '); switch (key) { case '$cd': @@ -41,7 +39,7 @@ export default class ParsedQC { break; case '$origin': - this.origin = new Vector(...value.split(/\s+/).map((n) => Q.atof(n))); + this.origin = new Vector(...value.split(/\s+/).map((component) => Q.atof(component))); break; case '$base': @@ -82,4 +80,4 @@ export default class ParsedQC { return this; } -}; +} diff --git a/test/common/alias-mdl-loader.test.mjs b/test/common/alias-mdl-loader.test.mjs index 15f12c1e..bed2586d 100644 --- a/test/common/alias-mdl-loader.test.mjs +++ b/test/common/alias-mdl-loader.test.mjs @@ -1,10 +1,10 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import { buildAliasSkinLayers } from '../../source/engine/common/model/loaders/AliasMDLLoader.mjs'; +import { buildAliasSkinLayers } from '../../source/engine/common/model/loaders/AliasMDLLoader.ts'; -describe('buildAliasSkinLayers', () => { - test('splits legacy alias fullbright pixels into diffuse and luminance layers', () => { +void describe('buildAliasSkinLayers', () => { + void test('splits legacy alias fullbright pixels into diffuse and luminance layers', () => { const palette = new Uint8Array(256 * 3); palette[16 * 3] = 1; palette[16 * 3 + 1] = 2; @@ -25,7 +25,7 @@ describe('buildAliasSkinLayers', () => { ]); }); - test('respects transparent pixels when building alias luminance layers', () => { + void test('respects transparent pixels when building alias luminance layers', () => { const palette = new Uint8Array(256 * 3); palette[255 * 3] = 70; palette[255 * 3 + 1] = 80; From 29c80004a77e6ba312e6e464791b2e0e27e6f830 Mon Sep 17 00:00:00 2001 From: Christian R Date: Fri, 3 Apr 2026 09:47:39 +0300 Subject: [PATCH 29/67] TS: common/GameAPIs, common/CollisionModelSource --- source/engine/client/ClientEntities.mjs | 2 +- source/engine/client/ClientLifecycle.mjs | 2 +- .../client/ClientServerCommandHandlers.mjs | 4 +- source/engine/client/Draw.mjs | 2 +- source/engine/client/menu/Multiplayer.mjs | 2 +- source/engine/common/CollisionModelSource.mjs | 93 -- source/engine/common/CollisionModelSource.ts | 105 ++ source/engine/common/Console.ts | 2 +- source/engine/common/GameAPIs.mjs | 1164 ----------------- source/engine/common/GameAPIs.ts | 1103 ++++++++++++++++ source/engine/common/Host.ts | 2 +- source/engine/server/Navigation.mjs | 2 +- source/engine/server/Progs.mjs | 2 +- source/engine/server/ProgsAPI.mjs | 2 +- source/engine/server/Server.mjs | 4 +- source/engine/server/physics/ServerArea.mjs | 2 +- .../engine/server/physics/ServerCollision.mjs | 2 +- source/shared/ClientEdict.ts | 2 +- source/shared/GameInterfaces.ts | 2 +- test/common/collision-model-source.test.mjs | 76 ++ test/common/game-apis.test.mjs | 23 +- 21 files changed, 1309 insertions(+), 1289 deletions(-) delete mode 100644 source/engine/common/CollisionModelSource.mjs create mode 100644 source/engine/common/CollisionModelSource.ts delete mode 100644 source/engine/common/GameAPIs.mjs create mode 100644 source/engine/common/GameAPIs.ts create mode 100644 test/common/collision-model-source.test.mjs diff --git a/source/engine/client/ClientEntities.mjs b/source/engine/client/ClientEntities.mjs index 2379731f..fd37709e 100644 --- a/source/engine/client/ClientEntities.mjs +++ b/source/engine/client/ClientEntities.mjs @@ -5,7 +5,7 @@ import { content, effect, solid } from '../../shared/Defs.ts'; import Chase from './Chase.mjs'; import { DefaultClientEdictHandler } from './ClientLegacy.mjs'; import { BaseClientEdictHandler } from '../../shared/ClientEdict.ts'; -import { ClientEngineAPI } from '../common/GameAPIs.mjs'; +import { ClientEngineAPI } from '../common/GameAPIs.ts'; import { SFX } from './Sound.mjs'; import { Node, revealedVisibility } from '../common/model/BSP.ts'; import { BaseModel } from '../common/model/BaseModel.ts'; diff --git a/source/engine/client/ClientLifecycle.mjs b/source/engine/client/ClientLifecycle.mjs index b68fa5a9..b3b20102 100644 --- a/source/engine/client/ClientLifecycle.mjs +++ b/source/engine/client/ClientLifecycle.mjs @@ -6,7 +6,7 @@ import ClientInput from './ClientInput.mjs'; import CL from './CL.mjs'; import { clientRuntimeState } from './ClientState.mjs'; import { MoveVars, Pmove } from '../common/Pmove.ts'; -import { ClientEngineAPI } from '../common/GameAPIs.mjs'; +import { ClientEngineAPI } from '../common/GameAPIs.ts'; import { eventBus, registry } from '../registry.mjs'; let { Host, PR, S } = registry; diff --git a/source/engine/client/ClientServerCommandHandlers.mjs b/source/engine/client/ClientServerCommandHandlers.mjs index 4af8f9e7..875bf973 100644 --- a/source/engine/client/ClientServerCommandHandlers.mjs +++ b/source/engine/client/ClientServerCommandHandlers.mjs @@ -4,8 +4,8 @@ import Cmd from '../common/Cmd.ts'; import { HostError } from '../common/Errors.ts'; import { gameCapabilities } from '../../shared/Defs.ts'; import Vector from '../../shared/Vector.ts'; -import { ClientEngineAPI } from '../common/GameAPIs.mjs'; -import { sharedCollisionModelSource } from '../common/CollisionModelSource.mjs'; +import { ClientEngineAPI } from '../common/GameAPIs.ts'; +import { sharedCollisionModelSource } from '../common/CollisionModelSource.ts'; import { eventBus, registry } from '../registry.mjs'; import { ScoreSlot } from './ClientState.mjs'; diff --git a/source/engine/client/Draw.mjs b/source/engine/client/Draw.mjs index 2157f21a..ee87273f 100644 --- a/source/engine/client/Draw.mjs +++ b/source/engine/client/Draw.mjs @@ -6,7 +6,7 @@ import W, { WadFileInterface, WadLumpTexture } from '../common/W.ts'; import { eventBus, registry } from '../registry.mjs'; import GL, { GLTexture } from './GL.mjs'; -import { ClientEngineAPI } from '../common/GameAPIs.mjs'; +import { ClientEngineAPI } from '../common/GameAPIs.ts'; let { Host } = registry; diff --git a/source/engine/client/menu/Multiplayer.mjs b/source/engine/client/menu/Multiplayer.mjs index 96047af2..415e4c07 100644 --- a/source/engine/client/menu/Multiplayer.mjs +++ b/source/engine/client/menu/Multiplayer.mjs @@ -4,7 +4,7 @@ import Cmd from '../../common/Cmd.ts'; import { eventBus, registry } from '../../registry.mjs'; import { Action, Label, Spacer } from './MenuItem.mjs'; import { MenuPage, VerticalLayout } from './MenuPage.mjs'; -import { ServerEngineAPI } from '../../common/GameAPIs.mjs'; +import { ServerEngineAPI } from '../../common/GameAPIs.ts'; let { M } = registry; diff --git a/source/engine/common/CollisionModelSource.mjs b/source/engine/common/CollisionModelSource.mjs deleted file mode 100644 index 969ecbec..00000000 --- a/source/engine/common/CollisionModelSource.mjs +++ /dev/null @@ -1,93 +0,0 @@ -import { registry } from '../registry.mjs'; - -/** @typedef {import('../server/Client.mjs').ServerEdict} ServerEdict */ - -/** - * Runtime-neutral model and world resolver for collision code. - * Server and client bootstrap code inject live accessors so physics classes do - * not need to know where model caches or world state live. - */ -export class CollisionModelSource { - /** @type {() => ServerEdict|null} */ - #getServerWorldEntity = () => null; - - /** @type {() => import('./Mod.ts').BrushModel|null} */ - #getServerWorldModel = () => null; - - /** @type {() => Array|null} */ - #getServerModels = () => null; - - /** @type {() => import('./Mod.ts').BrushModel|null} */ - #getClientWorldModel = () => null; - - /** @type {() => Array|null} */ - #getClientModels = () => null; - - /** - * Install live server accessors. - * @param {{getWorldEntity?: () => ServerEdict|null, getWorldModel?: () => import('./Mod.ts').BrushModel|null, getModels?: () => Array|null}} accessors server accessors - */ - configureServer(accessors = {}) { - this.#getServerWorldEntity = accessors.getWorldEntity ?? (() => null); - this.#getServerWorldModel = accessors.getWorldModel ?? (() => null); - this.#getServerModels = accessors.getModels ?? (() => null); - } - - /** - * Install live client accessors. - * @param {{getWorldModel?: () => import('./Mod.ts').BrushModel|null, getModels?: () => Array|null}} accessors client accessors - */ - configureClient(accessors = {}) { - this.#getClientWorldModel = accessors.getWorldModel ?? (() => null); - this.#getClientModels = accessors.getModels ?? (() => null); - } - - /** @returns {ServerEdict|null} active static-world entity, if any */ - getWorldEntity() { - return this.#getServerWorldEntity(); - } - - /** @returns {import('./Mod.ts').BrushModel|null} active static-world model */ - getWorldModel() { - return this.#getServerWorldModel() - ?? this.#getClientWorldModel() - ?? this.#getClientModels()?.[1] - ?? null; - } - - /** - * Resolve a model from the active runtime's model cache. - * @param {number} modelIndex precached model index - * @returns {import('./Mod.ts').BrushModel|object|null} resolved model, if any - */ - getModelByIndex(modelIndex) { - return this.#getServerModels()?.[modelIndex] - ?? this.#getClientModels()?.[modelIndex] - ?? null; - } -} - -/** - * Compatibility adapter for tests and legacy call sites that still construct - * collision helpers directly without explicit injection. - * @returns {CollisionModelSource} registry-backed collision model source - */ -export function createRegistryCollisionModelSource() { - const modelSource = new CollisionModelSource(); - - modelSource.configureServer({ - getWorldEntity: () => registry.SV?.server?.edicts?.[0] ?? null, - getWorldModel: () => registry.SV?.server?.worldmodel ?? null, - getModels: () => registry.SV?.server?.models ?? null, - }); - modelSource.configureClient({ - getWorldModel: () => registry.CL?.state?.worldmodel ?? null, - getModels: () => registry.CL?.state?.model_precache ?? null, - }); - - return modelSource; -} - -export const sharedCollisionModelSource = new CollisionModelSource(); - -export default CollisionModelSource; diff --git a/source/engine/common/CollisionModelSource.ts b/source/engine/common/CollisionModelSource.ts new file mode 100644 index 00000000..57d40814 --- /dev/null +++ b/source/engine/common/CollisionModelSource.ts @@ -0,0 +1,105 @@ +import { eventBus, registry } from '../registry.mjs'; + +import type { ServerEdict } from '../server/Edict.mjs'; +import type { BrushModel } from './model/BSP.ts'; + +interface ServerCollisionModelAccessors { + readonly getWorldEntity?: () => ServerEdict | null; + readonly getWorldModel?: () => BrushModel | null; + readonly getModels?: () => Array | null; +} + +interface ClientCollisionModelAccessors { + readonly getWorldModel?: () => BrushModel | null; + readonly getModels?: () => Array | null; +} + +let { CL, SV } = registry; + +eventBus.subscribe('registry.frozen', () => { + ({ CL, SV } = registry); +}); + +/** + * Runtime-neutral model and world resolver for collision code. + * Server and client bootstrap code inject live accessors so physics classes do + * not need to know where model caches or world state live. + */ +export class CollisionModelSource { + #getServerWorldEntity: () => ServerEdict | null = () => null; + #getServerWorldModel: () => BrushModel | null = () => null; + #getServerModels: () => Array | null = () => null; + #getClientWorldModel: () => BrushModel | null = () => null; + #getClientModels: () => Array | null = () => null; + + /** + * Install live server accessors. + */ + configureServer(accessors: ServerCollisionModelAccessors = {}): void { + this.#getServerWorldEntity = accessors.getWorldEntity ?? (() => null); + this.#getServerWorldModel = accessors.getWorldModel ?? (() => null); + this.#getServerModels = accessors.getModels ?? (() => null); + } + + /** + * Install live client accessors. + */ + configureClient(accessors: ClientCollisionModelAccessors = {}): void { + this.#getClientWorldModel = accessors.getWorldModel ?? (() => null); + this.#getClientModels = accessors.getModels ?? (() => null); + } + + /** + * Return the active static-world entity, if any. + * @returns The current server world entity. + */ + getWorldEntity(): ServerEdict | null { + return this.#getServerWorldEntity(); + } + + /** + * Return the active static-world model. + * @returns The current server or client world model. + */ + getWorldModel(): BrushModel | null { + return this.#getServerWorldModel() + ?? this.#getClientWorldModel() + ?? this.#getClientModels()?.[1] + ?? null; + } + + /** + * Resolve a model from the active runtime's model cache. + * @returns The resolved model, if any. + */ + getModelByIndex(modelIndex: number): BrushModel | object | null { + return this.#getServerModels()?.[modelIndex] + ?? this.#getClientModels()?.[modelIndex] + ?? null; + } +} + +/** + * Compatibility adapter for tests and legacy call sites that still construct + * collision helpers directly without explicit injection. + * @returns A registry-backed collision model source. + */ +export function createRegistryCollisionModelSource(): CollisionModelSource { + const modelSource = new CollisionModelSource(); + + modelSource.configureServer({ + getWorldEntity: () => SV?.server?.edicts?.[0] ?? null, + getWorldModel: () => SV?.server?.worldmodel ?? null, + getModels: () => SV?.server?.models ?? null, + }); + modelSource.configureClient({ + getWorldModel: () => CL?.state?.worldmodel ?? null, + getModels: () => CL?.state?.model_precache ?? null, + }); + + return modelSource; +} + +export const sharedCollisionModelSource = new CollisionModelSource(); + +export default CollisionModelSource; diff --git a/source/engine/common/Console.ts b/source/engine/common/Console.ts index fb927ea5..6f71d1c2 100644 --- a/source/engine/common/Console.ts +++ b/source/engine/common/Console.ts @@ -4,7 +4,7 @@ import Cvar from './Cvar.ts'; import Cmd from './Cmd.ts'; import VID from '../client/VID.mjs'; import { clientConnectionState } from './Def.ts'; -import { ClientEngineAPI } from './GameAPIs.mjs'; +import { ClientEngineAPI } from './GameAPIs.ts'; let { CL, Draw, Host, Key, M, SCR } = getClientRegistry(); diff --git a/source/engine/common/GameAPIs.mjs b/source/engine/common/GameAPIs.mjs deleted file mode 100644 index 9b6968c3..00000000 --- a/source/engine/common/GameAPIs.mjs +++ /dev/null @@ -1,1164 +0,0 @@ -import { PmoveConfiguration } from '../../shared/Pmove.ts'; -import Vector from '../../shared/Vector.ts'; -import { solid } from '../../shared/Defs.ts'; -import Key from '../client/Key.mjs'; -import { SFX } from '../client/Sound.mjs'; -import VID from '../client/VID.mjs'; -import * as Protocol from '../network/Protocol.ts'; -import { EventBus, eventBus, registry } from '../registry.mjs'; -import { ED, ServerEdict } from '../server/Edict.mjs'; -import Cmd from './Cmd.ts'; -import Cvar from './Cvar.ts'; -import { HostError } from './Errors.ts'; -import Mod from './Mod.ts'; -import W from './W.ts'; - -/** @typedef {import('../client/ClientEntities.mjs').ClientEdict} ClientEdict */ -/** @typedef {import('../client/ClientEntities.mjs').ClientDlight} ClientDlight */ -/** @typedef {import('../client/GL.mjs').GLTexture} GLTexture */ -/** @typedef {import('../network/MSG.ts').SzBuffer} SzBuffer */ -/** @typedef {import('../server/Navigation.mjs').Navigation} Navigation */ -/** @typedef {import('./model/parsers/ParsedQC.ts').default} ParsedQC */ -/** @typedef {import('./model/BaseModel.ts').BaseModel} BaseModel */ -/** @typedef {import('../server/physics/ServerCollisionSupport.mjs').CollisionTrace} CollisionTrace */ -/** - * @typedef ClientTraceOptions - * @property {boolean} [includeEntities] include current client entities in addition to static world geometry - * @property {?number} [passEntityId] client entity number to skip during entity tracing - * @property {?((entity: ClientEdict) => boolean)} [filter] optional candidate filter for entity tracing - */ -/** - * @typedef GameTrace - * @property {{ all: boolean, start: boolean }} solid solid hit flags - * @property {number} fraction completed trace fraction - * @property {{ normal: Vector, distance: number }} plane impact plane - * @property {{ inOpen: boolean, inWater: boolean }} contents terminal contents flags - * @property {Vector} point final trace point - * @property {import('../../game/id1/entity/BaseEntity.mjs').default|ClientEdict|null} entity hit entity, if any - */ - -let { CL, Con, Draw, Host, R, S, SCR, SV, V} = registry; - -eventBus.subscribe('registry.frozen', () => { - CL = registry.CL; - Con = registry.Con; - Draw = registry.Draw; - Host = registry.Host; - R = registry.R; - S = registry.S; - SCR = registry.SCR; - SV = registry.SV; - V = registry.V; -}); - -eventBus.subscribe('com.ready', () => { - const COM = registry.COM; - - if (!COM.registered) { - CommonEngineAPI.gameFlavors.push(GameFlavors.shareware); - } - - if (COM.hipnotic) { - CommonEngineAPI.gameFlavors.push(GameFlavors.hipnotic); - } - - if (COM.rogue) { - CommonEngineAPI.gameFlavors.push(GameFlavors.rogue); - } - - if (COM.registered.value === 1) { - ServerEngineAPI.registered = true; - ClientEngineAPI.registered = true; - } -}); - -/** @enum {string} */ -export const GameFlavors = Object.freeze({ - hipnotic: 'hipnotic', - rogue: 'rogue', - shareware: 'shareware', -}); - -// eslint-disable-next-line jsdoc/require-jsdoc -function internalTraceToGameTrace(trace) { - return { - solid: { - /** @type {boolean} */ - all: trace.allsolid, - /** @type {boolean} */ - start: trace.startsolid, - }, - /** @type {number} */ - fraction: trace.fraction, - plane: { - /** @type {Vector} */ - normal: trace.plane.normal, - /** @type {number} */ - distance: trace.plane.dist, - }, - contents: { - /** @type {boolean} */ - inOpen: !!trace.inopen, - /** @type {boolean} */ - inWater: !!trace.inwater, - }, - /** @type {Vector} final position of the line */ - point: trace.endpos, - /** @type {?import('../../game/id1/entity/BaseEntity.mjs').default} entity */ - entity: trace.ent ? trace.ent.entity : null, - }; -} - -/** - * @param {ClientEdict} entity client entity candidate - * @returns {boolean} true when the entity can be traced against - */ -function isTraceableClientSolid(entity) { - return entity.solid === solid.SOLID_BBOX - || entity.solid === solid.SOLID_SLIDEBOX - || entity.solid === solid.SOLID_BSP - || entity.solid === solid.SOLID_MESH; -} - -/** - * @param {ClientEdict} entity client entity candidate - * @returns {{ mins: Vector, maxs: Vector }} extents used for tracing this entity - */ -function getClientTraceExtents(entity) { - if (entity.model !== null && entity.mins.isOrigin() && entity.maxs.isOrigin()) { - return { - mins: entity.model.mins, - maxs: entity.model.maxs, - }; - } - - return { - mins: entity.mins, - maxs: entity.maxs, - }; -} - -/** - * @param {ClientEdict} entity client entity candidate - * @param {Vector} absmin output minimum bounds - * @param {Vector} absmax output maximum bounds - */ -function computeClientTraceBounds(entity, absmin, absmax) { - const { mins, maxs } = getClientTraceExtents(entity); - - if (!entity.angles.isOrigin()) { - const basis = entity.angles.toRotationMatrix(); - const forward = new Vector(basis[0], basis[1], basis[2]); - const right = new Vector(basis[3], basis[4], basis[5]); - const up = new Vector(basis[6], basis[7], basis[8]); - - const centerX = (mins[0] + maxs[0]) * 0.5; - const centerY = (mins[1] + maxs[1]) * 0.5; - const centerZ = (mins[2] + maxs[2]) * 0.5; - const extentsX = (maxs[0] - mins[0]) * 0.5; - const extentsY = (maxs[1] - mins[1]) * 0.5; - const extentsZ = (maxs[2] - mins[2]) * 0.5; - - const worldCenter = entity.origin.copy() - .add(forward.copy().multiply(centerX)) - .add(right.copy().multiply(centerY)) - .add(up.copy().multiply(centerZ)); - - const worldExtentX = Math.abs(forward[0]) * extentsX + Math.abs(right[0]) * extentsY + Math.abs(up[0]) * extentsZ; - const worldExtentY = Math.abs(forward[1]) * extentsX + Math.abs(right[1]) * extentsY + Math.abs(up[1]) * extentsZ; - const worldExtentZ = Math.abs(forward[2]) * extentsX + Math.abs(right[2]) * extentsY + Math.abs(up[2]) * extentsZ; - - absmin.setTo( - worldCenter[0] - worldExtentX, - worldCenter[1] - worldExtentY, - worldCenter[2] - worldExtentZ, - ); - absmax.setTo( - worldCenter[0] + worldExtentX, - worldCenter[1] + worldExtentY, - worldCenter[2] + worldExtentZ, - ); - return; - } - - absmin.set(entity.origin).add(mins); - absmax.set(entity.origin).add(maxs); -} - -/** - * @param {Vector} traceMins trace minimum bounds - * @param {Vector} traceMaxs trace maximum bounds - * @param {Vector} entityMins entity minimum bounds - * @param {Vector} entityMaxs entity maximum bounds - * @returns {boolean} true when the AABBs overlap - */ -function traceBoundsOverlap(traceMins, traceMaxs, entityMins, entityMaxs) { - return !( - traceMins[0] > entityMaxs[0] - || traceMins[1] > entityMaxs[1] - || traceMins[2] > entityMaxs[2] - || traceMaxs[0] < entityMins[0] - || traceMaxs[1] < entityMins[1] - || traceMaxs[2] < entityMins[2] - ); -} - -/** - * @param {Vector} start trace start - * @param {Vector} end trace end - * @param {CollisionTrace} worldTrace current static-world trace result - * @param {ClientTraceOptions} options client trace options - * @returns {CollisionTrace} best trace including eligible client entities - */ -function traceClientEntities(start, end, worldTrace, options) { - const traceMins = new Vector( - Math.min(start[0], worldTrace.endpos[0]), - Math.min(start[1], worldTrace.endpos[1]), - Math.min(start[2], worldTrace.endpos[2]), - ); - const traceMaxs = new Vector( - Math.max(start[0], worldTrace.endpos[0]), - Math.max(start[1], worldTrace.endpos[1]), - Math.max(start[2], worldTrace.endpos[2]), - ); - const entityMins = new Vector(); - const entityMaxs = new Vector(); - - /** @type {CollisionTrace} */ - let bestTrace = worldTrace; - - for (const entity of CL.state.clientEntities.getEntities()) { - if (entity.num === 0 || entity.free || entity.origin.isInfinite() || entity.model === null) { - continue; - } - - if (!isTraceableClientSolid(entity)) { - continue; - } - - if (options.passEntityId !== null && options.passEntityId !== undefined && entity.num === options.passEntityId) { - continue; - } - - if (options.filter !== null && options.filter !== undefined && !options.filter(entity)) { - continue; - } - - computeClientTraceBounds(entity, entityMins, entityMaxs); - - if (!traceBoundsOverlap(traceMins, traceMaxs, entityMins, entityMaxs)) { - continue; - } - - const trace = SV.collision.clipMoveToEntity({ - // @ts-ignore Client tracing reuses shared narrow-phase helpers with a lightweight ClientEdict adapter. - entity, - num: entity.num, - equals(other) { - return this === other; - }, - }, start, Vector.origin, Vector.origin, bestTrace.endpos); - - if (trace.allsolid || trace.startsolid || trace.fraction < bestTrace.fraction) { - bestTrace = trace; - } - } - - return bestTrace; -} - -export class CommonEngineAPI { - /** - * Indicates whether the game is registered (not shareware). - * @type {boolean} true if the game is registered, false if it is shareware - */ - static registered = false; - - /** @type {GameFlavors[]} */ - static gameFlavors = []; - - /** - * Appends text to the command buffer. - * @param {string} text command strings to be added to the Cmd.text buffer - */ - static AppendConsoleText(text) { - Cmd.text += text; - } - - /** - * Gets a cvar by name. - * @param {string} name name of the variable - * @returns {Cvar} the variable - */ - static GetCvar(name) { - return Cvar.FindVar(name); - } - - /** - * Changes the value of a cvar. - * @param {string} name name of the variable - * @param {string} value value - * @returns {Cvar} the modified variable - */ - static SetCvar(name, value) { - return Cvar.Set(name, value); - } - - /** - * Make sure to free the variable in shutdown(). - * @see {@link Cvar} - * @param {string} name name of the variable - * @param {string} value value - * @param {number} flags optional flags - * @param {?string} description optional description - * @returns {Cvar} the created variable - */ - static RegisterCvar(name, value, flags = 0, description = null) { - return new Cvar(name, value, flags | Cvar.FLAG.GAME, description); - } - - static ConsolePrint(msg, color = new Vector(1.0, 1.0, 1.0)) { - Con.Print(msg, color); - } - - static ConsoleWarning(msg) { - Con.PrintWarning(msg); - } - - static ConsoleError(msg) { - Con.PrintError(msg); - } - - static ConsoleDebug(str) { - Con.DPrint(str); - } - - /** - * Parses QuakeC for model animation information. - * @param {string} qcContent qc content - * @returns {ParsedQC} parsed QC content - */ - static ParseQC(qcContent) { - return Mod.ParseQC(qcContent); - } -}; - -export class ServerEngineAPI extends CommonEngineAPI { - /** - * Make sure to free the variable in shutdown(). - * @see {@link Cvar} - * @param {string} name name of the variable - * @param {string} value value - * @param {number} flags optional flags - * @param {?string} description optional description - * @returns {Cvar} the created variable - */ - static RegisterCvar(name, value, flags = 0, description = null) { - return new Cvar(name, value, flags | Cvar.FLAG.GAME | Cvar.FLAG.SERVER, description); - } - - static BroadcastPrint(str) { - Host.BroadcastPrint(str); - } - - static StartParticles(origin, direction, color, count) { - SV.messages.startParticle(origin, direction, color, count); - } - - static SpawnAmbientSound(origin, sfxName, volume, attenuation) { - let i = 0; - - for (; i < SV.server.soundPrecache.length; i++) { - if (SV.server.soundPrecache[i] === sfxName) { - break; - } - } - - if (i === SV.server.soundPrecache.length) { - Con.Print('no precache: ' + sfxName + '\n'); - return false; - } - - const signon = SV.server.signon; - signon.writeByte(Protocol.svc.spawnstaticsound); - signon.writeCoordVector(origin); - signon.writeByte(i); - signon.writeByte(volume * 255.0); - signon.writeByte(attenuation * 64.0); - - return true; - } - - static StartSound(edict, channel, sfxName, volume, attenuation) { - SV.messages.startSound(edict, channel, sfxName, volume * 255.0, attenuation); - - return true; - } - - static Traceline(start, end, noMonsters, passEdict, mins = null, maxs = null) { - const nullVec = Vector.origin; - const trace = SV.collision.move(start, mins ? mins : nullVec, maxs ? maxs : nullVec, end, noMonsters, passEdict); - return internalTraceToGameTrace(trace); - } - - static TracelineLegacy(start, end, noMonsters, passEdict, mins = null, maxs = null) { - const nullVec = Vector.origin; - return SV.collision.move(start, mins ? mins : nullVec, maxs ? maxs : nullVec, end, noMonsters, passEdict); - } - - /** - * Defines a lightstyle (e.g. aazzaa). - * It will also send an update to all connected clients. - * @param {number} styleId - * @param {string} sequenceString - */ - static Lightstyle(styleId, sequenceString) { - SV.server.lightstyles[styleId] = sequenceString; - - if (SV.server.loading) { - return; - } - - for (const client of SV.svs.spawnedClients()) { - client.message.writeByte(Protocol.svc.lightstyle); - client.message.writeByte(styleId); - client.message.writeString(sequenceString); - } - } - - /** - * Finds out what contents the given point is in, using the active world - * collision backend. - * @param {Vector} origin point in space - * @returns {number} contents - */ - static DetermineStaticWorldContents(origin) { - return SV.collision.staticWorldContents(origin); - } - - /** - * Compatibility alias for DetermineStaticWorldContents. - * @param {Vector} origin point in space - * @returns {number} contents - */ - static DetermineWorldContents(origin) { - return this.DetermineStaticWorldContents(origin); - } - - /** - * Finds out what contents the given point is in. - * @param {Vector} origin point in space - * @returns {number} contents - */ - static DeterminePointContents(origin) { - return this.DetermineStaticWorldContents(origin); - } - - /** - * Set an area portal's open/close state. - * Call this when a door or platform opens/closes to control sound propagation - * and area connectivity. Uses reference counting: multiple entities can hold - * the same portal open. - * @param {number} portalNum portal index (from the BSP area portals lump) - * @param {boolean} open true to open (increment ref count), false to close (decrement) - */ - static SetAreaPortalState(portalNum, open) { - if (SV.server.worldmodel === null) { - return; - } - - SV.server.worldmodel.areaPortals.setPortalState(portalNum, open); - - for (const client of SV.svs.spawnedClients()) { - client.message.writeByte(Protocol.svc.setportalstate); - client.message.writeShort(portalNum); - client.message.writeByte(open ? 1 : 0); - } - } - - /** - * Check if two areas are connected through open portals. - * @param {number} area0 first area index - * @param {number} area1 second area index - * @returns {boolean} true if the areas are connected - */ - static AreasConnected(area0, area1) { - if (SV.server.worldmodel === null) { - return true; - } - - return SV.server.worldmodel.areaPortals.areasConnected(area0, area1); - } - - /** - * Get the auto-assigned portal number for a brush model. - * Returns -1 if the model has no associated portal. - * @param {string} modelName brush model name (e.g. "*1") - * @returns {number} portal number, or -1 if none - */ - static GetModelPortal(modelName) { - if (SV.server.worldmodel === null) { - return -1; - } - - return SV.server.worldmodel.modelPortalMap[modelName] ?? -1; - } - - static ChangeLevel(mapname) { - if (SV.svs.changelevelIssued) { - return; - } - - Cmd.text += `changelevel ${mapname}\n`; - } - - /** - * Finds all edicts around origin in given radius. - * @param {Vector} origin point in space - * @param {number} radius not really a radius, it’s used for creating an axis-aligned bounding box - * @param {(ent: ServerEdict) => boolean} filterFn optional filter function, if provided, will be used to filter entities - * @returns {ServerEdict[]} matching edict - */ - static FindInRadius(origin, radius, filterFn = null) { - const vradius = new Vector(radius, radius, radius); - const mins = origin.copy().subtract(vradius); - const maxs = origin.copy().add(vradius); - - /** @type {ServerEdict[]} */ - const edicts = []; - - for (const ent of SV.area.tree.queryAABB(mins, maxs)) { - if (ent.num === 0 || ent.isFree()) { - continue; - } - - const eorg = origin.copy().subtract(ent.entity.origin.copy().add(ent.entity.mins.copy().add(ent.entity.maxs).multiply(0.5))); - - if (eorg.len() > radius) { - continue; - } - - if (!filterFn || filterFn(ent)) { - edicts.push(ent); - } - } - - return edicts; // used to be a generator, but we need to return an array due to changing linked lists in between - } - - /** - * @param {string} field field name to inspect on each entity - * @param {import('../../shared/GameInterfaces').EdictValueType} value target field value - * @param {number} startEdictId entity number to start searching from - * @deprecated use FindAllByFieldAndValue instead - * @returns {ServerEdict|null} first matching edict, if any - */ - static FindByFieldAndValue(field, value, startEdictId = 0) { // FIXME: startEdictId should be edict? not 100% happy about this - for (let i = startEdictId; i < SV.server.num_edicts; i++) { - /** @type {ServerEdict} */ - const ent = SV.server.edicts[i]; - - if (ent.isFree()) { - continue; - } - - if (ent.entity[field] === value) { - return ent; // FIXME: turn it into yield - } - } - - return null; - } - - // TODO: optimize lookups by using maps for fields such as targetname, etc. - static *FindAllByFieldAndValue(field, value, startEdictId = 0) { // FIXME: startEdictId should be edict? not 100% happy about this - for (let i = startEdictId; i < SV.server.num_edicts; i++) { - /** @type {ServerEdict} */ - const ent = SV.server.edicts[i]; - - if (ent.isFree()) { - continue; - } - - if (ent.entity[field] === value) { - yield ent; - } - } - } - - /** - * @param {function(ServerEdict): boolean} filterFn - * @param {number} startEdictId - * @yields {ServerEdict} - */ - static *FindAllByFilter(filterFn = null, startEdictId = 0) { // FIXME: startEdictId should be edict? not 100% happy about this - for (let i = startEdictId; i < SV.server.num_edicts; i++) { - const ent = SV.server.edicts[i]; - - if (ent.isFree()) { - continue; - } - - if (!filterFn || filterFn(ent)) { - yield ent; - } - } - } - - static *GetClients() { - for (const client of SV.svs.spawnedClients()) { - yield client.edict; - } - } - - static GetEdictById(edictId) { - if (edictId < 0 || edictId >= SV.server.num_edicts) { - return null; - } - - return SV.server.edicts[edictId]; - } - - static PrecacheSound(sfxName) { - console.assert(typeof(sfxName) === 'string', 'sfxName must be a string'); - - if (SV.server.soundPrecache.includes(sfxName)) { - return; - } - - SV.server.soundPrecache.push(sfxName); - } - - static PrecacheModel(modelName) { - console.assert(typeof(modelName) === 'string', 'modelName must be a string'); - - if (SV.server.modelPrecache.includes(modelName)) { - return; - } - - SV.server.modelPrecache.push(modelName); - SV.server.models.push(Mod.ForNameAsync(modelName, true, Mod.scope.server)); // will cause promises in the array - } - - /** - * Spawns an Edict, not an entity. - * @param {string} classname classname of the entity to spawn, needs to be registered - * @param {Record} initialData key-value pairs to initialize the entity with, will be handled by the game code - * @returns {ServerEdict|null} the spawned edict (NOT ENTITY), or null on failure - */ - static SpawnEntity(classname, initialData = {}) { - const edict = ED.Alloc(); - - try { - if (!SV.server.gameAPI.prepareEntity(edict, classname, initialData)) { - edict.freeEdict(); - return null; - } - - if (!SV.server.gameAPI.spawnPreparedEntity(edict)) { - edict.freeEdict(); - return null; - } - } catch (e) { - edict.freeEdict(); - throw e; - } - - return edict; - } - - static IsLoading() { - return SV.server.loading; - } - - /** - * @param {number} tempEntityId temporary entity protocol id - * @param {Vector} origin event origin - * @deprecated use client events instead - */ - static DispatchTempEntityEvent(tempEntityId, origin) { - SV.server.datagram.writeByte(Protocol.svc.temp_entity); - SV.server.datagram.writeByte(tempEntityId); - SV.server.datagram.writeCoordVector(origin); - } - - /** - * @param {number} beamId beam protocol id - * @param {number} edictId source entity id - * @param {Vector} startOrigin beam start position - * @param {Vector} endOrigin beam end position - * @deprecated use client events instead - */ - static DispatchBeamEvent(beamId, edictId, startOrigin, endOrigin) { - SV.server.datagram.writeByte(Protocol.svc.temp_entity); // FIXME: unhappy about this - SV.server.datagram.writeByte(beamId); - SV.server.datagram.writeShort(edictId); - SV.server.datagram.writeCoordVector(startOrigin); - SV.server.datagram.writeCoordVector(endOrigin); - } - - /** - * Makes all clients play the specified audio track. - * @param {number} track audio track number - */ - static PlayTrack(track) { - SV.server.datagram.writeByte(Protocol.svc.cdtrack); - SV.server.datagram.writeByte(track); - SV.server.datagram.writeByte(0); // unused - } - - /** - * Shows the shareware sell screen to all clients. - * Used when completing shareware episode 1. - */ - static ShowSellScreen() { - SV.server.reliable_datagram.writeByte(Protocol.svc.sellscreen); - } - - /** - * Dispatches a client event to the specified receiver. - * NOTE: Events are written to the datagram AFTER an entity update, so referring to an entity that will be removed in the same frame will not work! - * @param {SzBuffer} destination destination to write the event to, can be SV.server.datagram or a client message buffer - * @param {number} eventCode event code, must be understood by the client - * @param {...import('../../shared/GameInterfaces').SerializableType} args any arguments to pass to the client event, will be serialized - */ - static #DispatchClientEventOnDestination(destination, eventCode, ...args) { - console.assert(typeof eventCode === 'number', 'eventCode must be a number'); - - destination.writeByte(Protocol.svc.clientevent); - destination.writeByte(eventCode); - - destination.writeSerializables(args); - } - - /** - * Dispatches a client event to everyone - * @param {boolean} expedited if true, the event will be sent before the next entity update, otherwise it will be sent after the next entity update - * @param {number} eventCode event code, must be understood by the client - * @param {...import('../../shared/GameInterfaces').SerializableType} args any arguments to pass to the client event, will be serialized - */ - static BroadcastClientEvent(expedited, eventCode, ...args) { - this.#DispatchClientEventOnDestination(expedited ? SV.server.datagram : SV.server.expedited_datagram, eventCode, ...args); - } - - /** - * Dispatches a client event to the specified receiver. - * @param {ServerEdict} receiverPlayerEdict the edict of the player to send the event to - * @param {boolean} expedited if true, the event will be sent before the next entity update, otherwise it will be sent after the next entity update - * @param {number} eventCode event code, must be understood by the client - * @param {...import('../../shared/GameInterfaces').SerializableType} args any arguments to pass to the client event, will be serialized - */ - static DispatchClientEvent(receiverPlayerEdict, expedited, eventCode, ...args) { - console.assert(receiverPlayerEdict instanceof ServerEdict && receiverPlayerEdict.isClient(), 'emitterEdict must be a ServerEdict connected to a client'); - - const destination = expedited ? receiverPlayerEdict.getClient().expedited_message : receiverPlayerEdict.getClient().message; - - this.#DispatchClientEventOnDestination(destination, eventCode, ...args); - } - - /** - * Will return a series of waypoints from start to end. - * @param {Vector} start start point - * @param {Vector} end end point - * @returns {Vector[]} array of waypoints from start to end, including start and end - */ - static Navigate(start, end) { - return SV.server.navigation.findPath(start, end); - } - - /** - * Will return a series of waypoints from start to end. - * @param {Vector} start start point - * @param {Vector} end end point - * @returns {Promise} array of waypoints from start to end, including start and end - */ - static async NavigateAsync(start, end) { - return SV.server.navigation.findPathAsync(start, end); - } - - static GetPHS(origin) { - return SV.server.worldmodel.getPhsByPoint(origin); - } - - static GetPVS(origin) { - return SV.server.worldmodel.getPvsByPoint(origin); - } - - /** - * Get the area index for a world position. - * @param {Vector} origin world position - * @returns {number} area index (0 = outside/invalid) - */ - static GetAreaForPoint(origin) { - return SV.server.worldmodel.getLeafForPoint(origin).area; - } - - /** - * Sets the player movement configuration. This is used by the PMove code to determine how the player should move. - * @param {PmoveConfiguration} config pmove profile - */ - static SetPmoveConfiguration(config) { - console.assert(config instanceof PmoveConfiguration, 'config must be an instance of PmoveConfiguration'); - - SV.pmove.configuration = config; - } - - static get maxplayers() { - return SV.svs.maxclients; - } - - /** - * Server game event bus, will be reset on every map load. - * @returns {EventBus} event bus - */ - static get eventBus() { - return SV.server.eventBus; - } -}; - -export class ClientEngineAPI extends CommonEngineAPI { - /** - * Make sure to free the variable in shutdown(). - * @see {@link Cvar} - * @param {string} name name of the variable - * @param {string} value value - * @param {number} flags optional flags - * @param {?string} description optional description - * @returns {Cvar} the created variable - */ - static RegisterCvar(name, value, flags = 0, description = null) { - return new Cvar(name, value, flags | Cvar.FLAG.GAME | Cvar.FLAG.CLIENT, description); - } - - /** - * @param {string} name command name - * @param {Function} callback callback function - */ - static RegisterCommand(name, callback) { - Cmd.AddCommand(name, callback); - } - - // eslint-disable-next-line no-unused-vars - static UnregisterCommand(name) { - // TODO: implement - console.assert(false, 'UnregisterCommand is not implemented yet'); - } - - /** - * Loads texture from lump. - * @param {string} name lump name - * @returns {GLTexture} texture - */ - static LoadPicFromLump(name) { - return Draw.LoadPicFromLumpDeferred(name); - } - - /** - * Loads texture from WAD. - * @param {string} name lump name - * @returns {GLTexture} texture - */ - static LoadPicFromWad(name) { - return Draw.LoadPicFromWad(name); - } - - /** - * Loads texture from file. - * @param {string} filename texture filename - * @returns {Promise} texture - */ - static async LoadPicFromFile(filename) { - return await Draw.LoadPicFromFile(filename); - } - - /** - * Plays a sound effect. - * @param {SFX} sfx sound effect - */ - static PlaySound(sfx) { - S.LocalSound(sfx); - } - - /** - * Loads a sound effect. Can be used with PlaySound. - * @param {string} sfxName sound effect name, e.g. "misc/talk.wav" - * @returns {SFX} sound effect - */ - static LoadSound(sfxName) { - return S.PrecacheSound(sfxName); - } - - /** - * Draws a picture at the specified position. - * @param {number} x x position - * @param {number} y y position - * @param {GLTexture} pic pic texture to draw - * @param {number} scale optional scale (default: 1.0) - */ - static DrawPic(x, y, pic, scale = 1.0) { - Draw.Pic(x, y, pic, scale); - } - - /** - * Draws a string on the screen at the specified position. - * @param {number} x x position - * @param {number} y y position - * @param {string} str string - * @param {number} scale optional scale (default: 1.0) - * @param {Vector} color optional color in RGB format (default: white) - */ - static DrawString(x, y, str, scale = 1.0, color = new Vector(1.0, 1.0, 1.0)) { - Draw.String(x, y, str, scale, color); - } - - /** - * Fills a rectangle with a solid color. - * @param {number} x The x position. - * @param {number} y The y position. - * @param {number} w The width of the rectangle. - * @param {number} h The height of the rectangle. - * @param {Vector} c The color index. - * @param {number} a Optional alpha value (default is 1.0). - */ - static DrawRect(x, y, w, h, c, a = 1.0) { - Draw.Fill(x, y, w, h, c, a); - } - - /** - * @param {number} index index on the palette, must be in range [0, 255] - * @returns {Vector} RGB color vector - */ - static IndexToRGB(index) { - console.assert(typeof index === 'number', 'index must be a number'); - console.assert(index >= 0 && index < 256, 'index must be in range [0, 255]'); - - return new Vector( - W.d_8to24table_u8[index * 3] / 256, - W.d_8to24table_u8[index * 3 + 1] / 256, - W.d_8to24table_u8[index * 3 + 2] / 256, - ); - } - - /** - * Translates world coordinates to screen coordinates. - * @param {Vector} origin position in world coordinates - * @returns {Vector|null} position in screen coordinates, or null if the point is behind the camera - */ - static WorldToScreen(origin) { - return R.WorldToScreen(origin); - } - - /** - * Gets all entities in the game. Both client-only and server entities. - * @param {(ent: ClientEdict) => boolean} filter filter function, if provided, will be used to filter entities - * @yields {ClientEdict} entity - */ - static *GetEntities(filter = null) { - for (const entity of CL.state.clientEntities.getEntities()) { - if (filter && !filter(entity)) { - continue; - } - - yield entity; - } - } - - /** - * Gets all entities staged for rendering. Both client-only and server entities. - * @param {(ent: ClientEdict) => boolean} filter filter function, if provided, will be used to filter entities - * @yields {ClientEdict} entity - */ - static *GetVisibleEntities(filter = null) { - for (const entity of CL.state.clientEntities.getVisibleEntities()) { - if (filter && !filter(entity)) { - continue; - } - - yield entity; - } - } - - /** - * Performs a trace line in the client game world. - * By default this traces static world geometry only. - * Keep this legacy entry point aligned with the server-side Traceline name so - * client tracing can grow into entity-aware behavior later without another API - * rename. - * @param {Vector} start start position - * @param {Vector} end end position - * @param {ClientTraceOptions} [options] optional entity-tracing options - * @returns {GameTrace} trace result - */ - static Traceline(start, end, options = null) { - const worldTrace = SV.collision.traceWorldLine(start, end); - - if (options === null || options.includeEntities !== true) { - return internalTraceToGameTrace(worldTrace); - } - - return internalTraceToGameTrace(traceClientEntities(start, end, worldTrace, options)); - } - - /** - * Allocates a dynamic light for the given entity Id. - * @param {number} entityId entity Id, can be 0 - * @returns {ClientDlight} dynamic light instance - */ - static AllocDlight(entityId) { - return CL.state.clientEntities.allocateDynamicLight(entityId); - } - - /** - * Allocates a new client entity. - * This is a client-side entity, not a server-side edict. - * Make sure to invoke spawn() when ready. - * Make sure to use setOrigin() to set the position of the entity. - * @returns {ClientEdict} a new client entity - */ - static AllocEntity() { - return CL.state.clientEntities.allocateClientEntity(); - } - - /** - * Spawns a rocket trail effect from start to end - * @param {Vector} start e.g. previous origin - * @param {Vector} end e.g. current origin - * @param {number} type type of the trail - */ - static RocketTrail(start, end, type) { - R.RocketTrail(start, end, type); - } - - /** - * Places a decal in the world. - * @param {Vector} origin position to place decal at - * @param {Vector} normal normal/orientation - * @param {GLTexture} texture texture to place - */ - static PlaceDecal(origin, normal, texture) { - R.PlaceDecal(origin, normal, texture); - } - - /** - * Gets model by name. Must be precached first. - * @param {string} modelName model name - * @returns {BaseModel} model index - */ - static ModForName(modelName) { - console.assert(typeof modelName === 'string', 'modelName must be a string'); - - for (let i = 1; i < CL.state.model_precache.length; i++) { - if (CL.state.model_precache[i].name === modelName) { - return CL.state.model_precache[i]; - } - } - - throw new HostError(`ClientEngineAPI.ModForName: ${modelName} not precached`); - } - - static ModById(id) { - console.assert(typeof id === 'number' && id > 0, 'id must be a number and greater than 0'); - - if (CL.state.model_precache[id]) { - return CL.state.model_precache[id]; - } - - throw new HostError(`ClientEngineAPI.ModById: ${id} not found`); - } - - /** - * @param {number} slot see Def.contentShift - * @param {Vector} color RGB color vector - * @param {number} alpha alpha value, default is 0.5 - */ - static ContentShift(slot, color, alpha = 0.5) { - V.ContentShift(slot + 4, color, alpha); - } - - /** - * Sets the player movement configuration. This is used by the PMove code to determine how the player will move. - * @param {PmoveConfiguration} config pmove profile - */ - static SetPmoveConfiguration(config) { - console.assert(config instanceof PmoveConfiguration, 'config must be an instance of PmoveConfiguration'); - - CL.pmove.configuration = config; - } - - static M = null; - - static CL = { - get viewangles() { - return CL.state.viewangles.copy(); - }, - get vieworigin() { - return CL.state.viewent.origin.copy(); - }, - get maxclients() { - return CL.state.maxclients; - }, - get levelname() { - return CL.state.levelname; - }, - get entityNum() { - return CL.state.viewentity; - }, - /** - * local time, not game time! If you are looking for SV.server.time, check gametime - * @returns {number} local time - */ - get time() { // FIXME: rename to localtime to make the distinction clearer - return CL.state.time; - }, - /** - * latest SV.server.time, NOT local time! - * @returns {number} game time - */ - get gametime() { - return CL.state.clientMessages.mtime[0]; - }, - get frametime() { - return Host.frametime; - }, - get intermission() { - return CL.state.intermission > 0; - }, - set intermission(value) { - CL.state.intermission = value ? 1 : 0; - }, - score(/** @type {number} */ num) { - return CL.state.scores[num]; - }, - get serverInfo() { - return CL.cls.serverInfo; - }, - }; - - static VID = { - get width() { return VID.width; }, - get height() { return VID.height; }, - get pixelRatio() { return VID.pixelRatio; }, - }; - - static Key = { - /** - * Gets the string representation of a key binding, e.g. "+attack" -> "mouse1" - * @param {string} binding key binding string - * @returns {string|null} string representation of the key binding, or null if not found - */ - getKeyForBinding(binding) { - return Key.BindingToString(binding); - }, - }; - - static SCR = { - /** - * @returns {number} the current view size (important ones for the status bar are 100, 110, 120) - */ - get viewsize() { return /** @type {number} */ (SCR.viewsize.value); }, - }; - - static get eventBus() { - return CL.state.eventBus; - }; -}; diff --git a/source/engine/common/GameAPIs.ts b/source/engine/common/GameAPIs.ts new file mode 100644 index 00000000..927668cc --- /dev/null +++ b/source/engine/common/GameAPIs.ts @@ -0,0 +1,1103 @@ +import type BaseEntity from '../../game/id1/entity/BaseEntity.mjs'; +import type { EdictValueType, SerializableType } from '../../shared/GameInterfaces.ts'; +import type { ClientDlight, ClientEdict } from '../client/ClientEntities.mjs'; +import type { GLTexture } from '../client/GL.mjs'; +import type { SzBuffer } from '../network/MSG.ts'; +import type ParsedQC from './model/parsers/ParsedQC.ts'; +import type { BaseModel } from './model/BaseModel.ts'; +import type { Visibility } from './model/BSP.ts'; + +import { PmoveConfiguration } from '../../shared/Pmove.ts'; +import Vector from '../../shared/Vector.ts'; +import { solid } from '../../shared/Defs.ts'; +import Key from '../client/Key.mjs'; +import { SFX as SFXValue } from '../client/Sound.mjs'; +import VID from '../client/VID.mjs'; +import * as Protocol from '../network/Protocol.ts'; +import { EventBus, eventBus, registry } from '../registry.mjs'; +import { ED, ServerEdict as ServerEdictValue } from '../server/Edict.mjs'; +import Cmd from './Cmd.ts'; +import Cvar from './Cvar.ts'; +import { HostError } from './Errors.ts'; +import Mod from './Mod.ts'; +import W from './W.ts'; + +type ServerEdict = ServerEdictValue; + +interface ClientTraceOptions { + readonly includeEntities?: boolean; + readonly passEntityId?: number | null; + readonly filter?: ((entity: ClientEdict) => boolean) | null; +} + +interface GameTrace { + readonly solid: { + readonly all: boolean; + readonly start: boolean; + }; + readonly fraction: number; + readonly plane: { + readonly normal: Vector; + readonly distance: number; + }; + readonly contents: { + readonly inOpen: boolean; + readonly inWater: boolean; + }; + readonly point: Vector; + readonly entity: BaseEntity | ClientEdict | null; +} + +interface InternalTraceLike { + readonly allsolid: boolean; + readonly startsolid: boolean; + readonly fraction: number; + readonly plane: { + readonly normal: Vector; + readonly dist: number; + }; + readonly inopen: boolean; + readonly inwater: boolean; + readonly endpos: Vector; + readonly ent: { + readonly entity: BaseEntity | ClientEdict; + } | null; +} + +interface ClientTraceEntityAdapter { + readonly entity: ClientEdict; + readonly num: number; + equals(other: unknown): boolean; +} + +type ServerEntityFilter = ((entity: ServerEdict) => boolean) | null; +type ClientEntityFilter = ((entity: ClientEdict) => boolean) | null; +type CommandCallback = (...args: string[]) => void | Promise; + +let { CL, COM, Con, Draw, Host, R, S, SCR, SV, V } = registry; + +eventBus.subscribe('registry.frozen', () => { + ({ CL, COM, Con, Draw, Host, R, S, SCR, SV, V } = registry); +}); + +eventBus.subscribe('com.ready', () => { + if (!COM.registered) { + CommonEngineAPI.gameFlavors.push(GameFlavors.shareware); + } + + if (COM.hipnotic) { + CommonEngineAPI.gameFlavors.push(GameFlavors.hipnotic); + } + + if (COM.rogue) { + CommonEngineAPI.gameFlavors.push(GameFlavors.rogue); + } + + if (COM.registered.value === 1) { + ServerEngineAPI.registered = true; + ClientEngineAPI.registered = true; + } +}); + +export enum GameFlavors { + hipnotic = 'hipnotic', + rogue = 'rogue', + shareware = 'shareware', +} + +// eslint-disable-next-line jsdoc/require-jsdoc +function internalTraceToGameTrace(trace: InternalTraceLike): GameTrace { + return { + solid: { + all: trace.allsolid, + start: trace.startsolid, + }, + fraction: trace.fraction, + plane: { + normal: trace.plane.normal, + distance: trace.plane.dist, + }, + contents: { + inOpen: !!trace.inopen, + inWater: !!trace.inwater, + }, + point: trace.endpos, + entity: trace.ent ? trace.ent.entity : null, + }; +} + +/** + * Return whether the entity can be traced against. + * @returns True when the entity can be traced against. + */ +function isTraceableClientSolid(entity: ClientEdict): boolean { + return entity.solid === solid.SOLID_BBOX + || entity.solid === solid.SOLID_SLIDEBOX + || entity.solid === solid.SOLID_BSP + || entity.solid === solid.SOLID_MESH; +} + +/** + * Return the extents used for tracing the entity. + * @returns The mins/maxs extents used for tracing. + */ +function getClientTraceExtents(entity: ClientEdict): { mins: Vector; maxs: Vector } { + if (entity.model !== null && entity.mins.isOrigin() && entity.maxs.isOrigin()) { + return { + mins: entity.model.mins, + maxs: entity.model.maxs, + }; + } + + return { + mins: entity.mins, + maxs: entity.maxs, + }; +} + +/** + * Compute the client's world-space trace bounds. + */ +function computeClientTraceBounds(entity: ClientEdict, absmin: Vector, absmax: Vector): void { + const { mins, maxs } = getClientTraceExtents(entity); + + if (!entity.angles.isOrigin()) { + const basis = entity.angles.toRotationMatrix(); + const forward = new Vector(basis[0], basis[1], basis[2]); + const right = new Vector(basis[3], basis[4], basis[5]); + const up = new Vector(basis[6], basis[7], basis[8]); + + const centerX = (mins[0] + maxs[0]) * 0.5; + const centerY = (mins[1] + maxs[1]) * 0.5; + const centerZ = (mins[2] + maxs[2]) * 0.5; + const extentsX = (maxs[0] - mins[0]) * 0.5; + const extentsY = (maxs[1] - mins[1]) * 0.5; + const extentsZ = (maxs[2] - mins[2]) * 0.5; + + const worldCenter = entity.origin.copy() + .add(forward.copy().multiply(centerX)) + .add(right.copy().multiply(centerY)) + .add(up.copy().multiply(centerZ)); + + const worldExtentX = Math.abs(forward[0]) * extentsX + Math.abs(right[0]) * extentsY + Math.abs(up[0]) * extentsZ; + const worldExtentY = Math.abs(forward[1]) * extentsX + Math.abs(right[1]) * extentsY + Math.abs(up[1]) * extentsZ; + const worldExtentZ = Math.abs(forward[2]) * extentsX + Math.abs(right[2]) * extentsY + Math.abs(up[2]) * extentsZ; + + absmin.setTo( + worldCenter[0] - worldExtentX, + worldCenter[1] - worldExtentY, + worldCenter[2] - worldExtentZ, + ); + absmax.setTo( + worldCenter[0] + worldExtentX, + worldCenter[1] + worldExtentY, + worldCenter[2] + worldExtentZ, + ); + return; + } + + absmin.set(entity.origin).add(mins); + absmax.set(entity.origin).add(maxs); +} + +/** + * Return whether the two AABBs overlap. + * @returns True when the AABBs overlap. + */ +function traceBoundsOverlap(traceMins: Vector, traceMaxs: Vector, entityMins: Vector, entityMaxs: Vector): boolean { + return !( + traceMins[0] > entityMaxs[0] + || traceMins[1] > entityMaxs[1] + || traceMins[2] > entityMaxs[2] + || traceMaxs[0] < entityMins[0] + || traceMaxs[1] < entityMins[1] + || traceMaxs[2] < entityMins[2] + ); +} + +/** + * Resolve the best trace including eligible client entities. + * @returns The best trace including eligible client entities. + */ +function traceClientEntities( + start: Vector, + end: Vector, + worldTrace: InternalTraceLike, + options: ClientTraceOptions, +): InternalTraceLike { + const traceMins = new Vector( + Math.min(start[0], worldTrace.endpos[0]), + Math.min(start[1], worldTrace.endpos[1]), + Math.min(start[2], worldTrace.endpos[2]), + ); + const traceMaxs = new Vector( + Math.max(start[0], worldTrace.endpos[0]), + Math.max(start[1], worldTrace.endpos[1]), + Math.max(start[2], worldTrace.endpos[2]), + ); + const entityMins = new Vector(); + const entityMaxs = new Vector(); + + let bestTrace: InternalTraceLike = worldTrace; + + for (const entity of CL.state.clientEntities.getEntities()) { + if (entity.num === 0 || entity.free || entity.origin.isInfinite() || entity.model === null) { + continue; + } + + if (!isTraceableClientSolid(entity)) { + continue; + } + + if (options.passEntityId !== null && options.passEntityId !== undefined && entity.num === options.passEntityId) { + continue; + } + + if (options.filter !== null && options.filter !== undefined && !options.filter(entity)) { + continue; + } + + computeClientTraceBounds(entity, entityMins, entityMaxs); + + if (!traceBoundsOverlap(traceMins, traceMaxs, entityMins, entityMaxs)) { + continue; + } + + const adapter: ClientTraceEntityAdapter = { + entity, + num: entity.num, + equals(other: unknown): boolean { + return this === other; + }, + }; + const trace = SV.collision.clipMoveToEntity( + // @ts-ignore Client tracing reuses shared narrow-phase helpers with a lightweight ClientEdict adapter. + adapter, + start, + Vector.origin, + Vector.origin, + bestTrace.endpos, + ) as InternalTraceLike; + + if (trace.allsolid || trace.startsolid || trace.fraction < bestTrace.fraction) { + bestTrace = trace; + } + } + + return bestTrace; +} + +export class CommonEngineAPI { + static registered = false; + static gameFlavors: GameFlavors[] = []; + + /** + * Append text to the command buffer. + */ + static AppendConsoleText(text: string): void { + Cmd.text += text; + } + + /** + * Return a cvar by name. + * @returns The variable. + */ + static GetCvar(name: string): Cvar | null { + return Cvar.FindVar(name); + } + + /** + * Change the value of a cvar. + * @returns The modified variable. + */ + static SetCvar(name: string, value: string): Cvar { + return Cvar.Set(name, value); + } + + /** + * Make sure to free the variable in shutdown(). + * @see {@link Cvar} + * @returns The created variable. + */ + static RegisterCvar(name: string, value: string, flags = 0, description: string | null = null): Cvar { + return new Cvar(name, value, flags | Cvar.FLAG.GAME, description); + } + + static ConsolePrint(msg: string, color = new Vector(1.0, 1.0, 1.0)): void { + Con.Print(msg, color); + } + + static ConsoleWarning(msg: string): void { + Con.PrintWarning(msg); + } + + static ConsoleError(msg: string): void { + Con.PrintError(msg); + } + + static ConsoleDebug(str: string): void { + Con.DPrint(str); + } + + /** + * Parse QuakeC for model animation information. + * @returns Parsed QC content. + */ + static ParseQC(qcContent: string): ParsedQC { + return Mod.ParseQC(qcContent); + } +} + +export class ServerEngineAPI extends CommonEngineAPI { + /** + * Make sure to free the variable in shutdown(). + * @see {@link Cvar} + * @returns The created variable. + */ + static override RegisterCvar(name: string, value: string, flags = 0, description: string | null = null): Cvar { + return new Cvar(name, value, flags | Cvar.FLAG.GAME | Cvar.FLAG.SERVER, description); + } + + static BroadcastPrint(str: string): void { + Host.BroadcastPrint(str); + } + + static StartParticles(origin: Vector, direction: Vector, color: number, count: number): void { + SV.messages.startParticle(origin, direction, color, count); + } + + static SpawnAmbientSound(origin: Vector, sfxName: string, volume: number, attenuation: number): boolean { + let index = 0; + + for (; index < SV.server.soundPrecache.length; index++) { + if (SV.server.soundPrecache[index] === sfxName) { + break; + } + } + + if (index === SV.server.soundPrecache.length) { + Con.Print(`no precache: ${sfxName}\n`); + return false; + } + + const signon = SV.server.signon; + signon.writeByte(Protocol.svc.spawnstaticsound); + signon.writeCoordVector(origin); + signon.writeByte(index); + signon.writeByte(volume * 255.0); + signon.writeByte(attenuation * 64.0); + + return true; + } + + static StartSound(edict: ServerEdict, channel: number, sfxName: string, volume: number, attenuation: number): boolean { + SV.messages.startSound(edict, channel, sfxName, volume * 255.0, attenuation); + + return true; + } + + static Traceline( + start: Vector, + end: Vector, + noMonsters: boolean, + passEdict: ServerEdict | null, + mins: Vector | null = null, + maxs: Vector | null = null, + ): GameTrace { + const nullVec = Vector.origin; + const trace = SV.collision.move(start, mins ? mins : nullVec, maxs ? maxs : nullVec, end, noMonsters, passEdict) as InternalTraceLike; + return internalTraceToGameTrace(trace); + } + + static TracelineLegacy( + start: Vector, + end: Vector, + noMonsters: boolean, + passEdict: ServerEdict | null, + mins: Vector | null = null, + maxs: Vector | null = null, + ): InternalTraceLike { + const nullVec = Vector.origin; + return SV.collision.move(start, mins ? mins : nullVec, maxs ? maxs : nullVec, end, noMonsters, passEdict) as InternalTraceLike; + } + + /** + * Define a lightstyle (e.g. aazzaa). + * It will also send an update to all connected clients. + */ + static Lightstyle(styleId: number, sequenceString: string): void { + SV.server.lightstyles[styleId] = sequenceString; + + if (SV.server.loading) { + return; + } + + for (const client of SV.svs.spawnedClients()) { + client.message.writeByte(Protocol.svc.lightstyle); + client.message.writeByte(styleId); + client.message.writeString(sequenceString); + } + } + + /** + * Find what contents the given point is in, using the active world collision backend. + * @returns The contents constant. + */ + static DetermineStaticWorldContents(origin: Vector): number { + return SV.collision.staticWorldContents(origin); + } + + /** + * Compatibility alias for DetermineStaticWorldContents. + * @returns The contents constant. + */ + static DetermineWorldContents(origin: Vector): number { + return this.DetermineStaticWorldContents(origin); + } + + /** + * Find what contents the given point is in. + * @returns The contents constant. + */ + static DeterminePointContents(origin: Vector): number { + return this.DetermineStaticWorldContents(origin); + } + + /** + * Set an area portal's open or close state. + */ + static SetAreaPortalState(portalNum: number, open: boolean): void { + if (SV.server.worldmodel === null) { + return; + } + + SV.server.worldmodel.areaPortals.setPortalState(portalNum, open); + + for (const client of SV.svs.spawnedClients()) { + client.message.writeByte(Protocol.svc.setportalstate); + client.message.writeShort(portalNum); + client.message.writeByte(open ? 1 : 0); + } + } + + /** + * Check whether two areas are connected through open portals. + * @returns True when the areas are connected. + */ + static AreasConnected(area0: number, area1: number): boolean { + if (SV.server.worldmodel === null) { + return true; + } + + return SV.server.worldmodel.areaPortals.areasConnected(area0, area1); + } + + /** + * Return the auto-assigned portal number for a brush model. + * @returns The portal number, or `-1` when none exists. + */ + static GetModelPortal(modelName: string): number { + if (SV.server.worldmodel === null) { + return -1; + } + + return SV.server.worldmodel.modelPortalMap[modelName] ?? -1; + } + + static ChangeLevel(mapname: string): void { + if (SV.svs.changelevelIssued) { + return; + } + + Cmd.text += `changelevel ${mapname}\n`; + } + + /** + * Find all edicts around the origin within the given radius. + * @returns Matching edicts. + */ + static FindInRadius(origin: Vector, radius: number, filterFn: ServerEntityFilter = null): ServerEdict[] { + const vradius = new Vector(radius, radius, radius); + const mins = origin.copy().subtract(vradius); + const maxs = origin.copy().add(vradius); + const edicts: ServerEdict[] = []; + + for (const ent of SV.area.tree.queryAABB(mins, maxs)) { + if (ent.num === 0 || ent.isFree()) { + continue; + } + + const eorg = origin.copy().subtract(ent.entity.origin.copy().add(ent.entity.mins.copy().add(ent.entity.maxs).multiply(0.5))); + + if (eorg.len() > radius) { + continue; + } + + if (!filterFn || filterFn(ent)) { + edicts.push(ent); + } + } + + return edicts; // used to be a generator, but we need to return an array due to changing linked lists in between + } + + /** + * Find the first edict that matches the field value. + * @deprecated use FindAllByFieldAndValue instead + * @returns The first matching edict, if any. + */ + static FindByFieldAndValue(field: string, value: EdictValueType, startEdictId = 0): ServerEdict | null { + for (let index = startEdictId; index < SV.server.num_edicts; index++) { + const ent = SV.server.edicts[index] as ServerEdict; + + if (ent.isFree()) { + continue; + } + + if (ent.entity[field] === value) { + return ent; // FIXME: turn it into yield + } + } + + return null; + } + + // TODO: optimize lookups by using maps for fields such as targetname, etc. + /** + * Yield all edicts whose field matches the supplied value. + * @yields Matching edicts. + */ + static *FindAllByFieldAndValue(field: string, value: EdictValueType, startEdictId = 0): Generator { // FIXME: startEdictId should be edict? not 100% happy about this + for (let index = startEdictId; index < SV.server.num_edicts; index++) { + const ent = SV.server.edicts[index] as ServerEdict; + + if (ent.isFree()) { + continue; + } + + if (ent.entity[field] === value) { + yield ent; + } + } + } + + /** + * Yield all edicts that match the filter. + * @yields Matching edicts. + */ + static *FindAllByFilter(filterFn: ServerEntityFilter = null, startEdictId = 0): Generator { // FIXME: startEdictId should be edict? not 100% happy about this + for (let index = startEdictId; index < SV.server.num_edicts; index++) { + const ent = SV.server.edicts[index] as ServerEdict; + + if (ent.isFree()) { + continue; + } + + if (!filterFn || filterFn(ent)) { + yield ent; + } + } + } + + /** + * Yield all connected client edicts. + * @yields Connected client edicts. + */ + static *GetClients(): Generator { + for (const client of SV.svs.spawnedClients()) { + yield client.edict; + } + } + + static GetEdictById(edictId: number): ServerEdict | null { + if (edictId < 0 || edictId >= SV.server.num_edicts) { + return null; + } + + return SV.server.edicts[edictId]; + } + + static PrecacheSound(sfxName: string): void { + console.assert(typeof sfxName === 'string', 'sfxName must be a string'); + + if (SV.server.soundPrecache.includes(sfxName)) { + return; + } + + SV.server.soundPrecache.push(sfxName); + } + + static PrecacheModel(modelName: string): void { + console.assert(typeof modelName === 'string', 'modelName must be a string'); + + if (SV.server.modelPrecache.includes(modelName)) { + return; + } + + SV.server.modelPrecache.push(modelName); + SV.server.models.push(Mod.ForNameAsync(modelName, true, Mod.scope.server)); // will cause promises in the array + } + + /** + * Spawn an Edict, not an entity. + * @returns The spawned edict, or `null` on failure. + */ + static SpawnEntity(classname: string, initialData: Record = {}): ServerEdict | null { + const edict = ED.Alloc(); + + try { + if (!SV.server.gameAPI.prepareEntity(edict, classname, initialData)) { + edict.freeEdict(); + return null; + } + + if (!SV.server.gameAPI.spawnPreparedEntity(edict)) { + edict.freeEdict(); + return null; + } + } catch (e) { + edict.freeEdict(); + throw e; + } + + return edict; + } + + static IsLoading(): boolean { + return SV.server.loading; + } + + /** + * Dispatch a temporary entity protocol event. + * @deprecated use client events instead + */ + static DispatchTempEntityEvent(tempEntityId: number, origin: Vector): void { + SV.server.datagram.writeByte(Protocol.svc.temp_entity); + SV.server.datagram.writeByte(tempEntityId); + SV.server.datagram.writeCoordVector(origin); + } + + /** + * Dispatch a beam protocol event. + * @deprecated use client events instead + */ + static DispatchBeamEvent(beamId: number, edictId: number, startOrigin: Vector, endOrigin: Vector): void { + SV.server.datagram.writeByte(Protocol.svc.temp_entity); // FIXME: unhappy about this + SV.server.datagram.writeByte(beamId); + SV.server.datagram.writeShort(edictId); + SV.server.datagram.writeCoordVector(startOrigin); + SV.server.datagram.writeCoordVector(endOrigin); + } + + /** + * Make all clients play the specified audio track. + */ + static PlayTrack(track: number): void { + SV.server.datagram.writeByte(Protocol.svc.cdtrack); + SV.server.datagram.writeByte(track); + SV.server.datagram.writeByte(0); // unused + } + + /** + * Show the shareware sell screen to all clients. + */ + static ShowSellScreen(): void { + SV.server.reliable_datagram.writeByte(Protocol.svc.sellscreen); + } + + /** + * Dispatch a client event to the specified destination buffer. + */ + static #DispatchClientEventOnDestination(destination: SzBuffer, eventCode: number, ...args: SerializableType[]): void { + console.assert(typeof eventCode === 'number', 'eventCode must be a number'); + + destination.writeByte(Protocol.svc.clientevent); + destination.writeByte(eventCode); + + destination.writeSerializables(args); + } + + /** + * Dispatch a client event to everyone. + */ + static BroadcastClientEvent(expedited: boolean, eventCode: number, ...args: SerializableType[]): void { + this.#DispatchClientEventOnDestination(expedited ? SV.server.datagram : SV.server.expedited_datagram, eventCode, ...args); + } + + /** + * Dispatch a client event to the specified receiver. + */ + static DispatchClientEvent(receiverPlayerEdict: ServerEdict, expedited: boolean, eventCode: number, ...args: SerializableType[]): void { + console.assert(receiverPlayerEdict instanceof ServerEdictValue && receiverPlayerEdict.isClient(), 'emitterEdict must be a ServerEdict connected to a client'); + + const destination = expedited ? receiverPlayerEdict.getClient().expedited_message : receiverPlayerEdict.getClient().message; + + this.#DispatchClientEventOnDestination(destination, eventCode, ...args); + } + + /** + * Return a series of waypoints from start to end. + * @returns Waypoints from start to end, including start and end. + */ + static Navigate(start: Vector, end: Vector): Vector[] { + return SV.server.navigation.findPath(start, end); + } + + /** + * Return a series of waypoints from start to end asynchronously. + * @returns Waypoints from start to end, including start and end. + */ + static NavigateAsync(start: Vector, end: Vector): Promise { + return SV.server.navigation.findPathAsync(start, end); + } + + static GetPHS(origin: Vector): Visibility { + return SV.server.worldmodel.getPhsByPoint(origin); + } + + static GetPVS(origin: Vector): Visibility { + return SV.server.worldmodel.getPvsByPoint(origin); + } + + /** + * Get the area index for a world position. + * @returns The area index, where `0` means outside or invalid. + */ + static GetAreaForPoint(origin: Vector): number { + return SV.server.worldmodel.getLeafForPoint(origin).area; + } + + /** + * Set the player movement configuration. This is used by the PMove code to determine how the player should move. + */ + static SetPmoveConfiguration(config: PmoveConfiguration): void { + console.assert(config instanceof PmoveConfiguration, 'config must be an instance of PmoveConfiguration'); + + SV.pmove.configuration = config; + } + + static get maxplayers(): number { + return SV.svs.maxclients; + } + + /** + * Server game event bus, reset on every map load. + * @returns The active server event bus. + */ + static get eventBus(): EventBus { + return SV.server.eventBus; + } +} + +export class ClientEngineAPI extends CommonEngineAPI { + /** + * Make sure to free the variable in shutdown(). + * @see {@link Cvar} + * @returns The created variable. + */ + static override RegisterCvar(name: string, value: string, flags = 0, description: string | null = null): Cvar { + return new Cvar(name, value, flags | Cvar.FLAG.GAME | Cvar.FLAG.CLIENT, description); + } + + static RegisterCommand(name: string, callback: CommandCallback): void { + Cmd.AddCommand(name, callback); + } + + static UnregisterCommand(name: string): void { + void name; + // TODO: implement + console.assert(false, 'UnregisterCommand is not implemented yet'); + } + + /** + * Load a texture from a lump. + * @returns The loaded texture. + */ + static LoadPicFromLump(name: string): GLTexture { + return Draw.LoadPicFromLumpDeferred(name); + } + + /** + * Load a texture from a WAD. + * @returns The loaded texture. + */ + static LoadPicFromWad(name: string): GLTexture { + return Draw.LoadPicFromWad(name); + } + + /** + * Load a texture from a file. + * @returns The loaded texture. + */ + static LoadPicFromFile(filename: string): Promise { + return Draw.LoadPicFromFile(filename); + } + + /** + * Play a sound effect. + */ + static PlaySound(sfx: SFXValue): void { + S.LocalSound(sfx); + } + + /** + * Load a sound effect. Can be used with PlaySound. + * @returns The loaded sound effect. + */ + static LoadSound(sfxName: string): SFXValue { + return S.PrecacheSound(sfxName); + } + + /** + * Draw a picture at the specified position. + */ + static DrawPic(x: number, y: number, pic: GLTexture, scale = 1.0): void { + Draw.Pic(x, y, pic, scale); + } + + /** + * Draw a string on the screen at the specified position. + */ + static DrawString(x: number, y: number, str: string, scale = 1.0, color = new Vector(1.0, 1.0, 1.0)): void { + Draw.String(x, y, str, scale, color); + } + + /** + * Fill a rectangle with a solid color. + */ + static DrawRect(x: number, y: number, w: number, h: number, c: Vector, a = 1.0): void { + Draw.Fill(x, y, w, h, c, a); + } + + /** + * Translate a palette index into an RGB color vector. + * @returns The RGB color vector. + */ + static IndexToRGB(index: number): Vector { + console.assert(typeof index === 'number', 'index must be a number'); + console.assert(index >= 0 && index < 256, 'index must be in range [0, 255]'); + + return new Vector( + W.d_8to24table_u8[index * 3] / 256, + W.d_8to24table_u8[index * 3 + 1] / 256, + W.d_8to24table_u8[index * 3 + 2] / 256, + ); + } + + /** + * Translate world coordinates to screen coordinates. + * @returns Screen coordinates, or `null` if the point is behind the camera. + */ + static WorldToScreen(origin: Vector): Vector | null { + return R.WorldToScreen(origin); + } + + /** + * Get all entities in the game. Both client-only and server entities. + * @yields Client entities. + */ + static *GetEntities(filter: ClientEntityFilter = null): Generator { + for (const entity of CL.state.clientEntities.getEntities()) { + if (filter && !filter(entity)) { + continue; + } + + yield entity; + } + } + + /** + * Get all entities staged for rendering. Both client-only and server entities. + * @yields Visible client entities. + */ + static *GetVisibleEntities(filter: ClientEntityFilter = null): Generator { + for (const entity of CL.state.clientEntities.getVisibleEntities()) { + if (filter && !filter(entity)) { + continue; + } + + yield entity; + } + } + + /** + * Perform a trace line in the client game world. + * By default this traces static world geometry only. + * Keep this legacy entry point aligned with the server-side Traceline name so + * client tracing can grow into entity-aware behavior later without another API + * rename. + * @returns The trace result. + */ + static Traceline(start: Vector, end: Vector, options: ClientTraceOptions | null = null): GameTrace { + const worldTrace = SV.collision.traceWorldLine(start, end) as InternalTraceLike; + + if (options === null || options.includeEntities !== true) { + return internalTraceToGameTrace(worldTrace); + } + + return internalTraceToGameTrace(traceClientEntities(start, end, worldTrace, options)); + } + + /** + * Allocate a dynamic light for the given entity Id. + * @returns The dynamic light instance. + */ + static AllocDlight(entityId: number): ClientDlight { + return CL.state.clientEntities.allocateDynamicLight(entityId); + } + + /** + * Allocate a new client entity. + * This is a client-side entity, not a server-side edict. + * Make sure to invoke spawn() when ready. + * Make sure to use setOrigin() to set the position of the entity. + * @returns A new client entity. + */ + static AllocEntity(): ClientEdict { + return CL.state.clientEntities.allocateClientEntity(); + } + + /** + * Spawn a rocket trail effect from start to end. + */ + static RocketTrail(start: Vector, end: Vector, type: number): void { + R.RocketTrail(start, end, type); + } + + /** + * Place a decal in the world. + */ + static PlaceDecal(origin: Vector, normal: Vector, texture: GLTexture): void { + R.PlaceDecal(origin, normal, texture); + } + + /** + * Get a model by name. Must be precached first. + * @returns The model. + */ + static ModForName(modelName: string): BaseModel { + console.assert(typeof modelName === 'string', 'modelName must be a string'); + + for (let index = 1; index < CL.state.model_precache.length; index++) { + if (CL.state.model_precache[index].name === modelName) { + return CL.state.model_precache[index]; + } + } + + throw new HostError(`ClientEngineAPI.ModForName: ${modelName} not precached`); + } + + /** + * Get a model by id. + * @returns The model. + */ + static ModById(id: number): BaseModel { + console.assert(typeof id === 'number' && id > 0, 'id must be a number and greater than 0'); + + if (CL.state.model_precache[id]) { + return CL.state.model_precache[id]; + } + + throw new HostError(`ClientEngineAPI.ModById: ${id} not found`); + } + + /** + * Apply a content shift. + */ + static ContentShift(slot: number, color: Vector, alpha = 0.5): void { + V.ContentShift(slot + 4, color, alpha); + } + + /** + * Set the player movement configuration. This is used by the PMove code to determine how the player will move. + */ + static SetPmoveConfiguration(config: PmoveConfiguration): void { + console.assert(config instanceof PmoveConfiguration, 'config must be an instance of PmoveConfiguration'); + + CL.pmove.configuration = config; + } + + static M: object | null = null; + + static readonly CL = { + get viewangles(): Vector { + return CL.state.viewangles.copy(); + }, + get vieworigin(): Vector { + return CL.state.viewent.origin.copy(); + }, + get maxclients(): number { + return CL.state.maxclients; + }, + get levelname(): string { + return CL.state.levelname; + }, + get entityNum(): number { + return CL.state.viewentity; + }, + /** + * local time, not game time! If you are looking for SV.server.time, check gametime + * @returns Local time. + */ + get time(): number { // FIXME: rename to localtime to make the distinction clearer + return CL.state.time; + }, + /** + * latest SV.server.time, NOT local time! + * @returns Game time. + */ + get gametime(): number { + return CL.state.clientMessages.mtime[0]; + }, + get frametime(): number { + return Host.frametime; + }, + get intermission(): boolean { + return CL.state.intermission > 0; + }, + set intermission(value: boolean) { + CL.state.intermission = value ? 1 : 0; + }, + score(num: number) { + return CL.state.scores[num]; + }, + get serverInfo() { + return CL.cls.serverInfo; + }, + }; + + static readonly VID = { + get width(): number { + return VID.width; + }, + get height(): number { + return VID.height; + }, + get pixelRatio(): number { + return VID.pixelRatio; + }, + }; + + static readonly Key = { + /** + * Get the string representation of a key binding, e.g. "+attack" -> "mouse1". + * @returns The bound key string, or `null` when not found. + */ + getKeyForBinding(binding: string): string | null { + return Key.BindingToString(binding); + }, + }; + + static readonly SCR = { + /** + * @returns The current view size. + */ + get viewsize(): number { + return SCR.viewsize.value as number; + }, + }; + + static get eventBus(): EventBus { + return CL.state.eventBus; + } +} diff --git a/source/engine/common/Host.ts b/source/engine/common/Host.ts index b3717966..ff5b7dd1 100644 --- a/source/engine/common/Host.ts +++ b/source/engine/common/Host.ts @@ -18,7 +18,7 @@ import { eventBus, getClientRegistry, getCommonRegistry, registry } from '../reg import Vector from '../../shared/Vector.ts'; import Q from '../../shared/Q.ts'; import { ServerClient } from '../server/Client.mjs'; -import { ServerEngineAPI } from './GameAPIs.mjs'; +import { ServerEngineAPI } from './GameAPIs.ts'; import Chase from '../client/Chase.mjs'; import VID from '../client/VID.mjs'; import { HostError } from './Errors.ts'; diff --git a/source/engine/server/Navigation.mjs b/source/engine/server/Navigation.mjs index da222d81..de4dc880 100644 --- a/source/engine/server/Navigation.mjs +++ b/source/engine/server/Navigation.mjs @@ -6,7 +6,7 @@ import Cmd from '../common/Cmd.ts'; // import Cmd, { ConsoleCommand } from '../common/Cmd.ts'; import Cvar from '../common/Cvar.ts'; import { CorruptedResourceError, MissingResourceError } from '../common/Errors.ts'; -import { ServerEngineAPI } from '../common/GameAPIs.mjs'; +import { ServerEngineAPI } from '../common/GameAPIs.ts'; import { BrushModel } from '../common/Mod.ts'; import { MIN_STEP_NORMAL, STEPSIZE } from '../common/Pmove.ts'; import { Face } from '../common/model/BaseModel.ts'; diff --git a/source/engine/server/Progs.mjs b/source/engine/server/Progs.mjs index 7e873a85..b563eadd 100644 --- a/source/engine/server/Progs.mjs +++ b/source/engine/server/Progs.mjs @@ -6,7 +6,7 @@ import Q from '../../shared/Q.ts'; import Vector from '../../shared/Vector.ts'; import { eventBus, registry } from '../registry.mjs'; import { ED, ServerEdict } from './Edict.mjs'; -import { ServerEngineAPI } from '../common/GameAPIs.mjs'; +import { ServerEngineAPI } from '../common/GameAPIs.ts'; import PF, { etype, ofs } from './ProgsAPI.mjs'; import { gameCapabilities } from '../../shared/Defs.ts'; import { loadGameModule } from './GameLoader.mjs'; diff --git a/source/engine/server/ProgsAPI.mjs b/source/engine/server/ProgsAPI.mjs index c5efc8bf..e5a823b0 100644 --- a/source/engine/server/ProgsAPI.mjs +++ b/source/engine/server/ProgsAPI.mjs @@ -1,7 +1,7 @@ import Vector from '../../shared/Vector.ts'; import Cmd from '../common/Cmd.ts'; import { HostError } from '../common/Errors.ts'; -import { ServerEngineAPI } from '../common/GameAPIs.mjs'; +import { ServerEngineAPI } from '../common/GameAPIs.ts'; import { eventBus, registry } from '../registry.mjs'; import { ED, ServerEdict } from './Edict.mjs'; diff --git a/source/engine/server/Server.mjs b/source/engine/server/Server.mjs index f342c912..1f053cb2 100644 --- a/source/engine/server/Server.mjs +++ b/source/engine/server/Server.mjs @@ -7,7 +7,7 @@ import * as Def from './../common/Def.ts'; import Cmd, { ConsoleCommand } from '../common/Cmd.ts'; import { ED, ServerEdict } from './Edict.mjs'; import { EventBus, eventBus, registry } from '../registry.mjs'; -import { ServerEngineAPI } from '../common/GameAPIs.mjs'; +import { ServerEngineAPI } from '../common/GameAPIs.ts'; import * as Defs from '../../shared/Defs.ts'; import { Navigation } from './Navigation.mjs'; import { ServerPhysics } from './physics/ServerPhysics.mjs'; @@ -16,7 +16,7 @@ import { ServerMessages } from './ServerMessages.mjs'; import { ServerMovement } from './physics/ServerMovement.mjs'; import { ServerArea } from './physics/ServerArea.mjs'; import { ServerCollision } from './physics/ServerCollision.mjs'; -import { sharedCollisionModelSource } from '../common/CollisionModelSource.mjs'; +import { sharedCollisionModelSource } from '../common/CollisionModelSource.ts'; import { BrushModel } from '../common/Mod.ts'; import { ServerClient } from './Client.mjs'; diff --git a/source/engine/server/physics/ServerArea.mjs b/source/engine/server/physics/ServerArea.mjs index 25e6d4eb..ae2dfbeb 100644 --- a/source/engine/server/physics/ServerArea.mjs +++ b/source/engine/server/physics/ServerArea.mjs @@ -2,7 +2,7 @@ import Vector from '../../../shared/Vector.ts'; import * as Defs from '../../../shared/Defs.ts'; import { Octree } from '../../../shared/Octree.ts'; import { eventBus, registry } from '../../registry.mjs'; -import CollisionModelSource, { createRegistryCollisionModelSource } from '../../common/CollisionModelSource.mjs'; +import CollisionModelSource, { createRegistryCollisionModelSource } from '../../common/CollisionModelSource.ts'; import { BrushModel } from '../../../engine/common/Mod.ts'; let { SV } = registry; diff --git a/source/engine/server/physics/ServerCollision.mjs b/source/engine/server/physics/ServerCollision.mjs index 29032c48..7ceec252 100644 --- a/source/engine/server/physics/ServerCollision.mjs +++ b/source/engine/server/physics/ServerCollision.mjs @@ -1,6 +1,6 @@ import Vector from '../../../shared/Vector.ts'; import * as Defs from '../../../shared/Defs.ts'; -import CollisionModelSource, { createRegistryCollisionModelSource } from '../../common/CollisionModelSource.mjs'; +import CollisionModelSource, { createRegistryCollisionModelSource } from '../../common/CollisionModelSource.ts'; import Mod, { BrushModel } from '../../common/Mod.ts'; import { BrushTrace, DIST_EPSILON, Trace as SharedTrace } from '../../common/Pmove.ts'; import { eventBus, registry } from '../../registry.mjs'; diff --git a/source/shared/ClientEdict.ts b/source/shared/ClientEdict.ts index 4d1cc16e..ca1017ce 100644 --- a/source/shared/ClientEdict.ts +++ b/source/shared/ClientEdict.ts @@ -1,5 +1,5 @@ import type { ClientEdict } from '../engine/client/ClientEntities.mjs'; -import type { ClientEngineAPI as ClientEngineApiValue } from '../engine/common/GameAPIs.mjs'; +import type { ClientEngineAPI as ClientEngineApiValue } from '../engine/common/GameAPIs.ts'; type ClientEngineAPI = typeof ClientEngineApiValue; diff --git a/source/shared/GameInterfaces.ts b/source/shared/GameInterfaces.ts index 9a37b56a..33de8a84 100644 --- a/source/shared/GameInterfaces.ts +++ b/source/shared/GameInterfaces.ts @@ -1,5 +1,5 @@ import type { BaseClientEdictHandler } from './ClientEdict.ts'; -import type { ClientEngineAPI as ClientEngineApiValue, ServerEngineAPI as ServerEngineApiValue } from '../engine/common/GameAPIs.mjs'; +import type { ClientEngineAPI as ClientEngineApiValue, ServerEngineAPI as ServerEngineApiValue } from '../engine/common/GameAPIs.ts'; import type { ServerEdict as ServerEdictValue } from '../engine/server/Edict.mjs'; import type { GLTexture as GLTextureValue } from '../engine/client/GL.mjs'; import type { SFX as SFXValue } from '../engine/client/Sound.mjs'; diff --git a/test/common/collision-model-source.test.mjs b/test/common/collision-model-source.test.mjs new file mode 100644 index 00000000..beeb9c26 --- /dev/null +++ b/test/common/collision-model-source.test.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import CollisionModelSource, { createRegistryCollisionModelSource } from '../../source/engine/common/CollisionModelSource.ts'; +import { createBoxBrushModel, defaultMockRegistry, withMockRegistry } from '../physics/fixtures.mjs'; + +void describe('CollisionModelSource', () => { + void test('uses injected server accessors before client fallbacks', () => { + const source = new CollisionModelSource(); + const worldEntity = { num: 0 }; + const worldModel = createBoxBrushModel({ name: 'server-world', halfExtents: [16, 16, 16] }); + const clientWorldModel = createBoxBrushModel({ name: 'client-world', halfExtents: [24, 24, 24] }); + const serverModel = { name: 'server-model' }; + const clientModel = { name: 'client-model' }; + + source.configureServer({ + getWorldEntity: () => worldEntity, + getWorldModel: () => worldModel, + getModels: () => [null, serverModel], + }); + source.configureClient({ + getWorldModel: () => clientWorldModel, + getModels: () => [null, clientModel], + }); + + assert.equal(source.getWorldEntity(), worldEntity); + assert.equal(source.getWorldModel(), worldModel); + assert.equal(source.getModelByIndex(1), serverModel); + }); + + void test('falls back to client world model and client precache when server world is unavailable', () => { + const clientWorldModel = createBoxBrushModel({ name: 'client-world', halfExtents: [32, 32, 32] }); + const clientModel = { name: 'client-model' }; + + void withMockRegistry(defaultMockRegistry({ + server: { + edicts: [], + worldmodel: null, + models: null, + }, + }, { + state: { + worldmodel: clientWorldModel, + model_precache: [null, clientModel], + }, + }), () => { + const source = createRegistryCollisionModelSource(); + + assert.equal(source.getWorldEntity(), null); + assert.equal(source.getWorldModel(), clientWorldModel); + assert.equal(source.getModelByIndex(1), clientModel); + }); + }); + + void test('falls back to client precache slot 1 when the client world model is missing', () => { + const clientWorldFromPrecache = createBoxBrushModel({ name: 'client-precache-world', halfExtents: [48, 48, 48] }); + + void withMockRegistry(defaultMockRegistry({ + server: { + edicts: [], + worldmodel: null, + models: null, + }, + }, { + state: { + worldmodel: null, + model_precache: [null, clientWorldFromPrecache], + }, + }), () => { + const source = createRegistryCollisionModelSource(); + + assert.equal(source.getWorldModel(), clientWorldFromPrecache); + assert.equal(source.getModelByIndex(1), clientWorldFromPrecache); + }); + }); +}); diff --git a/test/common/game-apis.test.mjs b/test/common/game-apis.test.mjs index 89c0c20a..0929a279 100644 --- a/test/common/game-apis.test.mjs +++ b/test/common/game-apis.test.mjs @@ -4,19 +4,12 @@ import assert from 'node:assert/strict'; import Vector from '../../source/shared/Vector.ts'; import { solid } from '../../source/shared/Defs.ts'; import { ClientEdict } from '../../source/engine/client/ClientEntities.mjs'; -import { ClientEngineAPI } from '../../source/engine/common/GameAPIs.mjs'; +import { ClientEngineAPI } from '../../source/engine/common/GameAPIs.ts'; import { ServerArea } from '../../source/engine/server/physics/ServerArea.mjs'; import { ServerCollision } from '../../source/engine/server/physics/ServerCollision.mjs'; import { CollisionTrace } from '../../source/engine/server/physics/ServerCollisionSupport.mjs'; import { defaultMockRegistry, withMockRegistry } from '../physics/fixtures.mjs'; -/** - * - * @param num - * @param origin - * @param mins - * @param maxs - */ /** * @param {number} num entity number * @param {Vector} origin entity origin @@ -38,11 +31,11 @@ function createClientTraceEntity(num, origin, mins, maxs) { return entity; } -describe('ClientEngineAPI.Traceline', () => { - test('keeps the default client trace static-world only', () => { +void describe('ClientEngineAPI.Traceline', () => { + void test('keeps the default client trace static-world only', () => { let clipMoveCalls = 0; - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ collision: { traceWorldLine(_start, end) { return CollisionTrace.empty(end); @@ -68,7 +61,7 @@ describe('ClientEngineAPI.Traceline', () => { }); }); - test('can trace current client entities on demand', () => { + void test('can trace current client entities on demand', () => { const collision = new ServerCollision(); const area = new ServerArea(); area.initBoxHull(); @@ -80,7 +73,7 @@ describe('ClientEngineAPI.Traceline', () => { new Vector(16, 16, 32), ); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area, collision: { traceWorldLine(_start, end) { @@ -108,7 +101,7 @@ describe('ClientEngineAPI.Traceline', () => { }); }); - test('supports skipping and filtering client trace candidates', () => { + void test('supports skipping and filtering client trace candidates', () => { const collision = new ServerCollision(); const area = new ServerArea(); area.initBoxHull(); @@ -126,7 +119,7 @@ describe('ClientEngineAPI.Traceline', () => { new Vector(16, 16, 32), ); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area, collision: { traceWorldLine(_start, end) { From 66e22caf0a1d71a68c652921a24c1eac213686be Mon Sep 17 00:00:00 2001 From: Christian R Date: Fri, 3 Apr 2026 12:38:54 +0300 Subject: [PATCH 30/67] TS: server/physics/ --- .../typescript-port.instructions.md | 124 ++++ source/engine/common/CollisionModelSource.ts | 8 +- source/engine/common/GameAPIs.ts | 113 +++- source/engine/common/PlatformWorker.ts | 6 +- .../common/model/loaders/AliasMDLLoader.ts | 18 +- .../common/model/loaders/BSP29Loader.ts | 6 +- source/engine/network/Network.ts | 6 +- source/engine/server/Navigation.mjs | 2 +- source/engine/server/Server.mjs | 10 +- .../server/physics/{Defs.mjs => Defs.ts} | 32 +- .../physics/{ServerArea.mjs => ServerArea.ts} | 215 ++++--- ...ientPhysics.mjs => ServerClientPhysics.ts} | 155 ++--- ...ServerCollision.mjs => ServerCollision.ts} | 533 ++++++++---------- .../server/physics/ServerCollisionSupport.mjs | 336 ----------- .../server/physics/ServerCollisionSupport.ts | 388 +++++++++++++ ...ision.mjs => ServerLegacyHullCollision.ts} | 100 ++-- .../engine/server/physics/ServerMovement.mjs | 333 ----------- .../engine/server/physics/ServerMovement.ts | 369 ++++++++++++ .../{ServerPhysics.mjs => ServerPhysics.ts} | 456 ++++++++------- test/common/game-apis.test.mjs | 74 ++- test/physics/collision-regressions.test.mjs | 8 +- test/physics/fixtures.mjs | 2 +- test/physics/func-door.test.mjs | 4 +- test/physics/quake-entity-ai.test.mjs | 128 +++++ test/physics/server-client-physics.test.mjs | 21 +- test/physics/server-collision.test.mjs | 47 +- test/physics/server-movement.test.mjs | 2 +- test/physics/server-physics.test.mjs | 223 +++++--- 28 files changed, 2125 insertions(+), 1594 deletions(-) rename source/engine/server/physics/{Defs.mjs => Defs.ts} (71%) rename source/engine/server/physics/{ServerArea.mjs => ServerArea.ts} (56%) rename source/engine/server/physics/{ServerClientPhysics.mjs => ServerClientPhysics.ts} (68%) rename source/engine/server/physics/{ServerCollision.mjs => ServerCollision.ts} (51%) delete mode 100644 source/engine/server/physics/ServerCollisionSupport.mjs create mode 100644 source/engine/server/physics/ServerCollisionSupport.ts rename source/engine/server/physics/{ServerLegacyHullCollision.mjs => ServerLegacyHullCollision.ts} (62%) delete mode 100644 source/engine/server/physics/ServerMovement.mjs create mode 100644 source/engine/server/physics/ServerMovement.ts rename source/engine/server/physics/{ServerPhysics.mjs => ServerPhysics.ts} (53%) create mode 100644 test/physics/quake-entity-ai.test.mjs diff --git a/.github/instructions/typescript-port.instructions.md b/.github/instructions/typescript-port.instructions.md index cc107a4a..a48c4554 100644 --- a/.github/instructions/typescript-port.instructions.md +++ b/.github/instructions/typescript-port.instructions.md @@ -163,6 +163,22 @@ constructor() { // ✅ Just omit it ``` +### Avoid typeof to check existence of functions + +```typescript + +// ❌ Avoid this pattern +… +if (typeof someModule.someFunction === 'function') { +… +const attachedClient = typeof ent.getClient === 'function' ? ent.getClient() : null; +… + +// ✅ Instead, use optional chaining and nullish coalescing +if (sometype instanceof SomeClass) { +… +``` + ### Template Literals Replace string concatenation with template literals for readability. @@ -302,3 +318,111 @@ For example, consider this original code snippet with helpful comments: If you encounter important logic that is not covered by tests, add new tests to cover it. This is especially critical for complex algorithms, edge cases, or any code that has caused bugs in the past. If code looks risky or has had bugs before, but there are no tests for it, that's a strong signal that tests should be added. Don't skip this step just to get the TS port done faster — the goal is not just to convert to TypeScript, but to improve code quality and maintainability overall. + +### Initialize the registry properly + +```typescript + +// ❌ This will cause static analysis regarding e.g. Con being undefined: + +let { Con } = registry; + +eventBus.subscribe('registry.frozen', () => { + ({ Con } = registry); +}); + +// ✅ Instead, initialize with the helper function that has the correct typing and will be updated when the registry is frozen: + +let { Con } = getCommonRegistry(); + +eventBus.subscribe('registry.frozen', () => { + ({ Con } = getCommonRegistry()); +}); + +``` + +### Potential null and undefined values + +When porting, if you encounter a variable that is initialized to `null` or `undefined` and later assigned an object, make sure to update the type annotation to reflect this. For example: + +```typescript + +// ❌ Original JS with JSDoc cast +let model = /** @type {BaseModel} */ (null); + +// ✅ TS with explicit nullability +let model: BaseModel | null = null; + +``` + +There are cases where the variable is initialized to `null` but is guaranteed to be assigned a non-null value before it is used. In such cases, you can use the non-null assertion operator (`!`) when accessing the variable, or you can refactor the code to ensure that the variable is properly initialized before use. + +```typescript +// Example of using non-null assertion +let model: BaseModel | null = null; + +function initializeModel() { + model = new BaseModel(); +} + +function useModel() { + console.assert(model !== null, 'Model must be initialized before use'); + + model!.doSomething(); // Using non-null assertion +} +``` + +**Note**: When in doubt, always combine a console.assert() check with the non-null assertion to ensure that the assumption holds true at runtime. This way, if there is a case where the variable is accessed before being initialized, it will throw an error with a clear message. Prefer console.assert() over if-checks that throw errors, as it is more concise and clearly indicates that this is an invariant assumption rather than normal control flow. It will also be stripped out in production builds, so it won't have any performance impact. + +#### Avoiding unnecessary null checks together with indirections + +Another important optimization while porting over code and in regards to nullability is to **avoid unnecessary null checks**. If you have a variable that is initialized to `null` but is guaranteed to be assigned a non-null value before it is used, you can safely use the non-null assertion operator (`!`) without adding redundant null checks throughout the code. + +```typescript + +// ❌ ent.entity is guaranteed to be non-null, so the null check would be redundant and add unnecessary complexity + +for (const ent of SV.area.tree.queryAABB(mins, maxs)) { + if (ent.num === 0 || ent.isFree()) { + continue; + } + + const eorg = origin.copy().subtract(ent.entity.origin.copy().add(ent.entity.mins.copy().add(ent.entity.maxs).multiply(0.5))); + + if (eorg.len() > radius) { + continue; + } + + if (!filterFn || filterFn(ent)) { + edicts.push(ent); + } +} + +// ✅ Instead, use non-null assertion and add a console.assert to ensure the assumption holds + +for (const ent of SV.area.tree.queryAABB(mins, maxs)) { + if (ent.num === 0 || ent.isFree()) { + continue; + } + + const entity = ent.entity!; // Non-null assertion + console.assert(entity !== null, 'Entity must be initialized before use'); + + const eorg = origin.copy().subtract(entity.origin.copy().add(entity.mins.copy().add(entity.maxs).multiply(0.5))); + + if (eorg.len() > radius) { + continue; + } + + if (!filterFn || filterFn(ent)) { + edicts.push(ent); + } +} + +``` + +In absolutely guaranteed non-null cases, avoid the console.assert as well. + +### Do not touch game code unless necessary + +When porting the engine code, try to avoid making changes to the core game logic or mechanics unless it is necessary for the TypeScript conversion. Before making any changes, raise a question or discussion to clarify whether the change is necessary and beneficial for the overall codebase. If a change is needed, make sure to add tests to cover the new behavior and ensure that all existing tests still pass. diff --git a/source/engine/common/CollisionModelSource.ts b/source/engine/common/CollisionModelSource.ts index 57d40814..f889a0c7 100644 --- a/source/engine/common/CollisionModelSource.ts +++ b/source/engine/common/CollisionModelSource.ts @@ -1,4 +1,4 @@ -import { eventBus, registry } from '../registry.mjs'; +import { eventBus, getClientRegistry, getCommonRegistry } from '../registry.mjs'; import type { ServerEdict } from '../server/Edict.mjs'; import type { BrushModel } from './model/BSP.ts'; @@ -14,10 +14,12 @@ interface ClientCollisionModelAccessors { readonly getModels?: () => Array | null; } -let { CL, SV } = registry; +let { SV } = getCommonRegistry(); +let { CL } = getClientRegistry(); eventBus.subscribe('registry.frozen', () => { - ({ CL, SV } = registry); + ({ SV } = getCommonRegistry()); + ({ CL } = getClientRegistry()); }); /** diff --git a/source/engine/common/GameAPIs.ts b/source/engine/common/GameAPIs.ts index 927668cc..ca60655d 100644 --- a/source/engine/common/GameAPIs.ts +++ b/source/engine/common/GameAPIs.ts @@ -9,12 +9,12 @@ import type { Visibility } from './model/BSP.ts'; import { PmoveConfiguration } from '../../shared/Pmove.ts'; import Vector from '../../shared/Vector.ts'; -import { solid } from '../../shared/Defs.ts'; +import { moveTypes, solid } from '../../shared/Defs.ts'; import Key from '../client/Key.mjs'; import { SFX as SFXValue } from '../client/Sound.mjs'; import VID from '../client/VID.mjs'; import * as Protocol from '../network/Protocol.ts'; -import { EventBus, eventBus, registry } from '../registry.mjs'; +import { EventBus, eventBus, getClientRegistry, getCommonRegistry } from '../registry.mjs'; import { ED, ServerEdict as ServerEdictValue } from '../server/Edict.mjs'; import Cmd from './Cmd.ts'; import Cvar from './Cvar.ts'; @@ -74,10 +74,12 @@ type ServerEntityFilter = ((entity: ServerEdict) => boolean) | null; type ClientEntityFilter = ((entity: ClientEdict) => boolean) | null; type CommandCallback = (...args: string[]) => void | Promise; -let { CL, COM, Con, Draw, Host, R, S, SCR, SV, V } = registry; +let { COM, Con, Host, SV, V } = getCommonRegistry(); +let { CL, Draw, R, S, SCR } = getClientRegistry(); eventBus.subscribe('registry.frozen', () => { - ({ CL, COM, Con, Draw, Host, R, S, SCR, SV, V } = registry); + ({ COM, Con, Host, SV, V } = getCommonRegistry()); + ({ CL, Draw, R, S, SCR } = getClientRegistry()); }); eventBus.subscribe('com.ready', () => { @@ -93,7 +95,9 @@ eventBus.subscribe('com.ready', () => { CommonEngineAPI.gameFlavors.push(GameFlavors.rogue); } - if (COM.registered.value === 1) { + console.assert(COM.registered !== null, 'COM.registered must exist after com.ready'); + + if (COM.registered!.value === 1) { ServerEngineAPI.registered = true; ClientEngineAPI.registered = true; } @@ -311,7 +315,11 @@ export class CommonEngineAPI { * @returns The modified variable. */ static SetCvar(name: string, value: string): Cvar { - return Cvar.Set(name, value); + const variable = Cvar.Set(name, value); + + console.assert(variable !== null, 'Cvar.Set requires a registered variable', name); + + return variable!; } /** @@ -405,7 +413,25 @@ export class ServerEngineAPI extends CommonEngineAPI { maxs: Vector | null = null, ): GameTrace { const nullVec = Vector.origin; - const trace = SV.collision.move(start, mins ? mins : nullVec, maxs ? maxs : nullVec, end, noMonsters, passEdict) as InternalTraceLike; + const moveType = noMonsters ? moveTypes.MOVE_NOMONSTERS : moveTypes.MOVE_NORMAL; + const collision = SV.collision as { + move( + start: Vector, + mins: Vector, + maxs: Vector, + end: Vector, + type: moveTypes, + passedict: ServerEdict | null, + ): InternalTraceLike; + }; + const trace = collision.move( + start, + mins ? mins : nullVec, + maxs ? maxs : nullVec, + end, + moveType, + passEdict, + ); return internalTraceToGameTrace(trace); } @@ -418,7 +444,25 @@ export class ServerEngineAPI extends CommonEngineAPI { maxs: Vector | null = null, ): InternalTraceLike { const nullVec = Vector.origin; - return SV.collision.move(start, mins ? mins : nullVec, maxs ? maxs : nullVec, end, noMonsters, passEdict) as InternalTraceLike; + const moveType = noMonsters ? moveTypes.MOVE_NOMONSTERS : moveTypes.MOVE_NORMAL; + const collision = SV.collision as { + move( + start: Vector, + mins: Vector, + maxs: Vector, + end: Vector, + type: moveTypes, + passedict: ServerEdict | null, + ): InternalTraceLike; + }; + return collision.move( + start, + mins ? mins : nullVec, + maxs ? maxs : nullVec, + end, + moveType, + passEdict, + ); } /** @@ -426,9 +470,11 @@ export class ServerEngineAPI extends CommonEngineAPI { * It will also send an update to all connected clients. */ static Lightstyle(styleId: number, sequenceString: string): void { - SV.server.lightstyles[styleId] = sequenceString; + const server = SV.server as typeof SV.server & { lightstyles: string[]; loading: boolean }; + + server.lightstyles[styleId] = sequenceString; - if (SV.server.loading) { + if (server.loading) { return; } @@ -521,13 +567,17 @@ export class ServerEngineAPI extends CommonEngineAPI { const mins = origin.copy().subtract(vradius); const maxs = origin.copy().add(vradius); const edicts: ServerEdict[] = []; + const tree = SV.area.tree; - for (const ent of SV.area.tree.queryAABB(mins, maxs)) { + console.assert(tree !== null, 'SV.area.tree must be initialized before radius queries'); + + for (const ent of tree!.queryAABB(mins, maxs)) { if (ent.num === 0 || ent.isFree()) { continue; } - const eorg = origin.copy().subtract(ent.entity.origin.copy().add(ent.entity.mins.copy().add(ent.entity.maxs).multiply(0.5))); + const entity = ent.entity!; + const eorg = origin.copy().subtract(entity.origin.copy().add(entity.mins.copy().add(entity.maxs).multiply(0.5))); if (eorg.len() > radius) { continue; @@ -554,7 +604,9 @@ export class ServerEngineAPI extends CommonEngineAPI { continue; } - if (ent.entity[field] === value) { + const entity = ent.entity! as BaseEntity & Record; + + if (entity[field] === value) { return ent; // FIXME: turn it into yield } } @@ -575,7 +627,9 @@ export class ServerEngineAPI extends CommonEngineAPI { continue; } - if (ent.entity[field] === value) { + const entity = ent.entity! as BaseEntity & Record; + + if (entity[field] === value) { yield ent; } } @@ -664,7 +718,7 @@ export class ServerEngineAPI extends CommonEngineAPI { } static IsLoading(): boolean { - return SV.server.loading; + return (SV.server as typeof SV.server & { loading: boolean }).loading; } /** @@ -729,25 +783,27 @@ export class ServerEngineAPI extends CommonEngineAPI { */ static DispatchClientEvent(receiverPlayerEdict: ServerEdict, expedited: boolean, eventCode: number, ...args: SerializableType[]): void { console.assert(receiverPlayerEdict instanceof ServerEdictValue && receiverPlayerEdict.isClient(), 'emitterEdict must be a ServerEdict connected to a client'); + console.assert(receiverPlayerEdict.getClient() !== null, 'receiverPlayerEdict must have a client'); - const destination = expedited ? receiverPlayerEdict.getClient().expedited_message : receiverPlayerEdict.getClient().message; + const receiverClient = receiverPlayerEdict.getClient()!; + const destination = expedited ? receiverClient.expedited_message : receiverClient.message; this.#DispatchClientEventOnDestination(destination, eventCode, ...args); } /** * Return a series of waypoints from start to end. - * @returns Waypoints from start to end, including start and end. + * @returns The waypoints from start to end, or `null` when no path could be found. */ - static Navigate(start: Vector, end: Vector): Vector[] { + static Navigate(start: Vector, end: Vector): Vector[] | null { return SV.server.navigation.findPath(start, end); } /** * Return a series of waypoints from start to end asynchronously. - * @returns Waypoints from start to end, including start and end. + * @returns The waypoints from start to end, or `null` when no path could be found. */ - static NavigateAsync(start: Vector, end: Vector): Promise { + static NavigateAsync(start: Vector, end: Vector): Promise { return SV.server.navigation.findPathAsync(start, end); } @@ -772,8 +828,9 @@ export class ServerEngineAPI extends CommonEngineAPI { */ static SetPmoveConfiguration(config: PmoveConfiguration): void { console.assert(config instanceof PmoveConfiguration, 'config must be an instance of PmoveConfiguration'); + console.assert(SV.pmove !== null, 'SV.pmove must exist before setting configuration'); - SV.pmove.configuration = config; + SV.pmove!.configuration = config; } static get maxplayers(): number { @@ -845,7 +902,11 @@ export class ClientEngineAPI extends CommonEngineAPI { * @returns The loaded sound effect. */ static LoadSound(sfxName: string): SFXValue { - return S.PrecacheSound(sfxName); + const sfx = S.PrecacheSound(sfxName); + + console.assert(sfx !== null, 'sound must be precached before being returned', sfxName); + + return sfx!; } /** @@ -1024,13 +1085,15 @@ export class ClientEngineAPI extends CommonEngineAPI { return CL.state.viewangles.copy(); }, get vieworigin(): Vector { - return CL.state.viewent.origin.copy(); + console.assert(CL.state.viewent !== null, 'client view entity must exist when reading vieworigin'); + + return CL.state.viewent!.origin.copy(); }, get maxclients(): number { return CL.state.maxclients; }, get levelname(): string { - return CL.state.levelname; + return CL.state.levelname ?? ''; }, get entityNum(): number { return CL.state.viewentity; @@ -1093,7 +1156,7 @@ export class ClientEngineAPI extends CommonEngineAPI { * @returns The current view size. */ get viewsize(): number { - return SCR.viewsize.value as number; + return (SCR as typeof SCR & { viewsize: Cvar }).viewsize.value as number; }, }; diff --git a/source/engine/common/PlatformWorker.ts b/source/engine/common/PlatformWorker.ts index 8c79e3ef..e95c97b0 100644 --- a/source/engine/common/PlatformWorker.ts +++ b/source/engine/common/PlatformWorker.ts @@ -1,10 +1,10 @@ -import { registry, eventBus } from '../registry.mjs'; +import { eventBus, getCommonRegistry } from '../registry.mjs'; import { BaseWorker, type WorkerMessageListener } from './Sys.ts'; -let { Host } = registry; +let { Host } = getCommonRegistry(); eventBus.subscribe('registry.frozen', () => { - ({ Host } = registry); + ({ Host } = getCommonRegistry()); }); const isNode = typeof process !== 'undefined' && process.versions?.node !== undefined; diff --git a/source/engine/common/model/loaders/AliasMDLLoader.ts b/source/engine/common/model/loaders/AliasMDLLoader.ts index 03255da6..5d36fe09 100644 --- a/source/engine/common/model/loaders/AliasMDLLoader.ts +++ b/source/engine/common/model/loaders/AliasMDLLoader.ts @@ -629,9 +629,9 @@ export class AliasMDLLoader extends ModelLoader { const scaleOrigin = loadmodel._scale_origin; console.assert(scale !== null && scaleOrigin !== null); - if (scale === null || scaleOrigin === null) { - return; - } + + const activeScale = scale!; + const activeScaleOrigin = scaleOrigin!; const cmds: number[] = []; @@ -680,9 +680,9 @@ export class AliasMDLLoader extends ModelLoader { for (let vertexOffset = 0; vertexOffset < 3; vertexOffset++) { const vert = frame.v[triangle.vertindex[vertexOffset]]; console.assert(vert.lightnormalindex < avertexnormals.length / 3); - cmds.push(vert.v[0] * scale[0] + scaleOrigin[0]); - cmds.push(vert.v[1] * scale[1] + scaleOrigin[1]); - cmds.push(vert.v[2] * scale[2] + scaleOrigin[2]); + cmds.push(vert.v[0] * activeScale[0] + activeScaleOrigin[0]); + cmds.push(vert.v[1] * activeScale[1] + activeScaleOrigin[1]); + cmds.push(vert.v[2] * activeScale[2] + activeScaleOrigin[2]); cmds.push(avertexnormals[vert.lightnormalindex * 3]); cmds.push(avertexnormals[vert.lightnormalindex * 3 + 1]); cmds.push(avertexnormals[vert.lightnormalindex * 3 + 2]); @@ -701,9 +701,9 @@ export class AliasMDLLoader extends ModelLoader { for (let vertexOffset = 0; vertexOffset < 3; vertexOffset++) { const vert = frame.v[triangle.vertindex[vertexOffset]]; console.assert(vert.lightnormalindex < avertexnormals.length / 3); - cmds.push(vert.v[0] * scale[0] + scaleOrigin[0]); - cmds.push(vert.v[1] * scale[1] + scaleOrigin[1]); - cmds.push(vert.v[2] * scale[2] + scaleOrigin[2]); + cmds.push(vert.v[0] * activeScale[0] + activeScaleOrigin[0]); + cmds.push(vert.v[1] * activeScale[1] + activeScaleOrigin[1]); + cmds.push(vert.v[2] * activeScale[2] + activeScaleOrigin[2]); cmds.push(avertexnormals[vert.lightnormalindex * 3]); cmds.push(avertexnormals[vert.lightnormalindex * 3 + 1]); cmds.push(avertexnormals[vert.lightnormalindex * 3 + 2]); diff --git a/source/engine/common/model/loaders/BSP29Loader.ts b/source/engine/common/model/loaders/BSP29Loader.ts index 34b310c2..1df427b1 100644 --- a/source/engine/common/model/loaders/BSP29Loader.ts +++ b/source/engine/common/model/loaders/BSP29Loader.ts @@ -5,7 +5,7 @@ import { GLTexture } from '../../../client/GL.mjs'; import W, { readWad3Texture, translateIndexToLuminanceRGBA, translateIndexToRGBA } from '../../W.ts'; import { CRC16CCITT } from '../../CRC.ts'; import { CorruptedResourceError } from '../../Errors.ts'; -import { eventBus, registry } from '../../../registry.mjs'; +import { eventBus, getCommonRegistry, registry } from '../../../registry.mjs'; import { ModelLoader } from '../ModelLoader.ts'; import { Brush, BrushModel, BrushSide, Node, type BSPXLumps, type Clipnode, type Hull } from '../BSP.ts'; import { Face, Plane } from '../BaseModel.ts'; @@ -13,10 +13,10 @@ import { materialFlags, noTextureMaterial, PBRMaterial, QuakeMaterial } from '.. import { Quake1Sky, SimpleSkyBox } from '../../../client/renderer/Sky.mjs'; // Get registry references (will be set by eventBus) -let { COM, Con } = registry; +let { COM, Con } = getCommonRegistry(); eventBus.subscribe('registry.frozen', () => { - ({ COM, Con } = registry); + ({ COM, Con } = getCommonRegistry()); }); interface AllowedClipnodeHull extends Hull { diff --git a/source/engine/network/Network.ts b/source/engine/network/Network.ts index cd0bba3c..f5b96621 100644 --- a/source/engine/network/Network.ts +++ b/source/engine/network/Network.ts @@ -288,16 +288,16 @@ export default class NET { eventBus.subscribe('server.spawned', () => { if (SV.svs.maxclients === 1 && NET.listening) { - Cmd.ExecuteString('listen 0'); + void Cmd.ExecuteString('listen 0'); } if (SV.svs.maxclients > 1 && !NET.listening) { - Cmd.ExecuteString('listen 1'); + void Cmd.ExecuteString('listen 1'); } }); eventBus.subscribe('server.shutdown', () => { if (NET.listening) { - Cmd.ExecuteString('listen 0'); + void Cmd.ExecuteString('listen 0'); } }); diff --git a/source/engine/server/Navigation.mjs b/source/engine/server/Navigation.mjs index de4dc880..4845335b 100644 --- a/source/engine/server/Navigation.mjs +++ b/source/engine/server/Navigation.mjs @@ -686,7 +686,7 @@ export class Navigation { /** * @param {Vector} startpos stand origin * @param {Vector} endpos stand origin - * @returns {import('./physics/ServerCollisionSupport.mjs').CollisionTrace} collision result + * @returns {import('./physics/ServerCollisionSupport.ts').CollisionTrace} collision result */ #traceWalkerStatic(startpos, endpos) { return SV.collision.traceStaticWorld( diff --git a/source/engine/server/Server.mjs b/source/engine/server/Server.mjs index 1f053cb2..7bde8bbe 100644 --- a/source/engine/server/Server.mjs +++ b/source/engine/server/Server.mjs @@ -10,12 +10,12 @@ import { EventBus, eventBus, registry } from '../registry.mjs'; import { ServerEngineAPI } from '../common/GameAPIs.ts'; import * as Defs from '../../shared/Defs.ts'; import { Navigation } from './Navigation.mjs'; -import { ServerPhysics } from './physics/ServerPhysics.mjs'; -import { ServerClientPhysics } from './physics/ServerClientPhysics.mjs'; +import { ServerPhysics } from './physics/ServerPhysics.ts'; +import { ServerClientPhysics } from './physics/ServerClientPhysics.ts'; import { ServerMessages } from './ServerMessages.mjs'; -import { ServerMovement } from './physics/ServerMovement.mjs'; -import { ServerArea } from './physics/ServerArea.mjs'; -import { ServerCollision } from './physics/ServerCollision.mjs'; +import { ServerMovement } from './physics/ServerMovement.ts'; +import { ServerArea } from './physics/ServerArea.ts'; +import { ServerCollision } from './physics/ServerCollision.ts'; import { sharedCollisionModelSource } from '../common/CollisionModelSource.ts'; import { BrushModel } from '../common/Mod.ts'; import { ServerClient } from './Client.mjs'; diff --git a/source/engine/server/physics/Defs.mjs b/source/engine/server/physics/Defs.ts similarity index 71% rename from source/engine/server/physics/Defs.mjs rename to source/engine/server/physics/Defs.ts index e856528e..fe3c06e9 100644 --- a/source/engine/server/physics/Defs.mjs +++ b/source/engine/server/physics/Defs.ts @@ -1,13 +1,11 @@ /** * Minimum ground angle normal (Z component) to be considered "on ground". * Normal vectors with Z >= this value are walkable slopes. - * @constant {number} */ export const GROUND_ANGLE_THRESHOLD = 0.7; /** * Maximum step height an entity can climb automatically (in units). - * @constant {number} */ export const STEP_HEIGHT = 18.0; @@ -22,47 +20,37 @@ export const VELOCITY_EPSILON = 0.1; */ export const WATER_SPEED_FACTOR = 0.7; -/** - * Number of bump iterations allowed in fly/move physics. - */ - /** * Overbounce factor for wall/floor collisions. * Values > 1.0 make objects bounce slightly, < 1.0 absorb energy. - * @constant {number} */ export const BOUNCE_OVERBOUNCE = 1.0; /** * Overbounce factor for stopping movement. - * @constant {number} */ export const STOP_OVERBOUNCE = 1.0; /** * Maximum number of collision planes to slide against in flyMove. - * @constant {number} */ export const MAX_CLIP_PLANES = 5; /** * Maximum number of bump iterations in flyMove. - * @constant {number} */ export const MAX_BUMP_COUNT = 4; /** * Blocked flags for movement traces. - * @readonly - * @enum {number} */ -export const BlockedFlags = { - /** Movement not blocked */ - NONE: 0, - /** Blocked by floor */ - FLOOR: 1, - /** Blocked by wall/step */ - WALL: 2, - /** Blocked by floor and wall */ - BOTH: 3, -}; +export enum BlockedFlags { + /** Movement not blocked. */ + NONE = 0, + /** Blocked by floor. */ + FLOOR = 1, + /** Blocked by wall/step. */ + WALL = 2, + /** Blocked by floor and wall. */ + BOTH = 3, +} diff --git a/source/engine/server/physics/ServerArea.mjs b/source/engine/server/physics/ServerArea.ts similarity index 56% rename from source/engine/server/physics/ServerArea.mjs rename to source/engine/server/physics/ServerArea.ts index ae2dfbeb..11c6f246 100644 --- a/source/engine/server/physics/ServerArea.mjs +++ b/source/engine/server/physics/ServerArea.ts @@ -1,14 +1,35 @@ +import type { Hull, Node } from '../../common/model/BSP.ts'; +import type { BaseEntity, ServerEdict } from '../Edict.mjs'; + import Vector from '../../../shared/Vector.ts'; import * as Defs from '../../../shared/Defs.ts'; import { Octree } from '../../../shared/Octree.ts'; -import { eventBus, registry } from '../../registry.mjs'; +import { eventBus, getCommonRegistry } from '../../registry.mjs'; import CollisionModelSource, { createRegistryCollisionModelSource } from '../../common/CollisionModelSource.ts'; -import { BrushModel } from '../../../engine/common/Mod.ts'; +import { BrushModel } from '../../common/Mod.ts'; + +interface BoxClipNode { + planenum: number; + children: number[]; +} + +interface BoxPlane { + type: number; + normal: Vector; + dist: number; +} + +interface BoxHull { + clipnodes: BoxClipNode[]; + planes: BoxPlane[]; + firstclipnode: number; + lastclipnode: number; +} -let { SV } = registry; +let { SV } = getCommonRegistry(); eventBus.subscribe('registry.frozen', () => { - SV = registry.SV; + ({ SV } = getCommonRegistry()); }); /** @@ -16,42 +37,46 @@ eventBus.subscribe('registry.frozen', () => { * Handles the area node BSP tree used for spatial queries. */ export class ServerArea { - /** @type {?Octree} */ - tree = null; + tree: Octree | null = null; + box_clipnodes: BoxClipNode[] = []; + box_planes: BoxPlane[] = []; + box_hull: BoxHull | null = null; + readonly _modelSource: CollisionModelSource; /** - * @param {CollisionModelSource} [modelSource] runtime model resolver + * @param modelSource Runtime model resolver. */ - constructor(modelSource = createRegistryCollisionModelSource()) { + constructor(modelSource: CollisionModelSource = createRegistryCollisionModelSource()) { this._modelSource = modelSource; } /** * Resolve a collision model by model index from either the active server or * the client precache populated during signon. - * @param {number} modelIndex precached model index - * @returns {BrushModel|object|null} resolved model, if any + * @param modelIndex Precached model index. + * @returns Resolved model, if any. */ - _getModelByIndex(modelIndex) { + _getModelByIndex(modelIndex: number): ReturnType { return this._modelSource.getModelByIndex(modelIndex); } /** * Compute entity world bounds, expanding rotated BSP bounds into a world AABB. - * @param {import('../Edict.mjs').ServerEdict} ent entity being linked - * @param {Vector} absmin output minimum bounds - * @param {Vector} absmax output maximum bounds + * @param ent Entity being linked. + * @param absmin Output minimum bounds. + * @param absmax Output maximum bounds. */ - _computeEntityBounds(ent, absmin, absmax) { - const origin = ent.entity.origin; - const mins = ent.entity.mins; - const maxs = ent.entity.maxs; - const model = this._getModelByIndex(ent.entity.modelindex); - - if (ent.entity.solid === Defs.solid.SOLID_BSP + _computeEntityBounds(ent: ServerEdict, absmin: Vector, absmax: Vector): void { + const entity = ent.entity!; + const origin = entity.origin; + const mins = entity.mins; + const maxs = entity.maxs; + const model = this._getModelByIndex(entity.modelindex); + + if (entity.solid === Defs.solid.SOLID_BSP && model instanceof BrushModel - && !ent.entity.angles.isOrigin()) { - const basis = ent.entity.angles.toRotationMatrix(); + && !entity.angles.isOrigin()) { + const basis = entity.angles.toRotationMatrix(); const forward = new Vector(basis[0], basis[1], basis[2]); const right = new Vector(basis[3], basis[4], basis[5]); const up = new Vector(basis[6], basis[7], basis[8]); @@ -92,7 +117,7 @@ export class ServerArea { /** * Initializes the temporary hull data used for axis-aligned clipping. */ - initBoxHull() { + initBoxHull(): void { this.box_clipnodes = []; this.box_planes = []; this.box_hull = { @@ -102,42 +127,45 @@ export class ServerArea { lastclipnode: 5, }; - for (let i = 0; i <= 5; i++) { - const node = {}; - this.box_clipnodes[i] = node; - node.planenum = i; - node.children = []; - node.children[i & 1] = Defs.content.CONTENT_EMPTY; - if (i !== 5) { - node.children[1 - (i & 1)] = i + 1; + for (let index = 0; index <= 5; index++) { + const node: BoxClipNode = { + planenum: index, + children: [], + }; + this.box_clipnodes[index] = node; + node.children[index & 1] = Defs.content.CONTENT_EMPTY; + if (index !== 5) { + node.children[1 - (index & 1)] = index + 1; } else { - node.children[1 - (i & 1)] = Defs.content.CONTENT_SOLID; + node.children[1 - (index & 1)] = Defs.content.CONTENT_SOLID; } - const plane = {}; - this.box_planes[i] = plane; - plane.type = i >> 1; - plane.normal = new Vector(); - plane.normal[i >> 1] = 1.0; - plane.dist = 0.0; + const plane: BoxPlane = { + type: index >> 1, + normal: new Vector(), + dist: 0.0, + }; + this.box_planes[index] = plane; + plane.normal[index >> 1] = 1.0; } } /** * Resolves the hull that should be used when clipping against a given entity. - * @param {import('../Edict.mjs').ServerEdict} ent edict to create a hull for - * @param {Vector} mins minimum extents of the moving object - * @param {Vector} maxs maximum extents of the moving object - * @param {Vector} out_offset receives the hull offset relative to entity origin - * @returns {*} the hull structure used for collision tests + * @param ent Edict to create a hull for. + * @param mins Minimum extents of the moving object. + * @param maxs Maximum extents of the moving object. + * @param out_offset Receives the hull offset relative to entity origin. + * @returns The hull structure used for collision tests. */ - hullForEntity(ent, mins, maxs, out_offset) { - const model = this._getModelByIndex(ent.entity.modelindex); - const origin = ent.entity.origin; - - if (ent.entity.solid !== Defs.solid.SOLID_BSP || !(model instanceof BrushModel)) { // CR: don’t ask - const emaxs = ent.entity.maxs; - const emins = ent.entity.mins; + hullForEntity(ent: ServerEdict, mins: Vector, maxs: Vector, out_offset: Vector): Hull | BoxHull { + const entity = ent.entity!; + const model = this._getModelByIndex(entity.modelindex); + const origin = entity.origin; + + if (entity.solid !== Defs.solid.SOLID_BSP || !(model instanceof BrushModel)) { // CR: don’t ask + const emaxs = entity.maxs; + const emins = entity.mins; // FIXME: create a new hull for this instead of mutating the box hull planes (which could cause issues if multiple entities use it at the same time) this.box_planes[0].dist = emaxs[0] - mins[0]; this.box_planes[1].dist = emins[0] - maxs[0]; @@ -146,20 +174,20 @@ export class ServerArea { this.box_planes[4].dist = emaxs[2] - mins[2]; this.box_planes[5].dist = emins[2] - maxs[2]; out_offset.set(origin); - return this.box_hull; + return this.box_hull!; } - console.assert(ent.entity.movetype !== Defs.moveType.MOVETYPE_NONE, + console.assert(entity.movetype !== Defs.moveType.MOVETYPE_NONE, 'requires SOLID_BSP with MOVETYPE_NONE, use MOVETYPE_PUSH instead'); const size = maxs[0] - mins[0]; - let hull; + let hull: Hull; if (size < 3.0) { - hull = model.hulls[0]; + hull = model.hulls[0]!; } else if (size <= 32.0) { - hull = model.hulls[1]; + hull = model.hulls[1]!; } else { - hull = model.hulls[2]; + hull = model.hulls[2]!; } out_offset.setTo( @@ -173,10 +201,10 @@ export class ServerArea { /** * Recursively builds the area node BSP used for spatial queries. - * @param {Vector} mins minimum bounds - * @param {Vector} maxs maximum bounds + * @param mins Minimum bounds. + * @param maxs Maximum bounds. */ - initOctree(mins, maxs) { + initOctree(mins: Vector, maxs: Vector): void { // center is the midpoint of mins/maxs const center = mins.copy().add(maxs).multiply(0.5); @@ -194,14 +222,14 @@ export class ServerArea { const halfSize = pow2 / 2; - this.tree = /** @type {Octree} */ (new Octree(center, halfSize, 16, 64)); + this.tree = new Octree(center, halfSize, 16, 64); } /** * Removes an edict from any area lists it is currently linked to. - * @param {import('../Edict.mjs').ServerEdict} ent edict to unlink + * @param ent Edict to unlink. */ - unlinkEdict(ent) { + unlinkEdict(ent: ServerEdict): void { if (ent.octreeNode) { ent.octreeNode.remove(ent); ent.octreeNode = null; @@ -210,32 +238,43 @@ export class ServerArea { /** * Iterates all trigger edicts that potentially overlap the provided entity. - * @param {import('../Edict.mjs').ServerEdict} ent subject edict + * @param ent Subject edict. */ - touchLinks(ent) { - const absmin = ent.entity.absmin; - const absmax = ent.entity.absmax; + touchLinks(ent: ServerEdict): void { + const tree = this.tree; + const entity = ent.entity!; + const gameAPI = SV.server.gameAPI as typeof SV.server.gameAPI & { time: number }; + + console.assert(tree !== null, 'ServerArea tree must be initialized before touchLinks'); + + const activeTree = tree!; + + const absmin = entity.absmin; + const absmax = entity.absmax; - for (const touch of this.tree.queryAABB(absmin, absmax)) { + for (const touch of activeTree.queryAABB(absmin, absmax)) { if (touch === ent) { continue; } - if (!touch.entity.touch || touch.entity.solid !== Defs.solid.SOLID_TRIGGER) { + const touchEntity = touch.entity!; + if (!touchEntity.touch || touchEntity.solid !== Defs.solid.SOLID_TRIGGER) { continue; } - SV.server.gameAPI.time = SV.server.time; - touch.entity.touch(!ent.isFree() ? ent.entity : null); + const touchFn = touchEntity.touch as (this: BaseEntity, other: BaseEntity | null) => void; + + gameAPI.time = SV.server.time; + touchFn.call(touchEntity, !ent.isFree() ? ent.entity : null); } } /** * Populates the leaf list for an entity by traversing the BSP tree. - * @param {import('../Edict.mjs').ServerEdict} ent subject edict - * @param {*} node current BSP node + * @param ent Subject edict. + * @param node Current BSP node. */ - findTouchedLeafs(ent, node) { + findTouchedLeafs(ent: ServerEdict, node: Node): void { if (node.contents === Defs.content.CONTENT_SOLID) { return; } @@ -249,33 +288,33 @@ export class ServerArea { return; } - const entity = /** @type {import('../Edict.mjs').BaseEntity} */ (ent.entity); - console.assert(entity !== null); + console.assert(ent.entity !== null); + const entity = ent.entity! as BaseEntity; - const sides = Vector.boxOnPlaneSide(entity.absmin, entity.absmax, node.plane); + const sides = Vector.boxOnPlaneSide(entity.absmin, entity.absmax, node.plane!); if ((sides & 1) !== 0) { - this.findTouchedLeafs(ent, node.children[0]); + this.findTouchedLeafs(ent, node.children[0] as Node); } if ((sides & 2) !== 0) { - this.findTouchedLeafs(ent, node.children[1]); + this.findTouchedLeafs(ent, node.children[1] as Node); } } /** * Inserts an edict into the area lists and optionally processes trigger touches. * NOTE: absmin/absmax will be reset. - * @param {import('../Edict.mjs').ServerEdict} ent edict to link - * @param {boolean} touchTriggers whether triggers should be evaluated + * @param ent Edict to link. + * @param touchTriggers Whether triggers should be evaluated. */ - linkEdict(ent, touchTriggers = false) { + linkEdict(ent: ServerEdict, touchTriggers = false): void { if (ent.equals(SV.server.edicts[0]) || ent.isFree()) { return; } - const entity = /** @type {import('../Edict.mjs').BaseEntity} */ (ent.entity); - console.assert(entity !== null); + console.assert(ent.entity !== null); + const entity = ent.entity! as BaseEntity; SV.server.navigation.relinkEdict(ent); this.unlinkEdict(ent); @@ -307,7 +346,13 @@ export class ServerArea { return; } - const node = this.tree.insert(ent); + const tree = this.tree; + + console.assert(tree !== null, 'ServerArea tree must be initialized before linkEdict'); + + const activeTree = tree!; + + const node = activeTree.insert(ent); ent.octreeNode = node; if (entity.movetype !== Defs.moveType.MOVETYPE_NOCLIP && touchTriggers) { diff --git a/source/engine/server/physics/ServerClientPhysics.mjs b/source/engine/server/physics/ServerClientPhysics.ts similarity index 68% rename from source/engine/server/physics/ServerClientPhysics.mjs rename to source/engine/server/physics/ServerClientPhysics.ts index f994716f..edb1c850 100644 --- a/source/engine/server/physics/ServerClientPhysics.mjs +++ b/source/engine/server/physics/ServerClientPhysics.ts @@ -1,19 +1,19 @@ +import type { ServerEdict } from '../Edict.mjs'; + import Vector from '../../../shared/Vector.ts'; import * as Defs from '../../../shared/Defs.ts'; -import { eventBus, registry } from '../../registry.mjs'; +import { eventBus, getCommonRegistry } from '../../registry.mjs'; import { VELOCITY_EPSILON, -} from './Defs.mjs'; +} from './Defs.ts'; import { ServerClient } from '../Client.mjs'; import { PM_TYPE } from '../../common/Pmove.ts'; import { BrushModel } from '../../common/Mod.ts'; -let { Host, SV, V } = registry; +let { Host, SV, V } = getCommonRegistry(); eventBus.subscribe('registry.frozen', () => { - Host = registry.Host; - SV = registry.SV; - V = registry.V; + ({ Host, SV, V } = getCommonRegistry()); }); /** @@ -23,9 +23,6 @@ eventBus.subscribe('registry.frozen', () => { * and server authoritative movement use the exact same code path. */ export class ServerClientPhysics { - constructor() { - } - // ========================================================================= // Shared PmovePlayer integration // ========================================================================= @@ -33,14 +30,18 @@ export class ServerClientPhysics { /** * Populates SV.pmove physents with solid entities near the player. * Must be called before running a PmovePlayer for a client. - * @param {import('../Edict.mjs').ServerEdict} playerEdict player edict (excluded from list) + * @param playerEdict Player edict (excluded from list). */ - _setupPhysents(playerEdict) { + _setupPhysents(playerEdict: ServerEdict): void { const pm = SV.pmove; - pm.clearEntities(); + console.assert(pm !== null, 'SV.pmove must be initialized before setting up physents'); + + const activePmove = pm!; + + activePmove.clearEntities(); - for (let i = 1; i < SV.server.num_edicts; i++) { - const edict = SV.server.edicts[i]; + for (let index = 1; index < SV.server.num_edicts; index++) { + const edict = SV.server.edicts[index]; if (!edict || edict.isFree() || edict === playerEdict) { continue; } @@ -50,17 +51,17 @@ export class ServerClientPhysics { continue; } - const s = entity.solid; + const solidType = entity.solid; - if (s !== Defs.solid.SOLID_BSP && s !== Defs.solid.SOLID_BBOX && s !== Defs.solid.SOLID_SLIDEBOX) { + if (solidType !== Defs.solid.SOLID_BSP && solidType !== Defs.solid.SOLID_BBOX && solidType !== Defs.solid.SOLID_SLIDEBOX) { continue; } - const model = (s === Defs.solid.SOLID_BSP && entity.modelindex) + const model = solidType === Defs.solid.SOLID_BSP && entity.modelindex ? SV.server.models[entity.modelindex] : null; - pm.addEntity(entity, /** @type {BrushModel} */ (model instanceof BrushModel ? model : null)); + activePmove.addEntity(entity, model instanceof BrushModel ? model : null); } } @@ -69,20 +70,23 @@ export class ServerClientPhysics { * the entity and client objects. This replaces the old server-side * walkMove/airMove/waterMove/friction/accelerate code with the same * movement code the client uses for prediction. - * @param {import('../Edict.mjs').ServerEdict} ent player edict - * @param {ServerClient} client client connection + * @param ent Player edict. + * @param client Client connection. */ - _runSharedPmove(ent, client) { - const entity = ent.entity; + _runSharedPmove(ent: ServerEdict, client: ServerClient): void { + const entity = ent.entity!; const pm = SV.pmove; + console.assert(pm !== null, 'SV.pmove must be initialized before running shared pmove'); + + const activePmove = pm!; // --- Set up physents --- this._setupPhysents(ent); // --- Create a fresh player mover --- - const pmove = pm.newPlayerMove(); + const pmove = activePmove.newPlayerMove(); - // --- Copy entity state → pmove --- + // --- Copy entity state -> pmove --- pmove.origin.set(entity.origin); pmove.velocity.set(entity.velocity); pmove.angles.set(entity.v_angle ?? entity.angles); @@ -136,17 +140,19 @@ export class ServerClientPhysics { pmove.move(); } - // --- Copy results back → entity --- + // --- Copy results back -> entity --- entity.origin = entity.origin.set(pmove.origin); entity.velocity = entity.velocity.set(pmove.velocity); // Ground entity if (pmove.onground !== null) { entity.flags |= Defs.flags.FL_ONGROUND; - if (pmove.onground > 0 && pmove.onground < pm.physents.length) { - const pe = pm.physents[pmove.onground]; - if (pe.edictId !== undefined && pe.edictId < SV.server.num_edicts) { - entity.groundentity = SV.server.edicts[pe.edictId].entity; + if (pmove.onground > 0 && pmove.onground < activePmove.physents.length) { + const physent = activePmove.physents[pmove.onground]!; + const edictId = physent.edictId; + + if (edictId !== undefined && edictId !== null && edictId < SV.server.num_edicts) { + entity.groundentity = SV.server.edicts[edictId].entity; } else { entity.groundentity = null; } @@ -177,15 +183,17 @@ export class ServerClientPhysics { client.pmFlags = pmove.pmFlags; client.pmTime = pmove.pmTime; - // Touched entities — fire touch functions via SV.physics.impact + // Touched entities - fire touch functions via SV.physics.impact // to match the bidirectional touch semantics used by SV_FlyMove. - const touchedSet = new Set(); - for (const idx of pmove.touchindices) { - if (idx > 0 && idx < pm.physents.length && !touchedSet.has(idx)) { - touchedSet.add(idx); - const pe = pm.physents[idx]; - if (pe.edictId !== undefined && pe.edictId < SV.server.num_edicts) { - const touchEdict = SV.server.edicts[pe.edictId]; + const touchedSet = new Set(); + for (const index of pmove.touchindices) { + if (index > 0 && index < activePmove.physents.length && !touchedSet.has(index)) { + touchedSet.add(index); + const physent = activePmove.physents[index]!; + const edictId = physent.edictId; + + if (edictId !== undefined && edictId !== null && edictId < SV.server.num_edicts) { + const touchEdict = SV.server.edicts[edictId]; if (!touchEdict.isFree()) { SV.physics.impact(ent, touchEdict, entity.velocity.copy()); } @@ -200,24 +208,25 @@ export class ServerClientPhysics { /** * Updates the ideal pitch for a client when standing on the ground. - * @param {import('../Edict.mjs').ServerEdict} ent player entity + * @param ent Player entity. */ - setIdealPitch(ent) { - if (!ent || (ent.entity.flags & Defs.flags.FL_ONGROUND) === 0) { + setIdealPitch(ent: ServerEdict): void { + if (!ent || (ent.entity!.flags & Defs.flags.FL_ONGROUND) === 0) { return; } - const origin = ent.entity.origin; - const angleval = ent.entity.angles[1] * (Math.PI / 180.0); + const entity = ent.entity!; + const origin = entity.origin; + const angleval = entity.angles[1] * (Math.PI / 180.0); const sinval = Math.sin(angleval); const cosval = Math.cos(angleval); - const top = new Vector(0.0, 0.0, origin[2] + ent.entity.view_ofs[2]); + const top = new Vector(0.0, 0.0, origin[2] + entity.view_ofs[2]); const bottom = new Vector(0.0, 0.0, top[2] - 160.0); - const z = []; + const z: number[] = []; - for (let i = 0; i < 6; i++) { - top[0] = bottom[0] = origin[0] + cosval * (i + 3) * 12.0; - top[1] = bottom[1] = origin[1] + sinval * (i + 3) * 12.0; + for (let index = 0; index < 6; index++) { + top[0] = bottom[0] = origin[0] + cosval * (index + 3) * 12.0; + top[1] = bottom[1] = origin[1] + sinval * (index + 3) * 12.0; const tr = SV.collision.move(top, Vector.origin, Vector.origin, bottom, 1, ent); @@ -225,14 +234,14 @@ export class ServerClientPhysics { return; } - z[i] = top[2] - tr.fraction * 160.0; + z[index] = top[2] - tr.fraction * 160.0; } let dir = 0.0; let steps = 0; - for (let i = 1; i < 6; i++) { - const step = z[i] - z[i - 1]; + for (let index = 1; index < 6; index++) { + const step = z[index] - z[index - 1]; if (Math.abs(step) <= VELOCITY_EPSILON) { continue; @@ -247,28 +256,30 @@ export class ServerClientPhysics { } if (dir === 0.0) { - ent.entity.idealpitch = 0.0; + entity.idealpitch = 0.0; return; } if (steps >= 2) { - ent.entity.idealpitch = -dir * SV.idealpitchscale.value; + entity.idealpitch = -dir * SV.idealpitchscale.value; } } // ========================================================================= - // Client think — punchangle decay and visual angle setup + // Client think - punchangle decay and visual angle setup // ========================================================================= /** * Executes per-frame input processing for a client. - * Movement is NOT done here — it runs through PmovePlayer in physicsClient. + * Movement is NOT done here - it runs through PmovePlayer in physicsClient. * This only handles punchangle decay and visual angle updates. - * @param {import('../Edict.mjs').ServerEdict} edict client edict - * @param {ServerClient} client client connection (unused, movement runs in physicsClient) + * @param edict Client edict. + * @param _client Client connection (unused, movement runs in physicsClient). */ - clientThink(edict, client) { // eslint-disable-line no-unused-vars - const entity = edict.entity; + clientThink(edict: ServerEdict, _client: ServerClient): void { + void _client; + + const entity = edict.entity!; if (!edict || entity.movetype === Defs.moveType.MOVETYPE_NONE) { return; @@ -315,20 +326,24 @@ export class ServerClientPhysics { * move commands can arrive in a single server frame. We process each * queued command individually with its original msec so the server * movement matches the client-side prediction (QW-style). - * @param {import('../Edict.mjs').ServerEdict} ent edict + * @param ent Edict. */ - physicsClient(ent) { + physicsClient(ent: ServerEdict): void { const client = ent.getClient(); + console.assert(client !== null, 'client edict must have an attached server client'); + + const activeClient = client!; + const gameAPI = SV.server.gameAPI as typeof SV.server.gameAPI & { time: number }; - if (client.state < ServerClient.STATE.CONNECTED) { + if (activeClient.state < ServerClient.STATE.CONNECTED) { return; } - SV.server.gameAPI.time = SV.server.time; + gameAPI.time = SV.server.time; SV.server.gameAPI.PlayerPreThink(ent); SV.physics.checkVelocity(ent); - const movetype = ent.entity.movetype >> 0; - if ((movetype === Defs.moveType.MOVETYPE_TOSS) || (movetype === Defs.moveType.MOVETYPE_BOUNCE)) { + const movetype = ent.entity!.movetype >> 0; + if (movetype === Defs.moveType.MOVETYPE_TOSS || movetype === Defs.moveType.MOVETYPE_BOUNCE) { SV.physics.physicsToss(ent); } else { if (!SV.physics.runThink(ent)) { @@ -347,21 +362,21 @@ export class ServerClientPhysics { // smooth and the next packet will catch up. Running with the // last known cmd would add phantom movement, making the // remote player appear to move faster than the host. - for (const cmd of client.pendingCmds) { - client.cmd.set(cmd); - this._runSharedPmove(ent, client); + for (const cmd of activeClient.pendingCmds) { + activeClient.cmd.set(cmd); + this._runSharedPmove(ent, activeClient); } - client.pendingCmds.length = 0; + activeClient.pendingCmds.length = 0; break; case Defs.moveType.MOVETYPE_FLY: SV.physics.flyMove(ent, Host.frametime); break; default: - throw new Error('SV.Physics_Client: bad movetype ' + movetype); + throw new Error(`SV.Physics_Client: bad movetype ${movetype}`); } } SV.area.linkEdict(ent, true); - SV.server.gameAPI.time = SV.server.time; + gameAPI.time = SV.server.time; SV.server.gameAPI.PlayerPostThink(ent); } } diff --git a/source/engine/server/physics/ServerCollision.mjs b/source/engine/server/physics/ServerCollision.ts similarity index 51% rename from source/engine/server/physics/ServerCollision.mjs rename to source/engine/server/physics/ServerCollision.ts index 7ceec252..5023da89 100644 --- a/source/engine/server/physics/ServerCollision.mjs +++ b/source/engine/server/physics/ServerCollision.ts @@ -1,9 +1,12 @@ +import type { Hull } from '../../common/model/BSP.ts'; +import type { ServerEdict } from '../Edict.mjs'; + import Vector from '../../../shared/Vector.ts'; import * as Defs from '../../../shared/Defs.ts'; import CollisionModelSource, { createRegistryCollisionModelSource } from '../../common/CollisionModelSource.ts'; -import Mod, { BrushModel } from '../../common/Mod.ts'; +import { BrushModel, MeshModel } from '../../common/Mod.ts'; import { BrushTrace, DIST_EPSILON, Trace as SharedTrace } from '../../common/Pmove.ts'; -import { eventBus, registry } from '../../registry.mjs'; +import { eventBus, getCommonRegistry } from '../../registry.mjs'; import { BrushCollisionState, CollisionState, @@ -13,22 +16,29 @@ import { MeshTraceContext, MeshTriangle, MoveClip, -} from './ServerCollisionSupport.mjs'; +} from './ServerCollisionSupport.ts'; import { hullPointContents as legacyHullPointContents, pointContents as legacyPointContents, recursiveHullCheck as legacyRecursiveHullCheck, -} from './ServerLegacyHullCollision.mjs'; +} from './ServerLegacyHullCollision.ts'; + +type CollisionModel = BrushModel | MeshModel | object | null; -let { Con, SV } = registry; +interface StaticWorldSource { + readonly worldEntity: ServerEdict | null; + readonly worldModel: BrushModel | null; +} -/** @typedef {import('../Client.mjs').ServerEdict} ServerEdict */ +interface TraceExtents { + readonly mins: Vector; + readonly maxs: Vector; +} -/** @typedef {import('../../common/Pmove.ts').Trace} SharedBrushTrace */ +let { Con, SV } = getCommonRegistry(); eventBus.subscribe('registry.frozen', () => { - Con = registry.Con; - SV = registry.SV; + ({ Con, SV } = getCommonRegistry()); }); /** @@ -37,60 +47,59 @@ eventBus.subscribe('registry.frozen', () => { * falling back to legacy hull traces otherwise. */ export class ServerCollision { - static MISSILE_MINS = new Vector(-15.0, -15.0, -15.0); - static MISSILE_MAXS = new Vector(15.0, 15.0, 15.0); + static readonly MISSILE_MINS = new Vector(-15.0, -15.0, -15.0); + static readonly MISSILE_MAXS = new Vector(15.0, 15.0, 15.0); + + private readonly _modelSource: CollisionModelSource; /** - * @param {CollisionModelSource} [modelSource] runtime model resolver + * Resolve a collision model by model index from either the active server or + * the client precache populated by server signon data. */ - constructor(modelSource = createRegistryCollisionModelSource()) { + constructor(modelSource: CollisionModelSource = createRegistryCollisionModelSource()) { this._modelSource = modelSource; } /** * Resolve a collision model by model index from either the active server or * the client precache populated by server signon data. - * @param {number} modelIndex precached model index - * @returns {BrushModel|object|null} resolved model, if any */ - _getModelByIndex(modelIndex) { + _getModelByIndex(modelIndex: number): CollisionModel { return this._modelSource.getModelByIndex(modelIndex); } /** * Resolve the model used by an entity for collision. - * @param {ServerEdict} ent entity being tested - * @returns {BrushModel|object|null} collision model, if any */ - _getEntityModel(ent) { + _getEntityModel(ent: ServerEdict): CollisionModel { if (ent === SV.server?.edicts?.[0]) { return this._getStaticWorldSource().worldModel; } - return this._getModelByIndex(ent.entity.modelindex); + return this._getModelByIndex(ent.entity!.modelindex); } /** * Resolve the collision state used by an entity during tracing. - * @param {ServerEdict} ent entity being tested - * @returns {CollisionState|null} collision state */ - _getEntityCollisionState(ent) { - if (ent.entity.solid === Defs.solid.SOLID_MESH) { + _getEntityCollisionState(ent: ServerEdict): CollisionState | null { + const entity = ent.entity!; + + if (entity.solid === Defs.solid.SOLID_MESH) { const model = this._getEntityModel(ent); - return model === null || model === undefined - ? null - : new MeshCollisionState(ent, model); + return this._isMeshModel(model) + ? new MeshCollisionState(ent, model) + : null; } - if (ent.entity.solid === Defs.solid.SOLID_BSP) { + if (entity.solid === Defs.solid.SOLID_BSP) { const model = this._getEntityModel(ent); - if (!(model instanceof BrushModel) || !model.hasBrushData) { + if (!this._isBrushModel(model) || !model.hasBrushData) { return new HullCollisionState(ent); } - return new BrushCollisionState(ent, model, ent.entity.origin, ent.entity.angles); + return new BrushCollisionState(ent, model, entity.origin, entity.angles); } return new HullCollisionState(ent); @@ -98,19 +107,16 @@ export class ServerCollision { /** * Build a hull fallback state for callers that want a guaranteed collision mode. - * @param {ServerEdict} ent entity being tested - * @returns {HullCollisionState} hull collision state */ - _getHullFallbackState(ent) { + _getHullFallbackState(ent: ServerEdict): HullCollisionState { return new HullCollisionState(ent); } /** * Resolve the active static-world model for traces that can run on either a * local server or a pure client connection. - * @returns {{ worldEntity: ServerEdict|null, worldModel: BrushModel|null }} static world source */ - _getStaticWorldSource() { + _getStaticWorldSource(): StaticWorldSource { return { worldEntity: this._modelSource.getWorldEntity(), worldModel: this._modelSource.getWorldModel(), @@ -119,33 +125,54 @@ export class ServerCollision { /** * Convert a shared brush trace result into the server collision trace shape. - * @param {import('../../common/Pmove.ts').Trace} brushTrace shared brush trace result - * @param {ServerEdict} ent entity that owns the brush model - * @returns {CollisionTrace} server collision trace */ - _toServerTrace(brushTrace, ent) { - return CollisionTrace.fromSharedTrace(brushTrace, ent); + _toServerTrace(brushTrace: SharedTrace, ent: ServerEdict | null): CollisionTrace { + if (ent !== null) { + return CollisionTrace.fromSharedTrace(brushTrace, ent); + } + + const trace = new CollisionTrace(brushTrace.endpos.copy(), { + fraction: brushTrace.fraction, + allsolid: brushTrace.allsolid, + startsolid: brushTrace.startsolid, + plane: { normal: brushTrace.plane.normal.copy(), dist: brushTrace.plane.dist }, + inopen: brushTrace.inopen, + inwater: brushTrace.inwater, + }); + + if (trace.allsolid) { + trace.startsolid = true; + } + + return trace; + } + + /** + * Returns true when the provided model is a brush model. + */ + _isBrushModel(model: CollisionModel): model is BrushModel { + return model instanceof BrushModel; + } + + /** + * Returns true when the provided model is a mesh model. + */ + _isMeshModel(model: CollisionModel): model is MeshModel { + return model instanceof MeshModel; } /** - * @param {Vector} mins minimum extents of the moving box - * @param {Vector} maxs maximum extents of the moving box - * @returns {boolean} true when the trace is point-sized + * Determine whether a trace is point-sized. */ - _isPointTrace(mins, maxs) { + _isPointTrace(mins: Vector, maxs: Vector): boolean { return mins.isOrigin() && maxs.isOrigin(); } /** * Emit a developer-only summary for point-trace hits so live repros can * distinguish world hull issues from dynamic-entity hits. - * @param {CollisionTrace} trace final trace result - * @param {Vector} start trace start - * @param {Vector} end trace end - * @param {Vector} mins trace mins - * @param {Vector} maxs trace maxs */ - _debugLogPointTraceHit(trace, start, end, mins, maxs) { + _debugLogPointTraceHit(trace: CollisionTrace, start: Vector, end: Vector, mins: Vector, maxs: Vector): void { if (!this._isPointTrace(mins, maxs)) { return; } @@ -154,14 +181,19 @@ export class ServerCollision { return; } - const hitEntity = trace.ent.entity; + console.assert(trace.ent.entity !== null, 'point trace hit entity must resolve to a live entity'); + const hitEntity = trace.ent.entity!; const model = this._getEntityModel(trace.ent); - const modelName = model && typeof model.name === 'string' ? model.name : ''; + const modelName = model !== null + && typeof model === 'object' + && 'name' in model + && typeof model.name === 'string' + ? model.name + : ''; const classname = typeof hitEntity.classname === 'string' ? hitEntity.classname : ''; Con.DPrint( - 'ServerCollision.move point trace hit ' - + `ent=${trace.ent.num} classname=${classname} solid=${hitEntity.solid} movetype=${hitEntity.movetype} ` + `ServerCollision.move point trace hit ent=${trace.ent.num} classname=${classname} solid=${hitEntity.solid} movetype=${hitEntity.movetype} ` + `modelindex=${hitEntity.modelindex} model=${modelName} fraction=${trace.fraction.toFixed(4)} ` + `start=(${start[0].toFixed(1)} ${start[1].toFixed(1)} ${start[2].toFixed(1)}) ` + `end=(${end[0].toFixed(1)} ${end[1].toFixed(1)} ${end[2].toFixed(1)}) ` @@ -172,13 +204,8 @@ export class ServerCollision { /** * Legacy hull point traces remain the compatibility baseline when brush and * hull BSP paths disagree about the first finite hit. - * When the brush path reports an earlier finite hit than the legacy hull path, - * prefer the later hull impact to avoid terminating on traversal-only planes. - * @param {CollisionTrace} brushTrace brush-based trace result - * @param {CollisionTrace} hullTrace hull-based trace result - * @returns {boolean} true when the hull result should replace the brush result */ - _shouldPreferHullPointTrace(brushTrace, hullTrace) { + _shouldPreferHullPointTrace(brushTrace: CollisionTrace, hullTrace: CollisionTrace): boolean { if (hullTrace.fraction >= 1.0) { return false; } @@ -197,12 +224,8 @@ export class ServerCollision { /** * Hull fallback is only safe for point traces whose BSP entity is not rotated, * because the legacy hull path does not apply entity angles. - * @param {BrushCollisionState} state brush collision state - * @param {Vector} mins minimum extents of the moving box - * @param {Vector} maxs maximum extents of the moving box - * @returns {boolean} true when the brush trace can be cross-checked with hulls */ - _canUseHullPointFallback(state, mins, maxs) { + _canUseHullPointFallback(state: BrushCollisionState, mins: Vector, maxs: Vector): boolean { if (!this._isPointTrace(mins, maxs)) { return false; } @@ -217,14 +240,14 @@ export class ServerCollision { /** * Trace a BSP entity through the shared brush path and cross-check supported * point traces against the legacy hull path to avoid false early hits. - * @param {BrushCollisionState} state brush collision state - * @param {Vector} start world-space start position - * @param {Vector} mins minimum extents of the moving box - * @param {Vector} maxs maximum extents of the moving box - * @param {Vector} end world-space end position - * @returns {CollisionTrace} collision result - */ - _clipMoveToBrushStateWithHullFallback(state, start, mins, maxs, end) { + */ + _clipMoveToBrushStateWithHullFallback( + state: BrushCollisionState, + start: Vector, + mins: Vector, + maxs: Vector, + end: Vector, + ): CollisionTrace { const brushTrace = this._traceBrushModel( state.model, start, @@ -252,16 +275,16 @@ export class ServerCollision { /** * Run the shared brush trace path for a BSP entity, including zero-length * position tests that must avoid swept-trace startsolid artifacts. - * @param {BrushModel} model brush collision model - * @param {Vector} start world-space start position - * @param {Vector} mins box mins - * @param {Vector} maxs box maxs - * @param {Vector} end world-space end position - * @param {Vector} origin entity origin - * @param {Vector} angles entity angles - * @returns {SharedTrace} shared trace result - */ - _traceBrushModel(model, start, mins, maxs, end, origin, angles) { + */ + _traceBrushModel( + model: BrushModel, + start: Vector, + mins: Vector, + maxs: Vector, + end: Vector, + origin: Vector, + angles: Vector, + ): SharedTrace { if (start.equals(end)) { const blocked = !BrushTrace.transformedTestPosition( model, @@ -297,27 +320,15 @@ export class ServerCollision { /** * Trace against an entity through the shared brush path. - * @param {BrushCollisionState} state brush collision state - * @param {Vector} start world-space start position - * @param {Vector} mins minimum extents of the moving box - * @param {Vector} maxs maximum extents of the moving box - * @param {Vector} end world-space end position - * @returns {CollisionTrace} collision result - */ - _clipMoveToBrushState(state, start, mins, maxs, end) { + */ + _clipMoveToBrushState(state: BrushCollisionState, start: Vector, mins: Vector, maxs: Vector, end: Vector): CollisionTrace { return this._clipMoveToBrushStateWithHullFallback(state, start, mins, maxs, end); } /** * Trace against an entity through the legacy hull path. - * @param {HullCollisionState} state hull collision state - * @param {Vector} start world-space start position - * @param {Vector} mins minimum extents of the moving box - * @param {Vector} maxs maximum extents of the moving box - * @param {Vector} end world-space end position - * @returns {CollisionTrace} collision result - */ - _clipMoveToHullState(state, start, mins, maxs, end) { + */ + _clipMoveToHullState(state: HullCollisionState, start: Vector, mins: Vector, maxs: Vector, end: Vector): CollisionTrace { const trace = CollisionTrace.hullInitial(end); const offset = new Vector(); @@ -325,7 +336,7 @@ export class ServerCollision { const startLocal = start.copy().subtract(offset); const endLocal = end.copy().subtract(offset); - this.recursiveHullCheck(hull, hull.firstclipnode, 0.0, 1.0, startLocal, endLocal, trace); + this.recursiveHullCheck(hull, hull.firstclipnode ?? 0, 0.0, 1.0, startLocal, endLocal, trace); if (trace.fraction !== 1.0) { trace.endpos.add(offset); @@ -341,34 +352,24 @@ export class ServerCollision { /** * Trace a line through a specific legacy hull without exposing clipnode walks * to higher-level callers. - * @param {*} hull hull to trace against - * @param {Vector} start start position in hull space - * @param {Vector} end end position in hull space - * @returns {CollisionTrace} collision result */ - _traceLegacyHullLine(hull, start, end) { + _traceLegacyHullLine(hull: Hull, start: Vector, end: Vector): CollisionTrace { const trace = CollisionTrace.hullInitial(end); - this.recursiveHullCheck(hull, hull.firstclipnode, 0.0, 1.0, start, end, trace); + this.recursiveHullCheck(hull, hull.firstclipnode ?? 0, 0.0, 1.0, start, end, trace); return trace; } /** * Determines the contents inside a hull by descending the clipnode tree. - * @param {*} hull hull data to test against - * @param {number} num starting clipnode index - * @param {Vector} p point to classify - * @returns {number} content type for the point */ - hullPointContents(hull, num, p) { + hullPointContents(hull: Hull, num: number, p: Vector): number { return legacyHullPointContents(hull, num, p); } /** * Normalize static-world contents values so current volumes behave like water. - * @param {number} contents raw contents value - * @returns {number} normalized static-world contents value */ - _normalizeStaticWorldContents(contents) { + _normalizeStaticWorldContents(contents: number): number { if ((contents <= Defs.content.CONTENT_CURRENT_0) && (contents >= Defs.content.CONTENT_CURRENT_DOWN)) { return Defs.content.CONTENT_WATER; } @@ -379,11 +380,8 @@ export class ServerCollision { /** * Sample the contents of a brush-backed world without exposing brush internals * to higher-level callers. - * @param {BrushModel} worldModel brush-backed world model - * @param {Vector} point position to sample - * @returns {number} world contents value */ - _pointContentsBrushStaticWorld(worldModel, point) { + _pointContentsBrushStaticWorld(worldModel: BrushModel, point: Vector): number { if (!BrushTrace.transformedTestPosition( worldModel, point, @@ -400,21 +398,16 @@ export class ServerCollision { /** * Sample static-world contents using the best collision backend for the - * active map. This queries worldspawn only; BSP entities such as doors are - * not included here. Hull 0 may dispatch to brush contents when available, - * while explicit non-zero hull queries stay on the legacy compatibility path. - * @param {Vector} point position to sample - * @param {number} [hullNum] explicit world hull index for legacy compatibility - * @returns {number} static-world contents value - */ - staticWorldContents(point, hullNum = 0) { + * active map. + */ + staticWorldContents(point: Vector, hullNum = 0): number { const { worldModel } = this._getStaticWorldSource(); if (worldModel === null) { return Defs.content.CONTENT_EMPTY; } - if (hullNum === 0 && worldModel instanceof BrushModel && worldModel.hasBrushData) { + if (hullNum === 0 && worldModel.hasBrushData) { return this._pointContentsBrushStaticWorld(worldModel, point); } @@ -423,47 +416,33 @@ export class ServerCollision { /** * Compatibility alias for staticWorldContents. - * @param {Vector} point position to sample - * @param {number} [hullNum] explicit world hull index for legacy compatibility - * @returns {number} static-world contents value */ - worldContents(point, hullNum = 0) { + worldContents(point: Vector, hullNum = 0): number { return this.staticWorldContents(point, hullNum); } /** * Compatibility alias for staticWorldContents. - * @param {Vector} p position to sample - * @param {number} [hullNum] explicit world hull index for legacy compatibility - * @returns {number} static-world content */ - pointContents(p, hullNum = 0) { + pointContents(p: Vector, hullNum = 0): number { return this.staticWorldContents(p, hullNum); } /** * Trace static-world geometry using the best collision backend for the active - * map. This traces worldspawn only; BSP entities such as doors are excluded. - * Hull 0 can dispatch to shared brush tracing when brush data is available, - * while explicit non-zero hull queries remain on the legacy compatibility path. - * @param {Vector} start start position - * @param {Vector} mins minimum extents of the moving box - * @param {Vector} maxs maximum extents of the moving box - * @param {Vector} end end position - * @param {number} [hullNum] explicit world hull index for legacy compatibility - * @returns {CollisionTrace} collision result against static world geometry - */ - traceStaticWorld(start, mins, maxs, end, hullNum = 0) { + * map. + */ + traceStaticWorld(start: Vector, mins: Vector, maxs: Vector, end: Vector, hullNum = 0): CollisionTrace { const { worldEntity, worldModel } = this._getStaticWorldSource(); if (worldModel === null) { return CollisionTrace.empty(end); } - if (hullNum === 0 && worldModel instanceof BrushModel && worldModel.hasBrushData) { + if (hullNum === 0 && worldModel.hasBrushData) { return this._toServerTrace( this._traceBrushModel(worldModel, start, mins, maxs, end, Vector.origin, Vector.origin), - worldEntity ?? /** @type {ServerEdict} */ (null), + worldEntity, ); } @@ -484,68 +463,48 @@ export class ServerCollision { /** * Compatibility alias for traceStaticWorld. - * @param {Vector} start start position - * @param {Vector} mins minimum extents of the moving box - * @param {Vector} maxs maximum extents of the moving box - * @param {Vector} end end position - * @param {number} [hullNum] explicit world hull index for legacy compatibility - * @returns {CollisionTrace} collision result against static world geometry - */ - traceWorld(start, mins, maxs, end, hullNum = 0) { + */ + traceWorld(start: Vector, mins: Vector, maxs: Vector, end: Vector, hullNum = 0): CollisionTrace { return this.traceStaticWorld(start, mins, maxs, end, hullNum); } /** * Trace a point-sized line against the static world. - * @param {Vector} start start position - * @param {Vector} end end position - * @param {number} [hullNum] explicit world hull index for legacy compatibility - * @returns {CollisionTrace} collision result against static world geometry */ - traceStaticWorldLine(start, end, hullNum = 0) { + traceStaticWorldLine(start: Vector, end: Vector, hullNum = 0): CollisionTrace { return this.traceStaticWorld(start, Vector.origin, Vector.origin, end, hullNum); } /** * Compatibility alias for traceStaticWorldLine. - * @param {Vector} start start position - * @param {Vector} end end position - * @param {number} [hullNum] explicit world hull index for legacy compatibility - * @returns {CollisionTrace} collision result against static world geometry */ - traceWorldLine(start, end, hullNum = 0) { + traceWorldLine(start: Vector, end: Vector, hullNum = 0): CollisionTrace { return this.traceStaticWorldLine(start, end, hullNum); } /** * Recursively tests a swept hull against the world and aggregates the trace result. - * @param {*} hull hull to trace against - * @param {number} num clipnode index - * @param {number} p1f fraction at the start point - * @param {number} p2f fraction at the end point - * @param {Vector} p1 start point - * @param {Vector} p2 end point - * @param {CollisionTrace} trace trace accumulator - * @param {number} [depth] recursion depth for scratch-vector reuse - * @returns {boolean} true if traversal should continue downward - */ - recursiveHullCheck(hull, num, p1f, p2f, p1, p2, trace, depth = 0) { + */ + recursiveHullCheck( + hull: Hull, + num: number, + p1f: number, + p2f: number, + p1: Vector, + p2: Vector, + trace: CollisionTrace, + depth = 0, + ): boolean { return legacyRecursiveHullCheck(hull, num, p1f, p2f, p1, p2, trace, depth); } /** * Tests whether a point lies inside a triangle using cross-product winding. - * @param {Vector} p point to test (should lie on the triangle plane) - * @param {Vector} v0 first vertex - * @param {Vector} v1 second vertex - * @param {Vector} v2 third vertex - * @param {Vector} normal unit face normal of the triangle - * @returns {boolean} true if the point is inside the triangle - */ - _pointInTriangle(p, v0, v1, v2, normal) { + */ + _pointInTriangle(p: Vector, v0: Vector, v1: Vector, v2: Vector, normal: Vector): boolean { // Small negative tolerance closes micro-gaps between adjacent triangles. // The cross product magnitude scales with edge length, so for typical - // game triangles (edges ~5-50 units) this allows roughly 0.01–0.025 units + // game triangles (edges ~5-50 units) this allows roughly 0.01-0.025 units // of perpendicular tolerance per edge. const EDGE_TOLERANCE = -0.125; @@ -558,14 +517,15 @@ export class ServerCollision { /** * Update a trace with start-solid information for a mesh triangle. - * @param {CollisionTrace} trace current trace result - * @param {MeshTraceContext} meshTrace mesh tracing context - * @param {MeshTriangle} triangle transformed triangle - * @param {number} startDistance signed start distance to the expanded plane - * @param {number} supportRadius projected box support radius - * @param {number} approach rate of approach toward the face - */ - _updateMeshStartSolid(trace, meshTrace, triangle, startDistance, supportRadius, approach) { + */ + _updateMeshStartSolid( + trace: CollisionTrace, + meshTrace: MeshTraceContext, + triangle: MeshTriangle, + startDistance: number, + supportRadius: number, + approach: number, + ): void { if (startDistance < -supportRadius) { return; } @@ -583,13 +543,14 @@ export class ServerCollision { /** * Try to record a nearer face impact from a mesh triangle. - * @param {CollisionTrace} trace current trace result - * @param {MeshTraceContext} meshTrace mesh tracing context - * @param {MeshTriangle} triangle transformed triangle - * @param {number} startDistance signed start distance to the expanded plane - * @param {number} approach rate of approach toward the face */ - _updateMeshImpact(trace, meshTrace, triangle, startDistance, approach) { + _updateMeshImpact( + trace: CollisionTrace, + meshTrace: MeshTraceContext, + triangle: MeshTriangle, + startDistance: number, + approach: number, + ): void { if (approach < DIST_EPSILON) { return; } @@ -615,25 +576,14 @@ export class ServerCollision { /** * Build a mesh tracing context if the target entity has usable mesh data. - * @param {ServerEdict} ent entity to collide with - * @param {Vector} start start position - * @param {Vector} mins minimum extents of the moving box - * @param {Vector} maxs maximum extents of the moving box - * @param {Vector} end end position - * @returns {MeshTraceContext|null} mesh tracing context, or null when mesh tracing is not available - */ - _createMeshTraceContext(ent, start, mins, maxs, end) { + */ + _createMeshTraceContext(ent: ServerEdict, start: Vector, mins: Vector, maxs: Vector, end: Vector): MeshTraceContext | null { const model = this._getEntityModel(ent); - if (!model || model.type !== Mod.type.mesh) { + if (!this._isMeshModel(model) || model.indices === null || model.vertices === null || model.numTriangles === 0) { return null; } - const meshModel = /** @type {import('../../common/model/MeshModel.ts').MeshModel} */ (model); - if (!meshModel.indices || !meshModel.vertices || meshModel.numTriangles === 0) { - return null; - } - - return new MeshTraceContext(ent, meshModel, start, mins, maxs, end); + return new MeshTraceContext(ent, model, start, mins, maxs, end); } /** @@ -641,16 +591,9 @@ export class ServerCollision { * Each triangle face is expanded outward by the box's support radius * (Minkowski sum) and tested for ray intersection. A DIST_EPSILON push-back * keeps the endpoint slightly in front of the surface, preventing the next - * frame's trace from starting on or inside the plane (which causes - * wall-sticking during slides). - * @param {ServerEdict} ent entity to collide with - * @param {Vector} start start position - * @param {Vector} mins minimum extents of the moving box - * @param {Vector} maxs maximum extents of the moving box - * @param {Vector} end end position - * @returns {CollisionTrace} collision result - */ - clipMoveToMesh(ent, start, mins, maxs, end) { + * frame's trace from starting on or inside the plane. + */ + clipMoveToMesh(ent: ServerEdict, start: Vector, mins: Vector, maxs: Vector, end: Vector): CollisionTrace { const trace = CollisionTrace.empty(end); const meshTrace = this._createMeshTraceContext(ent, start, mins, maxs, end); @@ -658,8 +601,8 @@ export class ServerCollision { return trace; } - for (let i = 0; i < meshTrace.model.numTriangles; i++) { - const triangle = MeshTriangle.fromMesh(meshTrace, i); + for (let index = 0; index < meshTrace.model.numTriangles; index++) { + const triangle = MeshTriangle.fromMesh(meshTrace, index); if (triangle === null) { continue; } @@ -685,14 +628,8 @@ export class ServerCollision { /** * Traces a moving box against a target entity. - * @param {ServerEdict} ent entity to collide with - * @param {Vector} start start position - * @param {Vector} mins minimum extents of the moving box - * @param {Vector} maxs maximum extents of the moving box - * @param {Vector} end end position - * @returns {CollisionTrace} collision result - */ - clipMoveToEntity(ent, start, mins, maxs, end) { + */ + clipMoveToEntity(ent: ServerEdict, start: Vector, mins: Vector, maxs: Vector, end: Vector): CollisionTrace { const state = this._getEntityCollisionState(ent) ?? this._getHullFallbackState(ent); return this._clipMoveToEntityWithState(state, start, mins, maxs, end); @@ -700,14 +637,8 @@ export class ServerCollision { /** * Trace against a target entity using its pre-resolved collision state. - * @param {CollisionState} state collision state - * @param {Vector} start start position - * @param {Vector} mins minimum extents of the moving box - * @param {Vector} maxs maximum extents of the moving box - * @param {Vector} end end position - * @returns {CollisionTrace} collision result - */ - _clipMoveToEntityWithState(state, start, mins, maxs, end) { + */ + _clipMoveToEntityWithState(state: CollisionState, start: Vector, mins: Vector, maxs: Vector, end: Vector): CollisionTrace { if (state instanceof MeshCollisionState) { return this.clipMoveToMesh(state.ent, start, mins, maxs, end); } @@ -722,12 +653,9 @@ export class ServerCollision { /** * Select the extents used to trace against a touched entity. * Missiles expand only against monsters. - * @param {MoveClip} clip move clip state - * @param {ServerEdict} touch touched entity candidate - * @returns {{mins: Vector, maxs: Vector}} trace extents for this interaction */ - _getTouchTraceExtents(clip, touch) { - if ((touch.entity.flags & Defs.flags.FL_MONSTER) !== 0) { + _getTouchTraceExtents(clip: MoveClip, touch: ServerEdict): TraceExtents { + if ((touch.entity!.flags & Defs.flags.FL_MONSTER) !== 0) { return { mins: clip.mins2, maxs: clip.maxs2 }; } @@ -736,33 +664,32 @@ export class ServerCollision { /** * Determine whether a touched entity should be skipped before narrow-phase tracing. - * @param {MoveClip} clip move clip state - * @param {ServerEdict} touch touched entity candidate - * @returns {boolean} true when the touched entity should be ignored */ - _shouldSkipTouch(clip, touch) { + _shouldSkipTouch(clip: MoveClip, touch: ServerEdict): boolean { + const touchEntity = touch.entity!; + if (touch === clip.passedict) { return true; } - if (touch.entity.solid === Defs.solid.SOLID_NOT || touch.entity.solid === Defs.solid.SOLID_TRIGGER) { + if (touchEntity.solid === Defs.solid.SOLID_NOT || touchEntity.solid === Defs.solid.SOLID_TRIGGER) { return true; } - if (clip.type === Defs.moveTypes.MOVE_NOMONSTERS && touch.entity.solid !== Defs.solid.SOLID_BSP) { + if (clip.type === Defs.moveTypes.MOVE_NOMONSTERS && touchEntity.solid !== Defs.solid.SOLID_BSP) { return true; } - if (clip.passedict && clip.passedict.entity.size[0] && !touch.entity.size[0]) { + if (clip.passedict && clip.passedict.entity!.size[0] && !touchEntity.size[0]) { return true; } if (clip.passedict) { - if (touch.entity.owner && touch.entity.owner.equals(clip.passedict)) { + if (touchEntity.owner && touchEntity.owner.equals(clip.passedict)) { return true; } - if (clip.passedict.entity.owner && clip.passedict.entity.owner.equals(touch)) { + if (clip.passedict.entity!.owner && clip.passedict.entity!.owner.equals(touch)) { return true; } } @@ -772,28 +699,24 @@ export class ServerCollision { /** * Check whether a touched entity overlaps the clip broadphase box. - * @param {MoveClip} clip move clip state - * @param {ServerEdict} touch touched entity candidate - * @returns {boolean} true when the entity overlaps the broadphase bounds */ - _touchOverlapsClipBounds(clip, touch) { + _touchOverlapsClipBounds(clip: MoveClip, touch: ServerEdict): boolean { + const touchEntity = touch.entity!; + return !( - clip.boxmins[0] > touch.entity.absmax[0] - || clip.boxmins[1] > touch.entity.absmax[1] - || clip.boxmins[2] > touch.entity.absmax[2] - || clip.boxmaxs[0] < touch.entity.absmin[0] - || clip.boxmaxs[1] < touch.entity.absmin[1] - || clip.boxmaxs[2] < touch.entity.absmin[2] + clip.boxmins[0] > touchEntity.absmax[0] + || clip.boxmins[1] > touchEntity.absmax[1] + || clip.boxmins[2] > touchEntity.absmax[2] + || clip.boxmaxs[0] < touchEntity.absmin[0] + || clip.boxmaxs[1] < touchEntity.absmin[1] + || clip.boxmaxs[2] < touchEntity.absmin[2] ); } /** * Run narrow-phase tracing against a touched entity using the correct extents. - * @param {MoveClip} clip move clip state - * @param {ServerEdict} touch touched entity candidate - * @returns {CollisionTrace} trace result against the entity */ - _traceTouch(clip, touch) { + _traceTouch(clip: MoveClip, touch: ServerEdict): CollisionTrace { const touchState = this._getEntityCollisionState(touch) ?? this._getHullFallbackState(touch); const { mins, maxs } = this._getTouchTraceExtents(clip, touch); const trace = this._clipMoveToEntityWithState(touchState, clip.start, mins, maxs, clip.end); @@ -815,11 +738,8 @@ export class ServerCollision { /** * Replace the current best clip trace when a touched entity produced a nearer hit. - * @param {MoveClip} clip move clip state - * @param {ServerEdict} touch touched entity candidate - * @param {CollisionTrace} trace candidate trace result */ - _updateClipTrace(clip, touch, trace) { + _updateClipTrace(clip: MoveClip, touch: ServerEdict, trace: CollisionTrace): void { if (trace.allsolid || trace.startsolid || trace.fraction < clip.trace.fraction) { trace.ent = touch; clip.trace = trace; @@ -828,31 +748,30 @@ export class ServerCollision { /** * Fill the broadphase AABB used to query touched entities for a trace. - * @param {MoveClip} clip move clip state */ - _updateClipBounds(clip) { - for (let i = 0; i < 3; i++) { - if (clip.end[i] > clip.start[i]) { - clip.boxmins[i] = clip.start[i] + clip.mins2[i] - 1.0; - clip.boxmaxs[i] = clip.end[i] + clip.maxs2[i] + 1.0; + _updateClipBounds(clip: MoveClip): void { + for (let index = 0; index < 3; index++) { + if (clip.end[index] > clip.start[index]) { + clip.boxmins[index] = clip.start[index] + clip.mins2[index] - 1.0; + clip.boxmaxs[index] = clip.end[index] + clip.maxs2[index] + 1.0; } else { - clip.boxmins[i] = clip.end[i] + clip.mins2[i] - 1.0; - clip.boxmaxs[i] = clip.start[i] + clip.maxs2[i] + 1.0; + clip.boxmins[index] = clip.end[index] + clip.mins2[index] - 1.0; + clip.boxmaxs[index] = clip.start[index] + clip.maxs2[index] + 1.0; } } } /** * Build the clip context used to trace a move through the world and dynamic entities. - * @param {Vector} start start position - * @param {Vector} mins minimum extents of the moving box - * @param {Vector} maxs maximum extents of the moving box - * @param {Vector} end end position - * @param {number} type move type constant from Defs.moveTypes - * @param {ServerEdict|null} passedict entity to skip - * @returns {MoveClip} initialized move clip context - */ - _createMoveClip(start, mins, maxs, end, type, passedict) { + */ + _createMoveClip( + start: Vector, + mins: Vector, + maxs: Vector, + end: Vector, + type: number, + passedict: ServerEdict | null, + ): MoveClip { const worldEdict = SV.server.edicts[0]; const worldState = this._getEntityCollisionState(worldEdict) ?? this._getHullFallbackState(worldEdict); const worldTrace = this._clipMoveToEntityWithState(worldState, start, mins, maxs, end); @@ -886,9 +805,8 @@ export class ServerCollision { /** * Recursively checks the links in the area node BSP for collision. - * @param {MoveClip} clip clip data */ - clipToLinks(clip) { + clipToLinks(clip: MoveClip): void { for (const touch of SV.area.tree.queryAABB(clip.boxmins, clip.boxmaxs)) { if (this._shouldSkipTouch(clip, touch)) { continue; @@ -913,15 +831,15 @@ export class ServerCollision { /** * Fully traces a moving box through the world. - * @param {Vector} start start position - * @param {Vector} mins minimum extents of the moving box - * @param {Vector} maxs minimum extents of the moving box - * @param {Vector} end end position - * @param {Defs.moveTypes} type move type constant from Defs.moveTypes - * @param {ServerEdict} passedict entity to skip - * @returns {CollisionTrace} collision result - */ - move(start, mins, maxs, end, type, passedict) { + */ + move( + start: Vector, + mins: Vector, + maxs: Vector, + end: Vector, + type: number, + passedict: ServerEdict | null, + ): CollisionTrace { const clip = this._createMoveClip(start, mins, maxs, end, type, passedict); this.clipToLinks(clip); @@ -931,11 +849,10 @@ export class ServerCollision { /** * Tests whether an entity is currently stuck in solid geometry. - * @param {ServerEdict} ent entity to test - * @returns {boolean} true if the entity is stuck */ - testEntityPosition(ent) { - const origin = ent.entity.origin.copy(); - return this.move(origin, ent.entity.mins, ent.entity.maxs, origin, 0, ent).startsolid; + testEntityPosition(ent: ServerEdict): boolean { + const entity = ent.entity!; + const origin = entity.origin.copy(); + return this.move(origin, entity.mins, entity.maxs, origin, 0, ent).startsolid; } } diff --git a/source/engine/server/physics/ServerCollisionSupport.mjs b/source/engine/server/physics/ServerCollisionSupport.mjs deleted file mode 100644 index f85704de..00000000 --- a/source/engine/server/physics/ServerCollisionSupport.mjs +++ /dev/null @@ -1,336 +0,0 @@ -import Vector from '../../../shared/Vector.ts'; - -/** @typedef {import('../Client.mjs').ServerEdict} ServerEdict */ - -export class CollisionState { - /** - * @param {ServerEdict} ent entity being traced against - */ - constructor(ent) { - this.ent = ent; - } -} - -export class MeshCollisionState extends CollisionState { - /** - * @param {ServerEdict} ent entity being traced against - * @param {object} model collision model for the mesh entity - */ - constructor(ent, model) { - super(ent); - this.model = model; - } -} - -export class BrushCollisionState extends CollisionState { - /** - * @param {ServerEdict} ent entity being traced against - * @param {import('../../common/Mod.ts').BrushModel} model brush collision model - * @param {Vector} origin brush-model origin - * @param {Vector} angles brush-model angles - */ - constructor(ent, model, origin, angles) { - super(ent); - this.model = model; - this.origin = origin; - this.angles = angles; - } -} - -export class HullCollisionState extends CollisionState { -} - -export class MoveClip { - /** - * @param {CollisionTrace} trace current best trace result - * @param {Vector} start world-space trace start - * @param {Vector} end world-space trace end - * @param {Vector} mins default tracing mins - * @param {Vector} mins2 alternate mins used for missile-vs-monster checks - * @param {Vector} maxs default tracing maxs - * @param {Vector} maxs2 alternate maxs used for missile-vs-monster checks - * @param {number} type move type constant from Defs.moveTypes - * @param {ServerEdict|null} passedict entity to skip during tracing - */ - constructor(trace, start, end, mins, mins2, maxs, maxs2, type, passedict) { - this.trace = trace; - this.start = start; - this.end = end; - this.mins = mins; - this.mins2 = mins2; - this.maxs = maxs; - this.maxs2 = maxs2; - this.type = type; - this.passedict = passedict; - this.boxmins = new Vector(); - this.boxmaxs = new Vector(); - } -} - -export class CollisionPlane { - /** - * @param {Vector} [normal] collision normal - * @param {number} [dist] plane distance from origin - */ - constructor(normal = new Vector(), dist = 0.0) { - this.normal = normal; - this.dist = dist; - } - - /** - * @param {{normal: Vector, dist: number}} plane source plane - * @returns {CollisionPlane} copied collision plane - */ - static fromPlane(plane) { - return new CollisionPlane(plane.normal.copy(), plane.dist); - } -} - -export class CollisionTrace { - /** - * @param {Vector} endpos final trace end position - * @param {{fraction?: number, allsolid?: boolean, startsolid?: boolean, plane?: CollisionPlane, ent?: ServerEdict|null, inopen?: boolean, inwater?: boolean}} [options] trace initialization options - */ - constructor(endpos, options = {}) { - this.fraction = options.fraction ?? 1.0; - this.allsolid = options.allsolid ?? false; - this.startsolid = options.startsolid ?? false; - this.endpos = endpos; - this.plane = options.plane ?? new CollisionPlane(); - this.ent = options.ent ?? null; - this.inopen = options.inopen ?? false; - this.inwater = options.inwater ?? false; - } - - /** - * @param {Vector} end end position - * @returns {CollisionTrace} empty trace - */ - static empty(end) { - return new CollisionTrace(end.copy()); - } - - /** - * @param {Vector} end end position - * @returns {CollisionTrace} hull-initialized trace - */ - static hullInitial(end) { - return new CollisionTrace(end.copy(), { allsolid: true }); - } - - /** - * @param {import('../../common/Pmove.ts').Trace} brushTrace shared brush trace result - * @param {ServerEdict} ent entity that owns the brush model - * @returns {CollisionTrace} server collision trace - */ - static fromSharedTrace(brushTrace, ent) { - const trace = new CollisionTrace(brushTrace.endpos.copy(), { - fraction: brushTrace.fraction, - allsolid: brushTrace.allsolid, - startsolid: brushTrace.startsolid, - plane: CollisionPlane.fromPlane(brushTrace.plane), - inopen: brushTrace.inopen, - inwater: brushTrace.inwater, - }); - - if (trace.allsolid) { - trace.startsolid = true; - } - - if (trace.fraction < 1.0 || trace.startsolid) { - trace.ent = ent; - } - - return trace; - } -} - -export class MeshTraceContext { - /** @type {ServerEdict} */ - ent; - /** @type {import('../../common/model/MeshModel.ts').MeshModel} */ - model; - /** @type {Vector} */ - start; - /** @type {Vector} */ - end; - /** @type {Vector} */ - origin; - /** @type {Vector} */ - moveDir; - /** @type {Vector} */ - startCenter; - /** @type {Vector} */ - boxExtents; - /** @type {Vector} */ - forward; - /** @type {Vector} */ - right; - /** @type {Vector} */ - up; - - /** - * @param {ServerEdict} ent entity being traced against - * @param {import('../../common/model/MeshModel.ts').MeshModel} model mesh collision model - * @param {Vector} start start position - * @param {Vector} mins minimum extents of the moving box - * @param {Vector} maxs maximum extents of the moving box - * @param {Vector} end end position - */ - constructor(ent, model, start, mins, maxs, end) { - this.ent = ent; - this.model = model; - this.start = start; - this.end = end; - this.origin = ent.entity.origin; - - const mat = ent.entity.angles.toRotationMatrix(); - this.forward = new Vector(mat[0], mat[1], mat[2]); - this.right = new Vector(mat[3], mat[4], mat[5]); - this.up = new Vector(mat[6], mat[7], mat[8]); - this.moveDir = end.copy().subtract(start); - this.boxExtents = maxs.copy().subtract(mins).multiply(0.5); - this.startCenter = start.copy().add(mins.copy().add(maxs).multiply(0.5)); - } - - /** - * Transform a model-space vertex into world space. - * @param {number} x x component in model space - * @param {number} y y component in model space - * @param {number} z z component in model space - * @returns {Vector} transformed world-space vertex - */ - transformVertex(x, y, z) { - return this.origin.copy() - .add(this.forward.copy().multiply(x)) - .add(this.right.copy().multiply(y)) - .add(this.up.copy().multiply(z)); - } - - /** - * Project a point onto a plane. - * @param {Vector} point point to project - * @param {Vector} normal plane normal - * @param {number} planeDist plane distance from origin - * @returns {Vector} projected point on the plane - */ - projectPointOntoPlane(point, normal, planeDist) { - const height = normal.dot(point) - planeDist; - return new Vector( - point[0] - normal[0] * height, - point[1] - normal[1] * height, - point[2] - normal[2] * height, - ); - } - - /** - * Compute the box support radius along a plane normal. - * @param {Vector} normal plane normal - * @returns {number} support radius along the normal - */ - getBoxSupportRadius(normal) { - return this.boxExtents[0] * Math.abs(normal[0]) - + this.boxExtents[1] * Math.abs(normal[1]) - + this.boxExtents[2] * Math.abs(normal[2]); - } - - /** - * Compute the box-center position at a trace fraction. - * @param {number} fraction trace fraction - * @returns {Vector} center position at the fraction - */ - getCenterAtFraction(fraction) { - return new Vector( - this.startCenter[0] + this.moveDir[0] * fraction, - this.startCenter[1] + this.moveDir[1] * fraction, - this.startCenter[2] + this.moveDir[2] * fraction, - ); - } - - /** - * Compute the trace endpoint at a trace fraction. - * @param {number} fraction trace fraction - * @returns {Vector} end position at the fraction - */ - getTraceEndAtFraction(fraction) { - return new Vector( - this.start[0] + this.moveDir[0] * fraction, - this.start[1] + this.moveDir[1] * fraction, - this.start[2] + this.moveDir[2] * fraction, - ); - } -} - -export class MeshTriangle { - /** @type {Vector} */ - v0; - /** @type {Vector} */ - v1; - /** @type {Vector} */ - v2; - /** @type {Vector} */ - normal; - /** @type {number} */ - planeDist; - - /** - * @param {Vector} v0 first world-space vertex - * @param {Vector} v1 second world-space vertex - * @param {Vector} v2 third world-space vertex - * @param {Vector} normal unit face normal - * @param {number} planeDist plane distance from origin - */ - constructor(v0, v1, v2, normal, planeDist) { - this.v0 = v0; - this.v1 = v1; - this.v2 = v2; - this.normal = normal; - this.planeDist = planeDist; - } - - /** - * Build a world-space triangle from mesh data. - * @param {MeshTraceContext} meshTrace mesh tracing context - * @param {number} triangleIndex triangle index within the mesh - * @returns {MeshTriangle|null} transformed triangle, or null for degenerate faces - */ - static fromMesh(meshTrace, triangleIndex) { - const idx0 = meshTrace.model.indices[triangleIndex * 3]; - const idx1 = meshTrace.model.indices[triangleIndex * 3 + 1]; - const idx2 = meshTrace.model.indices[triangleIndex * 3 + 2]; - - const v0 = meshTrace.transformVertex( - meshTrace.model.vertices[idx0 * 3], - meshTrace.model.vertices[idx0 * 3 + 1], - meshTrace.model.vertices[idx0 * 3 + 2], - ); - const v1 = meshTrace.transformVertex( - meshTrace.model.vertices[idx1 * 3], - meshTrace.model.vertices[idx1 * 3 + 1], - meshTrace.model.vertices[idx1 * 3 + 2], - ); - const v2 = meshTrace.transformVertex( - meshTrace.model.vertices[idx2 * 3], - meshTrace.model.vertices[idx2 * 3 + 1], - meshTrace.model.vertices[idx2 * 3 + 2], - ); - - const normal = v1.copy().subtract(v0).cross(v2.copy().subtract(v0)); - const lenSq = normal.dot(normal); - if (lenSq < 1e-12) { - return null; - } - - normal.multiply(1.0 / Math.sqrt(lenSq)); - return new MeshTriangle(v0, v1, v2, normal, normal.dot(v0)); - } - - /** - * Compute the approach speed of a sweep against this triangle face. - * @param {Vector} moveDir sweep direction in world space - * @returns {number} positive when moving toward the front face - */ - getApproach(moveDir) { - return -(this.normal[0] * moveDir[0] + this.normal[1] * moveDir[1] + this.normal[2] * moveDir[2]); - } -} diff --git a/source/engine/server/physics/ServerCollisionSupport.ts b/source/engine/server/physics/ServerCollisionSupport.ts new file mode 100644 index 00000000..d353ae88 --- /dev/null +++ b/source/engine/server/physics/ServerCollisionSupport.ts @@ -0,0 +1,388 @@ +import type { Trace as SharedBrushTrace } from '../../common/Pmove.ts'; +import type { BrushModel } from '../../common/model/BSP.ts'; +import type { MeshModel } from '../../common/model/MeshModel.ts'; +import type { ServerEdict } from '../Edict.mjs'; + +import Vector from '../../../shared/Vector.ts'; + +interface CollisionPlaneSource { + readonly normal: Vector; + readonly dist: number; +} + +interface CollisionTraceOptions { + readonly fraction?: number; + readonly allsolid?: boolean; + readonly startsolid?: boolean; + readonly plane?: CollisionPlane; + readonly ent?: ServerEdict | null; + readonly inopen?: boolean; + readonly inwater?: boolean; +} + +export class CollisionState { + readonly ent: ServerEdict; + + /** + * @param ent Entity being traced against. + */ + constructor(ent: ServerEdict) { + this.ent = ent; + } +} + +export class MeshCollisionState extends CollisionState { + readonly model: MeshModel; + + /** + * @param ent Entity being traced against. + * @param model Collision model for the mesh entity. + */ + constructor(ent: ServerEdict, model: MeshModel) { + super(ent); + this.model = model; + } +} + +export class BrushCollisionState extends CollisionState { + readonly model: BrushModel; + readonly origin: Vector; + readonly angles: Vector; + + /** + * @param ent Entity being traced against. + * @param model Brush collision model. + * @param origin Brush-model origin. + * @param angles Brush-model angles. + */ + constructor(ent: ServerEdict, model: BrushModel, origin: Vector, angles: Vector) { + super(ent); + this.model = model; + this.origin = origin; + this.angles = angles; + } +} + +export class HullCollisionState extends CollisionState { +} + +export class MoveClip { + trace: CollisionTrace; + start: Vector; + end: Vector; + mins: Vector; + mins2: Vector; + maxs: Vector; + maxs2: Vector; + type: number; + passedict: ServerEdict | null; + boxmins: Vector; + boxmaxs: Vector; + + /** + * @param trace Current best trace result. + * @param start World-space trace start. + * @param end World-space trace end. + * @param mins Default tracing mins. + * @param mins2 Alternate mins used for missile-vs-monster checks. + * @param maxs Default tracing maxs. + * @param maxs2 Alternate maxs used for missile-vs-monster checks. + * @param type Move type constant from Defs.moveTypes. + * @param passedict Entity to skip during tracing. + */ + constructor( + trace: CollisionTrace, + start: Vector, + end: Vector, + mins: Vector, + mins2: Vector, + maxs: Vector, + maxs2: Vector, + type: number, + passedict: ServerEdict | null, + ) { + this.trace = trace; + this.start = start; + this.end = end; + this.mins = mins; + this.mins2 = mins2; + this.maxs = maxs; + this.maxs2 = maxs2; + this.type = type; + this.passedict = passedict; + this.boxmins = new Vector(); + this.boxmaxs = new Vector(); + } +} + +export class CollisionPlane { + normal: Vector; + dist: number; + + /** + * @param normal Collision normal. + * @param dist Plane distance from origin. + */ + constructor(normal: Vector = new Vector(), dist = 0.0) { + this.normal = normal; + this.dist = dist; + } + + /** + * @param plane Source plane. + * @returns Copied collision plane. + */ + static fromPlane(plane: CollisionPlaneSource): CollisionPlane { + return new CollisionPlane(plane.normal.copy(), plane.dist); + } +} + +export class CollisionTrace { + fraction: number; + allsolid: boolean; + startsolid: boolean; + endpos: Vector; + plane: CollisionPlane; + ent: ServerEdict | null; + inopen: boolean; + inwater: boolean; + + /** + * @param endpos Final trace end position. + * @param options Trace initialization options. + */ + constructor(endpos: Vector, options: CollisionTraceOptions = {}) { + this.fraction = options.fraction ?? 1.0; + this.allsolid = options.allsolid ?? false; + this.startsolid = options.startsolid ?? false; + this.endpos = endpos; + this.plane = options.plane ?? new CollisionPlane(); + this.ent = options.ent ?? null; + this.inopen = options.inopen ?? false; + this.inwater = options.inwater ?? false; + } + + /** + * @param end End position. + * @returns Empty trace. + */ + static empty(end: Vector): CollisionTrace { + return new CollisionTrace(end.copy()); + } + + /** + * @param end End position. + * @returns Hull-initialized trace. + */ + static hullInitial(end: Vector): CollisionTrace { + return new CollisionTrace(end.copy(), { allsolid: true }); + } + + /** + * @param brushTrace Shared brush trace result. + * @param ent Entity that owns the brush model. + * @returns Server collision trace. + */ + static fromSharedTrace(brushTrace: SharedBrushTrace, ent: ServerEdict): CollisionTrace { + const trace = new CollisionTrace(brushTrace.endpos.copy(), { + fraction: brushTrace.fraction, + allsolid: brushTrace.allsolid, + startsolid: brushTrace.startsolid, + plane: CollisionPlane.fromPlane(brushTrace.plane), + inopen: brushTrace.inopen, + inwater: brushTrace.inwater, + }); + + if (trace.allsolid) { + trace.startsolid = true; + } + + if (trace.fraction < 1.0 || trace.startsolid) { + trace.ent = ent; + } + + return trace; + } +} + +export class MeshTraceContext { + readonly ent: ServerEdict; + readonly model: MeshModel; + readonly start: Vector; + readonly end: Vector; + readonly origin: Vector; + readonly moveDir: Vector; + readonly startCenter: Vector; + readonly boxExtents: Vector; + readonly forward: Vector; + readonly right: Vector; + readonly up: Vector; + + /** + * @param ent Entity being traced against. + * @param model Mesh collision model. + * @param start Start position. + * @param mins Minimum extents of the moving box. + * @param maxs Maximum extents of the moving box. + * @param end End position. + */ + constructor(ent: ServerEdict, model: MeshModel, start: Vector, mins: Vector, maxs: Vector, end: Vector) { + this.ent = ent; + this.model = model; + this.start = start; + this.end = end; + this.origin = ent.entity!.origin; + + const mat = ent.entity!.angles.toRotationMatrix(); + this.forward = new Vector(mat[0], mat[1], mat[2]); + this.right = new Vector(mat[3], mat[4], mat[5]); + this.up = new Vector(mat[6], mat[7], mat[8]); + this.moveDir = end.copy().subtract(start); + this.boxExtents = maxs.copy().subtract(mins).multiply(0.5); + this.startCenter = start.copy().add(mins.copy().add(maxs).multiply(0.5)); + } + + /** + * Transform a model-space vertex into world space. + * @param x X component in model space. + * @param y Y component in model space. + * @param z Z component in model space. + * @returns Transformed world-space vertex. + */ + transformVertex(x: number, y: number, z: number): Vector { + return this.origin.copy() + .add(this.forward.copy().multiply(x)) + .add(this.right.copy().multiply(y)) + .add(this.up.copy().multiply(z)); + } + + /** + * Project a point onto a plane. + * @param point Point to project. + * @param normal Plane normal. + * @param planeDist Plane distance from origin. + * @returns Projected point on the plane. + */ + projectPointOntoPlane(point: Vector, normal: Vector, planeDist: number): Vector { + const height = normal.dot(point) - planeDist; + return new Vector( + point[0] - normal[0] * height, + point[1] - normal[1] * height, + point[2] - normal[2] * height, + ); + } + + /** + * Compute the box support radius along a plane normal. + * @param normal Plane normal. + * @returns Support radius along the normal. + */ + getBoxSupportRadius(normal: Vector): number { + return this.boxExtents[0] * Math.abs(normal[0]) + + this.boxExtents[1] * Math.abs(normal[1]) + + this.boxExtents[2] * Math.abs(normal[2]); + } + + /** + * Compute the box-center position at a trace fraction. + * @param fraction Trace fraction. + * @returns Center position at the fraction. + */ + getCenterAtFraction(fraction: number): Vector { + return new Vector( + this.startCenter[0] + this.moveDir[0] * fraction, + this.startCenter[1] + this.moveDir[1] * fraction, + this.startCenter[2] + this.moveDir[2] * fraction, + ); + } + + /** + * Compute the trace endpoint at a trace fraction. + * @param fraction Trace fraction. + * @returns End position at the fraction. + */ + getTraceEndAtFraction(fraction: number): Vector { + return new Vector( + this.start[0] + this.moveDir[0] * fraction, + this.start[1] + this.moveDir[1] * fraction, + this.start[2] + this.moveDir[2] * fraction, + ); + } +} + +export class MeshTriangle { + readonly v0: Vector; + readonly v1: Vector; + readonly v2: Vector; + readonly normal: Vector; + readonly planeDist: number; + + /** + * @param v0 First world-space vertex. + * @param v1 Second world-space vertex. + * @param v2 Third world-space vertex. + * @param normal Unit face normal. + * @param planeDist Plane distance from origin. + */ + constructor(v0: Vector, v1: Vector, v2: Vector, normal: Vector, planeDist: number) { + this.v0 = v0; + this.v1 = v1; + this.v2 = v2; + this.normal = normal; + this.planeDist = planeDist; + } + + /** + * Build a world-space triangle from mesh data. + * @param meshTrace Mesh tracing context. + * @param triangleIndex Triangle index within the mesh. + * @returns Transformed triangle, or null for degenerate faces. + */ + static fromMesh(meshTrace: MeshTraceContext, triangleIndex: number): MeshTriangle | null { + const indices = meshTrace.model.indices; + const vertices = meshTrace.model.vertices; + + console.assert(indices !== null && vertices !== null, 'mesh collision model must have vertices and indices'); + + const liveIndices = indices!; + const liveVertices = vertices!; + + const idx0 = liveIndices[triangleIndex * 3]; + const idx1 = liveIndices[triangleIndex * 3 + 1]; + const idx2 = liveIndices[triangleIndex * 3 + 2]; + + const v0 = meshTrace.transformVertex( + liveVertices[idx0 * 3], + liveVertices[idx0 * 3 + 1], + liveVertices[idx0 * 3 + 2], + ); + const v1 = meshTrace.transformVertex( + liveVertices[idx1 * 3], + liveVertices[idx1 * 3 + 1], + liveVertices[idx1 * 3 + 2], + ); + const v2 = meshTrace.transformVertex( + liveVertices[idx2 * 3], + liveVertices[idx2 * 3 + 1], + liveVertices[idx2 * 3 + 2], + ); + + const normal = v1.copy().subtract(v0).cross(v2.copy().subtract(v0)); + const lenSq = normal.dot(normal); + if (lenSq < 1e-12) { + return null; + } + + normal.multiply(1.0 / Math.sqrt(lenSq)); + return new MeshTriangle(v0, v1, v2, normal, normal.dot(v0)); + } + + /** + * Compute the approach speed of a sweep against this triangle face. + * @param moveDir Sweep direction in world space. + * @returns Positive when moving toward the front face. + */ + getApproach(moveDir: Vector): number { + return -(this.normal[0] * moveDir[0] + this.normal[1] * moveDir[1] + this.normal[2] * moveDir[2]); + } +} diff --git a/source/engine/server/physics/ServerLegacyHullCollision.mjs b/source/engine/server/physics/ServerLegacyHullCollision.ts similarity index 62% rename from source/engine/server/physics/ServerLegacyHullCollision.mjs rename to source/engine/server/physics/ServerLegacyHullCollision.ts index cb5d178f..3a70aff5 100644 --- a/source/engine/server/physics/ServerLegacyHullCollision.mjs +++ b/source/engine/server/physics/ServerLegacyHullCollision.ts @@ -1,37 +1,42 @@ +import type { BrushModel, Hull } from '../../common/model/BSP.ts'; +import type { CollisionTrace } from './ServerCollisionSupport.ts'; + import Vector from '../../../shared/Vector.ts'; import * as Defs from '../../../shared/Defs.ts'; import { DIST_EPSILON } from '../../common/Pmove.ts'; -import { eventBus, registry } from '../../registry.mjs'; +import { eventBus, getCommonRegistry } from '../../registry.mjs'; + +interface LegacyHull extends Hull { + readonly firstclipnode: number; + readonly allowedClipNodes?: Uint8Array | null; +} -let { Con } = registry; +let { Con } = getCommonRegistry(); eventBus.subscribe('registry.frozen', () => { - ({ Con } = registry); + ({ Con } = getCommonRegistry()); }); -/** @typedef {import('./ServerCollisionSupport.mjs').CollisionTrace} CollisionTrace */ -/** @typedef {import('../../common/Mod.ts').BrushModel} BrushModel */ - /** * Check whether a clipnode belongs to the owning legacy hull subtree. * BSP29 hull arrays are shared across models, so foreign clipnodes must be * ignored to keep world traces from wandering into inline trigger geometry. - * @param {{allowedClipNodes?: Uint8Array|null}} hull hull descriptor - * @param {number} num clipnode index - * @returns {boolean} true when the clipnode belongs to the active hull subtree + * @param hull Hull descriptor. + * @param num Clipnode index. + * @returns True when the clipnode belongs to the active hull subtree. */ -export function isHullNodeAllowed(hull, num) { +export function isHullNodeAllowed(hull: LegacyHull, num: number): boolean { const allowedClipNodes = hull.allowedClipNodes; return allowedClipNodes === undefined || allowedClipNodes === null || allowedClipNodes[num] === 1; } /** * Compute the signed distance from a point to a hull plane. - * @param {{type: number, dist: number, normal: Vector}} plane hull plane - * @param {Vector} point point to test - * @returns {number} signed distance to the plane + * @param plane Hull plane. + * @param point Point to test. + * @returns Signed distance to the plane. */ -export function getHullPlaneDistance(plane, point) { +export function getHullPlaneDistance(plane: Hull['planes'][number], point: Vector): number { if (plane.type < 3) { return point[plane.type] - plane.dist; } @@ -41,11 +46,11 @@ export function getHullPlaneDistance(plane, point) { /** * Update trace state after reaching a terminal hull leaf. - * @param {number} contents terminal hull contents value - * @param {CollisionTrace} trace trace accumulator - * @returns {boolean} true when traversal should continue upward + * @param contents Terminal hull contents value. + * @param trace Trace accumulator. + * @returns True when traversal should continue upward. */ -export function classifyHullLeaf(contents, trace) { +export function classifyHullLeaf(contents: number, trace: CollisionTrace): boolean { if (contents !== Defs.content.CONTENT_SOLID) { trace.allsolid = false; if (contents === Defs.content.CONTENT_EMPTY) { @@ -62,20 +67,20 @@ export function classifyHullLeaf(contents, trace) { /** * Determines the contents inside a hull by descending the clipnode tree. - * @param {*} hull hull data to test against - * @param {number} num starting clipnode index - * @param {Vector} p point to classify - * @returns {number} content type for the point + * @param hull Hull data to test against. + * @param num Starting clipnode index. + * @param p Point to classify. + * @returns Content type for the point. */ -export function hullPointContents(hull, num, p) { +export function hullPointContents(hull: LegacyHull, num: number, p: Vector): number { while (num >= 0) { if (!isHullNodeAllowed(hull, num)) { return Defs.content.CONTENT_EMPTY; } console.assert(num >= hull.firstclipnode && num <= hull.lastclipnode, 'valid node number', num); - const node = hull.clipnodes[num]; - const plane = hull.planes[node.planenum]; + const node = hull.clipnodes[num]!; + const plane = hull.planes[node.planenum]!; const d = getHullPlaneDistance(plane, p); if (d < 0) { @@ -90,13 +95,13 @@ export function hullPointContents(hull, num, p) { /** * Returns the contents at the specified world position. - * @param {BrushModel} worldmodel world model that owns hull 0 - * @param {Vector} p position to sample - * @returns {number} world content + * @param worldmodel World model that owns hull 0. + * @param p Position to sample. + * @returns World content. */ -export function pointContents(worldmodel, p) { - const cont = hullPointContents(worldmodel.hulls[0], 0, p); - if ((cont <= Defs.content.CONTENT_CURRENT_0) && (cont >= Defs.content.CONTENT_CURRENT_DOWN)) { +export function pointContents(worldmodel: BrushModel, p: Vector): number { + const cont = hullPointContents(worldmodel.hulls[0] as LegacyHull, 0, p); + if (cont <= Defs.content.CONTENT_CURRENT_0 && cont >= Defs.content.CONTENT_CURRENT_DOWN) { return Defs.content.CONTENT_WATER; } return cont; @@ -104,17 +109,26 @@ export function pointContents(worldmodel, p) { /** * Recursively tests a swept hull against the world and aggregates the trace result. - * @param {*} hull hull to trace against - * @param {number} num clipnode index - * @param {number} p1f fraction at the start point - * @param {number} p2f fraction at the end point - * @param {Vector} p1 start point - * @param {Vector} p2 end point - * @param {CollisionTrace} trace trace accumulator - * @param {number} [depth] recursion depth reserved for API compatibility - * @returns {boolean} true if traversal should continue downward + * @param hull Hull to trace against. + * @param num Clipnode index. + * @param p1f Fraction at the start point. + * @param p2f Fraction at the end point. + * @param p1 Start point. + * @param p2 End point. + * @param trace Trace accumulator. + * @param depth Recursion depth reserved for API compatibility. + * @returns True if traversal should continue downward. */ -export function recursiveHullCheck(hull, num, p1f, p2f, p1, p2, trace, depth = 0) { +export function recursiveHullCheck( + hull: LegacyHull, + num: number, + p1f: number, + p2f: number, + p1: Vector, + p2: Vector, + trace: CollisionTrace, + depth = 0, +): boolean { void depth; if (trace.fraction <= p1f) { @@ -131,8 +145,8 @@ export function recursiveHullCheck(hull, num, p1f, p2f, p1, p2, trace, depth = 0 console.assert(num >= hull.firstclipnode && num <= hull.lastclipnode, 'valid node number', num); - const node = hull.clipnodes[num]; - const plane = hull.planes[node.planenum]; + const node = hull.clipnodes[num]!; + const plane = hull.planes[node.planenum]!; const t1 = getHullPlaneDistance(plane, p1); const t2 = getHullPlaneDistance(plane, p2); diff --git a/source/engine/server/physics/ServerMovement.mjs b/source/engine/server/physics/ServerMovement.mjs deleted file mode 100644 index 3521c517..00000000 --- a/source/engine/server/physics/ServerMovement.mjs +++ /dev/null @@ -1,333 +0,0 @@ -import Vector from '../../../shared/Vector.ts'; -import * as Defs from '../../../shared/Defs.ts'; -import { STEPSIZE } from '../../common/Pmove.ts'; -import { ServerEdict } from '../Edict.mjs'; -import { eventBus, registry } from '../../registry.mjs'; - -let { SV } = registry; - -eventBus.subscribe('registry.frozen', () => { - SV = registry.SV; -}); - -/** - * Everything related to moving entities around. - */ -export class ServerMovement { - constructor() { - } - - /** - * Checks if an entity has solid ground beneath all four bottom corners. - * If all corners are solid, returns true immediately. Otherwise performs - * a more detailed trace check to validate the ground surface. - * @param {import('../Edict.mjs').ServerEdict} ent entity to check - * @returns {boolean} true if entity has solid ground beneath it - */ - checkBottom(ent) { - const mins = ent.entity.origin.copy().add(ent.entity.mins); - const maxs = ent.entity.origin.copy().add(ent.entity.maxs); - - // Quick check: if all four corners are solid, we're definitely on ground - const allCornersSolid = - SV.collision.pointContents(new Vector(mins[0], mins[1], mins[2] - 1.0)) === Defs.content.CONTENT_SOLID && - SV.collision.pointContents(new Vector(mins[0], maxs[1], mins[2] - 1.0)) === Defs.content.CONTENT_SOLID && - SV.collision.pointContents(new Vector(maxs[0], mins[1], mins[2] - 1.0)) === Defs.content.CONTENT_SOLID && - SV.collision.pointContents(new Vector(maxs[0], maxs[1], mins[2] - 1.0)) === Defs.content.CONTENT_SOLID; - - if (allCornersSolid) { - return true; - } - - // Not all corners solid - do detailed trace check - const start = ent.entity.origin.copy().add(new Vector(0.0, 0.0, ent.entity.mins[2] + 1.0)); - const stop = start.copy().add(new Vector(0.0, 0.0, -2.0 * STEPSIZE)); - - let trace = SV.collision.move(start, Vector.origin, Vector.origin, stop, Defs.moveTypes.MOVE_NOMONSTERS, ent); - if (trace.fraction === 1.0) { - return false; - } - let bottom = trace.endpos[2]; - const mid = bottom; - for (let x = 0; x <= 1; x++) { - for (let y = 0; y <= 1; y++) { - start[0] = stop[0] = (x !== 0) ? maxs[0] : mins[0]; - start[1] = stop[1] = (y !== 0) ? maxs[1] : mins[1]; - trace = SV.collision.move(start, Vector.origin, Vector.origin, stop, Defs.moveTypes.MOVE_NOMONSTERS, ent); - if ((trace.fraction !== 1.0) && (trace.endpos[2] > bottom)) { - bottom = trace.endpos[2]; - } - if ((trace.fraction === 1.0) || ((mid - trace.endpos[2]) > STEPSIZE)) { - return false; - } - } - } - return true; - } - - movestep(ent, move, relink) { - const oldorg = ent.entity.origin.copy(); - const mins = ent.entity.mins; - const maxs = ent.entity.maxs; - if ((ent.entity.flags & (Defs.flags.FL_SWIM | Defs.flags.FL_FLY)) !== 0) { - const enemy = ent.entity.enemy; - const neworg = new Vector(); - for (let i = 0; i <= 1; i++) { - const origin = ent.entity.origin.copy(); - neworg[0] = origin[0] + move[0]; - neworg[1] = origin[1] + move[1]; - neworg[2] = origin[2]; - if (i === 0 && enemy) { - const enemyEntity = enemy instanceof ServerEdict ? enemy.entity : enemy; - const dz = ent.entity.origin[2] - enemyEntity.origin[2]; - if (dz > 40.0) { - neworg[2] -= 8.0; - } else if (dz < 30.0) { - neworg[2] += 8.0; - } - } - const trace = SV.collision.move(ent.entity.origin, mins, maxs, neworg, Defs.moveTypes.MOVE_NORMAL, ent); - if (trace.fraction === 1.0) { - if (((ent.entity.flags & Defs.flags.FL_SWIM) !== 0) && (SV.collision.pointContents(trace.endpos) === Defs.content.CONTENT_EMPTY)) { - return false; - } - ent.entity.origin = trace.endpos.copy(); - if (relink) { - SV.area.linkEdict(ent, true); - } - return true; - } - if (!enemy) { - return false; - } - } - return false; - } - const neworg = ent.entity.origin.copy(); - neworg[0] += move[0]; - neworg[1] += move[1]; - neworg[2] += STEPSIZE; - const end = neworg.copy(); - end[2] -= STEPSIZE * 2.0; - let trace = SV.collision.move(neworg, mins, maxs, end, Defs.moveTypes.MOVE_NORMAL, ent); - if (trace.allsolid === true) { - return false; - } - if (trace.startsolid === true) { - neworg[2] -= STEPSIZE; - trace = SV.collision.move(neworg, mins, maxs, end, Defs.moveTypes.MOVE_NORMAL, ent); - if ((trace.allsolid === true) || (trace.startsolid === true)) { - return false; - } - } - if (trace.fraction === 1.0) { - if ((ent.entity.flags & Defs.flags.FL_PARTIALGROUND) !== 0) { - const fallback = ent.entity.origin.copy(); - fallback[0] += move[0]; - fallback[1] += move[1]; - ent.entity.origin = fallback; - if (relink) { - SV.area.linkEdict(ent, true); - } - ent.entity.flags &= (~Defs.flags.FL_ONGROUND); - return true; - } - return false; - } - ent.entity.origin = trace.endpos.copy(); - if (!this.checkBottom(ent)) { - if ((ent.entity.flags & Defs.flags.FL_PARTIALGROUND) !== 0) { - if (relink) { - SV.area.linkEdict(ent, true); - } - return true; - } - ent.entity.origin = ent.entity.origin.set(oldorg); - return false; - } - ent.entity.flags &= ~Defs.flags.FL_PARTIALGROUND; - ent.entity.groundentity = trace.ent.entity; - if (relink) { - SV.area.linkEdict(ent, true); - } - return true; - } - - walkMove(ent, yaw, dist) { - if ((ent.entity.flags & (Defs.flags.FL_ONGROUND | Defs.flags.FL_FLY | Defs.flags.FL_SWIM)) === 0) { - return false; - } - - const radians = yaw * (Math.PI / 180.0); - return this.movestep(ent, new Vector(Math.cos(radians) * dist, Math.sin(radians) * dist, 0.0), true); - } - - moveToGoal(ent, dist, target = null) { - - if ((ent.entity.flags & (Defs.flags.FL_ONGROUND | Defs.flags.FL_FLY | Defs.flags.FL_SWIM)) === 0) { - return false; - } - - const resolveEdict = (value) => { - if (!value) { - return null; - } - if (value instanceof ServerEdict) { - return value; - } - return value.edict || null; - }; - - const goalEdict = resolveEdict(ent.entity.goalentity); - const enemyEdict = resolveEdict(ent.entity.enemy); - - console.assert(goalEdict !== null, 'must have goal for moveToGoal'); - - const goalTarget = target ?? goalEdict.entity.origin; - - if (enemyEdict !== null && !enemyEdict.isWorld() && this.closeEnough(ent, goalEdict, dist)) { - return false; - } - - // TODO: consider reintroducing direct movestep steering toward goal to reduce chase ping-pong. - if (Math.random() >= 0.75 || !this.stepDirection(ent, ent.entity.ideal_yaw, dist)) { - this.newChaseDir(ent, goalTarget, dist); - return true; - } - - return false; - } - - changeYaw(edict) { - const angle1 = edict.entity.angles[1]; - const current = Vector.anglemod(angle1); - const ideal = edict.entity.ideal_yaw; - - if (current === ideal) { - return angle1; - } - - let move = ideal - current; - - if (ideal > current) { - if (move >= 180.0) { - move -= 360.0; - } - } else if (move <= -180.0) { - move += 360.0; - } - - const speed = edict.entity.yaw_speed || 0; - - if (move > 0.0) { - if (move > speed) { - move = speed; - } - } else if (move < -speed) { - move = -speed; - } - - return Vector.anglemod(current + move); - } - - stepDirection(ent, yaw, dist) { - ent.entity.ideal_yaw = yaw; - ent.entity.angles = new Vector(ent.entity.angles[0], this.changeYaw(ent), ent.entity.angles[2]); - const radians = yaw * (Math.PI / 180.0); - const oldorigin = ent.entity.origin.copy(); - if (this.movestep(ent, new Vector(Math.cos(radians) * dist, Math.sin(radians) * dist, 0.0), false)) { - const delta = ent.entity.angles[1] - ent.entity.ideal_yaw; - if ((delta > 45.0) && (delta < 315.0)) { - ent.entity.origin = ent.entity.origin.set(oldorigin); - } - SV.area.linkEdict(ent, true); - return true; - } - SV.area.linkEdict(ent, true); - return false; - } - - newChaseDir(actor, endpos, dist) { - const olddir = Vector.anglemod(((actor.entity.ideal_yaw / 45.0) >> 0) * 45.0); - const turnaround = Vector.anglemod(olddir - 180.0); - const deltax = endpos[0] - actor.entity.origin[0]; - const deltay = endpos[1] - actor.entity.origin[1]; - let dx; - let dy; - if (deltax > 10.0) { - dx = 0.0; - } else if (deltax < -10.0) { - dx = 180.0; - } else { - dx = -1; - } - if (deltay < -10.0) { - dy = 270.0; - } else if (deltay > 10.0) { - dy = 90.0; - } else { - dy = -1; - } - let tdir; - if ((dx !== -1) && (dy !== -1)) { - if (dx === 0.0) { - tdir = (dy === 90.0) ? 45.0 : 315.0; - } else { - tdir = (dy === 90.0) ? 135.0 : 215.0; - } - if ((tdir !== turnaround) && this.stepDirection(actor, tdir, dist)) { - return; - } - } - if ((Math.random() >= 0.25) || (Math.abs(deltay) > Math.abs(deltax))) { - tdir = dx; - dx = dy; - dy = tdir; - } - if ((dx !== -1) && (dx !== turnaround) && this.stepDirection(actor, dx, dist)) { - return; - } - if ((dy !== -1) && (dy !== turnaround) && this.stepDirection(actor, dy, dist)) { - return; - } - if ((olddir !== -1) && this.stepDirection(actor, olddir, dist)) { - return; - } - if (Math.random() >= 0.5) { - for (tdir = 0.0; tdir <= 315.0; tdir += 45.0) { - if ((tdir !== turnaround) && this.stepDirection(actor, tdir, dist)) { - return; - } - } - } else { - for (tdir = 315.0; tdir >= 0.0; tdir -= 45.0) { - if ((tdir !== turnaround) && this.stepDirection(actor, tdir, dist)) { - return; - } - } - } - if ((turnaround !== -1) && this.stepDirection(actor, turnaround, dist)) { - return; - } - actor.entity.ideal_yaw = olddir; - if (!this.checkBottom(actor)) { - actor.entity.flags |= Defs.flags.FL_PARTIALGROUND; - } - } - - closeEnough(ent, goal, dist) { - const absmin = ent.entity.absmin; - const absmax = ent.entity.absmax; - const absminGoal = goal.entity.absmin; - const absmaxGoal = goal.entity.absmax; - for (let i = 0; i < 3; i++) { - if (absminGoal[i] > (absmax[i] + dist)) { - return false; - } - if (absmaxGoal[i] < (absmin[i] - dist)) { - return false; - } - } - return true; - } -}; diff --git a/source/engine/server/physics/ServerMovement.ts b/source/engine/server/physics/ServerMovement.ts new file mode 100644 index 00000000..f4db502b --- /dev/null +++ b/source/engine/server/physics/ServerMovement.ts @@ -0,0 +1,369 @@ +import type BaseEntity from '../../../game/id1/entity/BaseEntity.mjs'; +import type { ServerEdict } from '../Edict.mjs'; + +import Vector from '../../../shared/Vector.ts'; +import * as Defs from '../../../shared/Defs.ts'; +import { STEPSIZE } from '../../common/Pmove.ts'; +import { eventBus, getCommonRegistry } from '../../registry.mjs'; + +interface EdictReferenceLike { + readonly edict?: ServerEdict | null; +} + +let { SV } = getCommonRegistry(); + +eventBus.subscribe('registry.frozen', () => { + ({ SV } = getCommonRegistry()); +}); + +/** + * Everything related to moving entities around. + */ +export class ServerMovement { + /** + * Checks if an entity has solid ground beneath all four bottom corners. + * If all corners are solid, returns true immediately. Otherwise performs + * a more detailed trace check to validate the ground surface. + * @param ent Entity to check. + * @returns True if entity has solid ground beneath it. + */ + checkBottom(ent: ServerEdict): boolean { + const entity = ent.entity!; + const mins = entity.origin.copy().add(entity.mins); + const maxs = entity.origin.copy().add(entity.maxs); + + // Quick check: if all four corners are solid, we're definitely on ground + const allCornersSolid = + SV.collision.pointContents(new Vector(mins[0], mins[1], mins[2] - 1.0)) === Defs.content.CONTENT_SOLID && + SV.collision.pointContents(new Vector(mins[0], maxs[1], mins[2] - 1.0)) === Defs.content.CONTENT_SOLID && + SV.collision.pointContents(new Vector(maxs[0], mins[1], mins[2] - 1.0)) === Defs.content.CONTENT_SOLID && + SV.collision.pointContents(new Vector(maxs[0], maxs[1], mins[2] - 1.0)) === Defs.content.CONTENT_SOLID; + + if (allCornersSolid) { + return true; + } + + // Not all corners solid - do detailed trace check + const start = entity.origin.copy().add(new Vector(0.0, 0.0, entity.mins[2] + 1.0)); + const stop = start.copy().add(new Vector(0.0, 0.0, -2.0 * STEPSIZE)); + + let trace = SV.collision.move(start, Vector.origin, Vector.origin, stop, Defs.moveTypes.MOVE_NOMONSTERS, ent); + if (trace.fraction === 1.0) { + return false; + } + + let bottom = trace.endpos[2]; + const mid = bottom; + for (let x = 0; x <= 1; x++) { + for (let y = 0; y <= 1; y++) { + start[0] = stop[0] = x !== 0 ? maxs[0] : mins[0]; + start[1] = stop[1] = y !== 0 ? maxs[1] : mins[1]; + trace = SV.collision.move(start, Vector.origin, Vector.origin, stop, Defs.moveTypes.MOVE_NOMONSTERS, ent); + if (trace.fraction !== 1.0 && trace.endpos[2] > bottom) { + bottom = trace.endpos[2]; + } + if (trace.fraction === 1.0 || (mid - trace.endpos[2]) > STEPSIZE) { + return false; + } + } + } + + return true; + } + + movestep(ent: ServerEdict, move: Vector, relink: boolean): boolean { + const entity = ent.entity!; + const oldorg = entity.origin.copy(); + const mins = entity.mins; + const maxs = entity.maxs; + + if ((entity.flags & (Defs.flags.FL_SWIM | Defs.flags.FL_FLY)) !== 0) { + const enemy = entity.enemy; + const neworg = new Vector(); + for (let index = 0; index <= 1; index++) { + const origin = entity.origin.copy(); + neworg[0] = origin[0] + move[0]; + neworg[1] = origin[1] + move[1]; + neworg[2] = origin[2]; + if (index === 0 && enemy) { + const enemyEntity = this.#resolveEntity(enemy); + console.assert(enemyEntity !== null, 'enemy must resolve to an entity when steering swim/fly movement'); + + const dz = entity.origin[2] - enemyEntity!.origin[2]; + if (dz > 40.0) { + neworg[2] -= 8.0; + } else if (dz < 30.0) { + neworg[2] += 8.0; + } + } + const trace = SV.collision.move(entity.origin, mins, maxs, neworg, Defs.moveTypes.MOVE_NORMAL, ent); + if (trace.fraction === 1.0) { + if ((entity.flags & Defs.flags.FL_SWIM) !== 0 && SV.collision.pointContents(trace.endpos) === Defs.content.CONTENT_EMPTY) { + return false; + } + entity.origin = trace.endpos.copy(); + if (relink) { + SV.area.linkEdict(ent, true); + } + return true; + } + if (!enemy) { + return false; + } + } + return false; + } + + const neworg = entity.origin.copy(); + neworg[0] += move[0]; + neworg[1] += move[1]; + neworg[2] += STEPSIZE; + const end = neworg.copy(); + end[2] -= STEPSIZE * 2.0; + let trace = SV.collision.move(neworg, mins, maxs, end, Defs.moveTypes.MOVE_NORMAL, ent); + if (trace.allsolid === true) { + return false; + } + if (trace.startsolid === true) { + neworg[2] -= STEPSIZE; + trace = SV.collision.move(neworg, mins, maxs, end, Defs.moveTypes.MOVE_NORMAL, ent); + if (trace.allsolid === true || trace.startsolid === true) { + return false; + } + } + if (trace.fraction === 1.0) { + if ((entity.flags & Defs.flags.FL_PARTIALGROUND) !== 0) { + const fallback = entity.origin.copy(); + fallback[0] += move[0]; + fallback[1] += move[1]; + entity.origin = fallback; + if (relink) { + SV.area.linkEdict(ent, true); + } + entity.flags &= ~Defs.flags.FL_ONGROUND; + return true; + } + return false; + } + entity.origin = trace.endpos.copy(); + if (!this.checkBottom(ent)) { + if ((entity.flags & Defs.flags.FL_PARTIALGROUND) !== 0) { + if (relink) { + SV.area.linkEdict(ent, true); + } + return true; + } + entity.origin = entity.origin.set(oldorg); + return false; + } + console.assert(trace.ent !== null, 'ground trace must have an entity when movestep succeeds'); + entity.flags &= ~Defs.flags.FL_PARTIALGROUND; + entity.groundentity = trace.ent!.entity; + if (relink) { + SV.area.linkEdict(ent, true); + } + return true; + } + + walkMove(ent: ServerEdict, yaw: number, dist: number): boolean { + const entity = ent.entity!; + + if ((entity.flags & (Defs.flags.FL_ONGROUND | Defs.flags.FL_FLY | Defs.flags.FL_SWIM)) === 0) { + return false; + } + + const radians = yaw * (Math.PI / 180.0); + return this.movestep(ent, new Vector(Math.cos(radians) * dist, Math.sin(radians) * dist, 0.0), true); + } + + moveToGoal(ent: ServerEdict, dist: number, target: Vector | null = null): boolean { + const entity = ent.entity!; + + if ((entity.flags & (Defs.flags.FL_ONGROUND | Defs.flags.FL_FLY | Defs.flags.FL_SWIM)) === 0) { + return false; + } + + const goalEdict = this.#resolveEdict(entity.goalentity); + const enemyEdict = this.#resolveEdict(entity.enemy); + + console.assert(goalEdict !== null, 'must have goal for moveToGoal'); + + const goalTarget = target ?? goalEdict!.entity!.origin; + + if (enemyEdict !== null && !enemyEdict.isWorld() && this.closeEnough(ent, goalEdict!, dist)) { + return false; + } + + // TODO: consider reintroducing direct movestep steering toward goal to reduce chase ping-pong. + if (Math.random() >= 0.75 || !this.stepDirection(ent, entity.ideal_yaw, dist)) { + this.newChaseDir(ent, goalTarget, dist); + return true; + } + + return false; + } + + changeYaw(edict: ServerEdict): number { + const entity = edict.entity!; + const angle1 = entity.angles[1]; + const current = Vector.anglemod(angle1); + const ideal = entity.ideal_yaw; + + if (current === ideal) { + return angle1; + } + + let move = ideal - current; + + if (ideal > current) { + if (move >= 180.0) { + move -= 360.0; + } + } else if (move <= -180.0) { + move += 360.0; + } + + const speed = entity.yaw_speed || 0; + + if (move > 0.0) { + if (move > speed) { + move = speed; + } + } else if (move < -speed) { + move = -speed; + } + + return Vector.anglemod(current + move); + } + + stepDirection(ent: ServerEdict, yaw: number, dist: number): boolean { + const entity = ent.entity!; + + entity.ideal_yaw = yaw; + entity.angles = new Vector(entity.angles[0], this.changeYaw(ent), entity.angles[2]); + const radians = yaw * (Math.PI / 180.0); + const oldorigin = entity.origin.copy(); + if (this.movestep(ent, new Vector(Math.cos(radians) * dist, Math.sin(radians) * dist, 0.0), false)) { + const delta = entity.angles[1] - entity.ideal_yaw; + if (delta > 45.0 && delta < 315.0) { + entity.origin = entity.origin.set(oldorigin); + } + SV.area.linkEdict(ent, true); + return true; + } + SV.area.linkEdict(ent, true); + return false; + } + + newChaseDir(actor: ServerEdict, endpos: Vector, dist: number): void { + const entity = actor.entity!; + const olddir = Vector.anglemod(((entity.ideal_yaw / 45.0) >> 0) * 45.0); + const turnaround = Vector.anglemod(olddir - 180.0); + const deltax = endpos[0] - entity.origin[0]; + const deltay = endpos[1] - entity.origin[1]; + let dx: number; + let dy: number; + if (deltax > 10.0) { + dx = 0.0; + } else if (deltax < -10.0) { + dx = 180.0; + } else { + dx = -1; + } + if (deltay < -10.0) { + dy = 270.0; + } else if (deltay > 10.0) { + dy = 90.0; + } else { + dy = -1; + } + let tdir: number; + if (dx !== -1 && dy !== -1) { + if (dx === 0.0) { + tdir = dy === 90.0 ? 45.0 : 315.0; + } else { + tdir = dy === 90.0 ? 135.0 : 215.0; + } + if (tdir !== turnaround && this.stepDirection(actor, tdir, dist)) { + return; + } + } + if (Math.random() >= 0.25 || Math.abs(deltay) > Math.abs(deltax)) { + tdir = dx; + dx = dy; + dy = tdir; + } + if (dx !== -1 && dx !== turnaround && this.stepDirection(actor, dx, dist)) { + return; + } + if (dy !== -1 && dy !== turnaround && this.stepDirection(actor, dy, dist)) { + return; + } + if (olddir !== -1 && this.stepDirection(actor, olddir, dist)) { + return; + } + if (Math.random() >= 0.5) { + for (tdir = 0.0; tdir <= 315.0; tdir += 45.0) { + if (tdir !== turnaround && this.stepDirection(actor, tdir, dist)) { + return; + } + } + } else { + for (tdir = 315.0; tdir >= 0.0; tdir -= 45.0) { + if (tdir !== turnaround && this.stepDirection(actor, tdir, dist)) { + return; + } + } + } + if (turnaround !== -1 && this.stepDirection(actor, turnaround, dist)) { + return; + } + entity.ideal_yaw = olddir; + if (!this.checkBottom(actor)) { + entity.flags |= Defs.flags.FL_PARTIALGROUND; + } + } + + closeEnough(ent: ServerEdict, goal: ServerEdict, dist: number): boolean { + const absmin = ent.entity!.absmin; + const absmax = ent.entity!.absmax; + const absminGoal = goal.entity!.absmin; + const absmaxGoal = goal.entity!.absmax; + for (let index = 0; index < 3; index++) { + if (absminGoal[index] > absmax[index] + dist) { + return false; + } + if (absmaxGoal[index] < absmin[index] - dist) { + return false; + } + } + return true; + } + + #resolveEdict(value: BaseEntity | EdictReferenceLike | ServerEdict | null): ServerEdict | null { + if (!value) { + return null; + } + if (this.#isServerEdictLike(value)) { + return value; + } + return value.edict ?? null; + } + + #resolveEntity(value: BaseEntity | EdictReferenceLike | ServerEdict | null): BaseEntity | null { + if (!value) { + return null; + } + if (this.#isServerEdictLike(value)) { + return value.entity; + } + if ('origin' in value) { + return value; + } + return value.edict?.entity ?? null; + } + + #isServerEdictLike(value: BaseEntity | EdictReferenceLike | ServerEdict): value is ServerEdict { + return 'entity' in value && typeof value.isWorld === 'function'; + } +} diff --git a/source/engine/server/physics/ServerPhysics.mjs b/source/engine/server/physics/ServerPhysics.ts similarity index 53% rename from source/engine/server/physics/ServerPhysics.mjs rename to source/engine/server/physics/ServerPhysics.ts index e8b51af7..2d5cb02b 100644 --- a/source/engine/server/physics/ServerPhysics.mjs +++ b/source/engine/server/physics/ServerPhysics.ts @@ -1,37 +1,43 @@ +import type { ServerEdict } from '../Edict.mjs'; +import type { CollisionTrace } from './ServerCollisionSupport.ts'; + import Vector from '../../../shared/Vector.ts'; import * as Defs from '../../../shared/Defs.ts'; import Q from '../../../shared/Q.ts'; -import { eventBus, registry } from '../../registry.mjs'; +import { eventBus, getCommonRegistry } from '../../registry.mjs'; import { GROUND_ANGLE_THRESHOLD, VELOCITY_EPSILON, MAX_BUMP_COUNT, BlockedFlags, -} from './Defs.mjs'; +} from './Defs.ts'; + +interface FlyMoveResult { + readonly blocked: number; + readonly steptrace: CollisionTrace | null; +} -let { Con, Host, SV } = registry; +interface MovedEntityState { + readonly origin: Vector; + readonly angles: Vector; + readonly edict: ServerEdict; +} + +let { Con, Host, SV } = getCommonRegistry(); eventBus.subscribe('registry.frozen', () => { - Con = registry.Con; - Host = registry.Host; - SV = registry.SV; + ({ Con, Host, SV } = getCommonRegistry()); }); /** * Handles core physics simulation, entity movement, and collision handling. */ export class ServerPhysics { - constructor() { - } - /** * Convert a world-space point into local pusher space using an orthonormal basis. - * @param {Vector} point point in world space - * @param {Vector} origin transform origin - * @param {number[]} basis 3x3 rotation basis from Vector.toRotationMatrix() - * @returns {Vector} point in local space + * @returns Point in local space. */ - static _transformPointToLocal(point, origin, basis) { + static _transformPointToLocal(point: Vector, origin: Vector, basis: number[]): Vector { const delta = point.copy().subtract(origin); const forward = new Vector(basis[0], basis[1], basis[2]); const right = new Vector(basis[3], basis[4], basis[5]); @@ -46,12 +52,9 @@ export class ServerPhysics { /** * Convert a local-space point back into world space using an orthonormal basis. - * @param {Vector} point point in local space - * @param {Vector} origin transform origin - * @param {number[]} basis 3x3 rotation basis from Vector.toRotationMatrix() - * @returns {Vector} point in world space + * @returns Point in world space. */ - static _transformPointToWorld(point, origin, basis) { + static _transformPointToWorld(point: Vector, origin: Vector, basis: number[]): Vector { const forward = new Vector(basis[0], basis[1], basis[2]); const right = new Vector(basis[3], basis[4], basis[5]); const up = new Vector(basis[6], basis[7], basis[8]); @@ -65,14 +68,14 @@ export class ServerPhysics { /** * Iterates all non-static entities to ensure none start inside solid space. */ - checkAllEnts() { - for (let e = 1; e < SV.server.num_edicts; e++) { - const check = SV.server.edicts[e]; + checkAllEnts(): void { + for (let index = 1; index < SV.server.num_edicts; index++) { + const check = SV.server.edicts[index]; if (check.isFree()) { continue; } - switch (check.entity.movetype) { + switch (check.entity!.movetype) { case Defs.moveType.MOVETYPE_PUSH: case Defs.moveType.MOVETYPE_NONE: case Defs.moveType.MOVETYPE_NOCLIP: @@ -88,23 +91,23 @@ export class ServerPhysics { /** * Clamps velocity/origin components and guards against NaN values. - * @param {import('../Edict.mjs').ServerEdict} ent entity to validate */ - checkVelocity(ent) { - const velo = ent.entity.velocity; - const origin = ent.entity.origin; + checkVelocity(ent: ServerEdict): void { + const entity = ent.entity!; + const velo = entity.velocity; + const origin = entity.origin; - for (let i = 0; i < 3; i++) { - let component = velo[i]; + for (let index = 0; index < 3; index++) { + let component = velo[index]; if (Q.isNaN(component)) { - Con.Print('Got a NaN velocity on ' + ent.entity.classname + '\n'); + Con.Print(`Got a NaN velocity on ${entity.classname}\n`); component = 0.0; } - if (Q.isNaN(origin[i])) { - Con.Print('Got a NaN origin on ' + ent.entity.classname + '\n'); - origin[i] = 0.0; + if (Q.isNaN(origin[index])) { + Con.Print(`Got a NaN origin on ${entity.classname}\n`); + origin[index] = 0.0; } if (component > SV.maxvelocity.value) { @@ -113,21 +116,22 @@ export class ServerPhysics { component = -SV.maxvelocity.value; } - velo[i] = component; + velo[index] = component; } - ent.entity.origin = ent.entity.origin.set(origin); - ent.entity.velocity = ent.entity.velocity.set(velo); + entity.origin = entity.origin.set(origin); + entity.velocity = entity.velocity.set(velo); } /** * Executes pending thinks for an entity until caught up with server time. - * @param {import('../Edict.mjs').ServerEdict} ent entity to process - * @returns {boolean} false if the entity was freed during thinking + * @returns False if the entity was freed during thinking. */ - runThink(ent) { + runThink(ent: ServerEdict): boolean { + const entity = ent.entity!; + while (true) { - let thinktime = ent.entity.nextthink; + let thinktime = entity.nextthink; if (thinktime <= 0.0 || thinktime > (SV.server.time + Host.frametime)) { return true; @@ -137,9 +141,9 @@ export class ServerPhysics { thinktime = SV.server.time; } - ent.entity.nextthink = 0.0; + entity.nextthink = 0.0; SV.server.gameAPI.time = thinktime; - ent.entity.think(); + entity.think(); if (ent.isFree()) { return false; @@ -149,15 +153,12 @@ export class ServerPhysics { /** * Invokes touch callbacks between two entities. - * @param {import('../Edict.mjs').ServerEdict} e1 first entity - * @param {import('../Edict.mjs').ServerEdict} e2 second entity - * @param {Vector} pushVector vector representing the push force */ - impact(e1, e2, pushVector) { + impact(e1: ServerEdict, e2: ServerEdict, pushVector: Vector): void { SV.server.gameAPI.time = SV.server.time; - const ent1 = /** @type {import('../Edict.mjs').BaseEntity} */ (e1.entity); - const ent2 = /** @type {import('../Edict.mjs').BaseEntity} */ (e2.entity); + const ent1 = e1.entity!; + const ent2 = e2.entity!; if (ent1.touch && ent1.solid !== Defs.solid.SOLID_NOT) { ent1.touch(ent2, pushVector); @@ -170,12 +171,8 @@ export class ServerPhysics { /** * Clips the velocity vector against a collision plane. - * @param {Vector} vec incoming velocity - * @param {Vector} normal collision normal - * @param {Vector} out output velocity - * @param {number} overbounce overbounce factor */ - clipVelocity(vec, normal, out, overbounce) { + clipVelocity(vec: Vector, normal: Vector, out: Vector, overbounce: number): void { const backoff = vec.dot(normal) * overbounce; out[0] = vec[0] - normal[0] * backoff; @@ -196,35 +193,34 @@ export class ServerPhysics { /** * Performs sliding movement with up to four collision planes. - * @param {import('../Edict.mjs').ServerEdict} ent entity to move - * @param {number} time frame time slice - * @returns {{blocked: number, steptrace: import('./ServerCollision.mjs').Trace | null}} result with blocked flags and optional wall trace + * @returns Blocked flags and an optional wall trace. */ - flyMove(ent, time) { - const planes = []; - const primalVelocity = ent.entity.velocity.copy(); + flyMove(ent: ServerEdict, time: number): FlyMoveResult { + const entity = ent.entity!; + const planes: Vector[] = []; + const primalVelocity = entity.velocity.copy(); let originalVelocity = primalVelocity.copy(); const newVelocity = new Vector(); let timeLeft = time; let blocked = BlockedFlags.NONE; - let steptrace = null; + let steptrace: CollisionTrace | null = null; for (let bumpCount = 0; bumpCount < MAX_BUMP_COUNT; bumpCount++) { - if (ent.entity.velocity.isOrigin()) { + if (entity.velocity.isOrigin()) { break; } - const end = ent.entity.origin.copy().add(ent.entity.velocity.copy().multiply(timeLeft)); - const trace = SV.collision.move(ent.entity.origin, ent.entity.mins, ent.entity.maxs, end, 0, ent); + const end = entity.origin.copy().add(entity.velocity.copy().multiply(timeLeft)); + const trace = SV.collision.move(entity.origin, entity.mins, entity.maxs, end, 0, ent); if (trace.allsolid) { - ent.entity.velocity = new Vector(); + entity.velocity = new Vector(); return { blocked: BlockedFlags.BOTH, steptrace }; } if (trace.fraction > 0.0) { - ent.entity.origin = ent.entity.origin.set(trace.endpos); - originalVelocity = ent.entity.velocity.copy(); + entity.origin = entity.origin.set(trace.endpos); + originalVelocity = entity.velocity.copy(); planes.length = 0; if (trace.fraction === 1.0) { break; @@ -232,21 +228,22 @@ export class ServerPhysics { } console.assert(trace.ent !== null, 'trace.ent must not be null'); + const traceEnt = trace.ent!; if (trace.plane.normal[2] > GROUND_ANGLE_THRESHOLD) { blocked |= BlockedFlags.FLOOR; - if (trace.ent.entity.solid === Defs.solid.SOLID_BSP || - trace.ent.entity.solid === Defs.solid.SOLID_BBOX || - trace.ent.entity.solid === Defs.solid.SOLID_MESH) { - ent.entity.flags |= Defs.flags.FL_ONGROUND; - ent.entity.groundentity = trace.ent.entity; + if (traceEnt.entity!.solid === Defs.solid.SOLID_BSP + || traceEnt.entity!.solid === Defs.solid.SOLID_BBOX + || traceEnt.entity!.solid === Defs.solid.SOLID_MESH) { + entity.flags |= Defs.flags.FL_ONGROUND; + entity.groundentity = traceEnt.entity!; } } else if (trace.plane.normal[2] === 0.0) { blocked |= BlockedFlags.WALL; steptrace = trace; } - this.impact(ent, trace.ent, ent.entity.velocity.copy()); + this.impact(ent, traceEnt, entity.velocity.copy()); if (ent.isFree()) { break; @@ -255,42 +252,42 @@ export class ServerPhysics { timeLeft -= timeLeft * trace.fraction; if (planes.length >= 5) { - ent.entity.velocity = new Vector(); + entity.velocity = new Vector(); return { blocked: 3, steptrace }; } planes.push(trace.plane.normal.copy()); - let i; - let j; - for (i = 0; i < planes.length; i++) { - this.clipVelocity(originalVelocity, planes[i], newVelocity, 1.0); - for (j = 0; j < planes.length; j++) { - if (j !== i) { - const plane = planes[j]; + let planeIndex: number; + let otherPlaneIndex: number; + for (planeIndex = 0; planeIndex < planes.length; planeIndex++) { + this.clipVelocity(originalVelocity, planes[planeIndex], newVelocity, 1.0); + for (otherPlaneIndex = 0; otherPlaneIndex < planes.length; otherPlaneIndex++) { + if (otherPlaneIndex !== planeIndex) { + const plane = planes[otherPlaneIndex]; if ((newVelocity[0] * plane[0] + newVelocity[1] * plane[1] + newVelocity[2] * plane[2]) < 0.0) { break; } } } - if (j === planes.length) { + if (otherPlaneIndex === planes.length) { break; } } - if (i !== planes.length) { - ent.entity.velocity = newVelocity.copy(); + if (planeIndex !== planes.length) { + entity.velocity = newVelocity.copy(); } else { if (planes.length !== 2) { - ent.entity.velocity = new Vector(); + entity.velocity = new Vector(); return { blocked: 7, steptrace }; } const dir = planes[0].cross(planes[1]); - ent.entity.velocity = dir.multiply(dir.dot(ent.entity.velocity)); + entity.velocity = dir.multiply(dir.dot(entity.velocity)); } - if (ent.entity.velocity.dot(primalVelocity) <= 0.0) { - ent.entity.velocity = new Vector(); + if (entity.velocity.dot(primalVelocity) <= 0.0) { + entity.velocity = new Vector(); return { blocked, steptrace }; } } @@ -300,37 +297,35 @@ export class ServerPhysics { /** * Applies gravity to an entity taking custom gravity into account. - * @param {import('../Edict.mjs').ServerEdict} ent entity to influence */ - addGravity(ent) { - const entGravity = typeof(ent.entity.gravity) === 'number' ? ent.entity.gravity : 1.0; - const velocity = ent.entity.velocity; + addGravity(ent: ServerEdict): void { + const entity = ent.entity!; + const entGravity = typeof entity.gravity === 'number' ? entity.gravity : 1.0; + const velocity = entity.velocity; velocity[2] += entGravity * SV.gravity.value * Host.frametime * -1.0; - ent.entity.velocity = velocity; + entity.velocity = velocity; } /** * Applies a small upward force used for buoyancy. - * @param {import('../Edict.mjs').ServerEdict} ent entity to influence */ - addBuoyancy(ent) { - const velocity = ent.entity.velocity; + addBuoyancy(ent: ServerEdict): void { + const velocity = ent.entity!.velocity; velocity[2] += SV.gravity.value * Host.frametime * 0.01; - ent.entity.velocity = velocity; + ent.entity!.velocity = velocity; } /** * Pushes an entity by the provided vector and performs collision handling. - * @param {import('../Edict.mjs').ServerEdict} ent entity to move - * @param {Vector} pushVector movement vector - * @returns {import('./ServerCollision.mjs').Trace} resulting trace + * @returns Resulting trace. */ - pushEntity(ent, pushVector) { - const end = ent.entity.origin.copy().add(pushVector); - const solid = ent.entity.solid; + pushEntity(ent: ServerEdict, pushVector: Vector): CollisionTrace { + const entity = ent.entity!; + const end = entity.origin.copy().add(pushVector); + const solid = entity.solid; - let nomonsters; - if (ent.entity.movetype === Defs.moveType.MOVETYPE_FLYMISSILE) { + let nomonsters: number; + if (entity.movetype === Defs.moveType.MOVETYPE_FLYMISSILE) { nomonsters = Defs.moveTypes.MOVE_MISSILE; } else if (solid === Defs.solid.SOLID_TRIGGER || solid === Defs.solid.SOLID_NOT) { nomonsters = Defs.moveTypes.MOVE_NOMONSTERS; @@ -338,13 +333,13 @@ export class ServerPhysics { nomonsters = Defs.moveTypes.MOVE_NORMAL; } - const trace = SV.collision.move(ent.entity.origin, ent.entity.mins, ent.entity.maxs, end, nomonsters, ent); + const trace = SV.collision.move(entity.origin, entity.mins, entity.maxs, end, nomonsters, ent); // CR: Only move the entity if the trace made progress. When allsolid is true, // the entity started and remained entirely in solid (e.g. spawned inside a wall), // so we keep it at its current position to prevent falling out of world. if (!trace.allsolid) { - ent.entity.origin = ent.entity.origin.set(trace.endpos); + entity.origin = entity.origin.set(trace.endpos); } SV.area.linkEdict(ent, true); @@ -357,48 +352,49 @@ export class ServerPhysics { /** * Moves a pusher entity and resolves collisions with touched entities. - * @param {import('../Edict.mjs').ServerEdict} pusher pusher entity - * @param {number} movetime time to move */ - pushMove(pusher, movetime) { - if (pusher.entity.velocity.isOrigin() && pusher.entity.avelocity.isOrigin()) { - pusher.entity.ltime += movetime; + pushMove(pusher: ServerEdict, movetime: number): void { + const pusherEntity = pusher.entity!; + + if (pusherEntity.velocity.isOrigin() && pusherEntity.avelocity.isOrigin()) { + pusherEntity.ltime += movetime; return; } - const move = pusher.entity.velocity.copy().multiply(movetime); - const rotation = pusher.entity.avelocity.copy().multiply(movetime); - const mins = pusher.entity.absmin.copy().add(move); - const maxs = pusher.entity.absmax.copy().add(move); - const pushorig = pusher.entity.origin.copy(); - const pushangles = pusher.entity.angles.copy(); + const move = pusherEntity.velocity.copy().multiply(movetime); + const rotation = pusherEntity.avelocity.copy().multiply(movetime); + const mins = pusherEntity.absmin.copy().add(move); + const maxs = pusherEntity.absmax.copy().add(move); + const pushorig = pusherEntity.origin.copy(); + const pushangles = pusherEntity.angles.copy(); const pushbasis = pushangles.isOrigin() ? null : pushangles.toRotationMatrix(); - pusher.entity.origin = pusher.entity.origin.copy().add(move); - pusher.entity.angles = pusher.entity.angles.copy().add(rotation); - const finalbasis = pusher.entity.angles.isOrigin() ? null : pusher.entity.angles.toRotationMatrix(); - pusher.entity.ltime += movetime; + pusherEntity.origin = pusherEntity.origin.copy().add(move); + pusherEntity.angles = pusherEntity.angles.copy().add(rotation); + const finalbasis = pusherEntity.angles.isOrigin() ? null : pusherEntity.angles.toRotationMatrix(); + pusherEntity.ltime += movetime; SV.area.linkEdict(pusher); - const moved = []; + const moved: MovedEntityState[] = []; - for (let e = 1; e < SV.server.num_edicts; e++) { - const check = SV.server.edicts[e]; + for (let index = 1; index < SV.server.num_edicts; index++) { + const check = SV.server.edicts[index]; if (check.isFree()) { continue; } - const movetype = check.entity.movetype; + const checkEntity = check.entity!; + const movetype = checkEntity.movetype; if (movetype === Defs.moveType.MOVETYPE_PUSH || movetype === Defs.moveType.MOVETYPE_NONE || movetype === Defs.moveType.MOVETYPE_NOCLIP) { continue; } - const wasGroundedOnPusher = (check.entity.flags & Defs.flags.FL_ONGROUND) !== 0 && - check.entity.groundentity !== null && - check.entity.groundentity.equals(pusher.entity); + const wasGroundedOnPusher = (checkEntity.flags & Defs.flags.FL_ONGROUND) !== 0 + && checkEntity.groundentity !== null + && checkEntity.groundentity.equals(pusherEntity); if (!wasGroundedOnPusher) { - if (!check.entity.absmin.lt(maxs) || !check.entity.absmax.gt(mins)) { + if (!checkEntity.absmin.lt(maxs) || !checkEntity.absmax.gt(mins)) { continue; } @@ -408,71 +404,70 @@ export class ServerPhysics { } if (movetype !== Defs.moveType.MOVETYPE_WALK) { - check.entity.flags &= ~Defs.flags.FL_ONGROUND; + checkEntity.flags &= ~Defs.flags.FL_ONGROUND; } - const entorig = check.entity.origin.copy(); - const entangles = check.entity.angles.copy(); - moved[moved.length] = [entorig, entangles, check]; - pusher.entity.solid = Defs.solid.SOLID_NOT; + const entorig = checkEntity.origin.copy(); + const entangles = checkEntity.angles.copy(); + moved.push({ origin: entorig, angles: entangles, edict: check }); + pusherEntity.solid = Defs.solid.SOLID_NOT; let finalMove = move.copy(); if (!rotation.isOrigin()) { const localOffset = pushbasis === null - ? check.entity.origin.copy().subtract(pushorig) - : ServerPhysics._transformPointToLocal(check.entity.origin, pushorig, pushbasis); + ? checkEntity.origin.copy().subtract(pushorig) + : ServerPhysics._transformPointToLocal(checkEntity.origin, pushorig, pushbasis); const newPos = finalbasis === null - ? pusher.entity.origin.copy().add(localOffset) - : ServerPhysics._transformPointToWorld(localOffset, pusher.entity.origin, finalbasis); + ? pusherEntity.origin.copy().add(localOffset) + : ServerPhysics._transformPointToWorld(localOffset, pusherEntity.origin, finalbasis); - finalMove = newPos.subtract(check.entity.origin); + finalMove = newPos.subtract(checkEntity.origin); - check.entity.angles = check.entity.angles.copy().add(rotation); + checkEntity.angles = checkEntity.angles.copy().add(rotation); } this.pushEntity(check, finalMove); - pusher.entity.solid = Defs.solid.SOLID_BSP; + pusherEntity.solid = Defs.solid.SOLID_BSP; if (SV.collision.testEntityPosition(check)) { if (wasGroundedOnPusher) { - pusher.entity.solid = Defs.solid.SOLID_NOT; + pusherEntity.solid = Defs.solid.SOLID_NOT; const blockedByOtherSolid = SV.collision.testEntityPosition(check); - pusher.entity.solid = Defs.solid.SOLID_BSP; + pusherEntity.solid = Defs.solid.SOLID_BSP; if (!blockedByOtherSolid) { continue; } } - const cmins = check.entity.mins; - const cmaxs = check.entity.maxs; + const cmins = checkEntity.mins; + const cmaxs = checkEntity.maxs; if (cmins[0] === cmaxs[0]) { continue; } - if (check.entity.solid === Defs.solid.SOLID_NOT || check.entity.solid === Defs.solid.SOLID_TRIGGER) { + if (checkEntity.solid === Defs.solid.SOLID_NOT || checkEntity.solid === Defs.solid.SOLID_TRIGGER) { cmins[0] = cmaxs[0] = 0.0; cmins[1] = cmaxs[1] = 0.0; cmaxs[2] = cmins[2]; - check.entity.mins = cmins; - check.entity.maxs = cmaxs; + checkEntity.mins = cmins; + checkEntity.maxs = cmaxs; continue; } - check.entity.origin = entorig; - check.entity.angles = entangles; + checkEntity.origin = entorig; + checkEntity.angles = entangles; SV.area.linkEdict(check, true); - pusher.entity.origin = pusher.entity.origin.set(pushorig); - pusher.entity.angles = pusher.entity.angles.set(pushangles); + pusherEntity.origin = pusherEntity.origin.set(pushorig); + pusherEntity.angles = pusherEntity.angles.set(pushangles); SV.area.linkEdict(pusher); - pusher.entity.ltime -= movetime; - if (pusher.entity.blocked) { - pusher.entity.blocked(check.entity); + pusherEntity.ltime -= movetime; + if (pusherEntity.blocked) { + pusherEntity.blocked(checkEntity); } - for (let i = 0; i < moved.length; i++) { // FIXME: rewrite - const movedEdict = moved[i]; - movedEdict[2].entity.origin = movedEdict[0]; - movedEdict[2].entity.angles = movedEdict[1]; - SV.area.linkEdict(movedEdict[2]); + for (const movedEdict of moved) { // FIXME: rewrite + movedEdict.edict.entity!.origin = movedEdict.origin; + movedEdict.edict.entity!.angles = movedEdict.angles; + SV.area.linkEdict(movedEdict.edict); } return; } @@ -481,12 +476,12 @@ export class ServerPhysics { /** * Applies motion to MOVETYPE_PUSH entities. - * @param {import('../Edict.mjs').ServerEdict} ent entity to process */ - physicsPusher(ent) { - const oldltime = ent.entity.ltime; - const thinktime = ent.entity.nextthink; - let movetime; + physicsPusher(ent: ServerEdict): void { + const entity = ent.entity!; + const oldltime = entity.ltime; + const thinktime = entity.nextthink; + let movetime: number; if (thinktime > 0.0 && thinktime < (oldltime + Host.frametime)) { movetime = Math.max(thinktime - oldltime, 0.0); @@ -498,37 +493,38 @@ export class ServerPhysics { this.pushMove(ent, movetime); } - if (thinktime <= oldltime || thinktime > ent.entity.ltime) { + if (thinktime <= oldltime || thinktime > entity.ltime) { return; } - ent.entity.nextthink = 0.0; + entity.nextthink = 0.0; SV.server.gameAPI.time = SV.server.time; - ent.entity.think(); + entity.think(); } /** * Attempts to resolve a stuck player by nudging the entity around. - * @param {import('../Edict.mjs').ServerEdict} ent entity to fix */ - checkStuck(ent) { + checkStuck(ent: ServerEdict): void { + const entity = ent.entity!; + if (!SV.collision.testEntityPosition(ent)) { - ent.entity.oldorigin = ent.entity.oldorigin.set(ent.entity.origin); + entity.oldorigin = entity.oldorigin.set(entity.origin); return; } - ent.entity.origin = ent.entity.origin.set(ent.entity.oldorigin); + entity.origin = entity.origin.set(entity.oldorigin); if (!SV.collision.testEntityPosition(ent)) { Con.DPrint('Unstuck.\n'); SV.area.linkEdict(ent, true); return; } - const norg = ent.entity.origin.copy(); + const norg = entity.origin.copy(); for (norg[2] = 0.0; norg[2] <= 17.0; norg[2]++) { for (norg[0] = -1.0; norg[0] <= 1.0; norg[0]++) { for (norg[1] = -1.0; norg[1] <= 1.0; norg[1]++) { - ent.entity.origin = ent.entity.origin.set(norg).add(norg); + entity.origin = entity.origin.set(norg).add(norg); if (!SV.collision.testEntityPosition(ent)) { Con.DPrint('Unstuck.\n'); SV.area.linkEdict(ent, true); @@ -543,11 +539,10 @@ export class ServerPhysics { /** * Inspects the entity position to determine water level and type. - * @param {import('../Edict.mjs').ServerEdict} ent entity to inspect - * @returns {boolean} true if entity is largely underwater + * @returns True if entity is largely underwater. */ - checkWater(ent) { - const entity = ent.entity; + checkWater(ent: ServerEdict): boolean { + const entity = ent.entity!; const point = entity.origin.copy().add(new Vector(0.0, 0.0, entity.mins[2] + 1.0)); entity.waterlevel = Defs.waterlevel.WATERLEVEL_NONE; entity.watertype = Defs.content.CONTENT_EMPTY; @@ -574,42 +569,41 @@ export class ServerPhysics { /** * Emits splash sounds when transitioning between water and air. - * @param {import('../Edict.mjs').ServerEdict} ent entity to update */ - checkWaterTransition(ent) { - const cont = SV.collision.pointContents(ent.entity.origin); + checkWaterTransition(ent: ServerEdict): void { + const entity = ent.entity!; + const cont = SV.collision.pointContents(entity.origin); - if (!ent.entity.watertype) { // just spawned here - ent.entity.watertype = cont; - ent.entity.waterlevel = Defs.waterlevel.WATERLEVEL_FEET; + if (!entity.watertype) { + entity.watertype = cont; + entity.waterlevel = Defs.waterlevel.WATERLEVEL_FEET; return; } if (cont <= Defs.content.CONTENT_WATER) { - if (ent.entity.watertype === Defs.content.CONTENT_EMPTY) { + if (entity.watertype === Defs.content.CONTENT_EMPTY) { SV.messages.startSound(ent, 0, 'misc/h2ohit1.wav', 255, 1.0); } - ent.entity.watertype = cont; - ent.entity.waterlevel = Defs.waterlevel.WATERLEVEL_WAIST; + entity.watertype = cont; + entity.waterlevel = Defs.waterlevel.WATERLEVEL_WAIST; return; } - if (ent.entity.watertype !== Defs.content.CONTENT_EMPTY) { + if (entity.watertype !== Defs.content.CONTENT_EMPTY) { // just walked into water SV.messages.startSound(ent, 0, 'misc/h2ohit1.wav', 255, 1.0); } - ent.entity.watertype = Defs.content.CONTENT_EMPTY; - ent.entity.waterlevel = cont; // CR: I’m not sure whether this is correct or should be e.g. WATERLEVEL_NONE + entity.watertype = Defs.content.CONTENT_EMPTY; + entity.waterlevel = cont; // CR: I’m not sure whether this is correct or should be e.g. WATERLEVEL_NONE } /** * Applies wall friction to prevent jittering when sliding along geometry. - * @param {import('../Edict.mjs').ServerEdict} ent entity to modify - * @param {{plane: {normal: Vector}}} trace collision trace */ - wallFriction(ent, trace) { - const viewAngles = ent.entity.v_angle ?? ent.entity.angles; + wallFriction(ent: ServerEdict, trace: CollisionTrace): void { + const entity = ent.entity!; + const viewAngles = entity.v_angle ?? entity.angles; const { forward } = viewAngles.angleVectors(); const normal = trace.plane.normal; let d = normal.dot(forward) + 0.5; @@ -617,23 +611,22 @@ export class ServerPhysics { return; } d += 1.0; - const velo = ent.entity.velocity; + const velo = entity.velocity; velo[0] = (velo[0] - normal[0] * normal.dot(velo)) * d; velo[1] = (velo[1] - normal[1] * normal.dot(velo)) * d; - ent.entity.velocity = velo; + entity.velocity = velo; } /** * Attempts to unstick an entity by trying small offsets. - * @param {import('../Edict.mjs').ServerEdict} ent entity to adjust - * @param {Vector} oldvel previous velocity - * @returns {number} resulting clip flags + * @returns Resulting clip flags. */ - tryUnstick(ent, oldvel) { - const oldorg = ent.entity.origin.copy(); + tryUnstick(ent: ServerEdict, oldvel: Vector): number { + const entity = ent.entity!; + const oldorg = entity.origin.copy(); const dir = new Vector(2.0, 0.0, 0.0); - for (let i = 0; i <= 7; i++) { - switch (i) { + for (let index = 0; index <= 7; index++) { + switch (index) { case 1: dir[0] = 0.0; dir[1] = 2.0; break; case 2: dir[0] = -2.0; dir[1] = 0.0; break; case 3: dir[0] = 0.0; dir[1] = -2.0; break; @@ -644,45 +637,46 @@ export class ServerPhysics { default: break; } this.pushEntity(ent, dir); - ent.entity.velocity = new Vector(oldvel[0], oldvel[1], 0.0); + entity.velocity = new Vector(oldvel[0], oldvel[1], 0.0); const result = this.flyMove(ent, VELOCITY_EPSILON); - const curorg = ent.entity.origin; + const curorg = entity.origin; if (Math.abs(oldorg[1] - curorg[1]) > 4.0 || Math.abs(oldorg[0] - curorg[0]) > 4.0) { return result.blocked; } - ent.entity.origin = ent.entity.origin.set(oldorg); + entity.origin = entity.origin.set(oldorg); } - ent.entity.velocity = new Vector(); + entity.velocity = new Vector(); return 7; } /** * Simulates toss/bounce style movement. - * @param {import('../Edict.mjs').ServerEdict} ent entity to update */ - physicsToss(ent) { + physicsToss(ent: ServerEdict): void { + const entity = ent.entity!; + if (!this.runThink(ent)) { return; } - if ((ent.entity.flags & Defs.flags.FL_ONGROUND) !== 0) { + if ((entity.flags & Defs.flags.FL_ONGROUND) !== 0) { return; } this.checkVelocity(ent); - const movetype = ent.entity.movetype; + const movetype = entity.movetype; if (movetype !== Defs.moveType.MOVETYPE_FLY && movetype !== Defs.moveType.MOVETYPE_FLYMISSILE) { this.addGravity(ent); } - ent.entity.angles = ent.entity.angles.add(ent.entity.avelocity.copy().multiply(Host.frametime)); - const trace = this.pushEntity(ent, ent.entity.velocity.copy().multiply(Host.frametime)); + entity.angles = entity.angles.add(entity.avelocity.copy().multiply(Host.frametime)); + const trace = this.pushEntity(ent, entity.velocity.copy().multiply(Host.frametime)); // CR: If entity started and stayed entirely in solid (e.g. spawned inside a wall), // stop movement to prevent falling out of world. This commonly happens when items // are dropped by monsters dying near walls. if (trace.allsolid) { - ent.entity.velocity = new Vector(); - ent.entity.avelocity = new Vector(); + entity.velocity = new Vector(); + entity.avelocity = new Vector(); return; } @@ -691,15 +685,16 @@ export class ServerPhysics { } const velocity = new Vector(); - this.clipVelocity(ent.entity.velocity, trace.plane.normal, velocity, movetype === Defs.moveType.MOVETYPE_BOUNCE ? 1.5 : 1.0); - ent.entity.velocity = velocity; + this.clipVelocity(entity.velocity, trace.plane.normal, velocity, movetype === Defs.moveType.MOVETYPE_BOUNCE ? 1.5 : 1.0); + entity.velocity = velocity; if (trace.plane.normal[2] > GROUND_ANGLE_THRESHOLD) { - if (ent.entity.velocity[2] < 60.0 || movetype !== Defs.moveType.MOVETYPE_BOUNCE) { - ent.entity.flags |= Defs.flags.FL_ONGROUND; - ent.entity.groundentity = trace.ent.entity; - ent.entity.velocity = new Vector(); - ent.entity.avelocity = new Vector(); + if (entity.velocity[2] < 60.0 || movetype !== Defs.moveType.MOVETYPE_BOUNCE) { + console.assert(trace.ent !== null, 'grounding toss trace must resolve a hit entity'); + entity.flags |= Defs.flags.FL_ONGROUND; + entity.groundentity = trace.ent!.entity!; + entity.velocity = new Vector(); + entity.avelocity = new Vector(); } } @@ -708,12 +703,11 @@ export class ServerPhysics { /** * Handles MOVETYPE_STEP entities (most monsters). - * @param {import('../Edict.mjs').ServerEdict} ent entity to update */ - physicsStep(ent) { - const entity = ent.entity; + physicsStep(ent: ServerEdict): void { + const entity = ent.entity!; if ((entity.flags & (Defs.flags.FL_ONGROUND | Defs.flags.FL_FLY | Defs.flags.FL_SWIM)) === 0) { - const hitsound = (ent.entity.velocity[2] < (SV.gravity.value * -VELOCITY_EPSILON)); + const hitsound = entity.velocity[2] < (SV.gravity.value * -VELOCITY_EPSILON); this.addGravity(ent); this.checkVelocity(ent); this.flyMove(ent, Host.frametime); @@ -729,12 +723,12 @@ export class ServerPhysics { /** * Runs the main entity physics step for the server. */ - physics() { + physics(): void { SV.server.gameAPI.time = SV.server.time; SV.server.gameAPI.startFrame(); - for (let i = 0; i < SV.server.num_edicts; i++) { - const ent = SV.server.edicts[i]; + for (let index = 0; index < SV.server.num_edicts; index++) { + const ent = SV.server.edicts[index]; if (ent.isFree()) { continue; } @@ -747,7 +741,7 @@ export class ServerPhysics { SV.clientPhysics.physicsClient(ent); continue; } - switch (ent.entity.movetype) { + switch (ent.entity!.movetype) { case Defs.moveType.MOVETYPE_PUSH: this.physicsPusher(ent); continue; @@ -767,7 +761,7 @@ export class ServerPhysics { this.physicsToss(ent); continue; default: - throw new Error('SV.Physics: bad movetype ' + (ent.entity.movetype >> 0)); + throw new Error(`SV.Physics: bad movetype ${ent.entity!.movetype >> 0}`); } } diff --git a/test/common/game-apis.test.mjs b/test/common/game-apis.test.mjs index 0929a279..158be8fd 100644 --- a/test/common/game-apis.test.mjs +++ b/test/common/game-apis.test.mjs @@ -2,12 +2,12 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import Vector from '../../source/shared/Vector.ts'; -import { solid } from '../../source/shared/Defs.ts'; +import { moveTypes, solid } from '../../source/shared/Defs.ts'; import { ClientEdict } from '../../source/engine/client/ClientEntities.mjs'; -import { ClientEngineAPI } from '../../source/engine/common/GameAPIs.ts'; -import { ServerArea } from '../../source/engine/server/physics/ServerArea.mjs'; -import { ServerCollision } from '../../source/engine/server/physics/ServerCollision.mjs'; -import { CollisionTrace } from '../../source/engine/server/physics/ServerCollisionSupport.mjs'; +import { ClientEngineAPI, ServerEngineAPI } from '../../source/engine/common/GameAPIs.ts'; +import { ServerArea } from '../../source/engine/server/physics/ServerArea.ts'; +import { ServerCollision } from '../../source/engine/server/physics/ServerCollision.ts'; +import { CollisionTrace } from '../../source/engine/server/physics/ServerCollisionSupport.ts'; import { defaultMockRegistry, withMockRegistry } from '../physics/fixtures.mjs'; /** @@ -152,3 +152,67 @@ void describe('ClientEngineAPI.Traceline', () => { }); }); }); + +void describe('ServerEngineAPI.Traceline', () => { + void test('calls collision.move with the collision instance bound as this', () => { + const collision = { + calls: [], + move(start, mins, maxs, end, type, passedict) { + this.calls.push({ start, mins, maxs, end, type, passedict }); + return CollisionTrace.empty(end); + }, + }; + + void withMockRegistry(defaultMockRegistry({ + collision, + }), () => { + const trace = ServerEngineAPI.Traceline( + new Vector(1, 2, 3), + new Vector(4, 5, 6), + true, + null, + ); + + assert.equal(trace.fraction, 1.0); + }); + + assert.equal(collision.calls.length, 1); + assert.equal(collision.calls[0].type, moveTypes.MOVE_NOMONSTERS); + assert.deepEqual([...collision.calls[0].start], [1, 2, 3]); + assert.deepEqual([...collision.calls[0].end], [4, 5, 6]); + }); +}); + +void describe('ServerEngineAPI.Navigate', () => { + void test('passes through a missing synchronous path as null', () => { + void withMockRegistry(defaultMockRegistry({ + server: { + navigation: { + findPath() { + return null; + }, + }, + }, + }), () => { + const path = ServerEngineAPI.Navigate(new Vector(1, 2, 3), new Vector(4, 5, 6)); + + assert.equal(path, null); + }); + }); + + void test('passes through a missing asynchronous path as null', async () => { + await withMockRegistry(defaultMockRegistry({ + server: { + navigation: { + findPathAsync() { + return Promise.resolve(null); + }, + }, + }, + }), async () => { + const path = await ServerEngineAPI.NavigateAsync(new Vector(1, 2, 3), new Vector(4, 5, 6)); + + assert.equal(path, null); + }); + }); +}); diff --git a/test/physics/collision-regressions.test.mjs b/test/physics/collision-regressions.test.mjs index c473ccde..d6d3828e 100644 --- a/test/physics/collision-regressions.test.mjs +++ b/test/physics/collision-regressions.test.mjs @@ -9,10 +9,10 @@ import { BSP29Loader } from '../../source/engine/common/model/loaders/BSP29Loade import { eventBus, registry } from '../../source/engine/registry.mjs'; import { UserCmd } from '../../source/engine/network/Protocol.ts'; import { ClientEdict } from '../../source/engine/client/ClientEntities.mjs'; -import { ServerCollision } from '../../source/engine/server/physics/ServerCollision.mjs'; -import { ServerPhysics } from '../../source/engine/server/physics/ServerPhysics.mjs'; -import { ServerMovement } from '../../source/engine/server/physics/ServerMovement.mjs'; -import { BlockedFlags, MAX_BUMP_COUNT } from '../../source/engine/server/physics/Defs.mjs'; +import { ServerCollision } from '../../source/engine/server/physics/ServerCollision.ts'; +import { ServerPhysics } from '../../source/engine/server/physics/ServerPhysics.ts'; +import { ServerMovement } from '../../source/engine/server/physics/ServerMovement.ts'; +import { BlockedFlags, MAX_BUMP_COUNT } from '../../source/engine/server/physics/Defs.ts'; void test('PmovePlayer.DEBUG is disabled before Pmove.Init()', () => { assert.equal(PmovePlayer.DEBUG, false); diff --git a/test/physics/fixtures.mjs b/test/physics/fixtures.mjs index b8273752..acf43844 100644 --- a/test/physics/fixtures.mjs +++ b/test/physics/fixtures.mjs @@ -5,7 +5,7 @@ import { content, flags, moveType, solid } from '../../source/shared/Defs.ts'; import { Brush, BrushModel, BrushSide } from '../../source/engine/common/model/BSP.ts'; import { eventBus, registry } from '../../source/engine/registry.mjs'; import { ClientEdict } from '../../source/engine/client/ClientEntities.mjs'; -import { ServerPhysics } from '../../source/engine/server/physics/ServerPhysics.mjs'; +import { ServerPhysics } from '../../source/engine/server/physics/ServerPhysics.ts'; // ── Typedefs ──────────────────────────────────────────────────────────────── diff --git a/test/physics/func-door.test.mjs b/test/physics/func-door.test.mjs index 5af5d22e..d58540d1 100644 --- a/test/physics/func-door.test.mjs +++ b/test/physics/func-door.test.mjs @@ -89,8 +89,8 @@ function createDoorEntityFixture() { return { entity, gameAPI }; } -describe('DoorEntity', () => { - test('resets the scheduled go-down think when reopening a door that is already at the top', () => { +void describe('DoorEntity', () => { + void test('resets the scheduled go-down think when reopening a door that is already at the top', () => { const { entity } = createDoorEntityFixture(); const activator = { centerPoint: null }; diff --git a/test/physics/quake-entity-ai.test.mjs b/test/physics/quake-entity-ai.test.mjs new file mode 100644 index 00000000..f7f2a5bd --- /dev/null +++ b/test/physics/quake-entity-ai.test.mjs @@ -0,0 +1,128 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { worldType } from '../../source/game/id1/Defs.mjs'; +import { entityClasses } from '../../source/game/id1/GameAPI.mjs'; + +const OgreEntity = entityClasses.find((entityClass) => entityClass.classname === 'monster_ogre'); + +assert.ok(OgreEntity, 'monster_ogre must be registered in GameAPI'); + +/** + * @returns {InstanceType} monster fixture with a minimal engine API + */ +function createMonsterVisibilityFixture() { + const edict = { + num: 1, + entity: null, + equals(other) { + return this === other; + }, + freeEdict() {}, + setOrigin() {}, + setModel() {}, + setMinMaxSize() {}, + walkMove() { + return false; + }, + changeYaw() { + return 0; + }, + dropToFloor() { + return true; + }, + isOnTheFloor() { + return false; + }, + makeStatic() {}, + aim() { + return null; + }, + getNextBestClient() { + return null; + }, + }; + + const engine = { + IsLoading() { + return false; + }, + PrecacheModel() {}, + PrecacheSound() {}, + StartSound() {}, + SpawnAmbientSound() {}, + SpawnEntity() { + return { entity: null }; + }, + FindByFieldAndValue() { + return null; + }, + FindAllByFieldAndValue() { + return []; + }, + ParseQC() { + return null; + }, + Traceline() { + return null; + }, + SetAreaPortalState() {}, + GetCvar() { + return { value: 0 }; + }, + eventBus: { + publish() {}, + }, + }; + + const gameAPI = { + engine, + time: 0, + worldspawn: { + worldtype: worldType.MEDIEVAL, + }, + gameAI: { + _sightEntity: null, + _sightEntityTime: 0, + }, + }; + + const entity = new OgreEntity(edict, gameAPI); + edict.entity = entity; + + return entity; +} + +void describe('QuakeEntityAI._isVisible', () => { + void test('treats a clear ignoreMonsters traceline as visible even when no entity is returned', () => { + const entity = createMonsterVisibilityFixture(); + const target = createMonsterVisibilityFixture(); + + entity.engine.Traceline = () => ({ + fraction: 1.0, + entity: null, + contents: { + inOpen: false, + inWater: false, + }, + }); + + assert.equal(entity._ai._isVisible(target), true); + }); + + void test('rejects blocked sight lines when the trace stops short', () => { + const entity = createMonsterVisibilityFixture(); + const target = createMonsterVisibilityFixture(); + + entity.engine.Traceline = () => ({ + fraction: 0.5, + entity: null, + contents: { + inOpen: false, + inWater: false, + }, + }); + + assert.equal(entity._ai._isVisible(target), false); + }); +}); diff --git a/test/physics/server-client-physics.test.mjs b/test/physics/server-client-physics.test.mjs index b4a8912a..a5fa2c51 100644 --- a/test/physics/server-client-physics.test.mjs +++ b/test/physics/server-client-physics.test.mjs @@ -4,9 +4,8 @@ import assert from 'node:assert/strict'; import Vector from '../../source/shared/Vector.ts'; import { flags, moveType, solid } from '../../source/shared/Defs.ts'; import { UserCmd } from '../../source/engine/network/Protocol.ts'; -import { eventBus, registry } from '../../source/engine/registry.mjs'; import { ServerClient } from '../../source/engine/server/Client.mjs'; -import { ServerClientPhysics } from '../../source/engine/server/physics/ServerClientPhysics.mjs'; +import { ServerClientPhysics } from '../../source/engine/server/physics/ServerClientPhysics.ts'; import { createBoxBrushModel, @@ -30,9 +29,9 @@ function createUserCmd(options = {}) { return cmd; } -describe('ServerClientPhysics', () => { - describe('_runSharedPmove', () => { - test('syncs pmove state, splits long commands, and deduplicates touch impacts', () => { +void describe('ServerClientPhysics', () => { + void describe('_runSharedPmove', () => { + void test('syncs pmove state, splits long commands, and deduplicates touch impacts', () => { const clientPhysics = new ServerClientPhysics(); const impacts = []; const addEntityCalls = []; @@ -165,7 +164,7 @@ describe('ServerClientPhysics', () => { client.pmFlags = 2; client.pmTime = 3; - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ pmove, physics: { impact(ent, touchEdict, pushVector) { @@ -214,8 +213,8 @@ describe('ServerClientPhysics', () => { }); }); - describe('physicsClient', () => { - test('drains queued walk commands and links once per frame', () => { + void describe('physicsClient', () => { + void test('drains queued walk commands and links once per frame', () => { const clientPhysics = new ServerClientPhysics(); const events = []; const entity = createMockEntity({ @@ -231,7 +230,7 @@ describe('ServerClientPhysics', () => { ]; edict.getClient = () => client; - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area: { linkEdict(linkedEdict, touchTriggers) { events.push(['linkEdict', linkedEdict, touchTriggers]); @@ -288,7 +287,7 @@ describe('ServerClientPhysics', () => { assert.equal(client.pendingCmds.length, 0); }); - test('skips movement when no walk commands are queued', () => { + void test('skips movement when no walk commands are queued', () => { const clientPhysics = new ServerClientPhysics(); const events = []; const entity = createMockEntity({ @@ -300,7 +299,7 @@ describe('ServerClientPhysics', () => { client.state = ServerClient.STATE.CONNECTED; edict.getClient = () => client; - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area: { linkEdict() { events.push('linkEdict'); diff --git a/test/physics/server-collision.test.mjs b/test/physics/server-collision.test.mjs index dd54367f..47d3ce35 100644 --- a/test/physics/server-collision.test.mjs +++ b/test/physics/server-collision.test.mjs @@ -6,8 +6,8 @@ import { content, flags, moveType, moveTypes, solid } from '../../source/shared/ import { BrushModel } from '../../source/engine/common/model/BSP.ts'; import { Pmove } from '../../source/engine/common/Pmove.ts'; import { BSP29Loader } from '../../source/engine/common/model/loaders/BSP29Loader.ts'; -import { ServerCollision } from '../../source/engine/server/physics/ServerCollision.mjs'; -import { ServerArea } from '../../source/engine/server/physics/ServerArea.mjs'; +import { ServerCollision } from '../../source/engine/server/physics/ServerCollision.ts'; +import { ServerArea } from '../../source/engine/server/physics/ServerArea.ts'; import { assertNear, @@ -990,6 +990,49 @@ void describe('ServerArea', () => { assert.deepEqual([...offset], [...worldModel.hulls[0].clip_mins]); }); }); + + void test('invokes trigger touch with the trigger entity bound as this', () => { + const area = new ServerArea(); + const subjectEntity = createMockEntity({ + origin: new Vector(8, 0, 0), + mins: new Vector(-16, -16, -24), + maxs: new Vector(16, 16, 32), + solidType: solid.SOLID_BBOX, + }); + const subjectEdict = createMockEdict(subjectEntity); + const touchedEntities = []; + const triggerEntity = createMockEntity({ + origin: new Vector(), + mins: new Vector(-32, -32, -32), + maxs: new Vector(32, 32, 32), + solidType: solid.SOLID_TRIGGER, + }); + const triggerEdict = createMockEdict(triggerEntity); + + triggerEntity.spawnflags = 1234; + triggerEntity.touch = function(other) { + touchedEntities.push({ spawnflags: this.spawnflags, other }); + }; + + area.tree = { + queryAABB() { + return [subjectEdict, triggerEdict]; + }, + }; + + void withMockRegistry(defaultMockRegistry({ + server: { + time: 4.2, + gameAPI: { time: 0 }, + }, + }), () => { + area.touchLinks(subjectEdict); + }); + + assert.equal(touchedEntities.length, 1); + assert.equal(touchedEntities[0].spawnflags, 1234); + assert.equal(touchedEntities[0].other, subjectEntity); + }); }); void describe('BSP29Loader', () => { diff --git a/test/physics/server-movement.test.mjs b/test/physics/server-movement.test.mjs index 96ba81b8..323a17ac 100644 --- a/test/physics/server-movement.test.mjs +++ b/test/physics/server-movement.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import Vector from '../../source/shared/Vector.ts'; import { content, flags, solid } from '../../source/shared/Defs.ts'; -import { ServerMovement } from '../../source/engine/server/physics/ServerMovement.mjs'; +import { ServerMovement } from '../../source/engine/server/physics/ServerMovement.ts'; import { assertNear, diff --git a/test/physics/server-physics.test.mjs b/test/physics/server-physics.test.mjs index f773d705..d0480736 100644 --- a/test/physics/server-physics.test.mjs +++ b/test/physics/server-physics.test.mjs @@ -4,10 +4,10 @@ import assert from 'node:assert/strict'; import Vector from '../../source/shared/Vector.ts'; import { content, flags, gameCapabilities, moveType, moveTypes, solid } from '../../source/shared/Defs.ts'; import { eventBus, registry } from '../../source/engine/registry.mjs'; -import { ServerArea } from '../../source/engine/server/physics/ServerArea.mjs'; -import { ServerCollision } from '../../source/engine/server/physics/ServerCollision.mjs'; -import { ServerPhysics } from '../../source/engine/server/physics/ServerPhysics.mjs'; -import { BlockedFlags, MAX_BUMP_COUNT } from '../../source/engine/server/physics/Defs.mjs'; +import { ServerArea } from '../../source/engine/server/physics/ServerArea.ts'; +import { ServerCollision } from '../../source/engine/server/physics/ServerCollision.ts'; +import { ServerPhysics } from '../../source/engine/server/physics/ServerPhysics.ts'; +import { BlockedFlags, MAX_BUMP_COUNT } from '../../source/engine/server/physics/Defs.ts'; import { assertNear, @@ -20,9 +20,9 @@ import { withMockServerPhysics, } from './fixtures.mjs'; -describe('ServerPhysics', () => { - describe('checkVelocity', () => { - test('clears NaNs and clamps to maxvelocity', () => { +void describe('ServerPhysics', () => { + void describe('checkVelocity', () => { + void test('clears NaNs and clamps to maxvelocity', () => { const serverPhysics = new ServerPhysics(); const prints = []; const entity = createMockEntity({ @@ -34,7 +34,7 @@ describe('ServerPhysics', () => { entity.classname = 'test_entity'; const edict = createMockEdict(entity); - withMockRegistry({ + void withMockRegistry({ ...defaultMockRegistry({ maxvelocity: { value: 2000 } }), Con: { Print(message) { @@ -54,8 +54,8 @@ describe('ServerPhysics', () => { }); }); - describe('pushEntity', () => { - test('uses MOVE_MISSILE and preserves origin on allsolid', () => { + void describe('pushEntity', () => { + void test('uses MOVE_MISSILE and preserves origin on allsolid', () => { const serverPhysics = new ServerPhysics(); const linkCalls = []; const moveCalls = []; @@ -68,7 +68,7 @@ describe('ServerPhysics', () => { }); const edict = createMockEdict(entity); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area: { linkEdict(linkedEdict) { linkCalls.push(linkedEdict); @@ -110,7 +110,7 @@ describe('ServerPhysics', () => { assert.equal(linkCalls[0], edict); }); - test('uses MOVE_NOMONSTERS for trigger and non-solid entities', () => { + void test('uses MOVE_NOMONSTERS for trigger and non-solid entities', () => { const serverPhysics = new ServerPhysics(); const moveCalls = []; const touchCalls = []; @@ -134,7 +134,7 @@ describe('ServerPhysics', () => { }; const edict = createMockEdict(entity); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area: { linkEdict() {}, }, @@ -172,8 +172,8 @@ describe('ServerPhysics', () => { }); }); - describe('flyMove', () => { - test('clips against a wall and records steptrace', () => { + void describe('flyMove', () => { + void test('clips against a wall and records steptrace', () => { const serverPhysics = new ServerPhysics(); const moveCalls = []; const impacts = []; @@ -187,7 +187,7 @@ describe('ServerPhysics', () => { }); const edict = createMockEdict(entity); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ collision: { move(start, mins, maxs, end, type, passedict) { moveCalls.push({ start: start.copy(), end: end.copy(), type, passedict }); @@ -222,7 +222,7 @@ describe('ServerPhysics', () => { assert.deepEqual([...impacts[0].pushVector], [10, 0, 0]); }); - test('stops in a two-plane crease', () => { + void test('stops in a two-plane crease', () => { const serverPhysics = new ServerPhysics(); let moveCallCount = 0; const blockerA = createMockEdict(createMockEntity({ solidType: solid.SOLID_BBOX })); @@ -236,7 +236,7 @@ describe('ServerPhysics', () => { }); const edict = createMockEdict(entity); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ collision: { move() { moveCallCount += 1; @@ -279,7 +279,7 @@ describe('ServerPhysics', () => { assert.deepEqual([...edict.entity.velocity], [0, 0, 0]); }); - test('dead-stops when clipped by three non-coplanar planes', () => { + void test('dead-stops when clipped by three non-coplanar planes', () => { const serverPhysics = new ServerPhysics(); let moveCallCount = 0; const blockerA = createMockEdict(createMockEntity({ solidType: solid.SOLID_BBOX })); @@ -294,7 +294,7 @@ describe('ServerPhysics', () => { }); const edict = createMockEdict(entity); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ collision: { move() { moveCallCount += 1; @@ -348,7 +348,7 @@ describe('ServerPhysics', () => { assert.deepEqual([...edict.entity.velocity], [0, 0, 0]); }); - test('keeps state finite when a degenerate wall normal repeats', () => { + void test('keeps state finite when a degenerate wall normal repeats', () => { const serverPhysics = new ServerPhysics(); let moveCallCount = 0; const impacts = []; @@ -362,7 +362,7 @@ describe('ServerPhysics', () => { }); const edict = createMockEdict(entity); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ collision: { move() { moveCallCount += 1; @@ -401,8 +401,8 @@ describe('ServerPhysics', () => { }); }); - describe('checkAllEnts', () => { - test('skips static entities and reports invalid dynamic positions', () => { + void describe('checkAllEnts', () => { + void test('skips static entities and reports invalid dynamic positions', () => { const serverPhysics = new ServerPhysics(); const prints = []; const tested = []; @@ -417,7 +417,7 @@ describe('ServerPhysics', () => { const walkEdict = createMockEdict(walkEntity); walkEdict.num = 5; - withMockRegistry({ + void withMockRegistry({ ...defaultMockRegistry({ collision: { testEntityPosition(edict) { @@ -445,8 +445,8 @@ describe('ServerPhysics', () => { }); }); - describe('runThink', () => { - test('returns false when the entity frees itself during think', () => { + void describe('runThink', () => { + void test('returns false when the entity frees itself during think', () => { const serverPhysics = new ServerPhysics(); let freed = false; let thinkCalls = 0; @@ -459,7 +459,7 @@ describe('ServerPhysics', () => { const edict = createMockEdict(entity); edict.isFree = () => freed; - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ server: { time: 1.0, gameAPI: { time: 0 }, @@ -475,7 +475,7 @@ describe('ServerPhysics', () => { assert.equal(entity.nextthink, 0.0); }); - test('executes multiple thinks that become due within one frame', () => { + void test('executes multiple thinks that become due within one frame', () => { const serverPhysics = new ServerPhysics(); const thinkTimes = []; const entity = createMockEntity(); @@ -486,7 +486,7 @@ describe('ServerPhysics', () => { }; const edict = createMockEdict(entity); - withMockRegistry({ + void withMockRegistry({ ...defaultMockRegistry({ server: { time: 1.0, @@ -506,8 +506,8 @@ describe('ServerPhysics', () => { }); }); - describe('pushMove', () => { - test('carries a grounded rider upward without blocked()', () => { + void describe('pushMove', () => { + void test('carries a grounded rider upward without blocked()', () => { withMockServerPhysics(({ serverPhysics, pusherEdict, riderEdict, moveCalls, testCalls, blockedCalls }) => { serverPhysics.pushMove(pusherEdict, 0.1); @@ -522,7 +522,7 @@ describe('ServerPhysics', () => { }); }); - test('rolls back and calls blocked() when rider remains stuck', () => { + void test('rolls back and calls blocked() when rider remains stuck', () => { withMockServerPhysics(({ serverPhysics, pusherEdict, riderEdict, blockedCalls }) => { let testCount = 0; registry.SV.collision.testEntityPosition = (edict) => { @@ -542,7 +542,7 @@ describe('ServerPhysics', () => { }); }); - test('restores earlier riders when a later rider blocks the push', () => { + void test('restores earlier riders when a later rider blocks the push', () => { const linkCalls = []; const blockedCalls = []; @@ -584,7 +584,7 @@ describe('ServerPhysics', () => { const riderBEdict = createMockEdict(riderBEntity); riderBEdict.num = 3; - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ maxvelocity: { value: 2000 }, area: { linkEdict(edict) { @@ -627,7 +627,7 @@ describe('ServerPhysics', () => { assert.ok(linkCalls.length >= 5); }); - test('collapses trigger bounds instead of rolling back the pusher', () => { + void test('collapses trigger bounds instead of rolling back the pusher', () => { const blockedCalls = []; const pusherEntity = createMockEntity({ @@ -657,7 +657,7 @@ describe('ServerPhysics', () => { const triggerEdict = createMockEdict(triggerEntity); triggerEdict.num = 2; - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ maxvelocity: { value: 2000 }, area: { linkEdict(edict) { @@ -698,7 +698,7 @@ describe('ServerPhysics', () => { assert.equal(pusherEdict.entity.ltime, 0.1); }); - test('rotates grounded riders around the pusher yaw axis', () => { + void test('rotates grounded riders around the pusher yaw axis', () => { withMockServerPhysics(({ serverPhysics, pusherEdict, riderEdict, moveCalls, testCalls, blockedCalls }) => { pusherEdict.entity.velocity.clear(); pusherEdict.entity.avelocity = new Vector(0, 900, 0); @@ -727,7 +727,7 @@ describe('ServerPhysics', () => { }); }); - test('ignores rider overlap that only collides with the current pusher after the move', () => { + void test('ignores rider overlap that only collides with the current pusher after the move', () => { const testCalls = []; const blockedCalls = []; @@ -762,7 +762,7 @@ describe('ServerPhysics', () => { let testPositionCall = 0; - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area: { linkEdict() {}, }, @@ -812,7 +812,7 @@ describe('ServerPhysics', () => { assert.deepEqual([...riderEdict.entity.origin], [0, 0, 50]); }); - test('keeps non-rider overlaps with the current pusher blocking the move', () => { + void test('keeps non-rider overlaps with the current pusher blocking the move', () => { const testCalls = []; const blockedCalls = []; @@ -843,7 +843,7 @@ describe('ServerPhysics', () => { const blockerEdict = createMockEdict(blockerEntity); blockerEdict.num = 2; - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area: { linkEdict() {}, }, @@ -883,7 +883,7 @@ describe('ServerPhysics', () => { assert.deepEqual([...blockerEdict.entity.origin], [0, 0, 0]); }); - test('combines translation with non-yaw rotation when carrying riders', () => { + void test('combines translation with non-yaw rotation when carrying riders', () => { const transformPointToLocal = (point, origin, basis) => { const delta = point.copy().subtract(origin); const forward = new Vector(basis[0], basis[1], basis[2]); @@ -945,7 +945,7 @@ describe('ServerPhysics', () => { }); }); - test('does not call blocked() for a rider resting within sub-epsilon top contact on a BSP pusher', () => { + void test('does not call blocked() for a rider resting within sub-epsilon top contact on a BSP pusher', () => { const blockedCalls = []; const pusherModel = createBoxBrushModel({ halfExtents: [64, 64, 16], name: '*plat' }); const worldModel = createBrushWorldModel({ center: [4096, 0, 0], halfExtents: [16, 16, 16] }); @@ -999,7 +999,7 @@ describe('ServerPhysics', () => { const riderEdict = createMockEdict(riderEntity); riderEdict.num = 2; - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ maxvelocity: { value: 2000 }, area, collision, @@ -1037,8 +1037,8 @@ describe('ServerPhysics', () => { }); }); - describe('physicsPusher', () => { - test('limits movement to nextthink and then runs think', () => { + void describe('physicsPusher', () => { + void test('limits movement to nextthink and then runs think', () => { const serverPhysics = new ServerPhysics(); const moveTimes = []; let observedGameTime = -1; @@ -1054,7 +1054,7 @@ describe('ServerPhysics', () => { }; const edict = createMockEdict(entity); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ server: { time: 7.0, gameAPI: { time: 0 }, @@ -1078,7 +1078,7 @@ describe('ServerPhysics', () => { assert.equal(observedGameTime, 7.0); }); - test('keeps think deferred when nextthink is beyond this frame', () => { + void test('keeps think deferred when nextthink is beyond this frame', () => { const serverPhysics = new ServerPhysics(); const moveTimes = []; let observedGameTime = -1; @@ -1094,7 +1094,7 @@ describe('ServerPhysics', () => { }; const edict = createMockEdict(entity); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ server: { time: 8.0, gameAPI: { time: 0 }, @@ -1118,7 +1118,7 @@ describe('ServerPhysics', () => { assert.equal(observedGameTime, 0); }); - test('moves pushers for a full frame when no think is scheduled', () => { + void test('moves pushers for a full frame when no think is scheduled', () => { const serverPhysics = new ServerPhysics(); const moveTimes = []; let thinkCalls = 0; @@ -1134,7 +1134,7 @@ describe('ServerPhysics', () => { }; const edict = createMockEdict(entity); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ Host: { frametime: 0.1 }, server: { time: 9.0, @@ -1159,8 +1159,8 @@ describe('ServerPhysics', () => { }); }); - describe('checkStuck', () => { - test('restores oldorigin when the saved position is clear', () => { + void describe('checkStuck', () => { + void test('restores oldorigin when the saved position is clear', () => { const serverPhysics = new ServerPhysics(); const prints = []; const linkCalls = []; @@ -1173,7 +1173,7 @@ describe('ServerPhysics', () => { entity.oldorigin = new Vector(1, 2, 3); const edict = createMockEdict(entity); - withMockRegistry({ + void withMockRegistry({ ...defaultMockRegistry({ area: { linkEdict(linkedEdict, touchTriggers) { @@ -1206,7 +1206,7 @@ describe('ServerPhysics', () => { }); // checkStuck tries: 1 (current pos) + 1 (oldorigin) + 18 z-levels * 3 x * 3 y = 164 - test('reports failure after exhausting all nudges', () => { + void test('reports failure after exhausting all nudges', () => { const serverPhysics = new ServerPhysics(); const prints = []; const linkCalls = []; @@ -1219,7 +1219,7 @@ describe('ServerPhysics', () => { entity.oldorigin = new Vector(1, 2, 3); const edict = createMockEdict(entity); - withMockRegistry({ + void withMockRegistry({ ...defaultMockRegistry({ area: { linkEdict(linkedEdict, touchTriggers) { @@ -1250,8 +1250,8 @@ describe('ServerPhysics', () => { }); }); - describe('checkWater', () => { - test('leaves entities dry when feet probe is not water', () => { + void describe('checkWater', () => { + void test('leaves entities dry when feet probe is not water', () => { const serverPhysics = new ServerPhysics(); const probes = []; const entity = createMockEntity({ @@ -1262,7 +1262,7 @@ describe('ServerPhysics', () => { entity.view_ofs = new Vector(0, 0, 22); const edict = createMockEdict(entity); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ collision: { pointContents(point) { probes.push(point.copy()); @@ -1279,7 +1279,7 @@ describe('ServerPhysics', () => { assert.deepEqual([...probes[0]], [10, 20, 7]); }); - test('distinguishes feet waist and head submersion', () => { + void test('distinguishes feet waist and head submersion', () => { const serverPhysics = new ServerPhysics(); const feetEntity = createMockEntity({ origin: new Vector(0, 0, 40), @@ -1303,7 +1303,7 @@ describe('ServerPhysics', () => { const runCase = (entity, contents) => { let probeIndex = 0; - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ collision: { pointContents() { const result = contents[probeIndex]; @@ -1321,7 +1321,7 @@ describe('ServerPhysics', () => { const headResult = (() => { let result; - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ collision: { pointContents() { return content.CONTENT_WATER; @@ -1344,8 +1344,8 @@ describe('ServerPhysics', () => { }); }); - describe('checkWaterTransition', () => { - test('plays a splash and marks waist-deep water when entering from air', () => { + void describe('checkWaterTransition', () => { + void test('plays a splash and marks waist-deep water when entering from air', () => { const serverPhysics = new ServerPhysics(); const startSoundCalls = []; const entity = createMockEntity({ @@ -1355,7 +1355,7 @@ describe('ServerPhysics', () => { entity.waterlevel = 0; const edict = createMockEdict(entity); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ collision: { pointContents() { return content.CONTENT_WATER; @@ -1377,7 +1377,7 @@ describe('ServerPhysics', () => { assert.equal(entity.waterlevel, 2); }); - test('plays a splash and clears watertype when leaving water', () => { + void test('plays a splash and clears watertype when leaving water', () => { const serverPhysics = new ServerPhysics(); const startSoundCalls = []; const entity = createMockEntity({ @@ -1387,7 +1387,7 @@ describe('ServerPhysics', () => { entity.waterlevel = 2; const edict = createMockEdict(entity); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ collision: { pointContents() { return content.CONTENT_EMPTY; @@ -1410,8 +1410,8 @@ describe('ServerPhysics', () => { }); }); - describe('wallFriction', () => { - test('damps tangential speed when the player is steering into a wall', () => { + void describe('wallFriction', () => { + void test('damps tangential speed when the player is steering into a wall', () => { const serverPhysics = new ServerPhysics(); const entity = createMockEntity({ velocity: new Vector(10, 4, 3), @@ -1428,8 +1428,8 @@ describe('ServerPhysics', () => { }); }); - describe('addGravity / addBuoyancy', () => { - test('accumulate using entity gravity and frametime', () => { + void describe('addGravity / addBuoyancy', () => { + void test('accumulate using entity gravity and frametime', () => { const serverPhysics = new ServerPhysics(); const entity = createMockEntity({ velocity: new Vector(0, 0, 10), @@ -1437,7 +1437,7 @@ describe('ServerPhysics', () => { entity.gravity = 0.5; const edict = createMockEdict(entity); - withMockRegistry({ + void withMockRegistry({ ...defaultMockRegistry({ gravity: { value: 800 } }), Host: { frametime: 0.25 }, }, () => { @@ -1449,8 +1449,8 @@ describe('ServerPhysics', () => { }); }); - describe('clipVelocity', () => { - test('zeroes tiny residuals after clipping against an angled plane', () => { + void describe('clipVelocity', () => { + void test('zeroes tiny residuals after clipping against an angled plane', () => { const serverPhysics = new ServerPhysics(); const out = new Vector(); @@ -1467,8 +1467,8 @@ describe('ServerPhysics', () => { }); }); - describe('physicsToss', () => { - test('keeps a bounce entity moving after a hard floor impact', () => { + void describe('physicsToss', () => { + void test('keeps a bounce entity moving after a hard floor impact', () => { const serverPhysics = new ServerPhysics(); const entity = createMockEntity({ origin: new Vector(0, 0, 64), @@ -1488,7 +1488,7 @@ describe('ServerPhysics', () => { const floorEdict = createMockEdict(floorEntity); const edict = createMockEdict(entity); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ gravity: { value: 800 }, maxvelocity: { value: 2000 }, area: { @@ -1527,7 +1527,7 @@ describe('ServerPhysics', () => { assert.deepEqual([...entity.angles], [0, 0, 9]); }); - test('settles non-bounce tosses on walkable ground', () => { + void test('settles non-bounce tosses on walkable ground', () => { const serverPhysics = new ServerPhysics(); const entity = createMockEntity({ origin: new Vector(0, 0, 64), @@ -1547,7 +1547,7 @@ describe('ServerPhysics', () => { const floorEdict = createMockEdict(floorEntity); const edict = createMockEdict(entity); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ gravity: { value: 800 }, maxvelocity: { value: 2000 }, area: { @@ -1587,8 +1587,8 @@ describe('ServerPhysics', () => { }); }); - describe('physicsStep', () => { - test('applies airborne step movement, links, and plays the landing sound', () => { + void describe('physicsStep', () => { + void test('applies airborne step movement, links, and plays the landing sound', () => { const serverPhysics = new ServerPhysics(); const soundCalls = []; const linkCalls = []; @@ -1601,7 +1601,7 @@ describe('ServerPhysics', () => { }); const edict = createMockEdict(entity); - withMockRegistry({ + void withMockRegistry({ ...defaultMockRegistry({ gravity: { value: 800 }, area: { @@ -1647,7 +1647,7 @@ describe('ServerPhysics', () => { assert.equal(soundCalls[0][2], 'demon/dland2.wav'); }); - test('still runs think and water transition while already grounded', () => { + void test('still runs think and water transition while already grounded', () => { const serverPhysics = new ServerPhysics(); const sequence = []; const entity = createMockEntity({ @@ -1657,7 +1657,7 @@ describe('ServerPhysics', () => { }); const edict = createMockEdict(entity); - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ area: { linkEdict() { sequence.push('linkEdict'); @@ -1694,8 +1694,8 @@ describe('ServerPhysics', () => { }); }); - describe('physics', () => { - test('applies gravity and toss movement for one frame', () => { + void describe('physics', () => { + void test('applies gravity and toss movement for one frame', () => { const linkCalls = []; const moveCalls = []; let startFrameCount = 0; @@ -1719,7 +1719,7 @@ describe('ServerPhysics', () => { const tossEdict = createMockEdict(tossEntity); tossEdict.num = 1; - withMockRegistry(defaultMockRegistry({ + void withMockRegistry(defaultMockRegistry({ gravity: { value: 800 }, maxvelocity: { value: 2000 }, area: { @@ -1775,5 +1775,52 @@ describe('ServerPhysics', () => { assert.equal(linkCalls.length, 1); }); }); + + void test('does not treat reserved client slots as attached clients for non-client edicts', () => { + let startFrameCount = 0; + let pusherCalls = 0; + const worldEdict = createMockEdict(createMockEntity({ movetype: moveType.MOVETYPE_NONE })); + const doorEdict = createMockEdict(createMockEntity({ + movetype: moveType.MOVETYPE_PUSH, + solidType: solid.SOLID_BSP, + })); + + doorEdict.num = 16; + doorEdict.isClient = () => false; + doorEdict.getClient = () => ({ state: 0 }); + + void withMockRegistry(defaultMockRegistry({ + area: { + linkEdict() {}, + }, + clientPhysics: { + physicsClient() { + throw new Error('non-client edict should not run through client physics just because a reserved slot exists'); + }, + }, + server: { + time: 0, + num_edicts: 2, + edicts: [worldEdict, doorEdict], + gameAPI: { + time: 0, + force_retouch: 0, + startFrame() { + startFrameCount += 1; + }, + }, + }, + }), () => { + const serverPhysics = new ServerPhysics(); + serverPhysics.physicsPusher = (edict) => { + pusherCalls += 1; + assert.equal(edict, doorEdict); + }; + serverPhysics.physics(); + }); + + assert.equal(startFrameCount, 1); + assert.equal(pusherCalls, 1); + }); }); }); From 7c47db3b6c04dd0c631cb82b561aa8dfe1b0384e Mon Sep 17 00:00:00 2001 From: Christian R Date: Fri, 3 Apr 2026 12:42:13 +0300 Subject: [PATCH 31/67] TS: added missing JSDocs --- source/engine/common/model/ModelLoader.ts | 5 +++ .../common/model/ModelLoaderRegistry.ts | 2 + .../engine/server/physics/ServerCollision.ts | 39 +++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/source/engine/common/model/ModelLoader.ts b/source/engine/common/model/ModelLoader.ts index 9029d7f1..2d5ac38a 100644 --- a/source/engine/common/model/ModelLoader.ts +++ b/source/engine/common/model/ModelLoader.ts @@ -10,16 +10,19 @@ export abstract class ModelLoader { /** * Returns the magic numbers that identify this format. * Magic numbers are read from the first four bytes of the file. + * @returns The magic numbers recognized by this loader. */ abstract getMagicNumbers(): number[]; /** * Returns the file extensions supported by this loader. + * @returns The supported file extensions. */ abstract getExtensions(): string[]; /** * Returns a human-readable loader name. + * @returns The loader display name. */ abstract getName(): string; @@ -28,6 +31,7 @@ export abstract class ModelLoader { * * The default implementation requires both a matching extension and a * matching magic number. + * @returns True when the loader can handle the supplied file. */ canLoad(buffer: ArrayBuffer, filename: string): boolean { const view = new DataView(buffer); @@ -39,6 +43,7 @@ export abstract class ModelLoader { /** * Loads a model from the supplied file buffer. + * @returns A promise resolving to the loaded model. */ abstract load(buffer: ArrayBuffer, name: string): Promise; } diff --git a/source/engine/common/model/ModelLoaderRegistry.ts b/source/engine/common/model/ModelLoaderRegistry.ts index c47f98fb..29d8dfd9 100644 --- a/source/engine/common/model/ModelLoaderRegistry.ts +++ b/source/engine/common/model/ModelLoaderRegistry.ts @@ -20,6 +20,7 @@ export class ModelLoaderRegistry { /** * Finds a loader that can handle the given file. + * @returns The first matching loader, or `null` when none can load the file. */ findLoader(buffer: ArrayBuffer, filename: string): ModelLoader | null { for (const loader of this.loaders) { @@ -33,6 +34,7 @@ export class ModelLoaderRegistry { /** * Loads a model using the first compatible registered loader. + * @returns A promise resolving to the loaded model. */ async load(buffer: ArrayBuffer, name: string): Promise { const loader = this.findLoader(buffer, name); diff --git a/source/engine/server/physics/ServerCollision.ts b/source/engine/server/physics/ServerCollision.ts index 5023da89..5c5b9885 100644 --- a/source/engine/server/physics/ServerCollision.ts +++ b/source/engine/server/physics/ServerCollision.ts @@ -63,6 +63,7 @@ export class ServerCollision { /** * Resolve a collision model by model index from either the active server or * the client precache populated by server signon data. + * @returns The resolved collision model, if any. */ _getModelByIndex(modelIndex: number): CollisionModel { return this._modelSource.getModelByIndex(modelIndex); @@ -70,6 +71,7 @@ export class ServerCollision { /** * Resolve the model used by an entity for collision. + * @returns The collision model associated with the entity. */ _getEntityModel(ent: ServerEdict): CollisionModel { if (ent === SV.server?.edicts?.[0]) { @@ -81,6 +83,7 @@ export class ServerCollision { /** * Resolve the collision state used by an entity during tracing. + * @returns The resolved collision state, or `null` when the entity cannot be traced. */ _getEntityCollisionState(ent: ServerEdict): CollisionState | null { const entity = ent.entity!; @@ -107,6 +110,7 @@ export class ServerCollision { /** * Build a hull fallback state for callers that want a guaranteed collision mode. + * @returns The legacy hull collision state for the entity. */ _getHullFallbackState(ent: ServerEdict): HullCollisionState { return new HullCollisionState(ent); @@ -115,6 +119,7 @@ export class ServerCollision { /** * Resolve the active static-world model for traces that can run on either a * local server or a pure client connection. + * @returns The active world entity and world model. */ _getStaticWorldSource(): StaticWorldSource { return { @@ -125,6 +130,7 @@ export class ServerCollision { /** * Convert a shared brush trace result into the server collision trace shape. + * @returns The server collision trace derived from the shared brush trace. */ _toServerTrace(brushTrace: SharedTrace, ent: ServerEdict | null): CollisionTrace { if (ent !== null) { @@ -149,6 +155,7 @@ export class ServerCollision { /** * Returns true when the provided model is a brush model. + * @returns True when the model is a brush model. */ _isBrushModel(model: CollisionModel): model is BrushModel { return model instanceof BrushModel; @@ -156,6 +163,7 @@ export class ServerCollision { /** * Returns true when the provided model is a mesh model. + * @returns True when the model is a mesh model. */ _isMeshModel(model: CollisionModel): model is MeshModel { return model instanceof MeshModel; @@ -163,6 +171,7 @@ export class ServerCollision { /** * Determine whether a trace is point-sized. + * @returns True when the trace extents represent a point trace. */ _isPointTrace(mins: Vector, maxs: Vector): boolean { return mins.isOrigin() && maxs.isOrigin(); @@ -204,6 +213,7 @@ export class ServerCollision { /** * Legacy hull point traces remain the compatibility baseline when brush and * hull BSP paths disagree about the first finite hit. + * @returns True when the legacy hull result should override the brush result. */ _shouldPreferHullPointTrace(brushTrace: CollisionTrace, hullTrace: CollisionTrace): boolean { if (hullTrace.fraction >= 1.0) { @@ -224,6 +234,7 @@ export class ServerCollision { /** * Hull fallback is only safe for point traces whose BSP entity is not rotated, * because the legacy hull path does not apply entity angles. + * @returns True when the legacy hull fallback is safe for this trace. */ _canUseHullPointFallback(state: BrushCollisionState, mins: Vector, maxs: Vector): boolean { if (!this._isPointTrace(mins, maxs)) { @@ -240,6 +251,7 @@ export class ServerCollision { /** * Trace a BSP entity through the shared brush path and cross-check supported * point traces against the legacy hull path to avoid false early hits. + * @returns The preferred collision trace for the BSP entity. */ _clipMoveToBrushStateWithHullFallback( state: BrushCollisionState, @@ -275,6 +287,7 @@ export class ServerCollision { /** * Run the shared brush trace path for a BSP entity, including zero-length * position tests that must avoid swept-trace startsolid artifacts. + * @returns The shared brush trace result. */ _traceBrushModel( model: BrushModel, @@ -320,6 +333,7 @@ export class ServerCollision { /** * Trace against an entity through the shared brush path. + * @returns The collision trace against the brush-backed entity. */ _clipMoveToBrushState(state: BrushCollisionState, start: Vector, mins: Vector, maxs: Vector, end: Vector): CollisionTrace { return this._clipMoveToBrushStateWithHullFallback(state, start, mins, maxs, end); @@ -327,6 +341,7 @@ export class ServerCollision { /** * Trace against an entity through the legacy hull path. + * @returns The collision trace against the legacy hull-backed entity. */ _clipMoveToHullState(state: HullCollisionState, start: Vector, mins: Vector, maxs: Vector, end: Vector): CollisionTrace { const trace = CollisionTrace.hullInitial(end); @@ -352,6 +367,7 @@ export class ServerCollision { /** * Trace a line through a specific legacy hull without exposing clipnode walks * to higher-level callers. + * @returns The collision trace through the legacy hull. */ _traceLegacyHullLine(hull: Hull, start: Vector, end: Vector): CollisionTrace { const trace = CollisionTrace.hullInitial(end); @@ -361,6 +377,7 @@ export class ServerCollision { /** * Determines the contents inside a hull by descending the clipnode tree. + * @returns The contents value at the point within the hull. */ hullPointContents(hull: Hull, num: number, p: Vector): number { return legacyHullPointContents(hull, num, p); @@ -368,6 +385,7 @@ export class ServerCollision { /** * Normalize static-world contents values so current volumes behave like water. + * @returns The normalized contents value. */ _normalizeStaticWorldContents(contents: number): number { if ((contents <= Defs.content.CONTENT_CURRENT_0) && (contents >= Defs.content.CONTENT_CURRENT_DOWN)) { @@ -380,6 +398,7 @@ export class ServerCollision { /** * Sample the contents of a brush-backed world without exposing brush internals * to higher-level callers. + * @returns The contents sampled from the brush-backed world. */ _pointContentsBrushStaticWorld(worldModel: BrushModel, point: Vector): number { if (!BrushTrace.transformedTestPosition( @@ -399,6 +418,7 @@ export class ServerCollision { /** * Sample static-world contents using the best collision backend for the * active map. + * @returns The static-world contents at the sampled point. */ staticWorldContents(point: Vector, hullNum = 0): number { const { worldModel } = this._getStaticWorldSource(); @@ -416,6 +436,7 @@ export class ServerCollision { /** * Compatibility alias for staticWorldContents. + * @returns The static-world contents at the sampled point. */ worldContents(point: Vector, hullNum = 0): number { return this.staticWorldContents(point, hullNum); @@ -423,6 +444,7 @@ export class ServerCollision { /** * Compatibility alias for staticWorldContents. + * @returns The static-world contents at the sampled point. */ pointContents(p: Vector, hullNum = 0): number { return this.staticWorldContents(p, hullNum); @@ -431,6 +453,7 @@ export class ServerCollision { /** * Trace static-world geometry using the best collision backend for the active * map. + * @returns The collision trace through static world geometry. */ traceStaticWorld(start: Vector, mins: Vector, maxs: Vector, end: Vector, hullNum = 0): CollisionTrace { const { worldEntity, worldModel } = this._getStaticWorldSource(); @@ -463,6 +486,7 @@ export class ServerCollision { /** * Compatibility alias for traceStaticWorld. + * @returns The collision trace through static world geometry. */ traceWorld(start: Vector, mins: Vector, maxs: Vector, end: Vector, hullNum = 0): CollisionTrace { return this.traceStaticWorld(start, mins, maxs, end, hullNum); @@ -470,6 +494,7 @@ export class ServerCollision { /** * Trace a point-sized line against the static world. + * @returns The collision trace through the static world line segment. */ traceStaticWorldLine(start: Vector, end: Vector, hullNum = 0): CollisionTrace { return this.traceStaticWorld(start, Vector.origin, Vector.origin, end, hullNum); @@ -477,6 +502,7 @@ export class ServerCollision { /** * Compatibility alias for traceStaticWorldLine. + * @returns The collision trace through the static world line segment. */ traceWorldLine(start: Vector, end: Vector, hullNum = 0): CollisionTrace { return this.traceStaticWorldLine(start, end, hullNum); @@ -484,6 +510,7 @@ export class ServerCollision { /** * Recursively tests a swept hull against the world and aggregates the trace result. + * @returns True when traversal should continue. */ recursiveHullCheck( hull: Hull, @@ -500,6 +527,7 @@ export class ServerCollision { /** * Tests whether a point lies inside a triangle using cross-product winding. + * @returns True when the point lies inside the triangle. */ _pointInTriangle(p: Vector, v0: Vector, v1: Vector, v2: Vector, normal: Vector): boolean { // Small negative tolerance closes micro-gaps between adjacent triangles. @@ -576,6 +604,7 @@ export class ServerCollision { /** * Build a mesh tracing context if the target entity has usable mesh data. + * @returns The mesh trace context, or `null` when mesh tracing is unavailable. */ _createMeshTraceContext(ent: ServerEdict, start: Vector, mins: Vector, maxs: Vector, end: Vector): MeshTraceContext | null { const model = this._getEntityModel(ent); @@ -592,6 +621,7 @@ export class ServerCollision { * (Minkowski sum) and tested for ray intersection. A DIST_EPSILON push-back * keeps the endpoint slightly in front of the surface, preventing the next * frame's trace from starting on or inside the plane. + * @returns The collision trace against the mesh entity. */ clipMoveToMesh(ent: ServerEdict, start: Vector, mins: Vector, maxs: Vector, end: Vector): CollisionTrace { const trace = CollisionTrace.empty(end); @@ -628,6 +658,7 @@ export class ServerCollision { /** * Traces a moving box against a target entity. + * @returns The collision trace against the target entity. */ clipMoveToEntity(ent: ServerEdict, start: Vector, mins: Vector, maxs: Vector, end: Vector): CollisionTrace { const state = this._getEntityCollisionState(ent) ?? this._getHullFallbackState(ent); @@ -637,6 +668,7 @@ export class ServerCollision { /** * Trace against a target entity using its pre-resolved collision state. + * @returns The collision trace against the target entity. */ _clipMoveToEntityWithState(state: CollisionState, start: Vector, mins: Vector, maxs: Vector, end: Vector): CollisionTrace { if (state instanceof MeshCollisionState) { @@ -653,6 +685,7 @@ export class ServerCollision { /** * Select the extents used to trace against a touched entity. * Missiles expand only against monsters. + * @returns The mins and maxs used for the narrow-phase trace. */ _getTouchTraceExtents(clip: MoveClip, touch: ServerEdict): TraceExtents { if ((touch.entity!.flags & Defs.flags.FL_MONSTER) !== 0) { @@ -664,6 +697,7 @@ export class ServerCollision { /** * Determine whether a touched entity should be skipped before narrow-phase tracing. + * @returns True when the touched entity should be ignored. */ _shouldSkipTouch(clip: MoveClip, touch: ServerEdict): boolean { const touchEntity = touch.entity!; @@ -699,6 +733,7 @@ export class ServerCollision { /** * Check whether a touched entity overlaps the clip broadphase box. + * @returns True when the touched entity overlaps the broadphase bounds. */ _touchOverlapsClipBounds(clip: MoveClip, touch: ServerEdict): boolean { const touchEntity = touch.entity!; @@ -715,6 +750,7 @@ export class ServerCollision { /** * Run narrow-phase tracing against a touched entity using the correct extents. + * @returns The collision trace against the touched entity. */ _traceTouch(clip: MoveClip, touch: ServerEdict): CollisionTrace { const touchState = this._getEntityCollisionState(touch) ?? this._getHullFallbackState(touch); @@ -763,6 +799,7 @@ export class ServerCollision { /** * Build the clip context used to trace a move through the world and dynamic entities. + * @returns The initialized move clip context. */ _createMoveClip( start: Vector, @@ -831,6 +868,7 @@ export class ServerCollision { /** * Fully traces a moving box through the world. + * @returns The final collision trace for the move. */ move( start: Vector, @@ -849,6 +887,7 @@ export class ServerCollision { /** * Tests whether an entity is currently stuck in solid geometry. + * @returns True when the entity starts in solid. */ testEntityPosition(ent: ServerEdict): boolean { const entity = ent.entity!; From c80732cbf7219d3378126d481dc3f4bbc5dbad82 Mon Sep 17 00:00:00 2001 From: Christian R Date: Fri, 3 Apr 2026 12:59:08 +0300 Subject: [PATCH 32/67] TS: server/ part 1 --- source/engine/server/Client.mjs | 2 +- source/engine/server/DummyWorker.mjs | 31 +------ source/engine/server/DummyWorker.ts | 31 +++++++ source/engine/server/GameLoader.d.ts | 18 ---- source/engine/server/GameLoader.mjs | 31 +------ source/engine/server/GameLoader.ts | 48 +++++++++++ source/engine/server/NavigationWorker.mjs | 32 +------- source/engine/server/NavigationWorker.ts | 33 ++++++++ source/engine/server/Server.mjs | 69 +--------------- source/engine/server/ServerEntityState.mjs | 70 +--------------- source/engine/server/ServerEntityState.ts | 95 ++++++++++++++++++++++ test/physics/server-entity-state.test.mjs | 41 ++++++++++ 12 files changed, 255 insertions(+), 246 deletions(-) create mode 100644 source/engine/server/DummyWorker.ts delete mode 100644 source/engine/server/GameLoader.d.ts create mode 100644 source/engine/server/GameLoader.ts create mode 100644 source/engine/server/NavigationWorker.ts create mode 100644 source/engine/server/ServerEntityState.ts create mode 100644 test/physics/server-entity-state.test.mjs diff --git a/source/engine/server/Client.mjs b/source/engine/server/Client.mjs index 96d0abf0..b3870b38 100644 --- a/source/engine/server/Client.mjs +++ b/source/engine/server/Client.mjs @@ -5,7 +5,7 @@ import { SzBuffer } from '../network/MSG.ts'; import { QSocket } from '../network/NetworkDrivers.ts'; import * as Protocol from '../network/Protocol.ts'; import { eventBus, registry } from '../registry.mjs'; -import { ServerEntityState } from './Server.mjs'; +import { ServerEntityState } from './ServerEntityState.mjs'; let { SV } = registry; diff --git a/source/engine/server/DummyWorker.mjs b/source/engine/server/DummyWorker.mjs index 4c90b5a0..4d396172 100644 --- a/source/engine/server/DummyWorker.mjs +++ b/source/engine/server/DummyWorker.mjs @@ -1,31 +1,2 @@ -import WorkerFramework from '../common/WorkerFramework.ts'; -import { eventBus, registry } from '../registry.mjs'; +import './DummyWorker.ts'; -await WorkerFramework.Init(); - -const { Con } = registry; - -eventBus.subscribe('worker.test', (message) => { - if (message) { - Con.Print(`Reading back: ${message}\n`); - } - - Con.Print('Dummy Worker reporting back!\n'); -}); - -eventBus.subscribe('worker.busy', (timeInMillis) => { - const start = Date.now(); - - let number = 0; - - while (Date.now() - start < +timeInMillis) { - // Busy wait - number += Math.sqrt(Math.random()); - } - - Con.Print(`Dummy Worker finished busy work of ${timeInMillis} ms, calculated number: ${number}\n`); -}); - -eventBus.subscribe('worker.error', () => { - throw new Error('This is a test error from the Dummy Worker!'); -}); diff --git a/source/engine/server/DummyWorker.ts b/source/engine/server/DummyWorker.ts new file mode 100644 index 00000000..717c1715 --- /dev/null +++ b/source/engine/server/DummyWorker.ts @@ -0,0 +1,31 @@ +import WorkerFramework from '../common/WorkerFramework.ts'; +import { eventBus, registry } from '../registry.mjs'; + +await WorkerFramework.Init(); + +const { Con } = registry; + +eventBus.subscribe('worker.test', (message: string | null) => { + if (message) { + Con.Print(`Reading back: ${message}\n`); + } + + Con.Print('Dummy Worker reporting back!\n'); +}); + +eventBus.subscribe('worker.busy', (timeInMillis: number | string) => { + const start = Date.now(); + const duration = Number(timeInMillis); + let number = 0; + + while (Date.now() - start < duration) { + // Busy wait + number += Math.sqrt(Math.random()); + } + + Con.Print(`Dummy Worker finished busy work of ${duration} ms, calculated number: ${number}\n`); +}); + +eventBus.subscribe('worker.error', () => { + throw new Error('This is a test error from the Dummy Worker!'); +}); diff --git a/source/engine/server/GameLoader.d.ts b/source/engine/server/GameLoader.d.ts deleted file mode 100644 index 18c65642..00000000 --- a/source/engine/server/GameLoader.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ClientGameConstructor, ServerGameConstructor } from '../../shared/GameInterfaces.ts'; -import { gameCapabilities } from '../../shared/Defs.ts'; - -export interface GameModuleIdentification { - name: string; - author: string; - version: [number, number, number]; - capabilities: gameCapabilities[]; -} - -export interface GameModuleInterface { - identification: GameModuleIdentification, - ServerGameAPI: ServerGameConstructor; - ClientGameAPI: ClientGameConstructor; -}; - -export async function loadGameModule(gameDir: string): Promise; - diff --git a/source/engine/server/GameLoader.mjs b/source/engine/server/GameLoader.mjs index 254f0321..92b0ff46 100644 --- a/source/engine/server/GameLoader.mjs +++ b/source/engine/server/GameLoader.mjs @@ -1,31 +1,2 @@ -let gameModules = {}; +export { loadGameModule } from './GameLoader.ts'; -// CR: dear future self, do not try to optimize this import.meta.glob usage further. -try { - // @ts-ignore - import.meta.glob is a Vite-specific feature - gameModules = import.meta.glob('../../game/**/main.mjs'); -// eslint-disable-next-line no-unused-vars -} catch (e) { - // Not in Vite environment -} - -/** - * Loads a game module by directory name - * @param {string} gameDir - The game directory name (e.g., 'id1', etc.) - * @returns {Promise} The loaded game module - */ -export async function loadGameModule(gameDir) { - const modulePath = `../../game/${gameDir}/main.mjs`; - - // Try the pre-bundled modules first (Vite production build) - if (gameModules[modulePath]) { - return await gameModules[modulePath](); - } - - // Fallback to dynamic import - try { - return await import(/* @vite-ignore */ modulePath); - } catch (e) { - throw new Error(`Game module not found: ${gameDir} (${e.message})`); - } -}; diff --git a/source/engine/server/GameLoader.ts b/source/engine/server/GameLoader.ts new file mode 100644 index 00000000..f0bf8774 --- /dev/null +++ b/source/engine/server/GameLoader.ts @@ -0,0 +1,48 @@ +import type { ClientGameConstructor, ServerGameConstructor } from '../../shared/GameInterfaces.ts'; + +import { gameCapabilities } from '../../shared/Defs.ts'; + +export interface GameModuleIdentification { + readonly name: string; + readonly author: string; + readonly version: readonly [number, number, number]; + readonly capabilities: readonly gameCapabilities[]; +} + +export interface GameModuleInterface { + readonly identification: GameModuleIdentification; + readonly ServerGameAPI: ServerGameConstructor; + readonly ClientGameAPI: ClientGameConstructor; +} + +type GameModuleLoader = () => Promise; + +let gameModules: Record = {}; + +// CR: dear future self, do not try to optimize this import.meta.glob usage further. +try { + gameModules = import.meta.glob('../../game/**/main.mjs') as Record; +} catch (_error) { + // Not in Vite environment +} + +/** + * Loads a game module by directory name. + * @returns The loaded game module. + */ +export async function loadGameModule(gameDir: string): Promise { + const modulePath = `../../game/${gameDir}/main.mjs`; + + // Try the pre-bundled modules first (Vite production build) + if (gameModules[modulePath]) { + return await gameModules[modulePath](); + } + + // Fallback to dynamic import + try { + return await import(/* @vite-ignore */ modulePath) as GameModuleInterface; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Game module not found: ${gameDir} (${message})`); + } +} diff --git a/source/engine/server/NavigationWorker.mjs b/source/engine/server/NavigationWorker.mjs index d169d678..667f48ed 100644 --- a/source/engine/server/NavigationWorker.mjs +++ b/source/engine/server/NavigationWorker.mjs @@ -1,33 +1,3 @@ -import WorkerFramework from '../common/WorkerFramework.ts'; -import { eventBus, registry } from '../registry.mjs'; +import './NavigationWorker.ts'; -import { Navigation, NavMeshOutOfDateException } from './Navigation.mjs'; -import Vector from '../../shared/Vector.ts'; - -await WorkerFramework.Init(); - -const { Con } = registry; - -const navigation = new Navigation(); - -eventBus.subscribe('nav.load', async (mapname, checksum) => { - Con.DPrint('Navigation: loading navigation graph...\n'); - - try { - await navigation.load(mapname, checksum); - - Con.DPrint('Navigation: navigation graph loaded on worker thread!\n'); - } catch (e) { - // unusable navmesh, trigger a rebuild - if (e instanceof NavMeshOutOfDateException) { - WorkerFramework.Publish('nav.build'); - } - } -}); - -eventBus.subscribe('nav.path.request', (id, start, end) => { - const path = navigation.findPath(new Vector(...start), new Vector(...end)); - - WorkerFramework.Publish('nav.path.response', id, path); -}); diff --git a/source/engine/server/NavigationWorker.ts b/source/engine/server/NavigationWorker.ts new file mode 100644 index 00000000..623d83ff --- /dev/null +++ b/source/engine/server/NavigationWorker.ts @@ -0,0 +1,33 @@ +import WorkerFramework from '../common/WorkerFramework.ts'; +import { eventBus, registry } from '../registry.mjs'; + +import { Navigation, NavMeshOutOfDateException } from './Navigation.mjs'; +import Vector from '../../shared/Vector.ts'; + +type WorkerVectorLike = ArrayLike; + +await WorkerFramework.Init(); + +const { Con } = registry; +const navigation = new Navigation(); + +eventBus.subscribe('nav.load', async (mapname: string, checksum: number | null) => { + Con.DPrint('Navigation: loading navigation graph...\n'); + + try { + await navigation.load(mapname, checksum); + + Con.DPrint('Navigation: navigation graph loaded on worker thread!\n'); + } catch (error) { + // unusable navmesh, trigger a rebuild + if (error instanceof NavMeshOutOfDateException) { + WorkerFramework.Publish('nav.build'); + } + } +}); + +eventBus.subscribe('nav.path.request', (id: string, start: WorkerVectorLike, end: WorkerVectorLike) => { + const path = navigation.findPath(new Vector(...start), new Vector(...end)); + + WorkerFramework.Publish('nav.path.response', id, path); +}); diff --git a/source/engine/server/Server.mjs b/source/engine/server/Server.mjs index 7bde8bbe..e04e7825 100644 --- a/source/engine/server/Server.mjs +++ b/source/engine/server/Server.mjs @@ -1,6 +1,5 @@ import Cvar from '../common/Cvar.ts'; import { MoveVars, Pmove } from '../common/Pmove.ts'; -import Vector from '../../shared/Vector.ts'; import { SzBuffer } from '../network/MSG.ts'; import * as Protocol from '../network/Protocol.ts'; import * as Def from './../common/Def.ts'; @@ -19,6 +18,7 @@ import { ServerCollision } from './physics/ServerCollision.ts'; import { sharedCollisionModelSource } from '../common/CollisionModelSource.ts'; import { BrushModel } from '../common/Mod.ts'; import { ServerClient } from './Client.mjs'; +export { ServerEntityState } from './ServerEntityState.mjs'; let { COM, Con, Host, Mod, NET, PR } = registry; @@ -38,7 +38,7 @@ eventBus.subscribe('registry.frozen', () => { startsolid: boolean; endpos: any; plane: { - normal: Vector; + normal: import('../../shared/Vector.ts').Vector; dist: number; }; ent: any; @@ -71,71 +71,6 @@ const ALLOWED_CLIENT_COMMANDS = [ 'ban', ]; -export class ServerEntityState { // TODO: extends Protocol.EntityState - constructor(num = null) { - this.num = num; - this.flags = 0; - this.origin = new Vector(Infinity, Infinity, Infinity); - this.angles = new Vector(Infinity, Infinity, Infinity); - this.modelindex = 0; - this.frame = 0; - this.colormap = 0; - this.skin = 0; - this.effects = 0; - this.alpha = 1.0; - this.solid = 0; - this.free = false; - this.classname = null; - this.mins = new Vector(); - this.maxs = new Vector(); - this.velocity = new Vector(0, 0, 0); - this.nextthink = 0; - - /** @type {Record} */ - this.extended = {}; - } - - /** @param {ServerEntityState} other other state to copy */ - set(other) { - this.num = other.num; - this.flags = other.flags; - this.origin.set(other.origin); - this.angles.set(other.angles); - this.velocity.set(other.velocity); - this.modelindex = other.modelindex; - this.frame = other.frame; - this.colormap = other.colormap; - this.skin = other.skin; - this.effects = other.effects; - this.solid = other.solid; - this.free = other.free; - this.classname = other.classname; - this.mins.set(other.mins); - this.maxs.set(other.maxs); - this.nextthink = other.nextthink; - - for (const [key, value] of Object.entries(other.extended)) { - this.extended[key] = value; - } - } - - freeEdict() { - this.free = true; - this.flags = 0; - this.angles.setTo(Infinity, Infinity, Infinity); - this.origin.setTo(Infinity, Infinity, Infinity); - this.velocity.setTo(0, 0, 0); - this.nextthink = 0; - this.modelindex = 0; - this.frame = 0; - this.colormap = 0; - this.skin = 0; - this.effects = 0; - this.solid = 0; - this.classname = null; - } -} - /** * Main server class with all server-related functionality. * All properties and methods are static. diff --git a/source/engine/server/ServerEntityState.mjs b/source/engine/server/ServerEntityState.mjs index 6879543a..5d615b9d 100644 --- a/source/engine/server/ServerEntityState.mjs +++ b/source/engine/server/ServerEntityState.mjs @@ -1,70 +1,2 @@ -import Vector from '../../shared/Vector.ts'; +export { ServerEntityState } from './ServerEntityState.ts'; -/** @typedef {import('../../shared/GameInterfaces').SerializableType} SerializableType */ - -export class ServerEntityState { - constructor(num = null) { - this.num = num; - this.flags = 0; - this.origin = new Vector(Infinity, Infinity, Infinity); - this.angles = new Vector(Infinity, Infinity, Infinity); - this.modelindex = 0; - this.frame = 0; - this.colormap = 0; - this.skin = 0; - this.effects = 0; - this.alpha = 1; - this.solid = 0; - this.free = false; - this.classname = null; - this.mins = new Vector(); - this.maxs = new Vector(); - this.velocity = new Vector(0, 0, 0); - this.nextthink = 0; - - /** @type {Record} */ - this.extended = {}; - } - - /** @param {ServerEntityState} other other state to copy */ - set(other) { - this.num = other.num; - this.flags = other.flags; - this.origin.set(other.origin); - this.angles.set(other.angles); - this.velocity.set(other.velocity); - this.modelindex = other.modelindex; - this.frame = other.frame; - this.colormap = other.colormap; - this.skin = other.skin; - this.effects = other.effects; - this.alpha = other.alpha; - this.solid = other.solid; - this.free = other.free; - this.classname = other.classname; - this.mins.set(other.mins); - this.maxs.set(other.maxs); - this.nextthink = other.nextthink; - - for (const [key, value] of Object.entries(other.extended)) { - this.extended[key] = value; - } - } - - freeEdict() { - this.free = true; - this.flags = 0; - this.angles.setTo(Infinity, Infinity, Infinity); - this.origin.setTo(Infinity, Infinity, Infinity); - this.velocity.setTo(0, 0, 0); - this.nextthink = 0; - this.modelindex = 0; - this.frame = 0; - this.colormap = 0; - this.skin = 0; - this.effects = 0; - this.alpha = 1; - this.solid = 0; - this.classname = null; - } -}; diff --git a/source/engine/server/ServerEntityState.ts b/source/engine/server/ServerEntityState.ts new file mode 100644 index 00000000..a0cf38de --- /dev/null +++ b/source/engine/server/ServerEntityState.ts @@ -0,0 +1,95 @@ +import type { SerializableType } from '../../shared/GameInterfaces.ts'; + +import Vector from '../../shared/Vector.ts'; + +/** + * Stores the per-entity network state used for delta compression. + */ +export class ServerEntityState { + num: number | null; + flags: number; + readonly origin: Vector; + readonly angles: Vector; + modelindex: number; + frame: number; + colormap: number; + skin: number; + effects: number; + alpha: number; + solid: number; + free: boolean; + classname: string | null; + readonly mins: Vector; + readonly maxs: Vector; + readonly velocity: Vector; + nextthink: number; + readonly extended: Record; + + constructor(num: number | null = null) { + this.num = num; + this.flags = 0; + this.origin = new Vector(Infinity, Infinity, Infinity); + this.angles = new Vector(Infinity, Infinity, Infinity); + this.modelindex = 0; + this.frame = 0; + this.colormap = 0; + this.skin = 0; + this.effects = 0; + this.alpha = 1.0; + this.solid = 0; + this.free = false; + this.classname = null; + this.mins = new Vector(); + this.maxs = new Vector(); + this.velocity = new Vector(0, 0, 0); + this.nextthink = 0; + this.extended = {}; + } + + /** + * Copies the full state from another entity snapshot. + */ + set(other: ServerEntityState): void { + this.num = other.num; + this.flags = other.flags; + this.origin.set(other.origin); + this.angles.set(other.angles); + this.velocity.set(other.velocity); + this.modelindex = other.modelindex; + this.frame = other.frame; + this.colormap = other.colormap; + this.skin = other.skin; + this.effects = other.effects; + this.alpha = other.alpha; + this.solid = other.solid; + this.free = other.free; + this.classname = other.classname; + this.mins.set(other.mins); + this.maxs.set(other.maxs); + this.nextthink = other.nextthink; + + for (const [key, value] of Object.entries(other.extended)) { + this.extended[key] = value; + } + } + + /** + * Marks the state as free and restores the default baseline values. + */ + freeEdict(): void { + this.free = true; + this.flags = 0; + this.angles.setTo(Infinity, Infinity, Infinity); + this.origin.setTo(Infinity, Infinity, Infinity); + this.velocity.setTo(0, 0, 0); + this.nextthink = 0; + this.modelindex = 0; + this.frame = 0; + this.colormap = 0; + this.skin = 0; + this.effects = 0; + this.alpha = 1.0; + this.solid = 0; + this.classname = null; + } +} diff --git a/test/physics/server-entity-state.test.mjs b/test/physics/server-entity-state.test.mjs new file mode 100644 index 00000000..2b24ff00 --- /dev/null +++ b/test/physics/server-entity-state.test.mjs @@ -0,0 +1,41 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { ServerEntityState as ReExportedServerEntityState } from '../../source/engine/server/Server.mjs'; +import { ServerEntityState } from '../../source/engine/server/ServerEntityState.mjs'; + +void describe('ServerEntityState', () => { + void test('re-exports the canonical implementation from Server.mjs', () => { + assert.equal(ReExportedServerEntityState, ServerEntityState); + }); + + void test('copies and resets alpha with the rest of the entity state', () => { + const sourceState = new ServerEntityState(12); + sourceState.flags = 4; + sourceState.origin.setTo(1, 2, 3); + sourceState.angles.setTo(4, 5, 6); + sourceState.velocity.setTo(7, 8, 9); + sourceState.alpha = 0.25; + sourceState.nextthink = 0.3; + sourceState.classname = 'func_door'; + sourceState.extended.renderfx = 7; + + const copiedState = new ServerEntityState(); + copiedState.set(sourceState); + + assert.equal(copiedState.alpha, 0.25); + assert.equal(copiedState.nextthink, 0.3); + assert.equal(copiedState.classname, 'func_door'); + assert.deepEqual([...copiedState.origin], [1, 2, 3]); + assert.deepEqual([...copiedState.velocity], [7, 8, 9]); + assert.equal(copiedState.extended.renderfx, 7); + + copiedState.freeEdict(); + + assert.equal(copiedState.free, true); + assert.equal(copiedState.alpha, 1.0); + assert.equal(copiedState.classname, null); + assert.deepEqual([...copiedState.origin], [Infinity, Infinity, Infinity]); + assert.deepEqual([...copiedState.velocity], [0, 0, 0]); + }); +}); From fb21fb36c75ec7b447cf090c076bc272dbbcd37d Mon Sep 17 00:00:00 2001 From: Christian R Date: Fri, 3 Apr 2026 13:39:18 +0300 Subject: [PATCH 33/67] TS: server/ part 2 --- source/engine/common/Host.ts | 2 +- source/engine/server/Client.mjs | 231 +----- source/engine/server/Client.ts | 250 +++++++ source/engine/server/Edict.mjs | 699 +----------------- source/engine/server/Edict.ts | 819 +++++++++++++++++++++ source/engine/server/Server.mjs | 1101 +--------------------------- source/engine/server/Server.ts | 1055 ++++++++++++++++++++++++++ test/physics/server-edict.test.mjs | 28 + test/physics/server.test.mjs | 6 +- 9 files changed, 2161 insertions(+), 2030 deletions(-) create mode 100644 source/engine/server/Client.ts create mode 100644 source/engine/server/Edict.ts create mode 100644 source/engine/server/Server.ts create mode 100644 test/physics/server-edict.test.mjs diff --git a/source/engine/common/Host.ts b/source/engine/common/Host.ts index ff5b7dd1..a77335ad 100644 --- a/source/engine/common/Host.ts +++ b/source/engine/common/Host.ts @@ -733,7 +733,7 @@ export default class Host { Q.secsToTime(NET.time - client.netconnection.connecttime).padEnd(9), client.ping.toFixed(0).padStart(4), Number(0).toFixed(0).padStart(4), // TODO: add loss - ServerClient.STATE.toKey(client.state).padEnd(10), + (ServerClient.STATE[client.state] ?? `unknown (${client.state})`).padEnd(10), client.netconnection.address, ]; diff --git a/source/engine/server/Client.mjs b/source/engine/server/Client.mjs index b3870b38..4c9b7e38 100644 --- a/source/engine/server/Client.mjs +++ b/source/engine/server/Client.mjs @@ -1,230 +1 @@ -import { enumHelpers } from '../../shared/Q.ts'; -import { gameCapabilities } from '../../shared/Defs.ts'; -import Vector from '../../shared/Vector.ts'; -import { SzBuffer } from '../network/MSG.ts'; -import { QSocket } from '../network/NetworkDrivers.ts'; -import * as Protocol from '../network/Protocol.ts'; -import { eventBus, registry } from '../registry.mjs'; -import { ServerEntityState } from './ServerEntityState.mjs'; - -let { SV } = registry; - -eventBus.subscribe('registry.frozen', () => { - SV = registry.SV; -}); - -/** @typedef {import('./Edict.mjs').ServerEdict} ServerEdict */ -/** @typedef {import('../../shared/GameInterfaces').PlayerEntitySpawnParamsDynamic} PlayerEntitySpawnParamsDynamic */ - -export class ServerClient { - static STATE = Object.freeze({ - /** drop client as soon as possible */ - DROPASAP: -1, - /** free client slot, can be reused for a new connection */ - FREE: 0, - /** client is connecting, but not yet fully connected (signon = 1) */ - CONNECTING: 1, - /** has been assigned to a client, but not in game yet (signon = 2) */ - CONNECTED: 2, - /** client is fully in game */ - SPAWNED: 3, - - ...enumHelpers, - }); - - /** - * @param {number} num client number - */ - constructor(num) { - /** @type {number} @see {ServerClient.STATE} */ - this.state = ServerClient.STATE.FREE; - this.num = num; - /** @type {SzBuffer} messages sent after an entity update run */ - this.message = new SzBuffer(16000, 'ServerClient ' + num); - this.message.allowoverflow = true; - /** @type {SzBuffer} messages sent before an entity update run */ - this.expedited_message = new SzBuffer(4000, 'ServerClient expedited ' + num); - this.expedited_message.allowoverflow = true; - this.colors = 0; - this.old_frags = 0; - /** @type {number} last update sent to the client */ - this.last_update = 0; - /** @type {number} last Host.realtime when all ping times have been sent */ - this.last_ping_update = 0; - this.ping_times = new Array(16); - this.num_pings = 0; - /** @type {?QSocket} */ - this.netconnection = null; - - /** @type {number} the SV.server.time when the last command was processed */ - this.local_time = 0.0; - - /** @type {number} SV.server.time read back from the client */ - this.sync_time = 0.0; - - /** spawn parms are carried from level to level */ - this.spawn_parms = null; - - this.cmd = new Protocol.UserCmd(); - this.lastcmd = new Protocol.UserCmd(); - this.frames = []; - - /** - * Queued move commands received since the last physics tick. - * In remote multiplayer the client may send several commands between - * server frames, each is pushed here and drained by physicsClient - * so that every command is simulated with its original msec, - * matching client-side prediction exactly (QW-style). - * @type {Protocol.UserCmd[]} - */ - this.pendingCmds = []; - - /** @type {Map} olds entity states for this player only @private */ - this._entityStates = new Map(); - - this.wishdir = new Vector(); - - /** @type {number} Q2-style player movement flags (PMF bitmask), persisted across frames */ - this.pmFlags = 0; - /** @type {number} Q2-style timing counter for special states (msec/8 units) */ - this.pmTime = 0; - /** @type {number} previous frame button state for edge detection */ - this.pmOldButtons = 0; - /** @type {number} last received move command sequence (0-255, for prediction ack) */ - this.lastMoveSequence = 0; - - // Object.seal(this); - } - - toString() { - return `ServerClient (${this.num}, ${this.netconnection})`; - } - - /** @type {ServerEdict} */ - get edict() { - // clients are mapped to edicts with ids from 1 to maxclients - return SV.server.edicts[this.num + 1]; - } - - get entity() { - return this.edict.entity; - } - - clear() { - this.state = ServerClient.STATE.FREE; - this.netconnection = null; - this.message.clear(); - this.wishdir.clear(); - this.colors = 0; - this.old_frags = 0; - this.last_ping_update = 0.0; - this.num_pings = 0; - this.ping_times.fill(0); - this.cmd.reset(); - this.lastcmd.reset(); - this.pendingCmds.length = 0; - this.last_update = 0.0; - this.sync_time = 0; - this._entityStates = new Map(); - this.pmFlags = 0; - this.pmTime = 0; - this.pmOldButtons = 0; - this.lastMoveSequence = 0; - - if (SV.server.gameCapabilities.includes(gameCapabilities.CAP_SPAWNPARMS_LEGACY)) { - this.spawn_parms = new Array(16); - } else { - this.spawn_parms = null; - } - } - - /** - * Issues a changelevel to the specified map for this client. - * @param {string} mapname map name - */ - changelevel(mapname) { - const reconnect = new SzBuffer(128); - reconnect.writeByte(Protocol.svc.changelevel); - reconnect.writeString(mapname); - this.netconnection.SendMessage(reconnect); - - this._entityStates.clear(); - this.cmd.reset(); - this.lastcmd.reset(); - this.pendingCmds.length = 0; - this.pmFlags = 0; - this.pmTime = 0; - this.pmOldButtons = 0; - this.lastMoveSequence = 0; - } - - /** - * @param {number} num edict Id - * @returns {ServerEntityState} entity state - */ - getEntityState(num) { - const key = num.toString(); - - if (!this._entityStates.has(key)) { - this._entityStates.set(key, new ServerEntityState(num)); - } - - return this._entityStates.get(key); - } - - set name(/** @type {string} */ name) { - this.edict.entity.netname = name; - } - - get name() { - if (this.state !== ServerClient.STATE.CONNECTED && this.state !== ServerClient.STATE.SPAWNED) { - return ''; - } - - console.assert('netname' in this.edict.entity, 'entity needs netname'); - - return this.edict.entity.netname ?? `client #${this.num}`; - } - - get uniqueId() { - return 'N/A'; // TODO - } - - get ping() { - return Math.round((this.ping_times.reduce((sum, elem) => sum + elem) / this.ping_times.length) * 1000) || 0; - } - - saveSpawnparms() { - if (SV.server.gameCapabilities.includes(gameCapabilities.CAP_SPAWNPARMS_DYNAMIC)) { - this.spawn_parms = (this.edict.entity).saveSpawnParameters(); - return; - } - - if (SV.server.gameCapabilities.includes(gameCapabilities.CAP_SPAWNPARMS_LEGACY)) { - SV.server.gameAPI.SetChangeParms(this.edict); - - this.spawn_parms = new Array(16); - - for (let i = 0; i < this.spawn_parms.length; i++) { - this.spawn_parms[i] = SV.server.gameAPI[`parm${i + 1}`]; - } - - return; - } - } - - consolePrint(/** @type {string} */ message) { - this.message.writeByte(Protocol.svc.print); - this.message.writeString(message); - } - - centerPrint(/** @type {string} */ message) { - this.message.writeByte(Protocol.svc.centerprint); - this.message.writeString(message); - } - - sendConsoleCommands(/** @type {string} */ commandline) { - this.message.writeByte(Protocol.svc.stufftext); - this.message.writeString(commandline); - } -}; +export { ServerClient } from './Client.ts'; diff --git a/source/engine/server/Client.ts b/source/engine/server/Client.ts new file mode 100644 index 00000000..73caa94f --- /dev/null +++ b/source/engine/server/Client.ts @@ -0,0 +1,250 @@ +import type { PlayerEntitySpawnParamsDynamic } from '../../shared/GameInterfaces.ts'; +import type { QSocket } from '../network/NetworkDrivers.ts'; +import type { BaseEntity, ServerEdict } from './Edict.mjs'; + +import { gameCapabilities } from '../../shared/Defs.ts'; +import Vector from '../../shared/Vector.ts'; +import { SzBuffer } from '../network/MSG.ts'; +import * as Protocol from '../network/Protocol.ts'; +import { eventBus, getCommonRegistry } from '../registry.mjs'; +import { ServerEntityState } from './ServerEntityState.mjs'; + +interface LegacySpawnParmsGameAPI { + SetChangeParms(clientEdict: ServerEdict): void; + [key: `parm${number}`]: number; +} + +let { SV } = getCommonRegistry(); + +eventBus.subscribe('registry.frozen', () => { + ({ SV } = getCommonRegistry()); +}); + +export enum ServerClientState { + /** drop client as soon as possible */ + DROPASAP = -1, + /** free client slot, can be reused for a new connection */ + FREE = 0, + /** client is connecting, but not yet fully connected (signon = 1) */ + CONNECTING = 1, + /** has been assigned to a client, but not in game yet (signon = 2) */ + CONNECTED = 2, + /** client is fully in game */ + SPAWNED = 3, +} + +type ServerClientSpawnParameters = number[] | ReturnType | null; +type ServerClientEntity = BaseEntity & { netname?: string | null }; +type DynamicSpawnClientEntity = ServerClientEntity & PlayerEntitySpawnParamsDynamic; + +/** + * Runtime state for one connected or connectable server client slot. + */ +export class ServerClient { + static readonly STATE = ServerClientState; + + state: ServerClientState; + readonly num: number; + readonly message: SzBuffer; + readonly expedited_message: SzBuffer; + colors: number; + old_frags: number; + last_update: number; + last_ping_update: number; + readonly ping_times: number[]; + num_pings: number; + netconnection: QSocket | null; + local_time: number; + sync_time: number; + spawn_parms: ServerClientSpawnParameters; + readonly cmd: Protocol.UserCmd; + readonly lastcmd: Protocol.UserCmd; + readonly frames: number[]; + readonly pendingCmds: Protocol.UserCmd[]; + protected _entityStates: Map; + readonly wishdir: Vector; + pmFlags: number; + pmTime: number; + pmOldButtons: number; + lastMoveSequence: number; + + constructor(num: number) { + this.state = ServerClient.STATE.FREE; + this.num = num; + this.message = new SzBuffer(16000, `ServerClient ${num}`); + this.message.allowoverflow = true; + this.expedited_message = new SzBuffer(4000, `ServerClient expedited ${num}`); + this.expedited_message.allowoverflow = true; + this.colors = 0; + this.old_frags = 0; + this.last_update = 0; + this.last_ping_update = 0; + this.ping_times = new Array(16); + this.num_pings = 0; + this.netconnection = null; + this.local_time = 0.0; + this.sync_time = 0.0; + this.spawn_parms = null; + this.cmd = new Protocol.UserCmd(); + this.lastcmd = new Protocol.UserCmd(); + this.frames = []; + this.pendingCmds = []; + this._entityStates = new Map(); + this.wishdir = new Vector(); + this.pmFlags = 0; + this.pmTime = 0; + this.pmOldButtons = 0; + this.lastMoveSequence = 0; + } + + toString(): string { + return `ServerClient (${this.num}, ${this.netconnection})`; + } + + /** + * Returns the player edict assigned to this client slot. + * @returns The client edict. + */ + get edict(): ServerEdict { + return SV.server.edicts[this.num + 1] as ServerEdict; + } + + /** + * Returns the game entity owned by this client slot. + * @returns The linked game entity. + */ + get entity(): ServerClientEntity { + const entity = this.edict.entity; + + console.assert(entity !== null, 'ServerClient.entity requires a linked edict entity'); + + return entity as ServerClientEntity; + } + + clear(): void { + this.state = ServerClient.STATE.FREE; + this.netconnection = null; + this.message.clear(); + this.wishdir.clear(); + this.colors = 0; + this.old_frags = 0; + this.last_ping_update = 0.0; + this.num_pings = 0; + this.ping_times.fill(0); + this.cmd.reset(); + this.lastcmd.reset(); + this.pendingCmds.length = 0; + this.last_update = 0.0; + this.sync_time = 0; + this._entityStates = new Map(); + this.pmFlags = 0; + this.pmTime = 0; + this.pmOldButtons = 0; + this.lastMoveSequence = 0; + + if (SV.server.gameCapabilities.includes(gameCapabilities.CAP_SPAWNPARMS_LEGACY)) { + this.spawn_parms = new Array(16); + } else { + this.spawn_parms = null; + } + } + + /** + * Issues a changelevel to the specified map for this client. + */ + changelevel(mapname: string): void { + const reconnect = new SzBuffer(128); + reconnect.writeByte(Protocol.svc.changelevel); + reconnect.writeString(mapname); + + console.assert(this.netconnection !== null, 'ServerClient.changelevel requires a live connection'); + this.netconnection?.SendMessage(reconnect); + + this._entityStates.clear(); + this.cmd.reset(); + this.lastcmd.reset(); + this.pendingCmds.length = 0; + this.pmFlags = 0; + this.pmTime = 0; + this.pmOldButtons = 0; + this.lastMoveSequence = 0; + } + + /** + * Returns the per-entity delta baseline for this client. + * @returns The cached state for the requested entity. + */ + getEntityState(num: number): ServerEntityState { + const key = num.toString(); + + if (!this._entityStates.has(key)) { + this._entityStates.set(key, new ServerEntityState(num)); + } + + return this._entityStates.get(key) as ServerEntityState; + } + + set name(name: string) { + this.entity.netname = name; + } + + get name(): string { + if (this.state !== ServerClient.STATE.CONNECTED && this.state !== ServerClient.STATE.SPAWNED) { + return ''; + } + + console.assert('netname' in this.entity, 'entity needs netname'); + + return this.entity.netname ?? `client #${this.num}`; + } + + get uniqueId(): string { + return 'N/A'; + } + + get ping(): number { + return Math.round((this.ping_times.reduce((sum, elem) => sum + elem) / this.ping_times.length) * 1000) || 0; + } + + saveSpawnparms(): void { + if (SV.server.gameCapabilities.includes(gameCapabilities.CAP_SPAWNPARMS_DYNAMIC)) { + this.spawn_parms = (this.entity as DynamicSpawnClientEntity).saveSpawnParameters(); + return; + } + + if (SV.server.gameCapabilities.includes(gameCapabilities.CAP_SPAWNPARMS_LEGACY)) { + const gameAPI = SV.server.gameAPI as unknown as LegacySpawnParmsGameAPI; + gameAPI.SetChangeParms(this.edict); + + this.spawn_parms = new Array(16); + + for (let i = 0; i < this.spawn_parms.length; i++) { + this.spawn_parms[i] = gameAPI[`parm${i + 1}`]; + } + } + } + + /** + * Queues a console print for this client. + */ + consolePrint(message: string): void { + this.message.writeByte(Protocol.svc.print); + this.message.writeString(message); + } + + /** + * Queues a centerprint for this client. + */ + centerPrint(message: string): void { + this.message.writeByte(Protocol.svc.centerprint); + this.message.writeString(message); + } + + /** + * Queues server-issued console commands for this client. + */ + sendConsoleCommands(commandline: string): void { + this.message.writeByte(Protocol.svc.stufftext); + this.message.writeString(commandline); + } +} diff --git a/source/engine/server/Edict.mjs b/source/engine/server/Edict.mjs index 23db94c3..42acf15f 100644 --- a/source/engine/server/Edict.mjs +++ b/source/engine/server/Edict.mjs @@ -1,697 +1,4 @@ -import Vector from '../../shared/Vector.ts'; -import { SzBuffer, registerSerializableType } from '../network/MSG.ts'; -import * as Protocol from '../network/Protocol.ts'; -import * as Def from '../common/Def.ts'; -import * as Defs from '../../shared/Defs.ts'; -import { eventBus, registry } from '../registry.mjs'; -import Q from '../../shared/Q.ts'; -import { ConsoleCommand } from '../common/Cmd.ts'; -import { ClientEdict } from '../client/ClientEntities.mjs'; -import { OctreeNode } from '../../shared/Octree.ts'; -import { Visibility } from '../common/model/BSP.ts'; +/** @typedef {import('./Edict.ts').BaseEntity} BaseEntity */ +/** @typedef {import('./Edict.ts').WorldspawnEntity} WorldspawnEntity */ -/** @typedef {import('../../game/id1/entity/BaseEntity.mjs').default} BaseEntity */ -/** @typedef {import('../../game/id1/entity/Worldspawn.mjs').WorldspawnEntity} WorldspawnEntity */ - -let { CL, COM, Con, Host, NET, PR, SV } = registry; - -eventBus.subscribe('registry.frozen', () => { - ({ CL, COM, Con, Host, NET, PR, SV } = registry); -}); - -/** @typedef {import('./Client.mjs').ServerClient} ServerClient */ - -export class ED { - /** @param {ServerEdict} ed edict */ - static ClearEdict(ed) { // TODO: move to SV.Edict - if (ed.entity) { - ed.entity.free(); - ed.entity = null; - } - ed.clear(); - ed.free = false; - } - - static Alloc() { // TODO: move to SV? - let i; - /** @type {ServerEdict} */ - let e; - for (i = SV.svs.maxclients + 1; i < SV.server.num_edicts; i++) { - e = SV.server.edicts[i]; - if ((e.free === true) && ((e.freetime < 2.0) || ((SV.server.time - e.freetime) > 0.5))) { - ED.ClearEdict(e); - return e; - } - } - if ((i % Def.limits.edicts) === 0) { - // allocate another block - SV.server.edicts.length += Def.limits.edicts; - for (let j = i; j < SV.server.edicts.length; j++) { - SV.server.edicts[j] = new ServerEdict(j); - } - Con.DPrint(`ED.Alloc triggered Def.limits.edicts (${Def.limits.edicts})\n`); - } - e = SV.server.edicts[SV.server.num_edicts++]; - ED.ClearEdict(e); - return e; - } - - /** @param {ServerEdict} ed edict */ - static Free(ed) { // TODO: move to SV.Edict - SV.area.unlinkEdict(ed); - // mark as free, it will be cleared later - ed.free = true; - if (ed.entity) { - // only reset the data, not free the entire entity yet - // freeing the entity is done in ED.ClearEdict - ed.entity.clear(); - } - ed.freetime = SV.server.time; - } - - /** @param {ServerEdict} ed edict */ - static Print(ed) { - if (ed.isFree()) { - return; - } - Con.Print('\nEDICT ' + ed.num + ':\n'); - - for (let i = 1; i < PR.fielddefs.length; i++) { - const d = PR.fielddefs[i]; - const name = PR.GetString(d.name); - - if (/_[xyz]$/.test(name)) { - continue; - } - - Con.Print(`${name.padStart(24, '.')}: ${ed.entity[name]}\n`); - } - } - - static PrintEdicts() { - if (!SV.server.active) { - return; - } - - Con.Print(`${SV.server.num_edicts} entities\n`); - SV.server.edicts.forEach(ED.Print); - } - - static PrintEdict_f = class extends ConsoleCommand { - run(id) { - if (SV.server.active !== true) { - return; - } - if (id === undefined) { - Con.Print(`Usage: ${this.command} \n`); - return; - } - const i = Q.atoi(id); - if ((i >= 0) && (i < SV.server.num_edicts)) { - ED.Print(SV.server.edicts[i]); - } - } - }; - - static Count() { - if (SV.server.active !== true) { - return; - } - let active = 0, models = 0, solid = 0, step = 0; - for (let i = 0; i < SV.server.num_edicts; i++) { - const ent = SV.server.edicts[i]; - if (ent.isFree() === true) { - continue; - } - active++; - if (ent.entity.solid) { - solid++; - } - if (ent.entity.model) { - models++; - } - if (ent.entity.movetype === Defs.moveType.MOVETYPE_STEP) { - step++; - } - } - const num_edicts = SV.server.num_edicts; - Con.Print('num_edicts:' + (num_edicts <= 9 ? ' ' : (num_edicts <= 99 ? ' ' : '')) + num_edicts + '\n'); - Con.Print('active :' + (active <= 9 ? ' ' : (active <= 99 ? ' ' : '')) + active + '\n'); - Con.Print('view :' + (models <= 9 ? ' ' : (models <= 99 ? ' ' : '')) + models + '\n'); - Con.Print('touch :' + (solid <= 9 ? ' ' : (solid <= 99 ? ' ' : '')) + solid + '\n'); - Con.Print('step :' + (step <= 9 ? ' ' : (step <= 99 ? ' ' : '')) + step + '\n'); - } - - static ParseEdict(data, ent, initialData = {}) { - // If not the world entity, clear the entity data - // CR: this is required, otherwise we would overwrite data SV.SpawnServer had set prior - if (ent.num > 0) { - ent.clear(); - } - - let keyname; - let anglehack; - let init = false; - - // Parse until closing brace - while (true) { - const parsedKey = COM.Parse(data); - - data = parsedKey.data; - - if (parsedKey.token.charCodeAt(0) === 125) { - // Closing brace found - break; - } - - if (data === null) { - throw new Error('ED.ParseEdict: EOF without closing brace'); - } - - if (parsedKey.token === 'angle') { - keyname = 'angles'; - anglehack = true; - } else { - keyname = parsedKey.token; - anglehack = false; - - if (keyname === 'light') { - keyname = 'light_lev'; // Quake 1 convention - } - } - - // Remove trailing spaces in keyname - keyname = keyname.trimEnd(); - - // Parse the value - const parsedValue = COM.Parse(data); - - data = parsedValue.data; - - if (data === null) { - throw new Error('ED.ParseEdict: EOF without closing brace'); - } - - if (parsedValue.token.charCodeAt(0) === 125) { - throw new Error('ED.ParseEdict: Closing brace without data'); - } - - if (keyname.startsWith('_')) { - // Ignore keys starting with "_" - continue; - } - - if (anglehack) { - parsedValue.token = `0 ${parsedValue.token} 0`; - } - - initialData[keyname] = parsedValue.token.replace(/\\n/g, '\n'); - - init = true; - } - - // Mark the entity as free if no valid initialization occurred - if (!init) { - ent.free = true; - } - - return data; - } - - /** - * Loads entities from a file. - * @param {string} data - The data to load. - */ - static async LoadFromFile(data) { - let inhibit = 0; - let ent = null; - SV.server.gameAPI.time = SV.server.time; - - while (true) { - const parsed = COM.Parse(data); - - if (!parsed.data) { - break; - } - - data = parsed.data; - - if (parsed.token !== '{') { - throw new Error(`ED.LoadFromFile: found ${parsed.token} when expecting {`); - } - - /** @type {import('source/shared/GameInterfaces').EdictData} */ - const initialData = {}; - ent = ent ? ED.Alloc() : SV.server.edicts[0]; - data = ED.ParseEdict(data, ent, initialData); - - if (!initialData.classname) { - Con.Print(`No classname for edict ${ent.num}\n`); - ED.Free(ent); - continue; - } - - // console.assert(ent.num === 0 && initialData.classname === 'worldspawn', 'Edict 0 must be worldspawn'); - - const maySpawn = SV.server.gameAPI.prepareEntity(ent, /** @type {string} */(initialData.classname), initialData); - - if (!maySpawn) { - ED.Free(ent); - inhibit++; - continue; - } - - await SV.WaitForPrecachedResources(); - - const spawned = SV.server.gameAPI.spawnPreparedEntity(ent); - - if (!spawned) { - Con.Print(`Could not spawn entity for edict ${ent.num}:\n`); - ED.Print(ent); - ED.Free(ent); - continue; - } - } - - Con.DPrint(`${inhibit} entities inhibited\n`); - } -} - -export class ServerEdict { - static #lastcheckpvs = /** @type {Visibility|null} */ (null); - - /** - * @param {number} num edict number - */ - constructor(num) { - this.num = num; - this.free = false; - /** @type {OctreeNode|null} used for fast lookup */ - this.octreeNode = null; - /** @type {number[]} used for PXS lookup */ - this.leafnums = []; - this.freetime = 0.0; - /** @type {BaseEntity|null} entity managed by the game code */ - this.entity = null; - } - - clear() { - if (this.entity) { - this.entity.free(); - this.entity = null; - } - } - - /** - * Edict is no longer in use - * @returns {boolean} true when freed/unused - */ - isFree() { - return this.free || !this.entity; - } - - get origin() { // for Octree use - return this.entity ? this.entity.origin : null; - } - - get absmin() { // for Octree use - return this.entity ? this.entity.absmin : null; - } - - get absmax() { // for Octree use - return this.entity ? this.entity.absmax : null; - } - - toString() { - if (this.isFree()) { - return `unused (${this.num})`; - } - - return `Edict (${this.entity.classname}, num: ${this.num}, origin: ${this.entity.origin})`; - } - - /** - * Gives up this edict and can be reused differently later. - */ - freeEdict() { - ED.Free(this); - } - - /** - * - * @param {ServerEdict} otherEdict other edict - * @returns {boolean} whether it’s equal - */ - equals(otherEdict) { - return otherEdict && this.num === otherEdict.num; - } - - /** - * @param {Vector} min min - * @param {Vector} max max - */ - setMinMaxSize(min, max) { - // FIXME: console.assert this check - if (min[0] > max[0] || min[1] > max[1] || min[2] > max[2]) { - throw new Error('Edict.setMinMaxSize: backwards mins/maxs'); - } - - this.entity.mins = min.copy(); - this.entity.maxs = max.copy(); - this.entity.size = max.copy().subtract(min); - this.linkEdict(true); - } - - /** - * @param {Vector} vec origin - */ - setOrigin(vec) { - this.entity.origin = vec.copy(); - this.linkEdict(false); - } - - linkEdict(touchTriggers = false) { - SV.area.linkEdict(this, touchTriggers); - } - - /** - * Sets the model, also sets mins/maxs when applicable. - * Model has to be precached, otherwise an Error is thrown. - * @throws {Error} Model not precached. - * @param {string} model path to the model, e.g. progs/player.mdl - */ - setModel(model) { - let i; - - for (i = 0; i < SV.server.modelPrecache.length; i++) { - if (SV.server.modelPrecache[i] === model) { - break; - } - } - - if (i === SV.server.modelPrecache.length) { - throw new Error('Edict.setModel: ' + model + ' not precached'); - } - - this.entity.model = model; - this.entity.modelindex = i; - - const mod = SV.server.models[i]; - - if (mod instanceof Promise) { - // model is not yet loaded, this happens when spawning an entity and it’s calling precache AND setmodel right after (QuakeC jank) - void mod.then((loadedModel) => { - this.setMinMaxSize(loadedModel.mins, loadedModel.maxs); - }); - return; - } - - if (mod) { - this.setMinMaxSize(mod.mins, mod.maxs); - } else { - this.setMinMaxSize(Vector.origin, Vector.origin); - } - - // CR: dear future me, investigate the fun issues with entities with SOLID_BSP and non-brush models, right now it breaks Pmove and a few other things. - if (this.entity.solid === Defs.solid.SOLID_BSP) { - console.assert(mod && mod.type === 0, 'Edict.setModel: not a brush model for SOLID_BSP'); - } - } - - /** - * Moves self in the given direction. Returns success as a boolean. - * @param {number} yaw yaw in degrees - * @param {number} dist distance to move - * @returns {boolean} true, when walking was successful - */ - walkMove(yaw, dist) { - return SV.movement.walkMove(this, yaw, dist); - } - - /** - * Makes sure the entity is settled on the ground. - * @param {number} z maximum distance to look down to check - * @returns {boolean} true, when the dropping succeeded - */ - dropToFloor(z = -2048.0) { - const end = this.entity.origin.copy().add(new Vector(0.0, 0.0, z)); - const trace = SV.collision.move(this.entity.origin, this.entity.mins, this.entity.maxs, end, 0, this); - - if (trace.fraction === 1.0 || trace.allsolid) { - return false; - } - - this.setOrigin(trace.endpos); - this.entity.flags |= Defs.flags.FL_ONGROUND; - this.entity.groundentity = trace.ent.entity; - - return true; - } - - /** - * Checks if the entity is standing on the ground. - * @returns {boolean} true, when edict touches the ground - */ - isOnTheFloor() { - return SV.movement.checkBottom(this); - } - - /** - * It will send a svc_spawnstatic upon signon to make clients register a static entity. - * Also this will free and release this Edict. - */ - makeStatic() { - const message = SV.server.signon; - message.writeByte(Protocol.svc.spawnstatic); - message.writeString(this.entity.classname); // FIXME: compress this, it’s ballooning the signon buffer. - message.writeByte(SV.ModelIndex(this.entity.model)); - message.writeByte(this.entity.frame || 0); - message.writeByte(this.entity.colormap || 0); - message.writeByte(this.entity.skin || 0); - message.writeByte(this.entity.effects || 0); - message.writeByte(Math.floor(this.entity.alpha * 255.0)); - message.writeByte(this.entity.solid || 0); - message.writeAngleVector(this.entity.angles); - message.writeCoordVector(this.entity.origin); - this.freeEdict(); - } - - /** - * Returns client (or object that has a client enemy) that would be a valid target. If there are more than one - * valid options, they are cycled each frame. If (self.origin + self.viewofs) is not in the PVS of the target, null is returned. - * @returns {ServerEdict} Edict when client found, null otherwise - */ - getNextBestClient() { // TODO: move to GameAPI, this is not interesting for edicts - // refresh check cache - if (SV.server.time - SV.server.lastchecktime >= 0.1) { - let check = SV.server.lastcheck; - if (check <= 0) { - check = 1; - } else if (check > SV.svs.maxclients) { - check = SV.svs.maxclients; - } - let i = 1; - if (check !== SV.svs.maxclients) { - i += check; - } - let ent; - for (; ; i++) { - if (i === SV.svs.maxclients + 1) { - i = 1; - } - ent = SV.server.edicts[i]; - if (i === check) { - break; - } - if (ent.isFree()) { - continue; - } - if (ent.entity.health <= 0.0 || (ent.entity.flags & Defs.flags.FL_NOTARGET) !== 0) { - continue; - } - break; - } - SV.server.lastcheck = i; - ServerEdict.#lastcheckpvs = SV.server.worldmodel.getPvsByPoint(ent.entity.origin.copy().add(ent.entity.view_ofs)); - SV.server.lastchecktime = SV.server.time; - } - - const ent = SV.server.edicts[SV.server.lastcheck]; - - if (ent.isFree() || ent.entity.health <= 0.0) { // TODO: better interface, not health - // not interesting anymore - return null; - } - - const l = SV.server.worldmodel.getLeafForPoint(this.entity.origin.copy().add(this.entity.view_ofs)).num; - - if (l === 0 || !ServerEdict.#lastcheckpvs.isRevealed(l)) { - // outside leaf (sentinel) or leaf is not visible according to PVS - return null; - } - - return ent; - } - - /** - * Checks if this entity is in the given PHS/PVS. - * @param {Visibility} pxs PHS/PVS to check against - * @returns {boolean} true, when this entity is in the PVS - */ - isInPXS(pxs) { - return pxs.areRevealed(this.leafnums); - } - - /** - * Move this entity toward its goal. Used for monsters. - * @param {number} dist distance to move - * @param {Vector | null} target optional target position, otherwise .goalentity is used - * @returns {boolean} true, when successful - */ - moveToGoal(dist, target = null) { - return SV.movement.moveToGoal(this, dist, target); - } - - /** - * Returns a vector along which this entity can shoot. - * Usually, this entity is a player, and the vector returned is calculated by auto aiming to the closest enemy entity. - * NOTE: The original code and unofficial QuakeC reference docs say there’s an argument (speed/misslespeed), but it’s unused. - * @param {Vector} direction e.g. forward - * @returns {Vector} aim direction - */ - aim(direction) { - const dir = direction.copy(); - const origin = this.entity.origin.copy(); - const start = origin.add(new Vector(0.0, 0.0, 20.0)); - - const end = new Vector(start[0] + 2048.0 * dir[0], start[1] + 2048.0 * dir[1], start[2] + 2048.0 * dir[2]); - const tr = SV.collision.move(start, Vector.origin, Vector.origin, end, 0, this); - if (tr.ent !== null) { - if ((tr.ent.entity.takedamage === Defs.damage.DAMAGE_AIM) && (!Host.teamplay.value || this.entity.team <= 0 || this.entity.team !== tr.ent.entity.team)) { // Legacy cvars - return dir; - } - } - const bestdir = dir.copy(); - let bestdist = SV.aim.value; - let bestent = null; - for (let i = 1; i < SV.server.num_edicts; i++) { - const check = SV.server.edicts[i]; - if (check.isFree()) { - continue; - } - if (check.entity.takedamage !== Defs.damage.DAMAGE_AIM) { - continue; - } - if (check.equals(this)) { - continue; - } - if ((Host.teamplay.value !== 0) && (this.entity.team > 0) && (this.entity.team === check.entity.team)) { // Legacy cvars - continue; - } - const corigin = check.entity.origin, cmins = check.entity.mins, cmaxs = check.entity.maxs; - end.set(corigin).add(cmins.copy().add(cmaxs).multiply(0.5)); - dir.set(end).subtract(start); - dir.normalize(); - let dist = dir.dot(bestdir); - if (dist < bestdist) { - continue; - } - const tr = SV.collision.move(start, Vector.origin, Vector.origin, end, 0, this); - if (tr.ent === check) { - bestdist = dist; - bestent = check; - } - } - if (bestent !== null) { - dir.set(bestent.entity.origin).subtract(this.entity.origin); - const dist = dir.dot(bestdir); - end[0] = bestdir[0] * dist; - end[1] = bestdir[1] * dist; - end[2] = dir[2]; - end.normalize(); - return end; - } - return bestdir; - } - - /** - * Returns entity that is just after this in the entity list. - * Useful to browse the list of entities, because it skips the undefined ones. - * @returns {ServerEdict | null} next edict, or null if there are no more entities - */ - nextEdict() { - for (let i = this.num + 1; i < SV.server.num_edicts; i++) { - if (!SV.server.edicts[i].isFree()) { - return SV.server.edicts[i]; - } - } - - return null; - } - - /** - * Change the horizontal orientation of this entity. Turns towards .ideal_yaw at .yaw_speed. Called every 0.1 sec by monsters. - * @returns {number} new yaw angle - */ - changeYaw() { - const angles = this.entity.angles; - angles[1] = SV.movement.changeYaw(this); - this.entity.angles = angles; - - return angles[1]; - } - - /** - * returns the corresponding client object - * @returns {ServerClient | null} client object, if edict is actually a client edict - */ - getClient() { - return SV.svs.clients[this.num - 1] || null; - } - - /** - * check if edict is a client edict - * @returns {boolean} true, when edict is a client edict - */ - isClient() { - return (this.num > 0) && (this.num <= SV.svs.maxclients); - } - - /** - * checks if this entity is worldspawn - * @returns {boolean} true, when edict represents world - */ - isWorld() { - return this.num === 0; - } -}; - -registerSerializableType(ServerEdict, { - /** - * @param {SzBuffer} sz serialization buffer - * @param {ServerEdict} object edict to serialize - */ - serialize(sz, object) { - sz.writeShort(object.num); - }, - - /** - * @param {SzBuffer} sz serialization buffer - * @returns {ServerEdict} deserialized edict - */ - // eslint-disable-next-line no-unused-vars - deserializeOnServer(sz) { - const num = NET.message.readShort(); - console.assert(num >= 0 && num < SV.server.num_edicts, `ServerEdict.deserialize: invalid edict number ${num}`); - return SV.server.edicts[num]; - }, - - /** - * @param {SzBuffer} sz serialization buffer - * @returns {ClientEdict} deserialized edict - */ - // eslint-disable-next-line no-unused-vars - deserializeOnClient(sz) { - return CL.state.clientEntities.getEntity(NET.message.readShort()); - }, -}); +export { ED, ServerEdict } from './Edict.ts'; diff --git a/source/engine/server/Edict.ts b/source/engine/server/Edict.ts new file mode 100644 index 00000000..d1c389c4 --- /dev/null +++ b/source/engine/server/Edict.ts @@ -0,0 +1,819 @@ +import type { EdictData } from '../../shared/GameInterfaces.ts'; +import type { WorldspawnEntity as WorldspawnEntityValue } from '../../game/id1/entity/Worldspawn.mjs'; +import type { OctreeNode } from '../../shared/Octree.ts'; +import type { Visibility } from '../common/model/BSP.ts'; +import type { ClientEdict } from '../client/ClientEntities.mjs'; +import type { ServerClient } from './Client.mjs'; + +import Vector from '../../shared/Vector.ts'; +import { SzBuffer, registerSerializableType } from '../network/MSG.ts'; +import * as Protocol from '../network/Protocol.ts'; +import * as Def from '../common/Def.ts'; +import * as Defs from '../../shared/Defs.ts'; +import { eventBus, registry } from '../registry.mjs'; +import Q from '../../shared/Q.ts'; +import { ConsoleCommand } from '../common/Cmd.ts'; + +export interface BaseEntity { + classname: string; + alpha: number; + angles: Vector; + avelocity: Vector; + absmax: Vector; + absmin: Vector; + clear(): void; + colormap: number; + effects: number; + flags: number; + frame: number; + free(): void; + groundentity: BaseEntity | null; + health: number; + mins: Vector; + maxs: Vector; + model: string | null; + modelindex: number; + movetype: number; + netname?: string | null; + origin: Vector; + punchangle: Vector; + size: Vector; + skin: number; + solid: number; + takedamage: number; + team: number; + velocity: Vector; + view_ofs: Vector; + v_angle: Vector; +} + +export type WorldspawnEntity = WorldspawnEntityValue; + +let { CL, COM, Con, Host, NET, PR, SV } = registry; + +eventBus.subscribe('registry.frozen', () => { + ({ CL, COM, Con, Host, NET, PR, SV } = registry); +}); + +/** + * Server-side edict allocation, parsing, and debugging helpers. + */ +export class ED { + /** + * Clears an edict for reuse. + */ + static ClearEdict(ed: ServerEdict): void { + if (ed.entity) { + ed.entity.free(); + ed.entity = null; + } + + ed.clear(); + ed.free = false; + } + + /** + * Allocates a reusable edict slot. + * @returns The allocated edict. + */ + static Alloc(): ServerEdict { + let i: number; + let edict: ServerEdict; + + for (i = SV.svs.maxclients + 1; i < SV.server.num_edicts; i++) { + edict = SV.server.edicts[i] as ServerEdict; + + if (edict.free === true && (edict.freetime < 2.0 || SV.server.time - edict.freetime > 0.5)) { + ED.ClearEdict(edict); + return edict; + } + } + + if (i % Def.limits.edicts === 0) { + SV.server.edicts.length += Def.limits.edicts; + + for (let j = i; j < SV.server.edicts.length; j++) { + SV.server.edicts[j] = new ServerEdict(j); + } + + Con.DPrint(`ED.Alloc triggered Def.limits.edicts (${Def.limits.edicts})\n`); + } + + edict = SV.server.edicts[SV.server.num_edicts++] as ServerEdict; + ED.ClearEdict(edict); + return edict; + } + + /** + * Marks an edict free so it can be reused later. + */ + static Free(ed: ServerEdict): void { + SV.area.unlinkEdict(ed); + ed.free = true; + + if (ed.entity) { + ed.entity.clear(); + } + + ed.freetime = SV.server.time; + } + + /** + * Prints an edict's fields to the console. + */ + static Print(ed: ServerEdict): void { + if (ed.isFree()) { + return; + } + + const entity = ed.entity; + + console.assert(entity !== null, 'ED.Print requires a live entity'); + + Con.Print(`\nEDICT ${ed.num}:\n`); + + for (let i = 1; i < PR.fielddefs.length; i++) { + const fieldDef = PR.fielddefs[i]; + const name = PR.GetString(fieldDef.name); + + if (/_[xyz]$/.test(name)) { + continue; + } + + const printableEntity = entity as Record; + Con.Print(`${name.padStart(24, '.')}: ${printableEntity[name]}\n`); + } + } + + /** + * Prints all active edicts. + */ + static PrintEdicts(): void { + if (!SV.server.active) { + return; + } + + Con.Print(`${SV.server.num_edicts} entities\n`); + SV.server.edicts.forEach((edict: ServerEdict) => { + ED.Print(edict); + }); + } + + static PrintEdict_f = class PrintEdictCommand extends ConsoleCommand { + run(id?: string): void { + if (SV.server.active !== true) { + return; + } + + if (id === undefined) { + Con.Print(`Usage: ${this.command} \n`); + return; + } + + const index = Q.atoi(id); + + if (index >= 0 && index < SV.server.num_edicts) { + ED.Print(SV.server.edicts[index] as ServerEdict); + } + } + }; + + /** + * Prints an edict usage summary. + */ + static Count(): void { + if (SV.server.active !== true) { + return; + } + + let active = 0; + let models = 0; + let solid = 0; + let step = 0; + + for (let i = 0; i < SV.server.num_edicts; i++) { + const ent = SV.server.edicts[i] as ServerEdict; + + if (ent.isFree() === true) { + continue; + } + + const entity = ent.entity; + + console.assert(entity !== null, 'ED.Count requires a live entity'); + + active++; + + if (entity.solid) { + solid++; + } + + if (entity.model) { + models++; + } + + if (entity.movetype === Defs.moveType.MOVETYPE_STEP) { + step++; + } + } + + const numEdicts = SV.server.num_edicts; + + Con.Print(`num_edicts:${numEdicts <= 9 ? ' ' : numEdicts <= 99 ? ' ' : ''}${numEdicts}\n`); + Con.Print(`active :${active <= 9 ? ' ' : active <= 99 ? ' ' : ''}${active}\n`); + Con.Print(`view :${models <= 9 ? ' ' : models <= 99 ? ' ' : ''}${models}\n`); + Con.Print(`touch :${solid <= 9 ? ' ' : solid <= 99 ? ' ' : ''}${solid}\n`); + Con.Print(`step :${step <= 9 ? ' ' : step <= 99 ? ' ' : ''}${step}\n`); + } + + /** + * Parses one edict block from entity text. + * @returns The remaining entity data after the parsed block. + */ + static ParseEdict(data: string, ent: ServerEdict, initialData: EdictData = {}): string { + if (ent.num > 0) { + ent.clear(); + } + + let keyname = ''; + let anglehack = false; + let init = false; + + while (true) { + const parsedKey = COM.Parse(data); + + data = parsedKey.data; + + if (parsedKey.token.charCodeAt(0) === 125) { + break; + } + + if (data === null) { + throw new Error('ED.ParseEdict: EOF without closing brace'); + } + + if (parsedKey.token === 'angle') { + keyname = 'angles'; + anglehack = true; + } else { + keyname = parsedKey.token; + anglehack = false; + + if (keyname === 'light') { + keyname = 'light_lev'; + } + } + + keyname = keyname.trimEnd(); + + const parsedValue = COM.Parse(data); + + data = parsedValue.data; + + if (data === null) { + throw new Error('ED.ParseEdict: EOF without closing brace'); + } + + if (parsedValue.token.charCodeAt(0) === 125) { + throw new Error('ED.ParseEdict: Closing brace without data'); + } + + if (keyname.startsWith('_')) { + continue; + } + + if (anglehack) { + parsedValue.token = `0 ${parsedValue.token} 0`; + } + + initialData[keyname] = parsedValue.token.replace(/\\n/g, '\n'); + init = true; + } + + if (!init) { + ent.free = true; + } + + return data; + } + + /** + * Loads all entities from the worldspawn entity lump. + */ + static async LoadFromFile(data: string): Promise { + let inhibit = 0; + let ent: ServerEdict | null = null; + SV.server.gameAPI.time = SV.server.time; + + while (true) { + const parsed = COM.Parse(data); + + if (!parsed.data) { + break; + } + + data = parsed.data; + + if (parsed.token !== '{') { + throw new Error(`ED.LoadFromFile: found ${parsed.token} when expecting {`); + } + + const initialData: EdictData = {}; + ent = ent ? ED.Alloc() : (SV.server.edicts[0] as ServerEdict); + data = ED.ParseEdict(data, ent, initialData); + + if (!initialData.classname) { + Con.Print(`No classname for edict ${ent.num}\n`); + ED.Free(ent); + continue; + } + + const maySpawn = SV.server.gameAPI.prepareEntity(ent, initialData.classname as string, initialData); + + if (!maySpawn) { + ED.Free(ent); + inhibit++; + continue; + } + + await SV.WaitForPrecachedResources(); + + const spawned = SV.server.gameAPI.spawnPreparedEntity(ent); + + if (!spawned) { + Con.Print(`Could not spawn entity for edict ${ent.num}:\n`); + ED.Print(ent); + ED.Free(ent); + } + } + + Con.DPrint(`${inhibit} entities inhibited\n`); + } +} + +/** + * Mutable server-side wrapper around a game entity instance. + */ +export class ServerEdict { + static #lastcheckpvs: Visibility | null = null; + + readonly num: number; + free: boolean; + octreeNode: OctreeNode | null; + readonly leafnums: number[]; + freetime: number; + entity: BaseEntity | null; + + constructor(num: number) { + this.num = num; + this.free = false; + this.octreeNode = null; + this.leafnums = []; + this.freetime = 0.0; + this.entity = null; + } + + private _requireEntity(): BaseEntity { + if (this.entity === null) { + throw new Error(`Edict ${this.num} has no entity`); + } + + return this.entity; + } + + /** + * Clears the currently linked entity instance. + */ + clear(): void { + if (this.entity) { + this.entity.free(); + this.entity = null; + } + } + + /** + * Edict is no longer in use. + * @returns True when the edict has no live entity. + */ + isFree(): boolean { + return this.free || !this.entity; + } + + /** + * Returns the current origin for octree bookkeeping. + * @returns The entity origin, or null if the edict is unused. + */ + get origin(): Vector | null { + return this.entity ? this.entity.origin : null; + } + + /** + * Returns the current absolute mins for octree bookkeeping. + * @returns The entity mins, or null if the edict is unused. + */ + get absmin(): Vector | null { + return this.entity ? this.entity.absmin : null; + } + + /** + * Returns the current absolute maxs for octree bookkeeping. + * @returns The entity maxs, or null if the edict is unused. + */ + get absmax(): Vector | null { + return this.entity ? this.entity.absmax : null; + } + + toString(): string { + if (this.isFree()) { + return `unused (${this.num})`; + } + + const entity = this._requireEntity(); + return `Edict (${entity.classname}, num: ${this.num}, origin: ${entity.origin})`; + } + + /** + * Gives up this edict so it can be reused later. + */ + freeEdict(): void { + ED.Free(this); + } + + /** + * Compares edicts by slot number. + * @returns True when both edicts reference the same slot. + */ + equals(otherEdict: ServerEdict | null): boolean { + return otherEdict !== null && this.num === otherEdict.num; + } + + /** + * Updates mins, maxs, and size for this entity. + */ + setMinMaxSize(min: Vector, max: Vector): void { + if (min[0] > max[0] || min[1] > max[1] || min[2] > max[2]) { + throw new Error('Edict.setMinMaxSize: backwards mins/maxs'); + } + + const entity = this._requireEntity(); + entity.mins = min.copy(); + entity.maxs = max.copy(); + entity.size = max.copy().subtract(min); + this.linkEdict(true); + } + + /** + * Moves the entity to a new origin and relinks it. + */ + setOrigin(vec: Vector): void { + this._requireEntity().origin = vec.copy(); + this.linkEdict(false); + } + + /** + * Relinks the edict in the area tree. + */ + linkEdict(touchTriggers = false): void { + SV.area.linkEdict(this, touchTriggers); + } + + /** + * Sets the model, also setting mins/maxs when applicable. + */ + setModel(model: string): void { + let i: number; + + for (i = 0; i < SV.server.modelPrecache.length; i++) { + if (SV.server.modelPrecache[i] === model) { + break; + } + } + + if (i === SV.server.modelPrecache.length) { + throw new Error(`Edict.setModel: ${model} not precached`); + } + + const entity = this._requireEntity(); + entity.model = model; + entity.modelindex = i; + + const mod = SV.server.models[i]; + + if (mod instanceof Promise) { + void mod.then((loadedModel) => { + this.setMinMaxSize(loadedModel.mins, loadedModel.maxs); + }); + return; + } + + if (mod) { + this.setMinMaxSize(mod.mins, mod.maxs); + } else { + this.setMinMaxSize(Vector.origin, Vector.origin); + } + + if (entity.solid === Defs.solid.SOLID_BSP) { + console.assert(mod && mod.type === 0, 'Edict.setModel: not a brush model for SOLID_BSP'); + } + } + + /** + * Moves self in the given direction. + * @returns True when walking succeeded. + */ + walkMove(yaw: number, dist: number): boolean { + return SV.movement.walkMove(this, yaw, dist); + } + + /** + * Makes sure the entity is settled on the ground. + * @returns True when the drop succeeded. + */ + dropToFloor(z = -2048.0): boolean { + const entity = this._requireEntity(); + const end = entity.origin.copy().add(new Vector(0.0, 0.0, z)); + const trace = SV.collision.move(entity.origin, entity.mins, entity.maxs, end, 0, this); + + if (trace.fraction === 1.0 || trace.allsolid) { + return false; + } + + this.setOrigin(trace.endpos); + entity.flags |= Defs.flags.FL_ONGROUND; + entity.groundentity = trace.ent!.entity; + return true; + } + + /** + * Checks if the entity is standing on the ground. + * @returns True when the edict touches the ground. + */ + isOnTheFloor(): boolean { + return SV.movement.checkBottom(this); + } + + /** + * Converts this edict into a static entity for client signon. + */ + makeStatic(): void { + const entity = this._requireEntity(); + const message = SV.server.signon; + message.writeByte(Protocol.svc.spawnstatic); + message.writeString(entity.classname); + message.writeByte(SV.ModelIndex(entity.model)); + message.writeByte(entity.frame || 0); + message.writeByte(entity.colormap || 0); + message.writeByte(entity.skin || 0); + message.writeByte(entity.effects || 0); + message.writeByte(Math.floor(entity.alpha * 255.0)); + message.writeByte(entity.solid || 0); + message.writeAngleVector(entity.angles); + message.writeCoordVector(entity.origin); + this.freeEdict(); + } + + /** + * Returns the next client that is a valid auto-aim/AI target. + * @returns The selected client edict, or null when none is visible. + */ + getNextBestClient(): ServerEdict | null { + if (SV.server.time - SV.server.lastchecktime >= 0.1) { + let check = SV.server.lastcheck; + + if (check <= 0) { + check = 1; + } else if (check > SV.svs.maxclients) { + check = SV.svs.maxclients; + } + + let i = 1; + + if (check !== SV.svs.maxclients) { + i += check; + } + + let ent: ServerEdict; + + for (;; i++) { + if (i === SV.svs.maxclients + 1) { + i = 1; + } + + ent = SV.server.edicts[i] as ServerEdict; + + if (i === check) { + break; + } + + if (ent.isFree()) { + continue; + } + + const entity = ent._requireEntity(); + + if (entity.health <= 0.0 || (entity.flags & Defs.flags.FL_NOTARGET) !== 0) { + continue; + } + + break; + } + + SV.server.lastcheck = i; + ServerEdict.#lastcheckpvs = SV.server.worldmodel.getPvsByPoint(ent._requireEntity().origin.copy().add(ent._requireEntity().view_ofs)); + SV.server.lastchecktime = SV.server.time; + } + + const ent = SV.server.edicts[SV.server.lastcheck] as ServerEdict; + const entity = ent.entity; + + if (ent.isFree() || entity === null || entity.health <= 0.0) { + return null; + } + + const lastcheckpvs = ServerEdict.#lastcheckpvs; + + if (lastcheckpvs === null) { + return null; + } + + const leaf = SV.server.worldmodel.getLeafForPoint(this._requireEntity().origin.copy().add(this._requireEntity().view_ofs)).num; + + if (leaf === 0 || !lastcheckpvs.isRevealed(leaf)) { + return null; + } + + return ent; + } + + /** + * Checks if this entity is in the given PHS/PVS. + * @returns True when this entity is in the supplied visibility set. + */ + isInPXS(pxs: Visibility): boolean { + return pxs.areRevealed(this.leafnums); + } + + /** + * Move this entity toward its goal. + * @returns True when the move succeeded. + */ + moveToGoal(dist: number, target: Vector | null = null): boolean { + return SV.movement.moveToGoal(this, dist, target); + } + + /** + * Returns an auto-aim direction for this entity. + * @returns The resolved aim direction. + */ + aim(direction: Vector): Vector { + const entity = this._requireEntity(); + const dir = direction.copy(); + const origin = entity.origin.copy(); + const start = origin.add(new Vector(0.0, 0.0, 20.0)); + const end = new Vector(start[0] + 2048.0 * dir[0], start[1] + 2048.0 * dir[1], start[2] + 2048.0 * dir[2]); + const trace = SV.collision.move(start, Vector.origin, Vector.origin, end, 0, this); + + if (trace.ent !== null) { + const hitEntity = trace.ent.entity; + + if (hitEntity !== null && hitEntity.takedamage === Defs.damage.DAMAGE_AIM && (!Host.teamplay.value || entity.team <= 0 || entity.team !== hitEntity.team)) { + return dir; + } + } + + const bestdir = dir.copy(); + let bestdist = SV.aim.value; + let bestent: ServerEdict | null = null; + + for (let i = 1; i < SV.server.num_edicts; i++) { + const check = SV.server.edicts[i] as ServerEdict; + + if (check.isFree()) { + continue; + } + + const checkEntity = check.entity; + + if (checkEntity === null || checkEntity.takedamage !== Defs.damage.DAMAGE_AIM) { + continue; + } + + if (check.equals(this)) { + continue; + } + + if (Host.teamplay.value !== 0 && entity.team > 0 && entity.team === checkEntity.team) { + continue; + } + + const center = checkEntity.origin.copy().add(checkEntity.mins.copy().add(checkEntity.maxs).multiply(0.5)); + dir.set(center).subtract(start); + dir.normalize(); + + const dist = dir.dot(bestdir); + + if (dist < bestdist) { + continue; + } + + const trace = SV.collision.move(start, Vector.origin, Vector.origin, center, 0, this); + + if (trace.ent === check) { + bestdist = dist; + bestent = check; + } + } + + if (bestent !== null) { + const bestEntity = bestent._requireEntity(); + dir.set(bestEntity.origin).subtract(entity.origin); + const dist = dir.dot(bestdir); + end[0] = bestdir[0] * dist; + end[1] = bestdir[1] * dist; + end[2] = dir[2]; + end.normalize(); + return end; + } + + return bestdir; + } + + /** + * Returns the next non-free edict in the list. + * @returns The next active edict, or null if there are no more. + */ + nextEdict(): ServerEdict | null { + for (let i = this.num + 1; i < SV.server.num_edicts; i++) { + const edict = SV.server.edicts[i] as ServerEdict; + + if (!edict.isFree()) { + return edict; + } + } + + return null; + } + + /** + * Turns toward ideal_yaw at yaw_speed. + * @returns The new yaw angle. + */ + changeYaw(): number { + const entity = this._requireEntity(); + const angles = entity.angles; + angles[1] = SV.movement.changeYaw(this); + entity.angles = angles; + return angles[1]; + } + + /** + * Returns the corresponding client object. + * @returns The mapped client slot, or null when none exists. + */ + getClient(): ServerClient | null { + return (SV.svs.clients[this.num - 1] as ServerClient | undefined) ?? null; + } + + /** + * Checks whether this edict is a player-client slot. + * @returns True when the edict is within the active client slot range. + */ + isClient(): boolean { + return this.num > 0 && this.num <= SV.svs.maxclients; + } + + /** + * Checks whether this edict is worldspawn. + * @returns True when the edict represents the world. + */ + isWorld(): boolean { + return this.num === 0; + } +} + +registerSerializableType(ServerEdict, { + /** + * Serializes a server edict reference. + */ + serialize(sz: SzBuffer, object: ServerEdict): void { + sz.writeShort(object.num); + }, + + /** + * Deserializes a server edict reference on the server. + * @returns The referenced server edict. + */ + deserializeOnServer(_sz: SzBuffer): ServerEdict { + const num = NET.message.readShort(); + console.assert(num >= 0 && num < SV.server.num_edicts, `ServerEdict.deserialize: invalid edict number ${num}`); + return SV.server.edicts[num] as ServerEdict; + }, + + /** + * Deserializes a server edict reference on the client. + * @returns The client-side edict proxy. + */ + deserializeOnClient(_sz: SzBuffer): ClientEdict { + return CL.state.clientEntities.getEntity(NET.message.readShort()) as ClientEdict; + }, +}); diff --git a/source/engine/server/Server.mjs b/source/engine/server/Server.mjs index e04e7825..7bb99a51 100644 --- a/source/engine/server/Server.mjs +++ b/source/engine/server/Server.mjs @@ -1,1100 +1 @@ -import Cvar from '../common/Cvar.ts'; -import { MoveVars, Pmove } from '../common/Pmove.ts'; -import { SzBuffer } from '../network/MSG.ts'; -import * as Protocol from '../network/Protocol.ts'; -import * as Def from './../common/Def.ts'; -import Cmd, { ConsoleCommand } from '../common/Cmd.ts'; -import { ED, ServerEdict } from './Edict.mjs'; -import { EventBus, eventBus, registry } from '../registry.mjs'; -import { ServerEngineAPI } from '../common/GameAPIs.ts'; -import * as Defs from '../../shared/Defs.ts'; -import { Navigation } from './Navigation.mjs'; -import { ServerPhysics } from './physics/ServerPhysics.ts'; -import { ServerClientPhysics } from './physics/ServerClientPhysics.ts'; -import { ServerMessages } from './ServerMessages.mjs'; -import { ServerMovement } from './physics/ServerMovement.ts'; -import { ServerArea } from './physics/ServerArea.ts'; -import { ServerCollision } from './physics/ServerCollision.ts'; -import { sharedCollisionModelSource } from '../common/CollisionModelSource.ts'; -import { BrushModel } from '../common/Mod.ts'; -import { ServerClient } from './Client.mjs'; -export { ServerEntityState } from './ServerEntityState.mjs'; - -let { COM, Con, Host, Mod, NET, PR } = registry; - -eventBus.subscribe('registry.frozen', () => { - COM = registry.COM; - Con = registry.Con; - Host = registry.Host; - Mod = registry.Mod; - NET = registry.NET; - PR = registry.PR; -}); - -/** - * @typedef {{ - fraction: number; - allsolid: boolean; - startsolid: boolean; - endpos: any; - plane: { - normal: import('../../shared/Vector.ts').Vector; - dist: number; - }; - ent: any; - inopen?: boolean; - inwater?: boolean; - }} Trace - */ - -/** @typedef {import('../../shared/GameInterfaces').SerializableType} SerializableType */ - -const ALLOWED_CLIENT_COMMANDS = [ - 'status', - 'god', - 'notarget', - 'fly', - 'name', - 'noclip', - 'say', - 'say_team', - 'tell', - 'color', - 'kill', - 'pause', - 'spawn', - 'begin', - 'prespawn', - 'kick', - 'ping', - 'give', - 'ban', -]; - -/** - * Main server class with all server-related functionality. - * All properties and methods are static. - */ -export default class SV { - /** current server state */ - static server = { - time: 0, - num_edicts: 0, - datagram: new SzBuffer(16384, 'SV.server.datagram'), - expedited_datagram: new SzBuffer(16384, 'SV.server.expedited_datagram'), - reliable_datagram: new SzBuffer(16384, 'SV.server.reliable_datagram'), - /** sent during client prespawn */ - signon: new SzBuffer(16384, 'SV.server.signon'), - edicts: /** @type {ServerEdict[]} */ ([]), - mapname: /** @type {string} */ (null), - worldmodel: /** @type {BrushModel} */ (null), - /** server game event bus, will be reset on every map load */ - eventBus: new EventBus('server-game'), - /** @type {Navigation} navigation graph management */ - navigation: null, - /** @type {import('../../shared/GameInterfaces').ServerGameInterface} */ - gameAPI: null, - /** @type {string?} game version string */ - gameVersion: null, - /** @type {string?} game identification (e.g. Quake) */ - gameName: null, - /** @type {Defs.gameCapabilities[]} game capability flags */ - gameCapabilities: [], - /** @type {string[]} clientdata field names */ - clientdataFields: [], - /** @type {'writeByte' | 'writeShort' | 'writeLong' | null} */ - clientdataFieldsBitsWriter: null, - /** @type {Record} maps classname to its fields and the apropriate bits writer */ - clientEntityFields: {}, - /** @type {import('../common/Mod.ts').BaseModel[]} */ - models: [], - /** @type {string[]} */ - soundPrecache: [], - /** @type {string[]} */ - modelPrecache: [], - active: false, - }; - - /** server static, state across maps */ - static svs = { - changelevelIssued: false, - /** @type {ServerClient[]} */ - clients: [], - maxclients: 0, - maxclientslimit: 32, - /** gamestate across maps */ - gamestate: null, - - /** @type {string[]} list of maps */ - maplist: [], - - *spawnedClients() { - for (const client of this.clients) { - if (client.state === ServerClient.STATE.SPAWNED) { - yield client; - } - } - }, - }; - - // Class instances for modular functionality - static physics = new ServerPhysics(); - static clientPhysics = new ServerClientPhysics(); - static messages = new ServerMessages(); - static movement = new ServerMovement(); - static area = new ServerArea(sharedCollisionModelSource); - static collision = new ServerCollision(sharedCollisionModelSource); - - /** @type {?Pmove} shared player-move collision context */ - static pmove = null; - - // Cvars (initialized in Init()) - static maxvelocity = /** @type {Cvar} */ (null); - static edgefriction = /** @type {Cvar} */ (null); - static stopspeed = /** @type {Cvar} */ (null); - static accelerate = /** @type {Cvar} */ (null); - static idealpitchscale = /** @type {Cvar} */ (null); - static aim = /** @type {Cvar} */ (null); - static nostep = /** @type {Cvar} */ (null); - static cheats = /** @type {Cvar} */ (null); - static gravity = /** @type {Cvar} */ (null); - static friction = /** @type {Cvar} */ (null); - static maxspeed = /** @type {Cvar} */ (null); - static airaccelerate = /** @type {Cvar} */ (null); - static wateraccelerate = /** @type {Cvar} */ (null); - static spectatormaxspeed = /** @type {Cvar} */ (null); - static waterfriction = /** @type {Cvar} */ (null); - static rcon_password = /** @type {Cvar} */ (null); - static maplist = /** @type {Cvar} */ (null); - static nextmap = /** @type {Cvar} */ (null); - static public = /** @type {Cvar} */ (null); - - /** Scheduled game commands */ - static _scheduledGameCommands = []; - - // ===== STATIC METHODS ===== - - static InitPmove() { - SV.pmove = new Pmove(); - SV.pmove.movevars = new PlayerMoveCvars(); - } - - static Init() { - SV.maxvelocity = new Cvar('sv_maxvelocity', '2000', Cvar.FLAG.SERVER); - SV.edgefriction = new Cvar('edgefriction', '2', Cvar.FLAG.SERVER); - SV.stopspeed = new Cvar('sv_stopspeed', '100', Cvar.FLAG.SERVER); - SV.accelerate = new Cvar('sv_accelerate', '10', Cvar.FLAG.SERVER); - SV.idealpitchscale = new Cvar('sv_idealpitchscale', '0.8'); - SV.aim = new Cvar('sv_aim', '0.93'); - SV.nostep = new Cvar('sv_nostep', '0'); - SV.cheats = new Cvar('sv_cheats', '0', Cvar.FLAG.SERVER); - SV.gravity = new Cvar('sv_gravity', '800', Cvar.FLAG.SERVER); - SV.friction = new Cvar('sv_friction', '4', Cvar.FLAG.SERVER); - SV.maxspeed = new Cvar('sv_maxspeed', '320', Cvar.FLAG.SERVER); - SV.airaccelerate = new Cvar('sv_airaccelerate', '0.7', Cvar.FLAG.SERVER); - SV.wateraccelerate = new Cvar('sv_wateraccelerate', '10', Cvar.FLAG.SERVER); - SV.spectatormaxspeed = new Cvar('sv_spectatormaxspeed', '500', Cvar.FLAG.SERVER); - SV.waterfriction = new Cvar('sv_waterfriction', '4', Cvar.FLAG.SERVER); - SV.rcon_password = new Cvar('sv_rcon_password', '', Cvar.FLAG.ARCHIVE); - SV.public = new Cvar('sv_public', '1', Cvar.FLAG.ARCHIVE | Cvar.FLAG.SERVER, 'Make this server publicly listed in the master server'); - - Navigation.Init(); - - Cmd.AddCommand('nav', class extends ConsoleCommand { - run() { - if (!SV.server.navigation) { - Con.Print('navigation not initialized, you have to spawn a server first\n'); - return; - } - - SV.server.navigation.build(); - } - }); - - eventBus.subscribe('cvar.changed', (name) => { - const cvar = Cvar.FindVar(name); - - if ((cvar.flags & Cvar.FLAG.SERVER) && SV.server.active) { - SV.CvarChanged(cvar); - } - }); - - SV.InitNextmapStuff(); - - // TODO: we need to observe changes to those pmove vars and resend them to all clients when changed - - SV.InitPmove(); - - SV.area.initBoxHull(); // pmove, remove - } - - // ============================================================================= - // GAME COMMANDS & SCHEDULING - // Schedule and run commands from the game logic - // ============================================================================= - - /** - * Provides functionality for cycling through maps after each map change, based on sv_maplist and sv_nextmap cvars. - * The server game code doesn’t really have a state and this helps the game code with managing map cycling. - */ - static InitNextmapStuff() { - SV.maplist = new Cvar('sv_maplist', '', Cvar.FLAG.NONE, 'Comma-separated list of maps to cycle through after each map change'); - SV.nextmap = new Cvar('sv_nextmap', '', Cvar.FLAG.SERVER, 'Next map to change to after the current one, will be autopopulated with the next map in sv_maplist after each map change'); - - eventBus.subscribe('cvar.changed.sv_maplist', () => { - if (SV.maplist.string.trim() === '') { - SV.svs.maplist.length = 0; - return; - } - - SV.svs.maplist = SV.maplist.string.split(',').map((s) => s.trim()).filter((s) => s.length > 0); - }); - - eventBus.subscribe('server.spawning', ({ mapname }) => { - if (SV.svs.maplist.length === 0) { - return; - } - - if (!SV.svs.maplist.includes(mapname)) { - SV.nextmap.set(SV.svs.maplist[0]); - return; - } - - const currentIndex = SV.svs.maplist.indexOf(mapname); - const nextIndex = (currentIndex + 1) % SV.svs.maplist.length; - - SV.nextmap.set(SV.svs.maplist[nextIndex]); - }); - - eventBus.subscribe('server.shutdown', () => { - SV.nextmap.reset(); - }); - } - - static RunScheduledGameCommands() { - while (SV._scheduledGameCommands.length > 0) { - const command = SV._scheduledGameCommands.shift(); - - command(); - } - } - - static ScheduleGameCommand(command) { - SV._scheduledGameCommands.push(command); - } - - static ConnectClient(client, netconnection) { - Con.DPrint('Client ' + netconnection.address + ' connected\n'); - - const old_spawn_parms = SV.server.loadgame ? client.spawn_parms : null; - - client.clear(); - client.name = 'unconnected'; - client.netconnection = netconnection; - client.state = ServerClient.STATE.CONNECTING; - - client.old_frags = Infinity; // trigger a update frags - - if (SV.server.gameCapabilities.includes(Defs.gameCapabilities.CAP_SPAWNPARMS_DYNAMIC)) { - client.entity.restoreSpawnParameters(old_spawn_parms); - } else if (SV.server.gameCapabilities.includes(Defs.gameCapabilities.CAP_SPAWNPARMS_LEGACY)) { - if (SV.server.loadgame) { - console.assert(old_spawn_parms instanceof Array, 'old_spawn_parms is an array'); - - for (let i = 0; i < client.spawn_parms.length; i++) { - client.spawn_parms[i] = old_spawn_parms[i]; - } - } else { - SV.server.gameAPI.SetNewParms(); - for (let i = 0; i < client.spawn_parms.length; i++) { - client.spawn_parms[i] = SV.server.gameAPI[`parm${i + 1}`]; - } - } - } - - SV.messages.sendServerData(client); - } - - static CheckForNewClients() { - while (true) { - const ret = NET.CheckNewConnections(); - if (!ret) { - return; - } - let i; - for (i = 0; i < SV.svs.maxclients; i++) { - if (SV.svs.clients[i].state < ServerClient.STATE.CONNECTED) { - break; - } - } - if (i === SV.svs.maxclients) { - Con.Print('SV.CheckForNewClients: Server is full\n'); - const message = new SzBuffer(32); - message.writeByte(Protocol.svc.disconnect); - message.writeString('Server is full'); - NET.SendUnreliableMessage(ret, message); - NET.Close(ret); - return; - } - const client = SV.svs.clients[i]; - SV.ConnectClient(client, ret); - NET.activeconnections++; - eventBus.publish('server.client.connected', client.num, client.name); - } - } - - // ============================================================================= - // UTILITIES & HELPERS - // Miscellaneous helper functions for models, spawn parameters, etc. - // ============================================================================= - - static ModelIndex(name) { - if (!name) { - return 0; - } - for (let i = 0; i < SV.server.modelPrecache.length; i++) { - if (SV.server.modelPrecache[i] === name) { - return i; - } - } - console.assert(false, 'model must be precached', name); - return null; - } - - static SaveSpawnparms() { - if ('serverflags' in SV.server.gameAPI) { - SV.svs.serverflags = SV.server.gameAPI.serverflags; - } - - for (let i = 0; i < SV.svs.maxclients; i++) { - /** @type {ServerClient} */ - const client = SV.svs.clients[i]; - - if (client.state < ServerClient.STATE.CONNECTED) { - continue; - } - - client.saveSpawnparms(); - } - } - - static HasMap(mapname) { - console.trace('SV.HasMap called'); - return Mod.known['maps/' + mapname + '.bsp'] !== undefined; - } - - static async SpawnServer(mapname) { - // Ensure hostname is set - if (NET.hostname.string.trim() === '') { - NET.hostname.set('UNNAMED'); - } - - eventBus.publish('server.spawning', { mapname }); - Con.DPrint('SpawnServer: ' + mapname + '\n'); - - // If server is already active, notify clients about map change - if (SV.server.active) { - SV.#notifyClientsOfMapChange(mapname); - } - - // Clear memory and load game progs - Con.DPrint('Clearing memory\n'); - Mod.ClearAll(Mod.scope.server); - await SV.#loadGameProgs(); - - // Initialize edicts and server state - SV.#initializeEdicts(); - - // Load world model - if (!await SV.#loadWorldModel(mapname)) { - return false; - } - - // Initialize shared player-move collision context - SV.pmove.setWorldmodel(SV.server.worldmodel); - - // Setup area nodes for spatial partitioning - SV.area.initOctree(SV.server.worldmodel.mins, SV.server.worldmodel.maxs); - - // Setup model and sound precache - SV.#setupModelPrecache(); - - // Setup player entities - if (!SV.#setupPlayerEntities()) { - return false; - } - - // Initialize light styles - SV.#initializeLightStyles(); - - // Setup dynamic field compression - SV.#setupClientDataFields(); - SV.#setupExtendedEntityFields(); - - // Reset event bus subscriptions - SV.server.eventBus.unsubscribeAll(); - - // Initialize navigation graph - SV.server.navigation = new Navigation(SV.server.worldmodel); - - // Initialize the game - SV.server.gameAPI.init(mapname, SV.svs.serverflags); - - // Spawn worldspawn entity - if (!SV.#spawnWorldspawnEntity()) { - return false; - } - - // Wait on all precached models to load - await SV.WaitForPrecachedResources(); - - // Populate all edicts by the entities file - await ED.LoadFromFile(SV.server.worldmodel.entities); - - // Finalize and notify clients - SV.#finalizeServerSpawn(mapname); - - SV.svs.changelevelIssued = false; - - return true; - } - - static ShutdownServer(isCrashShutdown) { - // tell the game we are shutting down the game - SV.server.gameAPI.shutdown(isCrashShutdown); - - // make sure all references are dropped - SV.server.active = false; - SV.server.loading = false; - SV.server.worldmodel = null; - SV.server.gameAPI = null; - - // unlink all edicts from client structures, reset data - for (const client of SV.svs.clients) { - client.clear(); - } - - // purge out all edicts - for (const edict of SV.server.edicts) { - // explicitly tell entities to free memory - edict.clear(); - edict.freeEdict(); - } - - SV.server.edicts.length = 0; - SV.server.num_edicts = 0; - - for (const model of SV.server.models) { - if (model instanceof Promise) { - // still loading, cannot reset - /** @type {Promise} */ void (model).then((m) => m.reset()); - continue; - } - - if (model) { - model.reset(); // TODO: should be .free() in future - } - } - - SV.server.models.length = 0; - - if (SV.server.navigation) { - SV.server.navigation.shutdown(); - SV.server.navigation = null; - } - - SV.server.eventBus.unsubscribeAll(); - - // reset all static server state - SV.svs.changelevelIssued = false; - - if (isCrashShutdown) { - Con.PrintWarning('Server shut down due to a crash!\n'); - return; - } - - Con.DPrint('Server shut down.\n'); - - // TODO: send event - } - - /** - * Writes a cvar to a message stream. - * @param {SzBuffer} msg message stream - * @param {Cvar} cvar cvar to write - */ - static WriteCvar(msg, cvar) { - if (cvar.flags & Cvar.FLAG.SECRET) { - msg.writeString(cvar.name); - msg.writeString(cvar.string ? 'REDACTED' : ''); - } else { - msg.writeString(cvar.name); - msg.writeString(cvar.string); - } - } - - /** - * Called when a cvar changes. - * @param {Cvar} cvar cvar that changed - */ - static CvarChanged(cvar) { - for (let i = 0; i < SV.svs.maxclients; i++) { - const client = SV.svs.clients[i]; - if (client.state < ServerClient.STATE.CONNECTED) { - continue; - } - - client.message.writeByte(Protocol.svc.cvar); - client.message.writeByte(1); - SV.WriteCvar(client.message, cvar); - } - } - - // ============================================================================= - // CLIENT COMMUNICATION - // Functions for reading client input and handling client messages - // ============================================================================= - - /** - * Reads the movement command from a client. - * @param {ServerClient} client client - */ - static ReadClientMove(client) { - const cmd = new Protocol.UserCmd(); - cmd.msec = NET.message.readByte(); - cmd.angles = NET.message.readAngleVector(); - cmd.forwardmove = NET.message.readShort(); - cmd.sidemove = NET.message.readShort(); - cmd.upmove = NET.message.readShort(); - cmd.buttons = NET.message.readByte(); - cmd.impulse = NET.message.readByte(); - const seq = NET.message.readByte(); - - // Apply entity-side fields from the latest command (QuakeC compatibility) - client.edict.entity.button0 = (cmd.buttons & Protocol.button.attack) === 1; - client.edict.entity.button1 = ((cmd.buttons & Protocol.button.use) >> 2) === 1; - client.edict.entity.button2 = ((cmd.buttons & Protocol.button.jump) >> 1) === 1; - client.edict.entity.v_angle = cmd.angles; - if (cmd.impulse !== 0) { - client.edict.entity.impulse = cmd.impulse; - } - - // While paused, keep the latest command/entity-side compatibility fields - // in sync, but do not enqueue movement for later replay. - if (SV.server.paused) { - client.cmd.set(cmd); - client.lastMoveSequence = seq; - return; - } - - // Queue the command for per-command processing in physicsClient - // (QW-style: each command is simulated individually so msec matches - // what the client predicted, even when multiple arrive in one frame). - client.pendingCmds.push(cmd); - - // Keep client.cmd as the latest for backwards-compat code paths - client.cmd.set(cmd); - client.lastMoveSequence = seq; - } - - /** - * Handles a rcon request from a client. - * @param {ServerClient} client client - */ - static HandleRconRequest(client) { - const message = client.message; - - const password = NET.message.readString(); - const cmd = NET.message.readString(); - - const rconPassword = SV.rcon_password.string; - - if (rconPassword === '' || rconPassword !== password) { - message.writeByte(Protocol.svc.print); - message.writeString('Wrong rcon password!\n'); - if (rconPassword === '') { - Con.Print(`SV.HandleRconRequest: rcon attempted by ${client.name} from ${client.netconnection.address}: ${cmd}\n`); - } - return; - } - - Con.Print(`[${client.name}@${client.netconnection.address}] ${cmd}\n`); - - Con.StartCapturing(); - void Cmd.ExecuteString(cmd); - - const response = Con.StopCapturing(); - message.writeByte(Protocol.svc.print); - message.writeString(response); - } - - /** - * Reads the movement command from a client. - * @param {ServerClient} client client - * @returns {boolean} true if successful, false if failed - */ - static ReadClientMessage(client) { - // Process all pending network messages - while (true) { - const ret = NET.GetMessage(client.netconnection); - - if (ret === -1) { - Con.DPrint(`SV.ReadClientMessage: NET.GetMessage from ${client.name} (${client.netconnection.address}) failed\n`); - return false; - } - - if (ret === 0) { - return true; // No more messages - } - - NET.message.beginReading(); - - // Process all commands in this message - while (true) { - if (client.state < ServerClient.STATE.CONNECTED) { - return false; - } - - if (NET.message.badread) { - Con.Print('SV.ReadClientMessage: badread\n'); - return false; - } - - // Update client ping time - client.ping_times[client.num_pings++ % client.ping_times.length] = SV.server.time - client.sync_time; - - const cmd = NET.message.readChar(); - - if (cmd === -1) { - break; // End of message - } - - if (!SV.#processClientCommand(client, cmd)) { - return false; // Client should disconnect - } - } - } - } - - static RunClients() { - for (let i = 0; i < SV.svs.maxclients; i++) { - const client = SV.svs.clients[i]; - if (client.state < ServerClient.STATE.CONNECTED) { - continue; - } - if (!SV.ReadClientMessage(client)) { - Host.DropClient(client, false, 'Connectivity issues, failed to read message'); - continue; - } - if (client.state < ServerClient.STATE.CONNECTED) { - client.cmd.reset(); - continue; - } - // TODO: drop clients without an update - SV.clientPhysics.clientThink(client.edict, client); - } - } - - /** - * Finds a client by name. - * @param {string} name name of the client - * @returns {ServerClient|null} the client if found, null otherwise - */ - static FindClientByName(name) { - return SV.svs.clients - .filter((client) => client.state >= ServerClient.STATE.CONNECTED) - .find((client) => client.name === name) || null; - } - - /** - * Notifies all connected clients about a map change and resets their state. - * @param {string} mapname name of the new map - */ - static #notifyClientsOfMapChange(mapname) { - // Make sure that all client states are partially reset and ready for a new map - for (const client of SV.svs.clients) { - if (client.state < ServerClient.STATE.CONNECTED) { - continue; - } - - client.changelevel(mapname); - } - - // Shut down navigation, since map has changed - if (SV.server.navigation) { - SV.server.navigation.shutdown(); - SV.server.navigation = null; - } - } - - /** - * Loads the game progs and initializes game API. - * Sets up gameAPI, gameVersion, gameName, and gameCapabilities. - */ - static async #loadGameProgs() { - SV.server.gameAPI = PR.QuakeJS ? new PR.QuakeJS.ServerGameAPI(ServerEngineAPI) : await PR.LoadProgs(); - SV.server.gameVersion = `${(PR.QuakeJS ? `${PR.QuakeJS.identification.version.join('.')} QuakeJS` : `${PR.crc} CRC`)}`; - SV.server.gameName = PR.QuakeJS ? PR.QuakeJS.identification.name : COM.game; - SV.server.gameCapabilities = PR.QuakeJS ? PR.QuakeJS.identification.capabilities : PR.capabilities; - - Con.DPrint('Game progs loaded\n'); - } - - /** - * Initializes the edict array and server state. - * Preallocates edicts and resets server state variables. - */ - static #initializeEdicts() { - SV.server.edicts.length = 0; - - // Preallocating up to Def.limits.edicts, we can extend that later during runtime - for (let i = 0; i < Def.limits.edicts; i++) { - SV.server.edicts[i] = new ServerEdict(i); - } - - // Clear message buffers - SV.server.datagram.clear(); - SV.server.reliable_datagram.clear(); - SV.server.signon.clear(); - - // Hooking up the edicts reserved for clients - SV.server.num_edicts = SV.svs.maxclients + 1; - - // Reset server state - SV.server.loading = true; - SV.server.paused = false; - SV.server.loadgame = false; - SV.server.time = 1.0; - SV.server.lastcheck = 0; - SV.server.lastchecktime = 0.0; - - Con.DPrint('Edicts initialized\n'); - } - - /** - * Loads and initializes the world model for the given map. - * @param {string} mapname name of the map to load - * @returns {Promise} true if successful, false if map couldn't be loaded - */ - static async #loadWorldModel(mapname) { - SV.server.mapname = mapname; - SV.server.worldmodel = /** @type {BrushModel} */ (await Mod.ForNameAsync('maps/' + mapname + '.bsp', false, Mod.scope.server)); - - if (SV.server.worldmodel === null) { - Con.PrintWarning('SV.SpawnServer: Cannot start server, unable to load map ' + mapname + '\n'); - SV.server.active = false; - return false; - } - - Con.DPrint('World model loaded\n'); - - return true; - } - - /** - * Sets up model precache array including world model and submodels. - */ - static #setupModelPrecache() { - SV.server.models.length = 2; - SV.server.models[0] = null; // null model - SV.server.models[1] = SV.server.worldmodel; - - SV.server.soundPrecache.length = 1; - SV.server.soundPrecache[0] = ''; // null sound - - SV.server.modelPrecache.length = 2 + SV.server.worldmodel.submodels.length; - SV.server.modelPrecache[0] = ''; // null model - SV.server.modelPrecache[1] = SV.server.worldmodel.name; - - // Precache all submodels (brushes connected to entities like doors) - for (let i = 1; i <= SV.server.worldmodel.submodels.length; i++) { - SV.server.modelPrecache[i + 1] = '*' + i; - SV.server.models[i + 1] = Mod.ForName('*' + i, Mod.scope.server); - } - - Con.DPrint('Model precache setup complete\n'); - } - - /** - * Prepares player entities in the client edict slots. - * @returns {boolean} true if successful, false if game doesn't support player entities - */ - static #setupPlayerEntities() { - for (let i = 0; i < SV.svs.maxclients; i++) { - const ent = SV.server.edicts[i + 1]; - - // We need to spawn the player entity in those client edict slots - if (!SV.server.gameAPI.prepareEntity(ent, 'player')) { - Con.PrintWarning('SV.SpawnServer: Cannot start server, because game does not know what a player entity is.\n'); - SV.server.active = false; - return false; - } - } - - Con.DPrint('Player entities setup complete\n'); - - return true; - } - - /** - * Initializes light styles array. - */ - static #initializeLightStyles() { - SV.server.lightstyles = []; - for (let i = 0; i <= Def.limits.lightstyles; i++) { - SV.server.lightstyles[i] = ''; - } - - Con.DPrint('Light styles initialized\n'); - } - - /** - * Configures clientdata compression fields for dynamic entity serialization. - */ - static #setupClientDataFields() { - if (SV.server.gameCapabilities.includes(Defs.gameCapabilities.CAP_CLIENTDATA_DYNAMIC)) { - console.assert('clientdataFields' in SV.server.edicts[1].entity, 'CAP_CLIENTDATA_DYNAMIC requires clientdataFields on PlayerEntity'); - - const fields = SV.server.edicts[1].entity.clientdataFields; - - console.assert(fields instanceof Array, 'clientdataFields must be an array'); - - // Configure clientdata fields - SV.server.clientdataFields.length = 0; - SV.server.clientdataFields.push(...fields); - console.assert(SV.server.clientdataFields.length <= 32, 'clientdata must not have more than 32 fields'); - - // Select appropriate bits writer based on field count - if (fields.length <= 8) { - SV.server.clientdataFieldsBitsWriter = 'writeByte'; - } else if (fields.length <= 16) { - SV.server.clientdataFieldsBitsWriter = 'writeShort'; - } else if (fields.length <= 32) { - SV.server.clientdataFieldsBitsWriter = 'writeLong'; - } - - // Double check that all fields are actually defined - for (const field of fields) { - console.assert(SV.server.edicts[1].entity[field] !== undefined, `Undefined clientdata field ${field}`); - } - } - - Con.DPrint('Clientdata fields setup complete\n'); - } - - /** - * Configures extended entity field compression for client-side entities. - */ - static #setupExtendedEntityFields() { - if (SV.server.gameCapabilities.includes(Defs.gameCapabilities.CAP_ENTITY_EXTENDED)) { - const fields = SV.server.gameAPI.getClientEntityFields(); - - for (const [classname, extendedFields] of Object.entries(fields)) { - const clientEntityField = { - fields: [], - bitsWriter: null, - }; - - clientEntityField.fields.push(...extendedFields); - - // Select appropriate bits writer based on field count - if (extendedFields.length <= 8) { - clientEntityField.bitsWriter = 'writeByte'; - } else if (extendedFields.length <= 16) { - clientEntityField.bitsWriter = 'writeShort'; - } else if (extendedFields.length <= 32) { - clientEntityField.bitsWriter = 'writeLong'; - } - - SV.server.clientEntityFields[classname] = clientEntityField; - } - } - - Con.DPrint('Extended entity fields setup complete\n'); - } - - /** - * Spawns the worldspawn entity (edict 0). - * @returns {boolean} true if successful, false if worldspawn couldn't be created - */ - static #spawnWorldspawnEntity() { - const ent = SV.server.edicts[0]; - - if (!SV.server.gameAPI.prepareEntity(ent, 'worldspawn', { - model: SV.server.worldmodel.name, - modelindex: 1, - solid: Defs.solid.SOLID_BSP, - movetype: Defs.moveType.MOVETYPE_PUSH, - })) { - Con.PrintWarning('SV.SpawnServer: Cannot start server, because the game does not know what a worldspawn entity is.\n'); - SV.server.active = false; - return false; - } - - // Invoke the spawn function for the worldspawn - SV.server.gameAPI.spawnPreparedEntity(ent); - - Con.DPrint('Worldspawn entity spawned\n'); - - return true; - } - - static async WaitForPrecachedResources() { - for (let i = 0; i < SV.server.models.length; i++) { - const model = SV.server.models[i]; - - if (model instanceof Promise) { - // eslint-disable-next-line require-atomic-updates - SV.server.models[i] = await model; - } - } - - Con.DPrint('Pending precached resources loaded\n'); - } - - /** - * Finalizes server spawn by loading entities and notifying clients. - * @param {string} mapname name of the spawned map - */ - static #finalizeServerSpawn(mapname) { - SV.server.active = true; - SV.server.loading = false; - - // Run physics twice to settle entities - Host.frametime = 0.1; - SV.physics.physics(); - SV.physics.physics(); - - // Notify all active clients about the new map - for (let i = 0; i < SV.svs.maxclients; i++) { - const client = SV.svs.clients[i]; - if (client.state >= ServerClient.STATE.CONNECTED) { - SV.messages.sendServerData(client); - } - } - - // Initialize navigation - SV.server.navigation.init(); - - eventBus.publish('server.spawned', { mapname }); - Con.PrintSuccess('Server spawned.\n'); - } - - /** - * Handles a string command from the client. - * @param {ServerClient} client client sending the command - * @param {string} input command string - */ - static #handleClientStringCommand(client, input) { - const matchedCommand = ALLOWED_CLIENT_COMMANDS.find((command) => - input.toLowerCase().startsWith(command), - ); - - if (matchedCommand) { - void Cmd.ExecuteString(input, client); - } else { - Con.Print(`${client.name} tried to ${input}!\n`); - } - } - - /** - * Processes a single client command from the message buffer. - * @param {ServerClient} client client - * @param {number} cmd command type - * @returns {boolean} false if client should disconnect, true otherwise - */ - static #processClientCommand(client, cmd) { - switch (cmd) { - case Protocol.clc.nop: - Con.DPrint(`${client.netconnection.address} sent a nop\n`); - return true; - - case Protocol.clc.stringcmd: { - const input = NET.message.readString(); - SV.#handleClientStringCommand(client, input); - return true; - } - - case Protocol.clc.sync: - client.sync_time = NET.message.readFloat(); - return true; - - case Protocol.clc.rconcmd: - SV.HandleRconRequest(client); - return true; - - case Protocol.clc.disconnect: - return false; // Client disconnect - - case Protocol.clc.move: - SV.ReadClientMove(client); - return true; - - default: - Con.DPrint(`SV.ReadClientMessage: unknown command ${cmd} from ${client.netconnection.address}\n`); - return false; - } - } -}; - -/** - * Simple class hooking up all movevars with corresponding cvars. - */ -class PlayerMoveCvars extends MoveVars { - // @ts-ignore - get gravity() { return SV.gravity.value; } - // @ts-ignore - get stopspeed() { return SV.stopspeed.value; } - // @ts-ignore - get maxspeed() { return SV.maxspeed.value; } - // @ts-ignore - get spectatormaxspeed() { return SV.spectatormaxspeed.value; } - // @ts-ignore - get accelerate() { return SV.accelerate.value; } - // @ts-ignore - get airaccelerate() { return SV.airaccelerate.value; } - // @ts-ignore - get wateraccelerate() { return SV.wateraccelerate.value; } - // @ts-ignore - get friction() { return SV.friction.value; } - // @ts-ignore - get waterfriction() { return SV.waterfriction.value; } - // @ts-ignore - get edgefriction() { return SV.edgefriction.value; } - - set gravity(_value) { } - set stopspeed(_value) { } - set maxspeed(_value) { } - set spectatormaxspeed(_value) { } - set accelerate(_value) { } - set airaccelerate(_value) { } - set wateraccelerate(_value) { } - set friction(_value) { } - set waterfriction(_value) { } - set edgefriction(_value) { } - - /** - * Writes the movevars to the client. - * @param {SzBuffer} message message stream - */ - sendToClient(message) { - message.writeFloat(this.gravity); - message.writeFloat(this.stopspeed); - message.writeFloat(this.maxspeed); - message.writeFloat(this.spectatormaxspeed); - message.writeFloat(this.accelerate); - message.writeFloat(this.airaccelerate); - message.writeFloat(this.wateraccelerate); - message.writeFloat(this.friction); - message.writeFloat(this.waterfriction); - message.writeFloat(this.entgravity); - } - - // CR: leaving out entgravity, it's entity specific -} - -sharedCollisionModelSource.configureServer({ - getWorldEntity: () => SV.server?.edicts?.[0] ?? null, - getWorldModel: () => SV.server?.worldmodel ?? null, - getModels: () => SV.server?.models ?? null, -}); +export { default, ServerEntityState } from './Server.ts'; diff --git a/source/engine/server/Server.ts b/source/engine/server/Server.ts new file mode 100644 index 00000000..cf79338b --- /dev/null +++ b/source/engine/server/Server.ts @@ -0,0 +1,1055 @@ +import type { ServerGameInterface } from '../../shared/GameInterfaces.ts'; +import type { BaseModel } from '../common/model/BaseModel.ts'; +import type { QSocket } from '../network/NetworkDrivers.ts'; + +import Cvar from '../common/Cvar.ts'; +import { MoveVars, Pmove } from '../common/Pmove.ts'; +import { SzBuffer } from '../network/MSG.ts'; +import * as Protocol from '../network/Protocol.ts'; +import * as Def from './../common/Def.ts'; +import Cmd, { ConsoleCommand } from '../common/Cmd.ts'; +import { ED, ServerEdict } from './Edict.mjs'; +import { EventBus, eventBus, getCommonRegistry } from '../registry.mjs'; +import { ServerEngineAPI } from '../common/GameAPIs.ts'; +import * as Defs from '../../shared/Defs.ts'; +import { Navigation } from './Navigation.mjs'; +import { ServerPhysics } from './physics/ServerPhysics.ts'; +import { ServerClientPhysics } from './physics/ServerClientPhysics.ts'; +import { ServerMessages } from './ServerMessages.mjs'; +import { ServerMovement } from './physics/ServerMovement.ts'; +import { ServerArea } from './physics/ServerArea.ts'; +import { ServerCollision } from './physics/ServerCollision.ts'; +import { sharedCollisionModelSource } from '../common/CollisionModelSource.ts'; +import { BrushModel } from '../common/Mod.ts'; +import { ServerClient } from './Client.mjs'; + +export { ServerEntityState } from './ServerEntityState.mjs'; + +let { COM, Con, Host, Mod, NET, PR } = getCommonRegistry(); + +eventBus.subscribe('registry.frozen', () => { + ({ COM, Con, Host, Mod, NET, PR } = getCommonRegistry()); +}); + +type BitsWriter = 'writeByte' | 'writeShort' | 'writeLong'; +type ScheduledGameCommand = () => void; +type ServerClientSpawnParameters = ServerClient['spawn_parms']; +type ServerModel = BaseModel | null | Promise; +type DynamicSpawnClientEntity = ServerClient['entity'] & { + restoreSpawnParameters(data: string | null): void; +}; +type PlayerClientdataEntity = ServerClient['entity'] & { + clientdataFields: string[]; +} & Record; + +interface ServerRuntimeGameAPI extends ServerGameInterface { + frametime: number; + time: number; + serverflags?: number; +} + +interface LegacySpawnParmsGameAPI extends ServerRuntimeGameAPI { + SetNewParms(): void; + [key: `parm${number}`]: number; +} + +interface ClientEntityFieldConfig { + fields: string[]; + bitsWriter: BitsWriter | null; +} + +interface ServerState { + time: number; + num_edicts: number; + datagram: SzBuffer; + expedited_datagram: SzBuffer; + reliable_datagram: SzBuffer; + signon: SzBuffer; + edicts: ServerEdict[]; + mapname: string | null; + worldmodel: BrushModel | null; + eventBus: EventBus; + navigation: Navigation | null; + gameAPI: ServerRuntimeGameAPI | null; + gameVersion: string | null; + gameName: string | null; + gameCapabilities: Defs.gameCapabilities[]; + clientdataFields: string[]; + clientdataFieldsBitsWriter: BitsWriter | null; + clientEntityFields: Record; + models: ServerModel[]; + soundPrecache: string[]; + modelPrecache: string[]; + lightstyles: string[]; + active: boolean; + loading: boolean; + paused: boolean; + loadgame: boolean; + lastcheck: number; + lastchecktime: number; +} + +interface ServerStaticState { + changelevelIssued: boolean; + clients: ServerClient[]; + maxclients: number; + maxclientslimit: number; + gamestate: null; + maplist: string[]; + serverflags: number; + spawnedClients(): Generator; +} + +const ALLOWED_CLIENT_COMMANDS = Object.freeze([ + 'status', + 'god', + 'notarget', + 'fly', + 'name', + 'noclip', + 'say', + 'say_team', + 'tell', + 'color', + 'kill', + 'pause', + 'spawn', + 'begin', + 'prespawn', + 'kick', + 'ping', + 'give', + 'ban', +] as const); + +/** + * Main server class with all server-related functionality. + * All properties and methods are static. + */ +export default class SV { + /** current server state */ + static server: ServerState = { + time: 0, + num_edicts: 0, + datagram: new SzBuffer(16384, 'SV.server.datagram'), + expedited_datagram: new SzBuffer(16384, 'SV.server.expedited_datagram'), + reliable_datagram: new SzBuffer(16384, 'SV.server.reliable_datagram'), + signon: new SzBuffer(16384, 'SV.server.signon'), + edicts: [], + mapname: null, + worldmodel: null, + eventBus: new EventBus('server-game'), + navigation: null, + gameAPI: null, + gameVersion: null, + gameName: null, + gameCapabilities: [], + clientdataFields: [], + clientdataFieldsBitsWriter: null, + clientEntityFields: {}, + models: [], + soundPrecache: [], + modelPrecache: [], + lightstyles: [], + active: false, + loading: false, + paused: false, + loadgame: false, + lastcheck: 0, + lastchecktime: 0, + }; + + /** server static, state across maps */ + static svs: ServerStaticState = { + changelevelIssued: false, + clients: [], + maxclients: 0, + maxclientslimit: 32, + gamestate: null, + maplist: [], + serverflags: 0, + + *spawnedClients() { + for (const client of this.clients) { + if (client.state === ServerClient.STATE.SPAWNED) { + yield client; + } + } + }, + }; + + static physics = new ServerPhysics(); + static clientPhysics = new ServerClientPhysics(); + static messages = new ServerMessages(); + static movement = new ServerMovement(); + static area = new ServerArea(sharedCollisionModelSource); + static collision = new ServerCollision(sharedCollisionModelSource); + + /** shared player-move collision context */ + static pmove: Pmove | null = null; + + static maxvelocity: Cvar | null = null; + static edgefriction: Cvar | null = null; + static stopspeed: Cvar | null = null; + static accelerate: Cvar | null = null; + static idealpitchscale: Cvar | null = null; + static aim: Cvar | null = null; + static nostep: Cvar | null = null; + static cheats: Cvar | null = null; + static gravity: Cvar | null = null; + static friction: Cvar | null = null; + static maxspeed: Cvar | null = null; + static airaccelerate: Cvar | null = null; + static wateraccelerate: Cvar | null = null; + static spectatormaxspeed: Cvar | null = null; + static waterfriction: Cvar | null = null; + static rcon_password: Cvar | null = null; + static maplist: Cvar | null = null; + static nextmap: Cvar | null = null; + static ['public']: Cvar | null = null; + + /** Scheduled game commands. */ + static _scheduledGameCommands: ScheduledGameCommand[] = []; + + static #requireGameAPI(): ServerRuntimeGameAPI { + if (SV.server.gameAPI === null) { + throw new Error('SV.server.gameAPI is not initialized'); + } + + return SV.server.gameAPI; + } + + static #requireWorldModel(): BrushModel { + if (SV.server.worldmodel === null) { + throw new Error('SV.server.worldmodel is not initialized'); + } + + return SV.server.worldmodel; + } + + static #requirePmove(): Pmove { + if (SV.pmove === null) { + throw new Error('SV.pmove is not initialized'); + } + + return SV.pmove; + } + + static #requireNavigation(): Navigation { + if (SV.server.navigation === null) { + throw new Error('SV.server.navigation is not initialized'); + } + + return SV.server.navigation; + } + + static #getLegacySpawnParmsGameAPI(): LegacySpawnParmsGameAPI { + return SV.#requireGameAPI() as unknown as LegacySpawnParmsGameAPI; + } + + static InitPmove(): void { + SV.pmove = new Pmove(); + SV.pmove.movevars = new PlayerMoveCvars(); + } + + static Init(): void { + SV.maxvelocity = new Cvar('sv_maxvelocity', '2000', Cvar.FLAG.SERVER); + SV.edgefriction = new Cvar('edgefriction', '2', Cvar.FLAG.SERVER); + SV.stopspeed = new Cvar('sv_stopspeed', '100', Cvar.FLAG.SERVER); + SV.accelerate = new Cvar('sv_accelerate', '10', Cvar.FLAG.SERVER); + SV.idealpitchscale = new Cvar('sv_idealpitchscale', '0.8'); + SV.aim = new Cvar('sv_aim', '0.93'); + SV.nostep = new Cvar('sv_nostep', '0'); + SV.cheats = new Cvar('sv_cheats', '0', Cvar.FLAG.SERVER); + SV.gravity = new Cvar('sv_gravity', '800', Cvar.FLAG.SERVER); + SV.friction = new Cvar('sv_friction', '4', Cvar.FLAG.SERVER); + SV.maxspeed = new Cvar('sv_maxspeed', '320', Cvar.FLAG.SERVER); + SV.airaccelerate = new Cvar('sv_airaccelerate', '0.7', Cvar.FLAG.SERVER); + SV.wateraccelerate = new Cvar('sv_wateraccelerate', '10', Cvar.FLAG.SERVER); + SV.spectatormaxspeed = new Cvar('sv_spectatormaxspeed', '500', Cvar.FLAG.SERVER); + SV.waterfriction = new Cvar('sv_waterfriction', '4', Cvar.FLAG.SERVER); + SV.rcon_password = new Cvar('sv_rcon_password', '', Cvar.FLAG.ARCHIVE); + SV.public = new Cvar('sv_public', '1', Cvar.FLAG.ARCHIVE | Cvar.FLAG.SERVER, 'Make this server publicly listed in the master server'); + + Navigation.Init(); + + Cmd.AddCommand('nav', class NavCommand extends ConsoleCommand { + run(): void { + if (!SV.server.navigation) { + Con.Print('navigation not initialized, you have to spawn a server first\n'); + return; + } + + SV.server.navigation.build(); + } + }); + + eventBus.subscribe('cvar.changed', (name: string) => { + const cvar = Cvar.FindVar(name)!; + + if ((cvar.flags & Cvar.FLAG.SERVER) && SV.server.active) { + SV.CvarChanged(cvar); + } + }); + + SV.InitNextmapStuff(); + SV.InitPmove(); + SV.area.initBoxHull(); + } + + static InitNextmapStuff(): void { + SV.maplist = new Cvar('sv_maplist', '', Cvar.FLAG.NONE, 'Comma-separated list of maps to cycle through after each map change'); + SV.nextmap = new Cvar('sv_nextmap', '', Cvar.FLAG.SERVER, 'Next map to change to after the current one, will be autopopulated with the next map in sv_maplist after each map change'); + + eventBus.subscribe('cvar.changed.sv_maplist', () => { + if (SV.maplist!.string.trim() === '') { + SV.svs.maplist.length = 0; + return; + } + + SV.svs.maplist = SV.maplist!.string.split(',').map((value) => value.trim()).filter((value) => value.length > 0); + }); + + eventBus.subscribe('server.spawning', ({ mapname }: { mapname: string }) => { + if (SV.svs.maplist.length === 0) { + return; + } + + if (!SV.svs.maplist.includes(mapname)) { + SV.nextmap!.set(SV.svs.maplist[0]); + return; + } + + const currentIndex = SV.svs.maplist.indexOf(mapname); + const nextIndex = (currentIndex + 1) % SV.svs.maplist.length; + SV.nextmap!.set(SV.svs.maplist[nextIndex]); + }); + + eventBus.subscribe('server.shutdown', () => { + SV.nextmap!.reset(); + }); + } + + static RunScheduledGameCommands(): void { + while (SV._scheduledGameCommands.length > 0) { + const command = SV._scheduledGameCommands.shift(); + + command?.(); + } + } + + static ScheduleGameCommand(command: ScheduledGameCommand): void { + SV._scheduledGameCommands.push(command); + } + + static ConnectClient(client: ServerClient, netconnection: QSocket): void { + Con.DPrint(`Client ${netconnection.address} connected\n`); + + const oldSpawnParms: ServerClientSpawnParameters = SV.server.loadgame ? client.spawn_parms : null; + + client.clear(); + client.name = 'unconnected'; + client.netconnection = netconnection; + client.state = ServerClient.STATE.CONNECTING; + client.old_frags = Infinity; + + if (SV.server.gameCapabilities.includes(Defs.gameCapabilities.CAP_SPAWNPARMS_DYNAMIC)) { + const entity = client.entity as DynamicSpawnClientEntity; + entity.restoreSpawnParameters(typeof oldSpawnParms === 'string' ? oldSpawnParms : null); + } else if (SV.server.gameCapabilities.includes(Defs.gameCapabilities.CAP_SPAWNPARMS_LEGACY)) { + const spawnParms = client.spawn_parms as number[]; + + if (SV.server.loadgame) { + console.assert(oldSpawnParms instanceof Array, 'old_spawn_parms is an array'); + + for (let i = 0; i < spawnParms.length; i++) { + spawnParms[i] = oldSpawnParms![i] as number; + } + } else { + const gameAPI = SV.#getLegacySpawnParmsGameAPI(); + gameAPI.SetNewParms(); + + for (let i = 0; i < spawnParms.length; i++) { + spawnParms[i] = gameAPI[`parm${i + 1}`]; + } + } + } + + SV.messages.sendServerData(client); + } + + static CheckForNewClients(): void { + while (true) { + const ret = NET.CheckNewConnections(); + + if (!ret) { + return; + } + + let i: number; + + for (i = 0; i < SV.svs.maxclients; i++) { + if (SV.svs.clients[i].state < ServerClient.STATE.CONNECTED) { + break; + } + } + + if (i === SV.svs.maxclients) { + Con.Print('SV.CheckForNewClients: Server is full\n'); + const message = new SzBuffer(32); + message.writeByte(Protocol.svc.disconnect); + message.writeString('Server is full'); + NET.SendUnreliableMessage(ret, message); + NET.Close(ret); + return; + } + + const client = SV.svs.clients[i]; + SV.ConnectClient(client, ret); + NET.activeconnections++; + eventBus.publish('server.client.connected', client.num, client.name); + } + } + + static ModelIndex(name: string | null): number | null { + if (!name) { + return 0; + } + + for (let i = 0; i < SV.server.modelPrecache.length; i++) { + if (SV.server.modelPrecache[i] === name) { + return i; + } + } + + console.assert(false, 'model must be precached', name); + return null; + } + + static SaveSpawnparms(): void { + const gameAPI = SV.#requireGameAPI(); + + if ('serverflags' in gameAPI) { + SV.svs.serverflags = gameAPI.serverflags ?? 0; + } + + for (let i = 0; i < SV.svs.maxclients; i++) { + const client = SV.svs.clients[i]; + + if (client.state < ServerClient.STATE.CONNECTED) { + continue; + } + + client.saveSpawnparms(); + } + } + + static HasMap(mapname: string): boolean { + console.trace('SV.HasMap called'); + return Mod.known[`maps/${mapname}.bsp`] !== undefined; + } + + static async SpawnServer(mapname: string): Promise { + if (NET.hostname.string.trim() === '') { + NET.hostname.set('UNNAMED'); + } + + eventBus.publish('server.spawning', { mapname }); + Con.DPrint(`SpawnServer: ${mapname}\n`); + + if (SV.server.active) { + SV.#notifyClientsOfMapChange(mapname); + } + + Con.DPrint('Clearing memory\n'); + Mod.ClearAll(Mod.scope.server); + await SV.#loadGameProgs(); + + SV.#initializeEdicts(); + + if (!await SV.#loadWorldModel(mapname)) { + return false; + } + + const worldmodel = SV.#requireWorldModel(); + const pmove = SV.#requirePmove(); + pmove.setWorldmodel(worldmodel); + + SV.area.initOctree(worldmodel.mins, worldmodel.maxs); + SV.#setupModelPrecache(); + + if (!SV.#setupPlayerEntities()) { + return false; + } + + SV.#initializeLightStyles(); + SV.#setupClientDataFields(); + SV.#setupExtendedEntityFields(); + + SV.server.eventBus.unsubscribeAll(); + SV.server.navigation = new Navigation(worldmodel); + + const gameAPI = SV.#requireGameAPI(); + gameAPI.init(mapname, SV.svs.serverflags); + + if (!SV.#spawnWorldspawnEntity()) { + return false; + } + + await SV.WaitForPrecachedResources(); + await ED.LoadFromFile(worldmodel.entities!); + SV.#finalizeServerSpawn(mapname); + SV.svs.changelevelIssued = false; + + return true; + } + + static ShutdownServer(isCrashShutdown: boolean): void { + SV.server.gameAPI?.shutdown(isCrashShutdown); + + SV.server.active = false; + SV.server.loading = false; + SV.server.worldmodel = null; + SV.server.gameAPI = null; + + for (const client of SV.svs.clients) { + client.clear(); + } + + for (const edict of SV.server.edicts) { + edict.clear(); + edict.freeEdict(); + } + + SV.server.edicts.length = 0; + SV.server.num_edicts = 0; + + for (const model of SV.server.models) { + if (model instanceof Promise) { + void model.then((loadedModel) => loadedModel.reset()); + continue; + } + + model?.reset(); + } + + SV.server.models.length = 0; + + if (SV.server.navigation) { + SV.server.navigation.shutdown(); + SV.server.navigation = null; + } + + SV.server.eventBus.unsubscribeAll(); + SV.svs.changelevelIssued = false; + + if (isCrashShutdown) { + Con.PrintWarning('Server shut down due to a crash!\n'); + return; + } + + Con.DPrint('Server shut down.\n'); + } + + static WriteCvar(msg: SzBuffer, cvar: Cvar): void { + if (cvar.flags & Cvar.FLAG.SECRET) { + msg.writeString(cvar.name); + msg.writeString(cvar.string ? 'REDACTED' : ''); + return; + } + + msg.writeString(cvar.name); + msg.writeString(cvar.string); + } + + static CvarChanged(cvar: Cvar): void { + for (let i = 0; i < SV.svs.maxclients; i++) { + const client = SV.svs.clients[i]; + + if (client.state < ServerClient.STATE.CONNECTED) { + continue; + } + + client.message.writeByte(Protocol.svc.cvar); + client.message.writeByte(1); + SV.WriteCvar(client.message, cvar); + } + } + + static ReadClientMove(client: ServerClient): void { + const cmd = new Protocol.UserCmd(); + cmd.msec = NET.message.readByte(); + cmd.angles = NET.message.readAngleVector(); + cmd.forwardmove = NET.message.readShort(); + cmd.sidemove = NET.message.readShort(); + cmd.upmove = NET.message.readShort(); + cmd.buttons = NET.message.readByte(); + cmd.impulse = NET.message.readByte(); + const seq = NET.message.readByte(); + + console.assert(client.edict.entity !== null, 'ServerClient.entity requires a linked edict entity'); + + const entity = client.edict.entity as PlayerClientdataEntity; + + entity.button0 = (cmd.buttons & Protocol.button.attack) === 1; + entity.button1 = ((cmd.buttons & Protocol.button.use) >> 2) === 1; + entity.button2 = ((cmd.buttons & Protocol.button.jump) >> 1) === 1; + entity.v_angle = cmd.angles; + + if (cmd.impulse !== 0) { + entity.impulse = cmd.impulse; + } + + if (SV.server.paused) { + client.cmd.set(cmd); + client.lastMoveSequence = seq; + return; + } + + client.pendingCmds.push(cmd); + client.cmd.set(cmd); + client.lastMoveSequence = seq; + } + + static HandleRconRequest(client: ServerClient): void { + const message = client.message; + const netconnection = client.netconnection; + + if (netconnection === null) { + return; + } + + const password = NET.message.readString(); + const cmd = NET.message.readString(); + const rconPassword = SV.rcon_password!.string; + + if (rconPassword === '' || rconPassword !== password) { + message.writeByte(Protocol.svc.print); + message.writeString('Wrong rcon password!\n'); + + if (rconPassword === '') { + Con.Print(`SV.HandleRconRequest: rcon attempted by ${client.name} from ${netconnection.address}: ${cmd}\n`); + } + + return; + } + + Con.Print(`[${client.name}@${netconnection.address}] ${cmd}\n`); + + Con.StartCapturing(); + void Cmd.ExecuteString(cmd); + + const response = Con.StopCapturing(); + message.writeByte(Protocol.svc.print); + message.writeString(response); + } + + static ReadClientMessage(client: ServerClient): boolean { + const netconnection = client.netconnection; + + if (netconnection === null) { + return false; + } + + while (true) { + const ret = NET.GetMessage(netconnection); + + if (ret === -1) { + Con.DPrint(`SV.ReadClientMessage: NET.GetMessage from ${client.name} (${netconnection.address}) failed\n`); + return false; + } + + if (ret === 0) { + return true; + } + + NET.message.beginReading(); + + while (true) { + if (client.state < ServerClient.STATE.CONNECTED) { + return false; + } + + if (NET.message.badread) { + Con.Print('SV.ReadClientMessage: badread\n'); + return false; + } + + client.ping_times[client.num_pings++ % client.ping_times.length] = SV.server.time - client.sync_time; + + const cmd = NET.message.readChar(); + + if (cmd === -1) { + break; + } + + if (!SV.#processClientCommand(client, cmd)) { + return false; + } + } + } + } + + static RunClients(): void { + for (let i = 0; i < SV.svs.maxclients; i++) { + const client = SV.svs.clients[i]; + + if (client.state < ServerClient.STATE.CONNECTED) { + continue; + } + + if (!SV.ReadClientMessage(client)) { + Host.DropClient(client, false, 'Connectivity issues, failed to read message'); + continue; + } + + if (client.state < ServerClient.STATE.CONNECTED) { + client.cmd.reset(); + continue; + } + + SV.clientPhysics.clientThink(client.edict, client); + } + } + + static FindClientByName(name: string): ServerClient | null { + return SV.svs.clients + .filter((client) => client.state >= ServerClient.STATE.CONNECTED) + .find((client) => client.name === name) ?? null; + } + + static #notifyClientsOfMapChange(mapname: string): void { + for (const client of SV.svs.clients) { + if (client.state < ServerClient.STATE.CONNECTED) { + continue; + } + + client.changelevel(mapname); + } + + if (SV.server.navigation) { + SV.server.navigation.shutdown(); + SV.server.navigation = null; + } + } + + static async #loadGameProgs(): Promise { + const gameAPI = PR.QuakeJS ? new PR.QuakeJS.ServerGameAPI(ServerEngineAPI) : await PR.LoadProgs(); + + SV.server.gameAPI = gameAPI as ServerRuntimeGameAPI; + SV.server.gameVersion = PR.QuakeJS ? `${PR.QuakeJS.identification.version.join('.')} QuakeJS` : `${PR.crc} CRC`; + SV.server.gameName = PR.QuakeJS ? PR.QuakeJS.identification.name : COM.game; + SV.server.gameCapabilities = PR.QuakeJS ? PR.QuakeJS.identification.capabilities : PR.capabilities; + + Con.DPrint('Game progs loaded\n'); + } + + static #initializeEdicts(): void { + SV.server.edicts.length = 0; + + for (let i = 0; i < Def.limits.edicts; i++) { + SV.server.edicts[i] = new ServerEdict(i); + } + + SV.server.datagram.clear(); + SV.server.reliable_datagram.clear(); + SV.server.signon.clear(); + SV.server.num_edicts = SV.svs.maxclients + 1; + SV.server.loading = true; + SV.server.paused = false; + SV.server.loadgame = false; + SV.server.time = 1.0; + SV.server.lastcheck = 0; + SV.server.lastchecktime = 0.0; + + Con.DPrint('Edicts initialized\n'); + } + + static async #loadWorldModel(mapname: string): Promise { + SV.server.mapname = mapname; + SV.server.worldmodel = await Mod.ForNameAsync(`maps/${mapname}.bsp`, false, Mod.scope.server) as BrushModel | null; + + if (SV.server.worldmodel === null) { + Con.PrintWarning(`SV.SpawnServer: Cannot start server, unable to load map ${mapname}\n`); + SV.server.active = false; + return false; + } + + Con.DPrint('World model loaded\n'); + return true; + } + + static #setupModelPrecache(): void { + const worldmodel = SV.#requireWorldModel(); + + SV.server.models.length = 2; + SV.server.models[0] = null; + SV.server.models[1] = worldmodel; + + SV.server.soundPrecache.length = 1; + SV.server.soundPrecache[0] = ''; + + SV.server.modelPrecache.length = 2 + worldmodel.submodels.length; + SV.server.modelPrecache[0] = ''; + SV.server.modelPrecache[1] = worldmodel.name; + + for (let i = 1; i <= worldmodel.submodels.length; i++) { + SV.server.modelPrecache[i + 1] = `*${i}`; + SV.server.models[i + 1] = Mod.ForName(`*${i}`, Mod.scope.server) as BaseModel; + } + + Con.DPrint('Model precache setup complete\n'); + } + + static #setupPlayerEntities(): boolean { + const gameAPI = SV.#requireGameAPI(); + + for (let i = 0; i < SV.svs.maxclients; i++) { + const ent = SV.server.edicts[i + 1] as ServerEdict; + + if (!gameAPI.prepareEntity(ent, 'player')) { + Con.PrintWarning('SV.SpawnServer: Cannot start server, because game does not know what a player entity is.\n'); + SV.server.active = false; + return false; + } + } + + Con.DPrint('Player entities setup complete\n'); + return true; + } + + static #initializeLightStyles(): void { + SV.server.lightstyles = []; + + for (let i = 0; i <= Def.limits.lightstyles; i++) { + SV.server.lightstyles[i] = ''; + } + + Con.DPrint('Light styles initialized\n'); + } + + static #setupClientDataFields(): void { + if (SV.server.gameCapabilities.includes(Defs.gameCapabilities.CAP_CLIENTDATA_DYNAMIC)) { + const playerEntity = SV.server.edicts[1]?.entity; + + console.assert(playerEntity !== null, 'CAP_CLIENTDATA_DYNAMIC requires player entity'); + console.assert(playerEntity !== null && 'clientdataFields' in playerEntity, 'CAP_CLIENTDATA_DYNAMIC requires clientdataFields on PlayerEntity'); + + const typedPlayerEntity = playerEntity as PlayerClientdataEntity; + const fields = typedPlayerEntity.clientdataFields; + + console.assert(fields instanceof Array, 'clientdataFields must be an array'); + + SV.server.clientdataFields.length = 0; + SV.server.clientdataFields.push(...fields); + console.assert(SV.server.clientdataFields.length <= 32, 'clientdata must not have more than 32 fields'); + + if (fields.length <= 8) { + SV.server.clientdataFieldsBitsWriter = 'writeByte'; + } else if (fields.length <= 16) { + SV.server.clientdataFieldsBitsWriter = 'writeShort'; + } else if (fields.length <= 32) { + SV.server.clientdataFieldsBitsWriter = 'writeLong'; + } + + for (const field of fields) { + console.assert(typedPlayerEntity[field] !== undefined, `Undefined clientdata field ${field}`); + } + } + + Con.DPrint('Clientdata fields setup complete\n'); + } + + static #setupExtendedEntityFields(): void { + if (SV.server.gameCapabilities.includes(Defs.gameCapabilities.CAP_ENTITY_EXTENDED)) { + const fields = SV.#requireGameAPI().getClientEntityFields(); + + for (const [classname, extendedFields] of Object.entries(fields)) { + const clientEntityField: ClientEntityFieldConfig = { + fields: [], + bitsWriter: null, + }; + + clientEntityField.fields.push(...extendedFields); + + if (extendedFields.length <= 8) { + clientEntityField.bitsWriter = 'writeByte'; + } else if (extendedFields.length <= 16) { + clientEntityField.bitsWriter = 'writeShort'; + } else if (extendedFields.length <= 32) { + clientEntityField.bitsWriter = 'writeLong'; + } + + SV.server.clientEntityFields[classname] = clientEntityField; + } + } + + Con.DPrint('Extended entity fields setup complete\n'); + } + + static #spawnWorldspawnEntity(): boolean { + const ent = SV.server.edicts[0] as ServerEdict; + const worldmodel = SV.#requireWorldModel(); + const gameAPI = SV.#requireGameAPI(); + + if (!gameAPI.prepareEntity(ent, 'worldspawn', { + model: worldmodel.name, + modelindex: 1, + solid: Defs.solid.SOLID_BSP, + movetype: Defs.moveType.MOVETYPE_PUSH, + })) { + Con.PrintWarning('SV.SpawnServer: Cannot start server, because the game does not know what a worldspawn entity is.\n'); + SV.server.active = false; + return false; + } + + gameAPI.spawnPreparedEntity(ent); + Con.DPrint('Worldspawn entity spawned\n'); + return true; + } + + static async WaitForPrecachedResources(): Promise { + const resolvedModels: Array = []; + + for (const model of SV.server.models) { + if (model instanceof Promise) { + resolvedModels.push(await model); + continue; + } + + resolvedModels.push(model); + } + + SV.server.models.length = 0; + + for (const model of resolvedModels) { + SV.server.models.push(model); + } + + Con.DPrint('Pending precached resources loaded\n'); + } + + static #finalizeServerSpawn(mapname: string): void { + SV.server.active = true; + SV.server.loading = false; + + Host.frametime = 0.1; + SV.physics.physics(); + SV.physics.physics(); + + for (let i = 0; i < SV.svs.maxclients; i++) { + const client = SV.svs.clients[i]; + + if (client.state >= ServerClient.STATE.CONNECTED) { + SV.messages.sendServerData(client); + } + } + + SV.#requireNavigation().init(); + eventBus.publish('server.spawned', { mapname }); + Con.PrintSuccess('Server spawned.\n'); + } + + static #handleClientStringCommand(client: ServerClient, input: string): void { + const matchedCommand = ALLOWED_CLIENT_COMMANDS.find((command) => input.toLowerCase().startsWith(command)); + + if (matchedCommand) { + void Cmd.ExecuteString(input, client); + return; + } + + Con.Print(`${client.name} tried to ${input}!\n`); + } + + static #processClientCommand(client: ServerClient, cmd: number): boolean { + switch (cmd) { + case Protocol.clc.nop: + Con.DPrint(`${client.netconnection?.address ?? 'unknown'} sent a nop\n`); + return true; + + case Protocol.clc.stringcmd: { + const input = NET.message.readString(); + SV.#handleClientStringCommand(client, input); + return true; + } + + case Protocol.clc.sync: + client.sync_time = NET.message.readFloat(); + return true; + + case Protocol.clc.rconcmd: + SV.HandleRconRequest(client); + return true; + + case Protocol.clc.disconnect: + return false; + + case Protocol.clc.move: + SV.ReadClientMove(client); + return true; + + default: + Con.DPrint(`SV.ReadClientMessage: unknown command ${cmd} from ${client.netconnection?.address ?? 'unknown'}\n`); + return false; + } + } +} + +/** + * Simple class hooking up all movevars with corresponding cvars. + */ +class PlayerMoveCvars extends MoveVars { + // @ts-ignore + get gravity(): number { return SV.gravity!.value; } + // @ts-ignore + get stopspeed(): number { return SV.stopspeed!.value; } + // @ts-ignore + get maxspeed(): number { return SV.maxspeed!.value; } + // @ts-ignore + get spectatormaxspeed(): number { return SV.spectatormaxspeed!.value; } + // @ts-ignore + get accelerate(): number { return SV.accelerate!.value; } + // @ts-ignore + get airaccelerate(): number { return SV.airaccelerate!.value; } + // @ts-ignore + get wateraccelerate(): number { return SV.wateraccelerate!.value; } + // @ts-ignore + get friction(): number { return SV.friction!.value; } + // @ts-ignore + get waterfriction(): number { return SV.waterfriction!.value; } + // @ts-ignore + get edgefriction(): number { return SV.edgefriction!.value; } + + set gravity(_value: number) {} + set stopspeed(_value: number) {} + set maxspeed(_value: number) {} + set spectatormaxspeed(_value: number) {} + set accelerate(_value: number) {} + set airaccelerate(_value: number) {} + set wateraccelerate(_value: number) {} + set friction(_value: number) {} + set waterfriction(_value: number) {} + set edgefriction(_value: number) {} + + /** + * Writes the movevars to the client. + */ + sendToClient(message: SzBuffer): void { + message.writeFloat(this.gravity); + message.writeFloat(this.stopspeed); + message.writeFloat(this.maxspeed); + message.writeFloat(this.spectatormaxspeed); + message.writeFloat(this.accelerate); + message.writeFloat(this.airaccelerate); + message.writeFloat(this.wateraccelerate); + message.writeFloat(this.friction); + message.writeFloat(this.waterfriction); + message.writeFloat(this.entgravity); + } +} + +sharedCollisionModelSource.configureServer({ + getWorldEntity: () => SV.server.edicts[0] ?? null, + getWorldModel: () => SV.server.worldmodel, + getModels: () => SV.server.models, +}); diff --git a/test/physics/server-edict.test.mjs b/test/physics/server-edict.test.mjs new file mode 100644 index 00000000..fbe056f8 --- /dev/null +++ b/test/physics/server-edict.test.mjs @@ -0,0 +1,28 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { ServerEdict } from '../../source/engine/server/Edict.mjs'; + +import { defaultMockRegistry, withMockRegistry } from './fixtures.mjs'; + +void describe('ServerEdict', () => { + void test('keeps getClient slot mapping separate from isClient semantics', () => { + const reservedSlotClient = { state: 0 }; + + void withMockRegistry(defaultMockRegistry({ + svs: { + maxclients: 4, + clients: [null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, reservedSlotClient], + }, + server: { + num_edicts: 32, + edicts: [], + }, + }), () => { + const reservedWorldEdict = new ServerEdict(16); + + assert.equal(reservedWorldEdict.isClient(), false); + assert.equal(reservedWorldEdict.getClient(), reservedSlotClient); + }); + }); +}); diff --git a/test/physics/server.test.mjs b/test/physics/server.test.mjs index 91319edd..8e871a10 100644 --- a/test/physics/server.test.mjs +++ b/test/physics/server.test.mjs @@ -73,8 +73,8 @@ function installReadClientMoveContext({ paused }) { }; } -describe('SV.ReadClientMove', () => { - test('queues movement commands while the server is running', () => { +void describe('SV.ReadClientMove', () => { + void test('queues movement commands while the server is running', () => { const context = installReadClientMoveContext({ paused: false }); try { @@ -93,7 +93,7 @@ describe('SV.ReadClientMove', () => { } }); - test('does not enqueue paused movement backlog', () => { + void test('does not enqueue paused movement backlog', () => { const context = installReadClientMoveContext({ paused: true }); try { From 9975502b688ec6d75c453be0590d3adfe4636b89 Mon Sep 17 00:00:00 2001 From: Christian R Date: Fri, 3 Apr 2026 13:58:36 +0300 Subject: [PATCH 34/67] QuakeC support: fallback value for alpha --- source/engine/server/ServerMessages.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/engine/server/ServerMessages.mjs b/source/engine/server/ServerMessages.mjs index ec1f22de..927086cb 100644 --- a/source/engine/server/ServerMessages.mjs +++ b/source/engine/server/ServerMessages.mjs @@ -423,7 +423,7 @@ export class ServerMessages { if (bits & Protocol.u.effects) { msg.writeByte(to.effects); - msg.writeByte(Math.floor(to.alpha * 255.0)); + msg.writeByte(Math.floor((to.alpha || 1) * 255.0)); // CR: QuakeC may not have alpha } if (bits & Protocol.u.solid) { From 964debf66f1629a6237d4c23b73b962965c23f6b Mon Sep 17 00:00:00 2001 From: Christian R Date: Fri, 3 Apr 2026 14:00:14 +0300 Subject: [PATCH 35/67] TS: server/ part 3 --- source/engine/server/Progs.mjs | 1548 +----------------------------- source/engine/server/Progs.ts | 1643 ++++++++++++++++++++++++++++++++ 2 files changed, 1644 insertions(+), 1547 deletions(-) create mode 100644 source/engine/server/Progs.ts diff --git a/source/engine/server/Progs.mjs b/source/engine/server/Progs.mjs index b563eadd..7d62c3f9 100644 --- a/source/engine/server/Progs.mjs +++ b/source/engine/server/Progs.mjs @@ -1,1547 +1 @@ -import Cmd from '../common/Cmd.ts'; -import { CRC16CCITT } from '../common/CRC.ts'; -import Cvar from '../common/Cvar.ts'; -import { HostError, MissingResourceError } from '../common/Errors.ts'; -import Q from '../../shared/Q.ts'; -import Vector from '../../shared/Vector.ts'; -import { eventBus, registry } from '../registry.mjs'; -import { ED, ServerEdict } from './Edict.mjs'; -import { ServerEngineAPI } from '../common/GameAPIs.ts'; -import PF, { etype, ofs } from './ProgsAPI.mjs'; -import { gameCapabilities } from '../../shared/Defs.ts'; -import { loadGameModule } from './GameLoader.mjs'; - -const PR = {}; - -export default PR; - -let { COM, Con, SV } = registry; - -eventBus.subscribe('registry.frozen', () => { - COM = registry.COM; - Con = registry.Con; - SV = registry.SV; -}); - -PR.saveglobal = (1<<15); - -PR.op = Object.freeze({ - done: 0, - mul_f: 1, mul_v: 2, mul_fv: 3, mul_vf: 4, - div_f: 5, - add_f: 6, add_v: 7, - sub_f: 8, sub_v: 9, - eq_f: 10, eq_v: 11, eq_s: 12, eq_e: 13, eq_fnc: 14, - ne_f: 15, ne_v: 16, ne_s: 17, ne_e: 18, ne_fnc: 19, - le: 20, ge: 21, lt: 22, gt: 23, - load_f: 24, load_v: 25, load_s: 26, load_ent: 27, load_fld: 28, load_fnc: 29, - address: 30, - store_f: 31, store_v: 32, store_s: 33, store_ent: 34, store_fld: 35, store_fnc: 36, - storep_f: 37, storep_v: 38, storep_s: 39, storep_ent: 40, storep_fld: 41, storep_fnc: 42, - ret: 43, - not_f: 44, not_v: 45, not_s: 46, not_ent: 47, not_fnc: 48, - jnz: 49, jz: 50, - call0: 51, call1: 52, call2: 53, call3: 54, call4: 55, call5: 56, call6: 57, call7: 58, call8: 59, - state: 60, - jump: 61, - and: 62, or: 63, - bitand: 64, bitor: 65, -}); - -PR.version = 6; -PR.max_parms = 8; - -PR.globalvars = Object.freeze({ - self: 28, // edict - other: 29, // edict - time: 31, // float -}); - -PR.entvars = { - modelindex: 0, // float - absmin: 1, // vec3 - absmin1: 2, - absmin2: 3, - absmax: 4, // vec3 - absmax1: 5, - absmax2: 6, - ltime: 7, // float - movetype: 8, // float - solid: 9, // float - origin: 10, // vec3 - origin1: 11, - origin2: 12, - oldorigin: 13, // vec3 - oldorigin1: 14, - oldorigin2: 15, - velocity: 16, // vec3 - velocity1: 17, - velocity2: 18, - angles: 19, // vec3 - angles1: 20, - angles2: 21, - avelocity: 22, // vec3 - avelocity1: 23, - avelocity2: 24, - punchangle: 25, // vec3 - punchangle1: 26, - punchangle2: 27, - classname: 28, // string - model: 29, // string - frame: 30, // float - skin: 31, // float - effects: 32, // float - mins: 33, // vec3 - mins1: 34, - mins2: 35, - maxs: 36, // vec3 - maxs1: 37, - maxs2: 38, - size: 39, // vec3 - size1: 40, - size2: 41, - touch: 42, // func - use: 43, // func - think: 44, // func - blocked: 45, // func - nextthink: 46, // float - groundentity: 47, // edict - health: 48, // float - frags: 49, // float - weapon: 50, // float - weaponmodel: 51, // string - weaponframe: 52, // float - currentammo: 53, // float - ammo_shells: 54, // float - ammo_nails: 55, // float - ammo_rockets: 56, // float - ammo_cells: 57, // float - items: 58, // float - takedamage: 59, // float - chain: 60, // edict - deadflag: 61, // float - view_ofs: 62, // vec3 - view_ofs1: 63, - view_ofs2: 64, - button0: 65, // float - button1: 66, // float - button2: 67, // float - impulse: 68, // float - fixangle: 69, // float - v_angle: 70, // vec3 - v_angle1: 71, - v_angle2: 72, - idealpitch: 73, // float - netname: 74, // string - enemy: 75, // edict - flags: 76, // float - colormap: 77, // float - team: 78, // float - max_health: 79, // float - teleport_time: 80, // float - armortype: 81, // float - armorvalue: 82, // float - waterlevel: 83, // float - watertype: 84, // float - ideal_yaw: 85, // float - yaw_speed: 86, // float - aiment: 87, // edict - goalentity: 88, // edict - spawnflags: 89, // float - target: 90, // string - targetname: 91, // string - dmg_take: 92, // float - dmg_save: 93, // float - dmg_inflictor: 94, // edict - owner: 95, // edict - movedir: 96, // vec3 - movedir1: 97, - movedir2: 98, - message: 99, // string - sounds: 100, // float - noise: 101, // string - noise1: 102, // string - noise2: 103, // string - noise3: 104, // string -}; - -PR.ofs = ofs; - -PR.progheader_crc = 5927; - -// classes - -/** - * FIXME: function proxies need to become cached - */ -class ProgsFunctionProxy extends Function { - static proxyCache = []; - - constructor(fnc, ent = null, settings = {}) { - super(); - - this.fnc = fnc; - this.ent = ent; - this._signature = null; - this._settings = settings; - - const f = PR.functions[this.fnc]; - const name = PR.GetString(f.name); - - Object.defineProperty(this, 'name', { - value: name, - writable: false, - }); - - Object.freeze(this); - } - - toString() { - return `${PR.GetString(PR.functions[this.fnc].name)} (ProgsFunctionProxy(${this.fnc}))`; - } - - static create(fnc, ent, settings = {}) { - const cacheId = `${fnc}-${ent ? ent.num : 'null'}`; - - if (ProgsFunctionProxy.proxyCache[cacheId]) { - return ProgsFunctionProxy.proxyCache[cacheId]; - } - - const obj = new ProgsFunctionProxy(fnc, ent, settings); - - // such an ugly hack to make objects actually callable - ProgsFunctionProxy.proxyCache[cacheId] = new Proxy(obj, { - apply(target, thisArg, args) { - return obj.call.apply(obj, args); - }, - }); - - return ProgsFunctionProxy.proxyCache[cacheId]; - } - - static _getEdictId(ent) { - if (!ent) { - return 0; - } - - if (ent instanceof ProgsEntity) { - return ent._edictNum; - } - - return ent.num; - } - - /** - * calls - * @param {*} self (optional) the edict for self - */ - call(self) { - const old_self = PR.globals_int[PR.globalvars.self]; - const old_other = PR.globals_int[PR.globalvars.other]; - - if (this.ent && !this.ent.isFree()) { - // in case this is a function bound to an entity, we need to set it to self - PR.globals_int[PR.globalvars.self] = ProgsFunctionProxy._getEdictId(this.ent); - - // fun little hack, we always assume self being other if this is called on an ent - PR.globals_int[PR.globalvars.other] = ProgsFunctionProxy._getEdictId(self); - } else if (self) { - // in case it’s a global function, we need to set self to the first argument - PR.globals_int[PR.globalvars.self] = ProgsFunctionProxy._getEdictId(self); - } - - if (this._settings.resetOther) { - PR.globals_int[PR.globalvars.other] = 0; - } - - PR.ExecuteProgram(this.fnc); - - if (this._settings.backupSelfAndOther) { - PR.globals_int[PR.globalvars.self] = old_self; - PR.globals_int[PR.globalvars.other] = old_other; - } - - return PR.Value(etype.ev_float, PR.globals, 1); // assume float - } -}; - -// PR._stats = { -// edict: {}, -// global: {}, -// }; - -class ProgsEntity { - static SERIALIZATION_TYPE_EDICT = 'E'; - static SERIALIZATION_TYPE_FUNCTION = 'F'; - static SERIALIZATION_TYPE_VECTOR = 'V'; - static SERIALIZATION_TYPE_PRIMITIVE = 'P'; - - /** - * - * @param {*} ed can be null, then it’s global - */ - constructor(ed) { - // const stats = ed ? PR._stats.edict : PR._stats.global; - const defs = ed ? PR.fielddefs : PR.globaldefs; - - if (ed) { - this._edictNum = ed.num; - this._v = new ArrayBuffer(PR.entityfields * 4); - this._v_float = new Float32Array(this._v); - this._v_int = new Int32Array(this._v); - } - - this._serializableFields = []; - - for (let i = 1; i < defs.length; i++) { - const d = defs[i]; - const name = PR.GetString(d.name); - - if (name.charCodeAt(name.length - 2) === 95) { - // skip _x, _y, _z - continue; - } - - const [type, val, ofs] = [d.type & ~PR.saveglobal, ed ? this._v : PR.globals, d.ofs]; - - if ((type & ~PR.saveglobal) === 0) { - continue; - } - - if (!ed && (d.type & ~PR.saveglobal) !== 0 && [etype.ev_string, etype.ev_float, etype.ev_entity].includes(type)) { - this._serializableFields.push(name); - } else if (ed) { - this._serializableFields.push(name); - } - - const val_float = new Float32Array(val); - const val_int = new Int32Array(val); - - const assignedFunctions = []; - - switch (type) { - case etype.ev_string: - Object.defineProperty(this, name, { - get: function() { - return val_int[ofs] > 0 ? PR.GetString(val_int[ofs]) : null; - }, - set: function(value) { - val_int[ofs] = value !== null && value !== '' ? PR.SetString(val_int[ofs], value) : 0; - }, - configurable: true, - enumerable: true, - }); - break; - case etype.ev_entity: // TODO: actually accept entity instead of edict and vice-versa - Object.defineProperty(this, name, { - get: function() { - if (!SV.server?.edicts || !SV.server.edicts[val_int[ofs]]) { - return null; - } - - return SV.server.edicts[val_int[ofs]] || null; - }, - set: function(value) { - if (value === null) { - val_int[ofs] = 0; - return; - } - if (value === 0) { // making fixing stuff easier, though this is a breakpoint trap as well - val_int[ofs] = 0; - return; - } - if (typeof(value.edictId) !== 'undefined') { // TODO: Entity class - val_int[ofs] = value.edictId; - return; - } - if (typeof(value._edictNum) !== 'undefined') { // TODO: Edict class - val_int[ofs] = value.edictId; - return; - } - if (typeof(value.num) !== 'undefined') { // TODO: Edict class - val_int[ofs] = value.num; - return; - } - throw new TypeError('Expected Edict'); - }, - configurable: true, - enumerable: true, - }); - break; - case etype.ev_function: - Object.defineProperty(this, name, { - get: function() { - const id = val_int[ofs]; - if (id < 0 && assignedFunctions[(-id) - 1] instanceof Function) { - return assignedFunctions[(-id) - 1]; - } - return id > 0 ? ProgsFunctionProxy.create(id, ed, { - // some QuakeC related idiosyncrasis we need to take care of - backupSelfAndOther: ['touch'].includes(name), - resetOther: ['StartFrame'].includes(name), - }) : null; - }, - set: function(value) { - if (value === null) { - val_int[ofs] = 0; - return; - } - if (value instanceof Function) { - assignedFunctions.push(value); - val_int[ofs] = -assignedFunctions.length; - return; - } - if (value instanceof ProgsFunctionProxy) { - val_int[ofs] = value.fnc; - return; - } - if (typeof(value) === 'string') { // this is used by ED.ParseEdict etc. when parsing entities and setting fields - const d = PR.FindFunction(value); - if (!d) { - throw new TypeError('Invalid function: ' + value); - } - val_int[ofs] = d; - return; - } - if (typeof(value.fnc) !== 'undefined') { - val_int[ofs] = value.fnc; - return; - } - throw new TypeError('EdictProxy.' + name + ': Expected FunctionProxy, function name or function ID'); - }, - configurable: true, - enumerable: true, - }); - break; - case etype.ev_pointer: // unused and irrelevant - break; - case etype.ev_field: - Object.defineProperty(this, name, { - get: function() { - return val_int[ofs]; - }, - set: function(value) { - if (typeof(value.ofs) !== 'undefined') { - val_int[ofs] = value.ofs; - return; - } - val_int[ofs] = value; - }, - configurable: true, - enumerable: true, - }); - break; - case etype.ev_float: - Object.defineProperty(this, name, { - get: function() { - return val_float[ofs]; - }, - set: function(value) { - if (value === undefined || Q.isNaN(value)) { - throw new TypeError('EdictProxy.' + name + ': invalid value for ev_float passed: ' + value); - } - val_float[ofs] = value; - }, - configurable: true, - enumerable: true, - }); - break; - case etype.ev_vector: // TODO: Proxy for Vector? - Object.defineProperty(this, name, { - get: function() { - return new Vector(val_float[ofs], val_float[ofs + 1], val_float[ofs + 2]); - }, - set: function(value) { - val_float[ofs] = value[0]; - val_float[ofs+1] = value[1]; - val_float[ofs+2] = value[2]; - }, - configurable: true, - enumerable: true, - }); - break; - } - } - } - - serialize() { - const data = {}; - - for (const field of this._serializableFields) { - const value = this[field]; - - switch (true) { - case value === null: - data[field] = [ProgsEntity.SERIALIZATION_TYPE_PRIMITIVE, null]; - break; - - case value instanceof ProgsEntity: - data[field] = [ProgsEntity.SERIALIZATION_TYPE_EDICT, value._edictNum]; - break; - - case value instanceof ProgsFunctionProxy: - data[field] = [ProgsEntity.SERIALIZATION_TYPE_FUNCTION, value.fnc]; - break; - - case value instanceof Vector: - data[field] = [ProgsEntity.SERIALIZATION_TYPE_VECTOR, ...value]; - break; - - case typeof value === 'number': - case typeof value === 'boolean': - case typeof value === 'string': - data[field] = [ProgsEntity.SERIALIZATION_TYPE_PRIMITIVE, value]; - break; - } - } - - return data; - } - - deserialize(obj) { - for (const [key, value] of Object.entries(obj)) { - console.assert(this._serializableFields.includes(key)); - - const [type, ...data] = value; - - switch (type) { - case ProgsEntity.SERIALIZATION_TYPE_EDICT: - this[key] = SV.server.edicts[data[0]]; - break; - - case ProgsEntity.SERIALIZATION_TYPE_FUNCTION: - this[key] = {fnc: data[0]}; - break; - - case ProgsEntity.SERIALIZATION_TYPE_VECTOR: - this[key] = new Vector(...data); - break; - - case ProgsEntity.SERIALIZATION_TYPE_PRIMITIVE: - this[key] = data[0]; - break; - } - } - - return this; - } - - clear() { - if (this._v) { - const int32 = new Int32Array(this._v); - for (let i = 0; i < PR.entityfields; i++) { - int32[i] = 0; - } - } - } - - free() { - this.clear(); - } - - equals(other) { - return other && other._edictNum === this._edictNum; - } - - spawn() { - // QuakeC is different, the actual spawn function is called by its classname - SV.server.gameAPI[this.classname]({num: this._edictNum}); - } - - get edictId() { - return this._edictNum; - } -}; - -// edict - -/** - * Retrieves the global definition at the specified offset. - * @param {number} ofs - The offset to retrieve. - * @returns {object|null} - The global definition. - */ -PR.GlobalAtOfs = function(ofs) { - return PR.globaldefs.find((def) => def.ofs === ofs) || null; -}; - -/** - * Retrieves the field definition at the specified offset. - * @param {number} ofs - The offset to retrieve. - * @returns {object|null} - The field definition. - */ -PR.FieldAtOfs = function(ofs) { - return PR.fielddefs.find((def) => def.ofs === ofs) || null; -}; - -/** - * Finds a field definition by name. - * @param {string} name - The field name. - * @returns {object|null} - The field definition. - */ -PR.FindField = function(name) { - return PR.fielddefs.find((def) => PR.GetString(def.name) === name) || null; -}; - -/** - * Finds a global definition by name. - * @param {string} name - The global name. - * @returns {object|null} - The global definition. - */ -PR.FindGlobal = function(name) { - return PR.globaldefs.find((def) => PR.GetString(def.name) === name) || null; -}; - -/** - * Finds a function definition by name. - * @param {string} name - The function name. - * @returns {number} - The function index. - */ -PR.FindFunction = function(name) { - return PR.functions.findIndex((func) => PR.GetString(func.name) === name); -}; - -PR.ValueString = function(type, val, ofs) { - const val_float = new Float32Array(val); - const val_int = new Int32Array(val); - type &= ~PR.saveglobal; - switch (type) { - case etype.ev_string: - return PR.GetString(val_int[ofs]); - case etype.ev_entity: - return 'entity ' + val_int[ofs]; - case etype.ev_function: - return PR.GetString(PR.functions[val_int[ofs]].name) + '()'; - case etype.ev_field: { - const def = PR.FieldAtOfs(val_int[ofs]); - if (def !== null) { - return '.' + PR.GetString(def.name); - } - return '.'; - } - case etype.ev_void: - return 'void'; - case etype.ev_float: - return val_float[ofs].toFixed(1); - case etype.ev_vector: - return '\'' + val_float[ofs].toFixed(1) + - ' ' + val_float[ofs + 1].toFixed(1) + - ' ' + val_float[ofs + 2].toFixed(1) + '\''; - case etype.ev_pointer: - return 'pointer'; - } - return 'bad type ' + type; -}; - -PR.Value = function(type, val, ofs) { - const val_float = new Float32Array(val); - const val_int = new Int32Array(val); - type &= ~PR.saveglobal; - switch (type) { - case etype.ev_string: - return PR.GetString(val_int[ofs]); - case etype.ev_pointer: - case etype.ev_entity: - case etype.ev_field: - return val_int[ofs]; - // case etype.ev_field: { - // const def = PR.FieldAtOfs(val_int[ofs]); - // if (def != null) { - // return '.' + PR.GetString(def.name); - // } - // return '.'; - // } - case etype.ev_function: - return PR.GetString(PR.functions[val_int[ofs]].name) + '()'; - case etype.ev_void: - return null; - case etype.ev_float: - return val_float[ofs]; - case etype.ev_vector: - return [val_float[ofs], - val_float[ofs + 1], - val_float[ofs + 2]]; - } - throw new TypeError('bad PR etype ' + type); -}; - -PR.UglyValueString = function(type, val, ofs) { - const val_float = new Float32Array(val); - const val_int = new Int32Array(val); - type &= ~PR.saveglobal; - switch (type) { - case etype.ev_string: - return PR.GetString(val_int[ofs]); - case etype.ev_entity: - return val_int[ofs].toString(); - case etype.ev_function: - return PR.GetString(PR.functions[val_int[ofs]].name); - case etype.ev_field: { - const def = PR.FieldAtOfs(val_int[ofs]); - if (def !== null) { - return PR.GetString(def.name); - } - return ''; - } - case etype.ev_void: - return 'void'; - case etype.ev_float: - return val_float[ofs].toFixed(6); - case etype.ev_vector: - return val_float[ofs].toFixed(6) + - ' ' + val_float[ofs + 1].toFixed(6) + - ' ' + val_float[ofs + 2].toFixed(6); - } - return 'bad type ' + type; -}; - -PR.GlobalString = function(ofs) { - const def = PR.GlobalAtOfs(ofs); let line; - if (def !== null) { - line = ofs + '(' + PR.GetString(def.name) + ')' + PR.ValueString(def.type, PR.globals, ofs); - } else { - line = ofs + '(???)'; - } - for (; line.length <= 20; ) { - line += ' '; - } - return line; -}; - -PR.GlobalStringNoContents = function(ofs) { - const def = PR.GlobalAtOfs(ofs); let line; - if (def !== null) { - line = ofs + '(' + PR.GetString(def.name) + ')'; - } else { - line = ofs + '(???)'; - } - for (; line.length <= 20; ) { - line += ' '; - } - return line; -}; - -PR.LoadProgs = async function() { - const progs = await COM.LoadFile('progs.dat'); - if (progs === null) { - throw new MissingResourceError('progs.dat'); - } - Con.DPrint('Programs occupy ' + (progs.byteLength >> 10) + 'K.\n'); - const view = new DataView(progs); - - let i = view.getUint32(0, true); - if (i !== PR.version) { - throw new Error('progs.dat has wrong version number (' + i + ' should be ' + PR.version + ')'); - } - - if (view.getUint32(4, true) !== PR.progheader_crc) { - throw new Error('progs.dat system vars have been modified, PR.js is out of date'); - } - - PR.crc = CRC16CCITT.Block(new Uint8Array(progs)); - - PR.stack = []; - PR.depth = 0; - - PR.localstack = []; - for (i = 0; i < PR.localstack_size; i++) { - PR.localstack[i] = 0; - } - PR.localstack_used = 0; - - let ofs; let num; - - ofs = view.getUint32(8, true); - num = view.getUint32(12, true); - PR.statements = []; - for (i = 0; i < num; i++) { - PR.statements[i] = { - op: view.getUint16(ofs, true), - a: view.getInt16(ofs + 2, true), - b: view.getInt16(ofs + 4, true), - c: view.getInt16(ofs + 6, true), - }; - ofs += 8; - } - - ofs = view.getUint32(16, true); - num = view.getUint32(20, true); - PR.globaldefs = []; - for (i = 0; i < num; i++) { - PR.globaldefs[i] = { - type: view.getUint16(ofs, true), - ofs: view.getUint16(ofs + 2, true), - name: view.getUint32(ofs + 4, true), - }; - ofs += 8; - } - - ofs = view.getUint32(24, true); - num = view.getUint32(28, true); - PR.fielddefs = []; - for (i = 0; i < num; i++) { - PR.fielddefs[i] = { - type: view.getUint16(ofs, true), - ofs: view.getUint16(ofs + 2, true), - name: view.getUint32(ofs + 4, true), - }; - ofs += 8; - } - - ofs = view.getUint32(32, true); - num = view.getUint32(36, true); - PR.functions = []; - for (i = 0; i < num; i++) { - PR.functions[i] = { - first_statement: view.getInt32(ofs, true), - parm_start: view.getUint32(ofs + 4, true), - locals: view.getUint32(ofs + 8, true), - profile: view.getUint32(ofs + 12, true), - name: view.getUint32(ofs + 16, true), - file: view.getUint32(ofs + 20, true), - numparms: view.getUint32(ofs + 24, true), - parm_size: [ - view.getUint8(ofs + 28), view.getUint8(ofs + 29), - view.getUint8(ofs + 30), view.getUint8(ofs + 31), - view.getUint8(ofs + 32), view.getUint8(ofs + 33), - view.getUint8(ofs + 34), view.getUint8(ofs + 35), - ], - }; - ofs += 36; - } - - ofs = view.getUint32(40, true); - num = view.getUint32(44, true); - PR.strings = []; - for (i = 0; i < num; i++) { - PR.strings[i] = view.getUint8(ofs + i); - } - PR.string_temp = PR.NewString('', 1024); // allocates 1024 bytes for temp strings - PR.string_heap_start = PR.strings.length + 4; - PR.string_heap_current = PR.string_heap_start; - - ofs = view.getUint32(48, true); - num = view.getUint32(52, true); - PR.globals = new ArrayBuffer(num << 2); - PR.globals_float = new Float32Array(PR.globals); - PR.globals_int = new Int32Array(PR.globals); - for (i = 0; i < num; i++) { - PR.globals_int[i] = view.getInt32(ofs + (i << 2), true); - } - - PR.entityfields = view.getUint32(56, true); - PR.edict_size = 96 + (PR.entityfields << 2); - - const fields = [ - 'ammo_shells1', - 'ammo_nails1', - 'ammo_lava_nails', - 'ammo_rockets1', - 'ammo_multi_rockets', - 'ammo_cells1', - 'ammo_plasma', - 'gravity', - 'items2', - ]; - for (i = 0; i < fields.length; i++) { - const field = fields[i]; - const def = PR.FindField(field); - PR.entvars[field] = (def !== null) ? def.ofs : null; - } - ProgsFunctionProxy.proxyCache = []; // free all cached functions - // hook up progs.dat with our proxies - - const progsAPI = new ProgsEntity(null); - - const deathmatch = Cvar.FindVar('deathmatch'); - const skill = Cvar.FindVar('skill'); - const coop = Cvar.FindVar('coop'); - - const gameAPI = Object.assign(progsAPI, { - prepareEntity(edict, classname, initialData = {}) { - if (!edict.entity) { // do not use isFree(), check for unset entity property - edict.entity = new ProgsEntity(edict); - Object.freeze(edict.entity); - } - - // yet another hack, always be successful during a loadgame - if (SV.server.loadgame) { - return true; - } - - // special case for QuakeC: empty entity - if (classname === null) { - return true; - } - - // another special case for QuakeC: player has no spawn function - if (classname === 'player') { - return true; - } - - if (!SV.server.gameAPI[classname]) { - Con.PrintWarning(`No spawn function for edict ${edict.num}: ${classname}\n`); - // debugger; - return false; - } - - initialData.classname = classname; - - for (const [key, value] of Object.entries(initialData)) { - const field = PR.FindField(key); - - if (!field) { - Con.PrintWarning(`'${key}' is not a field\n`); - continue; - } - - switch (field.type & 0x7fff) { - case etype.ev_entity: - edict.entity[key] = value instanceof ServerEdict ? value : {num: Q.atoi(value)}; - break; - - case etype.ev_vector: - edict.entity[key] = value instanceof Vector ? value : new Vector(...value.split(' ').map((x) => Q.atof(x))); - break; - - case etype.ev_field: { - const d = PR.FindField(value); - if (!d) { - Con.PrintWarning(`Can't find field: ${value}\n`); - break; - } - edict.entity[key] = d; - break; - } - - case etype.ev_function: { - edict.entity[key] = {fnc: value}; - break; - } - - default: - edict.entity[key] = value; - } - } - - // these are quake specific things happening during loading - - const spawnflags = edict.entity.spawnflags || 0; - - if (deathmatch.value !== 0 && (spawnflags & 2048)) { - return false; - } - - const skillFlags = [256, 512, 1024, 1024]; - - if (spawnflags & skillFlags[Math.max(0, Math.min(skillFlags.length, skill.value))]) { - return false; - } - - return true; - }, - - spawnPreparedEntity(edict) { - if (!edict.entity) { - Con.PrintError('PR.LoadProgs.spawnPreparedEntity: no entity class instance set!\n'); - return false; - } - - // another special case for QuakeC: player has no spawn function - if (edict.entity.classname === 'player') { - return true; - } - - edict.entity.spawn(); - - return true; - }, - - init(mapname, serverflags) { - gameAPI.mapname = mapname; - gameAPI.serverflags = serverflags; - - // coop automatically disables deathmatch - if (coop.value) { - coop.set(true); - deathmatch.set(false); - } - - // make sure skill is in range - skill.set(Math.max(0, Math.min(3, Math.floor(skill.value)))); - - gameAPI.coop = coop.value; - gameAPI.deathmatch = deathmatch.value; - }, - - // eslint-disable-next-line no-unused-vars - shutdown(isCrashShutdown) { - }, - - startFrame() { - // pass to the VM - // @ts-ignore - progsAPI.StartFrame(null); - }, - }); - - Object.freeze(gameAPI); - - return gameAPI; -}; - -/** @type {Cvar[]} */ -PR._cvars = []; - -/** @type {import('./GameLoader').GameModuleInterface|null} */ -PR.QuakeJS = null; - -PR.Init = async function() { - try { - if (COM.CheckParm('-noquakejs')) { - Con.PrintWarning('PR.Init: QuakeJS disabled by request\n'); - PR.QuakeJS = null; - } else { - // try to get the game API - PR.QuakeJS = await loadGameModule(COM.gamedir[0].filename); - PR.QuakeJS.ServerGameAPI.Init(ServerEngineAPI); - - const identification = PR.QuakeJS.identification; - Con.Print(`PR.Init: ${identification.name} v${identification.version.join('.')} by ${identification.author} loaded.\n`); - return; - } - } catch (e) { - if (typeof(e.code) === 'string' && e.code !== 'ERR_MODULE_NOT_FOUND') { // only catch module not found errors - throw e; - } - - // CR: stupidest error convention ever, check https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import#return_value - - Con.PrintWarning('PR.Init: Falling back to QuakeC, failed to initialize QuakeJS server code: ' + e.message +'.\n'); - - PR.QuakeJS = null; - } - - // CR: we do not need any of this when running QuakeJS - Cmd.AddCommand('edict', ED.PrintEdict_f); - Cmd.AddCommand('edicts', ED.PrintEdicts); - Cmd.AddCommand('edictcount', ED.Count); - Cmd.AddCommand('profile', PR.Profile_f); - PR._cvars.push(new Cvar('gamecfg', '0')); - PR._cvars.push(new Cvar('scratch1', '0')); - PR._cvars.push(new Cvar('scratch2', '0')); - PR._cvars.push(new Cvar('scratch3', '0')); - PR._cvars.push(new Cvar('scratch4', '0')); - PR._cvars.push(new Cvar('savedgamecfg', '0', Cvar.FLAG.ARCHIVE)); - PR._cvars.push(new Cvar('saved1', '0', Cvar.FLAG.ARCHIVE)); - PR._cvars.push(new Cvar('saved2', '0', Cvar.FLAG.ARCHIVE)); - PR._cvars.push(new Cvar('saved3', '0', Cvar.FLAG.ARCHIVE)); - PR._cvars.push(new Cvar('saved4', '0', Cvar.FLAG.ARCHIVE)); - - PR._cvars.push(new Cvar('nomonsters', '0', Cvar.FLAG.SERVER)); - PR._cvars.push(new Cvar('fraglimit', '0', Cvar.FLAG.SERVER)); - PR._cvars.push(new Cvar('timelimit', '0', Cvar.FLAG.SERVER)); - PR._cvars.push(new Cvar('samelevel', '0', Cvar.FLAG.SERVER, 'Set to 1 to stay on the same map even the map is over')); - PR._cvars.push(new Cvar('noexit', '0', Cvar.FLAG.SERVER)); - PR._cvars.push(new Cvar('skill', '1', Cvar.FLAG.SERVER)); - PR._cvars.push(new Cvar('deathmatch', '0', Cvar.FLAG.SERVER)); - PR._cvars.push(new Cvar('coop', '0', Cvar.FLAG.SERVER)); -}; - -// exec - -PR.localstack_size = 2048; - -PR.opnames = [ - 'DONE', - 'MUL_F', 'MUL_V', 'MUL_FV', 'MUL_VF', - 'DIV', - 'ADD_F', 'ADD_V', - 'SUB_F', 'SUB_V', - 'EQ_F', 'EQ_V', 'EQ_S', 'EQ_E', 'EQ_FNC', - 'NE_F', 'NE_V', 'NE_S', 'NE_E', 'NE_FNC', - 'LE', 'GE', 'LT', 'GT', - 'INDIRECT', 'INDIRECT', 'INDIRECT', 'INDIRECT', 'INDIRECT', 'INDIRECT', - 'ADDRESS', - 'STORE_F', 'STORE_V', 'STORE_S', 'STORE_ENT', 'STORE_FLD', 'STORE_FNC', - 'STOREP_F', 'STOREP_V', 'STOREP_S', 'STOREP_ENT', 'STOREP_FLD', 'STOREP_FNC', - 'RETURN', - 'NOT_F', 'NOT_V', 'NOT_S', 'NOT_ENT', 'NOT_FNC', - 'IF', 'IFNOT', - 'CALL0', 'CALL1', 'CALL2', 'CALL3', 'CALL4', 'CALL5', 'CALL6', 'CALL7', 'CALL8', - 'STATE', - 'GOTO', - 'AND', 'OR', - 'BITAND', 'BITOR', -]; - -// PR.executions = []; - -PR.PrintStatement = function(s) { - let text; - if (s.op < PR.opnames.length) { - text = PR.opnames[s.op] + ' '; - for (; text.length <= 9; ) { - text += ' '; - } - } else { - text = ''; - } - if ((s.op === PR.op.jnz) || (s.op === PR.op.jz)) { - text += PR.GlobalString(s.a) + 'branch ' + s.b; - } else if (s.op === PR.op.jump) { - text += 'branch ' + s.a; - } else if ((s.op >= PR.op.store_f) && (s.op <= PR.op.store_fnc)) { - text += PR.GlobalString(s.a) + PR.GlobalStringNoContents(s.b); - } else { - if (s.a !== 0) { - text += PR.GlobalString(s.a); - } - if (s.b !== 0) { - text += PR.GlobalString(s.b); - } - if (s.c !== 0) { - text += PR.GlobalStringNoContents(s.c); - } - } - Con.Print(text + '\n'); - // if (PR.executions.length > 50) { - // PR.executions.shift(); - // } - // PR.executions.push(text); -}; - -PR.StackTrace = function() { - if (PR.depth === 0) { - Con.Print('\n'); - return; - } - PR.stack[PR.depth] = [PR.xstatement, PR.xfunction]; - let f; let file; - for (; PR.depth >= 0; PR.depth--) { - f = PR.stack[PR.depth][1]; - if (!f) { - Con.Print('\n'); - continue; - } - file = PR.GetString(f.file); - for (; file.length <= 11; ) { - file += ' '; - } - Con.Print(file + ' : ' + PR.GetString(f.name) + '\n'); - } - PR.depth = 0; -}; - -PR.Profile_f = function() { - if (SV.server.active !== true) { - return; - } - let num = 0; let max; let best; let i; let f; let profile; - while (true) { - max = 0; - best = null; - for (i = 0; i < PR.functions.length; i++) { - f = PR.functions[i]; - if (f.profile > max) { - max = f.profile; - best = f; - } - } - if (best === null) { - return; - } - if (num < 10) { - profile = best.profile.toString(); - for (; profile.length <= 6; ) { - profile = ' ' + profile; - } - Con.Print(profile + ' ' + PR.GetString(best.name) + '\n'); - } - num++; - best.profile = 0; - } -}; - -PR.RunError = function(error) { - PR.PrintStatement(PR.statements[PR.xstatement]); - PR.StackTrace(); - Con.PrintError(error + '\n'); - throw new HostError('Program error'); -}; - -PR.EnterFunction = function(f) { - PR.stack[PR.depth++] = [PR.xstatement, PR.xfunction]; - const c = f.locals; - if ((PR.localstack_used + c) > PR.localstack_size) { - PR.RunError('PR.EnterFunction: locals stack overflow\n'); - } - let i; - for (i = 0; i < c; i++) { - PR.localstack[PR.localstack_used + i] = PR.globals_int[f.parm_start + i]; - } - PR.localstack_used += c; - let o = f.parm_start; let j; - for (i = 0; i < f.numparms; i++) { - for (j = 0; j < f.parm_size[i]; j++) { - PR.globals_int[o++] = PR.globals_int[4 + i * 3 + j]; - } - } - PR.xfunction = f; - return f.first_statement - 1; -}; - -PR.LeaveFunction = function() { - if (PR.depth <= 0) { - throw new Error('prog stack underflow'); - } - let c = PR.xfunction.locals; - PR.localstack_used -= c; - if (PR.localstack_used < 0) { - PR.RunError('PR.LeaveFunction: locals stack underflow\n'); - } - for (--c; c >= 0; --c) { - PR.globals_int[PR.xfunction.parm_start + c] = PR.localstack[PR.localstack_used + c]; - } - PR.xfunction = PR.stack[--PR.depth][1]; - return PR.stack[PR.depth][0]; -}; - -PR.ExecuteProgram = function(fnum) { - if ((fnum === 0) || (fnum >= PR.functions.length)) { - if (PR.globals_int[PR.globalvars.self] !== 0) { - ED.Print(SV.server.edicts[PR.globals_int[PR.globalvars.self]]); - } - throw new HostError('PR.ExecuteProgram: NULL function'); - } - let runaway = 100000; - const exitdepth = PR.depth; - let s = PR.EnterFunction(PR.functions[fnum]); - let st; let ed; let ptr; let newf; - - while (true) { - s++; - st = PR.statements[s]; - if (--runaway === 0) { - PR.RunError('runaway loop error'); - } - PR.xfunction.profile++; - PR.xstatement = s; - if (PR.trace) { - PR.PrintStatement(st); - } - switch (st.op) { - case PR.op.add_f: - PR.globals_float[st.c] = PR.globals_float[st.a] + PR.globals_float[st.b]; - continue; - case PR.op.add_v: - PR.globals_float[st.c] = PR.globals_float[st.a] + PR.globals_float[st.b]; - PR.globals_float[st.c + 1] = PR.globals_float[st.a + 1] + PR.globals_float[st.b + 1]; - PR.globals_float[st.c + 2] = PR.globals_float[st.a + 2] + PR.globals_float[st.b + 2]; - continue; - case PR.op.sub_f: - PR.globals_float[st.c] = PR.globals_float[st.a] - PR.globals_float[st.b]; - continue; - case PR.op.sub_v: - PR.globals_float[st.c] = PR.globals_float[st.a] - PR.globals_float[st.b]; - PR.globals_float[st.c + 1] = PR.globals_float[st.a + 1] - PR.globals_float[st.b + 1]; - PR.globals_float[st.c + 2] = PR.globals_float[st.a + 2] - PR.globals_float[st.b + 2]; - continue; - case PR.op.mul_f: - PR.globals_float[st.c] = PR.globals_float[st.a] * PR.globals_float[st.b]; - continue; - case PR.op.mul_v: - PR.globals_float[st.c] = PR.globals_float[st.a] * PR.globals_float[st.b] + - PR.globals_float[st.a + 1] * PR.globals_float[st.b + 1] + - PR.globals_float[st.a + 2] * PR.globals_float[st.b + 2]; - continue; - case PR.op.mul_fv: - PR.globals_float[st.c] = PR.globals_float[st.a] * PR.globals_float[st.b]; - PR.globals_float[st.c + 1] = PR.globals_float[st.a] * PR.globals_float[st.b + 1]; - PR.globals_float[st.c + 2] = PR.globals_float[st.a] * PR.globals_float[st.b + 2]; - continue; - case PR.op.mul_vf: - PR.globals_float[st.c] = PR.globals_float[st.b] * PR.globals_float[st.a]; - PR.globals_float[st.c + 1] = PR.globals_float[st.b] * PR.globals_float[st.a + 1]; - PR.globals_float[st.c + 2] = PR.globals_float[st.b] * PR.globals_float[st.a + 2]; - continue; - case PR.op.div_f: - PR.globals_float[st.c] = PR.globals_float[st.a] / PR.globals_float[st.b]; - continue; - case PR.op.bitand: - PR.globals_float[st.c] = PR.globals_float[st.a] & PR.globals_float[st.b]; - continue; - case PR.op.bitor: - PR.globals_float[st.c] = PR.globals_float[st.a] | PR.globals_float[st.b]; - continue; - case PR.op.ge: - PR.globals_float[st.c] = (PR.globals_float[st.a] >= PR.globals_float[st.b]) ? 1.0 : 0.0; - continue; - case PR.op.le: - PR.globals_float[st.c] = (PR.globals_float[st.a] <= PR.globals_float[st.b]) ? 1.0 : 0.0; - continue; - case PR.op.gt: - PR.globals_float[st.c] = (PR.globals_float[st.a] > PR.globals_float[st.b]) ? 1.0 : 0.0; - continue; - case PR.op.lt: - PR.globals_float[st.c] = (PR.globals_float[st.a] < PR.globals_float[st.b]) ? 1.0 : 0.0; - continue; - case PR.op.and: - PR.globals_float[st.c] = ((PR.globals_float[st.a] !== 0.0) && (PR.globals_float[st.b] !== 0.0)) ? 1.0 : 0.0; - continue; - case PR.op.or: - PR.globals_float[st.c] = ((PR.globals_float[st.a] !== 0.0) || (PR.globals_float[st.b] !== 0.0)) ? 1.0 : 0.0; - continue; - case PR.op.not_f: - PR.globals_float[st.c] = (PR.globals_float[st.a] === 0.0) ? 1.0 : 0.0; - continue; - case PR.op.not_v: - PR.globals_float[st.c] = ((PR.globals_float[st.a] === 0.0) && - (PR.globals_float[st.a + 1] === 0.0) && - (PR.globals_float[st.a + 2] === 0.0)) ? 1.0 : 0.0; - continue; - case PR.op.not_s: - if (PR.globals_int[st.a] !== 0) { - PR.globals_float[st.c] = (PR.strings[PR.globals_int[st.a]] === 0) ? 1.0 : 0.0; - } else { - PR.globals_float[st.c] = 1.0; - } - continue; - case PR.op.not_fnc: - case PR.op.not_ent: - PR.globals_float[st.c] = (PR.globals_int[st.a] === 0) ? 1.0 : 0.0; - continue; - case PR.op.eq_f: - PR.globals_float[st.c] = (PR.globals_float[st.a] === PR.globals_float[st.b]) ? 1.0 : 0.0; - continue; - case PR.op.eq_v: - PR.globals_float[st.c] = ((PR.globals_float[st.a] === PR.globals_float[st.b]) && - (PR.globals_float[st.a + 1] === PR.globals_float[st.b + 1]) && - (PR.globals_float[st.a + 2] === PR.globals_float[st.b + 2])) ? 1.0 : 0.0; - continue; - case PR.op.eq_s: - PR.globals_float[st.c] = (PR.GetString(PR.globals_int[st.a]) === PR.GetString(PR.globals_int[st.b])) ? 1.0 : 0.0; - continue; - case PR.op.eq_e: - case PR.op.eq_fnc: - PR.globals_float[st.c] = (PR.globals_int[st.a] === PR.globals_int[st.b]) ? 1.0 : 0.0; - continue; - case PR.op.ne_f: - PR.globals_float[st.c] = (PR.globals_float[st.a] !== PR.globals_float[st.b]) ? 1.0 : 0.0; - continue; - case PR.op.ne_v: - PR.globals_float[st.c] = ((PR.globals_float[st.a] !== PR.globals_float[st.b]) || - (PR.globals_float[st.a + 1] !== PR.globals_float[st.b + 1]) || - (PR.globals_float[st.a + 2] !== PR.globals_float[st.b + 2])) ? 1.0 : 0.0; - continue; - case PR.op.ne_s: - PR.globals_float[st.c] = (PR.GetString(PR.globals_int[st.a]) !== PR.GetString(PR.globals_int[st.b])) ? 1.0 : 0.0; - continue; - case PR.op.ne_e: - case PR.op.ne_fnc: - PR.globals_float[st.c] = (PR.globals_int[st.a] !== PR.globals_int[st.b]) ? 1.0 : 0.0; - continue; - case PR.op.store_f: - case PR.op.store_ent: - case PR.op.store_fld: - case PR.op.store_s: - case PR.op.store_fnc: - PR.globals_int[st.b] = PR.globals_int[st.a]; - continue; - case PR.op.store_v: - PR.globals_int[st.b] = PR.globals_int[st.a]; - PR.globals_int[st.b + 1] = PR.globals_int[st.a + 1]; - PR.globals_int[st.b + 2] = PR.globals_int[st.a + 2]; - continue; - case PR.op.storep_f: - case PR.op.storep_ent: - case PR.op.storep_fld: - case PR.op.storep_s: - case PR.op.storep_fnc: - ptr = PR.globals_int[st.b]; - SV.server.edicts[Math.floor(ptr / PR.edict_size)].entity._v_int[((ptr % PR.edict_size) - 96) >> 2] = PR.globals_int[st.a]; - continue; - case PR.op.storep_v: - ed = SV.server.edicts[Math.floor(PR.globals_int[st.b] / PR.edict_size)]; - ptr = ((PR.globals_int[st.b] % PR.edict_size) - 96) >> 2; - ed.entity._v_int[ptr] = PR.globals_int[st.a]; - ed.entity._v_int[ptr + 1] = PR.globals_int[st.a + 1]; - ed.entity._v_int[ptr + 2] = PR.globals_int[st.a + 2]; - continue; - case PR.op.address: - ed = PR.globals_int[st.a]; - if ((ed === 0) && (SV.server.loading !== true)) { - PR.RunError('assignment to world entity'); - } - PR.globals_int[st.c] = ed * PR.edict_size + 96 + (PR.globals_int[st.b] << 2); - continue; - case PR.op.load_f: - case PR.op.load_fld: - case PR.op.load_ent: - case PR.op.load_s: - case PR.op.load_fnc: - PR.globals_int[st.c] = SV.server.edicts[PR.globals_int[st.a]].entity._v_int[PR.globals_int[st.b]]; - continue; - case PR.op.load_v: - ed = SV.server.edicts[PR.globals_int[st.a]]; - ptr = PR.globals_int[st.b]; - PR.globals_int[st.c] = ed.entity._v_int[ptr]; - PR.globals_int[st.c + 1] = ed.entity._v_int[ptr + 1]; - PR.globals_int[st.c + 2] = ed.entity._v_int[ptr + 2]; - continue; - case PR.op.jz: - if (PR.globals_int[st.a] === 0) { - s += st.b - 1; - } - continue; - case PR.op.jnz: - if (PR.globals_int[st.a] !== 0) { - s += st.b - 1; - } - continue; - case PR.op.jump: - s += st.a - 1; - continue; - case PR.op.call0: - case PR.op.call1: - case PR.op.call2: - case PR.op.call3: - case PR.op.call4: - case PR.op.call5: - case PR.op.call6: - case PR.op.call7: - case PR.op.call8: - PR.argc = st.op - PR.op.call0; - if (PR.globals_int[st.a] === 0) { - PR.RunError('NULL function'); - } - if (PR.globals_int[st.a] < 0) { - console.debug('special function called'); - continue; - } - newf = PR.functions[PR.globals_int[st.a]]; - if (newf.first_statement < 0) { - ptr = -newf.first_statement; - if (ptr >= PF.builtin.length) { - PR.RunError('Bad builtin call number'); - } - // PF.builtin[ptr]; - // try { - PF.builtin[ptr](); - // } catch (e) { - // PR.RunError(e.message); - // throw e; - // } - continue; - } - s = PR.EnterFunction(newf); - continue; - case PR.op.done: - case PR.op.ret: - PR.globals_int[PR.ofs.OFS_RETURN] = PR.globals_int[st.a]; - PR.globals_int[PR.ofs.OFS_RETURN + 1] = PR.globals_int[st.a + 1]; - PR.globals_int[PR.ofs.OFS_RETURN + 2] = PR.globals_int[st.a + 2]; - s = PR.LeaveFunction(); - if (PR.depth === exitdepth) { - return; - } - continue; - case PR.op.state: - ed = SV.server.edicts[PR.globals_int[PR.globalvars.self]]; - ed.entity._v_float[PR.entvars.nextthink] = PR.globals_float[PR.globalvars.time] + 0.1; - ed.entity._v_float[PR.entvars.frame] = PR.globals_float[st.a]; - ed.entity._v_int[PR.entvars.think] = PR.globals_int[st.b]; - continue; - } - PR.RunError('Bad opcode ' + st.op); - } -}; - -PR.GetString = function(num) { - let string = ''; - for (; num < PR.strings.length; num++) { - if (PR.strings[num] === 0) { - break; - } - string += String.fromCharCode(PR.strings[num]); - } - return string; -}; - -PR._StringLength = function(ofs) { - let len = 0; - - while(PR.strings[ofs+len]) { - len++; - } - - return len; -}; - -PR.SetString = function(ofs, s, length = null) { - // shortcut: empty strings are located at 0x0000 - if (s === '') { - return 0; - } - - const size = (length !== null ? Math.max(s.length, length || 0) : s.length) + 1; - - // check if it’s going to overwrite a constant (ofs < PR.string_heap_start) - // check if we can overwrite in place (s.length < &PR.strings[ofs].length) - if (ofs === null || ofs < PR.string_heap_start || PR._StringLength(ofs) <= size) { - ofs = PR.string_heap_current; - PR.string_heap_current += size; - } - - // overwrite found spot with s - for (let i = 0; i < s.length; i++) { - PR.strings[ofs + i] = s.charCodeAt(i); - } - - // add 0-byte string terminator - PR.strings[ofs + s.length] = 0; - - return ofs; -}; - -/** - * @param s - * @param length - */ -PR.NewString = function(s, length) { - const ofs = PR.strings.length; - let i; - if (s.length >= length) { - for (i = 0; i < (length - 1); i++) { - PR.strings[PR.strings.length] = s.charCodeAt(i); - } - PR.strings[PR.strings.length] = 0; - return ofs; - } - for (i = 0; i < s.length; i++) { - PR.strings[PR.strings.length] = s.charCodeAt(i); - } - length -= s.length; - for (i = 0; i < length; i++) { - PR.strings[PR.strings.length] = 0; - } - return ofs; -}; - -PR.TempString = function(string) { - if (string.length > 1023) { - string = string.substring(0, 1023); - } - for (let i = 0; i < string.length; i++) { - PR.strings[PR.string_temp + i] = string.charCodeAt(i); - } - PR.strings[PR.string_temp + string.length] = 0; - - return PR.string_temp; -}; - -PR.capabilities = [ - gameCapabilities.CAP_CLIENTDATA_UPDATESTAT, - gameCapabilities.CAP_CLIENTDATA_LEGACY, - gameCapabilities.CAP_SPAWNPARMS_LEGACY, - gameCapabilities.CAP_ENTITY_BBOX_ADJUSTMENTS_DURING_LINK, -]; +export { default } from './Progs.ts'; diff --git a/source/engine/server/Progs.ts b/source/engine/server/Progs.ts new file mode 100644 index 00000000..781baa59 --- /dev/null +++ b/source/engine/server/Progs.ts @@ -0,0 +1,1643 @@ +import type { ServerGameInterface } from '../../shared/GameInterfaces.ts'; +import type { GameModuleInterface } from './GameLoader.ts'; + +import Cmd from '../common/Cmd.ts'; +import { CRC16CCITT } from '../common/CRC.ts'; +import Cvar from '../common/Cvar.ts'; +import { HostError, MissingResourceError } from '../common/Errors.ts'; +import Q from '../../shared/Q.ts'; +import Vector from '../../shared/Vector.ts'; +import { eventBus, registry } from '../registry.mjs'; +import { ED, ServerEdict } from './Edict.mjs'; +import { ServerEngineAPI } from '../common/GameAPIs.ts'; +import PF, { etype, ofs } from './ProgsAPI.mjs'; +import { gameCapabilities } from '../../shared/Defs.ts'; +import { loadGameModule } from './GameLoader.mjs'; + +interface ProgsStatement { + op: number; + a: number; + b: number; + c: number; +} + +interface ProgsDefinition { + type: number; + ofs: number; + name: number; +} + +interface ProgsFunctionDefinition { + first_statement: number; + parm_start: number; + locals: number; + profile: number; + name: number; + file: number; + numparms: number; + parm_size: number[]; +} + +type ProgsValue = string | number | boolean | number[] | null; +type ProgsStackEntry = [number, ProgsFunctionDefinition | null]; + +const PROGS_OP = Object.freeze({ + done: 0, + mul_f: 1, mul_v: 2, mul_fv: 3, mul_vf: 4, + div_f: 5, + add_f: 6, add_v: 7, + sub_f: 8, sub_v: 9, + eq_f: 10, eq_v: 11, eq_s: 12, eq_e: 13, eq_fnc: 14, + ne_f: 15, ne_v: 16, ne_s: 17, ne_e: 18, ne_fnc: 19, + le: 20, ge: 21, lt: 22, gt: 23, + load_f: 24, load_v: 25, load_s: 26, load_ent: 27, load_fld: 28, load_fnc: 29, + address: 30, + store_f: 31, store_v: 32, store_s: 33, store_ent: 34, store_fld: 35, store_fnc: 36, + storep_f: 37, storep_v: 38, storep_s: 39, storep_ent: 40, storep_fld: 41, storep_fnc: 42, + ret: 43, + not_f: 44, not_v: 45, not_s: 46, not_ent: 47, not_fnc: 48, + jnz: 49, jz: 50, + call0: 51, call1: 52, call2: 53, call3: 54, call4: 55, call5: 56, call6: 57, call7: 58, call8: 59, + state: 60, + jump: 61, + and: 62, or: 63, + bitand: 64, bitor: 65, +}); + +const PROGS_GLOBALVARS = Object.freeze({ + self: 28, + other: 29, + time: 31, +}); + +interface ProgsModule { + saveglobal: number; + op: typeof PROGS_OP; + version: number; + max_parms: number; + globalvars: typeof PROGS_GLOBALVARS; + entvars: Record; + ofs: typeof ofs; + progheader_crc: number; + crc: number; + stack: ProgsStackEntry[]; + depth: number; + localstack: number[]; + localstack_used: number; + localstack_size: number; + statements: ProgsStatement[]; + globaldefs: ProgsDefinition[]; + fielddefs: ProgsDefinition[]; + functions: ProgsFunctionDefinition[]; + strings: number[]; + string_temp: number; + string_heap_start: number; + string_heap_current: number; + globals: ArrayBuffer; + globals_float: Float32Array; + globals_int: Int32Array; + entityfields: number; + edict_size: number; + _cvars: Cvar[]; + QuakeJS: GameModuleInterface | null; + opnames: string[]; + trace: boolean; + argc: number; + xstatement: number; + xfunction: ProgsFunctionDefinition; + capabilities: gameCapabilities[]; + GlobalAtOfs(ofs: number): ProgsDefinition | null; + FieldAtOfs(ofs: number): ProgsDefinition | null; + FindField(name: string): ProgsDefinition | null; + FindGlobal(name: string): ProgsDefinition | null; + FindFunction(name: string): number; + ValueString(type: number, val: ArrayBuffer, ofs: number): string; + Value(type: number, val: ArrayBuffer, ofs: number): ProgsValue; + UglyValueString(type: number, val: ArrayBuffer, ofs: number): string; + GlobalString(ofs: number): string; + GlobalStringNoContents(ofs: number): string; + LoadProgs(): Promise; + Init(): Promise; + PrintStatement(s: ProgsStatement): void; + StackTrace(): void; + Profile_f(): void; + RunError(error: string): never; + EnterFunction(f: ProgsFunctionDefinition): number; + LeaveFunction(): number; + ExecuteProgram(fnum: number): void; + GetString(num: number): string; + _StringLength(ofs: number): number; + SetString(ofs: number | null, s: string, length?: number | null): number; + NewString(s: string, length: number): number; + TempString(string: string): number; +} + +const PR = {} as ProgsModule; + +export default PR; + +let { COM, Con, SV } = registry; + +eventBus.subscribe('registry.frozen', () => { + COM = registry.COM; + Con = registry.Con; + SV = registry.SV; +}); + +PR.saveglobal = (1<<15); + +PR.op = PROGS_OP; + +PR.version = 6; +PR.max_parms = 8; + +PR.globalvars = PROGS_GLOBALVARS; + +PR.entvars = { + modelindex: 0, // float + absmin: 1, // vec3 + absmin1: 2, + absmin2: 3, + absmax: 4, // vec3 + absmax1: 5, + absmax2: 6, + ltime: 7, // float + movetype: 8, // float + solid: 9, // float + origin: 10, // vec3 + origin1: 11, + origin2: 12, + oldorigin: 13, // vec3 + oldorigin1: 14, + oldorigin2: 15, + velocity: 16, // vec3 + velocity1: 17, + velocity2: 18, + angles: 19, // vec3 + angles1: 20, + angles2: 21, + avelocity: 22, // vec3 + avelocity1: 23, + avelocity2: 24, + punchangle: 25, // vec3 + punchangle1: 26, + punchangle2: 27, + classname: 28, // string + model: 29, // string + frame: 30, // float + skin: 31, // float + effects: 32, // float + mins: 33, // vec3 + mins1: 34, + mins2: 35, + maxs: 36, // vec3 + maxs1: 37, + maxs2: 38, + size: 39, // vec3 + size1: 40, + size2: 41, + touch: 42, // func + use: 43, // func + think: 44, // func + blocked: 45, // func + nextthink: 46, // float + groundentity: 47, // edict + health: 48, // float + frags: 49, // float + weapon: 50, // float + weaponmodel: 51, // string + weaponframe: 52, // float + currentammo: 53, // float + ammo_shells: 54, // float + ammo_nails: 55, // float + ammo_rockets: 56, // float + ammo_cells: 57, // float + items: 58, // float + takedamage: 59, // float + chain: 60, // edict + deadflag: 61, // float + view_ofs: 62, // vec3 + view_ofs1: 63, + view_ofs2: 64, + button0: 65, // float + button1: 66, // float + button2: 67, // float + impulse: 68, // float + fixangle: 69, // float + v_angle: 70, // vec3 + v_angle1: 71, + v_angle2: 72, + idealpitch: 73, // float + netname: 74, // string + enemy: 75, // edict + flags: 76, // float + colormap: 77, // float + team: 78, // float + max_health: 79, // float + teleport_time: 80, // float + armortype: 81, // float + armorvalue: 82, // float + waterlevel: 83, // float + watertype: 84, // float + ideal_yaw: 85, // float + yaw_speed: 86, // float + aiment: 87, // edict + goalentity: 88, // edict + spawnflags: 89, // float + target: 90, // string + targetname: 91, // string + dmg_take: 92, // float + dmg_save: 93, // float + dmg_inflictor: 94, // edict + owner: 95, // edict + movedir: 96, // vec3 + movedir1: 97, + movedir2: 98, + message: 99, // string + sounds: 100, // float + noise: 101, // string + noise1: 102, // string + noise2: 103, // string + noise3: 104, // string +}; + +PR.ofs = ofs; + +PR.progheader_crc = 5927; + +// classes + +/** + * FIXME: function proxies need to become cached + */ +class ProgsFunctionProxy extends Function { + static proxyCache = []; + + constructor(fnc, ent = null, settings = {}) { + super(); + + this.fnc = fnc; + this.ent = ent; + this._signature = null; + this._settings = settings; + + const f = PR.functions[this.fnc]; + const name = PR.GetString(f.name); + + Object.defineProperty(this, 'name', { + value: name, + writable: false, + }); + + Object.freeze(this); + } + + toString() { + return `${PR.GetString(PR.functions[this.fnc].name)} (ProgsFunctionProxy(${this.fnc}))`; + } + + static create(fnc, ent, settings = {}) { + const cacheId = `${fnc}-${ent ? ent.num : 'null'}`; + + if (ProgsFunctionProxy.proxyCache[cacheId]) { + return ProgsFunctionProxy.proxyCache[cacheId]; + } + + const obj = new ProgsFunctionProxy(fnc, ent, settings); + + // such an ugly hack to make objects actually callable + ProgsFunctionProxy.proxyCache[cacheId] = new Proxy(obj, { + apply(target, thisArg, args) { + return obj.call.apply(obj, args); + }, + }); + + return ProgsFunctionProxy.proxyCache[cacheId]; + } + + static _getEdictId(ent) { + if (!ent) { + return 0; + } + + if (ent instanceof ProgsEntity) { + return ent._edictNum; + } + + return ent.num; + } + + /** + * Calls the proxied QuakeC function. + * @returns The QuakeC function return value interpreted as a float. + */ + call(self) { + const old_self = PR.globals_int[PR.globalvars.self]; + const old_other = PR.globals_int[PR.globalvars.other]; + + if (this.ent && !this.ent.isFree()) { + // in case this is a function bound to an entity, we need to set it to self + PR.globals_int[PR.globalvars.self] = ProgsFunctionProxy._getEdictId(this.ent); + + // fun little hack, we always assume self being other if this is called on an ent + PR.globals_int[PR.globalvars.other] = ProgsFunctionProxy._getEdictId(self); + } else if (self) { + // in case it’s a global function, we need to set self to the first argument + PR.globals_int[PR.globalvars.self] = ProgsFunctionProxy._getEdictId(self); + } + + if (this._settings.resetOther) { + PR.globals_int[PR.globalvars.other] = 0; + } + + PR.ExecuteProgram(this.fnc); + + if (this._settings.backupSelfAndOther) { + PR.globals_int[PR.globalvars.self] = old_self; + PR.globals_int[PR.globalvars.other] = old_other; + } + + return PR.Value(etype.ev_float, PR.globals, 1); // assume float + } +}; + +// PR._stats = { +// edict: {}, +// global: {}, +// }; + +class ProgsEntity { + static SERIALIZATION_TYPE_EDICT = 'E'; + static SERIALIZATION_TYPE_FUNCTION = 'F'; + static SERIALIZATION_TYPE_VECTOR = 'V'; + static SERIALIZATION_TYPE_PRIMITIVE = 'P'; + + /** + * + * @param {*} ed can be null, then it’s global + */ + constructor(ed) { + // const stats = ed ? PR._stats.edict : PR._stats.global; + const defs = ed ? PR.fielddefs : PR.globaldefs; + + if (ed) { + this._edictNum = ed.num; + this._v = new ArrayBuffer(PR.entityfields * 4); + this._v_float = new Float32Array(this._v); + this._v_int = new Int32Array(this._v); + } + + this._serializableFields = []; + + for (let i = 1; i < defs.length; i++) { + const d = defs[i]; + const name = PR.GetString(d.name); + + if (name.charCodeAt(name.length - 2) === 95) { + // skip _x, _y, _z + continue; + } + + const [type, val, ofs] = [d.type & ~PR.saveglobal, ed ? this._v : PR.globals, d.ofs]; + + if ((type & ~PR.saveglobal) === 0) { + continue; + } + + if (!ed && (d.type & ~PR.saveglobal) !== 0 && [etype.ev_string, etype.ev_float, etype.ev_entity].includes(type)) { + this._serializableFields.push(name); + } else if (ed) { + this._serializableFields.push(name); + } + + const val_float = new Float32Array(val); + const val_int = new Int32Array(val); + + const assignedFunctions = []; + + switch (type) { + case etype.ev_string: + Object.defineProperty(this, name, { + get: function() { + return val_int[ofs] > 0 ? PR.GetString(val_int[ofs]) : null; + }, + set: function(value) { + val_int[ofs] = value !== null && value !== '' ? PR.SetString(val_int[ofs], value) : 0; + }, + configurable: true, + enumerable: true, + }); + break; + case etype.ev_entity: // TODO: actually accept entity instead of edict and vice-versa + Object.defineProperty(this, name, { + get: function() { + if (!SV.server?.edicts || !SV.server.edicts[val_int[ofs]]) { + return null; + } + + return SV.server.edicts[val_int[ofs]] || null; + }, + set: function(value) { + if (value === null) { + val_int[ofs] = 0; + return; + } + if (value === 0) { // making fixing stuff easier, though this is a breakpoint trap as well + val_int[ofs] = 0; + return; + } + if (typeof(value.edictId) !== 'undefined') { // TODO: Entity class + val_int[ofs] = value.edictId; + return; + } + if (typeof(value._edictNum) !== 'undefined') { // TODO: Edict class + val_int[ofs] = value.edictId; + return; + } + if (typeof(value.num) !== 'undefined') { // TODO: Edict class + val_int[ofs] = value.num; + return; + } + throw new TypeError('Expected Edict'); + }, + configurable: true, + enumerable: true, + }); + break; + case etype.ev_function: + Object.defineProperty(this, name, { + get: function() { + const id = val_int[ofs]; + if (id < 0 && assignedFunctions[(-id) - 1] instanceof Function) { + return assignedFunctions[(-id) - 1]; + } + return id > 0 ? ProgsFunctionProxy.create(id, ed, { + // some QuakeC related idiosyncrasis we need to take care of + backupSelfAndOther: ['touch'].includes(name), + resetOther: ['StartFrame'].includes(name), + }) : null; + }, + set: function(value) { + if (value === null) { + val_int[ofs] = 0; + return; + } + if (value instanceof Function) { + assignedFunctions.push(value); + val_int[ofs] = -assignedFunctions.length; + return; + } + if (value instanceof ProgsFunctionProxy) { + val_int[ofs] = value.fnc; + return; + } + if (typeof(value) === 'string') { // this is used by ED.ParseEdict etc. when parsing entities and setting fields + const d = PR.FindFunction(value); + if (!d) { + throw new TypeError('Invalid function: ' + value); + } + val_int[ofs] = d; + return; + } + if (typeof(value.fnc) !== 'undefined') { + val_int[ofs] = value.fnc; + return; + } + throw new TypeError('EdictProxy.' + name + ': Expected FunctionProxy, function name or function ID'); + }, + configurable: true, + enumerable: true, + }); + break; + case etype.ev_pointer: // unused and irrelevant + break; + case etype.ev_field: + Object.defineProperty(this, name, { + get: function() { + return val_int[ofs]; + }, + set: function(value) { + if (typeof(value.ofs) !== 'undefined') { + val_int[ofs] = value.ofs; + return; + } + val_int[ofs] = value; + }, + configurable: true, + enumerable: true, + }); + break; + case etype.ev_float: + Object.defineProperty(this, name, { + get: function() { + return val_float[ofs]; + }, + set: function(value) { + if (value === undefined || Q.isNaN(value)) { + throw new TypeError('EdictProxy.' + name + ': invalid value for ev_float passed: ' + value); + } + val_float[ofs] = value; + }, + configurable: true, + enumerable: true, + }); + break; + case etype.ev_vector: // TODO: Proxy for Vector? + Object.defineProperty(this, name, { + get: function() { + return new Vector(val_float[ofs], val_float[ofs + 1], val_float[ofs + 2]); + }, + set: function(value) { + val_float[ofs] = value[0]; + val_float[ofs+1] = value[1]; + val_float[ofs+2] = value[2]; + }, + configurable: true, + enumerable: true, + }); + break; + } + } + } + + serialize() { + const data = {}; + + for (const field of this._serializableFields) { + const value = this[field]; + + switch (true) { + case value === null: + data[field] = [ProgsEntity.SERIALIZATION_TYPE_PRIMITIVE, null]; + break; + + case value instanceof ProgsEntity: + data[field] = [ProgsEntity.SERIALIZATION_TYPE_EDICT, value._edictNum]; + break; + + case value instanceof ProgsFunctionProxy: + data[field] = [ProgsEntity.SERIALIZATION_TYPE_FUNCTION, value.fnc]; + break; + + case value instanceof Vector: + data[field] = [ProgsEntity.SERIALIZATION_TYPE_VECTOR, ...value]; + break; + + case typeof value === 'number': + case typeof value === 'boolean': + case typeof value === 'string': + data[field] = [ProgsEntity.SERIALIZATION_TYPE_PRIMITIVE, value]; + break; + } + } + + return data; + } + + deserialize(obj) { + for (const [key, value] of Object.entries(obj)) { + console.assert(this._serializableFields.includes(key)); + + const [type, ...data] = value; + + switch (type) { + case ProgsEntity.SERIALIZATION_TYPE_EDICT: + this[key] = SV.server.edicts[data[0]]; + break; + + case ProgsEntity.SERIALIZATION_TYPE_FUNCTION: + this[key] = {fnc: data[0]}; + break; + + case ProgsEntity.SERIALIZATION_TYPE_VECTOR: + this[key] = new Vector(...data); + break; + + case ProgsEntity.SERIALIZATION_TYPE_PRIMITIVE: + this[key] = data[0]; + break; + } + } + + return this; + } + + clear() { + if (this._v) { + const int32 = new Int32Array(this._v); + for (let i = 0; i < PR.entityfields; i++) { + int32[i] = 0; + } + } + } + + free() { + this.clear(); + } + + equals(other) { + return other && other._edictNum === this._edictNum; + } + + spawn() { + // QuakeC is different, the actual spawn function is called by its classname + SV.server.gameAPI[this.classname]({num: this._edictNum}); + } + + get edictId() { + return this._edictNum; + } +}; + +// edict + +/** + * Retrieves the global definition at the specified offset. + * @param {number} ofs - The offset to retrieve. + * @returns {object|null} - The global definition. + */ +PR.GlobalAtOfs = function(ofs) { + return PR.globaldefs.find((def) => def.ofs === ofs) || null; +}; + +/** + * Retrieves the field definition at the specified offset. + * @param {number} ofs - The offset to retrieve. + * @returns {object|null} - The field definition. + */ +PR.FieldAtOfs = function(ofs) { + return PR.fielddefs.find((def) => def.ofs === ofs) || null; +}; + +/** + * Finds a field definition by name. + * @param {string} name - The field name. + * @returns {object|null} - The field definition. + */ +PR.FindField = function(name) { + return PR.fielddefs.find((def) => PR.GetString(def.name) === name) || null; +}; + +/** + * Finds a global definition by name. + * @param {string} name - The global name. + * @returns {object|null} - The global definition. + */ +PR.FindGlobal = function(name) { + return PR.globaldefs.find((def) => PR.GetString(def.name) === name) || null; +}; + +/** + * Finds a function definition by name. + * @param {string} name - The function name. + * @returns {number} - The function index. + */ +PR.FindFunction = function(name) { + return PR.functions.findIndex((func) => PR.GetString(func.name) === name); +}; + +PR.ValueString = function(type, val, ofs) { + const val_float = new Float32Array(val); + const val_int = new Int32Array(val); + type &= ~PR.saveglobal; + switch (type) { + case etype.ev_string: + return PR.GetString(val_int[ofs]); + case etype.ev_entity: + return 'entity ' + val_int[ofs]; + case etype.ev_function: + return PR.GetString(PR.functions[val_int[ofs]].name) + '()'; + case etype.ev_field: { + const def = PR.FieldAtOfs(val_int[ofs]); + if (def !== null) { + return '.' + PR.GetString(def.name); + } + return '.'; + } + case etype.ev_void: + return 'void'; + case etype.ev_float: + return val_float[ofs].toFixed(1); + case etype.ev_vector: + return '\'' + val_float[ofs].toFixed(1) + + ' ' + val_float[ofs + 1].toFixed(1) + + ' ' + val_float[ofs + 2].toFixed(1) + '\''; + case etype.ev_pointer: + return 'pointer'; + } + return 'bad type ' + type; +}; + +PR.Value = function(type, val, ofs) { + const val_float = new Float32Array(val); + const val_int = new Int32Array(val); + type &= ~PR.saveglobal; + switch (type) { + case etype.ev_string: + return PR.GetString(val_int[ofs]); + case etype.ev_pointer: + case etype.ev_entity: + case etype.ev_field: + return val_int[ofs]; + // case etype.ev_field: { + // const def = PR.FieldAtOfs(val_int[ofs]); + // if (def != null) { + // return '.' + PR.GetString(def.name); + // } + // return '.'; + // } + case etype.ev_function: + return PR.GetString(PR.functions[val_int[ofs]].name) + '()'; + case etype.ev_void: + return null; + case etype.ev_float: + return val_float[ofs]; + case etype.ev_vector: + return [val_float[ofs], + val_float[ofs + 1], + val_float[ofs + 2]]; + } + throw new TypeError('bad PR etype ' + type); +}; + +PR.UglyValueString = function(type, val, ofs) { + const val_float = new Float32Array(val); + const val_int = new Int32Array(val); + type &= ~PR.saveglobal; + switch (type) { + case etype.ev_string: + return PR.GetString(val_int[ofs]); + case etype.ev_entity: + return val_int[ofs].toString(); + case etype.ev_function: + return PR.GetString(PR.functions[val_int[ofs]].name); + case etype.ev_field: { + const def = PR.FieldAtOfs(val_int[ofs]); + if (def !== null) { + return PR.GetString(def.name); + } + return ''; + } + case etype.ev_void: + return 'void'; + case etype.ev_float: + return val_float[ofs].toFixed(6); + case etype.ev_vector: + return val_float[ofs].toFixed(6) + + ' ' + val_float[ofs + 1].toFixed(6) + + ' ' + val_float[ofs + 2].toFixed(6); + } + return 'bad type ' + type; +}; + +PR.GlobalString = function(ofs) { + const def = PR.GlobalAtOfs(ofs); let line; + if (def !== null) { + line = ofs + '(' + PR.GetString(def.name) + ')' + PR.ValueString(def.type, PR.globals, ofs); + } else { + line = ofs + '(???)'; + } + for (; line.length <= 20; ) { + line += ' '; + } + return line; +}; + +PR.GlobalStringNoContents = function(ofs) { + const def = PR.GlobalAtOfs(ofs); let line; + if (def !== null) { + line = ofs + '(' + PR.GetString(def.name) + ')'; + } else { + line = ofs + '(???)'; + } + for (; line.length <= 20; ) { + line += ' '; + } + return line; +}; + +PR.LoadProgs = async function() { + const progs = await COM.LoadFile('progs.dat'); + if (progs === null) { + throw new MissingResourceError('progs.dat'); + } + Con.DPrint('Programs occupy ' + (progs.byteLength >> 10) + 'K.\n'); + const view = new DataView(progs); + + let i = view.getUint32(0, true); + if (i !== PR.version) { + throw new Error('progs.dat has wrong version number (' + i + ' should be ' + PR.version + ')'); + } + + if (view.getUint32(4, true) !== PR.progheader_crc) { + throw new Error('progs.dat system vars have been modified, PR.js is out of date'); + } + + PR.crc = CRC16CCITT.Block(new Uint8Array(progs)); + + PR.stack = []; + PR.depth = 0; + + PR.localstack = []; + for (i = 0; i < PR.localstack_size; i++) { + PR.localstack[i] = 0; + } + PR.localstack_used = 0; + + let ofs; let num; + + ofs = view.getUint32(8, true); + num = view.getUint32(12, true); + PR.statements = []; + for (i = 0; i < num; i++) { + PR.statements[i] = { + op: view.getUint16(ofs, true), + a: view.getInt16(ofs + 2, true), + b: view.getInt16(ofs + 4, true), + c: view.getInt16(ofs + 6, true), + }; + ofs += 8; + } + + ofs = view.getUint32(16, true); + num = view.getUint32(20, true); + PR.globaldefs = []; + for (i = 0; i < num; i++) { + PR.globaldefs[i] = { + type: view.getUint16(ofs, true), + ofs: view.getUint16(ofs + 2, true), + name: view.getUint32(ofs + 4, true), + }; + ofs += 8; + } + + ofs = view.getUint32(24, true); + num = view.getUint32(28, true); + PR.fielddefs = []; + for (i = 0; i < num; i++) { + PR.fielddefs[i] = { + type: view.getUint16(ofs, true), + ofs: view.getUint16(ofs + 2, true), + name: view.getUint32(ofs + 4, true), + }; + ofs += 8; + } + + ofs = view.getUint32(32, true); + num = view.getUint32(36, true); + PR.functions = []; + for (i = 0; i < num; i++) { + PR.functions[i] = { + first_statement: view.getInt32(ofs, true), + parm_start: view.getUint32(ofs + 4, true), + locals: view.getUint32(ofs + 8, true), + profile: view.getUint32(ofs + 12, true), + name: view.getUint32(ofs + 16, true), + file: view.getUint32(ofs + 20, true), + numparms: view.getUint32(ofs + 24, true), + parm_size: [ + view.getUint8(ofs + 28), view.getUint8(ofs + 29), + view.getUint8(ofs + 30), view.getUint8(ofs + 31), + view.getUint8(ofs + 32), view.getUint8(ofs + 33), + view.getUint8(ofs + 34), view.getUint8(ofs + 35), + ], + }; + ofs += 36; + } + + ofs = view.getUint32(40, true); + num = view.getUint32(44, true); + PR.strings = []; + for (i = 0; i < num; i++) { + PR.strings[i] = view.getUint8(ofs + i); + } + PR.string_temp = PR.NewString('', 1024); // allocates 1024 bytes for temp strings + PR.string_heap_start = PR.strings.length + 4; + PR.string_heap_current = PR.string_heap_start; + + ofs = view.getUint32(48, true); + num = view.getUint32(52, true); + PR.globals = new ArrayBuffer(num << 2); + PR.globals_float = new Float32Array(PR.globals); + PR.globals_int = new Int32Array(PR.globals); + for (i = 0; i < num; i++) { + PR.globals_int[i] = view.getInt32(ofs + (i << 2), true); + } + + PR.entityfields = view.getUint32(56, true); + PR.edict_size = 96 + (PR.entityfields << 2); + + const fields = [ + 'ammo_shells1', + 'ammo_nails1', + 'ammo_lava_nails', + 'ammo_rockets1', + 'ammo_multi_rockets', + 'ammo_cells1', + 'ammo_plasma', + 'gravity', + 'items2', + ]; + for (i = 0; i < fields.length; i++) { + const field = fields[i]; + const def = PR.FindField(field); + PR.entvars[field] = (def !== null) ? def.ofs : null; + } + ProgsFunctionProxy.proxyCache = []; // free all cached functions + // hook up progs.dat with our proxies + + const progsAPI = new ProgsEntity(null); + + const deathmatch = Cvar.FindVar('deathmatch'); + const skill = Cvar.FindVar('skill'); + const coop = Cvar.FindVar('coop'); + + const gameAPI = Object.assign(progsAPI, { + prepareEntity(edict, classname, initialData = {}) { + if (!edict.entity) { // do not use isFree(), check for unset entity property + edict.entity = new ProgsEntity(edict); + Object.freeze(edict.entity); + } + + // yet another hack, always be successful during a loadgame + if (SV.server.loadgame) { + return true; + } + + // special case for QuakeC: empty entity + if (classname === null) { + return true; + } + + // another special case for QuakeC: player has no spawn function + if (classname === 'player') { + return true; + } + + if (!SV.server.gameAPI[classname]) { + Con.PrintWarning(`No spawn function for edict ${edict.num}: ${classname}\n`); + // debugger; + return false; + } + + initialData.classname = classname; + + for (const [key, value] of Object.entries(initialData)) { + const field = PR.FindField(key); + + if (!field) { + Con.PrintWarning(`'${key}' is not a field\n`); + continue; + } + + switch (field.type & 0x7fff) { + case etype.ev_entity: + edict.entity[key] = value instanceof ServerEdict ? value : {num: Q.atoi(value)}; + break; + + case etype.ev_vector: + edict.entity[key] = value instanceof Vector ? value : new Vector(...value.split(' ').map((x) => Q.atof(x))); + break; + + case etype.ev_field: { + const d = PR.FindField(value); + if (!d) { + Con.PrintWarning(`Can't find field: ${value}\n`); + break; + } + edict.entity[key] = d; + break; + } + + case etype.ev_function: { + edict.entity[key] = {fnc: value}; + break; + } + + default: + edict.entity[key] = value; + } + } + + // these are quake specific things happening during loading + + const spawnflags = edict.entity.spawnflags || 0; + + if (deathmatch.value !== 0 && (spawnflags & 2048)) { + return false; + } + + const skillFlags = [256, 512, 1024, 1024]; + + if (spawnflags & skillFlags[Math.max(0, Math.min(skillFlags.length, skill.value))]) { + return false; + } + + return true; + }, + + spawnPreparedEntity(edict) { + if (!edict.entity) { + Con.PrintError('PR.LoadProgs.spawnPreparedEntity: no entity class instance set!\n'); + return false; + } + + // another special case for QuakeC: player has no spawn function + if (edict.entity.classname === 'player') { + return true; + } + + edict.entity.spawn(); + + return true; + }, + + init(mapname, serverflags) { + gameAPI.mapname = mapname; + gameAPI.serverflags = serverflags; + + // coop automatically disables deathmatch + if (coop.value) { + coop.set(true); + deathmatch.set(false); + } + + // make sure skill is in range + skill.set(Math.max(0, Math.min(3, Math.floor(skill.value)))); + + gameAPI.coop = coop.value; + gameAPI.deathmatch = deathmatch.value; + }, + + + shutdown(_isCrashShutdown) { + }, + + startFrame() { + // pass to the VM + // @ts-ignore + progsAPI.StartFrame(null); + }, + }); + + Object.freeze(gameAPI); + + return gameAPI; +}; + +/** @type {Cvar[]} */ +PR._cvars = []; + +/** @type {import('./GameLoader').GameModuleInterface|null} */ +PR.QuakeJS = null; + +PR.Init = async function() { + try { + if (COM.CheckParm('-noquakejs')) { + Con.PrintWarning('PR.Init: QuakeJS disabled by request\n'); + PR.QuakeJS = null; + } else { + // try to get the game API + PR.QuakeJS = await loadGameModule(COM.gamedir[0].filename); + PR.QuakeJS.ServerGameAPI.Init(ServerEngineAPI); + + const identification = PR.QuakeJS.identification; + Con.Print(`PR.Init: ${identification.name} v${identification.version.join('.')} by ${identification.author} loaded.\n`); + return; + } + } catch (e) { + if (typeof(e.code) === 'string' && e.code !== 'ERR_MODULE_NOT_FOUND') { // only catch module not found errors + throw e; + } + + // CR: stupidest error convention ever, check https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import#return_value + + Con.PrintWarning('PR.Init: Falling back to QuakeC, failed to initialize QuakeJS server code: ' + e.message +'.\n'); + + PR.QuakeJS = null; + } + + // CR: we do not need any of this when running QuakeJS + Cmd.AddCommand('edict', ED.PrintEdict_f); + Cmd.AddCommand('edicts', ED.PrintEdicts); + Cmd.AddCommand('edictcount', ED.Count); + Cmd.AddCommand('profile', PR.Profile_f); + PR._cvars.push(new Cvar('gamecfg', '0')); + PR._cvars.push(new Cvar('scratch1', '0')); + PR._cvars.push(new Cvar('scratch2', '0')); + PR._cvars.push(new Cvar('scratch3', '0')); + PR._cvars.push(new Cvar('scratch4', '0')); + PR._cvars.push(new Cvar('savedgamecfg', '0', Cvar.FLAG.ARCHIVE)); + PR._cvars.push(new Cvar('saved1', '0', Cvar.FLAG.ARCHIVE)); + PR._cvars.push(new Cvar('saved2', '0', Cvar.FLAG.ARCHIVE)); + PR._cvars.push(new Cvar('saved3', '0', Cvar.FLAG.ARCHIVE)); + PR._cvars.push(new Cvar('saved4', '0', Cvar.FLAG.ARCHIVE)); + + PR._cvars.push(new Cvar('nomonsters', '0', Cvar.FLAG.SERVER)); + PR._cvars.push(new Cvar('fraglimit', '0', Cvar.FLAG.SERVER)); + PR._cvars.push(new Cvar('timelimit', '0', Cvar.FLAG.SERVER)); + PR._cvars.push(new Cvar('samelevel', '0', Cvar.FLAG.SERVER, 'Set to 1 to stay on the same map even the map is over')); + PR._cvars.push(new Cvar('noexit', '0', Cvar.FLAG.SERVER)); + PR._cvars.push(new Cvar('skill', '1', Cvar.FLAG.SERVER)); + PR._cvars.push(new Cvar('deathmatch', '0', Cvar.FLAG.SERVER)); + PR._cvars.push(new Cvar('coop', '0', Cvar.FLAG.SERVER)); +}; + +// exec + +PR.localstack_size = 2048; + +PR.opnames = [ + 'DONE', + 'MUL_F', 'MUL_V', 'MUL_FV', 'MUL_VF', + 'DIV', + 'ADD_F', 'ADD_V', + 'SUB_F', 'SUB_V', + 'EQ_F', 'EQ_V', 'EQ_S', 'EQ_E', 'EQ_FNC', + 'NE_F', 'NE_V', 'NE_S', 'NE_E', 'NE_FNC', + 'LE', 'GE', 'LT', 'GT', + 'INDIRECT', 'INDIRECT', 'INDIRECT', 'INDIRECT', 'INDIRECT', 'INDIRECT', + 'ADDRESS', + 'STORE_F', 'STORE_V', 'STORE_S', 'STORE_ENT', 'STORE_FLD', 'STORE_FNC', + 'STOREP_F', 'STOREP_V', 'STOREP_S', 'STOREP_ENT', 'STOREP_FLD', 'STOREP_FNC', + 'RETURN', + 'NOT_F', 'NOT_V', 'NOT_S', 'NOT_ENT', 'NOT_FNC', + 'IF', 'IFNOT', + 'CALL0', 'CALL1', 'CALL2', 'CALL3', 'CALL4', 'CALL5', 'CALL6', 'CALL7', 'CALL8', + 'STATE', + 'GOTO', + 'AND', 'OR', + 'BITAND', 'BITOR', +]; + +// PR.executions = []; + +PR.PrintStatement = function(s) { + let text; + if (s.op < PR.opnames.length) { + text = PR.opnames[s.op] + ' '; + for (; text.length <= 9; ) { + text += ' '; + } + } else { + text = ''; + } + if ((s.op === PR.op.jnz) || (s.op === PR.op.jz)) { + text += PR.GlobalString(s.a) + 'branch ' + s.b; + } else if (s.op === PR.op.jump) { + text += 'branch ' + s.a; + } else if ((s.op >= PR.op.store_f) && (s.op <= PR.op.store_fnc)) { + text += PR.GlobalString(s.a) + PR.GlobalStringNoContents(s.b); + } else { + if (s.a !== 0) { + text += PR.GlobalString(s.a); + } + if (s.b !== 0) { + text += PR.GlobalString(s.b); + } + if (s.c !== 0) { + text += PR.GlobalStringNoContents(s.c); + } + } + Con.Print(text + '\n'); + // if (PR.executions.length > 50) { + // PR.executions.shift(); + // } + // PR.executions.push(text); +}; + +PR.StackTrace = function() { + if (PR.depth === 0) { + Con.Print('\n'); + return; + } + PR.stack[PR.depth] = [PR.xstatement, PR.xfunction]; + let f; let file; + for (; PR.depth >= 0; PR.depth--) { + f = PR.stack[PR.depth][1]; + if (!f) { + Con.Print('\n'); + continue; + } + file = PR.GetString(f.file); + for (; file.length <= 11; ) { + file += ' '; + } + Con.Print(file + ' : ' + PR.GetString(f.name) + '\n'); + } + PR.depth = 0; +}; + +PR.Profile_f = function() { + if (SV.server.active !== true) { + return; + } + let num = 0; let max; let best; let i; let f; let profile; + while (true) { + max = 0; + best = null; + for (i = 0; i < PR.functions.length; i++) { + f = PR.functions[i]; + if (f.profile > max) { + max = f.profile; + best = f; + } + } + if (best === null) { + return; + } + if (num < 10) { + profile = best.profile.toString(); + for (; profile.length <= 6; ) { + profile = ' ' + profile; + } + Con.Print(profile + ' ' + PR.GetString(best.name) + '\n'); + } + num++; + best.profile = 0; + } +}; + +PR.RunError = function(error) { + PR.PrintStatement(PR.statements[PR.xstatement]); + PR.StackTrace(); + Con.PrintError(error + '\n'); + throw new HostError('Program error'); +}; + +PR.EnterFunction = function(f) { + PR.stack[PR.depth++] = [PR.xstatement, PR.xfunction]; + const c = f.locals; + if ((PR.localstack_used + c) > PR.localstack_size) { + PR.RunError('PR.EnterFunction: locals stack overflow\n'); + } + let i; + for (i = 0; i < c; i++) { + PR.localstack[PR.localstack_used + i] = PR.globals_int[f.parm_start + i]; + } + PR.localstack_used += c; + let o = f.parm_start; let j; + for (i = 0; i < f.numparms; i++) { + for (j = 0; j < f.parm_size[i]; j++) { + PR.globals_int[o++] = PR.globals_int[4 + i * 3 + j]; + } + } + PR.xfunction = f; + return f.first_statement - 1; +}; + +PR.LeaveFunction = function() { + if (PR.depth <= 0) { + throw new Error('prog stack underflow'); + } + let c = PR.xfunction.locals; + PR.localstack_used -= c; + if (PR.localstack_used < 0) { + PR.RunError('PR.LeaveFunction: locals stack underflow\n'); + } + for (--c; c >= 0; --c) { + PR.globals_int[PR.xfunction.parm_start + c] = PR.localstack[PR.localstack_used + c]; + } + PR.xfunction = PR.stack[--PR.depth][1]; + return PR.stack[PR.depth][0]; +}; + +PR.ExecuteProgram = function(fnum) { + if ((fnum === 0) || (fnum >= PR.functions.length)) { + if (PR.globals_int[PR.globalvars.self] !== 0) { + ED.Print(SV.server.edicts[PR.globals_int[PR.globalvars.self]]); + } + throw new HostError('PR.ExecuteProgram: NULL function'); + } + let runaway = 100000; + const exitdepth = PR.depth; + let s = PR.EnterFunction(PR.functions[fnum]); + let st; let ed; let ptr; let newf; + + while (true) { + s++; + st = PR.statements[s]; + if (--runaway === 0) { + PR.RunError('runaway loop error'); + } + PR.xfunction.profile++; + PR.xstatement = s; + if (PR.trace) { + PR.PrintStatement(st); + } + switch (st.op) { + case PR.op.add_f: + PR.globals_float[st.c] = PR.globals_float[st.a] + PR.globals_float[st.b]; + continue; + case PR.op.add_v: + PR.globals_float[st.c] = PR.globals_float[st.a] + PR.globals_float[st.b]; + PR.globals_float[st.c + 1] = PR.globals_float[st.a + 1] + PR.globals_float[st.b + 1]; + PR.globals_float[st.c + 2] = PR.globals_float[st.a + 2] + PR.globals_float[st.b + 2]; + continue; + case PR.op.sub_f: + PR.globals_float[st.c] = PR.globals_float[st.a] - PR.globals_float[st.b]; + continue; + case PR.op.sub_v: + PR.globals_float[st.c] = PR.globals_float[st.a] - PR.globals_float[st.b]; + PR.globals_float[st.c + 1] = PR.globals_float[st.a + 1] - PR.globals_float[st.b + 1]; + PR.globals_float[st.c + 2] = PR.globals_float[st.a + 2] - PR.globals_float[st.b + 2]; + continue; + case PR.op.mul_f: + PR.globals_float[st.c] = PR.globals_float[st.a] * PR.globals_float[st.b]; + continue; + case PR.op.mul_v: + PR.globals_float[st.c] = PR.globals_float[st.a] * PR.globals_float[st.b] + + PR.globals_float[st.a + 1] * PR.globals_float[st.b + 1] + + PR.globals_float[st.a + 2] * PR.globals_float[st.b + 2]; + continue; + case PR.op.mul_fv: + PR.globals_float[st.c] = PR.globals_float[st.a] * PR.globals_float[st.b]; + PR.globals_float[st.c + 1] = PR.globals_float[st.a] * PR.globals_float[st.b + 1]; + PR.globals_float[st.c + 2] = PR.globals_float[st.a] * PR.globals_float[st.b + 2]; + continue; + case PR.op.mul_vf: + PR.globals_float[st.c] = PR.globals_float[st.b] * PR.globals_float[st.a]; + PR.globals_float[st.c + 1] = PR.globals_float[st.b] * PR.globals_float[st.a + 1]; + PR.globals_float[st.c + 2] = PR.globals_float[st.b] * PR.globals_float[st.a + 2]; + continue; + case PR.op.div_f: + PR.globals_float[st.c] = PR.globals_float[st.a] / PR.globals_float[st.b]; + continue; + case PR.op.bitand: + PR.globals_float[st.c] = PR.globals_float[st.a] & PR.globals_float[st.b]; + continue; + case PR.op.bitor: + PR.globals_float[st.c] = PR.globals_float[st.a] | PR.globals_float[st.b]; + continue; + case PR.op.ge: + PR.globals_float[st.c] = (PR.globals_float[st.a] >= PR.globals_float[st.b]) ? 1.0 : 0.0; + continue; + case PR.op.le: + PR.globals_float[st.c] = (PR.globals_float[st.a] <= PR.globals_float[st.b]) ? 1.0 : 0.0; + continue; + case PR.op.gt: + PR.globals_float[st.c] = (PR.globals_float[st.a] > PR.globals_float[st.b]) ? 1.0 : 0.0; + continue; + case PR.op.lt: + PR.globals_float[st.c] = (PR.globals_float[st.a] < PR.globals_float[st.b]) ? 1.0 : 0.0; + continue; + case PR.op.and: + PR.globals_float[st.c] = ((PR.globals_float[st.a] !== 0.0) && (PR.globals_float[st.b] !== 0.0)) ? 1.0 : 0.0; + continue; + case PR.op.or: + PR.globals_float[st.c] = ((PR.globals_float[st.a] !== 0.0) || (PR.globals_float[st.b] !== 0.0)) ? 1.0 : 0.0; + continue; + case PR.op.not_f: + PR.globals_float[st.c] = (PR.globals_float[st.a] === 0.0) ? 1.0 : 0.0; + continue; + case PR.op.not_v: + PR.globals_float[st.c] = ((PR.globals_float[st.a] === 0.0) && + (PR.globals_float[st.a + 1] === 0.0) && + (PR.globals_float[st.a + 2] === 0.0)) ? 1.0 : 0.0; + continue; + case PR.op.not_s: + if (PR.globals_int[st.a] !== 0) { + PR.globals_float[st.c] = (PR.strings[PR.globals_int[st.a]] === 0) ? 1.0 : 0.0; + } else { + PR.globals_float[st.c] = 1.0; + } + continue; + case PR.op.not_fnc: + case PR.op.not_ent: + PR.globals_float[st.c] = (PR.globals_int[st.a] === 0) ? 1.0 : 0.0; + continue; + case PR.op.eq_f: + PR.globals_float[st.c] = (PR.globals_float[st.a] === PR.globals_float[st.b]) ? 1.0 : 0.0; + continue; + case PR.op.eq_v: + PR.globals_float[st.c] = ((PR.globals_float[st.a] === PR.globals_float[st.b]) && + (PR.globals_float[st.a + 1] === PR.globals_float[st.b + 1]) && + (PR.globals_float[st.a + 2] === PR.globals_float[st.b + 2])) ? 1.0 : 0.0; + continue; + case PR.op.eq_s: + PR.globals_float[st.c] = (PR.GetString(PR.globals_int[st.a]) === PR.GetString(PR.globals_int[st.b])) ? 1.0 : 0.0; + continue; + case PR.op.eq_e: + case PR.op.eq_fnc: + PR.globals_float[st.c] = (PR.globals_int[st.a] === PR.globals_int[st.b]) ? 1.0 : 0.0; + continue; + case PR.op.ne_f: + PR.globals_float[st.c] = (PR.globals_float[st.a] !== PR.globals_float[st.b]) ? 1.0 : 0.0; + continue; + case PR.op.ne_v: + PR.globals_float[st.c] = ((PR.globals_float[st.a] !== PR.globals_float[st.b]) || + (PR.globals_float[st.a + 1] !== PR.globals_float[st.b + 1]) || + (PR.globals_float[st.a + 2] !== PR.globals_float[st.b + 2])) ? 1.0 : 0.0; + continue; + case PR.op.ne_s: + PR.globals_float[st.c] = (PR.GetString(PR.globals_int[st.a]) !== PR.GetString(PR.globals_int[st.b])) ? 1.0 : 0.0; + continue; + case PR.op.ne_e: + case PR.op.ne_fnc: + PR.globals_float[st.c] = (PR.globals_int[st.a] !== PR.globals_int[st.b]) ? 1.0 : 0.0; + continue; + case PR.op.store_f: + case PR.op.store_ent: + case PR.op.store_fld: + case PR.op.store_s: + case PR.op.store_fnc: + PR.globals_int[st.b] = PR.globals_int[st.a]; + continue; + case PR.op.store_v: + PR.globals_int[st.b] = PR.globals_int[st.a]; + PR.globals_int[st.b + 1] = PR.globals_int[st.a + 1]; + PR.globals_int[st.b + 2] = PR.globals_int[st.a + 2]; + continue; + case PR.op.storep_f: + case PR.op.storep_ent: + case PR.op.storep_fld: + case PR.op.storep_s: + case PR.op.storep_fnc: + ptr = PR.globals_int[st.b]; + SV.server.edicts[Math.floor(ptr / PR.edict_size)].entity._v_int[((ptr % PR.edict_size) - 96) >> 2] = PR.globals_int[st.a]; + continue; + case PR.op.storep_v: + ed = SV.server.edicts[Math.floor(PR.globals_int[st.b] / PR.edict_size)]; + ptr = ((PR.globals_int[st.b] % PR.edict_size) - 96) >> 2; + ed.entity._v_int[ptr] = PR.globals_int[st.a]; + ed.entity._v_int[ptr + 1] = PR.globals_int[st.a + 1]; + ed.entity._v_int[ptr + 2] = PR.globals_int[st.a + 2]; + continue; + case PR.op.address: + ed = PR.globals_int[st.a]; + if ((ed === 0) && (SV.server.loading !== true)) { + PR.RunError('assignment to world entity'); + } + PR.globals_int[st.c] = ed * PR.edict_size + 96 + (PR.globals_int[st.b] << 2); + continue; + case PR.op.load_f: + case PR.op.load_fld: + case PR.op.load_ent: + case PR.op.load_s: + case PR.op.load_fnc: + PR.globals_int[st.c] = SV.server.edicts[PR.globals_int[st.a]].entity._v_int[PR.globals_int[st.b]]; + continue; + case PR.op.load_v: + ed = SV.server.edicts[PR.globals_int[st.a]]; + ptr = PR.globals_int[st.b]; + PR.globals_int[st.c] = ed.entity._v_int[ptr]; + PR.globals_int[st.c + 1] = ed.entity._v_int[ptr + 1]; + PR.globals_int[st.c + 2] = ed.entity._v_int[ptr + 2]; + continue; + case PR.op.jz: + if (PR.globals_int[st.a] === 0) { + s += st.b - 1; + } + continue; + case PR.op.jnz: + if (PR.globals_int[st.a] !== 0) { + s += st.b - 1; + } + continue; + case PR.op.jump: + s += st.a - 1; + continue; + case PR.op.call0: + case PR.op.call1: + case PR.op.call2: + case PR.op.call3: + case PR.op.call4: + case PR.op.call5: + case PR.op.call6: + case PR.op.call7: + case PR.op.call8: + PR.argc = st.op - PR.op.call0; + if (PR.globals_int[st.a] === 0) { + PR.RunError('NULL function'); + } + if (PR.globals_int[st.a] < 0) { + console.debug('special function called'); + continue; + } + newf = PR.functions[PR.globals_int[st.a]]; + if (newf.first_statement < 0) { + ptr = -newf.first_statement; + if (ptr >= PF.builtin.length) { + PR.RunError('Bad builtin call number'); + } + // PF.builtin[ptr]; + // try { + PF.builtin[ptr](); + // } catch (e) { + // PR.RunError(e.message); + // throw e; + // } + continue; + } + s = PR.EnterFunction(newf); + continue; + case PR.op.done: + case PR.op.ret: + PR.globals_int[PR.ofs.OFS_RETURN] = PR.globals_int[st.a]; + PR.globals_int[PR.ofs.OFS_RETURN + 1] = PR.globals_int[st.a + 1]; + PR.globals_int[PR.ofs.OFS_RETURN + 2] = PR.globals_int[st.a + 2]; + s = PR.LeaveFunction(); + if (PR.depth === exitdepth) { + return; + } + continue; + case PR.op.state: + ed = SV.server.edicts[PR.globals_int[PR.globalvars.self]]; + ed.entity._v_float[PR.entvars.nextthink] = PR.globals_float[PR.globalvars.time] + 0.1; + ed.entity._v_float[PR.entvars.frame] = PR.globals_float[st.a]; + ed.entity._v_int[PR.entvars.think] = PR.globals_int[st.b]; + continue; + } + PR.RunError('Bad opcode ' + st.op); + } +}; + +PR.GetString = function(num) { + let string = ''; + for (; num < PR.strings.length; num++) { + if (PR.strings[num] === 0) { + break; + } + string += String.fromCharCode(PR.strings[num]); + } + return string; +}; + +PR._StringLength = function(ofs) { + let len = 0; + + while(PR.strings[ofs+len]) { + len++; + } + + return len; +}; + +PR.SetString = function(ofs, s, length = null) { + // shortcut: empty strings are located at 0x0000 + if (s === '') { + return 0; + } + + const size = (length !== null ? Math.max(s.length, length || 0) : s.length) + 1; + + // check if it’s going to overwrite a constant (ofs < PR.string_heap_start) + // check if we can overwrite in place (s.length < &PR.strings[ofs].length) + if (ofs === null || ofs < PR.string_heap_start || PR._StringLength(ofs) <= size) { + ofs = PR.string_heap_current; + PR.string_heap_current += size; + } + + // overwrite found spot with s + for (let i = 0; i < s.length; i++) { + PR.strings[ofs + i] = s.charCodeAt(i); + } + + // add 0-byte string terminator + PR.strings[ofs + s.length] = 0; + + return ofs; +}; + +/** + * Allocates a new string in the VM string heap. + * @returns The offset of the allocated string. + */ +PR.NewString = function(s, length) { + const ofs = PR.strings.length; + let i; + if (s.length >= length) { + for (i = 0; i < (length - 1); i++) { + PR.strings[PR.strings.length] = s.charCodeAt(i); + } + PR.strings[PR.strings.length] = 0; + return ofs; + } + for (i = 0; i < s.length; i++) { + PR.strings[PR.strings.length] = s.charCodeAt(i); + } + length -= s.length; + for (i = 0; i < length; i++) { + PR.strings[PR.strings.length] = 0; + } + return ofs; +}; + +PR.TempString = function(string) { + if (string.length > 1023) { + string = string.substring(0, 1023); + } + for (let i = 0; i < string.length; i++) { + PR.strings[PR.string_temp + i] = string.charCodeAt(i); + } + PR.strings[PR.string_temp + string.length] = 0; + + return PR.string_temp; +}; + +PR.capabilities = [ + gameCapabilities.CAP_CLIENTDATA_UPDATESTAT, + gameCapabilities.CAP_CLIENTDATA_LEGACY, + gameCapabilities.CAP_SPAWNPARMS_LEGACY, + gameCapabilities.CAP_ENTITY_BBOX_ADJUSTMENTS_DURING_LINK, +]; From 289eed1c49dcdd6a96cb722221d0b1bf157f6b26 Mon Sep 17 00:00:00 2001 From: Christian R Date: Fri, 3 Apr 2026 14:18:52 +0300 Subject: [PATCH 36/67] TS: server/ part 4 --- source/engine/server/ServerMessages.mjs | 847 +-------------------- source/engine/server/ServerMessages.ts | 932 ++++++++++++++++++++++++ test/physics/server-messages.test.mjs | 91 +++ 3 files changed, 1024 insertions(+), 846 deletions(-) create mode 100644 source/engine/server/ServerMessages.ts create mode 100644 test/physics/server-messages.test.mjs diff --git a/source/engine/server/ServerMessages.mjs b/source/engine/server/ServerMessages.mjs index 927086cb..df6e4829 100644 --- a/source/engine/server/ServerMessages.mjs +++ b/source/engine/server/ServerMessages.mjs @@ -1,846 +1 @@ -import { SzBuffer } from '../network/MSG.ts'; -import * as Protocol from '../network/Protocol.ts'; -import * as Defs from '../../shared/Defs.ts'; -import Cvar from '../common/Cvar.ts'; -import { eventBus, registry } from '../registry.mjs'; -import { ServerClient } from './Client.mjs'; -import { ServerEntityState } from './ServerEntityState.mjs'; - -let { COM, Con, Host, NET, PR, SV } = registry; - -eventBus.subscribe('registry.frozen', () => { - COM = registry.COM; - Con = registry.Con; - Host = registry.Host; - NET = registry.NET; - PR = registry.PR; - SV = registry.SV; -}); - -/** - * Handles all server to client message assembly and related helpers. - */ -export class ServerMessages { - constructor() { - this.nullcmd = new Protocol.UserCmd(); - } - - startParticle(org, dir, color, count) { - const datagram = SV.server.datagram; - if (datagram.cursize >= 1009) { - return; - } - datagram.writeByte(Protocol.svc.particle); - datagram.writeCoordVector(org); - datagram.writeCoordVector(dir); - datagram.writeByte(Math.min(count, 255)); - datagram.writeByte(color); - } - - startSound(edict, channel, sample, volume, attenuation) { - console.assert(volume >= 0 && volume <= 255, 'volume out of range', volume); - console.assert(attenuation >= 0.0 && attenuation <= 4.0, 'attenuation out of range', attenuation); - console.assert(channel >= 0 && channel <= 7, 'channel out of range', channel); - - const datagram = SV.server.datagram; - if (datagram.cursize >= 1009) { - return; - } - - let i; - for (i = 1; i < SV.server.soundPrecache.length; i++) { - if (sample === SV.server.soundPrecache[i]) { - break; - } - } - if (i >= SV.server.soundPrecache.length) { - Con.Print('SV.StartSound: ' + sample + ' was not precached\n'); - SV.server.soundPrecache.push(sample); - datagram.writeByte(Protocol.svc.loadsound); - datagram.writeByte(i); - datagram.writeString(sample); - } - - let fieldMask = 0; - - if (volume !== 255) { - fieldMask |= 1; - } - if (attenuation !== 1.0) { - fieldMask |= 2; - } - - datagram.writeByte(Protocol.svc.sound); - datagram.writeByte(fieldMask); - if ((fieldMask & 1) !== 0) { - datagram.writeByte(volume); - } - if ((fieldMask & 2) !== 0) { - datagram.writeByte(Math.floor(attenuation * 64.0)); - } - datagram.writeShort((edict.num << 3) + channel); - datagram.writeByte(i); - datagram.writeCoordVector(edict.entity.origin.copy().add(edict.entity.mins.copy().add(edict.entity.maxs).multiply(0.5))); - } - - /** - * Sends the serverdata message to a specific client. - * Needs to be done in order to complete the signon process step 1. - * @param {ServerClient} client client - */ - sendServerData(client) { - const message = client.message; - - message.writeByte(Protocol.svc.print); - message.writeString(`\x02\nVERSION ${Host.version.string} SERVER (${SV.server.gameVersion})\n`); - - message.writeByte(Protocol.svc.serverdata); - message.writeByte(Protocol.version); - - if (PR.QuakeJS?.ClientGameAPI) { - const { author, name, version } = PR.QuakeJS.identification; - message.writeByte(1); - message.writeString(name); - message.writeString(author); - message.writeByte(version[0]); - message.writeByte(version[1]); - message.writeByte(version[2]); - } else { - message.writeByte(0); - message.writeString(COM.game); - } - - message.writeByte(SV.svs.maxclients); - message.writeString(SV.server.edicts[0].entity.message || SV.server.mapname); - // SV.pmove.movevars.sendToClient(message); - for (let i = 1; i < SV.server.modelPrecache.length; i++) { - message.writeString(SV.server.modelPrecache[i]); - } - message.writeByte(0); - for (let i = 1; i < SV.server.soundPrecache.length; i++) { - message.writeString(SV.server.soundPrecache[i]); - } - message.writeByte(0); - - if (SV.server.gameCapabilities.includes(Defs.gameCapabilities.CAP_CLIENTDATA_DYNAMIC)) { - for (const field of SV.server.clientdataFields) { - message.writeString(field); - } - message.writeByte(0); - } - - if (SV.server.gameCapabilities.includes(Defs.gameCapabilities.CAP_ENTITY_EXTENDED)) { - for (const [classname, { fields }] of Object.entries(SV.server.clientEntityFields)) { - message.writeString(classname); - for (const field of fields) { - message.writeString(field); - } - message.writeByte(0); - } - message.writeByte(0); - } - - // sounds on worldspawn defines the cd track - const cdtrack = /** @type {number} */ (/** @type {import('./Edict.mjs').WorldspawnEntity} */(SV.server.edicts[0].entity).sounds); - - // only play cd track automatically if set in worldspawn - if (typeof cdtrack === 'number') { - message.writeByte(Protocol.svc.cdtrack); - message.writeByte(cdtrack); - message.writeByte(0); // unused - } - - message.writeByte(Protocol.svc.setview); - message.writeShort(client.edict.num); - - const serverCvars = Array.from(Cvar.Filter((/** @type {Cvar} */ cvar) => (cvar.flags & Cvar.FLAG.SERVER) !== 0)); - if (serverCvars.length > 0) { - client.message.writeByte(Protocol.svc.cvar); - client.message.writeByte(serverCvars.length); - for (const serverCvar of serverCvars) { - this.writeCvar(client.message, serverCvar); - } - } - - // make sure the client knows about the paused state - if (SV.server.paused) { - client.message.writeByte(Protocol.svc.setpause); - client.message.writeByte(1); - } - - message.writeByte(Protocol.svc.signonnum); - message.writeByte(1); - - client.state = ServerClient.STATE.CONNECTED; - } - - writeCvar(msg, cvar) { - if (cvar.flags & Cvar.FLAG.SECRET) { - msg.writeString(cvar.name); - msg.writeString(cvar.string ? 'REDACTED' : ''); - } else { - msg.writeString(cvar.name); - msg.writeString(cvar.string); - } - } - - cvarChanged(cvar) { - for (let i = 0; i < SV.svs.maxclients; i++) { - const client = SV.svs.clients[i]; - if (client.state < ServerClient.STATE.CONNECTED) { - continue; - } - - client.message.writeByte(Protocol.svc.cvar); - client.message.writeByte(1); - this.writeCvar(client.message, cvar); - } - } - - *traversePVS(pvs, ignoreEdictIds = [], alwaysIncludeEdictIds = [], includeFree = false) { - for (let e = 1; e < SV.server.num_edicts; e++) { - const ent = SV.server.edicts[e]; - - if (alwaysIncludeEdictIds.includes(e)) { - yield ent; - continue; - } - - if (!includeFree && ent.isFree()) { - continue; - } - - if (ignoreEdictIds.includes(e)) { - continue; - } - - if (!ent.isInPXS(pvs)) { - continue; - } - - yield ent; - } - } - - writePlayersToClient(clientEdict, pvs, msg) { - let changes = false; - - for (let i = 0; i < SV.svs.maxclients; i++) { - const cl = SV.svs.clients[i]; - const playerEntity = cl.edict.entity; - - if (cl.state !== ServerClient.STATE.SPAWNED) { - continue; - } - - if (!clientEdict.equals(cl.edict) && !clientEdict.isInPXS(pvs)) { - continue; - } - - let pflags = Protocol.pf.PF_MSEC | Protocol.pf.PF_COMMAND; - - if (playerEntity.model !== 'progs/player.mdl') { - pflags |= Protocol.pf.PF_MODEL; - } - - if (!playerEntity.velocity.isOrigin()) { - pflags |= Protocol.pf.PF_VELOCITY; - } - - if (playerEntity.effects) { - pflags |= Protocol.pf.PF_EFFECTS; - } - - if (playerEntity.skin) { - pflags |= Protocol.pf.PF_SKINNUM; - } - - if (playerEntity.health <= 0) { - pflags |= Protocol.pf.PF_DEAD; - } - - if (clientEdict.equals(cl.edict)) { - pflags &= ~(Protocol.pf.PF_MSEC | Protocol.pf.PF_COMMAND); - - if (playerEntity.weaponframe) { - pflags |= Protocol.pf.PF_WEAPONFRAME; - } - } - - msg.writeByte(Protocol.svc.playerinfo); - msg.writeByte(i); - msg.writeShort(pflags); - - msg.writeCoordVector(playerEntity.origin); - msg.writeByte(playerEntity.frame); - - if (pflags & Protocol.pf.PF_MSEC) { - const msec = 1000 * (SV.server.time - cl.local_time); - msg.writeByte(Math.max(0, Math.min(msec, 255))); - } - - if (pflags & Protocol.pf.PF_COMMAND) { - const cmd = cl.cmd; - - if (pflags & Protocol.pf.PF_DEAD) { - cmd.angles.setTo(0, playerEntity.angles[1], 0); - } - - cmd.buttons = 0; - cmd.impulse = 0; - - msg.writeDeltaUsercmd(this.nullcmd, cmd); - } - - if (pflags & Protocol.pf.PF_VELOCITY) { - msg.writeCoordVector(playerEntity.velocity); - } - - if (pflags & Protocol.pf.PF_MODEL) { - msg.writeByte(playerEntity.modelindex); - } - - if (pflags & Protocol.pf.PF_EFFECTS) { - msg.writeByte(playerEntity.effects); - } - - if (pflags & Protocol.pf.PF_SKINNUM) { - msg.writeByte(playerEntity.skin); - } - - if (pflags & Protocol.pf.PF_WEAPONFRAME) { - msg.writeByte(playerEntity.weaponframe); - } - - changes = true; - } - - return changes; - } - - /** - * Writes delta between two entity states to the message. - * @param {SzBuffer} msg The message to write to - * @param {ServerEntityState} from The previous entity state - * @param {ServerEntityState} to The new entity state - * @returns {boolean} true if any data was written, false otherwise - */ - writeDeltaEntity(msg, from, to) { - const EPSILON = 0.01; - - let bits = 0; - - if (from.classname !== to.classname) { - bits |= Protocol.u.classname; - } - - if (from.free !== to.free) { - bits |= Protocol.u.free; - } - - if (from.modelindex !== to.modelindex) { - bits |= Protocol.u.model; - } - - if (from.frame !== to.frame) { - bits |= Protocol.u.frame; - } - - if ((from.colormap || 0) !== (to.colormap || 0)) { - bits |= Protocol.u.colormap; - } - - if (from.skin !== to.skin) { - bits |= Protocol.u.skin; - } - - if (from.alpha !== to.alpha || from.effects !== to.effects) { - bits |= Protocol.u.effects; - } - - if (from.solid !== to.solid) { - bits |= Protocol.u.solid; - } - - if (to.nextthink >= SV.server.time && (to.nextthink - from.nextthink) > 0.001) { - bits |= Protocol.u.nextthink; - } - - for (let i = 0; i < 3; i++) { - if (isFinite(to.origin[i]) && Math.abs(from.origin[i] - to.origin[i]) > EPSILON) { - bits |= Protocol.u.origin1 << i; - } - - if (isFinite(to.angles[i]) && Math.abs(from.angles[i] - to.angles[i]) > EPSILON) { - bits |= Protocol.u.angle1 << i; - } - - if (isFinite(to.velocity[i]) && Math.abs(from.velocity[i] - to.velocity[i]) > EPSILON) { - bits |= Protocol.u.angle1 << i; - } - } - - if (!from.maxs.equals(to.maxs)) { - bits |= Protocol.u.size; - } - - if (!from.mins.equals(to.mins)) { - bits |= Protocol.u.size; - } - - if (bits === 0) { - return false; - } - - console.assert(to.num > 0, 'valid entity num', to.num); - - msg.writeUint16(to.num); - msg.writeUint16(bits); - - if (bits & Protocol.u.classname) { - msg.writeString(to.classname); - } - - if (bits & Protocol.u.free) { - msg.writeByte(to.free ? 1 : 0); - } - - if (bits & Protocol.u.frame) { - msg.writeByte(to.frame); - } - - if (bits & Protocol.u.model) { - msg.writeByte(to.modelindex); - } - - if (bits & Protocol.u.colormap) { - msg.writeByte(to.colormap); - } - - if (bits & Protocol.u.skin) { - msg.writeByte(to.skin); - } - - if (bits & Protocol.u.effects) { - msg.writeByte(to.effects); - msg.writeByte(Math.floor((to.alpha || 1) * 255.0)); // CR: QuakeC may not have alpha - } - - if (bits & Protocol.u.solid) { - msg.writeByte(to.solid); - } - - for (let i = 0; i < 3; i++) { - if (bits & (Protocol.u.origin1 << i)) { - msg.writeCoord(to.origin[i]); - } - - if (bits & (Protocol.u.angle1 << i)) { - msg.writeAngle(isFinite(to.angles[i]) ? to.angles[i] : 0); - msg.writeCoord(to.velocity[i]); - } - } - - if (bits & Protocol.u.size) { - msg.writeCoordVector(to.maxs); - msg.writeCoordVector(to.mins); - } - - if (bits & Protocol.u.nextthink) { - if (from.nextthink <= 0) { - from.nextthink = SV.server.time; - } - msg.writeByte(to.nextthink - from.nextthink < 0.250 ? Math.min(255, (to.nextthink - from.nextthink) * 255.0) : 0); - } - - if (SV.server.gameCapabilities.includes(Defs.gameCapabilities.CAP_ENTITY_EXTENDED)) { - if (SV.server.clientEntityFields[to.classname]) { - const entityFields = SV.server.clientEntityFields[to.classname]; - const fields = entityFields.fields; - const bitsWriter = entityFields.bitsWriter; - - let fieldbits = 0; - const values = []; - - for (const field of fields) { - if (from.extended[field] !== to.extended[field]) { - fieldbits |= 1 << fields.indexOf(field); - values.push(to.extended[field]); - } - } - - msg[bitsWriter](fieldbits); - - if (fieldbits > 0) { - msg.writeSerializables(values); - } - } - } - - return true; - } - - writeEntitiesToClient(clientEdict, msg) { - const origin = clientEdict.entity.origin.copy().add(clientEdict.entity.view_ofs); - const pvs = SV.server.worldmodel.getFatPvsByPoint(origin); - - let changes = this.writePlayersToClient(clientEdict, pvs, msg) ? 1 : 0; - - const cl = SV.svs.clients[clientEdict.num - 1]; - - msg.writeByte(Protocol.svc.deltapacketentities); - - const visedicts = []; - - for (const ent of this.traversePVS(pvs, [], [clientEdict.num])) { - if ((msg.data.byteLength - msg.cursize) < 16) { - Con.PrintWarning('SV.WriteEntitiesToClient: packet overflow, not writing more entities\n'); - break; - } - - const toState = new ServerEntityState(ent.num); - toState.classname = ent.entity.classname; - toState.modelindex = ent.entity.model ? ent.entity.modelindex : 0; - toState.frame = ent.entity.frame; - toState.colormap = ent.entity?.colormap || 0; - toState.skin = ent.entity.skin; - toState.solid = ent.entity.solid; - toState.origin.set(ent.entity.origin); - toState.angles.set(ent.entity.angles); - toState.velocity.set(ent.entity.velocity); - toState.effects = ent.entity.effects; - toState.alpha = ent.entity.alpha; - toState.free = false; - toState.maxs.set(ent.entity.maxs); - toState.mins.set(ent.entity.mins); - toState.nextthink = ent.entity.nextthink || 0; - - if (SV.server.gameCapabilities.includes(Defs.gameCapabilities.CAP_ENTITY_EXTENDED)) { - if (SV.server.clientEntityFields[ent.entity.classname]) { - const entityFields = SV.server.clientEntityFields[ent.entity.classname]; - const fields = entityFields.fields; - - for (const field of fields) { - toState.extended[field] = ent.entity[field]; - } - } - } - - const fromState = cl.getEntityState(ent.num); - - changes |= this.writeDeltaEntity(msg, fromState, toState) ? 1 : 0; - - fromState.set(toState); - - visedicts.push(ent.num); - } - - for (let i = 1; i < SV.server.num_edicts; i++) { - const ent = SV.server.edicts[i]; - - if (visedicts.includes(ent.num)) { - continue; - } - - const fromState = cl.getEntityState(ent.num); - const toState = new ServerEntityState(ent.num); - toState.freeEdict(); - - changes |= this.writeDeltaEntity(msg, fromState, toState) ? 1 : 0; - fromState.set(toState); - } - - msg.writeShort(0); - - return changes > 0; - } - - writeClientdataToMessage(client, msg) { - const clientEdict = client.edict; - if ((clientEdict.entity.dmg_take || clientEdict.entity.dmg_save) && clientEdict.entity.dmg_inflictor) { - const other = clientEdict.entity.dmg_inflictor.edict ? clientEdict.entity.dmg_inflictor.edict : clientEdict.entity.dmg_inflictor; - const vec = !other.isFree() ? other.entity.origin.copy().add(other.entity.mins.copy().add(other.entity.maxs).multiply(0.5)) : clientEdict.entity.origin; - msg.writeByte(Protocol.svc.damage); - msg.writeByte(Math.min(255, clientEdict.entity.dmg_save)); - msg.writeByte(Math.min(255, clientEdict.entity.dmg_take)); - msg.writeCoordVector(vec); - clientEdict.entity.dmg_take = 0.0; - clientEdict.entity.dmg_save = 0.0; - } - - if (clientEdict.entity.fixangle) { - msg.writeByte(Protocol.svc.setangle); - msg.writeAngleVector(clientEdict.entity.angles); - clientEdict.entity.fixangle = false; - } - - let bits = Protocol.su.items | Protocol.su.weapon | Protocol.su.moveack; - if (clientEdict.entity.view_ofs[2] !== Protocol.default_viewheight) { - bits |= Protocol.su.viewheight; - } - if (clientEdict.entity.idealpitch !== 0.0) { - bits |= Protocol.su.idealpitch; - } - - const serverflags = SV.server.gameAPI?.serverflags ?? 0; - - let items; - if (clientEdict.entity.items2 !== undefined) { - if (clientEdict.entity.items2 !== 0.0) { - items = (clientEdict.entity.items >> 0) + ((clientEdict.entity.items2 << 23) >>> 0); - } else { - items = (clientEdict.entity.items >> 0) + ((serverflags << 28) >>> 0); - } - } else { - items = (clientEdict.entity.items >> 0) + ((serverflags << 28) >>> 0); - } - - if (clientEdict.entity.flags & Defs.flags.FL_ONGROUND) { - bits |= Protocol.su.onground; - } - if (clientEdict.entity.waterlevel >= Defs.waterlevel.WATERLEVEL_WAIST) { - bits |= Protocol.su.inwater; - } - - const punchangle = clientEdict.entity.punchangle; - - if (punchangle[0] !== 0.0) { - bits |= Protocol.su.punch1; - } - if (punchangle[1] !== 0.0) { - bits |= Protocol.su.punch2; - } - if (punchangle[2] !== 0.0) { - bits |= Protocol.su.punch3; - } - - if (clientEdict.entity.weaponframe !== 0.0) { - bits |= Protocol.su.weaponframe; - } - if (clientEdict.entity.armorvalue !== 0.0) { - bits |= Protocol.su.armor; - } - - msg.writeByte(Protocol.svc.clientdata); - msg.writeShort(bits); - if ((bits & Protocol.su.viewheight) !== 0) { - msg.writeChar(clientEdict.entity.view_ofs[2]); - } - if ((bits & Protocol.su.idealpitch) !== 0) { - msg.writeChar(clientEdict.entity.idealpitch); - } - - if ((bits & Protocol.su.punch1) !== 0) { - msg.writeShort(punchangle[0] * 90); - } - if ((bits & Protocol.su.punch2) !== 0) { - msg.writeShort(punchangle[1] * 90.0); - } - if ((bits & Protocol.su.punch3) !== 0) { - msg.writeShort(punchangle[2] * 90.0); - } - - if ((bits & Protocol.su.moveack) !== 0) { - msg.writeByte(client.lastMoveSequence); - // send authoritative PM state alongside the move ack so the client - // can start prediction replay from the correct pmFlags / pmTime - msg.writeByte(client.pmFlags); - msg.writeByte(client.pmTime); - msg.writeByte(client.pmOldButtons); - } - - if (SV.server.gameCapabilities.includes(Defs.gameCapabilities.CAP_CLIENTDATA_LEGACY)) { - msg.writeLong(items); - if ((bits & Protocol.su.weaponframe) !== 0) { - msg.writeByte(clientEdict.entity.weaponframe); - } - if ((bits & Protocol.su.armor) !== 0) { - msg.writeByte(clientEdict.entity.armorvalue); - } - msg.writeByte(SV.ModelIndex(clientEdict.entity.weaponmodel)); - msg.writeShort(clientEdict.entity.health); - msg.writeByte(clientEdict.entity.currentammo); - msg.writeByte(clientEdict.entity.ammo_shells); - msg.writeByte(clientEdict.entity.ammo_nails); - msg.writeByte(clientEdict.entity.ammo_rockets); - msg.writeByte(clientEdict.entity.ammo_cells); - if (COM.standard_quake === true) { - msg.writeByte(clientEdict.entity.weapon & 0xff); - } else { - const weapon = clientEdict.entity.weapon; - for (let i = 0; i <= 31; i++) { - if ((weapon & (1 << i)) !== 0) { - msg.writeByte(i); - break; - } - } - } - } - - if (SV.server.gameCapabilities.includes(Defs.gameCapabilities.CAP_CLIENTDATA_DYNAMIC)) { - const clientdataFields = SV.server.clientdataFields; - const destination = msg; - - let fieldbits = 0; - const values = []; - - for (let i = 0; i < clientdataFields.length; i++) { - const field = clientdataFields[i]; - const value = clientEdict.entity[field]; - - if (!value) { - continue; - } - - fieldbits |= (1 << i); - values.push(value); - } - - const bitsWriter = SV.server.clientdataFieldsBitsWriter; - console.assert(bitsWriter, 'clientdataFieldsBitsWriter must be configured when CAP_CLIENTDATA_DYNAMIC is enabled'); - if (bitsWriter) { - destination[bitsWriter](fieldbits); - destination.writeSerializables(values); - } - } - - return true; - } - - /** - * Sends a datagram to a specific client. - * @param {import('./Client.mjs').ServerClient} client client to send to - * @returns {boolean} success - */ - sendClientDatagram(client) { - const msg = new SzBuffer(16000, 'SV.SendClientDatagram'); - msg.writeByte(Protocol.svc.time); - msg.writeFloat(SV.server.time); - - let changes = 0; - - if (Host.realtime - client.last_ping_update >= 1) { - for (let i = 0; i < SV.svs.clients.length; i++) { - const pingClient = SV.svs.clients[i]; - - if (pingClient.state < ServerClient.STATE.CONNECTED) { - continue; - } - - msg.writeByte(Protocol.svc.updatepings); - msg.writeByte(i); - msg.writeShort(Math.max(0, Math.min(Math.round(pingClient.ping * 10), 30000))); - - changes |= 1; - } - - client.last_ping_update = Host.realtime; - } - - if (client.expedited_message.cursize > 0 && (msg.cursize + client.expedited_message.cursize) < msg.data.byteLength) { - msg.write(new Uint8Array(client.expedited_message.data), client.expedited_message.cursize); - client.expedited_message.clear(); - changes |= 1; - } - - if ((msg.cursize + SV.server.expedited_datagram.cursize) < msg.data.byteLength) { - msg.write(new Uint8Array(SV.server.expedited_datagram.data), SV.server.expedited_datagram.cursize); - changes |= 1; - } - - changes |= this.writeClientdataToMessage(client, msg) ? 1 : 0; - changes |= this.writeEntitiesToClient(client.edict, msg) ? 1 : 0; - - if (client.state !== ServerClient.STATE.SPAWNED) { - Con.DPrint('SV.SendClientDatagram: not spawned\n'); - return true; - } - - if (!changes) { - Con.DPrint('SV.SendClientDatagram: no changes for client ' + client.num + '\n'); - } - - client.last_update = SV.server.time; - - if ((msg.cursize + SV.server.datagram.cursize) < msg.data.byteLength) { - msg.write(new Uint8Array(SV.server.datagram.data), SV.server.datagram.cursize); - } - - if (NET.SendUnreliableMessage(client.netconnection, msg) === -1) { - Host.DropClient(client, true, 'Connectivity issues'); - return false; - } - return true; - } - - updateToReliableMessages() { - for (let i = 0; i < SV.svs.maxclients; i++) { - const currentClient = SV.svs.clients[i]; - const frags = currentClient.edict.entity ? currentClient.edict.entity.frags | 0 : 0; - if (currentClient.old_frags === frags) { - continue; - } - for (let j = 0; j < SV.svs.maxclients; j++) { - const client = SV.svs.clients[j]; - if (client.state < ServerClient.STATE.CONNECTED) { - continue; - } - client.message.writeByte(Protocol.svc.updatefrags); - client.message.writeByte(i); - client.message.writeShort(frags); - } - currentClient.old_frags = frags; - } - - for (let i = 0; i < SV.svs.maxclients; i++) { - const client = SV.svs.clients[i]; - if (client.state >= ServerClient.STATE.CONNECTED) { - client.message.write(new Uint8Array(SV.server.reliable_datagram.data), SV.server.reliable_datagram.cursize); - } - } - - SV.server.reliable_datagram.clear(); - } - - sendClientMessages() { - this.updateToReliableMessages(); - - for (let i = 0; i < SV.svs.maxclients; i++) { - const client = SV.svs.clients[i]; - if (client.state < ServerClient.STATE.CONNECTED) { - continue; - } - if (client.state === ServerClient.STATE.SPAWNED) { - if (!this.sendClientDatagram(client)) { - continue; - } - } - if (client.message.overflowed) { - Host.DropClient(client, true, 'Connectivity issues, too many messages'); - client.message.overflowed = false; - continue; - } - if (client.state === ServerClient.STATE.DROPASAP) { - if (NET.CanSendMessage(client.netconnection)) { - Host.DropClient(client, false, 'Connectivity issues, ASAP drop requested'); - } - } else if (client.message.cursize !== 0) { - if (!NET.CanSendMessage(client.netconnection)) { - continue; - } - if (NET.SendMessage(client.netconnection, client.message) === -1) { - Host.DropClient(client, true, 'Connectivity issues, failed to send message'); - } - client.message.clear(); - } - } - - for (let i = 1; i < SV.server.num_edicts; i++) { - if (SV.server.edicts[i].isFree()) { - continue; - } - - SV.server.edicts[i].entity.effects &= ~Defs.effect.EF_MUZZLEFLASH; - } - } -}; +export { ServerMessages } from './ServerMessages.ts'; diff --git a/source/engine/server/ServerMessages.ts b/source/engine/server/ServerMessages.ts new file mode 100644 index 00000000..344437e6 --- /dev/null +++ b/source/engine/server/ServerMessages.ts @@ -0,0 +1,932 @@ +import type { Visibility } from '../common/model/BSP.ts'; +import type Vector from '../../shared/Vector.ts'; +import type { BaseEntity, ServerEdict, WorldspawnEntity } from './Edict.mjs'; + +import { SzBuffer } from '../network/MSG.ts'; +import * as Protocol from '../network/Protocol.ts'; +import * as Defs from '../../shared/Defs.ts'; +import Cvar from '../common/Cvar.ts'; +import { eventBus, getCommonRegistry } from '../registry.mjs'; +import { ServerClient } from './Client.mjs'; +import { ServerEntityState } from './ServerEntityState.mjs'; + +type BitsWriter = 'writeByte' | 'writeShort' | 'writeLong'; +type EntityFieldValue = string | number | boolean | Vector | null | ServerEdict | BaseEntity | undefined; + +interface DamageInflictorEntityLike { + edict?: ServerEdict; + isFree(): boolean; + entity: ServerMessageEntity; +} + +interface ServerMessageEntity extends BaseEntity, Record { + alpha: number; + ammo_cells: number; + ammo_nails: number; + ammo_rockets: number; + ammo_shells: number; + armorvalue: number; + classname: string; + colormap?: number; + currentammo: number; + dmg_inflictor: DamageInflictorEntityLike | ServerEdict | null; + dmg_save: number; + dmg_take: number; + effects: number; + fixangle: boolean; + flags: number; + frame: number; + health: number; + idealpitch: number; + items: number; + items2?: number; + message?: string | null; + mins: Vector; + maxs: Vector; + model: string | null; + modelindex: number; + nextthink?: number; + origin: Vector; + punchangle: Vector; + skin: number; + solid: number; + sounds?: number; + velocity: Vector; + view_ofs: Vector; + waterlevel: number; + weapon: number; + weaponframe: number; + weaponmodel: string | null; +} + +let { COM, Con, Host, NET, PR, SV } = getCommonRegistry(); + +eventBus.subscribe('registry.frozen', () => { + ({ COM, Con, Host, NET, PR, SV } = getCommonRegistry()); +}); + +/** + * Returns a live entity view for an edict. + * @returns The typed entity bound to the edict. + */ +function requireEntity(edict: ServerEdict): ServerMessageEntity { + const entity = edict.entity; + + console.assert(entity !== null, 'ServerMessages requires a live edict entity'); + + return entity as ServerMessageEntity; +} + +/** + * Returns the initialized worldspawn entity. + * @returns The current worldspawn entity. + */ +function requireWorldspawnEntity(): WorldspawnEntity { + const entity = SV.server.edicts[0]?.entity; + + console.assert(entity !== null, 'ServerMessages requires a worldspawn entity'); + + return entity as WorldspawnEntity; +} + +/** + * Handles all server to client message assembly and related helpers. + */ +export class ServerMessages { + readonly nullcmd: Protocol.UserCmd; + + constructor() { + this.nullcmd = new Protocol.UserCmd(); + } + + startParticle(org: Vector, dir: Vector, color: number, count: number): void { + const datagram = SV.server.datagram; + if (datagram.cursize >= 1009) { + return; + } + datagram.writeByte(Protocol.svc.particle); + datagram.writeCoordVector(org); + datagram.writeCoordVector(dir); + datagram.writeByte(Math.min(count, 255)); + datagram.writeByte(color); + } + + startSound(edict: ServerEdict, channel: number, sample: string, volume: number, attenuation: number): void { + console.assert(volume >= 0 && volume <= 255, 'volume out of range', volume); + console.assert(attenuation >= 0.0 && attenuation <= 4.0, 'attenuation out of range', attenuation); + console.assert(channel >= 0 && channel <= 7, 'channel out of range', channel); + + const datagram = SV.server.datagram; + if (datagram.cursize >= 1009) { + return; + } + + let i; + for (i = 1; i < SV.server.soundPrecache.length; i++) { + if (sample === SV.server.soundPrecache[i]) { + break; + } + } + if (i >= SV.server.soundPrecache.length) { + Con.Print('SV.StartSound: ' + sample + ' was not precached\n'); + SV.server.soundPrecache.push(sample); + datagram.writeByte(Protocol.svc.loadsound); + datagram.writeByte(i); + datagram.writeString(sample); + } + + let fieldMask = 0; + + if (volume !== 255) { + fieldMask |= 1; + } + if (attenuation !== 1.0) { + fieldMask |= 2; + } + + datagram.writeByte(Protocol.svc.sound); + datagram.writeByte(fieldMask); + if ((fieldMask & 1) !== 0) { + datagram.writeByte(volume); + } + if ((fieldMask & 2) !== 0) { + datagram.writeByte(Math.floor(attenuation * 64.0)); + } + const entity = requireEntity(edict); + + datagram.writeShort((edict.num << 3) + channel); + datagram.writeByte(i); + datagram.writeCoordVector(entity.origin.copy().add(entity.mins.copy().add(entity.maxs).multiply(0.5))); + } + + /** + * Sends the serverdata message to a specific client. + * Needs to be done in order to complete the signon process step 1. + * @param {ServerClient} client client + */ + sendServerData(client: ServerClient): void { + const message = client.message; + const worldspawnEntity = requireWorldspawnEntity(); + + message.writeByte(Protocol.svc.print); + message.writeString(`\x02\nVERSION ${Host.version.string} SERVER (${SV.server.gameVersion})\n`); + + message.writeByte(Protocol.svc.serverdata); + message.writeByte(Protocol.version); + + if (PR.QuakeJS?.ClientGameAPI) { + const { author, name, version } = PR.QuakeJS.identification; + message.writeByte(1); + message.writeString(name); + message.writeString(author); + message.writeByte(version[0]); + message.writeByte(version[1]); + message.writeByte(version[2]); + } else { + message.writeByte(0); + message.writeString(COM.game); + } + + message.writeByte(SV.svs.maxclients); + message.writeString(worldspawnEntity.message || SV.server.mapname); + // SV.pmove.movevars.sendToClient(message); + for (let i = 1; i < SV.server.modelPrecache.length; i++) { + message.writeString(SV.server.modelPrecache[i]); + } + message.writeByte(0); + for (let i = 1; i < SV.server.soundPrecache.length; i++) { + message.writeString(SV.server.soundPrecache[i]); + } + message.writeByte(0); + + if (SV.server.gameCapabilities.includes(Defs.gameCapabilities.CAP_CLIENTDATA_DYNAMIC)) { + for (const field of SV.server.clientdataFields) { + message.writeString(field); + } + message.writeByte(0); + } + + if (SV.server.gameCapabilities.includes(Defs.gameCapabilities.CAP_ENTITY_EXTENDED)) { + for (const [classname, { fields }] of Object.entries(SV.server.clientEntityFields)) { + message.writeString(classname); + for (const field of fields) { + message.writeString(field); + } + message.writeByte(0); + } + message.writeByte(0); + } + + // sounds on worldspawn defines the cd track + const cdtrack = worldspawnEntity.sounds; + + // only play cd track automatically if set in worldspawn + if (typeof cdtrack === 'number') { + message.writeByte(Protocol.svc.cdtrack); + message.writeByte(cdtrack); + message.writeByte(0); // unused + } + + message.writeByte(Protocol.svc.setview); + message.writeShort(client.edict.num); + + const serverCvars = Array.from(Cvar.Filter((/** @type {Cvar} */ cvar) => (cvar.flags & Cvar.FLAG.SERVER) !== 0)); + if (serverCvars.length > 0) { + client.message.writeByte(Protocol.svc.cvar); + client.message.writeByte(serverCvars.length); + for (const serverCvar of serverCvars) { + this.writeCvar(client.message, serverCvar); + } + } + + // make sure the client knows about the paused state + if (SV.server.paused) { + client.message.writeByte(Protocol.svc.setpause); + client.message.writeByte(1); + } + + message.writeByte(Protocol.svc.signonnum); + message.writeByte(1); + + client.state = ServerClient.STATE.CONNECTED; + } + + writeCvar(msg: SzBuffer, cvar: Cvar): void { + if (cvar.flags & Cvar.FLAG.SECRET) { + msg.writeString(cvar.name); + msg.writeString(cvar.string ? 'REDACTED' : ''); + } else { + msg.writeString(cvar.name); + msg.writeString(cvar.string); + } + } + + cvarChanged(cvar: Cvar): void { + for (let i = 0; i < SV.svs.maxclients; i++) { + const client = SV.svs.clients[i]; + if (client.state < ServerClient.STATE.CONNECTED) { + continue; + } + + client.message.writeByte(Protocol.svc.cvar); + client.message.writeByte(1); + this.writeCvar(client.message, cvar); + } + } + + *traversePVS(pvs: Visibility, ignoreEdictIds: number[] = [], alwaysIncludeEdictIds: number[] = [], includeFree = false): Generator { + for (let e = 1; e < SV.server.num_edicts; e++) { + const ent = SV.server.edicts[e]; + + if (alwaysIncludeEdictIds.includes(e)) { + yield ent; + continue; + } + + if (!includeFree && ent.isFree()) { + continue; + } + + if (ignoreEdictIds.includes(e)) { + continue; + } + + if (!ent.isInPXS(pvs)) { + continue; + } + + yield ent; + } + } + + writePlayersToClient(clientEdict: ServerEdict, pvs: Visibility, msg: SzBuffer): boolean { + let changes = false; + + for (let i = 0; i < SV.svs.maxclients; i++) { + const cl = SV.svs.clients[i]; + const playerEntity = requireEntity(cl.edict); + + if (cl.state !== ServerClient.STATE.SPAWNED) { + continue; + } + + if (!clientEdict.equals(cl.edict) && !clientEdict.isInPXS(pvs)) { + continue; + } + + let pflags = Protocol.pf.PF_MSEC | Protocol.pf.PF_COMMAND; + + if (playerEntity.model !== 'progs/player.mdl') { + pflags |= Protocol.pf.PF_MODEL; + } + + if (!playerEntity.velocity.isOrigin()) { + pflags |= Protocol.pf.PF_VELOCITY; + } + + if (playerEntity.effects) { + pflags |= Protocol.pf.PF_EFFECTS; + } + + if (playerEntity.skin) { + pflags |= Protocol.pf.PF_SKINNUM; + } + + if (playerEntity.health <= 0) { + pflags |= Protocol.pf.PF_DEAD; + } + + if (clientEdict.equals(cl.edict)) { + pflags &= ~(Protocol.pf.PF_MSEC | Protocol.pf.PF_COMMAND); + + if (playerEntity.weaponframe) { + pflags |= Protocol.pf.PF_WEAPONFRAME; + } + } + + msg.writeByte(Protocol.svc.playerinfo); + msg.writeByte(i); + msg.writeShort(pflags); + + msg.writeCoordVector(playerEntity.origin); + msg.writeByte(playerEntity.frame); + + if (pflags & Protocol.pf.PF_MSEC) { + const msec = 1000 * (SV.server.time - cl.local_time); + msg.writeByte(Math.max(0, Math.min(msec, 255))); + } + + if (pflags & Protocol.pf.PF_COMMAND) { + const cmd = cl.cmd; + + if (pflags & Protocol.pf.PF_DEAD) { + cmd.angles.setTo(0, playerEntity.angles[1], 0); + } + + cmd.buttons = 0; + cmd.impulse = 0; + + msg.writeDeltaUsercmd(this.nullcmd, cmd); + } + + if (pflags & Protocol.pf.PF_VELOCITY) { + msg.writeCoordVector(playerEntity.velocity); + } + + if (pflags & Protocol.pf.PF_MODEL) { + msg.writeByte(playerEntity.modelindex); + } + + if (pflags & Protocol.pf.PF_EFFECTS) { + msg.writeByte(playerEntity.effects); + } + + if (pflags & Protocol.pf.PF_SKINNUM) { + msg.writeByte(playerEntity.skin); + } + + if (pflags & Protocol.pf.PF_WEAPONFRAME) { + msg.writeByte(playerEntity.weaponframe); + } + + changes = true; + } + + return changes; + } + + /** + * Writes delta between two entity states to the message. + * @param {SzBuffer} msg The message to write to + * @param {ServerEntityState} from The previous entity state + * @param {ServerEntityState} to The new entity state + * @returns {boolean} true if any data was written, false otherwise + */ + writeDeltaEntity(msg: SzBuffer, from: ServerEntityState, to: ServerEntityState): boolean { + const EPSILON = 0.01; + + let bits = 0; + + if (from.classname !== to.classname) { + bits |= Protocol.u.classname; + } + + if (from.free !== to.free) { + bits |= Protocol.u.free; + } + + if (from.modelindex !== to.modelindex) { + bits |= Protocol.u.model; + } + + if (from.frame !== to.frame) { + bits |= Protocol.u.frame; + } + + if ((from.colormap || 0) !== (to.colormap || 0)) { + bits |= Protocol.u.colormap; + } + + if (from.skin !== to.skin) { + bits |= Protocol.u.skin; + } + + if (from.alpha !== to.alpha || from.effects !== to.effects) { + bits |= Protocol.u.effects; + } + + if (from.solid !== to.solid) { + bits |= Protocol.u.solid; + } + + if (to.nextthink >= SV.server.time && (to.nextthink - from.nextthink) > 0.001) { + bits |= Protocol.u.nextthink; + } + + for (let i = 0; i < 3; i++) { + if (isFinite(to.origin[i]) && Math.abs(from.origin[i] - to.origin[i]) > EPSILON) { + bits |= Protocol.u.origin1 << i; + } + + if (isFinite(to.angles[i]) && Math.abs(from.angles[i] - to.angles[i]) > EPSILON) { + bits |= Protocol.u.angle1 << i; + } + + if (isFinite(to.velocity[i]) && Math.abs(from.velocity[i] - to.velocity[i]) > EPSILON) { + bits |= Protocol.u.angle1 << i; + } + } + + if (!from.maxs.equals(to.maxs)) { + bits |= Protocol.u.size; + } + + if (!from.mins.equals(to.mins)) { + bits |= Protocol.u.size; + } + + if (bits === 0) { + return false; + } + + console.assert(to.num > 0, 'valid entity num', to.num); + + msg.writeUint16(to.num); + msg.writeUint16(bits); + + if (bits & Protocol.u.classname) { + msg.writeString(to.classname); + } + + if (bits & Protocol.u.free) { + msg.writeByte(to.free ? 1 : 0); + } + + if (bits & Protocol.u.frame) { + msg.writeByte(to.frame); + } + + if (bits & Protocol.u.model) { + msg.writeByte(to.modelindex); + } + + if (bits & Protocol.u.colormap) { + msg.writeByte(to.colormap); + } + + if (bits & Protocol.u.skin) { + msg.writeByte(to.skin); + } + + if (bits & Protocol.u.effects) { + msg.writeByte(to.effects); + msg.writeByte(Math.floor((to.alpha || 1) * 255.0)); // CR: QuakeC may not have alpha + } + + if (bits & Protocol.u.solid) { + msg.writeByte(to.solid); + } + + for (let i = 0; i < 3; i++) { + if (bits & (Protocol.u.origin1 << i)) { + msg.writeCoord(to.origin[i]); + } + + if (bits & (Protocol.u.angle1 << i)) { + msg.writeAngle(isFinite(to.angles[i]) ? to.angles[i] : 0); + msg.writeCoord(to.velocity[i]); + } + } + + if (bits & Protocol.u.size) { + msg.writeCoordVector(to.maxs); + msg.writeCoordVector(to.mins); + } + + if (bits & Protocol.u.nextthink) { + if (from.nextthink <= 0) { + from.nextthink = SV.server.time; + } + msg.writeByte(to.nextthink - from.nextthink < 0.250 ? Math.min(255, (to.nextthink - from.nextthink) * 255.0) : 0); + } + + if (SV.server.gameCapabilities.includes(Defs.gameCapabilities.CAP_ENTITY_EXTENDED)) { + if (SV.server.clientEntityFields[to.classname]) { + const entityFields = SV.server.clientEntityFields[to.classname]; + const fields = entityFields.fields; + const bitsWriter = entityFields.bitsWriter as BitsWriter | null; + + let fieldbits = 0; + const values = []; + + for (const field of fields) { + if (from.extended[field] !== to.extended[field]) { + fieldbits |= 1 << fields.indexOf(field); + values.push(to.extended[field]); + } + } + + if (bitsWriter) { + msg[bitsWriter](fieldbits); + } + + if (bitsWriter && fieldbits > 0) { + msg.writeSerializables(values); + } + } + } + + return true; + } + + writeEntitiesToClient(clientEdict: ServerEdict, msg: SzBuffer): boolean { + const clientEntity = requireEntity(clientEdict); + const origin = clientEntity.origin.copy().add(clientEntity.view_ofs); + const pvs = SV.server.worldmodel.getFatPvsByPoint(origin); + + let changes = this.writePlayersToClient(clientEdict, pvs, msg) ? 1 : 0; + + const cl = SV.svs.clients[clientEdict.num - 1]; + + msg.writeByte(Protocol.svc.deltapacketentities); + + const visedicts = []; + + for (const ent of this.traversePVS(pvs, [], [clientEdict.num])) { + if ((msg.data.byteLength - msg.cursize) < 16) { + Con.PrintWarning('SV.WriteEntitiesToClient: packet overflow, not writing more entities\n'); + break; + } + + const entity = requireEntity(ent); + const toState = new ServerEntityState(ent.num); + toState.classname = entity.classname; + toState.modelindex = entity.model ? entity.modelindex : 0; + toState.frame = entity.frame; + toState.colormap = entity.colormap || 0; + toState.skin = entity.skin; + toState.solid = entity.solid; + toState.origin.set(entity.origin); + toState.angles.set(entity.angles); + toState.velocity.set(entity.velocity); + toState.effects = entity.effects; + toState.alpha = entity.alpha; + toState.free = false; + toState.maxs.set(entity.maxs); + toState.mins.set(entity.mins); + toState.nextthink = entity.nextthink || 0; + + if (SV.server.gameCapabilities.includes(Defs.gameCapabilities.CAP_ENTITY_EXTENDED)) { + if (SV.server.clientEntityFields[entity.classname]) { + const entityFields = SV.server.clientEntityFields[entity.classname]; + const fields = entityFields.fields; + + for (const field of fields) { + toState.extended[field] = entity[field]; + } + } + } + + const fromState = cl.getEntityState(ent.num); + + changes |= this.writeDeltaEntity(msg, fromState, toState) ? 1 : 0; + + fromState.set(toState); + + visedicts.push(ent.num); + } + + for (let i = 1; i < SV.server.num_edicts; i++) { + const ent = SV.server.edicts[i]; + + if (visedicts.includes(ent.num)) { + continue; + } + + const fromState = cl.getEntityState(ent.num); + const toState = new ServerEntityState(ent.num); + toState.freeEdict(); + + changes |= this.writeDeltaEntity(msg, fromState, toState) ? 1 : 0; + fromState.set(toState); + } + + msg.writeShort(0); + + return changes > 0; + } + + writeClientdataToMessage(client: ServerClient, msg: SzBuffer): boolean { + const clientEdict = client.edict; + const clientEntity = requireEntity(clientEdict); + + if ((clientEntity.dmg_take || clientEntity.dmg_save) && clientEntity.dmg_inflictor) { + const inflictor = clientEntity.dmg_inflictor; + const other = 'edict' in inflictor && inflictor.edict ? inflictor.edict : inflictor; + const otherEntity = requireEntity(other as ServerEdict); + const vec = !other.isFree() ? otherEntity.origin.copy().add(otherEntity.mins.copy().add(otherEntity.maxs).multiply(0.5)) : clientEntity.origin; + msg.writeByte(Protocol.svc.damage); + msg.writeByte(Math.min(255, clientEntity.dmg_save)); + msg.writeByte(Math.min(255, clientEntity.dmg_take)); + msg.writeCoordVector(vec); + clientEntity.dmg_take = 0.0; + clientEntity.dmg_save = 0.0; + } + + if (clientEntity.fixangle) { + msg.writeByte(Protocol.svc.setangle); + msg.writeAngleVector(clientEntity.angles); + clientEntity.fixangle = false; + } + + let bits = Protocol.su.items | Protocol.su.weapon | Protocol.su.moveack; + if (clientEntity.view_ofs[2] !== Protocol.default_viewheight) { + bits |= Protocol.su.viewheight; + } + if (clientEntity.idealpitch !== 0.0) { + bits |= Protocol.su.idealpitch; + } + + const serverflags = SV.server.gameAPI?.serverflags ?? 0; + + let items; + if (clientEntity.items2 !== undefined) { + if (clientEntity.items2 !== 0.0) { + items = (clientEntity.items >> 0) + ((clientEntity.items2 << 23) >>> 0); + } else { + items = (clientEntity.items >> 0) + ((serverflags << 28) >>> 0); + } + } else { + items = (clientEntity.items >> 0) + ((serverflags << 28) >>> 0); + } + + if (clientEntity.flags & Defs.flags.FL_ONGROUND) { + bits |= Protocol.su.onground; + } + if (clientEntity.waterlevel >= Defs.waterlevel.WATERLEVEL_WAIST) { + bits |= Protocol.su.inwater; + } + + const punchangle = clientEntity.punchangle; + + if (punchangle[0] !== 0.0) { + bits |= Protocol.su.punch1; + } + if (punchangle[1] !== 0.0) { + bits |= Protocol.su.punch2; + } + if (punchangle[2] !== 0.0) { + bits |= Protocol.su.punch3; + } + + if (clientEntity.weaponframe !== 0.0) { + bits |= Protocol.su.weaponframe; + } + if (clientEntity.armorvalue !== 0.0) { + bits |= Protocol.su.armor; + } + + msg.writeByte(Protocol.svc.clientdata); + msg.writeShort(bits); + if ((bits & Protocol.su.viewheight) !== 0) { + msg.writeChar(clientEntity.view_ofs[2]); + } + if ((bits & Protocol.su.idealpitch) !== 0) { + msg.writeChar(clientEntity.idealpitch); + } + + if ((bits & Protocol.su.punch1) !== 0) { + msg.writeShort(punchangle[0] * 90); + } + if ((bits & Protocol.su.punch2) !== 0) { + msg.writeShort(punchangle[1] * 90.0); + } + if ((bits & Protocol.su.punch3) !== 0) { + msg.writeShort(punchangle[2] * 90.0); + } + + if ((bits & Protocol.su.moveack) !== 0) { + msg.writeByte(client.lastMoveSequence); + // send authoritative PM state alongside the move ack so the client + // can start prediction replay from the correct pmFlags / pmTime + msg.writeByte(client.pmFlags); + msg.writeByte(client.pmTime); + msg.writeByte(client.pmOldButtons); + } + + if (SV.server.gameCapabilities.includes(Defs.gameCapabilities.CAP_CLIENTDATA_LEGACY)) { + msg.writeLong(items); + if ((bits & Protocol.su.weaponframe) !== 0) { + msg.writeByte(clientEntity.weaponframe); + } + if ((bits & Protocol.su.armor) !== 0) { + msg.writeByte(clientEntity.armorvalue); + } + const weaponModelIndex = SV.ModelIndex(clientEntity.weaponmodel); + msg.writeByte(weaponModelIndex ?? 0); + msg.writeShort(clientEntity.health); + msg.writeByte(clientEntity.currentammo); + msg.writeByte(clientEntity.ammo_shells); + msg.writeByte(clientEntity.ammo_nails); + msg.writeByte(clientEntity.ammo_rockets); + msg.writeByte(clientEntity.ammo_cells); + if (COM.standard_quake === true) { + msg.writeByte(clientEntity.weapon & 0xff); + } else { + const weapon = clientEntity.weapon; + for (let i = 0; i <= 31; i++) { + if ((weapon & (1 << i)) !== 0) { + msg.writeByte(i); + break; + } + } + } + } + + if (SV.server.gameCapabilities.includes(Defs.gameCapabilities.CAP_CLIENTDATA_DYNAMIC)) { + const clientdataFields = SV.server.clientdataFields; + const destination = msg; + + let fieldbits = 0; + const values = []; + + for (let i = 0; i < clientdataFields.length; i++) { + const field = clientdataFields[i]; + const value = clientEntity[field]; + + if (!value) { + continue; + } + + fieldbits |= (1 << i); + values.push(value); + } + + const bitsWriter = SV.server.clientdataFieldsBitsWriter as BitsWriter | null; + console.assert(bitsWriter, 'clientdataFieldsBitsWriter must be configured when CAP_CLIENTDATA_DYNAMIC is enabled'); + if (bitsWriter) { + destination[bitsWriter](fieldbits); + destination.writeSerializables(values); + } + } + + return true; + } + + /** + * Sends a datagram to a specific client. + * @param {import('./Client.mjs').ServerClient} client client to send to + * @returns {boolean} success + */ + sendClientDatagram(client: ServerClient): boolean { + const msg = new SzBuffer(16000, 'SV.SendClientDatagram'); + msg.writeByte(Protocol.svc.time); + msg.writeFloat(SV.server.time); + + let changes = 0; + + if (Host.realtime - client.last_ping_update >= 1) { + for (let i = 0; i < SV.svs.clients.length; i++) { + const pingClient = SV.svs.clients[i]; + + if (pingClient.state < ServerClient.STATE.CONNECTED) { + continue; + } + + msg.writeByte(Protocol.svc.updatepings); + msg.writeByte(i); + msg.writeShort(Math.max(0, Math.min(Math.round(pingClient.ping * 10), 30000))); + + changes |= 1; + } + + client.last_ping_update = Host.realtime; + } + + if (client.expedited_message.cursize > 0 && (msg.cursize + client.expedited_message.cursize) < msg.data.byteLength) { + msg.write(new Uint8Array(client.expedited_message.data), client.expedited_message.cursize); + client.expedited_message.clear(); + changes |= 1; + } + + if ((msg.cursize + SV.server.expedited_datagram.cursize) < msg.data.byteLength) { + msg.write(new Uint8Array(SV.server.expedited_datagram.data), SV.server.expedited_datagram.cursize); + changes |= 1; + } + + changes |= this.writeClientdataToMessage(client, msg) ? 1 : 0; + changes |= this.writeEntitiesToClient(client.edict, msg) ? 1 : 0; + + if (client.state !== ServerClient.STATE.SPAWNED) { + Con.DPrint('SV.SendClientDatagram: not spawned\n'); + return true; + } + + if (!changes) { + Con.DPrint('SV.SendClientDatagram: no changes for client ' + client.num + '\n'); + } + + client.last_update = SV.server.time; + + if ((msg.cursize + SV.server.datagram.cursize) < msg.data.byteLength) { + msg.write(new Uint8Array(SV.server.datagram.data), SV.server.datagram.cursize); + } + + if (NET.SendUnreliableMessage(client.netconnection, msg) === -1) { + Host.DropClient(client, true, 'Connectivity issues'); + return false; + } + return true; + } + + updateToReliableMessages(): void { + for (let i = 0; i < SV.svs.maxclients; i++) { + const currentClient = SV.svs.clients[i]; + const frags = currentClient.edict.entity ? currentClient.edict.entity.frags | 0 : 0; + if (currentClient.old_frags === frags) { + continue; + } + for (let j = 0; j < SV.svs.maxclients; j++) { + const client = SV.svs.clients[j]; + if (client.state < ServerClient.STATE.CONNECTED) { + continue; + } + client.message.writeByte(Protocol.svc.updatefrags); + client.message.writeByte(i); + client.message.writeShort(frags); + } + currentClient.old_frags = frags; + } + + for (let i = 0; i < SV.svs.maxclients; i++) { + const client = SV.svs.clients[i]; + if (client.state >= ServerClient.STATE.CONNECTED) { + client.message.write(new Uint8Array(SV.server.reliable_datagram.data), SV.server.reliable_datagram.cursize); + } + } + + SV.server.reliable_datagram.clear(); + } + + sendClientMessages(): void { + this.updateToReliableMessages(); + + for (let i = 0; i < SV.svs.maxclients; i++) { + const client = SV.svs.clients[i]; + if (client.state < ServerClient.STATE.CONNECTED) { + continue; + } + if (client.state === ServerClient.STATE.SPAWNED) { + if (!this.sendClientDatagram(client)) { + continue; + } + } + if (client.message.overflowed) { + Host.DropClient(client, true, 'Connectivity issues, too many messages'); + client.message.overflowed = false; + continue; + } + if (client.state === ServerClient.STATE.DROPASAP) { + if (NET.CanSendMessage(client.netconnection)) { + Host.DropClient(client, false, 'Connectivity issues, ASAP drop requested'); + } + } else if (client.message.cursize !== 0) { + if (!NET.CanSendMessage(client.netconnection)) { + continue; + } + if (NET.SendMessage(client.netconnection, client.message) === -1) { + Host.DropClient(client, true, 'Connectivity issues, failed to send message'); + } + client.message.clear(); + } + } + + for (let i = 1; i < SV.server.num_edicts; i++) { + if (SV.server.edicts[i].isFree()) { + continue; + } + + requireEntity(SV.server.edicts[i] as ServerEdict).effects &= ~Defs.effect.EF_MUZZLEFLASH; + } + } +} diff --git a/test/physics/server-messages.test.mjs b/test/physics/server-messages.test.mjs new file mode 100644 index 00000000..b9dcb52a --- /dev/null +++ b/test/physics/server-messages.test.mjs @@ -0,0 +1,91 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { eventBus, registry } from '../../source/engine/registry.mjs'; +import * as Protocol from '../../source/engine/network/Protocol.ts'; +import { SzBuffer } from '../../source/engine/network/MSG.ts'; +import SV from '../../source/engine/server/Server.mjs'; +import { ServerEntityState } from '../../source/engine/server/ServerEntityState.mjs'; +import { ServerMessages } from '../../source/engine/server/ServerMessages.mjs'; + +/** + * Installs a minimal server registry context for delta-entity message tests. + * @returns {{ restore: () => void }} A restore handle for the mocked registry state. + */ +function installWriteDeltaEntityContext() { + const previousCon = registry.Con; + const previousHost = registry.Host; + const previousSV = registry.SV; + const previousServer = SV.server; + + registry.Con = { Print() {}, DPrint() {}, PrintWarning() {} }; + registry.Host = { frametime: 0.1 }; + + SV.server = { + ...SV.server, + time: 1, + gameCapabilities: [], + clientEntityFields: {}, + }; + + registry.SV = SV; + eventBus.publish('registry.frozen'); + + return { + restore() { + registry.Con = previousCon; + registry.Host = previousHost; + registry.SV = previousSV; + SV.server = previousServer; + eventBus.publish('registry.frozen'); + }, + }; +} + +void describe('ServerMessages.writeDeltaEntity', () => { + void test('preserves the legacy opaque alpha fallback for falsy alpha values', () => { + const context = installWriteDeltaEntityContext(); + + try { + const messages = new ServerMessages(); + const from = new ServerEntityState(1); + const to = new ServerEntityState(1); + const buffer = new SzBuffer(64, 'ServerMessages.writeDeltaEntity alpha fallback'); + + to.effects = 7; + to.alpha = 0; + + assert.equal(messages.writeDeltaEntity(buffer, from, to), true); + + const view = new DataView(buffer.data); + assert.equal(view.getUint16(0, true), 1); + assert.equal(view.getUint16(2, true), Protocol.u.effects); + assert.equal(view.getUint8(4), 7); + assert.equal(view.getUint8(5), 255); + } finally { + context.restore(); + } + }); + + void test('writes scaled alpha bytes when alpha is explicitly set', () => { + const context = installWriteDeltaEntityContext(); + + try { + const messages = new ServerMessages(); + const from = new ServerEntityState(1); + const to = new ServerEntityState(1); + const buffer = new SzBuffer(64, 'ServerMessages.writeDeltaEntity alpha scaling'); + + to.effects = 5; + to.alpha = 0.5; + + assert.equal(messages.writeDeltaEntity(buffer, from, to), true); + + const view = new DataView(buffer.data); + assert.equal(view.getUint8(4), 5); + assert.equal(view.getUint8(5), Math.floor(0.5 * 255.0)); + } finally { + context.restore(); + } + }); +}); From a50cf9a820ea846cc16af951491afdb2fd38d704 Mon Sep 17 00:00:00 2001 From: Christian R Date: Fri, 3 Apr 2026 14:40:34 +0300 Subject: [PATCH 37/67] TS: server/Com, server/Sys --- source/engine/server/Com.mjs | 208 +---------------- source/engine/server/Com.ts | 203 +++++++++++++++++ source/engine/server/Sys.mjs | 252 +-------------------- source/engine/server/Sys.ts | 271 +++++++++++++++++++++++ test/common/server-module-shims.test.mjs | 25 +++ 5 files changed, 501 insertions(+), 458 deletions(-) create mode 100644 source/engine/server/Com.ts create mode 100644 source/engine/server/Sys.ts create mode 100644 test/common/server-module-shims.test.mjs diff --git a/source/engine/server/Com.mjs b/source/engine/server/Com.mjs index 7a0359b4..7526a79e 100644 --- a/source/engine/server/Com.mjs +++ b/source/engine/server/Com.mjs @@ -1,207 +1 @@ -/* global Buffer */ - -import { promises as fsPromises, existsSync, writeFileSync, constants } from 'fs'; - -import Q from '../../shared/Q.ts'; -import { CRC16CCITT as CRC } from '../common/CRC.ts'; -import COM from '../common/Com.ts'; - -import { CorruptedResourceError } from '../common/Errors.ts'; -import { registry, eventBus } from '../registry.mjs'; - -let { Con, Sys } = registry; - -eventBus.subscribe('registry.frozen', () => { - Con = registry.Con; - Sys = registry.Sys; -}); - -// @ts-ignore -export default class NodeCOM extends COM { - - /** - * Loads a file, searching through registered search paths and packs. - * @param {string} filename - The name of the file to load. - * @returns {Promise} - The file content as an ArrayBuffer or undefined if not found. - */ - static async LoadFile(filename) { - filename = filename.toLowerCase(); - - // Loop over search paths in reverse - for (let i = this.searchpaths.length - 1; i >= 0; i--) { - const search = this.searchpaths[i]; - const netpath = search.filename ? `${search.filename}/${filename}` : filename; - - // 1) Search within pack files - for (let j = search.pack.length - 1; j >= 0; j--) { - const pak = search.pack[j]; - - for (const file of pak) { - if (file.name !== filename) { - continue; - } - - // Found a matching file in the PAK metadata - if (file.filelen === 0) { - // The file length is zero, return an empty buffer - return new ArrayBuffer(0); - } - - const packPath = `data/${search.filename !== '' ? search.filename + '/' : ''}pak${j}.pak`; - - let fd; - try { - // Open the .pak file - fd = await fsPromises.open(packPath, 'r'); - - // Read the bytes - const buffer = Buffer.alloc(file.filelen); - await fd.read(buffer, 0, file.filelen, file.filepos); - - Sys.Print(`PackFile: ${packPath} : ${filename}\n`); - return new Uint8Array(buffer).buffer; - // eslint-disable-next-line no-unused-vars - } catch (err) { - // If we can't open or read from the PAK, just continue searching - } finally { - if (fd) { - await fd.close(); - } - } - } - } - - // 2) Search directly on the filesystem - const directPath = `data/${netpath}`; - - try { - // Check if file is accessible - await fsPromises.access(directPath, constants.F_OK); - - // If we got here, the file exists—read and return its contents - const buffer = await fsPromises.readFile(directPath); - Sys.Print(`FindFile: ${netpath}\n`); - return new Uint8Array(buffer).buffer; - // eslint-disable-next-line no-unused-vars - } catch (err) { - // Not accessible or doesn't exist—keep searching - } - } - - // If we exhaust all search paths and files, the file was not found - Sys.Print(`FindFile: can't find ${filename}\n`); - return null; - }; - - static Shutdown() { - }; - - /** - * Loads and parses a pack file. - * @param {string} packfile - The path to the pack file. - * @returns {Promise | null>} - The parsed pack file entries or null if the file doesn't exist. - */ - static async LoadPackFile(packfile) { - if (!existsSync(`data/${packfile}`)) { // CR: wanna see something ugly? check out the async version of existsSync… - return null; - } - - const fd = await fsPromises.open(`data/${packfile}`, 'r'); - - try { - // Read and validate the pack file header - const headerBuffer = Buffer.alloc(12); - await fd.read(headerBuffer, 0, 12, 0); - - const header = new DataView(new Uint8Array(headerBuffer).buffer); - if (header.getUint32(0, true) !== 0x4b434150) { // "PACK" magic number - throw new CorruptedResourceError(packfile, 'not a valid pack file'); - } - - const dirofs = header.getUint32(4, true); - const dirlen = header.getUint32(8, true); - const numpackfiles = dirlen >> 6; // Each entry is 64 bytes - - if (numpackfiles !== 339) { - this.modified = true; - } - - const pack = []; - - if (numpackfiles > 0) { - const infoBuffer = Buffer.alloc(dirlen); - await fd.read(infoBuffer, 0, dirlen, dirofs); - - const uint8ArrayInfo = new Uint8Array(infoBuffer); - if (CRC.Block(uint8ArrayInfo) !== 32981) { - this.modified = true; - } - - const dv = new DataView(uint8ArrayInfo.buffer); - - for (let i = 0; i < numpackfiles; i++) { - const offset = i << 6; // 64 bytes per entry - - pack.push({ - name: Q.memstr(uint8ArrayInfo.slice(offset, offset + 56)).toLowerCase(), - filepos: dv.getUint32(offset + 56, true), - filelen: dv.getUint32(offset + 60, true), - }); - } - } - - Con.Print(`Added packfile ${packfile} (${numpackfiles} files)\n`); - - return pack; - } finally { - await fd.close(); - } - } - - // eslint-disable-next-line no-unused-vars - static async WriteFile(filename, data, len) { // FIXME: len is actually required, needs to be async - const filepath = `data/${this.searchpaths[this.searchpaths.length - 1].filename}/${filename.toLowerCase()}`; - - try { - await fsPromises.writeFile(filepath, data); - } catch (e) { - Sys.Print('COM.WriteFile: failed on ' + filename + ', ' + e.message + '\n'); - return false; - } - Sys.Print('COM.WriteFile: ' + filename + '\n'); - return true; - } - - static WriteTextFile(filename, data) { - const filepath = `data/${this.searchpaths[this.searchpaths.length - 1].filename}/${filename.toLowerCase()}`; - - try { - writeFileSync(filepath, data); - } catch (e) { - Sys.Print('COM.WriteTextFile: failed on ' + filename + ', ' + e.message + '\n'); - return false; - } - Sys.Print('COM.WriteTextFile: ' + filename + '\n'); - return true; - } - - static async AddGameDirectory(dir) { - const search = { filename: dir, pack: [] }; - for (let i = 0; ; i++) { - const pak = await this.LoadPackFile((dir !== '' ? dir + '/' : '') + 'pak' + i + '.pak'); - if (pak === null) { - break; - } - search.pack[search.pack.length] = pak; - } - this.searchpaths[this.searchpaths.length] = search; - } - - static Path_f() { - Con.Print('Current search path:\n'); - for (let i = NodeCOM.searchpaths.length - 1; i >= 0; i--) { - const s = NodeCOM.searchpaths[i]; - Con.Print(` ${s.filename}/ (virtual Quake filesystem)\n`); - } - } -}; +export { default } from './Com.ts'; diff --git a/source/engine/server/Com.ts b/source/engine/server/Com.ts new file mode 100644 index 00000000..fed68f9d --- /dev/null +++ b/source/engine/server/Com.ts @@ -0,0 +1,203 @@ +/* global Buffer */ + +import { promises as fsPromises, existsSync, writeFileSync, constants } from 'fs'; + +import Q from '../../shared/Q.ts'; +import { CRC16CCITT as CRC } from '../common/CRC.ts'; +import COM, { type PackFileEntry, type SearchPath } from '../common/Com.ts'; + +import { CorruptedResourceError } from '../common/Errors.ts'; +import { eventBus, getCommonRegistry } from '../registry.mjs'; + +let { Con, Sys } = getCommonRegistry(); + +eventBus.subscribe('registry.frozen', () => { + ({ Con, Sys } = getCommonRegistry()); +}); + +export default class NodeCOM extends COM { + + /** + * Loads a file, searching through registered search paths and packs. + * @returns The file contents, or null when the file cannot be found. + */ + static override async LoadFile(filename: string): Promise { + filename = filename.toLowerCase(); + + // Loop over search paths in reverse + for (let i = this.searchpaths.length - 1; i >= 0; i--) { + const search = this.searchpaths[i]; + const netpath = search.filename ? `${search.filename}/${filename}` : filename; + + // 1) Search within pack files + for (let j = search.pack.length - 1; j >= 0; j--) { + const pak = search.pack[j]; + + for (const file of pak) { + if (file.name !== filename) { + continue; + } + + // Found a matching file in the PAK metadata + if (file.filelen === 0) { + // The file length is zero, return an empty buffer + return new ArrayBuffer(0); + } + + const packPath = `data/${search.filename !== '' ? search.filename + '/' : ''}pak${j}.pak`; + + let fd: Awaited> | undefined; + try { + // Open the .pak file + fd = await fsPromises.open(packPath, 'r'); + + // Read the bytes + const buffer = Buffer.alloc(file.filelen); + await fd.read(buffer, 0, file.filelen, file.filepos); + + Sys.Print(`PackFile: ${packPath} : ${filename}\n`); + return new Uint8Array(buffer).buffer; + } catch (_error) { + // If we can't open or read from the PAK, just continue searching + } finally { + if (fd) { + await fd.close(); + } + } + } + } + + // 2) Search directly on the filesystem + const directPath = `data/${netpath}`; + + try { + // Check if file is accessible + await fsPromises.access(directPath, constants.F_OK); + + // If we got here, the file exists—read and return its contents + const buffer = await fsPromises.readFile(directPath); + Sys.Print(`FindFile: ${netpath}\n`); + return new Uint8Array(buffer).buffer; + } catch (_error) { + // Not accessible or doesn't exist—keep searching + } + } + + // If we exhaust all search paths and files, the file was not found + Sys.Print(`FindFile: can't find ${filename}\n`); + return null; + } + + static override Shutdown(): void { + } + + /** + * Loads and parses a pack file. + * @returns The parsed pack entries, or null when the pack file does not exist. + */ + static async LoadPackFile(packfile: string): Promise { + if (!existsSync(`data/${packfile}`)) { // CR: wanna see something ugly? check out the async version of existsSync… + return null; + } + + const fd = await fsPromises.open(`data/${packfile}`, 'r'); + + try { + // Read and validate the pack file header + const headerBuffer = Buffer.alloc(12); + await fd.read(headerBuffer, 0, 12, 0); + + const header = new DataView(new Uint8Array(headerBuffer).buffer); + if (header.getUint32(0, true) !== 0x4b434150) { // "PACK" magic number + throw new CorruptedResourceError(packfile, 'not a valid pack file'); + } + + const dirofs = header.getUint32(4, true); + const dirlen = header.getUint32(8, true); + const numpackfiles = dirlen >> 6; // Each entry is 64 bytes + + if (numpackfiles !== 339) { + this.modified = true; + } + + const pack: PackFileEntry[] = []; + + if (numpackfiles > 0) { + const infoBuffer = Buffer.alloc(dirlen); + await fd.read(infoBuffer, 0, dirlen, dirofs); + + const uint8ArrayInfo = new Uint8Array(infoBuffer); + if (CRC.Block(uint8ArrayInfo) !== 32981) { + this.modified = true; + } + + const dv = new DataView(uint8ArrayInfo.buffer); + + for (let i = 0; i < numpackfiles; i++) { + const offset = i << 6; // 64 bytes per entry + + pack.push({ + name: Q.memstr(uint8ArrayInfo.slice(offset, offset + 56)).toLowerCase(), + filepos: dv.getUint32(offset + 56, true), + filelen: dv.getUint32(offset + 60, true), + }); + } + } + + Con.Print(`Added packfile ${packfile} (${numpackfiles} files)\n`); + + return pack; + } finally { + await fd.close(); + } + } + + static override async WriteFile(filename: string, data: ArrayLike, _len: number): Promise { // FIXME: len is actually required, needs to be async + const filepath = `data/${this.searchpaths[this.searchpaths.length - 1].filename}/${filename.toLowerCase()}`; + + try { + await fsPromises.writeFile(filepath, data); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + Sys.Print(`COM.WriteFile: failed on ${filename}, ${message}\n`); + return false; + } + Sys.Print(`COM.WriteFile: ${filename}\n`); + return true; + } + + static override WriteTextFile(filename: string, data: string): boolean { + const filepath = `data/${this.searchpaths[this.searchpaths.length - 1].filename}/${filename.toLowerCase()}`; + + try { + writeFileSync(filepath, data); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + Sys.Print(`COM.WriteTextFile: failed on ${filename}, ${message}\n`); + return false; + } + Sys.Print(`COM.WriteTextFile: ${filename}\n`); + return true; + } + + static override async AddGameDirectory(dir: string): Promise { + const search: SearchPath = { filename: dir, pack: [] }; + for (let i = 0; ; i++) { + const pak = await this.LoadPackFile(`${dir !== '' ? dir + '/' : ''}pak${i}.pak`); + if (pak === null) { + break; + } + search.pack[search.pack.length] = pak; + } + this.searchpaths[this.searchpaths.length] = search; + } + + static override Path_f(): void { + Con.Print('Current search path:\n'); + for (let i = NodeCOM.searchpaths.length - 1; i >= 0; i--) { + const s = NodeCOM.searchpaths[i]; + Con.Print(` ${s.filename}/ (virtual Quake filesystem)\n`); + } + } +} + diff --git a/source/engine/server/Sys.mjs b/source/engine/server/Sys.mjs index 36cdf2fa..4c7ba0be 100644 --- a/source/engine/server/Sys.mjs +++ b/source/engine/server/Sys.mjs @@ -1,252 +1,2 @@ -/* global Buffer */ - -import { argv, stdout, exit } from 'node:process'; -import { start } from 'repl'; - -import express from 'express'; -import { join } from 'path'; -import { createServer } from 'http'; - -import { registry, eventBus } from '../registry.mjs'; -import Cvar from '../common/Cvar.ts'; -/** @typedef {import('node:repl').REPLServer} REPLServer */ -import Cmd from '../common/Cmd.ts'; -import Q from '../../shared/Q.ts'; -import WorkerManager from '../common/WorkerManager.ts'; -import workerFactories from '../common/WorkerFactories.ts'; - -let { COM, Host, NET } = registry; - -eventBus.subscribe('registry.frozen', () => { - COM = registry.COM; - Host = registry.Host; - NET = registry.NET; -}); - -eventBus.subscribe('host.crash', (e) => { - console.error(e); - exit(1); -}); - -const mainLoop = { - _resolve: null, - sleep() { - return new Promise((resolve) => { - this._resolve = resolve; - }); - }, - notify() { - if (this._resolve) { - this._resolve(); - this._resolve = null; - } - }, -}; - -eventBus.subscribe('net.connection.accepted', () => { - mainLoop.notify(); -}); - -/** - * System class to manage initialization, quitting, and REPL functionality. - */ -export default class Sys { - static #oldtime = 0; - static #isRunning = false; - - /** @type {REPLServer} */ - static #repl = null; - - /** - * Initializes the low-level system. - */ - static async Init() { - // Initialize command-line arguments - COM.InitArgv(argv); - - eventBus.subscribe('console.print-line', (line) => { - stdout.write(line + '\n'); - }); - - // Record the initial time - Sys.#oldtime = Date.now() * 0.001; - - // Start worker manager - WorkerManager.Init(workerFactories); - - // Start webserver - await Sys.StartWebserver(); - - Sys.Print('Host.Init\n'); - await Host.Init(); - - // Start a REPL instance (if stdout is a TTY) - if (stdout && stdout.isTTY) { - Sys.#repl = start({ - prompt: '] ', - eval(command, context, filename, callback) { - mainLoop.notify(); - this.clearBufferedCommand(); - Cmd.text += command; - setTimeout(() => callback(null), 20); // we have to wait at least one frame before expecting a result - }, - completer(line) { - const completions = [ - ...Cmd.GetCommandNames(), - ...Cvar.GetVariableNames(), - ]; - - const hits = completions.filter((c) => c.startsWith(line)); - return [hits.length ? hits : completions, line]; - }, - }); - - Sys.#repl.on('exit', () => Sys.Quit()); - } - - Sys.#isRunning = true; - - if (Host.refreshrate.value === 0) { - Host.refreshrate.set(60); - } - - // Main loop - while (Sys.#isRunning) { - const startTime = Date.now(); - - await Host.Frame(); - - const dtime = Date.now() - startTime; - - if (dtime > 100) { - Sys.Print(`Host.Frame took too long: ${dtime} ms\n`); - } - - await Q.sleep(Math.max(0, 1000.0 / Math.min(300, Math.max(60, Host.refreshrate.value)) - dtime)); - - // when there are no more commands to process and no active connections, we can sleep indefinitely - if (NET.activeconnections === 0 && Host._scheduledForNextFrame.length === 0 && !Cmd.HasPendingCommands()) { - await mainLoop.sleep(); - } - } - } - - /** - * Handles quitting the system gracefully. - */ - static Quit() { - Sys.#isRunning = false; - - Host.Shutdown(); - Sys.Print('Sys.Quit: exitting process\n'); - exit(0); - } - - /** - * Prints a message to the console. - * @param {string} text - The text to print. - */ - static Print(text) { - stdout.write(String(text).trim() + '\n'); - } - - /** - * Returns the time elapsed since initialization. - * @returns {number} - Elapsed time in seconds. - */ - static FloatTime() { - return Date.now() * 0.001 - Sys.#oldtime; - } - - /** - * Returns the time elapsed since initialization in milliseconds. - * @returns {number} - Elapsed time in milliseconds. - */ - static FloatMilliTime() { - return performance.now(); - } - - /** @private */ - static async StartWebserver() { - if (COM.CheckParm('-noserver')) { - Sys.Print('Webserver disabled via -noserver\n'); - return; - } - - const app = express(); - - const basepath = COM.GetParm('-basepath') || ''; - - const listenPort = COM.GetParm('-port') || 3000; - const listenAddress = COM.GetParm('-ip'); - - Sys.Print(`Webserver will listen on ${listenAddress || 'all interfaces'} on port ${listenPort}\n`); - - const __dirname = import.meta.dirname + '/../..'; - - const distHeaders = (res) => { - res.set('Cross-Origin-Opener-Policy', 'same-origin'); - res.set('Cross-Origin-Embedder-Policy', 'require-corp'); - }; - - if (basepath !== '') { - app.use(basepath, express.static(join(__dirname + '/..', 'dist/browser'), { setHeaders: distHeaders })); - app.use(basepath + '/data', express.static(join(__dirname + '/..', 'data'))); - app.use(basepath + '/source', express.static(join(__dirname + '/..', 'source'))); - } else { - app.use(express.static(join(__dirname + '/..', 'dist/browser'), { setHeaders: distHeaders })); - app.use('/data', express.static(join(__dirname + '/..', 'data'))); - app.use('/source', express.static(join(__dirname + '/..', 'source'))); - } - - const skipChars = (basepath + '/qfs/').length; - app.get(basepath + '/qfs/*', async (req, res) => { - try { - // Remove the leading "/data/" to get the relative filename - // e.g. "/data/id1/progs/player.mdl" -> "id1/progs/player.mdl" - const requestedPath = req.path.substring(skipChars); - - const fileData = await COM.LoadFile(requestedPath); - - if (!fileData) { - // File not found or empty result - return res.status(404).send('File not found'); - } - - // Set headers and send the file data - res.setHeader('Content-Type', 'application/octet-stream'); - res.setHeader('Cache-Control', Host.developer.value ? 'private, max-age=0' : 'public, max-age=86400'); - - // Convert ArrayBuffer -> Buffer before sending - return res.send(Buffer.from(fileData)); - } catch (error) { - console.error('Error serving file:', error); - return res.status(500).send('Internal Server Error'); - } - }); - - const server = createServer(app); - - await new Promise((resolve, reject) => { - server.once('error', (error) => { - if ('code' in error && error.code === 'EADDRINUSE') { - reject(new Error(`Webserver failed to start: port ${listenPort} is already in use`, { cause: error })); - return; - } - - reject(new Error('Webserver failed to start', { cause: error })); - }); - - server.listen({ - port: listenPort, - host: listenAddress || undefined, - }, () => { - Sys.Print(`Webserver listening on port ${listenPort} (${listenAddress || 'all interfaces'})\n`); - - NET.server = server; - resolve(); - }); - }); - } -}; +export { default } from './Sys.ts'; diff --git a/source/engine/server/Sys.ts b/source/engine/server/Sys.ts new file mode 100644 index 00000000..bdca6b51 --- /dev/null +++ b/source/engine/server/Sys.ts @@ -0,0 +1,271 @@ +/* global Buffer */ + +import type { AddressInfo } from 'node:net'; +import type { REPLEval } from 'node:repl'; +import { argv, stdout, exit } from 'node:process'; +import { start } from 'repl'; + +import express from 'express'; +import { join } from 'path'; +import { createServer } from 'http'; + +import { eventBus, getCommonRegistry } from '../registry.mjs'; +import Cvar from '../common/Cvar.ts'; +import Cmd from '../common/Cmd.ts'; +import Q from '../../shared/Q.ts'; +import BaseSys from '../common/Sys.ts'; +import WorkerManager from '../common/WorkerManager.ts'; +import workerFactories from '../common/WorkerFactories.ts'; + +type MainLoopResolver = (() => void) | null; +type CrashReason = + | Error + | string + | null + | undefined + | { + readonly name?: string; + readonly message?: string; + readonly constructor?: { readonly name?: string }; + }; + +let { COM, Host, NET } = getCommonRegistry(); + +eventBus.subscribe('registry.frozen', () => { + ({ COM, Host, NET } = getCommonRegistry()); +}); + +eventBus.subscribe('host.crash', (error: CrashReason) => { + console.error(error); + exit(1); +}); + +class MainLoop { + static #resolve: MainLoopResolver = null; + + static sleep(): Promise { + return new Promise((resolve) => { + this.#resolve = resolve; + }); + } + + static notify(): void { + if (this.#resolve !== null) { + this.#resolve(); + this.#resolve = null; + } + } +} + +const evaluateReplCommand: REPLEval = function(command, _context, _filename, callback): void { + MainLoop.notify(); + this.clearBufferedCommand(); + Cmd.text += command; + setTimeout(() => callback(null), 20); // we have to wait at least one frame before expecting a result +}; + +eventBus.subscribe('net.connection.accepted', () => { + MainLoop.notify(); +}); + +/** + * System class to manage initialization, quitting, and REPL functionality. + */ +export default class Sys extends BaseSys { + static #oldtime = 0; + static #isRunning = false; + + /** + * Initializes the low-level system. + */ + static override async Init(): Promise { + // Initialize command-line arguments + COM.InitArgv(argv); + + eventBus.subscribe('console.print-line', (line: string) => { + stdout.write(line + '\n'); + }); + + // Record the initial time + Sys.#oldtime = Date.now() * 0.001; + + // Start worker manager + WorkerManager.Init(workerFactories); + + // Start webserver + await Sys.#startWebserver(); + + Sys.Print('Host.Init\n'); + await Host.Init(); + + // Start a REPL instance (if stdout is a TTY) + if (stdout && stdout.isTTY) { + const repl = start({ + prompt: '] ', + eval: evaluateReplCommand, + completer(line: string): [string[], string] { + const completions = [ + ...Cmd.GetCommandNames(), + ...Cvar.GetVariableNames(), + ]; + + const hits = completions.filter((c) => c.startsWith(line)); + return [hits.length ? hits : completions, line]; + }, + }); + + repl.on('exit', () => Sys.Quit()); + } + + // eslint-disable-next-line require-atomic-updates + Sys.#isRunning = true; + + if (Host.refreshrate.value === 0) { + Host.refreshrate.set(60); + } + + // Main loop + while (Sys.#isRunning) { + const startTime = Date.now(); + + await Host.Frame(); + + const dtime = Date.now() - startTime; + + if (dtime > 100) { + Sys.Print(`Host.Frame took too long: ${dtime} ms\n`); + } + + await Q.sleep(Math.max(0, 1000.0 / Math.min(300, Math.max(60, Host.refreshrate.value)) - dtime)); + + // when there are no more commands to process and no active connections, we can sleep indefinitely + if (NET.activeconnections === 0 && Host._scheduledForNextFrame.length === 0 && !Cmd.HasPendingCommands()) { + await MainLoop.sleep(); + } + } + } + + /** + * Handles quitting the system gracefully. + */ + static override Quit(): never { + Sys.#isRunning = false; + + Host.Shutdown(); + Sys.Print('Sys.Quit: exitting process\n'); + exit(0); + } + + /** + * Prints a message to the console. + */ + static override Print(text: string): void { + stdout.write(String(text).trim() + '\n'); + } + + /** + * Returns the time elapsed since initialization. + * @returns The elapsed time in seconds. + */ + static override FloatTime(): number { + return Date.now() * 0.001 - Sys.#oldtime; + } + + /** + * Returns the time elapsed since initialization in milliseconds. + * @returns The elapsed time in milliseconds. + */ + static override FloatMilliTime(): number { + return performance.now(); + } + + /** + * Starts the dedicated server web frontend. + */ + static async #startWebserver(): Promise { + if (COM.CheckParm('-noserver')) { + Sys.Print('Webserver disabled via -noserver\n'); + return; + } + + const app = express(); + + const basepath = COM.GetParm('-basepath') || ''; + + const listenPort = Number(COM.GetParm('-port') || 3000); + const listenAddress = COM.GetParm('-ip'); + + Sys.Print(`Webserver will listen on ${listenAddress || 'all interfaces'} on port ${listenPort}\n`); + + const __dirname = import.meta.dirname + '/../..'; + + const distHeaders = (res: express.Response): void => { + res.set('Cross-Origin-Opener-Policy', 'same-origin'); + res.set('Cross-Origin-Embedder-Policy', 'require-corp'); + }; + + if (basepath !== '') { + app.use(basepath, express.static(join(__dirname + '/..', 'dist/browser'), { setHeaders: distHeaders })); + app.use(basepath + '/data', express.static(join(__dirname + '/..', 'data'))); + app.use(basepath + '/source', express.static(join(__dirname + '/..', 'source'))); + } else { + app.use(express.static(join(__dirname + '/..', 'dist/browser'), { setHeaders: distHeaders })); + app.use('/data', express.static(join(__dirname + '/..', 'data'))); + app.use('/source', express.static(join(__dirname + '/..', 'source'))); + } + + const skipChars = (basepath + '/qfs/').length; + app.get(basepath + '/qfs/*', async (req: express.Request, res: express.Response) => { + try { + // Remove the leading "/data/" to get the relative filename + // e.g. "/data/id1/progs/player.mdl" -> "id1/progs/player.mdl" + const requestedPath = req.path.substring(skipChars); + + const fileData = await COM.LoadFile(requestedPath); + + if (!fileData) { + // File not found or empty result + return res.status(404).send('File not found'); + } + + // Set headers and send the file data + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Cache-Control', Host.developer.value ? 'private, max-age=0' : 'public, max-age=86400'); + + // Convert ArrayBuffer -> Buffer before sending + return res.send(Buffer.from(fileData)); + } catch (error) { + console.error('Error serving file:', error); + return res.status(500).send('Internal Server Error'); + } + }); + + const server = createServer(app); + + await new Promise((resolve, reject) => { + server.once('error', (error: NodeJS.ErrnoException) => { + if ('code' in error && error.code === 'EADDRINUSE') { + reject(new Error(`Webserver failed to start: port ${listenPort} is already in use`, { cause: error })); + return; + } + + reject(new Error('Webserver failed to start', { cause: error })); + }); + + server.listen({ + port: listenPort, + host: listenAddress || undefined, + }, () => { + const address = server.address() as AddressInfo | string | null; + const boundAddress = typeof address === 'object' && address !== null ? address.address : (listenAddress || 'all interfaces'); + + Sys.Print(`Webserver listening on port ${listenPort} (${boundAddress})\n`); + + NET.server = server; + resolve(); + }); + }); + } +} + + diff --git a/test/common/server-module-shims.test.mjs b/test/common/server-module-shims.test.mjs new file mode 100644 index 00000000..bee2f8a9 --- /dev/null +++ b/test/common/server-module-shims.test.mjs @@ -0,0 +1,25 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import BaseCom from '../../source/engine/common/Com.ts'; +import BaseSys from '../../source/engine/common/Sys.ts'; +import ComMjs from '../../source/engine/server/Com.mjs'; +import ComTs from '../../source/engine/server/Com.ts'; +import SysMjs from '../../source/engine/server/Sys.mjs'; +import SysTs from '../../source/engine/server/Sys.ts'; + +void test('server Com shim re-exports the TypeScript implementation', () => { + assert.strictEqual(ComMjs, ComTs); +}); + +void test('server Sys shim re-exports the TypeScript implementation', () => { + assert.strictEqual(SysMjs, SysTs); +}); + +void test('server Com inherits the common COM base class', () => { + assert.strictEqual(Object.getPrototypeOf(ComTs), BaseCom); +}); + +void test('server Sys inherits the common Sys base class', () => { + assert.strictEqual(Object.getPrototypeOf(SysTs), BaseSys); +}); From 367935591824c9315bae5c5c62e71093a090df20 Mon Sep 17 00:00:00 2001 From: Christian R Date: Fri, 3 Apr 2026 23:03:47 +0300 Subject: [PATCH 38/67] TS: server/ part 5 --- source/engine/server/Com.ts | 4 +- source/engine/server/Navigation.mjs | 1818 +-------------------- source/engine/server/Navigation.ts | 1863 ++++++++++++++++++++++ source/engine/server/Progs.ts | 30 +- source/engine/server/ProgsAPI.mjs | 549 +------ source/engine/server/ProgsAPI.ts | 645 ++++++++ source/engine/server/ServerMessages.ts | 11 +- source/engine/server/Sys.ts | 4 +- test/common/server-module-shims.test.mjs | 8 + test/physics/navigation.test.mjs | 24 +- 10 files changed, 2547 insertions(+), 2409 deletions(-) create mode 100644 source/engine/server/Navigation.ts create mode 100644 source/engine/server/ProgsAPI.ts diff --git a/source/engine/server/Com.ts b/source/engine/server/Com.ts index fed68f9d..fc723b76 100644 --- a/source/engine/server/Com.ts +++ b/source/engine/server/Com.ts @@ -19,7 +19,7 @@ export default class NodeCOM extends COM { /** * Loads a file, searching through registered search paths and packs. - * @returns The file contents, or null when the file cannot be found. + * @returns The file contents, or null when the file cannot be found. */ static override async LoadFile(filename: string): Promise { filename = filename.toLowerCase(); @@ -93,7 +93,7 @@ export default class NodeCOM extends COM { /** * Loads and parses a pack file. - * @returns The parsed pack entries, or null when the pack file does not exist. + * @returns The parsed pack entries, or null when the pack file does not exist. */ static async LoadPackFile(packfile: string): Promise { if (!existsSync(`data/${packfile}`)) { // CR: wanna see something ugly? check out the async version of existsSync… diff --git a/source/engine/server/Navigation.mjs b/source/engine/server/Navigation.mjs index 4845335b..8fa28c94 100644 --- a/source/engine/server/Navigation.mjs +++ b/source/engine/server/Navigation.mjs @@ -1,1817 +1 @@ -// import sampleBSpline from '../../shared/BSpline.ts'; -import * as Def from '../../shared/Defs.ts'; -import { Octree } from '../../shared/Octree.ts'; -import Vector from '../../shared/Vector.ts'; -import Cmd from '../common/Cmd.ts'; -// import Cmd, { ConsoleCommand } from '../common/Cmd.ts'; -import Cvar from '../common/Cvar.ts'; -import { CorruptedResourceError, MissingResourceError } from '../common/Errors.ts'; -import { ServerEngineAPI } from '../common/GameAPIs.ts'; -import { BrushModel } from '../common/Mod.ts'; -import { MIN_STEP_NORMAL, STEPSIZE } from '../common/Pmove.ts'; -import { Face } from '../common/model/BaseModel.ts'; -import PlatformWorker from '../common/PlatformWorker.ts'; -import WorkerManager from '../common/WorkerManager.ts'; -import { eventBus, registry } from '../registry.mjs'; -import { ServerEdict } from './Edict.mjs'; - -/** @typedef {import('./Edict.mjs').BaseEntity} ServerEntity */ - -let { CL, COM, Con, R, SV } = registry; - -eventBus.subscribe('registry.frozen', () => { - CL = registry.CL; - COM = registry.COM; - Con = registry.Con; - R = registry.R; - SV = registry.SV; -}); - -class Waypoint { - origin = new Vector(); - /** available clearance on the Z-axis */ - availableHeight = Infinity; // space above the waypoint that is free - /** whether waypoint is near a ledge */ - nearLedge = false; - /** whether waypoint is intersection something solid */ - isClipping = false; - /** whether the point is sitting in the air */ - isFloating = false; - - /** @param {Vector} origin waypoint’s position */ - constructor(origin) { - this.origin.set(origin); - } - - serialize() { - return [ - [...this.origin], - this.availableHeight, - this.nearLedge, - this.isClipping, - this.isFloating, - ]; - } - - static deserialize(data) { - const wp = new Waypoint(new Vector(...data[0])); - wp.availableHeight = data[1]; - wp.nearLedge = data[2]; - wp.isClipping = data[3]; - wp.isFloating = data[4]; - return wp; - } -} - -class WalkableSurface { - /** @type {number} dot product of downwards and plane’s normal, e.g. 1 = flat, down to ~0.7 = slope */ - stability = 0; - /** @type {Vector} surface’s normal vector */ - normal = new Vector(); - /** @type {Face} */ - face = null; - /** @type {Waypoint[]} */ - waypoints = []; - - /** - * @param {Face} face face - * @param {number} index face index in the worldmodel - */ - constructor(face, index) { - this.face = face; - this.faceIndex = index; - } - - serialize() { - return [ - this.stability, - [...this.normal], - this.faceIndex, - this.waypoints.map((wp) => wp.serialize()), - ]; - } - - static deserialize(data, navigation) { - const faceIndex = data[2]; - const face = navigation.worldmodel.faces[faceIndex]; - const surface = new WalkableSurface(face, faceIndex); - surface.stability = data[0]; - surface.normal = new Vector(...data[1]); - surface.waypoints = data[3].map((wpData) => Waypoint.deserialize(wpData)); - return surface; - } -}; - -/** - * Navigation graph node - */ -class Node { - id = -1; - origin = new Vector(); - absmin = /** @type {Vector} */(null); - absmax = /** @type {Vector} */(null); - octreeNode = null; - availableHeight = 0; // average available height from all waypoints - nearLedge = false; - isClipping = false; - isFloating = false; - /** @type {?Set} */ - surfaces = new Set(); - /** @type {number[][]} list of [id, cost, temporary cost adjustment] */ - neighbors = []; - - /** - * @param {number} id node ID - * @param {Vector} origin node position - */ - constructor(id, origin) { - this.id = id; - this.origin.set(origin); - } - - serialize() { - return [ - this.id, - [...this.origin], - this.availableHeight, - this.nearLedge, - this.isClipping, - this.isFloating, - Array.from(this.surfaces).map((s) => s.serialize()), - this.neighbors.slice(), - ]; - } - - /** - * @param {any[]} data serialized data - * @param {Navigation} navigation navigation instance - * @returns {Node} deserialized node - */ - // eslint-disable-next-line no-unused-vars - static deserialize(data, navigation) { - const node = new Node(data[0], new Vector(...data[1])); - - node.availableHeight = data[2]; - node.nearLedge = data[3]; - node.isClipping = data[4]; - node.isFloating = data[5]; - // node.surfaces = new Set(data[6].map((id) => WalkableSurface.deserialize(id, navigation))); - node.neighbors = data[7].slice(); - - return node; - } -} - -/** - * Binary min-heap keyed by fScore for efficient A* open-set extraction. - */ -class MinHeap { - /** @type {number[]} node IDs stored in heap order */ - #data = []; - /** @type {Float64Array} fScore reference, indexed by node ID */ - #keys; - /** @type {Int32Array} heap index of each node ID (-1 = not in heap) */ - #index; - - /** @param {number} capacity maximum node count */ - constructor(capacity) { - this.#keys = new Float64Array(capacity).fill(Infinity); - this.#index = new Int32Array(capacity).fill(-1); - } - - /** @returns {number} number of items in the heap */ - get size() { - return this.#data.length; - } - - /** - * Insert or update a node's priority. - * @param {number} id node ID - * @param {number} priority fScore value - */ - pushOrDecrease(id, priority) { - this.#keys[id] = priority; - - if (this.#index[id] !== -1) { - this.#bubbleUp(this.#index[id]); - return; - } - - this.#data.push(id); - this.#index[id] = this.#data.length - 1; - this.#bubbleUp(this.#data.length - 1); - } - - /** - * Extract the node with the smallest fScore. - * @returns {number} node ID with the lowest priority - */ - pop() { - const top = this.#data[0]; - const last = this.#data.pop(); - - this.#index[top] = -1; - - if (this.#data.length > 0) { - this.#data[0] = last; - this.#index[last] = 0; - this.#sinkDown(0); - } - - return top; - } - - /** - * @param {number} i heap array index to bubble up - */ - #bubbleUp(i) { - const data = this.#data; - const keys = this.#keys; - const idx = this.#index; - - while (i > 0) { - const parent = (i - 1) >> 1; - - if (keys[data[i]] >= keys[data[parent]]) { - break; - } - - const tmp = data[i]; - data[i] = data[parent]; - data[parent] = tmp; - idx[data[i]] = i; - idx[data[parent]] = parent; - i = parent; - } - } - - /** - * @param {number} i heap array index to sink down - */ - #sinkDown(i) { - const data = this.#data; - const keys = this.#keys; - const idx = this.#index; - const n = data.length; - - while (true) { - let smallest = i; - const left = 2 * i + 1; - const right = 2 * i + 2; - - if (left < n && keys[data[left]] < keys[data[smallest]]) { - smallest = left; - } - - if (right < n && keys[data[right]] < keys[data[smallest]]) { - smallest = right; - } - - if (smallest === i) { - break; - } - - const tmp = data[i]; - data[i] = data[smallest]; - data[smallest] = tmp; - idx[data[i]] = i; - idx[data[smallest]] = smallest; - i = smallest; - } - } -} - -export class NavMeshOutOfDateException extends CorruptedResourceError {} - -// TODO: in future we could build graphs per entity type (e.g. monster navmesh with tighter clearances, flying monster navmesh that ignores ground support, etc.) - -const NAV_FILE_VERSION = 3; -const NAV_MONSTER_MINS = new Vector(-16.0, -16.0, -24.0); -const NAV_MONSTER_MAXS = new Vector(16.0, 16.0, 40.0); -const NAV_LINK_STEP_DISTANCE = 8.0; - -export class Navigation { - /** @type {Cvar} */ - static nav_save_waypoints = null; - /** @type {Cvar} */ - static nav_debug_waypoints = null; - /** @type {Cvar} */ - static nav_debug_graph = null; - /** @type {Cvar} */ - static nav_debug_path = null; - /** @type {Cvar|null} NOTE: unavailable outside of dedicated server */ - static nav_build_process = null; - - /** maximum slope that is passable */ - maxSlope = MIN_STEP_NORMAL; - walkerMins = NAV_MONSTER_MINS.copy(); - walkerMaxs = NAV_MONSTER_MAXS.copy(); - /** units of headroom required above waypoint */ - requiredHeight = NAV_MONSTER_MAXS[2] - NAV_MONSTER_MINS[2]; - requiredRadius = Math.max( - NAV_MONSTER_MAXS[0], - NAV_MONSTER_MAXS[1], - -NAV_MONSTER_MINS[0], - -NAV_MONSTER_MINS[1], - ); - - /** @type {Record(void)>} holds pending requests for the worker thread */ - #requests = {}; - - /** @type {PlatformWorker} worker thread handling navigation lookups */ - #worker = null; - - /** @type {Function?} unsubscribe from nav.path.request */ - #pathRequestEventListener = null; - - /** @type {Function?} unsubscribe from nav.path.response */ - #pathResponseEventListener = null; - - /** @type {Function?} unsubscribe from nav_debug_graph changes */ - #debugGraphEventListener = null; - - /** @type {Function?} unsubscribe from nav_debug_waypoints changes */ - #debugWaypointsEventListener = null; - - constructor(worldmodel) { - /** @type {BrushModel?} */ - this.worldmodel = worldmodel; - this.graph = { - /** @type {Node[]} */ - nodes: [], - /** @type {?Octree} */ - octree: null, - }; - - this.geometry = { - /** @type {WalkableSurface[]} */ - walkableSurfaces: [], - }; - } - - static Init() { - if (registry.isDedicatedServer) { - this.nav_build_process = new Cvar('nav_build_process', '0', Cvar.FLAG.NONE, 'if set to 1, it will force build the nav mesh and quit'); - } - - this.nav_save_waypoints = new Cvar('nav_save_waypoints', '0', Cvar.FLAG.NONE, 'deprecated, extracted waypoints stay in memory and are not written to nav files'); - this.nav_debug_graph = new Cvar('nav_debug_graph', '0', Cvar.FLAG.NONE, 'if set to 1, will render the navigation graph for debugging'); - this.nav_debug_waypoints = new Cvar('nav_debug_waypoints', '0', Cvar.FLAG.NONE, 'if set to 1, will render all waypoints for debugging'); - this.nav_debug_path = new Cvar('nav_debug_path', '0', Cvar.FLAG.NONE | Cvar.FLAG.CHEAT, 'if set to 1, will render the last computed path for debugging'); - - // worker thread -> main thread: mesh probably out of date - eventBus.subscribe('nav.build', () => { - if (SV.server.navigation) { - SV.server.navigation.build(); - } - }); - - eventBus.subscribe('nav.debug.emit-dot.temporarily', (position, colors, ttl) => { - this.#emitDotFrontend(new Vector(...position), colors, ttl); - }); - - eventBus.subscribe('nav.debug.emit-dot.permanently', (position, colors) => { - this.#emitDotFrontend(new Vector(...position), colors, Infinity); - }); - } - - #initWorker() { - this.#worker = WorkerManager.SpawnWorker('server/NavigationWorker.mjs', [ - 'nav.load', - 'nav.path.request', - ]); - } - - #shutdownWorker() { - if (this.#worker) { - this.#worker.shutdown().catch((err) => { - Con.PrintError(`Failed to shutdown the navigation worker: ${err}\n`); - }); - - this.#worker = null; - } - } - - #subscribePathResponse() { - this.#pathResponseEventListener = eventBus.subscribe('nav.path.response', (/** @type {string} */ id, /** @type {Vector[]} */ path) => { - const vecpath = path ? path.map((p) => new Vector(...p)) : null; - - if (vecpath && Navigation.nav_debug_path?.value) { - this.#debugPath(vecpath); - } - - // since all events are global, we need to check what’s intended for us - if (id in this.#requests) { - this.#requests[id](vecpath); - delete this.#requests[id]; - } - }); - } - - #subscribeDebugCvars() { - this.#debugGraphEventListener = eventBus.subscribe('cvar.changed.nav_debug_graph', (/** @type {Cvar} */ cvar) => { - if (cvar.value !== 0) { - this.#scheduleDebugRefresh(); - } - }); - - this.#debugWaypointsEventListener = eventBus.subscribe('cvar.changed.nav_debug_waypoints', (/** @type {Cvar} */ cvar) => { - if (cvar.value !== 0) { - this.#scheduleDebugRefresh(); - } - }); - } - - #scheduleDebugRefresh() { - if (!R) { - return; - } - - setTimeout(() => { - this.#debugWaypoints(); - this.#debugNavigation(); - }, 1000); - } - - init() { - Con.DPrint('Navigation: initializing navigation graph...\n'); - - if (Navigation.nav_build_process?.value) { - this.build(); - } - - this.#initWorker(); - this.#subscribePathResponse(); - this.#subscribeDebugCvars(); - eventBus.publish('nav.load', SV.server.mapname, SV.server.worldmodel.checksum); - } - - shutdown() { - for (const timeout of Object.values(this.relinkEdictCooldown)) { - clearTimeout(timeout); - } - - this.#shutdownWorker(); - - if (this.#pathRequestEventListener) { - this.#pathRequestEventListener(); - this.#pathRequestEventListener = null; - } - - if (this.#pathResponseEventListener) { - this.#pathResponseEventListener(); - this.#pathResponseEventListener = null; - } - - if (this.#debugGraphEventListener) { - this.#debugGraphEventListener(); - this.#debugGraphEventListener = null; - } - - if (this.#debugWaypointsEventListener) { - this.#debugWaypointsEventListener(); - this.#debugWaypointsEventListener = null; - } - - Con.DPrint('Navigation: shutdown complete.\n'); - } - - async load(mapname, expectedChecksum = null) { - console.assert(this.worldmodel || expectedChecksum, 'Navigation: worldmodel or expectedChecksum is required'); - - const filename = `maps/${mapname}.nav`; - - this.graph.nodes.length = 0; - this.graph.octree = null; - this.relinkSkiplist.clear(); - - // Try to load binary file first (ArrayBuffer). Fallback to text JSON for older files. - const buf = await COM.LoadFile(filename); - - if (!buf) { - throw new MissingResourceError(filename); - } - - const dv = new DataView(buf); - let off = 0; - - const readBytes = (/** @type {number} */ n) => { - const out = new Uint8Array(buf, off, n); - off += n; - return out; - }; - - const readUint8 = () => dv.getUint8(off++); - const readUint32 = () => { const v = dv.getUint32(off, true); off += 4; return v; }; - const readInt32 = () => { const v = dv.getInt32(off, true); off += 4; return v; }; - const readFloat32 = () => { const v = dv.getFloat32(off, true); off += 4; return v; }; - - // magic: 4 bytes - const magic = String.fromCharCode(...readBytes(4)); - if (magic !== 'QSNM') { - throw new CorruptedResourceError(filename, 'invalid binary magic'); - } - - const version = readUint32(); - if (version !== NAV_FILE_VERSION) { - throw new CorruptedResourceError(filename, 'invalid binary version'); - } - - // worldmodel name (uint16 length + utf8 bytes) - const nameLen = dv.getUint16(off, true); off += 2; - const nameBytes = readBytes(nameLen); - const worldName = new TextDecoder().decode(nameBytes); - - const checksum = readUint32(); - const requiredHeight = readFloat32(); - const requiredRadius = readFloat32(); - - if (worldName !== mapname) { - throw new CorruptedResourceError(filename, 'wrong map'); - } - - if (expectedChecksum !== null) { - if (checksum !== expectedChecksum) { - throw new NavMeshOutOfDateException(filename, 'outdated map'); - } - } else if (checksum !== this.worldmodel.checksum) { - throw new NavMeshOutOfDateException(filename, 'outdated map'); - } - - if (requiredHeight !== this.requiredHeight || requiredRadius !== this.requiredRadius) { - throw new NavMeshOutOfDateException(filename, 'configuration changed'); - } - - // relink skiplist - const relinkCount = readUint32(); - for (let i = 0; i < relinkCount; i++) { - this.relinkSkiplist.add(readUint32()); - } - - // nodes - const nodeCount = readUint32(); - for (let ni = 0; ni < nodeCount; ni++) { - const id = readInt32(); - const ox = readFloat32(); const oy = readFloat32(); const oz = readFloat32(); - const node = new Node(id, new Vector(ox, oy, oz)); - node.availableHeight = readFloat32(); - node.nearLedge = !!readUint8(); - node.isClipping = !!readUint8(); - node.isFloating = !!readUint8(); - - // surfaces (optional) - const surfCount = readUint32(); - if (surfCount > 0) { - for (let si = 0; si < surfCount; si++) { - readFloat32(); - readFloat32(); readFloat32(); readFloat32(); - readUint32(); - const wpCount = readUint32(); - for (let wi = 0; wi < wpCount; wi++) { - readFloat32(); readFloat32(); readFloat32(); - readFloat32(); - readUint8(); - readUint8(); - readUint8(); - } - } - } - - // neighbors - const nbCount = readUint32(); - const nbs = []; - for (let k = 0; k < nbCount; k++) { - const nid = readInt32(); - const cost = readFloat32(); - const adj = readFloat32(); - nbs.push([nid, cost, adj]); - } - node.neighbors = nbs; - - this.graph.nodes.push(node); - } - - this.#buildOctree(); - this.#scheduleDebugRefresh(); - } - - async save() { - console.assert(Boolean(this.worldmodel), 'Navigation: worldmodel is required'); - - const filename = `maps/${SV.server.mapname}.nav`; - - const bytes = []; - const tmp = new ArrayBuffer(8); - const tdv = new DataView(tmp); - - const pushUint8 = (v) => { bytes.push(v & 0xff); }; - const pushUint16 = (v) => { bytes.push(v & 0xff); bytes.push((v >>> 8) & 0xff); }; - const pushUint32 = (v) => { - bytes.push(v & 0xff); - bytes.push((v >>> 8) & 0xff); - bytes.push((v >>> 16) & 0xff); - bytes.push((v >>> 24) & 0xff); - }; - const pushInt32 = (v) => pushUint32(v >>> 0); - const pushFloat32 = (f) => { tdv.setFloat32(0, f, true); const bv = new Uint8Array(tmp, 0, 4); bytes.push(bv[0], bv[1], bv[2], bv[3]); }; - const pushBytes = (arr) => { for (let i = 0; i < arr.length; i++) { bytes.push(arr[i]); } }; - - // header magic - pushBytes(new TextEncoder().encode('QSNM')); - pushUint32(NAV_FILE_VERSION); - - // world name - const nameBytes = new TextEncoder().encode(SV.server.mapname); - pushUint16(nameBytes.length); - pushBytes(nameBytes); - - pushUint32(this.worldmodel.checksum); - pushFloat32(this.requiredHeight); - pushFloat32(this.requiredRadius); - - // relink skiplist - pushUint32(this.relinkSkiplist.size); - for (const v of this.relinkSkiplist) { - pushUint32(v); - } - - // nodes - pushUint32(this.graph.nodes.length); - for (const n of this.graph.nodes) { - pushInt32(n.id); - pushFloat32(n.origin[0]); pushFloat32(n.origin[1]); pushFloat32(n.origin[2]); - pushFloat32(n.availableHeight); - pushUint8(n.nearLedge ? 1 : 0); - pushUint8(n.isClipping ? 1 : 0); - pushUint8(n.isFloating ? 1 : 0); - - // waypoints are build-only debug data and are intentionally not serialized - pushUint32(0); - - // neighbors - pushUint32(n.neighbors.length); - for (const nb of n.neighbors) { - pushInt32(nb[0]); - pushFloat32(nb[1]); - pushFloat32(nb[2]); - } - } - - const out = new Uint8Array(bytes); - await COM.WriteFile(filename, out, out.length); - - // Keep the worker in sync after every successful rebuild, including listen-server sessions. - eventBus.publish('nav.load', SV.server.mapname, this.worldmodel.checksum); - } - - #newWalkerStandOffset() { - return new Vector(0, 0, -this.walkerMins[2]); - } - - /** - * @param {Vector} position stand origin - * @returns {boolean} true when the player-sized box fits at the given origin - */ - #isValidStandOrigin(position) { - const trace = SV.collision.traceStaticWorld( - position.copy(), - this.walkerMins, - this.walkerMaxs, - position.copy(), - ); - - return !trace.startsolid && !trace.allsolid; - } - - /** - * @param {Vector} startpos stand origin - * @param {Vector} endpos stand origin - * @returns {import('./physics/ServerCollisionSupport.ts').CollisionTrace} collision result - */ - #traceWalkerStatic(startpos, endpos) { - return SV.collision.traceStaticWorld( - startpos.copy(), - this.walkerMins, - this.walkerMaxs, - endpos.copy(), - ); - } - - /** - * @param {Vector} position stand origin - * @returns {boolean} true when the walker has enough floor support at the position - */ - #hasGroundSupport(position) { - const mins = position.copy().add(this.walkerMins); - const maxs = position.copy().add(this.walkerMaxs); - - const allCornersSolid = - SV.collision.pointContents(new Vector(mins[0], mins[1], mins[2] - 1.0)) === Def.content.CONTENT_SOLID - && SV.collision.pointContents(new Vector(mins[0], maxs[1], mins[2] - 1.0)) === Def.content.CONTENT_SOLID - && SV.collision.pointContents(new Vector(maxs[0], mins[1], mins[2] - 1.0)) === Def.content.CONTENT_SOLID - && SV.collision.pointContents(new Vector(maxs[0], maxs[1], mins[2] - 1.0)) === Def.content.CONTENT_SOLID; - - if (allCornersSolid) { - return true; - } - - const start = position.copy().add(new Vector(0.0, 0.0, this.walkerMins[2] + 1.0)); - const stop = start.copy().add(new Vector(0.0, 0.0, -2.0 * STEPSIZE)); - - let trace = SV.collision.move(start, Vector.origin, Vector.origin, stop, Def.moveTypes.MOVE_NOMONSTERS, null); - - if (trace.fraction === 1.0) { - return false; - } - - let bottom = trace.endpos[2]; - const mid = bottom; - - for (let x = 0; x <= 1; x++) { - for (let y = 0; y <= 1; y++) { - start[0] = stop[0] = x !== 0 ? maxs[0] : mins[0]; - start[1] = stop[1] = y !== 0 ? maxs[1] : mins[1]; - - trace = SV.collision.move(start, Vector.origin, Vector.origin, stop, Def.moveTypes.MOVE_NOMONSTERS, null); - - if (trace.fraction !== 1.0 && trace.endpos[2] > bottom) { - bottom = trace.endpos[2]; - } - - if (trace.fraction === 1.0 || (mid - trace.endpos[2]) > STEPSIZE) { - return false; - } - } - } - - return true; - } - - /** - * @param {Vector} position stand origin - * @param {number} [probeHeight] upward probe distance - * @returns {number} free vertical movement before the player box hits something - */ - #measureAvailableHeight(position, probeHeight = this.requiredHeight) { - const trace = this.#traceWalkerStatic(position, position.copy().add(new Vector(0, 0, probeHeight))); - return Math.max(0.0, trace.endpos[2] - position[2]); - } - - /** - * @param {WalkableSurface} surface surface to use for projection - * @returns {Vector} a point on the surface plane - */ - #getSurfacePoint(surface) { - const surfedge = this.worldmodel.surfedges[surface.face.firstedge]; - - if (surfedge > 0) { - return new Vector().set(this.worldmodel.vertexes[this.worldmodel.edges[surfedge][0]]); - } - - return new Vector().set(this.worldmodel.vertexes[this.worldmodel.edges[-surfedge][1]]); - } - - /** - * @param {Vector} point point to project - * @param {WalkableSurface} surface target surface - * @returns {Vector} point projected onto the surface plane - */ - #projectPointOntoSurface(point, surface) { - const surfacePoint = this.#getSurfacePoint(surface); - const pointToSurface = point.copy().subtract(surfacePoint); - const distanceToPlane = pointToSurface.dot(surface.normal); - - return point.copy().subtract(surface.normal.copy().multiply(distanceToPlane)); - } - - /** - * @param {Vector} standOrigin stand origin to project - * @param {WalkableSurface} surface target surface - * @returns {Vector} stand origin snapped back onto the supporting plane - */ - #projectStandOriginOntoSurface(standOrigin, surface) { - const floorPoint = standOrigin.copy(); - floorPoint[2] += this.walkerMins[2]; - - return this.#projectPointOntoSurface(floorPoint, surface).add(this.#newWalkerStandOffset()); - } - - /** - * @param {Vector} origin base stand origin - * @param {WalkableSurface} surface target surface - * @param {number} x x offset - * @param {number} y y offset - * @returns {Vector} stand origin offset around the waypoint while following the surface plane - */ - #offsetStandOrigin(origin, surface, x, y) { - const floorPoint = origin.copy(); - floorPoint[2] += this.walkerMins[2]; - floorPoint[0] += x; - floorPoint[1] += y; - - return this.#projectPointOntoSurface(floorPoint, surface).add(this.#newWalkerStandOffset()); - } - - /** - * @param {Vector} startOrigin start stand origin - * @param {Vector} endOrigin end stand origin - * @returns {{ok: boolean, reason: string}} traversal result for static-world stand sampling - */ - #evaluateTraversalBetween(startOrigin, endOrigin) { - if (!this.#isValidStandOrigin(startOrigin)) { - return { ok: false, reason: 'start-fit' }; - } - - if (!this.#isValidStandOrigin(endOrigin)) { - return { ok: false, reason: 'end-fit' }; - } - - if (!this.#hasGroundSupport(startOrigin) || !this.#hasGroundSupport(endOrigin)) { - return { - ok: false, - reason: !this.#hasGroundSupport(startOrigin) ? 'start-support' : 'end-support', - }; - } - - const delta = endOrigin.copy().subtract(startOrigin); - delta[2] = 0.0; - - const totalDistance = delta.len(); - - if (totalDistance === 0.0) { - return { - ok: Math.abs(endOrigin[2] - startOrigin[2]) <= STEPSIZE, - reason: 'same-spot', - }; - } - - const stepDistance = Math.min(NAV_LINK_STEP_DISTANCE, totalDistance); - const direction = delta.copy().multiply(1.0 / totalDistance); - let previousOrigin = startOrigin; - - for (let travelled = stepDistance; travelled < totalDistance; travelled += stepDistance) { - const t = travelled / totalDistance; - const sampleOrigin = startOrigin.copy().add(direction.copy().multiply(travelled)); - sampleOrigin[2] = startOrigin[2] + (endOrigin[2] - startOrigin[2]) * t; - - if (Math.abs(sampleOrigin[2] - previousOrigin[2]) > STEPSIZE + 1.0) { - return { ok: false, reason: 'height-mismatch' }; - } - - if (!this.#isValidStandOrigin(sampleOrigin)) { - return { ok: false, reason: 'step-fit' }; - } - - if (!this.#hasGroundSupport(sampleOrigin)) { - return { ok: false, reason: 'step-support' }; - } - - previousOrigin = sampleOrigin; - } - - if (Math.abs(endOrigin[2] - previousOrigin[2]) > STEPSIZE + 1.0) { - return { ok: false, reason: 'height-mismatch' }; - } - - return { ok: true, reason: 'ok' }; - } - - #extractWalkableSurfaces() { - const walkableSurfaces = []; - let sampledWaypointCount = 0; - let retainedWaypointCount = 0; - - const upwards = new Vector(0, 0, 1); - const sidewards = new Vector(0, 1, 0); - - // Pass 1: collect all potentially walkable surfaces - for (let i = 0; i < this.worldmodel.faces.length; i++) { - const face = this.worldmodel.faces[i]; - - if (face.numedges < 3) { - continue; - } - - const walkableSurface = new WalkableSurface(face, i); - - // Only accept surfaces whose normals point upward and do not exceed a 45 degrees incline. - const faceNormal = face.normal; - - walkableSurface.stability = faceNormal.dot(upwards); - - if (walkableSurface.stability < this.maxSlope) { - continue; - } - - // Ignore special surfaces, also submodel faces - if (face.turbulent === true || face.sky === true || face.submodel === true) { - continue; - } - - walkableSurface.normal.set(faceNormal); - - walkableSurfaces.push(walkableSurface); - } - - // Pass 2: check if the walkable surfaces are really walkable by sampling points on them - // - create sample points across each walkable face (interior sampling) - // - approach: build ordered 3D vertex list for the face, project to a local 2D basis - // - grid-sample the face bounding box and keep points that lie inside the polygon - for (const surface of walkableSurfaces) { - const face = surface.face; - /** @type {Vector[]} collect ordered vertices for this face */ - const verts3 = []; - for (let i = 0; i < face.numedges; i++) { - const vec = new Vector(); - const surfedge = this.worldmodel.surfedges[face.firstedge + i]; - - if (surfedge > 0) { - vec.set(this.worldmodel.vertexes[this.worldmodel.edges[surfedge][0]]); - } else { - vec.set(this.worldmodel.vertexes[this.worldmodel.edges[-surfedge][1]]); - } - - verts3.push(vec); - } - - /** face plane normal */ - const n = surface.normal.copy(); - - /** pick arbitrary axis not parallel to normal */ - const arbitrary = Math.abs(n[2]) < 0.9 ? upwards : sidewards; - - // build local orthonormal basis (u, v) on the face plane - const u = n.cross(arbitrary); - const uLen = u.normalize(); - - if (uLen === 0) { - continue; - } - - const v = n.cross(u); - const vLen = v.normalize(); - - if (vLen === 0) { - continue; - } - - const origin = verts3[0]; - - // project verts to 2D coordinates in [u, v] basis - const verts2 = verts3.map((p3) => { - const rel = p3.copy().subtract(origin); - return [rel.dot(u), rel.dot(v)]; - }); - - // compute bounding box in 2D - let minX = Infinity, minY = Infinity; - let maxX = -Infinity, maxY = -Infinity; - - for (const p of verts2) { - if (p[0] < minX) { - minX = p[0]; - } - if (p[0] > maxX) { - maxX = p[0]; - } - if (p[1] < minY) { - minY = p[1]; - } - if (p[1] > maxY) { - maxY = p[1]; - } - } - - /** - * point-in-polygon (ray crossing) - * @param {number[]} pt 2D point - * @param {number[][]} poly polygon - * @returns {boolean} true if inside - */ - const pointInPoly = (pt, poly) => { - let inside = false; - for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { - const xi = poly[i][0]; - const yi = poly[i][1]; - const xj = poly[j][0]; - const yj = poly[j][1]; - const intersect = ((yi > pt[1]) !== (yj > pt[1])) && (pt[0] < (xj - xi) * (pt[1] - yi) / (yj - yi + 0.0) + xi); - if (intersect) { - inside = !inside; - } - } - return inside; - }; - - // sample the actor center lane instead of the full polygon so narrow stairs and ledges - // can still produce valid points without relying on later support pruning alone. - - // sampling resolution (units between samples on the face) - const step = 8; - - const startX = Math.floor(minX / step) * step + (step * 0.5); - const startY = Math.floor(minY / step) * step + (step * 0.5); - - // grid-sample the bounding box and test inclusion - for (let sx = startX; sx <= Math.ceil(maxX); sx += step) { - for (let sy = startY; sy <= Math.ceil(maxY); sy += step) { - const pt2 = [sx, sy]; - if (!pointInPoly(pt2, verts2)) { - continue; - } - - // map 2D point back to 3D: origin + u * x + v * y, then lift to a player stand origin - const worldPoint = origin.copy().add(u.copy().multiply(pt2[0])).add(v.copy().multiply(pt2[1])); - const standOrigin = worldPoint.add(this.#newWalkerStandOffset()); - - surface.waypoints.push(new Waypoint(standOrigin)); - sampledWaypointCount++; - } - } - } - - // Pass 3: prune waypoints that do not have enough player-sized clearance - const rr = this.requiredRadius; - const sideOffsets = [ - [-rr * 1.4, -rr], [0.0, -rr], [rr * 1.4, -rr], - [-rr, 0.0], [rr, 0.0], - [-rr * 1.4, rr], [0.0, rr], [rr * 1.4, rr], - ]; - const pruneStats = { - invalidFit: 0, - lowHeight: 0, - unsupported: 0, - retained: 0, - }; - - for (const surface of walkableSurfaces) { - for (const wp of surface.waypoints) { - if (!this.#isValidStandOrigin(wp.origin)) { - wp.availableHeight = 0; - wp.isClipping = true; - pruneStats.invalidFit++; - continue; - } - - wp.availableHeight = this.#measureAvailableHeight(wp.origin); - - if (wp.availableHeight < this.requiredHeight) { - wp.availableHeight = 0; - pruneStats.lowHeight++; - continue; - } - - if (!this.#hasGroundSupport(wp.origin)) { - wp.isFloating = true; - pruneStats.unsupported++; - continue; - } - - for (const [x, y] of sideOffsets) { - const sideOrigin = this.#offsetStandOrigin(wp.origin, surface, x, y); - - if (!this.#isValidStandOrigin(sideOrigin)) { - continue; - } - - if (!this.#hasGroundSupport(sideOrigin)) { - wp.nearLedge = true; - break; - } - } - } - } - - // Pass 4: filter out unsuitable waypoints and store the rest - for (const surface of walkableSurfaces) { - /** @type {Waypoint[]} */ - const suitableWaypoints = []; - - for (const wp of surface.waypoints) { - if ((wp.availableHeight >= this.walkerMaxs[2] - this.walkerMins[2]) && !wp.isClipping && !wp.isFloating) { - suitableWaypoints.push(wp); - pruneStats.retained++; - } - } - - if (suitableWaypoints.length === 0) { - continue; - } - - surface.waypoints = suitableWaypoints; - retainedWaypointCount += suitableWaypoints.length; - - this.geometry.walkableSurfaces.push(surface); - } - - Con.DPrint( - `Navigation: walkable surfaces=${walkableSurfaces.length}, sampled waypoints=${sampledWaypointCount}, retained waypoints=${retainedWaypointCount}, retained surfaces=${this.geometry.walkableSurfaces.length}, invalidFit=${pruneStats.invalidFit}, lowHeight=${pruneStats.lowHeight}, unsupported=${pruneStats.unsupported}\n`, - ); - } - - #buildNavigationGraph() { - // Build a simple navgraph from the extracted waypoints. - // Steps: - // 1) collect all waypoints - // 2) merge nearby waypoints into graph nodes - // 3) connect nodes with unobstructed links (trace check) - - const mergeRadius = 24; // units to merge nearby waypoints - const linkRadius = 64; // max distance to attempt a link - - // 1) collect all waypoints into flat list - const allWaypoints = []; - for (const surface of this.geometry.walkableSurfaces) { - for (const wp of surface.waypoints) { - allWaypoints.push({ wp, surface }); - } - } - - // 2) merge nearby waypoints into nodes using surface-aware clustering - /** @type {Node[]} */ - const nodes = this.graph.nodes; - nodes.length = 0; - - const distance = (/** @type {Vector} */ a, /** @type {Vector} */ b) => Math.hypot(a[0] - b[0], a[1] - b[1]); - - // Group waypoints that should be merged together - /** @type {{seedOrigin: Vector, items: {wp: Waypoint, surface: WalkableSurface, index: number}[]}[]} */ - const waypointGroups = []; - - for (let i = 0; i < allWaypoints.length; i++) { - const current = allWaypoints[i]; - let bestGroup = null; - let bestDistance = Infinity; - - for (const group of waypointGroups) { - const d = distance(group.seedOrigin, current.wp.origin); - const heightDiff = Math.abs(group.seedOrigin[2] - current.wp.origin[2]); - - if (d > mergeRadius || heightDiff > STEPSIZE) { - continue; - } - - if (d < bestDistance) { - bestDistance = d; - bestGroup = group; - } - } - - if (bestGroup === null) { - waypointGroups.push({ - seedOrigin: current.wp.origin.copy(), - items: [{ ...current, index: i }], - }); - continue; - } - - bestGroup.items.push({ ...current, index: i }); - } - - // Create nodes from waypoint groups - for (const group of waypointGroups) { - const id = nodes.length; - - // Compute centroid of all waypoints in the group - const centroid = new Vector(); - let representativeOrigin = /** @type {Vector|null} */ (null); - let representativeDistance = Infinity; - let minAvailableHeight = Infinity; - let nearLedge = false; - let isClipping = false; - let isFloating = false; - /** @type {Set} */ - const surfaces = new Set(); - - for (const { wp, surface } of group.items) { - centroid.add(wp.origin); - minAvailableHeight = Math.min(minAvailableHeight, wp.availableHeight); - nearLedge = nearLedge || wp.nearLedge; - isClipping = isClipping || wp.isClipping; - isFloating = isFloating || wp.isFloating; - surfaces.add(surface); - } - - centroid.multiply(1.0 / group.items.length); - - // If all waypoints are on the same surface, project centroid onto that surface - if (surfaces.size === 1) { - const surface = surfaces.values().next().value; - centroid.set(this.#projectStandOriginOntoSurface(centroid, surface)); - } - - for (const { wp } of group.items) { - const d = wp.origin.distanceTo(centroid); - - if (d < representativeDistance) { - representativeDistance = d; - representativeOrigin = wp.origin; - } - } - - const node = new Node(id, representativeOrigin ?? centroid); - node.availableHeight = Number.isFinite(minAvailableHeight) ? minAvailableHeight : 0.0; - node.nearLedge = nearLedge; - node.isClipping = isClipping; - node.isFloating = isFloating; - node.surfaces = surfaces; - - nodes.push(node); - } - - // 3) build spatial index before linking to accelerate neighbor search - this.#buildOctree(); - - // 4) connect nodes: attempt links between nearby, unobstructed node pairs - const linkStats = { - considered: 0, - linked: 0, - startFit: 0, - endFit: 0, - startSupport: 0, - endSupport: 0, - stepFit: 0, - stepSupport: 0, - heightMismatch: 0, - }; - - // track already-evaluated pairs to avoid duplicate work - const evaluatedPairs = new Set(); - - for (const a of nodes) { - for (const b of this.#findNearestNodes(a.origin, linkRadius)) { - if (a.id === b.id) { - continue; - } - - // ensure each pair is evaluated only once - const lo = Math.min(a.id, b.id); - const hi = Math.max(a.id, b.id); - const pairKey = lo * nodes.length + hi; - - if (evaluatedPairs.has(pairKey)) { - continue; - } - - evaluatedPairs.add(pairKey); - - const dist = b.origin.distanceTo(a.origin); - - linkStats.considered++; - - const aToB = this.#evaluateTraversalBetween(a.origin, b.origin); - const bToA = this.#evaluateTraversalBetween(b.origin, a.origin); - - if (!aToB.ok && !bToA.ok) { - const reasons = [aToB.reason, bToA.reason]; - - if (reasons.includes('start-fit')) { - linkStats.startFit++; - } else if (reasons.includes('end-fit')) { - linkStats.endFit++; - } else if (reasons.includes('start-support')) { - linkStats.startSupport++; - } else if (reasons.includes('end-support')) { - linkStats.endSupport++; - } else if (reasons.includes('step-fit')) { - linkStats.stepFit++; - } else if (reasons.includes('step-support')) { - linkStats.stepSupport++; - } else { - linkStats.heightMismatch++; - } - - continue; - } - - if (aToB.ok) { - let cost = dist + Math.max(0.0, b.origin[2] - a.origin[2]); - - if (a.nearLedge) { - cost += 96; - } - - if (b.nearLedge) { - cost += 96; - } - - a.neighbors.push([b.id, cost, 0]); - linkStats.linked++; - } - - if (bToA.ok) { - let cost = dist + Math.max(0.0, a.origin[2] - b.origin[2]); - - if (b.nearLedge) { - cost += 96; - } - - if (a.nearLedge) { - cost += 96; - } - - b.neighbors.push([a.id, cost, 0]); - linkStats.linked++; - } - } - } - - Con.DPrint( - `Navigation: merged ${allWaypoints.length} waypoints into ${waypointGroups.length} waypoint groups\n`, - ); - Con.PrintWarning( - `Navigation: link stats considered=${linkStats.considered} linked=${linkStats.linked} ` - + `startFit=${linkStats.startFit} endFit=${linkStats.endFit} ` - + `startSupport=${linkStats.startSupport} endSupport=${linkStats.endSupport} ` - + `stepFit=${linkStats.stepFit} stepSupport=${linkStats.stepSupport} heightMismatch=${linkStats.heightMismatch}\n`, - ); - } - - /** @type {Record} edict number to timeout, we cool down incoming updates here */ - relinkEdictCooldown = {}; - - /** @type {Record} */ - relinkEdictLinks = {}; - - /** @type {Set} edict numbers not interesting for relinking (e.g. func_door) */ - relinkSkiplist = new Set(); - - /** - * updates navigation links based on entity position - * @param {ServerEdict} edict edict to relink - */ - relinkEdict(edict) { - /** @type {?ServerEntity} */ - const entity = edict.entity; - - if (!entity) { - return; - } - - // only care about world and large static brushes for now - if (entity.solid !== Def.solid.SOLID_BSP) { - return; - } - - // this edict got flagged as not interesting earlier - if (this.relinkSkiplist.has(edict.num)) { - return; - } - - if (this.relinkEdictCooldown[edict.num]) { - clearTimeout(this.relinkEdictCooldown[edict.num]); - } - - this.relinkEdictCooldown[edict.num] = setTimeout(() => { - delete this.relinkEdictCooldown[edict.num]; - this.#relinkEdict(edict); - }, 1000); - } - - /** - * updates navigation links based on entity position - * @param {ServerEdict} edict edict to relink - */ - #relinkEdict(edict) { - if (edict.isFree()) { - return; - } - - // TODO: adjust the nav graph accordingly - } - - #relinkAll() { - for (let i = 0; i < SV.server.num_edicts; i++) { - const edict = SV.server.edicts[i]; - - if (edict.isFree()) { - continue; - } - - this.#relinkEdict(edict); - } - } - - #buildSpecialConnections() { - this.#buildTeleporterLinks(); - this.#buildDoorLinks(); - this.#relinkAll(); - } - - #buildTeleporterLinks() { - // looking for teleporters - for (const teleporterEdict of ServerEngineAPI.FindAllByFieldAndValue('classname', 'trigger_teleport')) { - /** @type {?ServerEntity} */ - const source = teleporterEdict.entity; - - if (!source) { - continue; - } - - if (!source.target) { - continue; - } - - const destinationEdict = Array.from(ServerEngineAPI.FindAllByFieldAndValue('targetname', source.target))[0]; - /** @type {?ServerEntity} */ - const destination = destinationEdict?.entity ?? null; - - if (!destination) { - Con.PrintWarning(`Navigation: teleporter without a valid target: ${source.classname}\n`); - continue; - } - - const sp = source.centerPoint.copy(), dp = destination.centerPoint.copy(); - - Con.DPrint(`Navigation: found teleporter [${sp}] --> [${dp}]\n`); - - const destNode = this.#findNearestNode(dp, 96); // Just grab one in proximity of the destination - - if (!destNode) { - Con.PrintWarning('Navigation: teleporter destination has no nearby navnode\n'); - continue; - } - - const cost = 0; // no cost for teleporters, since traveling is instant - - // insert a new node here to smooth out the path to the teleporter trigger - const sourceNode = new Node(this.graph.nodes.length, sp); - sourceNode.availableHeight = source.maxs[2] - source.mins[2]; - this.graph.nodes.push(sourceNode); - Con.DPrint(`Navigation: adding teleporter source node ${sourceNode.id}\n`); - - // link the new node to its neighbors - for (const sourceNodeNeighbor of this.#findNearestNodes(sp, 64)) { - Con.DPrint(`Navigation: linking teleporter nodes ${sourceNodeNeighbor.id} --> ${sourceNode.id}\n`); - sourceNodeNeighbor.neighbors.push([sourceNode.id, cost, 0]); // one-way link - // this.graph.edges.push([ sourceNodeNeighbor.id, sourceNode.id, cost ]); - } - - // link the new node to the destination node - Con.DPrint(`Navigation: linking teleporter nodes ${sourceNode.id} --> ${destNode.id}\n`); - sourceNode.neighbors.push([destNode.id, cost, 0]); // one-way link - // this.graph.edges.push([ sourceNode.id, destNode.id, cost ]); - } - } - - #buildDoorLinks() { - // looking for simple doors - for (const doorEdict of ServerEngineAPI.FindAllByFieldAndValue('classname', 'func_door')) { - /** @type {?ServerEntity} */ - const door = doorEdict.entity; - - if (!door) { - continue; - } - - if (door.targetname) { // remote controlled door, skip for now - continue; - } - - this.relinkSkiplist.add(doorEdict.num); - } - } - - #buildOctree() { - if (this.graph.nodes.length === 0) { - this.graph.octree = null; - return; - } - - // compute bounding box of node origins - let minX = Infinity, minY = Infinity, minZ = Infinity; - let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; - - for (const n of this.graph.nodes) { - const o = n.origin; - if (o[0] < minX) { minX = o[0]; } - if (o[1] < minY) { minY = o[1]; } - if (o[2] < minZ) { minZ = o[2]; } - if (o[0] > maxX) { maxX = o[0]; } - if (o[1] > maxY) { maxY = o[1]; } - if (o[2] > maxZ) { maxZ = o[2]; } - } - - const cx = (minX + maxX) / 2; - const cy = (minY + maxY) / 2; - const cz = (minZ + maxZ) / 2; - const extentX = maxX - minX; - const extentY = maxY - minY; - const extentZ = maxZ - minZ; - const halfSize = Math.max(extentX, extentY, extentZ) / 2 + 1; - - const center = new Vector(cx, cy, cz); - this.graph.octree = /** @type {Octree} */(new Octree(center, halfSize, 12, 8)); - - for (const n of this.graph.nodes) { - this.graph.octree.insert(n); - } - } - - /** - * Find nearest graph node to a world position. - * @param {Vector} position world-space position to query - * @param {number} maxDist maximum search distance in world units - * @returns {Node|null} node if found, null if none within maxDist or graph is empty - */ - #findNearestNode(position, maxDist = 512) { - if (this.graph.nodes.length === 0) { - return null; - } - - // first try octree lookup, if available (linking specials won’t have access to the Octree yet) - if (this.graph.octree) { - const n = this.graph.octree.nearest(position, maxDist); - - if (n) { - return n; - } - } - - // fallthrough to full scan if nothing found within maxDist in octree - Con.DPrint('Navigation: nearest node not found in octree, falling back to linear scan\n'); - - let best = null; - let bestDist = Infinity; - - for (const node of this.graph.nodes) { - const d = position.distanceTo(node.origin); - if (d < bestDist && d <= maxDist) { - bestDist = d; - best = node; - } - } - - return best; - } - - /** - * Find all graph nodes within maxDist of a world position. - * Uses the octree when available, falls back to linear scan. - * @param {Vector} position world-space position to query - * @param {number} maxDist maximum search distance in world units - * @yields {Node} nodes within range - */ - *#findNearestNodes(position, maxDist = 512) { - if (this.graph.octree) { - for (const [, node] of this.graph.octree.root.querySphere(position, maxDist)) { - yield node; - } - return; - } - - for (const node of this.graph.nodes) { - const d = position.distanceTo(node.origin); - if (d <= maxDist) { - yield node; - } - } - } - - /** - * Find path between two world positions using A* over the navgraph. - * Returns an array of Vector positions (node origins) or null if no path. - * Using this async version will offload the pathfinding to another worker thread and it will not recover during save/load games! - * @param {Vector} startPos start position - * @param {Vector} goalPos goal position - * @returns {Promise} path made out of waypoints, or null if no path found, it will include start and end positions - */ - findPathAsync(startPos, goalPos) { - return new Promise((resolve) => { - const id = Math.random().toString(36).substring(2, 10); - - this.#requests[id] = resolve; - - eventBus.publish('nav.path.request', id, startPos, goalPos); - }); - } - - /** - * Find path between two world positions using A* over the navgraph. - * Returns an array of Vector positions (node origins) or null if no path. - * @param {Vector} startPos start position - * @param {Vector} goalPos goal position - * @returns {Vector[]|null} path made out of waypoints, or null if no path found, it will include start and end positions - */ - findPath(startPos, goalPos) { - if (!this.graph || !this.graph.nodes || this.graph.nodes.length === 0) { - return null; - } - - const startNode = this.#findNearestNode(startPos, 512); - const goalNode = this.#findNearestNode(goalPos, 512); - - if (!startNode || !goalNode) { - Con.DPrint('Navigation: no start or goal node found\n'); - return null; - } - - if (startNode.id === goalNode.id) { - const path = [startPos.copy(), goalPos.copy()]; - return path; - } - - const nodeCount = this.graph.nodes.length; - const gScore = new Float64Array(nodeCount).fill(Infinity); - const cameFrom = new Int32Array(nodeCount).fill(-1); - const openSet = new MinHeap(nodeCount); - - gScore[startNode.id] = 0; - openSet.pushOrDecrease(startNode.id, startNode.origin.distanceTo(goalNode.origin)); - - while (openSet.size > 0) { - const currentId = openSet.pop(); - - if (currentId === goalNode.id) { - const path = []; - let cur = currentId; - - while (cur !== -1) { - path.push(this.graph.nodes[cur].origin.copy()); - cur = cameFrom[cur]; - } - - path.reverse(); - path[0] = startPos.copy(); - path.push(goalPos.copy()); - return path; - } - - const currentG = gScore[currentId]; - - for (const nb of this.graph.nodes[currentId].neighbors) { - const nbId = nb[0]; - const tentativeG = currentG + nb[1] + nb[2]; - - if (tentativeG < gScore[nbId]) { - cameFrom[nbId] = currentId; - gScore[nbId] = tentativeG; - openSet.pushOrDecrease(nbId, tentativeG + this.graph.nodes[nbId].origin.distanceTo(goalNode.origin)); - } - } - } - - return null; - } - - #emitDot(position, color = 15, ttl = Infinity) { - if (Number.isFinite(ttl)) { - eventBus.publish('nav.debug.emit-dot.temporarily', position, color, ttl); - } else { - eventBus.publish('nav.debug.emit-dot.permanently', position, color); - } - } - - static #emitDotFrontend(position, color = 15, ttl = Infinity) { - if (!R) { - return; - } - - const pn = R.AllocParticles(1); - - if (pn.length !== 1) { - Con.PrintWarning(`Navigation: failed to allocate particle for debug dot at [${position}]\n`); - return; - } - - const p = R.particles[pn[0]]; - p.die = CL.state.time + ttl; - p.color = color; - p.vel = new Vector(0, 0, 0); - p.org = position.copy(); - p.type = R.ptype.tracer; - } - - #debugNavigation() { - if (!Navigation.nav_debug_graph.value) { - return; - } - - for (const node of this.graph.nodes) { - let color = 144; - - if (node.nearLedge) { - color = 251; - } - - this.#emitDot(node.origin.copy().add(new Vector(0, 0, 16)), color); - } - } - - /** - * @param {Vector[]} vectors waypoints - * @param {number} color indexed color - */ - #debugPath(vectors, color = 251) { - if (!vectors || vectors.length === 0) { - return; - } - - const viewOffset = new Vector(0, 0, 22); - - for (let i = 0; i < vectors.length - 1; i++) { - const start = vectors[i].copy().add(viewOffset); - const end = vectors[i + 1].copy().add(viewOffset); - const diff = end.copy().subtract(start); - const totalDistance = diff.len(); - const stepLength = 4; - diff.normalize(); - - // Sample along the segment every 5 units - for (let dist = 0; dist <= totalDistance; dist += stepLength) { - const samplePoint = start.copy().add(diff.copy().multiply(dist)); - this.#emitDot(samplePoint, color, 10); - } - } - } - - #debugWaypoints() { - if (!Navigation.nav_debug_waypoints.value) { - return; - } - - if (this.geometry.walkableSurfaces.length === 0) { - Con.PrintWarning('Navigation: waypoint debug is only available immediately after a local nav build. Nav files do not include waypoint data.\n'); - return; - } - - /** @type {{origin: Vector, color: number, surface: WalkableSurface}[]} */ - const debugPoints = []; - let waypoints = 0; - - for (const surface of this.geometry.walkableSurfaces) { - for (const wp of surface.waypoints) { - let color = 15; - - if (wp.nearLedge && surface.stability !== 1) { - color = 47; - } else if (wp.nearLedge) { - color = 251; - } else if (surface.stability !== 1) { - color = 192; - } - - debugPoints.push({ origin: wp.origin, color, surface }); - waypoints++; - } - } - - Con.DPrint(`Navigation: debug waypoints: ${waypoints}\n`); - Con.DPrint(`Navigation: extracted walkable surfaces: ${this.geometry.walkableSurfaces.length}\n`); - - for (const { color, origin } of debugPoints) { - this.#emitDot(origin, color); - } - } - - // #debugKnownTestNavMeshProbes() { - // if (SV.server.mapname !== 'test_nav_mesh') { - // return; - // } - - // const probes = [ - // ['knight-start', new Vector(-160.0, -112.0, -160.0)], - // ['player-start', new Vector(352.0, -120.0, 40.0)], - // ['tele-dest-1', new Vector(352.0, -24.0, 24.0)], - // ['tele-dest-2', new Vector(-168.0, -416.0, -168.0)], - // ]; - - // for (const [label, origin] of probes) { - // Con.PrintWarning( - // `Navigation: probe ${label} fit=${this.#isValidStandOrigin(origin)} support=${this.#hasGroundSupport(origin)} height=${this.#measureAvailableHeight(origin)}\n`, - // ); - // } - // } - - build() { - console.assert(Boolean(this.worldmodel), 'Navigation: worldmodel is required'); - - this.graph.octree = null; - this.graph.nodes.length = 0; - this.geometry.walkableSurfaces.length = 0; - this.relinkSkiplist.clear(); - this.relinkEdictLinks = {}; - - Con.PrintWarning('Navigation: node graph out of date, rebuilding...\n'); - - // this.#debugKnownTestNavMeshProbes(); - - this.#extractWalkableSurfaces(); - this.#buildNavigationGraph(); - this.#buildSpecialConnections(); - this.#buildOctree(); - - Con.DPrint('Navigation: node graph built with ' + this.graph.nodes.length + ' nodes.\n'); - - void this.save() - .then(() => { - Con.PrintSuccess('Navigation: navigation graph saved!\n'); - if (Navigation.nav_build_process?.value) { - void Cmd.ExecuteString('quit'); - } - }) - .catch((err) => Con.PrintError('Navigation: failed to save navigation graph: ' + err + '\n')); - - this.#scheduleDebugRefresh(); - } -} +export { Navigation, NavMeshOutOfDateException } from './Navigation.ts'; diff --git a/source/engine/server/Navigation.ts b/source/engine/server/Navigation.ts new file mode 100644 index 00000000..557e1091 --- /dev/null +++ b/source/engine/server/Navigation.ts @@ -0,0 +1,1863 @@ +// import sampleBSpline from '../../shared/BSpline.ts'; +import * as Def from '../../shared/Defs.ts'; +import { Octree, type OctreeNode } from '../../shared/Octree.ts'; +import Vector from '../../shared/Vector.ts'; +import Cmd from '../common/Cmd.ts'; +// import Cmd, { ConsoleCommand } from '../common/Cmd.ts'; +import Cvar from '../common/Cvar.ts'; +import { CorruptedResourceError, MissingResourceError } from '../common/Errors.ts'; +import { ServerEngineAPI } from '../common/GameAPIs.ts'; +import type { BrushModel } from '../common/Mod.ts'; +import { MIN_STEP_NORMAL, STEPSIZE } from '../common/Pmove.ts'; +import type { Face } from '../common/model/BaseModel.ts'; +import type PlatformWorker from '../common/PlatformWorker.ts'; +import WorkerManager from '../common/WorkerManager.ts'; +import { eventBus, registry } from '../registry.mjs'; +import type { BaseEntity, ServerEdict } from './Edict.mjs'; +import type { CollisionTrace } from './physics/ServerCollisionSupport.ts'; + +type VectorTuple = [number, number, number]; +type PlanePoint = [number, number]; +type WorkerVectorLike = Vector | VectorTuple; +type WorkerPath = WorkerVectorLike[] | null; +type NeighborLink = [id: number, cost: number, temporaryCostAdjustment: number]; +type SerializedWaypoint = [origin: VectorTuple, availableHeight: number, nearLedge: boolean, isClipping: boolean, isFloating: boolean]; +type SerializedWalkableSurface = [stability: number, normal: VectorTuple, faceIndex: number, waypoints: SerializedWaypoint[]]; +type SerializedNode = [ + id: number, + origin: VectorTuple, + availableHeight: number, + nearLedge: boolean, + isClipping: boolean, + isFloating: boolean, + surfaces: SerializedWalkableSurface[], + neighbors: NeighborLink[], +]; +type PathRequestResolver = (path: Vector[] | null) => void; +type EventUnsubscribe = (() => void) | null; +type TimeoutHandle = ReturnType; + +interface NavigationGraph { + readonly nodes: Node[]; + octree: Octree | null; +} + +interface NavigationGeometry { + walkableSurfaces: WalkableSurface[]; +} + +interface TraversalResult { + readonly ok: boolean; + readonly reason: string; +} + +interface WaypointGroupItem { + readonly wp: Waypoint; + readonly surface: WalkableSurface; + readonly index: number; +} + +interface WaypointGroup { + seedOrigin: Vector; + items: WaypointGroupItem[]; +} + +interface DebugPoint { + readonly origin: Vector; + readonly color: number; + readonly surface: WalkableSurface; +} + +interface ServerEntity extends BaseEntity { + readonly centerPoint: Vector; + target?: string | null; + targetname?: string | null; +} + +/** + * Converts a vector to a serializable tuple. + * @returns The vector components as a tuple. + */ +function vectorToTuple(vector: Vector): VectorTuple { + return [vector[0], vector[1], vector[2]]; +} + +let { CL, COM, Con, R, SV } = registry; + +eventBus.subscribe('registry.frozen', () => { + ({ CL, COM, Con, R, SV } = registry); +}); + +class Waypoint { + origin: Vector = new Vector(); + /** available clearance on the Z-axis */ + availableHeight = Infinity; // space above the waypoint that is free + /** whether waypoint is near a ledge */ + nearLedge = false; + /** whether waypoint is intersection something solid */ + isClipping = false; + /** whether the point is sitting in the air */ + isFloating = false; + + /** + * Creates a sampled waypoint on a walkable surface. + */ + constructor(origin: Vector) { + this.origin.set(origin); + } + + serialize(): SerializedWaypoint { + return [ + vectorToTuple(this.origin), + this.availableHeight, + this.nearLedge, + this.isClipping, + this.isFloating, + ]; + } + + static deserialize(data: SerializedWaypoint): Waypoint { + const wp = new Waypoint(new Vector(...data[0])); + wp.availableHeight = data[1]; + wp.nearLedge = data[2]; + wp.isClipping = data[3]; + wp.isFloating = data[4]; + return wp; + } +} + +class WalkableSurface { + /** dot product of downwards and plane’s normal, e.g. 1 = flat, down to ~0.7 = slope */ + stability = 0; + /** surface’s normal vector. */ + normal: Vector = new Vector(); + face: Face; + readonly faceIndex: number; + waypoints: Waypoint[] = []; + + /** + * Creates a walkable-face wrapper used during nav extraction. + */ + constructor(face: Face, index: number) { + this.face = face; + this.faceIndex = index; + } + + serialize(): SerializedWalkableSurface { + return [ + this.stability, + vectorToTuple(this.normal), + this.faceIndex, + this.waypoints.map((wp) => wp.serialize()), + ]; + } + + static deserialize(data: SerializedWalkableSurface, navigation: Navigation): WalkableSurface { + const faceIndex = data[2]; + const face = navigation.worldmodel?.faces[faceIndex]; + + console.assert(face, 'Navigation requires a worldmodel when deserializing surfaces'); + + const surface = new WalkableSurface(face, faceIndex); + surface.stability = data[0]; + surface.normal = new Vector(...data[1]); + surface.waypoints = data[3].map((wpData) => Waypoint.deserialize(wpData)); + return surface; + } +}; + +/** + * Navigation graph node + */ +class Node { + id = -1; + origin: Vector = new Vector(); + absmin: Vector | null = null; + absmax: Vector | null = null; + octreeNode: OctreeNode | null = null; + availableHeight = 0; // average available height from all waypoints + nearLedge = false; + isClipping = false; + isFloating = false; + surfaces: Set = new Set(); + /** list of [id, cost, temporary cost adjustment]. */ + neighbors: NeighborLink[] = []; + + /** + * Creates a graph node representing one merged walkable region. + */ + constructor(id: number, origin: Vector) { + this.id = id; + this.origin.set(origin); + } + + serialize(): SerializedNode { + return [ + this.id, + vectorToTuple(this.origin), + this.availableHeight, + this.nearLedge, + this.isClipping, + this.isFloating, + Array.from(this.surfaces).map((s) => s.serialize()), + this.neighbors.slice(), + ]; + } + + /** + * Rebuilds a node from nav-file data. + * @returns The reconstructed navigation node. + */ + static deserialize(data: SerializedNode, _navigation: Navigation): Node { + const node = new Node(data[0], new Vector(...data[1])); + + node.availableHeight = data[2]; + node.nearLedge = data[3]; + node.isClipping = data[4]; + node.isFloating = data[5]; + // node.surfaces = new Set(data[6].map((id) => WalkableSurface.deserialize(id, navigation))); + node.neighbors = data[7].slice(); + + return node; + } +} + +/** + * Binary min-heap keyed by fScore for efficient A* open-set extraction. + */ +class MinHeap { + /** node IDs stored in heap order. */ + #data: number[] = []; + /** fScore reference, indexed by node ID. */ + #keys: Float64Array; + /** heap index of each node ID (-1 = not in heap). */ + #index: Int32Array; + + /** + * Creates the A* open-set heap for a bounded node count. + */ + constructor(capacity: number) { + this.#keys = new Float64Array(capacity).fill(Infinity); + this.#index = new Int32Array(capacity).fill(-1); + } + + /** + * Returns the number of queued node IDs. + * @returns The current heap size. + */ + get size(): number { + return this.#data.length; + } + + /** + * Insert or update a node's priority. + */ + pushOrDecrease(id: number, priority: number): void { + this.#keys[id] = priority; + + if (this.#index[id] !== -1) { + this.#bubbleUp(this.#index[id]); + return; + } + + this.#data.push(id); + this.#index[id] = this.#data.length - 1; + this.#bubbleUp(this.#data.length - 1); + } + + /** + * Extract the node with the smallest fScore. + * @returns The node ID with the smallest priority. + */ + pop(): number { + const top = this.#data[0]!; + const last = this.#data.pop()!; + + this.#index[top] = -1; + + if (this.#data.length > 0) { + this.#data[0] = last; + this.#index[last] = 0; + this.#sinkDown(0); + } + + return top; + } + + /** + * Bubbles a node up until the heap invariant is restored. + */ + #bubbleUp(i: number): void { + const data = this.#data; + const keys = this.#keys; + const idx = this.#index; + + while (i > 0) { + const parent = (i - 1) >> 1; + + if (keys[data[i]] >= keys[data[parent]]) { + break; + } + + const tmp = data[i]; + data[i] = data[parent]; + data[parent] = tmp; + idx[data[i]] = i; + idx[data[parent]] = parent; + i = parent; + } + } + + /** + * Sinks a node down until the heap invariant is restored. + */ + #sinkDown(i: number): void { + const data = this.#data; + const keys = this.#keys; + const idx = this.#index; + const n = data.length; + + while (true) { + let smallest = i; + const left = 2 * i + 1; + const right = 2 * i + 2; + + if (left < n && keys[data[left]] < keys[data[smallest]]) { + smallest = left; + } + + if (right < n && keys[data[right]] < keys[data[smallest]]) { + smallest = right; + } + + if (smallest === i) { + break; + } + + const tmp = data[i]; + data[i] = data[smallest]; + data[smallest] = tmp; + idx[data[i]] = i; + idx[data[smallest]] = smallest; + i = smallest; + } + } +} + +export class NavMeshOutOfDateException extends CorruptedResourceError {} + +// TODO: in future we could build graphs per entity type (e.g. monster navmesh with tighter clearances, flying monster navmesh that ignores ground support, etc.) + +const NAV_FILE_VERSION = 3; +const NAV_MONSTER_MINS = new Vector(-16.0, -16.0, -24.0); +const NAV_MONSTER_MAXS = new Vector(16.0, 16.0, 40.0); +const NAV_LINK_STEP_DISTANCE = 8.0; + +export class Navigation { + static nav_save_waypoints: Cvar | null = null; + static nav_debug_waypoints: Cvar | null = null; + static nav_debug_graph: Cvar | null = null; + static nav_debug_path: Cvar | null = null; + /** unavailable outside of the dedicated server. */ + static nav_build_process: Cvar | null = null; + + /** maximum slope that is passable */ + readonly maxSlope = MIN_STEP_NORMAL; + readonly walkerMins: Vector = NAV_MONSTER_MINS.copy(); + readonly walkerMaxs: Vector = NAV_MONSTER_MAXS.copy(); + /** units of headroom required above waypoint */ + requiredHeight = NAV_MONSTER_MAXS[2] - NAV_MONSTER_MINS[2]; + requiredRadius = Math.max( + NAV_MONSTER_MAXS[0], + NAV_MONSTER_MAXS[1], + -NAV_MONSTER_MINS[0], + -NAV_MONSTER_MINS[1], + ); + + /** holds pending requests for the worker thread. */ + #requests: Record = {}; + + /** worker thread handling navigation lookups. */ + #worker: PlatformWorker | null = null; + + /** unsubscribe from nav.path.request. */ + #pathRequestEventListener: EventUnsubscribe = null; + + /** unsubscribe from nav.path.response. */ + #pathResponseEventListener: EventUnsubscribe = null; + + /** unsubscribe from nav_debug_graph changes. */ + #debugGraphEventListener: EventUnsubscribe = null; + + /** unsubscribe from nav_debug_waypoints changes. */ + #debugWaypointsEventListener: EventUnsubscribe = null; + + worldmodel: BrushModel | null; + graph: NavigationGraph; + geometry: NavigationGeometry; + + relinkEdictCooldown: Record = {}; + relinkEdictLinks: Record = {}; + relinkSkiplist: Set = new Set(); + + /** + * Creates a navigation graph builder/runtime for a worldmodel. + */ + constructor(worldmodel: BrushModel | null) { + this.worldmodel = worldmodel; + this.graph = { + nodes: [], + octree: null, + }; + + this.geometry = { + walkableSurfaces: [], + }; + } + + static Init(): void { + if (registry.isDedicatedServer) { + this.nav_build_process = new Cvar('nav_build_process', '0', Cvar.FLAG.NONE, 'if set to 1, it will force build the nav mesh and quit'); + } + + this.nav_save_waypoints = new Cvar('nav_save_waypoints', '0', Cvar.FLAG.NONE, 'deprecated, extracted waypoints stay in memory and are not written to nav files'); + this.nav_debug_graph = new Cvar('nav_debug_graph', '0', Cvar.FLAG.NONE, 'if set to 1, will render the navigation graph for debugging'); + this.nav_debug_waypoints = new Cvar('nav_debug_waypoints', '0', Cvar.FLAG.NONE, 'if set to 1, will render all waypoints for debugging'); + this.nav_debug_path = new Cvar('nav_debug_path', '0', Cvar.FLAG.NONE | Cvar.FLAG.CHEAT, 'if set to 1, will render the last computed path for debugging'); + + // worker thread -> main thread: mesh probably out of date + eventBus.subscribe('nav.build', (): void => { + if (SV.server.navigation) { + SV.server.navigation.build(); + } + }); + + eventBus.subscribe('nav.debug.emit-dot.temporarily', (position: WorkerVectorLike, color: number, ttl: number): void => { + this.#emitDotFrontend(new Vector(...position), color, ttl); + }); + + eventBus.subscribe('nav.debug.emit-dot.permanently', (position: WorkerVectorLike, color: number): void => { + this.#emitDotFrontend(new Vector(...position), color, Infinity); + }); + } + + #initWorker(): void { + this.#worker = WorkerManager.SpawnWorker('server/NavigationWorker.mjs', [ + 'nav.load', + 'nav.path.request', + ]); + } + + #shutdownWorker(): void { + if (this.#worker) { + this.#worker.shutdown().catch((err) => { + Con.PrintError(`Failed to shutdown the navigation worker: ${err}\n`); + }); + + this.#worker = null; + } + } + + #subscribePathResponse(): void { + this.#pathResponseEventListener = eventBus.subscribe('nav.path.response', (id: string, path: WorkerPath): void => { + const vecpath = path ? path.map((point) => new Vector(...point)) : null; + + if (vecpath && Navigation.nav_debug_path?.value) { + this.#debugPath(vecpath); + } + + // since all events are global, we need to check what’s intended for us + if (id in this.#requests) { + this.#requests[id](vecpath); + delete this.#requests[id]; + } + }); + } + + #subscribeDebugCvars(): void { + this.#debugGraphEventListener = eventBus.subscribe('cvar.changed.nav_debug_graph', (cvar: Cvar): void => { + if (cvar.value !== 0) { + this.#scheduleDebugRefresh(); + } + }); + + this.#debugWaypointsEventListener = eventBus.subscribe('cvar.changed.nav_debug_waypoints', (cvar: Cvar): void => { + if (cvar.value !== 0) { + this.#scheduleDebugRefresh(); + } + }); + } + + #scheduleDebugRefresh(): void { + if (!R) { + return; + } + + setTimeout((): void => { + this.#debugWaypoints(); + this.#debugNavigation(); + }, 1000); + } + + init(): void { + Con.DPrint('Navigation: initializing navigation graph...\n'); + + if (Navigation.nav_build_process?.value) { + this.build(); + } + + this.#initWorker(); + this.#subscribePathResponse(); + this.#subscribeDebugCvars(); + eventBus.publish('nav.load', SV.server.mapname, SV.server.worldmodel.checksum); + } + + shutdown(): void { + for (const timeout of Object.values(this.relinkEdictCooldown)) { + clearTimeout(timeout); + } + + this.#shutdownWorker(); + + if (this.#pathRequestEventListener) { + this.#pathRequestEventListener(); + this.#pathRequestEventListener = null; + } + + if (this.#pathResponseEventListener) { + this.#pathResponseEventListener(); + this.#pathResponseEventListener = null; + } + + if (this.#debugGraphEventListener) { + this.#debugGraphEventListener(); + this.#debugGraphEventListener = null; + } + + if (this.#debugWaypointsEventListener) { + this.#debugWaypointsEventListener(); + this.#debugWaypointsEventListener = null; + } + + Con.DPrint('Navigation: shutdown complete.\n'); + } + + async load(mapname: string, expectedChecksum: number | null = null): Promise { + console.assert(this.worldmodel || expectedChecksum, 'Navigation: worldmodel or expectedChecksum is required'); + + const filename = `maps/${mapname}.nav`; + + this.graph.nodes.length = 0; + this.graph.octree = null; + this.relinkSkiplist.clear(); + + // Try to load binary file first (ArrayBuffer). Fallback to text JSON for older files. + const buf = await COM.LoadFile(filename); + + if (!buf) { + throw new MissingResourceError(filename); + } + + const dv = new DataView(buf); + let off = 0; + + const readBytes = (n: number): Uint8Array => { + const out = new Uint8Array(buf, off, n); + off += n; + return out; + }; + + const readUint8 = (): number => dv.getUint8(off++); + const readUint32 = (): number => { + const value = dv.getUint32(off, true); + off += 4; + return value; + }; + const readInt32 = (): number => { + const value = dv.getInt32(off, true); + off += 4; + return value; + }; + const readFloat32 = (): number => { + const value = dv.getFloat32(off, true); + off += 4; + return value; + }; + + // magic: 4 bytes + const magic = String.fromCharCode(...readBytes(4)); + if (magic !== 'QSNM') { + throw new CorruptedResourceError(filename, 'invalid binary magic'); + } + + const version = readUint32(); + if (version !== NAV_FILE_VERSION) { + throw new CorruptedResourceError(filename, 'invalid binary version'); + } + + // worldmodel name (uint16 length + utf8 bytes) + const nameLen = dv.getUint16(off, true); off += 2; + const nameBytes = readBytes(nameLen); + const worldName = new TextDecoder().decode(nameBytes); + + const checksum = readUint32(); + const requiredHeight = readFloat32(); + const requiredRadius = readFloat32(); + + if (worldName !== mapname) { + throw new CorruptedResourceError(filename, 'wrong map'); + } + + if (expectedChecksum !== null) { + if (checksum !== expectedChecksum) { + throw new NavMeshOutOfDateException(filename, 'outdated map'); + } + } else if (checksum !== this.worldmodel.checksum) { + throw new NavMeshOutOfDateException(filename, 'outdated map'); + } + + if (requiredHeight !== this.requiredHeight || requiredRadius !== this.requiredRadius) { + throw new NavMeshOutOfDateException(filename, 'configuration changed'); + } + + // relink skiplist + const relinkCount = readUint32(); + for (let i = 0; i < relinkCount; i++) { + this.relinkSkiplist.add(readUint32()); + } + + // nodes + const nodeCount = readUint32(); + for (let ni = 0; ni < nodeCount; ni++) { + const id = readInt32(); + const ox = readFloat32(); const oy = readFloat32(); const oz = readFloat32(); + const node = new Node(id, new Vector(ox, oy, oz)); + node.availableHeight = readFloat32(); + node.nearLedge = !!readUint8(); + node.isClipping = !!readUint8(); + node.isFloating = !!readUint8(); + + // surfaces (optional) + const surfCount = readUint32(); + if (surfCount > 0) { + for (let si = 0; si < surfCount; si++) { + readFloat32(); + readFloat32(); readFloat32(); readFloat32(); + readUint32(); + const wpCount = readUint32(); + for (let wi = 0; wi < wpCount; wi++) { + readFloat32(); readFloat32(); readFloat32(); + readFloat32(); + readUint8(); + readUint8(); + readUint8(); + } + } + } + + // neighbors + const nbCount = readUint32(); + const nbs: NeighborLink[] = []; + for (let k = 0; k < nbCount; k++) { + const nid = readInt32(); + const cost = readFloat32(); + const adj = readFloat32(); + nbs.push([nid, cost, adj]); + } + node.neighbors = nbs; + + this.graph.nodes.push(node); + } + + this.#buildOctree(); + this.#scheduleDebugRefresh(); + } + + async save(): Promise { + console.assert(Boolean(this.worldmodel), 'Navigation: worldmodel is required'); + + const filename = `maps/${SV.server.mapname}.nav`; + + const bytes: number[] = []; + const tmp = new ArrayBuffer(8); + const tdv = new DataView(tmp); + + const pushUint8 = (value: number): void => { bytes.push(value & 0xff); }; + const pushUint16 = (value: number): void => { bytes.push(value & 0xff); bytes.push((value >>> 8) & 0xff); }; + const pushUint32 = (value: number): void => { + bytes.push(value & 0xff); + bytes.push((value >>> 8) & 0xff); + bytes.push((value >>> 16) & 0xff); + bytes.push((value >>> 24) & 0xff); + }; + const pushInt32 = (value: number): void => pushUint32(value >>> 0); + const pushFloat32 = (value: number): void => { + tdv.setFloat32(0, value, true); + const byteView = new Uint8Array(tmp, 0, 4); + bytes.push(byteView[0], byteView[1], byteView[2], byteView[3]); + }; + const pushBytes = (values: Uint8Array): void => { + for (let i = 0; i < values.length; i++) { + bytes.push(values[i]); + } + }; + + // header magic + pushBytes(new TextEncoder().encode('QSNM')); + pushUint32(NAV_FILE_VERSION); + + // world name + const nameBytes = new TextEncoder().encode(SV.server.mapname); + pushUint16(nameBytes.length); + pushBytes(nameBytes); + + pushUint32(this.worldmodel.checksum); + pushFloat32(this.requiredHeight); + pushFloat32(this.requiredRadius); + + // relink skiplist + pushUint32(this.relinkSkiplist.size); + for (const v of this.relinkSkiplist) { + pushUint32(v); + } + + // nodes + pushUint32(this.graph.nodes.length); + for (const n of this.graph.nodes) { + pushInt32(n.id); + pushFloat32(n.origin[0]); pushFloat32(n.origin[1]); pushFloat32(n.origin[2]); + pushFloat32(n.availableHeight); + pushUint8(n.nearLedge ? 1 : 0); + pushUint8(n.isClipping ? 1 : 0); + pushUint8(n.isFloating ? 1 : 0); + + // waypoints are build-only debug data and are intentionally not serialized + pushUint32(0); + + // neighbors + pushUint32(n.neighbors.length); + for (const nb of n.neighbors) { + pushInt32(nb[0]); + pushFloat32(nb[1]); + pushFloat32(nb[2]); + } + } + + const out = new Uint8Array(bytes); + await COM.WriteFile(filename, out, out.length); + + // Keep the worker in sync after every successful rebuild, including listen-server sessions. + eventBus.publish('nav.load', SV.server.mapname, this.worldmodel.checksum); + } + + #newWalkerStandOffset(): Vector { + return new Vector(0, 0, -this.walkerMins[2]); + } + + /** + * Returns whether the player-sized walker fits at the given stand origin. + * @returns True when the walker box does not start in solid. + */ + #isValidStandOrigin(position: Vector): boolean { + const trace = SV.collision.traceStaticWorld( + position.copy(), + this.walkerMins, + this.walkerMaxs, + position.copy(), + ); + + return !trace.startsolid && !trace.allsolid; + } + + /** + * Traces the player-sized walker through the static world. + * @returns The resulting static-world collision trace. + */ + #traceWalkerStatic(startpos: Vector, endpos: Vector): CollisionTrace { + return SV.collision.traceStaticWorld( + startpos.copy(), + this.walkerMins, + this.walkerMaxs, + endpos.copy(), + ); + } + + /** + * Returns whether the walker has enough floor support at the stand origin. + * @returns True when the walker can stand on the sampled floor. + */ + #hasGroundSupport(position: Vector): boolean { + const mins = position.copy().add(this.walkerMins); + const maxs = position.copy().add(this.walkerMaxs); + + const allCornersSolid = + SV.collision.pointContents(new Vector(mins[0], mins[1], mins[2] - 1.0)) === Def.content.CONTENT_SOLID + && SV.collision.pointContents(new Vector(mins[0], maxs[1], mins[2] - 1.0)) === Def.content.CONTENT_SOLID + && SV.collision.pointContents(new Vector(maxs[0], mins[1], mins[2] - 1.0)) === Def.content.CONTENT_SOLID + && SV.collision.pointContents(new Vector(maxs[0], maxs[1], mins[2] - 1.0)) === Def.content.CONTENT_SOLID; + + if (allCornersSolid) { + return true; + } + + const start = position.copy().add(new Vector(0.0, 0.0, this.walkerMins[2] + 1.0)); + const stop = start.copy().add(new Vector(0.0, 0.0, -2.0 * STEPSIZE)); + + let trace = SV.collision.move(start, Vector.origin, Vector.origin, stop, Def.moveTypes.MOVE_NOMONSTERS, null); + + if (trace.fraction === 1.0) { + return false; + } + + let bottom = trace.endpos[2]; + const mid = bottom; + + for (let x = 0; x <= 1; x++) { + for (let y = 0; y <= 1; y++) { + start[0] = stop[0] = x !== 0 ? maxs[0] : mins[0]; + start[1] = stop[1] = y !== 0 ? maxs[1] : mins[1]; + + trace = SV.collision.move(start, Vector.origin, Vector.origin, stop, Def.moveTypes.MOVE_NOMONSTERS, null); + + if (trace.fraction !== 1.0 && trace.endpos[2] > bottom) { + bottom = trace.endpos[2]; + } + + if (trace.fraction === 1.0 || (mid - trace.endpos[2]) > STEPSIZE) { + return false; + } + } + } + + return true; + } + + /** + * Measures the free vertical space above a stand origin. + * @returns The available vertical distance before colliding overhead. + */ + #measureAvailableHeight(position: Vector, probeHeight = this.requiredHeight): number { + const trace = this.#traceWalkerStatic(position, position.copy().add(new Vector(0, 0, probeHeight))); + return Math.max(0.0, trace.endpos[2] - position[2]); + } + + /** + * Returns a point on the surface plane. + * @returns A point lying on the given surface plane. + */ + #getSurfacePoint(surface: WalkableSurface): Vector { + const surfedge = this.worldmodel.surfedges[surface.face.firstedge]; + + if (surfedge > 0) { + return new Vector().set(this.worldmodel.vertexes[this.worldmodel.edges[surfedge][0]]); + } + + return new Vector().set(this.worldmodel.vertexes[this.worldmodel.edges[-surfedge][1]]); + } + + /** + * Projects a world point onto the supporting surface plane. + * @returns The projected point on the target surface. + */ + #projectPointOntoSurface(point: Vector, surface: WalkableSurface): Vector { + const surfacePoint = this.#getSurfacePoint(surface); + const pointToSurface = point.copy().subtract(surfacePoint); + const distanceToPlane = pointToSurface.dot(surface.normal); + + return point.copy().subtract(surface.normal.copy().multiply(distanceToPlane)); + } + + /** + * Projects a stand origin back onto the supporting surface plane. + * @returns The projected stand origin. + */ + #projectStandOriginOntoSurface(standOrigin: Vector, surface: WalkableSurface): Vector { + const floorPoint = standOrigin.copy(); + floorPoint[2] += this.walkerMins[2]; + + return this.#projectPointOntoSurface(floorPoint, surface).add(this.#newWalkerStandOffset()); + } + + /** + * Offsets a stand origin along a surface plane. + * @returns The offset stand origin following the surface plane. + */ + #offsetStandOrigin(origin: Vector, surface: WalkableSurface, x: number, y: number): Vector { + const floorPoint = origin.copy(); + floorPoint[2] += this.walkerMins[2]; + floorPoint[0] += x; + floorPoint[1] += y; + + return this.#projectPointOntoSurface(floorPoint, surface).add(this.#newWalkerStandOffset()); + } + + /** + * Evaluates whether a walker can traverse between two stand origins. + * @returns The traversal decision and failure reason. + */ + #evaluateTraversalBetween(startOrigin: Vector, endOrigin: Vector): TraversalResult { + if (!this.#isValidStandOrigin(startOrigin)) { + return { ok: false, reason: 'start-fit' }; + } + + if (!this.#isValidStandOrigin(endOrigin)) { + return { ok: false, reason: 'end-fit' }; + } + + if (!this.#hasGroundSupport(startOrigin) || !this.#hasGroundSupport(endOrigin)) { + return { + ok: false, + reason: !this.#hasGroundSupport(startOrigin) ? 'start-support' : 'end-support', + }; + } + + const delta = endOrigin.copy().subtract(startOrigin); + delta[2] = 0.0; + + const totalDistance = delta.len(); + + if (totalDistance === 0.0) { + return { + ok: Math.abs(endOrigin[2] - startOrigin[2]) <= STEPSIZE, + reason: 'same-spot', + }; + } + + const stepDistance = Math.min(NAV_LINK_STEP_DISTANCE, totalDistance); + const direction = delta.copy().multiply(1.0 / totalDistance); + let previousOrigin = startOrigin; + + for (let travelled = stepDistance; travelled < totalDistance; travelled += stepDistance) { + const t = travelled / totalDistance; + const sampleOrigin = startOrigin.copy().add(direction.copy().multiply(travelled)); + sampleOrigin[2] = startOrigin[2] + (endOrigin[2] - startOrigin[2]) * t; + + if (Math.abs(sampleOrigin[2] - previousOrigin[2]) > STEPSIZE + 1.0) { + return { ok: false, reason: 'height-mismatch' }; + } + + if (!this.#isValidStandOrigin(sampleOrigin)) { + return { ok: false, reason: 'step-fit' }; + } + + if (!this.#hasGroundSupport(sampleOrigin)) { + return { ok: false, reason: 'step-support' }; + } + + previousOrigin = sampleOrigin; + } + + if (Math.abs(endOrigin[2] - previousOrigin[2]) > STEPSIZE + 1.0) { + return { ok: false, reason: 'height-mismatch' }; + } + + return { ok: true, reason: 'ok' }; + } + + #extractWalkableSurfaces(): void { + const walkableSurfaces = []; + let sampledWaypointCount = 0; + let retainedWaypointCount = 0; + + const upwards = new Vector(0, 0, 1); + const sidewards = new Vector(0, 1, 0); + + // Pass 1: collect all potentially walkable surfaces + for (let i = 0; i < this.worldmodel.faces.length; i++) { + const face = this.worldmodel.faces[i]; + + if (face.numedges < 3) { + continue; + } + + const walkableSurface = new WalkableSurface(face, i); + + // Only accept surfaces whose normals point upward and do not exceed a 45 degrees incline. + const faceNormal = face.normal; + + walkableSurface.stability = faceNormal.dot(upwards); + + if (walkableSurface.stability < this.maxSlope) { + continue; + } + + // Ignore special surfaces, also submodel faces + if (face.turbulent === true || face.sky === true || face.submodel === true) { + continue; + } + + walkableSurface.normal.set(faceNormal); + + walkableSurfaces.push(walkableSurface); + } + + // Pass 2: check if the walkable surfaces are really walkable by sampling points on them + // - create sample points across each walkable face (interior sampling) + // - approach: build ordered 3D vertex list for the face, project to a local 2D basis + // - grid-sample the face bounding box and keep points that lie inside the polygon + for (const surface of walkableSurfaces) { + const face = surface.face; + const verts3: Vector[] = []; + for (let i = 0; i < face.numedges; i++) { + const vec = new Vector(); + const surfedge = this.worldmodel.surfedges[face.firstedge + i]; + + if (surfedge > 0) { + vec.set(this.worldmodel.vertexes[this.worldmodel.edges[surfedge][0]]); + } else { + vec.set(this.worldmodel.vertexes[this.worldmodel.edges[-surfedge][1]]); + } + + verts3.push(vec); + } + + /** face plane normal */ + const n = surface.normal.copy(); + + /** pick arbitrary axis not parallel to normal */ + const arbitrary = Math.abs(n[2]) < 0.9 ? upwards : sidewards; + + // build local orthonormal basis (u, v) on the face plane + const u = n.cross(arbitrary); + const uLen = u.normalize(); + + if (uLen === 0) { + continue; + } + + const v = n.cross(u); + const vLen = v.normalize(); + + if (vLen === 0) { + continue; + } + + const origin = verts3[0]; + + // project verts to 2D coordinates in [u, v] basis + const verts2 = verts3.map((p3): PlanePoint => { + const rel = p3.copy().subtract(origin); + return [rel.dot(u), rel.dot(v)]; + }); + + // compute bounding box in 2D + let minX = Infinity, minY = Infinity; + let maxX = -Infinity, maxY = -Infinity; + + for (const p of verts2) { + if (p[0] < minX) { + minX = p[0]; + } + if (p[0] > maxX) { + maxX = p[0]; + } + if (p[1] < minY) { + minY = p[1]; + } + if (p[1] > maxY) { + maxY = p[1]; + } + } + + /** + * point-in-polygon (ray crossing) + * @returns True when the point lies inside the polygon. + */ + const pointInPoly = (pt: PlanePoint, poly: PlanePoint[]): boolean => { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const xi = poly[i][0]; + const yi = poly[i][1]; + const xj = poly[j][0]; + const yj = poly[j][1]; + const intersect = ((yi > pt[1]) !== (yj > pt[1])) && (pt[0] < (xj - xi) * (pt[1] - yi) / (yj - yi + 0.0) + xi); + if (intersect) { + inside = !inside; + } + } + return inside; + }; + + // sample the actor center lane instead of the full polygon so narrow stairs and ledges + // can still produce valid points without relying on later support pruning alone. + + // sampling resolution (units between samples on the face) + const step = 8; + + const startX = Math.floor(minX / step) * step + (step * 0.5); + const startY = Math.floor(minY / step) * step + (step * 0.5); + + // grid-sample the bounding box and test inclusion + for (let sx = startX; sx <= Math.ceil(maxX); sx += step) { + for (let sy = startY; sy <= Math.ceil(maxY); sy += step) { + const pt2 = [sx, sy]; + if (!pointInPoly(pt2, verts2)) { + continue; + } + + // map 2D point back to 3D: origin + u * x + v * y, then lift to a player stand origin + const worldPoint = origin.copy().add(u.copy().multiply(pt2[0])).add(v.copy().multiply(pt2[1])); + const standOrigin = worldPoint.add(this.#newWalkerStandOffset()); + + surface.waypoints.push(new Waypoint(standOrigin)); + sampledWaypointCount++; + } + } + } + + // Pass 3: prune waypoints that do not have enough player-sized clearance + const rr = this.requiredRadius; + const sideOffsets = [ + [-rr * 1.4, -rr], [0.0, -rr], [rr * 1.4, -rr], + [-rr, 0.0], [rr, 0.0], + [-rr * 1.4, rr], [0.0, rr], [rr * 1.4, rr], + ]; + const pruneStats = { + invalidFit: 0, + lowHeight: 0, + unsupported: 0, + retained: 0, + }; + + for (const surface of walkableSurfaces) { + for (const wp of surface.waypoints) { + if (!this.#isValidStandOrigin(wp.origin)) { + wp.availableHeight = 0; + wp.isClipping = true; + pruneStats.invalidFit++; + continue; + } + + wp.availableHeight = this.#measureAvailableHeight(wp.origin); + + if (wp.availableHeight < this.requiredHeight) { + wp.availableHeight = 0; + pruneStats.lowHeight++; + continue; + } + + if (!this.#hasGroundSupport(wp.origin)) { + wp.isFloating = true; + pruneStats.unsupported++; + continue; + } + + for (const [x, y] of sideOffsets) { + const sideOrigin = this.#offsetStandOrigin(wp.origin, surface, x, y); + + if (!this.#isValidStandOrigin(sideOrigin)) { + continue; + } + + if (!this.#hasGroundSupport(sideOrigin)) { + wp.nearLedge = true; + break; + } + } + } + } + + // Pass 4: filter out unsuitable waypoints and store the rest + for (const surface of walkableSurfaces) { + const suitableWaypoints: Waypoint[] = []; + + for (const wp of surface.waypoints) { + if ((wp.availableHeight >= this.walkerMaxs[2] - this.walkerMins[2]) && !wp.isClipping && !wp.isFloating) { + suitableWaypoints.push(wp); + pruneStats.retained++; + } + } + + if (suitableWaypoints.length === 0) { + continue; + } + + surface.waypoints = suitableWaypoints; + retainedWaypointCount += suitableWaypoints.length; + + this.geometry.walkableSurfaces.push(surface); + } + + Con.DPrint( + `Navigation: walkable surfaces=${walkableSurfaces.length}, sampled waypoints=${sampledWaypointCount}, retained waypoints=${retainedWaypointCount}, retained surfaces=${this.geometry.walkableSurfaces.length}, invalidFit=${pruneStats.invalidFit}, lowHeight=${pruneStats.lowHeight}, unsupported=${pruneStats.unsupported}\n`, + ); + } + + #buildNavigationGraph(): void { + // Build a simple navgraph from the extracted waypoints. + // Steps: + // 1) collect all waypoints + // 2) merge nearby waypoints into graph nodes + // 3) connect nodes with unobstructed links (trace check) + + const mergeRadius = 24; // units to merge nearby waypoints + const linkRadius = 64; // max distance to attempt a link + + // 1) collect all waypoints into flat list + const allWaypoints: Array<{ wp: Waypoint; surface: WalkableSurface }> = []; + for (const surface of this.geometry.walkableSurfaces) { + for (const wp of surface.waypoints) { + allWaypoints.push({ wp, surface }); + } + } + + // 2) merge nearby waypoints into nodes using surface-aware clustering + const nodes = this.graph.nodes; + nodes.length = 0; + + const distance = (a: Vector, b: Vector): number => Math.hypot(a[0] - b[0], a[1] - b[1]); + + // Group waypoints that should be merged together + const waypointGroups: WaypointGroup[] = []; + + for (let i = 0; i < allWaypoints.length; i++) { + const current = allWaypoints[i]; + let bestGroup: WaypointGroup | null = null; + let bestDistance = Infinity; + + for (const group of waypointGroups) { + const d = distance(group.seedOrigin, current.wp.origin); + const heightDiff = Math.abs(group.seedOrigin[2] - current.wp.origin[2]); + + if (d > mergeRadius || heightDiff > STEPSIZE) { + continue; + } + + if (d < bestDistance) { + bestDistance = d; + bestGroup = group; + } + } + + if (bestGroup === null) { + waypointGroups.push({ + seedOrigin: current.wp.origin.copy(), + items: [{ ...current, index: i }], + }); + continue; + } + + bestGroup.items.push({ ...current, index: i }); + } + + // Create nodes from waypoint groups + for (const group of waypointGroups) { + const id = nodes.length; + + // Compute centroid of all waypoints in the group + const centroid = new Vector(); + let representativeOrigin: Vector | null = null; + let representativeDistance = Infinity; + let minAvailableHeight = Infinity; + let nearLedge = false; + let isClipping = false; + let isFloating = false; + const surfaces = new Set(); + + for (const { wp, surface } of group.items) { + centroid.add(wp.origin); + minAvailableHeight = Math.min(minAvailableHeight, wp.availableHeight); + nearLedge = nearLedge || wp.nearLedge; + isClipping = isClipping || wp.isClipping; + isFloating = isFloating || wp.isFloating; + surfaces.add(surface); + } + + centroid.multiply(1.0 / group.items.length); + + // If all waypoints are on the same surface, project centroid onto that surface + if (surfaces.size === 1) { + const surface = surfaces.values().next().value as WalkableSurface; + centroid.set(this.#projectStandOriginOntoSurface(centroid, surface)); + } + + for (const { wp } of group.items) { + const d = wp.origin.distanceTo(centroid); + + if (d < representativeDistance) { + representativeDistance = d; + representativeOrigin = wp.origin; + } + } + + const node = new Node(id, representativeOrigin ?? centroid); + node.availableHeight = Number.isFinite(minAvailableHeight) ? minAvailableHeight : 0.0; + node.nearLedge = nearLedge; + node.isClipping = isClipping; + node.isFloating = isFloating; + node.surfaces = surfaces; + + nodes.push(node); + } + + // 3) build spatial index before linking to accelerate neighbor search + this.#buildOctree(); + + // 4) connect nodes: attempt links between nearby, unobstructed node pairs + const linkStats = { + considered: 0, + linked: 0, + startFit: 0, + endFit: 0, + startSupport: 0, + endSupport: 0, + stepFit: 0, + stepSupport: 0, + heightMismatch: 0, + }; + + // track already-evaluated pairs to avoid duplicate work + const evaluatedPairs = new Set(); + + for (const a of nodes) { + for (const b of this.#findNearestNodes(a.origin, linkRadius)) { + if (a.id === b.id) { + continue; + } + + // ensure each pair is evaluated only once + const lo = Math.min(a.id, b.id); + const hi = Math.max(a.id, b.id); + const pairKey = lo * nodes.length + hi; + + if (evaluatedPairs.has(pairKey)) { + continue; + } + + evaluatedPairs.add(pairKey); + + const dist = b.origin.distanceTo(a.origin); + + linkStats.considered++; + + const aToB = this.#evaluateTraversalBetween(a.origin, b.origin); + const bToA = this.#evaluateTraversalBetween(b.origin, a.origin); + + if (!aToB.ok && !bToA.ok) { + const reasons = [aToB.reason, bToA.reason]; + + if (reasons.includes('start-fit')) { + linkStats.startFit++; + } else if (reasons.includes('end-fit')) { + linkStats.endFit++; + } else if (reasons.includes('start-support')) { + linkStats.startSupport++; + } else if (reasons.includes('end-support')) { + linkStats.endSupport++; + } else if (reasons.includes('step-fit')) { + linkStats.stepFit++; + } else if (reasons.includes('step-support')) { + linkStats.stepSupport++; + } else { + linkStats.heightMismatch++; + } + + continue; + } + + if (aToB.ok) { + let cost = dist + Math.max(0.0, b.origin[2] - a.origin[2]); + + if (a.nearLedge) { + cost += 96; + } + + if (b.nearLedge) { + cost += 96; + } + + a.neighbors.push([b.id, cost, 0]); + linkStats.linked++; + } + + if (bToA.ok) { + let cost = dist + Math.max(0.0, a.origin[2] - b.origin[2]); + + if (b.nearLedge) { + cost += 96; + } + + if (a.nearLedge) { + cost += 96; + } + + b.neighbors.push([a.id, cost, 0]); + linkStats.linked++; + } + } + } + + Con.DPrint( + `Navigation: merged ${allWaypoints.length} waypoints into ${waypointGroups.length} waypoint groups\n`, + ); + Con.PrintWarning( + `Navigation: link stats considered=${linkStats.considered} linked=${linkStats.linked} ` + + `startFit=${linkStats.startFit} endFit=${linkStats.endFit} ` + + `startSupport=${linkStats.startSupport} endSupport=${linkStats.endSupport} ` + + `stepFit=${linkStats.stepFit} stepSupport=${linkStats.stepSupport} heightMismatch=${linkStats.heightMismatch}\n`, + ); + } + + /** + * updates navigation links based on entity position + */ + relinkEdict(edict: ServerEdict): void { + const entity = edict.entity as ServerEntity | null; + + if (!entity) { + return; + } + + // only care about world and large static brushes for now + if (entity.solid !== Def.solid.SOLID_BSP) { + return; + } + + // this edict got flagged as not interesting earlier + if (this.relinkSkiplist.has(edict.num)) { + return; + } + + if (this.relinkEdictCooldown[edict.num]) { + clearTimeout(this.relinkEdictCooldown[edict.num]); + } + + this.relinkEdictCooldown[edict.num] = setTimeout(() => { + delete this.relinkEdictCooldown[edict.num]; + this.#relinkEdict(edict); + }, 1000); + } + + /** + * updates navigation links based on entity position + */ + #relinkEdict(edict: ServerEdict): void { + if (edict.isFree()) { + return; + } + + // TODO: adjust the nav graph accordingly + } + + #relinkAll(): void { + for (let i = 0; i < SV.server.num_edicts; i++) { + const edict = SV.server.edicts[i]; + + if (edict.isFree()) { + continue; + } + + this.#relinkEdict(edict); + } + } + + #buildSpecialConnections(): void { + this.#buildTeleporterLinks(); + this.#buildDoorLinks(); + this.#relinkAll(); + } + + #buildTeleporterLinks(): void { + // looking for teleporters + for (const teleporterEdict of ServerEngineAPI.FindAllByFieldAndValue('classname', 'trigger_teleport')) { + const source = teleporterEdict.entity as ServerEntity | null; + + if (!source) { + continue; + } + + if (!source.target) { + continue; + } + + const destinationEdict = Array.from(ServerEngineAPI.FindAllByFieldAndValue('targetname', source.target))[0]; + const destination = destinationEdict?.entity as ServerEntity | null; + + if (!destination) { + Con.PrintWarning(`Navigation: teleporter without a valid target: ${source.classname}\n`); + continue; + } + + const sp = source.centerPoint.copy(), dp = destination.centerPoint.copy(); + + Con.DPrint(`Navigation: found teleporter [${sp}] --> [${dp}]\n`); + + const destNode = this.#findNearestNode(dp, 96); // Just grab one in proximity of the destination + + if (!destNode) { + Con.PrintWarning('Navigation: teleporter destination has no nearby navnode\n'); + continue; + } + + const cost = 0; // no cost for teleporters, since traveling is instant + + // insert a new node here to smooth out the path to the teleporter trigger + const sourceNode = new Node(this.graph.nodes.length, sp); + sourceNode.availableHeight = source.maxs[2] - source.mins[2]; + this.graph.nodes.push(sourceNode); + Con.DPrint(`Navigation: adding teleporter source node ${sourceNode.id}\n`); + + // link the new node to its neighbors + for (const sourceNodeNeighbor of this.#findNearestNodes(sp, 64)) { + Con.DPrint(`Navigation: linking teleporter nodes ${sourceNodeNeighbor.id} --> ${sourceNode.id}\n`); + sourceNodeNeighbor.neighbors.push([sourceNode.id, cost, 0]); // one-way link + // this.graph.edges.push([ sourceNodeNeighbor.id, sourceNode.id, cost ]); + } + + // link the new node to the destination node + Con.DPrint(`Navigation: linking teleporter nodes ${sourceNode.id} --> ${destNode.id}\n`); + sourceNode.neighbors.push([destNode.id, cost, 0]); // one-way link + // this.graph.edges.push([ sourceNode.id, destNode.id, cost ]); + } + } + + #buildDoorLinks(): void { + // looking for simple doors + for (const doorEdict of ServerEngineAPI.FindAllByFieldAndValue('classname', 'func_door')) { + const door = doorEdict.entity as ServerEntity | null; + + if (!door) { + continue; + } + + if (door.targetname) { // remote controlled door, skip for now + continue; + } + + this.relinkSkiplist.add(doorEdict.num); + } + } + + #buildOctree(): void { + if (this.graph.nodes.length === 0) { + this.graph.octree = null; + return; + } + + // compute bounding box of node origins + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + + for (const n of this.graph.nodes) { + const o = n.origin; + if (o[0] < minX) { minX = o[0]; } + if (o[1] < minY) { minY = o[1]; } + if (o[2] < minZ) { minZ = o[2]; } + if (o[0] > maxX) { maxX = o[0]; } + if (o[1] > maxY) { maxY = o[1]; } + if (o[2] > maxZ) { maxZ = o[2]; } + } + + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + const cz = (minZ + maxZ) / 2; + const extentX = maxX - minX; + const extentY = maxY - minY; + const extentZ = maxZ - minZ; + const halfSize = Math.max(extentX, extentY, extentZ) / 2 + 1; + + const center = new Vector(cx, cy, cz); + this.graph.octree = new Octree(center, halfSize, 12, 8); + + for (const n of this.graph.nodes) { + this.graph.octree.insert(n); + } + } + + /** + * Find nearest graph node to a world position. + * @returns The nearest node within the search radius, or null. + */ + #findNearestNode(position: Vector, maxDist = 512): Node | null { + if (this.graph.nodes.length === 0) { + return null; + } + + // first try octree lookup, if available (linking specials won’t have access to the Octree yet) + if (this.graph.octree) { + const n = this.graph.octree.nearest(position, maxDist); + + if (n) { + return n; + } + } + + // fallthrough to full scan if nothing found within maxDist in octree + Con.DPrint('Navigation: nearest node not found in octree, falling back to linear scan\n'); + + let best = null; + let bestDist = Infinity; + + for (const node of this.graph.nodes) { + const d = position.distanceTo(node.origin); + if (d < bestDist && d <= maxDist) { + bestDist = d; + best = node; + } + } + + return best; + } + + /** + * Find all graph nodes within maxDist of a world position. + * Uses the octree when available, falls back to linear scan. + * @yields Nodes within the requested radius. + */ + *#findNearestNodes(position: Vector, maxDist = 512): Generator { + if (this.graph.octree) { + for (const [, node] of this.graph.octree.root.querySphere(position, maxDist)) { + yield node; + } + return; + } + + for (const node of this.graph.nodes) { + const d = position.distanceTo(node.origin); + if (d <= maxDist) { + yield node; + } + } + } + + /** + * Find path between two world positions using A* over the navgraph. + * Returns an array of Vector positions (node origins) or null if no path. + * Using this async version will offload the pathfinding to another worker thread and it will not recover during save/load games! + * @returns A promised path containing the start and goal positions, or null. + */ + findPathAsync(startPos: Vector, goalPos: Vector): Promise { + return new Promise((resolve) => { + const id = Math.random().toString(36).substring(2, 10); + + this.#requests[id] = resolve; + + eventBus.publish('nav.path.request', id, startPos, goalPos); + }); + } + + /** + * Find path between two world positions using A* over the navgraph. + * Returns an array of Vector positions (node origins) or null if no path. + * @returns A path containing the start and goal positions, or null. + */ + findPath(startPos: Vector, goalPos: Vector): Vector[] | null { + if (!this.graph || !this.graph.nodes || this.graph.nodes.length === 0) { + return null; + } + + const startNode = this.#findNearestNode(startPos, 512); + const goalNode = this.#findNearestNode(goalPos, 512); + + if (!startNode || !goalNode) { + Con.DPrint('Navigation: no start or goal node found\n'); + return null; + } + + if (startNode.id === goalNode.id) { + const path = [startPos.copy(), goalPos.copy()]; + return path; + } + + const nodeCount = this.graph.nodes.length; + const gScore = new Float64Array(nodeCount).fill(Infinity); + const cameFrom = new Int32Array(nodeCount).fill(-1); + const openSet = new MinHeap(nodeCount); + + gScore[startNode.id] = 0; + openSet.pushOrDecrease(startNode.id, startNode.origin.distanceTo(goalNode.origin)); + + while (openSet.size > 0) { + const currentId = openSet.pop(); + + if (currentId === goalNode.id) { + const path = []; + let cur = currentId; + + while (cur !== -1) { + path.push(this.graph.nodes[cur].origin.copy()); + cur = cameFrom[cur]; + } + + path.reverse(); + path[0] = startPos.copy(); + path.push(goalPos.copy()); + return path; + } + + const currentG = gScore[currentId]; + + for (const nb of this.graph.nodes[currentId].neighbors) { + const nbId = nb[0]; + const tentativeG = currentG + nb[1] + nb[2]; + + if (tentativeG < gScore[nbId]) { + cameFrom[nbId] = currentId; + gScore[nbId] = tentativeG; + openSet.pushOrDecrease(nbId, tentativeG + this.graph.nodes[nbId].origin.distanceTo(goalNode.origin)); + } + } + } + + return null; + } + + #emitDot(position: Vector, color = 15, ttl = Infinity): void { + if (Number.isFinite(ttl)) { + eventBus.publish('nav.debug.emit-dot.temporarily', position, color, ttl); + } else { + eventBus.publish('nav.debug.emit-dot.permanently', position, color); + } + } + + static #emitDotFrontend(position: Vector, color = 15, ttl = Infinity): void { + if (!R) { + return; + } + + const pn = R.AllocParticles(1); + + if (pn.length !== 1) { + Con.PrintWarning(`Navigation: failed to allocate particle for debug dot at [${position}]\n`); + return; + } + + const p = R.particles[pn[0]]; + p.die = CL.state.time + ttl; + p.color = color; + p.vel = new Vector(0, 0, 0); + p.org = position.copy(); + p.type = R.ptype.tracer; + } + + #debugNavigation(): void { + if (!Navigation.nav_debug_graph.value) { + return; + } + + for (const node of this.graph.nodes) { + let color = 144; + + if (node.nearLedge) { + color = 251; + } + + this.#emitDot(node.origin.copy().add(new Vector(0, 0, 16)), color); + } + } + + /** + * Emits a short-lived dotted debug path in the renderer. + */ + #debugPath(vectors: Vector[], color = 251): void { + if (!vectors || vectors.length === 0) { + return; + } + + const viewOffset = new Vector(0, 0, 22); + + for (let i = 0; i < vectors.length - 1; i++) { + const start = vectors[i].copy().add(viewOffset); + const end = vectors[i + 1].copy().add(viewOffset); + const diff = end.copy().subtract(start); + const totalDistance = diff.len(); + const stepLength = 4; + diff.normalize(); + + // Sample along the segment every 5 units + for (let dist = 0; dist <= totalDistance; dist += stepLength) { + const samplePoint = start.copy().add(diff.copy().multiply(dist)); + this.#emitDot(samplePoint, color, 10); + } + } + } + + #debugWaypoints(): void { + if (!Navigation.nav_debug_waypoints.value) { + return; + } + + if (this.geometry.walkableSurfaces.length === 0) { + Con.PrintWarning('Navigation: waypoint debug is only available immediately after a local nav build. Nav files do not include waypoint data.\n'); + return; + } + + const debugPoints: DebugPoint[] = []; + let waypoints = 0; + + for (const surface of this.geometry.walkableSurfaces) { + for (const wp of surface.waypoints) { + let color = 15; + + if (wp.nearLedge && surface.stability !== 1) { + color = 47; + } else if (wp.nearLedge) { + color = 251; + } else if (surface.stability !== 1) { + color = 192; + } + + debugPoints.push({ origin: wp.origin, color, surface }); + waypoints++; + } + } + + Con.DPrint(`Navigation: debug waypoints: ${waypoints}\n`); + Con.DPrint(`Navigation: extracted walkable surfaces: ${this.geometry.walkableSurfaces.length}\n`); + + for (const { color, origin } of debugPoints) { + this.#emitDot(origin, color); + } + } + + // #debugKnownTestNavMeshProbes() { + // if (SV.server.mapname !== 'test_nav_mesh') { + // return; + // } + + // const probes = [ + // ['knight-start', new Vector(-160.0, -112.0, -160.0)], + // ['player-start', new Vector(352.0, -120.0, 40.0)], + // ['tele-dest-1', new Vector(352.0, -24.0, 24.0)], + // ['tele-dest-2', new Vector(-168.0, -416.0, -168.0)], + // ]; + + // for (const [label, origin] of probes) { + // Con.PrintWarning( + // `Navigation: probe ${label} fit=${this.#isValidStandOrigin(origin)} support=${this.#hasGroundSupport(origin)} height=${this.#measureAvailableHeight(origin)}\n`, + // ); + // } + // } + + build(): void { + console.assert(Boolean(this.worldmodel), 'Navigation: worldmodel is required'); + + this.graph.octree = null; + this.graph.nodes.length = 0; + this.geometry.walkableSurfaces.length = 0; + this.relinkSkiplist.clear(); + this.relinkEdictLinks = {}; + + Con.PrintWarning('Navigation: node graph out of date, rebuilding...\n'); + + // this.#debugKnownTestNavMeshProbes(); + + this.#extractWalkableSurfaces(); + this.#buildNavigationGraph(); + this.#buildSpecialConnections(); + this.#buildOctree(); + + Con.DPrint(`Navigation: node graph built with ${this.graph.nodes.length} nodes.\n`); + + void this.save() + .then(() => { + Con.PrintSuccess('Navigation: navigation graph saved!\n'); + if (Navigation.nav_build_process?.value) { + void Cmd.ExecuteString('quit'); + } + }) + .catch((err) => Con.PrintError(`Navigation: failed to save navigation graph: ${err}\n`)); + + this.#scheduleDebugRefresh(); + } +} diff --git a/source/engine/server/Progs.ts b/source/engine/server/Progs.ts index 781baa59..26129dba 100644 --- a/source/engine/server/Progs.ts +++ b/source/engine/server/Progs.ts @@ -7,7 +7,7 @@ import Cvar from '../common/Cvar.ts'; import { HostError, MissingResourceError } from '../common/Errors.ts'; import Q from '../../shared/Q.ts'; import Vector from '../../shared/Vector.ts'; -import { eventBus, registry } from '../registry.mjs'; +import { eventBus, getCommonRegistry } from '../registry.mjs'; import { ED, ServerEdict } from './Edict.mjs'; import { ServerEngineAPI } from '../common/GameAPIs.ts'; import PF, { etype, ofs } from './ProgsAPI.mjs'; @@ -136,12 +136,10 @@ const PR = {} as ProgsModule; export default PR; -let { COM, Con, SV } = registry; +let { COM, Con, SV } = getCommonRegistry(); eventBus.subscribe('registry.frozen', () => { - COM = registry.COM; - Con = registry.Con; - SV = registry.SV; + ({ COM, Con, SV } = getCommonRegistry()); }); PR.saveglobal = (1<<15); @@ -373,10 +371,9 @@ class ProgsEntity { static SERIALIZATION_TYPE_PRIMITIVE = 'P'; /** - * - * @param {*} ed can be null, then it’s global + * Creates a QuakeC entity view backed by an edict or the globals table. */ - constructor(ed) { + constructor(ed: ServerEdict | null) { // const stats = ed ? PR._stats.edict : PR._stats.global; const defs = ed ? PR.fielddefs : PR.globaldefs; @@ -653,8 +650,7 @@ class ProgsEntity { /** * Retrieves the global definition at the specified offset. - * @param {number} ofs - The offset to retrieve. - * @returns {object|null} - The global definition. + * @returns The matching global definition, or null when none exists. */ PR.GlobalAtOfs = function(ofs) { return PR.globaldefs.find((def) => def.ofs === ofs) || null; @@ -662,8 +658,7 @@ PR.GlobalAtOfs = function(ofs) { /** * Retrieves the field definition at the specified offset. - * @param {number} ofs - The offset to retrieve. - * @returns {object|null} - The field definition. + * @returns The matching field definition, or null when none exists. */ PR.FieldAtOfs = function(ofs) { return PR.fielddefs.find((def) => def.ofs === ofs) || null; @@ -671,8 +666,7 @@ PR.FieldAtOfs = function(ofs) { /** * Finds a field definition by name. - * @param {string} name - The field name. - * @returns {object|null} - The field definition. + * @returns The matching field definition, or null when none exists. */ PR.FindField = function(name) { return PR.fielddefs.find((def) => PR.GetString(def.name) === name) || null; @@ -680,8 +674,7 @@ PR.FindField = function(name) { /** * Finds a global definition by name. - * @param {string} name - The global name. - * @returns {object|null} - The global definition. + * @returns The matching global definition, or null when none exists. */ PR.FindGlobal = function(name) { return PR.globaldefs.find((def) => PR.GetString(def.name) === name) || null; @@ -689,8 +682,7 @@ PR.FindGlobal = function(name) { /** * Finds a function definition by name. - * @param {string} name - The function name. - * @returns {number} - The function index. + * @returns The function index, or `-1` when the function cannot be found. */ PR.FindFunction = function(name) { return PR.functions.findIndex((func) => PR.GetString(func.name) === name); @@ -1085,10 +1077,8 @@ PR.LoadProgs = async function() { return gameAPI; }; -/** @type {Cvar[]} */ PR._cvars = []; -/** @type {import('./GameLoader').GameModuleInterface|null} */ PR.QuakeJS = null; PR.Init = async function() { diff --git a/source/engine/server/ProgsAPI.mjs b/source/engine/server/ProgsAPI.mjs index e5a823b0..697e2408 100644 --- a/source/engine/server/ProgsAPI.mjs +++ b/source/engine/server/ProgsAPI.mjs @@ -1,548 +1 @@ -import Vector from '../../shared/Vector.ts'; -import Cmd from '../common/Cmd.ts'; -import { HostError } from '../common/Errors.ts'; -import { ServerEngineAPI } from '../common/GameAPIs.ts'; -import { eventBus, registry } from '../registry.mjs'; -import { ED, ServerEdict } from './Edict.mjs'; - -const PF = {}; - -export default PF; - -let { Con, PR, SV } = registry; - -eventBus.subscribe('registry.frozen', () => { - Con = registry.Con; - PR = registry.PR; - SV = registry.SV; -}); - -PF._assertTrue = function _assertTrue(check, message) { - if (!check) { - throw new Error('Program assert failed: ' + message); - } -}; - -/** - * PR.fielddefs[].type - * @enum {number} - * @readonly - */ -export const etype = Object.freeze({ - ev_void: 0, - ev_string: 1, - ev_float: 2, - ev_vector: 3, - ev_entity: 4, - ev_field: 5, - ev_function: 6, - ev_pointer: 7, - - ev_strings: 101, - ev_integer: 102, - ev_string_not_empty: 103, - ev_entity_client: 104, - ev_bool: 200, -}); - -/** - * @enum {number} - * @readonly - */ -export const ofs = Object.freeze({ - OFS_NULL: 0, - OFS_RETURN: 1, - OFS_PARM0: 4, // leave 3 ofs for each parm to hold vectors - OFS_PARM1: 7, - OFS_PARM2: 10, - OFS_PARM3: 13, - OFS_PARM4: 16, - OFS_PARM5: 19, - OFS_PARM6: 22, - OFS_PARM7: 25, -}); - -/** - * Will generate a function that can be exposed to the QuakeC VM (so called built-in function) - * @param {string} name - * @param {Function} func - * @param {etype[]} argTypes - * @param {etype} returnType - * @returns {Function} - */ -function _PF_GenerateBuiltinFunction(name, func, argTypes = [], returnType = etype.ev_void) { - if (!(func instanceof Function)) { - throw new TypeError('func must be a Function!'); - } - - const args = []; - const asserts = []; - - for (const argType of argTypes) { - const parmNum = args.length; - const parmName = `PR.ofs.OFS_PARM${parmNum}`; - - switch (argType) { - case etype.ev_entity_client: - asserts.push({check: `PR.globals_int[${parmName}] > 0 && PR.globals_int[${parmName}] <= SV.svs.maxclients`, message: 'edict points to a non-client'}); - // eslint-disable-next-line no-fallthrough - case etype.ev_entity: - args.push(`SV.server.edicts[PR.globals_int[${parmName}]]`); - break; - - case etype.ev_vector: - args.push(`new Vector(PR.globals_float[${parmName}], PR.globals_float[${parmName} + 1], PR.globals_float[${parmName} + 2])`); - break; - - case etype.ev_float: - args.push(`PR.globals_float[${parmName}]`); - break; - - case etype.ev_integer: - args.push(`PR.globals_float[${parmName}] >> 0`); - break; - - case etype.ev_bool: - args.push(`!!PR.globals_float[${parmName}]`); - break; - - case etype.ev_string_not_empty: - asserts.push({check: `PR.globals_int[${parmName}]`, message: 'string must not be empty'}); - // eslint-disable-next-line no-fallthrough - case etype.ev_string: - args.push(`PR.GetString(PR.globals_int[${parmName}])`); - break; - - case etype.ev_strings: - args.push(`PF._VarString(${args.length})`); - break; - - case etype.ev_field: - args.push(`Object.entries(PR.entvars).find((entry) => entry[1] === PR.globals_int[${parmName}])[0]`); - break; - - case etype.ev_void: - args.push(null); - break; - - default: - throw new TypeError('unsupported arg type: ' + argType); - } - } - - let returnCode = ''; - - switch (returnType) { - case etype.ev_vector: - asserts.push({check: 'returnValue instanceof Vector', message: 'returnValue must be a Vector'}); - asserts.push({check: '!Number.isNaN(returnValue[0])', message: 'returnValue[0] must not be NaN'}); - asserts.push({check: '!Number.isNaN(returnValue[1])', message: 'returnValue[1] must not be NaN'}); - asserts.push({check: '!Number.isNaN(returnValue[2])', message: 'returnValue[2] must not be NaN'}); - returnCode = ` - PR.globals_float[${ofs.OFS_RETURN + 0}] = returnValue[0]; - PR.globals_float[${ofs.OFS_RETURN + 1}] = returnValue[1]; - PR.globals_float[${ofs.OFS_RETURN + 2}] = returnValue[2]; - `; - break; - - case etype.ev_entity_client: - asserts.push({check: 'returnValue === null || (returnValue.num > 0 && returnValue.num <= SV.svs.maxclients)', message: 'edict points to a non-client'}); - // eslint-disable-next-line no-fallthrough - case etype.ev_entity: - // additional sanity check: the returnValue _must_ be an Edict or null, otherwise chaos will ensue - asserts.push({check: 'returnValue === null || returnValue instanceof ServerEdict', message: 'returnValue must be an Edict or null'}); - returnCode = `PR.globals_int[${ofs.OFS_RETURN}] = returnValue ? returnValue.num : 0;`; - break; - - case etype.ev_integer: - asserts.push({check: '!Number.isNaN(returnValue)', message: 'returnValue must not be NaN'}); - returnCode = `PR.globals_float[${ofs.OFS_RETURN}] = returnValue >> 0;`; - break; - - case etype.ev_float: - asserts.push({check: '!Number.isNaN(returnValue)', message: 'returnValue must not be NaN'}); - returnCode = `PR.globals_float[${ofs.OFS_RETURN}] = returnValue;`; - break; - - case etype.ev_bool: - asserts.push({check: 'typeof(returnValue) === \'boolean\'', message: 'returnValue must be bool'}); - returnCode = `PR.globals_float[${ofs.OFS_RETURN}] = returnValue;`; - break; - - case etype.ev_void: - returnCode = '/* no return value */'; - break; - - case etype.ev_string_not_empty: - asserts.push({check: 'returnValue !== null && returnValue !== \'\'', message: 'string must not be empty'}); - // eslint-disable-next-line no-fallthrough - case etype.ev_string: - returnCode = `PR.globals_int[${ofs.OFS_RETURN}] = PR.TempString(returnValue);`; - break; - - default: - throw new TypeError('unsupported return type: ' + returnType); - } - - // TODO: do not pass on `asserts` when not in debug mode or developer mode - - const code = `return function ${name}() { - const { PR, SV } = registry; - - ${args.map((def, index) => ` const arg${index} = ${def};`).join('\n')} - - const returnValue = _${name}(${args.map((_, index) => `arg${index}`).join(', ')}); - - ${asserts.map(({check, message}) => ` PF._assertTrue(${check}, ${JSON.stringify(message)});`).join('\n')} - - ${returnCode} -}`; - - return (new Function('ED', 'PF', 'ServerEdict', 'Vector', 'registry', '_' + name, code))(ED, PF, ServerEdict, Vector, registry, func); -}; - -PF._VarString = function _VarString(first) { - let i; let out = ''; - for (i = first; i < PR.argc; i++) { - out += PR.GetString(PR.globals_int[ofs.OFS_PARM0 + i * 3]); - } - return out; -}; - -PF.error = _PF_GenerateBuiltinFunction('error', function (str) { - Con.PrintError('======SERVER ERROR in ' + PR.GetString(PR.xfunction.name) + '\n' + str + '\n'); - ED.Print(SV.server.gameAPI.self); - throw new HostError('Program error: ' + str); -}, [etype.ev_strings]); - -PF.objerror = _PF_GenerateBuiltinFunction('objerror', function (str) { - Con.PrintError('======OBJECT ERROR in ' + PR.GetString(PR.xfunction.name) + '\n' + str + '\n'); - ED.Print(SV.server.gameAPI.self); - throw new HostError('Program error: ' + str); -}, [etype.ev_strings]); - -PF.makevectors = _PF_GenerateBuiltinFunction('makevectors', function (vec) { - const {forward, right, up} = vec.angleVectors(); - SV.server.gameAPI.v_forward = forward; - SV.server.gameAPI.v_right = right; - SV.server.gameAPI.v_up = up; -}, [etype.ev_vector]); - -PF.setorigin = _PF_GenerateBuiltinFunction('setorigin', (edict, vec) => edict.setOrigin(vec), [etype.ev_entity, etype.ev_vector], etype.ev_void); - -PF.setsize = _PF_GenerateBuiltinFunction('setsize', (edict, min, max) => edict.setMinMaxSize(min, max), [etype.ev_entity, etype.ev_vector, etype.ev_vector], etype.ev_void); - -PF.setmodel = _PF_GenerateBuiltinFunction('setmodel', function(ed, model) { - ed.setModel(model); -}, [etype.ev_entity, etype.ev_string]); - -PF.bprint = _PF_GenerateBuiltinFunction('bprint', ServerEngineAPI.BroadcastPrint, [etype.ev_strings]); - -PF.sprint = _PF_GenerateBuiltinFunction('sprint', (clientEdict, message) => clientEdict.getClient().consolePrint(message), [etype.ev_entity_client, etype.ev_strings]); - -PF.centerprint = _PF_GenerateBuiltinFunction('centerprint', (clientEdict, message) => clientEdict.getClient().centerPrint(message), [etype.ev_entity_client, etype.ev_strings]); - -PF.normalize = _PF_GenerateBuiltinFunction('normalize', function (vec) { - vec.normalize(); - - return vec; -}, [etype.ev_vector], etype.ev_vector); - -PF.vlen = _PF_GenerateBuiltinFunction('vlen', (vec) => vec.len(), [etype.ev_vector], etype.ev_float); - -PF.vectoyaw = _PF_GenerateBuiltinFunction('vectoyaw', (vec) => vec.toYaw(), [etype.ev_vector], etype.ev_float); - -PF.vectoangles = _PF_GenerateBuiltinFunction('vectoangles', (vec) => vec.toAngles(), [etype.ev_vector], etype.ev_vector); - -PF.random = _PF_GenerateBuiltinFunction('random', Math.random, [], etype.ev_float); - -PF.particle = _PF_GenerateBuiltinFunction('particle', ServerEngineAPI.StartParticles, [etype.ev_vector, etype.ev_vector, etype.ev_integer, etype.ev_integer]); - -PF.ambientsound = _PF_GenerateBuiltinFunction('ambientsound', ServerEngineAPI.SpawnAmbientSound, [ - etype.ev_vector, - etype.ev_string_not_empty, - etype.ev_float, - etype.ev_float, -], etype.ev_bool); - -PF.sound = _PF_GenerateBuiltinFunction('sound', ServerEngineAPI.StartSound, [ - etype.ev_entity, - etype.ev_integer, - etype.ev_string_not_empty, - etype.ev_float, - etype.ev_float, -], etype.ev_bool); - -PF.breakstatement = function breakstatement() { // PR - Con.Print('break statement\n'); -}; - -PF.traceline = _PF_GenerateBuiltinFunction('traceline', function(start, end, noMonsters, passEdict) { - const trace = ServerEngineAPI.TracelineLegacy(start, end, noMonsters, passEdict); - - SV.server.gameAPI.trace_allsolid = (trace.allsolid === true) ? 1.0 : 0.0; - SV.server.gameAPI.trace_startsolid = (trace.startsolid === true) ? 1.0 : 0.0; - SV.server.gameAPI.trace_fraction = trace.fraction; - SV.server.gameAPI.trace_inwater = (trace.inwater === true) ? 1.0 : 0.0; - SV.server.gameAPI.trace_inopen = (trace.inopen === true) ? 1.0 : 0.0; - SV.server.gameAPI.trace_endpos = trace.endpos; - SV.server.gameAPI.trace_plane_normal = trace.plane.normal; - SV.server.gameAPI.trace_plane_dist = trace.plane.dist; - SV.server.gameAPI.trace_ent = trace.ent || null; -}, [ - etype.ev_vector, - etype.ev_vector, - etype.ev_integer, - etype.ev_entity, -]); - -PF.checkclient = _PF_GenerateBuiltinFunction('checkclient', () => SV.server.gameAPI.self.getNextBestClient(), [], etype.ev_entity_client); - -PF.stuffcmd = _PF_GenerateBuiltinFunction('stuffcmd', function(clientEdict, cmd) { - clientEdict.getClient().sendConsoleCommands(cmd); -}, [etype.ev_entity_client, etype.ev_string]); - -PF.localcmd = _PF_GenerateBuiltinFunction('localcmd', function(cmd) { - Cmd.text += cmd; -}, [etype.ev_string]); - -PF.cvar = _PF_GenerateBuiltinFunction('cvar', function(name) { - const cvar = ServerEngineAPI.GetCvar(name); - return cvar ? cvar.value : 0.0; -}, [etype.ev_string], etype.ev_float); - -PF.cvar_set = _PF_GenerateBuiltinFunction('cvar_set', ServerEngineAPI.SetCvar, [etype.ev_string, etype.ev_string]); - -PF.findradius = _PF_GenerateBuiltinFunction('findradius', (origin, radius) => { - const edicts = ServerEngineAPI.FindInRadius(origin, radius); - - // doing the chain dance - let chain = SV.server.edicts[0]; // starts with worldspawn - - // iterate over the list of edicts - for (const edict of edicts) { - edict.entity.chain = chain; - chain = edict; - } - - return chain; -}, [etype.ev_vector, etype.ev_float], etype.ev_entity); - -PF.dprint = _PF_GenerateBuiltinFunction('dprint', (str) => ServerEngineAPI.ConsoleDebug(str), [etype.ev_strings]); - -PF.ftos = _PF_GenerateBuiltinFunction('ftos', (f) => (+f|0) === +f ? f.toString() : f.toFixed(1), [etype.ev_float], etype.ev_string); - -PF.fabs = _PF_GenerateBuiltinFunction('fabs', Math.abs, [etype.ev_float], etype.ev_float); - -PF.vtos = _PF_GenerateBuiltinFunction('vtos', (vec) => vec.toString(), [etype.ev_vector], etype.ev_string); - -PF.Spawn = _PF_GenerateBuiltinFunction('Spawn', () => { - // this is a special null entity we spawn for the QuakeC - // it will be automatically populated with an EdictProxy - // the JS Game is supposed to use ServerEngineAPI.SpawnEntity - const edict = ED.Alloc(); - SV.server.gameAPI.prepareEntity(edict, null, {}); - return edict; -}, [], etype.ev_entity); - -PF.Remove = _PF_GenerateBuiltinFunction('Remove', (edict) => edict.freeEdict(), [etype.ev_entity]); - -PF.Find = _PF_GenerateBuiltinFunction('Find', (edict, field, value) => ServerEngineAPI.FindByFieldAndValue(field, value, edict.num + 1), [etype.ev_entity, etype.ev_field, etype.ev_string], etype.ev_entity); - -PF.MoveToGoal = _PF_GenerateBuiltinFunction('MoveToGoal', (dist) => SV.server.gameAPI.self.moveToGoal(dist), [etype.ev_float], etype.ev_bool); - -PF.precache_file = _PF_GenerateBuiltinFunction('precache_file', (integer) => - integer // dummy behavior -, [etype.ev_integer], etype.ev_integer); - -PF.precache_sound = _PF_GenerateBuiltinFunction('precache_sound', (sfxName) => ServerEngineAPI.PrecacheSound(sfxName), [etype.ev_string_not_empty]); - -PF.precache_model = _PF_GenerateBuiltinFunction('precache_model', (modelName) => - // FIXME: handle this more gracefully - // if (SV.server.loading !== true) { - // PR.RunError('PF.Precache_*: Precache can only be done in spawn functions'); - // } - - ServerEngineAPI.PrecacheModel(modelName) -, [etype.ev_string_not_empty]); - -PF.coredump = function coredump() { - ED.PrintEdicts(); -}; - -PF.traceon = function traceon() { - PR.trace = true; -}; - -PF.traceoff = function traceoff() { - PR.trace = false; -}; - -PF.eprint = function eprint() { - ED.Print(SV.server.edicts[PR.globals_float[4]]); -}; - -PF.walkmove = _PF_GenerateBuiltinFunction('walkmove', function(yaw, dist) { - const oldf = PR.xfunction; // ??? - const res = SV.server.gameAPI.self.walkMove(yaw, dist); - PR.xfunction = oldf; // ??? - - return res; -}, [etype.ev_float, etype.ev_float], etype.ev_bool); - -PF.droptofloor = _PF_GenerateBuiltinFunction('droptofloor', () => SV.server.gameAPI.self.dropToFloor(-256.0), [], etype.ev_bool); - -PF.lightstyle = _PF_GenerateBuiltinFunction('lightstyle', ServerEngineAPI.Lightstyle, [etype.ev_integer, etype.ev_string]); - -PF.rint = _PF_GenerateBuiltinFunction('rint', (f) => (f >= 0.0 ? f + 0.5 : f - 0.5), [etype.ev_float], etype.ev_integer); - -PF.floor = _PF_GenerateBuiltinFunction('floor', Math.floor, [etype.ev_float], etype.ev_float); - -PF.ceil = _PF_GenerateBuiltinFunction('ceil', Math.ceil, [etype.ev_float], etype.ev_float); - -PF.checkbottom = _PF_GenerateBuiltinFunction('checkbottom', (edict) => edict.isOnTheFloor(), [etype.ev_entity], etype.ev_bool); - -PF.pointcontents = _PF_GenerateBuiltinFunction('pointcontents', ServerEngineAPI.DeterminePointContents, [etype.ev_vector], etype.ev_float); - -PF.nextent = _PF_GenerateBuiltinFunction('nextent', (edict) => edict.nextEdict(), [etype.ev_entity], etype.ev_entity); - -PF.aim = _PF_GenerateBuiltinFunction('aim', (edict) => { - // CR: `makevectors(self.v_angle);` is called in `W_Attack` and propagates all the way down here - const dir = SV.server.gameAPI.v_forward; - return edict.aim(dir); -}, [etype.ev_entity], etype.ev_vector); - -PF.changeyaw = _PF_GenerateBuiltinFunction('changeyaw', () => SV.server.gameAPI.self.changeYaw(), []); - -// eslint-disable-next-line jsdoc/require-jsdoc -function WriteGeneric(dest) { - switch (dest) { - case 0: // broadcast - return SV.server.datagram; - case 1: { // one - // CR: I’m not happy with the structure of the code, Write* needs to be on Edict as well - const msg_entity = SV.server.gameAPI.msg_entity; - const entnum = msg_entity.num; - if (!msg_entity.isClient()) { - throw new Error('WriteGeneric: not a client ' + entnum); - } - return msg_entity.getClient().message; - } - case 2: // all - return SV.server.reliable_datagram; - case 3: // init - return SV.server.signon; - } - throw new Error('WriteGeneric: bad destination ' + dest); -}; - -for (const [fn, method] of [['WriteByte', 'writeByte'], ['WriteChar', 'writeChar'], ['WriteShort', 'writeShort'], ['WriteLong', 'writeLong'], ['WriteAngle', 'writeAngle'], ['WriteCoord', 'writeCoord']]) { - PF[fn] = _PF_GenerateBuiltinFunction(fn, (dest, val) => WriteGeneric(dest)[method](val), [etype.ev_integer, etype.ev_float]); -} - -PF.WriteString = _PF_GenerateBuiltinFunction('WriteString', (dest, val) => WriteGeneric(dest).writeString(val), [etype.ev_integer, etype.ev_string]); - -PF.WriteEntity = _PF_GenerateBuiltinFunction('WriteEntity', (dest, val) => WriteGeneric(dest).writeShort(val.num), [etype.ev_integer, etype.ev_entity]); - -PF.makestatic = _PF_GenerateBuiltinFunction('makestatic', (edict) => edict.makeStatic(), [etype.ev_entity]); - -PF.setspawnparms = _PF_GenerateBuiltinFunction('setspawnparms', function (clientEdict) { - const spawn_parms = clientEdict.getClient().spawn_parms; - - for (let i = 0; i <= 15; i++) { - SV.server.gameAPI[`parm${i + 1}`] = spawn_parms[i]; - } -}, [etype.ev_entity_client]); - -PF.changelevel = _PF_GenerateBuiltinFunction('changelevel', ServerEngineAPI.ChangeLevel, [etype.ev_string]); - -PF.Fixme = function Fixme() { - throw new Error('unimplemented builtin'); -}; - -PF.builtin = [ - PF.Fixme, - PF.makevectors, - PF.setorigin, - PF.setmodel, - PF.setsize, - PF.Fixme, - PF.breakstatement, - PF.random, - PF.sound, - PF.normalize, - PF.error, - PF.objerror, - PF.vlen, - PF.vectoyaw, - PF.Spawn, - PF.Remove, - PF.traceline, - PF.checkclient, - PF.Find, - PF.precache_sound, - PF.precache_model, - PF.stuffcmd, - PF.findradius, - PF.bprint, - PF.sprint, - PF.dprint, - PF.ftos, - PF.vtos, - PF.coredump, - PF.traceon, - PF.traceoff, - PF.eprint, - PF.walkmove, - PF.Fixme, - PF.droptofloor, - PF.lightstyle, - PF.rint, - PF.floor, - PF.ceil, - PF.Fixme, - PF.checkbottom, - PF.pointcontents, - PF.Fixme, - PF.fabs, - PF.aim, - PF.cvar, - PF.localcmd, - PF.nextent, - PF.particle, - PF.changeyaw, - PF.Fixme, - PF.vectoangles, - PF.WriteByte, - PF.WriteChar, - PF.WriteShort, - PF.WriteLong, - PF.WriteCoord, - PF.WriteAngle, - PF.WriteString, - PF.WriteEntity, - PF.Fixme, - PF.Fixme, - PF.Fixme, - PF.Fixme, - PF.Fixme, - PF.Fixme, - PF.Fixme, - PF.MoveToGoal, - PF.precache_file, - PF.makestatic, - PF.changelevel, - PF.Fixme, - PF.cvar_set, - PF.centerprint, - PF.ambientsound, - PF.precache_model, - PF.precache_sound, - PF.precache_file, - PF.setspawnparms, - - PF.Fixme, // PF.logfrag, - PF.Fixme, // PF.infokey, - PF.Fixme, // PF.stof, - PF.Fixme, // PF.multicast, -]; +export { default, etype, ofs } from './ProgsAPI.ts'; diff --git a/source/engine/server/ProgsAPI.ts b/source/engine/server/ProgsAPI.ts new file mode 100644 index 00000000..79457a0e --- /dev/null +++ b/source/engine/server/ProgsAPI.ts @@ -0,0 +1,645 @@ +import type { SzBuffer } from '../network/MSG.ts'; + +import Vector from '../../shared/Vector.ts'; +import Cmd from '../common/Cmd.ts'; +import { HostError } from '../common/Errors.ts'; +import { ServerEngineAPI } from '../common/GameAPIs.ts'; +import { eventBus, getCommonRegistry, registry } from '../registry.mjs'; +import { ED, ServerEdict } from './Edict.mjs'; + +type BuiltinValue = string | number | boolean | Vector | ServerEdict | null; +type BuiltinImplementation = (...args: BuiltinValue[]) => BuiltinValue | void; +type BuiltinFunction = () => void; + +interface GeneratedAssert { + readonly check: string; + readonly message: string; +} + +interface ProgsAPI { + _assertTrue(check: boolean, message: string): void; + _VarString(first: number): string; + builtin: BuiltinFunction[]; +} + +const PF: ProgsAPI = { + _assertTrue(check: boolean, message: string): void { + if (!check) { + throw new Error(`Program assert failed: ${message}`); + } + }, + + _VarString(first: number): string { + let out = ''; + + for (let i = first; i < PR.argc; i++) { + out += PR.GetString(PR.globals_int[ofs.OFS_PARM0 + i * 3]); + } + + return out; + }, + + builtin: [], +}; + +export default PF; + +let { Con, PR, SV } = getCommonRegistry(); + +eventBus.subscribe('registry.frozen', () => { + ({ Con, PR, SV } = getCommonRegistry()); +}); + +/** + * QuakeC field definition types from `PR.fielddefs[].type`. + */ +export enum etype { + ev_void = 0, + ev_string = 1, + ev_float = 2, + ev_vector = 3, + ev_entity = 4, + ev_field = 5, + ev_function = 6, + ev_pointer = 7, + + ev_strings = 101, + ev_integer = 102, + ev_string_not_empty = 103, + ev_entity_client = 104, + ev_bool = 200, +} + +/** + * QuakeC global parameter offsets. + */ +export enum ofs { + OFS_NULL = 0, + OFS_RETURN = 1, + OFS_PARM0 = 4, + OFS_PARM1 = 7, + OFS_PARM2 = 10, + OFS_PARM3 = 13, + OFS_PARM4 = 16, + OFS_PARM5 = 19, + OFS_PARM6 = 22, + OFS_PARM7 = 25, +} + +/** + * Generates a function that can be exposed to the QuakeC VM as a builtin. + * @returns The VM-facing builtin wrapper. + */ +function generateBuiltinFunction(name: string, func: BuiltinImplementation, argTypes: readonly etype[] = [], returnType: etype = etype.ev_void): BuiltinFunction { + if (!(func instanceof Function)) { + throw new TypeError('func must be a Function!'); + } + + const args: Array = []; + const asserts: GeneratedAssert[] = []; + + for (const argType of argTypes) { + const parmNum = args.length; + const parmName = `PR.ofs.OFS_PARM${parmNum}`; + + switch (argType) { + case etype.ev_entity_client: + asserts.push({ check: `PR.globals_int[${parmName}] > 0 && PR.globals_int[${parmName}] <= SV.svs.maxclients`, message: 'edict points to a non-client' }); + // eslint-disable-next-line no-fallthrough + case etype.ev_entity: + args.push(`SV.server.edicts[PR.globals_int[${parmName}]]`); + break; + + case etype.ev_vector: + args.push(`new Vector(PR.globals_float[${parmName}], PR.globals_float[${parmName} + 1], PR.globals_float[${parmName} + 2])`); + break; + + case etype.ev_float: + args.push(`PR.globals_float[${parmName}]`); + break; + + case etype.ev_integer: + args.push(`PR.globals_float[${parmName}] >> 0`); + break; + + case etype.ev_bool: + args.push(`!!PR.globals_float[${parmName}]`); + break; + + case etype.ev_string_not_empty: + asserts.push({ check: `PR.globals_int[${parmName}]`, message: 'string must not be empty' }); + // eslint-disable-next-line no-fallthrough + case etype.ev_string: + args.push(`PR.GetString(PR.globals_int[${parmName}])`); + break; + + case etype.ev_strings: + args.push(`PF._VarString(${args.length})`); + break; + + case etype.ev_field: + args.push(`Object.entries(PR.entvars).find((entry) => entry[1] === PR.globals_int[${parmName}])[0]`); + break; + + case etype.ev_void: + args.push(null); + break; + + default: + throw new TypeError(`unsupported arg type: ${argType}`); + } + } + + let returnCode = ''; + + switch (returnType) { + case etype.ev_vector: + asserts.push({ check: 'returnValue instanceof Vector', message: 'returnValue must be a Vector' }); + asserts.push({ check: '!Number.isNaN(returnValue[0])', message: 'returnValue[0] must not be NaN' }); + asserts.push({ check: '!Number.isNaN(returnValue[1])', message: 'returnValue[1] must not be NaN' }); + asserts.push({ check: '!Number.isNaN(returnValue[2])', message: 'returnValue[2] must not be NaN' }); + returnCode = ` + PR.globals_float[${ofs.OFS_RETURN + 0}] = returnValue[0]; + PR.globals_float[${ofs.OFS_RETURN + 1}] = returnValue[1]; + PR.globals_float[${ofs.OFS_RETURN + 2}] = returnValue[2]; + `; + break; + + case etype.ev_entity_client: + asserts.push({ check: 'returnValue === null || (returnValue.num > 0 && returnValue.num <= SV.svs.maxclients)', message: 'edict points to a non-client' }); + // eslint-disable-next-line no-fallthrough + case etype.ev_entity: + asserts.push({ check: 'returnValue === null || returnValue instanceof ServerEdict', message: 'returnValue must be an Edict or null' }); + returnCode = `PR.globals_int[${ofs.OFS_RETURN}] = returnValue ? returnValue.num : 0;`; + break; + + case etype.ev_integer: + asserts.push({ check: '!Number.isNaN(returnValue)', message: 'returnValue must not be NaN' }); + returnCode = `PR.globals_float[${ofs.OFS_RETURN}] = returnValue >> 0;`; + break; + + case etype.ev_float: + asserts.push({ check: '!Number.isNaN(returnValue)', message: 'returnValue must not be NaN' }); + returnCode = `PR.globals_float[${ofs.OFS_RETURN}] = returnValue;`; + break; + + case etype.ev_bool: + asserts.push({ check: 'typeof(returnValue) === \'boolean\'', message: 'returnValue must be bool' }); + returnCode = `PR.globals_float[${ofs.OFS_RETURN}] = returnValue;`; + break; + + case etype.ev_void: + returnCode = '/* no return value */'; + break; + + case etype.ev_string_not_empty: + asserts.push({ check: 'returnValue !== null && returnValue !== \'\'', message: 'string must not be empty' }); + // eslint-disable-next-line no-fallthrough + case etype.ev_string: + returnCode = `PR.globals_int[${ofs.OFS_RETURN}] = PR.TempString(returnValue);`; + break; + + default: + throw new TypeError(`unsupported return type: ${returnType}`); + } + + const code = `return function ${name}() { + const { PR, SV } = registry; + + ${args.map((definition, index) => ` const arg${index} = ${definition};`).join('\n')} + + const returnValue = _${name}(${args.map((_, index) => `arg${index}`).join(', ')}); + + ${asserts.map(({ check, message }) => ` PF._assertTrue(${check}, ${JSON.stringify(message)});`).join('\n')} + + ${returnCode} +}`; + + return new Function('ED', 'PF', 'ServerEdict', 'Vector', 'registry', `_${name}`, code)(ED, PF, ServerEdict, Vector, registry, func) as BuiltinFunction; +} + +const error = generateBuiltinFunction('error', (str: string): never => { + Con.PrintError(`======SERVER ERROR in ${PR.GetString(PR.xfunction.name)}\n${str}\n`); + ED.Print(SV.server.gameAPI.self); + throw new HostError(`Program error: ${str}`); +}, [etype.ev_strings]); + +const objerror = generateBuiltinFunction('objerror', (str: string): never => { + Con.PrintError(`======OBJECT ERROR in ${PR.GetString(PR.xfunction.name)}\n${str}\n`); + ED.Print(SV.server.gameAPI.self); + throw new HostError(`Program error: ${str}`); +}, [etype.ev_strings]); + +const makevectors = generateBuiltinFunction('makevectors', (vec: Vector): void => { + const { forward, right, up } = vec.angleVectors(); + SV.server.gameAPI.v_forward = forward; + SV.server.gameAPI.v_right = right; + SV.server.gameAPI.v_up = up; +}, [etype.ev_vector]); + +const setorigin = generateBuiltinFunction('setorigin', (edict: ServerEdict, vec: Vector): void => edict.setOrigin(vec), [etype.ev_entity, etype.ev_vector], etype.ev_void); +const setsize = generateBuiltinFunction('setsize', (edict: ServerEdict, min: Vector, max: Vector): void => edict.setMinMaxSize(min, max), [etype.ev_entity, etype.ev_vector, etype.ev_vector], etype.ev_void); + +const setmodel = generateBuiltinFunction('setmodel', (edict: ServerEdict, model: string): void => { + edict.setModel(model); +}, [etype.ev_entity, etype.ev_string]); + +const bprint = generateBuiltinFunction('bprint', (message: string): void => { + ServerEngineAPI.BroadcastPrint(message); +}, [etype.ev_strings]); + +const sprint = generateBuiltinFunction('sprint', (clientEdict: ServerEdict, message: string): void => { + clientEdict.getClient().consolePrint(message); +}, [etype.ev_entity_client, etype.ev_strings]); + +const centerprint = generateBuiltinFunction('centerprint', (clientEdict: ServerEdict, message: string): void => { + clientEdict.getClient().centerPrint(message); +}, [etype.ev_entity_client, etype.ev_strings]); + +const normalize = generateBuiltinFunction('normalize', (vec: Vector): Vector => { + vec.normalize(); + return vec; +}, [etype.ev_vector], etype.ev_vector); + +const vlen = generateBuiltinFunction('vlen', (vec: Vector): number => vec.len(), [etype.ev_vector], etype.ev_float); +const vectoyaw = generateBuiltinFunction('vectoyaw', (vec: Vector): number => vec.toYaw(), [etype.ev_vector], etype.ev_float); +const vectoangles = generateBuiltinFunction('vectoangles', (vec: Vector): Vector => vec.toAngles(), [etype.ev_vector], etype.ev_vector); +const random = generateBuiltinFunction('random', (): number => Math.random(), [], etype.ev_float); + +const particle = generateBuiltinFunction('particle', (origin: Vector, direction: Vector, color: number, count: number): void => { + ServerEngineAPI.StartParticles(origin, direction, color, count); +}, [etype.ev_vector, etype.ev_vector, etype.ev_integer, etype.ev_integer]); + +const ambientsound = generateBuiltinFunction('ambientsound', (origin: Vector, sample: string, volume: number, attenuation: number): boolean => ServerEngineAPI.SpawnAmbientSound(origin, sample, volume, attenuation), [ + etype.ev_vector, + etype.ev_string_not_empty, + etype.ev_float, + etype.ev_float, +], etype.ev_bool); + +const sound = generateBuiltinFunction('sound', (edict: ServerEdict, channel: number, sample: string, volume: number, attenuation: number): boolean => ServerEngineAPI.StartSound(edict, channel, sample, volume, attenuation), [ + etype.ev_entity, + etype.ev_integer, + etype.ev_string_not_empty, + etype.ev_float, + etype.ev_float, +], etype.ev_bool); + +const breakstatement: BuiltinFunction = function breakstatement() { + Con.Print('break statement\n'); +}; + +const traceline = generateBuiltinFunction('traceline', (start: Vector, end: Vector, noMonsters: number, passEdict: ServerEdict | null): void => { + const trace = ServerEngineAPI.TracelineLegacy(start, end, noMonsters, passEdict); + + SV.server.gameAPI.trace_allsolid = trace.allsolid === true ? 1.0 : 0.0; + SV.server.gameAPI.trace_startsolid = trace.startsolid === true ? 1.0 : 0.0; + SV.server.gameAPI.trace_fraction = trace.fraction; + SV.server.gameAPI.trace_inwater = trace.inwater === true ? 1.0 : 0.0; + SV.server.gameAPI.trace_inopen = trace.inopen === true ? 1.0 : 0.0; + SV.server.gameAPI.trace_endpos = trace.endpos; + SV.server.gameAPI.trace_plane_normal = trace.plane.normal; + SV.server.gameAPI.trace_plane_dist = trace.plane.dist; + SV.server.gameAPI.trace_ent = trace.ent || null; +}, [etype.ev_vector, etype.ev_vector, etype.ev_integer, etype.ev_entity]); + +const checkclient = generateBuiltinFunction('checkclient', (): ServerEdict | null => SV.server.gameAPI.self.getNextBestClient(), [], etype.ev_entity_client); + +const stuffcmd = generateBuiltinFunction('stuffcmd', (clientEdict: ServerEdict, command: string): void => { + clientEdict.getClient().sendConsoleCommands(command); +}, [etype.ev_entity_client, etype.ev_string]); + +const localcmd = generateBuiltinFunction('localcmd', (command: string): void => { + Cmd.text += command; +}, [etype.ev_string]); + +const cvar = generateBuiltinFunction('cvar', (name: string): number => { + const cvarValue = ServerEngineAPI.GetCvar(name); + return cvarValue ? cvarValue.value : 0.0; +}, [etype.ev_string], etype.ev_float); + +const cvar_set = generateBuiltinFunction('cvar_set', (name: string, value: string): void => { + ServerEngineAPI.SetCvar(name, value); +}, [etype.ev_string, etype.ev_string]); + +const findradius = generateBuiltinFunction('findradius', (origin: Vector, radius: number): ServerEdict => { + const edicts = ServerEngineAPI.FindInRadius(origin, radius); + let chain = SV.server.edicts[0]; + + for (const edict of edicts) { + edict.entity.chain = chain; + chain = edict; + } + + return chain; +}, [etype.ev_vector, etype.ev_float], etype.ev_entity); + +const dprint = generateBuiltinFunction('dprint', (message: string): void => { + ServerEngineAPI.ConsoleDebug(message); +}, [etype.ev_strings]); + +const ftos = generateBuiltinFunction('ftos', (value: number): string => ((+value | 0) === +value ? value.toString() : value.toFixed(1)), [etype.ev_float], etype.ev_string); +const fabs = generateBuiltinFunction('fabs', (value: number): number => Math.abs(value), [etype.ev_float], etype.ev_float); +const vtos = generateBuiltinFunction('vtos', (vec: Vector): string => vec.toString(), [etype.ev_vector], etype.ev_string); + +const Spawn = generateBuiltinFunction('Spawn', (): ServerEdict => { + const edict = ED.Alloc(); + SV.server.gameAPI.prepareEntity(edict, null, {}); + return edict; +}, [], etype.ev_entity); + +const Remove = generateBuiltinFunction('Remove', (edict: ServerEdict): void => { + edict.freeEdict(); +}, [etype.ev_entity]); + +const Find = generateBuiltinFunction('Find', (edict: ServerEdict, field: string, value: string): ServerEdict | null => ServerEngineAPI.FindByFieldAndValue(field, value, edict.num + 1), [etype.ev_entity, etype.ev_field, etype.ev_string], etype.ev_entity); +const MoveToGoal = generateBuiltinFunction('MoveToGoal', (dist: number): boolean => SV.server.gameAPI.self.moveToGoal(dist), [etype.ev_float], etype.ev_bool); +const precache_file = generateBuiltinFunction('precache_file', (value: number): number => value, [etype.ev_integer], etype.ev_integer); + +const precache_sound = generateBuiltinFunction('precache_sound', (sfxName: string): void => { + ServerEngineAPI.PrecacheSound(sfxName); +}, [etype.ev_string_not_empty]); + +const precache_model = generateBuiltinFunction('precache_model', (modelName: string): void => { + ServerEngineAPI.PrecacheModel(modelName); +}, [etype.ev_string_not_empty]); + +const coredump: BuiltinFunction = function coredump() { + ED.PrintEdicts(); +}; + +const traceon: BuiltinFunction = function traceon() { + PR.trace = true; +}; + +const traceoff: BuiltinFunction = function traceoff() { + PR.trace = false; +}; + +const eprint: BuiltinFunction = function eprint() { + ED.Print(SV.server.edicts[PR.globals_float[4]]); +}; + +const walkmove = generateBuiltinFunction('walkmove', (yaw: number, dist: number): boolean => { + const oldFunction = PR.xfunction; + const result = SV.server.gameAPI.self.walkMove(yaw, dist); + PR.xfunction = oldFunction; + return result; +}, [etype.ev_float, etype.ev_float], etype.ev_bool); + +const droptofloor = generateBuiltinFunction('droptofloor', (): boolean => SV.server.gameAPI.self.dropToFloor(-256.0), [], etype.ev_bool); + +const lightstyle = generateBuiltinFunction('lightstyle', (style: number, value: string): void => { + ServerEngineAPI.Lightstyle(style, value); +}, [etype.ev_integer, etype.ev_string]); + +const rint = generateBuiltinFunction('rint', (value: number): number => (value >= 0.0 ? value + 0.5 : value - 0.5), [etype.ev_float], etype.ev_integer); +const floor = generateBuiltinFunction('floor', (value: number): number => Math.floor(value), [etype.ev_float], etype.ev_float); +const ceil = generateBuiltinFunction('ceil', (value: number): number => Math.ceil(value), [etype.ev_float], etype.ev_float); +const checkbottom = generateBuiltinFunction('checkbottom', (edict: ServerEdict): boolean => edict.isOnTheFloor(), [etype.ev_entity], etype.ev_bool); +const pointcontents = generateBuiltinFunction('pointcontents', (point: Vector): number => ServerEngineAPI.DeterminePointContents(point), [etype.ev_vector], etype.ev_float); +const nextent = generateBuiltinFunction('nextent', (edict: ServerEdict): ServerEdict | null => edict.nextEdict(), [etype.ev_entity], etype.ev_entity); + +const aim = generateBuiltinFunction('aim', (edict: ServerEdict): Vector => { + const direction = SV.server.gameAPI.v_forward; + return edict.aim(direction); +}, [etype.ev_entity], etype.ev_vector); + +const changeyaw = generateBuiltinFunction('changeyaw', (): void => { + SV.server.gameAPI.self.changeYaw(); +}, []); + +/** + * Resolves a Quake message destination to a concrete buffer. + * @returns The destination message buffer. + */ +function WriteGeneric(dest: number): SzBuffer { + switch (dest) { + case 0: + return SV.server.datagram; + + case 1: { + const messageEntity = SV.server.gameAPI.msg_entity; + const entityNumber = messageEntity.num; + + if (!messageEntity.isClient()) { + throw new Error(`WriteGeneric: not a client ${entityNumber}`); + } + + return messageEntity.getClient().message; + } + + case 2: + return SV.server.reliable_datagram; + + case 3: + return SV.server.signon; + + default: + throw new Error(`WriteGeneric: bad destination ${dest}`); + } +} + +const WriteByte = generateBuiltinFunction('WriteByte', (dest: number, value: number): void => { + WriteGeneric(dest).writeByte(value); +}, [etype.ev_integer, etype.ev_float]); + +const WriteChar = generateBuiltinFunction('WriteChar', (dest: number, value: number): void => { + WriteGeneric(dest).writeChar(value); +}, [etype.ev_integer, etype.ev_float]); + +const WriteShort = generateBuiltinFunction('WriteShort', (dest: number, value: number): void => { + WriteGeneric(dest).writeShort(value); +}, [etype.ev_integer, etype.ev_float]); + +const WriteLong = generateBuiltinFunction('WriteLong', (dest: number, value: number): void => { + WriteGeneric(dest).writeLong(value); +}, [etype.ev_integer, etype.ev_float]); + +const WriteAngle = generateBuiltinFunction('WriteAngle', (dest: number, value: number): void => { + WriteGeneric(dest).writeAngle(value); +}, [etype.ev_integer, etype.ev_float]); + +const WriteCoord = generateBuiltinFunction('WriteCoord', (dest: number, value: number): void => { + WriteGeneric(dest).writeCoord(value); +}, [etype.ev_integer, etype.ev_float]); + +const WriteString = generateBuiltinFunction('WriteString', (dest: number, value: string): void => { + WriteGeneric(dest).writeString(value); +}, [etype.ev_integer, etype.ev_string]); + +const WriteEntity = generateBuiltinFunction('WriteEntity', (dest: number, value: ServerEdict): void => { + WriteGeneric(dest).writeShort(value.num); +}, [etype.ev_integer, etype.ev_entity]); + +const makestatic = generateBuiltinFunction('makestatic', (edict: ServerEdict): void => { + edict.makeStatic(); +}, [etype.ev_entity]); + +const setspawnparms = generateBuiltinFunction('setspawnparms', (clientEdict: ServerEdict): void => { + const spawnParameters = clientEdict.getClient().spawn_parms; + + for (let i = 0; i <= 15; i++) { + SV.server.gameAPI[`parm${i + 1}`] = spawnParameters[i]; + } +}, [etype.ev_entity_client]); + +const changelevel = generateBuiltinFunction('changelevel', (levelName: string): void => { + ServerEngineAPI.ChangeLevel(levelName); +}, [etype.ev_string]); + +const Fixme: BuiltinFunction = function Fixme() { + throw new Error('unimplemented builtin'); +}; + +Object.assign(PF, { + error, + objerror, + makevectors, + setorigin, + setsize, + setmodel, + bprint, + sprint, + centerprint, + normalize, + vlen, + vectoyaw, + vectoangles, + random, + particle, + ambientsound, + sound, + breakstatement, + traceline, + checkclient, + stuffcmd, + localcmd, + cvar, + cvar_set, + findradius, + dprint, + ftos, + fabs, + vtos, + Spawn, + Remove, + Find, + MoveToGoal, + precache_file, + precache_sound, + precache_model, + coredump, + traceon, + traceoff, + eprint, + walkmove, + droptofloor, + lightstyle, + rint, + floor, + ceil, + checkbottom, + pointcontents, + nextent, + aim, + changeyaw, + WriteByte, + WriteChar, + WriteShort, + WriteLong, + WriteAngle, + WriteCoord, + WriteString, + WriteEntity, + makestatic, + setspawnparms, + changelevel, + Fixme, +}); + +PF.builtin = [ + Fixme, + makevectors, + setorigin, + setmodel, + setsize, + Fixme, + breakstatement, + random, + sound, + normalize, + error, + objerror, + vlen, + vectoyaw, + Spawn, + Remove, + traceline, + checkclient, + Find, + precache_sound, + precache_model, + stuffcmd, + findradius, + bprint, + sprint, + dprint, + ftos, + vtos, + coredump, + traceon, + traceoff, + eprint, + walkmove, + Fixme, + droptofloor, + lightstyle, + rint, + floor, + ceil, + Fixme, + checkbottom, + pointcontents, + Fixme, + fabs, + aim, + cvar, + localcmd, + nextent, + particle, + changeyaw, + Fixme, + vectoangles, + WriteByte, + WriteChar, + WriteShort, + WriteLong, + WriteCoord, + WriteAngle, + WriteString, + WriteEntity, + Fixme, + Fixme, + Fixme, + Fixme, + Fixme, + Fixme, + Fixme, + MoveToGoal, + precache_file, + makestatic, + changelevel, + Fixme, + cvar_set, + centerprint, + ambientsound, + precache_model, + precache_sound, + precache_file, + setspawnparms, + Fixme, + Fixme, + Fixme, + Fixme, +]; diff --git a/source/engine/server/ServerMessages.ts b/source/engine/server/ServerMessages.ts index 344437e6..501ddc86 100644 --- a/source/engine/server/ServerMessages.ts +++ b/source/engine/server/ServerMessages.ts @@ -162,7 +162,6 @@ export class ServerMessages { /** * Sends the serverdata message to a specific client. * Needs to be done in order to complete the signon process step 1. - * @param {ServerClient} client client */ sendServerData(client: ServerClient): void { const message = client.message; @@ -230,7 +229,7 @@ export class ServerMessages { message.writeByte(Protocol.svc.setview); message.writeShort(client.edict.num); - const serverCvars = Array.from(Cvar.Filter((/** @type {Cvar} */ cvar) => (cvar.flags & Cvar.FLAG.SERVER) !== 0)); + const serverCvars = Array.from(Cvar.Filter((cvar: Cvar) => (cvar.flags & Cvar.FLAG.SERVER) !== 0)); if (serverCvars.length > 0) { client.message.writeByte(Protocol.svc.cvar); client.message.writeByte(serverCvars.length); @@ -397,10 +396,7 @@ export class ServerMessages { /** * Writes delta between two entity states to the message. - * @param {SzBuffer} msg The message to write to - * @param {ServerEntityState} from The previous entity state - * @param {ServerEntityState} to The new entity state - * @returns {boolean} true if any data was written, false otherwise + * @returns True when any entity state data was written. */ writeDeltaEntity(msg: SzBuffer, from: ServerEntityState, to: ServerEntityState): boolean { const EPSILON = 0.01; @@ -795,8 +791,7 @@ export class ServerMessages { /** * Sends a datagram to a specific client. - * @param {import('./Client.mjs').ServerClient} client client to send to - * @returns {boolean} success + * @returns True when the datagram contained any replicated changes. */ sendClientDatagram(client: ServerClient): boolean { const msg = new SzBuffer(16000, 'SV.SendClientDatagram'); diff --git a/source/engine/server/Sys.ts b/source/engine/server/Sys.ts index bdca6b51..d4d19b05 100644 --- a/source/engine/server/Sys.ts +++ b/source/engine/server/Sys.ts @@ -165,7 +165,7 @@ export default class Sys extends BaseSys { /** * Returns the time elapsed since initialization. - * @returns The elapsed time in seconds. + * @returns The elapsed time in seconds. */ static override FloatTime(): number { return Date.now() * 0.001 - Sys.#oldtime; @@ -173,7 +173,7 @@ export default class Sys extends BaseSys { /** * Returns the time elapsed since initialization in milliseconds. - * @returns The elapsed time in milliseconds. + * @returns The elapsed time in milliseconds. */ static override FloatMilliTime(): number { return performance.now(); diff --git a/test/common/server-module-shims.test.mjs b/test/common/server-module-shims.test.mjs index bee2f8a9..11a04b47 100644 --- a/test/common/server-module-shims.test.mjs +++ b/test/common/server-module-shims.test.mjs @@ -5,6 +5,8 @@ import BaseCom from '../../source/engine/common/Com.ts'; import BaseSys from '../../source/engine/common/Sys.ts'; import ComMjs from '../../source/engine/server/Com.mjs'; import ComTs from '../../source/engine/server/Com.ts'; +import ProgsAPIMjs, { etype as etypeMjs, ofs as ofsMjs } from '../../source/engine/server/ProgsAPI.mjs'; +import ProgsAPITs, { etype as etypeTs, ofs as ofsTs } from '../../source/engine/server/ProgsAPI.ts'; import SysMjs from '../../source/engine/server/Sys.mjs'; import SysTs from '../../source/engine/server/Sys.ts'; @@ -16,6 +18,12 @@ void test('server Sys shim re-exports the TypeScript implementation', () => { assert.strictEqual(SysMjs, SysTs); }); +void test('server ProgsAPI shim re-exports the TypeScript implementation', () => { + assert.strictEqual(ProgsAPIMjs, ProgsAPITs); + assert.strictEqual(etypeMjs, etypeTs); + assert.strictEqual(ofsMjs, ofsTs); +}); + void test('server Com inherits the common COM base class', () => { assert.strictEqual(Object.getPrototypeOf(ComTs), BaseCom); }); diff --git a/test/physics/navigation.test.mjs b/test/physics/navigation.test.mjs index 3fa18fad..140c26f4 100644 --- a/test/physics/navigation.test.mjs +++ b/test/physics/navigation.test.mjs @@ -277,8 +277,8 @@ function readNavSurfaceCounts(data) { return { nodeCount, surfaceCounts }; } -describe('Navigation.build', () => { - test('uses monster-sized static-world traces and stores stand origins', async () => { +void describe('Navigation.build', () => { + void test('uses monster-sized static-world traces and stores stand origins', async () => { const worldmodel = createNavigationWorldModel(); let boxTraceCount = 0; let lineTraceCount = 0; @@ -345,7 +345,7 @@ describe('Navigation.build', () => { } }); - test('clears previously extracted surfaces before rebuilding', async () => { + void test('clears previously extracted surfaces before rebuilding', async () => { const worldmodel = createNavigationWorldModel(); const collision = { traceStaticWorld(start, mins, maxs, end) { @@ -404,7 +404,7 @@ describe('Navigation.build', () => { }); }); - test('does not collapse a large walkable region into a single node', async () => { + void test('does not collapse a large walkable region into a single node', async () => { const worldmodel = createNavigationWorldModel(); const collision = { traceStaticWorld(start, mins, maxs, end) { @@ -459,7 +459,7 @@ describe('Navigation.build', () => { assert.ok(navigation.graph.nodes.some((node) => node.neighbors.length > 0)); }); - test('rejects walkable samples that lack monster-style corner support', async () => { + void test('rejects walkable samples that lack monster-style corner support', async () => { const worldmodel = createNavigationWorldModel(); const collision = { traceStaticWorld(start, mins, maxs, end) { @@ -527,7 +527,7 @@ describe('Navigation.build', () => { assert.equal(navigation.geometry.walkableSurfaces.length, 0); }); - test('does not persist extracted waypoints into nav files', async () => { + void test('does not persist extracted waypoints into nav files', async () => { const worldmodel = createNavigationWorldModel(); let writtenData = null; @@ -593,7 +593,7 @@ describe('Navigation.build', () => { assert.ok(surfaceCounts.every((count) => count === 0)); }); - test('publishes nav.load after saving a rebuilt graph in listen-server mode', async () => { + void test('publishes nav.load after saving a rebuilt graph in listen-server mode', async () => { const worldmodel = createNavigationWorldModel(); const publishedLoads = []; @@ -659,8 +659,8 @@ describe('Navigation.build', () => { }); }); -describe('Navigation.findPath', () => { - test('returns direct path when start and goal share the nearest node', async () => { +void describe('Navigation.findPath', () => { + void test('returns direct path when start and goal share the nearest node', async () => { const worldmodel = createNavigationWorldModel(); const collision = { traceStaticWorld(start, mins, maxs, end) { @@ -706,7 +706,7 @@ describe('Navigation.findPath', () => { assert.deepEqual([...path[1]], [...goal]); }); - test('finds a multi-hop path across connected nodes', async () => { + void test('finds a multi-hop path across connected nodes', async () => { const worldmodel = createNavigationWorldModel(); const collision = { traceStaticWorld(start, mins, maxs, end) { @@ -760,7 +760,7 @@ describe('Navigation.findPath', () => { } }); - test('returns null when graph is empty', () => { + void test('returns null when graph is empty', () => { const navigation = new Navigation(null); Navigation.nav_debug_path = { value: 0 }; @@ -769,7 +769,7 @@ describe('Navigation.findPath', () => { assert.equal(path, null); }); - test('returns null when start or goal has no nearby node', async () => { + void test('returns null when start or goal has no nearby node', async () => { const worldmodel = createNavigationWorldModel(); const collision = { traceStaticWorld(start, mins, maxs, end) { From e214d3197a834304764860abb0830257b385b1cf Mon Sep 17 00:00:00 2001 From: Christian R Date: Sat, 4 Apr 2026 00:47:53 +0300 Subject: [PATCH 39/67] TS: entrypoints --- dedicated.mjs => dedicated.ts | 3 +- eslint.config.mjs | 2 - index.html | 2 +- package.json | 4 +- .../{main-browser.mjs => main-browser.ts} | 38 +++++++++---------- .../{main-dedicated.mjs => main-dedicated.ts} | 34 ++++++++--------- source/engine/registry.mjs | 8 ++-- test/common/runtime-entrypoints.test.mjs | 15 ++++++++ vite.config.dedicated.mjs | 4 +- 9 files changed, 60 insertions(+), 50 deletions(-) rename dedicated.mjs => dedicated.ts (78%) mode change 100755 => 100644 rename source/engine/{main-browser.mjs => main-browser.ts} (77%) rename source/engine/{main-dedicated.mjs => main-dedicated.ts} (70%) create mode 100644 test/common/runtime-entrypoints.test.mjs diff --git a/dedicated.mjs b/dedicated.ts old mode 100755 new mode 100644 similarity index 78% rename from dedicated.mjs rename to dedicated.ts index 791729ea..68cc7370 --- a/dedicated.mjs +++ b/dedicated.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node --experimental-transform-types import process from 'node:process'; -import EngineLauncher from './source/engine/main-dedicated.mjs'; + +import EngineLauncher from './source/engine/main-dedicated.ts'; // make sure working directory is the directory of this script process.chdir(new URL('./', import.meta.url).pathname); diff --git a/eslint.config.mjs b/eslint.config.mjs index 25c3e939..b01d7901 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -133,7 +133,6 @@ export default defineConfig([ }, { files: [ - 'dedicated.mjs', 'dedicated.ts', 'eslint.config.mjs', 'eslint.config.ts', @@ -141,7 +140,6 @@ export default defineConfig([ 'vite.config.ts', 'vite.config.dedicated.mjs', 'vite.config.dedicated.ts', - 'source/engine/main-dedicated.mjs', 'source/engine/main-dedicated.ts', 'source/engine/server/**/*.{mjs,ts,mts,cts}', 'source/engine/common/**/*.{mjs,ts,mts,cts}', diff --git a/index.html b/index.html index 263dae8a..5fdd6bdf 100644 --- a/index.html +++ b/index.html @@ -34,7 +34,7 @@