step 10.13: done
This commit is contained in:
@@ -8,9 +8,9 @@
|
|||||||
|
|
||||||
## Current Focus
|
## Current Focus
|
||||||
- **Phase:** 10
|
- **Phase:** 10
|
||||||
- **Step:** 10.13 — Starter kit: createCharacter grants class-appropriate weapon + 2 Minor Healing Potions in same transaction
|
- **Step:** 10.14 — Material sell prices use item-level formula (BALANCE.md) instead of hero-level formula
|
||||||
- **Blocked?** No
|
- **Blocked?** No
|
||||||
- **Last verified working command:** `npm test` — 249/249 passing (97 unit + 152 integration incl. 4 equipment bonus tests), 2026-10-23 step 10.12
|
- **Last verified working command:** `npm test` — 253/253 passing (97 unit + 156 integration incl. 4 equipment bonus tests + 4 starter kit tests), 2026-10-23 step 10.13
|
||||||
|
|
||||||
## Project Health
|
## Project Health
|
||||||
| Item | State |
|
| Item | State |
|
||||||
@@ -177,8 +177,9 @@
|
|||||||
- [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`.
|
- [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.
|
*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.
|
*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.
|
- [x] **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.
|
*Acceptance:* tests per class: inventory has exactly the weapon (equipped) and 2 potions (one stack row, quantity 2); derived attack includes the weapon.
|
||||||
|
*Verified:* 4 new integration tests (warrior/mage/ranger starter kit + duplicate-name rejection); `STARTER_WEAPONS` map in `characterService.js`; `grantKit` transaction inserts equipped weapon + 2 potions; `grantItem` helpers in inventory/shop tests updated to stack with existing rows; 10+ existing tests updated to account for starter kit baseline; 253/253 tests pass.
|
||||||
### E. Economy
|
### E. Economy
|
||||||
- [ ] **10.14** Fix material sell prices. The code uses `defaultRate * heroLevel / itemMinLevel`; BALANCE.md says price depends on the ITEM's level: common `2 × min_level`, uncommon `4 × min_level`, rare `8 × min_level` (epic `16×`, legendary `32×`; log this extension in DECISIONS.md), minimum 1. Note BALANCE.md's table says Drake Scale sells for 90g but its formula gives 112g: follow the formula and correct the table. Use ONE function (`materialSellPrice` in `src/game/rewards.js`) for the shop list AND for selling.
|
- [ ] **10.14** Fix material sell prices. The code uses `defaultRate * heroLevel / itemMinLevel`; BALANCE.md says price depends on the ITEM's level: common `2 × min_level`, uncommon `4 × min_level`, rare `8 × min_level` (epic `16×`, legendary `32×`; log this extension in DECISIONS.md), minimum 1. Note BALANCE.md's table says Drake Scale sells for 90g but its formula gives 112g: follow the formula and correct the table. Use ONE function (`materialSellPrice` in `src/game/rewards.js`) for the shop list AND for selling.
|
||||||
*Acceptance:* tests: Rat Tail sells for 2g at hero level 1, 10 and 50; Drake Scale (rare, level 14) sells for 112g at any hero level; shop-listed price equals the amount actually paid.
|
*Acceptance:* tests: Rat Tail sells for 2g at hero level 1, 10 and 50; Drake Scale (rare, level 14) sells for 112g at any hero level; shop-listed price equals the amount actually paid.
|
||||||
@@ -285,3 +286,4 @@ _Format:_ `YYYY-MM-DD | step | what changed | result`
|
|||||||
- 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.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-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
|
- 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
|
||||||
|
- 2026-10-23 | 10.13 | Starter kit: createCharacter grants class-appropriate weapon (equipped) + 2 Minor Healing Potions in same transaction; 10+ existing tests updated to account for starter kit baseline; grantItem helpers fixed to stack with existing rows | 253/253 tests pass
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ const { computeHp } = require('../game/hp');
|
|||||||
const { getEquipmentBonuses } = require('../game/equipment');
|
const { getEquipmentBonuses } = require('../game/equipment');
|
||||||
|
|
||||||
|
|
||||||
|
// Class → starter weapon item_id mapping.
|
||||||
|
const STARTER_WEAPONS = {
|
||||||
|
warrior: 1, // Rusty Sword
|
||||||
|
mage: 2, // Apprentice Staff
|
||||||
|
ranger: 3, // Hunters Shortbow
|
||||||
|
};
|
||||||
|
|
||||||
function wrapStmts(db) {
|
function wrapStmts(db) {
|
||||||
return {
|
return {
|
||||||
findByUserId: db.prepare(
|
findByUserId: db.prepare(
|
||||||
@@ -65,7 +72,21 @@ function createCharacter(userId, name, classKey) {
|
|||||||
hpCurrent, ENERGY_MAX, now, now, now
|
hpCurrent, ENERGY_MAX, now, now, now
|
||||||
);
|
);
|
||||||
|
|
||||||
return { id: result.lastInsertRowid };
|
const characterId = result.lastInsertRowid;
|
||||||
|
|
||||||
|
// Grant starter kit: class weapon (equipped) + 2 Minor Healing Potions.
|
||||||
|
const grantKit = db.transaction((cid, weaponId, potionId, qty, acquired) => {
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO inventory (character_id, item_id, quantity, equipped_slot, acquired_at) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
).run(cid, weaponId, 1, 'weapon', acquired);
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO inventory (character_id, item_id, quantity, equipped_slot, acquired_at) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
).run(cid, potionId, qty, null, acquired);
|
||||||
|
});
|
||||||
|
|
||||||
|
grantKit(characterId, STARTER_WEAPONS[classKey], 14, 2, now);
|
||||||
|
|
||||||
|
return { id: characterId };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ describe('hero profile (public)', () => {
|
|||||||
assert.ok(typeof data.int === 'number');
|
assert.ok(typeof data.int === 'number');
|
||||||
assert.ok(typeof data.vit === 'number');
|
assert.ok(typeof data.vit === 'number');
|
||||||
assert.ok(typeof data.xp === 'number');
|
assert.ok(typeof data.xp === 'number');
|
||||||
assert.deepStrictEqual(data.equipment, { weapon: null, armor: null, trinket: null });
|
assert.deepStrictEqual(data.equipment, { weapon: 'Rusty Sword', armor: null, trinket: null });
|
||||||
|
|
||||||
// Response must NOT contain private fields.
|
// Response must NOT contain private fields.
|
||||||
assert.ok(!('energy' in data));
|
assert.ok(!('energy' in data));
|
||||||
@@ -98,26 +98,28 @@ describe('hero profile (public)', () => {
|
|||||||
const cookie = await registerAndLogin(`herouser2`, 'password123');
|
const cookie = await registerAndLogin(`herouser2`, 'password123');
|
||||||
const characterId = await createHero(cookie, 'ArmoredOne', 'mage');
|
const characterId = await createHero(cookie, 'ArmoredOne', 'mage');
|
||||||
|
|
||||||
// Insert starter gear directly into inventory (starter gear not auto-created yet).
|
// Mage already has Apprentice Staff (item 2) equipped from starter kit.
|
||||||
|
// Insert an armor item and equip it to test equipped item names.
|
||||||
const db = require('../../src/db').getDb();
|
const db = require('../../src/db').getDb();
|
||||||
|
|
||||||
// Get a weapon item from the items table.
|
// Get an armor item from the items table.
|
||||||
const weapon = db.prepare(
|
const armor = db.prepare(
|
||||||
"SELECT id FROM items WHERE type = 'weapon' AND min_level <= 1 ORDER BY id LIMIT 1"
|
"SELECT id FROM items WHERE type = 'armor' AND min_level <= 1 ORDER BY id LIMIT 1"
|
||||||
).get();
|
).get();
|
||||||
assert.ok(weapon, 'Should have a level-1 weapon in the items table');
|
assert.ok(armor, 'Should have a level-1 armor in the items table');
|
||||||
|
|
||||||
// Insert it into inventory, equipped as weapon.
|
// Insert it into inventory, equipped as armor.
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
'INSERT INTO inventory (character_id, item_id, quantity, equipped_slot, acquired_at) VALUES (?, ?, 1, ?, ?)'
|
'INSERT INTO inventory (character_id, item_id, quantity, equipped_slot, acquired_at) VALUES (?, ?, 1, ?, ?)'
|
||||||
).run(characterId, weapon.id, 'weapon', now);
|
).run(characterId, armor.id, 'armor', now);
|
||||||
|
|
||||||
const res = await fetch(`${baseUrl}/api/heroes/ArmoredOne`);
|
const res = await fetch(`${baseUrl}/api/heroes/ArmoredOne`);
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
assert.equal(json.ok, true);
|
assert.equal(json.ok, true);
|
||||||
assert.ok(json.data.equipment.weapon, 'Should have a weapon equipped');
|
assert.ok(json.data.equipment.weapon, 'Should have a weapon equipped');
|
||||||
|
assert.ok(json.data.equipment.armor, 'Should have armor equipped');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is case-insensitive', async () => {
|
it('is case-insensitive', async () => {
|
||||||
|
|||||||
@@ -123,11 +123,15 @@ describe('huntService.hunt', () => {
|
|||||||
const id = makeHero({ energy: 100, energy_updated_at: NOW });
|
const id = makeHero({ energy: 100, energy_updated_at: NOW });
|
||||||
run(id, 100);
|
run(id, 100);
|
||||||
run(id, 100);
|
run(id, 100);
|
||||||
|
// stackableId and weaponId are set up in before() with 100% drop rate on monster 100.
|
||||||
|
// Starter kit has 2 potions (item 14), hunts add 2 more → total 4.
|
||||||
const stack = db.prepare('SELECT quantity FROM inventory WHERE character_id = ? AND item_id = ?').all(id, stackableId);
|
const stack = db.prepare('SELECT quantity FROM inventory WHERE character_id = ? AND item_id = ?').all(id, stackableId);
|
||||||
assert.equal(stack.length, 1);
|
assert.equal(stack.length, 1);
|
||||||
assert.equal(stack[0].quantity, 2);
|
assert.equal(stack[0].quantity, 4);
|
||||||
const weapons = db.prepare('SELECT quantity FROM inventory WHERE character_id = ? AND item_id = ?').all(id, weaponId);
|
// weaponId is the first non-stackable weapon (item 1 = Rusty Sword).
|
||||||
assert.equal(weapons.length, 2);
|
// Starter kit has 1 equipped, 2 drops from hunts → total 3 rows.
|
||||||
assert.ok(weapons.every((w) => w.quantity === 1));
|
const allWeapons = db.prepare('SELECT quantity FROM inventory WHERE character_id = ? AND item_id = ?').all(id, weaponId);
|
||||||
|
assert.equal(allWeapons.length, 3);
|
||||||
|
assert.ok(allWeapons.every((w) => w.quantity === 1));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,29 +31,40 @@ describe('inventoryService', () => {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Grant an item to a character's inventory (bypasses hunting). */
|
/** Grant an item to a character's inventory (bypasses hunting). Stacks with existing rows. */
|
||||||
function grantItem(characterId, itemId, quantity = 1) {
|
function grantItem(characterId, itemId, quantity = 1) {
|
||||||
db.prepare('INSERT INTO inventory (character_id, item_id, quantity, equipped_slot, acquired_at) VALUES (?, ?, ?, NULL, ?)')
|
const existing = db.prepare(
|
||||||
.run(characterId, itemId, quantity, NOW);
|
'SELECT id, quantity FROM inventory WHERE character_id = ? AND item_id = ?'
|
||||||
|
).get(characterId, itemId);
|
||||||
|
if (existing) {
|
||||||
|
db.prepare('UPDATE inventory SET quantity = quantity + ? WHERE id = ?').run(quantity, existing.id);
|
||||||
|
} else {
|
||||||
|
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', () => {
|
it('returns empty inventory for a fresh character', () => {
|
||||||
const id = makeHero();
|
const id = makeHero();
|
||||||
const result = listInventory(id);
|
const result = listInventory(id);
|
||||||
assert.equal(result.ok !== undefined || true, true); // service returns raw obj
|
assert.equal(result.ok !== undefined || true, true); // service returns raw obj
|
||||||
assert.equal(result.items.length, 0);
|
// Starter kit: 2 items (equipped weapon + 2 potions)
|
||||||
|
assert.equal(result.items.length, 2);
|
||||||
assert.equal(result.character.name, `Hero${userSeq}`);
|
assert.equal(result.character.name, `Hero${userSeq}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('equips a weapon and updates derived stats', () => {
|
it('equips a weapon and updates derived stats', () => {
|
||||||
const id = makeHero();
|
const id = makeHero({ level: 5, str: 8, agi: 4, int: 2, vit: 8 });
|
||||||
grantItem(id, 1); // Rusty Sword (weapon, +3 attack)
|
// Warrior already has Rusty Sword (item 1) from starter kit.
|
||||||
const result = equipItem(id, 1, 'weapon');
|
// Grant Iron Blade (item 4, weapon, +8 attack, level 5) and equip it.
|
||||||
assert.equal(result.items.find((i) => i.itemId === 1)?.equipped, true);
|
grantItem(id, 4); // Iron Blade
|
||||||
assert.equal(result.items.find((i) => i.itemId === 1)?.equipped_slot, 'weapon');
|
const result = equipItem(id, 4, 'weapon');
|
||||||
|
assert.equal(result.items.find((i) => i.itemId === 4)?.equipped, true);
|
||||||
|
assert.equal(result.items.find((i) => i.itemId === 4)?.equipped_slot, 'weapon');
|
||||||
|
|
||||||
// Derived stats should reflect the +3 attack bonus
|
// Derived stats should reflect the +8 attack bonus
|
||||||
assert.ok(result.derived.attack >= 7, `attack should include bonus (got ${result.derived.attack})`);
|
assert.ok(result.derived.attack >= 33, `attack should include bonus (got ${result.derived.attack})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('equips armor and trinket independently', () => {
|
it('equips armor and trinket independently', () => {
|
||||||
@@ -74,30 +85,25 @@ describe('inventoryService', () => {
|
|||||||
|
|
||||||
it('auto-unequips the old item when equipping to an occupied slot', () => {
|
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 });
|
const id = makeHero({ level: 5, str: 8, agi: 4, int: 2, vit: 8 });
|
||||||
grantItem(id, 1); // Rusty Sword
|
// Warrior already has Rusty Sword (item 1) from starter kit.
|
||||||
grantItem(id, 4); // Iron Blade (requires level 5)
|
// Grant Iron Blade (item 4, level 5) to test swap.
|
||||||
equipItem(id, 1, 'weapon');
|
grantItem(id, 4); // Iron Blade
|
||||||
equipItem(id, 4, 'weapon');
|
equipItem(id, 4, 'weapon');
|
||||||
|
|
||||||
const result = listInventory(id);
|
const result = listInventory(id);
|
||||||
// After equipping item 4, item 1 should be unequipped (only one per slot).
|
// Iron Blade should be equipped (warrior's starter sword is unequipped).
|
||||||
// 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);
|
const equippedItem = result.items.find((i) => i.equipped);
|
||||||
assert.ok(equippedItem, 'should have one equipped item');
|
assert.ok(equippedItem, 'should have one equipped item');
|
||||||
assert.equal(equippedItem.itemId, 4);
|
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', () => {
|
it('unequips an item from a slot', () => {
|
||||||
const id = makeHero();
|
const id = makeHero();
|
||||||
grantItem(id, 1);
|
// Warrior already has Rusty Sword (item 1) equipped from starter kit.
|
||||||
equipItem(id, 1, 'weapon');
|
// Just unequip the weapon slot.
|
||||||
const result = unequipItem(id, 'weapon');
|
const result = unequipItem(id, 'weapon');
|
||||||
const item1 = result.items.find((i) => i.itemId === 1);
|
const item1 = result.items.find((i) => i.itemId === 1);
|
||||||
assert.ok(item1, 'item 1 should still be in inventory after unequip');
|
assert.ok(item1, 'Rusty Sword should still be in inventory after unequip');
|
||||||
assert.equal(item1.equipped, false);
|
assert.equal(item1.equipped, false);
|
||||||
assert.equal(item1.equipped_slot, null);
|
assert.equal(item1.equipped_slot, null);
|
||||||
});
|
});
|
||||||
@@ -121,6 +127,9 @@ describe('inventoryService', () => {
|
|||||||
|
|
||||||
it('rejects unequip when slot is empty', () => {
|
it('rejects unequip when slot is empty', () => {
|
||||||
const id = makeHero();
|
const id = makeHero();
|
||||||
|
// Warrior already has Rusty Sword (item 1) equipped from starter kit.
|
||||||
|
// Unequip it first, then try to unequip again.
|
||||||
|
unequipItem(id, 'weapon');
|
||||||
assert.throws(() => unequipItem(id, 'weapon'), (e) => e.code === 'NOT_FOUND');
|
assert.throws(() => unequipItem(id, 'weapon'), (e) => e.code === 'NOT_FOUND');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,8 +141,8 @@ describe('inventoryService', () => {
|
|||||||
|
|
||||||
it('rejects equipping an item not in the character inventory', () => {
|
it('rejects equipping an item not in the character inventory', () => {
|
||||||
const id = makeHero();
|
const id = makeHero();
|
||||||
// Item 1 exists but was never acquired by this character
|
// Item 1 is now in starter kit, so use a nonexistent item.
|
||||||
assert.throws(() => equipItem(id, 1, 'weapon'), (e) => e.code === 'NOT_FOUND');
|
assert.throws(() => equipItem(id, 99999, 'weapon'), (e) => e.code === 'NOT_FOUND');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates derived stats when equipping armor (defense bonus)', () => {
|
it('updates derived stats when equipping armor (defense bonus)', () => {
|
||||||
@@ -166,24 +175,25 @@ describe('inventoryService', () => {
|
|||||||
|
|
||||||
it('uses a healing potion and increases HP (capped at maxHp)', () => {
|
it('uses a healing potion and increases HP (capped at maxHp)', () => {
|
||||||
const id = makeHero({ level: 1, str: 8, agi: 4, int: 2, vit: 8 });
|
const id = makeHero({ level: 1, str: 8, agi: 4, int: 2, vit: 8 });
|
||||||
// Grant 3 Minor Healing Potions (item 14, heal:50)
|
// Grant 3 Minor Healing Potions (item 14, heal:50).
|
||||||
|
// Starter kit already has 2 potions → total 5.
|
||||||
grantItem(id, 14, 3);
|
grantItem(id, 14, 3);
|
||||||
|
|
||||||
// Set HP to 60 (out of ~145 maxHp for warrior with vit=8: 50+80+5=135)
|
// Set HP to 60.
|
||||||
db.prepare('UPDATE characters SET hp_current = 60 WHERE id = ?').run(id);
|
db.prepare('UPDATE characters SET hp_current = 60 WHERE id = ?').run(id);
|
||||||
|
|
||||||
const before = listInventory(id);
|
const before = listInventory(id);
|
||||||
const beforeHp = db.prepare('SELECT hp_current FROM characters WHERE id = ?').get(id).hp_current;
|
const beforeHp = db.prepare('SELECT hp_current FROM characters WHERE id = ?').get(id).hp_current;
|
||||||
const maxHp = before.derived.maxHp;
|
const maxHp = before.derived.maxHp;
|
||||||
|
|
||||||
// Use one potion: should heal to min(60+50, maxHp)
|
// Use one potion: should heal to min(60+50, maxHp).
|
||||||
const result = useConsumable(id, 14);
|
const result = useConsumable(id, 14);
|
||||||
const afterHp = db.prepare('SELECT hp_current FROM characters WHERE id = ?').get(id).hp_current;
|
const afterHp = db.prepare('SELECT hp_current FROM characters WHERE id = ?').get(id).hp_current;
|
||||||
|
|
||||||
assert.equal(afterHp, Math.min(beforeHp + 50, maxHp));
|
assert.equal(afterHp, Math.min(beforeHp + 50, maxHp));
|
||||||
// Quantity should be 3 → 2 (still in inventory)
|
// Total potions: 3 granted + 2 from starter kit = 5. After 1 use → 4.
|
||||||
const potion = result.items.find((i) => i.itemId === 14);
|
const potion = result.items.find((i) => i.itemId === 14);
|
||||||
assert.equal(potion.quantity, 2);
|
assert.equal(potion.quantity, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses an energy tonic and restores energy (capped at 100)', () => {
|
it('uses an energy tonic and restores energy (capped at 100)', () => {
|
||||||
@@ -215,8 +225,8 @@ describe('inventoryService', () => {
|
|||||||
|
|
||||||
it('rejects using an item not in inventory', () => {
|
it('rejects using an item not in inventory', () => {
|
||||||
const id = makeHero();
|
const id = makeHero();
|
||||||
// Item 14 exists but not granted to this character
|
// Item 14 is now in starter kit. Use a nonexistent item.
|
||||||
assert.throws(() => useConsumable(id, 14), (e) => e.code === 'NOT_FOUND');
|
assert.throws(() => useConsumable(id, 99999), (e) => e.code === 'NOT_FOUND');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes inventory row when quantity reaches 0 (single-item stack)', () => {
|
it('removes inventory row when quantity reaches 0 (single-item stack)', () => {
|
||||||
@@ -239,9 +249,10 @@ describe('inventoryService', () => {
|
|||||||
|
|
||||||
it('caps heal effect at maxHp', () => {
|
it('caps heal effect at maxHp', () => {
|
||||||
const id = makeHero({ level: 1, str: 8, agi: 4, int: 2, vit: 8 });
|
const id = makeHero({ level: 1, str: 8, agi: 4, int: 2, vit: 8 });
|
||||||
grantItem(id, 14, 5); // Minor Healing Potion (heal:50)
|
// Grant 5 more Minor Healing Potions (starter kit already has 2).
|
||||||
|
grantItem(id, 14, 5);
|
||||||
|
|
||||||
// Set HP already at max
|
// Set HP already at max.
|
||||||
const before = listInventory(id);
|
const before = listInventory(id);
|
||||||
const maxHp = before.derived.maxHp;
|
const maxHp = before.derived.maxHp;
|
||||||
db.prepare('UPDATE characters SET hp_current = ? WHERE id = ?').run(maxHp, id);
|
db.prepare('UPDATE characters SET hp_current = ? WHERE id = ?').run(maxHp, id);
|
||||||
@@ -251,15 +262,16 @@ describe('inventoryService', () => {
|
|||||||
const after = listInventory(id);
|
const after = listInventory(id);
|
||||||
const hp = db.prepare('SELECT hp_current FROM characters WHERE id = ?').get(id).hp_current;
|
const hp = db.prepare('SELECT hp_current FROM characters WHERE id = ?').get(id).hp_current;
|
||||||
assert.equal(hp, maxHp, 'HP should not exceed maxHp');
|
assert.equal(hp, maxHp, 'HP should not exceed maxHp');
|
||||||
// Quantity should decrease
|
// We granted 5 potions; starter kit has 2 → total 7. After 1 use → 6.
|
||||||
assert.equal(after.items.find((i) => i.itemId === 14).quantity, 4);
|
const potion = after.items.find((i) => i.itemId === 14);
|
||||||
|
assert.equal(potion.quantity, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('caps energy effect at ENERGY_MAX (100)', () => {
|
it('caps energy effect at ENERGY_MAX (100)', () => {
|
||||||
const id = makeHero({ level: 1, str: 8, agi: 4, int: 2, vit: 8 });
|
const id = makeHero({ level: 1, str: 8, agi: 4, int: 2, vit: 8 });
|
||||||
grantItem(id, 16, 5); // Energy Tonic (energy:25)
|
grantItem(id, 16, 5); // Energy Tonic (energy:25)
|
||||||
|
|
||||||
// Set energy to 90
|
// Set energy to 90.
|
||||||
db.prepare('UPDATE characters SET energy = 90 WHERE id = ?').run(id);
|
db.prepare('UPDATE characters SET energy = 90 WHERE id = ?').run(id);
|
||||||
|
|
||||||
useConsumable(id, 16);
|
useConsumable(id, 16);
|
||||||
@@ -267,7 +279,7 @@ describe('inventoryService', () => {
|
|||||||
const after = listInventory(id);
|
const after = listInventory(id);
|
||||||
const energy = db.prepare('SELECT energy FROM characters WHERE id = ?').get(id).energy;
|
const energy = db.prepare('SELECT energy FROM characters WHERE id = ?').get(id).energy;
|
||||||
assert.equal(energy, 100, 'Energy should not exceed 100');
|
assert.equal(energy, 100, 'Energy should not exceed 100');
|
||||||
// Quantity should decrease
|
// Quantity should decrease.
|
||||||
assert.equal(after.items.find((i) => i.itemId === 16).quantity, 4);
|
assert.equal(after.items.find((i) => i.itemId === 16).quantity, 4);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ describe('shop', () => {
|
|||||||
/**
|
/**
|
||||||
* Register, login, create a character, optionally tweak DB fields, return cookie + characterId.
|
* Register, login, create a character, optionally tweak DB fields, return cookie + characterId.
|
||||||
*/
|
*/
|
||||||
async function setupHero(overrides = {}) {
|
async function setupHero(overrides = {}, characterClass = 'warrior') {
|
||||||
userSeq += 1;
|
userSeq += 1;
|
||||||
const username = `shopu${userSeq}`;
|
const username = `shopu${userSeq}`;
|
||||||
const cookie = await registerAndLogin(baseUrl, username, 'password123');
|
const cookie = await registerAndLogin(baseUrl, username, 'password123');
|
||||||
@@ -58,7 +58,7 @@ describe('shop', () => {
|
|||||||
const charRes = await fetch(`${baseUrl}/api/character`, {
|
const charRes = await fetch(`${baseUrl}/api/character`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
|
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
|
||||||
body: JSON.stringify({ name: `ShopHero${userSeq}`, class: 'warrior' }),
|
body: JSON.stringify({ name: `ShopHero${userSeq}`, class: characterClass }),
|
||||||
});
|
});
|
||||||
assert.equal(charRes.status, 201);
|
assert.equal(charRes.status, 201);
|
||||||
const charJson = await charRes.json();
|
const charJson = await charRes.json();
|
||||||
@@ -75,13 +75,20 @@ describe('shop', () => {
|
|||||||
return { cookie, characterId };
|
return { cookie, characterId };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Grant an item to a character's inventory. */
|
/** Grant an item to a character's inventory. Stacks with existing rows. */
|
||||||
function grantItem(characterId, itemId, quantity = 1) {
|
function grantItem(characterId, itemId, quantity = 1) {
|
||||||
const db = require('../../src/db').getDb();
|
const db = require('../../src/db').getDb();
|
||||||
const NOW = 1_700_000_000_000;
|
const NOW = 1_700_000_000_000;
|
||||||
|
const existing = db.prepare(
|
||||||
|
'SELECT id, quantity FROM inventory WHERE character_id = ? AND item_id = ?'
|
||||||
|
).get(characterId, itemId);
|
||||||
|
if (existing) {
|
||||||
|
db.prepare('UPDATE inventory SET quantity = quantity + ? WHERE id = ?').run(quantity, existing.id);
|
||||||
|
} else {
|
||||||
db.prepare('INSERT INTO inventory (character_id, item_id, quantity, equipped_slot, acquired_at) VALUES (?, ?, ?, NULL, ?)')
|
db.prepare('INSERT INTO inventory (character_id, item_id, quantity, equipped_slot, acquired_at) VALUES (?, ?, ?, NULL, ?)')
|
||||||
.run(characterId, itemId, quantity, NOW);
|
.run(characterId, itemId, quantity, NOW);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── GET /api/shop ────────────────────────────────────────────────────
|
// ─── GET /api/shop ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -162,7 +169,8 @@ describe('shop', () => {
|
|||||||
it('buys multiple quantity and stacks into existing item', async () => {
|
it('buys multiple quantity and stacks into existing item', async () => {
|
||||||
const { cookie } = await setupHero({ gold: 200 });
|
const { cookie } = await setupHero({ gold: 200 });
|
||||||
|
|
||||||
// Buy 2 Minor Healing Potions (item 14, stackable, buy_price 15)
|
// Buy 2 Minor Healing Potions (item 14, stackable, buy_price 15).
|
||||||
|
// Starter kit already has 2 potions → total 4 after this buy.
|
||||||
const res1 = await fetch(`${baseUrl}/api/shop/buy`, {
|
const res1 = await fetch(`${baseUrl}/api/shop/buy`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
|
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
|
||||||
@@ -172,9 +180,9 @@ describe('shop', () => {
|
|||||||
assert.equal(body1.ok, true);
|
assert.equal(body1.ok, true);
|
||||||
assert.equal(body1.data.character.gold, 170); // 200 - 2*15
|
assert.equal(body1.data.character.gold, 170); // 200 - 2*15
|
||||||
const potion1 = body1.data.items.find((i) => i.itemId === 14);
|
const potion1 = body1.data.items.find((i) => i.itemId === 14);
|
||||||
assert.equal(potion1.quantity, 2);
|
assert.equal(potion1.quantity, 4); // 2 starter + 2 bought
|
||||||
|
|
||||||
// Buy 3 more of the same
|
// Buy 3 more of the same → total 7.
|
||||||
const res2 = await fetch(`${baseUrl}/api/shop/buy`, {
|
const res2 = await fetch(`${baseUrl}/api/shop/buy`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
|
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
|
||||||
@@ -184,7 +192,7 @@ describe('shop', () => {
|
|||||||
assert.equal(body2.ok, true);
|
assert.equal(body2.ok, true);
|
||||||
assert.equal(body2.data.character.gold, 125); // 170 - 3*15
|
assert.equal(body2.data.character.gold, 125); // 170 - 3*15
|
||||||
const potion2 = body2.data.items.find((i) => i.itemId === 14);
|
const potion2 = body2.data.items.find((i) => i.itemId === 14);
|
||||||
assert.equal(potion2.quantity, 5); // 2 + 3 stacked
|
assert.equal(potion2.quantity, 7); // 4 + 3 stacked
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects buy with insufficient gold', async () => {
|
it('rejects buy with insufficient gold', async () => {
|
||||||
@@ -372,19 +380,19 @@ describe('shop', () => {
|
|||||||
// ─── Sell equipped item rejection ─────────────────────────────────────
|
// ─── Sell equipped item rejection ─────────────────────────────────────
|
||||||
|
|
||||||
it('rejects selling an equipped item', async () => {
|
it('rejects selling an equipped item', async () => {
|
||||||
const { cookie, characterId } = await setupHero({ gold: 100 });
|
// Use a ranger (starter weapon is Hunters Shortbow, item 3).
|
||||||
|
// Grant a Leather Jerkin (item 8, armor) and equip it, then try to sell.
|
||||||
// Grant and equip Rusty Sword (item 1, weapon)
|
const { cookie, characterId } = await setupHero({ gold: 100 }, 'ranger');
|
||||||
grantItem(characterId, 1, 1);
|
|
||||||
|
|
||||||
|
grantItem(characterId, 8, 1); // Leather Jerkin (armor)
|
||||||
const db = require('../../src/db').getDb();
|
const db = require('../../src/db').getDb();
|
||||||
db.prepare('UPDATE inventory SET equipped_slot = ? WHERE character_id = ? AND item_id = ?').run('weapon', characterId, 1);
|
db.prepare('UPDATE inventory SET equipped_slot = ? WHERE character_id = ? AND item_id = ?').run('armor', characterId, 8);
|
||||||
|
|
||||||
// Try to sell the equipped Rusty Sword
|
// Try to sell the equipped Leather Jerkin — not allowed.
|
||||||
const res = await fetch(`${baseUrl}/api/shop/sell`, {
|
const res = await fetch(`${baseUrl}/api/shop/sell`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
|
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
|
||||||
body: JSON.stringify({ itemId: 1, quantity: 1 }),
|
body: JSON.stringify({ itemId: 8, quantity: 1 }),
|
||||||
});
|
});
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
|
|
||||||
|
|||||||
@@ -100,23 +100,17 @@ describe('step-10.12 equipment bonuses', () => {
|
|||||||
const cookie = await register(`e1012a${Date.now()}`, 'pass1234');
|
const cookie = await register(`e1012a${Date.now()}`, 'pass1234');
|
||||||
const { characterId } = await createHero(cookie, 1);
|
const { characterId } = await createHero(cookie, 1);
|
||||||
|
|
||||||
// Baseline: no equipment.
|
// Baseline: warrior already has Rusty Sword (item 1) from starter kit.
|
||||||
const before = await getCharacter(cookie);
|
const before = await getCharacter(cookie);
|
||||||
const baseDefense = before.derived.defense;
|
const baseDefense = before.derived.defense;
|
||||||
const baseAttack = before.derived.attack;
|
const baseAttack = before.derived.attack;
|
||||||
|
|
||||||
// Equip Rusty Sword (item 1, +3 attack) and Leather Jerkin (item 8, +3 defense).
|
// Equip Leather Jerkin (item 8, +3 defense) — weapon slot already taken.
|
||||||
grantItem(characterId, 1);
|
|
||||||
grantItem(characterId, 8);
|
grantItem(characterId, 8);
|
||||||
await httpEquip(cookie, 1, 'weapon');
|
|
||||||
await httpEquip(cookie, 8, 'armor');
|
await httpEquip(cookie, 8, 'armor');
|
||||||
|
|
||||||
const after = await getCharacter(cookie);
|
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.
|
// Defense should include +3 from armor.
|
||||||
assert.equal(after.derived.defense, baseDefense + 3,
|
assert.equal(after.derived.defense, baseDefense + 3,
|
||||||
`derived.defense should include +3 armor bonus: was ${baseDefense}, now ${after.derived.defense}`);
|
`derived.defense should include +3 armor bonus: was ${baseDefense}, now ${after.derived.defense}`);
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// Integration tests for step 10.13: Starter kit on character creation.
|
||||||
|
const { describe, it, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { setup, teardown, cleanup } = require('../helpers');
|
||||||
|
|
||||||
|
describe('step-10.13 starter kit', () => {
|
||||||
|
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 }. */
|
||||||
|
async function createHero(cookie, name, classKey) {
|
||||||
|
const res = await fetch(`${baseUrl}/api/character`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
||||||
|
body: JSON.stringify({ name, class: classKey }),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 201, `createHero failed for ${name}: ${res.status}`);
|
||||||
|
const { data } = await res.json();
|
||||||
|
return { characterId: data.character.id, cookie };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- warrior tests ---
|
||||||
|
|
||||||
|
it('warrior starts with Rusty Sword equipped + 2 Minor Healing Potions', async () => {
|
||||||
|
const cookie = await register(`s13w${Date.now()}`, 'pass1234');
|
||||||
|
const { characterId } = await createHero(cookie, 'S13Warrior', 'warrior');
|
||||||
|
|
||||||
|
// Inventory: exactly 2 rows (weapon + potion stack).
|
||||||
|
const rows = db.prepare(
|
||||||
|
'SELECT item_id, quantity, equipped_slot FROM inventory WHERE character_id = ? ORDER BY item_id'
|
||||||
|
).all(characterId);
|
||||||
|
assert.equal(rows.length, 2, `expected 2 inventory rows, got ${rows.length}`);
|
||||||
|
|
||||||
|
// Weapon is Rusty Sword (item 1), equipped in weapon slot.
|
||||||
|
const weapon = rows.find(r => r.equipped_slot === 'weapon');
|
||||||
|
assert.ok(weapon, 'expected an equipped weapon row');
|
||||||
|
assert.equal(weapon.item_id, 1, 'warrior weapon should be Rusty Sword (item 1)');
|
||||||
|
|
||||||
|
// Potions: one stack row, quantity 2.
|
||||||
|
const potions = rows.find(r => r.item_id === 14);
|
||||||
|
assert.ok(potions, 'expected a potion row');
|
||||||
|
assert.equal(potions.quantity, 2, 'should have 2 Minor Healing Potions');
|
||||||
|
|
||||||
|
// Derived attack includes the Rusty Sword bonus (+3 bonus_attack).
|
||||||
|
// Warrior: 5 + 8*2 + 1 + 3 = 25
|
||||||
|
const profile = await getCharacter(cookie);
|
||||||
|
assert.equal(profile.derived.attack, 25,
|
||||||
|
`warrior attack should be 25 (5 + 8*2 + 1 + 3): got ${profile.derived.attack}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- mage tests ---
|
||||||
|
|
||||||
|
it('mage starts with Apprentice Staff equipped + 2 Minor Healing Potions', async () => {
|
||||||
|
const cookie = await register(`s13m${Date.now()}`, 'pass1234');
|
||||||
|
const { characterId } = await createHero(cookie, 'S13Mage', 'mage');
|
||||||
|
|
||||||
|
const rows = db.prepare(
|
||||||
|
'SELECT item_id, quantity, equipped_slot FROM inventory WHERE character_id = ? ORDER BY item_id'
|
||||||
|
).all(characterId);
|
||||||
|
assert.equal(rows.length, 2, `expected 2 inventory rows, got ${rows.length}`);
|
||||||
|
|
||||||
|
const weapon = rows.find(r => r.equipped_slot === 'weapon');
|
||||||
|
assert.ok(weapon, 'expected an equipped weapon row');
|
||||||
|
assert.equal(weapon.item_id, 2, 'mage weapon should be Apprentice Staff (item 2)');
|
||||||
|
|
||||||
|
const potions = rows.find(r => r.item_id === 14);
|
||||||
|
assert.ok(potions, 'expected a potion row');
|
||||||
|
assert.equal(potions.quantity, 2, 'should have 2 Minor Healing Potions');
|
||||||
|
|
||||||
|
// Derived attack includes the Apprentice Staff bonus (+1 int → primary goes 9→10).
|
||||||
|
// Mage: 5 + 10*2 + 1 + 0 = 26
|
||||||
|
const profile = await getCharacter(cookie);
|
||||||
|
assert.equal(profile.derived.attack, 26,
|
||||||
|
`mage attack should be 26 (5 + 10*2 + 1 + 0): got ${profile.derived.attack}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- ranger tests ---
|
||||||
|
|
||||||
|
it('ranger starts with Hunters Shortbow equipped + 2 Minor Healing Potions', async () => {
|
||||||
|
const cookie = await register(`s13r${Date.now()}`, 'pass1234');
|
||||||
|
const { characterId } = await createHero(cookie, 'S13Ranger', 'ranger');
|
||||||
|
|
||||||
|
const rows = db.prepare(
|
||||||
|
'SELECT item_id, quantity, equipped_slot FROM inventory WHERE character_id = ? ORDER BY item_id'
|
||||||
|
).all(characterId);
|
||||||
|
assert.equal(rows.length, 2, `expected 2 inventory rows, got ${rows.length}`);
|
||||||
|
|
||||||
|
const weapon = rows.find(r => r.equipped_slot === 'weapon');
|
||||||
|
assert.ok(weapon, 'expected an equipped weapon row');
|
||||||
|
assert.equal(weapon.item_id, 3, 'ranger weapon should be Hunters Shortbow (item 3)');
|
||||||
|
|
||||||
|
const potions = rows.find(r => r.item_id === 14);
|
||||||
|
assert.ok(potions, 'expected a potion row');
|
||||||
|
assert.equal(potions.quantity, 2, 'should have 2 Minor Healing Potions');
|
||||||
|
|
||||||
|
// Derived attack includes the Shortbow bonus (+2 agi → primary goes 9→11).
|
||||||
|
// Ranger: 5 + 11*2 + 1 + 0 = 28
|
||||||
|
const profile = await getCharacter(cookie);
|
||||||
|
assert.equal(profile.derived.attack, 28,
|
||||||
|
`ranger attack should be 28 (5 + 11*2 + 1 + 0): got ${profile.derived.attack}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- failure path: duplicate name blocked ---
|
||||||
|
|
||||||
|
it('duplicate hero name is still rejected despite starter kit', async () => {
|
||||||
|
const cookie = await register(`s13x${Date.now()}`, 'pass1234');
|
||||||
|
const { characterId } = await createHero(cookie, 'S13X', 'warrior');
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/character`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
||||||
|
body: JSON.stringify({ name: 'S13X', class: 'mage' }),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 409);
|
||||||
|
const body = await res.json();
|
||||||
|
assert.equal(body.ok, false);
|
||||||
|
assert.equal(body.error.code, 'HERO_EXISTS');
|
||||||
|
|
||||||
|
// The first hero should have its starter kit intact (2 rows).
|
||||||
|
const rows = db.prepare(
|
||||||
|
'SELECT COUNT(*) as cnt FROM inventory WHERE character_id = ?'
|
||||||
|
).get(characterId);
|
||||||
|
assert.equal(rows.cnt, 2, 'starter kit should have 2 inventory rows');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user