feat: economy and polishing

This commit is contained in:
2026-09-05 19:12:28 +05:00
parent ca830b00b9
commit c56ec113cc
18 changed files with 868 additions and 42 deletions
+46 -16
View File
@@ -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,
};
}
+211 -2
View File
@@ -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;
+32
View File
@@ -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) {
+7
View File
@@ -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();
}
+144 -11
View File
@@ -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);
}
+52
View File
@@ -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;
}
}
+10 -7
View File
@@ -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 ?? '?'} с`);
});