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,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