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) => {
+171 -35
View File
@@ -1,9 +1,14 @@
import {
COMMAND_COOLDOWN_MS,
FISH,
FISH_SPOTS,
FORGE_SPOT,
ITEM_LOG,
KITCHEN_SPOT,
ORES,
RECIPES,
REST,
REST_SPOT,
ROCK_SPOTS,
ROCK,
SKILLS,
@@ -73,14 +78,22 @@ interface SimAvatar {
lastCommandAt: number;
pendingNodeId: string | null;
pendingRecipeId: string | null;
pendingRest: boolean;
/** Бафф отдыха: стаки и до какого времени. */
restStacks: number;
restUntil: number;
/** Дробные прибавки добычи от баффа копятся до целого предмета. */
bonusAcc: number;
}
interface SimNode extends NodeState {}
type SimNode = NodeState;
const CHOP_ALIASES = new Set(['рубить', 'chop']);
const MINE_ALIASES = new Set(['копать', 'mine']);
const SMITH_ALIASES = new Set(['ковать', 'smith']);
const FISH_ALIASES = new Set(['рыбачить', 'fish']);
const CRAFT_ALIASES = new Set(['ковать', 'готовить', 'craft']);
const STOP_ALIASES = new Set(['стоп', 'stop']);
const REST_ALIASES = new Set(['отдых', 'rest']);
const SKILL_IDS: SkillId[] = SKILLS.map((s) => s.id);
@@ -89,13 +102,13 @@ function dist(ax: number, ay: number, bx: number, by: number): number {
}
/**
* Симуляционный слой M2: plain TS за узким интерфейсом (решение об ECS — в M3).
* Симуляционный слой M3: plain TS за узким интерфейсом (решение об ECS — в M4).
* Правила:
* - команды обрабатываются сразу при приходе, не ждут тика;
* - аватар продолжает действие, пока не придёт другая команда или !стоп
* (ковка — пока хватает материалов);
* (ковка — пока хватает материалов, отдых — пока есть рыба и есть место стакам);
* - движение декларативно (x→tx, speed, moveStart) — клиент дорисовывает сам;
* - инвентарь и навыки — мета-слой, вне ECS.
* - инвентарь, навыки и баффы — мета-слой, вне ECS.
*/
export class SimWorld {
readonly avatars = new Map<string, SimAvatar>();
@@ -119,6 +132,16 @@ export class SimWorld {
maxHp: ROCK.maxHp,
respawnAt: null,
})),
...FISH_SPOTS.map((f, i): SimNode => ({
id: `fish-${i + 1}`,
kind: 'fish',
variant: 'river',
x: f.x,
y: f.y,
hp: FISH.maxHp,
maxHp: FISH.maxHp,
respawnAt: null,
})),
];
private readonly dirtyAvatars = new Set<string>();
@@ -143,7 +166,9 @@ export class SimWorld {
const arg = parts.slice(1).join(' ');
if (CHOP_ALIASES.has(word)) this.doChop(cmd, now);
else if (MINE_ALIASES.has(word)) this.doMine(cmd, now);
else if (SMITH_ALIASES.has(word)) this.doSmith(cmd, arg, now);
else if (FISH_ALIASES.has(word)) this.doFish(cmd, now);
else if (CRAFT_ALIASES.has(word)) this.doCraft(cmd, arg, now);
else if (REST_ALIASES.has(word)) this.doRest(cmd, now);
else if (STOP_ALIASES.has(word)) this.doStop(cmd, now);
}
@@ -159,6 +184,12 @@ export class SimWorld {
}
for (const av of this.avatars.values()) {
if (av.restStacks > 0 && now >= av.restUntil) {
av.restStacks = 0;
av.bonusAcc = 0;
this.dirtyAvatars.add(av.id);
}
if (av.moving) {
const travelMs = (dist(av.x, av.y, av.tx, av.ty) / av.speed) * 1000;
if (now >= av.moveStart + travelMs) {
@@ -193,7 +224,7 @@ export class SimWorld {
if (av.pendingRecipeId) {
const recipe = RECIPES.find((r) => r.id === av.pendingRecipeId);
if (recipe && this.hasInputs(av, recipe)) {
this.beginSmith(av, recipe, now);
this.beginCraft(av, recipe, now);
return;
}
av.pendingRecipeId = null;
@@ -205,6 +236,10 @@ export class SimWorld {
reason: `материалы закончились: ${this.inputsText(recipe)}`,
});
}
if (av.pendingRest) {
av.pendingRest = false;
this.beginRest(av, now);
}
}
// ---- команды ----
@@ -235,22 +270,34 @@ export class SimWorld {
});
if (!rock) {
const minReq = Math.min(...ROCK_SPOTS.map((r) => ORES[r.ore].level));
this.pendingEvents.push(
this.blocked(av, `нужен ${minReq}-й уровень добычи руды`),
);
this.pendingEvents.push(this.blocked(av, `нужен ${minReq}-й уровень добычи руды`));
return;
}
this.walkTo(av, rock.x, rock.y + 26, now);
av.pendingNodeId = rock.id;
}
private doSmith(cmd: ChatCommand, arg: string, now: number): void {
private doFish(cmd: ChatCommand, now: number): void {
const av = this.ensureAvatar(cmd, now);
if (this.onCooldown(av, now)) return;
if (av.action === 'fish' && !av.moving) return;
const spot = this.nearestAvailable(av, (n) => n.kind === 'fish');
if (!spot) {
this.pendingEvents.push(this.blocked(av, 'рыба ещё не подошла к берегу'));
return;
}
this.walkTo(av, spot.x, spot.y + 45, now);
av.pendingNodeId = spot.id;
}
private doCraft(cmd: ChatCommand, arg: string, now: number): void {
const av = this.ensureAvatar(cmd, now);
if (this.onCooldown(av, now)) return;
if (!arg) {
this.pendingEvents.push(
this.blocked(av, 'что ковать? медный слиток · железный слиток · топор · кирка'),
this.blocked(av, 'что готовим? медный слиток · железо · топор · кирка · жареная рыба'),
);
return;
}
@@ -261,9 +308,10 @@ export class SimWorld {
);
return;
}
if (av.skills.smithing.level < recipe.level) {
const skill = av.skills[recipe.skill];
if (!skill || skill.level < recipe.level) {
this.pendingEvents.push(
this.blocked(av, `«${recipe.title}» нужен ${recipe.level}-й уровень кузнечного дела`),
this.blocked(av, `«${recipe.title}» нужен ${recipe.level}-й уровень навыка`),
);
return;
}
@@ -272,10 +320,27 @@ export class SimWorld {
return;
}
this.walkTo(av, FORGE_SPOT.x, FORGE_SPOT.y, now);
const spot = recipe.place === 'kitchen' ? KITCHEN_SPOT : FORGE_SPOT;
this.walkTo(av, spot.x, spot.y, now);
av.pendingRecipeId = recipe.id;
}
private doRest(cmd: ChatCommand, now: number): void {
const av = this.ensureAvatar(cmd, now);
if (this.onCooldown(av, now)) return;
if (av.restStacks >= REST.maxStacks && now < av.restUntil) {
this.pendingEvents.push(this.blocked(av, 'отдохнул с запасом — бафф ещё действует'));
return;
}
if ((av.items.get('cooked_fish') ?? 0) < 1) {
this.pendingEvents.push(this.blocked(av, 'нужна жареная рыба: !готовить рыбу'));
return;
}
this.walkTo(av, REST_SPOT.x, REST_SPOT.y, now);
av.pendingRest = true;
}
private doStop(cmd: ChatCommand, now: number): void {
const av = this.ensureAvatar(cmd, now);
if (this.onCooldown(av, now)) return;
@@ -288,6 +353,7 @@ export class SimWorld {
}
av.pendingNodeId = null;
av.pendingRecipeId = null;
av.pendingRest = false;
if (av.action !== 'idle') {
av.action = 'idle';
av.nodeId = null;
@@ -305,23 +371,27 @@ export class SimWorld {
const tool = this.bestTool(av, 'axe');
av.action = 'chop';
av.actionDur = Math.round(TREE.chopDurMs / tool.multiplier);
} else {
} else if (node.kind === 'rock') {
const tool = this.bestTool(av, 'pick');
av.action = 'mine';
const ore = ORES[(node.variant ?? 'copper') as OreKind];
av.actionDur = Math.round(ore.cycleMs / tool.multiplier);
} else {
av.action = 'fish';
av.actionDur = FISH.cycleMs;
}
av.nodeId = node.id;
av.actionStart = now;
av.pendingNodeId = null;
av.pendingRecipeId = null;
av.pendingRest = false;
this.dirtyAvatars.add(av.id);
this.statsDirty = true;
}
private beginSmith(av: SimAvatar, recipe: RecipeDef, now: number): void {
av.action = 'smith';
// для ковки nodeId хранит id рецепта — узел-нода у кузницы одна
private beginCraft(av: SimAvatar, recipe: RecipeDef, now: number): void {
av.action = recipe.place === 'kitchen' ? 'cook' : 'smith';
// nodeId хранит id рецепта для действий у пропсов
av.nodeId = recipe.id;
av.actionDur = recipe.cycleMs;
av.actionStart = now;
@@ -330,6 +400,15 @@ export class SimWorld {
this.statsDirty = true;
}
private beginRest(av: SimAvatar, now: number): void {
av.action = 'rest';
av.nodeId = null;
av.actionDur = REST.cycleMs;
av.actionStart = now;
this.dirtyAvatars.add(av.id);
this.statsDirty = true;
}
private completeCycle(av: SimAvatar, now: number, events: GameEvent[]): void {
if (av.action === 'chop') {
const tree = av.nodeId ? this.nodeById(av.nodeId) : undefined;
@@ -343,10 +422,13 @@ export class SimWorld {
tree.respawnAt = now + TREE.respawnMs;
events.push({ k: 'fell', userId: av.id, name: av.name, color: av.color, nodeId: tree.id });
}
this.addItem(av, ITEM_LOG, 1);
events.push({ k: 'item', userId: av.id, name: av.name, color: av.color, item: ITEM_LOG, qty: 1 });
this.addXp(av, 'woodcutting', TREE.xp, events);
this.finishOrContinue(av, now, Math.round(TREE.chopDurMs / this.bestTool(av, 'axe').multiplier), () => tree.respawnAt === null);
this.gatherYield(av, ITEM_LOG, TREE.xp, 'woodcutting', events);
this.finishOrContinue(
av,
now,
Math.round(TREE.chopDurMs / this.bestTool(av, 'axe').multiplier),
() => tree.respawnAt === null,
);
} else if (av.action === 'mine') {
const rock = av.nodeId ? this.nodeById(av.nodeId) : undefined;
const ore = rock?.variant ? ORES[rock.variant as OreKind] : undefined;
@@ -360,19 +442,29 @@ export class SimWorld {
rock.respawnAt = now + ore.respawnMs;
events.push({ k: 'depleted', userId: av.id, name: av.name, color: av.color, nodeId: rock.id });
}
this.addItem(av, ore.item, 1);
events.push({ k: 'item', userId: av.id, name: av.name, color: av.color, item: ore.item, qty: 1 });
this.addXp(av, 'mining', ore.xp, events);
this.gatherYield(av, ore.item, ore.xp, 'mining', events);
this.finishOrContinue(
av,
now,
Math.round(ore.cycleMs / this.bestTool(av, 'pick').multiplier),
() => rock.respawnAt === null,
);
} else if (av.action === 'smith') {
const recipe = av.nodeId
? RECIPES.find((r) => r.id === av.nodeId)
: undefined;
} else if (av.action === 'fish') {
const spot = av.nodeId ? this.nodeById(av.nodeId) : undefined;
if (!spot || spot.respawnAt !== null) {
this.goIdle(av);
return;
}
spot.hp -= 1;
this.dirtyNodes.add(spot.id);
if (spot.hp <= 0) {
spot.respawnAt = now + FISH.respawnMs;
events.push({ k: 'depleted', userId: av.id, name: av.name, color: av.color, nodeId: spot.id });
}
this.gatherYield(av, FISH.item, FISH.xp, 'fishing', events);
this.finishOrContinue(av, now, FISH.cycleMs, () => spot.respawnAt === null);
} else if (av.action === 'smith' || av.action === 'cook') {
const recipe = av.nodeId ? RECIPES.find((r) => r.id === av.nodeId) : undefined;
if (!recipe) {
this.goIdle(av);
return;
@@ -387,15 +479,48 @@ export class SimWorld {
item: recipe.output.item,
qty: recipe.output.qty,
});
this.addXp(av, 'smithing', recipe.xp, events);
this.addXp(av, recipe.skill, recipe.xp, events);
this.finishOrContinue(av, now, recipe.cycleMs, () => this.hasInputs(av, recipe), () => {
events.push(
this.blocked(av, `материалы кончились: ${this.inputsText(recipe)}`),
);
events.push(this.blocked(av, `материалы кончились: ${this.inputsText(recipe)}`));
});
} else if (av.action === 'rest') {
if ((av.items.get('cooked_fish') ?? 0) < 1) {
this.goIdle(av);
return;
}
this.takeItem(av, 'cooked_fish', 1);
av.restStacks = Math.min(REST.maxStacks, av.restStacks + 1);
av.restUntil = now + REST.buffMs;
events.push({ k: 'rest', userId: av.id, name: av.name, color: av.color, stacks: av.restStacks });
this.dirtyAvatars.add(av.id);
if (av.restStacks >= REST.maxStacks) {
this.pendingEvents.push(this.blocked(av, 'отдохнул с запасом — бафф на максимум'));
this.finishOrContinue(av, now, REST.cycleMs, () => false);
} else {
this.finishOrContinue(av, now, REST.cycleMs, () => (av.items.get('cooked_fish') ?? 0) >= 1);
}
}
}
/** Добыча за цикл: предмет + XP, бафф отдыха добавляет дробные прибавки. */
private gatherYield(
av: SimAvatar,
item: string,
xp: number,
skill: SkillId,
events: GameEvent[],
): void {
let qty = 1;
av.bonusAcc += av.restStacks * REST.bonusPerStack;
if (av.bonusAcc >= 1) {
qty += Math.floor(av.bonusAcc);
av.bonusAcc %= 1;
}
this.addItem(av, item, qty);
events.push({ k: 'item', userId: av.id, name: av.name, color: av.color, item, qty });
this.addXp(av, skill, xp, events);
}
/** Продолжить цикл того же действия либо уйти в idle (по правилу «работает, пока не скажут»). */
private finishOrContinue(
av: SimAvatar,
@@ -520,6 +645,8 @@ export class SimWorld {
if (av.action === 'chop') return this.bestTool(av, 'axe').item;
if (av.action === 'mine') return this.bestTool(av, 'pick').item;
if (av.action === 'smith') return 'hammer';
if (av.action === 'cook') return 'spoon';
if (av.action === 'fish') return 'rod';
return 'none';
}
@@ -583,7 +710,11 @@ export class SimWorld {
return true;
}
lookupViewer(name: string): { name: string; color: string; skills: Record<string, SkillSim>; items: Record<string, number> } | undefined {
lookupViewer(
name: string,
):
| { name: string; color: string; skills: Record<string, SkillSim>; items: Record<string, number> }
| undefined {
const lower = name.toLowerCase();
const av = [...this.avatars.values()].find((a) => a.name.toLowerCase() === lower);
if (av) {
@@ -623,6 +754,7 @@ export class SimWorld {
actionStart: av.actionStart,
actionDur: av.actionDur,
skills,
restStacks: av.restStacks,
};
}
@@ -716,6 +848,10 @@ export class SimWorld {
lastCommandAt: 0,
pendingNodeId: null,
pendingRecipeId: null,
pendingRest: false,
restStacks: 0,
restUntil: 0,
bonusAcc: 0,
};
this.avatars.set(av.id, av);
this.dirtyAvatars.add(av.id);