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:
@@ -24,6 +24,7 @@ function scene(id: string): Scene {
|
||||
darkenScene: false,
|
||||
traps: [],
|
||||
tokens: [],
|
||||
npcTokens: [],
|
||||
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
|
||||
media: { videos: [], audios: [] },
|
||||
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
asNpcRelationId,
|
||||
asProjectId,
|
||||
asSceneId,
|
||||
asSceneNpcTokenId,
|
||||
asTokenId,
|
||||
} from '../types/ids';
|
||||
|
||||
@@ -291,6 +292,15 @@ export function buildPartialExportProject(
|
||||
npcs: exportedNpcs.map((n) => ({ ...n })),
|
||||
npcGroups: exportedGroups.map((g) => ({ ...g })),
|
||||
npcRelations: exportedRelations.map((r) => ({ ...r })),
|
||||
scenes: Object.fromEntries(
|
||||
Object.entries(draft.scenes).map(([sid, sc]) => [
|
||||
sid,
|
||||
{
|
||||
...sc,
|
||||
npcTokens: (sc.npcTokens ?? []).filter((t) => exportedNpcIds.has(t.npcId)),
|
||||
},
|
||||
]),
|
||||
) as Project['scenes'],
|
||||
};
|
||||
|
||||
const assetIds = collectReferencedAssetIdsForProject(draft);
|
||||
@@ -686,11 +696,38 @@ export function mergeStorylinesIntoProject(
|
||||
x: n.x,
|
||||
y: n.y,
|
||||
groupId: mappedGroupId && npcGroups.some((g) => g.id === mappedGroupId) ? mappedGroupId : null,
|
||||
ringColor: n.ringColor ?? '#c9a227',
|
||||
imageOffset: n.imageOffset ?? { x: 0, y: 0 },
|
||||
imageScale: typeof n.imageScale === 'number' && Number.isFinite(n.imageScale) ? n.imageScale : 1,
|
||||
});
|
||||
npcNameKeys.add(name.toLowerCase());
|
||||
npcsCreated += 1;
|
||||
}
|
||||
|
||||
// Remap npcTokens on newly created scenes to imported NPC ids.
|
||||
for (const sid of sourceSceneIds) {
|
||||
const res = resolutionBySource.get(sid);
|
||||
if (res?.mode === 'use') continue;
|
||||
const newSid = sceneIdMap.get(sid);
|
||||
if (!newSid) continue;
|
||||
const sc = scenes[newSid];
|
||||
if (!sc) continue;
|
||||
scenes[newSid] = {
|
||||
...sc,
|
||||
npcTokens: (sc.npcTokens ?? [])
|
||||
.map((t) => {
|
||||
const mappedNpc = npcIdMap.get(t.npcId);
|
||||
if (!mappedNpc) return null;
|
||||
return {
|
||||
...t,
|
||||
id: asSceneNpcTokenId(`snt_${generateId()}`),
|
||||
npcId: asNpcId(mappedNpc),
|
||||
};
|
||||
})
|
||||
.filter((t): t is NonNullable<typeof t> => Boolean(t)),
|
||||
};
|
||||
}
|
||||
|
||||
const npcRelations = [...(target.npcRelations ?? [])];
|
||||
const exportedNpcIdSet = new Set(exportedNpcs.map((n) => n.id));
|
||||
for (const r of source.npcRelations ?? []) {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const contractsPath = path.join(here, 'contracts.ts');
|
||||
|
||||
void test('contracts: players and sceneNpcTokensSession channels exist', () => {
|
||||
const src = fs.readFileSync(contractsPath, 'utf8');
|
||||
assert.match(src, /players:\s*\{/);
|
||||
assert.match(src, /list:\s*'players\.list'/);
|
||||
assert.match(src, /upsertProgress:\s*'players\.upsertProgress'/);
|
||||
assert.match(src, /sceneNpcTokensSession:\s*\{/);
|
||||
assert.match(src, /npcTokens\?:\s*SceneNpcToken\[\]/);
|
||||
});
|
||||
@@ -17,11 +17,20 @@ import type {
|
||||
Project,
|
||||
ProjectId,
|
||||
AppToken,
|
||||
AppPlayer,
|
||||
AppPlayerTeam,
|
||||
PlayerId,
|
||||
PlayerTeamId,
|
||||
PlayerImageOffset,
|
||||
PlayersUpsertProgressEvent,
|
||||
Scene,
|
||||
SceneDarknessEvent,
|
||||
SceneDarknessState,
|
||||
SceneGrid,
|
||||
SceneId,
|
||||
SceneNpcToken,
|
||||
SceneNpcTokensSessionEvent,
|
||||
SceneNpcTokensSessionState,
|
||||
SceneToken,
|
||||
SceneTokensSessionEvent,
|
||||
SceneTokensSessionState,
|
||||
@@ -182,6 +191,25 @@ export const ipcChannels = {
|
||||
dispatch: 'sceneTokensSession.dispatch',
|
||||
stateChanged: 'sceneTokensSession.stateChanged',
|
||||
},
|
||||
players: {
|
||||
list: 'players.list',
|
||||
upsert: 'players.upsert',
|
||||
delete: 'players.delete',
|
||||
setOrder: 'players.setOrder',
|
||||
upsertTeam: 'players.upsertTeam',
|
||||
deleteTeam: 'players.deleteTeam',
|
||||
setTeamsOrder: 'players.setTeamsOrder',
|
||||
assignTeam: 'players.assignTeam',
|
||||
pickImage: 'players.pickImage',
|
||||
imageUrl: 'players.imageUrl',
|
||||
upsertProgress: 'players.upsertProgress',
|
||||
stateChanged: 'players.stateChanged',
|
||||
},
|
||||
sceneNpcTokensSession: {
|
||||
getState: 'sceneNpcTokensSession.getState',
|
||||
dispatch: 'sceneNpcTokensSession.dispatch',
|
||||
stateChanged: 'sceneNpcTokensSession.stateChanged',
|
||||
},
|
||||
video: {
|
||||
getState: 'video.getState',
|
||||
dispatch: 'video.dispatch',
|
||||
@@ -254,6 +282,9 @@ export type IpcEventMap = {
|
||||
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
||||
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
|
||||
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
|
||||
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
||||
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
|
||||
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
|
||||
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
||||
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
|
||||
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
|
||||
@@ -368,6 +399,9 @@ export type IpcInvokeMap = {
|
||||
description?: string;
|
||||
filePath?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
ringColor?: string;
|
||||
imageOffset?: PlayerImageOffset;
|
||||
imageScale?: number;
|
||||
};
|
||||
res: { project: Project };
|
||||
};
|
||||
@@ -377,6 +411,9 @@ export type IpcInvokeMap = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
ringColor?: string;
|
||||
imageOffset?: PlayerImageOffset;
|
||||
imageScale?: number;
|
||||
};
|
||||
res: { project: Project };
|
||||
};
|
||||
@@ -708,6 +745,62 @@ export type IpcInvokeMap = {
|
||||
req: { event: SceneTokensSessionEvent };
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.players.list]: {
|
||||
req: Record<string, never>;
|
||||
res: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
||||
};
|
||||
[ipcChannels.players.upsert]: {
|
||||
req: {
|
||||
id?: PlayerId | null;
|
||||
name: string;
|
||||
filePath?: string | null;
|
||||
teamId?: PlayerTeamId | null;
|
||||
ringColor?: string;
|
||||
imageOffset?: PlayerImageOffset;
|
||||
imageScale?: number;
|
||||
};
|
||||
res: { player: AppPlayer };
|
||||
};
|
||||
[ipcChannels.players.delete]: {
|
||||
req: { id: PlayerId };
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.players.setOrder]: {
|
||||
req: { playerIds: PlayerId[] };
|
||||
res: { players: AppPlayer[] };
|
||||
};
|
||||
[ipcChannels.players.upsertTeam]: {
|
||||
req: { id?: PlayerTeamId | null; name: string; color?: string };
|
||||
res: { team: AppPlayerTeam };
|
||||
};
|
||||
[ipcChannels.players.deleteTeam]: {
|
||||
req: { id: PlayerTeamId };
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.players.setTeamsOrder]: {
|
||||
req: { teamIds: PlayerTeamId[] };
|
||||
res: { teams: AppPlayerTeam[] };
|
||||
};
|
||||
[ipcChannels.players.assignTeam]: {
|
||||
req: { playerId: PlayerId; teamId: PlayerTeamId | null };
|
||||
res: { player: AppPlayer | null };
|
||||
};
|
||||
[ipcChannels.players.pickImage]: {
|
||||
req: Record<string, never>;
|
||||
res: { canceled: true } | { canceled: false; filePath: string; previewDataUrl: string };
|
||||
};
|
||||
[ipcChannels.players.imageUrl]: {
|
||||
req: { id: PlayerId };
|
||||
res: { url: string | null };
|
||||
};
|
||||
[ipcChannels.sceneNpcTokensSession.getState]: {
|
||||
req: Record<string, never>;
|
||||
res: { state: SceneNpcTokensSessionState };
|
||||
};
|
||||
[ipcChannels.sceneNpcTokensSession.dispatch]: {
|
||||
req: { event: SceneNpcTokensSessionEvent };
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.video.getState]: {
|
||||
req: Record<string, never>;
|
||||
res: { state: VideoPlaybackState };
|
||||
@@ -749,6 +842,9 @@ export type LegacyIpcEventMap = {
|
||||
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
||||
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
|
||||
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
|
||||
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
||||
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
|
||||
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
|
||||
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
||||
[ipcChannels.license.statusChanged]: Record<string, never>;
|
||||
};
|
||||
@@ -764,6 +860,7 @@ export type ScenePatch = {
|
||||
darkenScene?: boolean;
|
||||
traps?: SceneTrap[];
|
||||
tokens?: SceneToken[];
|
||||
npcTokens?: SceneNpcToken[];
|
||||
grid?: SceneGrid;
|
||||
settings?: Partial<Scene['settings']>;
|
||||
media?: Partial<Scene['media']>;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
assignPlayerToTeam,
|
||||
createPlayerTeamDraft,
|
||||
deletePlayerTeam,
|
||||
normalizeAppPlayerTeams,
|
||||
uniquePlayerTeamName,
|
||||
} from './playerTeams';
|
||||
import type { AppPlayer, AppPlayerTeam } from '../types/appPlayers';
|
||||
import { asPlayerId, asPlayerTeamId } from '../types/ids';
|
||||
|
||||
void test('uniquePlayerTeamName appends suffix on collision', () => {
|
||||
const teams: AppPlayerTeam[] = [
|
||||
{ id: asPlayerTeamId('t1'), name: 'Alpha', color: '#ffffff' },
|
||||
];
|
||||
assert.equal(uniquePlayerTeamName('Alpha', teams), 'Alpha (2)');
|
||||
assert.equal(uniquePlayerTeamName('Beta', teams), 'Beta');
|
||||
});
|
||||
|
||||
void test('normalizeAppPlayerTeams drops invalid and duplicates', () => {
|
||||
const teams = normalizeAppPlayerTeams([
|
||||
{ id: 't1', name: 'A', color: '#abc' },
|
||||
{ id: 't1', name: 'Dup', color: '#ffffff' },
|
||||
{ id: '', name: 'Bad', color: '#ffffff' },
|
||||
null,
|
||||
{ id: 't2', name: 'B', color: '#112233' },
|
||||
]);
|
||||
assert.equal(teams.length, 2);
|
||||
assert.equal(teams[0]!.id, 't1');
|
||||
assert.equal(teams[0]!.color, '#aabbcc'); // #abc → expanded
|
||||
assert.equal(teams[1]!.color, '#112233');
|
||||
});
|
||||
|
||||
void test('createPlayerTeamDraft has no parentId field', () => {
|
||||
const team = createPlayerTeamDraft('Team', '#ff0000', []);
|
||||
assert.equal(team.name, 'Team');
|
||||
assert.ok(!('parentId' in team));
|
||||
});
|
||||
|
||||
void test('assignPlayerToTeam and deletePlayerTeam ungroup players', () => {
|
||||
const t1 = asPlayerTeamId('t1');
|
||||
const players: AppPlayer[] = [
|
||||
{
|
||||
id: asPlayerId('p1'),
|
||||
name: 'P',
|
||||
imageRelPath: 'files/a.png',
|
||||
sha256: 'x',
|
||||
teamId: t1,
|
||||
ringColor: '#c9a227',
|
||||
imageOffset: { x: 0, y: 0 },
|
||||
imageScale: 1,
|
||||
},
|
||||
];
|
||||
const teams: AppPlayerTeam[] = [{ id: t1, name: 'T', color: '#ffffff' }];
|
||||
const assigned = assignPlayerToTeam(players, 'p1', null, new Set([t1]));
|
||||
assert.equal(assigned[0]!.teamId, null);
|
||||
const del = deletePlayerTeam(teams, players, t1);
|
||||
assert.equal(del.teams.length, 0);
|
||||
assert.equal(del.players[0]!.teamId, null);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { AppPlayer, AppPlayerTeam, PlayerTeamId } from '../types/appPlayers';
|
||||
import { asPlayerTeamId } from '../types/ids';
|
||||
import { DEFAULT_PLAYER_RING_COLOR, normalizeAppPlayerTeam } from '../types/appPlayers';
|
||||
import { normalizeHexColor } from '../npcs/npcGroups';
|
||||
|
||||
/** Имя команды уникально среди плоского списка (без вложенности). */
|
||||
export function uniquePlayerTeamName(
|
||||
base: string,
|
||||
teams: readonly AppPlayerTeam[],
|
||||
exceptId?: PlayerTeamId | null,
|
||||
): string {
|
||||
const root = base.trim() || 'Team';
|
||||
const used = new Set(
|
||||
teams.filter((t) => t.id !== exceptId).map((t) => t.name.trim().toLowerCase()),
|
||||
);
|
||||
if (!used.has(root.toLowerCase())) return root;
|
||||
let i = 2;
|
||||
while (used.has(`${root.toLowerCase()} (${String(i)})`)) i += 1;
|
||||
return `${root} (${String(i)})`;
|
||||
}
|
||||
|
||||
export function normalizeAppPlayerTeams(raw: unknown): AppPlayerTeam[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: AppPlayerTeam[] = [];
|
||||
const ids = new Set<string>();
|
||||
for (const item of raw) {
|
||||
const t = normalizeAppPlayerTeam(item);
|
||||
if (!t || ids.has(t.id)) continue;
|
||||
ids.add(t.id);
|
||||
out.push(t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function assignPlayerToTeam(
|
||||
players: readonly AppPlayer[],
|
||||
playerId: string,
|
||||
teamId: PlayerTeamId | null,
|
||||
teamIds: Set<string>,
|
||||
): AppPlayer[] {
|
||||
return players.map((p) => {
|
||||
if (p.id !== playerId) return p;
|
||||
if (teamId && !teamIds.has(teamId)) return { ...p, teamId: null };
|
||||
return { ...p, teamId };
|
||||
});
|
||||
}
|
||||
|
||||
/** Удаление команды: игроки этой команды становятся без команды. */
|
||||
export function deletePlayerTeam(
|
||||
teams: readonly AppPlayerTeam[],
|
||||
players: readonly AppPlayer[],
|
||||
teamId: PlayerTeamId,
|
||||
): { teams: AppPlayerTeam[]; players: AppPlayer[] } {
|
||||
return {
|
||||
teams: teams.filter((t) => t.id !== teamId),
|
||||
players: players.map((p) => (p.teamId === teamId ? { ...p, teamId: null } : p)),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPlayerTeamDraft(
|
||||
name: string,
|
||||
color: string | undefined,
|
||||
teams: readonly AppPlayerTeam[],
|
||||
): AppPlayerTeam {
|
||||
const id = asPlayerTeamId(`pteam_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
|
||||
return {
|
||||
id,
|
||||
name: uniquePlayerTeamName(name, teams),
|
||||
color: normalizeHexColor(color, DEFAULT_PLAYER_RING_COLOR),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
clampPlayerImageOffset,
|
||||
clampPlayerImageScale,
|
||||
DEFAULT_PLAYER_IMAGE_SCALE,
|
||||
DEFAULT_PLAYER_RING_COLOR,
|
||||
normalizeAppPlayer,
|
||||
normalizeSceneNpcToken,
|
||||
PLAYER_IMAGE_OFFSET_MAX,
|
||||
PLAYER_IMAGE_SCALE_MAX,
|
||||
PLAYER_IMAGE_SCALE_MIN,
|
||||
} from './appPlayers';
|
||||
import { asNpcId, asSceneNpcTokenId } from './ids';
|
||||
|
||||
void test('clampPlayerImageOffset clamps and defaults', () => {
|
||||
assert.deepEqual(clampPlayerImageOffset(null), { x: 0, y: 0 });
|
||||
assert.deepEqual(clampPlayerImageOffset({ x: 99, y: -99 }), {
|
||||
x: PLAYER_IMAGE_OFFSET_MAX,
|
||||
y: -PLAYER_IMAGE_OFFSET_MAX,
|
||||
});
|
||||
});
|
||||
|
||||
void test('clampPlayerImageScale clamps and defaults', () => {
|
||||
assert.equal(clampPlayerImageScale(null), DEFAULT_PLAYER_IMAGE_SCALE);
|
||||
assert.equal(clampPlayerImageScale(0.1), PLAYER_IMAGE_SCALE_MIN);
|
||||
assert.equal(clampPlayerImageScale(9), PLAYER_IMAGE_SCALE_MAX);
|
||||
});
|
||||
|
||||
void test('normalizeAppPlayer fills ringColor and offset', () => {
|
||||
const p = normalizeAppPlayer({
|
||||
id: 'player_1',
|
||||
name: 'Ada',
|
||||
imageRelPath: 'files/a.png',
|
||||
sha256: 'abc',
|
||||
});
|
||||
assert.ok(p);
|
||||
assert.equal(p!.ringColor, DEFAULT_PLAYER_RING_COLOR);
|
||||
assert.deepEqual(p!.imageOffset, { x: 0, y: 0 });
|
||||
assert.equal(p!.imageScale, DEFAULT_PLAYER_IMAGE_SCALE);
|
||||
assert.equal(p!.teamId, null);
|
||||
});
|
||||
|
||||
void test('normalizeAppPlayer drops unknown teamId when set provided', () => {
|
||||
const p = normalizeAppPlayer(
|
||||
{
|
||||
id: 'player_1',
|
||||
name: 'Ada',
|
||||
imageRelPath: 'files/a.png',
|
||||
sha256: 'abc',
|
||||
teamId: 'missing',
|
||||
},
|
||||
new Set(['other']),
|
||||
);
|
||||
assert.equal(p!.teamId, null);
|
||||
});
|
||||
|
||||
void test('normalizeSceneNpcToken validates and clamps', () => {
|
||||
const t = normalizeSceneNpcToken({
|
||||
id: 'snt_1',
|
||||
npcId: 'npc_1',
|
||||
nx: 1.5,
|
||||
ny: -0.2,
|
||||
sizeN: 0.2,
|
||||
});
|
||||
assert.ok(t);
|
||||
assert.equal(t!.id, asSceneNpcTokenId('snt_1'));
|
||||
assert.equal(t!.npcId, asNpcId('npc_1'));
|
||||
assert.equal(t!.nx, 1);
|
||||
assert.equal(t!.ny, 0);
|
||||
assert.equal(normalizeSceneNpcToken({ id: 'x' }), null);
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
/** App-local библиотека живых игроков (userData, не в project zip). */
|
||||
|
||||
import type { NpcId, PlayerId, PlayerTeamId, SceneNpcTokenId } from './ids';
|
||||
import { asNpcId, asPlayerId, asPlayerTeamId, asSceneNpcTokenId } from './ids';
|
||||
import { normalizeHexColor } from '../npcs/npcGroups';
|
||||
|
||||
export type { PlayerId, PlayerTeamId, SceneNpcTokenId };
|
||||
export { asPlayerId, asPlayerTeamId, asSceneNpcTokenId };
|
||||
|
||||
export type PlayerImageOffset = { x: number; y: number };
|
||||
|
||||
/** Плоская команда игроков (без вложенности). */
|
||||
export type AppPlayerTeam = {
|
||||
id: PlayerTeamId;
|
||||
name: string;
|
||||
/** Hex `#rrggbb`. */
|
||||
color: string;
|
||||
};
|
||||
|
||||
export type AppPlayer = {
|
||||
id: PlayerId;
|
||||
name: string;
|
||||
/** Относительный путь файла в каталоге `userData/players/`. */
|
||||
imageRelPath: string;
|
||||
sha256: string;
|
||||
teamId: PlayerTeamId | null;
|
||||
ringColor: string;
|
||||
imageOffset: PlayerImageOffset;
|
||||
/** Масштаб аватара внутри круга (1 = по умолчанию). */
|
||||
imageScale: number;
|
||||
};
|
||||
|
||||
/** Расстановка кампанийного НПС на карте как игрового токена (в проекте). */
|
||||
export type SceneNpcToken = {
|
||||
id: SceneNpcTokenId;
|
||||
npcId: NpcId;
|
||||
nx: number;
|
||||
ny: number;
|
||||
sizeN: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_PLAYER_RING_COLOR = '#c9a227';
|
||||
export const DEFAULT_PLAYER_IMAGE_OFFSET: PlayerImageOffset = { x: 0, y: 0 };
|
||||
export const PLAYER_IMAGE_OFFSET_MIN = -0.45;
|
||||
export const PLAYER_IMAGE_OFFSET_MAX = 0.45;
|
||||
export const DEFAULT_PLAYER_IMAGE_SCALE = 1;
|
||||
export const PLAYER_IMAGE_SCALE_MIN = 0.5;
|
||||
export const PLAYER_IMAGE_SCALE_MAX = 3;
|
||||
export const PLAYER_IMAGE_SCALE_STEP = 0.08;
|
||||
|
||||
export const DEFAULT_SCENE_NPC_TOKEN_SIZE_N = 0.1;
|
||||
export const SCENE_NPC_TOKEN_SIZE_MIN = 0.04;
|
||||
export const SCENE_NPC_TOKEN_SIZE_MAX = 0.45;
|
||||
|
||||
/** Множитель размера всех НПС-токенов на пульте/презентации (session-only). */
|
||||
export const DEFAULT_NPC_TOKEN_SESSION_SCALE = 1;
|
||||
export const NPC_TOKEN_SESSION_SCALE_MIN = 0.4;
|
||||
export const NPC_TOKEN_SESSION_SCALE_MAX = 2.5;
|
||||
|
||||
export type SceneNpcTokensSessionState = {
|
||||
revision: number;
|
||||
byPlacementId: Record<string, { nx: number; ny: number }>;
|
||||
/** Общий масштаб отображения всех НПС-токенов (не пишется в проект). */
|
||||
scale: number;
|
||||
};
|
||||
|
||||
export type SceneNpcTokensSessionEvent =
|
||||
| { kind: 'move'; placementId: string; nx: number; ny: number }
|
||||
| { kind: 'setScale'; scale: number }
|
||||
| { kind: 'clear' };
|
||||
|
||||
export function clampNpcTokenSessionScale(raw: unknown): number {
|
||||
const n = typeof raw === 'number' && Number.isFinite(raw) ? raw : DEFAULT_NPC_TOKEN_SESSION_SCALE;
|
||||
return Math.max(NPC_TOKEN_SESSION_SCALE_MIN, Math.min(NPC_TOKEN_SESSION_SCALE_MAX, n));
|
||||
}
|
||||
|
||||
export type PlayersUpsertProgressEvent = {
|
||||
percent: number;
|
||||
stage: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
export function clampPlayerImageOffset(raw: unknown): PlayerImageOffset {
|
||||
if (!raw || typeof raw !== 'object') return { ...DEFAULT_PLAYER_IMAGE_OFFSET };
|
||||
const obj = raw as { x?: unknown; y?: unknown };
|
||||
const x = typeof obj.x === 'number' && Number.isFinite(obj.x) ? obj.x : 0;
|
||||
const y = typeof obj.y === 'number' && Number.isFinite(obj.y) ? obj.y : 0;
|
||||
return {
|
||||
x: Math.max(PLAYER_IMAGE_OFFSET_MIN, Math.min(PLAYER_IMAGE_OFFSET_MAX, x)),
|
||||
y: Math.max(PLAYER_IMAGE_OFFSET_MIN, Math.min(PLAYER_IMAGE_OFFSET_MAX, y)),
|
||||
};
|
||||
}
|
||||
|
||||
export function clampPlayerImageScale(raw: unknown): number {
|
||||
const n = typeof raw === 'number' && Number.isFinite(raw) ? raw : DEFAULT_PLAYER_IMAGE_SCALE;
|
||||
return Math.max(PLAYER_IMAGE_SCALE_MIN, Math.min(PLAYER_IMAGE_SCALE_MAX, n));
|
||||
}
|
||||
|
||||
export function clampSceneNpcTokenSizeN(sizeN: number): number {
|
||||
if (!Number.isFinite(sizeN)) return DEFAULT_SCENE_NPC_TOKEN_SIZE_N;
|
||||
return Math.max(SCENE_NPC_TOKEN_SIZE_MIN, Math.min(SCENE_NPC_TOKEN_SIZE_MAX, sizeN));
|
||||
}
|
||||
|
||||
export function normalizeAppPlayerTeam(raw: unknown): AppPlayerTeam | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const obj = raw as Partial<AppPlayerTeam>;
|
||||
if (typeof obj.id !== 'string' || !obj.id) return null;
|
||||
if (typeof obj.name !== 'string') return null;
|
||||
const name = obj.name.trim();
|
||||
if (!name) return null;
|
||||
return {
|
||||
id: asPlayerTeamId(obj.id),
|
||||
name,
|
||||
color: normalizeHexColor(obj.color, DEFAULT_PLAYER_RING_COLOR),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAppPlayer(raw: unknown, teamIds?: Set<string>): AppPlayer | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const obj = raw as Partial<AppPlayer> & { teamId?: string | null };
|
||||
if (typeof obj.id !== 'string' || !obj.id) return null;
|
||||
if (typeof obj.name !== 'string') return null;
|
||||
const name = obj.name.trim();
|
||||
if (!name) return null;
|
||||
if (typeof obj.imageRelPath !== 'string' || !obj.imageRelPath) return null;
|
||||
if (typeof obj.sha256 !== 'string' || !obj.sha256) return null;
|
||||
let teamId: PlayerTeamId | null = null;
|
||||
if (typeof obj.teamId === 'string' && obj.teamId) {
|
||||
if (!teamIds || teamIds.has(obj.teamId)) teamId = asPlayerTeamId(obj.teamId);
|
||||
}
|
||||
return {
|
||||
id: asPlayerId(obj.id),
|
||||
name,
|
||||
imageRelPath: obj.imageRelPath,
|
||||
sha256: obj.sha256,
|
||||
teamId,
|
||||
ringColor: normalizeHexColor(obj.ringColor, DEFAULT_PLAYER_RING_COLOR),
|
||||
imageOffset: clampPlayerImageOffset(obj.imageOffset),
|
||||
imageScale: clampPlayerImageScale((obj as { imageScale?: unknown }).imageScale),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSceneNpcToken(raw: unknown): SceneNpcToken | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const obj = raw as Partial<SceneNpcToken>;
|
||||
if (typeof obj.id !== 'string' || !obj.id) return null;
|
||||
if (typeof obj.npcId !== 'string' || !obj.npcId) return null;
|
||||
const nx = typeof obj.nx === 'number' && Number.isFinite(obj.nx) ? obj.nx : null;
|
||||
const ny = typeof obj.ny === 'number' && Number.isFinite(obj.ny) ? obj.ny : null;
|
||||
if (nx === null || ny === null) return null;
|
||||
return {
|
||||
id: asSceneNpcTokenId(obj.id),
|
||||
npcId: asNpcId(obj.npcId),
|
||||
nx: Math.max(0, Math.min(1, nx)),
|
||||
ny: Math.max(0, Math.min(1, ny)),
|
||||
sizeN: clampSceneNpcTokenSizeN(typeof obj.sizeN === 'number' ? obj.sizeN : DEFAULT_SCENE_NPC_TOKEN_SIZE_N),
|
||||
};
|
||||
}
|
||||
@@ -10,10 +10,11 @@ import type {
|
||||
} from './ids';
|
||||
import type { MaterialLegend } from './materialLegend';
|
||||
import type { SceneToken } from './appTokens';
|
||||
import type { SceneNpcToken, PlayerImageOffset } from './appPlayers';
|
||||
import type { SceneGrid } from './sceneGrid';
|
||||
import type { SceneTrap } from './sceneTraps';
|
||||
|
||||
export const PROJECT_SCHEMA_VERSION = 10 as const;
|
||||
export const PROJECT_SCHEMA_VERSION = 11 as const;
|
||||
|
||||
/** Материал кампании: изображение, показываемое поверх сцены во время игры. */
|
||||
export type ProjectMaterial = {
|
||||
@@ -46,6 +47,12 @@ export type ProjectNpc = {
|
||||
y: number;
|
||||
/** `null` — системная секция «Без группы». */
|
||||
groupId: NpcGroupId | null;
|
||||
/** Цвет кольца игрового токена на сцене. */
|
||||
ringColor: string;
|
||||
/** Сдвиг аватара внутри круга токена. */
|
||||
imageOffset: PlayerImageOffset;
|
||||
/** Масштаб аватара внутри круга токена. */
|
||||
imageScale: number;
|
||||
};
|
||||
|
||||
/** Однонаправленная связь: от `sourceNpcId` к `targetNpcId`; подпись на линии. */
|
||||
@@ -154,6 +161,8 @@ export type Scene = {
|
||||
traps: SceneTrap[];
|
||||
/** Неигровые токены на карте (ссылки на app-local пул). */
|
||||
tokens: SceneToken[];
|
||||
/** Кампанийные НПС на карте как игровые токены (отдельно от неигровых). */
|
||||
npcTokens: SceneNpcToken[];
|
||||
/** Боевая сетка поверх превью (под ловушками/эффектами). */
|
||||
grid: SceneGrid;
|
||||
media: SceneMediaRefs;
|
||||
|
||||
@@ -10,6 +10,9 @@ export type NpcRelationId = Brand<string, 'NpcRelationId'>;
|
||||
export type NpcGroupId = Brand<string, 'NpcGroupId'>;
|
||||
export type TokenId = Brand<string, 'TokenId'>;
|
||||
export type SceneTokenId = Brand<string, 'SceneTokenId'>;
|
||||
export type PlayerId = Brand<string, 'PlayerId'>;
|
||||
export type PlayerTeamId = Brand<string, 'PlayerTeamId'>;
|
||||
export type SceneNpcTokenId = Brand<string, 'SceneNpcTokenId'>;
|
||||
|
||||
export function asProjectId(value: string): ProjectId {
|
||||
return value as ProjectId;
|
||||
@@ -50,3 +53,15 @@ export function asTokenId(value: string): TokenId {
|
||||
export function asSceneTokenId(value: string): SceneTokenId {
|
||||
return value as SceneTokenId;
|
||||
}
|
||||
|
||||
export function asPlayerId(value: string): PlayerId {
|
||||
return value as PlayerId;
|
||||
}
|
||||
|
||||
export function asPlayerTeamId(value: string): PlayerTeamId {
|
||||
return value as PlayerTeamId;
|
||||
}
|
||||
|
||||
export function asSceneNpcTokenId(value: string): SceneNpcTokenId {
|
||||
return value as SceneNpcTokenId;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './appPlayers';
|
||||
export * from './appTokens';
|
||||
export * from './domain';
|
||||
export * from './effects';
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { sceneGridTokenFitFactor } from './sceneGrid';
|
||||
|
||||
void test('sceneGridTokenFitFactor: square and disabled = 1', () => {
|
||||
assert.equal(sceneGridTokenFitFactor(undefined), 1);
|
||||
assert.equal(sceneGridTokenFitFactor({ enabled: false, type: 'hex' }), 1);
|
||||
assert.equal(sceneGridTokenFitFactor({ enabled: true, type: 'square' }), 1);
|
||||
});
|
||||
|
||||
void test('sceneGridTokenFitFactor: hex uses inscribed diameter', () => {
|
||||
assert.ok(Math.abs(sceneGridTokenFitFactor({ enabled: true, type: 'hex' }) - Math.sqrt(3) / 2) < 1e-9);
|
||||
});
|
||||
@@ -59,3 +59,14 @@ export function normalizeSceneGrid(raw: unknown): SceneGrid {
|
||||
export function sceneGridTypeLabelRu(type: SceneGridType): string {
|
||||
return type === 'hex' ? 'Гексогональная' : 'Квадратная';
|
||||
}
|
||||
|
||||
/**
|
||||
* Множитель диаметра токена «в одну ячейку» относительно `grid.sizeN`.
|
||||
* Square: сторона клетки = sizeN.
|
||||
* Flat-top hex: sizeN — ширина (vertex-to-vertex, описанная окружность);
|
||||
* вписанная окружность = flat-to-flat = sizeN · √3/2.
|
||||
*/
|
||||
export function sceneGridTokenFitFactor(grid: Pick<SceneGrid, 'enabled' | 'type'> | null | undefined): number {
|
||||
if (!grid?.enabled || grid.type !== 'hex') return 1;
|
||||
return Math.sqrt(3) / 2;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user