545 lines
18 KiB
TypeScript
545 lines
18 KiB
TypeScript
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`);
|
||
}
|
||
});
|