loop: baseline
This commit is contained in:
@@ -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
@@ -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) => {
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user