step 5.3: done

This commit is contained in:
bepis
2026-10-20 22:55:51 +11:00
parent 0e053aaee0
commit bf3cb35e68
6 changed files with 248 additions and 22 deletions
+1
View File
@@ -57,6 +57,7 @@ function createApp() {
app.get('/api/inventory', requireAuth, inventoryRoutes.getInventory);
app.post('/api/inventory/equip', requireAuth, inventoryRoutes.postEquip);
app.post('/api/inventory/unequip', requireAuth, inventoryRoutes.postUnequip);
app.post('/api/inventory/use', requireAuth, inventoryRoutes.postUse);
// Central error handler
app.use((err, _req, res, _next) => {
+20 -2
View File
@@ -1,9 +1,9 @@
// Inventory routes: list, equip, unequip.
// Inventory routes: list, equip, unequip, use consumables.
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');
const { listInventory, equipItem, unequipItem, useConsumable } = require('../services/inventoryService');
// Validation schemas
const slotSchema = zod.enum(['weapon', 'armor', 'trinket']);
@@ -56,10 +56,28 @@ async function handleUnequip(req, res) {
res.json({ ok: true, data: result });
}
/**
* POST /api/inventory/use — use a stackable consumable (heal/energy).
*/
async function handleUse(req, res) {
const parsed = zod.strictObject({
itemId: zod.number().int().positive(),
}).safeParse(req.body);
if (!parsed.success) {
throw new AppError('VALIDATION_ERROR', 'Request validation failed.', 400);
}
const { itemId } = parsed.data;
const characterId = getCharacterIdForUser(req.session.userId);
const result = useConsumable(characterId, itemId);
res.json({ ok: true, data: result });
}
module.exports = {
routes: {
getInventory: wrap(handleListInventory),
postEquip: wrap(handleEquip),
postUnequip: wrap(handleUnequip),
postUse: wrap(handleUse),
},
};
+98 -2
View File
@@ -1,7 +1,8 @@
// Inventory service: list, equip, unequip items.
// Inventory service: list, equip, unequip, and use consumables.
// Equipment bonuses feed into calcDerived() for stat updates.
const { getDb } = require('../db');
const { AppError } = require('../utils/errors');
const { calcDerived, ENERGY_MAX } = require('../game/stats');
const VALID_SLOTS = new Set(['weapon', 'armor', 'trinket']);
@@ -54,6 +55,31 @@ function getStmts(db) {
character: db.prepare(
'SELECT * FROM characters WHERE id = ?'
),
// Get inventory row for a specific (character, item) — including quantity.
inventoryRowForUse: db.prepare(
'SELECT * FROM inventory WHERE character_id = ? AND item_id = ? AND quantity >= 1'
),
// Decrement quantity by 1 (only when qty > 1 to avoid CHECK violation).
decrementQty: db.prepare(
'UPDATE inventory SET quantity = quantity - 1 WHERE id = ? AND quantity > 1'
),
// Delete row when it's the last item (qty == 1).
deleteLast: db.prepare(
'DELETE FROM inventory WHERE id = ? AND quantity = 1'
),
// Update character hp_current.
updateHp: db.prepare(
'UPDATE characters SET hp_current = ? WHERE id = ?'
),
// Update character energy.
updateEnergy: db.prepare(
'UPDATE characters SET energy = ?, energy_updated_at = ? WHERE id = ?'
),
};
stmtCache.set(db, s);
}
@@ -209,4 +235,74 @@ function unequipItem(characterId, slot) {
return listInventory(characterId);
}
module.exports = { listInventory, equipItem, unequipItem };
/**
* Use a stackable consumable item. Applies its effect (heal or energy),
* decrements quantity, removes the row when quantity reaches 0.
* @param {number} characterId
* @param {number} itemId
* @returns {object} updated inventory listing
*/
function useConsumable(characterId, itemId) {
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 !== 'consumable') {
throw new AppError('VALIDATION_ERROR', `${item.name} is not a consumable.`, 400);
}
if (!item.effect) {
throw new AppError('VALIDATION_ERROR', `${item.name} has no effect.`, 400);
}
// Parse effect: 'heal:N' or 'energy:N'
const match = item.effect.match(/^(heal|energy):(\d+)$/);
if (!match) {
throw new AppError('VALIDATION_ERROR', `${item.name} has an invalid effect format.`, 400);
}
const [_full, effectType, effectValueStr] = match;
const effectValue = parseInt(effectValueStr, 10);
// Find the inventory row (must have quantity >= 1).
const invRow = s.inventoryRowForUse.get(characterId, item.id);
if (!invRow) throw new AppError('NOT_FOUND', 'Item not in inventory or depleted.', 404);
// Apply effect inside a transaction.
const applyEffect = db.transaction((rowId, qty, etype, evalue, charId, c) => {
// Apply the effect.
if (etype === 'heal') {
const maxHp = calcDerived(
{ str: c.str, agi: c.agi, int: c.int, vit: c.vit },
c.level, c.class
).maxHp;
const newHp = Math.min(c.hp_current + evalue, maxHp);
s.updateHp.run(newHp, charId);
} else if (etype === 'energy') {
const { computeEnergy } = require('../game/stats');
const now = require('../utils/time').now();
const energy = computeEnergy(c.energy, c.energy_updated_at, now);
const newEnergy = Math.min(energy.energy + evalue, ENERGY_MAX);
const newUpdatedAt = newEnergy >= ENERGY_MAX ? now : energy.updatedAtMs;
s.updateEnergy.run(newEnergy, newUpdatedAt, charId);
}
// Delete directly when qty==1; otherwise decrement.
if (qty === 1) {
s.deleteLast.run(rowId);
} else {
s.decrementQty.run(rowId);
}
});
applyEffect(invRow.id, invRow.quantity, effectType, effectValue, characterId, ch);
// Return updated listing.
return listInventory(characterId);
}
module.exports = { listInventory, equipItem, unequipItem, useConsumable };