feat: complete Phase 4 combat/hunting system

- Add content migration (003_content.sql): zones, monsters, items, loot tables
- Add inventory migration (004_inventory.sql) and logs migration (006_logs.sql)
- Add Mulberry32 PRNG (src/utils/rng.js) for deterministic combat
- Add combat system (src/game/combat.js) with turn order, crit, mage bonus, 20-round cap
- Add rewards/loot system (src/game/rewards.js) and leveling (src/game/leveling.js)
- Add hunt endpoint (POST /api/hunt) with full combat flow in DB transaction
- Add hunt integration tests and unit tests for rewards/leveling/combat
- Fix session middleware to use memory store in tests (avoids cleanup hang)
- Fix huntService transaction (db.transaction() returns wrapper, must call separately)
- Add huntService to helpers.js cache invalidation for test isolation
This commit is contained in:
bepis
2026-10-20 17:46:18 +11:00
parent 7a3e408ffc
commit f1ae7b3119
16 changed files with 1239 additions and 21 deletions
+118
View File
@@ -0,0 +1,118 @@
// Tests for src/game/combat.js — pure combat resolution.
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { resolveCombat } = require('../src/game/combat');
const { mulberry32 } = require('../src/utils/rng');
describe('resolveCombat', () => {
const heroBase = {
class: 'warrior', level: 1, hpCurrent: 70, maxHp: 70,
str: 8, agi: 4, int: 2, vit: 8,
bonus_attack: 0, bonus_defense: 0, bonus_vit: 0,
};
const rat = { name: 'Giant Rat', level: 1, hp: 20, attack: 6, defense: 1, speed: 12 };
const wolf = { name: 'Meadow Wolf', level: 2, hp: 32, attack: 9, defense: 3, speed: 16 };
const goblin = { name: 'Goblin Scrapper', level: 3, hp: 45, attack: 12, defense: 5, speed: 11 };
const drake = { name: 'Ashfang Drake', level: 14, hp: 300, attack: 50, defense: 30, speed: 18 };
it('hero wins against equal-level monster', () => {
const rng = mulberry32(42);
const result = resolveCombat({ ...heroBase }, rat, rng);
assert.strictEqual(result.winner, 'hero');
assert.ok(result.rounds.length > 0);
assert.ok(result.hpRemaining > 0);
});
it('monster wins against much higher-level monster', () => {
const rng = mulberry32(99);
const result = resolveCombat({ ...heroBase }, drake, rng);
assert.strictEqual(result.winner, 'monster');
assert.ok(result.rounds.length > 0);
assert.ok(result.hpRemaining > 0);
});
it('returns round-by-round log', () => {
const rng = mulberry32(42);
const result = resolveCombat({ ...heroBase }, rat, rng);
for (const round of result.rounds) {
assert.ok('round' in round);
assert.ok('actor' in round);
assert.ok('damage' in round);
assert.ok('targetHpAfter' in round);
assert.ok(round.damage >= 1);
}
});
it('hero acts first when speed is equal or higher', () => {
// Hero warrior speed = 10 + 4 = 14. Rat speed = 12. Hero acts first.
const rng = mulberry32(1);
const result = resolveCombat({ ...heroBase }, rat, rng);
assert.strictEqual(result.rounds[0].actor, 'hero');
});
it('monster acts first when monster speed is higher', () => {
// Wolf speed = 16. Hero warrior speed = 14. Monster acts first.
const rng = mulberry32(1);
const result = resolveCombat({ ...heroBase }, wolf, rng);
assert.strictEqual(result.rounds[0].actor, 'monster');
});
it('deterministic with same seed', () => {
const rng1 = mulberry32(123);
const result1 = resolveCombat({ ...heroBase }, goblin, rng1);
const rng2 = mulberry32(123);
const result2 = resolveCombat({ ...heroBase }, goblin, rng2);
assert.deepStrictEqual(result1.winner, result2.winner);
assert.deepStrictEqual(result1.rounds.length, result2.rounds.length);
assert.deepStrictEqual(result1.hpRemaining, result2.hpRemaining);
});
it('caps at 20 rounds — higher HP% wins', () => {
// Create a scenario where both are nearly dead after many rounds
// Use a seed that produces low damage rolls
const rng = mulberry32(7777);
const weakHero = { ...heroBase, hpCurrent: 10, maxHp: 70, str: 2, agi: 2, vit: 2 };
const weakMonster = { ...rat, hp: 10, attack: 1, defense: 0, speed: 5 };
const result = resolveCombat(weakHero, weakMonster, rng);
assert.ok(result.winner === 'hero' || result.winner === 'monster');
assert.ok(result.rounds.length <= 20);
});
it('mage gets bonus vs high-defense monster', () => {
const mageHero = {
class: 'mage', level: 5, hpCurrent: 55, maxHp: 55,
str: 2, agi: 4, int: 9, vit: 5,
bonus_attack: 0, bonus_defense: 0, bonus_vit: 0,
};
// Stone Golem has defense 40
const golem = { name: 'Stone Golem', level: 12, hp: 260, attack: 38, defense: 40, speed: 6 };
const rng = mulberry32(55);
const result = resolveCombat(mageHero, golem, rng);
// Just verify it runs without error and returns valid structure
assert.ok(result.winner === 'hero' || result.winner === 'monster');
assert.ok(result.rounds.length > 0);
});
it('crit hits deal multiplied damage', () => {
// Force a crit by using a seed that produces rng() * 100 < critChance on first roll
// Warrior critChance = min(40, 5 + floor(4*0.5)) = 7%
// We can't easily force a crit, but verify crit field exists in log
const rng = mulberry32(42);
const result = resolveCombat({ ...heroBase }, rat, rng);
const critRounds = result.rounds.filter(r => r.crit);
// Crit may or may not occur, but if it does, damage should be higher
for (const cr of critRounds) {
assert.strictEqual(cr.crit, true);
}
});
it('monster crit field is present', () => {
const rng = mulberry32(1);
const result = resolveCombat({ ...heroBase }, wolf, rng);
const monsterRounds = result.rounds.filter(r => r.actor === 'monster');
for (const mr of monsterRounds) {
assert.ok('crit' in mr);
}
});
});
+17 -1
View File
@@ -5,6 +5,7 @@ const os = require('os');
let tmpDir;
let dbPath;
let server;
let dbRef;
function createFreshApp() {
process.env.DB_PATH = dbPath;
@@ -14,6 +15,7 @@ function createFreshApp() {
require.resolve('../src/config'),
require.resolve('../src/middleware/session'),
require.resolve('../src/services/authService'),
require.resolve('../src/services/huntService'),
require.resolve('../src/app'),
require.resolve('../scripts/migrate'),
];
@@ -24,6 +26,10 @@ function createFreshApp() {
const { runMigrations } = require('../scripts/migrate');
runMigrations();
// Capture the DB instance before creating the app (session store uses it)
const dbModule = require('../src/db');
dbRef = dbModule.getDb();
const { createApp } = require('../src/app');
return createApp();
}
@@ -46,13 +52,23 @@ async function setup() {
function teardown() {
return new Promise((resolve) => {
if (server) {
// Force-close all connections and the server to avoid hanging on session store interval
// Close server first to stop accepting connections
try { server.closeAllConnections(); } catch (_) {}
server.close(() => {
server = null;
// Then close DB to stop session store's cleanup interval
if (dbRef) {
try { dbRef.close(); } catch (_) {}
dbRef = null;
}
resolve();
});
} else {
// No server, just close DB
if (dbRef) {
try { dbRef.close(); } catch (_) {}
dbRef = null;
}
resolve();
}
});
+100
View File
@@ -0,0 +1,100 @@
// Integration tests for POST /api/hunt.
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { setup, teardown, cleanup } = require('../helpers');
/**
* Helper: register a user and return the session cookie string.
*/
async function registerUser(baseUrl, username, password) {
const res = await fetch(`${baseUrl}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password: password || 'password123' }),
});
assert.strictEqual(res.status, 201);
const setCookie = res.headers.get('set-cookie');
assert.ok(setCookie, 'No Set-Cookie header');
return setCookie.split(';')[0]; // e.g. "sid=abc123"
}
/**
* Helper: create a character and return the response data.
*/
async function createCharacter(baseUrl, cookie, name) {
const res = await fetch(`${baseUrl}/api/character`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({ name, class: 'warrior' }),
});
assert.strictEqual(res.status, 201);
return res.json();
}
describe('POST /api/hunt', () => {
let baseUrl;
before(async () => {
baseUrl = await setup();
});
after(async () => {
await teardown();
cleanup();
});
it('requires authentication', async () => {
const res = await fetch(`${baseUrl}/api/hunt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ zoneId: 1 }),
});
assert.strictEqual(res.status, 401);
});
it('hunts in zone 1 successfully', async () => {
const cookie = await registerUser(baseUrl, 'huntest1');
await createCharacter(baseUrl, cookie, 'HuntTest');
const res = await fetch(`${baseUrl}/api/hunt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({ zoneId: 1 }),
});
assert.strictEqual(res.status, 200);
const data = await res.json();
assert.ok(data.ok);
assert.ok(data.data.winner === 'hero' || data.data.winner === 'monster');
assert.ok(Array.isArray(data.data.rounds));
assert.ok(data.data.rounds.length > 0);
assert.ok('xpGained' in data.data);
assert.ok('goldGained' in data.data);
assert.ok('hpRemaining' in data.data);
assert.ok('zone' in data.data);
assert.ok('monster' in data.data);
});
it('rejects invalid zone ID', async () => {
const cookie = await registerUser(baseUrl, 'huntest2');
await createCharacter(baseUrl, cookie, 'HuntTest2');
const res = await fetch(`${baseUrl}/api/hunt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({ zoneId: 999 }),
});
assert.strictEqual(res.status, 404);
});
it('rejects missing zone ID', async () => {
const cookie = await registerUser(baseUrl, 'huntest3');
await createCharacter(baseUrl, cookie, 'HuntTest3');
const res = await fetch(`${baseUrl}/api/hunt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({}),
});
assert.strictEqual(res.status, 400);
});
});
+156
View File
@@ -0,0 +1,156 @@
// Tests for src/game/rewards.js and src/game/leveling.js.
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { calculateRewards, rollLoot, materialSellPrice } = require('../../src/game/rewards');
const { xpToNext, checkLevelUp, allocatePoints } = require('../../src/game/leveling');
const { mulberry32 } = require('../../src/utils/rng');
describe('rewards', () => {
it('calculateRewards — equal level gives base rewards', () => {
const rng = mulberry32(1);
const result = calculateRewards(100, 10, 20, 5, 5, rng);
assert.ok(result.xp > 0);
assert.ok(result.gold >= 10);
assert.ok(result.gold <= 20);
});
it('calculateRewards — higher level monster gives bonus', () => {
const rng = mulberry32(1);
const result = calculateRewards(100, 10, 20, 10, 5, rng);
// levelDiffMult = clamp(1 + 0.1*(10-5), 0.2, 1.5) = clamp(1.5, 0.2, 1.5) = 1.5
assert.strictEqual(result.xp, Math.floor(100 * 1.5));
});
it('calculateRewards — lower level monster gives reduced rewards', () => {
const rng = mulberry32(1);
const result = calculateRewards(100, 10, 20, 2, 5, rng);
// levelDiffMult = clamp(1 + 0.1*(2-5), 0.2, 1.5) = clamp(0.7, 0.2, 1.5) = 0.7
assert.strictEqual(result.xp, Math.floor(100 * 0.7));
});
it('calculateRewards — max 1.5x cap', () => {
const rng = mulberry32(1);
const result = calculateRewards(100, 10, 20, 20, 5, rng);
// levelDiffMult = clamp(1 + 0.1*(20-5), 0.2, 1.5) = clamp(2.5, 0.2, 1.5) = 1.5
assert.strictEqual(result.xp, Math.floor(100 * 1.5));
});
it('rollLoot — respects chance percentages', () => {
const rng = mulberry32(1);
const candidates = [
{ itemId: 1, chancePct: 100, qtyMin: 1, qtyMax: 1 },
{ itemId: 2, chancePct: 100, qtyMin: 1, qtyMax: 1 },
{ itemId: 3, chancePct: 100, qtyMin: 1, qtyMax: 1 },
];
const drops = rollLoot(candidates, rng);
// Max 2 distinct drops
assert.ok(drops.length <= 2);
});
it('rollLoot — max 2 distinct drops', () => {
const rng = mulberry32(1);
const candidates = Array.from({ length: 10 }, (_, i) => ({
itemId: i + 1, chancePct: 100, qtyMin: 1, qtyMax: 1,
}));
const drops = rollLoot(candidates, rng);
assert.ok(drops.length <= 2);
// All distinct
const itemIds = drops.map(d => d.itemId);
assert.strictEqual(itemIds.length, new Set(itemIds).size);
});
it('rollLoot — zero chance yields nothing', () => {
const rng = mulberry32(1);
const candidates = [
{ itemId: 1, chancePct: 0, qtyMin: 1, qtyMax: 1 },
];
const drops = rollLoot(candidates, rng);
assert.strictEqual(drops.length, 0);
});
it('materialSellPrice — common 2×level', () => {
assert.strictEqual(materialSellPrice('common', 5), 10);
});
it('materialSellPrice — uncommon 4×level', () => {
assert.strictEqual(materialSellPrice('uncommon', 5), 20);
});
it('materialSellPrice — rare 8×level', () => {
assert.strictEqual(materialSellPrice('rare', 14), 112);
});
it('materialSellPrice — minimum 1', () => {
assert.strictEqual(materialSellPrice('common', 0), 1);
});
});
describe('leveling', () => {
it('xpToNext — level 1→2 is 100', () => {
assert.strictEqual(xpToNext(1), 100);
});
it('xpToNext — level 2→3 is 282', () => {
assert.strictEqual(xpToNext(2), 282);
});
it('xpToNext — level 5→6 is 1118', () => {
assert.strictEqual(xpToNext(5), 1118);
});
it('xpToNext — level 10→11 is 3162', () => {
assert.strictEqual(xpToNext(10), 3162);
});
it('checkLevelUp — no level up', () => {
const result = checkLevelUp(1, 50, 10);
assert.strictEqual(result.newLevel, 1);
assert.strictEqual(result.leveledUp, false);
assert.strictEqual(result.totalXp, 60);
assert.strictEqual(result.unspentPoints, 0);
});
it('checkLevelUp — exact threshold', () => {
const result = checkLevelUp(1, 90, 10);
assert.strictEqual(result.newLevel, 2);
assert.strictEqual(result.leveledUp, true);
assert.strictEqual(result.totalXp, 0);
assert.strictEqual(result.unspentPoints, 3);
});
it('checkLevelUp — multiple level ups', () => {
// L1 needs 100 to reach L2, L2 needs 282 to reach L3
const result = checkLevelUp(1, 0, 100 + 282);
assert.strictEqual(result.newLevel, 3);
assert.strictEqual(result.leveledUp, true);
assert.strictEqual(result.totalXp, 0);
assert.strictEqual(result.unspentPoints, 6);
});
it('checkLevelUp — caps at max level 50', () => {
const result = checkLevelUp(50, 9999, 100);
assert.strictEqual(result.newLevel, 50);
assert.strictEqual(result.leveledUp, false);
});
it('allocatePoints — valid allocation', () => {
const result = allocatePoints(8, 4, 2, 8, 3, { str: 2, vit: 1 });
assert.strictEqual(result.str, 10);
assert.strictEqual(result.vit, 9);
assert.strictEqual(result.agi, 4);
assert.strictEqual(result.int, 2);
assert.strictEqual(result.remainingPoints, 0);
});
it('allocatePoints — invalid key throws', () => {
assert.throws(() => allocatePoints(8, 4, 2, 8, 3, { dex: 3 }), /Invalid stat/);
});
it('allocatePoints — wrong total throws', () => {
assert.throws(() => allocatePoints(8, 4, 2, 8, 3, { str: 1 }), /Allocated 1 but had 3/);
});
it('allocatePoints — non-integer throws', () => {
assert.throws(() => allocatePoints(8, 4, 2, 8, 3, { str: 1.5 }), /positive integer/);
});
});