step 5.2: done
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user