feat: add server and frontend initial - wood chopping
This commit is contained in:
+179
-26
@@ -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`);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user