feat(tokens): app-local non-player tokens with session moves and UI polish

Add token library/placements, keep play-time moves for the session, lock presentation interactions, and fix export/import modal layout plus freeform trap label.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-27 11:04:12 +08:00
parent bdeb64e356
commit f270812219
44 changed files with 2617 additions and 341 deletions
@@ -0,0 +1,54 @@
import type { SceneTokensSessionEvent, SceneTokensSessionState } from '../../shared/types';
function emptyState(): SceneTokensSessionState {
return {
revision: 1,
byPlacementId: {},
};
}
export class SceneTokensSessionStore {
private state: SceneTokensSessionState = emptyState();
getState(): SceneTokensSessionState {
return this.state;
}
reset(): SceneTokensSessionState {
if (Object.keys(this.state.byPlacementId).length === 0) return this.state;
this.state = {
revision: this.state.revision + 1,
byPlacementId: {},
};
return this.state;
}
dispatch(event: SceneTokensSessionEvent): SceneTokensSessionState {
switch (event.kind) {
case 'clear':
return this.reset();
case 'move': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const nx = Math.max(0, Math.min(1, event.nx));
const ny = Math.max(0, Math.min(1, event.ny));
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
const prev = this.state.byPlacementId[placementId];
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: { nx, ny },
},
};
return this.state;
}
default: {
const _exhaustive: never = event;
void _exhaustive;
return this.state;
}
}
}
}
+277
View File
@@ -0,0 +1,277 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { AppToken, TokenId } from '../../shared/types';
import { asTokenId } from '../../shared/types/ids';
import { optimizeImageBufferVisuallyLossless } from '../project/optimizeImageImport.lib.mjs';
type TokensManifest = {
tokens: AppToken[];
};
function mimeFromExt(ext: string): string {
const e = ext.toLowerCase();
if (e === '.png') return 'image/png';
if (e === '.jpg' || e === '.jpeg') return 'image/jpeg';
if (e === '.webp') return 'image/webp';
if (e === '.gif') return 'image/gif';
return 'application/octet-stream';
}
function safeFileBase(name: string): string {
const base = name.replace(/[^\w.\-]+/gu, '_').slice(0, 48);
return base || 'token';
}
function randomTokenId(): TokenId {
return asTokenId(`token_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
}
export class TokensStore {
private readonly rootDir: string;
private readonly filesDir: string;
private readonly manifestPath: string;
private tokens: AppToken[] = [];
private loaded = false;
constructor(userData: string) {
this.rootDir = path.join(userData, 'tokens');
this.filesDir = path.join(this.rootDir, 'files');
this.manifestPath = path.join(this.rootDir, 'tokens.json');
}
async ensureLoaded(): Promise<void> {
if (this.loaded) return;
await fs.mkdir(this.filesDir, { recursive: true });
try {
const raw = await fs.readFile(this.manifestPath, 'utf8');
const parsed = JSON.parse(raw) as TokensManifest;
this.tokens = Array.isArray(parsed.tokens)
? parsed.tokens.filter(
(t): t is AppToken =>
Boolean(t) &&
typeof t.id === 'string' &&
typeof t.name === 'string' &&
typeof t.imageRelPath === 'string' &&
typeof t.sha256 === 'string',
)
: [];
} catch {
this.tokens = [];
await this.persist();
}
this.loaded = true;
}
list(): AppToken[] {
return [...this.tokens];
}
getById(id: TokenId): AppToken | null {
return this.tokens.find((t) => t.id === id) ?? null;
}
findBySha256(sha256: string): AppToken | null {
return this.tokens.find((t) => t.sha256 === sha256) ?? null;
}
getImageReadInfo(id: TokenId): { absPath: string; mime: string } | null {
const token = this.getById(id);
if (!token) return null;
const absPath = path.join(this.rootDir, token.imageRelPath);
return { absPath, mime: mimeFromExt(path.extname(token.imageRelPath)) };
}
getImageUrl(id: TokenId): string | null {
if (!this.getImageReadInfo(id)) return null;
return `dnd://token?id=${encodeURIComponent(id)}`;
}
absPathForRel(relPath: string): string {
return path.join(this.rootDir, relPath);
}
async upsert(input: {
id?: TokenId | null;
name: string;
filePath?: string | null;
}): Promise<AppToken> {
await this.ensureLoaded();
const name = input.name.trim();
if (!name) throw new Error('Token name is required');
const existing = input.id ? this.getById(input.id) : null;
if (input.id && !existing) throw new Error('Token not found');
if (!existing && !input.filePath) throw new Error('Token image is required');
let imageRelPath = existing?.imageRelPath ?? '';
let sha256 = existing?.sha256 ?? '';
if (input.filePath) {
let buf = await fs.readFile(input.filePath);
try {
const opt = await optimizeImageBufferVisuallyLossless(buf);
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
} catch {
/* keep original */
}
sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const id = existing?.id ?? randomTokenId();
const ext = path.extname(input.filePath) || '.png';
const fileName = `${id}_${safeFileBase(name)}${ext.toLowerCase()}`;
imageRelPath = path.join('files', fileName).replace(/\\/gu, '/');
const abs = path.join(this.rootDir, imageRelPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, buf);
if (existing && existing.imageRelPath !== imageRelPath) {
try {
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
} catch {
/* ignore */
}
}
const token: AppToken = {
id,
name,
imageRelPath,
sha256,
};
if (existing) {
this.tokens = this.tokens.map((t) => (t.id === id ? token : t));
} else {
this.tokens = [...this.tokens, token];
}
await this.persist();
return token;
}
const token: AppToken = {
id: existing!.id,
name,
imageRelPath,
sha256,
};
this.tokens = this.tokens.map((t) => (t.id === token.id ? token : t));
await this.persist();
return token;
}
/** Импорт токена из внешнего файла (storyline zip) с заданным id или дедупом по sha256. */
async importFromFile(input: {
preferredId: TokenId;
name: string;
absFilePath: string;
sha256?: string;
}): Promise<{ token: AppToken; remappedFrom: TokenId }> {
await this.ensureLoaded();
let buf = await fs.readFile(input.absFilePath);
const sha256 =
input.sha256 ?? crypto.createHash('sha256').update(buf).digest('hex');
const existingByHash = this.findBySha256(sha256);
if (existingByHash) {
return { token: existingByHash, remappedFrom: input.preferredId };
}
const existingById = this.getById(input.preferredId);
const id = existingById ? randomTokenId() : input.preferredId;
try {
const opt = await optimizeImageBufferVisuallyLossless(buf);
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
} catch {
/* keep */
}
const ext = path.extname(input.absFilePath) || '.png';
const fileName = `${id}_${safeFileBase(input.name)}${ext.toLowerCase()}`;
const imageRelPath = path.join('files', fileName).replace(/\\/gu, '/');
const abs = path.join(this.rootDir, imageRelPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, buf);
const token: AppToken = {
id,
name: input.name.trim() || 'Token',
imageRelPath,
sha256: crypto.createHash('sha256').update(buf).digest('hex'),
};
this.tokens = [...this.tokens, token];
await this.persist();
return { token, remappedFrom: input.preferredId };
}
async delete(id: TokenId): Promise<void> {
await this.ensureLoaded();
const existing = this.getById(id);
if (!existing) return;
this.tokens = this.tokens.filter((t) => t.id !== id);
await this.persist();
try {
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
} catch {
/* ignore */
}
}
/** Упаковать выбранные токены в каталог экспорта (`app-tokens/`). */
async packForExport(tokenIds: string[], exportRoot: string): Promise<void> {
await this.ensureLoaded();
const idSet = new Set(tokenIds);
const selected = this.tokens.filter((t) => idSet.has(t.id));
if (selected.length === 0) return;
const outDir = path.join(exportRoot, 'app-tokens');
const filesOut = path.join(outDir, 'files');
await fs.mkdir(filesOut, { recursive: true });
const packed: AppToken[] = [];
for (const t of selected) {
const src = path.join(this.rootDir, t.imageRelPath);
const base = path.basename(t.imageRelPath);
const destRel = path.join('files', base).replace(/\\/gu, '/');
await fs.copyFile(src, path.join(outDir, destRel));
packed.push({ ...t, imageRelPath: destRel });
}
await fs.writeFile(
path.join(outDir, 'tokens.json'),
`${JSON.stringify({ tokens: packed }, null, 2)}\n`,
'utf8',
);
}
/**
* Импорт из `sourceCache/app-tokens/`.
* @returns map oldTokenId → newTokenId
*/
async importFromExportDir(sourceCache: string): Promise<Map<string, string>> {
await this.ensureLoaded();
const remap = new Map<string, string>();
const manifestPath = path.join(sourceCache, 'app-tokens', 'tokens.json');
let raw: string;
try {
raw = await fs.readFile(manifestPath, 'utf8');
} catch {
return remap;
}
let parsed: TokensManifest;
try {
parsed = JSON.parse(raw) as TokensManifest;
} catch {
return remap;
}
const list = Array.isArray(parsed.tokens) ? parsed.tokens : [];
for (const t of list) {
if (!t?.id || !t.imageRelPath) continue;
const abs = path.join(sourceCache, 'app-tokens', t.imageRelPath);
const { token, remappedFrom } = await this.importFromFile({
preferredId: asTokenId(t.id),
name: typeof t.name === 'string' ? t.name : 'Token',
absFilePath: abs,
sha256: typeof t.sha256 === 'string' ? t.sha256 : undefined,
});
remap.set(remappedFrom, token.id);
}
return remap;
}
private async persist(): Promise<void> {
await fs.mkdir(this.rootDir, { recursive: true });
const payload: TokensManifest = { tokens: this.tokens };
await fs.writeFile(this.manifestPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
}
}