step 9.5: wip (not ticked)
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user