diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..ce625dfa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - name: Install dependencies + run: npm ci + - name: Lint + run: npm run lint + + type-check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - name: Install dependencies + run: npm ci + - name: Type check + run: npm run type-check + + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - name: Install dependencies + run: npm ci + - name: Test + run: npm run test + + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - name: Install dependencies + run: npm ci + - name: Build + run: npm run build diff --git a/.gitignore b/.gitignore index 136a9d6b..5ef861ec 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # Testing /coverage +/dist-tests # Next.js /.next/ diff --git a/README.md b/README.md index 928e7485..2066318a 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,7 @@ Areas where contributions would be valuable: ```bash npm run type-check # TypeScript validation npm run lint # ESLint +npm run test # Unit tests npm run dev # Development server ``` diff --git a/package.json b/package.json index 5a711bad..7d9cf89b 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "start": "next start", "lint": "next lint", "type-check": "tsc --noEmit", + "test": "rm -rf dist-tests && tsc -p tsconfig.test.json && node --require ./scripts/test-alias.cjs --test $(find dist-tests/tests -name \"*.test.js\")", "optimize-models": "node scripts/optimize-models.js", "generate-manifests": "node scripts/generate-music-manifest.js" }, diff --git a/scripts/test-alias.cjs b/scripts/test-alias.cjs new file mode 100644 index 00000000..fa1a7d1c --- /dev/null +++ b/scripts/test-alias.cjs @@ -0,0 +1,19 @@ +const path = require('path'); +const Module = require('module'); + +const originalResolveFilename = Module._resolveFilename; + +Module._resolveFilename = function resolveFilename(request, parent, isMain, options) { + if (request.startsWith('@/')) { + const resolvedPath = path.resolve( + __dirname, + '..', + 'dist-tests', + 'src', + request.slice(2) + ); + return originalResolveFilename.call(this, resolvedPath, parent, isMain, options); + } + + return originalResolveFilename.call(this, request, parent, isMain, options); +}; diff --git a/tests/engine/core/gameLoop.test.ts b/tests/engine/core/gameLoop.test.ts new file mode 100644 index 00000000..9f519715 --- /dev/null +++ b/tests/engine/core/gameLoop.test.ts @@ -0,0 +1,47 @@ +import { GameLoop } from '@/engine/core/GameLoop'; +import assert from 'node:assert/strict'; +import { afterEach, describe, it } from 'node:test'; + +const wait = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +describe('GameLoop fallback timing', () => { + const originalWorker = globalThis.Worker; + + afterEach(() => { + globalThis.Worker = originalWorker; + }); + + it('ticks at the configured rate using the fallback interval', async () => { + globalThis.Worker = undefined as unknown as typeof Worker; + + const updates: number[] = []; + const loop = new GameLoop(20, (delta) => updates.push(delta)); + + loop.start(); + await wait(220); + loop.stop(); + + assert.ok(updates.length >= 3); + assert.ok(updates.length <= 6); + assert.ok(updates.every((delta) => delta === 50)); + }); + + it('respects tick rate changes while running', async () => { + globalThis.Worker = undefined as unknown as typeof Worker; + + const updates: number[] = []; + const loop = new GameLoop(10, (delta) => updates.push(delta)); + + loop.start(); + await wait(240); + const beforeChange = updates.length; + loop.setTickRate(5); + await wait(450); + loop.stop(); + + assert.ok(beforeChange >= 1); + assert.ok(updates.length > beforeChange); + assert.ok(updates.slice(beforeChange).every((delta) => delta === 200)); + }); +}); diff --git a/tests/engine/ecs/entityId.test.ts b/tests/engine/ecs/entityId.test.ts new file mode 100644 index 00000000..ddaa2701 --- /dev/null +++ b/tests/engine/ecs/entityId.test.ts @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + EntityIdAllocator, + INVALID_ENTITY_ID, + MAX_ENTITY_INDEX, + MAX_GENERATION, + getEntityGeneration, + getEntityIndex, + isInvalidEntityId, + packEntityId, +} from '@/engine/ecs/EntityId'; + +describe('EntityId utilities', () => { + it('packs and unpacks entity ids', () => { + const id = packEntityId(42, 7); + assert.strictEqual(getEntityIndex(id), 42); + assert.strictEqual(getEntityGeneration(id), 7); + }); + + it('reserves invalid entity id sentinel', () => { + assert.ok(isInvalidEntityId(INVALID_ENTITY_ID)); + assert.strictEqual(getEntityIndex(INVALID_ENTITY_ID), 0); + assert.strictEqual(getEntityGeneration(INVALID_ENTITY_ID), 0); + }); + + it('masks indices and generations to allowed ranges', () => { + const id = packEntityId(MAX_ENTITY_INDEX + 10, MAX_GENERATION + 5); + assert.strictEqual(getEntityIndex(id), MAX_ENTITY_INDEX + 10 & MAX_ENTITY_INDEX); + assert.strictEqual(getEntityGeneration(id), (MAX_GENERATION + 5) & MAX_GENERATION); + }); +}); + +describe('EntityIdAllocator', () => { + it('allocates and validates ids, then recycles with generation increment', () => { + const allocator = new EntityIdAllocator(4); + + const first = allocator.allocate(); + const second = allocator.allocate(); + + assert.ok(allocator.isValid(first)); + assert.ok(allocator.isValid(second)); + assert.strictEqual(allocator.getAllocatedCount(), 2); + + allocator.free(first); + + assert.strictEqual(allocator.isValid(first), false); + assert.strictEqual(allocator.getFreeCount(), 1); + + const recycled = allocator.allocate(); + assert.strictEqual(getEntityIndex(recycled), getEntityIndex(first)); + assert.ok(getEntityGeneration(recycled) > getEntityGeneration(first)); + }); + + it('returns invalid id when capacity exceeded', () => { + const allocator = new EntityIdAllocator(2); + + const first = allocator.allocate(); + const second = allocator.allocate(); + const third = allocator.allocate(); + + assert.ok(!isInvalidEntityId(first)); + assert.ok(isInvalidEntityId(second)); + assert.ok(isInvalidEntityId(third)); + }); + + it('resets allocator state on clear', () => { + const allocator = new EntityIdAllocator(8); + allocator.allocate(); + allocator.allocate(); + + allocator.clear(); + + assert.strictEqual(allocator.getAllocatedCount(), 0); + assert.strictEqual(allocator.getFreeCount(), 0); + assert.strictEqual(allocator.getStats().highWaterMark, 0); + }); +}); diff --git a/tests/engine/network/merkleTree.test.ts b/tests/engine/network/merkleTree.test.ts new file mode 100644 index 00000000..40a4f93c --- /dev/null +++ b/tests/engine/network/merkleTree.test.ts @@ -0,0 +1,88 @@ +import { + MerkleTreeBuilder, + MerkleTreeComparator, + type MerkleTreeData, +} from '@/engine/network/MerkleTree'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const buildTree = (hashOverrides?: { unit2?: number }): MerkleTreeData => { + const unit1 = MerkleTreeBuilder.createEntityNode(1, 'unit', 111); + const unit2 = MerkleTreeBuilder.createEntityNode(2, 'unit', hashOverrides?.unit2 ?? 222); + + const playerGroup = MerkleTreeBuilder.createGroupNode('player1', [unit2, unit1]); + const unitsCategory = MerkleTreeBuilder.createCategoryNode('units', [playerGroup]); + const root = MerkleTreeBuilder.createRootNode([unitsCategory]); + + return { + root, + tick: 10, + timestamp: 1234, + entityCount: 2, + }; +}; + +describe('MerkleTree utilities', () => { + it('builds deterministic hashes regardless of child ordering', () => { + const treeA = buildTree(); + + const unit1 = MerkleTreeBuilder.createEntityNode(1, 'unit', 111); + const unit2 = MerkleTreeBuilder.createEntityNode(2, 'unit', 222); + const playerGroup = MerkleTreeBuilder.createGroupNode('player1', [unit1, unit2]); + const unitsCategory = MerkleTreeBuilder.createCategoryNode('units', [playerGroup]); + const root = MerkleTreeBuilder.createRootNode([unitsCategory]); + + const treeB: MerkleTreeData = { + root, + tick: 10, + timestamp: 1234, + entityCount: 2, + }; + + assert.strictEqual(treeA.root.hash, treeB.root.hash); + }); + + it('identifies divergent entities', () => { + const local = buildTree(); + const remote = buildTree({ unit2: 999 }); + + const result = MerkleTreeComparator.findDivergence(local.root, remote.root); + + assert.ok(result.entityIds.includes(2)); + assert.strictEqual(result.path[0], 'root'); + assert.ok(result.comparisons > 0); + }); + + it('finds divergent categories and groups from network summaries', () => { + const tree = buildTree(); + const serialized = MerkleTreeComparator.serializeForNetwork(tree); + + assert.strictEqual(MerkleTreeComparator.isIdentical(tree, serialized), true); + + const tampered = { + ...serialized, + categoryHashes: { + ...serialized.categoryHashes, + units: serialized.categoryHashes.units + 1, + }, + }; + + assert.deepStrictEqual(MerkleTreeComparator.findDivergentCategories(tree, tampered), ['units']); + + const tamperedGroups = { + ...serialized, + groupHashes: { + ...serialized.groupHashes, + units: { + ...serialized.groupHashes.units, + player1: serialized.groupHashes.units.player1 + 1, + }, + }, + }; + + assert.deepStrictEqual( + MerkleTreeComparator.findDivergentGroups(tree, tamperedGroups, 'units'), + ['player1'] + ); + }); +}); diff --git a/tests/utils/fixedPoint.test.ts b/tests/utils/fixedPoint.test.ts new file mode 100644 index 00000000..2ac79f3a --- /dev/null +++ b/tests/utils/fixedPoint.test.ts @@ -0,0 +1,60 @@ +import { + FP_MAX_SAFE, + FP_MIN_SAFE, + deterministicDamage, + deterministicDistance, + fpDiv, + fpFromFloat, + fpMul, + fpToFloat, + integerSqrt, + quantize, + dequantize, +} from '@/utils/FixedPoint'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +describe('FixedPoint utilities', () => { + it('round-trips floats through fixed-point conversion', () => { + const value = 1.2345; + const fixed = fpFromFloat(value); + const roundTrip = fpToFloat(fixed); + + assert.ok(Math.abs(roundTrip - value) < 0.0002); + }); + + it('multiplies fixed-point values deterministically', () => { + const a = fpFromFloat(1.5); + const b = fpFromFloat(-2.25); + const result = fpMul(a, b); + + assert.ok(Math.abs(fpToFloat(result) + 3.375) < 0.0002); + }); + + it('divides with zero denominator safely', () => { + assert.strictEqual(fpDiv(10, 0), FP_MAX_SAFE); + assert.strictEqual(fpDiv(-10, 0), FP_MIN_SAFE); + }); + + it('computes integer square roots deterministically', () => { + assert.strictEqual(integerSqrt(0), 0); + assert.strictEqual(integerSqrt(1), 1); + assert.strictEqual(integerSqrt(15), 3); + assert.strictEqual(integerSqrt(16), 4); + assert.strictEqual(integerSqrt(0x80000000), 46340); + }); + + it('computes deterministic distance on quantized coordinates', () => { + assert.ok(Math.abs(deterministicDistance(0, 0, 3, 4) - 5) < 0.0001); + }); + + it('enforces minimum deterministic damage', () => { + const damage = deterministicDamage(5, 1, 10); + assert.strictEqual(damage, 1); + }); + + it('quantizes and dequantizes values consistently', () => { + const quantized = quantize(3.14159, 1000); + assert.ok(Math.abs(dequantize(quantized, 1000) - 3.142) < 0.001); + }); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 00000000..a290febf --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2019", + "module": "commonjs", + "moduleResolution": "node", + "lib": ["ES2019", "DOM"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": false, + "outDir": "dist-tests", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "jsx": "react-jsx", + "resolveJsonModule": true, + "types": ["node", "react"] + }, + "include": ["tests/**/*.test.ts"], + "exclude": ["node_modules", "dist-tests"] +}