step 5.4: done

This commit is contained in:
bepis
2026-10-20 23:07:57 +11:00
parent bf3cb35e68
commit ae8ee02721
5 changed files with 540 additions and 23 deletions
+17 -14
View File
@@ -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:** 5.3 (stackable consumables: POST /api/inventory/use)
- **Next step:** 5.4 — UI: inventory grid, equipment panel, tooltips with stats
- **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
- **Was the last session cut off mid-task?** No
@@ -23,21 +23,22 @@
- `src/services/zoneService.js` — `listZones(characterLevel)` → zones with `unlocked` flag.
- `src/services/combatLogService.js` — `getCombatLog(characterId, limit)` → recent fights.
- `src/services/characterService.js` — `createCharacter`, `getCharacterProfile`, `getCharacterIdForUser`.
- **NEW:** `src/services/inventoryService.js` — `listInventory`, `equipItem`, `unequipItem`, `useConsumable`. Uses WeakMap stmt cache. Derived stats recalculated on list. Consumables: heal (capped at maxHp) and energy (capped at ENERGY_MAX), qty decrement, row removal at 0.
- **NEW:** `src/routes/inventory.js` — `GET /api/inventory`, `POST /api/inventory/equip`, `POST /api/inventory/unequip`, `POST /api/inventory/use`. Zod-validated.
- `src/services/inventoryService.js` — `listInventory`, `equipItem`, `unequipItem`, `useConsumable`. Uses WeakMap stmt cache.
- `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/inventory.test.js` — 20 integration tests (13 equip/unequip + 7 consumable).
- `public/game.html` — dashboard with stats panel, zone list, combat log, result panel.
- `public/js/game.js` — full hunt UI: load zones, hunt handler, result display, combat log replay, toast notifications, energy sync.
- `public/css/main.css` — zone cards, combat log entries, result panel, toast styles.
- `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/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 consumable UI yet (Phase 5 step 5.4).
- No inventory grid/equipment panel/tooltips in game.html.
- No shop UI yet (Phase 6).
- 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`.
@@ -49,6 +50,8 @@
- Inventory response uses `itemId` (item.id) and `inventoryId` (inventory row id) to avoid confusion.
- Consumable effect parsing: regex `^(heal|energy):(\d+)$` on items.effect field.
- 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).
## 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.
@@ -61,11 +64,11 @@
- `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.
## 7. Next-step briefing (5.4)
- **Goal:** UI for inventory — grid display of items (equipment + consumables), equipment panel showing current gear with stats, tooltips with stat bonuses.
- **Docs:** GAME_DESIGN.md §Items, UI_GUIDE.md, API.md.
- **Files to modify:** `public/game.html` (add inventory/equipment panels), `public/js/game.js` (load inventory, render grid, equip/unequip/use handlers, tooltips), `public/css/main.css` (inventory grid, tooltips, equipment panel styles).
- **Key rules:** Use `GET /api/inventory` for data; render equipment slots + consumable list; show stat bonuses on hover; click to equip/unequip; click consumable to use.
## 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.
## 8. Open Known Issues
STATUS.md issues 1–6 (balance, dangling `seed` script, restart test, equipment-in-combat, HP regen, zone min_level balance).
+7 -4
View File
@@ -7,10 +7,10 @@
> - Keep Session Log entries to one or two lines.
## Current Focus
- **Phase:** 5
- **Step:** 5.4
- **Phase:** 6
- **Step:** 6.1
- **Blocked?** No
- **Last verified working command:** `npm test` — 118/118 passing (87 unit + 31 integration), 2026-10-20 step 5.3
- **Last verified working command:** `npm test` — 118/118 passing (87 unit + 31 integration), 2026-10-20 step 5.4
## Project Health
| Item | State |
@@ -78,7 +78,9 @@
*Acceptance:* list returns items with derived stats; equip moves previous item; unequip clears slot; wrong-type/level/missing-item rejected.
- [x] **5.3** Stackable consumables: `POST /api/inventory/use` (health potion heals; energy tonic restores energy, capped).
*Acceptance:* heal effect caps at maxHp; energy effect caps at ENERGY_MAX; quantity decrements; row removed at qty 0; non-consumable rejected.
- [ ] **5.4** UI: inventory grid, equipment panel, tooltips with stats.
- [x] **5.4** UI: inventory grid, equipment panel, tooltips with stats.
*Acceptance:* inventory grid, equipment panel, tooltips with stats.
*Verified:* tab switching (Hunt/Inventory), equipment panel shows 3 slots with item names, rarity colors, stat bonuses, unequip button; inventory grid shows unequipped items with emoji icons, rarity badges, compact stats, quantity display, and hover tooltips showing full item details (name, type, stats/effect, level requirement).
## Phase 6 — Shop & Economy (docs: GAME_DESIGN §Economy)
- [ ] **6.1** `GET /api/shop`, `POST /api/shop/buy`, `POST /api/shop/sell`. Sell value = 40% of base price. Gold can never go negative.
@@ -148,6 +150,7 @@ Severity: `blocker` / `major` / `minor` / `cosmetic`. Status: `open` / `fixing`
## Session Log
_Format:_ `YYYY-MM-DD | step | what changed | result`
- 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
- 2026-10-20 | 5.1 | Verified 004_inventory.sql migration (inventory table, FK constraints, indexes) applies clean; all tests pass | 98/98 tests pass
+250
View File
@@ -290,6 +290,256 @@ button:hover { background: #c73650; }
color: #eadfce;
}
/* ===== Tabs ===== */
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border, #3a2f27);
margin-bottom: 0.75rem;
}
.tab {
padding: 0.5rem 1.25rem;
background: transparent;
color: var(--muted, #a59a8a);
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
transition: color 0.2s, border-color 0.2s;
}
.tab:hover {
color: var(--text, #eadfce);
}
.tab.active {
color: var(--accent, #e2822b);
border-bottom-color: var(--accent, #e2822b);
}
/* ===== Inventory layout ===== */
.inventory-container {
display: grid;
grid-template-columns: 1fr 1.5fr;
gap: 1rem;
}
@media (max-width: 600px) {
.inventory-container {
grid-template-columns: 1fr;
}
}
/* ===== Equipment panel ===== */
.equipment-panel h4 {
color: var(--accent, #e2822b);
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.equipment-slots {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.equip-slot {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.4rem 0.6rem;
background: #0f3460;
border-radius: 4px;
font-size: 0.8rem;
position: relative;
}
.slot-label {
color: var(--muted, #a59a8a);
text-transform: capitalize;
min-width: 2.5em;
}
.slot-item {
flex: 1;
text-align: right;
}
.slot-item .item-name {
font-weight: 600;
}
.slot-item .item-stats {
font-size: 0.7rem;
color: var(--muted, #a59a8a);
}
.btn-unequip {
background: transparent;
color: var(--danger, #c0392b);
border: 1px solid var(--danger, #c0392b);
padding: 0.15rem 0.5rem;
font-size: 0.7rem;
border-radius: 3px;
cursor: pointer;
margin-left: 0.5rem;
}
.btn-unequip:hover {
background: var(--danger, #c0392b);
color: #fff;
}
.slot-empty {
color: var(--muted, #a59a8a);
font-style: italic;
font-size: 0.75rem;
}
/* ===== Inventory grid ===== */
.inventory-grid-panel h4 {
color: var(--accent, #e2822b);
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.inventory-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.5rem;
max-height: 320px;
overflow-y: auto;
}
.inv-item {
background: #0f3460;
border: 1px solid var(--border, #3a2f27);
border-radius: 4px;
padding: 0.5rem;
cursor: pointer;
position: relative;
transition: border-color 0.2s;
}
.inv-item:hover {
border-color: var(--accent, #e2822b);
}
.inv-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.inv-item-icon {
font-size: 1rem;
}
.inv-item-name {
font-weight: 600;
font-size: 0.8rem;
flex: 1;
text-align: right;
margin-right: 0.5rem;
}
.inv-item-rarity {
font-size: 0.65rem;
padding: 0.1rem 0.35rem;
border-radius: 3px;
text-transform: uppercase;
font-weight: 600;
}
.inv-item-stats {
font-size: 0.7rem;
color: var(--success, #4f9d69);
line-height: 1.3;
}
.inv-item-type {
font-size: 0.65rem;
color: var(--muted, #a59a8a);
margin-top: 0.15rem;
}
.inv-item-qty {
position: absolute;
bottom: 0.3rem;
right: 0.4rem;
font-size: 0.7rem;
color: var(--muted, #a59a8a);
font-weight: 600;
}
/* Rarity colors */
.rarity-common { color: #b8b0a3; }
.rarity-uncommon { color: #5fb36b; }
.rarity-rare { color: #4a8fe0; }
.rarity-epic { color: #a45ee0; }
.rarity-legendary { color: #f2a23a; }
.bg-rarity-common { background: rgba(184, 176, 163, 0.2); color: #b8b0a3; }
.bg-rarity-uncommon { background: rgba(95, 179, 107, 0.2); color: #5fb36b; }
.bg-rarity-rare { background: rgba(74, 143, 224, 0.2); color: #4a8fe0; }
.bg-rarity-epic { background: rgba(164, 94, 224, 0.2); color: #a45ee0; }
.bg-rarity-legendary { background: rgba(242, 162, 58, 0.2); color: #f2a23a; }
/* ===== Tooltip ===== */
.inv-item .tooltip {
display: none;
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: #1a1a2e;
border: 1px solid var(--border, #3a2f27);
border-radius: 6px;
padding: 0.6rem 0.75rem;
min-width: 170px;
max-width: 220px;
z-index: 100;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.inv-item:hover .tooltip {
display: block;
}
.tooltip-name {
font-weight: 700;
font-size: 0.85rem;
margin-bottom: 0.15rem;
}
.tooltip-type {
font-size: 0.7rem;
color: var(--muted, #a59a8a);
margin-bottom: 0.35rem;
}
.tooltip-stats {
font-size: 0.75rem;
color: var(--success, #4f9d69);
line-height: 1.4;
}
.tooltip-effect {
font-size: 0.75rem;
color: var(--accent, #e2822b);
margin-top: 0.25rem;
}
.tooltip-level {
font-size: 0.7rem;
color: var(--muted, #a59a8a);
margin-top: 0.2rem;
}
/* ===== Toast notifications ===== */
.toast {
position: fixed;
+21 -2
View File
@@ -72,12 +72,31 @@
</div>
</section>
<!-- Action panel -->
<!-- Action panel (tabs: Hunt / Inventory) -->
<section class="panel action-panel" id="action-panel">
<h3>Hunt</h3>
<div class="tabs" role="tablist">
<button class="tab active" data-tab="hunt" role="tab" aria-selected="true">Hunt</button>
<button class="tab" data-tab="inventory" role="tab" aria-selected="false">Inventory</button>
</div>
<div id="hunt-tab">
<div id="zone-list" class="zone-list">
<p class="loading">Loading zones...</p>
</div>
</div>
<div id="inventory-tab" style="display:none;">
<div class="inventory-container">
<div class="equipment-panel">
<h4>Equipment</h4>
<div id="equipment-slots" class="equipment-slots"></div>
</div>
<div class="inventory-grid-panel">
<h4>Items</h4>
<div id="inventory-grid" class="inventory-grid">
<p class="loading">Loading...</p>
</div>
</div>
</div>
</div>
</section>
<!-- Combat log panel -->
+243 -1
View File
@@ -1,8 +1,24 @@
// Game dashboard: load character profile, zones, hunt, and combat log.
// Game dashboard: load character profile, zones, hunt, combat log, and inventory.
let energyInterval = null;
let energyData = null; // store latest server energy data for sync
let isHunting = false;
let currentTab = 'hunt';
let inventoryCache = null;
/**
* Switch active tab in the action panel.
*/
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('hunt-tab').style.display = tab === 'hunt' ? '' : 'none';
document.getElementById('inventory-tab').style.display = tab === 'inventory' ? '' : 'none';
}
/**
* Refresh energy state from the server at a fixed interval.
@@ -357,6 +373,226 @@ async function loadCombatLog() {
}
}
// ===== Item 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`;
}
// ===== Inventory =====
const EQUIP_SLOTS = ['weapon', 'armor', 'trinket'];
async function loadInventory() {
try {
const data = await api.get('/inventory');
if (!data) return;
inventoryCache = data;
renderEquipmentPanel(data.items);
renderInventoryGrid(data.items);
} catch (_) {
// Character doesn't exist yet or not authenticated — skip.
}
}
function renderEquipmentPanel(items) {
const container = document.getElementById('equipment-slots');
container.innerHTML = '';
for (const slot of EQUIP_SLOTS) {
const slotDiv = document.createElement('div');
slotDiv.className = 'equip-slot';
const label = document.createElement('span');
label.className = 'slot-label';
label.textContent = slot;
const equipped = items.find((i) => i.equipped_slot === slot);
const itemInfo = document.createElement('span');
itemInfo.className = 'slot-item';
if (equipped) {
const nameSpan = document.createElement('span');
nameSpan.className = `item-name rarity-${equipped.rarity}`;
nameSpan.textContent = equipped.name;
itemInfo.appendChild(nameSpan);
const statsText = formatBonuses(equipped.bonuses);
if (statsText) {
const statsSpan = document.createElement('span');
statsSpan.className = 'item-stats';
statsSpan.textContent = statsText;
itemInfo.appendChild(statsSpan);
}
const unequipBtn = document.createElement('button');
unequipBtn.className = 'btn-unequip';
unequipBtn.textContent = '✕';
unequipBtn.title = `Unequip ${equipped.name}`;
unequipBtn.addEventListener('click', (e) => {
e.stopPropagation();
handleUnequip(slot);
});
itemInfo.appendChild(unequipBtn);
} else {
const emptySpan = document.createElement('span');
emptySpan.className = 'slot-empty';
emptySpan.textContent = 'Empty';
itemInfo.appendChild(emptySpan);
}
slotDiv.appendChild(label);
slotDiv.appendChild(itemInfo);
container.appendChild(slotDiv);
}
}
function renderInventoryGrid(items) {
const container = document.getElementById('inventory-grid');
container.innerHTML = '';
const unequipped = items.filter((i) => !i.equipped);
if (unequipped.length === 0) {
const p = document.createElement('p');
p.className = 'placeholder';
p.textContent = 'No items.';
container.appendChild(p);
return;
}
for (const item of unequipped) {
const card = document.createElement('div');
card.className = 'inv-item';
// Tooltip (always shown on hover)
const tooltip = document.createElement('div');
tooltip.className = 'tooltip';
const tooltipName = document.createElement('div');
tooltipName.className = `tooltip-name rarity-${item.rarity}`;
tooltipName.textContent = item.name;
tooltip.appendChild(tooltipName);
const tooltipType = document.createElement('div');
tooltipType.className = 'tooltip-type';
tooltipType.textContent = `${item.type}${item.quantity > 1 ? ` (x${item.quantity})` : ''}`;
tooltip.appendChild(tooltipType);
const statsText = formatBonuses(item.bonuses);
if (statsText) {
const tooltipStats = document.createElement('div');
tooltipStats.className = 'tooltip-stats';
tooltipStats.textContent = statsText;
tooltip.appendChild(tooltipStats);
}
const effectText = formatEffect(item);
if (effectText) {
const tooltipEffect = document.createElement('div');
tooltipEffect.className = 'tooltip-effect';
tooltipEffect.textContent = effectText;
tooltip.appendChild(tooltipEffect);
}
if (item.min_level > 1) {
const tooltipLevel = document.createElement('div');
tooltipLevel.className = 'tooltip-level';
tooltipLevel.textContent = `Requires Lv. ${item.min_level}`;
tooltip.appendChild(tooltipLevel);
}
card.appendChild(tooltip);
// Compact card content
const header = document.createElement('div');
header.className = 'inv-item-header';
const icon = document.createElement('span');
icon.className = 'inv-item-icon';
icon.textContent = ITEM_EMOJI[item.type] || '❓';
header.appendChild(icon);
const name = document.createElement('span');
name.className = 'inv-item-name rarity-' + item.rarity;
name.textContent = item.name;
header.appendChild(name);
const rarityBadge = document.createElement('span');
rarityBadge.className = `inv-item-rarity bg-rarity-${item.rarity}`;
rarityBadge.textContent = item.rarity;
header.appendChild(rarityBadge);
card.appendChild(header);
const stats = document.createElement('div');
stats.className = 'inv-item-stats';
stats.textContent = formatBonuses(item.bonuses) || formatEffect(item) || '—';
card.appendChild(stats);
if (item.quantity > 1) {
const qty = document.createElement('span');
qty.className = 'inv-item-qty';
qty.textContent = `x${item.quantity}`;
card.appendChild(qty);
}
card.addEventListener('click', () => handleItemClick(item));
container.appendChild(card);
}
}
async function handleItemClick(item) {
try {
let data;
if (item.type === 'consumable') {
data = await api.post('/inventory/use', { itemId: item.itemId });
} else {
data = await api.post('/inventory/equip', { itemId: item.itemId, slot: item.type });
}
if (!data || !data.ok) {
showToast(data?.error?.message || 'Action failed.', 'error');
return;
}
inventoryCache = data;
renderEquipmentPanel(data.items);
renderInventoryGrid(data.items);
loadCharacter();
} catch (err) {
showToast(err.message || 'Action failed.', 'error');
}
}
async function handleUnequip(slot) {
try {
const data = await api.post('/inventory/unequip', { slot });
if (!data || !data.ok) {
showToast(data?.error?.message || 'Unequip failed.', 'error');
return;
}
inventoryCache = data;
renderEquipmentPanel(data.items);
renderInventoryGrid(data.items);
loadCharacter();
} catch (err) {
showToast(err.message || 'Unequip failed.', 'error');
}
}
// ===== Toast notifications =====
function showToast(message, type = 'info') {
// Remove existing toasts
@@ -377,7 +613,13 @@ document.addEventListener('DOMContentLoaded', async () => {
await loadCharacter();
await loadZones();
await loadCombatLog();
await loadInventory();
startEnergySync();
// Tab click handlers
document.querySelectorAll('.tab').forEach((tab) => {
tab.addEventListener('click', () => switchTab(tab.dataset.tab));
});
});
// Logout handler