feat: add common social fireplace
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user