step 5.2: done

This commit is contained in:
bepis
2026-10-20 22:39:34 +11:00
parent 9846e12997
commit 0e053aaee0
7 changed files with 474 additions and 15 deletions
+6
View File
@@ -6,6 +6,7 @@ const { routes: characterRoutes } = require('./routes/character');
const { routes: huntRoutes } = require('./routes/hunt');
const { routes: zonesRoutes } = require('./routes/zones');
const { routes: combatLogRoutes } = require('./routes/combatLog');
const { routes: inventoryRoutes } = require('./routes/inventory');
const { AppError } = require('./utils/errors');
function createApp() {
@@ -52,6 +53,11 @@ function createApp() {
// Combat log routes (require session)
app.get('/api/combat-log', requireAuth, combatLogRoutes.getCombatLog);
// Inventory routes (require session)
app.get('/api/inventory', requireAuth, inventoryRoutes.getInventory);
app.post('/api/inventory/equip', requireAuth, inventoryRoutes.postEquip);
app.post('/api/inventory/unequip', requireAuth, inventoryRoutes.postUnequip);
// Central error handler
app.use((err, _req, res, _next) => {
// Log stack traces server-side only
+65
View File
@@ -0,0 +1,65 @@
// Inventory routes: list, equip, unequip.
const zod = require('zod');
const { AppError } = require('../utils/errors');
const { requireAuth } = require('../middleware/session');
const { getCharacterIdForUser } = require('../services/characterService');
const { listInventory, equipItem, unequipItem } = require('../services/inventoryService');
// Validation schemas
const slotSchema = zod.enum(['weapon', 'armor', 'trinket']);
function wrap(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}
/**
* GET /api/inventory — list all items, equipped gear, and derived stats.
*/
async function handleListInventory(req, _res) {
const characterId = getCharacterIdForUser(req.session.userId);
const result = listInventory(characterId);
res.json({ ok: true, data: result });
}
/**
* POST /api/inventory/equip — equip an item into a slot.
*/
async function handleEquip(req, res) {
const parsed = zod.strictObject({
itemId: zod.number().int().positive(),
slot: slotSchema,
}).safeParse(req.body);
if (!parsed.success) {
throw new AppError('VALIDATION_ERROR', 'Request validation failed.', 400);
}
const { itemId, slot } = parsed.data;
const characterId = getCharacterIdForUser(req.session.userId);
const result = equipItem(characterId, itemId, slot);
res.json({ ok: true, data: result });
}
/**
* POST /api/inventory/unequip — unequip an item from a slot.
*/
async function handleUnequip(req, res) {
const parsed = zod.strictObject({
slot: slotSchema,
}).safeParse(req.body);
if (!parsed.success) {
throw new AppError('VALIDATION_ERROR', 'Request validation failed.', 400);
}
const { slot } = parsed.data;
const characterId = getCharacterIdForUser(req.session.userId);
const result = unequipItem(characterId, slot);
res.json({ ok: true, data: result });
}
module.exports = {
routes: {
getInventory: wrap(handleListInventory),
postEquip: wrap(handleEquip),
postUnequip: wrap(handleUnequip),
},
};
+212
View File
@@ -0,0 +1,212 @@
// Inventory service: list, equip, unequip items.
// Equipment bonuses feed into calcDerived() for stat updates.
const { getDb } = require('../db');
const { AppError } = require('../utils/errors');
const VALID_SLOTS = new Set(['weapon', 'armor', 'trinket']);
// Prepared statements cached per DB handle (WeakMap).
const stmtCache = new WeakMap();
function getStmts(db) {
let s = stmtCache.get(db);
if (!s) {
s = {
// Get all inventory items for a character, with item details.
listItems: db.prepare(`
SELECT i.id AS inventory_id, i.item_id, i.quantity, i.equipped_slot, it.slug, it.name, it.type, it.rarity,
it.min_level, it.stackable,
it.bonus_str, it.bonus_agi, it.bonus_int, it.bonus_vit,
it.bonus_attack, it.bonus_defense, it.effect
FROM inventory i
JOIN items it ON it.id = i.item_id
WHERE i.character_id = ?
ORDER BY CASE i.equipped_slot WHEN 'weapon' THEN 0 WHEN 'armor' THEN 1 WHEN 'trinket' THEN 2 ELSE 3 END,
CASE it.type WHEN 'weapon' THEN 0 WHEN 'armor' THEN 1 WHEN 'trinket' THEN 2 WHEN 'consumable' THEN 3 WHEN 'material' THEN 4 ELSE 5 END
`),
// Get item details by id (for equip/unequip validation).
itemById: db.prepare(
'SELECT * FROM items WHERE id = ?'
),
// Check what's currently equipped in a slot.
itemInSlot: db.prepare(
'SELECT id AS inventory_id FROM inventory WHERE character_id = ? AND equipped_slot = ?'
),
// Get inventory row for a specific (character, item) pair.
inventoryRow: db.prepare(
'SELECT * FROM inventory WHERE character_id = ? AND item_id = ?'
),
// Equip an item (set slot).
equip: db.prepare(
'UPDATE inventory SET equipped_slot = ? WHERE id = ?'
),
// Unequip an item (clear slot).
unequip: db.prepare(
'UPDATE inventory SET equipped_slot = NULL WHERE id = ?'
),
// Get character for derived stats recalculation.
character: db.prepare(
'SELECT * FROM characters WHERE id = ?'
),
};
stmtCache.set(db, s);
}
return s;
}
/**
* List all inventory items for a character, including what's equipped.
* @param {number} characterId
* @returns {object} { items: [...], derived: {...}, gold }
*/
function listInventory(characterId) {
const db = getDb();
const s = getStmts(db);
const ch = s.character.get(characterId);
if (!ch) throw new AppError('NOT_FOUND', 'Character not found.', 404);
const rows = s.listItems.all(characterId);
// Calculate current derived stats including equipment bonuses.
const equipBonuses = {
bonus_str: 0, bonus_agi: 0, bonus_int: 0, bonus_vit: 0,
bonus_attack: 0, bonus_defense: 0,
};
for (const row of rows) {
if (row.equipped_slot) {
equipBonuses.bonus_str += row.bonus_str || 0;
equipBonuses.bonus_agi += row.bonus_agi || 0;
equipBonuses.bonus_int += row.bonus_int || 0;
equipBonuses.bonus_vit += row.bonus_vit || 0;
equipBonuses.bonus_attack += row.bonus_attack || 0;
equipBonuses.bonus_defense += row.bonus_defense || 0;
}
}
const { calcDerived } = require('../game/stats');
const derived = calcDerived(
{ str: ch.str, agi: ch.agi, int: ch.int, vit: ch.vit },
ch.level,
ch.class,
equipBonuses
);
// Format items for the client.
const items = rows.map((row) => ({
itemId: row.item_id,
inventoryId: row.inventory_id,
slug: row.slug,
name: row.name,
type: row.type,
rarity: row.rarity,
quantity: row.quantity,
equipped: row.equipped_slot !== null,
equipped_slot: row.equipped_slot || null,
bonuses: {
bonus_str: row.bonus_str || 0,
bonus_agi: row.bonus_agi || 0,
bonus_int: row.bonus_int || 0,
bonus_vit: row.bonus_vit || 0,
bonus_attack: row.bonus_attack || 0,
bonus_defense: row.bonus_defense || 0,
},
}));
return {
character: {
id: ch.id,
name: ch.name,
class: ch.class,
level: ch.level,
gold: ch.gold,
},
items,
derived: {
maxHp: derived.maxHp,
attack: derived.attack,
defense: derived.defense,
speed: derived.speed,
critChance: derived.critChance,
critMult: derived.critMult,
},
};
}
/**
* Equip an item into a slot. If something is already equipped there, unequip it first.
* @param {number} characterId
* @param {number} itemId
* @param {string} slot - 'weapon' | 'armor' | 'trinket'
* @returns {object} updated inventory listing
*/
function equipItem(characterId, itemId, slot) {
if (!VALID_SLOTS.has(slot)) {
throw new AppError('VALIDATION_ERROR', `Invalid slot '${slot}'. Must be weapon, armor, or trinket.`, 400);
}
const db = getDb();
const s = getStmts(db);
const ch = s.character.get(characterId);
if (!ch) throw new AppError('NOT_FOUND', 'Character not found.', 404);
const item = s.itemById.get(itemId);
if (!item) throw new AppError('NOT_FOUND', 'Item not found.', 404);
if (item.type !== slot) {
throw new AppError('VALIDATION_ERROR', `${item.name} is a ${item.type}, not a ${slot}.`, 400);
}
if (ch.level < item.min_level) {
throw new AppError('LEVEL_TOO_LOW', `${item.name} requires level ${item.min_level}.`, 422);
}
// Find the inventory row for this (character, item).
const invRow = s.inventoryRow.get(characterId, item.id);
if (!invRow) throw new AppError('NOT_FOUND', 'Item not in inventory.', 404);
// If something is already equipped in this slot, unequip it.
const existing = s.itemInSlot.get(characterId, slot);
if (existing) {
s.unequip.run(existing.inventory_id);
}
// Equip the new item.
s.equip.run(slot, invRow.id);
// Return updated listing.
return listInventory(characterId);
}
/**
* Unequip an item from a slot.
* @param {number} characterId
* @param {string} slot - 'weapon' | 'armor' | 'trinket'
* @returns {object} updated inventory listing
*/
function unequipItem(characterId, slot) {
if (!VALID_SLOTS.has(slot)) {
throw new AppError('VALIDATION_ERROR', `Invalid slot '${slot}'. Must be weapon, armor, or trinket.`, 400);
}
const db = getDb();
const s = getStmts(db);
const existing = s.itemInSlot.get(characterId, slot);
if (!existing) {
throw new AppError('NOT_FOUND', `Nothing equipped in ${slot} slot.`, 404);
}
s.unequip.run(existing.inventory_id);
return listInventory(characterId);
}
module.exports = { listInventory, equipItem, unequipItem };