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
+148 -1
View File
@@ -10,6 +10,7 @@ import {
asNpcRelationId,
asProjectId,
asSceneId,
asSceneNpcTokenId,
} from '../types/ids';
import { PROJECT_SCHEMA_VERSION } from '../types';
@@ -40,6 +41,7 @@ function scene(id: string, title: string): Scene {
darkenScene: false,
traps: [],
tokens: [],
npcTokens: [],
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
@@ -242,6 +244,9 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
x: 0,
y: 0,
groupId: gChild,
ringColor: '#c9a227',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
{
id: n2,
@@ -251,6 +256,9 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
x: 10,
y: 0,
groupId: gChild,
ringColor: '#c9a227',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
{
id: n3,
@@ -260,6 +268,9 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
x: 20,
y: 0,
groupId: asNpcGroupId('g_other'),
ringColor: '#c9a227',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
];
const relations: ProjectNpcRelation[] = [
@@ -308,6 +319,139 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
assert.ok(!partial.npcGroups.some((g) => g.id === asNpcGroupId('g_other')));
});
void test('buildPartialExportProject: keeps npcTokens only for exported NPCs', () => {
const n1 = asNpcId('npc1');
const n2 = asNpcId('npc2');
const avatar = asAssetId('a1');
const sceneId = asSceneId('s1');
const source = minimalProject({
scenes: {
[sceneId]: {
...scene('s1', 'Start'),
npcTokens: [
{ id: asSceneNpcTokenId('nt1'), npcId: n1, nx: 0.2, ny: 0.3, sizeN: 0.1 },
{ id: asSceneNpcTokenId('nt2'), npcId: n2, nx: 0.5, ny: 0.5, sizeN: 0.1 },
{ id: asSceneNpcTokenId('nt3'), npcId: asNpcId('missing'), nx: 0.1, ny: 0.1, sizeN: 0.1 },
],
},
},
sceneGraphNodes: [node('main', 's1', { isStartScene: true })],
assets: {
[avatar]: {
id: avatar,
type: 'image',
mime: 'image/png',
originalName: 'a.png',
relPath: 'assets/a.png',
sha256: 'abc',
sizeBytes: 1,
createdAt: '2020-01-01T00:00:00.000Z',
},
},
npcs: [
{
id: n1,
name: 'A',
avatarAssetId: avatar,
description: '',
x: 0,
y: 0,
groupId: null,
ringColor: '#c9a227',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
{
id: n2,
name: 'B',
avatarAssetId: avatar,
description: '',
x: 10,
y: 0,
groupId: null,
ringColor: '#c9a227',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
],
});
const partial = buildPartialExportProject(source, [{ kind: 'main' }], {
newProjectId: newExportBundleProjectId(),
exportTitle: 'Export',
labels: LABELS,
npcIds: [n1],
});
const tokens = partial.scenes[sceneId]?.npcTokens ?? [];
assert.equal(tokens.length, 1);
assert.equal(tokens[0]!.npcId, n1);
});
void test('mergeStorylinesIntoProject: remaps npcTokens to created NPC ids', () => {
const avatar = asAssetId('a1');
const sourceNpcId = asNpcId('src_npc');
const source = minimalProject({
scenes: {
[asSceneId('s1')]: {
...scene('s1', 'Side'),
npcTokens: [
{ id: asSceneNpcTokenId('nt1'), npcId: sourceNpcId, nx: 0.4, ny: 0.6, sizeN: 0.12 },
],
},
},
sceneGraphNodes: [node('side', 's1', { isSideStoryStart: true, sideStoryLineTitle: 'Side', x: 0 })],
assets: {
[avatar]: {
id: avatar,
type: 'image',
mime: 'image/png',
originalName: 'a.png',
relPath: 'assets/a.png',
sha256: 'npcsha',
sizeBytes: 1,
createdAt: '2020-01-01T00:00:00.000Z',
},
},
npcs: [
{
id: sourceNpcId,
name: 'Hero',
avatarAssetId: avatar,
description: '',
x: 0,
y: 0,
groupId: null,
ringColor: '#aabbcc',
imageOffset: { x: 0.1, y: -0.1 },
imageScale: 1,
},
],
});
const target = minimalProject({
scenes: { [asSceneId('t1')]: scene('t1', 'Existing') },
sceneGraphNodes: [node('tgn', 't1', { x: 0 })],
});
const { project: merged } = mergeStorylinesIntoProject(
target,
source,
[{ kind: 'side', startGraphNodeId: asGraphNodeId('side') }],
[{ sourceSceneId: asSceneId('s1'), mode: 'create' }],
{
graphOffsetX: 200,
npcResolutions: [{ sourceNpcId, mode: 'create' }],
},
);
assert.equal(merged.npcs.length, 1);
const importedScene = Object.values(merged.scenes).find((s) => s.title === 'Side');
assert.ok(importedScene);
assert.equal(importedScene!.npcTokens.length, 1);
assert.equal(importedScene!.npcTokens[0]!.npcId, merged.npcs[0]!.id);
assert.notEqual(importedScene!.npcTokens[0]!.npcId, sourceNpcId);
assert.equal(merged.npcs[0]!.ringColor, '#aabbcc');
});
void test('mergeStorylinesIntoProject: imports all NPCs from export bundle', () => {
const avatar = asAssetId('a1');
const sourceNpcId = asNpcId('src_npc');
@@ -335,7 +479,10 @@ void test('mergeStorylinesIntoProject: imports all NPCs from export bundle', ()
x: 0,
y: 0,
groupId: null,
},
ringColor: '#c9a227',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
],
});
const target = minimalProject({