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:
bepis
2026-10-20 17:46:18 +11:00
parent 7a3e408ffc
commit f1ae7b3119
16 changed files with 1239 additions and 21 deletions
+4
View File
@@ -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
+145
View File
@@ -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);
+16
View File
@@ -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;
+22
View File
@@ -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
);
+111
View File
@@ -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 };
+96
View File
@@ -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 };
+62
View File
@@ -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 };
+12 -9
View File
@@ -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,
+44
View File
@@ -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),
},
};
+284
View File
@@ -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 };
+36
View File
@@ -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 };