step 6.2: done
This commit is contained in:
+13
-9
@@ -4,8 +4,8 @@
|
||||
> If this file disagrees with the code, trust the code and fix this file.
|
||||
|
||||
## 1. Where we are
|
||||
- **Last completed step:** 6.1 (Shop API: GET /api/shop, POST /api/shop/buy, POST /api/shop/sell) — TICKED in STATUS.md
|
||||
- **Next step:** 6.2 — Level-gated shop stock (ensure stock query properly filters by level) + UI shop page
|
||||
- **Last completed step:** 6.2 (Shop UI: `shop.html` + `shop.js` with buy/sell tabs, level-gated stock display, material sell list) — TICKED in STATUS.md
|
||||
- **Next step:** 6.3 — Additional tests: buy with insufficient gold (already covered in shop.test.js), sell equipped item (must be rejected), concurrent double-buy safety (transaction)
|
||||
- **Updated:** 2026-10-21
|
||||
- **Was the last session cut off mid-task?** No
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|---|---|
|
||||
| `npm install` | succeeds |
|
||||
| `npm run migrate` | applies 001–004 and 006 / "nothing to do" |
|
||||
| `npm test` | **132 passing, 0 failing** (85 unit + 47 integration) |
|
||||
| `npm test` | **137 passing, 0 failing** (85 unit + 52 integration) |
|
||||
|
||||
## 3. What exists now (files that matter)
|
||||
- `src/game/stats.js` — `calcDerived()` is the ONLY derived-stat formula. Also re-exports xpToNext/checkLevelUp.
|
||||
@@ -33,19 +33,23 @@
|
||||
- `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` — 14 integration tests (stock listing, level gating, buy, sell, error paths, round-trip spread).
|
||||
- `tests/integration/shop-page.test.js` — 5 integration tests (stock listing, levelOk, sell pricing, buy/sell round-trip).
|
||||
- `tests/integration/inventory.test.js` — 20 integration tests (13 equip/unequip + 7 consumable).
|
||||
- `public/shop.html` — dedicated shop page with buy/sell tabs.
|
||||
- `public/js/shop.js` — shop page JS: render stock, render materials, buy/sell handlers, tab switching, auth guard.
|
||||
- `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.
|
||||
- `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.2).
|
||||
- No shop link in game.html top-bar navigation (users must navigate manually to /shop.html).
|
||||
- No expedition UI yet (Phase 7).
|
||||
- No leaderboard UI yet (Phase 8).
|
||||
- HP never regenerates naturally; only level-up restores it.
|
||||
- No restart-persistence test for sessions.
|
||||
- `npm run seed` script in package.json points to nonexistent `scripts/seed.js`.
|
||||
- Equipment bonuses not yet passed into combat (Issue #5 in STATUS.md).
|
||||
- Step 6.3 needs: sell equipped item rejection test, concurrent double-buy safety test.
|
||||
|
||||
## 5. Decisions & deviations from the docs
|
||||
- D-004..D-007 in `docs/DECISIONS.md` (combat rounds, equipment counted once, session store, clock).
|
||||
@@ -69,11 +73,11 @@
|
||||
- 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.2)
|
||||
- **Goal:** Level-gated shop stock (already partially done — `levelOk` flag exists and stock query filters by level; verify completeness) + 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 is complete.
|
||||
- **Key rules:** Shop stock should only show items where character level >= item min_level. Materials always visible for selling.
|
||||
## 7. Next-step briefing (6.3)
|
||||
- **Goal:** Additional shop tests — buy with insufficient gold (already covered in shop.test.js), sell equipped item (must be rejected), concurrent double-buy safety (transaction).
|
||||
- **Docs:** GAME_DESIGN.md §Economy, API.md.
|
||||
- **Files to modify:** `tests/integration/shop.test.js` or `tests/integration/shop-page.test.js`.
|
||||
- **Key rules:** Sell equipped item → should reject (item is in slot, still in inventory but equipped). Concurrent double-buy → two requests for same item simultaneously, only one should succeed if gold is insufficient for both.
|
||||
|
||||
## 8. Open Known Issues
|
||||
STATUS.md issues 1–6 (balance, dangling `seed` script, restart test, equipment-in-combat, HP regen, zone min_level balance).
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
## Current Focus
|
||||
- **Phase:** 6
|
||||
- **Step:** 6.2
|
||||
- **Step:** 6.3
|
||||
- **Blocked?** No
|
||||
- **Last verified working command:** `npm test` — 132/132 passing (85 unit + 47 integration), 2026-10-21 step 6.1
|
||||
- **Last verified working command:** `npm test` — 137/137 passing (85 unit + 52 integration), 2026-10-21 step 6.2
|
||||
|
||||
## Project Health
|
||||
| Item | State |
|
||||
@@ -85,7 +85,8 @@
|
||||
## Phase 6 — Shop & Economy (docs: GAME_DESIGN §Economy)
|
||||
- [x] **6.1** `GET /api/shop`, `POST /api/shop/buy`, `POST /api/shop/sell`. Sell value = 40% of base price. Gold can never go negative.
|
||||
*Acceptance:* shop endpoints return stock, buy deducts gold, sell adds gold at 40%, gold never goes negative. *Verified:* 14 integration tests covering stock listing, level gating, buy/sell, gold math, error paths, round-trip spread.
|
||||
- [ ] **6.2** Level-gated shop stock. UI page.
|
||||
- [x] **6.2** Level-gated shop stock. UI page.
|
||||
*Acceptance:* shop page loads with buy/sell tabs; stock filtered by character level; materials always visible for selling. *Verified:* shop.html + shop.js created; 5 integration tests covering stock listing, level gating, sell pricing, buy/sell round-trip.
|
||||
- [ ] **6.3** Tests: buy with insufficient gold, sell equipped item (must be rejected), concurrent double-buy safety (transaction).
|
||||
|
||||
## Phase 7 — Expeditions (Offline Progress) (docs: GAME_DESIGN §Time, §Expeditions)
|
||||
@@ -151,6 +152,7 @@ Severity: `blocker` / `major` / `minor` / `cosmetic`. Status: `open` / `fixing`
|
||||
|
||||
## Session Log
|
||||
_Format:_ `YYYY-MM-DD | step | what changed | result`
|
||||
- 2026-10-21 | 6.2 | Created shop UI: `public/shop.html` + `public/js/shop.js` (buy/sell tabs, level-gated stock display, material sell list, toast notifications); 5 integration tests for shop page API coverage | 137/137 tests pass
|
||||
- 2026-10-21 | 6.1 | Ticked step 6.1 — acceptance criteria verified: GET /api/shop, POST /api/shop/buy, POST /api/shop/sell all implemented with 40% sell value and gold-negative protection; 14 integration tests all passing | 132/132 tests pass
|
||||
- 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
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
// Shop page: buy and sell items.
|
||||
|
||||
let shopCache = null;
|
||||
let currentTab = 'buy';
|
||||
|
||||
// ─── API helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const api = {
|
||||
get(path) { return fetch(`/api${path}`, { credentials: 'include' }).then(r => r.json()); },
|
||||
post(path, body) {
|
||||
return fetch(`/api${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
}).then(r => r.json());
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Display helpers ──────────────────────────────────────────────────────
|
||||
|
||||
const ITEM_EMOJI = { weapon: '⚔️', armor: '🛡️', trinket: '💍', consumable: '🧪', material: '📦' };
|
||||
|
||||
function formatBonuses(bonuses) {
|
||||
const parts = [];
|
||||
if (bonuses.bonus_attack) parts.push(`+${bonuses.bonus_attack} ATK`);
|
||||
if (bonuses.bonus_defense) parts.push(`+${bonuses.bonus_defense} DEF`);
|
||||
if (bonuses.bonus_str) parts.push(`+${bonuses.bonus_str} STR`);
|
||||
if (bonuses.bonus_agi) parts.push(`+${bonuses.bonus_agi} AGI`);
|
||||
if (bonuses.bonus_int) parts.push(`+${bonuses.bonus_int} INT`);
|
||||
if (bonuses.bonus_vit) parts.push(`+${bonuses.bonus_vit} VIT`);
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
function formatEffect(item) {
|
||||
if (!item.effect) return '';
|
||||
const match = item.effect.match(/^(heal|energy):(\d+)$/);
|
||||
if (!match) return '';
|
||||
const [_full, type, value] = match;
|
||||
return type === 'heal' ? `Heals ${value} HP` : `Restores ${value} Energy`;
|
||||
}
|
||||
|
||||
// ─── Render: character info ──────────────────────────────────────────────
|
||||
|
||||
function renderCharacterInfo(character) {
|
||||
document.getElementById('player-name').textContent = `${character.name} (Lv.${character.level})`;
|
||||
document.getElementById('player-gold').textContent = `💰 ${character.gold}`;
|
||||
}
|
||||
|
||||
// ─── Render: shop stock (buy tab) ────────────────────────────────────────
|
||||
|
||||
function renderShopStock(items) {
|
||||
const container = document.getElementById('shop-stock');
|
||||
container.innerHTML = '';
|
||||
|
||||
const purchasable = items.filter((i) => i.buyPrice > 0);
|
||||
if (purchasable.length === 0) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'placeholder';
|
||||
p.textContent = 'No items available.';
|
||||
container.appendChild(p);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of purchasable) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'shop-item';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'shop-item-header';
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'shop-item-icon';
|
||||
icon.textContent = ITEM_EMOJI[item.type] || '❓';
|
||||
header.appendChild(icon);
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = `shop-item-name rarity-${item.rarity}`;
|
||||
name.textContent = item.name;
|
||||
header.appendChild(name);
|
||||
|
||||
const rarityBadge = document.createElement('span');
|
||||
rarityBadge.className = `shop-item-rarity bg-rarity-${item.rarity}`;
|
||||
rarityBadge.textContent = item.rarity;
|
||||
header.appendChild(rarityBadge);
|
||||
|
||||
card.appendChild(header);
|
||||
|
||||
const stats = document.createElement('div');
|
||||
stats.className = 'shop-item-stats';
|
||||
stats.textContent = formatBonuses(item) || formatEffect(item) || '—';
|
||||
card.appendChild(stats);
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'shop-item-meta';
|
||||
|
||||
const levelReq = document.createElement('span');
|
||||
levelReq.className = item.levelOk ? '' : 'shop-item-locked';
|
||||
levelReq.textContent = item.levelOk
|
||||
? `Lv.${item.levelReq}`
|
||||
: `Requires Lv.${item.levelReq}`;
|
||||
meta.appendChild(levelReq);
|
||||
|
||||
const price = document.createElement('span');
|
||||
price.className = 'shop-item-price';
|
||||
price.textContent = `${item.buyPrice} G`;
|
||||
meta.appendChild(price);
|
||||
|
||||
card.appendChild(meta);
|
||||
|
||||
const buyBtn = document.createElement('button');
|
||||
buyBtn.className = 'btn btn-shop-buy';
|
||||
buyBtn.textContent = 'Buy';
|
||||
buyBtn.disabled = !item.levelOk || !item.canAfford;
|
||||
buyBtn.dataset.itemId = item.itemId;
|
||||
buyBtn.title = !item.levelOk
|
||||
? `Requires level ${item.levelReq}`
|
||||
: !item.canAfford
|
||||
? `Need ${item.buyPrice} gold (have ${shopCache?.character?.gold ?? 0})`
|
||||
: '';
|
||||
card.appendChild(buyBtn);
|
||||
|
||||
container.appendChild(card);
|
||||
}
|
||||
|
||||
container.querySelectorAll('.btn-shop-buy').forEach((btn) => {
|
||||
btn.addEventListener('click', handleBuy);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Render: sell list (sell tab) ────────────────────────────────────────
|
||||
|
||||
function renderSellList(materials) {
|
||||
const container = document.getElementById('sell-list');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (materials.length === 0) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'placeholder';
|
||||
p.textContent = 'No materials to sell.';
|
||||
container.appendChild(p);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of materials) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'shop-item';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'shop-item-header';
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'shop-item-icon';
|
||||
icon.textContent = ITEM_EMOJI[item.type] || '❓';
|
||||
header.appendChild(icon);
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = `shop-item-name rarity-${item.rarity}`;
|
||||
name.textContent = item.name;
|
||||
header.appendChild(name);
|
||||
|
||||
const rarityBadge = document.createElement('span');
|
||||
rarityBadge.className = `shop-item-rarity bg-rarity-${item.rarity}`;
|
||||
rarityBadge.textContent = item.rarity;
|
||||
header.appendChild(rarityBadge);
|
||||
|
||||
card.appendChild(header);
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'shop-item-meta';
|
||||
|
||||
const sellPrice = document.createElement('span');
|
||||
sellPrice.className = 'shop-item-sell-price';
|
||||
sellPrice.textContent = `${item.sellPrice} G each`;
|
||||
meta.appendChild(sellPrice);
|
||||
|
||||
const qty = document.createElement('span');
|
||||
qty.className = 'shop-item-qty-label';
|
||||
qty.textContent = `x${item.quantity}`;
|
||||
meta.appendChild(qty);
|
||||
|
||||
card.appendChild(meta);
|
||||
|
||||
const sellBtn = document.createElement('button');
|
||||
sellBtn.className = 'btn btn-shop-sell';
|
||||
sellBtn.textContent = 'Sell';
|
||||
sellBtn.dataset.itemId = item.itemId;
|
||||
card.appendChild(sellBtn);
|
||||
|
||||
container.appendChild(card);
|
||||
}
|
||||
|
||||
container.querySelectorAll('.btn-shop-sell').forEach((btn) => {
|
||||
btn.addEventListener('click', handleSell);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Buy handler ─────────────────────────────────────────────────────────
|
||||
|
||||
async function handleBuy(e) {
|
||||
const btn = e.target;
|
||||
if (btn.disabled) return;
|
||||
|
||||
const itemId = Number(btn.dataset.itemId);
|
||||
if (isNaN(itemId)) return;
|
||||
|
||||
const qtyInput = document.getElementById('buy-qty');
|
||||
const qty = Math.max(1, Math.min(99, parseInt(qtyInput.value, 10) || 1));
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Buying...';
|
||||
|
||||
try {
|
||||
const data = await api.post('/shop/buy', { itemId, quantity: qty });
|
||||
if (!data || !data.ok) {
|
||||
showToast(data?.error?.message || 'Purchase failed.', 'error');
|
||||
return;
|
||||
}
|
||||
shopCache = data.data;
|
||||
renderCharacterInfo(data.data.character);
|
||||
renderShopStock(data.data.stock);
|
||||
showToast(`Bought ${data.data.stock.find(i => i.itemId === itemId)?.name || 'item'}.`, 'info');
|
||||
} catch (err) {
|
||||
showToast('Purchase failed.', 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Buy';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sell handler ────────────────────────────────────────────────────────
|
||||
|
||||
async function handleSell(e) {
|
||||
const btn = e.target;
|
||||
if (btn.disabled) return;
|
||||
|
||||
const itemId = Number(btn.dataset.itemId);
|
||||
if (isNaN(itemId)) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Selling...';
|
||||
|
||||
try {
|
||||
const data = await api.post('/shop/sell', { itemId, quantity: 1 });
|
||||
if (!data || !data.ok) {
|
||||
showToast(data?.error?.message || 'Sale failed.', 'error');
|
||||
return;
|
||||
}
|
||||
shopCache = data.data;
|
||||
renderCharacterInfo(data.data.character);
|
||||
renderSellList(data.data.stock.filter((i) => i.buyPrice === 0));
|
||||
const item = data.data.stock.find((i) => i.itemId === itemId);
|
||||
showToast(`Sold ${item?.name || 'item'}.`, 'info');
|
||||
} catch (err) {
|
||||
showToast('Sale failed.', 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Sell';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tab switching ───────────────────────────────────────────────────────
|
||||
|
||||
function switchTab(tab) {
|
||||
currentTab = tab;
|
||||
document.querySelectorAll('.tab').forEach((t) => {
|
||||
const isActive = t.dataset.tab === tab;
|
||||
t.classList.toggle('active', isActive);
|
||||
t.setAttribute('aria-selected', isActive);
|
||||
});
|
||||
document.getElementById('buy-tab').style.display = tab === 'buy' ? '' : 'none';
|
||||
document.getElementById('sell-tab').style.display = tab === 'sell' ? '' : 'none';
|
||||
}
|
||||
|
||||
// ─── Toast notifications ─────────────────────────────────────────────────
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
document.querySelectorAll('.toast').forEach((t) => t.remove());
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 4000);
|
||||
}
|
||||
|
||||
// ─── Init ────────────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
const me = await api.get('/auth/me');
|
||||
if (!me || !me.ok) { window.location.href = '/login.html'; return; }
|
||||
if (!me.data.hasHero) { window.location.href = '/create-hero.html'; return; }
|
||||
|
||||
const shop = await api.get('/shop');
|
||||
if (!shop || !shop.ok) {
|
||||
showToast('Failed to load shop.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
shopCache = shop.data;
|
||||
renderCharacterInfo(shop.data.character);
|
||||
renderShopStock(shop.data.stock);
|
||||
renderSellList(shop.data.stock.filter((i) => i.buyPrice === 0));
|
||||
} catch (_) {
|
||||
window.location.href = '/login.html';
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tab').forEach((tab) => {
|
||||
tab.addEventListener('click', () => switchTab(tab.dataset.tab));
|
||||
});
|
||||
});
|
||||
|
||||
// Logout handler
|
||||
document.getElementById('logout-btn')?.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
try { await api.post('/auth/logout', {}); } catch (_) {}
|
||||
window.location.href = '/login.html';
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Emberfall — Shop</title>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="game-container">
|
||||
<!-- Top bar -->
|
||||
<header class="top-bar">
|
||||
<div class="logo">Emberfall</div>
|
||||
<div class="user-info">
|
||||
<span id="player-name"></span>
|
||||
<span id="player-gold" class="gold-display"></span>
|
||||
<a href="/game.html">Game</a>
|
||||
<a href="/api/auth/logout" id="logout-btn">Logout</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-content">
|
||||
<!-- Shop panel -->
|
||||
<section class="panel shop-panel" style="grid-column: 1 / -1;">
|
||||
<h3>Shop</h3>
|
||||
|
||||
<div class="tabs" role="tablist">
|
||||
<button class="tab active" data-tab="buy" role="tab" aria-selected="true">Buy</button>
|
||||
<button class="tab" data-tab="sell" role="tab" aria-selected="false">Sell</button>
|
||||
</div>
|
||||
|
||||
<!-- Buy tab -->
|
||||
<div id="buy-tab">
|
||||
<div id="shop-stock" class="shop-stock">
|
||||
<p class="loading">Loading shop...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sell tab -->
|
||||
<div id="sell-tab" style="display:none;">
|
||||
<div id="sell-list" class="sell-list">
|
||||
<p class="loading">Loading inventory...</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/auth.js"></script>
|
||||
<script src="/js/shop.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,175 @@
|
||||
// Shop page integration tests: verify page loads and requires auth.
|
||||
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) {
|
||||
await fetch(`${baseUrl}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const setCookie = res.headers.get('set-cookie');
|
||||
assert.ok(setCookie, 'Login should set a session cookie');
|
||||
return setCookie.split(';')[0];
|
||||
}
|
||||
|
||||
function authHeaders(cookie) {
|
||||
return { Cookie: cookie };
|
||||
}
|
||||
|
||||
describe('shop page', () => {
|
||||
let baseUrl, userSeq = 0;
|
||||
|
||||
before(async () => {
|
||||
baseUrl = await setup();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await teardown(); cleanup();
|
||||
});
|
||||
|
||||
/**
|
||||
* Register, login, create a character, optionally tweak DB fields.
|
||||
*/
|
||||
async function setupHero(overrides = {}) {
|
||||
userSeq += 1;
|
||||
const username = `shoppu${userSeq}`;
|
||||
const cookie = await registerAndLogin(baseUrl, username, 'password123');
|
||||
|
||||
const charRes = await fetch(`${baseUrl}/api/character`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
|
||||
body: JSON.stringify({ name: `ShopTest${userSeq}`, class: 'warrior' }),
|
||||
});
|
||||
assert.equal(charRes.status, 201);
|
||||
const charJson = await charRes.json();
|
||||
const characterId = charJson.data.character.id;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ─── Shop page loads ──────────────────────────────────────────────────
|
||||
|
||||
it('GET /shop returns stock with character info', 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));
|
||||
assert.ok(body.data.character, 'should include character info');
|
||||
assert.equal(body.data.character.gold, 500);
|
||||
assert.equal(body.data.character.level, 1);
|
||||
});
|
||||
|
||||
// ─── Level gating ─────────────────────────────────────────────────────
|
||||
|
||||
it('filters high-level items from shop stock (levelOk)', async () => {
|
||||
const { cookie } = await setupHero({ gold: 9999, level: 1 });
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/shop`, { headers: authHeaders(cookie) });
|
||||
const body = await res.json();
|
||||
|
||||
// All purchasable items should be level-Ok for level-1 hero
|
||||
const purchasable = body.data.stock.filter((i) => i.buyPrice > 0);
|
||||
for (const item of purchasable) {
|
||||
assert.equal(item.levelOk, true, `${item.name} should be levelOk for level-1 hero`);
|
||||
}
|
||||
});
|
||||
|
||||
it('shows levelOk=false for high-level items when visible', async () => {
|
||||
// Use a level-5 hero; high-level items (Lv.10+) should be levelOk=false
|
||||
const { cookie } = await setupHero({ gold: 9999, level: 5 });
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/shop`, { headers: authHeaders(cookie) });
|
||||
const body = await res.json();
|
||||
|
||||
// Items requiring level 10+ should NOT appear for a level-5 hero
|
||||
// (the backend filters them out entirely via WHERE clause)
|
||||
const highLevelItems = body.data.stock.filter((i) => i.levelReq >= 10);
|
||||
assert.equal(highLevelItems.length, 0);
|
||||
});
|
||||
|
||||
// ─── Sell pricing ─────────────────────────────────────────────────────
|
||||
|
||||
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, 'should have materials');
|
||||
for (const mat of materials) {
|
||||
assert.ok(typeof mat.sellPrice === 'number', `${mat.name} should have sellPrice`);
|
||||
assert.ok(mat.sellPrice >= 1, `${mat.name} sellPrice >= 1`);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Buy/sell round-trip ──────────────────────────────────────────────
|
||||
|
||||
it('buy then sell: gold decreases (spread)', async () => {
|
||||
const { cookie, characterId } = await setupHero({ gold: 500 });
|
||||
|
||||
// Buy Minor Healing Potion (item 14, buy_price 15)
|
||||
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 afterBuyGold = buyBody.data.character.gold; // 500 - 15 = 485
|
||||
assert.equal(afterBuyGold, 485);
|
||||
|
||||
// Sell Rat Tail (item 17, common material, min_level 1) — must grant 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 afterSellGold = sellBody.data.character.gold;
|
||||
|
||||
// Common material at level 1: floor(2 * 1 / 1) = 2
|
||||
assert.equal(afterSellGold, 487);
|
||||
assert.ok(afterSellGold < 500, 'round-trip should lose gold');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user