3 Commits

Author SHA1 Message Date
Pandipipas 3a6289a2ea feat: implement start.gg OAuth integration and services
- Added start.gg OAuth server and session management in startgg.ts
- Implemented functions to fetch recent tournaments and tournament players from start.gg
- Created utility functions for string and country code handling
- Introduced Challonge OAuth server and services for tournament data fetching
- Refactored shared types and utility functions for better organization
- Updated scoreboard graphics to use new country resolution utilities
- Removed legacy startgg.ts file to streamline codebase
2026-06-04 17:42:44 +02:00
Pandipipas 71c18b479b feat: add architectural documentation for refactor process, including audit, rules, migration plan, session handoff, and target architecture 2026-06-04 17:07:01 +02:00
Pandipipas 8c270feb5b feat: enhance pack management and character handling; implement automatic registry refresh and logo display updates 2026-05-22 21:19:45 +02:00
35 changed files with 1275 additions and 1295 deletions
+60
View File
@@ -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.
+37
View File
@@ -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.
+47
View File
@@ -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.
+20
View File
@@ -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*.
+24
View File
@@ -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.
+53
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
export {};
+28
View File
@@ -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(/^\/+/, '');
};
@@ -6,6 +6,7 @@
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
import { computed, watch } from 'vue'; import { computed, watch } from 'vue';
import { getPackLogoUrl } from '../../../shared/pack-config';
import type { PackRegistryEntry } from '../../../shared/pack-types'; import type { PackRegistryEntry } from '../../../shared/pack-types';
import { usePackRegistry } from '../composables/usePackRegistry'; import { usePackRegistry } from '../composables/usePackRegistry';
@@ -48,15 +49,13 @@ const isError = computed(() => downloadState.value?.status === 'error');
const progress = computed(() => downloadState.value?.progress ?? 0); const progress = computed(() => downloadState.value?.progress ?? 0);
const logoUrl = computed(() => // Pre-install: show logo directly from Gitea (pack not on disk yet).
props.packEntry ? packRegistry.getLocalLogoUrl(props.packEntry.id) : '', // Update mode: pack is installed, serve from local /packs/ route.
); const logoSrc = computed(() => {
if (!props.packEntry) return '';
const giteaLogoUrl = computed(() => if (props.isUpdate) return packRegistry.getLocalLogoUrl(props.packEntry.id);
props.packEntry return getPackLogoUrl(props.packEntry.id);
? `${packRegistry.registry.value ? '' : ''}` // resolved from packEntry.logoPath via Gitea });
: '',
);
// Close automatically once download completes and emit so parent sets the game // Close automatically once download completes and emit so parent sets the game
watch(isDone, (done) => { watch(isDone, (done) => {
@@ -98,8 +97,15 @@ const close = () => emit('update:modelValue', false);
{{ packEntry.name }} {{ packEntry.name }}
</div> </div>
<div class="text-caption text-grey-5"> <div class="text-caption text-grey-5">
v{{ packEntry.version }} · {{ packEntry.characterCount }} personajes · <template v-if="isUpdate && updateInfo">
{{ packRegistry.formatBytes(packEntry.totalSizeBytes) }} 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>
</div> </div>
<QBtn <QBtn
@@ -112,16 +118,23 @@ const close = () => emit('update:modelValue', false);
/> />
</div> </div>
<!-- Gradient banner using the pack's palette --> <!-- Banner: logo del juego con gradiente de fallback -->
<div <div
class="pack-download-dialog__banner" class="pack-download-dialog__banner"
:style="{ :style="{
background: `linear-gradient(135deg, ${packEntry.palette.start}, ${packEntry.palette.end})`, 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 <QIcon
:name="isUpdate ? 'upgrade' : 'sports_esports'" :name="isUpdate ? 'upgrade' : 'sports_esports'"
size="48px" size="40px"
color="white" color="white"
class="pack-download-dialog__banner-icon" class="pack-download-dialog__banner-icon"
/> />
@@ -253,8 +266,18 @@ const close = () => emit('update:modelValue', false);
overflow: hidden; 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 { .pack-download-dialog__banner-icon {
opacity: 0.35; position: relative; /* above the logo */
opacity: 0.25;
} }
.pack-download-dialog__progress-section { .pack-download-dialog__progress-section {
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { inject, onMounted, ref } from 'vue'; import { inject, onMounted, onUnmounted, ref } from 'vue';
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame'; import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
import { usePackRegistry } from '../composables/usePackRegistry'; import { usePackRegistry } from '../composables/usePackRegistry';
import { t } from '../i18n'; import { t } from '../i18n';
@@ -18,12 +18,20 @@ const {
showDownloadDialog, showDownloadDialog,
} = inject(CHARACTER_GAME_KEY)!; } = inject(CHARACTER_GAME_KEY)!;
// Refresca el catálogo de Gitea al montar el panel. // 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. // Si Gitea no está disponible se usa la caché persistida del replicante.
onMounted(() => { onMounted(() => {
packRegistry.fetchRegistry(); packRegistry.fetchRegistry();
}); });
const refreshInterval = setInterval(() => {
packRegistry.fetchRegistry();
}, 15_000);
onUnmounted(() => {
clearInterval(refreshInterval);
});
const adjustLeftScore = (delta: number) => { const adjustLeftScore = (delta: number) => {
scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore + delta); scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore + delta);
}; };
@@ -188,27 +196,7 @@ const openUpdateDialog = (opt: import('../../../shared/pack-types').GameSelectOp
class="scoreboard-preview__action-btn" class="scoreboard-preview__action-btn"
@click="scoreboardStore.resetScores" @click="scoreboardStore.resetScores"
/> />
<!-- Botón para refrescar el catálogo de juegos desde Gitea -->
<QBtn
flat
dense
round
size="sm"
icon="refresh"
class="scoreboard-preview__action-btn"
@click="packRegistry.fetchRegistry()"
>
<QTooltip>Actualizar catálogo de juegos</QTooltip>
<!-- Badge con el número de packs que tienen actualización pendiente -->
<QBadge
v-if="packRegistry.updateCount.value > 0"
color="positive"
floating
rounded
>
{{ packRegistry.updateCount.value }}
</QBadge>
</QBtn>
</div> </div>
</div> </div>
@@ -39,7 +39,7 @@ export function useCharacterGame() {
* Populated from the pack registry when available; falls back to bundled games. * Populated from the pack registry when available; falls back to bundled games.
* GameSelectOption includes an `available` flag used to show the download icon. * GameSelectOption includes an `available` flag used to show the download icon.
*/ */
const fightingGameOptions = ref<GameSelectOption[]>(packRegistry.allGameOptions.value); const fightingGameOptions = ref<GameSelectOption[]>([]);
// Keep fightingGameOptions in sync when the registry updates // Keep fightingGameOptions in sync when the registry updates
watch( watch(
@@ -1,5 +1,5 @@
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { getCountryLabel, getCountryOptions } from '../../../shared/countries'; import { getCountryLabel, getCountryOptions } from '../../../shared/utils/countries';
import { locale } from '../i18n'; import { locale } from '../i18n';
/** /**
@@ -7,7 +7,6 @@
import { computed, ref, type ComputedRef, type InjectionKey } from 'vue'; import { computed, ref, type ComputedRef, type InjectionKey } from 'vue';
import { import {
BUNDLED_GAME_NAMES,
registerInstalledPack, registerInstalledPack,
unregisterInstalledPack, unregisterInstalledPack,
} from '../../../shared/fighting-characters'; } from '../../../shared/fighting-characters';
@@ -16,8 +15,7 @@ import type {
GameSelectOption, GameSelectOption,
PackDownloadState, PackDownloadState,
PackManifest, PackManifest,
PackRegistry, PackRegistry
PackRegistryEntry,
} from '../../../shared/pack-types'; } from '../../../shared/pack-types';
// ── NodeCG global type declarations ────────────────────────────────────────── // ── NodeCG global type declarations ──────────────────────────────────────────
@@ -110,11 +108,9 @@ const initReplicants = (): void => {
downloadStates.value = statesRep.value ?? {}; downloadStates.value = statesRep.value ?? {};
availableUpdates.value = updatesRep.value ?? {}; availableUpdates.value = updatesRep.value ?? {};
// Load manifests for packs already installed before this session // Load manifests for all installed packs
for (const id of installedPackIds.value) { for (const id of installedPackIds.value) {
if (!BUNDLED_GAME_NAMES.has(getGameNameById(id))) { loadInstalledManifest(id);
loadInstalledManifest(id);
}
} }
// Subscribe to changes // Subscribe to changes
@@ -130,9 +126,7 @@ const initReplicants = (): void => {
// Load manifests for newly installed packs // Load manifests for newly installed packs
const added = next.filter((id) => !prev.includes(id)); const added = next.filter((id) => !prev.includes(id));
for (const id of added) { for (const id of added) {
if (!BUNDLED_GAME_NAMES.has(getGameNameById(id))) { loadInstalledManifest(id);
loadInstalledManifest(id);
}
} }
// Unregister packs that were removed // Unregister packs that were removed
@@ -198,29 +192,13 @@ export const PACK_REGISTRY_KEY: InjectionKey<PackRegistryContext> = Symbol('pack
const buildAllGameOptions = () => const buildAllGameOptions = () =>
computed<GameSelectOption[]>(() => { computed<GameSelectOption[]>(() => {
if (!registry.value) { // Registry not loaded yet — return empty list
// Registry not loaded yet — surface only the bundled games as available if (!registry.value) return [];
return Array.from(BUNDLED_GAME_NAMES).map((name) => ({
label: name,
value: name,
available: true,
registryEntry: {
id: '',
name,
version: '',
totalSizeBytes: 0,
logoPath: '',
characterCount: 0,
palette: { start: '#334155', end: '#0f172a' },
bundled: true,
} satisfies PackRegistryEntry,
}));
}
return registry.value.packs.map((entry) => ({ return registry.value.packs.map((entry) => ({
label: entry.name, label: entry.name,
value: entry.name, value: entry.name,
available: entry.bundled || installedPackIds.value.includes(entry.id), available: installedPackIds.value.includes(entry.id),
registryEntry: entry, registryEntry: entry,
updateInfo: availableUpdates.value[entry.id], updateInfo: availableUpdates.value[entry.id],
})); }));
@@ -233,8 +211,8 @@ export function usePackRegistry(): PackRegistryContext {
const isGameAvailable = (gameName: string): boolean => { const isGameAvailable = (gameName: string): boolean => {
const entry = registry.value?.packs.find((p) => p.name === gameName); const entry = registry.value?.packs.find((p) => p.name === gameName);
if (!entry) return BUNDLED_GAME_NAMES.has(gameName); if (!entry) return false;
return entry.bundled || installedPackIds.value.includes(entry.id); return installedPackIds.value.includes(entry.id);
}; };
const getDownloadState = (packId: string): PackDownloadState => const getDownloadState = (packId: string): PackDownloadState =>
+1 -1
View File
@@ -2,7 +2,7 @@
import { useHead } from '@unhead/vue'; import { useHead } from '@unhead/vue';
import { useQuasar, type QTableColumn } from 'quasar'; import { useQuasar, type QTableColumn } from 'quasar';
import { computed, reactive, ref, watch } from 'vue'; 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 type { Schemas } from '../../../types';
import { useIntegration } from '../composables/useIntegration'; import { useIntegration } from '../composables/useIntegration';
import { locale, t } from '../i18n'; import { locale, t } from '../i18n';
+64
View File
@@ -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(),
);
};
+42
View File
@@ -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;
};
-505
View File
@@ -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');
}
});
+2 -2
View File
@@ -9,7 +9,7 @@ export default async (nodecg: NodeCGServerAPI) => {
set(nodecg); // set nodecg "context" before anything else set(nodecg); // set nodecg "context" before anything else
await import('./util/replicants.js'); // make sure replicants are set up await import('./util/replicants.js'); // make sure replicants are set up
await import('./example.js'); await import('./example.js');
await import('./startgg.js'); await import('./nodecg-bindings/startgg.js');
await import('./challonge.js'); await import('./nodecg-bindings/challonge.js');
await import('./pack-manager.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');
}
});
+91
View File
@@ -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');
}
});
+137
View File
@@ -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,
});
+139
View File
@@ -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,
});
+3 -1
View File
@@ -85,6 +85,7 @@ const reply = (ack: Acknowledgement | undefined, err: Error | null, result?: unk
const IMAGE_EXTENSIONS = ['png', 'webp', 'jpg', 'jpeg', 'avif'] as const; const IMAGE_EXTENSIONS = ['png', 'webp', 'jpg', 'jpeg', 'avif'] as const;
// Raíz del proyecto: 2 niveles por encima de extension/pack-manager.js // Raíz del proyecto: 2 niveles por encima de extension/pack-manager.js
// Usamos import.meta.url porque nodecg.bundleDir no está disponible cuando // Usamos import.meta.url porque nodecg.bundleDir no está disponible cuando
// NodeCG se usa como dependencia en lugar de servidor standalone. // NodeCG se usa como dependencia en lugar de servidor standalone.
@@ -211,6 +212,7 @@ const trySaveImage = async (
const checkForUpdates = (): void => { const checkForUpdates = (): void => {
const registry = packRegistryRep.value; const registry = packRegistryRep.value;
const installed = installedPacksRep.value ?? []; const installed = installedPacksRep.value ?? [];
if (!registry || installed.length === 0) { if (!registry || installed.length === 0) {
availableUpdatesRep.value = {}; availableUpdatesRep.value = {};
return; return;
@@ -372,7 +374,7 @@ nodecg.listenFor('updatePack', async (packId: unknown, ack: Acknowledgement | un
const packDir = path.join(packsDir, packId); const packDir = path.join(packsDir, packId);
const charsDir = path.join(packDir, 'characters'); const charsDir = path.join(packDir, 'characters');
// 2. Limpiar imágenes antiguas (evita residuos de personajes renombrados/eliminados) // 2. Limpiar imágenes antiguas para evitar residuos de personajes renombrados
if (fs.existsSync(charsDir)) { if (fs.existsSync(charsDir)) {
fs.rmSync(charsDir, { recursive: true, force: true }); fs.rmSync(charsDir, { recursive: true, force: true });
} }
+138
View File
@@ -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);
};
+115
View File
@@ -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());
};
-447
View File
@@ -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');
}
});
+1 -1
View File
@@ -2,7 +2,7 @@
import { useHead } from '@unhead/vue'; import { useHead } from '@unhead/vue';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants'; 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 { getCharactersByGame } from '../../shared/fighting-characters';
import type { Schemas } from '../../types'; import type { Schemas } from '../../types';
+1 -1
View File
@@ -2,7 +2,7 @@
import { useHead } from '@unhead/vue'; import { useHead } from '@unhead/vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants'; import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
import { resolveCountryCode } from '../../shared/countries'; import { resolveCountryCode } from '../../shared/utils/countries';
import type { Schemas } from '../../types'; import type { Schemas } from '../../types';
useHead({ title: 'Scoreboard' }); useHead({ title: 'Scoreboard' });
+35 -263
View File
@@ -1,11 +1,7 @@
// src/shared/fighting-characters.ts // src/shared/fighting-characters.ts
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Two sources of character data: // Todo el contenido de personajes viene de packs descargados desde Gitea.
// 1. BUNDLED — shipped with the app, images loaded at build time via // No hay datos bundled — el proyecto arranca vacío y se rellena en runtime.
// import.meta.glob (unchanged from before).
// 2. INSTALLED — downloaded from Gitea at runtime, registered via
// registerInstalledPack(). Images served by NodeCG from
// /assets/<bundleName>/packs/<packId>/characters/<slug>.<ext>
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
import { ref } from 'vue'; import { ref } from 'vue';
@@ -18,154 +14,43 @@ export interface FightingCharacterOption {
dlc?: boolean; dlc?: boolean;
} }
type GamePalette = readonly [startColor: string, endColor: string]; // ── Runtime registry ──────────────────────────────────────────────────────────
const DEFAULT_PLACEHOLDER_PALETTE: GamePalette = ['#334155', '#0f172a']; const installedPackCharacters: Record<string, FightingCharacterOption[]> = {};
const MAX_INITIALS = 2; const installedPackDefaults: Record<string, { leftCharacter: string; rightCharacter: string }> = {};
// ───────────────────────────────────────────────────────────────────────────── /**
// BUNDLED DATA * 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 characterNamesByGame: Record<string, string[]> = { /**
'2XKO': [ * Vacío — ya no hay juegos bundled.
'Ahri', 'Akali', 'Braum', 'Caitlyn', 'Darius', 'Ekko', * Mantenido por compatibilidad con usePackRegistry.
'Illaoi', 'Jinx', 'Senna', 'Teemo', 'Vi', 'Warwick', 'Yasuo', */
], export const BUNDLED_GAME_NAMES = new Set<string>();
'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',
],
};
const defaultCharacterPairByGame: Record<string, { leftCharacter: string; rightCharacter: string }> = { // ── Placeholder SVG ───────────────────────────────────────────────────────────
'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' },
};
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'],
};
const dlcCharactersByGame: Record<string, ReadonlySet<string>> = {
'FATAL FURY: City of the Wolves': new Set([
'Chun-Li', 'Cristiano Ronaldo', 'Ken Masters', 'Kenshiro',
'Nightmare Geese', 'Salvatore Ganacci', 'Vox Reaper',
]),
'Guilty Gear -Strive-': new Set([
'Goldlewis Dickinson', 'Jack-O', 'Happy Chaos', 'Baiken', 'Testament',
'Bridget', 'Sin Kiske', 'Bedman?', 'Asuka R. Kreutz', 'Johnny',
'Elphelt Valentine', 'A.B.A', 'Slayer', 'Dizzy', 'Venom',
'Lucy', 'Unika',
]),
'Mortal Kombat 1': new Set([
'Ermac', 'Homelander', 'Omni-Man', 'Peacemaker', 'Quan Chi', 'Tanya',
'Conan the Barbarian', 'Cyrax', 'Ghostface', 'Noob Saibot', 'Sektor',
'Shang Tsung', 'Takeda', 'T-1000',
]),
'Street Fighter 6': new Set([
'A.K.I.', 'Akuma', 'Bison', 'Ed',
'Alex', 'Elena', 'Mai', 'Sagat', 'Terry', 'Viper',
]),
'TEKKEN 8': new Set([
'Clive', 'Eddy', 'Heihachi', 'Lidia',
'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',
]),
};
// ─────────────────────────────────────────────────────────────────────────────
// Image resolution — BUNDLED
// ─────────────────────────────────────────────────────────────────────────────
const toSlug = (value: string): string =>
value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
const toDataUrl = (svg: string): string => const toDataUrl = (svg: string): string =>
`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
const buildCharacterPlaceholder = (game: string, character: string): string => { const buildPlaceholder = (game: string, character: string, start: string, end: string): string => {
const [startColor, endColor] = paletteByGame[game] ?? DEFAULT_PLACEHOLDER_PALETTE;
const initials = character const initials = character
.split(/\s+/) .split(/\s+/)
.map((part) => part[0]) .map((p) => p[0])
.join('') .join('')
.slice(0, MAX_INITIALS) .slice(0, 2)
.toUpperCase(); .toUpperCase();
const svg = ` const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 220" role="img" aria-label="${character}"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 220" role="img" aria-label="${character}">
<defs> <defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"> <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${startColor}"/> <stop offset="0%" stop-color="${start}"/>
<stop offset="100%" stop-color="${endColor}"/> <stop offset="100%" stop-color="${end}"/>
</linearGradient> </linearGradient>
</defs> </defs>
<rect width="480" height="220" fill="url(#bg)" rx="18"/> <rect width="480" height="220" fill="url(#bg)" rx="18"/>
@@ -173,107 +58,27 @@ const buildCharacterPlaceholder = (game: string, character: string): 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="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="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> <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( // ── Pack registration ─────────────────────────────────────────────────────────
'/src/shared/character-images/**/*.{png,jpg,jpeg,webp,avif,svg}',
{ eager: true, import: 'default', query: '?url' },
) as Record<string, string>;
const resolveImageKey = (path: string): string | null => {
const segments = path.split('/');
const gameFolder = segments.at(-2);
const filename = segments.at(-1);
if (!gameFolder || !filename) return null;
return `${gameFolder}/${filename.replace(/\.[^.]+$/, '')}`;
};
const characterImageByKey = Object.entries(characterImageModules).reduce<Record<string, string>>(
(acc, [path, url]) => {
const key = resolveImageKey(path);
if (key) acc[key] = url;
return acc;
},
{},
);
const getBundledCharacterImage = (game: string, character: string, slug: string): string => {
const gameSlug = toSlug(game);
const key = `${gameSlug}/${slug}`;
return characterImageByKey[key] ?? buildCharacterPlaceholder(game, character);
};
// ─────────────────────────────────────────────────────────────────────────────
// Compile bundled game options
// ─────────────────────────────────────────────────────────────────────────────
export const fightingCharactersByGame: Record<string, FightingCharacterOption[]> = Object.fromEntries(
Object.entries(characterNamesByGame).map(([game, characterNames]) => [
game,
characterNames.map((character) => {
const value = toSlug(character);
return {
label: character,
value,
image: getBundledCharacterImage(game, character, value),
dlc: dlcCharactersByGame[game]?.has(character) ?? false,
};
}),
]),
);
/** /**
* The set of game names that are bundled with the application. * Registra un pack instalado para que getCharactersByGame() lo devuelva.
* Used by usePackRegistry to determine if a pack needs to be downloaded. * Llamado por usePackRegistry cuando carga el manifest.json local de un pack.
*/
export const BUNDLED_GAME_NAMES = new Set(Object.keys(characterNamesByGame));
// ─────────────────────────────────────────────────────────────────────────────
// INSTALLED PACK REGISTRY (runtime, populated by usePackRegistry)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Runtime character data for packs that have been downloaded from Gitea.
* Keyed by game display name (same as PackManifest.name) so that
* getCharactersByGame() can look them up with the same key as bundled games.
*/
const installedPackCharacters: Record<string, FightingCharacterOption[]> = {};
const installedPackDefaults: Record<string, { leftCharacter: string; rightCharacter: string }> = {};
/**
* Incremented every time a pack is registered or unregistered.
* Composables subscribe to this ref so Vue re-evaluates computed values
* that depend on installedPackCharacters (which is a plain object, not reactive).
*/
export const installedPacksRevision = ref(0);
/**
* Registers an installed (downloaded) pack so that getCharactersByGame() and
* getDefaultCharactersByGame() return its data.
*
* Called by usePackRegistry when:
* - The composable mounts and an installed pack's manifest is read from disk.
* - A new pack finishes downloading.
*
* Images are served by NodeCG from /assets/<BUNDLE_NAME>/packs/<packId>/characters/.
* The function tries the most common extension; the browser will 404 gracefully
* for missing files (placeholder is shown by the img error handler in the template).
*/ */
export const registerInstalledPack = (manifest: PackManifest): void => { export const registerInstalledPack = (manifest: PackManifest): void => {
const { id, name, palette, characters, defaultPair } = manifest; const { id, name, palette, characters, defaultPair } = manifest;
const [startColor, endColor] = [palette.start, palette.end];
installedPackCharacters[name] = characters.map((char) => ({ installedPackCharacters[name] = characters.map((char) => ({
label: char.name, label: char.name,
value: char.slug, value: char.slug,
// Images are served at runtime by NodeCG's static asset handler
image: `/packs/${id}/characters/${char.slug}.png`, image: `/packs/${id}/characters/${char.slug}.png`,
dlc: char.dlc ?? false, dlc: char.dlc ?? false,
// Fallback placeholder uses the same palette as the manifest // Fallback inline por si la imagen no se encuentra en disco
_placeholder: buildInstalledPlaceholder(name, char.name, startColor, endColor), _placeholder: buildPlaceholder(name, char.name, palette.start, palette.end),
})); }));
if (defaultPair) { if (defaultPair) {
@@ -282,12 +87,13 @@ export const registerInstalledPack = (manifest: PackManifest): void => {
rightCharacter: defaultPair.right, rightCharacter: defaultPair.right,
}; };
} }
installedPacksRevision.value++; installedPacksRevision.value++;
}; };
/** /**
* Removes a previously registered installed pack. * Elimina un pack del registro en memoria.
* Called by usePackRegistry when a pack is uninstalled. * Llamado por usePackRegistry cuando el usuario desinstala un pack.
*/ */
export const unregisterInstalledPack = (gameName: string): void => { export const unregisterInstalledPack = (gameName: string): void => {
delete installedPackCharacters[gameName]; delete installedPackCharacters[gameName];
@@ -295,46 +101,12 @@ export const unregisterInstalledPack = (gameName: string): void => {
installedPacksRevision.value++; installedPacksRevision.value++;
}; };
const buildInstalledPlaceholder = ( // ── Public API ────────────────────────────────────────────────────────────────
game: string,
character: string,
startColor: string,
endColor: string,
): string => {
const initials = character
.split(/\s+/)
.map((p) => p[0])
.join('')
.slice(0, MAX_INITIALS)
.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}"/>
</linearGradient>
</defs>
<rect width="480" height="220" fill="url(#bg)" rx="18"/>
<circle cx="90" cy="110" r="64" fill="rgba(255,255,255,0.13)"/>
<text x="90" y="130" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="56" font-weight="700">${initials}</text>
<text x="170" y="96" fill="#e2e8f0" font-family="Arial, sans-serif" font-size="20" font-weight="700">${game}</text>
<text x="170" y="145" fill="#ffffff" font-family="Arial, sans-serif" font-size="38" font-weight="700">${character}</text>
</svg>`;
return toDataUrl(svg.trim());
};
// ─────────────────────────────────────────────────────────────────────────────
// Public API
// ─────────────────────────────────────────────────────────────────────────────
/** Returns the character list for a game, checking both bundled and installed packs. */
export const getCharactersByGame = (game: string): FightingCharacterOption[] => export const getCharactersByGame = (game: string): FightingCharacterOption[] =>
fightingCharactersByGame[game] ?? installedPackCharacters[game] ?? []; installedPackCharacters[game] ?? [];
/** Returns the default character pair for a game, checking both bundled and installed packs. */
export const getDefaultCharactersByGame = ( export const getDefaultCharactersByGame = (
game: string, game: string,
): { leftCharacter: string; rightCharacter: string } | undefined => ): { leftCharacter: string; rightCharacter: string } | undefined =>
defaultCharacterPairByGame[game] ?? installedPackDefaults[game]; installedPackDefaults[game];
+1
View File
@@ -0,0 +1 @@
export {};
+27
View File
@@ -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 };
+28
View File
@@ -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(/^\/+/, '');
};
+26
View File
@@ -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(/^\/+/, '');
};
+2 -2
View File
@@ -7,8 +7,8 @@
"./node_modules/@types" "./node_modules/@types"
], ],
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.extension.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.extension.tsbuildinfo",
"rootDir": "./src/extension", "rootDir": "./src",
"outDir": "./extension", "outDir": "./",
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
}, },
"include": [ "include": [