feat: add ore and blacksmith
This commit is contained in:
+93
-30
@@ -1,17 +1,61 @@
|
||||
/* Дымовой тест M1: сервер должен быть запущен с DEV_HTTP=1, SQLITE_PATH=:memory:.
|
||||
/* Дымовой тест M2: сервер должен быть запущен с 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: 2 })));
|
||||
ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 3 })));
|
||||
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();
|
||||
}
|
||||
|
||||
/** Ждём дельту, удовлетворяющую условию. */
|
||||
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) => {
|
||||
@@ -26,37 +70,56 @@ await new Promise((resolve, reject) => {
|
||||
setTimeout(() => reject(new Error('timeout waiting welcome+snapshot')), 5000);
|
||||
});
|
||||
const [welcome, snap] = first;
|
||||
console.log('welcome:', JSON.stringify({ t: welcome.t, v: welcome.v, tickMs: welcome.tickMs }));
|
||||
if (welcome.t !== 'welcome' || welcome.v !== 2) throw new Error(`bad welcome: ${JSON.stringify(welcome)}`);
|
||||
if (snap.t !== 'snapshot' || snap.nodes.length !== 7) throw new Error(`bad snapshot: ${snap.t}, nodes=${snap.nodes?.length}`);
|
||||
console.log(`snapshot: деревьев=${snap.nodes.length}, зрителей=${snap.avatars.length}`);
|
||||
console.log('welcome: v' + welcome.v, '| деревьев+жил:', snap.nodes.length, '| пропсов:', welcome.world.props.length);
|
||||
if (welcome.t !== 'welcome' || welcome.v !== 3) throw new Error('bad welcome');
|
||||
if (snap.nodes.length !== 13 || snap.nodes.filter((n) => n.kind === 'rock').length !== 6) {
|
||||
throw new Error('bad snapshot nodes');
|
||||
}
|
||||
if (!welcome.world.props.some((p) => p.kind === 'anvil')) throw new Error('no anvil prop');
|
||||
|
||||
// 2. команда через dev-эндпоинт
|
||||
const res = await fetch(`${BASE}/dev/command`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Тестер', text: '!рубить' }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`dev command failed: ${res.status} ${await res.text()}`);
|
||||
console.log('dev command: accepted');
|
||||
// 2. спавн через !рубить
|
||||
await dev('/dev/command', { name: 'Тестер', text: '!рубить' });
|
||||
const d1 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.moving));
|
||||
console.log('spawn: движется к дереву ok');
|
||||
|
||||
// 3. ждём дельту: аватар «Тестер» должен заспавниться и пойти к дереву
|
||||
const delta = await new Promise((resolve, reject) => {
|
||||
const ws = connect((m) => {
|
||||
if (m.t === 'delta' && m.avatars?.some((a) => a.name === 'Тестер')) {
|
||||
ws.close();
|
||||
resolve(m);
|
||||
}
|
||||
});
|
||||
ws.on('error', reject);
|
||||
setTimeout(() => reject(new Error('no delta with Тестер')), 8000);
|
||||
});
|
||||
const tester = delta.avatars.find((a) => a.name === 'Тестер');
|
||||
console.log(`delta: ${tester.name} moving=${tester.moving} action=${tester.action} target=(${tester.tx},${tester.ty})`);
|
||||
console.log(`events: ${(delta.events ?? []).map((e) => e.k).join(',') || '—'}`);
|
||||
if (!tester.moving || tester.action !== 'idle') throw new Error('Тестер должен идти к дереву (idle + moving)');
|
||||
// 3. телепорт к медной жиле и !копать
|
||||
await dev('/dev/tp', { name: 'Тестер', x: 1140, y: 346 });
|
||||
await sleep(COOLDOWN);
|
||||
await dev('/dev/command', { name: 'Тестер', text: '!копать' });
|
||||
const d2 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'mine'));
|
||||
const miner = d2.avatars.find((a) => a.name === 'Тестер');
|
||||
console.log('mine: action=mine tool=' + miner.tool);
|
||||
if (miner.action !== 'mine' || miner.tool !== 'pick_rusty') throw new Error('bad mine state');
|
||||
|
||||
// 4. неверная версия протокола — close 4001
|
||||
// 4. ждём добытую руду в инвентаре
|
||||
await sleep(13_500);
|
||||
const inv1 = await inventory('Тестер');
|
||||
console.log('inventory: copper_ore=' + (inv1.items?.copper_ore ?? 0), 'mining xp=' + (inv1.skills?.mining?.xp ?? 0));
|
||||
if ((inv1.items?.copper_ore ?? 0) < 1 || (inv1.skills?.mining?.xp ?? 0) < 15) throw new Error('mining did not yield');
|
||||
|
||||
// 5. ковка медного слитка у наковальни
|
||||
await dev('/dev/give', { name: 'Тестер', item: 'copper_ore', qty: 2 });
|
||||
await dev('/dev/tp', { name: 'Тестер', x: 1830, y: 415 });
|
||||
await sleep(COOLDOWN);
|
||||
await dev('/dev/command', { name: 'Тестер', text: '!ковать медный слиток' });
|
||||
const d3 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'smith'));
|
||||
const smith = d3.avatars.find((a) => a.name === 'Тестер');
|
||||
console.log('smith: action=smith tool=' + smith.tool);
|
||||
if (smith.tool !== 'hammer') throw new Error('expected hammer');
|
||||
await sleep(9_500);
|
||||
const inv2 = await inventory('Тестер');
|
||||
console.log('inventory: copper_bar=' + (inv2.items?.copper_bar ?? 0), 'smithing xp=' + (inv2.skills?.smithing?.xp ?? 0));
|
||||
if ((inv2.items?.copper_bar ?? 0) !== 1) throw new Error('smelting did not yield');
|
||||
|
||||
// 6. недостижимый рецепт → blocked (нет железных слитков и низкий уровень ковки)
|
||||
await sleep(COOLDOWN);
|
||||
await dev('/dev/command', { name: 'Тестер', text: '!ковать топор' });
|
||||
const d4 = await waitForDelta((m) => m.events?.some((e) => e.k === 'blocked'), 5000);
|
||||
const reason = d4.events.find((e) => e.k === 'blocked').reason;
|
||||
console.log('blocked:', reason);
|
||||
if (!/уровень|хватает/.test(reason)) throw new Error('unexpected blocked reason');
|
||||
|
||||
// 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 })));
|
||||
|
||||
Reference in New Issue
Block a user