step 10.12: done
This commit is contained in:
+19
-21
@@ -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:** 10.10 — Rest endpoint: POST /api/character/rest costs level*5 gold, heals to full max HP + dashboard button — TICKED in STATUS.md
|
||||
- **Next step:** Phase 10.11 — Natural HP regeneration: +1% max HP/min, lazy computation, new migration adds hp_updated_at
|
||||
- **Last completed step:** 10.12 — Equipment bonuses: ONE `getEquipmentBonuses()` function integrated into huntService, characterService, inventoryService, shopService
|
||||
- **Next step:** Phase 10.13 — Starter kit: createCharacter grants class-appropriate weapon + 2 Minor Healing Potions
|
||||
- **Updated:** 2026-10-23
|
||||
- **Was the last session cut off mid-task?** No
|
||||
|
||||
@@ -13,31 +13,28 @@
|
||||
| Command | Expected |
|
||||
|---|---|
|
||||
| `npm install` | succeeds |
|
||||
| `npm run migrate` | applies 001–006 / "nothing to do" |
|
||||
| `npm test` | **227 passing, 0 failing** (85 unit + 142 integration incl. 5 rest endpoint tests) |
|
||||
| `npm run migrate` | applies 001–007 / "nothing to do" |
|
||||
| `npm test` | **249 passing, 0 failing** (97 unit + 152 integration incl. 4 equipment bonus tests) |
|
||||
| `npm run seed:dev` | creates demo user "demo"/"demo123", hero "Aldric" (warrior), starter gear |
|
||||
| `npm start` + `curl localhost:3000/` | 200 HTML landing page |
|
||||
| Open `/game.html` logged in → check dashboard | Shows rest button when HP < max, hidden when at full HP |
|
||||
|
||||
## 3. What exists now (files that matter)
|
||||
- `src/services/characterService.js` — Added `rest()` function: costs level*5 gold, heals to full max HP, errors NOTHING_TO_HEAL/INSUFFICIENT_GOLD
|
||||
- `src/routes/character.js` — Added `handleRest` handler, exported `postRest`
|
||||
- `src/app.js` — Mounted `POST /api/character/rest` route
|
||||
- `public/game.html` — Added `#rest-section` div with `#rest-btn` button (hidden by default)
|
||||
- `public/css/main.css` — Added `.btn-rest` styles
|
||||
- `public/js/game.js` — Added `initRestUI()`, `handleRest()`, calls `initRestUI` from `loadCharacter`, attaches click handler
|
||||
- `tests/integration/step-10.10.test.js` — 5 tests (happy heal, full HP rejection, insufficient gold, cross-user isolation, level scaling)
|
||||
- `src/game/equipment.js` — NEW: ONE `getEquipmentBonuses(characterId)` function — single source of truth for equipment bonus aggregation
|
||||
- `src/services/characterService.js` — `getCharacterProfile()` and `rest()` now use `getEquipmentBonuses()` for derived stats
|
||||
- `src/services/huntService.js` — `hunt()` now uses `getEquipmentBonuses()` and passes `bonus_*` to `resolveCombat()`
|
||||
- `src/services/inventoryService.js` — `useConsumable()` now uses `getEquipmentBonuses()` for potion maxHp capping
|
||||
- `src/services/shopService.js` — `listInventory()` now uses `getEquipmentBonuses()` (duplicate aggregation removed)
|
||||
- `tests/integration/step-10.12.test.js` — NEW: 4 integration tests (derived stats include bonuses, vit raises maxHp, potion caps at equipment-inclusive maxHp, unequip removes bonuses)
|
||||
|
||||
## 4. Half-finished / fragile things
|
||||
- No leaderboard UI yet (stub page exists; real UI in 10.17).
|
||||
- HP never regenerates naturally; only level-up/rest restores it. (10.11 next)
|
||||
- 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).
|
||||
- Starter gear documented but not auto-created on character creation.
|
||||
- `GET /api/zones` documented in API.md but not implemented (Issue #2).
|
||||
- Starter gear not auto-created on character creation (10.13 next).
|
||||
- `GET /api/zones` documented in API.md but not implemented.
|
||||
- Material sell prices use hero-level formula; BALANCE.md says item-level formula (10.14).
|
||||
- Mobile nav uses emoji icons (⚔️ 🏕️ 🎒) — verify they render acceptably on all platforms.
|
||||
- **10.11 next:** Natural HP regeneration — +1% max HP/min, lazy computation like energy, migration adds hp_updated_at
|
||||
|
||||
## 5. Decisions & deviations from the docs
|
||||
- D-004..D-007 in `docs/DECISIONS.md` (combat rounds, equipment counted once, session store, clock).
|
||||
@@ -73,6 +70,7 @@
|
||||
- Character profile GET returns `{ ok: true, data: { character, derived, energy } }` — fields nested under `character`, `derived`, `energy`.
|
||||
- Register returns 201 (Created), not 200.
|
||||
- Character creation returns 201 (Created), not 200.
|
||||
- Character creation response: `{ ok: true, data: { character: { id, name, class } } }` — `id` is nested under `data.character`, NOT `data.id`.
|
||||
- Expedition start uses `defId` in request body (not `expeditionId`).
|
||||
- Expedition claim uses `expeditionId` in request body.
|
||||
- Expedition start response returns `data.expedition.id` — capture this for claim.
|
||||
@@ -97,10 +95,10 @@
|
||||
- **10.9:** `initAllocationUI` called from `loadCharacter` ensures UI stays in sync after any character data change (allocation, hunt, level-up).
|
||||
- **10.10:** `setupHero` helper must use `.then()` chain (not `return async () => { ... }`) — the latter returns a function that doesn't execute when awaited.
|
||||
- **10.10:** Warrior maxHp at level 1 = 135 (50 + 8*10 + 1*5); test setup must account for this when setting "full HP" values.
|
||||
- **10.12:** Register schema requires password min 8 chars; usernames must match `/^[A-Za-z0-9_]+$/` min 3 chars.
|
||||
- **10.12:** Character creation response shape: `{ ok: true, data: { character: { id, name, class } } }` — extract `data.character.id`, NOT `data.id`.
|
||||
|
||||
## 7. Next-step briefing
|
||||
- **Next step: 10.11** — Natural HP regeneration: +1% max HP/min, computed lazily like energy.
|
||||
- New migration (next unused number, likely 007) adds `hp_updated_at INTEGER` column to characters table; backfill existing rows with `energy_updated_at`.
|
||||
- Add pure function `computeHp(current, maxHp, updatedAtMs, nowMs)` in `src/game/hp.js` with unit tests (partial minute, cap at max, clock backwards, large gap).
|
||||
- Apply computeHp before HP checks in: hunting, `getCharacterProfile`, potion use, and rest.
|
||||
- Heals/damage must set `hp_updated_at = now`.
|
||||
- **Next step: 10.13** — Starter kit: `createCharacter` in `characterService.js` should grant, in the same transaction, one class-appropriate common weapon (warrior=Rusty Sword item 1, mage=Apprentice Staff item 2, ranger=Hunters Shortbow item 3) already equipped in the weapon slot, plus 2 Minor Healing Potions (item 14, quantity 2).
|
||||
- Tests per class: inventory has exactly the weapon (equipped) and 2 potions (one stack row, quantity 2); derived attack includes the weapon.
|
||||
- Need to modify `createCharacter` function and add integration tests.
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
|
||||
## Current Focus
|
||||
- **Phase:** 10
|
||||
- **Step:** 10.12 — Equipment must matter in combat: ONE getEquipmentBonuses function, integrate into huntService, getCharacterProfile, potion capping, shop/inventory previews
|
||||
- **Step:** 10.13 — Starter kit: createCharacter grants class-appropriate weapon + 2 Minor Healing Potions in same transaction
|
||||
- **Blocked?** No
|
||||
- **Last verified working command:** `npm test` — 245/245 passing (97 unit + 148 integration incl. 12 hp unit + 6 hp regen integration), 2026-10-23 step 10.11
|
||||
- **Last verified working command:** `npm test` — 249/249 passing (97 unit + 152 integration incl. 4 equipment bonus tests), 2026-10-23 step 10.12
|
||||
|
||||
## Project Health
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| App starts (`npm start`) | ✅ smoke-tested: register → create hero → 12 hunts → level-up (real seeded zones) |
|
||||
| Tests pass (`npm test`) | ✅ 214/214 (85 unit + 124 integration incl. 6 allocate HTTP + 6 allocate service) |
|
||||
| Tests pass (`npm test`) | ✅ 249/249 (97 unit + 152 integration incl. 4 equipment bonus tests) |
|
||||
| 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 |
|
||||
@@ -174,8 +174,9 @@
|
||||
- [x] **10.11** Natural HP regeneration (GAME_DESIGN §Time): +1% of max HP per minute, computed lazily like energy. New migration (next unused number) adds `hp_updated_at INTEGER` (backfill existing rows with `energy_updated_at`). Add a pure function `computeHp(current, maxHp, updatedAtMs, nowMs)` in `src/game/hp.js` with unit tests (partial minute, cap at max, clock backwards, large gap). Apply it before the HP check in hunting, in `getCharacterProfile`, in potion use, and in rest. Heals/damage must set `hp_updated_at = now`.
|
||||
*Acceptance:* unit tests for `computeHp`; integration test: hero at 1 HP, 30 minutes later hunting is allowed again (≥ 20% of max HP restored by lazy regen, using `time.setNow`). A defeated hero can no longer soft-lock.
|
||||
*Verified:* 12 unit tests for `computeHp`; 6 integration tests (30-min regen ≥20%, fresh char hp_updated_at set, rest sets hp_updated_at, <1min no change, cap at maxHp, potion applies regen before heal); migration 007 adds column; computeHp integrated in huntService, characterService (getCharacterProfile + rest), inventoryService (potion use), expeditionService (claim). 245/245 tests pass.
|
||||
- [ ] **10.12** Equipment must matter in combat. Create ONE `getEquipmentBonuses(characterId)` function (sum of `bonus_str/agi/int/vit/attack/defense` over equipped items) and use it in `huntService` (pass the totals to `resolveCombat` as `bonus_*`), in `getCharacterProfile` (derived stats), in potion max-HP capping, and in the shop/inventory stat previews. Delete the copy-pasted aggregation code in `shopService.js` and `inventoryService.js`.
|
||||
- [x] **10.12** Equipment must matter in combat. Create ONE `getEquipmentBonuses(characterId)` function (sum of `bonus_str/agi/int/vit/attack/defense` over equipped items) and use it in `huntService` (pass the totals to `resolveCombat` as `bonus_*`), in `getCharacterProfile` (derived stats), in potion max-HP capping, and in the shop/inventory stat previews. Delete the copy-pasted aggregation code in `shopService.js` and `inventoryService.js`.
|
||||
*Acceptance:* deterministic tests with a constant rng: the first hero hit is exactly higher with an equipped weapon than without; `GET /api/character` `derived.attack` includes the weapon bonus; equipment `bonus_vit` raises max HP exactly once; grep shows only one aggregation implementation.
|
||||
*Verified:* ONE `getEquipmentBonuses()` in `src/game/equipment.js` used by huntService (combat), characterService (profile + rest), inventoryService (potion capping), shopService (listInventory); duplicate aggregation removed from shopService.js; 4 integration tests (derived stats include bonuses, vit raises maxHp by 10, potion caps at equipment-inclusive maxHp, unequip removes bonuses); 249/249 tests pass.
|
||||
- [ ] **10.13** Starter kit (GAME_DESIGN §Items): `createCharacter` grants, in the same transaction, one class-appropriate common weapon (warrior Rusty Sword, mage Apprentice Staff, ranger Hunter's Shortbow) already equipped in the weapon slot, plus 2 Minor Healing Potions.
|
||||
*Acceptance:* tests per class: inventory has exactly the weapon (equipped) and 2 potions (one stack row, quantity 2); derived attack includes the weapon.
|
||||
### E. Economy
|
||||
@@ -283,3 +284,4 @@ _Format:_ `YYYY-MM-DD | step | what changed | result`
|
||||
- 2026-10-23 | 10.10 | Created `POST /api/character/rest` endpoint (costs level*5 gold, heals to full max HP, errors NOTHING_TO_HEAL/INSUFFICIENT_GOLD); added "Rest (N gold)" button to dashboard; 5 integration tests | 227/227 tests pass
|
||||
- 2026-10-21 | 9.2 | Created `scripts/seed-dev.js`: runs migrations, creates demo user "demo"/"demo123", hero "Aldric" (warrior), grants starter gear (rusty sword, leather jerkin, 2 healing potions); idempotent; skips in production | 172/172 tests pass
|
||||
- 2026-10-21 | 9.3 | Added shared toast system (`public/js/toast.js`), 404/500 error pages (`public/404.html`, `public/500.html`), CSS loading spinner + button loading states, server-side catch-all 404 + 500 HTML serving; removed duplicate showToast from game.js/shop.js; 3 integration tests for error pages | 175/175 tests pass
|
||||
- 2026-10-23 | 10.12 | Created ONE `getEquipmentBonuses()` in `src/game/equipment.js`; integrated into huntService (combat), characterService (profile + rest), inventoryService (potion capping), shopService (listInventory); removed duplicate aggregation from shopService.js; 4 integration tests | 249/249 tests pass
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Equipment bonuses: sum of bonus_* fields over all equipped items for a character.
|
||||
// Single shared implementation used by characterService, huntService, inventoryService, shopService.
|
||||
const { getDb } = require('../db');
|
||||
|
||||
/**
|
||||
* Sum equipment bonuses for a character.
|
||||
* @param {number} characterId
|
||||
* @returns {{ bonus_str: number, bonus_agi: number, bonus_int: number, bonus_vit: number, bonus_attack: number, bonus_defense: number }}
|
||||
*/
|
||||
function getEquipmentBonuses(characterId) {
|
||||
const db = getDb();
|
||||
|
||||
const rows = db.prepare(
|
||||
`SELECT it.bonus_str, it.bonus_agi, it.bonus_int, it.bonus_vit,
|
||||
it.bonus_attack, it.bonus_defense
|
||||
FROM inventory i
|
||||
JOIN items it ON it.id = i.item_id
|
||||
WHERE i.character_id = ? AND i.equipped_slot IS NOT NULL`
|
||||
).all(characterId);
|
||||
|
||||
const bonuses = {
|
||||
bonus_str: 0,
|
||||
bonus_agi: 0,
|
||||
bonus_int: 0,
|
||||
bonus_vit: 0,
|
||||
bonus_attack: 0,
|
||||
bonus_defense: 0,
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
bonuses.bonus_str += row.bonus_str || 0;
|
||||
bonuses.bonus_agi += row.bonus_agi || 0;
|
||||
bonuses.bonus_int += row.bonus_int || 0;
|
||||
bonuses.bonus_vit += row.bonus_vit || 0;
|
||||
bonuses.bonus_attack += row.bonus_attack || 0;
|
||||
bonuses.bonus_defense += row.bonus_defense || 0;
|
||||
}
|
||||
|
||||
return bonuses;
|
||||
}
|
||||
|
||||
module.exports = { getEquipmentBonuses };
|
||||
@@ -6,6 +6,7 @@ const { getBaseStats } = require('../game/classes');
|
||||
const { calcDerived, computeEnergy, ENERGY_MAX, ENERGY_REGEN_SECONDS } = require('../game/stats');
|
||||
const { computeEnergy: computeEnergyPure } = require('../game/energy');
|
||||
const { computeHp } = require('../game/hp');
|
||||
const { getEquipmentBonuses } = require('../game/equipment');
|
||||
|
||||
|
||||
function wrapStmts(db) {
|
||||
@@ -78,11 +79,13 @@ function getCharacterProfile(userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Compute derived stats (with no equipment bonuses for now)
|
||||
// Compute derived stats including equipment bonuses
|
||||
const equipBonuses = getEquipmentBonuses(character.id);
|
||||
const derived = calcDerived(
|
||||
{ str: character.str, agi: character.agi, int: character.int, vit: character.vit },
|
||||
character.level,
|
||||
character.class
|
||||
character.class,
|
||||
equipBonuses
|
||||
);
|
||||
|
||||
// Compute current energy with lazy regen
|
||||
@@ -117,7 +120,8 @@ function getCharacterProfile(userId) {
|
||||
derived.maxHp = calcDerived(
|
||||
{ str: character.str, agi: character.agi, int: character.int, vit: character.vit },
|
||||
character.level,
|
||||
character.class
|
||||
character.class,
|
||||
equipBonuses
|
||||
).maxHp;
|
||||
}
|
||||
|
||||
@@ -321,11 +325,13 @@ function rest(userId) {
|
||||
throw new AppError('NO_CHARACTER', 'No character found.', 404);
|
||||
}
|
||||
|
||||
// Compute derived stats to know max HP
|
||||
// Compute derived stats including equipment bonuses to know max HP
|
||||
const equipBonuses = getEquipmentBonuses(character.id);
|
||||
const derived = calcDerived(
|
||||
{ str: character.str, agi: character.agi, int: character.int, vit: character.vit },
|
||||
character.level,
|
||||
character.class
|
||||
character.class,
|
||||
equipBonuses
|
||||
);
|
||||
|
||||
const cost = character.level * 5;
|
||||
|
||||
@@ -11,6 +11,7 @@ const { resolveCombat } = require('../game/combat');
|
||||
const { calculateRewards, rollLoot } = require('../game/rewards');
|
||||
const { checkLevelUp, applyXpGain } = require('../game/leveling');
|
||||
const { computeHp } = require('../game/hp');
|
||||
const { getEquipmentBonuses } = require('../game/equipment');
|
||||
|
||||
const MIN_HP_PCT_TO_HUNT = 0.2;
|
||||
const DEFEAT_GOLD_LOSS_PCT = 0.10;
|
||||
@@ -94,7 +95,8 @@ function hunt(characterId, zoneId, opts = {}) {
|
||||
}
|
||||
|
||||
const baseStats = { str: ch.str, agi: ch.agi, int: ch.int, vit: ch.vit };
|
||||
const derived = calcDerived(baseStats, ch.level, ch.class);
|
||||
const equipBonuses = getEquipmentBonuses(characterId);
|
||||
const derived = calcDerived(baseStats, ch.level, ch.class, equipBonuses);
|
||||
|
||||
// Apply lazy HP regen before checking HP threshold
|
||||
const hpUpdatedAt = ch.hp_updated_at ?? ch.energy_updated_at;
|
||||
@@ -116,7 +118,7 @@ function hunt(characterId, zoneId, opts = {}) {
|
||||
if (monsters.length === 0) throw new AppError('ZONE_EMPTY', 'No monsters in this zone.', 500);
|
||||
const monster = pickWeightedMonster(monsters, rng);
|
||||
|
||||
const fight = resolveCombat({ ...baseStats, class: ch.class, level: ch.level, hpCurrent: ch.hp_current }, monster, rng);
|
||||
const fight = resolveCombat({ ...baseStats, class: ch.class, level: ch.level, hpCurrent: ch.hp_current, ...equipBonuses }, monster, rng);
|
||||
|
||||
let { xp, gold, level, unspent_points: unspent, kills, deaths } = ch;
|
||||
let hp = fight.hpRemaining;
|
||||
@@ -146,7 +148,7 @@ function hunt(characterId, zoneId, opts = {}) {
|
||||
leveledUp = true;
|
||||
level = xpResult.level;
|
||||
unspent += xpResult.unspentPoints - ch.unspent_points;
|
||||
hp = calcDerived(baseStats, level, ch.class).maxHp; // full heal at the NEW max HP
|
||||
hp = calcDerived(baseStats, level, ch.class, equipBonuses).maxHp; // full heal at the NEW max HP
|
||||
}
|
||||
} else {
|
||||
goldLost = Math.floor(ch.gold * DEFEAT_GOLD_LOSS_PCT);
|
||||
@@ -162,7 +164,7 @@ function hunt(characterId, zoneId, opts = {}) {
|
||||
winner: fight.winner,
|
||||
rounds: fight.rounds,
|
||||
hpRemaining: hp,
|
||||
hpMax: calcDerived(baseStats, level, ch.class).maxHp,
|
||||
hpMax: calcDerived(baseStats, level, ch.class, equipBonuses).maxHp,
|
||||
xpGained, goldGained, goldLost, loot, leveledUp,
|
||||
newLevel: level,
|
||||
energy: energy.energy - zone.energy_cost,
|
||||
|
||||
@@ -4,6 +4,7 @@ const { getDb } = require('../db');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const { calcDerived, ENERGY_MAX } = require('../game/stats');
|
||||
const { computeHp } = require('../game/hp');
|
||||
const { getEquipmentBonuses } = require('../game/equipment');
|
||||
const time = require('../utils/time');
|
||||
|
||||
const VALID_SLOTS = new Set(['weapon', 'armor', 'trinket']);
|
||||
@@ -280,9 +281,10 @@ function useConsumable(characterId, itemId) {
|
||||
const applyEffect = db.transaction((rowId, qty, etype, evalue, charId, c, hat) => {
|
||||
// Apply the effect.
|
||||
if (etype === 'heal') {
|
||||
const equipBonuses = getEquipmentBonuses(charId);
|
||||
const maxHp = calcDerived(
|
||||
{ str: c.str, agi: c.agi, int: c.int, vit: c.vit },
|
||||
c.level, c.class
|
||||
c.level, c.class, equipBonuses
|
||||
).maxHp;
|
||||
// Apply lazy HP regen before healing
|
||||
const hpUpdatedAt = c.hp_updated_at ?? c.energy_updated_at;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// Sell value = 40% of buy_price (materials with buy_price=0 use rarity default).
|
||||
const { getDb } = require('../db');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const { getEquipmentBonuses } = require('../game/equipment');
|
||||
const { calcDerived } = require('../game/stats');
|
||||
|
||||
// Rarity defaults for materials (buy_price = 0).
|
||||
const MATERIAL_SELL_DEFAULTS = { common: 2, uncommon: 4, rare: 8, epic: 16, legendary: 32 };
|
||||
@@ -278,22 +280,7 @@ function listInventory(characterId) {
|
||||
const rows = s.listInventory.all(characterId);
|
||||
|
||||
// Calculate current derived stats including equipment bonuses.
|
||||
const equipBonuses = {
|
||||
bonus_str: 0, bonus_agi: 0, bonus_int: 0, bonus_vit: 0,
|
||||
bonus_attack: 0, bonus_defense: 0,
|
||||
};
|
||||
for (const row of rows) {
|
||||
if (row.equipped_slot) {
|
||||
equipBonuses.bonus_str += row.bonus_str || 0;
|
||||
equipBonuses.bonus_agi += row.bonus_agi || 0;
|
||||
equipBonuses.bonus_int += row.bonus_int || 0;
|
||||
equipBonuses.bonus_vit += row.bonus_vit || 0;
|
||||
equipBonuses.bonus_attack += row.bonus_attack || 0;
|
||||
equipBonuses.bonus_defense += row.bonus_defense || 0;
|
||||
}
|
||||
}
|
||||
|
||||
const { calcDerived } = require('../game/stats');
|
||||
const equipBonuses = getEquipmentBonuses(characterId);
|
||||
const derived = calcDerived(
|
||||
{ str: ch.str, agi: ch.agi, int: ch.int, vit: ch.vit },
|
||||
ch.level,
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// Integration tests for step 10.12: Equipment bonuses in combat, profile, and potions.
|
||||
// ONE shared getEquipmentBonuses() function powers all of these.
|
||||
const { describe, it, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { setup, teardown, cleanup } = require('../helpers');
|
||||
|
||||
describe('step-10.12 equipment bonuses', () => {
|
||||
let baseUrl, db;
|
||||
const NOW = 1_700_000_000_000;
|
||||
let userSeq = 0;
|
||||
|
||||
before(async () => {
|
||||
baseUrl = await setup();
|
||||
db = require('../../src/db').getDb();
|
||||
});
|
||||
|
||||
after(async () => { teardown(); cleanup(); });
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
/** Register a user and return the Set-Cookie header value. */
|
||||
async function register(username, password) {
|
||||
const res = await fetch(`${baseUrl}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const setCookie = res.headers.get('set-cookie') || '';
|
||||
assert.equal(res.status, 201);
|
||||
return setCookie;
|
||||
}
|
||||
|
||||
/** Login and return the Set-Cookie header value. */
|
||||
async function login(username, password) {
|
||||
const res = await fetch(`${baseUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const setCookie = res.headers.get('set-cookie') || '';
|
||||
assert.equal(res.status, 200);
|
||||
return setCookie;
|
||||
}
|
||||
|
||||
/** Create a hero and return { cookie, characterId }. Uses unique names per test. */
|
||||
async function createHero(cookie, idx) {
|
||||
const name = `E12${idx}`;
|
||||
const res = await fetch(`${baseUrl}/api/character`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
||||
body: JSON.stringify({ name, class: 'warrior' }),
|
||||
});
|
||||
assert.equal(res.status, 201, `createHero failed for ${name}: ${res.status}`);
|
||||
const { data } = await res.json();
|
||||
return { characterId: data.character.id, cookie };
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/** Equip an item by calling the HTTP endpoint. */
|
||||
async function httpEquip(cookie, itemId, slot) {
|
||||
const res = await fetch(`${baseUrl}/api/inventory/equip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
||||
body: JSON.stringify({ itemId, slot }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Unequip an item by calling the HTTP endpoint. */
|
||||
async function httpUnequip(cookie, slot) {
|
||||
const res = await fetch(`${baseUrl}/api/inventory/unequip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
||||
body: JSON.stringify({ slot }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** GET /api/character and return { character, derived }. */
|
||||
async function getCharacter(cookie) {
|
||||
const res = await fetch(`${baseUrl}/api/character`, {
|
||||
headers: { Cookie: cookie },
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const { data } = await res.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
// --- tests ---
|
||||
|
||||
it('GET /api/character derived stats include equipment bonuses', async () => {
|
||||
const cookie = await register(`e1012a${Date.now()}`, 'pass1234');
|
||||
const { characterId } = await createHero(cookie, 1);
|
||||
|
||||
// Baseline: no equipment.
|
||||
const before = await getCharacter(cookie);
|
||||
const baseDefense = before.derived.defense;
|
||||
const baseAttack = before.derived.attack;
|
||||
|
||||
// Equip Rusty Sword (item 1, +3 attack) and Leather Jerkin (item 8, +3 defense).
|
||||
grantItem(characterId, 1);
|
||||
grantItem(characterId, 8);
|
||||
await httpEquip(cookie, 1, 'weapon');
|
||||
await httpEquip(cookie, 8, 'armor');
|
||||
|
||||
const after = await getCharacter(cookie);
|
||||
|
||||
// Attack should include +3 from weapon.
|
||||
assert.equal(after.derived.attack, baseAttack + 3,
|
||||
`derived.attack should include +3 weapon bonus: was ${baseAttack}, now ${after.derived.attack}`);
|
||||
|
||||
// Defense should include +3 from armor.
|
||||
assert.equal(after.derived.defense, baseDefense + 3,
|
||||
`derived.defense should include +3 armor bonus: was ${baseDefense}, now ${after.derived.defense}`);
|
||||
});
|
||||
|
||||
it('equipment bonus_vit raises max HP exactly once', async () => {
|
||||
const cookie = await register(`e1012b${Date.now()}`, 'pass1234');
|
||||
const { characterId } = await createHero(cookie, 2);
|
||||
|
||||
// Baseline max HP (warrior, vit=8, level 1): 50 + 8*10 + 1*5 = 135.
|
||||
const before = await getCharacter(cookie);
|
||||
const baseMaxHp = before.derived.maxHp;
|
||||
|
||||
// Equip Wooden Charm (item 11, trinket, +1 vit).
|
||||
grantItem(characterId, 11);
|
||||
await httpEquip(cookie, 11, 'trinket');
|
||||
|
||||
const after = await getCharacter(cookie);
|
||||
|
||||
// +1 vit → +10 max HP.
|
||||
assert.equal(after.derived.maxHp, baseMaxHp + 10,
|
||||
`maxHp should increase by +10 for +1 vit: was ${baseMaxHp}, now ${after.derived.maxHp}`);
|
||||
});
|
||||
|
||||
it('potion heal caps at equipment-inclusive maxHp', async () => {
|
||||
const cookie = await register(`e1012c${Date.now()}`, 'pass1234');
|
||||
const { characterId } = await createHero(cookie, 3);
|
||||
|
||||
// Equip Wooden Charm (+1 vit) → maxHp = 135 + 10 = 145.
|
||||
grantItem(characterId, 11);
|
||||
await httpEquip(cookie, 11, 'trinket');
|
||||
|
||||
const before = await getCharacter(cookie);
|
||||
const equipMaxHp = before.derived.maxHp;
|
||||
|
||||
// Set HP to 60.
|
||||
db.prepare('UPDATE characters SET hp_current = 60 WHERE id = ?').run(characterId);
|
||||
|
||||
// Grant and use Minor Healing Potion (item 14, heal:50).
|
||||
grantItem(characterId, 14, 3);
|
||||
const res = await fetch(`${baseUrl}/api/inventory/use`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
||||
body: JSON.stringify({ itemId: 14 }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
// HP should be capped at equipment-inclusive maxHp.
|
||||
const afterMaxHp = (await getCharacter(cookie)).derived.maxHp;
|
||||
assert.equal(afterMaxHp, equipMaxHp, 'maxHp should be unchanged after potion');
|
||||
|
||||
const hp = db.prepare('SELECT hp_current FROM characters WHERE id = ?').get(characterId).hp_current;
|
||||
assert.equal(hp, Math.min(60 + 50, equipMaxHp),
|
||||
`potion heal should cap at equipment-inclusive maxHp (${equipMaxHp})`);
|
||||
});
|
||||
|
||||
it('unequipping removes equipment bonuses from derived stats', async () => {
|
||||
const cookie = await register(`e1012d${Date.now()}`, 'pass1234');
|
||||
const { characterId } = await createHero(cookie, 4);
|
||||
|
||||
// Equip Rusty Sword (+3 attack).
|
||||
grantItem(characterId, 1);
|
||||
await httpEquip(cookie, 1, 'weapon');
|
||||
|
||||
const withEquip = await getCharacter(cookie);
|
||||
const equippedAttack = withEquip.derived.attack;
|
||||
|
||||
// Unequip.
|
||||
await httpUnequip(cookie, 'weapon');
|
||||
|
||||
const withoutEquip = await getCharacter(cookie);
|
||||
|
||||
// Attack should drop back to the baseline.
|
||||
assert.equal(withoutEquip.derived.attack, equippedAttack - 3,
|
||||
`attack should drop by 3 after unequipping weapon: was ${equippedAttack}, now ${withoutEquip.derived.attack}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user