loop: baseline

This commit is contained in:
bepis
2026-10-20 20:33:35 +11:00
parent f1ae7b3119
commit 9a308b697d
32 changed files with 959 additions and 2046 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();
}
});
@@ -0,0 +1,54 @@
// Service-level tests for characterService (profile + energy countdown).
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { setup, teardown, cleanup } = require('../helpers');
const NOW = 1_700_000_000_000;
describe('characterService.getCharacterProfile', () => {
let db, svc, time;
before(async () => {
await setup();
db = require('../../src/db').getDb();
svc = require('../../src/services/characterService');
time = require('../../src/utils/time');
time.setNow(() => NOW);
});
after(async () => { time.resetNow(); await teardown(); cleanup(); });
function hero(n, energy, updatedAt) {
const uid = Number(db.prepare('INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)').run(`cu${n}`, 'x', NOW).lastInsertRowid);
const { id } = svc.createCharacter(uid, `CHero${n}`, 'mage');
db.prepare('UPDATE characters SET energy = ?, energy_updated_at = ? WHERE id = ?').run(energy, updatedAt, id);
return uid;
}
it('REGRESSION: nextTickInSec is time to the NEXT +1, not time until full', () => {
// 40/100 energy, last tick 60s ago → next +1 in 120s (regen is 180s per point)
const p = svc.getCharacterProfile(hero(1, 40, NOW - 60_000));
assert.equal(p.energy.current, 40);
assert.equal(p.energy.nextTickInSec, 120);
});
it('nextTickInSec is 0 when energy is full', () => {
const p = svc.getCharacterProfile(hero(2, 100, NOW));
assert.equal(p.energy.current, 100);
assert.equal(p.energy.nextTickInSec, 0);
});
it('applies offline regen when the profile is read', () => {
// 10 energy, last tick 30 minutes ago → +10
const p = svc.getCharacterProfile(hero(3, 10, NOW - 30 * 60_000));
assert.equal(p.energy.current, 20);
});
it('a new hero starts at full HP and full energy, timestamped "now" (not server start time)', () => {
const uid = Number(db.prepare('INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)').run('cu4', 'x', NOW).lastInsertRowid);
const { id } = svc.createCharacter(uid, 'CHero4', 'warrior');
const c = db.prepare('SELECT * FROM characters WHERE id = ?').get(id);
assert.equal(c.energy, 100);
assert.equal(c.energy_updated_at, NOW);
assert.equal(c.hp_current, 135); // warrior L1: 50 + 8*10 + 5
});
});
+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();
}
});
+133
View File
@@ -0,0 +1,133 @@
// Service-level tests for huntService.hunt(): outcomes are forced via test-only zones/monsters,
// so results are deterministic and we can assert exactly what was persisted.
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { setup, teardown, cleanup } = require('../helpers');
const NOW = 1_700_000_000_000;
const rng = () => 0.5; // constant: loot chance 100% always drops, no crits for warriors (50 > 7)
describe('huntService.hunt', () => {
let db, hunt, createCharacter, userSeq = 0;
let stackableId, weaponId;
before(async () => {
await setup(); // fresh DB + migrations (includes seeded content)
db = require('../../src/db').getDb();
({ hunt } = require('../../src/services/huntService'));
({ createCharacter } = require('../../src/services/characterService'));
stackableId = db.prepare('SELECT id FROM items WHERE stackable = 1 LIMIT 1').get().id;
weaponId = db.prepare("SELECT id FROM items WHERE type = 'weapon' AND stackable = 0 LIMIT 1").get().id;
// Test-only zones. 100: trivial monster (always wins). 101: unkillable monster (always loses). 102: level-5 gate.
db.exec(`
INSERT INTO zones (id, slug, name, description, min_level, energy_cost) VALUES
(100, 't-easy', 'Test Easy', 'x', 1, 5), (101, 't-deadly', 'Test Deadly', 'x', 1, 5), (102, 't-gated', 'Test Gated', 'x', 5, 5);
INSERT INTO monsters (id, zone_id, name, level, hp, attack, defense, speed, xp, gold_min, gold_max, weight) VALUES
(100, 100, 'Test Dummy', 1, 1, 1, 0, 1, 150, 10, 10, 10),
(101, 101, 'Test Titan', 1, 99999, 9999, 0, 1, 50, 5, 5, 10),
(102, 102, 'Test Gated Mob', 5, 1, 1, 0, 1, 50, 5, 5, 10);
INSERT INTO loot_table (monster_id, item_id, chance_pct, qty_min, qty_max) VALUES
(100, ${stackableId}, 100, 1, 1), (100, ${weaponId}, 100, 1, 1);
`);
});
after(async () => { await teardown(); cleanup(); });
function makeHero(overrides = {}) {
userSeq += 1;
const uid = db.prepare('INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)')
.run(`u${userSeq}`, 'x', NOW).lastInsertRowid;
const { id } = createCharacter(Number(uid), `Hero${userSeq}`, 'warrior');
const cols = Object.keys(overrides);
if (cols.length) {
db.prepare(`UPDATE characters SET ${cols.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`)
.run(...Object.values(overrides), id);
}
return id;
}
const row = (id) => db.prepare('SELECT * FROM characters WHERE id = ?').get(id);
const run = (id, zone, extra = {}) => hunt(id, zone, { rng, now: NOW, ...extra });
const throwsCode = (fn, code) => assert.throws(fn, (e) => e.code === code);
it('a win spends energy and persists xp, gold, kills and a combat_log row', () => {
const id = makeHero({ energy: 100, energy_updated_at: NOW });
const res = run(id, 100);
assert.equal(res.winner, 'hero');
const c = row(id);
assert.equal(c.energy, 95);
assert.equal(c.gold, 50 + 10); // starting gold + monster gold
assert.equal(c.kills, 1);
assert.equal(c.deaths, 0);
assert.equal(db.prepare('SELECT COUNT(*) n FROM combat_log WHERE character_id = ?').get(id).n, 1);
});
it('REGRESSION: level-up deducts the XP spent and ADDS stat points', () => {
const id = makeHero({ energy: 100, energy_updated_at: NOW, unspent_points: 2 });
const res = run(id, 100); // +150 xp at level 1; level 1→2 costs 100
assert.equal(res.leveledUp, true);
const c = row(id);
assert.equal(c.level, 2);
assert.equal(c.xp, 50); // 150 - 100, not 150
assert.equal(c.unspent_points, 5); // 2 existing + 3 new, not overwritten
assert.equal(c.hp_current, res.hpMax); // healed to the NEW max HP
});
it('a defeat costs 10% gold, sets HP to 1, increments deaths, grants no xp, still spends energy', () => {
const id = makeHero({ energy: 100, energy_updated_at: NOW, gold: 200 });
const res = run(id, 101);
assert.equal(res.winner, 'monster');
const c = row(id);
assert.equal(c.gold, 180);
assert.equal(c.hp_current, 1);
assert.equal(c.deaths, 1);
assert.equal(c.kills, 0);
assert.equal(c.xp, 0);
assert.equal(c.energy, 95);
});
it('REGRESSION: zone min_level is enforced and nothing changes on rejection', () => {
const id = makeHero({ energy: 100, energy_updated_at: NOW });
throwsCode(() => run(id, 102), 'LEVEL_TOO_LOW');
assert.equal(row(id).energy, 100);
assert.equal(db.prepare('SELECT COUNT(*) n FROM combat_log WHERE character_id = ?').get(id).n, 0);
});
it('rejects when energy is insufficient, leaving state untouched', () => {
const id = makeHero({ energy: 3, energy_updated_at: NOW });
throwsCode(() => run(id, 100), 'INSUFFICIENT_ENERGY');
assert.equal(row(id).energy, 3);
});
it('regenerates offline energy before checking cost (lazy time)', () => {
// 0 energy, last updated 30 minutes ago → +10 energy (1 per 3 min), cost 5 → 5 left
const id = makeHero({ energy: 0, energy_updated_at: NOW - 30 * 60 * 1000 });
run(id, 100);
assert.equal(row(id).energy, 5);
});
it('rejects hunting with HP below 20% of max', () => {
const id = makeHero({ energy: 100, energy_updated_at: NOW, hp_current: 5 });
throwsCode(() => run(id, 100), 'HP_TOO_LOW');
});
it('rejects unknown zones and invalid ids', () => {
const id = makeHero({ energy: 100, energy_updated_at: NOW });
throwsCode(() => run(id, 99999), 'NOT_FOUND');
throwsCode(() => run(id, -1), 'VALIDATION_ERROR');
throwsCode(() => run(id, 1.5), 'VALIDATION_ERROR');
});
it('REGRESSION: stackable loot merges into one row; non-stackable gets a row per drop', () => {
const id = makeHero({ energy: 100, energy_updated_at: NOW });
run(id, 100);
run(id, 100);
const stack = db.prepare('SELECT quantity FROM inventory WHERE character_id = ? AND item_id = ?').all(id, stackableId);
assert.equal(stack.length, 1);
assert.equal(stack[0].quantity, 2);
const weapons = db.prepare('SELECT quantity FROM inventory WHERE character_id = ? AND item_id = ?').all(id, weaponId);
assert.equal(weapons.length, 2);
assert.ok(weapons.every((w) => w.quantity === 1));
});
});