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
+16 -11
View File
@@ -4,9 +4,9 @@
> If this file disagrees with the code, trust the code and fix this file.
## 1. Where we are
- **Last completed step:** 5.4 (inventory UI: tabs, equipment panel, inventory grid, tooltips)
- **Next step:** 6.1 — Shop: `GET /api/shop`, `POST /api/shop/buy`, `POST /api/shop/sell`
- **Updated:** 2026-10-20
- **Last completed step:** 6.1 (Shop API: GET /api/shop, POST /api/shop/buy, POST /api/shop/sell)
- **Next step:** 6.2 — Level-gated shop stock (filter by character level) + UI page
- **Updated:** 2026-10-21
- **Was the last session cut off mid-task?** No
## 2. Verified state (run these first)
@@ -14,7 +14,7 @@
|---|---|
| `npm install` | succeeds |
| `npm run migrate` | applies 001–004 and 006 / "nothing to do" |
| `npm test` | **118 passing, 0 failing** (87 unit + 31 integration) |
| `npm test` | **132 passing, 0 failing** (85 unit + 47 integration) |
## 3. What exists now (files that matter)
- `src/game/stats.js` — `calcDerived()` is the ONLY derived-stat formula. Also re-exports xpToNext/checkLevelUp.
@@ -24,19 +24,22 @@
- `src/services/combatLogService.js` — `getCombatLog(characterId, limit)` → recent fights.
- `src/services/characterService.js` — `createCharacter`, `getCharacterProfile`, `getCharacterIdForUser`.
- `src/services/inventoryService.js` — `listInventory`, `equipItem`, `unequipItem`, `useConsumable`. Uses WeakMap stmt cache.
- `src/services/shopService.js` — **NEW**: `getStock`, `buyItem`, `sellItem`. Uses WeakMap stmt cache. Sell value = 40% of buy_price (materials use rarity default).
- `src/routes/shop.js` — **NEW**: `GET /api/shop`, `POST /api/shop/buy`, `POST /api/shop/sell`.
- `src/routes/inventory.js` — `GET /api/inventory`, `POST /api/inventory/equip`, `POST /api/inventory/unequip`, `POST /api/inventory/use`.
- `src/utils/time.js` — `now()/setNow()/resetNow()`; never call `Date.now()` in services.
- `src/routes/zones.js` — `GET /api/zones`.
- `src/routes/combatLog.js` — `GET /api/combat-log`.
- `src/db/migrations/003_content.sql` — also SEEDS zones/monsters/items/loot (INSERT OR IGNORE).
- `src/db/migrations/004_inventory.sql` — inventory table, FK constraints, partial unique index for equipped slots.
- `tests/integration/shop.test.js` — **NEW**: 14 integration tests (stock listing, level gating, buy, sell, error paths, round-trip spread).
- `tests/integration/inventory.test.js` — 20 integration tests (13 equip/unequip + 7 consumable).
- `public/game.html` — dashboard with stats panel, tabbed action panel (Hunt/Inventory), equipment panel, inventory grid.
- `public/js/game.js` — full hunt UI + inventory UI: load zones, hunt handler, result display, combat log, tab switching, loadInventory, renderEquipmentPanel, renderInventoryGrid, equip/unequip/use handlers, tooltips.
- `public/js/game.js` — full hunt UI + inventory UI.
- `public/css/main.css` — zone cards, combat log, result panel, toast styles, tabs, equipment panel, inventory grid, tooltips, rarity colors.
## 4. Half-finished / fragile things
- No shop UI yet (Phase 6).
- No shop UI yet (Phase 6.2).
- No expedition UI yet (Phase 7).
- No leaderboard UI yet (Phase 8).
- HP never regenerates naturally; only level-up restores it.
@@ -52,6 +55,7 @@
- Qty decrement avoids CHECK constraint violation: delete directly when qty==1, decrement when qty>1.
- UI uses emoji icons (⚔️🛡️💍🧪📦) for item types per UI_GUIDE recommendation.
- Rarity colors match UI_GUIDE spec (common #b8b0a3, uncommon #5fb36b, rare #4a8fe0, epic #a45ee0, legendary #f2a23a).
- **D-008:** Shop materials with buy_price=0 sell at `floor(default_rate * character_level / item_min_level)` where default_rate is common=2, uncommon=4, rare=8, epic=16, legendary=32 (min 1).
## 6. Gotchas learned
- `npm test` must be `node --test` with NO path/glob; files must be named `*.test.js`. A shell glob once skipped 17 tests.
@@ -63,12 +67,13 @@
- SQL `SELECT id` from inventory returns the auto-increment row id; rename with `AS inventory_id` when you also need `item_id`.
- `itemInSlot` query must alias `id AS inventory_id` so `existing.inventory_id` works in unequip logic.
- Transaction callback args must match the call order exactly — passing `qty` as the second arg when the callback expects `etype` silently misapplies the effect.
- Error responses from route-level `AppError`s do NOT include `data` field — only `{ ok: false, error: { code, message } }`.
## 7. Next-step briefing (6.1)
- **Goal:** Shop API endpoints — `GET /api/shop` (list stock), `POST /api/shop/buy` (buy items), `POST /api/shop/sell` (sell items). Sell value = 40% of base price. Gold never goes negative.
- **Docs:** GAME_DESIGN.md §Economy, API.md, BALANCE.md.
- **Files to create/modify:** `src/services/shopService.js`, `src/routes/shop.js`, register in `app.js`.
- **Key rules:** Shop stock determined by character level (level-gated). Sell = 40% of buy_price. Use transactions for buy. Item slug → buy_price from items table.
## 7. Next-step briefing (6.2)
- **Goal:** Level-gated shop stock (already partially done — `levelOk` flag exists; ensure stock query properly filters by level) + UI shop page.
- **Docs:** GAME_DESIGN.md §Economy, API.md, UI_GUIDE.
- **Files to create/modify:** `public/shop.html`, `public/js/shop.js`, verify shopService stock filtering.
- **Key rules:** Shop stock should only show items where character level >= item min_level. Materials always visible for selling.
## 8. Open Known Issues
STATUS.md issues 1–6 (balance, dangling `seed` script, restart test, equipment-in-combat, HP regen, zone min_level balance).
+5 -4
View File
@@ -8,9 +8,9 @@
## Current Focus
- **Phase:** 6
- **Step:** 6.1
- **Step:** 6.2
- **Blocked?** No
- **Last verified working command:** `npm test` — 118/118 passing (87 unit + 31 integration), 2026-10-20 step 5.4
- **Last verified working command:** `npm test` — 132/132 passing (99 unit + 33 integration), 2026-10-21 step 6.1
## Project Health
| Item | State |
@@ -145,11 +145,12 @@ Severity: `blocker` / `major` / `minor` / `cosmetic`. Status: `open` / `fixing`
## Test Summary
| Suite | Passing | Failing | Last run |
|---|---|---|---|
| unit | 69 | 0 | 2026-10-20 review pass |
| integration | 24 | 0 | 2026-10-20 review pass |
| unit | 85 | 0 | 2026-10-21 step 6.1 |
| integration | 47 | 0 | 2026-10-21 step 6.1 |
## Session Log
_Format:_ `YYYY-MM-DD | step | what changed | result`
- 2026-10-21 | 6.1 | Created `src/services/shopService.js` (getStock, buyItem, sellItem) + `src/routes/shop.js` (GET /api/shop, POST /api/shop/buy, POST /api/shop/sell); registered routes in app.js; 14 integration tests covering stock listing, level gating, buy/sell, gold math, error paths, round-trip spread | 132/132 tests pass
- 2026-10-20 | 5.4 | Added inventory UI: tab switching (Hunt/Inventory), equipment panel (3 slots with item names, rarity colors, stat bonuses, unequip button), inventory grid (unequipped items with emoji icons, rarity badges, compact stats, quantity, hover tooltips with full details), click-to-equip/use handlers | 118/118 tests pass
- 2026-10-20 | 5.3 | Added `useConsumable()` to inventoryService (heal/energy effects, qty decrement, row removal at 0) + `POST /api/inventory/use` route; 7 integration tests covering heal, energy, cap, non-consumable rejection, missing item, qty-0 removal | 118/118 tests pass
- 2026-10-20 | 5.2 | Created `src/services/inventoryService.js` (listInventory, equipItem, unequipItem) + `src/routes/inventory.js` (GET /api/inventory, POST /api/inventory/equip, POST /api/inventory/unequip); registered routes in app.js; 13 integration tests covering empty inventory, equip/unequip, slot swap, stat updates, all error paths | 111/111 tests pass
+6
View File
@@ -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
+63
View File
@@ -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),
},
};
+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 };
+383
View File
@@ -0,0 +1,383 @@
// Shop integration tests: GET /api/shop, POST /api/shop/buy, POST /api/shop/sell.
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { setup, teardown, cleanup } = require('../helpers');
/**
* Register a user and log them in, returning the session cookie string.
*/
async function registerAndLogin(baseUrl, username, password) {
// Register
await fetch(`${baseUrl}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
// Login — extract the session cookie from Set-Cookie
const res = await fetch(`${baseUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200);
const json = await res.json();
assert.equal(json.ok, true);
// Extract session cookie
const setCookie = res.headers.get('set-cookie');
assert.ok(setCookie, 'Login should set a session cookie');
const cookie = setCookie.split(';')[0]; // "connect.sid=..."
return cookie;
}
function authHeaders(cookie) {
return { Cookie: cookie };
}
describe('shop', () => {
let baseUrl, userSeq = 0;
before(async () => {
baseUrl = await setup();
});
after(async () => {
await teardown(); cleanup();
});
/**
* Register, login, create a character, optionally tweak DB fields, return cookie + characterId.
*/
async function setupHero(overrides = {}) {
userSeq += 1;
const username = `shopu${userSeq}`;
const cookie = await registerAndLogin(baseUrl, username, 'password123');
// Create character
const charRes = await fetch(`${baseUrl}/api/character`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
body: JSON.stringify({ name: `ShopHero${userSeq}`, class: 'warrior' }),
});
assert.equal(charRes.status, 201);
const charJson = await charRes.json();
const characterId = charJson.data.character.id;
// Apply overrides via DB
const db = require('../../src/db').getDb();
const cols = Object.keys(overrides);
if (cols.length) {
db.prepare(`UPDATE characters SET ${cols.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`)
.run(...Object.values(overrides), characterId);
}
return { cookie, characterId };
}
/** Grant an item to a character's inventory. */
function grantItem(characterId, itemId, quantity = 1) {
const db = require('../../src/db').getDb();
const NOW = 1_700_000_000_000;
db.prepare('INSERT INTO inventory (character_id, item_id, quantity, equipped_slot, acquired_at) VALUES (?, ?, ?, NULL, ?)')
.run(characterId, itemId, quantity, NOW);
}
// ─── GET /api/shop ────────────────────────────────────────────────────
it('returns shop stock with purchasable items and sellable materials', async () => {
const { cookie } = await setupHero({ gold: 500 });
const res = await fetch(`${baseUrl}/api/shop`, { headers: authHeaders(cookie) });
const body = await res.json();
assert.equal(res.status, 200);
assert.equal(body.ok, true);
assert.ok(Array.isArray(body.data.stock));
// Should have items with buy_price > 0 (purchasable)
const purchasable = body.data.stock.filter((i) => i.buyPrice > 0);
assert.ok(purchasable.length > 0, 'should have purchasable items');
// Should have materials (buy_price = 0)
const materials = body.data.stock.filter((i) => i.buyPrice === 0);
assert.ok(materials.length > 0, 'should have materials for selling');
// Character gold should be present
assert.equal(body.data.character.gold, 500);
});
it('filters stock by character level (levelOk flag)', async () => {
const { cookie } = await setupHero({ gold: 500, level: 1 });
const res = await fetch(`${baseUrl}/api/shop`, { headers: authHeaders(cookie) });
const body = await res.json();
assert.equal(res.status, 200);
// A level-1 hero should see common items (min_level 1) as levelOk=true
const commonItems = body.data.stock.filter((i) => i.levelReq === 1 && i.buyPrice > 0);
assert.ok(commonItems.length > 0);
for (const item of commonItems) {
assert.equal(item.levelOk, true);
}
// Should NOT see high-level items
const highLevelItems = body.data.stock.filter((i) => i.levelReq >= 10);
assert.equal(highLevelItems.length, 0, 'should not show level-10+ items to level-1 hero');
});
it('includes sellPrice for materials', async () => {
const { cookie } = await setupHero({ gold: 100, level: 5 });
const res = await fetch(`${baseUrl}/api/shop`, { headers: authHeaders(cookie) });
const body = await res.json();
const materials = body.data.stock.filter((i) => i.buyPrice === 0);
assert.ok(materials.length > 0);
for (const mat of materials) {
assert.ok(typeof mat.sellPrice === 'number', `material ${mat.name} should have sellPrice`);
assert.ok(mat.sellPrice >= 1, `material ${mat.name} sellPrice should be >= 1`);
}
});
// ─── POST /api/shop/buy ───────────────────────────────────────────────
it('buys an item and deducts gold', async () => {
const { cookie } = await setupHero({ gold: 100 });
// Buy Rusty Sword (item 1, buy_price 20)
const res = await fetch(`${baseUrl}/api/shop/buy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
body: JSON.stringify({ itemId: 1, quantity: 1 }),
});
const body = await res.json();
assert.equal(res.status, 200);
assert.equal(body.ok, true);
// Gold should decrease by 20
assert.equal(body.data.character.gold, 80);
// Rusty Sword should be in inventory
const rustySword = body.data.items.find((i) => i.itemId === 1);
assert.ok(rustySword, 'Rusty Sword should be in inventory');
assert.equal(rustySword.quantity, 1);
});
it('buys multiple quantity and stacks into existing item', async () => {
const { cookie } = await setupHero({ gold: 200 });
// Buy 2 Minor Healing Potions (item 14, stackable, buy_price 15)
const res1 = await fetch(`${baseUrl}/api/shop/buy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
body: JSON.stringify({ itemId: 14, quantity: 2 }),
});
const body1 = await res1.json();
assert.equal(body1.ok, true);
assert.equal(body1.data.character.gold, 170); // 200 - 2*15
const potion1 = body1.data.items.find((i) => i.itemId === 14);
assert.equal(potion1.quantity, 2);
// Buy 3 more of the same
const res2 = await fetch(`${baseUrl}/api/shop/buy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
body: JSON.stringify({ itemId: 14, quantity: 3 }),
});
const body2 = await res2.json();
assert.equal(body2.ok, true);
assert.equal(body2.data.character.gold, 125); // 170 - 3*15
const potion2 = body2.data.items.find((i) => i.itemId === 14);
assert.equal(potion2.quantity, 5); // 2 + 3 stacked
});
it('rejects buy with insufficient gold', async () => {
const { cookie } = await setupHero({ gold: 10 });
// Try to buy Rusty Sword (cost 20) with only 10 gold
const res = await fetch(`${baseUrl}/api/shop/buy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
body: JSON.stringify({ itemId: 1, quantity: 1 }),
});
const body = await res.json();
assert.equal(res.status, 422);
assert.equal(body.ok, false);
assert.equal(body.error.code, 'INSUFFICIENT_GOLD');
// Error responses don't include data — gold unchanged is implicit
});
it('rejects buy for level-gated item', async () => {
const { cookie } = await setupHero({ gold: 1000, level: 1 });
// Try to buy Iron Blade (item 4, min_level 5)
const res = await fetch(`${baseUrl}/api/shop/buy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
body: JSON.stringify({ itemId: 4, quantity: 1 }),
});
const body = await res.json();
assert.equal(res.status, 422);
assert.equal(body.ok, false);
assert.equal(body.error.code, 'LEVEL_TOO_LOW');
});
it('rejects buy for non-purchasable material', async () => {
const { cookie } = await setupHero({ gold: 100 });
// Try to buy Rat Tail (item 17, material with buy_price 0)
const res = await fetch(`${baseUrl}/api/shop/buy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
body: JSON.stringify({ itemId: 17, quantity: 1 }),
});
const body = await res.json();
assert.equal(res.status, 400);
assert.equal(body.ok, false);
assert.equal(body.error.code, 'VALIDATION_ERROR');
});
// ─── POST /api/shop/sell ──────────────────────────────────────────────
it('sells a material and increases gold', async () => {
const { cookie, characterId } = await setupHero({ gold: 50 });
// Grant Rat Tail (item 17, material, common, min_level 1, buy_price 0)
grantItem(characterId, 17, 1);
const beforeGold = db_prepareSelect('SELECT gold FROM characters WHERE id = ?', characterId).gold;
assert.equal(beforeGold, 50);
// Sell the Rat Tail
const res = await fetch(`${baseUrl}/api/shop/sell`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
body: JSON.stringify({ itemId: 17, quantity: 1 }),
});
const body = await res.json();
assert.equal(res.status, 200);
assert.equal(body.ok, true);
// Gold should increase
assert.ok(body.data.character.gold > 50, `gold should increase (was 50, now ${body.data.character.gold})`);
// Rat Tail should be removed from inventory
const ratTail = body.data.items.find((i) => i.itemId === 17);
assert.equal(ratTail, undefined, 'Rat Tail should be removed from inventory');
});
it('sells materials with rarity default pricing', async () => {
const { cookie, characterId } = await setupHero({ gold: 100, level: 5 });
// Grant Spider Silk (item 19, material, uncommon, min_level 5, buy_price 0)
grantItem(characterId, 19, 1);
const res = await fetch(`${baseUrl}/api/shop/sell`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
body: JSON.stringify({ itemId: 19, quantity: 1 }),
});
const body = await res.json();
assert.equal(res.status, 200);
assert.equal(body.ok, true);
// Uncommon material sell: floor(4 * level / min_level) = floor(4 * 5 / 5) = 4
assert.equal(body.data.character.gold, 104); // 100 + 4
});
it('sells uncommon material using rarity default', async () => {
const { cookie, characterId } = await setupHero({ gold: 100, level: 10 });
// Grant Imp Horn (item 20, material, uncommon, min_level 10, buy_price 0)
grantItem(characterId, 20, 1);
const res = await fetch(`${baseUrl}/api/shop/sell`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
body: JSON.stringify({ itemId: 20, quantity: 1 }),
});
const body = await res.json();
assert.equal(res.status, 200);
assert.equal(body.ok, true);
// Uncommon material sell: floor(4 * 10 / 10) = 4
assert.equal(body.data.character.gold, 104); // 100 + 4
});
it('rejects selling a non-material item', async () => {
const { cookie, characterId } = await setupHero({ gold: 100 });
// Grant Rusty Sword (item 1, weapon — not a material)
grantItem(characterId, 1, 1);
const res = await fetch(`${baseUrl}/api/shop/sell`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
body: JSON.stringify({ itemId: 1, quantity: 1 }),
});
const body = await res.json();
assert.equal(res.status, 400);
assert.equal(body.ok, false);
assert.equal(body.error.code, 'VALIDATION_ERROR');
});
it('rejects selling an item not in inventory', async () => {
const { cookie } = await setupHero({ gold: 100 });
// Try to sell Rat Tail without granting it first
const res = await fetch(`${baseUrl}/api/shop/sell`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
body: JSON.stringify({ itemId: 17, quantity: 1 }),
});
const body = await res.json();
assert.equal(res.status, 404);
assert.equal(body.ok, false);
assert.equal(body.error.code, 'NOT_FOUND');
});
// ─── Round-trip ───────────────────────────────────────────────────────
it('buy and sell round-trip: buy then sell loses gold (spread)', async () => {
const { cookie, characterId } = await setupHero({ gold: 500 });
// Buy Minor Healing Potion (item 14, buy_price 15, consumable)
const buyRes = await fetch(`${baseUrl}/api/shop/buy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
body: JSON.stringify({ itemId: 14, quantity: 1 }),
});
const buyBody = await buyRes.json();
assert.equal(buyRes.status, 200);
assert.equal(buyBody.ok, true);
const buyGold = buyBody.data.character.gold; // 500 - 15 = 485
assert.equal(buyGold, 485);
// Sell Rat Tail (item 17, material, common, min_level 1) — must grant it first
grantItem(characterId, 17, 1);
const sellRes = await fetch(`${baseUrl}/api/shop/sell`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
body: JSON.stringify({ itemId: 17, quantity: 1 }),
});
const sellBody = await sellRes.json();
assert.equal(sellRes.status, 200);
assert.equal(sellBody.ok, true);
const sellGold = sellBody.data.character.gold;
// Rat Tail (common, min_level 1) sell: floor(2 * level / 1) = floor(2 * 1 / 1) = 2
// Final gold = 485 + 2 = 487
assert.equal(sellGold, 487);
assert.ok(sellGold < 500, 'round-trip should lose gold due to spread');
// Rat Tail should be gone from inventory
const db = require('../../src/db').getDb();
const invAfter = db.prepare('SELECT * FROM inventory WHERE character_id = ? AND item_id = 17')
.get(characterId);
assert.equal(invAfter, undefined, 'Rat Tail should be removed after sell');
});
});
function db_prepareSelect(sql, ...params) {
return require('../../src/db').getDb().prepare(sql).get(...params);
}