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
+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/);
});
});