Files

545 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string | null> {
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`);
}
});