First commit, phased 1 and 2 created but untested

This commit is contained in:
bepis
2026-10-20 07:40:35 +11:00
commit 7a3e408ffc
66 changed files with 5639 additions and 0 deletions
+171
View File
@@ -0,0 +1,171 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const { setup, teardown, cleanup } = require('./helpers');
// Simple cookie jar for tests
function createJar() {
return { cookies: [] };
}
function jarSet(jar, res) {
const cookies = res.headers['set-cookie'];
if (cookies && cookies.length) {
jar.cookies = cookies.map((c) => c.split(';')[0]);
}
}
function jarGet(jar) {
return jar.cookies.join('; ');
}
function fetchJson(base, path, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(path, base);
const opts = {
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
...(options.cookie ? { Cookie: options.cookie } : {}),
},
};
const req = http.request(url, opts, (res) => {
let data = '';
res.on('data', (c) => { data += c; });
res.on('end', () => {
resolve({
status: res.statusCode,
headers: res.headers,
body: data ? JSON.parse(data) : null,
});
});
});
req.on('error', reject);
if (options.body) req.write(JSON.stringify(options.body));
req.end();
});
}
test('auth register ok', async () => {
const baseUrl = await setup();
try {
const res = await fetchJson(baseUrl, '/api/auth/register', {
method: 'POST',
body: { username: 'testuser', password: 'password123' },
});
assert.equal(res.status, 201);
assert.equal(res.body.ok, true);
assert.equal(res.body.data.user.username, 'testuser');
} finally {
await teardown();
cleanup();
}
});
test('auth duplicate username', async () => {
const baseUrl = await setup();
try {
await fetchJson(baseUrl, '/api/auth/register', {
method: 'POST',
body: { username: 'dupuser', password: 'password123' },
});
const res = await fetchJson(baseUrl, '/api/auth/register', {
method: 'POST',
body: { username: 'DupUser', password: 'password123' },
});
assert.equal(res.status, 409);
assert.equal(res.body.error.code, 'USERNAME_TAKEN');
} finally {
await teardown();
cleanup();
}
});
test('auth login ok', async () => {
const baseUrl = await setup();
try {
await fetchJson(baseUrl, '/api/auth/register', {
method: 'POST',
body: { username: 'logintest', password: 'password123' },
});
const res = await fetchJson(baseUrl, '/api/auth/login', {
method: 'POST',
body: { username: 'logintest', password: 'password123' },
});
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
assert.equal(res.body.data.user.username, 'logintest');
assert.equal(res.body.data.hasHero, false);
} finally {
await teardown();
cleanup();
}
});
test('auth login bad password', async () => {
const baseUrl = await setup();
try {
await fetchJson(baseUrl, '/api/auth/register', {
method: 'POST',
body: { username: 'badpass', password: 'password123' },
});
const res = await fetchJson(baseUrl, '/api/auth/login', {
method: 'POST',
body: { username: 'badpass', password: 'wrongpassword' },
});
assert.equal(res.status, 401);
assert.equal(res.body.error.code, 'INVALID_CREDENTIALS');
} finally {
await teardown();
cleanup();
}
});
test('auth me requires session', async () => {
const baseUrl = await setup();
try {
const res = await fetchJson(baseUrl, '/api/auth/me');
assert.equal(res.status, 401);
assert.equal(res.body.error.code, 'UNAUTHENTICATED');
} finally {
await teardown();
cleanup();
}
});
test('auth logout clears session', async () => {
const baseUrl = await setup();
const jar = createJar();
try {
// Register
const regRes = await fetchJson(baseUrl, '/api/auth/register', {
method: 'POST',
body: { username: 'logouttest', password: 'password123' },
});
assert.equal(regRes.status, 201);
jarSet(jar, regRes);
// Me should work with session from register
const meRes1 = await fetchJson(baseUrl, '/api/auth/me', {
cookie: jarGet(jar),
});
assert.equal(meRes1.status, 200);
// Logout
const logoutRes = await fetchJson(baseUrl, '/api/auth/logout', {
method: 'POST',
cookie: jarGet(jar),
});
assert.equal(logoutRes.status, 200);
// Me should fail after logout
const meRes2 = await fetchJson(baseUrl, '/api/auth/me', {
cookie: jarGet(jar),
});
assert.equal(meRes2.status, 401);
assert.equal(meRes2.body.error.code, 'UNAUTHENTICATED');
} finally {
await teardown();
cleanup();
}
});
+36
View File
@@ -0,0 +1,36 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const { createApp } = require('../src/app');
function startServer(app) {
return new Promise((resolve) => {
const server = app.listen(0, () => {
const port = server.address().port;
resolve({ server, port });
});
});
}
test('GET /api/health returns { ok: true }', async () => {
const app = createApp();
const { server, port } = await startServer(app);
try {
const result = await new Promise((resolve, reject) => {
http.get(`http://localhost:${port}/api/health`, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
resolve({ status: res.statusCode, body: JSON.parse(data) });
});
res.on('error', reject);
});
});
assert.equal(result.status, 200);
assert.equal(result.body.ok, true);
} finally {
server.close();
}
});
+69
View File
@@ -0,0 +1,69 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
let tmpDir;
let dbPath;
let server;
function createFreshApp() {
process.env.DB_PATH = dbPath;
const cached = [
require.resolve('../src/db'),
require.resolve('../src/config'),
require.resolve('../src/middleware/session'),
require.resolve('../src/services/authService'),
require.resolve('../src/app'),
require.resolve('../scripts/migrate'),
];
for (const id of cached) {
if (require.cache[id]) delete require.cache[id];
}
const { runMigrations } = require('../scripts/migrate');
runMigrations();
const { createApp } = require('../src/app');
return createApp();
}
async function setup() {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emberfall-test-'));
dbPath = path.join(tmpDir, 'test.db');
const app = createFreshApp();
return new Promise((resolve, reject) => {
server = app.listen(0, () => {
const port = server.address().port;
resolve(`http://localhost:${port}`);
});
server.on('error', reject);
});
}
function teardown() {
return new Promise((resolve) => {
if (server) {
// Force-close all connections and the server to avoid hanging on session store interval
try { server.closeAllConnections(); } catch (_) {}
server.close(() => {
server = null;
resolve();
});
} else {
resolve();
}
});
}
function cleanup() {
if (tmpDir && fs.existsSync(tmpDir)) {
try { fs.rmSync(tmpDir, { recursive: true }); } catch (_) {}
}
tmpDir = undefined;
dbPath = undefined;
}
module.exports = { setup, teardown, cleanup };
View File
View File
+53
View File
@@ -0,0 +1,53 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { CLASSES, CLASS_KEYS, getClass, getBaseStats } = require('../../src/game/classes');
test('CLASSES contains exactly 3 classes', () => {
assert.equal(CLASS_KEYS.length, 3);
assert.ok(CLASS_KEYS.includes('warrior'));
assert.ok(CLASS_KEYS.includes('mage'));
assert.ok(CLASS_KEYS.includes('ranger'));
});
test('getClass returns correct base stats for warrior', () => {
const stats = getBaseStats('warrior');
assert.equal(stats.str, 8);
assert.equal(stats.agi, 4);
assert.equal(stats.int, 2);
assert.equal(stats.vit, 8);
});
test('getClass returns correct base stats for mage', () => {
const stats = getBaseStats('mage');
assert.equal(stats.str, 2);
assert.equal(stats.agi, 4);
assert.equal(stats.int, 9);
assert.equal(stats.vit, 5);
});
test('getClass returns correct base stats for ranger', () => {
const stats = getBaseStats('ranger');
assert.equal(stats.str, 5);
assert.equal(stats.agi, 9);
assert.equal(stats.int, 3);
assert.equal(stats.vit, 5);
});
test('getClass throws for unknown class', () => {
assert.throws(() => getClass('paladin'), Error);
});
test('getClass returns the class object with name and primary', () => {
const warrior = getClass('warrior');
assert.equal(warrior.name, 'Warrior');
assert.equal(warrior.primary, 'str');
assert.ok(warrior.bonuses.defensePct);
const mage = getClass('mage');
assert.equal(mage.primary, 'int');
assert.ok(mage.bonuses.magicVsHighDef);
const ranger = getClass('ranger');
assert.equal(ranger.primary, 'agi');
assert.ok(ranger.bonuses.critChance);
});
+90
View File
@@ -0,0 +1,90 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { computeEnergy, ENERGY_MAX, ENERGY_REGEN_SECONDS } = require('../../src/game/energy');
// No time passed
test('computeEnergy no time passed', () => {
const now = Date.now();
const result = computeEnergy(50, now, now);
assert.equal(result.energy, 50);
assert.equal(result.updatedAtMs, now);
});
// Clock going backwards
test('computeEnergy clock going backwards', () => {
const result = computeEnergy(50, Date.now(), Date.now() - 100000);
assert.equal(result.energy, 50);
});
// Less than one regen interval
test('computeEnergy partial tick (less than 1 regen interval)', () => {
const now = Date.now();
const result = computeEnergy(50, now - 10000, now);
// 10s < 180s → no regen
assert.equal(result.energy, 50);
});
// One full tick
test('computeEnergy one full tick', () => {
const now = Date.now();
const result = computeEnergy(50, now - 180000, now);
// 180s / 180s = 1 tick
assert.equal(result.energy, 51);
});
// Multiple full ticks
test('computeEnergy multiple full ticks', () => {
const now = Date.now();
const result = computeEnergy(50, now - 360000, now);
// 360s / 180s = 2 ticks
assert.equal(result.energy, 52);
});
// Caps at max
test('computeEnergy caps at max', () => {
const now = Date.now();
const result = computeEnergy(100, now - 3600000, now);
assert.equal(result.energy, ENERGY_MAX);
});
// Large gap (hours ago)
test('computeEnergy large gap caps at max', () => {
const now = Date.now();
const result = computeEnergy(30, now - 36000000, now); // 10 hours ago
assert.equal(result.energy, ENERGY_MAX);
});
// Partial interval preserved (timestamp advances by whole ticks only)
test('computeEnergy preserves partial interval', () => {
const updatedAt = 1000000;
const now = 1000000 + 180000 + 50000; // 1 tick + 50s partial
const result = computeEnergy(50, updatedAt, now);
assert.equal(result.energy, 51);
// Timestamp should advance by exactly 1 tick, not the full elapsed
assert.equal(result.updatedAtMs, updatedAt + 180000);
});
// Starting from zero
test('computeEnergy from zero', () => {
const now = Date.now();
const result = computeEnergy(0, now - 540000, now);
// 540s / 180s = 3 ticks → 0 + 3 = 3
assert.equal(result.energy, 3);
});
// At max, large gap → resets timestamp
test('computeEnergy at max resets timestamp', () => {
const now = Date.now();
const result = computeEnergy(100, now - 10000000, now);
assert.equal(result.energy, ENERGY_MAX);
assert.equal(result.updatedAtMs, now);
});
// Constants are correct
test('ENERGY_MAX is 100', () => {
assert.equal(ENERGY_MAX, 100);
});
test('ENERGY_REGEN_SECONDS is 180', () => {
assert.equal(ENERGY_REGEN_SECONDS, 180);
});
+138
View File
@@ -0,0 +1,138 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { calcDerived, computeEnergy, xpToNext, checkLevelUp, ENERGY_MAX } = require('../../src/game/stats');
// calcDerived tests
test('calcDerived warrior at level 1 with no equipment', () => {
const derived = calcDerived({ str: 8, agi: 4, int: 2, vit: 8 }, 1, 'warrior');
// maxHp = 50 + 8*10 + 1*5 = 135
assert.equal(derived.maxHp, 135);
// primary = str = 8, attack = 5 + 8*2 + 1 = 22
assert.equal(derived.attack, 22);
// defense = floor(8*1.5) = 12, +10% warrior = 13
assert.equal(derived.defense, 13);
// speed = 10 + 4 = 14
assert.equal(derived.speed, 14);
// critChance = min(40, 5 + floor(4*0.5)) = min(40, 7) = 7
assert.equal(derived.critChance, 7);
});
test('calcDerived mage at level 1 with no equipment', () => {
const derived = calcDerived({ str: 2, agi: 4, int: 9, vit: 5 }, 1, 'mage');
// maxHp = 50 + 5*10 + 1*5 = 105
assert.equal(derived.maxHp, 105);
// primary = int = 9, attack = 5 + 9*2 + 1 = 24
assert.equal(derived.attack, 24);
// defense = floor(5*1.5) = 7 (no warrior bonus)
assert.equal(derived.defense, 7);
// speed = 10 + 4 = 14
assert.equal(derived.speed, 14);
// critChance = min(40, 5 + floor(4*0.5)) = 7
assert.equal(derived.critChance, 7);
});
test('calcDerived ranger at level 1 with no equipment', () => {
const derived = calcDerived({ str: 5, agi: 9, int: 3, vit: 5 }, 1, 'ranger');
// maxHp = 50 + 5*10 + 1*5 = 105
assert.equal(derived.maxHp, 105);
// primary = agi = 9, attack = 5 + 9*2 + 1 = 24
assert.equal(derived.attack, 24);
// defense = floor(5*1.5) = 7
assert.equal(derived.defense, 7);
// speed = 10 + 9 = 19
assert.equal(derived.speed, 19);
// critChance = min(40, 5 + floor(9*0.5)) + 8 = min(40, 9) + 8 = 17
assert.equal(derived.critChance, 17);
});
test('calcDerived with equipment bonuses', () => {
const derived = calcDerived(
{ str: 8, agi: 4, int: 2, vit: 8 },
1,
'warrior',
{ bonus_str: 2, bonus_attack: 5, bonus_defense: 3 }
);
// totalStr = 10, attack = 5 + 10*2 + 1 + 5 = 31
assert.equal(derived.attack, 31);
// totalVit = 8, defense = floor(8*1.5) = 12, +3 equip = 15, +10% = 16
assert.equal(derived.defense, 16);
});
// computeEnergy tests
test('computeEnergy no time passed', () => {
const result = computeEnergy(50, Date.now(), Date.now() - 1000);
assert.equal(result.energy, 50);
});
test('computeEnergy partial tick (less than 1 regen interval)', () => {
const now = Date.now();
const result = computeEnergy(50, now - 10000, now);
// 10s < 180s → no regen
assert.equal(result.energy, 50);
});
test('computeEnergy full tick', () => {
const now = Date.now();
const result = computeEnergy(50, now - 360000, now);
// 360s / 180s = 2 ticks
assert.equal(result.energy, 52);
});
test('computeEnergy caps at max', () => {
const now = Date.now();
const result = computeEnergy(100, now - 3600000, now);
assert.equal(result.energy, ENERGY_MAX);
});
test('computeEnergy clock going backwards', () => {
const result = computeEnergy(50, Date.now(), Date.now() - 100000);
assert.equal(result.energy, 50);
});
test('computeEnergy large gap', () => {
const now = Date.now();
const result = computeEnergy(30, now - 36000000, now); // 10 hours ago
assert.equal(result.energy, ENERGY_MAX);
});
// xpToNext tests
test('xpToNext level 1→2 is 100', () => {
assert.equal(xpToNext(1), 100);
});
test('xpToNext level 2→3 is 282', () => {
assert.equal(xpToNext(2), 282);
});
test('xpToNext level 5→6 is 1118', () => {
assert.equal(xpToNext(5), 1118);
});
// checkLevelUp tests
test('checkLevelUp no level up', () => {
const result = checkLevelUp(1, 50, 10);
assert.equal(result.newLevel, 1);
assert.equal(result.leveledUp, false);
assert.equal(result.totalXp, 60);
});
test('checkLevelUp exact threshold', () => {
const result = checkLevelUp(1, 90, 10);
assert.equal(result.newLevel, 2);
assert.equal(result.leveledUp, true);
assert.equal(result.totalXp, 0);
});
test('checkLevelUp multiple level ups', () => {
// L1→2 costs 100, L2→3 costs 282. Total 382 for 2 levels. 500 XP → 2 levels, 118 remaining.
const result = checkLevelUp(1, 0, 500);
assert.equal(result.newLevel, 3);
assert.equal(result.leveledUp, true);
assert.equal(result.totalXp, 118);
});
test('checkLevelUp grants 3 points per level', () => {
const result = checkLevelUp(1, 0, 282); // exactly enough for L1→L2
assert.equal(result.newLevel, 2);
assert.equal(result.unspentPoints, 3);
});