// 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'); const { getEquipmentBonuses } = require('../game/equipment'); const { calcDerived } = require('../game/stats'); // 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); // 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); } // Reject if any row is equipped. for (const row of rows) { if (row.equipped_slot !== null) { throw new AppError('VALIDATION_ERROR', 'Unequip the item before selling.', 400); } } 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)); } // 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 = getEquipmentBonuses(characterId); 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 };