feat: add server and frontend initial - wood chopping

This commit is contained in:
2026-09-05 16:37:45 +05:00
parent c79681dc59
commit f6c338e1b0
28 changed files with 1857 additions and 195 deletions
+3
View File
@@ -6,11 +6,14 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"smoke": "node smoke.mjs",
"build": "tsc --noEmit",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@idle/shared": "workspace:*",
"@twurple/auth": "^8.0.0",
"@twurple/chat": "^8.0.0",
"sirv": "^3.0.0",
"ws": "^8.18.0"
},
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env node
/* Отправить команду в мир через dev-эндпоинт (сервер должен быть запущен с DEV_HTTP=1):
node scripts/dev-cmd.mjs Тестер '!рубить' */
const [name, ...rest] = process.argv.slice(2);
const text = rest.join(' ');
if (!name || !text) {
console.error('usage: node scripts/dev-cmd.mjs <ник> <!команда>');
process.exit(1);
}
const res = await fetch('http://localhost:3000/dev/command', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name, text }),
});
console.log(res.status, await res.text());
+49 -19
View File
@@ -1,38 +1,68 @@
/* Дымовой тест M0: сервер должен быть запущен. `pnpm --filter @idle/server smoke` */
/* Дымовой тест M1: сервер должен быть запущен с DEV_HTTP=1, SQLITE_PATH=:memory:.
`pnpm --filter @idle/server smoke` */
import WebSocket from 'ws';
const seen = [];
const BASE = 'http://localhost:3000';
const WS = 'ws://localhost:3000/ws';
function connect(onMsg) {
const ws = new WebSocket(WS);
ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 2 })));
ws.on('message', (d) => onMsg(JSON.parse(String(d))));
return ws;
}
// 1. welcome + полный снапшот
const first = [];
await new Promise((resolve, reject) => {
const ws = new WebSocket('ws://localhost:3000/ws');
ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 1 })));
ws.on('message', (d) => {
seen.push(JSON.parse(String(d)));
if (seen.length >= 2) {
const ws = connect((m) => {
first.push(m);
if (first.length >= 2) {
ws.close();
resolve();
}
});
ws.on('error', reject);
setTimeout(() => reject(new Error('timeout waiting for welcome+snapshot')), 5000);
setTimeout(() => reject(new Error('timeout waiting welcome+snapshot')), 5000);
});
const [welcome, snap] = first;
console.log('welcome:', JSON.stringify({ t: welcome.t, v: welcome.v, tickMs: welcome.tickMs }));
if (welcome.t !== 'welcome' || welcome.v !== 2) throw new Error(`bad welcome: ${JSON.stringify(welcome)}`);
if (snap.t !== 'snapshot' || snap.nodes.length !== 7) throw new Error(`bad snapshot: ${snap.t}, nodes=${snap.nodes?.length}`);
console.log(`snapshot: деревьев=${snap.nodes.length}, зрителей=${snap.avatars.length}`);
const [welcome, snap] = seen;
console.log('welcome:', JSON.stringify(welcome));
console.log(
'avatars:',
snap.avatars.map((a) => `${a.name}@${a.x},${a.y} ${a.face}`).join(' | '),
);
if (welcome.t !== 'welcome' || welcome.v !== 1) throw new Error('bad welcome');
if (snap.t !== 'snapshot' || snap.avatars.length !== 3) throw new Error('bad snapshot');
// 2. команда через dev-эндпоинт
const res = await fetch(`${BASE}/dev/command`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'Тестер', text: '!рубить' }),
});
if (!res.ok) throw new Error(`dev command failed: ${res.status} ${await res.text()}`);
console.log('dev command: accepted');
// неверная версия протокола — сервер обязан закрыть сокет с кодом 4001
// 3. ждём дельту: аватар «Тестер» должен заспавниться и пойти к дереву
const delta = await new Promise((resolve, reject) => {
const ws = connect((m) => {
if (m.t === 'delta' && m.avatars?.some((a) => a.name === 'Тестер')) {
ws.close();
resolve(m);
}
});
ws.on('error', reject);
setTimeout(() => reject(new Error('no delta with Тестер')), 8000);
});
const tester = delta.avatars.find((a) => a.name === 'Тестер');
console.log(`delta: ${tester.name} moving=${tester.moving} action=${tester.action} target=(${tester.tx},${tester.ty})`);
console.log(`events: ${(delta.events ?? []).map((e) => e.k).join(',') || '—'}`);
if (!tester.moving || tester.action !== 'idle') throw new Error('Тестер должен идти к дереву (idle + moving)');
// 4. неверная версия протокола — close 4001
const closeCode = await new Promise((resolve) => {
const ws = new WebSocket('ws://localhost:3000/ws');
const ws = new WebSocket(WS);
ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 999 })));
ws.on('close', (code) => resolve(code));
setTimeout(() => resolve('no-close'), 3000);
});
console.log('close code for wrong version:', closeCode);
if (closeCode !== 4001) throw new Error(`expected close 4001, got ${closeCode}`);
console.log('WS smoke: OK');
+79
View File
@@ -0,0 +1,79 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** Крошечный загрузчик .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, '');
}
}
export interface TwitchConfig {
clientId: string;
clientSecret: string;
channel: string;
botToken: string;
botRefresh: string;
}
export interface AppConfig {
port: number;
tickMs: number;
dbPath: string;
/** channelId присутствует везде с первого дня (задел на мультистримерный хостинг). */
channelId: string;
fakeViewers: number;
devHttp: boolean;
chatAnnounceLevelUp: boolean;
twitch: TwitchConfig | null;
}
function num(name: string, def: number): number {
const v = Number(process.env[name]);
return Number.isFinite(v) && v > 0 ? v : def;
}
function bool(name: string, def: boolean): boolean {
const v = process.env[name];
if (v === undefined || v === '') return def;
return v !== '0' && v.toLowerCase() !== 'false';
}
export function loadConfig(): AppConfig {
loadDotEnv();
const clientId = process.env.TWITCH_CLIENT_ID ?? '';
const clientSecret = process.env.TWITCH_CLIENT_SECRET ?? '';
const channel = (process.env.TWITCH_CHANNEL ?? '').toLowerCase();
const botToken = process.env.TWITCH_BOT_TOKEN ?? '';
const botRefresh = process.env.TWITCH_BOT_REFRESH ?? '';
const twitch =
clientId && clientSecret && channel && botToken && botRefresh
? { clientId, clientSecret, channel, botToken, botRefresh }
: null;
return {
port: num('PORT', 3000),
tickMs: num('TICK_MS', 1000),
dbPath: process.env.SQLITE_PATH ?? 'data/idle-xboct.db',
channelId: twitch ? twitch.channel : '__local__',
fakeViewers: num('FAKE_VIEWERS', 0),
devHttp: bool('DEV_HTTP', false),
chatAnnounceLevelUp: bool('CHAT_ANNOUNCE_LEVELUP', true),
twitch,
};
}
+179 -26
View File
@@ -4,37 +4,156 @@ import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import type { IncomingMessage, ServerResponse } from 'node:http';
import sirv from 'sirv';
import { PROTOCOL_VERSION, type ServerMessage } from '@idle/shared';
import { WsGateway } from './net/ws';
import { stubAvatarsAt } from './sim/stub';
import {
PROTOCOL_VERSION,
WORLD,
ZONES,
colorForName,
type DeltaMsg,
type GameEvent,
type SnapshotMsg,
type WelcomeMsg,
} from '@idle/shared';
import { loadConfig } 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 type { TwitchSource } from './twitch/source';
const PORT = Number(process.env.PORT ?? 3000);
const TICK_MS = Number(process.env.TICK_MS ?? 1000);
const startedAt = Date.now();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const currentTick = (): number => Math.floor((Date.now() - startedAt) / TICK_MS);
const snapshot = (): ServerMessage => ({
t: 'snapshot',
tick: currentTick(),
avatars: stubAvatarsAt(Date.now()),
// node:sqlite числится экспериментальным — глушим только это предупреждение
process.on('warning', (w) => {
if (w.name === 'ExperimentalWarning' && /sqlite/i.test(w.message)) return;
console.warn(w.toString());
});
const gateway = new WsGateway({ getSnapshot: snapshot, tickMs: TICK_MS });
const cfg = loadConfig();
const startedAt = Date.now();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const currentTick = (): number => Math.floor((Date.now() - startedAt) / cfg.tickMs);
// Собранные клиенты раздаём сами — OBS обращается к одному порту.
// В dev клиенты живут на своих vite-портах (5173/5174).
const staticMounts = [
{ prefix: '/overlay/', dir: path.resolve(__dirname, '../../overlay/dist') },
{ prefix: '/web/', dir: path.resolve(__dirname, '../../web/dist') },
].map((m) => ({ ...m, middleware: existsSync(m.dir) ? sirv(m.dir) : null }));
const db = new Db(cfg.dbPath);
const sim = new SimWorld(cfg.channelId, db.loadViewers(cfg.channelId));
const camera = new CameraController();
function handleCommand(cmd: ChatCommand): void {
const before = sim.avatars.size;
sim.handleCommand(cmd);
if (sim.avatars.size > before) {
console.log(`[game] новый зритель в мире: ${cmd.name}`);
}
}
// ---- источники команд чата ----
const sources: TwitchSource[] = [];
if (cfg.twitch) {
const src = new TwurpleSource(cfg.twitch, handleCommand);
void src
.start()
.then(() => console.log(`[twitch] бот слушает чат канала ${cfg.twitch?.channel}`))
.catch((e) => console.error('[twitch] не запустился (проверь токены/сигнатуры twurple):', e));
sources.push(src);
} else {
console.log('[twitch] TWITCH_* не заданы — живой чат выключен (см. docs/twitch-setup.md)');
}
if (cfg.fakeViewers > 0) {
const fake = new FakeTwitchSource(cfg.channelId, cfg.fakeViewers, handleCommand);
void fake.start();
sources.push(fake);
console.log(`[fake] ${cfg.fakeViewers} фейковых зрителей генерируют команды`);
}
// значимые события пишем первым ответившим источником, с его троттлингом
const announce = (text: string): boolean => sources.some((s) => s.announce(text));
// ---- источник мира для WS-шлюза ----
const world: WorldSource = {
welcome(): WelcomeMsg {
return {
t: 'welcome',
v: PROTOCOL_VERSION,
tickMs: cfg.tickMs,
serverNow: Date.now(),
world: { w: WORLD.w, h: WORLD.h, spawn: { ...WORLD.spawn }, zones: ZONES },
};
},
snapshot(): SnapshotMsg {
return {
t: 'snapshot',
tick: currentTick(),
serverNow: Date.now(),
camera: camera.state,
avatars: [...sim.avatars.values()].map((av) => sim.serializeAvatar(av)),
nodes: sim.serializeTrees(),
stats: sim.stats(),
};
},
};
const gateway = new WsGateway(world);
// ---- тик ----
function buildDelta(events: GameEvent[], camChanged: boolean): DeltaMsg | null {
const avatars = sim.takeDirtyAvatars();
const nodes = sim.takeDirtyTrees();
const stats = sim.takeStatsIfDirty();
if (avatars.length === 0 && nodes.length === 0 && !stats && events.length === 0 && !camChanged) {
return null;
}
const msg: DeltaMsg = { t: 'delta', tick: currentTick(), serverNow: Date.now() };
if (camChanged) msg.camera = camera.state;
if (avatars.length > 0) msg.avatars = avatars;
if (nodes.length > 0) msg.nodes = nodes;
if (stats) msg.stats = stats;
if (events.length > 0) msg.events = events;
return msg;
}
setInterval(() => {
const now = Date.now();
const events = sim.tick(now);
const camChanged = camera.tick(now);
for (const rec of sim.takeDirtyViewers()) db.saveViewer(cfg.channelId, rec);
for (const ev of events) {
if (ev.k === 'levelup' && cfg.chatAnnounceLevelUp) {
announce(`🌲 ${ev.name} вырос(ла) до уровня ${ev.level} в рубке леса!`);
}
}
const delta = buildDelta(events, camChanged);
if (delta) gateway.broadcast(delta);
}, cfg.tickMs);
// ---- http: healthz, dev-эндпоинты, раздача собранных клиентов ----
function json(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
}
function readBody(req: IncomingMessage, cb: (body: string) => void): void {
let data = '';
req.on('data', (chunk) => {
data += chunk;
if (data.length > 10_000) req.destroy();
});
req.on('end', () => cb(data));
}
const staticMounts = [
{ prefix: '/overlay/', dir: path.resolve(__dirname, '../../overlay/dist') },
{ prefix: '/web/', dir: path.resolve(__dirname, '../../web/dist') },
].map((m) => ({ ...m, middleware: existsSync(m.dir) ? sirv(m.dir) : null }));
function handleHttp(req: IncomingMessage, res: ServerResponse): void {
const url = new URL(req.url ?? '/', 'http://localhost');
const p = url.pathname;
@@ -43,12 +162,45 @@ function handleHttp(req: IncomingMessage, res: ServerResponse): void {
json(res, 200, {
ok: true,
protocol: PROTOCOL_VERSION,
tickMs: TICK_MS,
tickMs: cfg.tickMs,
uptimeSec: Math.round((Date.now() - startedAt) / 1000),
viewers: sim.avatars.size,
channel: cfg.channelId,
});
return;
}
if (cfg.devHttp && p === '/dev/command' && req.method === 'POST') {
readBody(req, (body) => {
try {
const parsed = JSON.parse(body || '{}') as { name?: string; color?: string; text?: string };
const name = (parsed.name ?? '').trim();
const text = (parsed.text ?? '').trim();
if (!name || !text) {
json(res, 400, { error: 'нужны name и text' });
return;
}
handleCommand({
channelId: cfg.channelId,
userId: `dev:${name}`,
login: name,
name,
color: parsed.color ?? colorForName(name),
text,
});
json(res, 200, { ok: true });
} catch (e) {
json(res, 400, { error: String(e) });
}
});
return;
}
if (cfg.devHttp && p === '/dev/state' && req.method === 'GET') {
json(res, 200, world.snapshot());
return;
}
for (const m of staticMounts) {
if (!m.middleware) continue;
const base = m.prefix.slice(0, -1);
@@ -81,13 +233,14 @@ server.on('upgrade', (req, socket, head) => {
}
});
setInterval(() => gateway.broadcast(snapshot()), TICK_MS);
server.listen(PORT, () => {
console.log(`[idle-xboct] http://localhost:${PORT} — протокол v${PROTOCOL_VERSION}, тик ${TICK_MS} мс`);
server.listen(cfg.port, () => {
console.log(`[idle-xboct] http://localhost:${cfg.port} — протокол v${PROTOCOL_VERSION}, тик ${cfg.tickMs} мс, канал «${cfg.channelId}»`);
console.log(`[idle-xboct] overlay: http://localhost:5173 (dev) | web: http://localhost:5174 (dev)`);
const built = staticMounts.filter((m) => m.middleware).map((m) => m.prefix);
if (built.length > 0) {
console.log(`[idle-xboct] раздаю сборки: ${built.join(', ')}`);
}
if (cfg.devHttp) {
console.log(`[dev] POST /dev/command {"name":"Ник","text":"!рубить"} | GET /dev/state`);
}
});
+46
View File
@@ -0,0 +1,46 @@
import { ZONES, type CameraState, type ZoneDef } from '@idle/shared';
const CAM_W = 900;
const CAM_H = 560;
const DRIFT_X = 60;
const DRIFT_Y = 30;
/**
* Камера стрима авторитарна на сервере — все клиенты видят одно и то же.
* M1: одна зона, камера медленно дрейфует вокруг её центра.
* M2+: автотур по зонам с активностью (стоянка ~45 сек, плавные переезды).
*/
export class CameraController {
state: CameraState;
private readonly seed = Math.random() * 100;
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 };
}
/** Возвращает true, если состояние изменилось (его стоит разослать). */
tick(nowMs: number): boolean {
const zone = this.zones[0];
if (!zone) return false;
const next = this.forZone(zone, nowMs);
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;
}
private forZone(z: ZoneDef, nowMs: number): CameraState {
const w = Math.min(CAM_W, z.w);
const h = Math.min(CAM_H, z.h);
const cx = z.x + z.w / 2 + Math.sin(nowMs / 9000 + this.seed) * DRIFT_X;
const cy = z.y + z.h / 2 + Math.cos(nowMs / 11000 + this.seed) * DRIFT_Y;
return { x: Math.round(cx - w / 2), y: Math.round(cy - h / 2), w, h };
}
}
+11 -9
View File
@@ -1,7 +1,7 @@
import { WebSocketServer, WebSocket } from 'ws';
import type { Duplex } from 'node:stream';
import type { IncomingMessage } from 'node:http';
import { PROTOCOL_VERSION, type ClientMessage, type ServerMessage } from '@idle/shared';
import { PROTOCOL_VERSION, type ClientMessage, type ServerMessage, type SnapshotMsg, type WelcomeMsg } from '@idle/shared';
declare module 'ws' {
interface WebSocket {
@@ -9,20 +9,22 @@ declare module 'ws' {
}
}
interface GatewayOptions {
getSnapshot: () => ServerMessage;
tickMs: number;
/** Что шлюз отдаёт клиенту при подключении; собирается в index.ts из sim + камеры. */
export interface WorldSource {
welcome(): WelcomeMsg;
snapshot(): SnapshotMsg;
}
/**
* Шлюз WS: рукопожатие по версии протокола, welcome + снапшот новому клиенту,
* broadcast снапшота каждый тик, heartbeat для мёртвых OBS-сокетов.
* Шлюз WS: рукопожатие по версии протокола, welcome + полный снапшот новому
* клиенту, далее broadcast дельт (их собирает index), heartbeat для мёртвых
* OBS-сокетов.
*/
export class WsGateway {
private readonly wss = new WebSocketServer({ noServer: true });
private readonly clients = new Set<WebSocket>();
constructor(private readonly opts: GatewayOptions) {
constructor(private readonly source: WorldSource) {
this.wss.on('connection', (ws) => this.onConnection(ws));
setInterval(() => this.sweep(), 30_000).unref();
}
@@ -63,8 +65,8 @@ export class WsGateway {
}
this.clients.add(ws);
this.send(ws, { t: 'welcome', v: PROTOCOL_VERSION, tickMs: this.opts.tickMs });
this.send(ws, this.opts.getSnapshot());
this.send(ws, this.source.welcome());
this.send(ws, this.source.snapshot());
});
ws.on('close', () => this.clients.delete(ws));
+56
View File
@@ -0,0 +1,56 @@
import { mkdirSync } from 'node:fs';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import type { ViewerRecord } from '../sim/world';
/**
* Тонкий слой persist на встроенном node:sqlite — без нативных сборок и
* install-скриптов. При необходимости заменяется (better-sqlite3/postgres)
* без изменений в остальном коде.
*/
export class Db {
private readonly db: DatabaseSync;
constructor(dbPath: string) {
if (dbPath !== ':memory:') {
mkdirSync(path.dirname(dbPath), { recursive: true });
}
this.db = new DatabaseSync(dbPath);
this.db.exec('PRAGMA journal_mode = WAL');
this.db.exec(`
CREATE TABLE IF NOT EXISTS viewers (
channel_id TEXT NOT NULL,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT NOT NULL,
xp INTEGER NOT NULL DEFAULT 0,
logs INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (channel_id, user_id)
)
`);
}
loadViewers(channelId: string): ViewerRecord[] {
const rows = this.db
.prepare('SELECT user_id, name, color, xp, logs FROM viewers WHERE channel_id = ?')
.all(channelId) as Array<{ user_id: string; name: string; color: string; xp: number; logs: number }>;
return rows.map((r) => ({ userId: r.user_id, name: r.name, color: r.color, xp: r.xp, logs: r.logs }));
}
saveViewer(channelId: string, v: ViewerRecord): void {
this.db
.prepare(
`INSERT INTO viewers (channel_id, user_id, name, color, xp, logs, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(channel_id, user_id) DO UPDATE SET
name = excluded.name, color = excluded.color, xp = excluded.xp,
logs = excluded.logs, updated_at = excluded.updated_at`,
)
.run(channelId, v.userId, v.name, v.color, v.xp, v.logs, Date.now());
}
close(): void {
this.db.close();
}
}
-43
View File
@@ -1,43 +0,0 @@
import type { AvatarState } from '@idle/shared';
interface Walker {
id: string;
name: string;
color: string;
y: number;
minX: number;
maxX: number;
/** px/сек */
speed: number;
/** сдвиг фазы 0..1, чтобы ходоки не шли строем */
offset: number;
}
const WALKERS: Walker[] = [
{ id: 'stub-1', name: 'XBOCT', color: '#ff8c3b', y: 300, minX: 140, maxX: 820, speed: 90, offset: 0 },
{ id: 'stub-2', name: 'pixel_proger', color: '#5bd7ff', y: 360, minX: 220, maxX: 900, speed: 70, offset: 0.35 },
{ id: 'stub-3', name: 'viewer_42', color: '#b58cff', y: 240, minX: 80, maxX: 700, speed: 110, offset: 0.7 },
];
/**
* Заглушка M0: движение — чистая функция wall-clock.
* Сервер мог «не жить» сколько угодно — позиция восстановится из таймстемпа,
* как и задумано для ретроактивной симуляции.
*/
export function stubAvatarsAt(nowMs: number): AvatarState[] {
return WALKERS.map((w) => {
const length = w.maxX - w.minX;
const period = (2 * length) / w.speed;
const phase = (((nowMs / 1000) / period) + w.offset) % 1;
const leg = phase < 0.5 ? phase * 2 : 2 - phase * 2;
return {
id: w.id,
name: w.name,
color: w.color,
x: Math.round(w.minX + length * leg),
y: w.y,
face: phase < 0.5 ? 'right' : 'left',
moving: true,
} satisfies AvatarState;
});
}
+369
View File
@@ -0,0 +1,369 @@
import {
COMMAND_COOLDOWN_MS,
ITEM_LOG,
TREE,
TREE_SPOTS,
WALK_SPEED,
WORLD,
XP_PER_CHOP,
colorForName,
levelForTotalXp,
type AvatarAction,
type AvatarState,
type GameEvent,
type NodeState,
type ViewerStat,
} from '@idle/shared';
export interface ChatCommand {
channelId: string;
userId: string;
login: string;
name: string;
color: string;
text: string;
}
export interface ViewerRecord {
userId: string;
name: string;
color: string;
xp: number;
logs: number;
}
interface SimAvatar extends AvatarState {
lastCommandAt: number;
pendingNodeId: string | null;
logs: number;
}
type SimTree = NodeState;
const CHOP_ALIASES = new Set(['рубить', 'chop']);
const STOP_ALIASES = new Set(['стоп', 'stop']);
function dist(ax: number, ay: number, bx: number, by: number): number {
return Math.hypot(bx - ax, by - ay);
}
/**
* Симуляционный слой M1: plain TS за узким интерфейсом (десятки сущностей).
* Решение об ECS (bitecs 0.4 / koota) отложено до M2 — см. PLAN.md.
*
* Правила:
* - команды обрабатываются сразу при приходе, не ждут тика;
* - аватар продолжает действие, пока не придёт другая команда или !стоп;
* - движение декларативно (x→tx, speed, moveStart) — клиент дорисовывает сам.
*/
export class SimWorld {
readonly avatars = new Map<string, SimAvatar>();
readonly trees: SimTree[] = TREE_SPOTS.map((t, i) => ({
id: `tree-${i + 1}`,
kind: 'tree',
x: t.x,
y: t.y,
hp: TREE.maxHp,
maxHp: TREE.maxHp,
respawnAt: null,
}));
private readonly dirtyAvatars = new Set<string>();
private readonly dirtyTrees = new Set<string>();
private readonly dirtyViewers = new Map<string, ViewerRecord>();
private readonly preloaded = new Map<string, ViewerRecord>();
private statsDirty = true;
constructor(
private readonly channelId: string,
preloaded: ViewerRecord[],
) {
for (const r of preloaded) this.preloaded.set(r.userId, r);
}
handleCommand(cmd: ChatCommand, now = Date.now()): void {
if (cmd.channelId !== this.channelId) return;
if (!cmd.text.startsWith('!')) return;
const word = (cmd.text.slice(1).split(/\s+/)[0] ?? '').toLowerCase();
if (CHOP_ALIASES.has(word)) this.doChop(cmd, now);
else if (STOP_ALIASES.has(word)) this.doStop(cmd, now);
}
tick(now: number): GameEvent[] {
const events: GameEvent[] = [];
for (const tree of this.trees) {
if (tree.respawnAt !== null && now >= tree.respawnAt) {
tree.respawnAt = null;
tree.hp = tree.maxHp;
this.dirtyTrees.add(tree.id);
}
}
for (const av of this.avatars.values()) {
if (av.moving) {
const travelMs = (dist(av.x, av.y, av.tx, av.ty) / av.speed) * 1000;
if (now >= av.moveStart + travelMs) {
av.x = av.tx;
av.y = av.ty;
av.moving = false;
this.dirtyAvatars.add(av.id);
const tree = av.pendingNodeId ? this.treeById(av.pendingNodeId) : undefined;
if (tree && tree.respawnAt === null) {
this.beginChop(av, tree, now);
} else {
av.pendingNodeId = null;
}
}
}
if (av.action === 'chop' && av.actionStart !== null) {
const dur = av.actionDur ?? TREE.chopDurMs;
if (now >= av.actionStart + dur) {
const tree = av.nodeId ? this.treeById(av.nodeId) : undefined;
if (!tree || tree.respawnAt !== null) {
av.action = 'idle';
av.nodeId = null;
av.actionStart = null;
this.dirtyAvatars.add(av.id);
this.statsDirty = true;
} else {
this.completeChopCycle(av, tree, now, events);
}
}
}
}
return events;
}
// ---- команды ----
private doChop(cmd: ChatCommand, now: number): void {
const av = this.ensureAvatar(cmd, now);
if (now - av.lastCommandAt < COMMAND_COOLDOWN_MS) return;
av.lastCommandAt = now;
if (av.action === 'chop' && !av.moving) return; // уже рубит
const tree = this.nearestAvailableTree(av);
if (!tree) return; // всё вырублено и не отросло — просто ждём
this.walkTo(av, tree.x, tree.y + 26, now);
av.pendingNodeId = tree.id;
}
private doStop(cmd: ChatCommand, now: number): void {
const av = this.ensureAvatar(cmd, now);
if (now - av.lastCommandAt < COMMAND_COOLDOWN_MS) return;
av.lastCommandAt = now;
if (av.moving) {
const p = this.currentPos(av, now);
av.x = p.x;
av.y = p.y;
av.moving = false;
}
av.pendingNodeId = null;
if (av.action !== 'idle') {
av.action = 'idle';
av.nodeId = null;
av.actionStart = null;
av.actionDur = null;
this.statsDirty = true;
}
this.dirtyAvatars.add(av.id);
}
private ensureAvatar(cmd: ChatCommand, now: number): SimAvatar {
let av = this.avatars.get(cmd.userId);
if (!av) {
const rec = this.preloaded.get(cmd.userId);
av = {
id: cmd.userId,
name: cmd.name,
color: cmd.color || colorForName(cmd.name),
x: WORLD.spawn.x + (Math.random() * 60 - 30),
y: WORLD.spawn.y + (Math.random() * 40 - 20),
tx: WORLD.spawn.x,
ty: WORLD.spawn.y,
speed: WALK_SPEED,
moveStart: 0,
moving: false,
action: 'idle',
nodeId: null,
actionStart: null,
actionDur: null,
level: rec ? levelForTotalXp(rec.xp) : 1,
xp: rec?.xp ?? 0,
lastCommandAt: 0,
pendingNodeId: null,
logs: rec?.logs ?? 0,
};
this.avatars.set(av.id, av);
this.dirtyAvatars.add(av.id);
this.markViewerDirty(av);
this.statsDirty = true;
} else if (av.name !== cmd.name || av.color !== cmd.color) {
av.name = cmd.name;
av.color = cmd.color || av.color;
this.dirtyAvatars.add(av.id);
this.markViewerDirty(av);
}
return av;
}
private walkTo(av: SimAvatar, tx: number, ty: number, now: number): void {
if (av.moving) {
const p = this.currentPos(av, now);
av.x = p.x;
av.y = p.y;
}
av.tx = tx;
av.ty = ty;
av.moveStart = now;
av.moving = true;
av.action = 'idle';
av.actionStart = null;
av.actionDur = null;
this.dirtyAvatars.add(av.id);
this.statsDirty = true;
}
private beginChop(av: SimAvatar, tree: SimTree, now: number): void {
av.pendingNodeId = null;
av.action = 'chop';
av.nodeId = tree.id;
av.actionStart = now;
av.actionDur = TREE.chopDurMs;
this.dirtyAvatars.add(av.id);
this.statsDirty = true;
}
private completeChopCycle(av: SimAvatar, tree: SimTree, now: number, events: GameEvent[]): void {
const start = av.actionStart;
const dur = av.actionDur ?? TREE.chopDurMs;
if (start === null) return;
tree.hp -= 1;
this.dirtyTrees.add(tree.id);
if (tree.hp <= 0) {
tree.respawnAt = now + TREE.respawnMs;
events.push({ k: 'fell', userId: av.id, name: av.name, color: av.color, nodeId: tree.id });
}
av.logs += 1;
events.push({ k: 'item', userId: av.id, name: av.name, color: av.color, item: ITEM_LOG, qty: 1 });
av.xp += XP_PER_CHOP;
events.push({ k: 'xp', userId: av.id, name: av.name, color: av.color, amount: XP_PER_CHOP, total: av.xp });
const level = levelForTotalXp(av.xp);
if (level > av.level) {
av.level = level;
events.push({ k: 'levelup', userId: av.id, name: av.name, color: av.color, level });
}
this.dirtyAvatars.add(av.id);
this.markViewerDirty(av);
if (tree.respawnAt !== null) {
// дерево свалено — аватар отдыхает, пока зритель не отправит к следующему
av.action = 'idle';
av.nodeId = null;
av.actionStart = null;
} else {
av.actionStart = start + dur; // следующий цикл той же команды
}
}
// ---- вспомогательное ----
private treeById(id: string): SimTree | undefined {
return this.trees.find((t) => t.id === id);
}
private nearestAvailableTree(av: SimAvatar): SimTree | undefined {
let best: SimTree | undefined;
let bestD = Infinity;
for (const t of this.trees) {
if (t.respawnAt !== null) continue;
const d = dist(av.x, av.y, t.x, t.y);
if (d < bestD) {
bestD = d;
best = t;
}
}
return best;
}
private currentPos(av: SimAvatar, now: number): { x: number; y: number } {
if (!av.moving) return { x: av.x, y: av.y };
const dx = av.tx - av.x;
const dy = av.ty - av.y;
const d = Math.hypot(dx, dy);
if (d === 0) return { x: av.tx, y: av.ty };
const k = Math.min(((now - av.moveStart) / 1000) * av.speed, d);
return { x: av.x + (dx / d) * k, y: av.y + (dy / d) * k };
}
private markViewerDirty(av: SimAvatar): void {
this.dirtyViewers.set(av.id, { userId: av.id, name: av.name, color: av.color, xp: av.xp, logs: av.logs });
}
// ---- выдача для сети/себя ----
serializeAvatar(av: SimAvatar): AvatarState {
return {
id: av.id, name: av.name, color: av.color,
x: av.x, y: av.y, tx: av.tx, ty: av.ty,
speed: av.speed, moveStart: av.moveStart, moving: av.moving,
action: av.action, nodeId: av.nodeId, actionStart: av.actionStart, actionDur: av.actionDur,
level: av.level, xp: av.xp,
};
}
takeDirtyAvatars(): AvatarState[] {
if (this.dirtyAvatars.size === 0) return [];
const out: AvatarState[] = [];
for (const id of this.dirtyAvatars) {
const av = this.avatars.get(id);
if (av) out.push(this.serializeAvatar(av));
}
this.dirtyAvatars.clear();
return out;
}
takeDirtyTrees(): NodeState[] {
if (this.dirtyTrees.size === 0) return [];
const out: NodeState[] = [];
for (const id of this.dirtyTrees) {
const t = this.treeById(id);
if (t) out.push({ ...t });
}
this.dirtyTrees.clear();
return out;
}
takeDirtyViewers(): ViewerRecord[] {
const out = [...this.dirtyViewers.values()];
this.dirtyViewers.clear();
return out;
}
serializeTrees(): NodeState[] {
return this.trees.map((t) => ({ ...t }));
}
stats(): ViewerStat[] {
return [...this.avatars.values()]
.map((av) => ({
id: av.id, name: av.name, color: av.color,
level: av.level, xp: av.xp, logs: av.logs, action: av.action,
}))
.sort((a, b) => b.xp - a.xp);
}
takeStatsIfDirty(): ViewerStat[] | null {
if (!this.statsDirty) return null;
this.statsDirty = false;
return this.stats();
}
}
+85
View File
@@ -0,0 +1,85 @@
import { colorForName } from '@idle/shared';
import type { ChatCommand } from '../sim/world';
import type { TwitchSource } from './source';
const FAKE_NAMES = [
'Медведь228',
'ЛеснойЭльф',
'pixel_proger',
'Кот_Вася',
'viewer_42',
'ТихийОмут',
'мамкин_дровосек',
'ЗлойБобр',
];
interface FakeViewer {
userId: string;
name: string;
color: string;
}
/**
* Фейковые зрители/команды для разработки и проверки без живого стрима.
* Включается через FAKE_VIEWERS=N в .env.
*/
export class FakeTwitchSource implements TwitchSource {
private readonly viewers: FakeViewer[];
private timers: NodeJS.Timeout[] = [];
private lastAnnounce = 0;
constructor(
private readonly channelId: string,
viewerCount: number,
private readonly onCommand: (cmd: ChatCommand) => void,
) {
this.viewers = FAKE_NAMES.slice(0, viewerCount).map((name, i) => ({
userId: `fake-${i + 1}`,
name,
color: colorForName(name),
}));
}
async start(): Promise<void> {
this.viewers.forEach((v, i) => this.schedule(v, 1500 + i * 700 + Math.random() * 3000));
}
say(text: string): void {
console.log(`[fake:chat] ${text}`);
}
announce(text: string, now = Date.now()): boolean {
if (now - this.lastAnnounce < 60_000) return false;
this.lastAnnounce = now;
this.say(text);
return true;
}
stop(): void {
for (const t of this.timers) clearTimeout(t);
this.timers = [];
}
private schedule(v: FakeViewer, delayMs: number): void {
this.timers.push(
setTimeout(() => {
this.fire(v);
this.schedule(v, 4000 + Math.random() * 6000);
}, delayMs),
);
}
private fire(v: FakeViewer): void {
const roll = Math.random();
const text = roll < 0.75 ? '!рубить' : roll < 0.9 ? '!стоп' : null;
if (!text) return;
this.onCommand({
channelId: this.channelId,
userId: v.userId,
login: v.name,
name: v.name,
color: v.color,
text,
});
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { TwitchConfig } from '../config';
import type { ChatCommand } from '../sim/world';
/** Источник команд чата. Локально — twurple-бот; на хостинге — OAuth-flow без изменений остального кода. */
export interface TwitchSource {
start(): Promise<void>;
stop(): void;
/** Ответ в чат; дозируется вызывающим кодом. */
say(text: string): void;
/** Значимые события (например, левелап) — с троттлингом. Возвращает true, если отправлено. */
announce(text: string, now?: number): boolean;
}
export type { ChatCommand, TwitchConfig };
+72
View File
@@ -0,0 +1,72 @@
import { colorForName } from '@idle/shared';
import { RefreshingAuthProvider } from '@twurple/auth';
import { ChatClient } from '@twurple/chat';
import type { TwitchConfig } from '../config';
import type { ChatCommand } from '../sim/world';
import type { TwitchSource } from './source';
/**
* Живой чат через twurple:RefreshingAuthProvider сам обновляет токены,
* вручную перелогиниваться не нужно. Токены — см. docs/twitch-setup.md.
* Внимание: путь до live-чата будет впервые проверен при подключении реальных
* токенов (сигнатуры twurple могут потребовать мелкой правки).
*/
export class TwurpleSource implements TwitchSource {
private client: ChatClient | null = null;
private lastAnnounce = 0;
constructor(
private readonly cfg: TwitchConfig,
private readonly onCommand: (cmd: ChatCommand) => void,
) {}
async start(): Promise<void> {
const authProvider = new RefreshingAuthProvider({
clientId: this.cfg.clientId,
clientSecret: this.cfg.clientSecret,
});
// 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(),
});
authProvider.onRefresh((_userId, token) => {
console.log(`[twitch] токен обновлён, действителен ещё ${token.expiresIn ?? '?'} с`);
});
this.client = new ChatClient({ authProvider, channels: [this.cfg.channel] });
this.client.onMessage((_channel, _user, text, msg) => {
if (!text.startsWith('!')) return;
const name = msg.userInfo.displayName || msg.userInfo.userName;
this.onCommand({
channelId: this.cfg.channel,
userId: msg.userInfo.userId,
login: msg.userInfo.userName,
name,
color: msg.userInfo.color || colorForName(name),
text,
});
});
await this.client.connect();
}
say(text: string): void {
this.client?.say(this.cfg.channel, text).catch((e) => console.error('[twitch] say:', e));
}
announce(text: string, now = Date.now()): boolean {
if (now - this.lastAnnounce < 60_000) return false;
this.lastAnnounce = now;
this.say(text);
return true;
}
stop(): void {
void this.client?.quit();
this.client = null;
}
}