Merge branch 'users' into main

Players library, circular NPC tokens, disposition types; keep main overlay/release fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-04 08:03:22 +08:00
69 changed files with 5943 additions and 327 deletions
+1
View File
@@ -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 },
+155 -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,10 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
x: 0,
y: 0,
groupId: gChild,
ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
{
id: n2,
@@ -251,6 +257,10 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
x: 10,
y: 0,
groupId: gChild,
ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
{
id: n3,
@@ -260,6 +270,10 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
x: 20,
y: 0,
groupId: asNpcGroupId('g_other'),
ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
];
const relations: ProjectNpcRelation[] = [
@@ -308,6 +322,142 @@ 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, disposition: 'neutral' },
{ id: asSceneNpcTokenId('nt2'), npcId: n2, nx: 0.5, ny: 0.5, sizeN: 0.1, disposition: 'hostile' },
{ id: asSceneNpcTokenId('nt3'), npcId: asNpcId('missing'), nx: 0.1, ny: 0.1, sizeN: 0.1, disposition: 'neutral' },
],
},
},
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',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
{
id: n2,
name: 'B',
avatarAssetId: avatar,
description: '',
x: 10,
y: 0,
groupId: null,
ringColor: '#c9a227',
disposition: 'neutral',
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, disposition: 'friendly' },
],
},
},
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',
disposition: 'neutral',
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 +485,11 @@ void test('mergeStorylinesIntoProject: imports all NPCs from export bundle', ()
x: 0,
y: 0,
groupId: null,
},
ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
],
});
const target = minimalProject({
+38
View File
@@ -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,39 @@ export function mergeStorylinesIntoProject(
x: n.x,
y: n.y,
groupId: mappedGroupId && npcGroups.some((g) => g.id === mappedGroupId) ? mappedGroupId : null,
ringColor: n.ringColor ?? '#c9a227',
disposition: n.disposition === 'hostile' || n.disposition === 'friendly' ? n.disposition : 'neutral',
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 ?? []) {
+18
View File
@@ -0,0 +1,18 @@
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, /scenePlayerTokensSession:\s*\{/);
assert.match(src, /npcTokens\?:\s*SceneNpcToken\[\]/);
});
+118 -1
View File
@@ -12,16 +12,28 @@ import type {
NpcId,
NpcRelationId,
NpcGroupId,
NpcDisposition,
NpcsOverlayEvent,
NpcsOverlayState,
Project,
ProjectId,
AppToken,
AppPlayer,
AppPlayerTeam,
PlayerId,
PlayerTeamId,
PlayerImageOffset,
PlayersUpsertProgressEvent,
Scene,
SceneDarknessEvent,
SceneDarknessState,
SceneGrid,
SceneId,
SceneNpcToken,
SceneNpcTokensSessionEvent,
SceneNpcTokensSessionState,
ScenePlayerTokensSessionEvent,
ScenePlayerTokensSessionState,
SceneToken,
SceneTokensSessionEvent,
SceneTokensSessionState,
@@ -185,6 +197,30 @@ 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',
},
scenePlayerTokensSession: {
getState: 'scenePlayerTokensSession.getState',
dispatch: 'scenePlayerTokensSession.dispatch',
stateChanged: 'scenePlayerTokensSession.stateChanged',
},
video: {
getState: 'video.getState',
dispatch: 'video.dispatch',
@@ -257,6 +293,10 @@ 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.scenePlayerTokensSession.stateChanged]: { state: ScenePlayerTokensSessionState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
@@ -372,6 +412,10 @@ export type IpcInvokeMap = {
description?: string;
filePath?: string;
groupId?: NpcGroupId | null;
ringColor?: string;
disposition?: NpcDisposition;
imageOffset?: PlayerImageOffset;
imageScale?: number;
};
res: { project: Project };
};
@@ -381,6 +425,10 @@ export type IpcInvokeMap = {
name?: string;
description?: string;
groupId?: NpcGroupId | null;
ringColor?: string;
disposition?: NpcDisposition;
imageOffset?: PlayerImageOffset;
imageScale?: number;
};
res: { project: Project };
};
@@ -577,7 +625,7 @@ export type IpcInvokeMap = {
res: { ok: true };
};
[ipcChannels.windows.openMultiWindow]: {
req: Record<string, never>;
req: { playerIds?: string[] };
res: { ok: true };
};
[ipcChannels.windows.closeMultiWindow]: {
@@ -720,6 +768,70 @@ 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.scenePlayerTokensSession.getState]: {
req: Record<string, never>;
res: { state: ScenePlayerTokensSessionState };
};
[ipcChannels.scenePlayerTokensSession.dispatch]: {
req: { event: ScenePlayerTokensSessionEvent };
res: { ok: true };
};
[ipcChannels.video.getState]: {
req: Record<string, never>;
res: { state: VideoPlaybackState };
@@ -761,6 +873,10 @@ 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.scenePlayerTokensSession.stateChanged]: { state: ScenePlayerTokensSessionState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: Record<string, never>;
};
@@ -776,6 +892,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,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { asPlayerId, asPlayerTeamId } from '../types/ids';
import { layoutPlayerTokensBottom } from '../types/appPlayers';
import { resolveLaunchPlayerIds } from './resolveLaunchPlayerIds';
void test('layoutPlayerTokensBottom spaces tokens along bottom', () => {
const layout = layoutPlayerTokensBottom(['a', 'b', 'c'], 0.1);
assert.equal(layout.a?.ny, 0.88);
assert.equal(layout.a?.nx, 0.25);
assert.equal(layout.b?.nx, 0.5);
assert.equal(layout.c?.nx, 0.75);
});
void test('resolveLaunchPlayerIds merges players and teams with dedupe', () => {
const teamId = asPlayerTeamId('t1');
const players = [
{
id: asPlayerId('p1'),
name: 'A',
imageRelPath: 'x',
sha256: '1',
teamId,
ringColor: '#fff',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
{
id: asPlayerId('p2'),
name: 'B',
imageRelPath: 'y',
sha256: '2',
teamId: null,
ringColor: '#fff',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
];
const ids = resolveLaunchPlayerIds(players, new Set(['p1', 'p2']), new Set(['t1']));
assert.deepEqual([...ids].sort(), ['p1', 'p2']);
});
+62
View File
@@ -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);
});
+71
View File
@@ -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,15 @@
import type { AppPlayer } from '../types';
/** Игроки + члены выбранных команд, без дублей. */
export function resolveLaunchPlayerIds(
players: readonly AppPlayer[],
selectedPlayerIds: ReadonlySet<string>,
selectedTeamIds: ReadonlySet<string>,
): string[] {
const out = new Set<string>();
for (const id of selectedPlayerIds) out.add(id);
for (const p of players) {
if (p.teamId && selectedTeamIds.has(String(p.teamId))) out.add(String(p.id));
}
return [...out];
}
+73
View File
@@ -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);
});
+211
View File
@@ -0,0 +1,211 @@
/** App-local библиотека живых игроков (userData, не в project zip). */
import type { NpcDisposition } from './npcDisposition';
import { normalizeNpcDisposition } from './npcDisposition';
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;
/** Состояние экземпляра на карте (копируется из НПС при постановке). */
disposition: NpcDisposition;
};
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 SceneNpcTokensSessionPlacement = {
/** Есть после move; без move позиция берётся из сцены. */
nx?: number;
ny?: number;
/** Session-only override типа экземпляра. */
disposition?: NpcDisposition;
/** Session-only: серая рамка и grayscale. */
inactive?: boolean;
};
export type SceneNpcTokensSessionState = {
revision: number;
byPlacementId: Record<string, SceneNpcTokensSessionPlacement>;
/** Общий масштаб отображения всех НПС-токенов (не пишется в проект). */
scale: number;
};
export type SceneNpcTokensSessionEvent =
| { kind: 'move'; placementId: string; nx: number; ny: number }
| { kind: 'setDisposition'; placementId: string; disposition: NpcDisposition }
| { kind: 'setInactive'; placementId: string; inactive: boolean }
| { kind: 'setScale'; scale: number }
| { kind: 'clear' };
/** Session-only токены живых игроков на карте во время показа. */
export type ScenePlayerTokenPlacement = { nx: number; ny: number; sizeN: number };
export type ScenePlayerTokensSessionState = {
revision: number;
selectedPlayerIds: string[];
visible: boolean;
byPlayerId: Record<string, ScenePlayerTokenPlacement>;
};
export type ScenePlayerTokensSessionEvent =
| { kind: 'setSelection'; playerIds: readonly string[] }
| { kind: 'setVisible'; visible: boolean }
| { kind: 'show'; sizeN: number }
| { kind: 'seedBottom'; sizeN: number }
| { kind: 'move'; playerId: string; nx: number; ny: number }
| { kind: 'clearPlacements' }
| { 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 function layoutPlayerTokensBottom(
playerIds: readonly string[],
sizeN: number,
): Record<string, ScenePlayerTokenPlacement> {
const clamped = clampSceneNpcTokenSizeN(sizeN);
const n = playerIds.length;
const out: Record<string, ScenePlayerTokenPlacement> = {};
const ny = 0.88;
for (let i = 0; i < n; i += 1) {
const id = playerIds[i];
if (!id) continue;
out[id] = { nx: (i + 1) / (n + 1), ny, sizeN: clamped };
}
return out;
}
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),
disposition: normalizeNpcDisposition((obj as { disposition?: unknown }).disposition),
};
}
+15 -1
View File
@@ -10,10 +10,12 @@ import type {
} from './ids';
import type { MaterialLegend } from './materialLegend';
import type { SceneToken } from './appTokens';
import type { SceneNpcToken, PlayerImageOffset } from './appPlayers';
import type { NpcDisposition } from './npcDisposition';
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 +48,16 @@ export type ProjectNpc = {
y: number;
/** `null` — системная секция «Без группы». */
groupId: NpcGroupId | null;
/**
* @deprecated Цвет кольца выводится из `disposition`. Поле оставлено для совместимости старых проектов.
*/
ringColor: string;
/** Дефолтный тип токена при постановке на карту. */
disposition: NpcDisposition;
/** Сдвиг аватара внутри круга токена. */
imageOffset: PlayerImageOffset;
/** Масштаб аватара внутри круга токена. */
imageScale: number;
};
/** Однонаправленная связь: от `sourceNpcId` к `targetNpcId`; подпись на линии. */
@@ -154,6 +166,8 @@ export type Scene = {
traps: SceneTrap[];
/** Неигровые токены на карте (ссылки на app-local пул). */
tokens: SceneToken[];
/** Кампанийные НПС на карте как игровые токены (отдельно от неигровых). */
npcTokens: SceneNpcToken[];
/** Боевая сетка поверх превью (под ловушками/эффектами). */
grid: SceneGrid;
media: SceneMediaRefs;
+15
View File
@@ -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;
}
+2
View File
@@ -1,9 +1,11 @@
export * from './appPlayers';
export * from './appTokens';
export * from './domain';
export * from './effects';
export * from './ids';
export * from './materialLegend';
export * from './materials';
export * from './npcDisposition';
export * from './npcs';
export * from './sceneDarkness';
export * from './sceneGrid';
+28
View File
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
DEFAULT_NPC_DISPOSITION,
normalizeNpcDisposition,
npcDispositionRingColor,
otherNpcDispositions,
} from './npcDisposition';
void test('normalizeNpcDisposition defaults to neutral', () => {
assert.equal(normalizeNpcDisposition(undefined), DEFAULT_NPC_DISPOSITION);
assert.equal(normalizeNpcDisposition('hostile'), 'hostile');
assert.equal(normalizeNpcDisposition('friendly'), 'friendly');
assert.equal(normalizeNpcDisposition('nope'), 'neutral');
});
void test('npcDispositionRingColor and inactive', () => {
assert.equal(npcDispositionRingColor('hostile'), '#e53935');
assert.equal(npcDispositionRingColor('neutral'), '#c9a227');
assert.equal(npcDispositionRingColor('friendly'), '#43a047');
assert.equal(npcDispositionRingColor('hostile', true), '#9e9e9e');
});
void test('otherNpcDispositions hides current', () => {
assert.deepEqual(otherNpcDispositions('neutral'), ['hostile', 'friendly']);
assert.deepEqual(otherNpcDispositions('hostile'), ['neutral', 'friendly']);
});
+29
View File
@@ -0,0 +1,29 @@
/** Отношение НПС: задаёт цвет кольца токена. */
export const NPC_DISPOSITIONS = ['hostile', 'neutral', 'friendly'] as const;
export type NpcDisposition = (typeof NPC_DISPOSITIONS)[number];
export const DEFAULT_NPC_DISPOSITION: NpcDisposition = 'neutral';
export const NPC_DISPOSITION_RING_COLOR: Record<NpcDisposition, string> = {
hostile: '#e53935',
neutral: '#c9a227',
friendly: '#43a047',
};
/** Серый для session-only «Неактивен». */
export const NPC_INACTIVE_RING_COLOR = '#9e9e9e';
export function normalizeNpcDisposition(raw: unknown): NpcDisposition {
if (raw === 'hostile' || raw === 'friendly' || raw === 'neutral') return raw;
return DEFAULT_NPC_DISPOSITION;
}
export function npcDispositionRingColor(disposition: NpcDisposition, inactive = false): string {
if (inactive) return NPC_INACTIVE_RING_COLOR;
return NPC_DISPOSITION_RING_COLOR[disposition];
}
export function otherNpcDispositions(current: NpcDisposition): NpcDisposition[] {
return NPC_DISPOSITIONS.filter((d) => d !== current);
}
+14
View File
@@ -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);
});
+11
View File
@@ -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;
}