feat: economy and polishing
This commit is contained in:
@@ -42,7 +42,7 @@
|
||||
<div class="badge">{STATUS_TEXT[status]}</div>
|
||||
<!-- онбординг — в самом виджете, чат ботом не засоряем -->
|
||||
{#if status === 'online'}
|
||||
<div class="hint">!рубить · !копать · !рыбачить · !ковать <рецепт> · !готовить <рецепт> · !отдых · !костёр · !стоп</div>
|
||||
<div class="hint">!рубить · !копать · !рыбачить · !ковать/!готовить <рецепт> · !отдых · !костёр · !подарить · !топ · !стоп</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Idle XBOCT — дашборд</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { margin: 0; background: #151a21; color: #dfe7f0; font: 14px/1.5 system-ui, sans-serif; }
|
||||
header { padding: 14px 20px; border-bottom: 1px solid #2a3242; font-weight: 700; letter-spacing: 0.04em; }
|
||||
main { max-width: 900px; margin: 0 auto; padding: 16px 20px 40px; }
|
||||
section { border: 1px solid #2a3242; border-radius: 10px; padding: 14px 16px; margin-bottom: 16px; }
|
||||
h2 { margin: 0 0 10px; font-size: 15px; color: #b7c4d4; }
|
||||
button { background: #2f3a4c; color: #dfe7f0; border: none; border-radius: 6px; padding: 6px 12px; font-size: 13px; cursor: pointer; margin-right: 8px; }
|
||||
button:hover { background: #3a4759; }
|
||||
input { background: #232b38; border: 1px solid #2a3242; border-radius: 6px; color: #dfe7f0; padding: 5px 8px; font-size: 13px; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||
th, td { text-align: left; padding: 4px 8px; border-bottom: 1px solid #232b38; }
|
||||
th { color: #8a99ab; font-weight: 600; }
|
||||
.firebar { height: 10px; background: #232b38; border-radius: 5px; overflow: hidden; margin: 6px 0 10px; }
|
||||
.firefill { height: 100%; background: linear-gradient(90deg, #ff8c3b, #ffd166); }
|
||||
.muted { color: #6d7d90; font-size: 12px; }
|
||||
.row { margin-bottom: 8px; }
|
||||
#status { font: 12px monospace; color: #7ee2a8; margin-left: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>Idle XBOCT — дашборд стримера <span id="status"></span></header>
|
||||
<main>
|
||||
<section>
|
||||
<h2>Ключ администратора</h2>
|
||||
<div class="row">
|
||||
<input id="key" type="password" placeholder="ADMIN_KEY (если задан в .env)" style="width: 320px" />
|
||||
<button id="reload">Обновить</button>
|
||||
</div>
|
||||
<p class="muted">Ключ нужен, только если ADMIN_KEY задан в .env. Без него дашборд открыт — держи сервер за localhost.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Состояние</h2>
|
||||
<div id="state" class="muted">загрузка…</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Костёр</h2>
|
||||
<div id="fire"></div>
|
||||
<div class="row">
|
||||
<button data-action="campfire-reset" data-keep="1">Сбросить прогресс (сохранить вклады)</button>
|
||||
<button data-action="campfire-reset" data-keep="0">Полный сброс костра</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Зритель</h2>
|
||||
<div class="row">
|
||||
<input id="viewer" placeholder="ник зрителя" style="width: 220px" />
|
||||
<button data-action="viewer-reset">Сбросить зрителя</button>
|
||||
</div>
|
||||
<p class="muted">Стирает навыки, инвентарь и вклад в костёр. Аватар исчезнет из мира у всех клиентов.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Объявления в чат</h2>
|
||||
<div class="row">
|
||||
<button data-action="announce" data-value="1">Включить</button>
|
||||
<button data-action="announce" data-value="0">Выключить</button>
|
||||
<span id="announceState" class="muted"></span>
|
||||
</div>
|
||||
<p class="muted">Левелапы, рост костра и подарки — пишутся ботом в чат (с троттлингом).</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
let adminKey = localStorage.getItem('idle-admin-key') ?? '';
|
||||
document.getElementById('key').value = adminKey;
|
||||
document.getElementById('key').addEventListener('change', (e) => {
|
||||
adminKey = e.target.value;
|
||||
localStorage.setItem('idle-admin-key', adminKey);
|
||||
});
|
||||
|
||||
async function api(path, body) {
|
||||
const res = await fetch(path, {
|
||||
method: body === undefined ? 'GET' : 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-admin-key': adminKey },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (!res.ok) throw new Error(j.error ?? res.status);
|
||||
return j;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function firePct(progress, level, thr) {
|
||||
if (level >= thr.length) return 100;
|
||||
const lo = thr[level - 1] ?? 0;
|
||||
const hi = thr[level] ?? 1;
|
||||
return Math.min(100, Math.max(0, ((progress - lo) / (hi - lo)) * 100));
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const s = await api('/api/admin/state');
|
||||
document.getElementById('status').textContent = `канал ${esc(s.channel)} · зрителей ${s.viewers} · аптайм ${Math.floor(s.uptimeSec / 60)} мин`;
|
||||
const thr = [0, 100, 300, 700, 1500];
|
||||
document.getElementById('fire').innerHTML =
|
||||
`Уровень ${s.campfire.level} — ${s.campfire.progress} очков` +
|
||||
`<div class="firebar"><div class="firefill" style="width:${firePct(s.campfire.progress, s.campfire.level, thr)}%"></div></div>` +
|
||||
(s.campfire.top.length
|
||||
? '<table><tr><th>Вкладчик</th><th>Вклад</th></tr>' +
|
||||
s.campfire.top.map((t) => `<tr><td style="color:${esc(t.color)}">${esc(t.name)}</td><td>${t.contributed}</td></tr>`).join('') +
|
||||
'</table>'
|
||||
: '<p class="muted">вкладчиков пока нет</p>');
|
||||
document.getElementById('state').innerHTML =
|
||||
`Тик ${s.tickMs} мс · аптайм ${s.uptimeSec} с · очередь спотлайта: ${s.spotlightQueue}` +
|
||||
'<table><tr><th>Зритель</th><th>Уровень</th><th>Действие</th></tr>' +
|
||||
s.top.map((v) => {
|
||||
const lvl = Math.max(1, ...Object.values(v.skills).map((x) => x.level));
|
||||
return `<tr><td style="color:${esc(v.color)}">${esc(v.name)}</td><td>${lvl}</td><td>${esc(v.action)}</td></tr>`;
|
||||
}).join('') + '</table>';
|
||||
document.getElementById('announceState').textContent = s.chatAnnounce ? 'включены' : 'выключены';
|
||||
} catch (e) {
|
||||
document.getElementById('status').textContent = 'ошибка: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('button[data-action]');
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.action;
|
||||
try {
|
||||
if (action === 'campfire-reset') {
|
||||
await api('/api/admin/campfire/reset', { keepLedger: btn.dataset.keep === '1' });
|
||||
} else if (action === 'viewer-reset') {
|
||||
const name = document.getElementById('viewer').value.trim();
|
||||
if (!name) return;
|
||||
await api('/api/admin/viewer/reset', { name });
|
||||
} else if (action === 'announce') {
|
||||
await api('/api/admin/announce', { enabled: btn.dataset.value === '1' });
|
||||
}
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
alert('Ошибка: ' + err.message);
|
||||
}
|
||||
});
|
||||
document.getElementById('reload').addEventListener('click', refresh);
|
||||
refresh();
|
||||
setInterval(refresh, 15000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<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 = {
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<string, ViewerRecord>();
|
||||
private readonly preloaded = new Map<string, ViewerRecord>();
|
||||
private readonly pendingEvents: GameEvent[] = [];
|
||||
private readonly removedAvatars = new Set<string>();
|
||||
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<SkillId, SkillSim>;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 ?? '?'} с`);
|
||||
});
|
||||
|
||||
@@ -303,6 +303,43 @@ export function findRecipe(text: string): RecipeDef | undefined {
|
||||
});
|
||||
}
|
||||
|
||||
// ---- предметы: поиск по названию (для !подарить) ----
|
||||
|
||||
export const ITEM_ALIASES: Record<string, string[]> = {
|
||||
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/сек
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user