feat(players): app Players library and circular NPC tokens on scenes
Add userData players/teams, scene npcTokens with hex-inscribed sizing, session scale synced to presentation, and Playwright e2e coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user