From c56ec113ccb4d9f806206035879dea182928abef Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sat, 5 Sep 2026 19:12:28 +0500 Subject: [PATCH] feat: economy and polishing --- README.md | 15 +- docs/PLAN.md | 4 + packages/overlay/src/App.svelte | 2 +- packages/render/src/world.ts | 2 + packages/server/package.json | 2 + packages/server/public/dashboard/index.html | 155 ++++++++++++++ packages/server/smoke.mjs | 81 +++++++- packages/server/src/config.ts | 62 ++++-- packages/server/src/index.ts | 213 +++++++++++++++++++- packages/server/src/net/camera.ts | 32 +++ packages/server/src/persist/db.ts | 7 + packages/server/src/sim/world.ts | 155 +++++++++++++- packages/server/src/twitch/eventsub.ts | 52 +++++ packages/server/src/twitch/twurple.ts | 17 +- packages/shared/src/content.ts | 37 ++++ packages/shared/src/protocol.ts | 6 +- packages/web/src/App.svelte | 4 + pnpm-lock.yaml | 64 ++++++ 18 files changed, 868 insertions(+), 42 deletions(-) create mode 100644 packages/server/public/dashboard/index.html create mode 100644 packages/server/src/twitch/eventsub.ts diff --git a/README.md b/README.md index 89191c6..3386359 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,17 @@ pnpm dev - web-мир: http://localhost:5174 — топ зрителей и лента событий Сборка клиентов (`pnpm build`) — сервер начнёт раздавать их сам: `/overlay/` и `/web/` -на порту 3000 (это режим для OBS, без vite dev-сервера). +на порту 3000 (это режим для OBS, без vite dev-сервера). Там же — дашборд стримера: +http://localhost:3000/dashboard/ -### Разработка без живого стрима +### Живой чат и спотлайт + +Заполни `packages/server/.env` по чек-листу [docs/twitch-setup.md](docs/twitch-setup.md) — +бот подключится к чату автоматически при старте (пример в `.env.example`). + +Для спотлайта создай в твитч-панели награду за очки канала с названием +**«Показать меня»** (меняется через `SPOTLIGHT_REWARD`) — зритель выкупает её, +и камера едет к его аватару на минуту. Скопируй `.env.example` в `.env` и включи `FAKE_VIEWERS=5` — фейковые зрители начнут писать `!рубить`/`!стоп`. С `DEV_HTTP=1` можно дёргать вручную: @@ -30,5 +38,6 @@ curl -X POST http://localhost:3000/dev/command -H "content-type: application/jso Живой чат: заполни `TWITCH_*` по чек-листу [docs/twitch-setup.md](docs/twitch-setup.md) — бот подхватит их при старте автоматически. -Дымовой тест: сервер с `DEV_HTTP=1 SQLITE_PATH=:memory:`, затем `pnpm --filter @idle/server smoke`. +Дымовой тест (без twitch): сервер с `IDLE_NO_TWITCH=1 DEV_HTTP=1 SQLITE_PATH=:memory:`, +затем `pnpm --filter @idle/server smoke m5`. diff --git a/docs/PLAN.md b/docs/PLAN.md index ec64c51..39886c1 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -116,6 +116,10 @@ XP-кривая Melvor-подобная, уровни открывают нод своя экономика не нужна) → сервер ловит redemption по EventSub → камера едет к аватару на ~1 минуту → возврат в тур. Очередь спотлайтов; балансировка спроса — позже, другими наградами за те же очки +- Реализация (M5): имя награды настраивается (`SPOTLIGHT_REWARD`, по умолчанию + «Показать меня»), длительность — `SPOTLIGHT_MS`; веб-страница получает спотлайт + как обычную камеру. Дашборд стримера — `/dashboard` на порту сервера + (состояние, сброс костра/зрителя, объявления в чат; опциональный `ADMIN_KEY`) - Камера стрима **авторитарна на сервере** — все клиенты стрима видят одно и то же; веб-страница-мир получает свободную камеру + «следовать за мной» diff --git a/packages/overlay/src/App.svelte b/packages/overlay/src/App.svelte index dd59133..9a6478c 100644 --- a/packages/overlay/src/App.svelte +++ b/packages/overlay/src/App.svelte @@ -42,7 +42,7 @@
{STATUS_TEXT[status]}
{#if status === 'online'} -
!рубить · !копать · !рыбачить · !ковать <рецепт> · !готовить <рецепт> · !отдых · !костёр · !стоп
+
!рубить · !копать · !рыбачить · !ковать/!готовить <рецепт> · !отдых · !костёр · !подарить · !топ · !стоп
{/if} diff --git a/packages/render/src/world.ts b/packages/render/src/world.ts index 377ca04..57e0738 100644 --- a/packages/render/src/world.ts +++ b/packages/render/src/world.ts @@ -391,6 +391,8 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {} clockOffset = d.serverNow - Date.now(); if (d.camera) mergeCamera(d.camera); if (d.avatars) for (const av of d.avatars) syncAvatar(av); + if (d.removed) for (const id of d.removed) avatars.delete(id); + if (d.avatars || d.removed) pruneEntities(); if (d.nodes) for (const st of d.nodes) syncNode(st); for (const e of d.events ?? []) opts.onEvent?.(e); if (d.stats) opts.onStats?.(d.stats); diff --git a/packages/server/package.json b/packages/server/package.json index eb08d2e..0ad2e79 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -12,8 +12,10 @@ }, "dependencies": { "@idle/shared": "workspace:*", + "@twurple/api": "^8.1.4", "@twurple/auth": "^8.0.0", "@twurple/chat": "^8.0.0", + "@twurple/eventsub-ws": "^8.1.4", "sirv": "^3.0.0", "ws": "^8.18.0" }, diff --git a/packages/server/public/dashboard/index.html b/packages/server/public/dashboard/index.html new file mode 100644 index 0000000..a579707 --- /dev/null +++ b/packages/server/public/dashboard/index.html @@ -0,0 +1,155 @@ + + + + + + Idle XBOCT — дашборд + + + +
Idle XBOCT — дашборд стримера
+
+
+

