Files
idle-xboct/packages/server/smoke.mjs
T

214 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Дымовой тест 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}`);
}