feat: economy and polishing
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user