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