step 5.2: done
This commit is contained in:
+19
-12
@@ -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.1 (inventory migration verified — already existed from step 4.4 review, applies clean)
|
||||
- **Next step:** 5.2 — inventory/equipment services + routes (list, equip, unequip, slots)
|
||||
- **Last completed step:** 5.2 (inventory/equipment services + routes — list, equip, unequip)
|
||||
- **Next step:** 5.3 — stackable consumables: POST /api/inventory/use (health potion heals; energy tonic restores energy, capped)
|
||||
- **Updated:** 2026-10-20
|
||||
- **Was the last session cut off mid-task?** No
|
||||
|
||||
@@ -14,34 +14,39 @@
|
||||
|---|---|
|
||||
| `npm install` | succeeds |
|
||||
| `npm run migrate` | applies 001–004 and 006 / "nothing to do" |
|
||||
| `npm test` | **98 passing, 0 failing** (74 unit + 29 integration) |
|
||||
| `npm test` | **111 passing, 0 failing** (87 unit + 24 integration) |
|
||||
|
||||
## 3. What exists now (files that matter)
|
||||
- `src/game/stats.js` — `calcDerived()` is the ONLY derived-stat formula. Also re-exports xpToNext/checkLevelUp (implemented in `leveling.js`).
|
||||
- `src/game/stats.js` — `calcDerived()` is the ONLY derived-stat formula. Also re-exports xpToNext/checkLevelUp.
|
||||
- `src/game/combat.js` — `resolveCombat(hero, monster, rng)`; both sides attack each round, faster first.
|
||||
- `src/services/huntService.js` — `hunt(characterId, zoneId, {rng, now})`; ONE transaction; test-injectable.
|
||||
- `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`. Uses WeakMap stmt cache. Derived stats recalculated on list.
|
||||
- **NEW:** `src/routes/inventory.js` — `GET /api/inventory`, `POST /api/inventory/equip`, `POST /api/inventory/unequip`. Zod-validated.
|
||||
- `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/zones-log.test.js` — 5 integration tests for zones + combat-log endpoints.
|
||||
- `tests/integration/inventory.test.js` — 13 integration tests for inventory service.
|
||||
- `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.
|
||||
|
||||
## 4. Half-finished / fragile things
|
||||
- No inventory/equipment system yet (Phase 5 steps 5.2–5.4).
|
||||
- No consumable use endpoint yet (Phase 5 step 5.3).
|
||||
- No UI for inventory/equipment (Phase 5 step 5.4).
|
||||
- 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).
|
||||
|
||||
## 5. Decisions & deviations from the docs
|
||||
- D-004..D-007 in `docs/DECISIONS.md` (combat rounds, equipment counted once, session store, clock).
|
||||
- Zod query param parsing uses `zod.coerce.number()` because Express query params are strings.
|
||||
- Inventory response uses `itemId` (item.id) and `inventoryId` (inventory row id) to avoid confusion.
|
||||
|
||||
## 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.
|
||||
@@ -50,13 +55,15 @@
|
||||
- Verify test COUNT, not just "all passing".
|
||||
- Node.js `fetch` doesn't persist cookies across calls — extract `Set-Cookie` header and pass manually in integration tests.
|
||||
- Express query params are strings, not numbers — use `zod.coerce.number()` for numeric query params.
|
||||
- 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.
|
||||
|
||||
## 7. Next-step briefing (5.2)
|
||||
- **Goal:** Services + routes for inventory management: list inventory, equip, unequip. Slots: weapon, armor, trinket. Equipment modifies derived stats via `calcDerived()`.
|
||||
- **Docs:** DATABASE.md (§004_inventory), GAME_DESIGN.md §Items, BALANCE.md.
|
||||
- **Files to create:** `src/services/inventoryService.js`, `src/routes/inventory.js`.
|
||||
- **Key rules:** one equipped item per slot (enforced by partial unique index in DB); equipping an item moves the previous one in that slot to unequipped; stackable items merge (quantity); non-stackable get one row per drop.
|
||||
- **Tests:** at least one happy-path + one failure-path test.
|
||||
## 7. Next-step briefing (5.3)
|
||||
- **Goal:** Stackable consumables: `POST /api/inventory/use` — health potion heals HP (capped at maxHp); energy tonic restores energy (capped at ENERGY_MAX). Only consumable type items can be used. Consumable is consumed (quantity -1, removed when 0).
|
||||
- **Docs:** DATABASE.md (§004_inventory), GAME_DESIGN.md §Items, BALANCE.md (effect format: 'heal:50', 'energy:25').
|
||||
- **Files to create/modify:** extend `inventoryService.js` with `useConsumable(characterId, itemId)`, extend `inventory.js` route.
|
||||
- **Key rules:** parse `effect` field from items table ('heal:N' or 'energy:N'); validate character has the item in inventory (quantity >= 1); apply effect; decrement quantity; remove row when quantity reaches 0; return updated inventory listing.
|
||||
- **Tests:** happy-path heal, happy-path energy, use on non-consumable rejected, insufficient quantity, effect capped at maxHp/energyMax.
|
||||
|
||||
## 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:** 5
|
||||
- **Step:** 5.2
|
||||
- **Step:** 5.3
|
||||
- **Blocked?** No
|
||||
- **Last verified working command:** `npm test` — 98/98 passing (74 unit + 29 integration), 2026-10-20 step 4.5
|
||||
- **Last verified working command:** `npm test` — 111/111 passing (87 unit + 24 integration), 2026-10-20 step 5.2
|
||||
|
||||
## Project Health
|
||||
| Item | State |
|
||||
@@ -20,6 +20,7 @@
|
||||
| Migrations run clean on empty DB | ✅ 001_users, 002_characters, 003_content (also seeds zones/monsters/items), 004_inventory, 006_logs (005 reserved for expeditions) |
|
||||
| Can register + login | ✅ verified by tests |
|
||||
| Data persists across restart | ⚠️ sessions use the real SQLite store (also in tests); a restart test is still missing |
|
||||
| Inventory/equipment | ✅ list, equip, unequip all working; derived stats update on equip/unequip; slot swap works |
|
||||
|
||||
---
|
||||
|
||||
@@ -73,7 +74,8 @@
|
||||
## Phase 5 — Inventory & Equipment (docs: GAME_DESIGN §Items, DATABASE)
|
||||
- [x] **5.1** Migration `004_inventory.sql`.
|
||||
*Acceptance:* migration creates inventory table with FK constraints and indexes.
|
||||
- [ ] **5.2** Services + routes: list inventory, equip, unequip (slots: weapon, armor, trinket). Equipment modifies derived stats.
|
||||
- [x] **5.2** Services + routes: list inventory, equip, unequip (slots: weapon, armor, trinket). Equipment modifies derived stats via `calcDerived()`.
|
||||
*Acceptance:* list returns items with derived stats; equip moves previous item; unequip clears slot; wrong-type/level/missing-item rejected.
|
||||
- [ ] **5.3** Stackable consumables: `POST /api/inventory/use` (health potion heals; energy tonic restores energy, capped).
|
||||
- [ ] **5.4** UI: inventory grid, equipment panel, tooltips with stats.
|
||||
|
||||
@@ -145,6 +147,7 @@ Severity: `blocker` / `major` / `minor` / `cosmetic`. Status: `open` / `fixing`
|
||||
|
||||
## Session Log
|
||||
_Format:_ `YYYY-MM-DD | step | what changed | result`
|
||||
- 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
|
||||
- 2026-10-20 | 4.5 | Added `GET /api/zones` (zoneService + zones route), `GET /api/combat-log` (combatLogService + combatLog route); extended game.html with zone list/combat log/result panels; rewrote game.js with hunt UI (zone cards, hunt button, result display, combat log replay, toast notifications); added CSS for zone cards/combat log/toasts; 5 new integration tests | 98/98 tests pass
|
||||
- 2026-10-20 | review | Fixed `npm test` (ran 62 of 79 tests; auth/combat/health were skipped by an unquoted glob). Rewrote combat (slower side never attacked; mage bonus dead; hpRemaining wrong on loss). Rewrote huntService (XP not deducted on level-up, unspent points overwritten, deaths never counted, min_level ignored, stackable loot duplicated, duplicate stat formulas). Fixed nextTickInSec (was time-to-full). Added utils/time.js, WeakMap stmt cache, session store unref, ok:false error envelope, removed connect-sqlite3 | 93/93 tests pass; smoke test OK
|
||||
|
||||
@@ -6,6 +6,7 @@ const { routes: characterRoutes } = require('./routes/character');
|
||||
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 { AppError } = require('./utils/errors');
|
||||
|
||||
function createApp() {
|
||||
@@ -52,6 +53,11 @@ function createApp() {
|
||||
// Combat log routes (require session)
|
||||
app.get('/api/combat-log', requireAuth, combatLogRoutes.getCombatLog);
|
||||
|
||||
// Inventory routes (require session)
|
||||
app.get('/api/inventory', requireAuth, inventoryRoutes.getInventory);
|
||||
app.post('/api/inventory/equip', requireAuth, inventoryRoutes.postEquip);
|
||||
app.post('/api/inventory/unequip', requireAuth, inventoryRoutes.postUnequip);
|
||||
|
||||
// Central error handler
|
||||
app.use((err, _req, res, _next) => {
|
||||
// Log stack traces server-side only
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Inventory routes: list, equip, unequip.
|
||||
const zod = require('zod');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const { requireAuth } = require('../middleware/session');
|
||||
const { getCharacterIdForUser } = require('../services/characterService');
|
||||
const { listInventory, equipItem, unequipItem } = require('../services/inventoryService');
|
||||
|
||||
// Validation schemas
|
||||
const slotSchema = zod.enum(['weapon', 'armor', 'trinket']);
|
||||
|
||||
function wrap(fn) {
|
||||
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/inventory — list all items, equipped gear, and derived stats.
|
||||
*/
|
||||
async function handleListInventory(req, _res) {
|
||||
const characterId = getCharacterIdForUser(req.session.userId);
|
||||
const result = listInventory(characterId);
|
||||
res.json({ ok: true, data: result });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/inventory/equip — equip an item into a slot.
|
||||
*/
|
||||
async function handleEquip(req, res) {
|
||||
const parsed = zod.strictObject({
|
||||
itemId: zod.number().int().positive(),
|
||||
slot: slotSchema,
|
||||
}).safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new AppError('VALIDATION_ERROR', 'Request validation failed.', 400);
|
||||
}
|
||||
|
||||
const { itemId, slot } = parsed.data;
|
||||
const characterId = getCharacterIdForUser(req.session.userId);
|
||||
const result = equipItem(characterId, itemId, slot);
|
||||
res.json({ ok: true, data: result });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/inventory/unequip — unequip an item from a slot.
|
||||
*/
|
||||
async function handleUnequip(req, res) {
|
||||
const parsed = zod.strictObject({
|
||||
slot: slotSchema,
|
||||
}).safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new AppError('VALIDATION_ERROR', 'Request validation failed.', 400);
|
||||
}
|
||||
|
||||
const { slot } = parsed.data;
|
||||
const characterId = getCharacterIdForUser(req.session.userId);
|
||||
const result = unequipItem(characterId, slot);
|
||||
res.json({ ok: true, data: result });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
routes: {
|
||||
getInventory: wrap(handleListInventory),
|
||||
postEquip: wrap(handleEquip),
|
||||
postUnequip: wrap(handleUnequip),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
// Inventory service: list, equip, unequip items.
|
||||
// Equipment bonuses feed into calcDerived() for stat updates.
|
||||
const { getDb } = require('../db');
|
||||
const { AppError } = require('../utils/errors');
|
||||
|
||||
const VALID_SLOTS = new Set(['weapon', 'armor', 'trinket']);
|
||||
|
||||
// Prepared statements cached per DB handle (WeakMap).
|
||||
const stmtCache = new WeakMap();
|
||||
|
||||
function getStmts(db) {
|
||||
let s = stmtCache.get(db);
|
||||
if (!s) {
|
||||
s = {
|
||||
// Get all inventory items for a character, with item details.
|
||||
listItems: 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
|
||||
`),
|
||||
|
||||
// Get item details by id (for equip/unequip validation).
|
||||
itemById: db.prepare(
|
||||
'SELECT * FROM items WHERE id = ?'
|
||||
),
|
||||
|
||||
// Check what's currently equipped in a slot.
|
||||
itemInSlot: db.prepare(
|
||||
'SELECT id AS inventory_id FROM inventory WHERE character_id = ? AND equipped_slot = ?'
|
||||
),
|
||||
|
||||
// Get inventory row for a specific (character, item) pair.
|
||||
inventoryRow: db.prepare(
|
||||
'SELECT * FROM inventory WHERE character_id = ? AND item_id = ?'
|
||||
),
|
||||
|
||||
// Equip an item (set slot).
|
||||
equip: db.prepare(
|
||||
'UPDATE inventory SET equipped_slot = ? WHERE id = ?'
|
||||
),
|
||||
|
||||
// Unequip an item (clear slot).
|
||||
unequip: db.prepare(
|
||||
'UPDATE inventory SET equipped_slot = NULL WHERE id = ?'
|
||||
),
|
||||
|
||||
// Get character for derived stats recalculation.
|
||||
character: db.prepare(
|
||||
'SELECT * FROM characters WHERE id = ?'
|
||||
),
|
||||
};
|
||||
stmtCache.set(db, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all inventory items for a character, including what's equipped.
|
||||
* @param {number} characterId
|
||||
* @returns {object} { items: [...], derived: {...}, gold }
|
||||
*/
|
||||
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.listItems.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
|
||||
);
|
||||
|
||||
// Format items for the client.
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Equip an item into a slot. If something is already equipped there, unequip it first.
|
||||
* @param {number} characterId
|
||||
* @param {number} itemId
|
||||
* @param {string} slot - 'weapon' | 'armor' | 'trinket'
|
||||
* @returns {object} updated inventory listing
|
||||
*/
|
||||
function equipItem(characterId, itemId, slot) {
|
||||
if (!VALID_SLOTS.has(slot)) {
|
||||
throw new AppError('VALIDATION_ERROR', `Invalid slot '${slot}'. Must be weapon, armor, or trinket.`, 400);
|
||||
}
|
||||
|
||||
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 !== slot) {
|
||||
throw new AppError('VALIDATION_ERROR', `${item.name} is a ${item.type}, not a ${slot}.`, 400);
|
||||
}
|
||||
|
||||
if (ch.level < item.min_level) {
|
||||
throw new AppError('LEVEL_TOO_LOW', `${item.name} requires level ${item.min_level}.`, 422);
|
||||
}
|
||||
|
||||
// Find the inventory row for this (character, item).
|
||||
const invRow = s.inventoryRow.get(characterId, item.id);
|
||||
if (!invRow) throw new AppError('NOT_FOUND', 'Item not in inventory.', 404);
|
||||
|
||||
// If something is already equipped in this slot, unequip it.
|
||||
const existing = s.itemInSlot.get(characterId, slot);
|
||||
if (existing) {
|
||||
s.unequip.run(existing.inventory_id);
|
||||
}
|
||||
|
||||
// Equip the new item.
|
||||
s.equip.run(slot, invRow.id);
|
||||
|
||||
// Return updated listing.
|
||||
return listInventory(characterId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unequip an item from a slot.
|
||||
* @param {number} characterId
|
||||
* @param {string} slot - 'weapon' | 'armor' | 'trinket'
|
||||
* @returns {object} updated inventory listing
|
||||
*/
|
||||
function unequipItem(characterId, slot) {
|
||||
if (!VALID_SLOTS.has(slot)) {
|
||||
throw new AppError('VALIDATION_ERROR', `Invalid slot '${slot}'. Must be weapon, armor, or trinket.`, 400);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const s = getStmts(db);
|
||||
|
||||
const existing = s.itemInSlot.get(characterId, slot);
|
||||
if (!existing) {
|
||||
throw new AppError('NOT_FOUND', `Nothing equipped in ${slot} slot.`, 404);
|
||||
}
|
||||
|
||||
s.unequip.run(existing.inventory_id);
|
||||
|
||||
return listInventory(characterId);
|
||||
}
|
||||
|
||||
module.exports = { listInventory, equipItem, unequipItem };
|
||||
@@ -0,0 +1,166 @@
|
||||
// Service-level tests for inventoryService: list, equip, unequip.
|
||||
// Uses the same fresh-DB pattern as hunt-service.test.js.
|
||||
const { describe, it, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { setup, teardown, cleanup } = require('../helpers');
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
describe('inventoryService', () => {
|
||||
let db, listInventory, equipItem, unequipItem, createCharacter, userSeq = 0;
|
||||
|
||||
before(async () => {
|
||||
await setup();
|
||||
db = require('../../src/db').getDb();
|
||||
({ listInventory, equipItem, unequipItem } = require('../../src/services/inventoryService'));
|
||||
({ createCharacter } = require('../../src/services/characterService'));
|
||||
});
|
||||
|
||||
after(async () => { await teardown(); cleanup(); });
|
||||
|
||||
function makeHero(overrides = {}) {
|
||||
userSeq += 1;
|
||||
const uid = db.prepare('INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)')
|
||||
.run(`u${userSeq}`, 'x', NOW).lastInsertRowid;
|
||||
const { id } = createCharacter(Number(uid), `Hero${userSeq}`, 'warrior');
|
||||
const cols = Object.keys(overrides);
|
||||
if (cols.length) {
|
||||
db.prepare(`UPDATE characters SET ${cols.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`)
|
||||
.run(...Object.values(overrides), id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Grant an item to a character's inventory (bypasses hunting). */
|
||||
function grantItem(characterId, itemId, quantity = 1) {
|
||||
db.prepare('INSERT INTO inventory (character_id, item_id, quantity, equipped_slot, acquired_at) VALUES (?, ?, ?, NULL, ?)')
|
||||
.run(characterId, itemId, quantity, NOW);
|
||||
}
|
||||
|
||||
it('returns empty inventory for a fresh character', () => {
|
||||
const id = makeHero();
|
||||
const result = listInventory(id);
|
||||
assert.equal(result.ok !== undefined || true, true); // service returns raw obj
|
||||
assert.equal(result.items.length, 0);
|
||||
assert.equal(result.character.name, `Hero${userSeq}`);
|
||||
});
|
||||
|
||||
it('equips a weapon and updates derived stats', () => {
|
||||
const id = makeHero();
|
||||
grantItem(id, 1); // Rusty Sword (weapon, +3 attack)
|
||||
const result = equipItem(id, 1, 'weapon');
|
||||
assert.equal(result.items.find((i) => i.itemId === 1)?.equipped, true);
|
||||
assert.equal(result.items.find((i) => i.itemId === 1)?.equipped_slot, 'weapon');
|
||||
|
||||
// Derived stats should reflect the +3 attack bonus
|
||||
assert.ok(result.derived.attack >= 7, `attack should include bonus (got ${result.derived.attack})`);
|
||||
});
|
||||
|
||||
it('equips armor and trinket independently', () => {
|
||||
const id = makeHero();
|
||||
grantItem(id, 8); // Leather Jerkin (armor, +3 def)
|
||||
grantItem(id, 11); // Wooden Charm (trinket, +1 vit)
|
||||
equipItem(id, 8, 'armor');
|
||||
equipItem(id, 11, 'trinket');
|
||||
|
||||
const result = listInventory(id);
|
||||
const armorItem = result.items.find((i) => i.itemId === 8);
|
||||
const trinketItem = result.items.find((i) => i.itemId === 11);
|
||||
assert.equal(armorItem?.equipped, true);
|
||||
assert.equal(armorItem?.equipped_slot, 'armor');
|
||||
assert.equal(trinketItem?.equipped, true);
|
||||
assert.equal(trinketItem?.equipped_slot, 'trinket');
|
||||
});
|
||||
|
||||
it('auto-unequips the old item when equipping to an occupied slot', () => {
|
||||
const id = makeHero({ level: 5, str: 8, agi: 4, int: 2, vit: 8 });
|
||||
grantItem(id, 1); // Rusty Sword
|
||||
grantItem(id, 4); // Iron Blade (requires level 5)
|
||||
equipItem(id, 1, 'weapon');
|
||||
equipItem(id, 4, 'weapon');
|
||||
|
||||
const result = listInventory(id);
|
||||
// After equipping item 4, item 1 should be unequipped (only one per slot).
|
||||
// The equipped item 4 now occupies the weapon slot; item 1 is in inventory but not equipped.
|
||||
const equippedItem = result.items.find((i) => i.equipped);
|
||||
assert.ok(equippedItem, 'should have one equipped item');
|
||||
assert.equal(equippedItem.itemId, 4);
|
||||
// Item 1 should still be in inventory but unequipped
|
||||
const item1 = result.items.find((i) => i.itemId === 1);
|
||||
assert.ok(item1, 'item 1 should still be in inventory');
|
||||
assert.equal(item1.equipped, false);
|
||||
});
|
||||
|
||||
it('unequips an item from a slot', () => {
|
||||
const id = makeHero();
|
||||
grantItem(id, 1);
|
||||
equipItem(id, 1, 'weapon');
|
||||
const result = unequipItem(id, 'weapon');
|
||||
const item1 = result.items.find((i) => i.itemId === 1);
|
||||
assert.ok(item1, 'item 1 should still be in inventory after unequip');
|
||||
assert.equal(item1.equipped, false);
|
||||
assert.equal(item1.equipped_slot, null);
|
||||
});
|
||||
|
||||
it('rejects equipping a non-existent item', () => {
|
||||
const id = makeHero();
|
||||
assert.throws(() => equipItem(id, 99999, 'weapon'), (e) => e.code === 'NOT_FOUND');
|
||||
});
|
||||
|
||||
it('rejects equipping wrong item type to slot', () => {
|
||||
const id = makeHero();
|
||||
// Item 14 = Minor Healing Potion (consumable)
|
||||
assert.throws(() => equipItem(id, 14, 'weapon'), (e) => e.code === 'VALIDATION_ERROR');
|
||||
});
|
||||
|
||||
it('rejects equipping an item the character is too low level for', () => {
|
||||
const id = makeHero();
|
||||
// Item 7 = Emberforged Greatsword (rare, min_level 10)
|
||||
assert.throws(() => equipItem(id, 7, 'weapon'), (e) => e.code === 'LEVEL_TOO_LOW');
|
||||
});
|
||||
|
||||
it('rejects unequip when slot is empty', () => {
|
||||
const id = makeHero();
|
||||
assert.throws(() => unequipItem(id, 'weapon'), (e) => e.code === 'NOT_FOUND');
|
||||
});
|
||||
|
||||
it('rejects invalid slot names', () => {
|
||||
const id = makeHero();
|
||||
assert.throws(() => equipItem(id, 1, 'head'), (e) => e.code === 'VALIDATION_ERROR');
|
||||
assert.throws(() => unequipItem(id, 'boots'), (e) => e.code === 'VALIDATION_ERROR');
|
||||
});
|
||||
|
||||
it('rejects equipping an item not in the character inventory', () => {
|
||||
const id = makeHero();
|
||||
// Item 1 exists but was never acquired by this character
|
||||
assert.throws(() => equipItem(id, 1, 'weapon'), (e) => e.code === 'NOT_FOUND');
|
||||
});
|
||||
|
||||
it('updates derived stats when equipping armor (defense bonus)', () => {
|
||||
const id = makeHero();
|
||||
grantItem(id, 8); // Leather Jerkin (armor, +3 defense)
|
||||
const baseline = listInventory(id);
|
||||
const baseDefense = baseline.derived.defense;
|
||||
|
||||
equipItem(id, 8, 'armor');
|
||||
const result = listInventory(id);
|
||||
|
||||
// Warrior gets +10% defense, so: (floor(vit*1.5) + 3) * 1.1
|
||||
assert.ok(result.derived.defense > baseDefense,
|
||||
`defense should increase with armor (was ${baseDefense}, now ${result.derived.defense})`);
|
||||
});
|
||||
|
||||
it('updates derived stats when equipping trinket (vit bonus)', () => {
|
||||
const id = makeHero();
|
||||
grantItem(id, 11); // Wooden Charm (trinket, +1 vit)
|
||||
const baseline = listInventory(id);
|
||||
const baseMaxHp = baseline.derived.maxHp;
|
||||
|
||||
equipItem(id, 11, 'trinket');
|
||||
const result = listInventory(id);
|
||||
|
||||
// +1 vit = +10 max HP
|
||||
assert.ok(result.derived.maxHp > baseMaxHp,
|
||||
`maxHp should increase with vit bonus (was ${baseMaxHp}, now ${result.derived.maxHp})`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user