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 — дашборд стримера
+
+
+
+
+ Состояние
+ загрузка…
+
+
+
+ Костёр
+
+
+ Сбросить прогресс (сохранить вклады)
+ Полный сброс костра
+
+
+
+
+
+
+ Объявления в чат
+
+ Включить
+ Выключить
+
+
+ Левелапы, рост костра и подарки — пишутся ботом в чат (с троттлингом).
+
+
+
+
+
+
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