step 6.1: wip (not ticked)
This commit is contained in:
@@ -7,6 +7,7 @@ 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 { routes: shopRoutes } = require('./routes/shop');
|
||||
const { AppError } = require('./utils/errors');
|
||||
|
||||
function createApp() {
|
||||
@@ -59,6 +60,11 @@ function createApp() {
|
||||
app.post('/api/inventory/unequip', requireAuth, inventoryRoutes.postUnequip);
|
||||
app.post('/api/inventory/use', requireAuth, inventoryRoutes.postUse);
|
||||
|
||||
// Shop routes (require session)
|
||||
app.get('/api/shop', requireAuth, shopRoutes.getShop);
|
||||
app.post('/api/shop/buy', requireAuth, shopRoutes.postBuy);
|
||||
app.post('/api/shop/sell', requireAuth, shopRoutes.postSell);
|
||||
|
||||
// Central error handler
|
||||
app.use((err, _req, res, _next) => {
|
||||
// Log stack traces server-side only
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Shop routes: GET /api/shop, POST /api/shop/buy, POST /api/shop/sell.
|
||||
const zod = require('zod');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const { requireAuth } = require('../middleware/session');
|
||||
const { getCharacterIdForUser } = require('../services/characterService');
|
||||
const { getStock, buyItem, sellItem } = require('../services/shopService');
|
||||
|
||||
function wrap(fn) {
|
||||
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/shop — list shop stock and materials available for sale.
|
||||
*/
|
||||
async function handleListShop(_req, res) {
|
||||
const characterId = getCharacterIdForUser(_req.session.userId);
|
||||
const result = getStock(characterId);
|
||||
res.json({ ok: true, data: result });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/shop/buy — buy an item from the shop.
|
||||
*/
|
||||
async function handleBuy(req, res) {
|
||||
const parsed = zod.strictObject({
|
||||
itemId: zod.number().int().positive(),
|
||||
quantity: zod.number().int().min(1).max(99),
|
||||
}).safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new AppError('VALIDATION_ERROR', 'Request validation failed.', 400);
|
||||
}
|
||||
|
||||
const { itemId, quantity } = parsed.data;
|
||||
const characterId = getCharacterIdForUser(req.session.userId);
|
||||
const result = buyItem(characterId, itemId, quantity);
|
||||
res.json({ ok: true, data: result });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/shop/sell — sell a material back to the shop.
|
||||
*/
|
||||
async function handleSell(req, res) {
|
||||
const parsed = zod.strictObject({
|
||||
itemId: zod.number().int().positive(),
|
||||
quantity: zod.number().int().min(1).max(99),
|
||||
}).safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new AppError('VALIDATION_ERROR', 'Request validation failed.', 400);
|
||||
}
|
||||
|
||||
const { itemId, quantity } = parsed.data;
|
||||
const characterId = getCharacterIdForUser(req.session.userId);
|
||||
const result = sellItem(characterId, itemId, quantity);
|
||||
res.json({ ok: true, data: result });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
routes: {
|
||||
getShop: wrap(handleListShop),
|
||||
postBuy: wrap(handleBuy),
|
||||
postSell: wrap(handleSell),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,337 @@
|
||||
// Shop service: buy and sell items.
|
||||
// Sell value = 40% of buy_price (materials with buy_price=0 use rarity default).
|
||||
const { getDb } = require('../db');
|
||||
const { AppError } = require('../utils/errors');
|
||||
|
||||
// Rarity defaults for materials (buy_price = 0).
|
||||
const MATERIAL_SELL_DEFAULTS = { common: 2, uncommon: 4, rare: 8, epic: 16, legendary: 32 };
|
||||
|
||||
// Prepared statements cached per DB handle (WeakMap).
|
||||
const stmtCache = new WeakMap();
|
||||
|
||||
function getStmts(db) {
|
||||
let s = stmtCache.get(db);
|
||||
if (!s) {
|
||||
s = {
|
||||
// Get purchasable items (buy_price > 0) where character level >= min_level.
|
||||
stock: db.prepare(
|
||||
`SELECT it.*,
|
||||
CASE WHEN ? >= it.min_level THEN 1 ELSE 0 END AS levelOk
|
||||
FROM items it
|
||||
WHERE it.buy_price > 0 AND ? >= it.min_level
|
||||
ORDER BY it.type, it.rarity, it.id`
|
||||
),
|
||||
|
||||
// Get all items where character level >= min_level (for stock display).
|
||||
allStock: db.prepare(
|
||||
`SELECT it.*,
|
||||
CASE WHEN ? >= it.min_level THEN 1 ELSE 0 END AS levelOk
|
||||
FROM items it
|
||||
WHERE ? >= it.min_level
|
||||
ORDER BY it.type, it.rarity, it.id`
|
||||
),
|
||||
|
||||
// Get item details by id.
|
||||
itemById: db.prepare(
|
||||
'SELECT * FROM items WHERE id = ?'
|
||||
),
|
||||
|
||||
// Get character profile.
|
||||
character: db.prepare(
|
||||
'SELECT * FROM characters WHERE id = ?'
|
||||
),
|
||||
|
||||
// Get inventory rows for a character + item (for selling).
|
||||
inventoryRows: db.prepare(
|
||||
'SELECT * FROM inventory WHERE character_id = ? AND item_id = ? AND quantity >= ?'
|
||||
),
|
||||
|
||||
// Update character gold.
|
||||
updateGold: db.prepare(
|
||||
'UPDATE characters SET gold = ? WHERE id = ?'
|
||||
),
|
||||
|
||||
// Add item to inventory (or increment quantity if stackable).
|
||||
addToInventory: db.prepare(
|
||||
'INSERT INTO inventory (character_id, item_id, quantity, equipped_slot, acquired_at) VALUES (?, ?, ?, NULL, ?)'
|
||||
),
|
||||
|
||||
// Increment quantity for stackable items.
|
||||
incrementQty: db.prepare(
|
||||
'UPDATE inventory SET quantity = quantity + ? WHERE character_id = ? AND item_id = ?'
|
||||
),
|
||||
|
||||
// Decrement inventory quantity.
|
||||
decrementQty: db.prepare(
|
||||
'UPDATE inventory SET quantity = quantity - ? WHERE id = ? AND quantity >= ?'
|
||||
),
|
||||
|
||||
// Delete inventory row when quantity reaches 0.
|
||||
deleteRow: db.prepare(
|
||||
'DELETE FROM inventory WHERE id = ? AND quantity = ?'
|
||||
),
|
||||
|
||||
// List all inventory items for a character.
|
||||
listInventory: 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`
|
||||
),
|
||||
};
|
||||
stmtCache.set(db, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shop stock: items the character can buy + materials they can sell.
|
||||
* @param {number} characterId
|
||||
* @returns {object} { stock: [...], character: {...} }
|
||||
*/
|
||||
function getStock(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.allStock.all(ch.level, ch.level);
|
||||
|
||||
const stock = rows.map((row) => ({
|
||||
itemId: row.id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
rarity: row.rarity,
|
||||
buyPrice: row.buy_price,
|
||||
levelReq: row.min_level,
|
||||
levelOk: row.levelOk === 1,
|
||||
canAfford: ch.gold >= row.buy_price,
|
||||
// For materials: calculate sell price.
|
||||
sellPrice: row.buy_price > 0
|
||||
? Math.floor(row.buy_price * 0.4)
|
||||
: Math.max(1, Math.floor((MATERIAL_SELL_DEFAULTS[row.rarity] || 2) * ch.level / row.min_level)),
|
||||
}));
|
||||
|
||||
return {
|
||||
stock,
|
||||
character: {
|
||||
id: ch.id,
|
||||
name: ch.name,
|
||||
class: ch.class,
|
||||
level: ch.level,
|
||||
gold: ch.gold,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sell an inventory item back to the shop.
|
||||
* @param {number} characterId
|
||||
* @param {number} itemId
|
||||
* @param {number} quantity
|
||||
* @returns {object} { gold, inventory }
|
||||
*/
|
||||
function sellItem(characterId, itemId, quantity) {
|
||||
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 !== 'material') {
|
||||
throw new AppError('VALIDATION_ERROR', 'Only materials can be sold.', 400);
|
||||
}
|
||||
|
||||
// Calculate sell price for this material.
|
||||
let sellPrice;
|
||||
if (item.buy_price > 0) {
|
||||
sellPrice = Math.floor(item.buy_price * 0.4);
|
||||
} else {
|
||||
const defaultRate = MATERIAL_SELL_DEFAULTS[item.rarity] || 2;
|
||||
sellPrice = Math.max(1, Math.floor(defaultRate * ch.level / item.min_level));
|
||||
}
|
||||
|
||||
// Find inventory rows for this (character, item).
|
||||
const rows = s.inventoryRows.all(characterId, itemId, 1);
|
||||
if (rows.length === 0) {
|
||||
throw new AppError('NOT_FOUND', 'Item not in inventory.', 404);
|
||||
}
|
||||
|
||||
// Calculate how many we can actually sell from available rows.
|
||||
let remaining = quantity;
|
||||
const sales = []; // { rowId, qty, sellPrice }
|
||||
for (const row of rows) {
|
||||
if (remaining <= 0) break;
|
||||
const qty = Math.min(remaining, row.quantity);
|
||||
sales.push({ rowId: row.id, qty, sellPrice });
|
||||
remaining -= qty;
|
||||
}
|
||||
|
||||
if (remaining > 0) {
|
||||
throw new AppError('VALIDATION_ERROR', `Only ${quantity - remaining} in inventory.`, 400);
|
||||
}
|
||||
|
||||
const totalGold = sales.reduce((sum, sale) => sum + sale.sellPrice * sale.qty, 0);
|
||||
const newGold = ch.gold + totalGold;
|
||||
|
||||
// Execute the sale inside a transaction.
|
||||
const doSell = db.transaction((salesArr, newGoldVal, cid) => {
|
||||
for (const sale of salesArr) {
|
||||
if (sale.qty === 1) {
|
||||
s.deleteRow.run(sale.rowId, 1);
|
||||
} else {
|
||||
s.decrementQty.run(sale.qty, sale.rowId, sale.qty);
|
||||
}
|
||||
}
|
||||
s.updateGold.run(newGoldVal, cid);
|
||||
});
|
||||
|
||||
doSell(sales, newGold, characterId);
|
||||
|
||||
// Return updated inventory listing.
|
||||
return listInventory(characterId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buy an item from the shop.
|
||||
* @param {number} characterId
|
||||
* @param {number} itemId
|
||||
* @param {number} quantity
|
||||
* @returns {object} { gold, inventory }
|
||||
*/
|
||||
function buyItem(characterId, itemId, quantity) {
|
||||
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.buy_price <= 0) {
|
||||
throw new AppError('VALIDATION_ERROR', 'This item cannot be bought.', 400);
|
||||
}
|
||||
|
||||
if (ch.level < item.min_level) {
|
||||
throw new AppError('LEVEL_TOO_LOW', `${item.name} requires level ${item.min_level}.`, 422);
|
||||
}
|
||||
|
||||
const totalCost = item.buy_price * quantity;
|
||||
if (ch.gold < totalCost) {
|
||||
throw new AppError('INSUFFICIENT_GOLD', `Not enough gold. Need ${totalCost}, have ${ch.gold}.`, 422);
|
||||
}
|
||||
|
||||
const newGold = ch.gold - totalCost;
|
||||
const now = require('../utils/time').now();
|
||||
|
||||
// Execute the purchase inside a transaction.
|
||||
const doBuy = db.transaction((cost, ng, cid, iid, qty, acquiredAt) => {
|
||||
// Deduct gold.
|
||||
s.updateGold.run(ng, cid);
|
||||
|
||||
// If stackable and already owned, increment quantity.
|
||||
const existing = s.inventoryRows.get(cid, iid, 1);
|
||||
if (existing && item.stackable) {
|
||||
s.incrementQty.run(qty, cid, iid);
|
||||
} else {
|
||||
// New inventory entry.
|
||||
s.addToInventory.run(cid, iid, qty, acquiredAt);
|
||||
}
|
||||
});
|
||||
|
||||
doBuy(totalCost, newGold, characterId, itemId, quantity, now);
|
||||
|
||||
// Return updated inventory listing.
|
||||
return listInventory(characterId);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all inventory items for a character (used after buy/sell).
|
||||
* @param {number} characterId
|
||||
* @returns {object} { items, character, derived }
|
||||
*/
|
||||
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.listInventory.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
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { getStock, buyItem, sellItem };
|
||||
Reference in New Issue
Block a user