// End-to-end "Definition of playable" flow — NOTES.md §7. // register → log in → create hero → see stats/energy → hunt → gain XP/gold/loot → level up → equip → buy/sell shop → start+claim expedition → leaderboard → log out → log back in → persistence check. const { describe, it, before, after } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const os = require('os'); describe('e2e: Definition of playable', () => { let BASE, cookie, userSeq = 0; let db, tmpDir, srv; before(async () => { process.env.NODE_ENV = 'test'; // Manual setup matching helpers.js pattern tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emberfall-e2e-')); const dbPath = path.join(tmpDir, 'e2e.db'); process.env.DB_PATH = dbPath; // Clear require cache for all src/ and scripts/ const root = path.resolve(__dirname, '..'); for (const id of Object.keys(require.cache)) { if (id.startsWith(path.join(root, '..') + path.sep) || id.startsWith(path.join(root, '../scripts') + path.sep)) { delete require.cache[id]; } } const { runMigrations } = require('../../scripts/migrate'); runMigrations(); const dbModule = require('../../src/db'); db = dbModule.getDb(); const { createApp } = require('../../src/app'); const app = createApp(); const port = await new Promise((resolve, reject) => { srv = app.listen(0, () => resolve(srv.address().port)); srv.on('error', reject); }); BASE = `http://localhost:${port}`; // Seed a trivial zone/monster for guaranteed wins (hero always wins). db.prepare(`INSERT OR IGNORE INTO zones (id, slug, name, description, min_level, energy_cost) VALUES (999, 't-e2e', 'Test Zone', 'e2e', 1, 10)`).run(); db.prepare(`INSERT OR IGNORE INTO monsters (id, zone_id, name, level, hp, attack, defense, speed, xp, gold_min, gold_max, weight) VALUES (999, 999, 'Slime', 1, 10, 1, 0, 1, 100, 5, 10, 100)`).run(); // Get a stackable item for loot drops const lootItem = db.prepare('SELECT id FROM items WHERE stackable = 1 LIMIT 1').get(); if (lootItem) { db.prepare('INSERT INTO loot_table (monster_id, item_id, chance_pct, qty_min, qty_max) VALUES (?, ?, 100, 1, 1)').run(999, lootItem.id); } }); after(async () => { try { db.close(); } catch (_) {} try { await new Promise((r) => { if (srv) srv.close(r); else r(); }); } catch (_) {} try { fs.rmSync(tmpDir, { recursive: true }); } catch (_) {} }); async function api(path, opts = {}) { const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) }; if (cookie && !opts.noCookie) headers['Cookie'] = cookie; const res = await fetch(`${BASE}${path}`, { method: opts.method || 'GET', headers, body: opts.body ? JSON.stringify(opts.body) : undefined, redirect: 'manual', }); const setCookie = res.headers.get('set-cookie'); if (setCookie) cookie = setCookie.split(';')[0]; const ct = res.headers.get('content-type') || ''; let data; if (ct.includes('application/json')) data = await res.json(); else data = await res.text(); return { status: res.status, data, headers: res.headers }; } async function registerAndLogin(username, password) { const r1 = await api('/api/auth/register', { method: 'POST', body: { username, password } }); assert.ok([200, 201].includes(r1.status), `register ${username}: ${JSON.stringify(r1.data)}`); const r2 = await api('/api/auth/login', { method: 'POST', body: { username, password } }); assert.equal(r2.status, 200, `login ${username}: ${JSON.stringify(r2.data)}`); return cookie; } async function loginOnly(username, password) { const r = await api('/api/auth/login', { method: 'POST', body: { username, password } }); assert.equal(r.status, 200, `login ${username}: ${JSON.stringify(r.data)}`); return cookie; } it('full playable flow: register → login → create hero → stats → hunt → level → equip → shop → expedition → leaderboard → logout → login → persistence', async () => { userSeq += 1; const username = `e2euser${userSeq}`; const password = 'password123'; const heroName = `Hero${userSeq}`; // 1. REGISTER // 2. LOGIN await registerAndLogin(username, password); // 3. CREATE HERO const createRes = await api('/api/character', { method: 'POST', body: { name: heroName, class: 'warrior' } }); assert.ok([200, 201].includes(createRes.status), `create hero: ${JSON.stringify(createRes.data)}`); assert.ok(createRes.data.ok); const charId = createRes.data.data.id; // 4. SEE STATS/ENERGY const charRes = await api('/api/character'); assert.equal(charRes.status, 200); assert.ok(charRes.data.ok); const charData = charRes.data.data; assert.equal(charData.character.name, heroName); assert.equal(charData.character.class, 'warrior'); assert.ok(charData.energy.current != null, 'Should have energy'); assert.ok(charData.derived != null, 'Should have derived stats'); assert.ok(charData.derived.maxHp != null, 'Should have maxHp'); // 5. HUNT (hero should win against slime) const huntRes = await api('/api/hunt', { method: 'POST', body: { zoneId: 999 } }); assert.equal(huntRes.status, 200, `hunt: ${JSON.stringify(huntRes.data)}`); assert.ok(huntRes.data.ok); const huntData = huntRes.data.data; assert.equal(huntData.winner, 'hero', 'Hero should win'); assert.ok(huntData.rounds && huntData.rounds.length > 0, 'Should have rounds'); assert.ok(huntData.xpGained > 0, 'Should gain XP'); assert.ok(huntData.goldGained >= 0, 'Should gain gold'); // 6. HUNT MORE TO ACCUMULATE XP (100 XP needed to level 1→2) let leveledUp = false; for (let i = 0; i < 6; i++) { const h2 = await api('/api/hunt', { method: 'POST', body: { zoneId: 999 } }); assert.equal(h2.status, 200, `hunt #${i}: ${JSON.stringify(h2.data)}`); assert.ok(h2.data.ok); if (h2.data.data.leveledUp) leveledUp = true; } // Verify XP increased const charAfter = await api('/api/character'); assert.ok(charAfter.data.data.character.xp >= 0, 'XP should be >= 0'); // 7. CHECK INVENTORY (should have loot) const invRes = await api('/api/inventory'); assert.equal(invRes.status, 200); assert.ok(invRes.data.ok); // 8. EQUIP ITEM (if we have unequipped equipment items, equip to weapon slot) const invData = invRes.data.data.items || []; if (invData.length > 0) { const unequipped = invData.find((item) => !item.equipped && item.type !== 'consumable'); if (unequipped) { const equipRes = await api('/api/inventory/equip', { method: 'POST', body: { itemId: unequipped.itemId, slot: unequipped.slot || 'weapon' } }); assert.equal(equipRes.status, 200, `equip: ${JSON.stringify(equipRes.data)}`); const inv2 = await api('/api/inventory'); const equippedItem = inv2.data.data.items.find((i) => i.inventoryId === unequipped.inventoryId); assert.ok(equippedItem && equippedItem.equipped, 'Item should now be equipped'); } } // 9. BUY FROM SHOP const shopRes = await api('/api/shop'); assert.equal(shopRes.status, 200); assert.ok(shopRes.data.ok); const stock = shopRes.data.data.stock || []; const heroGold = charAfter.data.data.character.gold || 0; if (stock.length > 0) { const buyable = stock.find((s) => s.buyPrice <= heroGold); if (buyable) { const buyRes = await api('/api/shop/buy', { method: 'POST', body: { itemId: buyable.itemId, quantity: 1 } }); assert.equal(buyRes.status, 200, `buy: ${JSON.stringify(buyRes.data)}`); assert.ok(buyRes.data.ok, `Buy should succeed: ${JSON.stringify(buyRes.data)}`); } } // 10. SELL AN ITEM VIA SHOP (if we have material-type items) const invAfterBuy = await api('/api/inventory'); const itemsToSell = (invAfterBuy.data.data.items || []).filter((item) => !item.equipped && item.type === 'material'); if (itemsToSell.length > 0) { // POST /api/shop/sell requires { itemId, quantity } const sellRes = await api('/api/shop/sell', { method: 'POST', body: { itemId: itemsToSell[0].itemId, quantity: 1 } }); assert.ok([200, 400].includes(sellRes.status), `sell status ${sellRes.status}: ${JSON.stringify(sellRes.data)}`); } // 11. START + CLAIM EXPEDITION const expDefs = await api('/api/expeditions/defs'); assert.equal(expDefs.status, 200); const defs = expDefs.data.data || []; let activeExpId = null; if (defs.length > 0) { const startRes = await api('/api/expeditions/start', { method: 'POST', body: { defId: defs[0].id } }); assert.equal(startRes.status, 200, `start expedition: ${JSON.stringify(startRes.data)}`); assert.ok(startRes.data.ok); activeExpId = startRes.data.data.expedition.id; // Check active const activeRes = await api('/api/expeditions/active'); assert.equal(activeRes.status, 200); assert.ok(activeRes.data.ok); assert.ok(activeRes.data.data != null, 'Should have active expedition'); // Fast-forward time past ends_at by updating the expedition in DB if (activeExpId) { const time = require('../../src/utils/time'); // Set ends_at to 10 minutes ago db.prepare('UPDATE expeditions SET ends_at = ? WHERE id = ?').run(time.now() - 600_000, activeExpId); } // Claim const claimRes = await api('/api/expeditions/claim', { method: 'POST', body: { expeditionId: activeExpId } }); assert.equal(claimRes.status, 200, `claim expedition: ${JSON.stringify(claimRes.data)}`); assert.ok(claimRes.data.ok); // Verify no active expedition left const activeAfter = await api('/api/expeditions/active'); assert.equal(activeAfter.status, 200); assert.ok(activeAfter.data.data == null, 'No active expedition after claim'); } // 12. SEE LEADERBOARD (level, gold, kills) const lbLevel = await api('/api/leaderboard?type=level'); assert.equal(lbLevel.status, 200); assert.ok(lbLevel.data.ok); assert.ok(Array.isArray(lbLevel.data.data.entries), 'Level leaderboard should have entries array'); const lbGold = await api('/api/leaderboard?type=gold'); assert.equal(lbGold.status, 200); assert.ok(lbGold.data.ok); const lbKills = await api('/api/leaderboard?type=kills'); assert.equal(lbKills.status, 200); assert.ok(lbKills.data.ok); // 13. LOG OUT const logoutRes = await api('/api/auth/logout', { method: 'POST' }); assert.equal(logoutRes.status, 200); // 14. LOG BACK IN (just login, not register) await loginOnly(username, password); // 15. VERIFY PERSISTENCE const charPersist = await api('/api/character'); assert.equal(charPersist.status, 200); assert.equal(charPersist.data.data.character.name, heroName); assert.equal(charPersist.data.data.character.class, 'warrior'); assert.ok(charPersist.data.data.energy.current >= 0, 'Energy should have regenerated'); assert.ok(charPersist.data.data.character.gold >= 0, 'Gold should be non-negative'); assert.ok(charPersist.data.data.character.xp >= 0, 'XP should persist'); }); });