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
+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;