loop: baseline

This commit is contained in:
bepis
2026-10-20 20:33:35 +11:00
parent f1ae7b3119
commit 9a308b697d
32 changed files with 959 additions and 2046 deletions
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+3 -2
View File
@@ -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;
+19 -11
View File
@@ -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
View File
@@ -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 };
+12
View File
@@ -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 };