feat: add server and frontend initial - wood chopping

This commit is contained in:
2026-09-05 16:37:45 +05:00
parent c79681dc59
commit f6c338e1b0
28 changed files with 1857 additions and 195 deletions
+179 -26
View File
@@ -4,37 +4,156 @@ import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import type { IncomingMessage, ServerResponse } from 'node:http';
import sirv from 'sirv';
import { PROTOCOL_VERSION, type ServerMessage } from '@idle/shared';
import { WsGateway } from './net/ws';
import { stubAvatarsAt } from './sim/stub';
import {
PROTOCOL_VERSION,
WORLD,
ZONES,
colorForName,
type DeltaMsg,
type GameEvent,
type SnapshotMsg,
type WelcomeMsg,
} from '@idle/shared';
import { loadConfig } from './config';
import { Db } from './persist/db';
import { CameraController } from './net/camera';
import { WsGateway, type WorldSource } from './net/ws';
import { SimWorld, type ChatCommand } from './sim/world';
import { FakeTwitchSource } from './twitch/fake';
import { TwurpleSource } from './twitch/twurple';
import type { TwitchSource } from './twitch/source';
const PORT = Number(process.env.PORT ?? 3000);
const TICK_MS = Number(process.env.TICK_MS ?? 1000);
const startedAt = Date.now();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const currentTick = (): number => Math.floor((Date.now() - startedAt) / TICK_MS);
const snapshot = (): ServerMessage => ({
t: 'snapshot',
tick: currentTick(),
avatars: stubAvatarsAt(Date.now()),
// node:sqlite числится экспериментальным — глушим только это предупреждение
process.on('warning', (w) => {
if (w.name === 'ExperimentalWarning' && /sqlite/i.test(w.message)) return;
console.warn(w.toString());
});
const gateway = new WsGateway({ getSnapshot: snapshot, tickMs: TICK_MS });
const cfg = loadConfig();
const startedAt = Date.now();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const currentTick = (): number => Math.floor((Date.now() - startedAt) / cfg.tickMs);
// Собранные клиенты раздаём сами — OBS обращается к одному порту.
// В dev клиенты живут на своих vite-портах (5173/5174).
const staticMounts = [
{ prefix: '/overlay/', dir: path.resolve(__dirname, '../../overlay/dist') },
{ prefix: '/web/', dir: path.resolve(__dirname, '../../web/dist') },
].map((m) => ({ ...m, middleware: existsSync(m.dir) ? sirv(m.dir) : null }));
const db = new Db(cfg.dbPath);
const sim = new SimWorld(cfg.channelId, db.loadViewers(cfg.channelId));
const camera = new CameraController();
function handleCommand(cmd: ChatCommand): void {
const before = sim.avatars.size;
sim.handleCommand(cmd);
if (sim.avatars.size > before) {
console.log(`[game] новый зритель в мире: ${cmd.name}`);
}
}
// ---- источники команд чата ----
const sources: TwitchSource[] = [];
if (cfg.twitch) {
const src = new TwurpleSource(cfg.twitch, handleCommand);
void src
.start()
.then(() => console.log(`[twitch] бот слушает чат канала ${cfg.twitch?.channel}`))
.catch((e) => console.error('[twitch] не запустился (проверь токены/сигнатуры twurple):', e));
sources.push(src);
} else {
console.log('[twitch] TWITCH_* не заданы — живой чат выключен (см. docs/twitch-setup.md)');
}
if (cfg.fakeViewers > 0) {
const fake = new FakeTwitchSource(cfg.channelId, cfg.fakeViewers, handleCommand);
void fake.start();
sources.push(fake);
console.log(`[fake] ${cfg.fakeViewers} фейковых зрителей генерируют команды`);
}
// значимые события пишем первым ответившим источником, с его троттлингом
const announce = (text: string): boolean => sources.some((s) => s.announce(text));
// ---- источник мира для WS-шлюза ----
const world: WorldSource = {
welcome(): WelcomeMsg {
return {
t: 'welcome',
v: PROTOCOL_VERSION,
tickMs: cfg.tickMs,
serverNow: Date.now(),
world: { w: WORLD.w, h: WORLD.h, spawn: { ...WORLD.spawn }, zones: ZONES },
};
},
snapshot(): SnapshotMsg {
return {
t: 'snapshot',
tick: currentTick(),
serverNow: Date.now(),
camera: camera.state,
avatars: [...sim.avatars.values()].map((av) => sim.serializeAvatar(av)),
nodes: sim.serializeTrees(),
stats: sim.stats(),
};
},
};
const gateway = new WsGateway(world);
// ---- тик ----
function buildDelta(events: GameEvent[], camChanged: boolean): DeltaMsg | null {
const avatars = sim.takeDirtyAvatars();
const nodes = sim.takeDirtyTrees();
const stats = sim.takeStatsIfDirty();
if (avatars.length === 0 && nodes.length === 0 && !stats && events.length === 0 && !camChanged) {
return null;
}
const msg: DeltaMsg = { t: 'delta', tick: currentTick(), serverNow: Date.now() };
if (camChanged) msg.camera = camera.state;
if (avatars.length > 0) msg.avatars = avatars;
if (nodes.length > 0) msg.nodes = nodes;
if (stats) msg.stats = stats;
if (events.length > 0) msg.events = events;
return msg;
}
setInterval(() => {
const now = Date.now();
const events = sim.tick(now);
const camChanged = camera.tick(now);
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} в рубке леса!`);
}
}
const delta = buildDelta(events, camChanged);
if (delta) gateway.broadcast(delta);
}, cfg.tickMs);
// ---- http: healthz, dev-эндпоинты, раздача собранных клиентов ----
function json(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
}
function readBody(req: IncomingMessage, cb: (body: string) => void): void {
let data = '';
req.on('data', (chunk) => {
data += chunk;
if (data.length > 10_000) req.destroy();
});
req.on('end', () => cb(data));
}
const staticMounts = [
{ prefix: '/overlay/', dir: path.resolve(__dirname, '../../overlay/dist') },
{ prefix: '/web/', dir: path.resolve(__dirname, '../../web/dist') },
].map((m) => ({ ...m, middleware: existsSync(m.dir) ? sirv(m.dir) : null }));
function handleHttp(req: IncomingMessage, res: ServerResponse): void {
const url = new URL(req.url ?? '/', 'http://localhost');
const p = url.pathname;
@@ -43,12 +162,45 @@ function handleHttp(req: IncomingMessage, res: ServerResponse): void {
json(res, 200, {
ok: true,
protocol: PROTOCOL_VERSION,
tickMs: TICK_MS,
tickMs: cfg.tickMs,
uptimeSec: Math.round((Date.now() - startedAt) / 1000),
viewers: sim.avatars.size,
channel: cfg.channelId,
});
return;
}
if (cfg.devHttp && p === '/dev/command' && req.method === 'POST') {
readBody(req, (body) => {
try {
const parsed = JSON.parse(body || '{}') as { name?: string; color?: string; text?: string };
const name = (parsed.name ?? '').trim();
const text = (parsed.text ?? '').trim();
if (!name || !text) {
json(res, 400, { error: 'нужны name и text' });
return;
}
handleCommand({
channelId: cfg.channelId,
userId: `dev:${name}`,
login: name,
name,
color: parsed.color ?? colorForName(name),
text,
});
json(res, 200, { ok: true });
} catch (e) {
json(res, 400, { error: String(e) });
}
});
return;
}
if (cfg.devHttp && p === '/dev/state' && req.method === 'GET') {
json(res, 200, world.snapshot());
return;
}
for (const m of staticMounts) {
if (!m.middleware) continue;
const base = m.prefix.slice(0, -1);
@@ -81,13 +233,14 @@ server.on('upgrade', (req, socket, head) => {
}
});
setInterval(() => gateway.broadcast(snapshot()), TICK_MS);
server.listen(PORT, () => {
console.log(`[idle-xboct] http://localhost:${PORT} — протокол v${PROTOCOL_VERSION}, тик ${TICK_MS} мс`);
server.listen(cfg.port, () => {
console.log(`[idle-xboct] http://localhost:${cfg.port} — протокол v${PROTOCOL_VERSION}, тик ${cfg.tickMs} мс, канал «${cfg.channelId}»`);
console.log(`[idle-xboct] overlay: http://localhost:5173 (dev) | web: http://localhost:5174 (dev)`);
const built = staticMounts.filter((m) => m.middleware).map((m) => m.prefix);
if (built.length > 0) {
console.log(`[idle-xboct] раздаю сборки: ${built.join(', ')}`);
}
if (cfg.devHttp) {
console.log(`[dev] POST /dev/command {"name":"Ник","text":"!рубить"} | GET /dev/state`);
}
});