138 lines
6.5 KiB
JavaScript
138 lines
6.5 KiB
JavaScript
// 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);
|
|
// stackableId and weaponId are set up in before() with 100% drop rate on monster 100.
|
|
// Starter kit has 2 potions (item 14), hunts add 2 more → total 4.
|
|
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, 4);
|
|
// weaponId is the first non-stackable weapon (item 1 = Rusty Sword).
|
|
// Starter kit has 1 equipped, 2 drops from hunts → total 3 rows.
|
|
const allWeapons = db.prepare('SELECT quantity FROM inventory WHERE character_id = ? AND item_id = ?').all(id, weaponId);
|
|
assert.equal(allWeapons.length, 3);
|
|
assert.ok(allWeapons.every((w) => w.quantity === 1));
|
|
});
|
|
});
|