Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a6289a2ea | |||
| 71c18b479b | |||
| 8c270feb5b | |||
| 618d18d8fb | |||
| 0bc6f60b2c | |||
| 88aeedb5ff |
@@ -142,3 +142,4 @@ dist
|
||||
/db/
|
||||
*.sqlite3
|
||||
/scoreko-electron-dev/
|
||||
/packs/
|
||||
@@ -0,0 +1,60 @@
|
||||
# Scoreko-dev: Auditoría de Arquitectura
|
||||
|
||||
Este documento consolida el análisis de la arquitectura actual y el diagnóstico de los problemas encontrados, sirviendo como punto de partida para el refactor.
|
||||
|
||||
## Análisis de la Estructura Actual
|
||||
|
||||
El proyecto está estructurado utilizando `pnpm workspaces` (`nodecg`, `shared` y el directorio raíz). El código fuente principal reside en `src/`, y se compila a `extension/`, `dashboard/` y `graphics/`.
|
||||
|
||||
### Distribución de Carpetas
|
||||
|
||||
| Carpeta | Descripción |
|
||||
| :--- | :--- |
|
||||
| `src/dashboard/scoreko-dev/` | Contiene la UI del dashboard construida con Vue 3, Quasar y Pinia. |
|
||||
| `src/graphics/` | Contiene los overlays (scoreboard, commentary, scoreboard-2xko) en Vue 3. |
|
||||
| `src/extension/` | Backend NodeCG. Contiene la integración con start.gg, Challonge y la gestión de packs. |
|
||||
| `src/shared/` | Lógica o utilidades compartidas (por ejemplo, lista de países). |
|
||||
| `src/browser_shared/` | Replicantes expuestos a las apps Vue. |
|
||||
|
||||
### Flujo de Datos y Estado (Stores y Replicants)
|
||||
|
||||
- **Dashboard (Pinia + Replicants)**: Se usan stores de Pinia (`scoreboard.ts`, `players.ts`, `commentary.ts`) para manejar el estado en el dashboard. Existe un mecanismo de sincronización `store-sync.ts` que ata los ref de Vue (stores) a los Replicants de NodeCG, con un fallback en `localStorage`.
|
||||
- **Graphics**: Los componentes gráficos (ej. `src/graphics/scoreboard/main.vue`) importan directamente los replicants de `browser_shared/replicants.ts` y usan `watch` para reaccionar a cambios. No parecen usar Pinia, sino estado reactivo local (`ref`, `computed`).
|
||||
|
||||
### Componentes Monolíticos
|
||||
|
||||
Existen componentes excesivamente grandes que mezclan responsabilidades:
|
||||
|
||||
- **`src/dashboard/scoreko-dev/views/Players.vue`** (>680 líneas): Mezcla presentación, diálogos de UI, validaciones de formulario, llamadas a composables de integración (`useIntegration`), transformación de datos, renderizado manual de iconos SVG hardcodeados y lógica de exportación/importación JSON.
|
||||
- **`src/graphics/scoreboard/main.vue`** (>690 líneas): Mezcla la UI del marcador, cálculos de dimensiones de fuentes (`fitText`), timeouts manuales para animaciones, carga asíncrona de SVG de banderas usando importación dinámica de Vite, bindings a NodeCG y redirecciones de URL basadas en configuraciones gráficas.
|
||||
|
||||
### Acoplamientos y Efectos Secundarios
|
||||
|
||||
- En el dashboard, la lógica de integración de torneos (start.gg/Challonge) está fuertemente acoplada a la vista de jugadores mediante composables y callbacks, con SVGs directamente embebidos en el template.
|
||||
- En el backend (`src/extension/startgg.ts`), hay mezcla de servidor OAuth HTTP puro, lógica de peticiones GraphQL con strings literales gigantes, parsing de errores y endpoints de NodeCG (`nodecg.listenFor`), todo en el mismo archivo (>440 líneas).
|
||||
|
||||
---
|
||||
|
||||
## Diagnóstico
|
||||
|
||||
### Problemas Críticos
|
||||
|
||||
1. **Lógica de negocio acoplada a la UI**: Componentes como `Players.vue` y `main.vue` del scoreboard saben demasiado. Tienen lógica de red, cálculos de DOM, manejo de timeouts y manipulación de datos en crudo en lugar de delegar a stores o servicios.
|
||||
2. **"Vibe Coding" y AI Slop**: Hay parches evidentes como la inclusión manual de SVGs inmensos inline en los templates, y utilidades infladas (cálculos rudimentarios de `fitText` en los overlays en lugar de usar CSS moderno o directivas reutilizables).
|
||||
3. **Estado implícito y dependencias circulares potenciales**: El sistema de `store-sync.ts` que sincroniza Pinia <-> LocalStorage <-> NodeCG Replicants es frágil, creando condiciones de carrera sobre cuál es la "fuente de la verdad".
|
||||
4. **Falta de abstracción en el Backend NodeCG**: Los archivos de `extension/` son scripts procesales en lugar de arquitecturas separadas.
|
||||
|
||||
### Impacto a Medio y Largo Plazo
|
||||
|
||||
- **Mantenibilidad Reducida**: Agregar nuevas integraciones (ej. smash.gg, Toornament) requerirá copiar/pegar más bloques monolíticos y añadir más SVGs hardcodeados.
|
||||
- **Riesgo de Regresiones**: Modificar animaciones del scoreboard puede romper el cálculo del tamaño de fuente o la lógica de banderas, debido al acoplamiento.
|
||||
- **Developer Experience (DX) Pobre**: La curva de aprendizaje es alta. Entender cómo fluye un cambio de score desde el dashboard hasta el overlay se vuelve muy complejo.
|
||||
|
||||
### Estrategia de Resolución
|
||||
|
||||
- **Reorganización**: Mantener la estructura base (`src/dashboard`, `src/graphics`, `src/extension`), pero crear subcarpetas por dominio/feature en el backend y separación estricta en el frontend.
|
||||
- **Refactor**: Simplificar stores (eliminar puentes complejos a NodeCG), extracción de composables puros en el dashboard, separación de UI *Dumb* vs UI *Smart*.
|
||||
- **Reescritura controlada**:
|
||||
- `Players.vue`: Dividir drásticamente.
|
||||
- `main.vue` (scoreboard): Extraer lógica de flags, animaciones y `fitText`.
|
||||
- `startgg.ts` / `challonge.ts`: Adoptar un patrón Service/Repository.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Reglas Arquitectónicas de Implementación
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Estas reglas son estrictas y obligatorias. Se deben aplicar sin excepción durante toda la fase de refactorización y en el futuro desarrollo.
|
||||
|
||||
1. **NO `any`, NO IGNORES**
|
||||
- Prohibido el uso de `any`, `@ts-ignore` o casteos forzados ciegos (`as unknown as Tipo`). Todo debe tener tipado fuerte en TypeScript.
|
||||
|
||||
2. **CERO LÓGICA DE NEGOCIO EN COMPONENTES**
|
||||
- Los componentes de Vue (`.vue`) no deben tener llamadas `fetch`, lógica compleja de parseo, o cálculos pesados.
|
||||
- Su sección `<script>` debe limitarse exclusivamente a invocar *composables* o *stores*, y exponer datos al `template`.
|
||||
|
||||
3. **COMPONENTES PEQUEÑOS Y "DUMB"**
|
||||
- Si el template de un componente supera las 100 líneas, es un síntoma de que debe subdividirse.
|
||||
- Fomentar la creación de componentes presentacionales ("dumb components") que reciben datos únicamente mediante `props` y se comunican hacia arriba mediante `emits`.
|
||||
|
||||
4. **FUNCIONES PURAS (PURE FUNCTIONS) PRIMERO**
|
||||
- Cualquier transformación de datos (ej. extraer *gamertags*, limpiar strings, dar formato a números) debe residir en una función pura y testeable, fuera del ecosistema Vue y de NodeCG.
|
||||
|
||||
5. **SIN WRAPPERS INÚTILES**
|
||||
- Evitar crear *composables* simplemente por envolver una o dos líneas de código si no aportan verdadera semántica o abstracción de dominio.
|
||||
|
||||
6. **USO DE PATRONES ESTÁNDAR VUE 3**
|
||||
- Utilizar exclusivamente convenciones estándar de Vue 3: Composition API pura, `<script setup>` y el ecosistema reactivo estándar de Pinia.
|
||||
- Nada de patrones híbridos ni inventados.
|
||||
|
||||
7. **BORRAR SOBRE CONSERVAR (Limpieza de AI Slop)**
|
||||
- Si se detecta código redundante o inútil (ej. código "AI slop" o enormes SVGs hardcodeados en HTML para iconos simples), la prioridad es eliminarlo.
|
||||
- Sustituir por alternativas limpias y mantenibles (como usar iconos vectoriales de Quasar u hojas de estilo puras).
|
||||
|
||||
8. **EFECTOS SECUNDARIOS (SIDE EFFECTS) CONTROLADOS**
|
||||
- El uso de `watch` debe ser el mínimo indispensable.
|
||||
- Siempre que se deba reaccionar a un cambio, preferir flujos de datos unidireccionales (ej. variables calculadas mediante `computed`) en lugar de mutar un estado local desde un watcher reactivo.
|
||||
|
||||
9. **REESCRITURA SÍ, PARCHEO NO**
|
||||
- Las zonas marcadas para reescritura en el plan (ej. `Players.vue` y `graphics/main.vue`) deben ser rehechas lógicamente.
|
||||
- El objetivo es mantener el output visual o funcional intacto pero desechando la estructura legacy interna. No se aceptan "parches temporales" en estas áreas clave.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Plan de Migración
|
||||
|
||||
> [!WARNING]
|
||||
> Este plan está diseñado para evitar regresiones y mantener un estado "compilable" en todo momento. Se debe ejecutar estrictamente de backend a frontend, y de lógica pura a UI.
|
||||
|
||||
## Paso 1: Estabilización del Shared y Tipos
|
||||
- **Acciones**:
|
||||
- Mover utilidades genéricas (como la lista de `countries`, funciones de manipulación de strings) a `src/shared/utils/`.
|
||||
- Refinar y consolidar los tipos en `src/shared/types/` para que representen el dominio real.
|
||||
- Eliminar tipos parciales o duplicados dispersos en el código.
|
||||
- **Riesgo**: Bajo. Gran parte es movimiento y ajuste de imports.
|
||||
|
||||
## Paso 2: Refactor del Backend (Extension)
|
||||
- **Acciones**:
|
||||
- **Reescritura controlada de integraciones**: Dividir `startgg.ts` en:
|
||||
- `services/startgg.ts` (lógica de negocio y transformaciones).
|
||||
- `api/startgg.ts` (GraphQL / HTTP requests).
|
||||
- `oauth/startgg.ts` (flujo OAuth).
|
||||
- `nodecg-bindings/startgg.ts` (vinculación exclusiva de `nodecg.listenFor`).
|
||||
- Repetir la misma división para `challonge.ts`.
|
||||
- Mover cualquier otra utilidad de backend a `extension/utils/`.
|
||||
- **Riesgo**: Moderado. Requiere trasladar código con cuidado para no romper las firmas de los métodos.
|
||||
|
||||
## Paso 3: Refactor del Estado del Dashboard (Stores)
|
||||
- **Acciones**:
|
||||
- Limpiar `store-sync.ts`. Reducir la complejidad y sobre-ingeniería generada por el uso del `localStorage`.
|
||||
- Asegurar que Pinia dependa directa y limpiamente de los Replicantes como fuente de datos real.
|
||||
- Garantizar que los stores exporten acciones limpias, evitando que el estado interno se mute manualmente desde los componentes de la vista.
|
||||
- **Riesgo**: Medio. Afecta el flujo de reactividad base de la UI.
|
||||
|
||||
## Paso 4: Modularización de los Componentes Grandes (Dashboard)
|
||||
- **Acciones**:
|
||||
- **Reescritura/División controlada de `Players.vue`**:
|
||||
- Extraer modales a componentes independientes (ej. `ImportDialog.vue`, `PlayerEditDialog.vue`).
|
||||
- Extraer los selectores de integración a componentes puros (`StartGGPanel.vue`, `ChallongePanel.vue`).
|
||||
- Reemplazar los SVGs "hardcodeados" en el template por un componente dedicado o usar la librería de iconos de Quasar.
|
||||
- Extraer partes de vistas monolíticas (`PlayerSidePanel.vue`) en sub-componentes especializados.
|
||||
- **Riesgo**: Medio. Afecta directamente a la UI. Es crítico asegurar que los eventos (`emits`) se propagen y conecten correctamente.
|
||||
|
||||
## Paso 5: Refactor de los Gráficos (Scoreboard)
|
||||
- **Acciones**:
|
||||
- **Reescritura controlada de `main.vue`**:
|
||||
- Extraer la lógica de ajuste de fuente a un archivo dedicado, ya sea como directiva o función: `graphics/shared/utils/fitText.ts` (o `v-fit-text`).
|
||||
- Extraer la lógica de resolución de banderas (flags) y su caché a un composable dedicado: `useFlag(countryCode)`.
|
||||
- Extraer el control de animaciones y timeouts a `useScoreAnimation(scoreRef)`.
|
||||
- Dividir el DOM en componentes claros: `<BackgroundPanel>`, `<PlayerInfo side="left">`, `<ScoreDisplay>`, orquestados desde `App.vue` (o `main.vue` simplificado).
|
||||
- **Riesgo**: Alto. Las animaciones y cálculos visuales de DOM son delicados. El objetivo visual final debe ser idéntico, y el comportamiento ante cambios de Replicants debe mantenerse exacto.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Summary: Phase 1 (Base Architecture)
|
||||
|
||||
## Objetivos Completados
|
||||
- **Reorganización Estructural**: Se movieron utilidades y tipos compartidos a `src/shared/utils/` y `src/shared/types/`.
|
||||
- **Desacoplamiento del Backend**: Se eliminaron los monolitos `startgg.ts` y `challonge.ts` de `src/extension/`.
|
||||
- **Creación de Capas**:
|
||||
- `api/`: Llamadas aisladas de GraphQL y HTTP (`startgg.api.ts`, `challonge.api.ts`).
|
||||
- `oauth/`: Lógica de autenticación OAuth manejada independientemente.
|
||||
- `services/`: Lógica de dominio pura para transformar y parsear respuestas (ej. extraer `RecentTournament` y `ImportedPlayer`).
|
||||
- `nodecg-bindings/`: Registros exclusivos de `nodecg.listenFor(...)` sin mezclar lógica de dominio.
|
||||
- **Tipado Fuerte**: Se crearon interfaces centralizadas en `src/shared/types/domain.ts` asegurando tipos explícitos y la ausencia de `any`.
|
||||
- **Consolidación**: Duplicidades como la resolución de códigos de país y parseo de strings (ej. `getStringProp`) se extrajeron a utilidades de `shared`.
|
||||
|
||||
## Ajustes Técnicos Realizados
|
||||
- El `tsconfig.extension.json` fue ajustado (`rootDir: "./src"`, `outDir: "./"`) para permitir que la compilación backend (`tsc`) incluya e integre los archivos de `src/shared/` de forma nativa sin romper la estructura requerida por NodeCG (que espera los archivos compilados del backend en el directorio raíz `extension/`).
|
||||
- Actualización de todos los *imports* en vistas (`Players.vue`), *composables* (`useCountryFilter.ts`) y gráficos (`main.vue`).
|
||||
- Compilación (`npm run build`) verificada y validada sin errores de TypeScript.
|
||||
|
||||
## Siguientes Pasos Requeridos
|
||||
- Avanzar a la **Fase 2**: Refactor del Estado del Dashboard (Stores), simplificando `store-sync.ts` e hidratando Pinia directamente desde los *Replicants*.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Session Handoff: Refactor NodeCG Scoreboard
|
||||
|
||||
Este documento sirve como registro de estado y transferencia de contexto para cualquier agente o desarrollador en futuras sesiones de trabajo.
|
||||
|
||||
## Estado Actual
|
||||
- **Fase de Análisis y Diagnóstico:** Completada.
|
||||
- **Fase de Definición de Arquitectura y Reglas:** Completada.
|
||||
- **Documentación:** Generada y almacenada en `docs/refactor/`.
|
||||
|
||||
## Fuente de la Verdad (Source of Truth)
|
||||
Para cualquier duda, decisión arquitectónica, o estructuración de código durante el refactor, consulta **EXCLUSIVAMENTE** el documento:
|
||||
- [TARGET_ARCHITECTURE.md](./TARGET_ARCHITECTURE.md)
|
||||
|
||||
Además, asegúrate de seguir las directrices dictadas en:
|
||||
- [ARCHITECTURE_RULES.md](./ARCHITECTURE_RULES.md)
|
||||
|
||||
## Próximos Pasos (Next Actions)
|
||||
La próxima sesión debe comenzar con la ejecución del `MIGRATION_PLAN.md`, ejecutando los pasos de forma estrictamente secuencial:
|
||||
|
||||
1. **Revisar [MIGRATION_PLAN.md](./MIGRATION_PLAN.md) -> Paso 1.**
|
||||
2. Comenzar la creación/movimiento de utilidades compartidas hacia `src/shared/`.
|
||||
3. Proceder únicamente al Paso 2 cuando el Paso 1 compile perfectamente y no existan errores de tipado.
|
||||
|
||||
No te desvíes de la secuencia. Evita realizar cambios no relacionados o abordar los gráficos antes de tener estabilizado el backend (`extension/`) y el core compartido.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Arquitectura Objetivo (Target Architecture)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Este documento sirve como la única fuente de la verdad para el diseño del sistema durante y después del refactor.
|
||||
|
||||
## Estructura de Capas
|
||||
|
||||
La aplicación se dividirá estrictamente en las siguientes capas lógicas:
|
||||
|
||||
1. **Capa NodeCG (Bindings)**: Archivos cuya *única* responsabilidad es declarar `nodecg.listenFor` (backend) o importar `nodecg.Replicant` (frontend).
|
||||
2. **Capa de Estado (Stores)**: Pinia será la única fuente de la verdad para la UI. Los stores se hidratarán de los replicants sin lógicas cruzadas complejas de `localStorage`.
|
||||
3. **Capa de Lógica Pura (Services/Domain)**: Funciones en TypeScript puro sin dependencias de Vue ni de NodeCG que transforman, formatean o calculan datos.
|
||||
4. **Capa de UI (Dumb Components)**: Componentes Vue puramente presentacionales que solo reciben `props` y emiten `events`.
|
||||
5. **Capa de Orquestación (Smart Components / Composables)**: Vistas y composables que conectan los Stores y/o NodeCG con los Dumb Components.
|
||||
|
||||
## Estructura de Carpetas Propuesta
|
||||
|
||||
```text
|
||||
src/
|
||||
├── browser_shared/
|
||||
│ ├── replicants.ts # Declaraciones puras
|
||||
│ └── useReplicant.ts # (NUEVO) Composable unificado para hidratar Vue desde NodeCG
|
||||
├── shared/
|
||||
│ ├── types/ # Tipos estrictos compartidos
|
||||
│ └── utils/ # Helpers de dominio puros (ej. formateo)
|
||||
├── extension/ # Backend NodeCG
|
||||
│ ├── index.ts # Entry point
|
||||
│ ├── nodecg-bindings/ # Registro exclusivo de nodecg.listenFor()
|
||||
│ ├── services/ # Lógica de negocio pura (StartGGService, ChallongeService)
|
||||
│ ├── api/ # Llamadas HTTP/GraphQL
|
||||
│ └── oauth/ # Manejo de flujos de autenticación OAuth aislados
|
||||
├── dashboard/
|
||||
│ └── scoreko-dev/
|
||||
│ ├── components/ # UI (Small, dumb components)
|
||||
│ ├── composables/ # Lógica orquestada y reutilizable
|
||||
│ ├── features/ # (NUEVO) Dominio agrupado (ej. /players, /integrations)
|
||||
│ ├── stores/ # Pinia stores (Fuente de la verdad UI)
|
||||
│ └── views/ # Smart components (Orquestadores)
|
||||
└── graphics/
|
||||
├── shared/ # (NUEVO) Componentes y composables compartidos entre gráficos
|
||||
│ ├── directives/ # ej. v-fit-text
|
||||
│ └── composables/ # ej. useScoreAnimation, useFlags
|
||||
├── scoreboard/
|
||||
│ ├── components/ # Componentes segregados (PlayerName.vue, Score.vue, BackgroundPanel.vue)
|
||||
│ └── App.vue # Orquestador principal del scoreboard
|
||||
└── scoreboard-2xko/
|
||||
```
|
||||
|
||||
## Reglas Arquitectónicas de Diseño
|
||||
|
||||
- **Domain Driven**: El backend y el dashboard se organizarán por dominio o feature (`players`, `scoreboard`, `integrations`) donde sea posible.
|
||||
- **Aislamiento de NodeCG**: En el backend, toda lógica debe vivir en clases o funciones de servicio que reciben datos y devuelven promesas. La integración con la API de NodeCG solo llama a esos servicios; no se debe inyectar NodeCG en los servicios si no es estrictamente necesario.
|
||||
- **Tipado Estricto**: Todo el output de GraphQL/HTTP debe validarse/parsearse a un tipo de dominio lo antes posible en la capa de API.
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,28 @@
|
||||
export const getStringProp = (payload, key) => {
|
||||
if (typeof payload !== 'object' || payload === null || !(key in payload))
|
||||
return '';
|
||||
const value = payload[key];
|
||||
return typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
||||
};
|
||||
export const getNumberProp = (payload, keys) => {
|
||||
for (const key of keys) {
|
||||
const raw = payload[key];
|
||||
if (typeof raw === 'number' && Number.isFinite(raw))
|
||||
return raw;
|
||||
if (typeof raw === 'string') {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed))
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
export const normalizeTournamentSlug = (value) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed)
|
||||
return '';
|
||||
return trimmed
|
||||
.replace(/^https?:\/\/[^/]+\//i, '')
|
||||
.replace(/^tournaments\//i, '')
|
||||
.replace(/^\/+/, '');
|
||||
};
|
||||
@@ -0,0 +1,324 @@
|
||||
<script setup lang="ts">
|
||||
// src/dashboard/scoreboard/components/GamePackDownloadDialog.vue
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Shown when the user clicks a game that is not yet installed.
|
||||
// Displays size, character roster, and a download progress bar.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { computed, watch } from 'vue';
|
||||
import { getPackLogoUrl } from '../../../shared/pack-config';
|
||||
import type { PackRegistryEntry } from '../../../shared/pack-types';
|
||||
import { usePackRegistry } from '../composables/usePackRegistry';
|
||||
|
||||
// ── Props / emits ─────────────────────────────────────────────────────────────
|
||||
|
||||
const props = defineProps<{
|
||||
/** v-model visibility */
|
||||
modelValue: boolean;
|
||||
/** The registry entry for the game the user wants to download/update */
|
||||
packEntry: PackRegistryEntry | null;
|
||||
/** When true the dialog shows "update" language and calls updatePack instead of downloadPack */
|
||||
isUpdate?: boolean;
|
||||
/** Version info shown in update mode */
|
||||
updateInfo?: { installedVersion: string; latestVersion: string };
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
/** Emitted after a successful download/update so the parent can switch to the game */
|
||||
downloaded: [gameName: string];
|
||||
}>();
|
||||
|
||||
// ── Pack registry ─────────────────────────────────────────────────────────────
|
||||
|
||||
const packRegistry = usePackRegistry();
|
||||
|
||||
// ── Computed ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const downloadState = computed(() =>
|
||||
props.packEntry ? packRegistry.getDownloadState(props.packEntry.id) : null,
|
||||
);
|
||||
|
||||
const isDownloading = computed(() =>
|
||||
downloadState.value?.status === 'downloading' ||
|
||||
downloadState.value?.status === 'fetching-manifest',
|
||||
);
|
||||
|
||||
const isDone = computed(() => downloadState.value?.status === 'done');
|
||||
const isError = computed(() => downloadState.value?.status === 'error');
|
||||
|
||||
const progress = computed(() => downloadState.value?.progress ?? 0);
|
||||
|
||||
// Pre-install: show logo directly from Gitea (pack not on disk yet).
|
||||
// Update mode: pack is installed, serve from local /packs/ route.
|
||||
const logoSrc = computed(() => {
|
||||
if (!props.packEntry) return '';
|
||||
if (props.isUpdate) return packRegistry.getLocalLogoUrl(props.packEntry.id);
|
||||
return getPackLogoUrl(props.packEntry.id);
|
||||
});
|
||||
|
||||
// Close automatically once download completes and emit so parent sets the game
|
||||
watch(isDone, (done) => {
|
||||
if (done && props.packEntry) {
|
||||
emit('downloaded', props.packEntry.name);
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const startDownload = () => {
|
||||
if (!props.packEntry) return;
|
||||
if (props.isUpdate) {
|
||||
packRegistry.updatePack(props.packEntry.id);
|
||||
} else {
|
||||
packRegistry.downloadPack(props.packEntry.id);
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => emit('update:modelValue', false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<QDialog
|
||||
:model-value="modelValue"
|
||||
persistent
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<QCard
|
||||
v-if="packEntry"
|
||||
class="pack-download-dialog"
|
||||
>
|
||||
<!-- ── Header ─────────────────────────────────────────────────────── -->
|
||||
<QCardSection class="pack-download-dialog__header">
|
||||
<div class="pack-download-dialog__title-row">
|
||||
<div>
|
||||
<div class="text-h6 text-weight-bold">
|
||||
{{ packEntry.name }}
|
||||
</div>
|
||||
<div class="text-caption text-grey-5">
|
||||
<template v-if="isUpdate && updateInfo">
|
||||
Bundled v{{ updateInfo.installedVersion }} →
|
||||
<span class="text-positive">v{{ updateInfo.latestVersion }}</span>
|
||||
· {{ packEntry.characterCount }} personajes
|
||||
</template>
|
||||
<template v-else>
|
||||
v{{ packEntry.version }} · {{ packEntry.characterCount }} personajes ·
|
||||
{{ packRegistry.formatBytes(packEntry.totalSizeBytes) }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<QBtn
|
||||
v-if="!isDownloading"
|
||||
flat
|
||||
round
|
||||
dense
|
||||
icon="close"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Banner: logo del juego con gradiente de fallback -->
|
||||
<div
|
||||
class="pack-download-dialog__banner"
|
||||
:style="{
|
||||
background: `linear-gradient(135deg, ${packEntry.palette.start}, ${packEntry.palette.end})`,
|
||||
}"
|
||||
>
|
||||
<img
|
||||
v-if="logoSrc"
|
||||
:src="logoSrc"
|
||||
class="pack-download-dialog__logo"
|
||||
alt=""
|
||||
@error="($event.target as HTMLImageElement).style.display = 'none'"
|
||||
/>
|
||||
<QIcon
|
||||
:name="isUpdate ? 'upgrade' : 'sports_esports'"
|
||||
size="40px"
|
||||
color="white"
|
||||
class="pack-download-dialog__banner-icon"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Version info shown only in update mode -->
|
||||
<div
|
||||
v-if="isUpdate && updateInfo"
|
||||
class="pack-download-dialog__version-badge"
|
||||
>
|
||||
<span class="text-grey-5">v{{ updateInfo.installedVersion }}</span>
|
||||
<QIcon name="arrow_forward" size="14px" color="grey-5" />
|
||||
<span class="text-positive text-weight-bold">v{{ updateInfo.latestVersion }}</span>
|
||||
</div>
|
||||
</QCardSection>
|
||||
|
||||
<QSeparator />
|
||||
|
||||
<!-- ── Progress / error ───────────────────────────────────────────── -->
|
||||
<QCardSection
|
||||
v-if="isDownloading || isDone || isError"
|
||||
class="pack-download-dialog__progress-section"
|
||||
>
|
||||
<div
|
||||
v-if="isError"
|
||||
class="pack-download-dialog__error"
|
||||
>
|
||||
<QIcon
|
||||
name="error"
|
||||
color="negative"
|
||||
size="20px"
|
||||
/>
|
||||
<span>{{ downloadState?.error ?? 'Error desconocido' }}</span>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="pack-download-dialog__progress-label">
|
||||
<span>{{ isDownloading ? 'Descargando…' : '¡Listo!' }}</span>
|
||||
<span>{{ progress }}%</span>
|
||||
</div>
|
||||
<QLinearProgress
|
||||
:value="progress / 100"
|
||||
:color="isDone ? 'positive' : 'primary'"
|
||||
rounded
|
||||
size="8px"
|
||||
/>
|
||||
</template>
|
||||
</QCardSection>
|
||||
|
||||
<!-- ── Character list ─────────────────────────────────────────────── -->
|
||||
<QCardSection class="pack-download-dialog__char-section">
|
||||
<div class="text-caption text-grey-5 q-mb-sm">
|
||||
Personajes incluidos
|
||||
</div>
|
||||
<!-- We only have the count in the registry entry; the full list lives
|
||||
in the manifest. Show a placeholder grid until the registry has
|
||||
a characters array (future enhancement: include it in registry.json). -->
|
||||
<div class="pack-download-dialog__char-count">
|
||||
<QIcon
|
||||
name="sports_martial_arts"
|
||||
size="16px"
|
||||
/>
|
||||
{{ packEntry.characterCount }} personajes en este pack
|
||||
</div>
|
||||
</QCardSection>
|
||||
|
||||
<QSeparator />
|
||||
|
||||
<!-- ── Actions ────────────────────────────────────────────────────── -->
|
||||
<QCardActions
|
||||
align="right"
|
||||
class="q-pa-md"
|
||||
>
|
||||
<QBtn
|
||||
v-if="!isDownloading"
|
||||
flat
|
||||
label="Cancelar"
|
||||
color="grey-5"
|
||||
@click="close"
|
||||
/>
|
||||
<QBtn
|
||||
v-if="!isDownloading && !isDone"
|
||||
unelevated
|
||||
:label="isError ? 'Reintentar' : isUpdate ? 'Actualizar pack' : 'Descargar pack'"
|
||||
:color="isUpdate ? 'positive' : 'primary'"
|
||||
:icon="isUpdate ? 'upgrade' : 'download'"
|
||||
@click="startDownload"
|
||||
/>
|
||||
<QBtn
|
||||
v-if="isDownloading"
|
||||
flat
|
||||
:label="isUpdate ? 'Actualizando…' : 'Descargando…'"
|
||||
:color="isUpdate ? 'positive' : 'primary'"
|
||||
loading
|
||||
disable
|
||||
/>
|
||||
</QCardActions>
|
||||
</QCard>
|
||||
</QDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pack-download-dialog {
|
||||
width: 420px;
|
||||
max-width: 95vw;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pack-download-dialog__header {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.pack-download-dialog__title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.pack-download-dialog__banner {
|
||||
position: relative;
|
||||
height: 88px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pack-download-dialog__logo {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.pack-download-dialog__banner-icon {
|
||||
position: relative; /* above the logo */
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.pack-download-dialog__progress-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.pack-download-dialog__progress-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.pack-download-dialog__error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--q-negative);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pack-download-dialog__char-section {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.pack-download-dialog__char-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.pack-download-dialog__version-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue';
|
||||
import { useScoreboardStore } from '../stores/scoreboard';
|
||||
import { inject, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
|
||||
import { usePackRegistry } from '../composables/usePackRegistry';
|
||||
import { t } from '../i18n';
|
||||
import { useScoreboardStore } from '../stores/scoreboard';
|
||||
import GamePackDownloadDialog from './GamePackDownloadDialog.vue';
|
||||
|
||||
const scoreboardStore = useScoreboardStore();
|
||||
const { gameInput, fightingGameOptions, onGameFilter } = inject(CHARACTER_GAME_KEY)!;
|
||||
const packRegistry = usePackRegistry();
|
||||
|
||||
const {
|
||||
gameInput,
|
||||
fightingGameOptions,
|
||||
onGameFilter,
|
||||
handleGameSelect,
|
||||
pendingDownloadEntry,
|
||||
showDownloadDialog,
|
||||
} = inject(CHARACTER_GAME_KEY)!;
|
||||
|
||||
// Refresca el catálogo al montar y luego cada 15 segundos automáticamente.
|
||||
// Si Gitea no está disponible se usa la caché persistida del replicante.
|
||||
onMounted(() => {
|
||||
packRegistry.fetchRegistry();
|
||||
});
|
||||
|
||||
const refreshInterval = setInterval(() => {
|
||||
packRegistry.fetchRegistry();
|
||||
}, 15_000);
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(refreshInterval);
|
||||
});
|
||||
|
||||
const adjustLeftScore = (delta: number) => {
|
||||
scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore + delta);
|
||||
@@ -14,12 +39,33 @@ const adjustLeftScore = (delta: number) => {
|
||||
const adjustRightScore = (delta: number) => {
|
||||
scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore + delta);
|
||||
};
|
||||
|
||||
/** Tras una descarga exitosa, activa el juego en el store. */
|
||||
const onPackDownloaded = (gameName: string) => {
|
||||
scoreboardStore.scoreboard.game = gameName;
|
||||
};
|
||||
|
||||
// ── Estado del diálogo de actualización ───────────────────────────────────────
|
||||
const pendingUpdateEntry = ref<import('../../../shared/pack-types').PackRegistryEntry | null>(null);
|
||||
const pendingUpdateInfo = ref<{ installedVersion: string; latestVersion: string } | undefined>(undefined);
|
||||
const showUpdateDialog = ref(false);
|
||||
|
||||
const openUpdateDialog = (opt: import('../../../shared/pack-types').GameSelectOption, event: Event) => {
|
||||
event.stopPropagation(); // evitar que el QItem cambie la selección
|
||||
pendingUpdateEntry.value = opt.registryEntry;
|
||||
pendingUpdateInfo.value = opt.updateInfo;
|
||||
showUpdateDialog.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scoreboard-preview__center">
|
||||
<!--
|
||||
v-model → :model-value + @update:model-value para interceptar la
|
||||
selección de juegos no instalados antes de escribir en el store.
|
||||
-->
|
||||
<QSelect
|
||||
v-model="scoreboardStore.scoreboard.game"
|
||||
:model-value="scoreboardStore.scoreboard.game"
|
||||
v-model:input-value="gameInput"
|
||||
:options="fightingGameOptions"
|
||||
:label="t('scoreboardLabelGame')"
|
||||
@@ -32,10 +78,59 @@ const adjustRightScore = (delta: number) => {
|
||||
fill-input
|
||||
class="scoreboard-preview__field scoreboard-preview__game-field"
|
||||
@filter="onGameFilter"
|
||||
@update:model-value="handleGameSelect"
|
||||
>
|
||||
<template #prepend>
|
||||
<QIcon name="sports_esports" />
|
||||
</template>
|
||||
|
||||
<!-- Slot personalizado: muestra iconos de descarga o actualización según el estado -->
|
||||
<template #option="scope">
|
||||
<QItem
|
||||
v-bind="scope.itemProps"
|
||||
:class="{ 'pack-option--unavailable': !scope.opt.available }"
|
||||
>
|
||||
<QItemSection>
|
||||
<QItemLabel>{{ scope.opt.label }}</QItemLabel>
|
||||
</QItemSection>
|
||||
|
||||
<!-- Icono de actualización disponible (pack instalado, versión nueva en repo) -->
|
||||
<QItemSection
|
||||
v-if="scope.opt.available && scope.opt.updateInfo"
|
||||
side
|
||||
>
|
||||
<QBtn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
size="xs"
|
||||
icon="upgrade"
|
||||
color="positive"
|
||||
@click="openUpdateDialog(scope.opt, $event)"
|
||||
>
|
||||
<QTooltip>
|
||||
Actualización disponible:
|
||||
v{{ scope.opt.updateInfo.installedVersion }} →
|
||||
v{{ scope.opt.updateInfo.latestVersion }}
|
||||
</QTooltip>
|
||||
</QBtn>
|
||||
</QItemSection>
|
||||
|
||||
<!-- Icono de descarga (pack no instalado) -->
|
||||
<QItemSection
|
||||
v-else-if="!scope.opt.available"
|
||||
side
|
||||
>
|
||||
<QIcon
|
||||
name="download"
|
||||
size="16px"
|
||||
color="grey-5"
|
||||
>
|
||||
<QTooltip>Pack no instalado — haz clic para descargarlo</QTooltip>
|
||||
</QIcon>
|
||||
</QItemSection>
|
||||
</QItem>
|
||||
</template>
|
||||
</QSelect>
|
||||
|
||||
<div class="scoreboard-preview__score-controls">
|
||||
@@ -101,8 +196,25 @@ const adjustRightScore = (delta: number) => {
|
||||
class="scoreboard-preview__action-btn"
|
||||
@click="scoreboardStore.resetScores"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog de descarga — se abre automáticamente al seleccionar un juego no instalado -->
|
||||
<GamePackDownloadDialog
|
||||
v-model="showDownloadDialog"
|
||||
:pack-entry="pendingDownloadEntry"
|
||||
@downloaded="onPackDownloaded"
|
||||
/>
|
||||
|
||||
<!-- Dialog de actualización — se abre al hacer clic en el icono de upgrade -->
|
||||
<GamePackDownloadDialog
|
||||
v-model="showUpdateDialog"
|
||||
:pack-entry="pendingUpdateEntry"
|
||||
:is-update="true"
|
||||
:update-info="pendingUpdateInfo"
|
||||
@downloaded="onPackDownloaded"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -188,4 +300,13 @@ const adjustRightScore = (delta: number) => {
|
||||
.scoreboard-preview__field :deep(.q-field__label) {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
/* Atenúa visualmente los juegos no instalados en el desplegable */
|
||||
.pack-option--unavailable {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.pack-option--unavailable:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,60 +1,96 @@
|
||||
// src/dashboard/scoreboard/composables/useCharacterGame.ts
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Manages game selection and character state for both PlayerSidePanels.
|
||||
// Must be called ONCE in ScoreboardPanel and provided via CHARACTER_GAME_KEY.
|
||||
//
|
||||
// Changes from original:
|
||||
// - fightingGameOptions is now driven by the pack registry (allGameOptions)
|
||||
// rather than a static hardcoded list. It falls back to bundled names
|
||||
// while the registry loads.
|
||||
// - Game selection is intercepted: selecting an unavailable game triggers
|
||||
// the download dialog instead of updating the store.
|
||||
// - pendingDownloadEntry / showDownloadDialog are exposed for ScoreCenterPanel.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { computed, ref, watch, type InjectionKey, type Ref } from 'vue';
|
||||
import { getCharactersByGame, getDefaultCharactersByGame } from '../../../shared/fighting-characters';
|
||||
import { getCharactersByGame, getDefaultCharactersByGame, installedPacksRevision } from '../../../shared/fighting-characters';
|
||||
import type { GameSelectOption, PackRegistryEntry } from '../../../shared/pack-types';
|
||||
import { useScoreboardStore } from '../stores/scoreboard';
|
||||
import { usePackRegistry } from './usePackRegistry';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ALL_FIGHTING_GAME_OPTIONS = [
|
||||
|
||||
'2XKO',
|
||||
'FATAL FURY: City of the Wolves',
|
||||
'Guilty Gear -Strive-',
|
||||
'Invincible VS',
|
||||
'Mortal Kombat 1',
|
||||
'Street Fighter 6',
|
||||
'TEKKEN 8',
|
||||
'THE KING OF FIGHTERS XV',
|
||||
|
||||
].map((game) => ({ label: game, value: game }));
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type CharacterOption = ReturnType<typeof getCharactersByGame>[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Injection key (type-safe provide/inject)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CharacterGameContext = ReturnType<typeof useCharacterGame>;
|
||||
export const CHARACTER_GAME_KEY: InjectionKey<CharacterGameContext> = Symbol('characterGame');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composable
|
||||
// ---------------------------------------------------------------------------
|
||||
// ── Composable ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Manages game selection and character state for both sides.
|
||||
* Must be called ONCE in the parent (ScoreboardPanel) and provided via
|
||||
* CHARACTER_GAME_KEY so both PlayerSidePanel instances share the same state.
|
||||
*/
|
||||
export function useCharacterGame() {
|
||||
const scoreboardStore = useScoreboardStore();
|
||||
const packRegistry = usePackRegistry();
|
||||
|
||||
// ── Game selector state ───────────────────────────────────────────────────
|
||||
|
||||
// Game selector
|
||||
const gameInput = ref('');
|
||||
const fightingGameOptions = ref(ALL_FIGHTING_GAME_OPTIONS);
|
||||
|
||||
// Per-side character state
|
||||
const characterOptions = computed(() => getCharactersByGame(scoreboardStore.scoreboard.game));
|
||||
/**
|
||||
* Game options surfaced to the QSelect.
|
||||
* Populated from the pack registry when available; falls back to bundled games.
|
||||
* GameSelectOption includes an `available` flag used to show the download icon.
|
||||
*/
|
||||
const fightingGameOptions = ref<GameSelectOption[]>([]);
|
||||
|
||||
// Keep fightingGameOptions in sync when the registry updates
|
||||
watch(
|
||||
packRegistry.allGameOptions,
|
||||
(options) => {
|
||||
fightingGameOptions.value = options;
|
||||
},
|
||||
);
|
||||
|
||||
// ── Download dialog state ─────────────────────────────────────────────────
|
||||
|
||||
/** Set when the user selects a game that isn't installed yet. */
|
||||
const pendingDownloadEntry = ref<PackRegistryEntry | null>(null);
|
||||
const showDownloadDialog = ref(false);
|
||||
|
||||
/**
|
||||
* Intercepting setter for the game selector.
|
||||
* If the selected game is not available, opens the download dialog instead
|
||||
* of writing to the store.
|
||||
*/
|
||||
const handleGameSelect = (gameName: string) => {
|
||||
if (!gameName) {
|
||||
scoreboardStore.scoreboard.game = '';
|
||||
return;
|
||||
}
|
||||
if (!packRegistry.isGameAvailable(gameName)) {
|
||||
const entry = fightingGameOptions.value.find((o) => o.value === gameName)?.registryEntry ?? null;
|
||||
pendingDownloadEntry.value = entry;
|
||||
showDownloadDialog.value = true;
|
||||
// Do NOT update the store — the game isn't installed
|
||||
return;
|
||||
}
|
||||
scoreboardStore.scoreboard.game = gameName;
|
||||
};
|
||||
|
||||
// ── Character state ───────────────────────────────────────────────────────
|
||||
|
||||
const characterOptions = computed(() => {
|
||||
// Subscribing to installedPacksRevision forces Vue to re-evaluate this
|
||||
// computed whenever a pack is registered/unregistered at runtime, even
|
||||
// though scoreboardStore.scoreboard.game itself hasn't changed.
|
||||
void installedPacksRevision.value;
|
||||
return getCharactersByGame(scoreboardStore.scoreboard.game);
|
||||
});
|
||||
const leftCharacterOptions = ref<CharacterOption[]>([]);
|
||||
const rightCharacterOptions = ref<CharacterOption[]>([]);
|
||||
const leftCharacterInput = ref('');
|
||||
const rightCharacterInput = ref('');
|
||||
|
||||
// Remembers selected characters per game so swapping games restores them
|
||||
const charactersByGame = ref<Record<string, { leftCharacter: string; rightCharacter: string }>>({});
|
||||
|
||||
// Character images for preview
|
||||
const leftCharacterImage = computed(() => {
|
||||
const match = characterOptions.value.find(
|
||||
(o) => o.value === scoreboardStore.scoreboard.leftCharacter,
|
||||
@@ -69,20 +105,21 @@ export function useCharacterGame() {
|
||||
return match?.image ?? '';
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
// ── Filter handlers ───────────────────────────────────────────────────────
|
||||
|
||||
const onGameFilter = (value: string, update: (fn: () => void) => void) => {
|
||||
update(() => {
|
||||
const needle = value.toLowerCase().trim();
|
||||
fightingGameOptions.value = needle
|
||||
? ALL_FIGHTING_GAME_OPTIONS.filter((g) => g.label.toLowerCase().includes(needle))
|
||||
: ALL_FIGHTING_GAME_OPTIONS;
|
||||
? packRegistry.allGameOptions.value.filter((g) =>
|
||||
g.label.toLowerCase().includes(needle),
|
||||
)
|
||||
: packRegistry.allGameOptions.value;
|
||||
});
|
||||
};
|
||||
|
||||
const makeCharacterFilter = (target: Ref<CharacterOption[]>) =>
|
||||
const makeCharacterFilter =
|
||||
(target: Ref<CharacterOption[]>) =>
|
||||
(value: string, update: (fn: () => void) => void) => {
|
||||
update(() => {
|
||||
const needle = value.toLowerCase().trim();
|
||||
@@ -95,16 +132,14 @@ export function useCharacterGame() {
|
||||
const onLeftCharacterFilter = makeCharacterFilter(leftCharacterOptions);
|
||||
const onRightCharacterFilter = makeCharacterFilter(rightCharacterOptions);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watchers
|
||||
// ---------------------------------------------------------------------------
|
||||
// ── Watchers ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Keep gameInput display value in sync
|
||||
// Keep gameInput display value in sync with the store
|
||||
watch(
|
||||
() => scoreboardStore.scoreboard.game,
|
||||
(value) => {
|
||||
const match = ALL_FIGHTING_GAME_OPTIONS.find((o) => o.value === value);
|
||||
gameInput.value = match?.label ?? '';
|
||||
const match = fightingGameOptions.value.find((o) => o.value === value);
|
||||
gameInput.value = match?.label ?? value;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
@@ -121,6 +156,13 @@ export function useCharacterGame() {
|
||||
}
|
||||
|
||||
const options = getCharactersByGame(newGame);
|
||||
|
||||
// If the game is set but has no options yet, the pack is still loading
|
||||
// (installed pack whose registerInstalledPack() hasn't run yet).
|
||||
// Bail out — the installedPacksRevision watcher below will restore state
|
||||
// once the pack becomes available.
|
||||
if (newGame && options.length === 0) return;
|
||||
|
||||
leftCharacterOptions.value = options;
|
||||
rightCharacterOptions.value = options;
|
||||
const allowed = new Set(options.map((o) => o.value));
|
||||
@@ -133,7 +175,6 @@ export function useCharacterGame() {
|
||||
if (!allowed.has(nextLeft)) nextLeft = '';
|
||||
if (!allowed.has(nextRight)) nextRight = '';
|
||||
|
||||
// Apply defaults only when neither side had a character yet
|
||||
if ((!nextLeft || !nextRight) && (!curLeft || !curRight)) {
|
||||
const defaults = getDefaultCharactersByGame(newGame);
|
||||
if (defaults) {
|
||||
@@ -159,7 +200,6 @@ export function useCharacterGame() {
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Keep left character display input and charactersByGame cache in sync
|
||||
watch(
|
||||
() => scoreboardStore.scoreboard.leftCharacter,
|
||||
(value) => {
|
||||
@@ -176,7 +216,6 @@ export function useCharacterGame() {
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Keep right character display input and charactersByGame cache in sync
|
||||
watch(
|
||||
() => scoreboardStore.scoreboard.rightCharacter,
|
||||
(value) => {
|
||||
@@ -193,16 +232,55 @@ export function useCharacterGame() {
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// When an installed pack becomes available (e.g. after page refresh while
|
||||
// the pack loads asynchronously), re-validate and restore the characters
|
||||
// that are already in the store but couldn't be confirmed before.
|
||||
watch(installedPacksRevision, () => {
|
||||
const game = scoreboardStore.scoreboard.game;
|
||||
if (!game) return;
|
||||
|
||||
const options = getCharactersByGame(game);
|
||||
if (options.length === 0) return;
|
||||
|
||||
const allowed = new Set(options.map((o) => o.value));
|
||||
leftCharacterOptions.value = options;
|
||||
rightCharacterOptions.value = options;
|
||||
|
||||
const { leftCharacter, rightCharacter } = scoreboardStore.scoreboard;
|
||||
|
||||
if (leftCharacter && allowed.has(leftCharacter)) {
|
||||
leftCharacterInput.value = options.find((o) => o.value === leftCharacter)?.label ?? '';
|
||||
} else if (leftCharacter && !allowed.has(leftCharacter)) {
|
||||
scoreboardStore.scoreboard.leftCharacter = '';
|
||||
leftCharacterInput.value = '';
|
||||
}
|
||||
|
||||
if (rightCharacter && allowed.has(rightCharacter)) {
|
||||
rightCharacterInput.value = options.find((o) => o.value === rightCharacter)?.label ?? '';
|
||||
} else if (rightCharacter && !allowed.has(rightCharacter)) {
|
||||
scoreboardStore.scoreboard.rightCharacter = '';
|
||||
rightCharacterInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// ── Return ────────────────────────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
// Game selector
|
||||
gameInput,
|
||||
fightingGameOptions,
|
||||
onGameFilter,
|
||||
handleGameSelect,
|
||||
// Download dialog
|
||||
pendingDownloadEntry,
|
||||
showDownloadDialog,
|
||||
// Character state
|
||||
leftCharacterOptions,
|
||||
rightCharacterOptions,
|
||||
leftCharacterInput,
|
||||
rightCharacterInput,
|
||||
leftCharacterImage,
|
||||
rightCharacterImage,
|
||||
onGameFilter,
|
||||
onLeftCharacterFilter,
|
||||
onRightCharacterFilter,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { getCountryLabel, getCountryOptions } from '../../../shared/countries';
|
||||
import { getCountryLabel, getCountryOptions } from '../../../shared/utils/countries';
|
||||
import { locale } from '../i18n';
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
// src/dashboard/scoreboard/composables/usePackRegistry.ts
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Singleton composable. The first caller sets up NodeCG replicant listeners;
|
||||
// subsequent calls return the same reactive state. This avoids duplicate event
|
||||
// listeners when multiple components call usePackRegistry().
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { computed, ref, type ComputedRef, type InjectionKey } from 'vue';
|
||||
import {
|
||||
registerInstalledPack,
|
||||
unregisterInstalledPack,
|
||||
} from '../../../shared/fighting-characters';
|
||||
import { BUNDLE_NAME } from '../../../shared/pack-config';
|
||||
import type {
|
||||
GameSelectOption,
|
||||
PackDownloadState,
|
||||
PackManifest,
|
||||
PackRegistry
|
||||
} from '../../../shared/pack-types';
|
||||
|
||||
// ── NodeCG global type declarations ──────────────────────────────────────────
|
||||
// NodeCG injects these into the browser window via its bundle script.
|
||||
|
||||
declare const NodeCG: {
|
||||
Replicant: <T>(
|
||||
name: string,
|
||||
bundleName: string,
|
||||
opts?: { defaultValue?: T },
|
||||
) => {
|
||||
value: T;
|
||||
on(event: 'change', handler: (newVal: T, oldVal?: T) => void): void;
|
||||
off(event: string, handler: (...args: unknown[]) => void): void;
|
||||
};
|
||||
waitForReplicants: (...reps: unknown[]) => Promise<void>;
|
||||
};
|
||||
|
||||
declare const nodecg: {
|
||||
sendMessage(name: string, data?: unknown): void;
|
||||
sendMessage(
|
||||
name: string,
|
||||
data: unknown,
|
||||
cb: (err: Error | null, result?: unknown) => void,
|
||||
): void;
|
||||
};
|
||||
|
||||
// ── Module-level singleton state ──────────────────────────────────────────────
|
||||
|
||||
let initialized = false;
|
||||
|
||||
const registry = ref<PackRegistry | null>(null);
|
||||
const installedPackIds = ref<string[]>([]);
|
||||
const downloadStates = ref<Record<string, PackDownloadState>>({});
|
||||
const availableUpdates = ref<Record<string, { installedVersion: string; latestVersion: string }>>({});
|
||||
|
||||
// Tracks which installed pack manifests have been loaded into fighting-characters.ts
|
||||
const loadedManifestIds = new Set<string>();
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Asks the NodeCG extension to read the local manifest.json for an installed
|
||||
* pack and registers the characters in fighting-characters.ts.
|
||||
*/
|
||||
const loadInstalledManifest = (packId: string): void => {
|
||||
if (loadedManifestIds.has(packId)) return;
|
||||
|
||||
nodecg.sendMessage('readLocalManifest', packId, (err, result) => {
|
||||
if (err) {
|
||||
console.error(`[usePackRegistry] Failed to load manifest for "${packId}":`, err);
|
||||
return;
|
||||
}
|
||||
const manifest = result as PackManifest;
|
||||
registerInstalledPack(manifest);
|
||||
loadedManifestIds.add(packId);
|
||||
});
|
||||
};
|
||||
|
||||
// ── Replicant setup (runs once) ───────────────────────────────────────────────
|
||||
|
||||
const initReplicants = (): void => {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const registryRep = NodeCG.Replicant<PackRegistry | null>('packRegistry', BUNDLE_NAME, {
|
||||
defaultValue: null,
|
||||
});
|
||||
const installedRep = NodeCG.Replicant<string[]>('installedPacks', BUNDLE_NAME, {
|
||||
defaultValue: [],
|
||||
});
|
||||
const statesRep = NodeCG.Replicant<Record<string, PackDownloadState>>('downloadStates', BUNDLE_NAME, {
|
||||
defaultValue: {},
|
||||
});
|
||||
|
||||
const updatesRep = NodeCG.Replicant<Record<string, { installedVersion: string; latestVersion: string }>>('availableUpdates', BUNDLE_NAME, {
|
||||
defaultValue: {},
|
||||
});
|
||||
|
||||
NodeCG.waitForReplicants(registryRep, installedRep, statesRep, updatesRep).then(() => {
|
||||
// Hydrate initial values
|
||||
registry.value = registryRep.value;
|
||||
installedPackIds.value = installedRep.value ?? [];
|
||||
downloadStates.value = statesRep.value ?? {};
|
||||
availableUpdates.value = updatesRep.value ?? {};
|
||||
|
||||
// Load manifests for all installed packs
|
||||
for (const id of installedPackIds.value) {
|
||||
loadInstalledManifest(id);
|
||||
}
|
||||
|
||||
// Subscribe to changes
|
||||
registryRep.on('change', (val) => {
|
||||
registry.value = val;
|
||||
});
|
||||
|
||||
installedRep.on('change', (newVal, oldVal) => {
|
||||
const next = newVal ?? [];
|
||||
const prev = oldVal ?? [];
|
||||
installedPackIds.value = next;
|
||||
|
||||
// Load manifests for newly installed packs
|
||||
const added = next.filter((id) => !prev.includes(id));
|
||||
for (const id of added) {
|
||||
loadInstalledManifest(id);
|
||||
}
|
||||
|
||||
// Unregister packs that were removed
|
||||
const removed = prev.filter((id) => !next.includes(id));
|
||||
for (const id of removed) {
|
||||
const gameName = getGameNameById(id);
|
||||
unregisterInstalledPack(gameName);
|
||||
loadedManifestIds.delete(id);
|
||||
}
|
||||
});
|
||||
|
||||
statesRep.on('change', (val) => {
|
||||
downloadStates.value = val ?? {};
|
||||
});
|
||||
|
||||
updatesRep.on('change', (val) => {
|
||||
availableUpdates.value = val ?? {};
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a pack ID (e.g. "street-fighter-6"), returns the matching game name
|
||||
* from the current registry, or an empty string if the registry isn't loaded.
|
||||
*/
|
||||
const getGameNameById = (packId: string): string =>
|
||||
registry.value?.packs.find((p) => p.id === packId)?.name ?? '';
|
||||
|
||||
// ── Public composable ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface PackRegistryContext {
|
||||
/** Full registry fetched from Gitea (null until first fetch). */
|
||||
registry: typeof registry;
|
||||
/** IDs of packs installed on disk (bundled packs are NOT in this list). */
|
||||
installedPackIds: typeof installedPackIds;
|
||||
/** Per-pack download state. */
|
||||
downloadStates: typeof downloadStates;
|
||||
/** Checks if a game is available (bundled OR installed). */
|
||||
isGameAvailable: (gameName: string) => boolean;
|
||||
/** Returns the download state for a pack, or a default idle state. */
|
||||
getDownloadState: (packId: string) => PackDownloadState;
|
||||
/** All games from the registry, enriched with availability info. */
|
||||
allGameOptions: ReturnType<typeof buildAllGameOptions>;
|
||||
/** Tells the extension to fetch the latest registry.json from Gitea. */
|
||||
fetchRegistry: () => void;
|
||||
/** Tells the extension to download and install a pack. */
|
||||
downloadPack: (packId: string) => void;
|
||||
/** Tells the extension to uninstall a pack and delete its files. */
|
||||
uninstallPack: (packId: string) => void;
|
||||
/** Tells the extension to download and apply an update for an installed pack. */
|
||||
updatePack: (packId: string) => void;
|
||||
/** Map of packId → version info for packs that have a newer version available. */
|
||||
availableUpdates: typeof availableUpdates;
|
||||
/** Total number of packs with available updates. */
|
||||
updateCount: ComputedRef<number>;
|
||||
/** Human-readable file size. */
|
||||
formatBytes: typeof formatBytes;
|
||||
/** Returns the URL for the pack's logo served by NodeCG (installed packs only). */
|
||||
getLocalLogoUrl: (packId: string) => string;
|
||||
}
|
||||
|
||||
export const PACK_REGISTRY_KEY: InjectionKey<PackRegistryContext> = Symbol('packRegistry');
|
||||
|
||||
const buildAllGameOptions = () =>
|
||||
computed<GameSelectOption[]>(() => {
|
||||
// Registry not loaded yet — return empty list
|
||||
if (!registry.value) return [];
|
||||
|
||||
return registry.value.packs.map((entry) => ({
|
||||
label: entry.name,
|
||||
value: entry.name,
|
||||
available: installedPackIds.value.includes(entry.id),
|
||||
registryEntry: entry,
|
||||
updateInfo: availableUpdates.value[entry.id],
|
||||
}));
|
||||
});
|
||||
|
||||
export function usePackRegistry(): PackRegistryContext {
|
||||
initReplicants();
|
||||
|
||||
const allGameOptions = buildAllGameOptions();
|
||||
|
||||
const isGameAvailable = (gameName: string): boolean => {
|
||||
const entry = registry.value?.packs.find((p) => p.name === gameName);
|
||||
if (!entry) return false;
|
||||
return installedPackIds.value.includes(entry.id);
|
||||
};
|
||||
|
||||
const getDownloadState = (packId: string): PackDownloadState =>
|
||||
downloadStates.value[packId] ?? { status: 'idle', progress: 0 };
|
||||
|
||||
const getLocalLogoUrl = (packId: string): string =>
|
||||
`/packs/${packId}/logo.png`;
|
||||
|
||||
const fetchRegistry = (): void => {
|
||||
nodecg.sendMessage('fetchPackRegistry', undefined, (err) => {
|
||||
if (err) console.error('[usePackRegistry] fetchPackRegistry failed:', err);
|
||||
});
|
||||
};
|
||||
|
||||
const downloadPack = (packId: string): void => {
|
||||
nodecg.sendMessage('downloadPack', packId, (err) => {
|
||||
if (err) console.error(`[usePackRegistry] downloadPack "${packId}" failed:`, err);
|
||||
});
|
||||
};
|
||||
|
||||
const uninstallPack = (packId: string): void => {
|
||||
nodecg.sendMessage('uninstallPack', packId, (err) => {
|
||||
if (err) console.error(`[usePackRegistry] uninstallPack "${packId}" failed:`, err);
|
||||
});
|
||||
};
|
||||
|
||||
const updatePack = (packId: string): void => {
|
||||
nodecg.sendMessage('updatePack', packId, (err) => {
|
||||
if (err) console.error(`[usePackRegistry] updatePack "${packId}" failed:`, err);
|
||||
});
|
||||
};
|
||||
|
||||
const updateCount = computed(() => Object.keys(availableUpdates.value).length);
|
||||
|
||||
return {
|
||||
registry,
|
||||
installedPackIds,
|
||||
downloadStates,
|
||||
isGameAvailable,
|
||||
getDownloadState,
|
||||
allGameOptions,
|
||||
fetchRegistry,
|
||||
downloadPack,
|
||||
uninstallPack,
|
||||
updatePack,
|
||||
availableUpdates,
|
||||
updateCount,
|
||||
formatBytes,
|
||||
getLocalLogoUrl,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useHead } from '@unhead/vue';
|
||||
import { useQuasar, type QTableColumn } from 'quasar';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { getCountryLabel, getCountryOptions } from '../../../shared/countries';
|
||||
import { getCountryLabel, getCountryOptions } from '../../../shared/utils/countries';
|
||||
import type { Schemas } from '../../../types';
|
||||
import { useIntegration } from '../composables/useIntegration';
|
||||
import { locale, t } from '../i18n';
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
export const CHALLONGE_API_BASE = 'https://api.challonge.com/v2.1';
|
||||
|
||||
export type ChallongeErrorPayload = { errors?: { detail?: string }; error?: string } | null;
|
||||
|
||||
const parseJsonResponse = async (response: Response): Promise<unknown> => {
|
||||
const rawBody = await response.text();
|
||||
if (!rawBody) return null;
|
||||
try {
|
||||
return JSON.parse(rawBody) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const requestChallonge = async (path: string, token: string): Promise<unknown> => {
|
||||
const requestUrl = `${CHALLONGE_API_BASE}${path}`;
|
||||
|
||||
// ── Intento v2 (OAuth Bearer) ─────────────────────────────────────────────
|
||||
const v2Response = await fetch(requestUrl, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/vnd.api+json',
|
||||
'Authorization-Type': 'v2',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
const v2Payload = await parseJsonResponse(v2Response);
|
||||
|
||||
if (v2Response.ok) {
|
||||
return v2Payload;
|
||||
}
|
||||
|
||||
// ── Fallback v1 (API key personal pegada manualmente) ─────────────────────
|
||||
if (v2Response.status === 401) {
|
||||
const v1Response = await fetch(requestUrl, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/vnd.api+json',
|
||||
'Authorization-Type': 'v1',
|
||||
Authorization: token,
|
||||
},
|
||||
});
|
||||
const v1Payload = await parseJsonResponse(v1Response);
|
||||
|
||||
if (v1Response.ok) {
|
||||
return v1Payload;
|
||||
}
|
||||
|
||||
const v1Error = v1Payload as ChallongeErrorPayload;
|
||||
throw new Error(
|
||||
v1Error?.errors?.detail ??
|
||||
v1Error?.error ??
|
||||
`Challonge responded with ${v1Response.status} ${v1Response.statusText}`.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Otros errores v2 (4xx/5xx que no sean 401) ────────────────────────────
|
||||
const v2Error = v2Payload as ChallongeErrorPayload;
|
||||
throw new Error(
|
||||
v2Error?.errors?.detail ??
|
||||
v2Error?.error ??
|
||||
`Challonge responded with ${v2Response.status} ${v2Response.statusText}`.trim(),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
export const STARTGG_ENDPOINT = 'https://api.start.gg/gql/alpha';
|
||||
|
||||
export interface StartGGGraphQLResponse<T> {
|
||||
data?: T;
|
||||
errors?: Array<{ message?: string }>;
|
||||
}
|
||||
|
||||
export const requestStartGG = async <T>(
|
||||
query: string,
|
||||
variables: Record<string, unknown>,
|
||||
token: string,
|
||||
): Promise<T> => {
|
||||
const response = await fetch(STARTGG_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`start.gg responded with ${response.status} ${response.statusText}`.trim());
|
||||
}
|
||||
|
||||
let payload: StartGGGraphQLResponse<T>;
|
||||
try {
|
||||
payload = (await response.json()) as StartGGGraphQLResponse<T>;
|
||||
} catch {
|
||||
throw new Error('Invalid JSON response from start.gg');
|
||||
}
|
||||
|
||||
if (payload.errors?.length) {
|
||||
throw new Error(payload.errors[0]?.message ?? 'Unknown start.gg error');
|
||||
}
|
||||
|
||||
if (!payload.data) {
|
||||
throw new Error('No data returned by start.gg');
|
||||
}
|
||||
|
||||
return payload.data;
|
||||
};
|
||||
@@ -1,505 +0,0 @@
|
||||
import { nodecg } from './util/nodecg.js';
|
||||
import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js';
|
||||
|
||||
// ─── Constantes ────────────────────────────────────────────────────────────────
|
||||
|
||||
const CHALLONGE_API_BASE = 'https://api.challonge.com/v2.1';
|
||||
const CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT = 'https://api.challonge.com/oauth/authorize';
|
||||
const CHALLONGE_OAUTH_TOKEN_ENDPOINT = 'https://api.challonge.com/oauth/token';
|
||||
const CHALLONGE_OAUTH_SCOPES = [
|
||||
'me',
|
||||
'tournaments:read',
|
||||
'tournaments:write',
|
||||
'matches:read',
|
||||
'matches:write',
|
||||
'participants:read',
|
||||
'participants:write',
|
||||
].join(' ');
|
||||
const CHALLONGE_OAUTH_CALLBACK_PATH = '/challonge/callback';
|
||||
const CHALLONGE_OAUTH_DEFAULT_PORT = 34921;
|
||||
const CHALLONGE_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
|
||||
const RECENT_TOURNAMENTS_LIMIT = 20;
|
||||
|
||||
// ─── URL del proxy OAuth ───────────────────────────────────────────────────────
|
||||
// Rellena esta constante con la URL de tu Cloudflare Worker tras el deploy.
|
||||
// Formato: 'https://scoreko-oauth-proxy.TU-SUBDOMINIO.workers.dev'
|
||||
//
|
||||
// También puedes sobreescribirla en cfg/scoreko.json con "oauthProxyUrl"
|
||||
// (útil para apuntar a un entorno de staging sin recompilar).
|
||||
const OAUTH_PROXY_BASE_URL = 'https://scoreko-oauth-proxy.panver.workers.dev';
|
||||
|
||||
// ─── Tipos ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface OAuthTokenResponse {
|
||||
access_token?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface RecentTournament {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
startAt: number | null;
|
||||
endAt: number | null;
|
||||
}
|
||||
|
||||
interface ImportedPlayer {
|
||||
id: string;
|
||||
gamertag: string;
|
||||
name: string;
|
||||
team: string;
|
||||
country: string;
|
||||
twitter: string;
|
||||
}
|
||||
|
||||
// ─── Modo OAuth ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// DEV: cfg/scoreko.json tiene challongeClientId + challongeClientSecret.
|
||||
// El exchange se hace directamente contra Challonge.
|
||||
//
|
||||
// PROXY: No hay credenciales en la config local.
|
||||
// El clientId se obtiene del Worker (es público, no secreto).
|
||||
// El exchange lo hace el Worker, que guarda el clientSecret en sus env vars.
|
||||
|
||||
type OAuthMode =
|
||||
| { type: 'dev'; clientId: string; clientSecret: string; callbackPort: number }
|
||||
| { type: 'proxy'; proxyBaseUrl: string; callbackPort: number };
|
||||
|
||||
const getOAuthMode = (): OAuthMode => {
|
||||
const bundleConfig = nodecg.bundleConfig as Record<string, unknown>;
|
||||
const clientId = String(bundleConfig.challongeClientId ?? '').trim();
|
||||
const clientSecret = String(bundleConfig.challongeClientSecret ?? '').trim();
|
||||
const rawPort = Number(bundleConfig.challongeOAuthPort ?? CHALLONGE_OAUTH_DEFAULT_PORT);
|
||||
const callbackPort =
|
||||
Number.isFinite(rawPort) && rawPort > 0 ? rawPort : CHALLONGE_OAUTH_DEFAULT_PORT;
|
||||
|
||||
const proxyBaseUrl =
|
||||
String(bundleConfig.oauthProxyUrl ?? '').trim() || OAUTH_PROXY_BASE_URL;
|
||||
|
||||
if (clientId && clientSecret) {
|
||||
nodecg.log.info('[Challonge] OAuth: modo dev (credenciales locales)');
|
||||
return { type: 'dev', clientId, clientSecret, callbackPort };
|
||||
}
|
||||
|
||||
nodecg.log.info(`[Challonge] OAuth: modo proxy → ${proxyBaseUrl}`);
|
||||
return { type: 'proxy', proxyBaseUrl, callbackPort };
|
||||
};
|
||||
|
||||
// ─── Exchange de token ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Modo dev: exchange directo con Challonge usando credenciales locales */
|
||||
const exchangeCodeDirectly = async (
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
): Promise<string> => {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
const response = await fetch(CHALLONGE_OAUTH_TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
const rawBody = await response.text();
|
||||
let payload: OAuthTokenResponse;
|
||||
try {
|
||||
payload = JSON.parse(rawBody) as OAuthTokenResponse;
|
||||
} catch {
|
||||
payload = { message: rawBody };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
payload.error_description ??
|
||||
payload.error ??
|
||||
payload.message ??
|
||||
`OAuth token request failed (${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const token = String(payload.access_token ?? '').trim();
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
payload.error_description ??
|
||||
payload.error ??
|
||||
payload.message ??
|
||||
'OAuth token response did not include an access token',
|
||||
);
|
||||
}
|
||||
|
||||
return token;
|
||||
};
|
||||
|
||||
/** Modo proxy: el Worker hace el exchange; el clientSecret nunca sale del Worker */
|
||||
const exchangeCodeViaProxy = async (
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
proxyBaseUrl: string,
|
||||
): Promise<string> => {
|
||||
const response = await fetch(`${proxyBaseUrl}/oauth/challonge/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, redirectUri }),
|
||||
});
|
||||
|
||||
const rawBody = await response.text();
|
||||
let payload: { access_token?: string; error?: string };
|
||||
try {
|
||||
payload = JSON.parse(rawBody) as typeof payload;
|
||||
} catch {
|
||||
payload = { error: rawBody };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error ?? `Proxy responded with ${response.status}`);
|
||||
}
|
||||
const token = String(payload.access_token ?? '').trim();
|
||||
if (!token) throw new Error(payload.error ?? 'Proxy did not return a token');
|
||||
return token;
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback que recibe oauth-server.ts cuando llega el código de autorización.
|
||||
* Delega al modo correcto; _config no se usa porque el modo ya está determinado.
|
||||
*/
|
||||
const exchangeOAuthCodeForToken = async (
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
_config: OAuthConfig,
|
||||
): Promise<string> => {
|
||||
const mode = getOAuthMode();
|
||||
if (mode.type === 'dev') {
|
||||
return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret);
|
||||
}
|
||||
return exchangeCodeViaProxy(code, redirectUri, mode.proxyBaseUrl);
|
||||
};
|
||||
|
||||
// ─── Servidor OAuth ────────────────────────────────────────────────────────────
|
||||
|
||||
const oauthServer = createOAuthServer({
|
||||
provider: 'Challonge',
|
||||
callbackPath: CHALLONGE_OAUTH_CALLBACK_PATH,
|
||||
authorizeEndpoint: CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT,
|
||||
scope: CHALLONGE_OAUTH_SCOPES,
|
||||
sessionTtlMs: CHALLONGE_OAUTH_SESSION_TTL_MS,
|
||||
exchangeToken: exchangeOAuthCodeForToken,
|
||||
});
|
||||
|
||||
// ─── API de Challonge ──────────────────────────────────────────────────────────
|
||||
|
||||
type ChallongeErrorPayload = { errors?: { detail?: string }; error?: string } | null;
|
||||
|
||||
const parseJsonResponse = async (response: Response): Promise<unknown> => {
|
||||
const rawBody = await response.text();
|
||||
if (!rawBody) return null;
|
||||
try {
|
||||
return JSON.parse(rawBody) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const requestChallonge = async (path: string, token: string): Promise<unknown> => {
|
||||
const requestUrl = `${CHALLONGE_API_BASE}${path}`;
|
||||
|
||||
// ── Intento v2 (OAuth Bearer) ─────────────────────────────────────────────
|
||||
const v2Response = await fetch(requestUrl, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/vnd.api+json',
|
||||
'Authorization-Type': 'v2',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
const v2Payload = await parseJsonResponse(v2Response);
|
||||
|
||||
if (v2Response.ok) {
|
||||
return v2Payload;
|
||||
}
|
||||
|
||||
// ── Fallback v1 (API key personal pegada manualmente) ─────────────────────
|
||||
if (v2Response.status === 401) {
|
||||
const v1Response = await fetch(requestUrl, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/vnd.api+json',
|
||||
'Authorization-Type': 'v1',
|
||||
Authorization: token,
|
||||
},
|
||||
});
|
||||
const v1Payload = await parseJsonResponse(v1Response);
|
||||
|
||||
if (v1Response.ok) {
|
||||
return v1Payload;
|
||||
}
|
||||
|
||||
const v1Error = v1Payload as ChallongeErrorPayload;
|
||||
throw new Error(
|
||||
v1Error?.errors?.detail ??
|
||||
v1Error?.error ??
|
||||
`Challonge responded with ${v1Response.status} ${v1Response.statusText}`.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Otros errores v2 (4xx/5xx que no sean 401) ────────────────────────────
|
||||
const v2Error = v2Payload as ChallongeErrorPayload;
|
||||
throw new Error(
|
||||
v2Error?.errors?.detail ??
|
||||
v2Error?.error ??
|
||||
`Challonge responded with ${v2Response.status} ${v2Response.statusText}`.trim(),
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Parsers de respuesta ──────────────────────────────────────────────────────
|
||||
|
||||
const normalizeTournamentSlug = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return '';
|
||||
return trimmed
|
||||
.replace(/^https?:\/\/[^/]+\//i, '')
|
||||
.replace(/^tournaments\//i, '')
|
||||
.replace(/^\/+/, '');
|
||||
};
|
||||
|
||||
const getNumberProp = (payload: Record<string, unknown>, keys: string[]): number | null => {
|
||||
for (const key of keys) {
|
||||
const raw = payload[key];
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
|
||||
if (typeof raw === 'string') {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseRecentTournaments = (payload: unknown): RecentTournament[] => {
|
||||
const rows: RecentTournament[] = [];
|
||||
|
||||
const push = (candidate: Record<string, unknown>) => {
|
||||
const attributes =
|
||||
typeof candidate.attributes === 'object' && candidate.attributes !== null
|
||||
? (candidate.attributes as Record<string, unknown>)
|
||||
: candidate;
|
||||
|
||||
const id = String(candidate.id ?? attributes.id ?? attributes.tournament_id ?? '').trim();
|
||||
const name = String(attributes.name ?? attributes.full_name ?? '').trim();
|
||||
const slug = normalizeTournamentSlug(
|
||||
String(attributes.url ?? attributes.slug ?? attributes.identifier ?? id),
|
||||
);
|
||||
|
||||
if (!id || !name || !slug) return;
|
||||
|
||||
rows.push({
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
startAt: getNumberProp(attributes, ['start_at', 'started_at', 'startAt']),
|
||||
endAt: getNumberProp(attributes, ['completed_at', 'end_at', 'ended_at', 'endAt']),
|
||||
});
|
||||
};
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
for (const row of payload) {
|
||||
const wrapper = row as Record<string, unknown>;
|
||||
const tournament =
|
||||
typeof wrapper.tournament === 'object' && wrapper.tournament !== null
|
||||
? (wrapper.tournament as Record<string, unknown>)
|
||||
: wrapper;
|
||||
push(tournament);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
if (typeof payload === 'object' && payload !== null) {
|
||||
const data = (payload as Record<string, unknown>).data;
|
||||
if (Array.isArray(data)) {
|
||||
for (const row of data) {
|
||||
if (typeof row === 'object' && row !== null) {
|
||||
push(row as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
const parseImportedPlayers = (payload: unknown): ImportedPlayer[] => {
|
||||
const map = new Map<string, ImportedPlayer>();
|
||||
|
||||
const push = (candidate: Record<string, unknown>) => {
|
||||
const attributes =
|
||||
typeof candidate.attributes === 'object' && candidate.attributes !== null
|
||||
? (candidate.attributes as Record<string, unknown>)
|
||||
: candidate;
|
||||
|
||||
const id = String(
|
||||
candidate.id ?? attributes.id ?? attributes.participant_id ?? '',
|
||||
).trim();
|
||||
|
||||
const rawDisplayName = String(
|
||||
attributes.display_name ??
|
||||
attributes.name ??
|
||||
attributes.username ??
|
||||
attributes.gamer_tag ??
|
||||
'',
|
||||
).trim();
|
||||
|
||||
if (!id || !rawDisplayName) return;
|
||||
|
||||
const PIPE_PATTERN = /^(.+?)\s*\|\s*(.+)$/;
|
||||
const pipeMatch = PIPE_PATTERN.exec(rawDisplayName);
|
||||
|
||||
const teamFromName = pipeMatch ? pipeMatch[1].trim() : '';
|
||||
const gamertag = pipeMatch ? pipeMatch[2].trim() : rawDisplayName;
|
||||
const team = String(attributes.team_name ?? '').trim() || teamFromName;
|
||||
|
||||
map.set(id, {
|
||||
id,
|
||||
gamertag,
|
||||
name: '',
|
||||
team,
|
||||
country: '',
|
||||
twitter: String(attributes.twitter_handle ?? attributes.twitter ?? '').trim(),
|
||||
});
|
||||
};
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
for (const row of payload) {
|
||||
const wrapper = row as Record<string, unknown>;
|
||||
const participant =
|
||||
typeof wrapper.participant === 'object' && wrapper.participant !== null
|
||||
? (wrapper.participant as Record<string, unknown>)
|
||||
: wrapper;
|
||||
push(participant);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
if (typeof payload === 'object' && payload !== null) {
|
||||
const data = (payload as Record<string, unknown>).data;
|
||||
if (Array.isArray(data)) {
|
||||
for (const row of data) {
|
||||
if (typeof row === 'object' && row !== null) {
|
||||
push(row as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
// ─── Utilidades ────────────────────────────────────────────────────────────────
|
||||
|
||||
const getStringProp = (payload: unknown, key: string): string => {
|
||||
if (typeof payload !== 'object' || payload === null || !(key in payload)) return '';
|
||||
const value = (payload as Record<string, unknown>)[key];
|
||||
return typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
||||
};
|
||||
|
||||
const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
|
||||
if (typeof ack === 'function') ack(error, response);
|
||||
};
|
||||
|
||||
// ─── Listeners de NodeCG ───────────────────────────────────────────────────────
|
||||
|
||||
nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) => {
|
||||
const mode = getOAuthMode();
|
||||
let serverConfig: OAuthConfig;
|
||||
|
||||
if (mode.type === 'dev') {
|
||||
serverConfig = {
|
||||
clientId: mode.clientId,
|
||||
callbackPort: mode.callbackPort,
|
||||
};
|
||||
} else {
|
||||
// Modo proxy: el clientId viene del Worker (es público, no secreto)
|
||||
try {
|
||||
const res = await fetch(`${mode.proxyBaseUrl}/oauth/challonge/client-id`);
|
||||
if (!res.ok) throw new Error(`Proxy responded with ${res.status}`);
|
||||
const data = await res.json() as { clientId?: string };
|
||||
const clientId = String(data.clientId ?? '').trim();
|
||||
if (!clientId) throw new Error('Proxy did not return a clientId');
|
||||
serverConfig = { clientId, callbackPort: mode.callbackPort };
|
||||
} catch (err) {
|
||||
sendAck(
|
||||
ack,
|
||||
err instanceof Error ? err.message : 'Could not fetch OAuth config from proxy',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await oauthServer.ensureServer(serverConfig);
|
||||
} catch (err) {
|
||||
sendAck(ack, err instanceof Error ? err.message : 'Could not start the OAuth callback server');
|
||||
return;
|
||||
}
|
||||
|
||||
sendAck(ack, null, oauthServer.createSession(serverConfig));
|
||||
});
|
||||
|
||||
nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => {
|
||||
const sessionId = getStringProp(payload, 'sessionId');
|
||||
if (!sessionId) {
|
||||
sendAck(ack, 'Missing OAuth session id');
|
||||
return;
|
||||
}
|
||||
|
||||
const status = oauthServer.getSessionStatus(sessionId);
|
||||
if (!status) {
|
||||
sendAck(ack, 'OAuth session not found');
|
||||
return;
|
||||
}
|
||||
|
||||
sendAck(ack, null, status);
|
||||
});
|
||||
|
||||
nodecg.listenFor('challonge:fetchRecentTournaments', async (payload: unknown, ack) => {
|
||||
const token = getStringProp(payload, 'token');
|
||||
if (!token) {
|
||||
sendAck(ack, 'Missing Challonge API token');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await requestChallonge('/tournaments.json', token);
|
||||
const tournaments = parseRecentTournaments(raw)
|
||||
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
|
||||
.slice(0, RECENT_TOURNAMENTS_LIMIT);
|
||||
sendAck(ack, null, tournaments);
|
||||
} catch (error) {
|
||||
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while loading tournaments');
|
||||
}
|
||||
});
|
||||
|
||||
nodecg.listenFor('challonge:fetchTournamentPlayers', async (payload: unknown, ack) => {
|
||||
const token = getStringProp(payload, 'token');
|
||||
const slug = normalizeTournamentSlug(getStringProp(payload, 'slug'));
|
||||
|
||||
if (!token) { sendAck(ack, 'Missing Challonge API token'); return; }
|
||||
if (!slug) { sendAck(ack, 'Missing tournament slug'); return; }
|
||||
|
||||
try {
|
||||
const raw = await requestChallonge(
|
||||
`/tournaments/${encodeURIComponent(slug)}/participants.json`,
|
||||
token,
|
||||
);
|
||||
sendAck(ack, null, parseImportedPlayers(raw));
|
||||
} catch (error) {
|
||||
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players');
|
||||
}
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export default async (nodecg: NodeCGServerAPI) => {
|
||||
set(nodecg); // set nodecg "context" before anything else
|
||||
await import('./util/replicants.js'); // make sure replicants are set up
|
||||
await import('./example.js');
|
||||
await import('./startgg.js');
|
||||
await import('./challonge.js');
|
||||
await import('./nodecg-bindings/startgg.js');
|
||||
await import('./nodecg-bindings/challonge.js');
|
||||
await import('./pack-manager.js');
|
||||
};
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { nodecg } from '../util/nodecg.js';
|
||||
import { getStringProp, normalizeTournamentSlug } from '../../shared/utils/string.js';
|
||||
import { challongeOAuthServer, getOAuthMode } from '../oauth/challonge.js';
|
||||
import { fetchRecentTournaments, fetchTournamentPlayers } from '../services/challonge.js';
|
||||
import type { OAuthConfig } from '../util/oauth-server.js';
|
||||
|
||||
const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
|
||||
if (typeof ack === 'function') ack(error, response);
|
||||
};
|
||||
|
||||
nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) => {
|
||||
const mode = getOAuthMode();
|
||||
let serverConfig: OAuthConfig;
|
||||
|
||||
if (mode.type === 'dev') {
|
||||
serverConfig = {
|
||||
clientId: mode.clientId,
|
||||
callbackPort: mode.callbackPort,
|
||||
};
|
||||
} else {
|
||||
try {
|
||||
const res = await fetch(`${mode.proxyBaseUrl}/oauth/challonge/client-id`);
|
||||
if (!res.ok) throw new Error(`Proxy responded with ${res.status}`);
|
||||
const data = await res.json() as { clientId?: string };
|
||||
const clientId = String(data.clientId ?? '').trim();
|
||||
if (!clientId) throw new Error('Proxy did not return a clientId');
|
||||
serverConfig = { clientId, callbackPort: mode.callbackPort };
|
||||
} catch (err) {
|
||||
sendAck(
|
||||
ack,
|
||||
err instanceof Error ? err.message : 'Could not fetch OAuth config from proxy',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await challongeOAuthServer.ensureServer(serverConfig);
|
||||
} catch (err) {
|
||||
sendAck(ack, err instanceof Error ? err.message : 'Could not start the OAuth callback server');
|
||||
return;
|
||||
}
|
||||
|
||||
sendAck(ack, null, challongeOAuthServer.createSession(serverConfig));
|
||||
});
|
||||
|
||||
nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => {
|
||||
const sessionId = getStringProp(payload, 'sessionId');
|
||||
if (!sessionId) {
|
||||
sendAck(ack, 'Missing OAuth session id');
|
||||
return;
|
||||
}
|
||||
|
||||
const status = challongeOAuthServer.getSessionStatus(sessionId);
|
||||
if (!status) {
|
||||
sendAck(ack, 'OAuth session not found');
|
||||
return;
|
||||
}
|
||||
|
||||
sendAck(ack, null, status);
|
||||
});
|
||||
|
||||
nodecg.listenFor('challonge:fetchRecentTournaments', async (payload: unknown, ack) => {
|
||||
const token = getStringProp(payload, 'token');
|
||||
if (!token) {
|
||||
sendAck(ack, 'Missing Challonge API token');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tournaments = await fetchRecentTournaments(token);
|
||||
sendAck(ack, null, tournaments);
|
||||
} catch (error) {
|
||||
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while loading tournaments');
|
||||
}
|
||||
});
|
||||
|
||||
nodecg.listenFor('challonge:fetchTournamentPlayers', async (payload: unknown, ack) => {
|
||||
const token = getStringProp(payload, 'token');
|
||||
const slug = normalizeTournamentSlug(getStringProp(payload, 'slug'));
|
||||
|
||||
if (!token) { sendAck(ack, 'Missing Challonge API token'); return; }
|
||||
if (!slug) { sendAck(ack, 'Missing tournament slug'); return; }
|
||||
|
||||
try {
|
||||
const players = await fetchTournamentPlayers(slug, token);
|
||||
sendAck(ack, null, players);
|
||||
} catch (error) {
|
||||
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { nodecg } from '../util/nodecg.js';
|
||||
import { getStringProp } from '../../shared/utils/string.js';
|
||||
import { startggOAuthServer, getOAuthMode } from '../oauth/startgg.js';
|
||||
import { fetchRecentTournaments, fetchTournamentPlayers } from '../services/startgg.js';
|
||||
import type { OAuthConfig } from '../util/oauth-server.js';
|
||||
|
||||
const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
|
||||
if (typeof ack === 'function') ack(error, response);
|
||||
};
|
||||
|
||||
nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => {
|
||||
const mode = getOAuthMode();
|
||||
let serverConfig: OAuthConfig;
|
||||
|
||||
if (mode.type === 'dev') {
|
||||
serverConfig = {
|
||||
clientId: mode.clientId,
|
||||
callbackPort: mode.callbackPort,
|
||||
};
|
||||
} else {
|
||||
try {
|
||||
const res = await fetch(`${mode.proxyBaseUrl}/oauth/startgg/client-id`);
|
||||
if (!res.ok) throw new Error(`Proxy responded with ${res.status}`);
|
||||
const data = await res.json() as { clientId?: string };
|
||||
const clientId = String(data.clientId ?? '').trim();
|
||||
if (!clientId) throw new Error('Proxy did not return a clientId');
|
||||
serverConfig = { clientId, callbackPort: mode.callbackPort };
|
||||
} catch (err) {
|
||||
sendAck(
|
||||
ack,
|
||||
err instanceof Error ? err.message : 'Could not fetch OAuth config from proxy',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await startggOAuthServer.ensureServer(serverConfig);
|
||||
} catch (err) {
|
||||
sendAck(ack, err instanceof Error ? err.message : 'Could not start the OAuth callback server');
|
||||
return;
|
||||
}
|
||||
|
||||
sendAck(ack, null, startggOAuthServer.createSession(serverConfig));
|
||||
});
|
||||
|
||||
nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => {
|
||||
const sessionId = getStringProp(payload, 'sessionId');
|
||||
if (!sessionId) {
|
||||
sendAck(ack, 'Missing OAuth session id');
|
||||
return;
|
||||
}
|
||||
|
||||
const status = startggOAuthServer.getSessionStatus(sessionId);
|
||||
if (!status) {
|
||||
sendAck(ack, 'OAuth session not found');
|
||||
return;
|
||||
}
|
||||
|
||||
sendAck(ack, null, status);
|
||||
});
|
||||
|
||||
nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack) => {
|
||||
const token = getStringProp(payload, 'token');
|
||||
if (!token) {
|
||||
sendAck(ack, 'Missing start.gg API token');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tournaments = await fetchRecentTournaments(token);
|
||||
sendAck(ack, null, tournaments);
|
||||
} catch (error) {
|
||||
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while loading tournaments');
|
||||
}
|
||||
});
|
||||
|
||||
nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack) => {
|
||||
const token = getStringProp(payload, 'token');
|
||||
const slug = getStringProp(payload, 'slug');
|
||||
|
||||
if (!token) { sendAck(ack, 'Missing start.gg API token'); return; }
|
||||
if (!slug) { sendAck(ack, 'Missing tournament slug'); return; }
|
||||
|
||||
try {
|
||||
const players = await fetchTournamentPlayers(slug, token);
|
||||
sendAck(ack, null, players);
|
||||
} catch (error) {
|
||||
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { nodecg } from '../util/nodecg.js';
|
||||
import { createOAuthServer, type OAuthConfig } from '../util/oauth-server.js';
|
||||
import type { OAuthMode, OAuthTokenResponse } from '../../shared/types/domain.js';
|
||||
|
||||
const CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT = 'https://api.challonge.com/oauth/authorize';
|
||||
const CHALLONGE_OAUTH_TOKEN_ENDPOINT = 'https://api.challonge.com/oauth/token';
|
||||
const CHALLONGE_OAUTH_SCOPES = [
|
||||
'me',
|
||||
'tournaments:read',
|
||||
'tournaments:write',
|
||||
'matches:read',
|
||||
'matches:write',
|
||||
'participants:read',
|
||||
'participants:write',
|
||||
].join(' ');
|
||||
export const CHALLONGE_OAUTH_CALLBACK_PATH = '/challonge/callback';
|
||||
const CHALLONGE_OAUTH_DEFAULT_PORT = 34921;
|
||||
const CHALLONGE_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
|
||||
const OAUTH_PROXY_BASE_URL = 'https://scoreko-oauth-proxy.panver.workers.dev';
|
||||
|
||||
export const getOAuthMode = (): OAuthMode => {
|
||||
const bundleConfig = nodecg.bundleConfig as Record<string, unknown>;
|
||||
const clientId = String(bundleConfig.challongeClientId ?? '').trim();
|
||||
const clientSecret = String(bundleConfig.challongeClientSecret ?? '').trim();
|
||||
const rawPort = Number(bundleConfig.challongeOAuthPort ?? CHALLONGE_OAUTH_DEFAULT_PORT);
|
||||
const callbackPort =
|
||||
Number.isFinite(rawPort) && rawPort > 0 ? rawPort : CHALLONGE_OAUTH_DEFAULT_PORT;
|
||||
|
||||
const proxyBaseUrl =
|
||||
String(bundleConfig.oauthProxyUrl ?? '').trim() || OAUTH_PROXY_BASE_URL;
|
||||
|
||||
if (clientId && clientSecret) {
|
||||
nodecg.log.info('[Challonge] OAuth: modo dev (credenciales locales)');
|
||||
return { type: 'dev', clientId, clientSecret, callbackPort };
|
||||
}
|
||||
|
||||
nodecg.log.info(`[Challonge] OAuth: modo proxy → ${proxyBaseUrl}`);
|
||||
return { type: 'proxy', proxyBaseUrl, callbackPort };
|
||||
};
|
||||
|
||||
const exchangeCodeDirectly = async (
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
): Promise<string> => {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
const response = await fetch(CHALLONGE_OAUTH_TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
const rawBody = await response.text();
|
||||
let payload: OAuthTokenResponse;
|
||||
try {
|
||||
payload = JSON.parse(rawBody) as OAuthTokenResponse;
|
||||
} catch {
|
||||
payload = { message: rawBody };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
payload.error_description ??
|
||||
payload.error ??
|
||||
payload.message ??
|
||||
`OAuth token request failed (${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const token = String(payload.access_token ?? '').trim();
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
payload.error_description ??
|
||||
payload.error ??
|
||||
payload.message ??
|
||||
'OAuth token response did not include an access token',
|
||||
);
|
||||
}
|
||||
|
||||
return token;
|
||||
};
|
||||
|
||||
const exchangeCodeViaProxy = async (
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
proxyBaseUrl: string,
|
||||
): Promise<string> => {
|
||||
const response = await fetch(`${proxyBaseUrl}/oauth/challonge/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, redirectUri }),
|
||||
});
|
||||
|
||||
const rawBody = await response.text();
|
||||
let payload: { access_token?: string; error?: string };
|
||||
try {
|
||||
payload = JSON.parse(rawBody) as typeof payload;
|
||||
} catch {
|
||||
payload = { error: rawBody };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error ?? `Proxy responded with ${response.status}`);
|
||||
}
|
||||
const token = String(payload.access_token ?? '').trim();
|
||||
if (!token) throw new Error(payload.error ?? 'Proxy did not return a token');
|
||||
return token;
|
||||
};
|
||||
|
||||
const exchangeOAuthCodeForToken = async (
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
_config: OAuthConfig,
|
||||
): Promise<string> => {
|
||||
const mode = getOAuthMode();
|
||||
if (mode.type === 'dev') {
|
||||
return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret);
|
||||
}
|
||||
return exchangeCodeViaProxy(code, redirectUri, mode.proxyBaseUrl);
|
||||
};
|
||||
|
||||
export const challongeOAuthServer = createOAuthServer({
|
||||
provider: 'Challonge',
|
||||
callbackPath: CHALLONGE_OAUTH_CALLBACK_PATH,
|
||||
authorizeEndpoint: CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT,
|
||||
scope: CHALLONGE_OAUTH_SCOPES,
|
||||
sessionTtlMs: CHALLONGE_OAUTH_SESSION_TTL_MS,
|
||||
exchangeToken: exchangeOAuthCodeForToken,
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { nodecg } from '../util/nodecg.js';
|
||||
import { createOAuthServer, type OAuthConfig } from '../util/oauth-server.js';
|
||||
import type { OAuthMode, OAuthTokenResponse } from '../../shared/types/domain.js';
|
||||
|
||||
const STARTGG_OAUTH_AUTHORIZE_ENDPOINT = 'https://www.start.gg/api/-/rest/oauth/authorize';
|
||||
const STARTGG_OAUTH_TOKEN_ENDPOINTS = [
|
||||
'https://www.start.gg/api/-/rest/oauth/access_token',
|
||||
'https://api.start.gg/oauth/access_token',
|
||||
];
|
||||
const STARTGG_OAUTH_SCOPES = 'user.identity tournament.manager';
|
||||
export const STARTGG_OAUTH_CALLBACK_PATH = '/startgg/callback';
|
||||
const STARTGG_OAUTH_DEFAULT_PORT = 34920;
|
||||
const STARTGG_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
|
||||
const OAUTH_PROXY_BASE_URL = 'https://scoreko-oauth-proxy.panver.workers.dev';
|
||||
|
||||
export const getOAuthMode = (): OAuthMode => {
|
||||
const bundleConfig = nodecg.bundleConfig as Record<string, unknown>;
|
||||
const clientId = String(bundleConfig.startggClientId ?? '').trim();
|
||||
const clientSecret = String(bundleConfig.startggClientSecret ?? '').trim();
|
||||
const rawPort = Number(bundleConfig.startggOAuthPort ?? STARTGG_OAUTH_DEFAULT_PORT);
|
||||
const callbackPort =
|
||||
Number.isFinite(rawPort) && rawPort > 0 ? rawPort : STARTGG_OAUTH_DEFAULT_PORT;
|
||||
|
||||
const proxyBaseUrl =
|
||||
String(bundleConfig.oauthProxyUrl ?? '').trim() || OAUTH_PROXY_BASE_URL;
|
||||
|
||||
if (clientId && clientSecret) {
|
||||
nodecg.log.info('[start.gg] OAuth: modo dev (credenciales locales)');
|
||||
return { type: 'dev', clientId, clientSecret, callbackPort };
|
||||
}
|
||||
|
||||
nodecg.log.info(`[start.gg] OAuth: modo proxy → ${proxyBaseUrl}`);
|
||||
return { type: 'proxy', proxyBaseUrl, callbackPort };
|
||||
};
|
||||
|
||||
const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => {
|
||||
const rawBody = await response.text();
|
||||
try {
|
||||
return JSON.parse(rawBody) as OAuthTokenResponse;
|
||||
} catch {
|
||||
return { message: rawBody };
|
||||
}
|
||||
};
|
||||
|
||||
const exchangeCodeDirectly = async (
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
): Promise<string> => {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
let lastError = 'Unknown OAuth token exchange error';
|
||||
|
||||
for (const tokenEndpoint of STARTGG_OAUTH_TOKEN_ENDPOINTS) {
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
const payload = await parseOAuthTokenPayload(response);
|
||||
|
||||
if (response.ok) {
|
||||
const token = String(payload.access_token ?? '').trim();
|
||||
if (token) return token;
|
||||
lastError =
|
||||
payload.error_description ??
|
||||
payload.error ??
|
||||
payload.message ??
|
||||
'OAuth token response did not include an access token';
|
||||
continue;
|
||||
}
|
||||
|
||||
lastError =
|
||||
payload.error_description ??
|
||||
payload.error ??
|
||||
payload.message ??
|
||||
`OAuth token request failed (${response.status})`;
|
||||
|
||||
if (response.status !== 404) break;
|
||||
}
|
||||
|
||||
throw new Error(lastError);
|
||||
};
|
||||
|
||||
const exchangeCodeViaProxy = async (
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
proxyBaseUrl: string,
|
||||
): Promise<string> => {
|
||||
const response = await fetch(`${proxyBaseUrl}/oauth/startgg/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, redirectUri }),
|
||||
});
|
||||
|
||||
const rawBody = await response.text();
|
||||
let payload: { access_token?: string; error?: string };
|
||||
try {
|
||||
payload = JSON.parse(rawBody) as typeof payload;
|
||||
} catch {
|
||||
payload = { error: rawBody };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error ?? `Proxy responded with ${response.status}`);
|
||||
}
|
||||
const token = String(payload.access_token ?? '').trim();
|
||||
if (!token) throw new Error(payload.error ?? 'Proxy did not return a token');
|
||||
return token;
|
||||
};
|
||||
|
||||
const exchangeOAuthCodeForToken = async (
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
_config: OAuthConfig,
|
||||
): Promise<string> => {
|
||||
const mode = getOAuthMode();
|
||||
if (mode.type === 'dev') {
|
||||
return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret);
|
||||
}
|
||||
return exchangeCodeViaProxy(code, redirectUri, mode.proxyBaseUrl);
|
||||
};
|
||||
|
||||
export const startggOAuthServer = createOAuthServer({
|
||||
provider: 'start.gg',
|
||||
callbackPath: STARTGG_OAUTH_CALLBACK_PATH,
|
||||
authorizeEndpoint: STARTGG_OAUTH_AUTHORIZE_ENDPOINT,
|
||||
scope: STARTGG_OAUTH_SCOPES,
|
||||
sessionTtlMs: STARTGG_OAUTH_SESSION_TTL_MS,
|
||||
exchangeToken: exchangeOAuthCodeForToken,
|
||||
});
|
||||
@@ -0,0 +1,441 @@
|
||||
// src/extension/pack-manager.ts
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Módulo autocontenido: no importa nada de src/shared/ para respetar el
|
||||
// rootDir del tsconfig de la extensión. Las constantes de Gitea y los tipos
|
||||
// necesarios están definidos aquí directamente.
|
||||
//
|
||||
// Para activarlo, añade UNA línea en src/extension/index.ts:
|
||||
// await import('./pack-manager.js');
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import * as fs from 'fs';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { nodecg } from './util/nodecg.js';
|
||||
|
||||
// ── Configuración de Gitea ────────────────────────────────────────────────────
|
||||
// Edita estas constantes para apuntar a tu instancia.
|
||||
|
||||
const GITEA_BASE_URL = 'http://10.0.0.10:3002';
|
||||
const GITEA_OWNER = 'Pandipipas';
|
||||
const GITEA_REPO = 'fighting-game-packs';
|
||||
const GITEA_BRANCH = 'main';
|
||||
|
||||
const rawUrl = (repoPath: string) =>
|
||||
`${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${repoPath}`;
|
||||
|
||||
const REGISTRY_URL = rawUrl('registry.json');
|
||||
const getManifestUrl = (id: string) => rawUrl(`${id}/manifest.json`);
|
||||
const getPackLogoUrl = (id: string) => rawUrl(`${id}/logo.png`);
|
||||
const getCharacterImageRepoUrl = (id: string, slug: string, ext: string) =>
|
||||
rawUrl(`${id}/characters/${slug}.${ext}`);
|
||||
|
||||
// ── Tipos locales ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface PackCharacter {
|
||||
name: string;
|
||||
slug: string;
|
||||
dlc?: boolean;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
interface PackManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
palette: { start: string; end: string };
|
||||
defaultPair?: { left: string; right: string };
|
||||
characters: PackCharacter[];
|
||||
}
|
||||
|
||||
interface PackRegistry {
|
||||
schemaVersion: number;
|
||||
updatedAt: string;
|
||||
packs: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
totalSizeBytes: number;
|
||||
logoPath: string;
|
||||
characterCount: number;
|
||||
palette: { start: string; end: string };
|
||||
bundled: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface PackDownloadState {
|
||||
status: 'idle' | 'fetching-manifest' | 'downloading' | 'done' | 'error';
|
||||
progress: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Replicamos la forma exacta del tipo Acknowledgement de NodeCG sin necesidad
|
||||
// de importar @nodecg/types. HandledAcknowledgement NO es callable (es un objeto),
|
||||
// UnhandledAcknowledgement SÍ lo es. El helper reply() comprueba cuál es antes de llamar.
|
||||
type HandledAcknowledgement = { handled: true };
|
||||
type UnhandledAcknowledgement = ((error?: Error | null, ...args: unknown[]) => void) & { handled: false };
|
||||
type Acknowledgement = HandledAcknowledgement | UnhandledAcknowledgement;
|
||||
|
||||
const reply = (ack: Acknowledgement | undefined, err: Error | null, result?: unknown): void => {
|
||||
if (ack && !ack.handled) ack(err ?? undefined, result);
|
||||
};
|
||||
|
||||
// ── Constantes ────────────────────────────────────────────────────────────────
|
||||
|
||||
const IMAGE_EXTENSIONS = ['png', 'webp', 'jpg', 'jpeg', 'avif'] as const;
|
||||
|
||||
|
||||
// Raíz del proyecto: 2 niveles por encima de extension/pack-manager.js
|
||||
// Usamos import.meta.url porque nodecg.bundleDir no está disponible cuando
|
||||
// NodeCG se usa como dependencia en lugar de servidor standalone.
|
||||
const bundleDir = fileURLToPath(new URL('../', import.meta.url));
|
||||
|
||||
// ── Replicants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const installedPacksRep = nodecg.Replicant<string[]>('installedPacks', {
|
||||
defaultValue: [],
|
||||
persistent: true,
|
||||
});
|
||||
|
||||
const packRegistryRep = nodecg.Replicant<PackRegistry | null>('packRegistry', {
|
||||
defaultValue: null,
|
||||
persistent: true,
|
||||
});
|
||||
|
||||
const downloadStatesRep = nodecg.Replicant<Record<string, PackDownloadState>>('downloadStates', {
|
||||
defaultValue: {},
|
||||
persistent: false,
|
||||
});
|
||||
|
||||
/** Packs instalados para los que hay una versión más nueva en el registro. */
|
||||
const availableUpdatesRep = nodecg.Replicant<Record<string, { installedVersion: string; latestVersion: string }>>('availableUpdates', {
|
||||
defaultValue: {},
|
||||
persistent: false,
|
||||
});
|
||||
|
||||
// ── Filesystem ────────────────────────────────────────────────────────────────
|
||||
|
||||
const packsDir = path.join(bundleDir, 'packs');
|
||||
fs.mkdirSync(packsDir, { recursive: true });
|
||||
nodecg.log.info(`[pack-manager] Packs directory: ${packsDir}`);
|
||||
|
||||
// Registrar el directorio de packs como ruta estática usando nodecg.mount().
|
||||
// Las imágenes quedan accesibles en /packs/<packId>/characters/<slug>.png
|
||||
// independientemente de cómo NodeCG configure el resto de rutas del bundle.
|
||||
const packsMiddleware = (req: IncomingMessage, res: ServerResponse) => {
|
||||
const urlPath = decodeURIComponent(req.url ?? '/');
|
||||
const safe = path.normalize(urlPath).replace(/^(\.\.[/\\])+/, '');
|
||||
const file = path.join(packsDir, safe);
|
||||
|
||||
// Security: only serve files inside packsDir
|
||||
if (!file.startsWith(packsDir)) {
|
||||
res.writeHead(403);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
fs.stat(file, (statErr, stat) => {
|
||||
if (statErr || !stat.isFile()) {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.json': 'application/json',
|
||||
};
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
res.setHeader('Content-Type', mimeTypes[ext] ?? 'application/octet-stream');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
fs.createReadStream(file).pipe(res);
|
||||
});
|
||||
};
|
||||
|
||||
// nodecg.mount registra el middleware en el servidor Express de NodeCG
|
||||
(nodecg as unknown as { mount: (p: string, h: typeof packsMiddleware) => void })
|
||||
.mount('/packs', packsMiddleware);
|
||||
|
||||
// Verificación de integridad al arrancar
|
||||
const installedAtStart = installedPacksRep.value ?? [];
|
||||
const verified = installedAtStart.filter((id) =>
|
||||
fs.existsSync(path.join(packsDir, id, 'manifest.json')),
|
||||
);
|
||||
if (verified.length !== installedAtStart.length) {
|
||||
nodecg.log.warn('[pack-manager] Algunos packs instalados no estaban en disco y se han eliminado del registro.');
|
||||
installedPacksRep.value = verified;
|
||||
}
|
||||
|
||||
// ── Helpers internos ──────────────────────────────────────────────────────────
|
||||
|
||||
const setDownloadState = (packId: string, patch: Partial<PackDownloadState>): void => {
|
||||
const current = downloadStatesRep.value?.[packId] ?? { status: 'idle', progress: 0 };
|
||||
downloadStatesRep.value = {
|
||||
...downloadStatesRep.value,
|
||||
[packId]: { ...current, ...patch },
|
||||
};
|
||||
};
|
||||
|
||||
const fetchBuffer = async (url: string): Promise<Buffer> => {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status} — ${url}`);
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
};
|
||||
|
||||
const trySaveImage = async (
|
||||
destDir: string,
|
||||
filename: string,
|
||||
extensions: readonly string[],
|
||||
buildUrl: (ext: string) => string,
|
||||
): Promise<boolean> => {
|
||||
for (const ext of extensions) {
|
||||
try {
|
||||
const buffer = await fetchBuffer(buildUrl(ext));
|
||||
// Siempre guardamos como .png para que la URL del dashboard sea predecible.
|
||||
// Los navegadores modernos identifican el formato por el contenido (magic bytes),
|
||||
// no por la extensión, así que WebP/AVIF/JPEG se renderizan correctamente.
|
||||
fs.writeFileSync(path.join(destDir, `${filename}.png`), buffer);
|
||||
return true;
|
||||
} catch { /* prueba siguiente extensión */ }
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// ── Detección de actualizaciones ─────────────────────────────────────────────
|
||||
// Compara la versión en el manifest.json local de cada pack instalado contra
|
||||
// la versión en el registro de Gitea. Solo aplica a packs descargados (no bundled).
|
||||
|
||||
const checkForUpdates = (): void => {
|
||||
const registry = packRegistryRep.value;
|
||||
const installed = installedPacksRep.value ?? [];
|
||||
|
||||
if (!registry || installed.length === 0) {
|
||||
availableUpdatesRep.value = {};
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: Record<string, { installedVersion: string; latestVersion: string }> = {};
|
||||
|
||||
for (const packId of installed) {
|
||||
const registryEntry = registry.packs.find((p) => p.id === packId);
|
||||
if (!registryEntry) continue;
|
||||
|
||||
const manifestPath = path.join(packsDir, packId, 'manifest.json');
|
||||
try {
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as PackManifest;
|
||||
if (manifest.version !== registryEntry.version) {
|
||||
updates[packId] = {
|
||||
installedVersion: manifest.version,
|
||||
latestVersion: registryEntry.version,
|
||||
};
|
||||
nodecg.log.info(
|
||||
`[pack-manager] Actualización disponible para "${packId}": ${manifest.version} → ${registryEntry.version}`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Manifest ilegible — ignorar este pack
|
||||
}
|
||||
}
|
||||
|
||||
availableUpdatesRep.value = updates;
|
||||
};
|
||||
|
||||
// Comprobar al arrancar si ya hay un registro cacheado
|
||||
checkForUpdates();
|
||||
|
||||
// ── Mensaje: fetchPackRegistry ────────────────────────────────────────────────
|
||||
|
||||
nodecg.listenFor('fetchPackRegistry', async (_data: unknown, ack: Acknowledgement | undefined) => {
|
||||
try {
|
||||
const response = await fetch(REGISTRY_URL);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const registry = await response.json() as PackRegistry;
|
||||
packRegistryRep.value = registry;
|
||||
checkForUpdates(); // re-evaluar actualizaciones con el registro nuevo
|
||||
reply(ack, null, registry);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
nodecg.log.error(`[pack-manager] Error al obtener el registro: ${message}`);
|
||||
reply(ack, new Error(message));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Mensaje: downloadPack ─────────────────────────────────────────────────────
|
||||
|
||||
nodecg.listenFor('downloadPack', async (packId: unknown, ack: Acknowledgement | undefined) => {
|
||||
if (typeof packId !== 'string' || !packId) {
|
||||
return reply(ack, new Error('downloadPack requiere un packId no vacío.'));
|
||||
}
|
||||
if (installedPacksRep.value?.includes(packId)) {
|
||||
return reply(ack, null, { alreadyInstalled: true });
|
||||
}
|
||||
if (downloadStatesRep.value?.[packId]?.status === 'downloading') {
|
||||
return reply(ack, new Error(`El pack "${packId}" ya se está descargando.`));
|
||||
}
|
||||
|
||||
setDownloadState(packId, { status: 'fetching-manifest', progress: 0, error: undefined });
|
||||
|
||||
try {
|
||||
const manifestRes = await fetch(getManifestUrl(packId));
|
||||
if (!manifestRes.ok) throw new Error(`No se puede obtener el manifest: HTTP ${manifestRes.status}`);
|
||||
const manifest = await manifestRes.json() as PackManifest;
|
||||
|
||||
const packDir = path.join(packsDir, packId);
|
||||
const charsDir = path.join(packDir, 'characters');
|
||||
fs.mkdirSync(charsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(packDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||
|
||||
setDownloadState(packId, { status: 'downloading', progress: 2 });
|
||||
try {
|
||||
const logoBuffer = await fetchBuffer(getPackLogoUrl(packId));
|
||||
fs.writeFileSync(path.join(packDir, 'logo.png'), logoBuffer);
|
||||
} catch {
|
||||
nodecg.log.warn(`[pack-manager] No se encontró logo para "${packId}" — se omite.`);
|
||||
}
|
||||
|
||||
const total = manifest.characters.length;
|
||||
for (let i = 0; i < total; i++) {
|
||||
const char = manifest.characters[i]!;
|
||||
const saved = await trySaveImage(
|
||||
charsDir,
|
||||
char.slug,
|
||||
IMAGE_EXTENSIONS,
|
||||
(ext) => getCharacterImageRepoUrl(packId, char.slug, ext),
|
||||
);
|
||||
if (!saved) {
|
||||
nodecg.log.warn(`[pack-manager] Sin imagen para "${packId}/${char.slug}" — se usará placeholder.`);
|
||||
}
|
||||
setDownloadState(packId, { progress: 5 + Math.round(((i + 1) / total) * 93) });
|
||||
}
|
||||
|
||||
const current = installedPacksRep.value ?? [];
|
||||
if (!current.includes(packId)) installedPacksRep.value = [...current, packId];
|
||||
|
||||
setDownloadState(packId, { status: 'done', progress: 100 });
|
||||
reply(ack, null, { packId, characterCount: manifest.characters.length });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
nodecg.log.error(`[pack-manager] Error al descargar "${packId}": ${message}`);
|
||||
setDownloadState(packId, { status: 'error', error: message });
|
||||
reply(ack, new Error(message));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Mensaje: uninstallPack ────────────────────────────────────────────────────
|
||||
|
||||
nodecg.listenFor('uninstallPack', (packId: unknown, ack: Acknowledgement | undefined) => {
|
||||
if (typeof packId !== 'string' || !packId) {
|
||||
return reply(ack, new Error('uninstallPack requiere un packId no vacío.'));
|
||||
}
|
||||
try {
|
||||
fs.rmSync(path.join(packsDir, packId), { recursive: true, force: true });
|
||||
installedPacksRep.value = (installedPacksRep.value ?? []).filter((id) => id !== packId);
|
||||
const states = { ...downloadStatesRep.value };
|
||||
delete states[packId];
|
||||
downloadStatesRep.value = states;
|
||||
const updates = { ...availableUpdatesRep.value };
|
||||
delete updates[packId];
|
||||
availableUpdatesRep.value = updates;
|
||||
reply(ack, null);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
nodecg.log.error(`[pack-manager] Error al desinstalar "${packId}": ${message}`);
|
||||
reply(ack, new Error(message));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Mensaje: updatePack ──────────────────────────────────────────────────────
|
||||
// Dashboard → Extension: "Actualiza el pack <packId> a la última versión."
|
||||
// Borra las imágenes antiguas y descarga las nuevas desde Gitea.
|
||||
|
||||
nodecg.listenFor('updatePack', async (packId: unknown, ack: Acknowledgement | undefined) => {
|
||||
if (typeof packId !== 'string' || !packId) {
|
||||
return reply(ack, new Error('updatePack requiere un packId no vacío.'));
|
||||
}
|
||||
if (!installedPacksRep.value?.includes(packId)) {
|
||||
return reply(ack, new Error(`El pack "${packId}" no está instalado. Usa downloadPack primero.`));
|
||||
}
|
||||
if (downloadStatesRep.value?.[packId]?.status === 'downloading') {
|
||||
return reply(ack, new Error(`El pack "${packId}" ya se está actualizando.`));
|
||||
}
|
||||
|
||||
setDownloadState(packId, { status: 'fetching-manifest', progress: 0, error: undefined });
|
||||
|
||||
try {
|
||||
// 1. Obtener nuevo manifest
|
||||
const manifestRes = await fetch(getManifestUrl(packId));
|
||||
if (!manifestRes.ok) throw new Error(`No se puede obtener el manifest: HTTP ${manifestRes.status}`);
|
||||
const manifest = await manifestRes.json() as PackManifest;
|
||||
|
||||
const packDir = path.join(packsDir, packId);
|
||||
const charsDir = path.join(packDir, 'characters');
|
||||
|
||||
// 2. Limpiar imágenes antiguas para evitar residuos de personajes renombrados
|
||||
if (fs.existsSync(charsDir)) {
|
||||
fs.rmSync(charsDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(charsDir, { recursive: true });
|
||||
|
||||
// 3. Guardar nuevo manifest en disco
|
||||
fs.writeFileSync(path.join(packDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||
|
||||
// 4. Logo
|
||||
setDownloadState(packId, { status: 'downloading', progress: 2 });
|
||||
try {
|
||||
const logoBuffer = await fetchBuffer(getPackLogoUrl(packId));
|
||||
fs.writeFileSync(path.join(packDir, 'logo.png'), logoBuffer);
|
||||
} catch {
|
||||
nodecg.log.warn(`[pack-manager] No se encontró logo para "${packId}" — se omite.`);
|
||||
}
|
||||
|
||||
// 5. Imágenes de personajes
|
||||
const total = manifest.characters.length;
|
||||
for (let i = 0; i < total; i++) {
|
||||
const char = manifest.characters[i]!;
|
||||
const saved = await trySaveImage(
|
||||
charsDir,
|
||||
char.slug,
|
||||
IMAGE_EXTENSIONS,
|
||||
(ext) => getCharacterImageRepoUrl(packId, char.slug, ext),
|
||||
);
|
||||
if (!saved) {
|
||||
nodecg.log.warn(`[pack-manager] Sin imagen para "${packId}/${char.slug}" — se usará placeholder.`);
|
||||
}
|
||||
setDownloadState(packId, { progress: 5 + Math.round(((i + 1) / total) * 93) });
|
||||
}
|
||||
|
||||
// 6. Quitar de availableUpdates
|
||||
const updates = { ...availableUpdatesRep.value };
|
||||
delete updates[packId];
|
||||
availableUpdatesRep.value = updates;
|
||||
|
||||
setDownloadState(packId, { status: 'done', progress: 100 });
|
||||
nodecg.log.info(`[pack-manager] Pack "${packId}" actualizado a v${manifest.version}.`);
|
||||
reply(ack, null, { packId, version: manifest.version });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
nodecg.log.error(`[pack-manager] Error al actualizar "${packId}": ${message}`);
|
||||
setDownloadState(packId, { status: 'error', error: message });
|
||||
reply(ack, new Error(message));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Mensaje: readLocalManifest ────────────────────────────────────────────────
|
||||
|
||||
nodecg.listenFor('readLocalManifest', (packId: unknown, ack: Acknowledgement | undefined) => {
|
||||
if (typeof packId !== 'string' || !packId) {
|
||||
return reply(ack, new Error('readLocalManifest requiere un packId no vacío.'));
|
||||
}
|
||||
const manifestPath = path.join(packsDir, packId, 'manifest.json');
|
||||
try {
|
||||
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
||||
reply(ack, null, JSON.parse(raw) as PackManifest);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
reply(ack, new Error(`No se puede leer el manifest de "${packId}": ${message}`));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { requestChallonge } from '../api/challonge.js';
|
||||
import { normalizeTournamentSlug, getNumberProp } from '../../shared/utils/string.js';
|
||||
import type { RecentTournament, ImportedPlayer } from '../../shared/types/domain.js';
|
||||
|
||||
const RECENT_TOURNAMENTS_LIMIT = 20;
|
||||
|
||||
export const parseRecentTournaments = (payload: unknown): RecentTournament[] => {
|
||||
const rows: RecentTournament[] = [];
|
||||
|
||||
const push = (candidate: Record<string, unknown>) => {
|
||||
const attributes =
|
||||
typeof candidate.attributes === 'object' && candidate.attributes !== null
|
||||
? (candidate.attributes as Record<string, unknown>)
|
||||
: candidate;
|
||||
|
||||
const id = String(candidate.id ?? attributes.id ?? attributes.tournament_id ?? '').trim();
|
||||
const name = String(attributes.name ?? attributes.full_name ?? '').trim();
|
||||
const slug = normalizeTournamentSlug(
|
||||
String(attributes.url ?? attributes.slug ?? attributes.identifier ?? id),
|
||||
);
|
||||
|
||||
if (!id || !name || !slug) return;
|
||||
|
||||
rows.push({
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
startAt: getNumberProp(attributes, ['start_at', 'started_at', 'startAt']),
|
||||
endAt: getNumberProp(attributes, ['completed_at', 'end_at', 'ended_at', 'endAt']),
|
||||
});
|
||||
};
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
for (const row of payload) {
|
||||
const wrapper = row as Record<string, unknown>;
|
||||
const tournament =
|
||||
typeof wrapper.tournament === 'object' && wrapper.tournament !== null
|
||||
? (wrapper.tournament as Record<string, unknown>)
|
||||
: wrapper;
|
||||
push(tournament);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
if (typeof payload === 'object' && payload !== null) {
|
||||
const data = (payload as Record<string, unknown>).data;
|
||||
if (Array.isArray(data)) {
|
||||
for (const row of data) {
|
||||
if (typeof row === 'object' && row !== null) {
|
||||
push(row as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
export const parseImportedPlayers = (payload: unknown): ImportedPlayer[] => {
|
||||
const map = new Map<string, ImportedPlayer>();
|
||||
|
||||
const push = (candidate: Record<string, unknown>) => {
|
||||
const attributes =
|
||||
typeof candidate.attributes === 'object' && candidate.attributes !== null
|
||||
? (candidate.attributes as Record<string, unknown>)
|
||||
: candidate;
|
||||
|
||||
const id = String(
|
||||
candidate.id ?? attributes.id ?? attributes.participant_id ?? '',
|
||||
).trim();
|
||||
|
||||
const rawDisplayName = String(
|
||||
attributes.display_name ??
|
||||
attributes.name ??
|
||||
attributes.username ??
|
||||
attributes.gamer_tag ??
|
||||
'',
|
||||
).trim();
|
||||
|
||||
if (!id || !rawDisplayName) return;
|
||||
|
||||
const PIPE_PATTERN = /^(.+?)\s*\|\s*(.+)$/;
|
||||
const pipeMatch = PIPE_PATTERN.exec(rawDisplayName);
|
||||
|
||||
const teamFromName = pipeMatch ? pipeMatch[1].trim() : '';
|
||||
const gamertag = pipeMatch ? pipeMatch[2].trim() : rawDisplayName;
|
||||
const team = String(attributes.team_name ?? '').trim() || teamFromName;
|
||||
|
||||
map.set(id, {
|
||||
id,
|
||||
gamertag,
|
||||
name: '',
|
||||
team,
|
||||
country: '',
|
||||
twitter: String(attributes.twitter_handle ?? attributes.twitter ?? '').trim(),
|
||||
});
|
||||
};
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
for (const row of payload) {
|
||||
const wrapper = row as Record<string, unknown>;
|
||||
const participant =
|
||||
typeof wrapper.participant === 'object' && wrapper.participant !== null
|
||||
? (wrapper.participant as Record<string, unknown>)
|
||||
: wrapper;
|
||||
push(participant);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
if (typeof payload === 'object' && payload !== null) {
|
||||
const data = (payload as Record<string, unknown>).data;
|
||||
if (Array.isArray(data)) {
|
||||
for (const row of data) {
|
||||
if (typeof row === 'object' && row !== null) {
|
||||
push(row as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
export const fetchRecentTournaments = async (token: string): Promise<RecentTournament[]> => {
|
||||
const raw = await requestChallonge('/tournaments.json', token);
|
||||
return parseRecentTournaments(raw)
|
||||
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
|
||||
.slice(0, RECENT_TOURNAMENTS_LIMIT);
|
||||
};
|
||||
|
||||
export const fetchTournamentPlayers = async (slug: string, token: string): Promise<ImportedPlayer[]> => {
|
||||
const raw = await requestChallonge(
|
||||
`/tournaments/${encodeURIComponent(slug)}/participants.json`,
|
||||
token,
|
||||
);
|
||||
return parseImportedPlayers(raw);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { getData, type CountryRecord } from 'country-list';
|
||||
import { requestStartGG } from '../api/startgg.js';
|
||||
import type { RecentTournament, ImportedPlayer } from '../../shared/types/domain.js';
|
||||
|
||||
const RECENT_TOURNAMENTS_LIMIT = 12;
|
||||
const PARTICIPANTS_PAGE_SIZE = 120;
|
||||
|
||||
const countries = getData();
|
||||
const countryByCode = new Set(countries.map((c: CountryRecord) => c.code.toUpperCase()));
|
||||
const countryByName = new Map(
|
||||
countries.map((c: CountryRecord) => [c.name.toLowerCase(), c.code.toUpperCase()]),
|
||||
);
|
||||
|
||||
export const resolveCountryCodeFromStartGG = (country: string | null | undefined): string => {
|
||||
const raw = (country ?? '').trim();
|
||||
if (!raw) return '';
|
||||
const upper = raw.toUpperCase();
|
||||
if (countryByCode.has(upper)) return upper;
|
||||
return countryByName.get(raw.toLowerCase()) ?? '';
|
||||
};
|
||||
|
||||
export const fetchRecentTournaments = async (token: string): Promise<RecentTournament[]> => {
|
||||
const query = `
|
||||
query RecentTournaments($perPage: Int!) {
|
||||
currentUser {
|
||||
tournaments(query: { perPage: $perPage, filter: { tournamentView: "admin" } }) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
slug
|
||||
startAt
|
||||
endAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await requestStartGG<{
|
||||
currentUser: { tournaments: { nodes: RecentTournament[] } } | null;
|
||||
}>(query, { perPage: RECENT_TOURNAMENTS_LIMIT }, token);
|
||||
|
||||
return data.currentUser?.tournaments.nodes
|
||||
.filter((item) => item.slug)
|
||||
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
|
||||
.map(({ id, name, slug, startAt, endAt }) => ({ id: String(id), name, slug, startAt, endAt })) ?? [];
|
||||
};
|
||||
|
||||
export const fetchTournamentPlayers = async (slug: string, token: string): Promise<ImportedPlayer[]> => {
|
||||
const query = `
|
||||
query TournamentParticipants($slug: String!, $page: Int!, $perPage: Int!) {
|
||||
tournament(slug: $slug) {
|
||||
participants(query: { page: $page, perPage: $perPage }) {
|
||||
pageInfo {
|
||||
totalPages
|
||||
}
|
||||
nodes {
|
||||
id
|
||||
gamerTag
|
||||
prefix
|
||||
user {
|
||||
location {
|
||||
country
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
const playersMap = new Map<string, ImportedPlayer>();
|
||||
|
||||
while (currentPage <= totalPages) {
|
||||
const data = await requestStartGG<{
|
||||
tournament: {
|
||||
participants: {
|
||||
pageInfo: { totalPages: number };
|
||||
nodes: Array<{
|
||||
id: number;
|
||||
gamerTag: string | null;
|
||||
prefix: string | null;
|
||||
user: { location: { country: string | null } | null } | null;
|
||||
}>;
|
||||
};
|
||||
} | null;
|
||||
}>(query, { slug, page: currentPage, perPage: PARTICIPANTS_PAGE_SIZE }, token);
|
||||
|
||||
if (!data.tournament) throw new Error('Tournament not found');
|
||||
|
||||
const apiTotalPages = Number(data.tournament.participants.pageInfo.totalPages);
|
||||
totalPages = Number.isFinite(apiTotalPages) ? Math.max(apiTotalPages, 1) : 1;
|
||||
|
||||
for (const participant of data.tournament.participants.nodes) {
|
||||
const playerId = String(participant.id);
|
||||
const gamertag = (participant.gamerTag ?? '').trim();
|
||||
if (!gamertag) continue;
|
||||
|
||||
playersMap.set(playerId, {
|
||||
id: playerId,
|
||||
gamertag,
|
||||
name: gamertag,
|
||||
team: (participant.prefix ?? '').trim(),
|
||||
country: resolveCountryCodeFromStartGG(participant.user?.location?.country),
|
||||
twitter: '',
|
||||
});
|
||||
}
|
||||
|
||||
currentPage += 1;
|
||||
}
|
||||
|
||||
return Array.from(playersMap.values());
|
||||
};
|
||||
@@ -1,447 +0,0 @@
|
||||
import { getData, type CountryRecord } from 'country-list';
|
||||
import { nodecg } from './util/nodecg.js';
|
||||
import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js';
|
||||
|
||||
// ─── Constantes ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STARTGG_ENDPOINT = 'https://api.start.gg/gql/alpha';
|
||||
const STARTGG_OAUTH_AUTHORIZE_ENDPOINT = 'https://www.start.gg/api/-/rest/oauth/authorize';
|
||||
const STARTGG_OAUTH_TOKEN_ENDPOINTS = [
|
||||
'https://www.start.gg/api/-/rest/oauth/access_token',
|
||||
'https://api.start.gg/oauth/access_token',
|
||||
];
|
||||
const STARTGG_OAUTH_SCOPES = 'user.identity tournament.manager';
|
||||
const STARTGG_OAUTH_CALLBACK_PATH = '/startgg/callback';
|
||||
const STARTGG_OAUTH_DEFAULT_PORT = 34920;
|
||||
const STARTGG_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
const RECENT_TOURNAMENTS_LIMIT = 12;
|
||||
const PARTICIPANTS_PAGE_SIZE = 120;
|
||||
|
||||
// ─── URL del proxy OAuth ───────────────────────────────────────────────────────
|
||||
// Rellena esta constante con la URL de tu Cloudflare Worker tras el deploy.
|
||||
// Formato: 'https://scoreko-oauth-proxy.TU-SUBDOMINIO.workers.dev'
|
||||
//
|
||||
// También puedes sobreescribirla en cfg/scoreko.json con "oauthProxyUrl"
|
||||
// (útil para apuntar a un entorno de staging sin recompilar).
|
||||
const OAUTH_PROXY_BASE_URL = 'https://scoreko-oauth-proxy.panver.workers.dev';
|
||||
|
||||
// ─── Tipos ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface StartGGGraphQLResponse<T> {
|
||||
data?: T;
|
||||
errors?: Array<{ message?: string }>;
|
||||
}
|
||||
|
||||
interface RecentTournament {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
startAt: number | null;
|
||||
endAt: number | null;
|
||||
}
|
||||
|
||||
interface ImportedPlayer {
|
||||
id: string;
|
||||
gamertag: string;
|
||||
name: string;
|
||||
team: string;
|
||||
country: string;
|
||||
twitter: string;
|
||||
}
|
||||
|
||||
interface OAuthTokenResponse {
|
||||
access_token?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ─── Modo OAuth ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// DEV: cfg/scoreko.json tiene startggClientId + startggClientSecret.
|
||||
// El exchange se hace directamente contra start.gg.
|
||||
//
|
||||
// PROXY: No hay credenciales en la config local.
|
||||
// El clientId se obtiene del Worker (es público, no secreto).
|
||||
// El exchange lo hace el Worker, que guarda el clientSecret en sus env vars.
|
||||
|
||||
type OAuthMode =
|
||||
| { type: 'dev'; clientId: string; clientSecret: string; callbackPort: number }
|
||||
| { type: 'proxy'; proxyBaseUrl: string; callbackPort: number };
|
||||
|
||||
const getOAuthMode = (): OAuthMode => {
|
||||
const bundleConfig = nodecg.bundleConfig as Record<string, unknown>;
|
||||
const clientId = String(bundleConfig.startggClientId ?? '').trim();
|
||||
const clientSecret = String(bundleConfig.startggClientSecret ?? '').trim();
|
||||
const rawPort = Number(bundleConfig.startggOAuthPort ?? STARTGG_OAUTH_DEFAULT_PORT);
|
||||
const callbackPort =
|
||||
Number.isFinite(rawPort) && rawPort > 0 ? rawPort : STARTGG_OAUTH_DEFAULT_PORT;
|
||||
|
||||
// oauthProxyUrl en config permite apuntar a un proxy distinto sin recompilar
|
||||
const proxyBaseUrl =
|
||||
String(bundleConfig.oauthProxyUrl ?? '').trim() || OAUTH_PROXY_BASE_URL;
|
||||
|
||||
if (clientId && clientSecret) {
|
||||
nodecg.log.info('[start.gg] OAuth: modo dev (credenciales locales)');
|
||||
return { type: 'dev', clientId, clientSecret, callbackPort };
|
||||
}
|
||||
|
||||
nodecg.log.info(`[start.gg] OAuth: modo proxy → ${proxyBaseUrl}`);
|
||||
return { type: 'proxy', proxyBaseUrl, callbackPort };
|
||||
};
|
||||
|
||||
// ─── Exchange de token ─────────────────────────────────────────────────────────
|
||||
|
||||
const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => {
|
||||
const rawBody = await response.text();
|
||||
try {
|
||||
return JSON.parse(rawBody) as OAuthTokenResponse;
|
||||
} catch {
|
||||
return { message: rawBody };
|
||||
}
|
||||
};
|
||||
|
||||
/** Modo dev: exchange directo con start.gg usando credenciales locales */
|
||||
const exchangeCodeDirectly = async (
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
): Promise<string> => {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
let lastError = 'Unknown OAuth token exchange error';
|
||||
|
||||
for (const tokenEndpoint of STARTGG_OAUTH_TOKEN_ENDPOINTS) {
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
const payload = await parseOAuthTokenPayload(response);
|
||||
|
||||
if (response.ok) {
|
||||
const token = String(payload.access_token ?? '').trim();
|
||||
if (token) return token;
|
||||
lastError =
|
||||
payload.error_description ??
|
||||
payload.error ??
|
||||
payload.message ??
|
||||
'OAuth token response did not include an access token';
|
||||
continue;
|
||||
}
|
||||
|
||||
lastError =
|
||||
payload.error_description ??
|
||||
payload.error ??
|
||||
payload.message ??
|
||||
`OAuth token request failed (${response.status})`;
|
||||
|
||||
if (response.status !== 404) break;
|
||||
}
|
||||
|
||||
throw new Error(lastError);
|
||||
};
|
||||
|
||||
/** Modo proxy: el Worker hace el exchange; el clientSecret nunca sale del Worker */
|
||||
const exchangeCodeViaProxy = async (
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
proxyBaseUrl: string,
|
||||
): Promise<string> => {
|
||||
const response = await fetch(`${proxyBaseUrl}/oauth/startgg/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, redirectUri }),
|
||||
});
|
||||
|
||||
const rawBody = await response.text();
|
||||
let payload: { access_token?: string; error?: string };
|
||||
try {
|
||||
payload = JSON.parse(rawBody) as typeof payload;
|
||||
} catch {
|
||||
payload = { error: rawBody };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error ?? `Proxy responded with ${response.status}`);
|
||||
}
|
||||
const token = String(payload.access_token ?? '').trim();
|
||||
if (!token) throw new Error(payload.error ?? 'Proxy did not return a token');
|
||||
return token;
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback que recibe oauth-server.ts cuando llega el código de autorización.
|
||||
* Delega al modo correcto; _config no se usa porque el modo ya está determinado.
|
||||
*/
|
||||
const exchangeOAuthCodeForToken = async (
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
_config: OAuthConfig,
|
||||
): Promise<string> => {
|
||||
const mode = getOAuthMode();
|
||||
if (mode.type === 'dev') {
|
||||
return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret);
|
||||
}
|
||||
return exchangeCodeViaProxy(code, redirectUri, mode.proxyBaseUrl);
|
||||
};
|
||||
|
||||
// ─── Servidor OAuth ────────────────────────────────────────────────────────────
|
||||
|
||||
const oauthServer = createOAuthServer({
|
||||
provider: 'start.gg',
|
||||
callbackPath: STARTGG_OAUTH_CALLBACK_PATH,
|
||||
authorizeEndpoint: STARTGG_OAUTH_AUTHORIZE_ENDPOINT,
|
||||
scope: STARTGG_OAUTH_SCOPES,
|
||||
sessionTtlMs: STARTGG_OAUTH_SESSION_TTL_MS,
|
||||
exchangeToken: exchangeOAuthCodeForToken,
|
||||
});
|
||||
|
||||
// ─── GraphQL ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const requestStartGG = async <T>(
|
||||
query: string,
|
||||
variables: Record<string, unknown>,
|
||||
token: string,
|
||||
): Promise<T> => {
|
||||
const response = await fetch(STARTGG_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`start.gg responded with ${response.status} ${response.statusText}`.trim());
|
||||
}
|
||||
|
||||
let payload: StartGGGraphQLResponse<T>;
|
||||
try {
|
||||
payload = (await response.json()) as StartGGGraphQLResponse<T>;
|
||||
} catch {
|
||||
throw new Error('Invalid JSON response from start.gg');
|
||||
}
|
||||
|
||||
if (payload.errors?.length) {
|
||||
throw new Error(payload.errors[0]?.message ?? 'Unknown start.gg error');
|
||||
}
|
||||
|
||||
if (!payload.data) {
|
||||
throw new Error('No data returned by start.gg');
|
||||
}
|
||||
|
||||
return payload.data;
|
||||
};
|
||||
|
||||
// ─── Resolución de países ──────────────────────────────────────────────────────
|
||||
|
||||
const countries = getData();
|
||||
const countryByCode = new Set(countries.map((c: CountryRecord) => c.code.toUpperCase()));
|
||||
const countryByName = new Map(
|
||||
countries.map((c: CountryRecord) => [c.name.toLowerCase(), c.code.toUpperCase()]),
|
||||
);
|
||||
|
||||
const resolveCountryCodeFromStartGG = (country: string | null | undefined): string => {
|
||||
const raw = (country ?? '').trim();
|
||||
if (!raw) return '';
|
||||
const upper = raw.toUpperCase();
|
||||
if (countryByCode.has(upper)) return upper;
|
||||
return countryByName.get(raw.toLowerCase()) ?? '';
|
||||
};
|
||||
|
||||
// ─── Utilidades ────────────────────────────────────────────────────────────────
|
||||
|
||||
const getStringProp = (payload: unknown, key: string): string => {
|
||||
if (typeof payload !== 'object' || payload === null || !(key in payload)) return '';
|
||||
const value = (payload as Record<string, unknown>)[key];
|
||||
return typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
||||
};
|
||||
|
||||
const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
|
||||
if (typeof ack === 'function') ack(error, response);
|
||||
};
|
||||
|
||||
// ─── Listeners de NodeCG ───────────────────────────────────────────────────────
|
||||
|
||||
nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => {
|
||||
const mode = getOAuthMode();
|
||||
let serverConfig: OAuthConfig;
|
||||
|
||||
if (mode.type === 'dev') {
|
||||
serverConfig = {
|
||||
clientId: mode.clientId,
|
||||
callbackPort: mode.callbackPort,
|
||||
};
|
||||
} else {
|
||||
// Modo proxy: el clientId viene del Worker.
|
||||
// Es público (va en la URL del navegador), pero no lo queremos en el repo.
|
||||
try {
|
||||
const res = await fetch(`${mode.proxyBaseUrl}/oauth/startgg/client-id`);
|
||||
if (!res.ok) throw new Error(`Proxy responded with ${res.status}`);
|
||||
const data = await res.json() as { clientId?: string };
|
||||
const clientId = String(data.clientId ?? '').trim();
|
||||
if (!clientId) throw new Error('Proxy did not return a clientId');
|
||||
serverConfig = { clientId, callbackPort: mode.callbackPort };
|
||||
} catch (err) {
|
||||
sendAck(
|
||||
ack,
|
||||
err instanceof Error ? err.message : 'Could not fetch OAuth config from proxy',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await oauthServer.ensureServer(serverConfig);
|
||||
} catch (err) {
|
||||
sendAck(ack, err instanceof Error ? err.message : 'Could not start the OAuth callback server');
|
||||
return;
|
||||
}
|
||||
|
||||
sendAck(ack, null, oauthServer.createSession(serverConfig));
|
||||
});
|
||||
|
||||
nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => {
|
||||
const sessionId = getStringProp(payload, 'sessionId');
|
||||
if (!sessionId) {
|
||||
sendAck(ack, 'Missing OAuth session id');
|
||||
return;
|
||||
}
|
||||
|
||||
const status = oauthServer.getSessionStatus(sessionId);
|
||||
if (!status) {
|
||||
sendAck(ack, 'OAuth session not found');
|
||||
return;
|
||||
}
|
||||
|
||||
sendAck(ack, null, status);
|
||||
});
|
||||
|
||||
nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack) => {
|
||||
const token = getStringProp(payload, 'token');
|
||||
if (!token) {
|
||||
sendAck(ack, 'Missing start.gg API token');
|
||||
return;
|
||||
}
|
||||
|
||||
const query = `
|
||||
query RecentTournaments($perPage: Int!) {
|
||||
currentUser {
|
||||
tournaments(query: { perPage: $perPage, filter: { tournamentView: "admin" } }) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
slug
|
||||
startAt
|
||||
endAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const data = await requestStartGG<{
|
||||
currentUser: { tournaments: { nodes: RecentTournament[] } } | null;
|
||||
}>(query, { perPage: RECENT_TOURNAMENTS_LIMIT }, token);
|
||||
|
||||
const tournaments =
|
||||
data.currentUser?.tournaments.nodes
|
||||
.filter((item) => item.slug)
|
||||
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
|
||||
.map(({ id, name, slug, startAt, endAt }) => ({ id, name, slug, startAt, endAt })) ?? [];
|
||||
|
||||
sendAck(ack, null, tournaments);
|
||||
} catch (error) {
|
||||
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while loading tournaments');
|
||||
}
|
||||
});
|
||||
|
||||
nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack) => {
|
||||
const token = getStringProp(payload, 'token');
|
||||
const slug = getStringProp(payload, 'slug');
|
||||
|
||||
if (!token) { sendAck(ack, 'Missing start.gg API token'); return; }
|
||||
if (!slug) { sendAck(ack, 'Missing tournament slug'); return; }
|
||||
|
||||
const query = `
|
||||
query TournamentParticipants($slug: String!, $page: Int!, $perPage: Int!) {
|
||||
tournament(slug: $slug) {
|
||||
participants(query: { page: $page, perPage: $perPage }) {
|
||||
pageInfo {
|
||||
totalPages
|
||||
}
|
||||
nodes {
|
||||
id
|
||||
gamerTag
|
||||
prefix
|
||||
user {
|
||||
location {
|
||||
country
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
const playersMap = new Map<string, ImportedPlayer>();
|
||||
|
||||
while (currentPage <= totalPages) {
|
||||
const data = await requestStartGG<{
|
||||
tournament: {
|
||||
participants: {
|
||||
pageInfo: { totalPages: number };
|
||||
nodes: Array<{
|
||||
id: number;
|
||||
gamerTag: string | null;
|
||||
prefix: string | null;
|
||||
user: { location: { country: string | null } | null } | null;
|
||||
}>;
|
||||
};
|
||||
} | null;
|
||||
}>(query, { slug, page: currentPage, perPage: PARTICIPANTS_PAGE_SIZE }, token);
|
||||
|
||||
if (!data.tournament) throw new Error('Tournament not found');
|
||||
|
||||
const apiTotalPages = Number(data.tournament.participants.pageInfo.totalPages);
|
||||
totalPages = Number.isFinite(apiTotalPages) ? Math.max(apiTotalPages, 1) : 1;
|
||||
|
||||
for (const participant of data.tournament.participants.nodes) {
|
||||
const playerId = String(participant.id);
|
||||
const gamertag = (participant.gamerTag ?? '').trim();
|
||||
if (!gamertag) continue;
|
||||
|
||||
playersMap.set(playerId, {
|
||||
id: playerId,
|
||||
gamertag,
|
||||
name: gamertag,
|
||||
team: (participant.prefix ?? '').trim(),
|
||||
country: resolveCountryCodeFromStartGG(participant.user?.location?.country),
|
||||
twitter: '',
|
||||
});
|
||||
}
|
||||
|
||||
currentPage += 1;
|
||||
}
|
||||
|
||||
sendAck(ack, null, Array.from(playersMap.values()));
|
||||
} catch (error) {
|
||||
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players');
|
||||
}
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useHead } from '@unhead/vue';
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
|
||||
import { resolveCountryCode } from '../../shared/countries';
|
||||
import { resolveCountryCode } from '../../shared/utils/countries';
|
||||
import { getCharactersByGame } from '../../shared/fighting-characters';
|
||||
import type { Schemas } from '../../types';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useHead } from '@unhead/vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
|
||||
import { resolveCountryCode } from '../../shared/countries';
|
||||
import { resolveCountryCode } from '../../shared/utils/countries';
|
||||
import type { Schemas } from '../../types';
|
||||
|
||||
useHead({ title: 'Scoreboard' });
|
||||
|
||||
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 527 KiB |
|
Before Width: | Height: | Size: 4.4 MiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 3.9 MiB After Width: | Height: | Size: 311 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 441 KiB |
|
Before Width: | Height: | Size: 3.9 MiB After Width: | Height: | Size: 410 KiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 586 KiB |
|
Before Width: | Height: | Size: 4.6 MiB After Width: | Height: | Size: 606 KiB |
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 16 MiB |
|
Before Width: | Height: | Size: 4.5 MiB After Width: | Height: | Size: 920 KiB |
|
Before Width: | Height: | Size: 4.0 MiB After Width: | Height: | Size: 744 KiB |
|
Before Width: | Height: | Size: 4.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 4.6 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 5.2 MiB After Width: | Height: | Size: 898 KiB |
|
Before Width: | Height: | Size: 4.9 MiB After Width: | Height: | Size: 12 MiB |
|
Before Width: | Height: | Size: 4.5 MiB After Width: | Height: | Size: 522 KiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 972 KiB |
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 611 KiB |
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 580 KiB |
|
Before Width: | Height: | Size: 4.0 MiB After Width: | Height: | Size: 606 KiB |
|
Before Width: | Height: | Size: 4.3 MiB After Width: | Height: | Size: 877 KiB |
|
Before Width: | Height: | Size: 5.6 MiB After Width: | Height: | Size: 967 KiB |
|
Before Width: | Height: | Size: 4.0 MiB After Width: | Height: | Size: 826 KiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 561 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 775 KiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 476 KiB |
|
Before Width: | Height: | Size: 4.5 MiB After Width: | Height: | Size: 708 KiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 464 KiB |
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 584 KiB |
|
Before Width: | Height: | Size: 5.0 MiB After Width: | Height: | Size: 965 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 657 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 687 KiB |
|
Before Width: | Height: | Size: 3.7 MiB After Width: | Height: | Size: 445 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 772 KiB |
|
Before Width: | Height: | Size: 4.0 MiB After Width: | Height: | Size: 673 KiB |
|
Before Width: | Height: | Size: 3.7 MiB After Width: | Height: | Size: 577 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 418 KiB |
|
Before Width: | Height: | Size: 4.6 MiB After Width: | Height: | Size: 807 KiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 552 KiB |
@@ -1,3 +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.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { ref } from 'vue';
|
||||
import type { PackManifest } from './pack-types';
|
||||
|
||||
export interface FightingCharacterOption {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -5,336 +14,43 @@ export interface FightingCharacterOption {
|
||||
dlc?: boolean;
|
||||
}
|
||||
|
||||
type GamePalette = readonly [startColor: string, endColor: string];
|
||||
// ── Runtime registry ──────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_PLACEHOLDER_PALETTE: GamePalette = ['#334155', '#0f172a'];
|
||||
const MAX_INITIALS = 2;
|
||||
const installedPackCharacters: Record<string, FightingCharacterOption[]> = {};
|
||||
const installedPackDefaults: Record<string, { leftCharacter: string; rightCharacter: string }> = {};
|
||||
|
||||
const characterNamesByGame: Record<string, string[]> = {
|
||||
'2XKO': [
|
||||
'Ahri',
|
||||
'Akali',
|
||||
'Braum',
|
||||
'Caitlyn',
|
||||
'Darius',
|
||||
'Ekko',
|
||||
'Illaoi',
|
||||
'Jinx',
|
||||
'Senna',
|
||||
'Teemo',
|
||||
'Vi',
|
||||
'Warwick',
|
||||
'Yasuo',
|
||||
],
|
||||
'FATAL FURY: City of the Wolves': [
|
||||
'Andy Bogard',
|
||||
'B. Jenet',
|
||||
'Billy Kane',
|
||||
'Blue Mary',
|
||||
'Chun-Li',
|
||||
'Cristiano Ronaldo',
|
||||
'Gato',
|
||||
'Hokutomaru',
|
||||
'Hotaru Futaba',
|
||||
'Joe Higashi',
|
||||
'Kain R. Heinlein',
|
||||
'Ken Masters',
|
||||
'Kenshiro',
|
||||
'Kevin Rian',
|
||||
'Kim Dong Hwan',
|
||||
'Kim Jae Hoon',
|
||||
'Mai Shiranui',
|
||||
'Marco Rodrigues',
|
||||
'Mr. Big',
|
||||
'Mr. Karate',
|
||||
'Nightmare Geese',
|
||||
'Preecha',
|
||||
'Rock Howard',
|
||||
'Salvatore Ganacci',
|
||||
'Terry Bogard',
|
||||
'Tizoc',
|
||||
'Vox Reaper',
|
||||
'Wolfgang Krauser',
|
||||
],
|
||||
'Guilty Gear -Strive-': [
|
||||
'A.B.A',
|
||||
'Anji Mito',
|
||||
'Asuka R.',
|
||||
'Axl Low',
|
||||
'Baiken',
|
||||
'Bedman?',
|
||||
'Bridget',
|
||||
'Chipp Zanuff',
|
||||
'Dizzy',
|
||||
'Elphelt Valentine',
|
||||
'Faust',
|
||||
'Giovanna',
|
||||
'Goldlewis Dickinson',
|
||||
'Happy Chaos',
|
||||
'I-No',
|
||||
'Jack-O',
|
||||
'Johnny',
|
||||
'Ky Kiske',
|
||||
'Leo Whitefang',
|
||||
'Lucy',
|
||||
'May',
|
||||
'Millia Rage',
|
||||
'Nagoriyuki',
|
||||
'Potemkin',
|
||||
'Ramlethal Valentine',
|
||||
'Sin Kiske',
|
||||
'Slayer',
|
||||
'Sol Badguy',
|
||||
'Testament',
|
||||
'Unika',
|
||||
'Venom',
|
||||
'Zato-1',
|
||||
],
|
||||
'Invincible VS': [
|
||||
'Allen the Alien',
|
||||
'Anissa',
|
||||
'Atom Eve',
|
||||
'Battle Beast',
|
||||
'Bulletproof',
|
||||
'Cecil',
|
||||
'Conquest',
|
||||
'Dupli-Kate',
|
||||
'Ella Mental',
|
||||
'Immortal',
|
||||
'Invincible',
|
||||
'Lucan',
|
||||
'Monster Girl',
|
||||
'Omni-Man',
|
||||
'Powerplex',
|
||||
'Rex Splode',
|
||||
'Robot',
|
||||
'Thula',
|
||||
'Titan',
|
||||
'Universa',
|
||||
],
|
||||
'Mortal Kombat 1': [
|
||||
'Ashrah',
|
||||
'Baraka',
|
||||
'Conan the Barbarian',
|
||||
'Cyrax',
|
||||
'Ermac',
|
||||
'Geras',
|
||||
'Ghostface',
|
||||
'Havik',
|
||||
'Homelander',
|
||||
'Johnny Cage',
|
||||
'Kenshi',
|
||||
'Kitana',
|
||||
'Kung Lao',
|
||||
'Li Mei',
|
||||
'Liu Kang',
|
||||
'Mileena',
|
||||
'Nitara',
|
||||
'Noob Saibot',
|
||||
'Omni-Man',
|
||||
'Peacemaker',
|
||||
'Quan Chi',
|
||||
'Raiden',
|
||||
'Rain',
|
||||
'Reiko',
|
||||
'Reptile',
|
||||
'Scorpion',
|
||||
'Sektor',
|
||||
'Shang Tsung',
|
||||
'Sindel',
|
||||
'Smoke',
|
||||
'Sub-Zero',
|
||||
'Takeda',
|
||||
'Tanya',
|
||||
'T-1000',
|
||||
],
|
||||
'Street Fighter 6': [
|
||||
'A.K.I.',
|
||||
'Akuma',
|
||||
'Alex',
|
||||
'Bison',
|
||||
'Blanka',
|
||||
'Cammy',
|
||||
'Chun-Li',
|
||||
'Dee Jay',
|
||||
'Dhalsim',
|
||||
'E. Honda',
|
||||
'Ed',
|
||||
'Elena',
|
||||
'Guile',
|
||||
'Jamie',
|
||||
'JP',
|
||||
'Juri',
|
||||
'Ken',
|
||||
'Kimberly',
|
||||
'Lily',
|
||||
'Luke',
|
||||
'Mai',
|
||||
'Manon',
|
||||
'Marisa',
|
||||
'Rashid',
|
||||
'Ryu',
|
||||
'Sagat',
|
||||
'Terry',
|
||||
'Viper',
|
||||
'Zangief',
|
||||
],
|
||||
'TEKKEN 8': [
|
||||
'Alisa',
|
||||
'Anna',
|
||||
'Armor King',
|
||||
'Asuka',
|
||||
'Azucena',
|
||||
'Bob',
|
||||
'Bryan',
|
||||
'Claudio',
|
||||
'Clive',
|
||||
'Devil Jin',
|
||||
'Dragunov',
|
||||
'Eddy',
|
||||
'Fahkumram',
|
||||
'Feng',
|
||||
'Heihachi',
|
||||
'Hwoarang',
|
||||
'Jack-8',
|
||||
'Jin',
|
||||
'Jun',
|
||||
'Kazuya',
|
||||
'King',
|
||||
'Kuma',
|
||||
'Kunimitsu',
|
||||
'Lars',
|
||||
'Law',
|
||||
'Lee',
|
||||
'Leo',
|
||||
'Leroy',
|
||||
'Lidia',
|
||||
'Lili',
|
||||
'Miary Zo',
|
||||
'Nina',
|
||||
'Panda',
|
||||
'Paul',
|
||||
'Raven',
|
||||
'Reina',
|
||||
'Roger Jr',
|
||||
'Shaheen',
|
||||
'Steve',
|
||||
'Victor',
|
||||
'Xiaoyu',
|
||||
'Yoshimitsu',
|
||||
'Zafina',
|
||||
],
|
||||
'THE KING OF FIGHTERS XV': [
|
||||
'Angel',
|
||||
'Antonov',
|
||||
'Ash Crimson',
|
||||
'Athena Asamiya',
|
||||
'Benimaru Nikaido',
|
||||
'Billy Kane',
|
||||
'Blue Mary',
|
||||
'Chizuru Kagura',
|
||||
'Chris',
|
||||
'Clark Still',
|
||||
'Dolores',
|
||||
'Duo Lon',
|
||||
'Elisabeth Blanctorche',
|
||||
'Gato',
|
||||
'Geese Howard',
|
||||
'Goenitz',
|
||||
'Heidern',
|
||||
'Hinako Shijo',
|
||||
'Iori Yagami',
|
||||
'Isla',
|
||||
'Joe Higashi',
|
||||
"K'",
|
||||
'Kim Kaphwan',
|
||||
'King',
|
||||
'King of Dinosaurs',
|
||||
'Krohnen McDougall',
|
||||
'Kula Diamond',
|
||||
'Kukri',
|
||||
'Kyo Kusanagi',
|
||||
'Leona Heidern',
|
||||
'Luong',
|
||||
'Mai Shiranui',
|
||||
'Maxima',
|
||||
'Meitenkun',
|
||||
'Najd',
|
||||
'Orochi Chris',
|
||||
'Orochi Shermie',
|
||||
'Orochi Yashiro',
|
||||
'Ralf Jones',
|
||||
'Ramón',
|
||||
'Robert Garcia',
|
||||
'Rock Howard',
|
||||
'Ryo Sakazaki',
|
||||
'Ryuji Yamazaki',
|
||||
'Shermie',
|
||||
'Shingo Yabuki',
|
||||
'Sylvie Paula Paula',
|
||||
'Terry Bogard',
|
||||
'Vanessa',
|
||||
'Whip',
|
||||
'Yashiro Nanakase',
|
||||
'Yuri Sakazaki',
|
||||
],
|
||||
};
|
||||
/**
|
||||
* 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);
|
||||
|
||||
const defaultCharacterPairByGame: Record<string, { leftCharacter: string; rightCharacter: string }> = {
|
||||
'Guilty Gear -Strive-': {
|
||||
leftCharacter: 'sol-badguy',
|
||||
rightCharacter: 'ky-kiske',
|
||||
},
|
||||
'Street Fighter 6': {
|
||||
leftCharacter: 'ryu',
|
||||
rightCharacter: 'chun-li',
|
||||
},
|
||||
'TEKKEN 8': {
|
||||
leftCharacter: 'jin',
|
||||
rightCharacter: 'kazuya',
|
||||
},
|
||||
'2XKO': {
|
||||
leftCharacter: 'ahri',
|
||||
rightCharacter: 'yasuo',
|
||||
},
|
||||
'Mortal Kombat 1': {
|
||||
leftCharacter: 'scorpion',
|
||||
rightCharacter: 'sub-zero',
|
||||
},
|
||||
'THE KING OF FIGHTERS XV': {
|
||||
leftCharacter: 'kyo-kusanagi',
|
||||
rightCharacter: 'iori-yagami',
|
||||
},
|
||||
};
|
||||
/**
|
||||
* Vacío — ya no hay juegos bundled.
|
||||
* Mantenido por compatibilidad con usePackRegistry.
|
||||
*/
|
||||
export const BUNDLED_GAME_NAMES = new Set<string>();
|
||||
|
||||
const paletteByGame: Record<string, GamePalette> = {
|
||||
'Street Fighter 6': ['#f97316', '#b91c1c'],
|
||||
'TEKKEN 8': ['#2563eb', '#111827'],
|
||||
'Guilty Gear -Strive-': ['#a855f7', '#312e81'],
|
||||
'2XKO': ['#7c3aed', '#1d4ed8'],
|
||||
'Mortal Kombat 1': ['#f59e0b', '#7f1d1d'],
|
||||
'THE KING OF FIGHTERS XV': ['#0ea5e9', '#1e3a8a'],
|
||||
};
|
||||
// ── Placeholder SVG ───────────────────────────────────────────────────────────
|
||||
|
||||
const toSlug = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
const toDataUrl = (svg: string): string =>
|
||||
`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
|
||||
const toDataUrl = (svg: string) => `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
|
||||
const buildCharacterPlaceholder = (game: string, character: string) => {
|
||||
const [startColor, endColor] = paletteByGame[game] ?? DEFAULT_PLACEHOLDER_PALETTE;
|
||||
const buildPlaceholder = (game: string, character: string, start: string, end: string): string => {
|
||||
const initials = character
|
||||
.split(/\s+/)
|
||||
.map((part) => part[0])
|
||||
.map((p) => p[0])
|
||||
.join('')
|
||||
.slice(0, MAX_INITIALS)
|
||||
.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="${startColor}"/>
|
||||
<stop offset="100%" stop-color="${endColor}"/>
|
||||
<stop offset="0%" stop-color="${start}"/>
|
||||
<stop offset="100%" stop-color="${end}"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="480" height="220" fill="url(#bg)" rx="18"/>
|
||||
@@ -342,113 +58,55 @@ const buildCharacterPlaceholder = (game: string, character: string) => {
|
||||
<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>`;
|
||||
</svg>`.trim();
|
||||
|
||||
return toDataUrl(svg.trim());
|
||||
return toDataUrl(svg);
|
||||
};
|
||||
|
||||
const characterImageModules = import.meta.glob('/src/shared/character-images/**/*.{png,jpg,jpeg,webp,avif,svg}', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
query: '?url',
|
||||
}) as Record<string, string>;
|
||||
// ── Pack registration ─────────────────────────────────────────────────────────
|
||||
|
||||
const resolveImageKey = (path: string): string | null => {
|
||||
const segments = path.split('/');
|
||||
const gameFolder = segments.at(-2);
|
||||
const filename = segments.at(-1);
|
||||
/**
|
||||
* 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;
|
||||
|
||||
if (!gameFolder || !filename) {
|
||||
return null;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
const characterSlug = filename.replace(/\.[^.]+$/, '');
|
||||
return `${gameFolder}/${characterSlug}`;
|
||||
};
|
||||
|
||||
const characterImageByKey = Object.entries(characterImageModules).reduce<Record<string, string>>((acc, [path, url]) => {
|
||||
const key = resolveImageKey(path);
|
||||
if (!key) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[key] = url;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const getCharacterImage = (game: string, character: string, characterValue: string) => {
|
||||
const gameSlug = toSlug(game);
|
||||
const key = `${gameSlug}/${characterValue}`;
|
||||
return characterImageByKey[key] ?? buildCharacterPlaceholder(game, character);
|
||||
installedPacksRevision.value++;
|
||||
};
|
||||
|
||||
/**
|
||||
* DLC characters per game. Update as new content is released.
|
||||
* Characters not listed here are treated as base-roster.
|
||||
* Elimina un pack del registro en memoria.
|
||||
* Llamado por usePackRegistry cuando el usuario desinstala un pack.
|
||||
*/
|
||||
const dlcCharactersByGame: Record<string, ReadonlySet<string>> = {
|
||||
'FATAL FURY: City of the Wolves': new Set([
|
||||
'Chun-Li', // Season Pass (crossover)
|
||||
'Cristiano Ronaldo', // Season Pass (celebrity)
|
||||
'Ken Masters', // Season Pass (crossover)
|
||||
'Kenshiro', // Season Pass (crossover)
|
||||
'Nightmare Geese', // Season Pass
|
||||
'Salvatore Ganacci', // Season Pass (celebrity)
|
||||
'Vox Reaper', // Season Pass
|
||||
]),
|
||||
'Guilty Gear -Strive-': new Set([
|
||||
// Season 1
|
||||
'Goldlewis Dickinson', 'Jack-O', 'Happy Chaos', 'Baiken', 'Testament',
|
||||
// Season 2
|
||||
'Bridget', 'Sin Kiske', 'Bedman?', 'Asuka R. Kreutz', 'Johnny',
|
||||
// Season 3
|
||||
'Elphelt Valentine', 'A.B.A', 'Slayer', 'Dizzy', 'Venom',
|
||||
// Season 4
|
||||
'Lucy', 'Unika',
|
||||
]),
|
||||
'Mortal Kombat 1': new Set([
|
||||
// Kombat Pack 1
|
||||
'Ermac', 'Homelander', 'Omni-Man', 'Peacemaker', 'Quan Chi', 'Tanya',
|
||||
// Kombat Pack 2
|
||||
'Conan the Barbarian', 'Cyrax', 'Ghostface', 'Noob Saibot', 'Sektor',
|
||||
'Shang Tsung', 'Takeda', 'T-1000',
|
||||
]),
|
||||
'Street Fighter 6': new Set([
|
||||
// Year 1
|
||||
'A.K.I.', 'Akuma', 'Bison', 'Ed',
|
||||
// Year 2
|
||||
'Alex', 'Elena', 'Mai', 'Sagat', 'Terry', 'Viper',
|
||||
]),
|
||||
'TEKKEN 8': new Set([
|
||||
// Season 1
|
||||
'Clive', 'Eddy', 'Heihachi', 'Lidia',
|
||||
// Season 2
|
||||
'Anna', 'Fahkumram', 'Kunimitsu', 'Miary Zo', 'Roger Jr',
|
||||
]),
|
||||
'THE KING OF FIGHTERS XV': new Set([
|
||||
'Antonov', 'Elisabeth Blanctorche', 'Gato', 'Geese Howard', 'Goenitz',
|
||||
'Hinako Shijo', 'Krohnen McDougall', 'Kukri', 'Luong', 'Najd',
|
||||
'Orochi Chris', 'Orochi Shermie', 'Orochi Yashiro', 'Rock Howard',
|
||||
'Sylvie Paula Paula',
|
||||
]),
|
||||
export const unregisterInstalledPack = (gameName: string): void => {
|
||||
delete installedPackCharacters[gameName];
|
||||
delete installedPackDefaults[gameName];
|
||||
installedPacksRevision.value++;
|
||||
};
|
||||
|
||||
export const fightingCharactersByGame: Record<string, FightingCharacterOption[]> = Object.fromEntries(
|
||||
Object.entries(characterNamesByGame).map(([game, characterNames]) => [
|
||||
game,
|
||||
characterNames.map((character) => {
|
||||
const value = toSlug(character);
|
||||
// Prefer packaged artwork and gracefully fallback to a generated image.
|
||||
return {
|
||||
label: character,
|
||||
value,
|
||||
image: getCharacterImage(game, character, value),
|
||||
dlc: dlcCharactersByGame[game]?.has(character) ?? false,
|
||||
};
|
||||
}),
|
||||
]),
|
||||
);
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const getCharactersByGame = (game: string) => fightingCharactersByGame[game] ?? [];
|
||||
export const getCharactersByGame = (game: string): FightingCharacterOption[] =>
|
||||
installedPackCharacters[game] ?? [];
|
||||
|
||||
export const getDefaultCharactersByGame = (game: string) => defaultCharacterPairByGame[game];
|
||||
export const getDefaultCharactersByGame = (
|
||||
game: string,
|
||||
): { leftCharacter: string; rightCharacter: string } | undefined =>
|
||||
installedPackDefaults[game];
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// src/shared/pack-config.ts
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Edit ONLY this file to point the pack system at your Gitea instance.
|
||||
// All other files import their Gitea/NodeCG constants from here.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/** Base URL of your Gitea instance — no trailing slash. */
|
||||
export const GITEA_BASE_URL = 'http://10.0.0.10:3002';
|
||||
/** Gitea owner (user or organisation) that owns the packs repository. */
|
||||
export const GITEA_OWNER = 'Pandipipas';
|
||||
/** Name of the repository that contains all game packs. */
|
||||
export const GITEA_REPO = 'fighting-game-packs';
|
||||
/** Branch to pull assets from. */
|
||||
export const GITEA_BRANCH = 'main';
|
||||
/**
|
||||
* NodeCG bundle name.
|
||||
* Must match the "name" field in your package.json / nodecg config.
|
||||
*/
|
||||
export const BUNDLE_NAME = 'scoreko-dev';
|
||||
// ── Derived URL helpers (do not edit below this line) ────────────────────────
|
||||
/** Returns the Gitea raw-file URL for any repo-relative path. */
|
||||
export const getGiteaRawUrl = (repoPath) => `${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${repoPath}`;
|
||||
/** URL of the master registry file that lists every available pack. */
|
||||
export const REGISTRY_URL = getGiteaRawUrl('registry.json');
|
||||
/** Returns the URL for a specific pack's manifest.json. */
|
||||
export const getManifestUrl = (packId) => getGiteaRawUrl(`${packId}/manifest.json`);
|
||||
/** Returns the URL for a pack's logo. */
|
||||
export const getPackLogoUrl = (packId) => getGiteaRawUrl(`${packId}/logo.png`);
|
||||
/**
|
||||
* Returns the URL for a specific character image stored in the Gitea repo.
|
||||
* Used during download; at runtime installed packs are served by NodeCG.
|
||||
*/
|
||||
export const getCharacterImageRepoUrl = (packId, slug, ext) => getGiteaRawUrl(`${packId}/characters/${slug}.${ext}`);
|
||||
/**
|
||||
* Returns the runtime URL for a character image from an *installed* (downloaded) pack.
|
||||
* NodeCG serves everything under assets/ at /assets/<bundleName>/.
|
||||
*/
|
||||
export const getInstalledCharacterImageUrl = (packId, slug, ext = 'png') => `/assets/${BUNDLE_NAME}/packs/${packId}/characters/${slug}.${ext}`;
|
||||
@@ -0,0 +1,54 @@
|
||||
// src/shared/pack-config.ts
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Edit ONLY this file to point the pack system at your Gitea instance.
|
||||
// All other files import their Gitea/NodeCG constants from here.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Base URL of your Gitea instance — no trailing slash. */
|
||||
export const GITEA_BASE_URL = 'http://10.0.0.10:3002';
|
||||
|
||||
/** Gitea owner (user or organisation) that owns the packs repository. */
|
||||
export const GITEA_OWNER = 'Pandipipas';
|
||||
|
||||
/** Name of the repository that contains all game packs. */
|
||||
export const GITEA_REPO = 'fighting-game-packs';
|
||||
|
||||
/** Branch to pull assets from. */
|
||||
export const GITEA_BRANCH = 'main';
|
||||
|
||||
/**
|
||||
* NodeCG bundle name.
|
||||
* Must match the "name" field in your package.json / nodecg config.
|
||||
*/
|
||||
export const BUNDLE_NAME = 'scoreko-dev';
|
||||
|
||||
// ── Derived URL helpers (do not edit below this line) ────────────────────────
|
||||
|
||||
/** Returns the Gitea raw-file URL for any repo-relative path. */
|
||||
export const getGiteaRawUrl = (repoPath: string): string =>
|
||||
`${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${repoPath}`;
|
||||
|
||||
/** URL of the master registry file that lists every available pack. */
|
||||
export const REGISTRY_URL = getGiteaRawUrl('registry.json');
|
||||
|
||||
/** Returns the URL for a specific pack's manifest.json. */
|
||||
export const getManifestUrl = (packId: string): string =>
|
||||
getGiteaRawUrl(`${packId}/manifest.json`);
|
||||
|
||||
/** Returns the URL for a pack's logo. */
|
||||
export const getPackLogoUrl = (packId: string): string =>
|
||||
getGiteaRawUrl(`${packId}/logo.png`);
|
||||
|
||||
/**
|
||||
* Returns the URL for a specific character image stored in the Gitea repo.
|
||||
* Used during download; at runtime installed packs are served by NodeCG.
|
||||
*/
|
||||
export const getCharacterImageRepoUrl = (packId: string, slug: string, ext: string): string =>
|
||||
getGiteaRawUrl(`${packId}/characters/${slug}.${ext}`);
|
||||
|
||||
/**
|
||||
* Returns the runtime URL for a character image from an *installed* (downloaded) pack.
|
||||
* NodeCG serves everything under assets/ at /assets/<bundleName>/.
|
||||
*/
|
||||
export const getInstalledCharacterImageUrl = (packId: string, slug: string, ext = 'png'): string =>
|
||||
`/packs/${packId}/characters/${slug}.${ext}`;
|
||||
@@ -0,0 +1,6 @@
|
||||
// src/shared/pack-types.ts
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Shared between the NodeCG extension (Node.js) and the dashboard (browser).
|
||||
// Do NOT import anything that is browser-only or Node-only from this file.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export {};
|
||||
@@ -0,0 +1,89 @@
|
||||
// src/shared/pack-types.ts
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Shared between the NodeCG extension (Node.js) and the dashboard (browser).
|
||||
// Do NOT import anything that is browser-only or Node-only from this file.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A single character entry inside a pack manifest. */
|
||||
export interface PackCharacter {
|
||||
/** Display name, e.g. "Chun-Li" */
|
||||
name: string;
|
||||
/** URL-safe slug that matches the image filename, e.g. "chun-li" */
|
||||
slug: string;
|
||||
/** True when the character is paid DLC (shown with the DLC badge in the UI). */
|
||||
dlc?: boolean;
|
||||
/** Approximate compressed size of the character image file in bytes. */
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight entry in the top-level registry.json.
|
||||
* Enough for the UI to render the game list and the download dialog preview
|
||||
* without having to fetch the full manifest.
|
||||
*/
|
||||
export interface PackRegistryEntry {
|
||||
/** Unique identifier — must match the folder name in the repo, e.g. "street-fighter-6". */
|
||||
id: string;
|
||||
/** Human-readable game title shown in the selector, e.g. "Street Fighter 6". */
|
||||
name: string;
|
||||
/** Semantic version of this pack, e.g. "1.0.0". Bump when adding/updating characters. */
|
||||
version: string;
|
||||
/** Total download size (sum of all character images + logo) in bytes. */
|
||||
totalSizeBytes: number;
|
||||
/** Repo-relative path to the game's logo image, e.g. "street-fighter-6/logo.png". */
|
||||
logoPath: string;
|
||||
/** Pre-computed character count so the dialog can show it without loading the manifest. */
|
||||
characterCount: number;
|
||||
/** Gradient used for placeholder images when a character has no artwork. */
|
||||
palette: { start: string; end: string };
|
||||
/**
|
||||
* True when the pack ships inside the application bundle (bundled via Vite's
|
||||
* import.meta.glob). Bundled packs are always "installed" and never show the
|
||||
* download button, but they still appear in the registry so the app can detect
|
||||
* updates (version mismatch between bundle and registry).
|
||||
*/
|
||||
bundled: boolean;
|
||||
}
|
||||
|
||||
/** Full pack data — lives at <packId>/manifest.json in the repo. */
|
||||
export interface PackManifest {
|
||||
/** Must match PackRegistryEntry.id and the folder name. */
|
||||
id: string;
|
||||
/** Must match PackRegistryEntry.name. */
|
||||
name: string;
|
||||
version: string;
|
||||
palette: { start: string; end: string };
|
||||
/** Default characters pre-selected when this game is first chosen. */
|
||||
defaultPair?: { left: string; right: string };
|
||||
/** Full character roster, in the order they should appear in the selector. */
|
||||
characters: PackCharacter[];
|
||||
}
|
||||
|
||||
/** Top-level registry.json structure. */
|
||||
export interface PackRegistry {
|
||||
schemaVersion: number;
|
||||
updatedAt: string;
|
||||
packs: PackRegistryEntry[];
|
||||
}
|
||||
|
||||
/** Tracks the download lifecycle of a single pack. */
|
||||
export interface PackDownloadState {
|
||||
status: 'idle' | 'fetching-manifest' | 'downloading' | 'done' | 'error';
|
||||
/** Progress percentage 0–100. */
|
||||
progress: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Shape of the option objects surfaced by usePackRegistry.allGameOptions. */
|
||||
export interface GameSelectOption {
|
||||
/** Display label for the QSelect. */
|
||||
label: string;
|
||||
/** Value stored in the scoreboard (equals PackRegistryEntry.name for installed games). */
|
||||
value: string;
|
||||
/** Whether the pack can be used right now (bundled or already downloaded). */
|
||||
available: boolean;
|
||||
/** Mirrors PackRegistryEntry so the download dialog can be populated inline. */
|
||||
registryEntry: PackRegistryEntry;
|
||||
/** Present when there is a newer version of this pack available in the registry. */
|
||||
updateInfo?: { installedVersion: string; latestVersion: string };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface OAuthTokenResponse {
|
||||
access_token?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface RecentTournament {
|
||||
id: string | number;
|
||||
name: string;
|
||||
slug: string;
|
||||
startAt: number | null;
|
||||
endAt: number | null;
|
||||
}
|
||||
|
||||
export interface ImportedPlayer {
|
||||
id: string;
|
||||
gamertag: string;
|
||||
name: string;
|
||||
team: string;
|
||||
country: string;
|
||||
twitter: string;
|
||||
}
|
||||
|
||||
export type OAuthMode =
|
||||
| { type: 'dev'; clientId: string; clientSecret: string; callbackPort: number }
|
||||
| { type: 'proxy'; proxyBaseUrl: string; callbackPort: number };
|
||||
@@ -0,0 +1,28 @@
|
||||
export const getStringProp = (payload, key) => {
|
||||
if (typeof payload !== 'object' || payload === null || !(key in payload))
|
||||
return '';
|
||||
const value = payload[key];
|
||||
return typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
||||
};
|
||||
export const getNumberProp = (payload, keys) => {
|
||||
for (const key of keys) {
|
||||
const raw = payload[key];
|
||||
if (typeof raw === 'number' && Number.isFinite(raw))
|
||||
return raw;
|
||||
if (typeof raw === 'string') {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed))
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
export const normalizeTournamentSlug = (value) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed)
|
||||
return '';
|
||||
return trimmed
|
||||
.replace(/^https?:\/\/[^/]+\//i, '')
|
||||
.replace(/^tournaments\//i, '')
|
||||
.replace(/^\/+/, '');
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
export const getStringProp = (payload: unknown, key: string): string => {
|
||||
if (typeof payload !== 'object' || payload === null || !(key in payload)) return '';
|
||||
const value = (payload as Record<string, unknown>)[key];
|
||||
return typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
||||
};
|
||||
|
||||
export const getNumberProp = (payload: Record<string, unknown>, keys: string[]): number | null => {
|
||||
for (const key of keys) {
|
||||
const raw = payload[key];
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
|
||||
if (typeof raw === 'string') {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const normalizeTournamentSlug = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return '';
|
||||
return trimmed
|
||||
.replace(/^https?:\/\/[^/]+\//i, '')
|
||||
.replace(/^tournaments\//i, '')
|
||||
.replace(/^\/+/, '');
|
||||
};
|
||||
@@ -7,8 +7,8 @@
|
||||
"./node_modules/@types"
|
||||
],
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.extension.tsbuildinfo",
|
||||
"rootDir": "./src/extension",
|
||||
"outDir": "./extension",
|
||||
"rootDir": "./src",
|
||||
"outDir": "./",
|
||||
"verbatimModuleSyntax": true,
|
||||
},
|
||||
"include": [
|
||||
|
||||