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
+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;
}