feat: implement replicant state synchronization for commentary, players, scoreboard, and graphics settings

- Added a new service for synchronizing state with replicants in `replicant-state-service.ts`.
- Refactored commentary store to utilize the new synchronization service.
- Created a new graphics settings store that syncs with replicants.
- Introduced a packs store for managing installed packs and their states.
- Updated players and scoreboard stores to use the new synchronization service.
- Created shared services for managing replicated state in graphics components.
- Refactored existing components to use the new shared services for replicant state.
- Added normalization and default values for commentary, graphics settings, players, and scoreboard.
- Improved type safety and organization in shared domain files for better maintainability.
This commit is contained in:
2026-05-30 21:22:48 +02:00
parent 02a108f983
commit b32c0e4560
33 changed files with 929 additions and 608 deletions
+1
View File
@@ -0,0 +1 @@
export * from './state';
+32
View File
@@ -0,0 +1,32 @@
import type { Schemas } from '../../../types';
export type Commentary = Schemas.Commentary;
export const defaultCommentary: Commentary = {
leftCommentator: '',
leftCommentatorTwitter: '',
rightCommentator: '',
rightCommentatorTwitter: '',
};
const toString = (value: unknown): string => (typeof value === 'string' ? value : '');
export const normalizeCommentary = (input: unknown): Commentary => {
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
return {
leftCommentator: toString(candidate.leftCommentator),
leftCommentatorTwitter: toString(candidate.leftCommentatorTwitter),
rightCommentator: toString(candidate.rightCommentator),
rightCommentatorTwitter: toString(candidate.rightCommentatorTwitter),
};
};
export const swapCommentary = (commentary: Commentary): Commentary => ({
leftCommentator: commentary.rightCommentator,
leftCommentatorTwitter: commentary.rightCommentatorTwitter,
rightCommentator: commentary.leftCommentator,
rightCommentatorTwitter: commentary.leftCommentatorTwitter,
});
export const stripTwitterPrefix = (value: string): string =>
value.startsWith('@') ? value.slice(1) : value;
+1
View File
@@ -0,0 +1 @@
export * from './state';
+16
View File
@@ -0,0 +1,16 @@
import type { Schemas } from '../../../types';
export type GraphicsSettings = Schemas.GraphicsSettings;
export const defaultGraphicsSettings: GraphicsSettings = {
scoreboardSkin: 'scoreboard/main.html',
};
export const normalizeGraphicsSettings = (input: unknown): GraphicsSettings => {
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
return {
scoreboardSkin: typeof candidate.scoreboardSkin === 'string'
? candidate.scoreboardSkin
: defaultGraphicsSettings.scoreboardSkin,
};
};
+48
View File
@@ -0,0 +1,48 @@
import type { PackManifest } from './types';
export interface FightingCharacterOption {
label: string;
value: string;
image: string;
dlc?: boolean;
}
export interface DefaultCharacterPair {
leftCharacter: string;
rightCharacter: string;
}
export const BUNDLED_GAME_NAMES = new Set<string>();
export const buildCharactersForManifest = (manifest: PackManifest): FightingCharacterOption[] =>
manifest.characters.map((character) => ({
label: character.name,
value: character.slug,
image: `/packs/${manifest.id}/characters/${character.slug}.png`,
dlc: character.dlc ?? false,
}));
export const buildCharactersByGame = (
manifests: readonly PackManifest[],
): Record<string, FightingCharacterOption[]> => {
const charactersByGame: Record<string, FightingCharacterOption[]> = {};
manifests.forEach((manifest) => {
charactersByGame[manifest.name] = buildCharactersForManifest(manifest);
});
return charactersByGame;
};
export const buildDefaultCharactersByGame = (
manifests: readonly PackManifest[],
): Record<string, DefaultCharacterPair> => {
const defaultsByGame: Record<string, DefaultCharacterPair> = {};
manifests.forEach((manifest) => {
if (manifest.defaultPair) {
defaultsByGame[manifest.name] = {
leftCharacter: manifest.defaultPair.left,
rightCharacter: manifest.defaultPair.right,
};
}
});
return defaultsByGame;
};
+1
View File
@@ -1,2 +1,3 @@
export * from './config';
export * from './characters';
export * from './types';
+51
View File
@@ -0,0 +1,51 @@
import type { Schemas } from '../../../types';
export type PlayersMap = Schemas.Players;
export type Player = PlayersMap[string];
const toString = (value: unknown): string => (typeof value === 'string' ? value : '');
export const normalizePlayer = (input: unknown): Player => {
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
return {
gamertag: toString(candidate.gamertag),
name: toString(candidate.name),
team: toString(candidate.team),
country: toString(candidate.country),
twitter: toString(candidate.twitter),
};
};
export const normalizePlayers = (input: unknown): PlayersMap => {
if (typeof input !== 'object' || input === null) {
return {};
}
const result: PlayersMap = {};
Object.entries(input as Record<string, unknown>).forEach(([id, value]) => {
if (id) {
result[id] = normalizePlayer(value);
}
});
return result;
};
export const normalizePlayerName = (value: string): string => value.trim().toLowerCase();
export const createPlayerId = (name: string, players: PlayersMap): string => {
const base = name
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[^\w\s-]/g, '')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '-') || 'player';
let index = 1;
let candidate = base;
while (players[candidate]) {
index += 1;
candidate = `${base}-${index}`;
}
return candidate;
};
+1
View File
@@ -0,0 +1 @@
export * from './state';
+88
View File
@@ -0,0 +1,88 @@
import type { Schemas } from '../../../types';
export type Scoreboard = Schemas.Scoreboard;
export type ScoreboardSide = 'left' | 'right';
export const defaultScoreboard: Scoreboard = {
leftPlayerId: '',
rightPlayerId: '',
leftNameOverride: '',
rightNameOverride: '',
leftTeamOverride: '',
rightTeamOverride: '',
leftCountryOverride: '',
rightCountryOverride: '',
leftCharacter: '',
rightCharacter: '',
leftScore: 0,
rightScore: 0,
round: '',
game: '',
};
const toString = (value: unknown): string => (typeof value === 'string' ? value : '');
const normalizeScore = (value: unknown): number =>
typeof value === 'number' ? Math.max(0, Math.floor(value)) : 0;
export const normalizeScoreboard = (input: unknown): Scoreboard => {
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
return {
leftPlayerId: toString(candidate.leftPlayerId),
rightPlayerId: toString(candidate.rightPlayerId),
leftNameOverride: toString(candidate.leftNameOverride),
rightNameOverride: toString(candidate.rightNameOverride),
leftTeamOverride: toString(candidate.leftTeamOverride),
rightTeamOverride: toString(candidate.rightTeamOverride),
leftCountryOverride: toString(candidate.leftCountryOverride),
rightCountryOverride: toString(candidate.rightCountryOverride),
leftCharacter: toString(candidate.leftCharacter),
rightCharacter: toString(candidate.rightCharacter),
leftScore: normalizeScore(candidate.leftScore),
rightScore: normalizeScore(candidate.rightScore),
round: toString(candidate.round),
game: toString(candidate.game),
};
};
export const setScoreboardScore = (
scoreboard: Scoreboard,
side: ScoreboardSide,
value: number,
): Scoreboard => ({
...scoreboard,
[side === 'left' ? 'leftScore' : 'rightScore']: Math.max(0, Math.floor(value)),
});
export const adjustScoreboardScore = (
scoreboard: Scoreboard,
side: ScoreboardSide,
delta: number,
): Scoreboard =>
setScoreboardScore(
scoreboard,
side,
(side === 'left' ? scoreboard.leftScore : scoreboard.rightScore) + delta,
);
export const swapScoreboardPlayers = (scoreboard: Scoreboard): Scoreboard => ({
...scoreboard,
leftPlayerId: scoreboard.rightPlayerId,
rightPlayerId: scoreboard.leftPlayerId,
leftNameOverride: scoreboard.rightNameOverride,
rightNameOverride: scoreboard.leftNameOverride,
leftTeamOverride: scoreboard.rightTeamOverride,
rightTeamOverride: scoreboard.leftTeamOverride,
leftCountryOverride: scoreboard.rightCountryOverride,
rightCountryOverride: scoreboard.leftCountryOverride,
leftCharacter: scoreboard.rightCharacter,
rightCharacter: scoreboard.leftCharacter,
leftScore: scoreboard.rightScore,
rightScore: scoreboard.leftScore,
});
export const resetScoreboardScores = (scoreboard: Scoreboard): Scoreboard => ({
...scoreboard,
leftScore: 0,
rightScore: 0,
});
+11 -111
View File
@@ -1,112 +1,12 @@
// src/shared/fighting-characters.ts
// ─────────────────────────────────────────────────────────────────────────────
// Todo el contenido de personajes viene de packs descargados desde Gitea.
// No hay datos bundled — el proyecto arranca vacío y se rellena en runtime.
// ─────────────────────────────────────────────────────────────────────────────
export {
BUNDLED_GAME_NAMES,
buildCharactersByGame,
buildCharactersForManifest,
buildDefaultCharactersByGame,
type DefaultCharacterPair,
type FightingCharacterOption,
} from './domain/packs/characters';
import type { DefaultCharacterPair, FightingCharacterOption } from './domain/packs/characters';
import { ref } from 'vue';
import type { PackManifest } from './domain/packs/types';
export interface FightingCharacterOption {
label: string;
value: string;
image: string;
dlc?: boolean;
}
// ── Runtime registry ──────────────────────────────────────────────────────────
const installedPackCharacters: Record<string, FightingCharacterOption[]> = {};
const installedPackDefaults: Record<string, { leftCharacter: string; rightCharacter: string }> = {};
/**
* Incrementado cada vez que se registra o elimina un pack.
* Los composables se suscriben a este ref para que Vue invalide los computed
* que dependen de installedPackCharacters (objeto plano, no reactivo).
*/
export const installedPacksRevision = ref(0);
/**
* Vacío — ya no hay juegos bundled.
* Mantenido por compatibilidad con usePackRegistry.
*/
export const BUNDLED_GAME_NAMES = new Set<string>();
// ── Placeholder SVG ───────────────────────────────────────────────────────────
const toDataUrl = (svg: string): string =>
`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
const buildPlaceholder = (game: string, character: string, start: string, end: string): string => {
const initials = character
.split(/\s+/)
.map((p) => p[0])
.join('')
.slice(0, 2)
.toUpperCase();
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 220" role="img" aria-label="${character}">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${start}"/>
<stop offset="100%" stop-color="${end}"/>
</linearGradient>
</defs>
<rect width="480" height="220" fill="url(#bg)" rx="18"/>
<circle cx="90" cy="110" r="64" fill="rgba(255,255,255,0.13)"/>
<text x="90" y="130" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="56" font-weight="700">${initials}</text>
<text x="170" y="96" fill="#e2e8f0" font-family="Arial, sans-serif" font-size="20" font-weight="700">${game}</text>
<text x="170" y="145" fill="#ffffff" font-family="Arial, sans-serif" font-size="38" font-weight="700">${character}</text>
</svg>`.trim();
return toDataUrl(svg);
};
// ── Pack registration ─────────────────────────────────────────────────────────
/**
* Registra un pack instalado para que getCharactersByGame() lo devuelva.
* Llamado por usePackRegistry cuando carga el manifest.json local de un pack.
*/
export const registerInstalledPack = (manifest: PackManifest): void => {
const { id, name, palette, characters, defaultPair } = manifest;
installedPackCharacters[name] = characters.map((char) => ({
label: char.name,
value: char.slug,
image: `/packs/${id}/characters/${char.slug}.png`,
dlc: char.dlc ?? false,
// Fallback inline por si la imagen no se encuentra en disco
_placeholder: buildPlaceholder(name, char.name, palette.start, palette.end),
}));
if (defaultPair) {
installedPackDefaults[name] = {
leftCharacter: defaultPair.left,
rightCharacter: defaultPair.right,
};
}
installedPacksRevision.value++;
};
/**
* Elimina un pack del registro en memoria.
* Llamado por usePackRegistry cuando el usuario desinstala un pack.
*/
export const unregisterInstalledPack = (gameName: string): void => {
delete installedPackCharacters[gameName];
delete installedPackDefaults[gameName];
installedPacksRevision.value++;
};
// ── Public API ────────────────────────────────────────────────────────────────
export const getCharactersByGame = (game: string): FightingCharacterOption[] =>
installedPackCharacters[game] ?? [];
export const getDefaultCharactersByGame = (
game: string,
): { leftCharacter: string; rightCharacter: string } | undefined =>
installedPackDefaults[game];
export const getCharactersByGame = (_game?: string): FightingCharacterOption[] => [];
export const getDefaultCharactersByGame = (_game?: string): DefaultCharacterPair | undefined => undefined;