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
-118
View File
@@ -1,118 +0,0 @@
// 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);
}
});
});
+7 -11
View File
@@ -10,17 +10,13 @@ let dbRef;
function createFreshApp() {
process.env.DB_PATH = dbPath;
const cached = [
require.resolve('../src/db'),
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'),
];
for (const id of cached) {
if (require.cache[id]) delete require.cache[id];
// Drop EVERY app module from the require cache so each test gets a fresh app + DB handle.
// (A hand-picked list missed routes/*, which kept services bound to a previous, closed DB.)
const root = path.resolve(__dirname, '..');
for (const id of Object.keys(require.cache)) {
if (id.startsWith(path.join(root, 'src') + path.sep) || id.startsWith(path.join(root, 'scripts') + path.sep)) {
delete require.cache[id];
}
}
const { runMigrations } = require('../scripts/migrate');
@@ -1,7 +1,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const { setup, teardown, cleanup } = require('./helpers');
const { setup, teardown, cleanup } = require('../helpers');
// Simple cookie jar for tests
function createJar() {
@@ -0,0 +1,54 @@
// Service-level tests for characterService (profile + energy countdown).
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;
describe('characterService.getCharacterProfile', () => {
let db, svc, time;
before(async () => {
await setup();
db = require('../../src/db').getDb();
svc = require('../../src/services/characterService');
time = require('../../src/utils/time');
time.setNow(() => NOW);
});
after(async () => { time.resetNow(); await teardown(); cleanup(); });
function hero(n, energy, updatedAt) {
const uid = Number(db.prepare('INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)').run(`cu${n}`, 'x', NOW).lastInsertRowid);
const { id } = svc.createCharacter(uid, `CHero${n}`, 'mage');
db.prepare('UPDATE characters SET energy = ?, energy_updated_at = ? WHERE id = ?').run(energy, updatedAt, id);
return uid;
}
it('REGRESSION: nextTickInSec is time to the NEXT +1, not time until full', () => {
// 40/100 energy, last tick 60s ago → next +1 in 120s (regen is 180s per point)
const p = svc.getCharacterProfile(hero(1, 40, NOW - 60_000));
assert.equal(p.energy.current, 40);
assert.equal(p.energy.nextTickInSec, 120);
});
it('nextTickInSec is 0 when energy is full', () => {
const p = svc.getCharacterProfile(hero(2, 100, NOW));
assert.equal(p.energy.current, 100);
assert.equal(p.energy.nextTickInSec, 0);
});
it('applies offline regen when the profile is read', () => {
// 10 energy, last tick 30 minutes ago → +10
const p = svc.getCharacterProfile(hero(3, 10, NOW - 30 * 60_000));
assert.equal(p.energy.current, 20);
});
it('a new hero starts at full HP and full energy, timestamped "now" (not server start time)', () => {
const uid = Number(db.prepare('INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)').run('cu4', 'x', NOW).lastInsertRowid);
const { id } = svc.createCharacter(uid, 'CHero4', 'warrior');
const c = db.prepare('SELECT * FROM characters WHERE id = ?').get(id);
assert.equal(c.energy, 100);
assert.equal(c.energy_updated_at, NOW);
assert.equal(c.hp_current, 135); // warrior L1: 50 + 8*10 + 5
});
});
@@ -1,7 +1,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const { createApp } = require('../src/app');
const { createApp } = require('../../src/app');
function startServer(app) {
return new Promise((resolve) => {
+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));
});
});
+105
View File
@@ -0,0 +1,105 @@
// Tests for src/game/combat.js — pure combat resolution (no DB, no clock).
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { resolveCombat, MAX_ROUNDS } = require('../../src/game/combat');
const { mulberry32 } = require('../../src/utils/rng');
// Level-1 warrior: attack 22, defense 13, speed 14, maxHp 135, crit 7%
const warrior = {
class: 'warrior', level: 1, hpCurrent: 135, str: 8, agi: 4, int: 2, vit: 8,
};
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 drake = { name: 'Ashfang Drake', level: 14, hp: 300, attack: 50, defense: 30, speed: 18 };
// A punching bag: very high HP, harmless, slow. Hero always acts first and the fight lasts all rounds.
const dummy = { name: 'Dummy', level: 1, hp: 100000, attack: 1, defense: 0, speed: 1 };
const constRng = (v) => () => v;
describe('resolveCombat', () => {
it('hero beats an easy monster and ends with HP left', () => {
const r = resolveCombat(warrior, rat, mulberry32(42));
assert.equal(r.winner, 'hero');
assert.ok(r.hpRemaining > 0 && r.hpRemaining <= warrior.hpCurrent);
});
it('hero loses to a far stronger monster and hpRemaining is 0 (hero HP, not monster HP)', () => {
const r = resolveCombat(warrior, drake, mulberry32(99));
assert.equal(r.winner, 'monster');
assert.equal(r.hpRemaining, 0);
});
it('REGRESSION: a slower hero still attacks (both sides act every round)', () => {
// Warrior speed 14 < wolf speed 16. The old code never let the slower side act.
const r = resolveCombat(warrior, wolf, mulberry32(1));
const actors = new Set(r.rounds.map((x) => x.actor));
assert.ok(actors.has('hero') && actors.has('monster'));
});
it('the faster side acts first each round; hero wins speed ties', () => {
const vsFast = resolveCombat(warrior, wolf, mulberry32(1));
assert.equal(vsFast.rounds[0].actor, 'monster'); // 16 > 14
assert.equal(vsFast.rounds[1].actor, 'hero');
const vsSlow = resolveCombat(warrior, rat, mulberry32(1));
assert.equal(vsSlow.rounds[0].actor, 'hero'); // 14 > 12
const tie = resolveCombat(warrior, { ...rat, speed: 14 }, mulberry32(1));
assert.equal(tie.rounds[0].actor, 'hero');
});
it('a monster killed by the first attacker never gets to swing', () => {
const glassRat = { ...rat, hp: 1 };
const r = resolveCombat(warrior, glassRat, mulberry32(3));
assert.equal(r.rounds.length, 1);
assert.equal(r.hpRemaining, warrior.hpCurrent);
});
it('is fully deterministic for the same seed', () => {
const a = resolveCombat(warrior, wolf, mulberry32(123));
const b = resolveCombat(warrior, wolf, mulberry32(123));
assert.deepEqual(a, b);
});
it('stops after MAX_ROUNDS and the higher HP% wins', () => {
// Hero deals ~2/hit against a huge-defense, huge-HP monster; monster deals 1/hit.
const tank = { name: 'Wall', level: 1, hp: 1000, attack: 1, defense: 1000, speed: 1 };
const r = resolveCombat({ ...warrior, hpCurrent: 100 }, tank, mulberry32(5));
assert.equal(r.rounds.length, MAX_ROUNDS * 2);
// hero ≈ 80/135 HP left (59%), monster ≈ 960/1000 (96%) → monster wins on percentage
assert.equal(r.winner, 'monster');
assert.ok(r.hpRemaining > 0);
});
it('crits multiply damage by 1.5 (forced with a constant rng)', () => {
// rng=0 → variance 0.9, crit roll 0 < 7% → crit. damage = round(22 * 0.9 * 1.5) = 30
const crit = resolveCombat(warrior, dummy, constRng(0)).rounds[0];
assert.equal(crit.crit, true);
assert.equal(crit.damage, 30);
// rng=0.999 → variance ≈1.0998, no crit. damage = round(22 * 1.0998) = 24
const plain = resolveCombat(warrior, dummy, constRng(0.999)).rounds[0];
assert.equal(plain.crit, false);
assert.equal(plain.damage, 24);
});
it('damage is never below 1', () => {
const wall = { ...dummy, defense: 1000000 };
const r = resolveCombat(warrior, wall, constRng(0.5));
assert.ok(r.rounds.every((x) => x.damage >= 1));
});
it('equipment attack bonus increases damage', () => {
const plain = resolveCombat(warrior, dummy, constRng(0.5)).rounds[0].damage;
const armed = resolveCombat({ ...warrior, bonus_attack: 40 }, dummy, constRng(0.5)).rounds[0].damage;
assert.ok(armed > plain);
});
it('REGRESSION: mage gets +15% only against defense >= 15', () => {
const mage = { class: 'mage', level: 1, hpCurrent: 100000, str: 2, agi: 4, int: 30, vit: 5 };
const dmgAt = (defense) => resolveCombat(mage, { ...dummy, defense }, constRng(0.5)).rounds[0].damage;
// def 15 is MORE mitigation than def 14, yet the mage bonus makes it hit harder.
assert.ok(dmgAt(15) > dmgAt(14));
// A warrior has no such bonus: higher defense means less damage.
const war = { ...warrior, hpCurrent: 100000, str: 30 };
const wDmgAt = (defense) => resolveCombat(war, { ...dummy, defense }, constRng(0.5)).rounds[0].damage;
assert.ok(wDmgAt(15) < wDmgAt(14));
});
});