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