loop: baseline

This commit is contained in:
bepis
2026-10-20 20:33:35 +11:00
parent f1ae7b3119
commit 9a308b697d
32 changed files with 959 additions and 2046 deletions
+133
View File
@@ -0,0 +1,133 @@
// 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);
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, 2);
const weapons = db.prepare('SELECT quantity FROM inventory WHERE character_id = ? AND item_id = ?').all(id, weaponId);
assert.equal(weapons.length, 2);
assert.ok(weapons.every((w) => w.quantity === 1));
});
});