feat: add common social fireplace
This commit is contained in:
+74
-99
@@ -1,12 +1,14 @@
|
||||
/* Дымовой тест M3: сервер должен быть запущен с DEV_HTTP=1, SQLITE_PATH=:memory:.
|
||||
`pnpm --filter @idle/server smoke` */
|
||||
/* Дымовой тест 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));
|
||||
|
||||
// ждём готовности сервера (tsx может стартовать дольше пары секунд)
|
||||
for (let i = 0; i < 30; i++) {
|
||||
try {
|
||||
const r = await fetch(`${BASE}/healthz`);
|
||||
@@ -15,12 +17,11 @@ for (let i = 0; i < 30; i++) {
|
||||
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('open', () => ws.send(JSON.stringify({ t: 'hello', v: 5 })));
|
||||
ws.on('message', (d) => onMsg(JSON.parse(String(d))));
|
||||
return ws;
|
||||
}
|
||||
@@ -34,17 +35,11 @@ async function dev(path, 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) => {
|
||||
@@ -61,99 +56,79 @@ function waitForDelta(pred, timeoutMs = 9000) {
|
||||
});
|
||||
}
|
||||
|
||||
// 1. welcome + полный снапшот
|
||||
const first = [];
|
||||
await new Promise((resolve, reject) => {
|
||||
const ws = connect((m) => {
|
||||
first.push(m);
|
||||
if (first.length >= 2) {
|
||||
ws.close();
|
||||
resolve();
|
||||
}
|
||||
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);
|
||||
});
|
||||
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');
|
||||
const phase = process.argv[2] ?? 'phase1';
|
||||
|
||||
// 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');
|
||||
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');
|
||||
|
||||
// 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');
|
||||
// спавн, брёвна, !костёр
|
||||
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');
|
||||
|
||||
// 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);
|
||||
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');
|
||||
|
||||
// 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');
|
||||
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');
|
||||
|
||||
// 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}`);
|
||||
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 {}
|
||||
|
||||
console.log('WS smoke: OK');
|
||||
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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user