feat: add common social fireplace

This commit is contained in:
2026-09-05 18:20:49 +05:00
parent 32527ec2ed
commit ca830b00b9
11 changed files with 539 additions and 121 deletions
+74 -99
View File
@@ -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}`);
}
+30 -2
View File
@@ -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;
+67 -1
View File
@@ -15,7 +15,13 @@ export interface ViewerRecord {
items: Record<string, number>;
}
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();
}
+162 -1
View File
@@ -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<string, CampfireContributor & { userId: string }>();
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,