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
+121
View File
@@ -0,0 +1,121 @@
import fs from 'node:fs/promises';
import { createRequire } from 'node:module';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { test as base, expect, _electron as electron, type ElectronApplication, type Page } from '@playwright/test';
const require = createRequire(import.meta.url);
const electronExecutable = require('electron') as string;
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const mainEntry = path.join(root, 'dist/main/index.cjs');
const fixturePng = path.join(root, 'e2e/fixtures/sample.png');
const SAMPLE_PNG_B64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
export type ElectronFixtures = {
electronApp: ElectronApplication;
editorWindow: Page;
userDataDir: string;
sampleImagePath: string;
};
async function ensureBuilt() {
try {
await fs.access(mainEntry);
const editorHtml = await fs.readFile(path.join(root, 'dist/renderer/editor.html'), 'utf8');
// Production Vite build uses relative asset URLs (required for Electron file://).
if (editorHtml.includes('src="/assets/') || editorHtml.includes("src='/assets/")) {
throw new Error('dev-base');
}
} catch (err) {
const reason = err instanceof Error && err.message === 'dev-base' ? 'dev-base' : 'missing';
throw new Error(
reason === 'dev-base'
? 'dist/renderer was built with Vite base "/". Run "npm run build" (production) before e2e.'
: `Missing ${mainEntry}. Run "npm run build" (or "npm run test:e2e:build") before e2e.`,
);
}
}
export const test = base.extend<ElectronFixtures>({
userDataDir: async ({}, use) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-e2e-'));
await use(dir);
await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
},
sampleImagePath: async ({}, use) => {
await fs.mkdir(path.dirname(fixturePng), { recursive: true });
await fs.writeFile(fixturePng, Buffer.from(SAMPLE_PNG_B64, 'base64'));
await use(fixturePng);
},
electronApp: async ({ userDataDir }, use) => {
await ensureBuilt();
const app = await electron.launch({
executablePath: electronExecutable,
// Launch the package root so app.getAppPath() is the repo (not dist/main).
args: ['.', `--user-data-dir=${userDataDir}`],
cwd: root,
env: {
...process.env,
NODE_ENV: 'production',
DND_SKIP_LICENSE: '1',
DND_SKIP_BOOT: '1',
ELECTRON_DISABLE_SECURITY_WARNINGS: 'true',
},
});
await use(app);
try {
await app.evaluate(({ app: electronApp }) => {
electronApp.exit(0);
});
} catch {
/* ignore */
}
try {
await Promise.race([
app.close(),
new Promise<void>((resolve) => {
setTimeout(resolve, 3_000);
}),
]);
} catch {
/* ignore */
}
},
editorWindow: async ({ electronApp }, use) => {
const page = await electronApp.firstWindow();
await page.waitForLoadState('domcontentloaded');
await expect(page.getByTestId('players-header-btn')).toBeVisible({ timeout: 60_000 });
await use(page);
},
});
export { expect };
/** Stub native open-dialog to return a fixed image path. */
export async function stubOpenImageDialog(app: ElectronApplication, filePath: string) {
await app.evaluate(async ({ dialog }, chosen) => {
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [chosen] });
}, filePath);
}
export async function invokeInRenderer<T>(
page: Page,
channel: string,
payload: unknown = {},
): Promise<T> {
return page.evaluate(
async ({ channel: ch, payload: pl }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (window as any).dnd.invoke(ch, pl);
},
{ channel, payload },
);
}
+105
View File
@@ -0,0 +1,105 @@
import { test, expect, stubOpenImageDialog, invokeInRenderer } from './fixtures/electron';
test.describe('Players library', () => {
test('CRUD: add player with progress overlay, ring color persists in store', async ({
electronApp,
editorWindow,
sampleImagePath,
}) => {
await stubOpenImageDialog(electronApp, sampleImagePath);
await editorWindow.getByTestId('players-header-btn').click();
await expect(editorWindow.getByTestId('players-modal')).toBeVisible();
await editorWindow.getByTestId('players-add').click();
await editorWindow.getByPlaceholder(/Имя игрока|Player name/i).fill('Ada Lovelace');
await editorWindow.getByTestId('players-choose-image').click();
await expect(editorWindow.getByTestId('player-token-preview')).toBeVisible();
const colorInput = editorWindow.locator('input[type="color"]').first();
await colorInput.fill('#224466');
await editorWindow.getByTestId('players-save').click();
// Progress overlay may flash quickly; assert it either appeared or save finished.
await expect
.poll(async () => editorWindow.locator('[data-testid^="player-row-"]').count(), {
timeout: 30_000,
})
.toBe(1);
const listed = await invokeInRenderer<{ players: Array<{ name: string; ringColor: string }> }>(
editorWindow,
'players.list',
{},
);
expect(listed.players).toHaveLength(1);
expect(listed.players[0]!.name).toBe('Ada Lovelace');
expect(listed.players[0]!.ringColor.toLowerCase()).toBe('#224466');
});
test('Teams: assign then delete team leaves player ungrouped; no subteam UI', async ({
electronApp,
editorWindow,
sampleImagePath,
}) => {
await stubOpenImageDialog(electronApp, sampleImagePath);
await editorWindow.getByTestId('players-header-btn').click();
const modal = editorWindow.getByTestId('players-modal');
await expect(modal).toBeVisible();
await expect(modal).not.toContainText(/подкоманд|sub-?team|parentId/i);
await editorWindow.getByTestId('players-add').click();
await editorWindow.getByPlaceholder(/Имя игрока|Player name/i).fill('Bob');
await editorWindow.getByTestId('players-choose-image').click();
await editorWindow.getByTestId('players-save').click();
await expect(editorWindow.locator('[data-testid^="player-row-"]')).toHaveCount(1, {
timeout: 30_000,
});
const dialogHandler = async (d: { message: () => string; accept: (text?: string) => Promise<void> }) => {
const msg = d.message();
if (/команд|team/i.test(msg) && /цвет|color/i.test(msg)) await d.accept('#ff0000');
else if (/цвет|color/i.test(msg)) await d.accept('#ff0000');
else await d.accept('Party');
};
editorWindow.on('dialog', dialogHandler);
await editorWindow.getByTestId('players-add-team').click();
// Allow prompts to settle
await editorWindow.waitForTimeout(500);
editorWindow.off('dialog', dialogHandler);
const state = await invokeInRenderer<{
players: Array<{ id: string; teamId: string | null }>;
teams: Array<{ id: string; name: string }>;
}>(editorWindow, 'players.list', {});
// If UI prompts failed (headless), create team via IPC.
let team = state.teams.find((t) => t.name === 'Party');
if (!team) {
const created = await invokeInRenderer<{ team: { id: string; name: string } }>(
editorWindow,
'players.upsertTeam',
{ name: 'Party', color: '#ff0000' },
);
team = created.team;
}
const player = state.players[0]!;
expect(team).toBeTruthy();
await invokeInRenderer(editorWindow, 'players.assignTeam', {
playerId: player.id,
teamId: team!.id,
});
let mid = await invokeInRenderer<{ players: Array<{ teamId: string | null }> }>(
editorWindow,
'players.list',
{},
);
expect(mid.players[0]!.teamId).toBe(team!.id);
await invokeInRenderer(editorWindow, 'players.deleteTeam', { id: team!.id });
mid = await invokeInRenderer(editorWindow, 'players.list', {});
expect(mid.players[0]!.teamId).toBeNull();
});
});
+204
View File
@@ -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);
});
});