import crypto from 'node:crypto'; import { applyFieldEffectEraserStroke } from '../../shared/fieldEffectEraser'; import { isPersistentFieldEffect, type EffectInstance, type EffectsEvent, type EffectsState, type EffectToolState, } from '../../shared/types'; function nowMs(): number { return Date.now(); } function defaultTool(): EffectToolState { return { tool: 'none', radiusN: 0.08, intensity: 0.6 }; } /** Сброс выбранного инструмента (при старте сессии). */ export function effectsDefaultTool(): EffectToolState { return defaultTool(); } export class EffectsStore { private state: EffectsState = { revision: 1, serverNowMs: nowMs(), tool: defaultTool(), instances: [], }; /** Постоянные эффекты по карточке (graphNodeId или sceneId) на время показа. */ private cache = new Map(); private currentKey: string | null = null; getState(): EffectsState { // Всегда обновляем serverNowMs при чтении — это наш "таймкод" для рендереров. return { ...this.state, serverNowMs: nowMs() }; } clear(): EffectsState { this.state = { ...this.state, revision: this.state.revision + 1, serverNowMs: nowMs(), instances: [], }; this.commitCurrent(); return this.state; } /** Новый показ: и текущая карточка, и кэш остальных. */ resetSession(): EffectsState { this.cache.clear(); this.currentKey = null; this.state = { revision: this.state.revision + 1, serverNowMs: nowMs(), tool: this.state.tool, instances: [], }; return this.getState(); } /** * Смена карточки: туман/огонь/дождь/вода/лёд/пятно остаются на ушедшей * и поднимаются на новой. Разовые эффекты действий сбрасываются. */ switchScene(cacheKey: string | null): EffectsState { this.commitCurrent(); this.currentKey = cacheKey; const instances = cacheKey ? [...(this.cache.get(cacheKey) ?? [])] : []; this.state = { ...this.state, revision: this.state.revision + 1, serverNowMs: nowMs(), instances, }; return this.getState(); } exportByCard(): Record { this.commitCurrent(); const out: Record = {}; for (const [key, instances] of this.cache) { const kept = instances.filter((i) => isPersistentFieldEffect(i.type)); if (kept.length > 0) out[key] = kept.map((i) => ({ ...i })); } return out; } importByCard(byCard: Record): void { this.cache.clear(); this.currentKey = null; this.state = { ...this.state, revision: this.state.revision + 1, serverNowMs: nowMs(), instances: [], }; for (const [key, instances] of Object.entries(byCard)) { const kept = instances.filter((i) => isPersistentFieldEffect(i.type)); if (kept.length > 0) this.cache.set(key, kept.map((i) => ({ ...i }))); } } dispatch(event: EffectsEvent): EffectsState { const s = this.state; const next: EffectsState = applyEvent(s, event, (prefix) => this.makeId(prefix)); this.state = next; this.commitCurrent(); return next; } /** Удаляет истёкшие (по lifetime) эффекты, чтобы state не разрастался бесконечно. */ pruneExpired(): boolean { const now = nowMs(); const before = this.state.instances.length; const kept = this.state.instances.filter((i) => { // Пятно льда не истекает по таймеру (только «очистить все» или ластик в UI). if (i.type === 'ice' || i.type === 'shadow') return true; if ( i.type === 'lightning' || i.type === 'sunbeam' || i.type === 'poisonCloud' || i.type === 'explosion' || i.type === 'darkness' ) { return now - i.createdAtMs < i.lifetimeMs; } if (i.type === 'scorch') { return now - i.createdAtMs < i.lifetimeMs; } if (i.type === 'fog' || i.type === 'fire' || i.type === 'rain' || i.type === 'water') { if (i.lifetimeMs === null) return true; return now - i.createdAtMs < i.lifetimeMs; } return true; }); if (kept.length === before) return false; this.state = { ...this.state, revision: this.state.revision + 1, serverNowMs: now, instances: kept, }; this.commitCurrent(); return true; } private commitCurrent(): void { if (this.currentKey === null) return; this.cache.set( this.currentKey, this.state.instances.filter((i) => isPersistentFieldEffect(i.type)).map((i) => ({ ...i })), ); } makeId(prefix: string): string { return `${prefix}_${crypto.randomBytes(6).toString('hex')}_${String(nowMs())}`; } } function applyEvent( state: EffectsState, event: EffectsEvent, makeId: (prefix: string) => string, ): EffectsState { const bump = (patch: Omit): EffectsState => ({ ...patch, revision: state.revision + 1, serverNowMs: nowMs(), }); switch (event.kind) { case 'tool.set': return bump({ ...state, tool: event.tool }); case 'instances.clear': return bump({ ...state, instances: [] }); case 'instance.add': return bump({ ...state, instances: [...state.instances, event.instance] }); case 'instance.remove': return bump({ ...state, instances: state.instances.filter((i) => i.id !== event.id) }); case 'field.erase': return bump({ ...state, instances: applyFieldEffectEraserStroke( state.instances, event.points, event.radiusN, makeId, event.types, event.hitMode ?? 'inclusive', ), }); default: { // Exhaustiveness // eslint-disable-next-line @typescript-eslint/no-unused-vars const _x: never = event; return state; } } }