feat: add ore and blacksmith

This commit is contained in:
2026-09-05 17:24:44 +05:00
parent f6c338e1b0
commit 6273f2849c
15 changed files with 1333 additions and 334 deletions
+93 -30
View File
@@ -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 })));
+66 -5
View File
@@ -6,9 +6,12 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
import sirv from 'sirv';
import {
PROTOCOL_VERSION,
PROPS,
SKILL_TITLES,
WORLD,
ZONES,
colorForName,
levelForTotalXp,
type DeltaMsg,
type GameEvent,
type SnapshotMsg,
@@ -80,7 +83,13 @@ const world: WorldSource = {
v: PROTOCOL_VERSION,
tickMs: cfg.tickMs,
serverNow: Date.now(),
world: { w: WORLD.w, h: WORLD.h, spawn: { ...WORLD.spawn }, zones: ZONES },
world: {
w: WORLD.w,
h: WORLD.h,
spawn: { ...WORLD.spawn },
zones: ZONES,
props: PROPS.map((p) => ({ ...p })),
},
};
},
snapshot(): SnapshotMsg {
@@ -90,7 +99,7 @@ const world: WorldSource = {
serverNow: Date.now(),
camera: camera.state,
avatars: [...sim.avatars.values()].map((av) => sim.serializeAvatar(av)),
nodes: sim.serializeTrees(),
nodes: sim.serializeNodes(),
stats: sim.stats(),
};
},
@@ -102,7 +111,7 @@ const gateway = new WsGateway(world);
function buildDelta(events: GameEvent[], camChanged: boolean): DeltaMsg | null {
const avatars = sim.takeDirtyAvatars();
const nodes = sim.takeDirtyTrees();
const nodes = sim.takeDirtyNodes();
const stats = sim.takeStatsIfDirty();
if (avatars.length === 0 && nodes.length === 0 && !stats && events.length === 0 && !camChanged) {
return null;
@@ -119,13 +128,13 @@ function buildDelta(events: GameEvent[], camChanged: boolean): DeltaMsg | null {
setInterval(() => {
const now = Date.now();
const events = sim.tick(now);
const camChanged = camera.tick(now);
const camChanged = camera.tick(now, sim.zoneActivity());
for (const rec of sim.takeDirtyViewers()) db.saveViewer(cfg.channelId, rec);
for (const ev of events) {
if (ev.k === 'levelup' && cfg.chatAnnounceLevelUp) {
announce(`🌲 ${ev.name} вырос(ла) до уровня ${ev.level} в рубке леса!`);
announce(` ${ev.name} вырос(ла) до ${ev.level}-го уровня: ${SKILL_TITLES[ev.skill] ?? ev.skill}!`);
}
}
@@ -201,6 +210,58 @@ function handleHttp(req: IncomingMessage, res: ServerResponse): void {
return;
}
if (cfg.devHttp && p === '/dev/give' && req.method === 'POST') {
readBody(req, (body) => {
try {
const parsed = JSON.parse(body || '{}') as { name?: string; item?: string; qty?: number };
const name = (parsed.name ?? '').trim();
const item = (parsed.item ?? '').trim();
const qty = Math.max(1, Math.min(999, Number(parsed.qty ?? 1)));
if (!name || !item) {
json(res, 400, { error: 'нужны name и item' });
return;
}
const ok = sim.giveItem(`dev:${name}`, item, qty);
json(res, ok ? 200 : 404, ok ? { ok: true } : { error: 'зритель не в мире' });
} catch (e) {
json(res, 400, { error: String(e) });
}
});
return;
}
if (cfg.devHttp && p === '/dev/tp' && req.method === 'POST') {
readBody(req, (body) => {
try {
const parsed = JSON.parse(body || '{}') as { name?: string; x?: number; y?: number };
const name = (parsed.name ?? '').trim();
if (!name || !Number.isFinite(parsed.x) || !Number.isFinite(parsed.y)) {
json(res, 400, { error: 'нужны name, x, y' });
return;
}
const ok = sim.teleport(`dev:${name}`, Number(parsed.x), Number(parsed.y));
json(res, ok ? 200 : 404, ok ? { ok: true } : { error: 'зритель не в мире' });
} catch (e) {
json(res, 400, { error: String(e) });
}
});
return;
}
if (p === '/api/inventory' && req.method === 'GET') {
const name = (url.searchParams.get('name') ?? '').trim();
const found = name ? sim.lookupViewer(name) : undefined;
if (!found) {
json(res, 200, { found: false });
return;
}
const skills = Object.fromEntries(
Object.entries(found.skills).map(([id, s]) => [id, { xp: s.xp, level: s.level ?? levelForTotalXp(s.xp) }]),
);
json(res, 200, { found: true, name: found.name, color: found.color, skills, items: found.items });
return;
}
for (const m of staticMounts) {
if (!m.middleware) continue;
const base = m.prefix.slice(0, -1);
+34 -10
View File
@@ -4,26 +4,48 @@ const CAM_W = 900;
const CAM_H = 560;
const DRIFT_X = 60;
const DRIFT_Y = 30;
const DWELL_MS = 45_000;
/**
* Камера стрима авторитарна на сервере — все клиенты видят одно и то же.
* M1: одна зона, камера медленно дрейфует вокруг её центра.
* M2+: автотур по зонам с активностью (стоянка ~45 сек, плавные переезды).
* Автотур: стоянка на зоне ~45 сек; зона с активностью приоритетна;
* без активности — круговой обход + медленный дрейф.
* Плавность переезда даёт клиентский lerp по прямоугольнику камеры.
*/
export class CameraController {
state: CameraState;
private readonly seed = Math.random() * 100;
private zoneIdx = 0;
private dwellUntil = 0;
constructor(private readonly zones: ZoneDef[] = ZONES) {
const first = this.zones[0];
this.state = first
? this.forZone(first, 0)
: { x: 0, y: 0, w: CAM_W, h: CAM_H };
this.state = first ? this.forZone(first, 0) : { x: 0, y: 0, w: CAM_W, h: CAM_H };
}
/** Возвращает true, если состояние изменилось (его стоит разослать). */
tick(nowMs: number): boolean {
const zone = this.zones[0];
/** activity — количество аватаров в каждой зоне (порядок ZONES). */
tick(nowMs: number, activity: number[]): boolean {
if (this.zones.length > 1) {
const curActive = (activity[this.zoneIdx] ?? 0) > 0;
if (nowMs >= this.dwellUntil || !curActive) {
let best = -1;
let bestA = 0;
for (let i = 0; i < this.zones.length; i++) {
const a = activity[i] ?? 0;
if (a > bestA) {
bestA = a;
best = i;
}
}
const next = best >= 0 ? best : (this.zoneIdx + 1) % this.zones.length;
if (next !== this.zoneIdx || nowMs >= this.dwellUntil) {
this.zoneIdx = next;
this.dwellUntil = nowMs + DWELL_MS;
}
}
}
const zone = this.zones[this.zoneIdx];
if (!zone) return false;
const next = this.forZone(zone, nowMs);
if (
@@ -39,8 +61,10 @@ export class CameraController {
private forZone(z: ZoneDef, nowMs: number): CameraState {
const w = Math.min(CAM_W, z.w);
const h = Math.min(CAM_H, z.h);
const cx = z.x + z.w / 2 + Math.sin(nowMs / 9000 + this.seed) * DRIFT_X;
const cy = z.y + z.h / 2 + Math.cos(nowMs / 11000 + this.seed) * DRIFT_Y;
const rangeX = Math.max(0, z.w - w) / 2;
const rangeY = Math.max(0, z.h - h) / 2;
const cx = z.x + z.w / 2 + Math.sin(nowMs / 9000 + this.seed) * Math.min(DRIFT_X, rangeX);
const cy = z.y + z.h / 2 + Math.cos(nowMs / 11000 + this.seed) * Math.min(DRIFT_Y, rangeY);
return { x: Math.round(cx - w / 2), y: Math.round(cy - h / 2), w, h };
}
}
+106 -26
View File
@@ -1,7 +1,21 @@
import { mkdirSync } from 'node:fs';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import type { ViewerRecord } from '../sim/world';
/**
* Запись зрителя: инвентарь и XP навыков. Хранится вне ECS (мета-слой).
*/
export interface ViewerRecord {
userId: string;
name: string;
color: string;
/** skill id -> суммарный xp */
skills: Record<string, number>;
/** item id -> количество */
items: Record<string, number>;
}
const SCHEMA_VERSION = 2;
/**
* Тонкий слой persist на встроенном node:sqlite — без нативных сборок и
@@ -17,37 +31,103 @@ export class Db {
}
this.db = new DatabaseSync(dbPath);
this.db.exec('PRAGMA journal_mode = WAL');
this.db.exec(`
CREATE TABLE IF NOT EXISTS viewers (
channel_id TEXT NOT NULL,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT NOT NULL,
xp INTEGER NOT NULL DEFAULT 0,
logs INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (channel_id, user_id)
)
`);
const row = this.db.prepare('PRAGMA user_version').get() as { user_version?: number };
const version = row?.user_version ?? 0;
if (version < SCHEMA_VERSION) {
// дев-стадия: старую схему не мигрируем, а пересоздаём
this.db.exec(`
DROP TABLE IF EXISTS viewer_skills;
DROP TABLE IF EXISTS viewer_items;
DROP TABLE IF EXISTS viewers;
CREATE TABLE viewers (
channel_id TEXT NOT NULL,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (channel_id, user_id)
);
CREATE TABLE viewer_skills (
channel_id TEXT NOT NULL,
user_id TEXT NOT NULL,
skill TEXT NOT NULL,
xp INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (channel_id, user_id, skill)
);
CREATE TABLE viewer_items (
channel_id TEXT NOT NULL,
user_id TEXT NOT NULL,
item TEXT NOT NULL,
qty INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (channel_id, user_id, item)
);
PRAGMA user_version = ${SCHEMA_VERSION};
`);
}
}
loadViewers(channelId: string): ViewerRecord[] {
const rows = this.db
.prepare('SELECT user_id, name, color, xp, logs FROM viewers WHERE channel_id = ?')
.all(channelId) as Array<{ user_id: string; name: string; color: string; xp: number; logs: number }>;
return rows.map((r) => ({ userId: r.user_id, name: r.name, color: r.color, xp: r.xp, logs: r.logs }));
const viewers = this.db
.prepare('SELECT user_id, name, color FROM viewers WHERE channel_id = ?')
.all(channelId) as Array<{ user_id: string; name: string; color: string }>;
const skills = this.db
.prepare('SELECT user_id, skill, xp FROM viewer_skills WHERE channel_id = ?')
.all(channelId) as Array<{ user_id: string; skill: string; xp: number }>;
const items = this.db
.prepare('SELECT user_id, item, qty FROM viewer_items WHERE channel_id = ?')
.all(channelId) as Array<{ user_id: string; item: string; qty: number }>;
const byId = new Map<string, ViewerRecord>();
for (const v of viewers) {
byId.set(v.user_id, { userId: v.user_id, name: v.name, color: v.color, skills: {}, items: {} });
}
for (const s of skills) {
const rec = byId.get(s.user_id);
if (rec) rec.skills[s.skill] = s.xp;
}
for (const it of items) {
const rec = byId.get(it.user_id);
if (rec) rec.items[it.item] = it.qty;
}
return [...byId.values()];
}
saveViewer(channelId: string, v: ViewerRecord): void {
this.db
.prepare(
`INSERT INTO viewers (channel_id, user_id, name, color, xp, logs, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(channel_id, user_id) DO UPDATE SET
name = excluded.name, color = excluded.color, xp = excluded.xp,
logs = excluded.logs, updated_at = excluded.updated_at`,
)
.run(channelId, v.userId, v.name, v.color, v.xp, v.logs, Date.now());
this.db.exec('BEGIN');
try {
this.db
.prepare(
`INSERT INTO viewers (channel_id, user_id, name, color, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(channel_id, user_id) DO UPDATE SET
name = excluded.name, color = excluded.color, updated_at = excluded.updated_at`,
)
.run(channelId, v.userId, v.name, v.color, Date.now());
this.db
.prepare('DELETE FROM viewer_skills WHERE channel_id = ? AND user_id = ?')
.run(channelId, v.userId);
this.db
.prepare('DELETE FROM viewer_items WHERE channel_id = ? AND user_id = ?')
.run(channelId, v.userId);
const skillStmt = this.db.prepare(
'INSERT INTO viewer_skills (channel_id, user_id, skill, xp) VALUES (?, ?, ?, ?)',
);
for (const [skill, xp] of Object.entries(v.skills)) {
skillStmt.run(channelId, v.userId, skill, xp);
}
const itemStmt = this.db.prepare(
'INSERT INTO viewer_items (channel_id, user_id, item, qty) VALUES (?, ?, ?, ?)',
);
for (const [item, qty] of Object.entries(v.items)) {
if (qty > 0) itemStmt.run(channelId, v.userId, item, qty);
}
this.db.exec('COMMIT');
} catch (e) {
this.db.exec('ROLLBACK');
throw e;
}
}
close(): void {
+543 -179
View File
@@ -1,17 +1,31 @@
import {
COMMAND_COOLDOWN_MS,
FORGE_SPOT,
ITEM_LOG,
ORES,
RECIPES,
ROCK_SPOTS,
ROCK,
SKILLS,
START_ITEMS,
TOOLS,
TREE,
TREE_SPOTS,
WALK_SPEED,
WORLD,
XP_PER_CHOP,
ZONES,
colorForName,
findRecipe,
itemTitle,
levelForTotalXp,
type AvatarAction,
type AvatarState,
type GameEvent,
type NodeState,
type OreKind,
type RecipeDef,
type SkillId,
type ToolDef,
type ViewerStat,
} from '@idle/shared';
@@ -28,50 +42,90 @@ export interface ViewerRecord {
userId: string;
name: string;
color: string;
xp: number;
logs: number;
/** skill id -> суммарный xp */
skills: Record<string, number>;
/** item id -> количество */
items: Record<string, number>;
}
interface SimAvatar extends AvatarState {
interface SkillSim {
xp: number;
level: number;
}
interface SimAvatar {
id: string;
name: string;
color: string;
x: number;
y: number;
tx: number;
ty: number;
speed: number;
moveStart: number;
moving: boolean;
action: AvatarAction;
nodeId: string | null;
actionStart: number | null;
actionDur: number | null;
skills: Record<SkillId, SkillSim>;
items: Map<string, number>;
lastCommandAt: number;
pendingNodeId: string | null;
logs: number;
pendingRecipeId: string | null;
}
type SimTree = NodeState;
interface SimNode extends NodeState {}
const CHOP_ALIASES = new Set(['рубить', 'chop']);
const MINE_ALIASES = new Set(['копать', 'mine']);
const SMITH_ALIASES = new Set(['ковать', 'smith']);
const STOP_ALIASES = new Set(['стоп', 'stop']);
const SKILL_IDS: SkillId[] = SKILLS.map((s) => s.id);
function dist(ax: number, ay: number, bx: number, by: number): number {
return Math.hypot(bx - ax, by - ay);
}
/**
* Симуляционный слой M1: plain TS за узким интерфейсом (десятки сущностей).
* Решение об ECS (bitecs 0.4 / koota) отложено до M2 — см. PLAN.md.
*
* Симуляционный слой M2: plain TS за узким интерфейсом (решение об ECS — в M3).
* Правила:
* - команды обрабатываются сразу при приходе, не ждут тика;
* - аватар продолжает действие, пока не придёт другая команда или !стоп;
* - движение декларативно (x→tx, speed, moveStart) — клиент дорисовывает сам.
* - аватар продолжает действие, пока не придёт другая команда или !стоп
* (ковка — пока хватает материалов);
* - движение декларативно (x→tx, speed, moveStart) — клиент дорисовывает сам;
* - инвентарь и навыки — мета-слой, вне ECS.
*/
export class SimWorld {
readonly avatars = new Map<string, SimAvatar>();
readonly trees: SimTree[] = TREE_SPOTS.map((t, i) => ({
id: `tree-${i + 1}`,
kind: 'tree',
x: t.x,
y: t.y,
hp: TREE.maxHp,
maxHp: TREE.maxHp,
respawnAt: null,
}));
readonly nodes: SimNode[] = [
...TREE_SPOTS.map((t, i): SimNode => ({
id: `tree-${i + 1}`,
kind: 'tree',
x: t.x,
y: t.y,
hp: TREE.maxHp,
maxHp: TREE.maxHp,
respawnAt: null,
})),
...ROCK_SPOTS.map((r, i): SimNode => ({
id: `rock-${i + 1}`,
kind: 'rock',
variant: r.ore,
x: r.x,
y: r.y,
hp: ROCK.maxHp,
maxHp: ROCK.maxHp,
respawnAt: null,
})),
];
private readonly dirtyAvatars = new Set<string>();
private readonly dirtyTrees = new Set<string>();
private readonly dirtyNodes = new Set<string>();
private readonly dirtyViewers = new Map<string, ViewerRecord>();
private readonly preloaded = new Map<string, ViewerRecord>();
private readonly pendingEvents: GameEvent[] = [];
private statsDirty = true;
constructor(
@@ -84,19 +138,23 @@ export class SimWorld {
handleCommand(cmd: ChatCommand, now = Date.now()): void {
if (cmd.channelId !== this.channelId) return;
if (!cmd.text.startsWith('!')) return;
const word = (cmd.text.slice(1).split(/\s+/)[0] ?? '').toLowerCase();
const parts = cmd.text.slice(1).trim().split(/\s+/);
const word = (parts[0] ?? '').toLowerCase();
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 (STOP_ALIASES.has(word)) this.doStop(cmd, now);
}
tick(now: number): GameEvent[] {
const events: GameEvent[] = [];
const events = this.pendingEvents.splice(0);
for (const tree of this.trees) {
if (tree.respawnAt !== null && now >= tree.respawnAt) {
tree.respawnAt = null;
tree.hp = tree.maxHp;
this.dirtyTrees.add(tree.id);
for (const node of this.nodes) {
if (node.respawnAt !== null && now >= node.respawnAt) {
node.respawnAt = null;
node.hp = node.maxHp;
this.dirtyNodes.add(node.id);
}
}
@@ -108,28 +166,14 @@ export class SimWorld {
av.y = av.ty;
av.moving = false;
this.dirtyAvatars.add(av.id);
const tree = av.pendingNodeId ? this.treeById(av.pendingNodeId) : undefined;
if (tree && tree.respawnAt === null) {
this.beginChop(av, tree, now);
} else {
av.pendingNodeId = null;
}
this.onArrival(av, now, events);
}
}
if (av.action === 'chop' && av.actionStart !== null) {
const dur = av.actionDur ?? TREE.chopDurMs;
if (now >= av.actionStart + dur) {
const tree = av.nodeId ? this.treeById(av.nodeId) : undefined;
if (!tree || tree.respawnAt !== null) {
av.action = 'idle';
av.nodeId = null;
av.actionStart = null;
this.dirtyAvatars.add(av.id);
this.statsDirty = true;
} else {
this.completeChopCycle(av, tree, now, events);
}
if (av.action !== 'idle' && av.actionStart !== null) {
const dur = av.actionDur ?? 0;
if (dur > 0 && now >= av.actionStart + dur) {
this.completeCycle(av, now, events);
}
}
}
@@ -137,25 +181,104 @@ export class SimWorld {
return events;
}
private onArrival(av: SimAvatar, now: number, events: GameEvent[]): void {
if (av.pendingNodeId) {
const node = this.nodeById(av.pendingNodeId);
if (node && node.respawnAt === null) {
this.beginGather(av, node, now);
return;
}
av.pendingNodeId = null;
}
if (av.pendingRecipeId) {
const recipe = RECIPES.find((r) => r.id === av.pendingRecipeId);
if (recipe && this.hasInputs(av, recipe)) {
this.beginSmith(av, recipe, now);
return;
}
av.pendingRecipeId = null;
events.push({
k: 'blocked',
userId: av.id,
name: av.name,
color: av.color,
reason: `материалы закончились: ${this.inputsText(recipe)}`,
});
}
}
// ---- команды ----
private doChop(cmd: ChatCommand, now: number): void {
const av = this.ensureAvatar(cmd, now);
if (now - av.lastCommandAt < COMMAND_COOLDOWN_MS) return;
av.lastCommandAt = now;
if (this.onCooldown(av, now)) return;
if (av.action === 'chop' && !av.moving) return; // уже рубит
const tree = this.nearestAvailableTree(av);
if (!tree) return; // всё вырублено и не отросло — просто ждём
const tree = this.nearestAvailable(av, (n) => n.kind === 'tree');
if (!tree) {
this.pendingEvents.push(this.blocked(av, 'все деревья ещё не отросли'));
return;
}
this.walkTo(av, tree.x, tree.y + 26, now);
av.pendingNodeId = tree.id;
}
private doMine(cmd: ChatCommand, now: number): void {
const av = this.ensureAvatar(cmd, now);
if (this.onCooldown(av, now)) return;
if (av.action === 'mine' && !av.moving) return;
const miningLevel = av.skills.mining.level;
const rock = this.nearestAvailable(av, (n) => {
if (n.kind !== 'rock' || !n.variant) return false;
return (ORES[n.variant as OreKind]?.level ?? 99) <= miningLevel;
});
if (!rock) {
const minReq = Math.min(...ROCK_SPOTS.map((r) => ORES[r.ore].level));
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 {
const av = this.ensureAvatar(cmd, now);
if (this.onCooldown(av, now)) return;
if (!arg) {
this.pendingEvents.push(
this.blocked(av, 'что ковать? медный слиток · железный слиток · топор · кирка'),
);
return;
}
const recipe = findRecipe(arg);
if (!recipe) {
this.pendingEvents.push(
this.blocked(av, `не знаю рецепт «${arg}». Можно: ${RECIPES.map((r) => r.title).join(', ')}`),
);
return;
}
if (av.skills.smithing.level < recipe.level) {
this.pendingEvents.push(
this.blocked(av, `«${recipe.title}» нужен ${recipe.level}-й уровень кузнечного дела`),
);
return;
}
if (!this.hasInputs(av, recipe)) {
this.pendingEvents.push(this.blocked(av, `не хватает: ${this.inputsText(recipe)}`));
return;
}
this.walkTo(av, FORGE_SPOT.x, FORGE_SPOT.y, now);
av.pendingRecipeId = recipe.id;
}
private doStop(cmd: ChatCommand, now: number): void {
const av = this.ensureAvatar(cmd, now);
if (now - av.lastCommandAt < COMMAND_COOLDOWN_MS) return;
av.lastCommandAt = now;
if (this.onCooldown(av, now)) return;
if (av.moving) {
const p = this.currentPos(av, now);
@@ -164,6 +287,7 @@ export class SimWorld {
av.moving = false;
}
av.pendingNodeId = null;
av.pendingRecipeId = null;
if (av.action !== 'idle') {
av.action = 'idle';
av.nodeId = null;
@@ -174,42 +298,239 @@ export class SimWorld {
this.dirtyAvatars.add(av.id);
}
private ensureAvatar(cmd: ChatCommand, now: number): SimAvatar {
let av = this.avatars.get(cmd.userId);
if (!av) {
const rec = this.preloaded.get(cmd.userId);
av = {
id: cmd.userId,
name: cmd.name,
color: cmd.color || colorForName(cmd.name),
x: WORLD.spawn.x + (Math.random() * 60 - 30),
y: WORLD.spawn.y + (Math.random() * 40 - 20),
tx: WORLD.spawn.x,
ty: WORLD.spawn.y,
speed: WALK_SPEED,
moveStart: 0,
moving: false,
action: 'idle',
nodeId: null,
actionStart: null,
actionDur: null,
level: rec ? levelForTotalXp(rec.xp) : 1,
xp: rec?.xp ?? 0,
lastCommandAt: 0,
pendingNodeId: null,
logs: rec?.logs ?? 0,
};
this.avatars.set(av.id, av);
this.dirtyAvatars.add(av.id);
this.markViewerDirty(av);
this.statsDirty = true;
} else if (av.name !== cmd.name || av.color !== cmd.color) {
av.name = cmd.name;
av.color = cmd.color || av.color;
this.dirtyAvatars.add(av.id);
this.markViewerDirty(av);
// ---- действия ----
private beginGather(av: SimAvatar, node: SimNode, now: number): void {
if (node.kind === 'tree') {
const tool = this.bestTool(av, 'axe');
av.action = 'chop';
av.actionDur = Math.round(TREE.chopDurMs / tool.multiplier);
} else {
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);
}
return av;
av.nodeId = node.id;
av.actionStart = now;
av.pendingNodeId = null;
av.pendingRecipeId = null;
this.dirtyAvatars.add(av.id);
this.statsDirty = true;
}
private beginSmith(av: SimAvatar, recipe: RecipeDef, now: number): void {
av.action = 'smith';
// для ковки nodeId хранит id рецепта — узел-нода у кузницы одна
av.nodeId = recipe.id;
av.actionDur = recipe.cycleMs;
av.actionStart = now;
av.pendingRecipeId = null;
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;
if (!tree || tree.respawnAt !== null) {
this.goIdle(av);
return;
}
tree.hp -= 1;
this.dirtyNodes.add(tree.id);
if (tree.hp <= 0) {
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);
} else if (av.action === 'mine') {
const rock = av.nodeId ? this.nodeById(av.nodeId) : undefined;
const ore = rock?.variant ? ORES[rock.variant as OreKind] : undefined;
if (!rock || !ore || rock.respawnAt !== null) {
this.goIdle(av);
return;
}
rock.hp -= 1;
this.dirtyNodes.add(rock.id);
if (rock.hp <= 0) {
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.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;
if (!recipe) {
this.goIdle(av);
return;
}
for (const input of recipe.inputs) this.takeItem(av, input.item, input.qty);
this.addItem(av, recipe.output.item, recipe.output.qty);
events.push({
k: 'item',
userId: av.id,
name: av.name,
color: av.color,
item: recipe.output.item,
qty: recipe.output.qty,
});
this.addXp(av, 'smithing', recipe.xp, events);
this.finishOrContinue(av, now, recipe.cycleMs, () => this.hasInputs(av, recipe), () => {
events.push(
this.blocked(av, `материалы кончились: ${this.inputsText(recipe)}`),
);
});
}
}
/** Продолжить цикл того же действия либо уйти в idle (по правилу «работает, пока не скажут»). */
private finishOrContinue(
av: SimAvatar,
now: number,
durMs: number,
canContinue: () => boolean,
onBlocked?: () => void,
): void {
if (canContinue()) {
av.actionStart = now + durMs; // сервер-время следующего завершения
this.dirtyAvatars.add(av.id);
} else {
if (onBlocked) onBlocked();
this.goIdle(av);
}
}
private goIdle(av: SimAvatar): void {
av.action = 'idle';
av.nodeId = null;
av.actionStart = null;
av.actionDur = null;
this.dirtyAvatars.add(av.id);
this.statsDirty = true;
}
// ---- инвентарь / навыки ----
private addItem(av: SimAvatar, item: string, qty: number): void {
av.items.set(item, (av.items.get(item) ?? 0) + qty);
this.markViewerDirty(av);
this.dirtyAvatars.add(av.id); // мог смениться лучший инструмент
}
private takeItem(av: SimAvatar, item: string, qty: number): void {
const left = (av.items.get(item) ?? 0) - qty;
if (left > 0) av.items.set(item, left);
else av.items.delete(item);
this.markViewerDirty(av);
this.dirtyAvatars.add(av.id);
}
private hasInputs(av: SimAvatar, recipe: RecipeDef): boolean {
return recipe.inputs.every((i) => (av.items.get(i.item) ?? 0) >= i.qty);
}
private inputsText(recipe: RecipeDef | undefined): string {
if (!recipe) return '?';
return recipe.inputs.map((i) => `${i.qty}× ${itemTitle(i.item)}`).join(' + ');
}
private addXp(av: SimAvatar, skill: SkillId, amount: number, events: GameEvent[]): void {
const s = av.skills[skill];
if (!s) return;
s.xp += amount;
events.push({
k: 'xp',
userId: av.id,
name: av.name,
color: av.color,
skill,
amount,
total: s.xp,
});
const level = levelForTotalXp(s.xp);
if (level > s.level) {
s.level = level;
events.push({
k: 'levelup',
userId: av.id,
name: av.name,
color: av.color,
skill,
level,
});
}
this.markViewerDirty(av);
this.dirtyAvatars.add(av.id);
}
// ---- вспомогательное ----
private blocked(av: SimAvatar, reason: string): GameEvent {
return { k: 'blocked', userId: av.id, name: av.name, color: av.color, reason };
}
private onCooldown(av: SimAvatar, now: number): boolean {
if (now - av.lastCommandAt < COMMAND_COOLDOWN_MS) return true;
av.lastCommandAt = now;
return false;
}
private nodeById(id: string): SimNode | undefined {
return this.nodes.find((n) => n.id === id);
}
private nearestAvailable(av: SimAvatar, filter: (n: SimNode) => boolean): SimNode | undefined {
let best: SimNode | undefined;
let bestD = Infinity;
for (const n of this.nodes) {
if (n.respawnAt !== null || !filter(n)) continue;
const d = dist(av.x, av.y, n.x, n.y);
if (d < bestD) {
bestD = d;
best = n;
}
}
return best;
}
private bestTool(av: SimAvatar, kind: ToolDef['kind']): ToolDef {
let best: ToolDef | undefined;
for (const t of TOOLS) {
if (t.kind !== kind || (av.items.get(t.item) ?? 0) < 1) continue;
if (!best || t.multiplier > best.multiplier) best = t;
}
// базовый случай: без инструмента — но START_ITEMS гарантирует ржавый набор
return best ?? { item: 'none', kind, multiplier: 1 };
}
private toolForAction(av: SimAvatar): string {
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';
return 'none';
}
private currentPos(av: SimAvatar, now: number): { x: number; y: number } {
if (!av.moving) return { x: av.x, y: av.y };
const dx = av.tx - av.x;
const dy = av.ty - av.y;
const d = Math.hypot(dx, dy);
if (d === 0) return { x: av.tx, y: av.ty };
const k = Math.min(((now - av.moveStart) / 1000) * av.speed, d);
return { x: av.x + (dx / d) * k, y: av.y + (dy / d) * k };
}
private walkTo(av: SimAvatar, tx: number, ty: number, now: number): void {
@@ -229,94 +550,79 @@ export class SimWorld {
this.statsDirty = true;
}
private beginChop(av: SimAvatar, tree: SimTree, now: number): void {
av.pendingNodeId = null;
av.action = 'chop';
av.nodeId = tree.id;
av.actionStart = now;
av.actionDur = TREE.chopDurMs;
this.dirtyAvatars.add(av.id);
this.statsDirty = true;
}
private completeChopCycle(av: SimAvatar, tree: SimTree, now: number, events: GameEvent[]): void {
const start = av.actionStart;
const dur = av.actionDur ?? TREE.chopDurMs;
if (start === null) return;
tree.hp -= 1;
this.dirtyTrees.add(tree.id);
if (tree.hp <= 0) {
tree.respawnAt = now + TREE.respawnMs;
events.push({ k: 'fell', userId: av.id, name: av.name, color: av.color, nodeId: tree.id });
}
av.logs += 1;
events.push({ k: 'item', userId: av.id, name: av.name, color: av.color, item: ITEM_LOG, qty: 1 });
av.xp += XP_PER_CHOP;
events.push({ k: 'xp', userId: av.id, name: av.name, color: av.color, amount: XP_PER_CHOP, total: av.xp });
const level = levelForTotalXp(av.xp);
if (level > av.level) {
av.level = level;
events.push({ k: 'levelup', userId: av.id, name: av.name, color: av.color, level });
}
this.dirtyAvatars.add(av.id);
this.markViewerDirty(av);
if (tree.respawnAt !== null) {
// дерево свалено — аватар отдыхает, пока зритель не отправит к следующему
av.action = 'idle';
av.nodeId = null;
av.actionStart = null;
} else {
av.actionStart = start + dur; // следующий цикл той же команды
}
}
// ---- вспомогательное ----
private treeById(id: string): SimTree | undefined {
return this.trees.find((t) => t.id === id);
}
private nearestAvailableTree(av: SimAvatar): SimTree | undefined {
let best: SimTree | undefined;
let bestD = Infinity;
for (const t of this.trees) {
if (t.respawnAt !== null) continue;
const d = dist(av.x, av.y, t.x, t.y);
if (d < bestD) {
bestD = d;
best = t;
}
}
return best;
}
private currentPos(av: SimAvatar, now: number): { x: number; y: number } {
if (!av.moving) return { x: av.x, y: av.y };
const dx = av.tx - av.x;
const dy = av.ty - av.y;
const d = Math.hypot(dx, dy);
if (d === 0) return { x: av.tx, y: av.ty };
const k = Math.min(((now - av.moveStart) / 1000) * av.speed, d);
return { x: av.x + (dx / d) * k, y: av.y + (dy / d) * k };
}
private markViewerDirty(av: SimAvatar): void {
this.dirtyViewers.set(av.id, { userId: av.id, name: av.name, color: av.color, xp: av.xp, logs: av.logs });
this.dirtyViewers.set(av.id, this.toRecord(av));
}
// ---- выдача для сети/себя ----
private toRecord(av: SimAvatar): ViewerRecord {
const skills: Record<string, number> = {};
for (const id of SKILL_IDS) skills[id] = av.skills[id]?.xp ?? 0;
const items: Record<string, number> = {};
for (const [item, qty] of av.items) items[item] = qty;
return { userId: av.id, name: av.name, color: av.color, skills, items };
}
// ---- dev-хелперы (только DEV_HTTP) ----
giveItem(userId: string, item: string, qty: number): boolean {
const av = this.avatars.get(userId);
if (!av) return false;
this.addItem(av, item, qty);
return true;
}
teleport(userId: string, x: number, y: number): boolean {
const av = this.avatars.get(userId);
if (!av) return false;
av.x = x;
av.y = y;
av.tx = x;
av.ty = y;
av.moving = false;
this.dirtyAvatars.add(av.id);
return true;
}
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) {
return { name: av.name, color: av.color, skills: av.skills, items: Object.fromEntries(av.items) };
}
const rec = [...this.preloaded.values()].find((r) => r.name.toLowerCase() === lower);
if (!rec) return undefined;
const skills: Record<string, SkillSim> = {};
for (const id of SKILL_IDS) {
const xp = rec.skills[id] ?? 0;
skills[id] = { xp, level: levelForTotalXp(xp) };
}
return { name: rec.name, color: rec.color, skills, items: { ...rec.items } };
}
zoneActivity(): number[] {
return ZONES.map(
(z) => [...this.avatars.values()].filter((a) => a.x >= z.x && a.x < z.x + z.w).length,
);
}
// ---- выдача для сети ----
serializeAvatar(av: SimAvatar): AvatarState {
const skills: Record<string, { xp: number; level: number }> = {};
for (const id of SKILL_IDS) {
const s = av.skills[id];
if (s) skills[id] = { xp: s.xp, level: s.level };
}
return {
id: av.id, name: av.name, color: av.color,
x: av.x, y: av.y, tx: av.tx, ty: av.ty,
speed: av.speed, moveStart: av.moveStart, moving: av.moving,
action: av.action, nodeId: av.nodeId, actionStart: av.actionStart, actionDur: av.actionDur,
level: av.level, xp: av.xp,
action: av.action,
tool: this.toolForAction(av),
nodeId: av.nodeId,
actionStart: av.actionStart,
actionDur: av.actionDur,
skills,
};
}
@@ -331,14 +637,14 @@ export class SimWorld {
return out;
}
takeDirtyTrees(): NodeState[] {
if (this.dirtyTrees.size === 0) return [];
takeDirtyNodes(): NodeState[] {
if (this.dirtyNodes.size === 0) return [];
const out: NodeState[] = [];
for (const id of this.dirtyTrees) {
const t = this.treeById(id);
if (t) out.push({ ...t });
for (const id of this.dirtyNodes) {
const n = this.nodeById(id);
if (n) out.push({ ...n });
}
this.dirtyTrees.clear();
this.dirtyNodes.clear();
return out;
}
@@ -348,17 +654,25 @@ export class SimWorld {
return out;
}
serializeTrees(): NodeState[] {
return this.trees.map((t) => ({ ...t }));
serializeNodes(): NodeState[] {
return this.nodes.map((n) => ({ ...n }));
}
stats(): ViewerStat[] {
return [...this.avatars.values()]
.map((av) => ({
id: av.id, name: av.name, color: av.color,
level: av.level, xp: av.xp, logs: av.logs, action: av.action,
id: av.id,
name: av.name,
color: av.color,
skills: Object.fromEntries(
SKILL_IDS.filter((id) => av.skills[id]).map((id) => [id, { ...av.skills[id]! }]),
),
action: av.action,
}))
.sort((a, b) => b.xp - a.xp);
.sort((a, b) => {
const total = (s: ViewerStat) => Object.values(s.skills).reduce((sum, v) => sum + v.xp, 0);
return total(b) - total(a);
});
}
takeStatsIfDirty(): ViewerStat[] | null {
@@ -366,4 +680,54 @@ export class SimWorld {
this.statsDirty = false;
return this.stats();
}
// ---- спавн ----
private ensureAvatar(cmd: ChatCommand, now: number): SimAvatar {
let av = this.avatars.get(cmd.userId);
if (!av) {
const rec = this.preloaded.get(cmd.userId);
const skills = {} as Record<SkillId, SkillSim>;
for (const id of SKILL_IDS) {
const xp = rec?.skills[id] ?? 0;
skills[id] = { xp, level: levelForTotalXp(xp) };
}
const items = new Map<string, number>(Object.entries(rec?.items ?? {}));
for (const item of START_ITEMS) {
if (!items.has(item)) items.set(item, 1);
}
av = {
id: cmd.userId,
name: cmd.name,
color: cmd.color || colorForName(cmd.name),
x: WORLD.spawn.x + (Math.random() * 60 - 30),
y: WORLD.spawn.y + (Math.random() * 40 - 20),
tx: WORLD.spawn.x,
ty: WORLD.spawn.y,
speed: WALK_SPEED,
moveStart: 0,
moving: false,
action: 'idle',
nodeId: null,
actionStart: null,
actionDur: null,
skills,
items,
lastCommandAt: 0,
pendingNodeId: null,
pendingRecipeId: null,
};
this.avatars.set(av.id, av);
this.dirtyAvatars.add(av.id);
this.markViewerDirty(av);
this.statsDirty = true;
this.pendingEvents.push({ k: 'spawn', userId: av.id, name: av.name, color: av.color });
} else if (av.name !== cmd.name || av.color !== cmd.color) {
av.name = cmd.name;
av.color = cmd.color || av.color;
this.dirtyAvatars.add(av.id);
this.markViewerDirty(av);
}
return av;
}
}