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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

# Testing
/coverage
/dist-tests

# Next.js
/.next/
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
19 changes: 19 additions & 0 deletions scripts/test-alias.cjs
Original file line number Diff line number Diff line change
@@ -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);
};
47 changes: 47 additions & 0 deletions tests/engine/core/gameLoop.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> =>
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));
});
});
78 changes: 78 additions & 0 deletions tests/engine/ecs/entityId.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
88 changes: 88 additions & 0 deletions tests/engine/network/merkleTree.test.ts
Original file line number Diff line number Diff line change
@@ -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']
);
});
});
60 changes: 60 additions & 0 deletions tests/utils/fixedPoint.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading