feat(players): app Players library and circular NPC tokens on scenes

Add userData players/teams, scene npcTokens with hex-inscribed sizing, session scale synced to presentation, and Playwright e2e coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-30 13:38:35 +08:00
parent a3a03eb9e3
commit 101f595bac
57 changed files with 4176 additions and 317 deletions
+117 -54
View File
@@ -53,9 +53,23 @@ import type {
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens';
import {
clampPlayerImageOffset,
clampPlayerImageScale,
DEFAULT_PLAYER_RING_COLOR,
normalizeSceneNpcToken,
type SceneNpcToken,
} from '../../shared/types/appPlayers';
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
import { normalizeSceneTrap } from '../../shared/types/sceneTraps';
import type { AssetId, GraphNodeId, MaterialId, NpcGroupId, NpcId, NpcRelationId } from '../../shared/types/ids';
import type {
AssetId,
GraphNodeId,
MaterialId,
NpcGroupId,
NpcId,
NpcRelationId,
} from '../../shared/types/ids';
import {
asAssetId,
asGraphNodeId,
@@ -614,6 +628,7 @@ export class ZipProjectStore {
darkenScene: false,
traps: [],
tokens: [],
npcTokens: [],
grid: { ...DEFAULT_SCENE_GRID },
} satisfies Scene);
@@ -621,6 +636,7 @@ export class ZipProjectStore {
...base,
traps: base.traps ?? [],
tokens: base.tokens ?? [],
npcTokens: base.npcTokens ?? [],
grid: base.grid ?? { ...DEFAULT_SCENE_GRID },
...(patch.title !== undefined ? { title: patch.title } : null),
...(patch.description !== undefined ? { description: patch.description } : null),
@@ -636,9 +652,7 @@ export class ZipProjectStore {
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
...(patch.traps !== undefined
? {
traps: patch.traps
.map((t) => normalizeSceneTrap(t))
.filter((t): t is SceneTrap => Boolean(t)),
traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)),
}
: null),
...(patch.tokens !== undefined
@@ -648,6 +662,13 @@ export class ZipProjectStore {
.filter((t): t is SceneToken => Boolean(t)),
}
: null),
...(patch.npcTokens !== undefined
? {
npcTokens: patch.npcTokens
.map((t) => normalizeSceneNpcToken(t))
.filter((t): t is SceneNpcToken => Boolean(t)),
}
: null),
...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null),
...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
@@ -798,7 +819,10 @@ export class ZipProjectStore {
const node = open.project.sceneGraphNodes.find((n) => n.id === graphNodeId);
if (!node) throw new Error('Graph node not found');
const enabling = !node.isSideStoryStart;
if (enabling && !canSetSideStoryStart(open.project.sceneGraphNodes, open.project.sceneGraphEdges, graphNodeId)) {
if (
enabling &&
!canSetSideStoryStart(open.project.sceneGraphNodes, open.project.sceneGraphEdges, graphNodeId)
) {
return open.project;
}
await this.updateProject((p) => {
@@ -1158,16 +1182,11 @@ export class ZipProjectStore {
return latest;
}
async setMaterialRotation(
materialId: MaterialId,
rotationDeg: 0 | 90 | 180 | 270,
): Promise<Project> {
async setMaterialRotation(materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => {
const materials = (p.materials ?? []).map((m) =>
m.id === materialId ? { ...m, rotationDeg } : m,
);
const materials = (p.materials ?? []).map((m) => (m.id === materialId ? { ...m, rotationDeg } : m));
return { ...p, materials };
});
const latest = this.getOpenProject();
@@ -1243,6 +1262,9 @@ export class ZipProjectStore {
description?: string;
filePath?: string;
groupId?: NpcGroupId | null;
ringColor?: string;
imageOffset?: { x: number; y: number };
imageScale?: number;
},
onProgress?: (p: { percent: number; stage: string; detail?: string }) => void,
): Promise<Project> {
@@ -1300,7 +1322,10 @@ export class ZipProjectStore {
if (stagedAsset) assets[stagedAsset.id] = stagedAsset;
const groupIds = new Set((p.npcGroups ?? []).map((g) => g.id));
const resolveGroup = (raw: NpcGroupId | null | undefined, prev: NpcGroupId | null): NpcGroupId | null => {
const resolveGroup = (
raw: NpcGroupId | null | undefined,
prev: NpcGroupId | null,
): NpcGroupId | null => {
if (raw === undefined) return prev;
if (raw === null) return null;
return groupIds.has(raw) ? raw : null;
@@ -1314,9 +1339,17 @@ export class ZipProjectStore {
...prev,
name,
avatarAssetId: nextAssetId ?? prev.avatarAssetId,
description:
typeof input.description === 'string' ? input.description : prev.description,
description: typeof input.description === 'string' ? input.description : prev.description,
groupId: resolveGroup(input.groupId, prev.groupId),
...(input.ringColor !== undefined
? { ringColor: normalizeHexColor(input.ringColor, DEFAULT_PLAYER_RING_COLOR) }
: {}),
...(input.imageOffset !== undefined
? { imageOffset: clampPlayerImageOffset(input.imageOffset) }
: {}),
...(input.imageScale !== undefined
? { imageScale: clampPlayerImageScale(input.imageScale) }
: {}),
};
} else {
if (!nextAssetId) throw new Error('NPC avatar is required');
@@ -1329,6 +1362,9 @@ export class ZipProjectStore {
x: 80 + (count % 4) * 220,
y: 80 + Math.floor(count / 4) * 200,
groupId: resolveGroup(input.groupId, null),
ringColor: normalizeHexColor(input.ringColor, DEFAULT_PLAYER_RING_COLOR),
imageOffset: clampPlayerImageOffset(input.imageOffset),
imageScale: clampPlayerImageScale(input.imageScale),
});
}
return { ...p, assets, npcs };
@@ -1346,20 +1382,18 @@ export class ZipProjectStore {
name?: string;
description?: string;
groupId?: NpcGroupId | null;
ringColor?: string;
imageOffset?: { x: number; y: number };
imageScale?: number;
},
): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const name =
typeof patch.name === 'string' ? patch.name.trim() : undefined;
const name = typeof patch.name === 'string' ? patch.name.trim() : undefined;
if (name !== undefined) {
if (name.length < 1) throw new Error('NPC name is required');
const nameKey = name.toLowerCase();
if (
(open.project.npcs ?? []).some(
(n) => n.id !== npcId && n.name.trim().toLowerCase() === nameKey,
)
) {
if ((open.project.npcs ?? []).some((n) => n.id !== npcId && n.name.trim().toLowerCase() === nameKey)) {
throw new Error('NPC name already exists');
}
}
@@ -1369,14 +1403,22 @@ export class ZipProjectStore {
if (n.id !== npcId) return n;
let groupId = n.groupId;
if (patch.groupId !== undefined) {
groupId =
patch.groupId === null ? null : groupIds.has(patch.groupId) ? patch.groupId : null;
groupId = patch.groupId === null ? null : groupIds.has(patch.groupId) ? patch.groupId : null;
}
return {
...n,
...(name !== undefined ? { name } : {}),
...(typeof patch.description === 'string' ? { description: patch.description } : {}),
groupId,
...(patch.ringColor !== undefined
? { ringColor: normalizeHexColor(patch.ringColor, DEFAULT_PLAYER_RING_COLOR) }
: {}),
...(patch.imageOffset !== undefined
? { imageOffset: clampPlayerImageOffset(patch.imageOffset) }
: {}),
...(patch.imageScale !== undefined
? { imageScale: clampPlayerImageScale(patch.imageScale) }
: {}),
};
});
return { ...p, npcs };
@@ -1401,13 +1443,25 @@ export class ZipProjectStore {
async deleteNpc(npcId: NpcId): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => ({
...p,
npcs: (p.npcs ?? []).filter((n) => n.id !== npcId),
npcRelations: (p.npcRelations ?? []).filter(
(r) => r.sourceNpcId !== npcId && r.targetNpcId !== npcId,
),
}));
await this.updateProject((p) => {
const scenes: Record<SceneId, Scene> = { ...p.scenes };
for (const sid of Object.keys(scenes) as SceneId[]) {
const sc = scenes[sid];
if (!sc) continue;
const npcTokens = (sc.npcTokens ?? []).filter((t) => t.npcId !== npcId);
if (npcTokens.length !== (sc.npcTokens ?? []).length) {
scenes[sid] = { ...sc, npcTokens };
}
}
return {
...p,
scenes,
npcs: (p.npcs ?? []).filter((n) => n.id !== npcId),
npcRelations: (p.npcRelations ?? []).filter(
(r) => r.sourceNpcId !== npcId && r.targetNpcId !== npcId,
),
};
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
@@ -1462,10 +1516,7 @@ export class ZipProjectStore {
}
const nameKey = name.toLowerCase();
const siblingConflict = groups.some(
(g) =>
g.id !== editingId &&
g.parentId === parentId &&
g.name.trim().toLowerCase() === nameKey,
(g) => g.id !== editingId && g.parentId === parentId && g.name.trim().toLowerCase() === nameKey,
);
if (siblingConflict) throw new Error('Group name already exists');
@@ -1498,9 +1549,7 @@ export class ZipProjectStore {
const nextGroups = groups
.filter((g) => g.id !== groupId)
.map((g) => (g.parentId === groupId ? { ...g, parentId: parentOfDeleted } : g));
const npcs = (p.npcs ?? []).map((n) =>
n.groupId === groupId ? { ...n, groupId: null } : n,
);
const npcs = (p.npcs ?? []).map((n) => (n.groupId === groupId ? { ...n, groupId: null } : n));
return { ...p, npcGroups: nextGroups, npcs };
});
const latest = this.getOpenProject();
@@ -1541,10 +1590,7 @@ export class ZipProjectStore {
if (label.length < 1) throw new Error('Relation label is required');
if (input.sourceNpcId === input.targetNpcId) throw new Error('Cannot relate NPC to itself');
const npcs = open.project.npcs ?? [];
if (
!npcs.some((n) => n.id === input.sourceNpcId) ||
!npcs.some((n) => n.id === input.targetNpcId)
) {
if (!npcs.some((n) => n.id === input.sourceNpcId) || !npcs.some((n) => n.id === input.targetNpcId)) {
throw new Error('NPC not found');
}
await this.updateProject((p) => {
@@ -2089,16 +2135,14 @@ export class ZipProjectStore {
sourceForMerge = remapProjectSceneTokenIds(source, remap);
}
const offsetX = computeGraphImportOffsetX(this.openProject.project);
const { project: merged, report, assetCopies } = mergeStorylinesIntoProject(
this.openProject.project,
sourceForMerge,
selections,
sceneResolutions,
{
graphOffsetX: offsetX,
...(npcResolutions ? { npcResolutions } : {}),
},
);
const {
project: merged,
report,
assetCopies,
} = mergeStorylinesIntoProject(this.openProject.project, sourceForMerge, selections, sceneResolutions, {
graphOffsetX: offsetX,
...(npcResolutions ? { npcResolutions } : {}),
});
const targetCache = this.openProject.cacheDir;
await fs.mkdir(path.join(targetCache, 'assets'), { recursive: true });
@@ -2307,6 +2351,10 @@ function normalizeScene(s: Scene): Scene {
const tokens = (Array.isArray(rawTokens) ? rawTokens : [])
.map((t) => normalizeSceneToken(t))
.filter((t): t is SceneToken => Boolean(t));
const rawNpcTokens = (s as unknown as { npcTokens?: unknown[] }).npcTokens;
const npcTokens = (Array.isArray(rawNpcTokens) ? rawNpcTokens : [])
.map((t) => normalizeSceneNpcToken(t))
.filter((t): t is SceneNpcToken => Boolean(t));
const grid = normalizeSceneGrid((s as unknown as { grid?: unknown }).grid);
const rawAudios = Array.isArray(raw.audios) ? raw.audios : [];
@@ -2338,6 +2386,7 @@ function normalizeScene(s: Scene): Scene {
darkenScene,
traps,
tokens,
npcTokens,
grid,
layout: layoutIn ?? { x: 0, y: 0 },
media: {
@@ -2429,6 +2478,8 @@ function normalizeProject(p: Project): Project {
x?: number;
y?: number;
groupId?: string | null;
ringColor?: string;
imageOffset?: unknown;
};
if (!obj.id || !obj.avatarAssetId || typeof obj.name !== 'string') return null;
const name = obj.name.trim();
@@ -2444,6 +2495,9 @@ function normalizeProject(p: Project): Project {
x,
y,
groupId: resolveNpcGroupId(obj.groupId, groupIdSet),
ringColor: normalizeHexColor(obj.ringColor, DEFAULT_PLAYER_RING_COLOR),
imageOffset: clampPlayerImageOffset(obj.imageOffset),
imageScale: clampPlayerImageScale((obj as { imageScale?: unknown }).imageScale),
};
})
.filter((x): x is ProjectNpc => Boolean(x));
@@ -2481,6 +2535,15 @@ function normalizeProject(p: Project): Project {
if (a && a.length > 0) return a;
return '0.0.0';
})();
const scenesPruned: Record<SceneId, Scene> = {};
for (const sid of Object.keys(scenes) as SceneId[]) {
const sc = scenes[sid];
if (!sc) continue;
scenesPruned[sid] = {
...sc,
npcTokens: (sc.npcTokens ?? []).filter((t) => npcIdSet.has(t.npcId)),
};
}
return {
...p,
meta: {
@@ -2491,7 +2554,7 @@ function normalizeProject(p: Project): Project {
createdWithAppVersion,
schemaVersion: PROJECT_SCHEMA_VERSION,
},
scenes,
scenes: scenesPruned,
campaignAudios,
materials,
npcs,
@@ -2501,7 +2564,7 @@ function normalizeProject(p: Project): Project {
sceneGraphEdges,
currentGraphNodeId,
sceneListOrder: reconcileSceneListOrder(
scenes,
scenesPruned,
(p as { sceneListOrder?: SceneId[] }).sceneListOrder,
),
};