Shop
+ +Loading shop...
+diff --git a/HANDOFF.md b/HANDOFF.md index 5b253b0..a8377df 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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). diff --git a/STATUS.md b/STATUS.md index 1bfb350..cb5f00d 100644 --- a/STATUS.md +++ b/STATUS.md @@ -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 diff --git a/public/js/shop.js b/public/js/shop.js new file mode 100644 index 0000000..f555eeb --- /dev/null +++ b/public/js/shop.js @@ -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'; +}); diff --git a/public/shop.html b/public/shop.html new file mode 100644 index 0000000..bd71230 --- /dev/null +++ b/public/shop.html @@ -0,0 +1,52 @@ + + +
+ + +Loading shop...
+