mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
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:
@@ -0,0 +1 @@
|
||||
export * from './state';
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './state';
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,2 +1,3 @@
|
||||
export * from './config';
|
||||
export * from './characters';
|
||||
export * from './types';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './state';
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user