loop: baseline
This commit is contained in:
@@ -53,6 +53,7 @@ function createApp() {
|
||||
|
||||
if (err instanceof AppError) {
|
||||
return res.status(err.status).json({
|
||||
ok: false,
|
||||
error: {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
@@ -63,6 +64,7 @@ function createApp() {
|
||||
// Check for Zod-like validation errors
|
||||
if (err.name === 'ZodError' || (err.errors && Array.isArray(err.errors))) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Request validation failed.',
|
||||
@@ -72,6 +74,7 @@ function createApp() {
|
||||
|
||||
// Generic error
|
||||
res.status(500).json({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Something went wrong.',
|
||||
|
||||
+59
-86
@@ -1,111 +1,84 @@
|
||||
// Combat resolution: pure function based on GAME_DESIGN.md §Combat and BALANCE.md.
|
||||
// Takes hero data + monster data + rng → returns fight result.
|
||||
// Takes hero data + monster data + rng → returns fight result. No I/O, no clock.
|
||||
//
|
||||
// Each ROUND, BOTH sides attack once. The higher-speed side attacks first (hero wins ties).
|
||||
// If the first attacker kills the target, the second does not get to act.
|
||||
|
||||
const { getClass } = require('./classes');
|
||||
const { calcDerived } = require('./stats');
|
||||
|
||||
const MAX_ROUNDS = 20;
|
||||
const MONSTER_CRIT_CHANCE = 5; // flat %
|
||||
const CRIT_MULT = 1.5;
|
||||
const MAGE_HIGH_DEF_THRESHOLD = 15;
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
* @param {object} hero - { class, level, hpCurrent, str, agi, int, vit, bonus_* (optional equipment bonuses) }
|
||||
* @param {object} monster - { name, level, hp, attack, defense, speed }
|
||||
* @param {function} rng - PRNG returning a float in [0, 1)
|
||||
* @returns {{winner: 'hero'|'monster', rounds: object[], hpRemaining: number}}
|
||||
* hpRemaining is ALWAYS the hero's remaining HP (0 if the hero died).
|
||||
*/
|
||||
function resolveCombat(hero, monster, rng) {
|
||||
const heroStats = calcHeroStats(hero);
|
||||
const stats = calcDerived(
|
||||
{ str: hero.str, agi: hero.agi, int: hero.int, vit: hero.vit },
|
||||
hero.level, hero.class, hero
|
||||
);
|
||||
const cls = getClass(hero.class);
|
||||
|
||||
const heroC = {
|
||||
type: 'hero',
|
||||
attack: stats.attack, defense: stats.defense, speed: stats.speed,
|
||||
critChance: stats.critChance,
|
||||
magicVsHighDef: cls.bonuses.magicVsHighDef || 0,
|
||||
};
|
||||
const monsterC = {
|
||||
type: 'monster',
|
||||
attack: monster.attack, defense: monster.defense, speed: monster.speed,
|
||||
critChance: MONSTER_CRIT_CHANCE,
|
||||
magicVsHighDef: 0,
|
||||
};
|
||||
|
||||
let heroHp = hero.hpCurrent;
|
||||
let monsterHp = monster.hp;
|
||||
const rounds = [];
|
||||
const order = heroC.speed >= monsterC.speed ? [heroC, monsterC] : [monsterC, heroC];
|
||||
|
||||
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' };
|
||||
for (let round = 1; round <= MAX_ROUNDS; round++) {
|
||||
for (const attacker of order) {
|
||||
const defender = attacker === heroC ? monsterC : heroC;
|
||||
const { damage, crit } = resolveAttack(attacker, defender, rng);
|
||||
|
||||
// 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 };
|
||||
if (attacker === heroC) {
|
||||
monsterHp -= damage;
|
||||
rounds.push({ round, actor: 'hero', damage, crit, targetHpAfter: Math.max(0, monsterHp) });
|
||||
if (monsterHp <= 0) return { winner: 'hero', rounds, hpRemaining: heroHp };
|
||||
} else {
|
||||
heroHp -= damage;
|
||||
rounds.push({ round, actor: 'monster', damage, crit, targetHpAfter: Math.max(0, heroHp) });
|
||||
if (heroHp <= 0) return { winner: 'monster', rounds, hpRemaining: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 20 rounds — higher HP% wins
|
||||
const heroPct = heroHp / heroStats.maxHp;
|
||||
// Round cap reached: higher remaining HP% wins (ties go to the hero).
|
||||
const heroPct = heroHp / stats.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,
|
||||
};
|
||||
return { winner: heroPct >= monsterPct ? 'hero' : 'monster', rounds, hpRemaining: heroHp };
|
||||
}
|
||||
|
||||
function resolveAttack(attacker, defender, rng) {
|
||||
// Mitigation
|
||||
const mitigation = 100 / (100 + defender.stats.defense);
|
||||
const mitigation = 100 / (100 + defender.defense);
|
||||
let raw = attacker.attack * mitigation;
|
||||
|
||||
// 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;
|
||||
if (attacker.magicVsHighDef && defender.defense >= MAGE_HIGH_DEF_THRESHOLD) {
|
||||
raw *= 1 + attacker.magicVsHighDef;
|
||||
}
|
||||
|
||||
// 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 };
|
||||
const variance = rng() * 0.2 + 0.9; // [0.9, 1.1)
|
||||
const crit = rng() * 100 < attacker.critChance;
|
||||
const damage = Math.max(1, Math.round(raw * variance * (crit ? CRIT_MULT : 1)));
|
||||
return { damage, crit };
|
||||
}
|
||||
|
||||
module.exports = { resolveCombat };
|
||||
module.exports = { resolveCombat, MAX_ROUNDS };
|
||||
|
||||
+4
-40
@@ -3,6 +3,7 @@
|
||||
|
||||
const { getClass } = require('./classes');
|
||||
const { computeEnergy: _computeEnergy, ENERGY_MAX, ENERGY_REGEN_SECONDS } = require('./energy');
|
||||
const { xpToNext, checkLevelUp } = require('./leveling'); // single implementation lives in leveling.js
|
||||
|
||||
/**
|
||||
* Calculate derived stats from base stats, level, and equipment bonuses.
|
||||
@@ -26,8 +27,8 @@ function calcDerived(stats, level, classKey, equipBonuses = {}) {
|
||||
: cls.primary === 'agi' ? totalAgi
|
||||
: totalInt;
|
||||
|
||||
// maxHp = 50 + vit*10 + level*5 + itemBonusVit*10
|
||||
const maxHp = 50 + totalVit * 10 + level * 5 + (equipBonuses.bonus_vit || 0) * 10;
|
||||
// maxHp = 50 + totalVit*10 + level*5 (totalVit already includes equipment VIT, so it is counted once)
|
||||
const maxHp = 50 + totalVit * 10 + level * 5;
|
||||
|
||||
// attack = 5 + primary*2 + level + equipment.bonus_attack
|
||||
const attack = 5 + primary * 2 + level + (equipBonuses.bonus_attack || 0);
|
||||
@@ -55,6 +56,7 @@ function calcDerived(stats, level, classKey, equipBonuses = {}) {
|
||||
critChance,
|
||||
critMult: 1.5,
|
||||
primary,
|
||||
primaryStat: cls.primary,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,44 +65,6 @@ function calcDerived(stats, level, classKey, equipBonuses = {}) {
|
||||
*/
|
||||
const computeEnergy = _computeEnergy;
|
||||
|
||||
/**
|
||||
* XP required to reach next level from given level.
|
||||
* @param {number} level - current level
|
||||
* @returns {number} XP needed
|
||||
*/
|
||||
function xpToNext(level) {
|
||||
return Math.floor(100 * Math.pow(level, 1.5));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if hero leveled up and return new level + unspent points.
|
||||
* @param {number} currentLevel - current level
|
||||
* @param {number} currentXp - current XP
|
||||
* @param {number} xpGained - XP gained from this action
|
||||
* @returns {object} { newLevel, leveledUp, totalXp, unspentPoints }
|
||||
*/
|
||||
function checkLevelUp(currentLevel, currentXp, xpGained) {
|
||||
let totalXp = currentXp + xpGained;
|
||||
let level = currentLevel;
|
||||
let xpNeeded = xpToNext(level);
|
||||
|
||||
while (totalXp >= xpNeeded && level < 50) {
|
||||
totalXp -= xpNeeded;
|
||||
level += 1;
|
||||
xpNeeded = xpToNext(level);
|
||||
}
|
||||
|
||||
const leveledUp = level > currentLevel;
|
||||
const unspentPoints = (level - currentLevel) * 3;
|
||||
|
||||
return {
|
||||
newLevel: level,
|
||||
leveledUp,
|
||||
totalXp,
|
||||
unspentPoints,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ENERGY_MAX,
|
||||
ENERGY_REGEN_SECONDS,
|
||||
|
||||
+22
-13
@@ -4,21 +4,29 @@ const config = require('../config');
|
||||
const { getDb } = require('../db');
|
||||
const { AppError } = require('../utils/errors');
|
||||
|
||||
function createSessionMiddleware() {
|
||||
// Use memory store in tests to avoid session store cleanup interval hanging tests
|
||||
const useMemoryStore = config.nodeEnv === 'test';
|
||||
// better-sqlite3-session-store ignores `expired.clear: false` (its code is `clear || true`), so it always
|
||||
// starts a cleanup setInterval. We unref that timer so it can never keep the process (or a test run) alive.
|
||||
function createSqliteStore() {
|
||||
const realSetInterval = global.setInterval;
|
||||
global.setInterval = (...args) => {
|
||||
const timer = realSetInterval(...args);
|
||||
if (timer && typeof timer.unref === 'function') timer.unref();
|
||||
return timer;
|
||||
};
|
||||
try {
|
||||
return new SessionStore({
|
||||
client: getDb(),
|
||||
table: 'sessions',
|
||||
expired: { clear: true, intervalMs: 15 * 60 * 1000 },
|
||||
});
|
||||
} finally {
|
||||
global.setInterval = realSetInterval;
|
||||
}
|
||||
}
|
||||
|
||||
function createSessionMiddleware() {
|
||||
return session({
|
||||
store: useMemoryStore
|
||||
? undefined // express-session built-in memory store
|
||||
: new SessionStore({
|
||||
client: getDb(),
|
||||
table: 'sessions',
|
||||
expired: {
|
||||
clear: true,
|
||||
intervalMs: 900000, // 15 min cleanup
|
||||
},
|
||||
}),
|
||||
store: createSqliteStore(), // real persistent store in every environment, including tests
|
||||
secret: config.sessionSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
@@ -38,6 +46,7 @@ function requireAuth(req, res, next) {
|
||||
// Return JSON error for API requests
|
||||
if (req.path.startsWith('/api/') || req.xhr) {
|
||||
return res.status(401).json({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'UNAUTHENTICATED',
|
||||
message: 'Authentication required.',
|
||||
|
||||
+2
-2
@@ -18,13 +18,13 @@ const loginSchema = zod.strictObject({
|
||||
const registerLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: process.env.NODE_ENV === 'test' ? 1000 : 5,
|
||||
message: { error: { code: 'RATE_LIMITED', message: 'Too many registration attempts. Try again later.' } },
|
||||
message: { ok: false, error: { code: 'RATE_LIMITED', message: 'Too many registration attempts. Try again later.' } },
|
||||
});
|
||||
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: process.env.NODE_ENV === 'test' ? 1000 : 10,
|
||||
message: { error: { code: 'RATE_LIMITED', message: 'Too many login attempts. Try again later.' } },
|
||||
message: { ok: false, error: { code: 'RATE_LIMITED', message: 'Too many login attempts. Try again later.' } },
|
||||
});
|
||||
|
||||
function wrap(fn) {
|
||||
|
||||
+3
-9
@@ -3,6 +3,7 @@ const zod = require('zod');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const { requireAuth } = require('../middleware/session');
|
||||
const { hunt } = require('../services/huntService');
|
||||
const { getCharacterIdForUser } = require('../services/characterService');
|
||||
|
||||
// Validation schema
|
||||
const huntSchema = zod.strictObject({
|
||||
@@ -20,16 +21,9 @@ async function handleHunt(req, res) {
|
||||
}
|
||||
|
||||
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);
|
||||
const characterId = getCharacterIdForUser(req.session.userId);
|
||||
const result = hunt(characterId, zoneId);
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { getDb } = require('../db');
|
||||
const time = require('../utils/time');
|
||||
const { AppError } = require('../utils/errors');
|
||||
|
||||
const BCRYPT_COST = 12;
|
||||
@@ -38,7 +39,7 @@ async function register(username, password) {
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_COST);
|
||||
const createdAt = Date.now();
|
||||
const createdAt = time.now();
|
||||
|
||||
const result = s.insertUser.run(username, passwordHash, createdAt);
|
||||
const user = { id: result.lastInsertRowid, username };
|
||||
@@ -68,7 +69,7 @@ async function verifyLogin(username, password) {
|
||||
}
|
||||
|
||||
// Update last_login_at
|
||||
db.prepare('UPDATE users SET last_login_at = ? WHERE id = ?').run(Date.now(), user.id);
|
||||
db.prepare('UPDATE users SET last_login_at = ? WHERE id = ?').run(time.now(), user.id);
|
||||
|
||||
const { password_hash: _, ...safeUser } = user;
|
||||
return safeUser;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Character service: create and retrieve characters with derived stats and energy.
|
||||
const { getDb } = require('../db');
|
||||
const time = require('../utils/time');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const { getBaseStats } = require('../game/classes');
|
||||
const { calcDerived, computeEnergy, ENERGY_MAX } = require('../game/stats');
|
||||
const { calcDerived, computeEnergy, ENERGY_MAX, ENERGY_REGEN_SECONDS } = require('../game/stats');
|
||||
const { computeEnergy: computeEnergyPure } = require('../game/energy');
|
||||
|
||||
const ENERGY_UPDATED_AT_DEFAULT = Date.now();
|
||||
|
||||
function wrapStmts(db) {
|
||||
return {
|
||||
@@ -41,7 +41,7 @@ function createCharacter(userId, name, classKey) {
|
||||
const db = getDb();
|
||||
const stmts = getStmts();
|
||||
const baseStats = getBaseStats(classKey);
|
||||
const now = Date.now();
|
||||
const now = time.now();
|
||||
|
||||
// Check if user already has a character
|
||||
const existing = stmts.findByUserId.get(userId);
|
||||
@@ -55,12 +55,12 @@ function createCharacter(userId, name, classKey) {
|
||||
throw new AppError('NAME_TAKEN', 'Hero name is already taken.', 409);
|
||||
}
|
||||
|
||||
const hpCurrent = 50 + baseStats.vit * 10 + 5; // level 1: 50 + vit*10 + level*5
|
||||
const hpCurrent = calcDerived(baseStats, 1, classKey).maxHp; // start at full HP
|
||||
|
||||
const result = stmts.insertCharacter.run(
|
||||
userId, name, classKey,
|
||||
baseStats.str, baseStats.agi, baseStats.int, baseStats.vit,
|
||||
hpCurrent, ENERGY_MAX, ENERGY_UPDATED_AT_DEFAULT, now
|
||||
hpCurrent, ENERGY_MAX, now, now
|
||||
);
|
||||
|
||||
return { id: result.lastInsertRowid };
|
||||
@@ -85,7 +85,7 @@ function getCharacterProfile(userId) {
|
||||
);
|
||||
|
||||
// Compute current energy with lazy regen
|
||||
const energy = computeEnergy(character.energy, character.energy_updated_at, Date.now());
|
||||
const energy = computeEnergy(character.energy, character.energy_updated_at, time.now());
|
||||
|
||||
// Update energy in DB if it changed
|
||||
if (energy.energy !== character.energy || energy.updatedAtMs !== character.energy_updated_at) {
|
||||
@@ -99,9 +99,10 @@ function getCharacterProfile(userId) {
|
||||
}
|
||||
|
||||
// Calculate next energy tick
|
||||
const now = Date.now();
|
||||
const nextTickMs = character.energy_updated_at + (ENERGY_MAX - character.energy) * 180 * 1000;
|
||||
const nextTickSec = Math.max(0, Math.ceil((nextTickMs - now) / 1000));
|
||||
const now = time.now();
|
||||
// Seconds until the NEXT +1 energy (0 when full). Not the time until full.
|
||||
const nextTickMs = character.energy_updated_at + ENERGY_REGEN_SECONDS * 1000;
|
||||
const nextTickSec = character.energy >= ENERGY_MAX ? 0 : Math.max(0, Math.ceil((nextTickMs - now) / 1000));
|
||||
|
||||
return {
|
||||
character: {
|
||||
@@ -146,7 +147,7 @@ function getCharacterProfile(userId) {
|
||||
*/
|
||||
function spendEnergy(characterId, cost) {
|
||||
const db = getDb();
|
||||
const now = Date.now();
|
||||
const now = time.now();
|
||||
|
||||
// Validate cost
|
||||
if (typeof cost !== 'number' || cost <= 0 || !Number.isInteger(cost)) {
|
||||
@@ -177,4 +178,11 @@ function spendEnergy(characterId, cost) {
|
||||
return { energy: energy.energy - cost, updatedAtMs: energy.updatedAtMs };
|
||||
}
|
||||
|
||||
module.exports = { createCharacter, getCharacterProfile, spendEnergy };
|
||||
// Resolve the logged-in user's character id (routes must not run SQL themselves).
|
||||
function getCharacterIdForUser(userId) {
|
||||
const row = getStmts().findByUserId.get(userId);
|
||||
if (!row) throw new AppError('NO_CHARACTER', 'No character found.', 404);
|
||||
return row.id;
|
||||
}
|
||||
|
||||
module.exports = { createCharacter, getCharacterProfile, spendEnergy, getCharacterIdForUser };
|
||||
|
||||
+158
-273
@@ -1,284 +1,169 @@
|
||||
// Hunt service: resolves a hunting attempt in a zone.
|
||||
// Combines energy spending, monster selection, combat, rewards, and logging
|
||||
// all within a single database transaction.
|
||||
// Hunt service: resolves one hunting attempt in a zone.
|
||||
// Everything (reads, energy spend, combat, rewards, loot, log) happens in ONE transaction.
|
||||
const crypto = require('node:crypto');
|
||||
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 time = require('../utils/time');
|
||||
const { mulberry32 } = require('../utils/rng');
|
||||
const { computeEnergy } = require('../game/energy');
|
||||
const { calcDerived } = require('../game/stats');
|
||||
const { resolveCombat } = require('../game/combat');
|
||||
const { calculateRewards, rollLoot } = require('../game/rewards');
|
||||
const { checkLevelUp } = require('../game/leveling');
|
||||
|
||||
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 = ?'
|
||||
),
|
||||
};
|
||||
const MIN_HP_PCT_TO_HUNT = 0.2;
|
||||
const DEFEAT_GOLD_LOSS_PCT = 0.10;
|
||||
|
||||
// Prepared statements are cached PER database handle, so a new DB (e.g. in tests) never reuses stale ones.
|
||||
const stmtCache = new WeakMap();
|
||||
|
||||
function getStmts(db) {
|
||||
let s = stmtCache.get(db);
|
||||
if (!s) {
|
||||
s = {
|
||||
character: db.prepare('SELECT * FROM characters WHERE id = ?'),
|
||||
zone: db.prepare('SELECT * FROM zones WHERE id = ?'),
|
||||
monsters: db.prepare('SELECT * FROM monsters WHERE zone_id = ?'),
|
||||
loot: db.prepare('SELECT item_id, chance_pct, qty_min, qty_max FROM loot_table WHERE monster_id = ?'),
|
||||
item: db.prepare('SELECT id, slug, name, type, rarity, stackable, effect FROM items WHERE id = ?'),
|
||||
stackRow: db.prepare(
|
||||
'SELECT id FROM inventory WHERE character_id = ? AND item_id = ? AND equipped_slot IS NULL'
|
||||
),
|
||||
addToStack: db.prepare('UPDATE inventory SET quantity = quantity + ? WHERE id = ?'),
|
||||
insertInv: db.prepare(
|
||||
'INSERT INTO inventory (character_id, item_id, quantity, acquired_at) VALUES (?, ?, ?, ?)'
|
||||
),
|
||||
updateChar: db.prepare(
|
||||
`UPDATE characters SET hp_current = ?, energy = ?, energy_updated_at = ?, xp = ?, gold = ?,
|
||||
level = ?, unspent_points = ?, kills = ?, deaths = ? WHERE id = ?`
|
||||
),
|
||||
insertLog: db.prepare(
|
||||
`INSERT INTO combat_log (character_id, monster_id, won, rounds, xp_gained, gold_gained, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
),
|
||||
};
|
||||
stmtCache.set(db, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
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;
|
||||
function pickWeightedMonster(monsters, rng) {
|
||||
const total = monsters.reduce((sum, m) => sum + m.weight, 0);
|
||||
let r = rng() * total;
|
||||
for (const m of monsters) {
|
||||
r -= m.weight;
|
||||
if (r < 0) return m;
|
||||
}
|
||||
return monsters[monsters.length - 1];
|
||||
}
|
||||
|
||||
// Stackable items merge into one row per (character, item); others get one row per unit.
|
||||
function grantItem(s, characterId, item, quantity, now) {
|
||||
if (item.stackable) {
|
||||
const row = s.stackRow.get(characterId, item.id);
|
||||
if (row) s.addToStack.run(quantity, row.id);
|
||||
else s.insertInv.run(characterId, item.id, quantity, now);
|
||||
} else {
|
||||
for (let i = 0; i < quantity; i++) s.insertInv.run(characterId, item.id, 1, now);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} characterId
|
||||
* @param {number} zoneId
|
||||
* @param {{rng?: function, now?: number}} [opts] - injectable for tests
|
||||
*/
|
||||
function hunt(characterId, zoneId, opts = {}) {
|
||||
if (!Number.isInteger(zoneId) || zoneId <= 0) {
|
||||
throw new AppError('VALIDATION_ERROR', 'Invalid zone ID.', 400);
|
||||
}
|
||||
const db = getDb();
|
||||
const s = getStmts(db);
|
||||
const now = opts.now ?? time.now();
|
||||
const rng = opts.rng || mulberry32(crypto.randomInt(0, 2 ** 31));
|
||||
|
||||
return db.transaction(() => {
|
||||
const ch = s.character.get(characterId);
|
||||
if (!ch) throw new AppError('NOT_FOUND', 'Character not found.', 404);
|
||||
|
||||
const zone = s.zone.get(zoneId);
|
||||
if (!zone) throw new AppError('NOT_FOUND', 'Zone not found.', 404);
|
||||
if (ch.level < zone.min_level) {
|
||||
throw new AppError('LEVEL_TOO_LOW', `${zone.name} requires level ${zone.min_level}.`, 422);
|
||||
}
|
||||
|
||||
const baseStats = { str: ch.str, agi: ch.agi, int: ch.int, vit: ch.vit };
|
||||
const derived = calcDerived(baseStats, ch.level, ch.class);
|
||||
if (ch.hp_current < derived.maxHp * MIN_HP_PCT_TO_HUNT) {
|
||||
throw new AppError('HP_TOO_LOW', 'Your HP is too low. Rest or use a potion.', 422);
|
||||
}
|
||||
|
||||
const energy = computeEnergy(ch.energy, ch.energy_updated_at, now);
|
||||
if (energy.energy < zone.energy_cost) {
|
||||
throw new AppError(
|
||||
'INSUFFICIENT_ENERGY',
|
||||
`Not enough energy. Have ${energy.energy}, need ${zone.energy_cost}.`, 422
|
||||
);
|
||||
}
|
||||
|
||||
const monsters = s.monsters.all(zoneId);
|
||||
if (monsters.length === 0) throw new AppError('ZONE_EMPTY', 'No monsters in this zone.', 500);
|
||||
const monster = pickWeightedMonster(monsters, rng);
|
||||
|
||||
const fight = resolveCombat({ ...baseStats, class: ch.class, level: ch.level, hpCurrent: ch.hp_current }, monster, rng);
|
||||
|
||||
let { xp, gold, level, unspent_points: unspent, kills, deaths } = ch;
|
||||
let hp = fight.hpRemaining;
|
||||
let xpGained = 0, goldGained = 0, goldLost = 0, leveledUp = false;
|
||||
const loot = [];
|
||||
|
||||
if (fight.winner === 'hero') {
|
||||
const rewards = calculateRewards(monster.xp, monster.gold_min, monster.gold_max, monster.level, ch.level, rng);
|
||||
xpGained = rewards.xp;
|
||||
goldGained = rewards.gold;
|
||||
gold += goldGained;
|
||||
kills += 1;
|
||||
|
||||
for (const drop of rollLoot(s.loot.all(monster.id).map((l) => ({
|
||||
itemId: l.item_id, chancePct: l.chance_pct, qtyMin: l.qty_min, qtyMax: l.qty_max,
|
||||
})), rng)) {
|
||||
const item = s.item.get(drop.itemId);
|
||||
if (!item) continue;
|
||||
grantItem(s, characterId, item, drop.quantity, now);
|
||||
loot.push({ slug: item.slug, name: item.name, type: item.type, rarity: item.rarity, quantity: drop.quantity });
|
||||
}
|
||||
|
||||
// `xp` is progress WITHIN the current level; checkLevelUp returns the leftover.
|
||||
const lv = checkLevelUp(ch.level, ch.xp, xpGained);
|
||||
xp = lv.totalXp;
|
||||
if (lv.leveledUp) {
|
||||
leveledUp = true;
|
||||
level = lv.newLevel;
|
||||
unspent += lv.unspentPoints;
|
||||
hp = calcDerived(baseStats, level, ch.class).maxHp; // full heal at the NEW max HP
|
||||
}
|
||||
} else {
|
||||
goldLost = Math.floor(ch.gold * DEFEAT_GOLD_LOSS_PCT);
|
||||
gold = Math.max(0, ch.gold - goldLost);
|
||||
hp = 1;
|
||||
deaths += 1;
|
||||
}
|
||||
|
||||
s.updateChar.run(hp, energy.energy - zone.energy_cost, energy.updatedAtMs, xp, gold, level, unspent, kills, deaths, characterId);
|
||||
s.insertLog.run(characterId, monster.id, fight.winner === 'hero' ? 1 : 0, fight.rounds.length, xpGained, goldGained, now);
|
||||
|
||||
return {
|
||||
winner: fight.winner,
|
||||
rounds: fight.rounds,
|
||||
hpRemaining: hp,
|
||||
hpMax: calcDerived(baseStats, level, ch.class).maxHp,
|
||||
xpGained, goldGained, goldLost, loot, leveledUp,
|
||||
newLevel: level,
|
||||
energy: energy.energy - zone.energy_cost,
|
||||
zone: { id: zone.id, slug: zone.slug, name: zone.name },
|
||||
monster: { id: monster.id, name: monster.name, level: monster.level },
|
||||
};
|
||||
})();
|
||||
}
|
||||
|
||||
module.exports = { hunt };
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Single source of "now" (ms since epoch, UTC). Services call now() instead of Date.now()
|
||||
// so tests can control the clock with setNow().
|
||||
let override = null;
|
||||
|
||||
function now() {
|
||||
return override ? override() : Date.now();
|
||||
}
|
||||
|
||||
function setNow(fn) { override = fn; }
|
||||
function resetNow() { override = null; }
|
||||
|
||||
module.exports = { now, setNow, resetNow };
|
||||
Reference in New Issue
Block a user