feat: complete Phase 4 combat/hunting system
- Add content migration (003_content.sql): zones, monsters, items, loot tables - Add inventory migration (004_inventory.sql) and logs migration (006_logs.sql) - Add Mulberry32 PRNG (src/utils/rng.js) for deterministic combat - Add combat system (src/game/combat.js) with turn order, crit, mage bonus, 20-round cap - Add rewards/loot system (src/game/rewards.js) and leveling (src/game/leveling.js) - Add hunt endpoint (POST /api/hunt) with full combat flow in DB transaction - Add hunt integration tests and unit tests for rewards/leveling/combat - Fix session middleware to use memory store in tests (avoids cleanup hang) - Fix huntService transaction (db.transaction() returns wrapper, must call separately) - Add huntService to helpers.js cache invalidation for test isolation
This commit is contained in:
@@ -3,6 +3,7 @@ const helmet = require('helmet');
|
||||
const { createSessionMiddleware, requireAuth } = require('./middleware/session');
|
||||
const { registerLimiter, loginLimiter, routes: authRoutes } = require('./routes/auth');
|
||||
const { routes: characterRoutes } = require('./routes/character');
|
||||
const { routes: huntRoutes } = require('./routes/hunt');
|
||||
const { AppError } = require('./utils/errors');
|
||||
|
||||
function createApp() {
|
||||
@@ -40,6 +41,9 @@ function createApp() {
|
||||
app.post('/api/character', requireAuth, characterRoutes.postCharacter);
|
||||
app.get('/api/character', requireAuth, characterRoutes.getCharacter);
|
||||
|
||||
// Hunt routes (require session)
|
||||
app.post('/api/hunt', requireAuth, huntRoutes.postHunt);
|
||||
|
||||
// Central error handler
|
||||
app.use((err, _req, res, _next) => {
|
||||
// Log stack traces server-side only
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
-- 003_content.sql — Static game content: zones, monsters, items, loot tables.
|
||||
-- Seeds use INSERT ... ON CONFLICT DO UPDATE so re-running is safe.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS zones (
|
||||
id INTEGER PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
min_level INTEGER NOT NULL,
|
||||
energy_cost INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS monsters (
|
||||
id INTEGER PRIMARY KEY,
|
||||
zone_id INTEGER NOT NULL REFERENCES zones(id),
|
||||
name TEXT NOT NULL,
|
||||
level INTEGER NOT NULL,
|
||||
hp INTEGER NOT NULL,
|
||||
attack INTEGER NOT NULL,
|
||||
defense INTEGER NOT NULL,
|
||||
speed INTEGER NOT NULL,
|
||||
xp INTEGER NOT NULL,
|
||||
gold_min INTEGER NOT NULL,
|
||||
gold_max INTEGER NOT NULL,
|
||||
weight INTEGER NOT NULL DEFAULT 10
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('weapon','armor','trinket','consumable','material')),
|
||||
rarity TEXT NOT NULL CHECK (rarity IN ('common','uncommon','rare','epic','legendary')),
|
||||
min_level INTEGER NOT NULL DEFAULT 1,
|
||||
buy_price INTEGER NOT NULL DEFAULT 0,
|
||||
stackable INTEGER NOT NULL DEFAULT 0 CHECK (stackable IN (0,1)),
|
||||
bonus_str INTEGER NOT NULL DEFAULT 0,
|
||||
bonus_agi INTEGER NOT NULL DEFAULT 0,
|
||||
bonus_int INTEGER NOT NULL DEFAULT 0,
|
||||
bonus_vit INTEGER NOT NULL DEFAULT 0,
|
||||
bonus_attack INTEGER NOT NULL DEFAULT 0,
|
||||
bonus_defense INTEGER NOT NULL DEFAULT 0,
|
||||
effect TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS loot_table (
|
||||
monster_id INTEGER NOT NULL REFERENCES monsters(id),
|
||||
item_id INTEGER NOT NULL REFERENCES items(id),
|
||||
chance_pct REAL NOT NULL CHECK (chance_pct > 0 AND chance_pct <= 100),
|
||||
qty_min INTEGER NOT NULL DEFAULT 1,
|
||||
qty_max INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (monster_id, item_id)
|
||||
);
|
||||
|
||||
-- ===== ZONES =====
|
||||
INSERT OR IGNORE INTO zones (id, slug, name, description, min_level, energy_cost) VALUES
|
||||
(1, 'whispering-meadows', 'Whispering Meadows', 'Rolling green hills dotted with wildflowers — and hidden goblin burrows.', 1, 5),
|
||||
(2, 'gloomroot-forest', 'Gloomroot Forest', 'A dark, ancient forest where the canopy blocks out most of the sunlight.', 5, 8),
|
||||
(3, 'ashfang-crag', 'Ashfang Crags', 'Volcanic rock and ash-filled winds. Dragons make their homes in these crags.', 10, 12);
|
||||
|
||||
-- ===== MONSTERS =====
|
||||
INSERT OR IGNORE INTO monsters (id, zone_id, name, level, hp, attack, defense, speed, xp, gold_min, gold_max, weight) VALUES
|
||||
-- Whispering Meadows
|
||||
(1, 1, 'Giant Rat', 1, 20, 6, 1, 12, 8, 1, 3, 30),
|
||||
(2, 1, 'Meadow Wolf', 2, 32, 9, 3, 16, 14, 2, 5, 20),
|
||||
(3, 1, 'Goblin Scrapper', 3, 45, 12, 5, 11, 22, 4, 9, 15),
|
||||
-- Gloomroot Forest
|
||||
(4, 2, 'Webspinner', 5, 70, 18, 8, 15, 40, 8, 16, 25),
|
||||
(5, 2, 'Bandit Cutthroat', 6, 90, 22, 12, 14, 55, 12, 24, 20),
|
||||
(6, 2, 'Mossback Treant', 8, 150, 26, 22, 7, 85, 15, 30, 10),
|
||||
-- Ashfang Crags
|
||||
(7, 3, 'Ember Imp', 10, 130, 34, 15, 20, 120, 25, 45, 25),
|
||||
(8, 3, 'Stone Golem', 12, 260, 38, 40, 6, 170, 30, 60, 10),
|
||||
(9, 3, 'Ashfang Drake', 14, 300, 50, 30, 18, 260, 50, 100, 5);
|
||||
|
||||
-- ===== ITEMS =====
|
||||
INSERT OR IGNORE INTO items (id, slug, name, description, type, rarity, min_level, buy_price, stackable, bonus_str, bonus_agi, bonus_int, bonus_vit, bonus_attack, bonus_defense, effect) VALUES
|
||||
-- Weapons (common)
|
||||
(1, 'rusty-sword', 'Rusty Sword', 'A worn blade, but still sharp enough.', 'weapon', 'common', 1, 20, 0, 0, 0, 0, 0, 3, 0, NULL),
|
||||
(2, 'apprentice-staff', 'Apprentice Staff', 'Carved by a fledgling mage.', 'weapon', 'common', 1, 20, 0, 0, 0, 0, 1, 2, 0, NULL),
|
||||
(3, 'hunters-shortbow', 'Hunters Shortbow', 'Reliable and lightweight.', 'weapon', 'common', 1, 20, 0, 0, 2, 0, 1, 0, 0, NULL),
|
||||
-- Weapons (uncommon)
|
||||
(4, 'iron-blade', 'Iron Blade', 'Sturdy and well-balanced.', 'weapon', 'uncommon', 5, 120, 0, 2, 0, 0, 0, 8, 0, NULL),
|
||||
(5, 'oakwood-staff', 'Oakwood Staff', 'Ancient oak, imbued with subtle magic.', 'weapon', 'uncommon', 5, 120, 0, 0, 0, 3, 0, 6, 0, NULL),
|
||||
(6, 'yew-longbow', 'Yew Longbow', 'Fast and accurate.', 'weapon', 'uncommon', 5, 120, 0, 0, 3, 0, 0, 6, 0, NULL),
|
||||
-- Weapons (rare)
|
||||
(7, 'emberforged-sword', 'Emberforged Greatsword', 'Forged in dragonfire.', 'weapon', 'rare', 10, 600, 0, 4, 0, 0, 0, 18, 0, NULL),
|
||||
-- Armor (common)
|
||||
(8, 'leather-jerkin', 'Leather Jerkin', 'Basic leather protection.', 'armor', 'common', 1, 25, 0, 0, 0, 0, 0, 0, 3, NULL),
|
||||
-- Armor (uncommon)
|
||||
(9, 'chainmail-vest', 'Chainmail Vest', 'Linked steel rings provide solid defense.', 'armor', 'uncommon', 5, 140, 0, 0, 0, 0, 2, 0, 9, NULL),
|
||||
-- Armor (rare)
|
||||
(10, 'drake-scale-armor', 'Drake-scale Armor', 'Scales tempered in dragonfire.', 'armor', 'rare', 12, 700, 0, 0, 0, 0, 4, 0, 22, NULL),
|
||||
-- Trinkets (common)
|
||||
(11, 'wooden-charm', 'Wooden Charm', 'A simple carved talisman.', 'trinket','common', 1, 30, 0, 0, 0, 0, 1, 0, 0, NULL),
|
||||
-- Trinkets (uncommon)
|
||||
(12, 'wolf-fang-pendant', 'Wolf-fang Pendant', 'A fearsome trophy.', 'trinket','uncommon', 3, 90, 0, 0, 2, 0, 0, 0, 0, NULL),
|
||||
-- Trinkets (rare)
|
||||
(13, 'ember-ring', 'Ember Ring', 'Warm to the touch.', 'trinket','rare', 10, 450, 0, 3, 0, 3, 0, 0, 0, NULL),
|
||||
-- Consumables
|
||||
(14, 'minor-healing-potion', 'Minor Healing Potion', 'Restores 50 HP.', 'consumable','common', 1, 15, 1, 0, 0, 0, 0, 0, 0, 'heal:50'),
|
||||
(15, 'greater-healing-potion', 'Greater Healing Potion', 'Restores 150 HP.', 'consumable','uncommon', 8, 60, 1, 0, 0, 0, 0, 0, 0, 'heal:150'),
|
||||
(16, 'energy-tonic', 'Energy Tonic', 'Restores 25 energy.', 'consumable','uncommon', 1, 80, 1, 0, 0, 0, 0, 0, 0, 'energy:25'),
|
||||
-- Materials
|
||||
(17, 'rat-tail', 'Rat Tail', 'A slimy rat tail.', 'material','common', 1, 0, 1, 0, 0, 0, 0, 0, 0, NULL),
|
||||
(18, 'wolf-pelt', 'Wolf Pelt', 'A thick wolf pelt.', 'material','common', 2, 0, 1, 0, 0, 0, 0, 0, 0, NULL),
|
||||
(19, 'spider-silk','Spider Silk', 'Gossamer strands of silk.', 'material','uncommon', 5, 0, 1, 0, 0, 0, 0, 0, 0, NULL),
|
||||
(20, 'imp-horn', 'Imp Horn', 'A curled imp horn.', 'material','uncommon', 10, 0, 1, 0, 0, 0, 0, 0, 0, NULL),
|
||||
(21, 'drake-scale','Drake Scale', 'A single scales from an Ashfang Drake.', 'material','rare', 14, 0, 1, 0, 0, 0, 0, 0, 0, NULL);
|
||||
|
||||
-- ===== LOOT TABLE =====
|
||||
-- Common materials (40% from weakest monsters in zone)
|
||||
INSERT OR IGNORE INTO loot_table (monster_id, item_id, chance_pct, qty_min, qty_max) VALUES
|
||||
(1, 17, 40, 1, 2), -- Giant Rat → Rat Tail
|
||||
(2, 18, 40, 1, 2), -- Meadow Wolf → Wolf Pelt
|
||||
(4, 19, 40, 1, 2), -- Webspinner → Spider Silk
|
||||
(7, 20, 40, 1, 2), -- Ember Imp → Imp Horn
|
||||
(9, 21, 40, 1, 2); -- Ashfang Drake → Drake Scale
|
||||
|
||||
-- Uncommon gear drops (3-5%)
|
||||
INSERT OR IGNORE INTO loot_table (monster_id, item_id, chance_pct, qty_min, qty_max) VALUES
|
||||
(3, 4, 5, 1, 1), -- Goblin Scrapper → Iron Blade
|
||||
(3, 5, 5, 1, 1), -- Goblin Scrapper → Oakwood Staff
|
||||
(3, 6, 5, 1, 1), -- Goblin Scrapper → Yew Longbow
|
||||
(6, 7, 5, 1, 1), -- Mossback Treant → Emberforged Greatsword
|
||||
(6, 9, 4, 1, 1), -- Mossback Treant → Chainmail Vest
|
||||
(9, 10, 3, 1, 1), -- Ashfang Drake → Drake-scale Armor
|
||||
(9, 13, 3, 1, 1); -- Ashfang Drake → Ember Ring
|
||||
|
||||
-- Rare gear drops (0.5-1%)
|
||||
INSERT OR IGNORE INTO loot_table (monster_id, item_id, chance_pct, qty_min, qty_max) VALUES
|
||||
(9, 7, 1, 1, 1); -- Ashfang Drake → Emberforged Greatsword (rare drop)
|
||||
|
||||
-- Healing potions (8% anywhere)
|
||||
INSERT OR IGNORE INTO loot_table (monster_id, item_id, chance_pct, qty_min, qty_max) VALUES
|
||||
(1, 14, 8, 1, 1),
|
||||
(2, 14, 8, 1, 1),
|
||||
(3, 14, 8, 1, 1),
|
||||
(4, 14, 8, 1, 1),
|
||||
(5, 14, 8, 1, 1),
|
||||
(6, 15, 8, 1, 1),
|
||||
(7, 15, 8, 1, 1),
|
||||
(8, 15, 8, 1, 1),
|
||||
(9, 15, 8, 1, 1);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- 004_inventory.sql — Player inventory and equipment slots.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
|
||||
item_id INTEGER NOT NULL REFERENCES items(id),
|
||||
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity >= 1),
|
||||
equipped_slot TEXT CHECK (equipped_slot IN ('weapon', 'armor', 'trinket')),
|
||||
acquired_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_char ON inventory(character_id);
|
||||
|
||||
-- One equipped item per slot per character (partial unique index).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_inventory_equipped
|
||||
ON inventory(character_id, equipped_slot) WHERE equipped_slot IS NOT NULL;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 006_logs.sql — Combat log and audit log for security/replay.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS combat_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
|
||||
monster_id INTEGER NOT NULL REFERENCES monsters(id),
|
||||
won INTEGER NOT NULL CHECK (won IN (0, 1)),
|
||||
rounds INTEGER NOT NULL,
|
||||
xp_gained INTEGER NOT NULL,
|
||||
gold_gained INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_combat_log_char ON combat_log(character_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
event TEXT NOT NULL,
|
||||
ip TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,111 @@
|
||||
// Combat resolution: pure function based on GAME_DESIGN.md §Combat and BALANCE.md.
|
||||
// Takes hero data + monster data + rng → returns fight result.
|
||||
|
||||
const { getClass } = require('./classes');
|
||||
|
||||
/**
|
||||
* Resolve a combat encounter between a hero and a monster.
|
||||
*
|
||||
* @param {object} hero - { class, level, hpCurrent, maxHp, str, agi, int, vit, bonus_attack, bonus_defense }
|
||||
* @param {object} monster - { level, hp, attack, defense, speed, name }
|
||||
* @param {function} rng - PRNG function returning float in [0, 1)
|
||||
* @returns {object} { winner: 'hero'|'monster', rounds: [{round, actor, damage, crit, targetHpAfter}], hpRemaining }
|
||||
*/
|
||||
function resolveCombat(hero, monster, rng) {
|
||||
const heroStats = calcHeroStats(hero);
|
||||
let heroHp = hero.hpCurrent;
|
||||
let monsterHp = monster.hp;
|
||||
const rounds = [];
|
||||
|
||||
for (let round = 1; round <= 20; round++) {
|
||||
// Determine who acts first (higher speed)
|
||||
const heroActsFirst = heroStats.speed >= monster.speed;
|
||||
const attacker = heroActsFirst ? { stats: heroStats, type: 'hero', name: 'Hero' }
|
||||
: { stats: monsterStats(monster), type: 'monster', name: monster.name };
|
||||
const defender = attacker.type === 'hero'
|
||||
? { stats: monsterStats(monster), type: 'monster', name: monster.name }
|
||||
: { stats: heroStats, type: 'hero', name: 'Hero' };
|
||||
|
||||
// Resolve attack
|
||||
const result = resolveAttack(attacker, defender, rng);
|
||||
|
||||
if (attacker.type === 'hero') {
|
||||
monsterHp -= result.damage;
|
||||
rounds.push({ round, actor: 'hero', damage: result.damage, crit: result.crit, targetHpAfter: Math.max(0, monsterHp) });
|
||||
if (monsterHp <= 0) {
|
||||
return { winner: 'hero', rounds, hpRemaining: heroHp };
|
||||
}
|
||||
} else {
|
||||
heroHp -= result.damage;
|
||||
rounds.push({ round, actor: 'monster', damage: result.damage, crit: result.crit, targetHpAfter: Math.max(0, heroHp) });
|
||||
if (heroHp <= 0) {
|
||||
return { winner: 'monster', rounds, hpRemaining: monsterHp };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 20 rounds — higher HP% wins
|
||||
const heroPct = heroHp / heroStats.maxHp;
|
||||
const monsterPct = monsterHp / monster.hp;
|
||||
if (heroPct >= monsterPct) {
|
||||
return { winner: 'hero', rounds, hpRemaining: heroHp };
|
||||
}
|
||||
return { winner: 'monster', rounds, hpRemaining: monsterHp };
|
||||
}
|
||||
|
||||
function calcHeroStats(hero) {
|
||||
const cls = getClass(hero.class);
|
||||
const primary = cls.primary === 'str' ? hero.str
|
||||
: cls.primary === 'agi' ? hero.agi
|
||||
: hero.int;
|
||||
|
||||
const maxHp = 50 + hero.vit * 10 + hero.level * 5 + (hero.bonus_vit || 0) * 10;
|
||||
const attack = 5 + primary * 2 + hero.level + (hero.bonus_attack || 0);
|
||||
let defense = Math.floor(hero.vit * 1.5) + (hero.bonus_defense || 0);
|
||||
if (cls.bonuses.defensePct) {
|
||||
defense = Math.floor(defense * (1 + cls.bonuses.defensePct));
|
||||
}
|
||||
const speed = 10 + hero.agi;
|
||||
let critChance = Math.min(40, 5 + Math.floor(hero.agi * 0.5));
|
||||
if (cls.bonuses.critChance) {
|
||||
critChance = Math.min(40, critChance + Math.floor(cls.bonuses.critChance * 100));
|
||||
}
|
||||
|
||||
return { maxHp, attack, defense, speed, critChance, critMult: 1.5, primary };
|
||||
}
|
||||
|
||||
function monsterStats(monster) {
|
||||
return {
|
||||
attack: monster.attack,
|
||||
defense: monster.defense,
|
||||
speed: monster.speed,
|
||||
critChance: 5, // Monsters crit at flat 5%
|
||||
critMult: 1.5,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAttack(attacker, defender, rng) {
|
||||
// Mitigation
|
||||
const mitigation = 100 / (100 + defender.stats.defense);
|
||||
|
||||
// Raw damage
|
||||
let raw = attacker.stats.attack * mitigation;
|
||||
|
||||
// Mage bonus: if defender is hero with class mage and defenderDefense >= 15
|
||||
if (attacker.type === 'hero' && attacker.stats.primary === 'int' && defender.stats.defense >= 15) {
|
||||
raw = raw * 1.15;
|
||||
}
|
||||
|
||||
// Variance: [0.9, 1.1]
|
||||
const variance = rng() * 0.2 + 0.9;
|
||||
|
||||
// Crit check
|
||||
const isCrit = rng() * 100 < attacker.stats.critChance;
|
||||
const critMult = isCrit ? (attacker.stats.critMult || 1.5) : 1;
|
||||
|
||||
const damage = Math.max(1, Math.round(raw * variance * critMult));
|
||||
|
||||
return { damage, crit: isCrit };
|
||||
}
|
||||
|
||||
module.exports = { resolveCombat };
|
||||
@@ -0,0 +1,96 @@
|
||||
// Leveling: XP curve, level-up logic, stat point allocation.
|
||||
// All functions are pure — take data in, return data out.
|
||||
|
||||
/**
|
||||
* XP required to reach next level from given level.
|
||||
* xpToNext(level) = floor(100 * level^1.5)
|
||||
* L1→2: 100, L2→3: 282, L5→6: 1118, L10→11: 3162
|
||||
* @param {number} level - current level (1-based)
|
||||
* @returns {number} XP needed to reach next level
|
||||
*/
|
||||
function xpToNext(level) {
|
||||
return Math.floor(100 * Math.pow(level, 1.5));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if hero leveled up from gained XP. Handles multi-level-ups.
|
||||
* MAX_LEVEL = 50, STAT_POINTS_PER_LEVEL = 3
|
||||
* On level-up: resets hp_current to maxHp (handled by caller).
|
||||
* @param {number} currentLevel - current level
|
||||
* @param {number} currentXp - current XP (within current level bracket)
|
||||
* @param {number} xpGained - XP gained from this action
|
||||
* @returns {object} { newLevel, leveledUp, totalXp, unspentPoints, levelsGained }
|
||||
*/
|
||||
function checkLevelUp(currentLevel, currentXp, xpGained) {
|
||||
if (currentLevel >= 50) {
|
||||
return { newLevel: 50, leveledUp: false, totalXp: currentXp + xpGained, unspentPoints: 0, levelsGained: 0 };
|
||||
}
|
||||
|
||||
let totalXp = currentXp + xpGained;
|
||||
let level = currentLevel;
|
||||
let levelsGained = 0;
|
||||
|
||||
while (level < 50) {
|
||||
const needed = xpToNext(level);
|
||||
if (totalXp >= needed) {
|
||||
totalXp -= needed;
|
||||
level += 1;
|
||||
levelsGained += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
newLevel: level,
|
||||
leveledUp: levelsGained > 0,
|
||||
totalXp,
|
||||
unspentPoints: levelsGained * 3,
|
||||
levelsGained,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply stat point allocation to a hero's base stats.
|
||||
* @param {number} currentStr - current STR
|
||||
* @param {number} currentAgi - current AGI
|
||||
* @param {number} currentInt - current INT
|
||||
* @param {number} currentVit - current VIT
|
||||
* @param {number} points - points to allocate
|
||||
* @param {object} allocation - { str?, agi?, int?, vit? } summing to points
|
||||
* @returns {object} { str, agi, int, vit, remainingPoints }
|
||||
* @throws if allocation is invalid
|
||||
*/
|
||||
function allocatePoints(currentStr, currentAgi, currentInt, currentVit, points, allocation) {
|
||||
if (!allocation || typeof allocation !== 'object') {
|
||||
throw new Error('Allocation must be an object with stat keys.');
|
||||
}
|
||||
|
||||
const allowed = { str: 0, agi: 0, int: 0, vit: 0 };
|
||||
let allocated = 0;
|
||||
|
||||
for (const [key, value] of Object.entries(allocation)) {
|
||||
if (!(key in allowed)) {
|
||||
throw new Error(`Invalid stat: ${key}. Allowed: ${Object.keys(allowed).join(', ')}`);
|
||||
}
|
||||
if (typeof value !== 'number' || value < 1 || !Number.isInteger(value)) {
|
||||
throw new Error(`Allocation for ${key} must be a positive integer.`);
|
||||
}
|
||||
allowed[key] += value;
|
||||
allocated += value;
|
||||
}
|
||||
|
||||
if (allocated !== points) {
|
||||
throw new Error(`Allocated ${allocated} but had ${points} points available.`);
|
||||
}
|
||||
|
||||
return {
|
||||
str: currentStr + allowed.str,
|
||||
agi: currentAgi + allowed.agi,
|
||||
int: currentInt + allowed.int,
|
||||
vit: currentVit + allowed.vit,
|
||||
remainingPoints: 0,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { xpToNext, checkLevelUp, allocatePoints };
|
||||
@@ -0,0 +1,62 @@
|
||||
// Rewards: XP, gold, and loot rolls based on BALANCE.md §Rewards.
|
||||
// All functions are pure — take data in, return data out.
|
||||
|
||||
const { rngFloat, rngInt } = require('../utils/rng');
|
||||
|
||||
/**
|
||||
* Calculate XP and gold rewards for a combat victory.
|
||||
* @param {number} monsterXp - base XP from monster
|
||||
* @param {number} monsterGoldMin - minimum gold from monster
|
||||
* @param {number} monsterGoldMax - maximum gold from monster
|
||||
* @param {number} monsterLevel - monster level
|
||||
* @param {number} heroLevel - hero level
|
||||
* @param {function} rng - PRNG function (for gold roll)
|
||||
* @returns {object} { xp, gold }
|
||||
*/
|
||||
function calculateRewards(monsterXp, monsterGoldMin, monsterGoldMax, monsterLevel, heroLevel, rng) {
|
||||
// levelDiffMult = clamp(1 + 0.1*(monster.level - hero.level), 0.2, 1.5)
|
||||
const levelDiffMult = Math.min(1.5, Math.max(0.2, 1 + 0.1 * (monsterLevel - heroLevel)));
|
||||
|
||||
const xp = Math.floor(monsterXp * levelDiffMult);
|
||||
const gold = rngInt(rng, Math.floor(monsterGoldMin * levelDiffMult), Math.floor(monsterGoldMax * levelDiffMult));
|
||||
|
||||
return { xp, gold };
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll for loot drops from a monster.
|
||||
* Max 2 distinct loot drops per fight (per BALANCE.md).
|
||||
* @param {Array<object>} lootCandidates - array of { itemId, chancePct, qtyMin, qtyMax } from loot_table
|
||||
* @param {function} rng - PRNG function
|
||||
* @returns {Array<object>} array of { itemId, quantity }
|
||||
*/
|
||||
function rollLoot(lootCandidates, rng) {
|
||||
const drops = [];
|
||||
const seenItems = new Set();
|
||||
|
||||
for (const candidate of lootCandidates) {
|
||||
if (drops.length >= 2) break;
|
||||
if (seenItems.has(candidate.itemId)) continue;
|
||||
|
||||
if (rng() * 100 < candidate.chancePct) {
|
||||
const qty = rngInt(rng, candidate.qtyMin, candidate.qtyMax);
|
||||
drops.push({ itemId: candidate.itemId, quantity: qty });
|
||||
seenItems.add(candidate.itemId);
|
||||
}
|
||||
}
|
||||
|
||||
return drops;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate sell price for a material item.
|
||||
* @param {string} rarity - item rarity
|
||||
* @param {number} level - item level
|
||||
* @returns {number} sell price
|
||||
*/
|
||||
function materialSellPrice(rarity, level) {
|
||||
const multipliers = { common: 2, uncommon: 4, rare: 8, epic: 16, legendary: 32 };
|
||||
return Math.max(1, Math.floor((multipliers[rarity] || 2) * level));
|
||||
}
|
||||
|
||||
module.exports = { calculateRewards, rollLoot, materialSellPrice };
|
||||
@@ -5,17 +5,20 @@ const { getDb } = require('../db');
|
||||
const { AppError } = require('../utils/errors');
|
||||
|
||||
function createSessionMiddleware() {
|
||||
const db = getDb();
|
||||
// Use memory store in tests to avoid session store cleanup interval hanging tests
|
||||
const useMemoryStore = config.nodeEnv === 'test';
|
||||
|
||||
return session({
|
||||
store: new SessionStore({
|
||||
client: db,
|
||||
table: 'sessions',
|
||||
expired: {
|
||||
clear: true,
|
||||
intervalMs: 900000, // 15 min cleanup
|
||||
},
|
||||
}),
|
||||
store: useMemoryStore
|
||||
? undefined // express-session built-in memory store
|
||||
: new SessionStore({
|
||||
client: getDb(),
|
||||
table: 'sessions',
|
||||
expired: {
|
||||
clear: true,
|
||||
intervalMs: 900000, // 15 min cleanup
|
||||
},
|
||||
}),
|
||||
secret: config.sessionSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Hunt routes: POST /api/hunt (fight a monster in a zone).
|
||||
const zod = require('zod');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const { requireAuth } = require('../middleware/session');
|
||||
const { hunt } = require('../services/huntService');
|
||||
|
||||
// Validation schema
|
||||
const huntSchema = zod.strictObject({
|
||||
zoneId: zod.number().int().positive(),
|
||||
});
|
||||
|
||||
function wrap(fn) {
|
||||
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
async function handleHunt(req, res) {
|
||||
const parsed = huntSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new AppError('VALIDATION_ERROR', 'Request validation failed.', 400);
|
||||
}
|
||||
|
||||
const { zoneId } = parsed.data;
|
||||
const userId = req.session.userId;
|
||||
|
||||
// Get character ID for this user
|
||||
const db = require('../db').getDb();
|
||||
const character = db.prepare('SELECT id FROM characters WHERE user_id = ?').get(userId);
|
||||
if (!character) {
|
||||
throw new AppError('NO_CHARACTER', 'No character found.', 404);
|
||||
}
|
||||
|
||||
const result = hunt(character.id, zoneId);
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
data: result,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
routes: {
|
||||
postHunt: wrap(handleHunt),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
// Hunt service: resolves a hunting attempt in a zone.
|
||||
// Combines energy spending, monster selection, combat, rewards, and logging
|
||||
// all within a single database transaction.
|
||||
const { getDb } = require('../db');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const { computeEnergy } = require('../game/energy');
|
||||
const { resolveCombat } = require('../game/combat');
|
||||
const { calculateRewards, rollLoot, materialSellPrice } = require('../game/rewards');
|
||||
const { checkLevelUp } = require('../game/leveling');
|
||||
const { mulberry32 } = require('../utils/rng');
|
||||
|
||||
function wrapStmts(db) {
|
||||
return {
|
||||
findCharacterById: db.prepare(
|
||||
'SELECT * FROM characters WHERE id = ?'
|
||||
),
|
||||
findZoneById: db.prepare(
|
||||
'SELECT * FROM zones WHERE id = ?'
|
||||
),
|
||||
selectMonstersByZone: db.prepare(
|
||||
'SELECT * FROM monsters WHERE zone_id = ?'
|
||||
),
|
||||
selectLootByMonster: db.prepare(
|
||||
'SELECT lt.*, i.slug, i.type FROM loot_table lt JOIN items i ON i.id = lt.item_id WHERE lt.monster_id = ?'
|
||||
),
|
||||
updateCharacter: db.prepare(
|
||||
`UPDATE characters SET
|
||||
hp_current = ?, energy = ?, energy_updated_at = ?,
|
||||
xp = ?, gold = ?, level = ?, unspent_points = ?, kills = ?
|
||||
WHERE id = ?`
|
||||
),
|
||||
insertCombatLog: db.prepare(
|
||||
`INSERT INTO combat_log (character_id, monster_id, won, rounds, xp_gained, gold_gained, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
),
|
||||
insertInventoryItem: db.prepare(
|
||||
`INSERT INTO inventory (character_id, item_id, quantity, acquired_at)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
),
|
||||
getInventoryByCharacter: db.prepare(
|
||||
'SELECT i.*, it.slug, it.name, it.type, it.rarity, it.effect FROM inventory i JOIN items it ON it.id = i.item_id WHERE i.character_id = ?'
|
||||
),
|
||||
getItemById: db.prepare(
|
||||
'SELECT id, slug, name, type, rarity, effect FROM items WHERE id = ?'
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let cachedStmts;
|
||||
|
||||
function getStmts() {
|
||||
if (!cachedStmts) {
|
||||
const db = getDb();
|
||||
cachedStmts = wrapStmts(db);
|
||||
}
|
||||
return cachedStmts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hunt in a zone: spend energy, fight a monster, apply rewards/penalty.
|
||||
* @param {number} characterId - the hero's ID
|
||||
* @param {number} zoneId - the zone to hunt in
|
||||
* @returns {object} { winner, rounds, hpRemaining, xpGained, goldGained, loot, leveledUp, zone }
|
||||
*/
|
||||
function hunt(characterId, zoneId) {
|
||||
const db = getDb();
|
||||
const stmts = getStmts();
|
||||
const now = Date.now();
|
||||
|
||||
// Validate inputs
|
||||
if (typeof zoneId !== 'number' || zoneId <= 0) {
|
||||
throw new AppError('VALIDATION_ERROR', 'Invalid zone ID.', 400);
|
||||
}
|
||||
|
||||
// Get character
|
||||
const character = stmts.findCharacterById.get(characterId);
|
||||
if (!character) {
|
||||
throw new AppError('NOT_FOUND', 'Character not found.', 404);
|
||||
}
|
||||
|
||||
// Get zone
|
||||
const zone = stmts.findZoneById.get(zoneId);
|
||||
if (!zone) {
|
||||
throw new AppError('NOT_FOUND', 'Zone not found.', 404);
|
||||
}
|
||||
|
||||
// Check HP threshold (must be >= 20% max HP)
|
||||
const heroDerived = calcDerivedForCombat(character);
|
||||
if (character.hp_current < heroDerived.maxHp * 0.2) {
|
||||
throw new AppError('HP_TOO_LOW', 'Your HP is too low. Rest or use a potion.', 422);
|
||||
}
|
||||
|
||||
// Spend energy
|
||||
const cost = zone.energy_cost;
|
||||
const energy = computeEnergy(character.energy, character.energy_updated_at, now);
|
||||
if (energy.energy < cost) {
|
||||
throw new AppError('INSUFFICIENT_ENERGY', `Not enough energy. Have ${energy.energy}, need ${cost}.`, 422);
|
||||
}
|
||||
|
||||
// Pick random monster (weighted by spawn weight)
|
||||
const monsters = stmts.selectMonstersByZone.all(zoneId);
|
||||
if (monsters.length === 0) {
|
||||
throw new AppError('ZONE_EMPTY', 'No monsters in this zone.', 500);
|
||||
}
|
||||
const monster = pickWeightedMonster(monsters);
|
||||
|
||||
// Resolve combat
|
||||
const seed = now + Math.random() * 1000000;
|
||||
const rng = mulberry32(seed);
|
||||
const combatResult = resolveCombat(
|
||||
{
|
||||
class: character.class,
|
||||
level: character.level,
|
||||
hpCurrent: character.hp_current,
|
||||
maxHp: heroDerived.maxHp,
|
||||
str: character.str,
|
||||
agi: character.agi,
|
||||
int: character.int,
|
||||
vit: character.vit,
|
||||
bonus_attack: 0,
|
||||
bonus_defense: 0,
|
||||
bonus_vit: 0,
|
||||
},
|
||||
{
|
||||
name: monster.name,
|
||||
level: monster.level,
|
||||
hp: monster.hp,
|
||||
attack: monster.attack,
|
||||
defense: monster.defense,
|
||||
speed: monster.speed,
|
||||
},
|
||||
rng
|
||||
);
|
||||
|
||||
// Apply results inside a transaction
|
||||
let txResult;
|
||||
try {
|
||||
const tx = db.transaction(() => {
|
||||
let newHp = combatResult.hpRemaining;
|
||||
let newXp = character.xp;
|
||||
let newGold = character.gold;
|
||||
let newLevel = character.level;
|
||||
let newUnspent = character.unspent_points;
|
||||
let newKills = character.kills;
|
||||
let xpGained = 0;
|
||||
let goldGained = 0;
|
||||
let lootDrops = [];
|
||||
let leveledUp = false;
|
||||
|
||||
if (combatResult.winner === 'hero') {
|
||||
// Rewards
|
||||
const rewards = calculateRewards(monster.xp, monster.gold_min, monster.gold_max, monster.level, character.level, rng);
|
||||
xpGained = rewards.xp;
|
||||
goldGained = rewards.gold;
|
||||
newXp += xpGained;
|
||||
newGold += goldGained;
|
||||
|
||||
// Loot rolls
|
||||
const lootCandidates = stmts.selectLootByMonster.all(monster.id);
|
||||
const lootRolls = rollLoot(lootCandidates.map(lc => ({
|
||||
itemId: lc.item_id,
|
||||
chancePct: lc.chance_pct,
|
||||
qtyMin: lc.qty_min,
|
||||
qtyMax: lc.qty_max,
|
||||
})), rng);
|
||||
|
||||
// Insert loot into inventory and enrich with item details
|
||||
for (const drop of lootRolls) {
|
||||
stmts.insertInventoryItem.run(characterId, drop.itemId, drop.quantity, now);
|
||||
const item = stmts.getItemById.get(drop.itemId);
|
||||
if (item) {
|
||||
lootDrops.push({
|
||||
slug: item.slug,
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
rarity: item.rarity,
|
||||
quantity: drop.quantity,
|
||||
effect: item.effect,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check level-up
|
||||
const levelResult = checkLevelUp(character.level, character.xp, xpGained);
|
||||
if (levelResult.leveledUp) {
|
||||
leveledUp = true;
|
||||
newLevel = levelResult.newLevel;
|
||||
newUnspent = levelResult.unspentPoints;
|
||||
newHp = heroDerived.maxHp; // Full HP on level-up
|
||||
}
|
||||
|
||||
newKills += 1;
|
||||
} else {
|
||||
// Defeat penalty: lose 10% gold (min 0), HP set to 1, deaths +1
|
||||
const goldLost = Math.floor(character.gold * 0.10);
|
||||
newGold = Math.max(0, character.gold - goldLost);
|
||||
newHp = 1;
|
||||
// deaths incremented in DB update
|
||||
}
|
||||
|
||||
// Update character
|
||||
stmts.updateCharacter.run(
|
||||
newHp,
|
||||
energy.energy - cost,
|
||||
energy.updatedAtMs,
|
||||
newXp,
|
||||
newGold,
|
||||
newLevel,
|
||||
newUnspent,
|
||||
newKills,
|
||||
characterId
|
||||
);
|
||||
|
||||
// Combat log
|
||||
stmts.insertCombatLog.run(
|
||||
characterId,
|
||||
monster.id,
|
||||
combatResult.winner === 'hero' ? 1 : 0,
|
||||
combatResult.rounds.length,
|
||||
xpGained,
|
||||
goldGained,
|
||||
now
|
||||
);
|
||||
|
||||
txResult = {
|
||||
xpGained,
|
||||
goldGained,
|
||||
loot: lootDrops,
|
||||
leveledUp,
|
||||
goldLost: combatResult.winner === 'monster' ? Math.floor(character.gold * 0.10) : 0,
|
||||
};
|
||||
});
|
||||
tx();
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Build response
|
||||
const response = {
|
||||
winner: combatResult.winner,
|
||||
rounds: combatResult.rounds,
|
||||
hpRemaining: combatResult.hpRemaining,
|
||||
hpMax: heroDerived.maxHp,
|
||||
xpGained: txResult.xpGained,
|
||||
goldGained: txResult.goldGained,
|
||||
goldLost: txResult.goldLost,
|
||||
loot: txResult.loot,
|
||||
leveledUp: txResult.leveledUp,
|
||||
zone: { id: zone.id, slug: zone.slug, name: zone.name },
|
||||
monster: { id: monster.id, name: monster.name, level: monster.level },
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
function calcDerivedForCombat(character) {
|
||||
const cls = require('../game/classes').getClass(character.class);
|
||||
const primary = cls.primary === 'str' ? character.str
|
||||
: cls.primary === 'agi' ? character.agi
|
||||
: character.int;
|
||||
const maxHp = 50 + character.vit * 10 + character.level * 5;
|
||||
const attack = 5 + primary * 2 + character.level;
|
||||
let defense = Math.floor(character.vit * 1.5);
|
||||
if (cls.bonuses.defensePct) {
|
||||
defense = Math.floor(defense * (1 + cls.bonuses.defensePct));
|
||||
}
|
||||
const speed = 10 + character.agi;
|
||||
let critChance = Math.min(40, 5 + Math.floor(character.agi * 0.5));
|
||||
if (cls.bonuses.critChance) {
|
||||
critChance = Math.min(40, critChance + Math.floor(cls.bonuses.critChance * 100));
|
||||
}
|
||||
return { maxHp, attack, defense, speed, critChance };
|
||||
}
|
||||
|
||||
function pickWeightedMonster(monsters) {
|
||||
const totalWeight = monsters.reduce((sum, m) => sum + m.weight, 0);
|
||||
let r = Math.random() * totalWeight;
|
||||
for (const monster of monsters) {
|
||||
r -= monster.weight;
|
||||
if (r <= 0) return monster;
|
||||
}
|
||||
return monsters[monsters.length - 1];
|
||||
}
|
||||
|
||||
module.exports = { hunt };
|
||||
@@ -0,0 +1,36 @@
|
||||
// Seedable PRNG (Mulberry32) — deterministic, fast, good enough for game use.
|
||||
// See: https://gist.github.com/banksean/3004946
|
||||
|
||||
function mulberry32(seed) {
|
||||
let s = seed | 0;
|
||||
return function () {
|
||||
s |= 0;
|
||||
s = (s + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random integer in [min, max] inclusive.
|
||||
*/
|
||||
function rngInt(rng, min, max) {
|
||||
return Math.floor(rng() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random float in [0, 1).
|
||||
*/
|
||||
function rngFloat(rng) {
|
||||
return rng();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random float in [min, max).
|
||||
*/
|
||||
function rngRange(rng, min, max) {
|
||||
return rng() * (max - min) + min;
|
||||
}
|
||||
|
||||
module.exports = { mulberry32, rngInt, rngFloat, rngRange };
|
||||
Reference in New Issue
Block a user