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:
@@ -0,0 +1,204 @@
|
||||
import { test, expect, invokeInRenderer, stubOpenImageDialog } from './fixtures/electron';
|
||||
|
||||
type Project = {
|
||||
id: string;
|
||||
currentSceneId: string | null;
|
||||
scenes: Record<
|
||||
string,
|
||||
{
|
||||
tokens: unknown[];
|
||||
npcTokens: Array<{ id: string; npcId: string; nx: number; ny: number; sizeN: number }>;
|
||||
}
|
||||
>;
|
||||
npcs: Array<{ id: string; name: string }>;
|
||||
};
|
||||
|
||||
async function seedProjectWithNpcAndPlacement(
|
||||
page: import('@playwright/test').Page,
|
||||
sampleImagePath: string,
|
||||
) {
|
||||
await invokeInRenderer<{ project: Project }>(page, 'project.create', {
|
||||
name: `E2E Scene ${Date.now()}`,
|
||||
});
|
||||
|
||||
const sceneId = `scene_e2e_${Date.now()}`;
|
||||
await invokeInRenderer(page, 'project.updateScene', {
|
||||
sceneId,
|
||||
patch: {
|
||||
title: 'Map',
|
||||
description: '',
|
||||
media: { videos: [], audios: [] },
|
||||
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
|
||||
layout: { x: 0, y: 0 },
|
||||
previewAssetId: null,
|
||||
previewAssetType: null,
|
||||
previewVideoAutostart: false,
|
||||
npcTokens: [],
|
||||
tokens: [],
|
||||
traps: [],
|
||||
},
|
||||
});
|
||||
await invokeInRenderer(page, 'project.setCurrentScene', { sceneId });
|
||||
await invokeInRenderer(page, 'project.importScenePreview', {
|
||||
sceneId,
|
||||
filePath: sampleImagePath,
|
||||
});
|
||||
|
||||
const npcUpsert = await invokeInRenderer<{ project: Project }>(page, 'project.upsertNpc', {
|
||||
name: `Guard ${Date.now()}`,
|
||||
filePath: sampleImagePath,
|
||||
ringColor: '#c9a227',
|
||||
imageOffset: { x: 0, y: 0 },
|
||||
});
|
||||
const npc = npcUpsert.project.npcs[npcUpsert.project.npcs.length - 1]!;
|
||||
const placementId = `snt_e2e_${Date.now()}`;
|
||||
|
||||
await invokeInRenderer(page, 'project.updateScene', {
|
||||
sceneId,
|
||||
patch: {
|
||||
npcTokens: [
|
||||
{
|
||||
id: placementId,
|
||||
npcId: npc.id,
|
||||
nx: 0.35,
|
||||
ny: 0.4,
|
||||
sizeN: 0.12,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const refreshed = await invokeInRenderer<{ project: Project }>(page, 'project.get', {});
|
||||
return {
|
||||
sceneId,
|
||||
npcId: npc.id,
|
||||
placementId,
|
||||
project: refreshed.project,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('Scene NPC tokens', () => {
|
||||
test('scene editor shows circular NPC token; clear removes both kinds', async ({
|
||||
electronApp,
|
||||
editorWindow,
|
||||
sampleImagePath,
|
||||
}) => {
|
||||
await stubOpenImageDialog(electronApp, sampleImagePath);
|
||||
const seeded = await seedProjectWithNpcAndPlacement(editorWindow, sampleImagePath);
|
||||
|
||||
const token = await invokeInRenderer<{ token: { id: string } }>(editorWindow, 'tokens.upsert', {
|
||||
name: `Prop ${Date.now()}`,
|
||||
filePath: sampleImagePath,
|
||||
});
|
||||
await invokeInRenderer(editorWindow, 'project.updateScene', {
|
||||
sceneId: seeded.sceneId,
|
||||
patch: {
|
||||
tokens: [
|
||||
{
|
||||
id: `st_${Date.now()}`,
|
||||
tokenId: token.token.id,
|
||||
nx: 0.7,
|
||||
ny: 0.7,
|
||||
sizeN: 0.1,
|
||||
rotationDeg: 0,
|
||||
},
|
||||
],
|
||||
npcTokens: [
|
||||
{
|
||||
id: seeded.placementId,
|
||||
npcId: seeded.npcId,
|
||||
nx: 0.35,
|
||||
ny: 0.4,
|
||||
sizeN: 0.12,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const winPromise = electronApp.waitForEvent('window');
|
||||
await invokeInRenderer(editorWindow, 'windows.openSceneEditor', {});
|
||||
const sceneWin = await winPromise;
|
||||
await sceneWin.waitForLoadState('domcontentloaded');
|
||||
|
||||
const accordion = sceneWin.getByTestId('scene-npc-accordion');
|
||||
await expect(accordion).toBeVisible({ timeout: 30_000 });
|
||||
// Expand if collapsed
|
||||
await accordion.locator('button').first().click().catch(() => undefined);
|
||||
await expect(sceneWin.getByTestId(`scene-npc-tile-${seeded.npcId}`)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(sceneWin.getByTestId(`scene-npc-token-${seeded.placementId}`)).toBeVisible();
|
||||
|
||||
await sceneWin.getByTestId('scene-clear-btn').click();
|
||||
await expect(sceneWin.getByTestId(`scene-npc-token-${seeded.placementId}`)).toHaveCount(0);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const after = await invokeInRenderer<{ project: Project }>(editorWindow, 'project.get', {});
|
||||
const scene = after.project.scenes[seeded.sceneId]!;
|
||||
return { tokens: scene.tokens.length, npcTokens: scene.npcTokens.length };
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toEqual({ tokens: 0, npcTokens: 0 });
|
||||
|
||||
await invokeInRenderer(editorWindow, 'windows.closeSceneEditor', {}).catch(() => undefined);
|
||||
});
|
||||
|
||||
test('session move resets after re-open multiwindow', async ({
|
||||
editorWindow,
|
||||
sampleImagePath,
|
||||
electronApp,
|
||||
}) => {
|
||||
await stubOpenImageDialog(electronApp, sampleImagePath);
|
||||
const seeded = await seedProjectWithNpcAndPlacement(editorWindow, sampleImagePath);
|
||||
|
||||
// Need a graph start node for session; open multiwindow may still work with currentSceneId.
|
||||
await invokeInRenderer(editorWindow, 'project.addSceneGraphNode', {
|
||||
sceneId: seeded.sceneId,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const withNode = await invokeInRenderer<{ project: { sceneGraphNodes: Array<{ id: string }> } }>(
|
||||
editorWindow,
|
||||
'project.get',
|
||||
{},
|
||||
);
|
||||
const nodeId = withNode.project.sceneGraphNodes[0]?.id;
|
||||
if (nodeId) {
|
||||
await invokeInRenderer(editorWindow, 'project.setSceneGraphNodeStart', {
|
||||
graphNodeId: nodeId,
|
||||
});
|
||||
}
|
||||
|
||||
await invokeInRenderer(editorWindow, 'windows.openMultiWindow', {});
|
||||
await editorWindow.waitForTimeout(1000);
|
||||
|
||||
await invokeInRenderer(editorWindow, 'sceneNpcTokensSession.dispatch', {
|
||||
event: { kind: 'move', placementId: seeded.placementId, nx: 0.8, ny: 0.8 },
|
||||
});
|
||||
|
||||
let session = await invokeInRenderer<{
|
||||
state: { byPlacementId: Record<string, { nx: number; ny: number }> };
|
||||
}>(editorWindow, 'sceneNpcTokensSession.getState', {});
|
||||
expect(session.state.byPlacementId[seeded.placementId]?.nx).toBeCloseTo(0.8, 5);
|
||||
|
||||
await invokeInRenderer(editorWindow, 'windows.closeMultiWindow', {});
|
||||
await editorWindow.waitForTimeout(500);
|
||||
|
||||
await invokeInRenderer(editorWindow, 'windows.openMultiWindow', {});
|
||||
await editorWindow.waitForTimeout(500);
|
||||
session = await invokeInRenderer(editorWindow, 'sceneNpcTokensSession.getState', {});
|
||||
expect(session.state.byPlacementId[seeded.placementId]).toBeUndefined();
|
||||
|
||||
const proj = await invokeInRenderer<{ project: Project }>(editorWindow, 'project.get', {});
|
||||
const placement = proj.project.scenes[seeded.sceneId]!.npcTokens.find(
|
||||
(t) => t.id === seeded.placementId,
|
||||
);
|
||||
expect(placement?.nx).toBeCloseTo(0.35, 5);
|
||||
expect(placement?.ny).toBeCloseTo(0.4, 5);
|
||||
|
||||
await invokeInRenderer(editorWindow, 'windows.closeMultiWindow', {}).catch(() => undefined);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user