/* Дымовой тест M4 (кооп-костёр), две фазы: node smoke.mjs phase1 — вклад, уровень, ledger (сервер с DEV_HTTP=1 + файловая база) node smoke.mjs phase2 — после рестарта сервера: персистентность + декей + guard версии */ import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'; import WebSocket from 'ws'; const BASE = 'http://localhost:3000'; const WS = 'ws://localhost:3000/ws'; const PROGRESS_FILE = new URL('./smoke-progress.tmp', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); 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: 6 }))); 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 state() { const res = await fetch(`${BASE}/dev/state`); return res.json(); } async function inventory(name) { const res = await fetch(`${BASE}/api/inventory?name=${encodeURIComponent(name)}`); 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); }); } function firstSnapshot() { return new Promise((resolve, reject) => { const seen = []; const ws = connect((m) => { seen.push(m); if (seen.length >= 2) { ws.close(); resolve(seen[1]); } }); ws.on('error', reject); setTimeout(() => reject(new Error('timeout')), 5000); }); } const phase = process.argv[2] ?? 'phase1'; if (phase === 'm5') { const snap = await firstSnapshot(); if (!snap.campfire) throw new Error('no campfire state'); console.log('welcome v6 ok'); // два зрителя await dev('/dev/command', { name: 'Тестер', text: '!рубить' }); await sleep(300); await dev('/dev/command', { name: 'Тестер2', text: '!рубить' }); await sleep(COOLDOWN); // подарок: у Тестера один топор → однозначно await dev('/dev/give', { name: 'Тестер', item: 'log', qty: 5 }); await dev('/dev/command', { name: 'Тестер', text: '!подарить Тестер2 топор' }); const d1 = await waitForDelta((m) => m.events?.some((e) => e.k === 'gift'), 5000); const gift = d1.events.find((e) => e.k === 'gift'); console.log(`gift: ${gift.name} → ${gift.toName}: ${gift.qty}× ${gift.item}`); if (gift.item !== 'axe_rusty' || gift.toName !== 'Тестер2') throw new Error('bad gift'); const inv1 = await inventory('Тестер2'); if ((inv1.items?.axe_rusty ?? 0) !== 2) throw new Error('gift not delivered: ' + JSON.stringify(inv1.items)); // неоднозначность: оба топора в инвентаре → «уточни» await dev('/dev/give', { name: 'Тестер', item: 'axe_rusty', qty: 1 }); await dev('/dev/give', { name: 'Тестер', item: 'axe_iron', qty: 1 }); await sleep(COOLDOWN); await dev('/dev/command', { name: 'Тестер', text: '!подарить Тестер2 топор' }); const d2 = await waitForDelta((m) => m.events?.some((e) => e.k === 'blocked' && /уточни/.test(e.reason)), 5000); console.log('ambiguous:', d2.events.find((e) => e.k === 'blocked').reason); // склонение + количество: «брёвна 2» await sleep(COOLDOWN); await dev('/dev/command', { name: 'Тестер', text: '!подарить Тестер2 брёвна 2' }); const d3 = await waitForDelta((m) => m.events?.some((e) => e.k === 'gift' && e.item === 'log'), 5000); console.log('gift2: логи ×' + d3.events.find((e) => e.k === 'gift').qty); const inv2 = await inventory('Тестер'); if ((inv2.items?.log ?? 0) !== 3) throw new Error('log qty wrong: ' + JSON.stringify(inv2.items)); // спотлайт: камера едет к аватару (прямоугольник 620×430 применяется со следующего тика) await dev('/dev/spotlight', { name: 'Тестер' }); const d4 = await waitForDelta((m) => m.camera?.w === 620, 5000); const cam = d4.camera; const st = await state(); const av = st.avatars.find((a) => a.name === 'Тестер'); const inFrame = cam && av && av.x >= cam.x && av.x <= cam.x + cam.w && av.y >= cam.y && av.y <= cam.y + cam.h; console.log(`spotlight: камера ${cam.w}×${cam.h}, аватар (${av.x},${av.y}), в кадре: ${inFrame ? 'да' : 'НЕТ'}`); if (!inFrame) throw new Error('camera did not follow avatar'); // дашборд: state, сброс костра, html const admin = await fetch(`${BASE}/api/admin/state`).then((r) => r.json()); console.log(`admin: канал=${admin.channel}, зрителей=${admin.viewers}, спотлайт-очередь=${admin.spotlightQueue}`); const reset = await fetch(`${BASE}/api/admin/campfire/reset`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ keepLedger: true }), }); const resetBody = await reset.json(); if (resetBody.campfire?.progress !== 0) throw new Error('campfire reset failed'); const dash = await fetch(`${BASE}/dashboard/`); console.log('dashboard:', dash.status, dash.headers.get('content-type')); if (dash.status !== 200) throw new Error('dashboard not served'); // сброс зрителя: аватар уходит из мира (removed) await fetch(`${BASE}/api/admin/viewer/reset`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Тестер2' }), }); const d5 = await waitForDelta((m) => m.removed?.includes(`dev:Тестер2`), 5000); console.log('viewer reset: removed from world ok'); console.log('M5 smoke: OK'); process.exit(0); } if (phase === 'phase1') { const snap = await firstSnapshot(); console.log('welcome v5 | campfire:', JSON.stringify(snap.campfire)); if (!snap.campfire || snap.campfire.level < 1) throw new Error('no campfire state'); // спавн, брёвна, !костёр await dev('/dev/command', { name: 'Тестер', text: '!рубить' }); await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.moving)); await dev('/dev/give', { name: 'Тестер', item: 'log', qty: 2 }); await dev('/dev/tp', { name: 'Тестер', x: 1900, y: 505 }); await sleep(COOLDOWN); await dev('/dev/command', { name: 'Тестер', text: '!костёр' }); const d1 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'feed')); const feeder = d1.avatars.find((a) => a.name === 'Тестер'); console.log('feed: action=' + feeder.action, 'tool=' + feeder.tool); if (feeder.action !== 'feed' || feeder.tool !== 'log') throw new Error('bad feed state'); const d2 = await waitForDelta((m) => m.events?.some((e) => e.k === 'fire' && e.name === 'Тестер'), 15000); const fireEv = d2.events.find((e) => e.k === 'fire'); console.log(`fire: прогресс=${fireEv.progress}, личный вклад=${fireEv.contributed}`); if (fireEv.contributed < 10 || fireEv.progress < 10) throw new Error('campfire did not grow'); await dev('/dev/fire', { points: 95 }); const d3 = await waitForDelta((m) => m.events?.some((e) => e.k === 'firelevel'), 5000); const lv = d3.events.find((e) => e.k === 'firelevel').level; const st1 = await state(); console.log( `firelevel: ${lv}, campfire.level=${st1.campfire.level}, топ: ${st1.campfire.top .map((t) => t.name + '=' + t.contributed) .join(', ')}`, ); if (lv < 2 || st1.campfire.level < 2) throw new Error('campfire level did not rise'); if (!st1.campfire.top.some((t) => t.name === 'Тестер')) throw new Error('ledger missing'); writeFileSync(PROGRESS_FILE, String(st1.campfire.progress), 'utf8'); console.log(`phase1 OK (progress=${st1.campfire.progress}). Перезапусти сервер и запусти: node smoke.mjs phase2`); } else if (phase === 'phase2') { const before = Number(readFileSync(PROGRESS_FILE, 'utf8')); const snap = await firstSnapshot(); console.log(`after restart: прогресс ${snap.campfire.progress} (был ${before}), уровень ${snap.campfire.level}`); if (snap.campfire.progress >= before) throw new Error('decay did not happen after restart'); if (snap.campfire.level < 2) throw new Error('campfire level lost after restart'); if (!snap.campfire.top.some((t) => t.name === 'Тестер')) throw new Error('ledger lost after restart'); try { unlinkSync(PROGRESS_FILE); } catch {} 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'); } else { throw new Error(`unknown phase: ${phase}`); }