feat: save games and edit scene darkness

Persist show state to local saves; author map darkness in scene editor with bake-to-scene and help updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-10-06 11:41:42 +08:00
parent f6c301a12b
commit 877fbaa8e7
43 changed files with 2086 additions and 72 deletions
+76 -1
View File
@@ -1,7 +1,13 @@
import crypto from 'node:crypto';
import { applyFieldEffectEraserStroke } from '../../shared/fieldEffectEraser';
import type { EffectsEvent, EffectsState, EffectToolState } from '../../shared/types';
import {
isPersistentFieldEffect,
type EffectInstance,
type EffectsEvent,
type EffectsState,
type EffectToolState,
} from '../../shared/types';
function nowMs(): number {
return Date.now();
@@ -23,6 +29,9 @@ export class EffectsStore {
tool: defaultTool(),
instances: [],
};
/** Постоянные эффекты по карточке (graphNodeId или sceneId) на время показа. */
private cache = new Map<string, EffectInstance[]>();
private currentKey: string | null = null;
getState(): EffectsState {
// Всегда обновляем serverNowMs при чтении — это наш "таймкод" для рендереров.
@@ -36,13 +45,70 @@ export class EffectsStore {
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<string, EffectInstance[]> {
this.commitCurrent();
const out: Record<string, EffectInstance[]> = {};
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<string, EffectInstance[]>): 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;
}
@@ -78,9 +144,18 @@ export class EffectsStore {
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())}`;
}
+108
View File
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { EffectsStore } from './effectsStore';
import { GameSavesStore } from '../gameSave/gameSavesStore';
import { canonicalGameSave, emptyGameSaveBody, freezeTokenPathPlayback } from '../../shared/gameSave/buildSnapshot';
import type { EffectInstance } from '../../shared/types';
import type { TokenPath } from '../../shared/types/tokenPath';
import type { TokenPathPlaybackEntry } from '../../shared/types/tokenPathSession';
function fog(): EffectInstance {
return {
id: 'fog1',
type: 'fog',
seed: 1,
createdAtMs: 1,
points: [{ x: 0.2, y: 0.2, tMs: 1 }],
radiusN: 0.1,
opacity: 0.4,
lifetimeMs: null,
};
}
function bolt(): EffectInstance {
return {
id: 'lt1',
type: 'lightning',
seed: 2,
createdAtMs: 1,
start: { x: 0, y: 0 },
end: { x: 1, y: 1 },
widthN: 0.02,
intensity: 1,
lifetimeMs: 800,
};
}
void test('effects stay on the card and action effects do not', () => {
const store = new EffectsStore();
store.switchScene('card-a');
store.dispatch({ kind: 'instance.add', instance: fog() });
store.dispatch({ kind: 'instance.add', instance: bolt() });
store.switchScene('card-b');
assert.equal(store.getState().instances.length, 0);
store.switchScene('card-a');
assert.deepEqual(
store.getState().instances.map((i) => i.type),
['fog'],
);
store.dispatch({ kind: 'instances.clear' });
store.switchScene('card-b');
store.switchScene('card-a');
assert.equal(store.getState().instances.length, 0);
});
void test('freezeTokenPathPlayback parks a moving token', () => {
const pathLine: TokenPath = {
points: [
{ nx: 0, ny: 0.5 },
{ nx: 1, ny: 0.5 },
],
closed: false,
loopMode: 'once',
durationSec: 2,
startMode: 'onEnter',
delaySec: 0,
facingMode: 'tangentSmooth',
fixedRotationDeg: 0,
};
const entry: TokenPathPlaybackEntry = {
kind: 'token',
placementId: 't1',
phase: 'moving',
segmentStartedAtMs: 0,
baseDist: 0,
direction: 1,
rejoinDist: null,
durationSec: 2,
pathLength: 1,
};
const frozen = freezeTokenPathPlayback({ 'token:t1': entry }, () => pathLine, 1000);
const next = frozen['token:t1'];
assert.ok(next);
assert.equal(next.phase, 'stopped');
assert.ok(next.baseDist > 0.4 && next.baseDist < 0.6);
});
void test('game save file keeps id on overwrite and lists one campaign', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-saves-'));
const store = new GameSavesStore(root);
const body = emptyGameSaveBody('p_one');
const first = await store.write('p_one', body, { id: null, name: 'Вечер' });
const again = await store.write('p_one', body, { id: first.id, name: 'Вечер 2' });
assert.equal(again.id, first.id);
assert.equal(again.name, 'Вечер 2');
const list = await store.list('p_one');
assert.equal(list.length, 1);
assert.equal(list[0]?.name, 'Вечер 2');
const other = emptyGameSaveBody('p_two');
await store.write('p_two', other, { id: null, name: 'Другая' });
assert.equal((await store.list('p_one')).length, 1);
const loaded = await store.read('p_one', first.id);
const { id: _id, name: _name, savedAt: _savedAt, ...rest } = loaded;
assert.equal(canonicalGameSave(rest), canonicalGameSave(body));
});
@@ -67,6 +67,66 @@ void test('SceneDarknessStore: draft синхронизируется и сбр
assert.equal(store.getState().strokes.length, 1);
});
void test('SceneDarknessStore: cache miss берёт seedStrokes из сцены', () => {
const store = new SceneDarknessStore();
const seed = [
{
id: 'base1',
seed: 7,
createdAtMs: 50,
points: [{ x: 0.3, y: 0.4, tMs: 50 }],
radiusN: 0.06,
mode: 'reveal' as const,
},
];
store.switchScene('card_a', true, seed);
assert.equal(store.getState().strokes.length, 1);
assert.equal(store.getState().strokes[0]?.id, 'base1');
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 'session1',
seed: 2,
createdAtMs: 100,
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
radiusN: 0.08,
mode: 'cover',
},
});
store.switchScene('card_b', true, []);
store.switchScene('card_a', true, seed);
assert.equal(store.getState().strokes.length, 2);
assert.equal(store.getState().strokes[1]?.id, 'session1');
});
void test('SceneDarknessStore: resetSession снова берёт seed', () => {
const store = new SceneDarknessStore();
const seed = [
{
id: 'base1',
seed: 1,
createdAtMs: 10,
points: [{ x: 0.1, y: 0.1, tMs: 10 }],
radiusN: 0.05,
},
];
store.switchScene('card_a', true, seed);
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 's1',
seed: 2,
createdAtMs: 20,
points: [{ x: 0.2, y: 0.2, tMs: 20 }],
radiusN: 0.05,
},
});
store.resetSession();
store.switchScene('card_a', true, seed);
assert.equal(store.getState().strokes.length, 1);
assert.equal(store.getState().strokes[0]?.id, 'base1');
});
void test('SceneDarknessStore: cover-штрих сохраняется в кэше сцены', () => {
const store = new SceneDarknessStore();
store.switchScene('scene_a', true);
+44 -4
View File
@@ -29,14 +29,26 @@ export class SceneDarknessStore {
/**
* Переключение сцены: сохраняем текущие штрихи в кэш и загружаем состояние новой сцены.
* Если ключа ещё не было в сессии — берём seed (база из файла партии).
*/
switchScene(cacheKey: string | null, enabled: boolean): SceneDarknessState {
switchScene(
cacheKey: string | null,
enabled: boolean,
seedStrokes: readonly SceneDarknessRevealStroke[] = [],
): SceneDarknessState {
if (this.currentKey !== null) {
this.cache.set(this.currentKey, [...this.state.strokes]);
this.cache.set(this.currentKey, this.state.strokes.map(cloneStroke));
}
this.currentKey = cacheKey;
const strokes =
cacheKey && enabled ? [...(this.cache.get(cacheKey) ?? [])] : [];
let strokes: SceneDarknessRevealStroke[] = [];
if (cacheKey && enabled) {
if (this.cache.has(cacheKey)) {
strokes = (this.cache.get(cacheKey) ?? []).map(cloneStroke);
} else {
strokes = seedStrokes.map(cloneStroke);
this.cache.set(cacheKey, strokes.map(cloneStroke));
}
}
this.state = {
revision: this.state.revision + 1,
enabled,
@@ -77,4 +89,32 @@ export class SceneDarknessStore {
}
}
}
/** Кэш по карточкам, включая текущую. Пустые карточки не входят. */
exportByCard(): Record<string, SceneDarknessRevealStroke[]> {
if (this.currentKey !== null) {
this.cache.set(this.currentKey, this.state.strokes.map(cloneStroke));
}
const out: Record<string, SceneDarknessRevealStroke[]> = {};
for (const [key, strokes] of this.cache) {
if (strokes.length === 0) continue;
out[key] = strokes.map(cloneStroke);
}
return out;
}
/** Кладёт кэш, не переключая карточку. Дальше вызывают switchScene. */
importByCard(byCard: Record<string, SceneDarknessRevealStroke[]>): void {
this.cache.clear();
this.currentKey = null;
this.state = emptyState();
for (const [key, strokes] of Object.entries(byCard)) {
if (!Array.isArray(strokes) || strokes.length === 0) continue;
this.cache.set(key, strokes.map(cloneStroke));
}
}
}
function cloneStroke(stroke: SceneDarknessRevealStroke): SceneDarknessRevealStroke {
return { ...stroke, points: stroke.points.map((p) => ({ ...p })) };
}
+26
View File
@@ -105,4 +105,30 @@ export class SceneMarkerStore {
}
}
}
exportByCard(): Record<string, SceneMarkerStroke[]> {
if (this.currentKey !== null) {
this.cache.set(this.currentKey, this.state.strokes.map(cloneStroke));
}
const out: Record<string, SceneMarkerStroke[]> = {};
for (const [key, strokes] of this.cache) {
if (strokes.length === 0) continue;
out[key] = strokes.map(cloneStroke);
}
return out;
}
importByCard(byCard: Record<string, SceneMarkerStroke[]>): void {
this.cache.clear();
this.currentKey = null;
this.state = emptyState();
for (const [key, strokes] of Object.entries(byCard)) {
if (!Array.isArray(strokes) || strokes.length === 0) continue;
this.cache.set(key, strokes.map(cloneStroke));
}
}
}
function cloneStroke(stroke: SceneMarkerStroke): SceneMarkerStroke {
return { ...stroke, points: stroke.points.map((p) => ({ ...p })) };
}