feat: add common social fireplace
This commit is contained in:
@@ -101,6 +101,7 @@ XP-кривая Melvor-подобная, уровни открывают нод
|
|||||||
- Онбординг — в самом виджете (HUD-подсказки), чат ботом не засоряем
|
- Онбординг — в самом виджете (HUD-подсказки), чат ботом не засоряем
|
||||||
- Команды: `!рубить`, `!копать`, `!ковать`, `!рыбачить`, `!готовить`, `!отдых`,
|
- Команды: `!рубить`, `!копать`, `!ковать`, `!рыбачить`, `!готовить`, `!отдых`,
|
||||||
`!стоп`, `!подарить`, `!топ`; спотлайт — не команда, а Channel Point награда.
|
`!стоп`, `!подарить`, `!топ`; спотлайт — не команда, а Channel Point награда.
|
||||||
|
M4: `!костёр` — жечь брёвна у костра (прогресс пулу + личный вклад).
|
||||||
RU сейчас, EN-алиасы потом через конфиг
|
RU сейчас, EN-алиасы потом через конфиг
|
||||||
|
|
||||||
### Камера и «показать меня»
|
### Камера и «показать меня»
|
||||||
@@ -123,6 +124,9 @@ XP-кривая Melvor-подобная, уровни открывают нод
|
|||||||
- Общий пул прогресса + ledger персональных вкладов
|
- Общий пул прогресса + ledger персональных вкладов
|
||||||
- Декей съедает вклады → визуальный откат, если долго никто не работает
|
- Декей съедает вклады → визуальный откат, если долго никто не работает
|
||||||
- Уровни костра 1–5
|
- Уровни костра 1–5
|
||||||
|
- Реализация (M4): `!костёр` сжигает бревно за цикл (10 очков), декей ~4 очка/мин
|
||||||
|
и догоняется ретроактивно за оффлайн; уровень усиливает бафф отдыха (+25% к бонусу
|
||||||
|
за уровень), размер пламени растёт с уровнем
|
||||||
|
|
||||||
### Отдых (бафф за активность, не за силу)
|
### Отдых (бафф за активность, не за силу)
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
<div class="badge">{STATUS_TEXT[status]}</div>
|
<div class="badge">{STATUS_TEXT[status]}</div>
|
||||||
<!-- онбординг — в самом виджете, чат ботом не засоряем -->
|
<!-- онбординг — в самом виджете, чат ботом не засоряем -->
|
||||||
{#if status === 'online'}
|
{#if status === 'online'}
|
||||||
<div class="hint">!рубить · !копать · !рыбачить · !ковать <рецепт> · !готовить <рецепт> · !отдых · !стоп</div>
|
<div class="hint">!рубить · !копать · !рыбачить · !ковать <рецепт> · !готовить <рецепт> · !отдых · !костёр · !стоп</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ interface Chip {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeTool(tool: string): Graphics {
|
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);
|
const g = new Graphics().rect(-1, -12, 2, 12).fill(HANDLE);
|
||||||
if (tool === 'axe_rusty' || tool === 'axe_iron') {
|
if (tool === 'axe_rusty' || tool === 'axe_iron') {
|
||||||
g.poly([-1, -12, 7, -12, 7, -7, -1, -8]).fill(tool === 'axe_iron' ? STEEL : RUSTY);
|
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 body = new Graphics().roundRect(-7, -18, 14, 13, 4).fill(av.color);
|
||||||
const head = new Graphics().circle(0, -23, 6.5).fill(SKIN);
|
const head = new Graphics().circle(0, -23, 6.5).fill(SKIN);
|
||||||
this.toolHolder.position.set(6, -14);
|
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);
|
const g = makeTool(tool);
|
||||||
g.visible = false;
|
g.visible = false;
|
||||||
this.tools.set(tool, g);
|
this.tools.set(tool, g);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Application, Container, Graphics, Text } from 'pixi.js';
|
|||||||
import type {
|
import type {
|
||||||
AvatarState,
|
AvatarState,
|
||||||
CameraState,
|
CameraState,
|
||||||
|
CampfireState,
|
||||||
DeltaMsg,
|
DeltaMsg,
|
||||||
GameEvent,
|
GameEvent,
|
||||||
NodeState,
|
NodeState,
|
||||||
@@ -10,7 +11,7 @@ import type {
|
|||||||
WelcomeMsg,
|
WelcomeMsg,
|
||||||
ZoneDef,
|
ZoneDef,
|
||||||
} from '@idle/shared';
|
} from '@idle/shared';
|
||||||
import { RIVER_WATER_Y } from '@idle/shared';
|
import { CAMPFIRE, RIVER_WATER_Y } from '@idle/shared';
|
||||||
import { AvatarView } from './avatar';
|
import { AvatarView } from './avatar';
|
||||||
import { FishSpotView } from './fish';
|
import { FishSpotView } from './fish';
|
||||||
import { RockView } from './rock';
|
import { RockView } from './rock';
|
||||||
@@ -25,6 +26,7 @@ export interface MountWorldOptions {
|
|||||||
onStatus?: (status: WorldStatus) => void;
|
onStatus?: (status: WorldStatus) => void;
|
||||||
onEvent?: (e: GameEvent) => void;
|
onEvent?: (e: GameEvent) => void;
|
||||||
onStats?: (s: ViewerStat[]) => void;
|
onStats?: (s: ViewerStat[]) => void;
|
||||||
|
onCampfire?: (s: CampfireState) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorldHandle {
|
export interface WorldHandle {
|
||||||
@@ -139,7 +141,9 @@ function drawAnvil(): Container {
|
|||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawCampfire(flickers: Flicker[]): Container {
|
function drawCampfire(
|
||||||
|
flickers: Flicker[],
|
||||||
|
): { c: Container; scalable: Array<{ g: Graphics; baseScale: number }> } {
|
||||||
const c = new Container();
|
const c = new Container();
|
||||||
const stones = new Graphics();
|
const stones = new Graphics();
|
||||||
for (let i = 0; i < 6; i++) {
|
for (let i = 0; i < 6; i++) {
|
||||||
@@ -158,8 +162,28 @@ function drawCampfire(flickers: Flicker[]): Container {
|
|||||||
{ g: flame1, base: 0.95, phase: 0 },
|
{ g: flame1, base: 0.95, phase: 0 },
|
||||||
{ g: flame2, base: 0.9, phase: 1.7 },
|
{ 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);
|
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 {
|
function drawStove(flickers: Flicker[]): Container {
|
||||||
@@ -208,6 +232,18 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}
|
|||||||
app.stage.addChild(worldLayer);
|
app.stage.addChild(worldLayer);
|
||||||
|
|
||||||
const flickers: Flicker[] = [];
|
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 clockOffset = 0; // serverNow - Date.now()
|
||||||
let camTarget: CameraState | null = null;
|
let camTarget: CameraState | null = null;
|
||||||
@@ -274,6 +310,9 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}
|
|||||||
for (const f of flickers) {
|
for (const f of flickers) {
|
||||||
f.g.alpha = f.base + Math.sin(now / 150 + f.phase) * 0.15;
|
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) {
|
for (const [id, view] of avatarViews) {
|
||||||
const av = avatars.get(id);
|
const av = avatars.get(id);
|
||||||
if (av) {
|
if (av) {
|
||||||
@@ -306,15 +345,31 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}
|
|||||||
ground.zIndex = -1000;
|
ground.zIndex = -1000;
|
||||||
entityLayer.addChild(ground);
|
entityLayer.addChild(ground);
|
||||||
for (const prop of w.world.props) {
|
for (const prop of w.world.props) {
|
||||||
const view =
|
if (prop.kind === 'anvil') {
|
||||||
prop.kind === 'anvil'
|
const view = drawAnvil();
|
||||||
? drawAnvil()
|
view.position.set(prop.x, prop.y);
|
||||||
: prop.kind === 'campfire'
|
view.zIndex = prop.y;
|
||||||
? drawCampfire(flickers)
|
entityLayer.addChild(view);
|
||||||
: drawStove(flickers);
|
} else if (prop.kind === 'campfire') {
|
||||||
view.position.set(prop.x, prop.y);
|
const built = drawCampfire(flickers);
|
||||||
view.zIndex = prop.y;
|
built.c.position.set(prop.x, prop.y);
|
||||||
entityLayer.addChild(view);
|
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 av of snap.avatars) syncAvatar(av);
|
||||||
for (const st of snap.nodes) syncNode(st);
|
for (const st of snap.nodes) syncNode(st);
|
||||||
pruneEntities();
|
pruneEntities();
|
||||||
|
if (snap.campfire) {
|
||||||
|
updateCampfireVisual(snap.campfire);
|
||||||
|
opts.onCampfire?.(snap.campfire);
|
||||||
|
}
|
||||||
opts.onStats?.(snap.stats);
|
opts.onStats?.(snap.stats);
|
||||||
},
|
},
|
||||||
applyDelta(d) {
|
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);
|
if (d.nodes) for (const st of d.nodes) syncNode(st);
|
||||||
for (const e of d.events ?? []) opts.onEvent?.(e);
|
for (const e of d.events ?? []) opts.onEvent?.(e);
|
||||||
if (d.stats) opts.onStats?.(d.stats);
|
if (d.stats) opts.onStats?.(d.stats);
|
||||||
|
if (d.campfire) {
|
||||||
|
updateCampfireVisual(d.campfire);
|
||||||
|
opts.onCampfire?.(d.campfire);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
void app.destroy(true, { children: true });
|
void app.destroy(true, { children: true });
|
||||||
|
|||||||
+74
-99
@@ -1,12 +1,14 @@
|
|||||||
/* Дымовой тест M3: сервер должен быть запущен с DEV_HTTP=1, SQLITE_PATH=:memory:.
|
/* Дымовой тест M4 (кооп-костёр), две фазы:
|
||||||
`pnpm --filter @idle/server smoke` */
|
node smoke.mjs phase1 — вклад, уровень, ledger (сервер с DEV_HTTP=1 + файловая база)
|
||||||
|
node smoke.mjs phase2 — после рестарта сервера: персистентность + декей + guard версии */
|
||||||
|
import { readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
const BASE = 'http://localhost:3000';
|
const BASE = 'http://localhost:3000';
|
||||||
const WS = 'ws://localhost:3000/ws';
|
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));
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
// ждём готовности сервера (tsx может стартовать дольше пары секунд)
|
|
||||||
for (let i = 0; i < 30; i++) {
|
for (let i = 0; i < 30; i++) {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${BASE}/healthz`);
|
const r = await fetch(`${BASE}/healthz`);
|
||||||
@@ -15,12 +17,11 @@ for (let i = 0; i < 30; i++) {
|
|||||||
await sleep(500);
|
await sleep(500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// антиспам-кулдаун команд — между командами выдерживаем паузу
|
|
||||||
const COOLDOWN = 1400;
|
const COOLDOWN = 1400;
|
||||||
|
|
||||||
function connect(onMsg) {
|
function connect(onMsg) {
|
||||||
const ws = new WebSocket(WS);
|
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))));
|
ws.on('message', (d) => onMsg(JSON.parse(String(d))));
|
||||||
return ws;
|
return ws;
|
||||||
}
|
}
|
||||||
@@ -34,17 +35,11 @@ async function dev(path, body) {
|
|||||||
if (!res.ok) throw new Error(`POST ${path} -> ${res.status} ${await res.text()}`);
|
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() {
|
async function state() {
|
||||||
const res = await fetch(`${BASE}/dev/state`);
|
const res = await fetch(`${BASE}/dev/state`);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ждём дельту, удовлетворяющую условию. */
|
|
||||||
function waitForDelta(pred, timeoutMs = 9000) {
|
function waitForDelta(pred, timeoutMs = 9000) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const ws = connect((m) => {
|
const ws = connect((m) => {
|
||||||
@@ -61,99 +56,79 @@ function waitForDelta(pred, timeoutMs = 9000) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. welcome + полный снапшот
|
function firstSnapshot() {
|
||||||
const first = [];
|
return new Promise((resolve, reject) => {
|
||||||
await new Promise((resolve, reject) => {
|
const seen = [];
|
||||||
const ws = connect((m) => {
|
const ws = connect((m) => {
|
||||||
first.push(m);
|
seen.push(m);
|
||||||
if (first.length >= 2) {
|
if (seen.length >= 2) {
|
||||||
ws.close();
|
ws.close();
|
||||||
resolve();
|
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. спавн через !рубить
|
const phase = process.argv[2] ?? 'phase1';
|
||||||
await dev('/dev/command', { name: 'Тестер', text: '!рубить' });
|
|
||||||
await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.moving));
|
|
||||||
console.log('spawn ok');
|
|
||||||
|
|
||||||
// 3. рыбалка
|
if (phase === 'phase1') {
|
||||||
await dev('/dev/tp', { name: 'Тестер', x: 2250, y: 375 });
|
const snap = await firstSnapshot();
|
||||||
await sleep(COOLDOWN);
|
console.log('welcome v5 | campfire:', JSON.stringify(snap.campfire));
|
||||||
await dev('/dev/command', { name: 'Тестер', text: '!рыбачить' });
|
if (!snap.campfire || snap.campfire.level < 1) throw new Error('no campfire state');
|
||||||
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');
|
|
||||||
|
|
||||||
// 4. готовка жареной рыбы
|
// спавн, брёвна, !костёр
|
||||||
await dev('/dev/give', { name: 'Тестер', item: 'raw_fish', qty: 2 });
|
await dev('/dev/command', { name: 'Тестер', text: '!рубить' });
|
||||||
await dev('/dev/tp', { name: 'Тестер', x: 2990, y: 425 });
|
await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.moving));
|
||||||
await sleep(COOLDOWN);
|
await dev('/dev/give', { name: 'Тестер', item: 'log', qty: 2 });
|
||||||
await dev('/dev/command', { name: 'Тестер', text: '!готовить жареную рыбу' });
|
await dev('/dev/tp', { name: 'Тестер', x: 1900, y: 505 });
|
||||||
const d2 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'cook'));
|
await sleep(COOLDOWN);
|
||||||
const cook = d2.avatars.find((a) => a.name === 'Тестер');
|
await dev('/dev/command', { name: 'Тестер', text: '!костёр' });
|
||||||
console.log('cook: action=' + cook.action, 'tool=' + cook.tool);
|
const d1 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'feed'));
|
||||||
if (cook.action !== 'cook' || cook.tool !== 'spoon') throw new Error('bad cook state');
|
const feeder = d1.avatars.find((a) => a.name === 'Тестер');
|
||||||
await sleep(11_000);
|
console.log('feed: action=' + feeder.action, 'tool=' + feeder.tool);
|
||||||
const inv2 = await inventory('Тестер');
|
if (feeder.action !== 'feed' || feeder.tool !== 'log') throw new Error('bad feed state');
|
||||||
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');
|
|
||||||
|
|
||||||
// 5. отдых без рыбы у второго зрителя → blocked
|
const d2 = await waitForDelta((m) => m.events?.some((e) => e.k === 'fire' && e.name === 'Тестер'), 15000);
|
||||||
await dev('/dev/command', { name: 'Тестер2', text: '!рубить' });
|
const fireEv = d2.events.find((e) => e.k === 'fire');
|
||||||
await sleep(300);
|
console.log(`fire: прогресс=${fireEv.progress}, личный вклад=${fireEv.contributed}`);
|
||||||
await dev('/dev/tp', { name: 'Тестер2', x: 1960, y: 515 });
|
if (fireEv.contributed < 10 || fireEv.progress < 10) throw new Error('campfire did not grow');
|
||||||
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);
|
|
||||||
|
|
||||||
// 6. отдых Тестера с рыбой: цикл 60 сек → стак баффа
|
await dev('/dev/fire', { points: 95 });
|
||||||
await dev('/dev/tp', { name: 'Тестер', x: 1960, y: 515 });
|
const d3 = await waitForDelta((m) => m.events?.some((e) => e.k === 'firelevel'), 5000);
|
||||||
await sleep(COOLDOWN);
|
const lv = d3.events.find((e) => e.k === 'firelevel').level;
|
||||||
await dev('/dev/command', { name: 'Тестер', text: '!отдых' });
|
const st1 = await state();
|
||||||
const d4 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'rest'));
|
console.log(
|
||||||
console.log('rest: начат');
|
`firelevel: ${lv}, campfire.level=${st1.campfire.level}, топ: ${st1.campfire.top
|
||||||
await sleep(62_000);
|
.map((t) => t.name + '=' + t.contributed)
|
||||||
const st = await state();
|
.join(', ')}`,
|
||||||
const tester = st.avatars.find((a) => a.name === 'Тестер');
|
);
|
||||||
const inv3 = await inventory('Тестер');
|
if (lv < 2 || st1.campfire.level < 2) throw new Error('campfire level did not rise');
|
||||||
console.log(
|
if (!st1.campfire.top.some((t) => t.name === 'Тестер')) throw new Error('ledger missing');
|
||||||
'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');
|
|
||||||
|
|
||||||
// 7. неверная версия протокола — close 4001
|
writeFileSync(PROGRESS_FILE, String(st1.campfire.progress), 'utf8');
|
||||||
const closeCode = await new Promise((resolve) => {
|
console.log(`phase1 OK (progress=${st1.campfire.progress}). Перезапусти сервер и запусти: node smoke.mjs phase2`);
|
||||||
const ws = new WebSocket(WS);
|
} else if (phase === 'phase2') {
|
||||||
ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 999 })));
|
const before = Number(readFileSync(PROGRESS_FILE, 'utf8'));
|
||||||
ws.on('close', (code) => resolve(code));
|
const snap = await firstSnapshot();
|
||||||
setTimeout(() => resolve('no-close'), 3000);
|
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 (closeCode !== 4001) throw new Error(`expected close 4001, got ${closeCode}`);
|
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}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
ZONES,
|
ZONES,
|
||||||
colorForName,
|
colorForName,
|
||||||
levelForTotalXp,
|
levelForTotalXp,
|
||||||
|
type CampfireState,
|
||||||
type DeltaMsg,
|
type DeltaMsg,
|
||||||
type GameEvent,
|
type GameEvent,
|
||||||
type SnapshotMsg,
|
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 currentTick = (): number => Math.floor((Date.now() - startedAt) / cfg.tickMs);
|
||||||
|
|
||||||
const db = new Db(cfg.dbPath);
|
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();
|
const camera = new CameraController();
|
||||||
|
|
||||||
function handleCommand(cmd: ChatCommand): void {
|
function handleCommand(cmd: ChatCommand): void {
|
||||||
@@ -101,6 +102,7 @@ const world: WorldSource = {
|
|||||||
avatars: [...sim.avatars.values()].map((av) => sim.serializeAvatar(av)),
|
avatars: [...sim.avatars.values()].map((av) => sim.serializeAvatar(av)),
|
||||||
nodes: sim.serializeNodes(),
|
nodes: sim.serializeNodes(),
|
||||||
stats: sim.stats(),
|
stats: sim.stats(),
|
||||||
|
campfire: sim.campfireState(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -113,7 +115,8 @@ function buildDelta(events: GameEvent[], camChanged: boolean): DeltaMsg | null {
|
|||||||
const avatars = sim.takeDirtyAvatars();
|
const avatars = sim.takeDirtyAvatars();
|
||||||
const nodes = sim.takeDirtyNodes();
|
const nodes = sim.takeDirtyNodes();
|
||||||
const stats = sim.takeStatsIfDirty();
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
const msg: DeltaMsg = { t: 'delta', tick: currentTick(), serverNow: Date.now() };
|
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 (avatars.length > 0) msg.avatars = avatars;
|
||||||
if (nodes.length > 0) msg.nodes = nodes;
|
if (nodes.length > 0) msg.nodes = nodes;
|
||||||
if (stats) msg.stats = stats;
|
if (stats) msg.stats = stats;
|
||||||
|
if (campfire) msg.campfire = campfire;
|
||||||
if (events.length > 0) msg.events = events;
|
if (events.length > 0) msg.events = events;
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
@@ -131,10 +135,16 @@ setInterval(() => {
|
|||||||
const camChanged = camera.tick(now, sim.zoneActivity());
|
const camChanged = camera.tick(now, sim.zoneActivity());
|
||||||
|
|
||||||
for (const rec of sim.takeDirtyViewers()) db.saveViewer(cfg.channelId, rec);
|
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) {
|
for (const ev of events) {
|
||||||
if (ev.k === 'levelup' && cfg.chatAnnounceLevelUp) {
|
if (ev.k === 'levelup' && cfg.chatAnnounceLevelUp) {
|
||||||
announce(`⭐ ${ev.name} вырос(ла) до ${ev.level}-го уровня: ${SKILL_TITLES[ev.skill] ?? ev.skill}!`);
|
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;
|
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') {
|
if (p === '/api/inventory' && req.method === 'GET') {
|
||||||
const name = (url.searchParams.get('name') ?? '').trim();
|
const name = (url.searchParams.get('name') ?? '').trim();
|
||||||
const found = name ? sim.lookupViewer(name) : undefined;
|
const found = name ? sim.lookupViewer(name) : undefined;
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ export interface ViewerRecord {
|
|||||||
items: Record<string, number>;
|
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 — без нативных сборок и
|
* Тонкий слой persist на встроенном node:sqlite — без нативных сборок и
|
||||||
@@ -37,6 +43,8 @@ export class Db {
|
|||||||
if (version < SCHEMA_VERSION) {
|
if (version < SCHEMA_VERSION) {
|
||||||
// дев-стадия: старую схему не мигрируем, а пересоздаём
|
// дев-стадия: старую схему не мигрируем, а пересоздаём
|
||||||
this.db.exec(`
|
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_skills;
|
||||||
DROP TABLE IF EXISTS viewer_items;
|
DROP TABLE IF EXISTS viewer_items;
|
||||||
DROP TABLE IF EXISTS viewers;
|
DROP TABLE IF EXISTS viewers;
|
||||||
@@ -62,6 +70,19 @@ export class Db {
|
|||||||
qty INTEGER NOT NULL DEFAULT 0,
|
qty INTEGER NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (channel_id, user_id, item)
|
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};
|
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 {
|
close(): void {
|
||||||
this.db.close();
|
this.db.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
|
CAMPFIRE,
|
||||||
|
CAMPFIRE_SPOT,
|
||||||
COMMAND_COOLDOWN_MS,
|
COMMAND_COOLDOWN_MS,
|
||||||
FISH,
|
FISH,
|
||||||
FISH_SPOTS,
|
FISH_SPOTS,
|
||||||
@@ -19,12 +21,16 @@ import {
|
|||||||
WALK_SPEED,
|
WALK_SPEED,
|
||||||
WORLD,
|
WORLD,
|
||||||
ZONES,
|
ZONES,
|
||||||
|
campfireLevelFor,
|
||||||
colorForName,
|
colorForName,
|
||||||
findRecipe,
|
findRecipe,
|
||||||
itemTitle,
|
itemTitle,
|
||||||
levelForTotalXp,
|
levelForTotalXp,
|
||||||
|
restBonusFor,
|
||||||
type AvatarAction,
|
type AvatarAction,
|
||||||
type AvatarState,
|
type AvatarState,
|
||||||
|
type CampfireContributor,
|
||||||
|
type CampfireState,
|
||||||
type GameEvent,
|
type GameEvent,
|
||||||
type NodeState,
|
type NodeState,
|
||||||
type OreKind,
|
type OreKind,
|
||||||
@@ -79,6 +85,7 @@ interface SimAvatar {
|
|||||||
pendingNodeId: string | null;
|
pendingNodeId: string | null;
|
||||||
pendingRecipeId: string | null;
|
pendingRecipeId: string | null;
|
||||||
pendingRest: boolean;
|
pendingRest: boolean;
|
||||||
|
pendingFire: boolean;
|
||||||
/** Бафф отдыха: стаки и до какого времени. */
|
/** Бафф отдыха: стаки и до какого времени. */
|
||||||
restStacks: number;
|
restStacks: number;
|
||||||
restUntil: number;
|
restUntil: number;
|
||||||
@@ -94,6 +101,7 @@ const FISH_ALIASES = new Set(['рыбачить', 'fish']);
|
|||||||
const CRAFT_ALIASES = new Set(['ковать', 'готовить', 'craft']);
|
const CRAFT_ALIASES = new Set(['ковать', 'готовить', 'craft']);
|
||||||
const STOP_ALIASES = new Set(['стоп', 'stop']);
|
const STOP_ALIASES = new Set(['стоп', 'stop']);
|
||||||
const REST_ALIASES = new Set(['отдых', 'rest']);
|
const REST_ALIASES = new Set(['отдых', 'rest']);
|
||||||
|
const FIRE_ALIASES = new Set(['костёр', 'костер', 'fire']);
|
||||||
|
|
||||||
const SKILL_IDS: SkillId[] = SKILLS.map((s) => s.id);
|
const SKILL_IDS: SkillId[] = SKILLS.map((s) => s.id);
|
||||||
|
|
||||||
@@ -151,11 +159,29 @@ export class SimWorld {
|
|||||||
private readonly pendingEvents: GameEvent[] = [];
|
private readonly pendingEvents: GameEvent[] = [];
|
||||||
private statsDirty = true;
|
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(
|
constructor(
|
||||||
private readonly channelId: string,
|
private readonly channelId: string,
|
||||||
preloaded: ViewerRecord[],
|
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);
|
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 {
|
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 (FISH_ALIASES.has(word)) this.doFish(cmd, now);
|
||||||
else if (CRAFT_ALIASES.has(word)) this.doCraft(cmd, arg, now);
|
else if (CRAFT_ALIASES.has(word)) this.doCraft(cmd, arg, now);
|
||||||
else if (REST_ALIASES.has(word)) this.doRest(cmd, 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);
|
else if (STOP_ALIASES.has(word)) this.doStop(cmd, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
tick(now: number): GameEvent[] {
|
tick(now: number): GameEvent[] {
|
||||||
const events = this.pendingEvents.splice(0);
|
const events = this.pendingEvents.splice(0);
|
||||||
|
|
||||||
|
this.applyCampfireDecay(now);
|
||||||
|
|
||||||
for (const node of this.nodes) {
|
for (const node of this.nodes) {
|
||||||
if (node.respawnAt !== null && now >= node.respawnAt) {
|
if (node.respawnAt !== null && now >= node.respawnAt) {
|
||||||
node.respawnAt = null;
|
node.respawnAt = null;
|
||||||
@@ -240,6 +269,10 @@ export class SimWorld {
|
|||||||
av.pendingRest = false;
|
av.pendingRest = false;
|
||||||
this.beginRest(av, now);
|
this.beginRest(av, now);
|
||||||
}
|
}
|
||||||
|
if (av.pendingFire) {
|
||||||
|
av.pendingFire = false;
|
||||||
|
this.beginFire(av, now);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- команды ----
|
// ---- команды ----
|
||||||
@@ -341,6 +374,19 @@ export class SimWorld {
|
|||||||
av.pendingRest = true;
|
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 {
|
private doStop(cmd: ChatCommand, now: number): void {
|
||||||
const av = this.ensureAvatar(cmd, now);
|
const av = this.ensureAvatar(cmd, now);
|
||||||
if (this.onCooldown(av, now)) return;
|
if (this.onCooldown(av, now)) return;
|
||||||
@@ -354,6 +400,7 @@ export class SimWorld {
|
|||||||
av.pendingNodeId = null;
|
av.pendingNodeId = null;
|
||||||
av.pendingRecipeId = null;
|
av.pendingRecipeId = null;
|
||||||
av.pendingRest = false;
|
av.pendingRest = false;
|
||||||
|
av.pendingFire = false;
|
||||||
if (av.action !== 'idle') {
|
if (av.action !== 'idle') {
|
||||||
av.action = 'idle';
|
av.action = 'idle';
|
||||||
av.nodeId = null;
|
av.nodeId = null;
|
||||||
@@ -385,6 +432,7 @@ export class SimWorld {
|
|||||||
av.pendingNodeId = null;
|
av.pendingNodeId = null;
|
||||||
av.pendingRecipeId = null;
|
av.pendingRecipeId = null;
|
||||||
av.pendingRest = false;
|
av.pendingRest = false;
|
||||||
|
av.pendingFire = false;
|
||||||
this.dirtyAvatars.add(av.id);
|
this.dirtyAvatars.add(av.id);
|
||||||
this.statsDirty = true;
|
this.statsDirty = true;
|
||||||
}
|
}
|
||||||
@@ -409,6 +457,15 @@ export class SimWorld {
|
|||||||
this.statsDirty = true;
|
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 {
|
private completeCycle(av: SimAvatar, now: number, events: GameEvent[]): void {
|
||||||
if (av.action === 'chop') {
|
if (av.action === 'chop') {
|
||||||
const tree = av.nodeId ? this.nodeById(av.nodeId) : undefined;
|
const tree = av.nodeId ? this.nodeById(av.nodeId) : undefined;
|
||||||
@@ -499,6 +556,70 @@ export class SimWorld {
|
|||||||
} else {
|
} else {
|
||||||
this.finishOrContinue(av, now, REST.cycleMs, () => (av.items.get('cooked_fish') ?? 0) >= 1);
|
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[],
|
events: GameEvent[],
|
||||||
): void {
|
): void {
|
||||||
let qty = 1;
|
let qty = 1;
|
||||||
av.bonusAcc += av.restStacks * REST.bonusPerStack;
|
av.bonusAcc += av.restStacks * restBonusFor(this.campfireLevel);
|
||||||
if (av.bonusAcc >= 1) {
|
if (av.bonusAcc >= 1) {
|
||||||
qty += Math.floor(av.bonusAcc);
|
qty += Math.floor(av.bonusAcc);
|
||||||
av.bonusAcc %= 1;
|
av.bonusAcc %= 1;
|
||||||
@@ -647,6 +768,7 @@ export class SimWorld {
|
|||||||
if (av.action === 'smith') return 'hammer';
|
if (av.action === 'smith') return 'hammer';
|
||||||
if (av.action === 'cook') return 'spoon';
|
if (av.action === 'cook') return 'spoon';
|
||||||
if (av.action === 'fish') return 'rod';
|
if (av.action === 'fish') return 'rod';
|
||||||
|
if (av.action === 'feed') return 'log';
|
||||||
return 'none';
|
return 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,6 +795,12 @@ export class SimWorld {
|
|||||||
av.action = 'idle';
|
av.action = 'idle';
|
||||||
av.actionStart = null;
|
av.actionStart = null;
|
||||||
av.actionDur = null;
|
av.actionDur = null;
|
||||||
|
// новая команда отменяет все прежние намерения — иначе прибыв в новую
|
||||||
|
// точку, аватар «по инерции» начнёт старое действие (ghost-chopping)
|
||||||
|
av.pendingNodeId = null;
|
||||||
|
av.pendingRecipeId = null;
|
||||||
|
av.pendingRest = false;
|
||||||
|
av.pendingFire = false;
|
||||||
this.dirtyAvatars.add(av.id);
|
this.dirtyAvatars.add(av.id);
|
||||||
this.statsDirty = true;
|
this.statsDirty = true;
|
||||||
}
|
}
|
||||||
@@ -710,6 +838,11 @@ export class SimWorld {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** dev-инъекция очков в костёр (для smoke-тестов). */
|
||||||
|
addCampfireProgressDev(points: number): void {
|
||||||
|
this.addCampfireProgress(points, null, []);
|
||||||
|
}
|
||||||
|
|
||||||
lookupViewer(
|
lookupViewer(
|
||||||
name: string,
|
name: string,
|
||||||
):
|
):
|
||||||
@@ -790,6 +923,33 @@ export class SimWorld {
|
|||||||
return this.nodes.map((n) => ({ ...n }));
|
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[] {
|
stats(): ViewerStat[] {
|
||||||
return [...this.avatars.values()]
|
return [...this.avatars.values()]
|
||||||
.map((av) => ({
|
.map((av) => ({
|
||||||
@@ -849,6 +1009,7 @@ export class SimWorld {
|
|||||||
pendingNodeId: null,
|
pendingNodeId: null,
|
||||||
pendingRecipeId: null,
|
pendingRecipeId: null,
|
||||||
pendingRest: false,
|
pendingRest: false,
|
||||||
|
pendingFire: false,
|
||||||
restStacks: 0,
|
restStacks: 0,
|
||||||
restUntil: 0,
|
restUntil: 0,
|
||||||
bonusAcc: 0,
|
bonusAcc: 0,
|
||||||
|
|||||||
@@ -108,10 +108,40 @@ export const REST = {
|
|||||||
cycleMs: 60_000,
|
cycleMs: 60_000,
|
||||||
buffMs: 300_000,
|
buffMs: 300_000,
|
||||||
maxStacks: 2,
|
maxStacks: 2,
|
||||||
/** Прибавка к добыче за стак (дробные циклы копятся аккумулятором). */
|
/** Прибавка к добыче за стак при костре 1-го уровня. */
|
||||||
bonusPerStack: 0.2,
|
bonusPerStack: 0.2,
|
||||||
} as const;
|
} 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 = [
|
export const SKILLS = [
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* дельты вместо полных снапшотов, игровые события, серверная камера.
|
* дельты вместо полных снапшотов, игровые события, серверная камера.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const PROTOCOL_VERSION = 4;
|
export const PROTOCOL_VERSION = 5;
|
||||||
|
|
||||||
export interface ZoneDef {
|
export interface ZoneDef {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -33,7 +33,7 @@ export interface WorldDef {
|
|||||||
props: PropDef[];
|
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 {
|
export interface SkillState {
|
||||||
xp: number;
|
xp: number;
|
||||||
@@ -105,6 +105,8 @@ export type GameEvent =
|
|||||||
| { k: 'fell'; userId: string; name: string; color: string; nodeId: string }
|
| { k: 'fell'; userId: string; name: string; color: string; nodeId: string }
|
||||||
| { k: 'depleted'; 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: '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 };
|
| { k: 'blocked'; userId: string; name: string; color: string; reason: string };
|
||||||
|
|
||||||
export interface WelcomeMsg {
|
export interface WelcomeMsg {
|
||||||
@@ -115,6 +117,19 @@ export interface WelcomeMsg {
|
|||||||
world: WorldDef;
|
world: WorldDef;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CampfireContributor {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
contributed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Кооп-костёр: общий пул прогресса + топ вкладчиков (ledger на сервере). */
|
||||||
|
export interface CampfireState {
|
||||||
|
progress: number;
|
||||||
|
level: number;
|
||||||
|
top: CampfireContributor[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface SnapshotMsg {
|
export interface SnapshotMsg {
|
||||||
t: 'snapshot';
|
t: 'snapshot';
|
||||||
tick: number;
|
tick: number;
|
||||||
@@ -123,6 +138,7 @@ export interface SnapshotMsg {
|
|||||||
avatars: AvatarState[];
|
avatars: AvatarState[];
|
||||||
nodes: NodeState[];
|
nodes: NodeState[];
|
||||||
stats: ViewerStat[];
|
stats: ViewerStat[];
|
||||||
|
campfire: CampfireState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeltaMsg {
|
export interface DeltaMsg {
|
||||||
@@ -133,6 +149,7 @@ export interface DeltaMsg {
|
|||||||
avatars?: AvatarState[];
|
avatars?: AvatarState[];
|
||||||
nodes?: NodeState[];
|
nodes?: NodeState[];
|
||||||
stats?: ViewerStat[];
|
stats?: ViewerStat[];
|
||||||
|
campfire?: CampfireState;
|
||||||
events?: GameEvent[];
|
events?: GameEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import {
|
import {
|
||||||
|
CAMPFIRE,
|
||||||
SKILL_TITLES,
|
SKILL_TITLES,
|
||||||
itemTitle,
|
itemTitle,
|
||||||
|
type CampfireState,
|
||||||
type GameEvent,
|
type GameEvent,
|
||||||
type SkillState,
|
type SkillState,
|
||||||
type ViewerStat,
|
type ViewerStat,
|
||||||
@@ -56,6 +58,10 @@
|
|||||||
return `${e.name} выработал(ла) жилу`;
|
return `${e.name} выработал(ла) жилу`;
|
||||||
case 'rest':
|
case 'rest':
|
||||||
return `🔥 ${e.name} отдыхает у костра — бафф ×${e.stacks}`;
|
return `🔥 ${e.name} отдыхает у костра — бафф ×${e.stacks}`;
|
||||||
|
case 'fire':
|
||||||
|
return `🪵 ${e.name} подкинул(ла) дров — костёр ${e.progress} (вклад ${e.contributed})`;
|
||||||
|
case 'firelevel':
|
||||||
|
return `🔥 Костёр достиг ${e.level}-го уровня!`;
|
||||||
case 'blocked':
|
case 'blocked':
|
||||||
return `${e.name}: ${e.reason}`;
|
return `${e.name}: ${e.reason}`;
|
||||||
}
|
}
|
||||||
@@ -69,9 +75,18 @@
|
|||||||
return Math.max(1, ...Object.values(s.skills).map((v) => v.level));
|
return Math.max(1, ...Object.values(s.skills).map((v) => v.level));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function firePct(cs: CampfireState): number {
|
||||||
|
const thr = CAMPFIRE.levelThresholds;
|
||||||
|
if (cs.level >= thr.length) return 100;
|
||||||
|
const lo = thr[cs.level - 1] ?? 0;
|
||||||
|
const hi = thr[cs.level] ?? 1;
|
||||||
|
return Math.min(100, Math.max(0, Math.round(((cs.progress - lo) / (hi - lo)) * 100)));
|
||||||
|
}
|
||||||
|
|
||||||
let host = $state<HTMLDivElement | undefined>(undefined);
|
let host = $state<HTMLDivElement | undefined>(undefined);
|
||||||
let status = $state<NetStatus>('connecting');
|
let status = $state<NetStatus>('connecting');
|
||||||
let stats = $state<ViewerStat[]>([]);
|
let stats = $state<ViewerStat[]>([]);
|
||||||
|
let campfire = $state<CampfireState | null>(null);
|
||||||
let feed = $state<FeedItem[]>([]);
|
let feed = $state<FeedItem[]>([]);
|
||||||
let feedId = 0;
|
let feedId = 0;
|
||||||
|
|
||||||
@@ -97,7 +112,7 @@
|
|||||||
let disposeNet: (() => void) | undefined;
|
let disposeNet: (() => void) | undefined;
|
||||||
|
|
||||||
if (host) {
|
if (host) {
|
||||||
void mountWorld(host, { background: 0x1d232d }).then((w) => {
|
void mountWorld(host, { background: 0x1d232d, onCampfire: (cf) => (campfire = cf) }).then((w) => {
|
||||||
world = w;
|
world = w;
|
||||||
disposeNet = connectWorld({
|
disposeNet = connectWorld({
|
||||||
onStatus: (s) => {
|
onStatus: (s) => {
|
||||||
@@ -171,6 +186,33 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Костёр</h2>
|
||||||
|
{#if campfire}
|
||||||
|
<div class="fire-line">
|
||||||
|
<span class="k">Уровень {campfire.level}</span>
|
||||||
|
<span class="meta">{campfire.progress} очков</span>
|
||||||
|
</div>
|
||||||
|
<div class="fire-bar"><div class="fire-fill" style:width={`${firePct(campfire)}%`}></div></div>
|
||||||
|
{#if campfire.top.length > 0}
|
||||||
|
<p class="muted" style="margin-top:8px">Вкладчики:</p>
|
||||||
|
<ul>
|
||||||
|
{#each campfire.top as c (c.name)}
|
||||||
|
<li>
|
||||||
|
<span class="dot" style:background={c.color}></span>
|
||||||
|
<span class="name" style:color={c.color}>{c.name}</span>
|
||||||
|
<span class="meta">{c.contributed}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="muted">никто ещё не подкинул дров — !костёр</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p class="muted">костёр ещё не разожгли — !костёр</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>События</h2>
|
<h2>События</h2>
|
||||||
{#if feed.length === 0}
|
{#if feed.length === 0}
|
||||||
@@ -291,6 +333,28 @@
|
|||||||
.inv .k {
|
.inv .k {
|
||||||
color: #c7d3e0;
|
color: #c7d3e0;
|
||||||
}
|
}
|
||||||
|
.fire-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.fire-line .k {
|
||||||
|
color: #ffc46b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.fire-bar {
|
||||||
|
height: 8px;
|
||||||
|
background: #232b38;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.fire-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #ff8c3b, #ffd166);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.6s ease;
|
||||||
|
}
|
||||||
.feed li {
|
.feed li {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
|
|||||||
Reference in New Issue
Block a user