loop: baseline
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,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
|
||||
});
|
||||
});
|
||||
@@ -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,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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user