Ключ администратора

+
+ + +
+

Ключ нужен, только если ADMIN_KEY задан в .env. Без него дашборд открыт — держи сервер за localhost.

+
+ +
+

Состояние

+
загрузка…
+
+ +
+

Костёр

+
+
+ + +
+
+ +
+

Зритель

+
+ + +
+

Стирает навыки, инвентарь и вклад в костёр. Аватар исчезнет из мира у всех клиентов.

+
+ +
+

Объявления в чат

+
+ + + +
+

Левелапы, рост костра и подарки — пишутся ботом в чат (с троттлингом).

+
+
+ + + + diff --git a/packages/server/smoke.mjs b/packages/server/smoke.mjs index 4aa8180..fdb4cf6 100644 --- a/packages/server/smoke.mjs +++ b/packages/server/smoke.mjs @@ -21,7 +21,7 @@ const COOLDOWN = 1400; function connect(onMsg) { const ws = new WebSocket(WS); - ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 5 }))); + ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 6 }))); ws.on('message', (d) => onMsg(JSON.parse(String(d)))); return ws; } @@ -40,6 +40,11 @@ async function state() { return res.json(); } +async function inventory(name) { + const res = await fetch(`${BASE}/api/inventory?name=${encodeURIComponent(name)}`); + return res.json(); +} + function waitForDelta(pred, timeoutMs = 9000) { return new Promise((resolve, reject) => { const ws = connect((m) => { @@ -73,6 +78,80 @@ function firstSnapshot() { const phase = process.argv[2] ?? 'phase1'; +if (phase === 'm5') { + const snap = await firstSnapshot(); + if (!snap.campfire) throw new Error('no campfire state'); + console.log('welcome v6 ok'); + + // два зрителя + await dev('/dev/command', { name: 'Тестер', text: '!рубить' }); + await sleep(300); + await dev('/dev/command', { name: 'Тестер2', text: '!рубить' }); + await sleep(COOLDOWN); + + // подарок: у Тестера один топор → однозначно + await dev('/dev/give', { name: 'Тестер', item: 'log', qty: 5 }); + await dev('/dev/command', { name: 'Тестер', text: '!подарить Тестер2 топор' }); + const d1 = await waitForDelta((m) => m.events?.some((e) => e.k === 'gift'), 5000); + const gift = d1.events.find((e) => e.k === 'gift'); + console.log(`gift: ${gift.name} → ${gift.toName}: ${gift.qty}× ${gift.item}`); + if (gift.item !== 'axe_rusty' || gift.toName !== 'Тестер2') throw new Error('bad gift'); + const inv1 = await inventory('Тестер2'); + if ((inv1.items?.axe_rusty ?? 0) !== 2) throw new Error('gift not delivered: ' + JSON.stringify(inv1.items)); + + // неоднозначность: оба топора в инвентаре → «уточни» + await dev('/dev/give', { name: 'Тестер', item: 'axe_rusty', qty: 1 }); + await dev('/dev/give', { name: 'Тестер', item: 'axe_iron', qty: 1 }); + await sleep(COOLDOWN); + await dev('/dev/command', { name: 'Тестер', text: '!подарить Тестер2 топор' }); + const d2 = await waitForDelta((m) => m.events?.some((e) => e.k === 'blocked' && /уточни/.test(e.reason)), 5000); + console.log('ambiguous:', d2.events.find((e) => e.k === 'blocked').reason); + + // склонение + количество: «брёвна 2» + await sleep(COOLDOWN); + await dev('/dev/command', { name: 'Тестер', text: '!подарить Тестер2 брёвна 2' }); + const d3 = await waitForDelta((m) => m.events?.some((e) => e.k === 'gift' && e.item === 'log'), 5000); + console.log('gift2: логи ×' + d3.events.find((e) => e.k === 'gift').qty); + const inv2 = await inventory('Тестер'); + if ((inv2.items?.log ?? 0) !== 3) throw new Error('log qty wrong: ' + JSON.stringify(inv2.items)); + + // спотлайт: камера едет к аватару (прямоугольник 620×430 применяется со следующего тика) + await dev('/dev/spotlight', { name: 'Тестер' }); + const d4 = await waitForDelta((m) => m.camera?.w === 620, 5000); + const cam = d4.camera; + const st = await state(); + const av = st.avatars.find((a) => a.name === 'Тестер'); + const inFrame = cam && av && av.x >= cam.x && av.x <= cam.x + cam.w && av.y >= cam.y && av.y <= cam.y + cam.h; + console.log(`spotlight: камера ${cam.w}×${cam.h}, аватар (${av.x},${av.y}), в кадре: ${inFrame ? 'да' : 'НЕТ'}`); + if (!inFrame) throw new Error('camera did not follow avatar'); + + // дашборд: state, сброс костра, html + const admin = await fetch(`${BASE}/api/admin/state`).then((r) => r.json()); + console.log(`admin: канал=${admin.channel}, зрителей=${admin.viewers}, спотлайт-очередь=${admin.spotlightQueue}`); + const reset = await fetch(`${BASE}/api/admin/campfire/reset`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ keepLedger: true }), + }); + const resetBody = await reset.json(); + if (resetBody.campfire?.progress !== 0) throw new Error('campfire reset failed'); + const dash = await fetch(`${BASE}/dashboard/`); + console.log('dashboard:', dash.status, dash.headers.get('content-type')); + if (dash.status !== 200) throw new Error('dashboard not served'); + + // сброс зрителя: аватар уходит из мира (removed) + await fetch(`${BASE}/api/admin/viewer/reset`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Тестер2' }), + }); + const d5 = await waitForDelta((m) => m.removed?.includes(`dev:Тестер2`), 5000); + console.log('viewer reset: removed from world ok'); + + console.log('M5 smoke: OK'); + process.exit(0); +} + if (phase === 'phase1') { const snap = await firstSnapshot(); console.log('welcome v5 | campfire:', JSON.stringify(snap.campfire)); diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index af61fe1..e95bef3 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -4,21 +4,28 @@ import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -/** Крошечный загрузчик .env из корня репо — без зависимостей и работающий в cmd.exe. */ +/** Крошечный загрузчик .env — без зависимостей и работающий в cmd.exe. */ function loadDotEnv(): void { - const file = path.resolve(__dirname, '../../../.env'); - let text: string; - try { - text = readFileSync(file, 'utf8'); - } catch { - return; - } - for (const line of text.split(/\r?\n/)) { - const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/); - if (!m) continue; - const key = m[1]; - if (!key || key in process.env) continue; - process.env[key] = (m[2] ?? '').replace(/^["']|["']$/g, ''); + // корень репо, затем packages/server/.env — локальный перекрывает корневой + const files = [ + path.resolve(__dirname, '../../../.env'), + path.resolve(__dirname, '../.env'), + ]; + for (const file of files) { + let text: string; + try { + text = readFileSync(file, 'utf8'); + } catch { + continue; + } + for (const line of text.split(/\r?\n/)) { + const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/); + if (!m || !m[1]) continue; + const key: string = m[1]; + const value = (m[2] ?? '').replace(/^["']|["']$/g, ''); + if (value !== '') process.env[key] = value; + else if (!(key in process.env)) process.env[key] = ''; + } } } @@ -28,6 +35,9 @@ export interface TwitchConfig { channel: string; botToken: string; botRefresh: string; + /** Стримерский токен для EventSub (награды канала) — опционален. */ + streamerToken?: string; + streamerRefresh?: string; } export interface AppConfig { @@ -40,6 +50,11 @@ export interface AppConfig { devHttp: boolean; chatAnnounceLevelUp: boolean; twitch: TwitchConfig | null; + /** Название Channel Point награды для спотлайта. */ + spotlightReward: string; + spotlightMs: number; + /** Ключ для /api/admin/* и /dashboard (не задан — только localhost-использование). */ + adminKey: string | null; } function num(name: string, def: number): number { @@ -61,10 +76,22 @@ export function loadConfig(): AppConfig { const channel = (process.env.TWITCH_CHANNEL ?? '').toLowerCase(); const botToken = process.env.TWITCH_BOT_TOKEN ?? ''; const botRefresh = process.env.TWITCH_BOT_REFRESH ?? ''; - const twitch = + const streamerToken = process.env.TWITCH_STREAMER_TOKEN ?? ''; + const streamerRefresh = process.env.TWITCH_STREAMER_REFRESH ?? ''; + let twitch: TwitchConfig | null = clientId && clientSecret && channel && botToken && botRefresh - ? { clientId, clientSecret, channel, botToken, botRefresh } + ? { + clientId, + clientSecret, + channel, + botToken, + botRefresh, + streamerToken: streamerToken && streamerRefresh ? streamerToken : undefined, + streamerRefresh: streamerToken && streamerRefresh ? streamerRefresh : undefined, + } : null; + // для дымовых тестов: полностью отключить twitch-интеграции + if (process.env.IDLE_NO_TWITCH === '1') twitch = null; return { port: num('PORT', 3000), @@ -75,5 +102,8 @@ export function loadConfig(): AppConfig { devHttp: bool('DEV_HTTP', false), chatAnnounceLevelUp: bool('CHAT_ANNOUNCE_LEVELUP', true), twitch, + spotlightReward: process.env.SPOTLIGHT_REWARD ?? 'Показать меня', + spotlightMs: num('SPOTLIGHT_MS', 60_000), + adminKey: process.env.ADMIN_KEY || null, }; } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index c83dd2b..3c0a2d5 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -11,6 +11,7 @@ import { WORLD, ZONES, colorForName, + itemTitle, levelForTotalXp, type CampfireState, type DeltaMsg, @@ -18,13 +19,14 @@ import { type SnapshotMsg, type WelcomeMsg, } from '@idle/shared'; -import { loadConfig } from './config'; +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 числится экспериментальным — глушим только это предупреждение @@ -43,6 +45,30 @@ const sim = new SimWorld(cfg.channelId, db.loadViewers(cfg.channelId), db.loadCa 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) { @@ -50,6 +76,8 @@ function handleCommand(cmd: ChatCommand): void { } } +let lastTopAt = 0; + // ---- источники команд чата ---- const sources: TwitchSource[] = []; @@ -75,6 +103,64 @@ if (cfg.fakeViewers > 0) { // значимые события пишем первым ответившим источником, с его троттлингом 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 = { @@ -116,7 +202,11 @@ function buildDelta(events: GameEvent[], camChanged: boolean): DeltaMsg | null { const nodes = sim.takeDirtyNodes(); const stats = sim.takeStatsIfDirty(); const campfire = sim.takeCampfireStateIfDirty(); - if (avatars.length === 0 && nodes.length === 0 && !stats && !campfire && events.length === 0 && !camChanged) { + 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() }; @@ -125,6 +215,7 @@ function buildDelta(events: GameEvent[], camChanged: boolean): DeltaMsg | null { 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; } @@ -140,11 +231,31 @@ setInterval(() => { 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)}!`); } } @@ -171,6 +282,7 @@ function readBody(req: IncomingMessage, cb: (body: string) => void): void { 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 { @@ -276,6 +388,103 @@ function handleHttp(req: IncomingMessage, res: ServerResponse): void { 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; diff --git a/packages/server/src/net/camera.ts b/packages/server/src/net/camera.ts index cf7a44b..9aea987 100644 --- a/packages/server/src/net/camera.ts +++ b/packages/server/src/net/camera.ts @@ -17,14 +17,46 @@ export class CameraController { private readonly seed = Math.random() * 100; private zoneIdx = 0; private dwellUntil = 0; + private spotlight: { x: number; y: number } | null = null; + private spotlightUntil = 0; constructor(private readonly zones: ZoneDef[] = ZONES) { const first = this.zones[0]; this.state = first ? this.forZone(first, 0) : { x: 0, y: 0, w: CAM_W, h: CAM_H }; } + /** Камера едет к аватару зрителя на durationMs (спотлайт), потом — возврат в тур. */ + setSpotlight(x: number, y: number, durationMs: number): void { + this.spotlight = { x, y }; + this.spotlightUntil = Date.now() + durationMs; + } + + clearSpotlight(): void { + this.spotlight = null; + this.spotlightUntil = 0; + } + /** activity — количество аватаров в каждой зоне (порядок ZONES). */ tick(nowMs: number, activity: number[]): boolean { + if (this.spotlight && nowMs >= this.spotlightUntil) { + this.clearSpotlight(); + } + if (this.spotlight) { + const w = 620; + const h = 430; + const next: CameraState = { + x: Math.round(this.spotlight.x - w / 2), + y: Math.round(this.spotlight.y - h / 2), + w, + h, + }; + if (Math.abs(next.x - this.state.x) < 0.5 && Math.abs(next.y - this.state.y) < 0.5) { + return false; + } + this.state = next; + return true; + } + if (this.zones.length > 1) { const curActive = (activity[this.zoneIdx] ?? 0) > 0; if (nowMs >= this.dwellUntil || !curActive) { diff --git a/packages/server/src/persist/db.ts b/packages/server/src/persist/db.ts index 4d36baa..a49d6fc 100644 --- a/packages/server/src/persist/db.ts +++ b/packages/server/src/persist/db.ts @@ -196,6 +196,13 @@ export class Db { } } + /** Полный сброс зрителя (дашборд). */ + deleteViewer(channelId: string, userId: string): void { + this.db.prepare('DELETE FROM viewers WHERE channel_id = ? AND user_id = ?').run(channelId, userId); + this.db.prepare('DELETE FROM viewer_skills WHERE channel_id = ? AND user_id = ?').run(channelId, userId); + this.db.prepare('DELETE FROM viewer_items WHERE channel_id = ? AND user_id = ?').run(channelId, userId); + } + close(): void { this.db.close(); } diff --git a/packages/server/src/sim/world.ts b/packages/server/src/sim/world.ts index da07b18..badd057 100644 --- a/packages/server/src/sim/world.ts +++ b/packages/server/src/sim/world.ts @@ -24,6 +24,7 @@ import { campfireLevelFor, colorForName, findRecipe, + itemCandidates, itemTitle, levelForTotalXp, restBonusFor, @@ -102,6 +103,7 @@ const CRAFT_ALIASES = new Set(['ковать', 'готовить', 'craft']); const STOP_ALIASES = new Set(['стоп', 'stop']); const REST_ALIASES = new Set(['отдых', 'rest']); const FIRE_ALIASES = new Set(['костёр', 'костер', 'fire']); +const GIFT_ALIASES = new Set(['подарить', 'gift']); const SKILL_IDS: SkillId[] = SKILLS.map((s) => s.id); @@ -157,6 +159,7 @@ export class SimWorld { private readonly dirtyViewers = new Map(); private readonly preloaded = new Map(); private readonly pendingEvents: GameEvent[] = []; + private readonly removedAvatars = new Set(); private statsDirty = true; /** Кооп-костёр: общий пул + ledger вкладов (мета-слой). */ @@ -196,6 +199,7 @@ export class SimWorld { else if (CRAFT_ALIASES.has(word)) this.doCraft(cmd, arg, now); else if (REST_ALIASES.has(word)) this.doRest(cmd, now); else if (FIRE_ALIASES.has(word)) this.doFire(cmd, now); + else if (GIFT_ALIASES.has(word)) this.doGift(cmd, arg, now); else if (STOP_ALIASES.has(word)) this.doStop(cmd, now); } @@ -374,8 +378,7 @@ export class SimWorld { av.pendingRest = true; } - private doFire(cmd: ChatCommand, now: number): void { - const av = this.ensureAvatar(cmd, now); + private doFire(cmd: ChatCommand, now: number): void { const av = this.ensureAvatar(cmd, now); if (this.onCooldown(av, now)) return; if (av.action === 'feed' && !av.moving) return; @@ -387,6 +390,94 @@ export class SimWorld { av.pendingFire = true; } + /** + * !подарить [@]ник предмет [кол-во]. Подарки только перемещают предметы: + * дарить можно лишь то, что у тебя есть (уходя — со склада офлайн-зрителей). + */ + private doGift(cmd: ChatCommand, arg: string, now: number): void { + const giver = this.ensureAvatar(cmd, now); + if (this.onCooldown(giver, now)) return; + + const parts = arg.trim().split(/\s+/).filter(Boolean); + if (parts.length < 2) { + this.pendingEvents.push( + this.blocked(giver, 'как подарить: !подарить @ник предмет [кол-во]'), + ); + return; + } + const targetName = (parts[0] ?? '').replace(/^@/, ''); + const last = parts[parts.length - 1] ?? ''; + const qtyParsed = Number(last); + const qty = Number.isInteger(qtyParsed) && qtyParsed > 0 && parts.length >= 3 ? qtyParsed : 1; + const itemText = (qty > 1 ? parts.slice(1, -1) : parts.slice(1)).join(' '); + + if (targetName.toLowerCase() === giver.name.toLowerCase()) { + this.pendingEvents.push(this.blocked(giver, 'себе дарить нечего')); + return; + } + const target = this.findViewerByName(targetName); + if (!target) { + this.pendingEvents.push( + this.blocked(giver, `не знаю зрителя «${targetName}» — пусть напишет любую команду`), + ); + return; + } + + const owned = itemCandidates(itemText).filter((id) => (giver.items.get(id) ?? 0) > 0); + if (owned.length === 0) { + const any = itemCandidates(itemText); + this.pendingEvents.push( + this.blocked( + giver, + any.length === 0 + ? `не знаю предмет «${itemText}»` + : `«${itemText}» у тебя нет — попробуй ${any.map((id) => itemTitle(id)).join(' / ')}`, + ), + ); + return; + } + if (owned.length > 1) { + this.pendingEvents.push( + this.blocked(giver, `уточни: ${owned.map((id) => itemTitle(id)).join(' или ')}`), + ); + return; + } + const item = owned[0]!; + const have = giver.items.get(item) ?? 0; + if (qty > have) { + this.pendingEvents.push(this.blocked(giver, `у тебя только ${have}× ${itemTitle(item)}`)); + return; + } + + this.takeItem(giver, item, qty); + if (target.kind === 'avatar') { + this.addItem(target.avatar, item, qty); + } else { + target.record.items[item] = (target.record.items[item] ?? 0) + qty; + this.dirtyViewers.set(target.record.userId, target.record); + } + this.pendingEvents.push({ + k: 'gift', + userId: giver.id, + name: giver.name, + color: giver.color, + toName: target.name, + item, + qty, + }); + } + + private findViewerByName( + name: string, + ): { kind: 'avatar'; avatar: SimAvatar; name: string } | { kind: 'record'; record: ViewerRecord; name: string } | null { + const lower = name.replace(/^@/, '').toLowerCase(); + const av = [...this.avatars.values()].find((a) => a.name.toLowerCase() === lower); + if (av) return { kind: 'avatar', avatar: av, name: av.name }; + const rec = [...this.preloaded.values()].find((r) => r.name.toLowerCase() === lower); + if (rec) return { kind: 'record', record: rec, name: rec.name }; + return null; + } + private doStop(cmd: ChatCommand, now: number): void { const av = this.ensureAvatar(cmd, now); if (this.onCooldown(av, now)) return; @@ -843,6 +934,48 @@ export class SimWorld { this.addCampfireProgress(points, null, []); } + /** Спавн аватара по внешнему событию (спотлайт): аватар появляется у спавна. */ + spawnAvatar(userId: string, name: string, color: string): AvatarState { + const av = this.ensureAvatar({ userId, name, color }, Date.now()); + return this.serializeAvatar(av); + } + + /** Сброс костра (дашборд): прогресс/уровень в ноль; ledger — по флагу. */ + resetCampfire(keepLedger: boolean): void { + this.campfireProgress = 0; + this.campfireLevel = 1; + this.campfireDirty = true; + if (!keepLedger) this.campfireLedger.clear(); + } + + /** Сброс зрителя (дашборд): аватар исчезает из мира, запись стирается. */ + resetViewer(name: string): { userId: string; name: string } | null { + const lower = name.toLowerCase(); + const av = [...this.avatars.values()].find((a) => a.name.toLowerCase() === lower); + if (av) { + this.avatars.delete(av.id); + this.preloaded.delete(av.id); + this.dirtyViewers.delete(av.id); + this.removedAvatars.add(av.id); + this.statsDirty = true; + return { userId: av.id, name: av.name }; + } + const rec = [...this.preloaded.values()].find((r) => r.name.toLowerCase() === lower); + if (rec) { + this.preloaded.delete(rec.userId); + this.dirtyViewers.delete(rec.userId); + return { userId: rec.userId, name: rec.name }; + } + return null; + } + + takeRemovedAvatars(): string[] { + if (this.removedAvatars.size === 0) return []; + const out = [...this.removedAvatars]; + this.removedAvatars.clear(); + return out; + } + lookupViewer( name: string, ): @@ -975,10 +1108,10 @@ export class SimWorld { // ---- спавн ---- - private ensureAvatar(cmd: ChatCommand, now: number): SimAvatar { - let av = this.avatars.get(cmd.userId); + private ensureAvatar(user: { userId: string; name: string; color: string }, now: number): SimAvatar { + let av = this.avatars.get(user.userId); if (!av) { - const rec = this.preloaded.get(cmd.userId); + const rec = this.preloaded.get(user.userId); const skills = {} as Record; for (const id of SKILL_IDS) { const xp = rec?.skills[id] ?? 0; @@ -989,9 +1122,9 @@ export class SimWorld { if (!items.has(item)) items.set(item, 1); } av = { - id: cmd.userId, - name: cmd.name, - color: cmd.color || colorForName(cmd.name), + id: user.userId, + name: user.name, + color: user.color || colorForName(user.name), x: WORLD.spawn.x + (Math.random() * 60 - 30), y: WORLD.spawn.y + (Math.random() * 40 - 20), tx: WORLD.spawn.x, @@ -1019,9 +1152,9 @@ export class SimWorld { this.markViewerDirty(av); this.statsDirty = true; this.pendingEvents.push({ k: 'spawn', userId: av.id, name: av.name, color: av.color }); - } else if (av.name !== cmd.name || av.color !== cmd.color) { - av.name = cmd.name; - av.color = cmd.color || av.color; + } else if (av.name !== user.name || av.color !== user.color) { + av.name = user.name; + av.color = user.color || av.color; this.dirtyAvatars.add(av.id); this.markViewerDirty(av); } diff --git a/packages/server/src/twitch/eventsub.ts b/packages/server/src/twitch/eventsub.ts new file mode 100644 index 0000000..4ca9d20 --- /dev/null +++ b/packages/server/src/twitch/eventsub.ts @@ -0,0 +1,52 @@ +import { RefreshingAuthProvider } from '@twurple/auth'; +import { ApiClient } from '@twurple/api'; +import { EventSubWsListener } from '@twurple/eventsub-ws'; +import type { TwitchConfig } from '../config'; + +/** + * Спотлайт через Channel Points (EventSub WebSocket — работает локально без + * публичного webhook-URL). Реагирует на выкуп награды с именем из конфига. + */ +export class SpotlightListener { + private listener: EventSubWsListener | null = null; + + constructor( + private readonly cfg: TwitchConfig, + private readonly broadcasterId: string, + private readonly onRedemption: (userId: string, userName: string, displayName: string, rewardTitle: string) => void, + ) {} + + async start(): Promise { + const authProvider = new RefreshingAuthProvider({ + clientId: this.cfg.clientId, + clientSecret: this.cfg.clientSecret, + }); + await authProvider.addUserForToken({ + accessToken: this.cfg.streamerToken ?? '', + refreshToken: this.cfg.streamerRefresh ?? null, + scope: ['channel:read:redemptions'], + expiresIn: 3600, + obtainmentTimestamp: Date.now(), + }); + + this.listener = new EventSubWsListener({ + apiClient: new ApiClient({ authProvider }), + }); + this.listener.onChannelRedemptionAdd(this.broadcasterId, (event) => { + // add приходит со status='unfulfilled' — реагируем один раз + if (event.status !== 'unfulfilled') return; + this.onRedemption( + event.userId, + event.userName, + event.userDisplayName ?? event.userName, + event.rewardTitle, + ); + }); + await this.listener.start(); + } + + stop(): void { + this.listener?.stop(); + this.listener = null; + } +} diff --git a/packages/server/src/twitch/twurple.ts b/packages/server/src/twitch/twurple.ts index 1b3a3e7..e10037a 100644 --- a/packages/server/src/twitch/twurple.ts +++ b/packages/server/src/twitch/twurple.ts @@ -27,13 +27,16 @@ export class TwurpleSource implements TwitchSource { }); // twitchtokengenerator выдаёт access на ~4 ч + долгоживущий refresh: // провайдер сам обновит токен до истечения; между рестартами работает refresh - await authProvider.addUserForToken({ - accessToken: this.cfg.botToken, - refreshToken: this.cfg.botRefresh, - scope: ['chat:read', 'chat:edit'], - expiresIn: 3600, - obtainmentTimestamp: Date.now(), - }); + await authProvider.addUserForToken( + { + accessToken: this.cfg.botToken, + refreshToken: this.cfg.botRefresh, + scope: ['chat:read', 'chat:edit'], + expiresIn: 3600, + obtainmentTimestamp: Date.now(), + }, + ['chat'], + ); authProvider.onRefresh((_userId, token) => { console.log(`[twitch] токен обновлён, действителен ещё ${token.expiresIn ?? '?'} с`); }); diff --git a/packages/shared/src/content.ts b/packages/shared/src/content.ts index 39806b6..407420b 100644 --- a/packages/shared/src/content.ts +++ b/packages/shared/src/content.ts @@ -303,6 +303,43 @@ export function findRecipe(text: string): RecipeDef | undefined { }); } +// ---- предметы: поиск по названию (для !подарить) ---- + +export const ITEM_ALIASES: Record = { + log: ['брёвна', 'бревно', 'дрова', 'дерево'], + copper_ore: ['медная руда'], + iron_ore: ['железная руда'], + copper_bar: ['медный слиток'], + iron_bar: ['железный слиток'], + raw_fish: ['сырая рыба', 'рыбка'], + cooked_fish: ['жареная рыба'], + axe_rusty: ['ржавый топор'], + axe_iron: ['железный топор'], + pick_rusty: ['ржавая кирка'], + pick_iron: ['железная кирка'], +}; + +/** Все id предметов, чьи слова матчатся запросу (точное совпадение — первым). */ +export function itemCandidates(text: string): string[] { + const q = text.trim().toLowerCase().replace(/\s+/g, ' '); + if (!q) return []; + const ids = Object.keys(ITEMS); + const exact = ids.filter((id) => { + const item = ITEMS[id]; + return id === q || item?.title === q || (ITEM_ALIASES[id] ?? []).includes(q); + }); + if (exact.length > 0) return exact; + const qStems = q.split(' ').map(stem); + return ids.filter((id) => { + const stems = new Set( + [ITEMS[id]?.title ?? '', ...(ITEM_ALIASES[id] ?? [])] + .flatMap((w) => w.split(' ')) + .map(stem), + ); + return qStems.every((s) => stems.has(s)); + }); +} + // ---- прочее ---- export const WALK_SPEED = 110; // px/сек diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 9f73e59..44cbccc 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -8,7 +8,7 @@ * дельты вместо полных снапшотов, игровые события, серверная камера. */ -export const PROTOCOL_VERSION = 5; +export const PROTOCOL_VERSION = 6; export interface ZoneDef { id: string; @@ -107,6 +107,8 @@ export type GameEvent = | { k: 'rest'; userId: string; name: string; color: string; stacks: number } | { k: 'fire'; userId: string; name: string; color: string; contributed: number; progress: number; level: number } | { k: 'firelevel'; level: number } + | { k: 'gift'; userId: string; name: string; color: string; toName: string; item: string; qty: number } + | { k: 'spotlight'; name: string; color: string } | { k: 'blocked'; userId: string; name: string; color: string; reason: string }; export interface WelcomeMsg { @@ -151,6 +153,8 @@ export interface DeltaMsg { stats?: ViewerStat[]; campfire?: CampfireState; events?: GameEvent[]; + /** Аватары, исчезнувшие из мира (сброс зрителя с дашборда). */ + removed?: string[]; } export type ServerMessage = WelcomeMsg | SnapshotMsg | DeltaMsg; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 6b4618a..d493701 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -62,6 +62,10 @@ return `🪵 ${e.name} подкинул(ла) дров — костёр ${e.progress} (вклад ${e.contributed})`; case 'firelevel': return `🔥 Костёр достиг ${e.level}-го уровня!`; + case 'gift': + return `🎁 ${e.name} → ${e.toName}: ${e.qty}× ${itemTitle(e.item)}`; + case 'spotlight': + return `🔍 Спотлайт: ${e.name}!`; case 'blocked': return `${e.name}: ${e.reason}`; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a397e0e..90b00bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,12 +48,18 @@ importers: '@idle/shared': specifier: workspace:* version: link:../shared + '@twurple/api': + specifier: ^8.1.4 + version: 8.1.4(@twurple/auth@8.1.4) '@twurple/auth': specifier: ^8.0.0 version: 8.1.4 '@twurple/chat': specifier: ^8.0.0 version: 8.1.4(@twurple/auth@8.1.4) + '@twurple/eventsub-ws': + specifier: ^8.1.4 + version: 8.1.4(@twurple/api@8.1.4(@twurple/auth@8.1.4)) sirv: specifier: ^3.0.0 version: 3.0.2 @@ -483,6 +489,11 @@ packages: '@twurple/api-call@8.1.4': resolution: {integrity: sha512-qh2TpdxxyiSkwadcCSes6uBHQB6l4Fz8sVfmzk+Brb12asemHMXTEyQAdrMJT7LlgtZq01nr+RASzWM3jmGtkw==} + '@twurple/api@8.1.4': + resolution: {integrity: sha512-UA2eg5lyZRB0w55NjGvdhSmWPyIBaOthFKGPjg3L/jCQVYnY/FcmUuPipe2Op9U4Ej99c29cdjsmTSQI7P7Vqg==} + peerDependencies: + '@twurple/auth': 8.1.4 + '@twurple/auth@8.1.4': resolution: {integrity: sha512-ylsJoPInCw9BwOqxKcx+1k2ce9QG3vJpKFzPdIyHh49HvM/ulQZ0CAGysydugDYXF0iO/TGryh7PluSwx5fIwA==} @@ -494,6 +505,14 @@ packages: '@twurple/common@8.1.4': resolution: {integrity: sha512-1iN5DvOnW+g+Nl3OTI5zUJHgAfjmPCb50HpKsAFik6OYQEAHLsscQKgTOJ+KRuFBYepo/JkHsOWOmWhXxnK6lQ==} + '@twurple/eventsub-base@8.1.4': + resolution: {integrity: sha512-tMt44+BoepEBRDn90d+axP9F+0JH0/AT6to0Fz6O/JyIXBXMa7GiZxAkHG6pMZsFYfhKZkb4aehP1iQfc2PkFQ==} + + '@twurple/eventsub-ws@8.1.4': + resolution: {integrity: sha512-fFqwkVq2/T48VVkPYlgIQCYsKN82MvOS51xMh8LPLW0PLVfau5b4v8X88JWx7wzGNZ9d9DImDhCHs+JV/efpfg==} + peerDependencies: + '@twurple/api': 8.1.4 + '@types/earcut@3.0.0': resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==} @@ -631,6 +650,10 @@ packages: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + rollup@4.63.1: resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -999,6 +1022,20 @@ snapshots: '@twurple/common': 8.1.4 tslib: 2.8.1 + '@twurple/api@8.1.4(@twurple/auth@8.1.4)': + dependencies: + '@d-fischer/cache-decorators': 4.0.1 + '@d-fischer/detect-node': 3.0.1 + '@d-fischer/logger': 4.2.4 + '@d-fischer/rate-limiter': 1.1.0 + '@d-fischer/shared-utils': 3.6.4 + '@d-fischer/typed-event-emitter': 3.3.3 + '@twurple/api-call': 8.1.4 + '@twurple/auth': 8.1.4 + '@twurple/common': 8.1.4 + retry: 0.13.1 + tslib: 2.8.1 + '@twurple/auth@8.1.4': dependencies: '@d-fischer/logger': 4.2.4 @@ -1030,6 +1067,31 @@ snapshots: klona: 2.0.6 tslib: 2.8.1 + '@twurple/eventsub-base@8.1.4': + dependencies: + '@d-fischer/logger': 4.2.4 + '@d-fischer/shared-utils': 3.6.4 + '@d-fischer/typed-event-emitter': 3.3.3 + '@twurple/api': 8.1.4(@twurple/auth@8.1.4) + '@twurple/auth': 8.1.4 + '@twurple/common': 8.1.4 + tslib: 2.8.1 + + '@twurple/eventsub-ws@8.1.4(@twurple/api@8.1.4(@twurple/auth@8.1.4))': + dependencies: + '@d-fischer/connection': 10.0.1 + '@d-fischer/logger': 4.2.4 + '@d-fischer/shared-utils': 3.6.4 + '@d-fischer/typed-event-emitter': 3.3.3 + '@twurple/api': 8.1.4(@twurple/auth@8.1.4) + '@twurple/auth': 8.1.4 + '@twurple/common': 8.1.4 + '@twurple/eventsub-base': 8.1.4 + tslib: 2.8.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@types/earcut@3.0.0': {} '@types/estree@1.0.9': {} @@ -1172,6 +1234,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + retry@0.13.1: {} + rollup@4.63.1: dependencies: '@types/estree': 1.0.9