/* Дымовой тест M3: сервер должен быть запущен с DEV_HTTP=1, SQLITE_PATH=:memory:. `pnpm --filter @idle/server smoke` */ import WebSocket from 'ws'; const BASE = 'http://localhost:3000'; const WS = 'ws://localhost:3000/ws'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // ждём готовности сервера (tsx может стартовать дольше пары секунд) for (let i = 0; i < 30; i++) { try { const r = await fetch(`${BASE}/healthz`); if (r.ok) break; } catch {} await sleep(500); } // антиспам-кулдаун команд — между командами выдерживаем паузу const COOLDOWN = 1400; function connect(onMsg) { const ws = new WebSocket(WS); ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 4 }))); ws.on('message', (d) => onMsg(JSON.parse(String(d)))); return ws; } async function dev(path, body) { const res = await fetch(BASE + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`POST ${path} -> ${res.status} ${await res.text()}`); } async function inventory(name) { const res = await fetch(`${BASE}/api/inventory?name=${encodeURIComponent(name)}`); return res.json(); } async function state() { const res = await fetch(`${BASE}/dev/state`); return res.json(); } /** Ждём дельту, удовлетворяющую условию. */ function waitForDelta(pred, timeoutMs = 9000) { return new Promise((resolve, reject) => { const ws = connect((m) => { if (m.t === 'delta' && pred(m)) { ws.close(); resolve(m); } }); ws.on('error', reject); setTimeout(() => { ws.terminate(); reject(new Error('timeout waiting delta')); }, timeoutMs); }); } // 1. welcome + полный снапшот const first = []; await new Promise((resolve, reject) => { const ws = connect((m) => { first.push(m); if (first.length >= 2) { ws.close(); resolve(); } }); ws.on('error', reject); setTimeout(() => reject(new Error('timeout waiting welcome+snapshot')), 5000); }); const [welcome, snap] = first; const fishSpots = snap.nodes.filter((n) => n.kind === 'fish').length; console.log( 'welcome: v' + welcome.v, '| нод:', snap.nodes.length, '| рыбных мест:', fishSpots, '| пропсов:', welcome.world.props.map((p) => p.kind).join(','), ); if (welcome.t !== 'welcome' || welcome.v !== 4) throw new Error('bad welcome'); if (snap.nodes.length !== 16 || fishSpots !== 3) throw new Error('bad snapshot nodes'); const propKinds = welcome.world.props.map((p) => p.kind).sort().join(','); if (propKinds !== 'anvil,campfire,stove') throw new Error(`bad props: ${propKinds}`); // 2. спавн через !рубить await dev('/dev/command', { name: 'Тестер', text: '!рубить' }); await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.moving)); console.log('spawn ok'); // 3. рыбалка await dev('/dev/tp', { name: 'Тестер', x: 2250, y: 375 }); await sleep(COOLDOWN); await dev('/dev/command', { name: 'Тестер', text: '!рыбачить' }); const d1 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'fish')); const fisher = d1.avatars.find((a) => a.name === 'Тестер'); console.log('fish: action=' + fisher.action, 'tool=' + fisher.tool); if (fisher.action !== 'fish' || fisher.tool !== 'rod') throw new Error('bad fish state'); await sleep(16_000); const inv1 = await inventory('Тестер'); console.log('inventory: raw_fish=' + (inv1.items?.raw_fish ?? 0), 'fishing xp=' + (inv1.skills?.fishing?.xp ?? 0)); if ((inv1.items?.raw_fish ?? 0) < 1 || (inv1.skills?.fishing?.xp ?? 0) < 15) throw new Error('fishing did not yield'); // 4. готовка жареной рыбы await dev('/dev/give', { name: 'Тестер', item: 'raw_fish', qty: 2 }); await dev('/dev/tp', { name: 'Тестер', x: 2990, y: 425 }); await sleep(COOLDOWN); await dev('/dev/command', { name: 'Тестер', text: '!готовить жареную рыбу' }); const d2 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'cook')); const cook = d2.avatars.find((a) => a.name === 'Тестер'); console.log('cook: action=' + cook.action, 'tool=' + cook.tool); if (cook.action !== 'cook' || cook.tool !== 'spoon') throw new Error('bad cook state'); await sleep(11_000); const inv2 = await inventory('Тестер'); console.log('inventory: cooked_fish=' + (inv2.items?.cooked_fish ?? 0), 'cooking xp=' + (inv2.skills?.cooking?.xp ?? 0)); if ((inv2.items?.cooked_fish ?? 0) < 1 || (inv2.skills?.cooking?.xp ?? 0) < 20) throw new Error('cooking did not yield'); // 5. отдых без рыбы у второго зрителя → blocked await dev('/dev/command', { name: 'Тестер2', text: '!рубить' }); await sleep(300); await dev('/dev/tp', { name: 'Тестер2', x: 1960, y: 515 }); await sleep(COOLDOWN); await dev('/dev/command', { name: 'Тестер2', text: '!отдых' }); const d3 = await waitForDelta((m) => m.events?.some((e) => e.k === 'blocked' && e.name === 'Тестер2'), 5000); console.log('blocked:', d3.events.find((e) => e.k === 'blocked').reason); // 6. отдых Тестера с рыбой: цикл 60 сек → стак баффа await dev('/dev/tp', { name: 'Тестер', x: 1960, y: 515 }); await sleep(COOLDOWN); await dev('/dev/command', { name: 'Тестер', text: '!отдых' }); const d4 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'rest')); console.log('rest: начат'); await sleep(62_000); const st = await state(); const tester = st.avatars.find((a) => a.name === 'Тестер'); const inv3 = await inventory('Тестер'); console.log( 'after rest: restStacks=' + tester.restStacks, 'cooked_fish=' + (inv3.items?.cooked_fish ?? 0), 'action=' + tester.action, ); if (tester.restStacks < 1) throw new Error('rest buff not granted'); if ((inv3.items?.cooked_fish ?? 0) !== 0) throw new Error('cooked fish not consumed'); if (tester.action !== 'idle') throw new Error('rest should end when out of fish'); // 7. неверная версия протокола — close 4001 const closeCode = await new Promise((resolve) => { const ws = new WebSocket(WS); ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 999 }))); ws.on('close', (code) => resolve(code)); setTimeout(() => resolve('no-close'), 3000); }); if (closeCode !== 4001) throw new Error(`expected close 4001, got ${closeCode}`); console.log('WS smoke: OK');