First commit, phased 1 and 2 created but untested
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user