import { createServer } from 'node:http'; import path from 'node:path'; import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import type { IncomingMessage, ServerResponse } from 'node:http'; import sirv from 'sirv'; import { PROTOCOL_VERSION, PROPS, SKILL_TITLES, WORLD, ZONES, colorForName, itemTitle, levelForTotalXp, type CampfireState, type DeltaMsg, type GameEvent, type SnapshotMsg, type WelcomeMsg, } from '@idle/shared'; import { loadConfig, type TwitchConfig } 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 { SpotlightListener } from './twitch/eventsub'; import type { TwitchSource } from './twitch/source'; // node:sqlite числится экспериментальным — глушим только это предупреждение process.on('warning', (w) => { if (w.name === 'ExperimentalWarning' && /sqlite/i.test(w.message)) return; console.warn(w.toString()); }); 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); const db = new Db(cfg.dbPath); const sim = new SimWorld(cfg.channelId, db.loadViewers(cfg.channelId), db.loadCampfire(cfg.channelId)); const camera = new CameraController(); function handleCommand(cmd: ChatCommand): void { // !топ отвечает в чат и не трогает симуляцию const word = cmd.text.startsWith('!') ? (cmd.text.slice(1).trim().split(/\s+/)[0] ?? '').toLowerCase() : ''; if (word === 'топ' || word === 'top') { const now = Date.now(); if (now - lastTopAt >= 15_000) { lastTopAt = now; const top = sim.stats().slice(0, 3); sources[0]?.say( top.length === 0 ? '🏆 Топ пока пуст — пиши !рубить' : '🏆 Топ: ' + top .map((s, i) => { const lvl = Math.max(1, ...Object.values(s.skills).map((v) => v.level)); return `${i + 1}. ${s.name} [ур.${lvl}]`; }) .join(' · '), ); } return; } const before = sim.avatars.size; sim.handleCommand(cmd); if (sim.avatars.size > before) { console.log(`[game] новый зритель в мире: ${cmd.name}`); } } let lastTopAt = 0; // ---- источники команд чата ---- 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)); // ---- спотлайт: Channel Points → EventSub → камера ---- interface SpotlightEntry { userId: string; name: string; color: string; until: number; } const spotlightQueue: SpotlightEntry[] = []; let spotlightActive: SpotlightEntry | null = null; function fireSpotlight(entry: SpotlightEntry, events: GameEvent[]): void { spotlightActive = entry; events.push({ k: 'spotlight', name: entry.name, color: entry.color }); announce(`🔍 ${entry.name} — у нас на экране!`); } async function fetchBroadcasterId(twitch: TwitchConfig): Promise { try { const res = await fetch( `https://api.twitch.tv/helix/users?login=${encodeURIComponent(twitch.channel)}`, { headers: { 'client-id': twitch.clientId, authorization: `Bearer ${twitch.botToken.replace(/^oauth:/i, '')}`, }, }, ); if (!res.ok) return null; const j = (await res.json()) as { data?: Array<{ id: string }> }; return j.data?.[0]?.id ?? null; } catch { return null; } } if (cfg.twitch?.streamerToken) { void fetchBroadcasterId(cfg.twitch).then((broadcasterId) => { if (!broadcasterId) { console.error('[spotlight] не получили id канала — спотлайт выключен'); return; } const listener = new SpotlightListener(cfg.twitch!, broadcasterId, (userId, _userName, displayName, rewardTitle) => { if (rewardTitle.trim().toLowerCase() !== cfg.spotlightReward.trim().toLowerCase()) return; sim.spawnAvatar(userId, displayName, colorForName(displayName)); spotlightQueue.push({ userId, name: displayName, color: colorForName(displayName), until: 0 }); console.log(`[spotlight] ${displayName} выкупил «${rewardTitle}» (очередь: ${spotlightQueue.length})`); }); listener .start() .then(() => console.log(`[spotlight] EventSub слушает награду «${cfg.spotlightReward}»`)) .catch((e) => console.error('[spotlight] не запустился:', e)); }); } else if (cfg.twitch) { console.log('[spotlight] стримерский токен не задан — спотлайт выключен (см. docs/twitch-setup.md)'); } // ---- источник мира для 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, props: PROPS.map((p) => ({ ...p })), }, }; }, snapshot(): SnapshotMsg { return { t: 'snapshot', tick: currentTick(), serverNow: Date.now(), camera: camera.state, avatars: [...sim.avatars.values()].map((av) => sim.serializeAvatar(av)), nodes: sim.serializeNodes(), stats: sim.stats(), campfire: sim.campfireState(), }; }, }; const gateway = new WsGateway(world); // ---- тик ---- function buildDelta(events: GameEvent[], camChanged: boolean): DeltaMsg | null { const avatars = sim.takeDirtyAvatars(); const nodes = sim.takeDirtyNodes(); const stats = sim.takeStatsIfDirty(); const campfire = sim.takeCampfireStateIfDirty(); const removed = sim.takeRemovedAvatars(); if ( avatars.length === 0 && nodes.length === 0 && !stats && !campfire && removed.length === 0 && 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 (campfire) msg.campfire = campfire; if (removed.length > 0) msg.removed = removed; if (events.length > 0) msg.events = events; return msg; } setInterval(() => { const now = Date.now(); const events = sim.tick(now); const camChanged = camera.tick(now, sim.zoneActivity()); for (const rec of sim.takeDirtyViewers()) db.saveViewer(cfg.channelId, rec); if (sim.isCampfireDirty()) { const cf = sim.campfireState(); db.saveCampfire(cfg.channelId, cf.progress, sim.campfireLedgerRows()); } // спотлайт: истёкший сменяется следующим из очереди if (spotlightActive && now >= spotlightActive.until) { spotlightActive = null; camera.clearSpotlight(); } if (!spotlightActive && spotlightQueue.length > 0) { fireSpotlight({ ...spotlightQueue.shift()!, until: now + cfg.spotlightMs }, events); } if (spotlightActive) { const av = sim.avatars.get(spotlightActive.userId); if (!av) { spotlightActive = null; camera.clearSpotlight(); } else { camera.setSpotlight(av.x, av.y, Math.max(1000, spotlightActive.until - now)); } } for (const ev of events) { if (ev.k === 'levelup' && cfg.chatAnnounceLevelUp) { announce(`⭐ ${ev.name} вырос(ла) до ${ev.level}-го уровня: ${SKILL_TITLES[ev.skill] ?? ev.skill}!`); } else if (ev.k === 'firelevel' && cfg.chatAnnounceLevelUp) { announce(`🔥 Костёр вырастает до ${ev.level}-го уровня! Общий вклад: ${sim.campfireState().progress}`); } else if (ev.k === 'gift' && cfg.chatAnnounceLevelUp) { announce(`🎁 ${ev.name} подарил(ла) ${ev.toName}: ${ev.qty}× ${itemTitle(ev.item)}!`); } } 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') }, { prefix: '/dashboard/', dir: path.resolve(__dirname, '../public/dashboard') }, ].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; if (p === '/healthz') { json(res, 200, { ok: true, protocol: PROTOCOL_VERSION, 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; } 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 (cfg.devHttp && p === '/dev/fire' && req.method === 'POST') { readBody(req, (body) => { try { const parsed = JSON.parse(body || '{}') as { points?: number }; const points = Number(parsed.points); if (!Number.isFinite(points) || points <= 0) { json(res, 400, { error: 'нужен points > 0' }); return; } sim.addCampfireProgressDev(points); json(res, 200, { ok: true, campfire: sim.campfireState() }); } catch (e) { json(res, 400, { error: String(e) }); } }); return; } if (cfg.devHttp && p === '/dev/spotlight' && req.method === 'POST') { readBody(req, (body) => { try { const parsed = JSON.parse(body || '{}') as { name?: string }; const name = (parsed.name ?? '').trim(); if (!name) { json(res, 400, { error: 'нужен name' }); return; } const viewer = sim.lookupViewer(name); const userId = `dev:${name}`; sim.spawnAvatar(userId, name, viewer?.color ?? colorForName(name)); spotlightQueue.push({ userId, name, color: viewer?.color ?? colorForName(name), until: 0 }); json(res, 200, { ok: true, queue: spotlightQueue.length }); } catch (e) { json(res, 400, { error: String(e) }); } }); return; } // ---- дашборд стримера ---- function checkAdmin(req: IncomingMessage): boolean { if (!cfg.adminKey) return true; return req.headers['x-admin-key'] === cfg.adminKey; } if (p.startsWith('/api/admin/')) { if (!checkAdmin(req)) { json(res, 401, { error: 'нужен x-admin-key' }); return; } if (p === '/api/admin/state' && req.method === 'GET') { json(res, 200, { channel: cfg.channelId, tickMs: cfg.tickMs, uptimeSec: Math.round((Date.now() - startedAt) / 1000), chatAnnounce: cfg.chatAnnounceLevelUp, spotlightReward: cfg.spotlightReward, spotlightQueue: spotlightQueue.length + (spotlightActive ? 1 : 0), viewers: sim.avatars.size, campfire: sim.campfireState(), top: sim.stats().slice(0, 10), }); return; } if (p === '/api/admin/campfire/reset' && req.method === 'POST') { readBody(req, (body) => { try { const parsed = JSON.parse(body || '{}') as { keepLedger?: boolean }; sim.resetCampfire(parsed.keepLedger === true); json(res, 200, { ok: true, campfire: sim.campfireState() }); } catch (e) { json(res, 400, { error: String(e) }); } }); return; } if (p === '/api/admin/viewer/reset' && req.method === 'POST') { readBody(req, (body) => { try { const parsed = JSON.parse(body || '{}') as { name?: string }; const name = (parsed.name ?? '').trim(); if (!name) { json(res, 400, { error: 'нужен name' }); return; } const reset = sim.resetViewer(name); if (!reset) { json(res, 404, { error: 'зритель не найден' }); return; } db.deleteViewer(cfg.channelId, reset.userId); json(res, 200, { ok: true, reset }); } catch (e) { json(res, 400, { error: String(e) }); } }); return; } if (p === '/api/admin/announce' && req.method === 'POST') { readBody(req, (body) => { try { const parsed = JSON.parse(body || '{}') as { enabled?: boolean }; cfg.chatAnnounceLevelUp = parsed.enabled === true; json(res, 200, { ok: true, chatAnnounce: cfg.chatAnnounceLevelUp }); } catch (e) { json(res, 400, { error: String(e) }); } }); return; } json(res, 404, { error: 'unknown admin route' }); 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); if (p === base) { res.writeHead(301, { location: `${base}/` }); res.end(); return; } if (p.startsWith(m.prefix)) { req.url = p.slice(base.length) + url.search; m.middleware(req, res, () => json(res, 404, { error: 'not found' })); return; } } json(res, 404, { error: 'not found', hint: 'dev: overlay http://localhost:5173, web http://localhost:5174; для /overlay/ и /web/ сначала pnpm build', }); } const server = createServer(handleHttp); server.on('upgrade', (req, socket, head) => { const { pathname } = new URL(req.url ?? '/', 'http://localhost'); if (pathname === '/ws') { gateway.handleUpgrade(req, socket, head); } else { socket.destroy(); } }); 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`); } });