diff --git a/docs/PLAN.md b/docs/PLAN.md index 59976f0..ec64c51 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -101,6 +101,7 @@ XP-кривая Melvor-подобная, уровни открывают нод - Онбординг — в самом виджете (HUD-подсказки), чат ботом не засоряем - Команды: `!рубить`, `!копать`, `!ковать`, `!рыбачить`, `!готовить`, `!отдых`, `!стоп`, `!подарить`, `!топ`; спотлайт — не команда, а Channel Point награда. + M4: `!костёр` — жечь брёвна у костра (прогресс пулу + личный вклад). RU сейчас, EN-алиасы потом через конфиг ### Камера и «показать меня» @@ -123,6 +124,9 @@ XP-кривая Melvor-подобная, уровни открывают нод - Общий пул прогресса + ledger персональных вкладов - Декей съедает вклады → визуальный откат, если долго никто не работает - Уровни костра 1–5 +- Реализация (M4): `!костёр` сжигает бревно за цикл (10 очков), декей ~4 очка/мин + и догоняется ретроактивно за оффлайн; уровень усиливает бафф отдыха (+25% к бонусу + за уровень), размер пламени растёт с уровнем ### Отдых (бафф за активность, не за силу) diff --git a/packages/overlay/src/App.svelte b/packages/overlay/src/App.svelte index 05f09e7..dd59133 100644 --- a/packages/overlay/src/App.svelte +++ b/packages/overlay/src/App.svelte @@ -42,7 +42,7 @@
{STATUS_TEXT[status]}
{#if status === 'online'} -
!рубить · !копать · !рыбачить · !ковать <рецепт> · !готовить <рецепт> · !отдых · !стоп
+
!рубить · !копать · !рыбачить · !ковать <рецепт> · !готовить <рецепт> · !отдых · !костёр · !стоп
{/if} diff --git a/packages/render/src/avatar.ts b/packages/render/src/avatar.ts index 885875b..c84c506 100644 --- a/packages/render/src/avatar.ts +++ b/packages/render/src/avatar.ts @@ -19,6 +19,16 @@ interface Chip { } function makeTool(tool: string): Graphics { + if (tool === 'log') { + // бревно в руках — для подкинутого дрова у костра + return new Graphics() + .rect(-6, -3, 12, 5) + .fill(0x7a5230) + .circle(-6, -0.5, 2.2) + .fill(0x9a7448) + .circle(6, -0.5, 2.2) + .fill(0x9a7448); + } const g = new Graphics().rect(-1, -12, 2, 12).fill(HANDLE); if (tool === 'axe_rusty' || tool === 'axe_iron') { g.poly([-1, -12, 7, -12, 7, -7, -1, -8]).fill(tool === 'axe_iron' ? STEEL : RUSTY); @@ -68,7 +78,7 @@ export class AvatarView { const body = new Graphics().roundRect(-7, -18, 14, 13, 4).fill(av.color); const head = new Graphics().circle(0, -23, 6.5).fill(SKIN); this.toolHolder.position.set(6, -14); - for (const tool of ['axe_rusty', 'axe_iron', 'pick_rusty', 'pick_iron', 'hammer', 'spoon', 'rod']) { + for (const tool of ['axe_rusty', 'axe_iron', 'pick_rusty', 'pick_iron', 'hammer', 'spoon', 'rod', 'log']) { const g = makeTool(tool); g.visible = false; this.tools.set(tool, g); diff --git a/packages/render/src/world.ts b/packages/render/src/world.ts index d8c7049..377ca04 100644 --- a/packages/render/src/world.ts +++ b/packages/render/src/world.ts @@ -2,6 +2,7 @@ import { Application, Container, Graphics, Text } from 'pixi.js'; import type { AvatarState, CameraState, + CampfireState, DeltaMsg, GameEvent, NodeState, @@ -10,7 +11,7 @@ import type { WelcomeMsg, ZoneDef, } from '@idle/shared'; -import { RIVER_WATER_Y } from '@idle/shared'; +import { CAMPFIRE, RIVER_WATER_Y } from '@idle/shared'; import { AvatarView } from './avatar'; import { FishSpotView } from './fish'; import { RockView } from './rock'; @@ -25,6 +26,7 @@ export interface MountWorldOptions { onStatus?: (status: WorldStatus) => void; onEvent?: (e: GameEvent) => void; onStats?: (s: ViewerStat[]) => void; + onCampfire?: (s: CampfireState) => void; } export interface WorldHandle { @@ -139,7 +141,9 @@ function drawAnvil(): Container { return c; } -function drawCampfire(flickers: Flicker[]): Container { +function drawCampfire( + flickers: Flicker[], +): { c: Container; scalable: Array<{ g: Graphics; baseScale: number }> } { const c = new Container(); const stones = new Graphics(); for (let i = 0; i < 6; i++) { @@ -158,8 +162,28 @@ function drawCampfire(flickers: Flicker[]): Container { { g: flame1, base: 0.95, phase: 0 }, { g: flame2, base: 0.9, phase: 1.7 }, ); + const scalable = [ + { g: glow, baseScale: 1 }, + { g: flame1, baseScale: 1 }, + { g: flame2, baseScale: 1 }, + ]; c.addChild(glow, stones, logA, logB, flame1, flame2); - return c; + return { c, scalable }; +} + +interface CampfireUi { + label: Text; + bar: Graphics; + /** Пламя/свечение, масштабируемые уровнем костра. */ + scalable: Array<{ g: Graphics; baseScale: number }>; +} + +function campfireBarPct(progress: number, level: number): number { + const thr = CAMPFIRE.levelThresholds; + 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)); } function drawStove(flickers: Flicker[]): Container { @@ -208,6 +232,18 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {} app.stage.addChild(worldLayer); const flickers: Flicker[] = []; + let campfireUi: CampfireUi | null = null; + let flameScale = 1; + + function updateCampfireVisual(cs: CampfireState): void { + if (!campfireUi) return; + flameScale = 1 + (cs.level - 1) * 0.18; + campfireUi.label.text = `Костёр ур.${cs.level}`; + const pct = campfireBarPct(cs.progress, cs.level); + campfireUi.bar.clear(); + campfireUi.bar.roundRect(-24, -84, 48, 5, 2.5).fill({ color: 0x10131a, alpha: 0.6 }); + campfireUi.bar.roundRect(-24, -84, 48 * pct, 5, 2.5).fill(0xff9a3b); + } let clockOffset = 0; // serverNow - Date.now() let camTarget: CameraState | null = null; @@ -274,6 +310,9 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {} for (const f of flickers) { f.g.alpha = f.base + Math.sin(now / 150 + f.phase) * 0.15; } + for (const s of campfireUi?.scalable ?? []) { + s.g.scale.set(flameScale); + } for (const [id, view] of avatarViews) { const av = avatars.get(id); if (av) { @@ -306,15 +345,31 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {} ground.zIndex = -1000; entityLayer.addChild(ground); for (const prop of w.world.props) { - const view = - prop.kind === 'anvil' - ? drawAnvil() - : prop.kind === 'campfire' - ? drawCampfire(flickers) - : drawStove(flickers); - view.position.set(prop.x, prop.y); - view.zIndex = prop.y; - entityLayer.addChild(view); + if (prop.kind === 'anvil') { + const view = drawAnvil(); + view.position.set(prop.x, prop.y); + view.zIndex = prop.y; + entityLayer.addChild(view); + } else if (prop.kind === 'campfire') { + const built = drawCampfire(flickers); + built.c.position.set(prop.x, prop.y); + built.c.zIndex = prop.y; + const label = new Text({ + text: 'Костёр ур.1', + style: { fontFamily: 'monospace', fontSize: 11, fontWeight: 'bold', fill: 0xffc46b, stroke: { color: 0x10131a, width: 3 } }, + }); + label.anchor.set(0.5, 1); + label.y = -74; + const bar = new Graphics(); + built.c.addChild(bar, label); + campfireUi = { label, bar, scalable: built.scalable }; + entityLayer.addChild(built.c); + } else { + const view = drawStove(flickers); + view.position.set(prop.x, prop.y); + view.zIndex = prop.y; + entityLayer.addChild(view); + } } } }, @@ -326,6 +381,10 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {} for (const av of snap.avatars) syncAvatar(av); for (const st of snap.nodes) syncNode(st); pruneEntities(); + if (snap.campfire) { + updateCampfireVisual(snap.campfire); + opts.onCampfire?.(snap.campfire); + } opts.onStats?.(snap.stats); }, applyDelta(d) { @@ -335,6 +394,10 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {} 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); + if (d.campfire) { + updateCampfireVisual(d.campfire); + opts.onCampfire?.(d.campfire); + } }, destroy() { void app.destroy(true, { children: true }); diff --git a/packages/server/smoke.mjs b/packages/server/smoke.mjs index 6e5a996..4aa8180 100644 --- a/packages/server/smoke.mjs +++ b/packages/server/smoke.mjs @@ -1,12 +1,14 @@ -/* Дымовой тест M3: сервер должен быть запущен с DEV_HTTP=1, SQLITE_PATH=:memory:. - `pnpm --filter @idle/server smoke` */ +/* Дымовой тест M4 (кооп-костёр), две фазы: + node smoke.mjs phase1 — вклад, уровень, ledger (сервер с DEV_HTTP=1 + файловая база) + node smoke.mjs phase2 — после рестарта сервера: персистентность + декей + guard версии */ +import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'; import WebSocket from 'ws'; const BASE = 'http://localhost:3000'; const WS = 'ws://localhost:3000/ws'; +const PROGRESS_FILE = new URL('./smoke-progress.tmp', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -// ждём готовности сервера (tsx может стартовать дольше пары секунд) for (let i = 0; i < 30; i++) { try { const r = await fetch(`${BASE}/healthz`); @@ -15,12 +17,11 @@ for (let i = 0; i < 30; i++) { await sleep(500); } -// антиспам-кулдаун команд — между командами выдерживаем паузу const COOLDOWN = 1400; function connect(onMsg) { const ws = new WebSocket(WS); - ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 4 }))); + ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 5 }))); ws.on('message', (d) => onMsg(JSON.parse(String(d)))); return ws; } @@ -34,17 +35,11 @@ async function dev(path, body) { if (!res.ok) throw new Error(`POST ${path} -> ${res.status} ${await res.text()}`); } -async function inventory(name) { - const res = await fetch(`${BASE}/api/inventory?name=${encodeURIComponent(name)}`); - return res.json(); -} - async function state() { const res = await fetch(`${BASE}/dev/state`); return res.json(); } -/** Ждём дельту, удовлетворяющую условию. */ function waitForDelta(pred, timeoutMs = 9000) { return new Promise((resolve, reject) => { const ws = connect((m) => { @@ -61,99 +56,79 @@ function waitForDelta(pred, timeoutMs = 9000) { }); } -// 1. welcome + полный снапшот -const first = []; -await new Promise((resolve, reject) => { - const ws = connect((m) => { - first.push(m); - if (first.length >= 2) { - ws.close(); - resolve(); - } +function firstSnapshot() { + return new Promise((resolve, reject) => { + const seen = []; + const ws = connect((m) => { + seen.push(m); + if (seen.length >= 2) { + ws.close(); + resolve(seen[1]); + } + }); + ws.on('error', reject); + setTimeout(() => reject(new Error('timeout')), 5000); }); - ws.on('error', reject); - setTimeout(() => reject(new Error('timeout waiting welcome+snapshot')), 5000); -}); -const [welcome, snap] = first; -const fishSpots = snap.nodes.filter((n) => n.kind === 'fish').length; -console.log( - 'welcome: v' + welcome.v, - '| нод:', snap.nodes.length, - '| рыбных мест:', fishSpots, - '| пропсов:', welcome.world.props.map((p) => p.kind).join(','), -); -if (welcome.t !== 'welcome' || welcome.v !== 4) throw new Error('bad welcome'); -if (snap.nodes.length !== 16 || fishSpots !== 3) throw new Error('bad snapshot nodes'); -const propKinds = welcome.world.props.map((p) => p.kind).sort().join(','); -if (propKinds !== 'anvil,campfire,stove') throw new Error(`bad props: ${propKinds}`); +} -// 2. спавн через !рубить -await dev('/dev/command', { name: 'Тестер', text: '!рубить' }); -await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.moving)); -console.log('spawn ok'); +const phase = process.argv[2] ?? 'phase1'; -// 3. рыбалка -await dev('/dev/tp', { name: 'Тестер', x: 2250, y: 375 }); -await sleep(COOLDOWN); -await dev('/dev/command', { name: 'Тестер', text: '!рыбачить' }); -const d1 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'fish')); -const fisher = d1.avatars.find((a) => a.name === 'Тестер'); -console.log('fish: action=' + fisher.action, 'tool=' + fisher.tool); -if (fisher.action !== 'fish' || fisher.tool !== 'rod') throw new Error('bad fish state'); -await sleep(16_000); -const inv1 = await inventory('Тестер'); -console.log('inventory: raw_fish=' + (inv1.items?.raw_fish ?? 0), 'fishing xp=' + (inv1.skills?.fishing?.xp ?? 0)); -if ((inv1.items?.raw_fish ?? 0) < 1 || (inv1.skills?.fishing?.xp ?? 0) < 15) throw new Error('fishing did not yield'); +if (phase === 'phase1') { + const snap = await firstSnapshot(); + console.log('welcome v5 | campfire:', JSON.stringify(snap.campfire)); + if (!snap.campfire || snap.campfire.level < 1) throw new Error('no campfire state'); -// 4. готовка жареной рыбы -await dev('/dev/give', { name: 'Тестер', item: 'raw_fish', qty: 2 }); -await dev('/dev/tp', { name: 'Тестер', x: 2990, y: 425 }); -await sleep(COOLDOWN); -await dev('/dev/command', { name: 'Тестер', text: '!готовить жареную рыбу' }); -const d2 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'cook')); -const cook = d2.avatars.find((a) => a.name === 'Тестер'); -console.log('cook: action=' + cook.action, 'tool=' + cook.tool); -if (cook.action !== 'cook' || cook.tool !== 'spoon') throw new Error('bad cook state'); -await sleep(11_000); -const inv2 = await inventory('Тестер'); -console.log('inventory: cooked_fish=' + (inv2.items?.cooked_fish ?? 0), 'cooking xp=' + (inv2.skills?.cooking?.xp ?? 0)); -if ((inv2.items?.cooked_fish ?? 0) < 1 || (inv2.skills?.cooking?.xp ?? 0) < 20) throw new Error('cooking did not yield'); + // спавн, брёвна, !костёр + await dev('/dev/command', { name: 'Тестер', text: '!рубить' }); + await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.moving)); + await dev('/dev/give', { name: 'Тестер', item: 'log', qty: 2 }); + await dev('/dev/tp', { name: 'Тестер', x: 1900, y: 505 }); + await sleep(COOLDOWN); + await dev('/dev/command', { name: 'Тестер', text: '!костёр' }); + const d1 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'feed')); + const feeder = d1.avatars.find((a) => a.name === 'Тестер'); + console.log('feed: action=' + feeder.action, 'tool=' + feeder.tool); + if (feeder.action !== 'feed' || feeder.tool !== 'log') throw new Error('bad feed state'); -// 5. отдых без рыбы у второго зрителя → blocked -await dev('/dev/command', { name: 'Тестер2', text: '!рубить' }); -await sleep(300); -await dev('/dev/tp', { name: 'Тестер2', x: 1960, y: 515 }); -await sleep(COOLDOWN); -await dev('/dev/command', { name: 'Тестер2', text: '!отдых' }); -const d3 = await waitForDelta((m) => m.events?.some((e) => e.k === 'blocked' && e.name === 'Тестер2'), 5000); -console.log('blocked:', d3.events.find((e) => e.k === 'blocked').reason); + const d2 = await waitForDelta((m) => m.events?.some((e) => e.k === 'fire' && e.name === 'Тестер'), 15000); + const fireEv = d2.events.find((e) => e.k === 'fire'); + console.log(`fire: прогресс=${fireEv.progress}, личный вклад=${fireEv.contributed}`); + if (fireEv.contributed < 10 || fireEv.progress < 10) throw new Error('campfire did not grow'); -// 6. отдых Тестера с рыбой: цикл 60 сек → стак баффа -await dev('/dev/tp', { name: 'Тестер', x: 1960, y: 515 }); -await sleep(COOLDOWN); -await dev('/dev/command', { name: 'Тестер', text: '!отдых' }); -const d4 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'rest')); -console.log('rest: начат'); -await sleep(62_000); -const st = await state(); -const tester = st.avatars.find((a) => a.name === 'Тестер'); -const inv3 = await inventory('Тестер'); -console.log( - 'after rest: restStacks=' + tester.restStacks, - 'cooked_fish=' + (inv3.items?.cooked_fish ?? 0), - 'action=' + tester.action, -); -if (tester.restStacks < 1) throw new Error('rest buff not granted'); -if ((inv3.items?.cooked_fish ?? 0) !== 0) throw new Error('cooked fish not consumed'); -if (tester.action !== 'idle') throw new Error('rest should end when out of fish'); + await dev('/dev/fire', { points: 95 }); + const d3 = await waitForDelta((m) => m.events?.some((e) => e.k === 'firelevel'), 5000); + const lv = d3.events.find((e) => e.k === 'firelevel').level; + const st1 = await state(); + console.log( + `firelevel: ${lv}, campfire.level=${st1.campfire.level}, топ: ${st1.campfire.top + .map((t) => t.name + '=' + t.contributed) + .join(', ')}`, + ); + if (lv < 2 || st1.campfire.level < 2) throw new Error('campfire level did not rise'); + if (!st1.campfire.top.some((t) => t.name === 'Тестер')) throw new Error('ledger missing'); -// 7. неверная версия протокола — close 4001 -const closeCode = await new Promise((resolve) => { - 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); -}); -if (closeCode !== 4001) throw new Error(`expected close 4001, got ${closeCode}`); + writeFileSync(PROGRESS_FILE, String(st1.campfire.progress), 'utf8'); + console.log(`phase1 OK (progress=${st1.campfire.progress}). Перезапусти сервер и запусти: node smoke.mjs phase2`); +} else if (phase === 'phase2') { + const before = Number(readFileSync(PROGRESS_FILE, 'utf8')); + const snap = await firstSnapshot(); + console.log(`after restart: прогресс ${snap.campfire.progress} (был ${before}), уровень ${snap.campfire.level}`); + if (snap.campfire.progress >= before) throw new Error('decay did not happen after restart'); + if (snap.campfire.level < 2) throw new Error('campfire level lost after restart'); + if (!snap.campfire.top.some((t) => t.name === 'Тестер')) throw new Error('ledger lost after restart'); + try { + unlinkSync(PROGRESS_FILE); + } catch {} -console.log('WS smoke: OK'); + const closeCode = await new Promise((resolve) => { + 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); + }); + if (closeCode !== 4001) throw new Error(`expected close 4001, got ${closeCode}`); + + console.log('WS smoke: OK'); +} else { + throw new Error(`unknown phase: ${phase}`); +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index d25bce2..c83dd2b 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -12,6 +12,7 @@ import { ZONES, colorForName, levelForTotalXp, + type CampfireState, type DeltaMsg, type GameEvent, type SnapshotMsg, @@ -38,7 +39,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const currentTick = (): number => Math.floor((Date.now() - startedAt) / cfg.tickMs); const db = new Db(cfg.dbPath); -const sim = new SimWorld(cfg.channelId, db.loadViewers(cfg.channelId)); +const sim = new SimWorld(cfg.channelId, db.loadViewers(cfg.channelId), db.loadCampfire(cfg.channelId)); const camera = new CameraController(); function handleCommand(cmd: ChatCommand): void { @@ -101,6 +102,7 @@ const world: WorldSource = { avatars: [...sim.avatars.values()].map((av) => sim.serializeAvatar(av)), nodes: sim.serializeNodes(), stats: sim.stats(), + campfire: sim.campfireState(), }; }, }; @@ -113,7 +115,8 @@ function buildDelta(events: GameEvent[], camChanged: boolean): DeltaMsg | null { const avatars = sim.takeDirtyAvatars(); const nodes = sim.takeDirtyNodes(); const stats = sim.takeStatsIfDirty(); - if (avatars.length === 0 && nodes.length === 0 && !stats && events.length === 0 && !camChanged) { + const campfire = sim.takeCampfireStateIfDirty(); + if (avatars.length === 0 && nodes.length === 0 && !stats && !campfire && events.length === 0 && !camChanged) { return null; } const msg: DeltaMsg = { t: 'delta', tick: currentTick(), serverNow: Date.now() }; @@ -121,6 +124,7 @@ function buildDelta(events: GameEvent[], camChanged: boolean): DeltaMsg | null { if (avatars.length > 0) msg.avatars = avatars; if (nodes.length > 0) msg.nodes = nodes; if (stats) msg.stats = stats; + if (campfire) msg.campfire = campfire; if (events.length > 0) msg.events = events; return msg; } @@ -131,10 +135,16 @@ setInterval(() => { const camChanged = camera.tick(now, sim.zoneActivity()); for (const rec of sim.takeDirtyViewers()) db.saveViewer(cfg.channelId, rec); + if (sim.isCampfireDirty()) { + const cf = sim.campfireState(); + db.saveCampfire(cfg.channelId, cf.progress, sim.campfireLedgerRows()); + } 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}`); } } @@ -248,6 +258,24 @@ function handleHttp(req: IncomingMessage, res: ServerResponse): void { return; } + if (cfg.devHttp && p === '/dev/fire' && req.method === 'POST') { + readBody(req, (body) => { + try { + const parsed = JSON.parse(body || '{}') as { points?: number }; + const points = Number(parsed.points); + if (!Number.isFinite(points) || points <= 0) { + json(res, 400, { error: 'нужен points > 0' }); + return; + } + sim.addCampfireProgressDev(points); + json(res, 200, { ok: true, campfire: sim.campfireState() }); + } catch (e) { + json(res, 400, { error: String(e) }); + } + }); + return; + } + if (p === '/api/inventory' && req.method === 'GET') { const name = (url.searchParams.get('name') ?? '').trim(); const found = name ? sim.lookupViewer(name) : undefined; diff --git a/packages/server/src/persist/db.ts b/packages/server/src/persist/db.ts index 4c27ec1..4d36baa 100644 --- a/packages/server/src/persist/db.ts +++ b/packages/server/src/persist/db.ts @@ -15,7 +15,13 @@ export interface ViewerRecord { items: Record; } -const SCHEMA_VERSION = 2; +export interface CampfireRecord { + progress: number; + updatedAt: number; + ledger: Array<{ userId: string; name: string; color: string; contributed: number }>; +} + +const SCHEMA_VERSION = 3; /** * Тонкий слой persist на встроенном node:sqlite — без нативных сборок и @@ -37,6 +43,8 @@ export class Db { if (version < SCHEMA_VERSION) { // дев-стадия: старую схему не мигрируем, а пересоздаём this.db.exec(` + DROP TABLE IF EXISTS campfire; + DROP TABLE IF EXISTS campfire_ledger; DROP TABLE IF EXISTS viewer_skills; DROP TABLE IF EXISTS viewer_items; DROP TABLE IF EXISTS viewers; @@ -62,6 +70,19 @@ export class Db { qty INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (channel_id, user_id, item) ); + CREATE TABLE campfire ( + channel_id TEXT PRIMARY KEY, + progress REAL NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL + ); + CREATE TABLE campfire_ledger ( + channel_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT NOT NULL, + contributed REAL NOT NULL DEFAULT 0, + PRIMARY KEY (channel_id, user_id) + ); PRAGMA user_version = ${SCHEMA_VERSION}; `); } @@ -130,6 +151,51 @@ export class Db { } } + loadCampfire(channelId: string): CampfireRecord { + const rows = this.db + .prepare('SELECT progress, updated_at FROM campfire WHERE channel_id = ?') + .all(channelId) as Array<{ progress: number; updated_at: number }>; + const ledgerRows = this.db + .prepare( + 'SELECT user_id, name, color, contributed FROM campfire_ledger WHERE channel_id = ? ORDER BY contributed DESC', + ) + .all(channelId) as Array<{ user_id: string; name: string; color: string; contributed: number }>; + const first = rows[0]; + return { + progress: first?.progress ?? 0, + updatedAt: first?.updated_at ?? Date.now(), + ledger: ledgerRows.map((r) => ({ + userId: r.user_id, + name: r.name, + color: r.color, + contributed: r.contributed, + })), + }; + } + + saveCampfire(channelId: string, progress: number, ledger: CampfireRecord['ledger']): void { + this.db.exec('BEGIN'); + try { + this.db + .prepare( + `INSERT INTO campfire (channel_id, progress, updated_at) VALUES (?, ?, ?) + ON CONFLICT(channel_id) DO UPDATE SET progress = excluded.progress, updated_at = excluded.updated_at`, + ) + .run(channelId, progress, Date.now()); + this.db.prepare('DELETE FROM campfire_ledger WHERE channel_id = ?').run(channelId); + const stmt = this.db.prepare( + 'INSERT INTO campfire_ledger (channel_id, user_id, name, color, contributed) VALUES (?, ?, ?, ?, ?)', + ); + for (const entry of ledger) { + stmt.run(channelId, entry.userId, entry.name, entry.color, entry.contributed); + } + this.db.exec('COMMIT'); + } catch (e) { + this.db.exec('ROLLBACK'); + throw e; + } + } + close(): void { this.db.close(); } diff --git a/packages/server/src/sim/world.ts b/packages/server/src/sim/world.ts index 5704db1..da07b18 100644 --- a/packages/server/src/sim/world.ts +++ b/packages/server/src/sim/world.ts @@ -1,4 +1,6 @@ import { + CAMPFIRE, + CAMPFIRE_SPOT, COMMAND_COOLDOWN_MS, FISH, FISH_SPOTS, @@ -19,12 +21,16 @@ import { WALK_SPEED, WORLD, ZONES, + campfireLevelFor, colorForName, findRecipe, itemTitle, levelForTotalXp, + restBonusFor, type AvatarAction, type AvatarState, + type CampfireContributor, + type CampfireState, type GameEvent, type NodeState, type OreKind, @@ -79,6 +85,7 @@ interface SimAvatar { pendingNodeId: string | null; pendingRecipeId: string | null; pendingRest: boolean; + pendingFire: boolean; /** Бафф отдыха: стаки и до какого времени. */ restStacks: number; restUntil: number; @@ -94,6 +101,7 @@ const FISH_ALIASES = new Set(['рыбачить', 'fish']); 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 SKILL_IDS: SkillId[] = SKILLS.map((s) => s.id); @@ -151,11 +159,29 @@ export class SimWorld { private readonly pendingEvents: GameEvent[] = []; private statsDirty = true; + /** Кооп-костёр: общий пул + ledger вкладов (мета-слой). */ + private campfireProgress = 0; + private campfireLevel = 1; + private campfireLedger = new Map(); + private campfireDirty = true; + private lastDecayAt = Date.now(); + constructor( private readonly channelId: string, preloaded: ViewerRecord[], + campfire?: { progress: number; updatedAt: number; ledger: Array<{ userId: string; name: string; color: string; contributed: number }> }, ) { for (const r of preloaded) this.preloaded.set(r.userId, r); + if (campfire) { + this.campfireProgress = campfire.progress; + this.campfireLevel = campfireLevelFor(campfire.progress); + for (const e of campfire.ledger) { + this.campfireLedger.set(e.userId, { userId: e.userId, name: e.name, color: e.color, contributed: e.contributed }); + } + // декей копился, пока стрим был оффлайн — догоняем ретроактивно + this.applyCampfireDecay(Date.now(), campfire.updatedAt); + } + this.lastDecayAt = Date.now(); } handleCommand(cmd: ChatCommand, now = Date.now()): void { @@ -169,12 +195,15 @@ export class SimWorld { else if (FISH_ALIASES.has(word)) this.doFish(cmd, now); 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 (STOP_ALIASES.has(word)) this.doStop(cmd, now); } tick(now: number): GameEvent[] { const events = this.pendingEvents.splice(0); + this.applyCampfireDecay(now); + for (const node of this.nodes) { if (node.respawnAt !== null && now >= node.respawnAt) { node.respawnAt = null; @@ -240,6 +269,10 @@ export class SimWorld { av.pendingRest = false; this.beginRest(av, now); } + if (av.pendingFire) { + av.pendingFire = false; + this.beginFire(av, now); + } } // ---- команды ---- @@ -341,6 +374,19 @@ export class SimWorld { av.pendingRest = true; } + 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; + + if ((av.items.get(ITEM_LOG) ?? 0) < 1) { + this.pendingEvents.push(this.blocked(av, 'нужны брёвна: !рубить')); + return; + } + this.walkTo(av, CAMPFIRE_SPOT.x, CAMPFIRE_SPOT.y, now); + av.pendingFire = true; + } + private doStop(cmd: ChatCommand, now: number): void { const av = this.ensureAvatar(cmd, now); if (this.onCooldown(av, now)) return; @@ -354,6 +400,7 @@ export class SimWorld { av.pendingNodeId = null; av.pendingRecipeId = null; av.pendingRest = false; + av.pendingFire = false; if (av.action !== 'idle') { av.action = 'idle'; av.nodeId = null; @@ -385,6 +432,7 @@ export class SimWorld { av.pendingNodeId = null; av.pendingRecipeId = null; av.pendingRest = false; + av.pendingFire = false; this.dirtyAvatars.add(av.id); this.statsDirty = true; } @@ -409,6 +457,15 @@ export class SimWorld { this.statsDirty = true; } + private beginFire(av: SimAvatar, now: number): void { + av.action = 'feed'; + av.nodeId = null; + av.actionDur = CAMPFIRE.cycleMs; + av.actionStart = now; + this.dirtyAvatars.add(av.id); + this.statsDirty = true; + } + private completeCycle(av: SimAvatar, now: number, events: GameEvent[]): void { if (av.action === 'chop') { const tree = av.nodeId ? this.nodeById(av.nodeId) : undefined; @@ -499,6 +556,70 @@ export class SimWorld { } else { this.finishOrContinue(av, now, REST.cycleMs, () => (av.items.get('cooked_fish') ?? 0) >= 1); } + } else if (av.action === 'feed') { + if ((av.items.get(ITEM_LOG) ?? 0) < 1) { + this.goIdle(av); + return; + } + this.takeItem(av, ITEM_LOG, 1); + this.addCampfireProgress(CAMPFIRE.progressPerLog, av, events); + this.finishOrContinue(av, now, CAMPFIRE.cycleMs, () => (av.items.get(ITEM_LOG) ?? 0) >= 1); + } + } + + /** Очки в пул костра + запись в персональный ledger. */ + private addCampfireProgress( + points: number, + contributor: { id: string; name: string; color: string } | null, + events: GameEvent[], + ): void { + this.campfireProgress += points; + this.campfireDirty = true; + // без контрибьютора (dev-инъекция) события уровня идут в общую очередь + const sink = contributor ? events : this.pendingEvents; + const level = this.updateCampfireLevel(sink); + if (contributor) { + const entry = + this.campfireLedger.get(contributor.id) ?? + { userId: contributor.id, name: contributor.name, color: contributor.color, contributed: 0 }; + entry.name = contributor.name; + entry.color = contributor.color; + entry.contributed += points; + this.campfireLedger.set(contributor.id, entry); + events.push({ + k: 'fire', + userId: contributor.id, + name: contributor.name, + color: contributor.color, + contributed: entry.contributed, + progress: Math.round(this.campfireProgress), + level, + }); + } + } + + private updateCampfireLevel(events: GameEvent[]): number { + const level = campfireLevelFor(this.campfireProgress); + if (level > this.campfireLevel) { + this.campfireLevel = level; + events.push({ k: 'firelevel', level }); + } else if (level < this.campfireLevel) { + // визуальный откат — событием не спамим + this.campfireLevel = level; + } + return this.campfireLevel; + } + + private applyCampfireDecay(now: number, from?: number): void { + const start = from ?? this.lastDecayAt; + const dt = now - start; + if (dt <= 0) return; + this.lastDecayAt = now; + const before = this.campfireProgress; + this.campfireProgress = Math.max(0, this.campfireProgress - (CAMPFIRE.decayPerMin / 60_000) * dt); + if (this.campfireProgress !== before) { + this.campfireDirty = true; + this.updateCampfireLevel([]); } } @@ -511,7 +632,7 @@ export class SimWorld { events: GameEvent[], ): void { let qty = 1; - av.bonusAcc += av.restStacks * REST.bonusPerStack; + av.bonusAcc += av.restStacks * restBonusFor(this.campfireLevel); if (av.bonusAcc >= 1) { qty += Math.floor(av.bonusAcc); av.bonusAcc %= 1; @@ -647,6 +768,7 @@ export class SimWorld { if (av.action === 'smith') return 'hammer'; if (av.action === 'cook') return 'spoon'; if (av.action === 'fish') return 'rod'; + if (av.action === 'feed') return 'log'; return 'none'; } @@ -673,6 +795,12 @@ export class SimWorld { av.action = 'idle'; av.actionStart = null; av.actionDur = null; + // новая команда отменяет все прежние намерения — иначе прибыв в новую + // точку, аватар «по инерции» начнёт старое действие (ghost-chopping) + av.pendingNodeId = null; + av.pendingRecipeId = null; + av.pendingRest = false; + av.pendingFire = false; this.dirtyAvatars.add(av.id); this.statsDirty = true; } @@ -710,6 +838,11 @@ export class SimWorld { return true; } + /** dev-инъекция очков в костёр (для smoke-тестов). */ + addCampfireProgressDev(points: number): void { + this.addCampfireProgress(points, null, []); + } + lookupViewer( name: string, ): @@ -790,6 +923,33 @@ export class SimWorld { return this.nodes.map((n) => ({ ...n })); } + campfireState(): CampfireState { + const top: CampfireContributor[] = [...this.campfireLedger.values()] + .sort((a, b) => b.contributed - a.contributed) + .slice(0, 5) + .map((e) => ({ name: e.name, color: e.color, contributed: e.contributed })); + return { progress: Math.round(this.campfireProgress * 10) / 10, level: this.campfireLevel, top }; + } + + takeCampfireStateIfDirty(): CampfireState | null { + if (!this.campfireDirty) return null; + this.campfireDirty = false; + return this.campfireState(); + } + + isCampfireDirty(): boolean { + return this.campfireDirty; + } + + campfireLedgerRows(): Array<{ userId: string; name: string; color: string; contributed: number }> { + return [...this.campfireLedger.values()].map((e) => ({ + userId: e.userId, + name: e.name, + color: e.color, + contributed: e.contributed, + })); + } + stats(): ViewerStat[] { return [...this.avatars.values()] .map((av) => ({ @@ -849,6 +1009,7 @@ export class SimWorld { pendingNodeId: null, pendingRecipeId: null, pendingRest: false, + pendingFire: false, restStacks: 0, restUntil: 0, bonusAcc: 0, diff --git a/packages/shared/src/content.ts b/packages/shared/src/content.ts index 0655ac8..39806b6 100644 --- a/packages/shared/src/content.ts +++ b/packages/shared/src/content.ts @@ -108,10 +108,40 @@ export const REST = { cycleMs: 60_000, buffMs: 300_000, maxStacks: 2, - /** Прибавка к добыче за стак (дробные циклы копятся аккумулятором). */ + /** Прибавка к добыче за стак при костре 1-го уровня. */ bonusPerStack: 0.2, } as const; +// ---- кооп-костёр (M4) ---- + +export const CAMPFIRE = { + /** Прогресс за каждое сожжённое бревно. */ + progressPerLog: 10, + cycleMs: 10_000, + /** Очков в минуту сгорает само (декей; копится и за оффлайн). */ + decayPerMin: 4, + /** Пороги прогресса для уровней 1..5. */ + levelThresholds: [0, 100, 300, 700, 1500], +} as const; + +/** Точка, куда встаёт аватар, чтобы подкинуть дров. */ +export const CAMPFIRE_SPOT = { x: 1900, y: 505 } as const; + +export function campfireLevelFor(progress: number): number { + let level = 1; + for (let l = 2; l <= CAMPFIRE.levelThresholds.length; l++) { + const threshold = CAMPFIRE.levelThresholds[l - 1]; + if (threshold === undefined || progress < threshold) break; + level = l; + } + return level; +} + +/** Уровень костра усиливает бафф отдыха: +25% к бонусу за уровень. */ +export function restBonusFor(fireLevel: number): number { + return REST.bonusPerStack * (1 + (fireLevel - 1) * 0.25); +} + // ---- навыки ---- export const SKILLS = [ diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 363566b..9f73e59 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -8,7 +8,7 @@ * дельты вместо полных снапшотов, игровые события, серверная камера. */ -export const PROTOCOL_VERSION = 4; +export const PROTOCOL_VERSION = 5; export interface ZoneDef { id: string; @@ -33,7 +33,7 @@ export interface WorldDef { props: PropDef[]; } -export type AvatarAction = 'idle' | 'chop' | 'mine' | 'smith' | 'cook' | 'fish' | 'rest'; +export type AvatarAction = 'idle' | 'chop' | 'mine' | 'smith' | 'cook' | 'fish' | 'rest' | 'feed'; export interface SkillState { xp: number; @@ -105,6 +105,8 @@ export type GameEvent = | { k: 'fell'; userId: string; name: string; color: string; nodeId: string } | { k: 'depleted'; userId: string; name: string; color: string; nodeId: string } | { 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: 'blocked'; userId: string; name: string; color: string; reason: string }; export interface WelcomeMsg { @@ -115,6 +117,19 @@ export interface WelcomeMsg { world: WorldDef; } +export interface CampfireContributor { + name: string; + color: string; + contributed: number; +} + +/** Кооп-костёр: общий пул прогресса + топ вкладчиков (ledger на сервере). */ +export interface CampfireState { + progress: number; + level: number; + top: CampfireContributor[]; +} + export interface SnapshotMsg { t: 'snapshot'; tick: number; @@ -123,6 +138,7 @@ export interface SnapshotMsg { avatars: AvatarState[]; nodes: NodeState[]; stats: ViewerStat[]; + campfire: CampfireState; } export interface DeltaMsg { @@ -133,6 +149,7 @@ export interface DeltaMsg { avatars?: AvatarState[]; nodes?: NodeState[]; stats?: ViewerStat[]; + campfire?: CampfireState; events?: GameEvent[]; } diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 8fae8b7..6b4618a 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1,8 +1,10 @@