feat: fishing and cooking

This commit is contained in:
2026-09-05 17:46:15 +05:00
parent 6273f2849c
commit 32527ec2ed
10 changed files with 518 additions and 113 deletions
+67 -39
View File
@@ -1,4 +1,4 @@
/* Дымовой тест M2: сервер должен быть запущен с DEV_HTTP=1, SQLITE_PATH=:memory:.
/* Дымовой тест M3: сервер должен быть запущен с DEV_HTTP=1, SQLITE_PATH=:memory:.
`pnpm --filter @idle/server smoke` */
import WebSocket from 'ws';
@@ -20,7 +20,7 @@ const COOLDOWN = 1400;
function connect(onMsg) {
const ws = new WebSocket(WS);
ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 3 })));
ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 4 })));
ws.on('message', (d) => onMsg(JSON.parse(String(d))));
return ws;
}
@@ -39,6 +39,11 @@ async function inventory(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) => {
@@ -70,54 +75,77 @@ await new Promise((resolve, reject) => {
setTimeout(() => reject(new Error('timeout waiting welcome+snapshot')), 5000);
});
const [welcome, snap] = first;
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');
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: '!рубить' });
const d1 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.moving));
console.log('spawn: движется к дереву ok');
await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.moving));
console.log('spawn ok');
// 3. телепорт к медной жиле и !копать
await dev('/dev/tp', { name: 'Тестер', x: 1140, y: 346 });
// 3. рыбалка
await dev('/dev/tp', { name: 'Тестер', x: 2250, y: 375 });
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. ждём добытую руду в инвентаре
await sleep(13_500);
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: 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');
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');
// 5. ковка медного слитка у наковальни
await dev('/dev/give', { name: 'Тестер', item: 'copper_ore', qty: 2 });
await dev('/dev/tp', { name: 'Тестер', x: 1830, y: 415 });
// 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 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);
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: 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');
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');
// 6. недостижимый рецепт → blocked (нет железных слитков и низкий уровень ковки)
// 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: 'Тестер', 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');
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) => {