step 6.1: wip (not ticked)

This commit is contained in:
bepis
2026-10-21 00:42:56 +11:00
parent ae8ee02721
commit ffee0efc47
6 changed files with 810 additions and 15 deletions
+337
View File
@@ -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 };