mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
Compare commits
3 Commits
antigravity
..
codex
| Author | SHA1 | Date | |
|---|---|---|---|
| b32c0e4560 | |||
| 02a108f983 | |||
| 225b2b36a2 |
@@ -136,6 +136,8 @@ dist
|
|||||||
/dashboard/
|
/dashboard/
|
||||||
/extension/
|
/extension/
|
||||||
/graphics/
|
/graphics/
|
||||||
|
/nodecg/
|
||||||
|
/shared/domain/
|
||||||
/shared/dist/
|
/shared/dist/
|
||||||
|
|
||||||
# Local runtime database
|
# Local runtime database
|
||||||
|
|||||||
@@ -1,60 +1,108 @@
|
|||||||
# Scoreko-dev: Auditoría de Arquitectura
|
# Architecture Audit
|
||||||
|
|
||||||
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.
|
Este documento resume el estado arquitectónico actual de Scoreko y debe usarse como referencia para el refactor. No redefine la arquitectura objetivo; documenta el diagnóstico existente y los riesgos detectados.
|
||||||
|
|
||||||
## Análisis de la Estructura Actual
|
## Estado 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/`.
|
La repo es un bundle NodeCG con Vite, Vue 3, Quasar, Pinia y `vite-plugin-nodecg`.
|
||||||
|
|
||||||
### Distribución de Carpetas
|
| Área | Ruta | Responsabilidad actual |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Extension | `src/extension` | Lógica server NodeCG, OAuth, brackets y pack manager. |
|
||||||
|
| Dashboard | `src/dashboard/scoreko-dev` | App Quasar/Pinia para control. |
|
||||||
|
| Graphics | `src/graphics` | Overlays broadcast: `scoreboard`, `scoreboard-2xko`, `commentary`. |
|
||||||
|
| Browser shared | `src/browser_shared/replicants.ts` | Acceso browser a replicants. |
|
||||||
|
| Shared | `src/shared` | Tipos, utilidades, países, packs y personajes. |
|
||||||
|
| Schemas | `schemas` | Schemas de replicants principales. |
|
||||||
|
| Build outputs | `dashboard`, `graphics`, `extension`, `shared/dist` | Outputs ignorados por git. |
|
||||||
|
|
||||||
| Carpeta | Descripción |
|
## Replicants
|
||||||
| :--- | :--- |
|
|
||||||
| `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)
|
### Declarados por Schema
|
||||||
|
|
||||||
- **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`.
|
- `scoreboard`
|
||||||
- **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`).
|
- `players`
|
||||||
|
- `commentary`
|
||||||
|
- `graphicsSettings`
|
||||||
|
- `exampleReplicant`
|
||||||
|
|
||||||
### Componentes Monolíticos
|
### Declarados Solo en Código
|
||||||
|
|
||||||
Existen componentes excesivamente grandes que mezclan responsabilidades:
|
- `installedPacks`
|
||||||
|
- `packRegistry`
|
||||||
|
- `downloadStates`
|
||||||
|
- `availableUpdates`
|
||||||
|
|
||||||
- **`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.
|
### Problema Principal
|
||||||
- **`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
|
El contrato realtime está dividido entre schemas y código runtime. Parte vive en `schemas`, y parte en `pack-manager` o `usePackRegistry`. Esto dificulta validar cambios, regenerar tipos y entender qué estado público existe realmente.
|
||||||
|
|
||||||
- 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.
|
## Flujo de Datos
|
||||||
- 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).
|
|
||||||
|
|
||||||
---
|
1. El dashboard escribe en Pinia stores.
|
||||||
|
2. Los stores sincronizan con replicants mediante `store-sync.ts`.
|
||||||
|
3. Los overlays leen replicants directamente desde `browser_shared/replicants.ts`.
|
||||||
|
4. La extensión escucha mensajes NodeCG como `startgg:*`, `challonge:*` y `downloadPack`.
|
||||||
|
5. La extensión actualiza replicants o disco.
|
||||||
|
6. `graphicsSettings.scoreboardSkin` redirige entre overlays `scoreboard` y `scoreboard-2xko`.
|
||||||
|
|
||||||
## Diagnóstico
|
## Zonas Grandes o de Riesgo
|
||||||
|
|
||||||
### Problemas Críticos
|
| Archivo | Riesgo |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/dashboard/scoreko-dev/views/Players.vue` | Vista demasiado grande: tabla, CRUD, import/export, integraciones y dialogs. |
|
||||||
|
| `src/dashboard/scoreko-dev/components/PlayerSidePanel.vue` | UI duplicada para left/right. |
|
||||||
|
| `src/dashboard/scoreko-dev/views/Settings.vue` | Mezcla idioma, shortcuts, OAuth y tokens. |
|
||||||
|
| `src/dashboard/scoreko-dev/composables/useIntegration.ts` | Mezcla estado UI, localStorage, polling OAuth, importación y cleanup. |
|
||||||
|
| `src/extension/pack-manager.ts` | Mezcla config, tipos, FS, HTTP static, downloads, updates, replicants y handlers. |
|
||||||
|
| `src/graphics/scoreboard/main.vue` | Lógica, layout, animaciones, flags y CSS mezclados. |
|
||||||
|
| `src/graphics/scoreboard-2xko/main.vue` | Mismo riesgo que `scoreboard/main.vue`. |
|
||||||
|
|
||||||
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.
|
## Hallazgos Técnicos
|
||||||
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
|
- No se detectaron ciclos de imports en los archivos TS/Vue revisados.
|
||||||
|
- Sí hay accesos frágiles al entorno NodeCG:
|
||||||
|
- `nodecg` global en composables.
|
||||||
|
- `NodeCG.Replicant` declarado manualmente.
|
||||||
|
- Imports a `package.json` desde UI.
|
||||||
|
- Tipos generados desalineados.
|
||||||
|
- Acceso directo a replicants fuera de stores o servicios.
|
||||||
|
|
||||||
- **Mantenibilidad Reducida**: Agregar nuevas integraciones (ej. smash.gg, Toornament) requerirá copiar/pegar más bloques monolíticos y añadir más SVGs hardcodeados.
|
## Problemas Reales
|
||||||
- **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
|
- La frontera NodeCG está rota: `browser_shared/replicants.ts`, stores, `Graphics.vue`, overlays y `usePackRegistry` acceden a NodeCG de formas distintas.
|
||||||
|
- Los contratos realtime no están centralizados.
|
||||||
|
- `pack-manager.ts` necesita reescritura controlada, no parcheo incremental.
|
||||||
|
- `usePackRegistry.ts` y `fighting-characters.ts` necesitan reescritura controlada.
|
||||||
|
- `startgg.ts` y `challonge.ts` duplican estructura: OAuth mode, proxy exchange, session polling API y parsing básico.
|
||||||
|
- `Players.vue` y `Settings.vue` son feature modules completos metidos en vistas.
|
||||||
|
- Los overlays son de alto riesgo visual y deben tocarse con mucho cuidado.
|
||||||
|
- Hay dead code claro: `exampleReplicant`, `example.ts`, `ExampleType` y schema de ejemplo.
|
||||||
|
- `src/types/schemas/configschema.d.ts` está stale: contiene `exampleProperty`, pero `configschema.json` ya no lo define.
|
||||||
|
- `lint` falla por `_id` en `Players.vue` y `_config` en `startgg.ts` y `challonge.ts`.
|
||||||
|
- El resto de lint son warnings de formato Vue.
|
||||||
|
|
||||||
|
## Impacto a Medio y Largo Plazo
|
||||||
|
|
||||||
|
- Añadir providers o skins duplicará lógica.
|
||||||
|
- Contributors externos no sabrán dónde tocar: store, composable, replicant o extensión.
|
||||||
|
- Refactors visuales pueden romper overlays porque no hay separación entre view model y presentación.
|
||||||
|
- El sistema de packs es el mayor riesgo por acoplar descarga, estado realtime, manifests, FS y UI.
|
||||||
|
|
||||||
|
## Zonas a Preservar
|
||||||
|
|
||||||
|
- La idea de `syncStateWithReplicant`.
|
||||||
|
- Stores `scoreboard`, `players` y `commentary` como base razonable.
|
||||||
|
- `oauth-server.ts` como pieza reusable.
|
||||||
|
- `countries.ts`, que está bien encapsulado.
|
||||||
|
- Schemas JSON como fuente de tipos.
|
||||||
|
- UI Quasar existente, preservando comportamiento visual mientras se divide.
|
||||||
|
|
||||||
|
## Baseline de Checks
|
||||||
|
|
||||||
|
| Check | Estado |
|
||||||
|
| --- | --- |
|
||||||
|
| `vue-tsc` | Pasa. |
|
||||||
|
| `tsc` | Pasa. |
|
||||||
|
| `lint` | Falla con 3 errores reales y 243 warnings de formato. |
|
||||||
|
|
||||||
- **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.
|
|
||||||
|
|||||||
@@ -1,37 +1,97 @@
|
|||||||
# Reglas Arquitectónicas de Implementación
|
# Architecture Rules
|
||||||
|
|
||||||
> [!IMPORTANT]
|
Estas reglas son obligatorias para cualquier refactor posterior. Están pensadas para mantener boundaries claros, reducir acoplamiento y preservar comportamiento durante la migración.
|
||||||
> 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**
|
## TypeScript
|
||||||
- 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**
|
- No usar `any`.
|
||||||
- Los componentes de Vue (`.vue`) no deben tener llamadas `fetch`, lógica compleja de parseo, o cálculos pesados.
|
- Usar `unknown` solo en boundaries.
|
||||||
- Su sección `<script>` debe limitarse exclusivamente a invocar *composables* o *stores*, y exponer datos al `template`.
|
- Normalizar `unknown` inmediatamente al entrar al dominio.
|
||||||
|
- No duplicar tipos entre extension y browser.
|
||||||
|
- Todo replicant nuevo debe tener schema y tipo generado.
|
||||||
|
- Regenerar tipos siempre desde schemas.
|
||||||
|
|
||||||
3. **COMPONENTES PEQUEÑOS Y "DUMB"**
|
## Boundaries NodeCG
|
||||||
- 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**
|
- No acceder directamente a replicants fuera de `nodecg/browser` o `nodecg/extension`.
|
||||||
- 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.
|
- No usar `nodecg.sendMessage` directo en componentes o composables de feature.
|
||||||
|
- No usar `nodecg.Replicant` directo fuera de la capa `nodecg`.
|
||||||
|
- No depender de `nodecg` global salvo dentro del boundary correspondiente.
|
||||||
|
- Centralizar nombres de replicants en `replicantNames`.
|
||||||
|
- Centralizar nombres de messages en una capa de messages.
|
||||||
|
|
||||||
5. **SIN WRAPPERS INÚTILES**
|
## Shared y Dominio
|
||||||
- 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**
|
- `shared/domain` solo puede contener funciones puras, tipos, normalizadores y mapping.
|
||||||
- Utilizar exclusivamente convenciones estándar de Vue 3: Composition API pura, `<script setup>` y el ecosistema reactivo estándar de Pinia.
|
- `shared/domain` no puede importar Vue.
|
||||||
- Nada de patrones híbridos ni inventados.
|
- `shared/domain` no puede importar NodeCG.
|
||||||
|
- `shared/domain` no puede acceder al DOM.
|
||||||
|
- Preferir funciones puras para normalización, parsing, derivación y mapping.
|
||||||
|
- Validar o normalizar datos externos al cruzar boundaries.
|
||||||
|
|
||||||
7. **BORRAR SOBRE CONSERVAR (Limpieza de AI Slop)**
|
## Dashboard
|
||||||
- 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**
|
- Los stores mantienen estado de aplicación y sync con replicants.
|
||||||
- El uso de `watch` debe ser el mínimo indispensable.
|
- Los stores no deben contener UI compleja.
|
||||||
- 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.
|
- Las vistas no deben implementar features completos.
|
||||||
|
- Los componentes deben ser pequeños y orientados a UI.
|
||||||
|
- Los composables de feature no deben hablar directamente con NodeCG.
|
||||||
|
- Toda lógica de negocio debe vivir en dominio, services o stores según corresponda.
|
||||||
|
|
||||||
|
## Extension
|
||||||
|
|
||||||
|
- `extension/modules` debe contener handlers pequeños registrados desde bootstrap explícito.
|
||||||
|
- No mezclar FS, HTTP, replicants, downloads y parsing en el mismo módulo.
|
||||||
|
- Todo handler NodeCG debe declarar claramente qué message escucha y qué replicants toca.
|
||||||
|
- Todo acceso a `nodecg.listenFor`, `nodecg.Replicant`, `nodecg.mount` y logging debe pasar por `nodecg/extension`.
|
||||||
|
|
||||||
|
## Packs
|
||||||
|
|
||||||
|
- Pack registry, manifests, downloads y estado instalado deben compartir tipos comunes.
|
||||||
|
- Todo replicant de packs debe tener schema.
|
||||||
|
- Mantener nombres y defaults actuales durante la migración.
|
||||||
|
- Validar manifests en el boundary antes de exponerlos al dominio o UI.
|
||||||
|
- No mantener estado mutable de módulo opaco para packs instalados.
|
||||||
|
- No usar `ref` de Vue dentro de shared.
|
||||||
|
|
||||||
|
## Integraciones
|
||||||
|
|
||||||
|
- Providers como Start.gg y Challonge deben compartir patrón de OAuth, session polling y parsing.
|
||||||
|
- Cada provider debe tener cliente propio y normalizadores propios.
|
||||||
|
- El flujo OAuth debe apoyarse en `oauth-server.ts` cuando aplique.
|
||||||
|
- Todo timer o polling debe tener cleanup.
|
||||||
|
- Todo listener debe tener cleanup.
|
||||||
|
|
||||||
|
## Graphics y Overlays
|
||||||
|
|
||||||
|
- Los overlays se refactorizan al final.
|
||||||
|
- Primero preservar píxel y comportamiento; después limpiar internals.
|
||||||
|
- No cambiar CSS, SVG, posiciones o markup sensible sin baseline visual.
|
||||||
|
- Extraer view models antes de deduplicar layout.
|
||||||
|
- Helpers compartidos de flags, score animation y text fitting deben vivir en `graphics/shared`.
|
||||||
|
|
||||||
|
## Side Effects
|
||||||
|
|
||||||
|
- No side effects en imports salvo bootstrap explícito.
|
||||||
|
- No estado mutable de módulo salvo singleton justificado y documentado.
|
||||||
|
- Todo timer/listener debe registrar cleanup.
|
||||||
|
- No wrappers vacíos.
|
||||||
|
- No inventar un patrón si una función simple basta.
|
||||||
|
|
||||||
|
## Naming
|
||||||
|
|
||||||
|
| Elemento | Regla | Ejemplo |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Replicants | `camelCase`, constantes en `replicantNames` | `graphicsSettings` |
|
||||||
|
| Messages | Namespaced por dominio | `packs:download` |
|
||||||
|
| Stores | `use<Feature>Store` | `useScoreboardStore` |
|
||||||
|
| Services | `create<Domain>Service` o `create<Provider>Client` | `createPackService` |
|
||||||
|
| View models | `use<Overlay>ViewModel` | `useScoreboardOverlayViewModel` |
|
||||||
|
|
||||||
|
## Compatibilidad
|
||||||
|
|
||||||
|
- Mantener comportamiento público durante la migración.
|
||||||
|
- Mantener nombres públicos hasta completar el refactor.
|
||||||
|
- No romper overlays sin baseline visual y verificación.
|
||||||
|
- Priorizar eliminar legacy muerto antes que envolverlo.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
+181
-41
@@ -1,47 +1,187 @@
|
|||||||
# Plan de Migración
|
# Migration Plan
|
||||||
|
|
||||||
> [!WARNING]
|
Este plan define el orden de migración. Debe ejecutarse de forma secuencial para reducir riesgo, preservar comportamiento y evitar reescrituras amplias sin baseline.
|
||||||
> 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
|
## Principios
|
||||||
- **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)
|
- Mantener nombres públicos y comportamiento hasta completar la migración.
|
||||||
- **Acciones**:
|
- Congelar comportamiento antes de mover responsabilidades.
|
||||||
- **Reescritura controlada de integraciones**: Dividir `startgg.ts` en:
|
- Eliminar legacy muerto antes de envolverlo.
|
||||||
- `services/startgg.ts` (lógica de negocio y transformaciones).
|
- Separar boundaries antes de reescribir módulos complejos.
|
||||||
- `api/startgg.ts` (GraphQL / HTTP requests).
|
- Tocar overlays al final y con verificación visual.
|
||||||
- `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)
|
## Secuencia
|
||||||
- **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)
|
| Paso | Objetivo | Tipo |
|
||||||
- **Acciones**:
|
| --- | --- | --- |
|
||||||
- **Reescritura/División controlada de `Players.vue`**:
|
| 1 | Congelar comportamiento con screenshots de overlays, fixtures de replicants, build, typecheck y lint baseline. | Baseline |
|
||||||
- Extraer modales a componentes independientes (ej. `ImportDialog.vue`, `PlayerEditDialog.vue`).
|
| 2 | Quitar `example*`, regenerar schema types y eliminar `.js` redundantes en `src/shared` si no se usan. | Limpieza |
|
||||||
- Extraer los selectores de integración a componentes puros (`StartGGPanel.vue`, `ChallongePanel.vue`).
|
| 3 | Crear `nodecg/browser` y `nodecg/extension` sin cambiar comportamiento. | Boundary |
|
||||||
- Reemplazar los SVGs "hardcodeados" en el template por un componente dedicado o usar la librería de iconos de Quasar.
|
| 4 | Añadir schemas para replicants de packs manteniendo nombres y defaults exactos. | Contratos |
|
||||||
- Extraer partes de vistas monolíticas (`PlayerSidePanel.vue`) en sub-componentes especializados.
|
| 5 | Extraer tipos/config de packs a `shared` y ajustar `tsconfig.extension` para no duplicar. | Shared |
|
||||||
- **Riesgo**: Medio. Afecta directamente a la UI. Es crítico asegurar que los eventos (`emits`) se propagen y conecten correctamente.
|
| 6 | Reescribir controladamente `pack-manager`. | Reescritura |
|
||||||
|
| 7 | Reescribir controladamente `usePackRegistry` y `fighting-characters`. | Reescritura |
|
||||||
|
| 8 | Dividir `useIntegration`. | Modularización |
|
||||||
|
| 9 | Dividir `Players.vue`. | Modularización |
|
||||||
|
| 10 | Dividir `Settings.vue`. | Modularización |
|
||||||
|
| 11 | Refactor suave de dashboard scoreboard para eliminar duplicación left/right. | Modularización |
|
||||||
|
| 12 | Extraer view models y helpers de overlays sin tocar CSS/markup al inicio. | Overlays |
|
||||||
|
| 13 | Añadir tests puros para normalizadores y lógica de dominio. | Tests |
|
||||||
|
| 14 | Añadir verificación visual Playwright para overlays principales. | Visual QA |
|
||||||
|
|
||||||
|
## Detalle por Fase
|
||||||
|
|
||||||
|
### 1. Congelar Comportamiento
|
||||||
|
|
||||||
|
Crear una baseline antes de refactorizar:
|
||||||
|
|
||||||
|
- Screenshots de `scoreboard`, `scoreboard-2xko` y `commentary`.
|
||||||
|
- Fixtures representativos de replicants.
|
||||||
|
- Resultado actual de build y typecheck.
|
||||||
|
- Resultado actual de lint, incluyendo los 3 errores reales conocidos.
|
||||||
|
|
||||||
|
### 2. Limpiar Dead y Stale Code
|
||||||
|
|
||||||
|
Eliminar código que no representa comportamiento productivo:
|
||||||
|
|
||||||
|
- `exampleReplicant`.
|
||||||
|
- `example.ts`.
|
||||||
|
- `ExampleType`.
|
||||||
|
- Schema de ejemplo.
|
||||||
|
- Tipos generados stale.
|
||||||
|
- `.js` redundantes en `src/shared` si se confirma que no se usan.
|
||||||
|
|
||||||
|
### 3. Crear Boundaries NodeCG
|
||||||
|
|
||||||
|
Introducir APIs sin cambiar comportamiento:
|
||||||
|
|
||||||
|
- `src/nodecg/browser/replicants.ts`
|
||||||
|
- `src/nodecg/browser/messages.ts`
|
||||||
|
- `src/nodecg/extension/replicants.ts`
|
||||||
|
- `src/nodecg/extension/messages.ts`
|
||||||
|
- `src/nodecg/extension/context.ts`
|
||||||
|
|
||||||
|
El objetivo es centralizar acceso a replicants, messages, logging y NodeCG globals.
|
||||||
|
|
||||||
|
### 4. Centralizar Contratos Realtime
|
||||||
|
|
||||||
|
Añadir schemas para:
|
||||||
|
|
||||||
|
- `installedPacks`
|
||||||
|
- `packRegistry`
|
||||||
|
- `downloadStates`
|
||||||
|
- `availableUpdates`
|
||||||
|
|
||||||
|
Los nombres y defaults deben mantenerse exactamente para no romper dashboard, extensión ni overlays.
|
||||||
|
|
||||||
|
### 5. Extraer Shared de Packs
|
||||||
|
|
||||||
|
Mover a `shared`:
|
||||||
|
|
||||||
|
- Tipos de manifest.
|
||||||
|
- Tipos de registry.
|
||||||
|
- Tipos de estado de descarga.
|
||||||
|
- Config derivada común.
|
||||||
|
- Validación ligera en boundaries.
|
||||||
|
|
||||||
|
No duplicar tipos entre extension y browser.
|
||||||
|
|
||||||
|
### 6. Reescritura Controlada de Pack Manager
|
||||||
|
|
||||||
|
Separar `pack-manager.ts` en módulos pequeños:
|
||||||
|
|
||||||
|
| Módulo | Responsabilidad |
|
||||||
|
| --- | --- |
|
||||||
|
| Registry client | Fetch y normalización del registry remoto. |
|
||||||
|
| Downloader | Descargas, progreso y errores. |
|
||||||
|
| Disk store | Lectura/escritura en disco. |
|
||||||
|
| Static mount | Exposición HTTP de assets instalados. |
|
||||||
|
| Handlers | Registro de mensajes NodeCG. |
|
||||||
|
| Replicant sync | Actualización centralizada de replicants. |
|
||||||
|
|
||||||
|
### 7. Reescritura de Pack Registry Runtime
|
||||||
|
|
||||||
|
Rehacer `usePackRegistry` y `fighting-characters` para:
|
||||||
|
|
||||||
|
- Quitar estado mutable de módulo opaco.
|
||||||
|
- Evitar `ref` en shared.
|
||||||
|
- Modelar packs instalados como estado explícito.
|
||||||
|
- Cargar manifests mediante boundaries claros.
|
||||||
|
- Eliminar comentarios que contradicen el estado real de assets.
|
||||||
|
|
||||||
|
### 8. Dividir Integraciones
|
||||||
|
|
||||||
|
Extraer `useIntegration` en piezas:
|
||||||
|
|
||||||
|
- `nodecgMessageClient`
|
||||||
|
- `oauthClient`
|
||||||
|
- `temporaryPlayers`
|
||||||
|
- `tournamentImport`
|
||||||
|
|
||||||
|
Cada pieza debe tener cleanup explícito para timers/listeners.
|
||||||
|
|
||||||
|
### 9. Dividir Players
|
||||||
|
|
||||||
|
Extraer desde `Players.vue`:
|
||||||
|
|
||||||
|
- `PlayersTable`
|
||||||
|
- `PlayerEditorDialog`
|
||||||
|
- `IntegrationImportCard`
|
||||||
|
- `ImportPlayersDialog`
|
||||||
|
|
||||||
|
La vista debe coordinar el feature, no contener la implementación completa.
|
||||||
|
|
||||||
|
### 10. Dividir Settings
|
||||||
|
|
||||||
|
Extraer desde `Settings.vue`:
|
||||||
|
|
||||||
|
- `LanguageSettings`
|
||||||
|
- `ShortcutSettings`
|
||||||
|
- `IntegrationSettings`
|
||||||
|
|
||||||
|
Mantener UI Quasar y comportamiento actual.
|
||||||
|
|
||||||
|
### 11. Refactor Suave de Scoreboard Dashboard
|
||||||
|
|
||||||
|
Eliminar duplicación left/right con subcomponentes pequeños, sin cambiar el comportamiento público ni el layout principal.
|
||||||
|
|
||||||
|
### 12. Overlays al Final
|
||||||
|
|
||||||
|
Primero extraer sin modificar presentación:
|
||||||
|
|
||||||
|
- View models.
|
||||||
|
- Helpers de flags.
|
||||||
|
- Helpers de score animation.
|
||||||
|
- Helpers de text fitting.
|
||||||
|
|
||||||
|
Después de verificar baseline visual, deduplicar internals.
|
||||||
|
|
||||||
|
### 13. Tests Puros
|
||||||
|
|
||||||
|
Añadir tests para:
|
||||||
|
|
||||||
|
- Normalizadores.
|
||||||
|
- Pack registry.
|
||||||
|
- Shortcut parsing.
|
||||||
|
- Country resolving.
|
||||||
|
- Bracket round formatting.
|
||||||
|
|
||||||
|
### 14. Verificación Visual
|
||||||
|
|
||||||
|
Añadir Playwright para overlays principales:
|
||||||
|
|
||||||
|
- `scoreboard`.
|
||||||
|
- `scoreboard-2xko`.
|
||||||
|
- `commentary`.
|
||||||
|
|
||||||
|
Debe validar screenshots o checks visuales estables antes de tocar CSS sensible.
|
||||||
|
|
||||||
|
## Clasificación de Trabajo
|
||||||
|
|
||||||
|
| Categoría | Incluye |
|
||||||
|
| --- | --- |
|
||||||
|
| Automatizable | Moves/imports, schema generation, lint autofix, snapshots. |
|
||||||
|
| Reescritura controlada | Packs y registry runtime. |
|
||||||
|
| División | `Players.vue`, `Settings.vue`, overlays. |
|
||||||
|
| Conservar | Quasar UI, Pinia, schemas, `oauth-server`, concepto de `store-sync`, layout visual de overlays. |
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
@@ -1,20 +1,56 @@
|
|||||||
# Summary: Phase 1 (Base Architecture)
|
# Phase 1 Summary
|
||||||
|
|
||||||
## Objetivos Completados
|
## Scope
|
||||||
- **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
|
Executed the base architecture phase only. This phase focused on structure, boundaries, shared contracts and compatibility without changing UX, overlay visuals or large feature logic.
|
||||||
- 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
|
## Completed
|
||||||
- Avanzar a la **Fase 2**: Refactor del Estado del Dashboard (Stores), simplificando `store-sync.ts` e hidratando Pinia directamente desde los *Replicants*.
|
|
||||||
|
- Added the `src/nodecg` boundary:
|
||||||
|
- `src/nodecg/browser`
|
||||||
|
- `src/nodecg/extension`
|
||||||
|
- centralized `replicantNames`
|
||||||
|
- centralized `messageNames`
|
||||||
|
- Moved browser replicant access out of `src/browser_shared`.
|
||||||
|
- Moved dashboard stores to `src/dashboard/stores`.
|
||||||
|
- Moved pure country helpers to `src/shared/domain/players`.
|
||||||
|
- Moved pack config and pack types to `src/shared/domain/packs`.
|
||||||
|
- Added pack replicant schemas:
|
||||||
|
- `installedPacks`
|
||||||
|
- `packRegistry`
|
||||||
|
- `downloadStates`
|
||||||
|
- `availableUpdates`
|
||||||
|
- Added generated TypeScript declarations for the new pack schemas.
|
||||||
|
- Removed dead example code:
|
||||||
|
- `exampleReplicant` schema/type
|
||||||
|
- `ExampleType`
|
||||||
|
- `src/extension/example.ts`
|
||||||
|
- Removed redundant stale JS files from `src/shared`.
|
||||||
|
- Updated extension bootstrap to use an explicit NodeCG context boundary.
|
||||||
|
- Routed browser messages through `src/nodecg/browser/messages.ts`.
|
||||||
|
- Routed extension message registration through `src/nodecg/extension/messages.ts`.
|
||||||
|
- Routed pack replicant creation through NodeCG pack boundary services.
|
||||||
|
- Updated build config so generated NodeCG/shared extension outputs are ignored and cleaned.
|
||||||
|
|
||||||
|
## Preserved
|
||||||
|
|
||||||
|
- No overlay UX, CSS, SVG, layout or animation logic was intentionally changed.
|
||||||
|
- No large dashboard view was split or rewritten.
|
||||||
|
- `pack-manager.ts` behavior was preserved; only imports, types, replicant/message names and boundaries were normalized.
|
||||||
|
- Public NodeCG message names were kept unchanged for compatibility.
|
||||||
|
- Public replicant names and defaults were kept unchanged.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `pnpm.cmd exec vue-tsc -p tsconfig.browser.json --noEmit`: passed.
|
||||||
|
- `pnpm.cmd exec tsc -b tsconfig.extension.json --pretty false`: passed.
|
||||||
|
- `pnpm.cmd exec eslint`: passed with 0 errors and existing Vue formatting warnings.
|
||||||
|
- `pnpm.cmd run build`: passed.
|
||||||
|
|
||||||
|
## Remaining For Later Phases
|
||||||
|
|
||||||
|
- Controlled rewrite of `pack-manager.ts`.
|
||||||
|
- Controlled rewrite of `usePackRegistry` and `fighting-characters.ts`.
|
||||||
|
- Formal provider module split for Start.gg and Challonge.
|
||||||
|
- Splitting `Players.vue` and `Settings.vue`.
|
||||||
|
- Overlay view models and visual baseline work.
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Phase 2 Summary
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Executed the state and replicants phase only.
|
||||||
|
|
||||||
|
This phase focused on isolating state logic, normalizing Pinia stores, encapsulating browser-side replicant access, and moving side effects behind services without changing UX, visual design, overlay CSS, or public NodeCG contracts.
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
|
||||||
|
- Added pure state/domain modules:
|
||||||
|
- `src/shared/domain/scoreboard`
|
||||||
|
- `src/shared/domain/commentary`
|
||||||
|
- `src/shared/domain/graphics`
|
||||||
|
- `src/shared/domain/players/state.ts`
|
||||||
|
- `src/shared/domain/packs/characters.ts`
|
||||||
|
- Moved normalization and pure state transitions out of dashboard stores.
|
||||||
|
- Replaced direct dashboard replicant imports with `src/dashboard/services/replicant-state-service.ts`.
|
||||||
|
- Added `useGraphicsSettingsStore` and moved dashboard graphics skin writes through the store.
|
||||||
|
- Reworked scoreboard, players and commentary stores to use shared domain normalizers and service-based replicant sync.
|
||||||
|
- Replaced the pack registry singleton composable with a normalized `usePacksStore`.
|
||||||
|
- Moved pack replicant listeners and NodeCG pack messages into `src/dashboard/services/pack-service.ts`.
|
||||||
|
- Removed Vue reactivity and mutable pack registration from `src/shared/fighting-characters.ts`.
|
||||||
|
- Modeled installed pack manifests as explicit store state instead of hidden module state.
|
||||||
|
- Centralized registry auto-refresh timer in the packs store.
|
||||||
|
- Routed integration NodeCG messages through `src/dashboard/services/integration-message-service.ts`.
|
||||||
|
- Added `src/graphics/shared/services/replicated-state.ts` so graphics read replicants through a service layer.
|
||||||
|
- Removed the redundant `src/dashboard/stores/store-sync.ts`.
|
||||||
|
|
||||||
|
## Preserved
|
||||||
|
|
||||||
|
- Public replicant names were unchanged.
|
||||||
|
- Public message names were unchanged.
|
||||||
|
- Existing dashboard UX was preserved.
|
||||||
|
- Overlay markup, CSS, positioning and animation logic were not intentionally changed.
|
||||||
|
- The existing `usePackRegistry` import path remains as a compatibility wrapper over the packs store.
|
||||||
|
- The legacy `src/shared/fighting-characters.ts` path remains as a compatibility export, but no longer owns mutable runtime state.
|
||||||
|
|
||||||
|
## Realtime Flow After This Phase
|
||||||
|
|
||||||
|
```text
|
||||||
|
schemas
|
||||||
|
-> nodecg/browser
|
||||||
|
-> dashboard services / graphics services
|
||||||
|
-> Pinia stores or overlay computed state
|
||||||
|
-> components
|
||||||
|
```
|
||||||
|
|
||||||
|
Pack runtime flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pack replicants
|
||||||
|
-> pack service
|
||||||
|
-> packs store
|
||||||
|
-> pack registry compatibility composable
|
||||||
|
-> game / character UI
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `pnpm.cmd exec vue-tsc -p tsconfig.browser.json --noEmit`: passed.
|
||||||
|
- `pnpm.cmd exec tsc -b tsconfig.extension.json --pretty false`: passed.
|
||||||
|
- `pnpm.cmd exec eslint`: passed with 0 errors and existing Vue formatting warnings.
|
||||||
|
- `pnpm.cmd run build`: passed.
|
||||||
|
- Searched dashboard, graphics and shared for direct NodeCG/message/replicant imports:
|
||||||
|
- remaining browser NodeCG access is contained in services and `nodecg/browser`.
|
||||||
|
- direct component/view replicant imports were removed.
|
||||||
|
- Searched for `any` in touched runtime areas:
|
||||||
|
- no new TypeScript `any` usage was added.
|
||||||
|
|
||||||
|
## Notes and Limits
|
||||||
|
|
||||||
|
- This phase did not split large views like `Players.vue` or `Settings.vue`.
|
||||||
|
- This phase did not refactor overlay internals beyond replacing direct replicant imports with a read service.
|
||||||
|
- This phase did not rewrite extension-side `pack-manager.ts`.
|
||||||
|
- This phase did not rename public messages to the future canonical names; compatibility was preserved.
|
||||||
|
- Existing Vue lint warnings remain formatting-only and were not addressed because they are outside this phase.
|
||||||
|
|
||||||
|
## Remaining For Later Phases
|
||||||
|
|
||||||
|
- Controlled rewrite of `pack-manager.ts`.
|
||||||
|
- Full split of `useIntegration` into provider clients, OAuth client, temporary players and import modules.
|
||||||
|
- Divide `Players.vue` and `Settings.vue`.
|
||||||
|
- Extract overlay view models and visual helpers after visual baseline.
|
||||||
|
- Add tests for pure normalizers and pack state derivations.
|
||||||
@@ -1,24 +1,70 @@
|
|||||||
# Session Handoff: Refactor NodeCG Scoreboard
|
# Session Handoff
|
||||||
|
|
||||||
Este documento sirve como registro de estado y transferencia de contexto para cualquier agente o desarrollador en futuras sesiones de trabajo.
|
Este handoff resume el contexto que debe asumir cualquier sesión futura antes de continuar el refactor. El análisis arquitectónico ya está hecho; no debe repetirse desde cero.
|
||||||
|
|
||||||
## Estado Actual
|
## Estado de la Sesión
|
||||||
- **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)
|
- No se habían modificado archivos antes de crear esta documentación.
|
||||||
Para cualquier duda, decisión arquitectónica, o estructuración de código durante el refactor, consulta **EXCLUSIVAMENTE** el documento:
|
- Se leyó la estructura del proyecto, configs, schemas, extensión, dashboard, overlays y shared.
|
||||||
- [TARGET_ARCHITECTURE.md](./TARGET_ARCHITECTURE.md)
|
- `vue-tsc` pasa.
|
||||||
|
- `tsc` pasa.
|
||||||
|
- `lint` falla con 3 errores reales y 243 warnings de formato.
|
||||||
|
|
||||||
Además, asegúrate de seguir las directrices dictadas en:
|
## Documentación Creada
|
||||||
- [ARCHITECTURE_RULES.md](./ARCHITECTURE_RULES.md)
|
|
||||||
|
|
||||||
## Próximos Pasos (Next Actions)
|
| Documento | Propósito |
|
||||||
La próxima sesión debe comenzar con la ejecución del `MIGRATION_PLAN.md`, ejecutando los pasos de forma estrictamente secuencial:
|
| --- | --- |
|
||||||
|
| `docs/refactor/ARCHITECTURE_AUDIT.md` | Diagnóstico del estado actual y riesgos. |
|
||||||
|
| `docs/refactor/MIGRATION_PLAN.md` | Orden secuencial de migración. |
|
||||||
|
| `docs/refactor/ARCHITECTURE_RULES.md` | Reglas accionables para implementación posterior. |
|
||||||
|
| `docs/refactor/TARGET_ARCHITECTURE.md` | Source of truth de la arquitectura objetivo. |
|
||||||
|
| `docs/refactor/SESSION_HANDOFF.md` | Contexto operativo para futuras sesiones. |
|
||||||
|
|
||||||
1. **Revisar [MIGRATION_PLAN.md](./MIGRATION_PLAN.md) -> Paso 1.**
|
## Source of Truth
|
||||||
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.
|
Para futuras sesiones:
|
||||||
|
|
||||||
|
1. Usar `TARGET_ARCHITECTURE.md` como referencia principal.
|
||||||
|
2. Aplicar siempre `ARCHITECTURE_RULES.md`.
|
||||||
|
3. Ejecutar `MIGRATION_PLAN.md` en orden.
|
||||||
|
4. Consultar `ARCHITECTURE_AUDIT.md` solo para entender el diagnóstico original.
|
||||||
|
|
||||||
|
## Próximo Paso Recomendado
|
||||||
|
|
||||||
|
El siguiente paso técnico, cuando se decida continuar, es iniciar el Paso 1 del plan:
|
||||||
|
|
||||||
|
- Congelar comportamiento.
|
||||||
|
- Capturar screenshots de overlays.
|
||||||
|
- Crear fixtures de replicants.
|
||||||
|
- Registrar baseline de build, typecheck y lint.
|
||||||
|
|
||||||
|
No empezar moviendo código antes de tener esa baseline.
|
||||||
|
|
||||||
|
## Riesgos a Recordar
|
||||||
|
|
||||||
|
- El sistema de packs es el área de mayor riesgo.
|
||||||
|
- Los overlays son sensibles a cambios visuales y deben tocarse al final.
|
||||||
|
- La frontera NodeCG debe centralizarse antes de reescribir features.
|
||||||
|
- Los replicants de packs deben formalizarse con schemas antes de limpiar runtime.
|
||||||
|
- `Players.vue` y `Settings.vue` deben dividirse, no reescribirse desde cero.
|
||||||
|
|
||||||
|
## Checks Conocidos
|
||||||
|
|
||||||
|
| Check | Resultado |
|
||||||
|
| --- | --- |
|
||||||
|
| `vue-tsc` | Pasa. |
|
||||||
|
| `tsc` | Pasa. |
|
||||||
|
| `lint` | Falla con 3 errores reales. |
|
||||||
|
|
||||||
|
Errores lint reales conocidos:
|
||||||
|
|
||||||
|
- `_id` en `Players.vue`.
|
||||||
|
- `_config` en `startgg.ts`.
|
||||||
|
- `_config` en `challonge.ts`.
|
||||||
|
|
||||||
|
Los demás avisos conocidos son warnings de formato Vue.
|
||||||
|
|
||||||
|
## Instrucción para Futuras Sesiones
|
||||||
|
|
||||||
|
No reanalizar el proyecto desde cero salvo que el código haya cambiado de forma sustancial. Continuar desde estos documentos y ejecutar el plan en orden.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
@@ -1,53 +1,203 @@
|
|||||||
# Arquitectura Objetivo (Target Architecture)
|
# Target Architecture
|
||||||
|
|
||||||
> [!IMPORTANT]
|
Este documento es la source of truth para la arquitectura objetivo del refactor. Las futuras sesiones deben alinearse con esta estructura y con las reglas de `ARCHITECTURE_RULES.md`.
|
||||||
> 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
|
## Objetivo
|
||||||
|
|
||||||
La aplicación se dividirá estrictamente en las siguientes capas lógicas:
|
Crear una arquitectura simple y realista que:
|
||||||
|
|
||||||
1. **Capa NodeCG (Bindings)**: Archivos cuya *única* responsabilidad es declarar `nodecg.listenFor` (backend) o importar `nodecg.Replicant` (frontend).
|
- Centralice la frontera NodeCG.
|
||||||
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`.
|
- Centralice contratos realtime.
|
||||||
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.
|
- Separe dominio puro de UI y runtime.
|
||||||
4. **Capa de UI (Dumb Components)**: Componentes Vue puramente presentacionales que solo reciben `props` y emiten `events`.
|
- Permita añadir providers, packs y skins sin duplicación.
|
||||||
5. **Capa de Orquestación (Smart Components / Composables)**: Vistas y composables que conectan los Stores y/o NodeCG con los Dumb Components.
|
- Preserve el comportamiento visual de overlays durante la migración.
|
||||||
|
|
||||||
## Estructura de Carpetas Propuesta
|
## Estructura Objetivo
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/
|
src/
|
||||||
├── browser_shared/
|
shared/
|
||||||
│ ├── replicants.ts # Declaraciones puras
|
schemas/
|
||||||
│ └── useReplicant.ts # (NUEVO) Composable unificado para hidratar Vue desde NodeCG
|
types/
|
||||||
├── shared/
|
domain/
|
||||||
│ ├── types/ # Tipos estrictos compartidos
|
scoreboard/
|
||||||
│ └── utils/ # Helpers de dominio puros (ej. formateo)
|
players/
|
||||||
├── extension/ # Backend NodeCG
|
commentary/
|
||||||
│ ├── index.ts # Entry point
|
packs/
|
||||||
│ ├── nodecg-bindings/ # Registro exclusivo de nodecg.listenFor()
|
integrations/
|
||||||
│ ├── services/ # Lógica de negocio pura (StartGGService, ChallongeService)
|
utils/
|
||||||
│ ├── api/ # Llamadas HTTP/GraphQL
|
nodecg/
|
||||||
│ └── oauth/ # Manejo de flujos de autenticación OAuth aislados
|
browser/
|
||||||
├── dashboard/
|
replicants.ts
|
||||||
│ └── scoreko-dev/
|
messages.ts
|
||||||
│ ├── components/ # UI (Small, dumb components)
|
extension/
|
||||||
│ ├── composables/ # Lógica orquestada y reutilizable
|
context.ts
|
||||||
│ ├── features/ # (NUEVO) Dominio agrupado (ej. /players, /integrations)
|
replicants.ts
|
||||||
│ ├── stores/ # Pinia stores (Fuente de la verdad UI)
|
messages.ts
|
||||||
│ └── views/ # Smart components (Orquestadores)
|
extension/
|
||||||
└── graphics/
|
modules/
|
||||||
├── shared/ # (NUEVO) Componentes y composables compartidos entre gráficos
|
packs/
|
||||||
│ ├── directives/ # ej. v-fit-text
|
integrations/
|
||||||
│ └── composables/ # ej. useScoreAnimation, useFlags
|
startgg/
|
||||||
├── scoreboard/
|
challonge/
|
||||||
│ ├── components/ # Componentes segregados (PlayerName.vue, Score.vue, BackgroundPanel.vue)
|
oauth/
|
||||||
│ └── App.vue # Orquestador principal del scoreboard
|
dashboard/
|
||||||
└── scoreboard-2xko/
|
app/
|
||||||
|
features/
|
||||||
|
scoreboard/
|
||||||
|
players/
|
||||||
|
graphics/
|
||||||
|
settings/
|
||||||
|
integrations/
|
||||||
|
packs/
|
||||||
|
stores/
|
||||||
|
ui/
|
||||||
|
graphics/
|
||||||
|
shared/
|
||||||
|
composables/
|
||||||
|
view-models/
|
||||||
|
assets/
|
||||||
|
scoreboard/
|
||||||
|
scoreboard-2xko/
|
||||||
|
commentary/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Reglas Arquitectónicas de Diseño
|
## Boundaries
|
||||||
|
|
||||||
|
| Boundary | Puede hacer | No puede hacer |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `shared/domain` | Tipos, funciones puras, normalizadores, mapping. | Importar Vue, NodeCG o DOM. |
|
||||||
|
| `nodecg/browser` | Acceso browser a replicants y messages. | Contener lógica de negocio o UI. |
|
||||||
|
| `nodecg/extension` | Acceso server a `nodecg.Replicant`, `listenFor`, `mount` y logging. | Implementar lógica específica de features. |
|
||||||
|
| `dashboard/stores` | Estado de aplicación y sync con replicants. | Contener UI compleja. |
|
||||||
|
| `dashboard/features` | Componentes y composables por feature. | Acceder directamente a NodeCG. |
|
||||||
|
| `graphics/shared` | View models, helpers visuales compartidos, assets compartidos. | Cambiar contratos realtime. |
|
||||||
|
| `extension/modules` | Handlers y servicios pequeños registrados desde bootstrap. | Mezclar responsabilidades sin separación. |
|
||||||
|
|
||||||
|
## Flujo Realtime Objetivo
|
||||||
|
|
||||||
|
```text
|
||||||
|
schemas
|
||||||
|
-> generated types
|
||||||
|
-> nodecg/browser + nodecg/extension
|
||||||
|
-> dashboard stores / extension modules / graphics view models
|
||||||
|
```
|
||||||
|
|
||||||
|
Reglas del flujo:
|
||||||
|
|
||||||
|
- Todo replicant persistente o realtime tiene schema.
|
||||||
|
- Los tipos se generan desde schemas.
|
||||||
|
- Dashboard y graphics no crean replicants directamente.
|
||||||
|
- Extension modules no exponen replicants sin pasar por `nodecg/extension`.
|
||||||
|
|
||||||
|
## Replicants
|
||||||
|
|
||||||
|
### Fuente de Verdad
|
||||||
|
|
||||||
|
Los schemas son la fuente de verdad para todos los replicants.
|
||||||
|
|
||||||
|
### Replicants a Mantener
|
||||||
|
|
||||||
|
- `scoreboard`
|
||||||
|
- `players`
|
||||||
|
- `commentary`
|
||||||
|
- `graphicsSettings`
|
||||||
|
|
||||||
|
### Replicants de Packs a Formalizar
|
||||||
|
|
||||||
|
- `installedPacks`
|
||||||
|
- `packRegistry`
|
||||||
|
- `downloadStates`
|
||||||
|
- `availableUpdates`
|
||||||
|
|
||||||
|
### Replicants a Eliminar
|
||||||
|
|
||||||
|
- `exampleReplicant`
|
||||||
|
|
||||||
|
## Messages
|
||||||
|
|
||||||
|
Los messages deben estar namespaced por dominio.
|
||||||
|
|
||||||
|
| Dominio | Ejemplos |
|
||||||
|
| --- | --- |
|
||||||
|
| Packs | `packs:fetchRegistry`, `packs:download` |
|
||||||
|
| Start.gg | `integrations:startgg:createOAuthSession` |
|
||||||
|
| Challonge | `integrations:challonge:createOAuthSession` |
|
||||||
|
|
||||||
|
Los componentes y composables de feature no deben llamar `nodecg.sendMessage` directamente. Deben usar clientes o services definidos en el boundary browser.
|
||||||
|
|
||||||
|
## Shared Domain
|
||||||
|
|
||||||
|
`shared/domain` contiene lógica reusable sin runtime:
|
||||||
|
|
||||||
|
- `scoreboard`: normalización de estado, mapping de jugadores, derivaciones de marcador.
|
||||||
|
- `players`: normalizadores, import/export, validación ligera.
|
||||||
|
- `commentary`: estado y mapping de comentaristas.
|
||||||
|
- `packs`: manifests, registry, installed packs y derivaciones.
|
||||||
|
- `integrations`: tipos normalizados, parsing básico y modelos comunes.
|
||||||
|
|
||||||
|
## Extension Modules
|
||||||
|
|
||||||
|
La extensión debe registrarse desde un bootstrap explícito y delegar en módulos:
|
||||||
|
|
||||||
|
| Módulo | Responsabilidad |
|
||||||
|
| --- | --- |
|
||||||
|
| `packs` | Registry, downloads, disk store, static mount, replicant sync y handlers. |
|
||||||
|
| `integrations/startgg` | Cliente Start.gg, OAuth session polling y parsing. |
|
||||||
|
| `integrations/challonge` | Cliente Challonge, OAuth session polling y parsing. |
|
||||||
|
| `oauth` | Reuso de `oauth-server.ts` y flujos comunes OAuth. |
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
El dashboard se organiza por features:
|
||||||
|
|
||||||
|
- `scoreboard`
|
||||||
|
- `players`
|
||||||
|
- `graphics`
|
||||||
|
- `settings`
|
||||||
|
- `integrations`
|
||||||
|
- `packs`
|
||||||
|
|
||||||
|
Las vistas coordinan features. Los componentes implementan UI. Los stores mantienen estado y sincronización.
|
||||||
|
|
||||||
|
## Graphics
|
||||||
|
|
||||||
|
Los overlays deben leer estado mediante view models:
|
||||||
|
|
||||||
|
- `useScoreboardOverlayViewModel`
|
||||||
|
- `useCommentaryOverlayViewModel`
|
||||||
|
|
||||||
|
`graphics/shared` contiene:
|
||||||
|
|
||||||
|
- Composables visuales.
|
||||||
|
- View models.
|
||||||
|
- Helpers de flags.
|
||||||
|
- Helpers de score animation.
|
||||||
|
- Helpers de text fitting.
|
||||||
|
- Assets compartidos.
|
||||||
|
|
||||||
|
El layout visual existente se conserva hasta tener verificación visual estable.
|
||||||
|
|
||||||
|
## Naming Canónico
|
||||||
|
|
||||||
|
| Tipo | Convención | Ejemplo |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Replicant | `camelCase` | `graphicsSettings` |
|
||||||
|
| Replicant constants | `replicantNames` | `replicantNames.graphicsSettings` |
|
||||||
|
| Message | `<domain>:<action>` | `packs:download` |
|
||||||
|
| Integration message | `integrations:<provider>:<action>` | `integrations:startgg:createOAuthSession` |
|
||||||
|
| Store | `use<Feature>Store` | `usePlayersStore` |
|
||||||
|
| Service | `create<ServiceName>` | `createPackService` |
|
||||||
|
| Provider client | `create<Provider>Client` | `createStartggClient` |
|
||||||
|
| Overlay view model | `use<Overlay>OverlayViewModel` | `useScoreboardOverlayViewModel` |
|
||||||
|
|
||||||
|
## Arquitectura a Preservar
|
||||||
|
|
||||||
|
- Pinia como estado del dashboard.
|
||||||
|
- Quasar como UI principal.
|
||||||
|
- Schemas JSON como fuente de tipos.
|
||||||
|
- `syncStateWithReplicant` como concepto.
|
||||||
|
- `oauth-server.ts` como base reusable.
|
||||||
|
- `countries.ts` como utilidad encapsulada.
|
||||||
|
- Layout visual de overlays hasta completar verificación visual.
|
||||||
|
|
||||||
- **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
-1
@@ -16,7 +16,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"autofix": "eslint --fix",
|
"autofix": "eslint --fix",
|
||||||
"prebuild": "trash ./extension && trash ./node_modules/.vite && trash ./shared/dist && trash ./dashboard && trash ./graphics",
|
"prebuild": "trash ./extension && trash ./nodecg && trash ./node_modules/.vite && trash ./shared/domain && trash ./shared/dist && trash ./dashboard && trash ./graphics",
|
||||||
"build": "vite build && tsc -b tsconfig.extension.json",
|
"build": "vite build && tsc -b tsconfig.extension.json",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"schema-types": "nodecg schema-types",
|
"schema-types": "nodecg schema-types",
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"installedVersion": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"latestVersion": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["installedVersion", "latestVersion"]
|
||||||
|
},
|
||||||
|
"default": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["idle", "fetching-manifest", "downloading", "done", "error"]
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["status", "progress"]
|
||||||
|
},
|
||||||
|
"default": {}
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"exampleProperty": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "exampleString"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"exampleProperty"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"default": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": ["object", "null"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"schemaVersion": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"packs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"version": { "type": "string" },
|
||||||
|
"totalSizeBytes": { "type": "number" },
|
||||||
|
"logoPath": { "type": "string" },
|
||||||
|
"characterCount": { "type": "integer" },
|
||||||
|
"palette": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"start": { "type": "string" },
|
||||||
|
"end": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["start", "end"]
|
||||||
|
},
|
||||||
|
"bundled": { "type": "boolean" }
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"version",
|
||||||
|
"totalSizeBytes",
|
||||||
|
"logoPath",
|
||||||
|
"characterCount",
|
||||||
|
"palette",
|
||||||
|
"bundled"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["schemaVersion", "updatedAt", "packs"],
|
||||||
|
"default": null
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
export {};
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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(/^\/+/, '');
|
|
||||||
};
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { useReplicant } from 'nodecg-vue-composable';
|
|
||||||
import type { Schemas } from '../types';
|
|
||||||
|
|
||||||
// YOU MUST CHANGE THIS TO YOUR BUNDLE'S NAME!
|
|
||||||
const thisBundle = 'scoreko-dev';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is where you can declare all of your replicants to import easily into other (browser based) files.
|
|
||||||
* "useReplicant" is a helper composable to make accessing/modifying replicants easier.
|
|
||||||
* For more information see https://github.com/Dan-Shields/nodecg-vue-composable
|
|
||||||
*/
|
|
||||||
export const exampleReplicant = useReplicant<Schemas.ExampleReplicant>('exampleReplicant', thisBundle);
|
|
||||||
export const playersReplicant = useReplicant<Schemas.Players>('players', thisBundle);
|
|
||||||
export const scoreboardReplicant = useReplicant<Schemas.Scoreboard>('scoreboard', thisBundle);
|
|
||||||
export const graphicsSettingsReplicant = useReplicant<Schemas.GraphicsSettings>('graphicsSettings', thisBundle);
|
|
||||||
|
|
||||||
export const commentaryReplicant = useReplicant<Schemas.Commentary>('commentary', thisBundle);
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { t } from '../i18n';
|
import { t } from '../i18n';
|
||||||
import { useScoreboardStore } from '../stores/scoreboard';
|
import { useScoreboardStore } from '../../stores/scoreboard';
|
||||||
|
|
||||||
const scoreboardStore = useScoreboardStore();
|
const scoreboardStore = useScoreboardStore();
|
||||||
|
|
||||||
@@ -83,12 +83,12 @@ const updateRound = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (customActive.value) {
|
if (customActive.value) {
|
||||||
scoreboardStore.scoreboard.round = customText.value.trim();
|
scoreboardStore.setRound(customText.value.trim());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefix = bracketSide.value ? `${bracketSide.value} ` : '';
|
const prefix = bracketSide.value ? `${bracketSide.value} ` : '';
|
||||||
scoreboardStore.scoreboard.round = `${prefix}${stage.value}`.trim();
|
scoreboardStore.setRound(`${prefix}${stage.value}`.trim());
|
||||||
};
|
};
|
||||||
|
|
||||||
watch([stage, bracketSide, customText, customActive], updateRound);
|
watch([stage, bracketSide, customText, customActive], updateRound);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { stripTwitterPrefix } from '../../../shared/domain/commentary';
|
||||||
import { t } from '../i18n';
|
import { t } from '../i18n';
|
||||||
import { useCommentaryStore } from '../stores/commentary';
|
import { useCommentaryStore } from '../../stores/commentary';
|
||||||
|
|
||||||
const commentaryStore = useCommentaryStore();
|
const commentaryStore = useCommentaryStore();
|
||||||
|
|
||||||
@@ -17,25 +18,18 @@ const twitterRules = [
|
|||||||
!val || TWITTER_VALID_CHARS.test(val) || t('commentaryTwitterInvalidChars'),
|
!val || TWITTER_VALID_CHARS.test(val) || t('commentaryTwitterInvalidChars'),
|
||||||
];
|
];
|
||||||
|
|
||||||
function stripAt(value: string): string {
|
|
||||||
return value.startsWith('@') ? value.slice(1) : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLeftTwitterInput(value: string | number | null) {
|
function handleLeftTwitterInput(value: string | number | null) {
|
||||||
commentaryStore.leftCommentatorTwitter = value ? stripAt(String(value)) : '';
|
commentaryStore.leftCommentatorTwitter = value ? stripTwitterPrefix(String(value)) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRightTwitterInput(value: string | number | null) {
|
function handleRightTwitterInput(value: string | number | null) {
|
||||||
commentaryStore.rightCommentatorTwitter = value ? stripAt(String(value)) : '';
|
commentaryStore.rightCommentatorTwitter = value ? stripTwitterPrefix(String(value)) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Clear ---
|
// --- Clear ---
|
||||||
|
|
||||||
function clearAll() {
|
function clearAll() {
|
||||||
commentaryStore.leftCommentator = '';
|
commentaryStore.clearCommentary();
|
||||||
commentaryStore.leftCommentatorTwitter = '';
|
|
||||||
commentaryStore.rightCommentator = '';
|
|
||||||
commentaryStore.rightCommentatorTwitter = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAnythingFilled = computed(() =>
|
const isAnythingFilled = computed(() =>
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import { computed, watch } from 'vue';
|
import { computed, watch } from 'vue';
|
||||||
import { getPackLogoUrl } from '../../../shared/pack-config';
|
import { getPackLogoUrl } from '../../../shared/domain/packs/config';
|
||||||
import type { PackRegistryEntry } from '../../../shared/pack-types';
|
import type { PackRegistryEntry } from '../../../shared/domain/packs/types';
|
||||||
import { usePackRegistry } from '../composables/usePackRegistry';
|
import { usePackRegistry } from '../composables/usePackRegistry';
|
||||||
|
|
||||||
// ── Props / emits ─────────────────────────────────────────────────────────────
|
// ── Props / emits ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { computed, inject } from 'vue';
|
|||||||
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
|
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
|
||||||
import { usePlayerSide } from '../composables/usePlayerSide';
|
import { usePlayerSide } from '../composables/usePlayerSide';
|
||||||
import { t } from '../i18n';
|
import { t } from '../i18n';
|
||||||
import { useScoreboardStore } from '../stores/scoreboard';
|
import { useScoreboardStore } from '../../stores/scoreboard';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Props
|
// Props
|
||||||
@@ -74,8 +74,7 @@ const character = computed({
|
|||||||
? scoreboardStore.scoreboard.leftCharacter
|
? scoreboardStore.scoreboard.leftCharacter
|
||||||
: scoreboardStore.scoreboard.rightCharacter),
|
: scoreboardStore.scoreboard.rightCharacter),
|
||||||
set: (v) => {
|
set: (v) => {
|
||||||
if (isLeft.value) scoreboardStore.scoreboard.leftCharacter = v;
|
scoreboardStore.setSideCharacter(props.side, v);
|
||||||
else scoreboardStore.scoreboard.rightCharacter = v;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ 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';
|
||||||
import { useScoreboardStore } from '../stores/scoreboard';
|
import { useScoreboardStore } from '../../stores/scoreboard';
|
||||||
import GamePackDownloadDialog from './GamePackDownloadDialog.vue';
|
import GamePackDownloadDialog from './GamePackDownloadDialog.vue';
|
||||||
|
|
||||||
const scoreboardStore = useScoreboardStore();
|
const scoreboardStore = useScoreboardStore();
|
||||||
@@ -21,36 +21,32 @@ const {
|
|||||||
// Refresca el catálogo al montar y luego cada 15 segundos automáticamente.
|
// 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.startRegistryRefresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshInterval = setInterval(() => {
|
|
||||||
packRegistry.fetchRegistry();
|
|
||||||
}, 15_000);
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
clearInterval(refreshInterval);
|
packRegistry.stopRegistryRefresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
const adjustLeftScore = (delta: number) => {
|
const adjustLeftScore = (delta: number) => {
|
||||||
scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore + delta);
|
scoreboardStore.adjustScore('left', delta);
|
||||||
};
|
};
|
||||||
|
|
||||||
const adjustRightScore = (delta: number) => {
|
const adjustRightScore = (delta: number) => {
|
||||||
scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore + delta);
|
scoreboardStore.adjustScore('right', delta);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Tras una descarga exitosa, activa el juego en el store. */
|
/** Tras una descarga exitosa, activa el juego en el store. */
|
||||||
const onPackDownloaded = (gameName: string) => {
|
const onPackDownloaded = (gameName: string) => {
|
||||||
scoreboardStore.scoreboard.game = gameName;
|
scoreboardStore.setGame(gameName);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Estado del diálogo de actualización ───────────────────────────────────────
|
// ── Estado del diálogo de actualización ───────────────────────────────────────
|
||||||
const pendingUpdateEntry = ref<import('../../../shared/pack-types').PackRegistryEntry | null>(null);
|
const pendingUpdateEntry = ref<import('../../../shared/domain/packs/types').PackRegistryEntry | null>(null);
|
||||||
const pendingUpdateInfo = ref<{ installedVersion: string; latestVersion: string } | undefined>(undefined);
|
const pendingUpdateInfo = ref<{ installedVersion: string; latestVersion: string } | undefined>(undefined);
|
||||||
const showUpdateDialog = ref(false);
|
const showUpdateDialog = ref(false);
|
||||||
|
|
||||||
const openUpdateDialog = (opt: import('../../../shared/pack-types').GameSelectOption, event: Event) => {
|
const openUpdateDialog = (opt: import('../../../shared/domain/packs/types').GameSelectOption, event: Event) => {
|
||||||
event.stopPropagation(); // evitar que el QItem cambie la selección
|
event.stopPropagation(); // evitar que el QItem cambie la selección
|
||||||
pendingUpdateEntry.value = opt.registryEntry;
|
pendingUpdateEntry.value = opt.registryEntry;
|
||||||
pendingUpdateInfo.value = opt.updateInfo;
|
pendingUpdateInfo.value = opt.updateInfo;
|
||||||
|
|||||||
@@ -13,14 +13,14 @@
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import { computed, ref, watch, type InjectionKey, type Ref } from 'vue';
|
import { computed, ref, watch, type InjectionKey, type Ref } from 'vue';
|
||||||
import { getCharactersByGame, getDefaultCharactersByGame, installedPacksRevision } from '../../../shared/fighting-characters';
|
import type { FightingCharacterOption } from '../../../shared/domain/packs/characters';
|
||||||
import type { GameSelectOption, PackRegistryEntry } from '../../../shared/pack-types';
|
import type { GameSelectOption, PackRegistryEntry } from '../../../shared/domain/packs/types';
|
||||||
import { useScoreboardStore } from '../stores/scoreboard';
|
import { useScoreboardStore } from '../../stores/scoreboard';
|
||||||
import { usePackRegistry } from './usePackRegistry';
|
import { usePackRegistry } from './usePackRegistry';
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type CharacterOption = ReturnType<typeof getCharactersByGame>[number];
|
export type CharacterOption = FightingCharacterOption;
|
||||||
export type CharacterGameContext = ReturnType<typeof useCharacterGame>;
|
export type CharacterGameContext = ReturnType<typeof useCharacterGame>;
|
||||||
export const CHARACTER_GAME_KEY: InjectionKey<CharacterGameContext> = Symbol('characterGame');
|
export const CHARACTER_GAME_KEY: InjectionKey<CharacterGameContext> = Symbol('characterGame');
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ export function useCharacterGame() {
|
|||||||
*/
|
*/
|
||||||
const handleGameSelect = (gameName: string) => {
|
const handleGameSelect = (gameName: string) => {
|
||||||
if (!gameName) {
|
if (!gameName) {
|
||||||
scoreboardStore.scoreboard.game = '';
|
scoreboardStore.setGame('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!packRegistry.isGameAvailable(gameName)) {
|
if (!packRegistry.isGameAvailable(gameName)) {
|
||||||
@@ -72,17 +72,13 @@ export function useCharacterGame() {
|
|||||||
// Do NOT update the store — the game isn't installed
|
// Do NOT update the store — the game isn't installed
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scoreboardStore.scoreboard.game = gameName;
|
scoreboardStore.setGame(gameName);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Character state ───────────────────────────────────────────────────────
|
// ── Character state ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const characterOptions = computed(() => {
|
const characterOptions = computed(() => {
|
||||||
// Subscribing to installedPacksRevision forces Vue to re-evaluate this
|
return packRegistry.getCharactersByGame(scoreboardStore.scoreboard.game);
|
||||||
// computed whenever a pack is registered/unregistered at runtime, even
|
|
||||||
// though scoreboardStore.scoreboard.game itself hasn't changed.
|
|
||||||
void installedPacksRevision.value;
|
|
||||||
return getCharactersByGame(scoreboardStore.scoreboard.game);
|
|
||||||
});
|
});
|
||||||
const leftCharacterOptions = ref<CharacterOption[]>([]);
|
const leftCharacterOptions = ref<CharacterOption[]>([]);
|
||||||
const rightCharacterOptions = ref<CharacterOption[]>([]);
|
const rightCharacterOptions = ref<CharacterOption[]>([]);
|
||||||
@@ -155,11 +151,11 @@ export function useCharacterGame() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = getCharactersByGame(newGame);
|
const options = packRegistry.getCharactersByGame(newGame);
|
||||||
|
|
||||||
// If the game is set but has no options yet, the pack is still loading
|
// If the game is set but has no options yet, the pack is still loading
|
||||||
// (installed pack whose registerInstalledPack() hasn't run yet).
|
// (installed pack whose manifest has not been loaded into the pack store yet).
|
||||||
// Bail out — the installedPacksRevision watcher below will restore state
|
// Bail out — the characterOptions watcher below will restore state
|
||||||
// once the pack becomes available.
|
// once the pack becomes available.
|
||||||
if (newGame && options.length === 0) return;
|
if (newGame && options.length === 0) return;
|
||||||
|
|
||||||
@@ -176,7 +172,7 @@ export function useCharacterGame() {
|
|||||||
if (!allowed.has(nextRight)) nextRight = '';
|
if (!allowed.has(nextRight)) nextRight = '';
|
||||||
|
|
||||||
if ((!nextLeft || !nextRight) && (!curLeft || !curRight)) {
|
if ((!nextLeft || !nextRight) && (!curLeft || !curRight)) {
|
||||||
const defaults = getDefaultCharactersByGame(newGame);
|
const defaults = packRegistry.getDefaultCharactersByGame(newGame);
|
||||||
if (defaults) {
|
if (defaults) {
|
||||||
if (!nextLeft) nextLeft = allowed.has(defaults.leftCharacter) ? defaults.leftCharacter : '';
|
if (!nextLeft) nextLeft = allowed.has(defaults.leftCharacter) ? defaults.leftCharacter : '';
|
||||||
if (!nextRight) nextRight = allowed.has(defaults.rightCharacter) ? defaults.rightCharacter : '';
|
if (!nextRight) nextRight = allowed.has(defaults.rightCharacter) ? defaults.rightCharacter : '';
|
||||||
@@ -184,16 +180,16 @@ export function useCharacterGame() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (allowed.has(nextLeft)) {
|
if (allowed.has(nextLeft)) {
|
||||||
scoreboardStore.scoreboard.leftCharacter = nextLeft;
|
scoreboardStore.setSideCharacter('left', nextLeft);
|
||||||
} else if (!allowed.has(scoreboardStore.scoreboard.leftCharacter)) {
|
} else if (!allowed.has(scoreboardStore.scoreboard.leftCharacter)) {
|
||||||
scoreboardStore.scoreboard.leftCharacter = '';
|
scoreboardStore.setSideCharacter('left', '');
|
||||||
leftCharacterInput.value = '';
|
leftCharacterInput.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allowed.has(nextRight)) {
|
if (allowed.has(nextRight)) {
|
||||||
scoreboardStore.scoreboard.rightCharacter = nextRight;
|
scoreboardStore.setSideCharacter('right', nextRight);
|
||||||
} else if (!allowed.has(scoreboardStore.scoreboard.rightCharacter)) {
|
} else if (!allowed.has(scoreboardStore.scoreboard.rightCharacter)) {
|
||||||
scoreboardStore.scoreboard.rightCharacter = '';
|
scoreboardStore.setSideCharacter('right', '');
|
||||||
rightCharacterInput.value = '';
|
rightCharacterInput.value = '';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -232,14 +228,12 @@ export function useCharacterGame() {
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
// When an installed pack becomes available (e.g. after page refresh while
|
// When an installed pack manifest becomes available, re-validate characters
|
||||||
// the pack loads asynchronously), re-validate and restore the characters
|
// already present in the replicated scoreboard state.
|
||||||
// that are already in the store but couldn't be confirmed before.
|
watch(characterOptions, (options) => {
|
||||||
watch(installedPacksRevision, () => {
|
|
||||||
const game = scoreboardStore.scoreboard.game;
|
const game = scoreboardStore.scoreboard.game;
|
||||||
if (!game) return;
|
if (!game) return;
|
||||||
|
|
||||||
const options = getCharactersByGame(game);
|
|
||||||
if (options.length === 0) return;
|
if (options.length === 0) return;
|
||||||
|
|
||||||
const allowed = new Set(options.map((o) => o.value));
|
const allowed = new Set(options.map((o) => o.value));
|
||||||
@@ -251,14 +245,14 @@ export function useCharacterGame() {
|
|||||||
if (leftCharacter && allowed.has(leftCharacter)) {
|
if (leftCharacter && allowed.has(leftCharacter)) {
|
||||||
leftCharacterInput.value = options.find((o) => o.value === leftCharacter)?.label ?? '';
|
leftCharacterInput.value = options.find((o) => o.value === leftCharacter)?.label ?? '';
|
||||||
} else if (leftCharacter && !allowed.has(leftCharacter)) {
|
} else if (leftCharacter && !allowed.has(leftCharacter)) {
|
||||||
scoreboardStore.scoreboard.leftCharacter = '';
|
scoreboardStore.setSideCharacter('left', '');
|
||||||
leftCharacterInput.value = '';
|
leftCharacterInput.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rightCharacter && allowed.has(rightCharacter)) {
|
if (rightCharacter && allowed.has(rightCharacter)) {
|
||||||
rightCharacterInput.value = options.find((o) => o.value === rightCharacter)?.label ?? '';
|
rightCharacterInput.value = options.find((o) => o.value === rightCharacter)?.label ?? '';
|
||||||
} else if (rightCharacter && !allowed.has(rightCharacter)) {
|
} else if (rightCharacter && !allowed.has(rightCharacter)) {
|
||||||
scoreboardStore.scoreboard.rightCharacter = '';
|
scoreboardStore.setSideCharacter('right', '');
|
||||||
rightCharacterInput.value = '';
|
rightCharacterInput.value = '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { getCountryLabel, getCountryOptions } from '../../../shared/utils/countries';
|
import { getCountryLabel, getCountryOptions } from '../../../shared/domain/players/countries';
|
||||||
import { locale } from '../i18n';
|
import { locale } from '../i18n';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
import { sendIntegrationMessage } from '../../services/integration-message-service';
|
||||||
|
|
||||||
// ─── Tipos ─────────────────────────────────────────────────────────────────────
|
// ─── Tipos ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -65,19 +66,6 @@ export interface UseIntegrationOptions {
|
|||||||
playersStore: PlayersStore;
|
playersStore: PlayersStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Utilidad para mensajes NodeCG ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
const sendNodeCGMessage = <T>(messageName: string, payload: unknown): Promise<T> =>
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
nodecg.sendMessage(messageName, payload, (error: unknown, response: unknown) => {
|
|
||||||
if (error) {
|
|
||||||
reject(new Error(String(error)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(response as T);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Composable ────────────────────────────────────────────────────────────────
|
// ─── Composable ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function useIntegration(options: UseIntegrationOptions) {
|
export function useIntegration(options: UseIntegrationOptions) {
|
||||||
@@ -165,8 +153,9 @@ export function useIntegration(options: UseIntegrationOptions) {
|
|||||||
tournamentsError.value = '';
|
tournamentsError.value = '';
|
||||||
loadingTournaments.value = true;
|
loadingTournaments.value = true;
|
||||||
try {
|
try {
|
||||||
const tournaments = await sendNodeCGMessage<IntegrationTournament[]>(
|
const tournaments = await sendIntegrationMessage<IntegrationTournament[]>(
|
||||||
`${messagePrefix}:fetchRecentTournaments`,
|
messagePrefix,
|
||||||
|
'fetchRecentTournaments',
|
||||||
{ token: currentToken },
|
{ token: currentToken },
|
||||||
);
|
);
|
||||||
hasValidatedToken.value = true;
|
hasValidatedToken.value = true;
|
||||||
@@ -204,8 +193,9 @@ export function useIntegration(options: UseIntegrationOptions) {
|
|||||||
players.value = [];
|
players.value = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const importedPlayers = await sendNodeCGMessage<IntegrationPlayer[]>(
|
const importedPlayers = await sendIntegrationMessage<IntegrationPlayer[]>(
|
||||||
`${messagePrefix}:fetchTournamentPlayers`,
|
messagePrefix,
|
||||||
|
'fetchTournamentPlayers',
|
||||||
{ token: token.value.trim(), slug: tournament.slug },
|
{ token: token.value.trim(), slug: tournament.slug },
|
||||||
);
|
);
|
||||||
players.value = importedPlayers;
|
players.value = importedPlayers;
|
||||||
@@ -325,8 +315,9 @@ export function useIntegration(options: UseIntegrationOptions) {
|
|||||||
if (!oauthSessionId.value) return;
|
if (!oauthSessionId.value) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await sendNodeCGMessage<OAuthStatusResponse>(
|
const status = await sendIntegrationMessage<OAuthStatusResponse>(
|
||||||
`${messagePrefix}:getOAuthSessionStatus`,
|
messagePrefix,
|
||||||
|
'getOAuthSessionStatus',
|
||||||
{ sessionId: oauthSessionId.value },
|
{ sessionId: oauthSessionId.value },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -362,8 +353,9 @@ export function useIntegration(options: UseIntegrationOptions) {
|
|||||||
stopPolling();
|
stopPolling();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await sendNodeCGMessage<OAuthSessionResponse>(
|
const session = await sendIntegrationMessage<OAuthSessionResponse>(
|
||||||
`${messagePrefix}:createOAuthSession`,
|
messagePrefix,
|
||||||
|
'createOAuthSession',
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
oauthSessionId.value = session.sessionId;
|
oauthSessionId.value = session.sessionId;
|
||||||
|
|||||||
@@ -1,266 +1,68 @@
|
|||||||
// src/dashboard/scoreboard/composables/usePackRegistry.ts
|
import { storeToRefs } from 'pinia';
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
import type { InjectionKey, Ref } from 'vue';
|
||||||
// Singleton composable. The first caller sets up NodeCG replicant listeners;
|
import { usePacksStore } from '../../stores/packs';
|
||||||
// subsequent calls return the same reactive state. This avoids duplicate event
|
import type { DefaultCharacterPair, FightingCharacterOption } from '../../../shared/domain/packs/characters';
|
||||||
// listeners when multiple components call usePackRegistry().
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
import { computed, ref, type ComputedRef, type InjectionKey } from 'vue';
|
|
||||||
import {
|
|
||||||
registerInstalledPack,
|
|
||||||
unregisterInstalledPack,
|
|
||||||
} from '../../../shared/fighting-characters';
|
|
||||||
import { BUNDLE_NAME } from '../../../shared/pack-config';
|
|
||||||
import type {
|
import type {
|
||||||
GameSelectOption,
|
GameSelectOption,
|
||||||
PackDownloadState,
|
PackDownloadState,
|
||||||
PackManifest,
|
PackRegistry,
|
||||||
PackRegistry
|
PackUpdateInfo,
|
||||||
} from '../../../shared/pack-types';
|
} from '../../../shared/domain/packs/types';
|
||||||
|
|
||||||
// ── NodeCG global type declarations ──────────────────────────────────────────
|
|
||||||
// NodeCG injects these into the browser window via its bundle script.
|
|
||||||
|
|
||||||
declare const NodeCG: {
|
|
||||||
Replicant: <T>(
|
|
||||||
name: string,
|
|
||||||
bundleName: string,
|
|
||||||
opts?: { defaultValue?: T },
|
|
||||||
) => {
|
|
||||||
value: T;
|
|
||||||
on(event: 'change', handler: (newVal: T, oldVal?: T) => void): void;
|
|
||||||
off(event: string, handler: (...args: unknown[]) => void): void;
|
|
||||||
};
|
|
||||||
waitForReplicants: (...reps: unknown[]) => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
declare const nodecg: {
|
|
||||||
sendMessage(name: string, data?: unknown): void;
|
|
||||||
sendMessage(
|
|
||||||
name: string,
|
|
||||||
data: unknown,
|
|
||||||
cb: (err: Error | null, result?: unknown) => void,
|
|
||||||
): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Module-level singleton state ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
let initialized = false;
|
|
||||||
|
|
||||||
const registry = ref<PackRegistry | null>(null);
|
|
||||||
const installedPackIds = ref<string[]>([]);
|
|
||||||
const downloadStates = ref<Record<string, PackDownloadState>>({});
|
|
||||||
const availableUpdates = ref<Record<string, { installedVersion: string; latestVersion: string }>>({});
|
|
||||||
|
|
||||||
// Tracks which installed pack manifests have been loaded into fighting-characters.ts
|
|
||||||
const loadedManifestIds = new Set<string>();
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const formatBytes = (bytes: number): string => {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Asks the NodeCG extension to read the local manifest.json for an installed
|
|
||||||
* pack and registers the characters in fighting-characters.ts.
|
|
||||||
*/
|
|
||||||
const loadInstalledManifest = (packId: string): void => {
|
|
||||||
if (loadedManifestIds.has(packId)) return;
|
|
||||||
|
|
||||||
nodecg.sendMessage('readLocalManifest', packId, (err, result) => {
|
|
||||||
if (err) {
|
|
||||||
console.error(`[usePackRegistry] Failed to load manifest for "${packId}":`, err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const manifest = result as PackManifest;
|
|
||||||
registerInstalledPack(manifest);
|
|
||||||
loadedManifestIds.add(packId);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Replicant setup (runs once) ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
const initReplicants = (): void => {
|
|
||||||
if (initialized) return;
|
|
||||||
initialized = true;
|
|
||||||
|
|
||||||
const registryRep = NodeCG.Replicant<PackRegistry | null>('packRegistry', BUNDLE_NAME, {
|
|
||||||
defaultValue: null,
|
|
||||||
});
|
|
||||||
const installedRep = NodeCG.Replicant<string[]>('installedPacks', BUNDLE_NAME, {
|
|
||||||
defaultValue: [],
|
|
||||||
});
|
|
||||||
const statesRep = NodeCG.Replicant<Record<string, PackDownloadState>>('downloadStates', BUNDLE_NAME, {
|
|
||||||
defaultValue: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updatesRep = NodeCG.Replicant<Record<string, { installedVersion: string; latestVersion: string }>>('availableUpdates', BUNDLE_NAME, {
|
|
||||||
defaultValue: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
NodeCG.waitForReplicants(registryRep, installedRep, statesRep, updatesRep).then(() => {
|
|
||||||
// Hydrate initial values
|
|
||||||
registry.value = registryRep.value;
|
|
||||||
installedPackIds.value = installedRep.value ?? [];
|
|
||||||
downloadStates.value = statesRep.value ?? {};
|
|
||||||
availableUpdates.value = updatesRep.value ?? {};
|
|
||||||
|
|
||||||
// Load manifests for all installed packs
|
|
||||||
for (const id of installedPackIds.value) {
|
|
||||||
loadInstalledManifest(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to changes
|
|
||||||
registryRep.on('change', (val) => {
|
|
||||||
registry.value = val;
|
|
||||||
});
|
|
||||||
|
|
||||||
installedRep.on('change', (newVal, oldVal) => {
|
|
||||||
const next = newVal ?? [];
|
|
||||||
const prev = oldVal ?? [];
|
|
||||||
installedPackIds.value = next;
|
|
||||||
|
|
||||||
// Load manifests for newly installed packs
|
|
||||||
const added = next.filter((id) => !prev.includes(id));
|
|
||||||
for (const id of added) {
|
|
||||||
loadInstalledManifest(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unregister packs that were removed
|
|
||||||
const removed = prev.filter((id) => !next.includes(id));
|
|
||||||
for (const id of removed) {
|
|
||||||
const gameName = getGameNameById(id);
|
|
||||||
unregisterInstalledPack(gameName);
|
|
||||||
loadedManifestIds.delete(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
statesRep.on('change', (val) => {
|
|
||||||
downloadStates.value = val ?? {};
|
|
||||||
});
|
|
||||||
|
|
||||||
updatesRep.on('change', (val) => {
|
|
||||||
availableUpdates.value = val ?? {};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Given a pack ID (e.g. "street-fighter-6"), returns the matching game name
|
|
||||||
* from the current registry, or an empty string if the registry isn't loaded.
|
|
||||||
*/
|
|
||||||
const getGameNameById = (packId: string): string =>
|
|
||||||
registry.value?.packs.find((p) => p.id === packId)?.name ?? '';
|
|
||||||
|
|
||||||
// ── Public composable ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface PackRegistryContext {
|
export interface PackRegistryContext {
|
||||||
/** Full registry fetched from Gitea (null until first fetch). */
|
registry: Ref<PackRegistry | null>;
|
||||||
registry: typeof registry;
|
installedPackIds: Ref<string[]>;
|
||||||
/** IDs of packs installed on disk (bundled packs are NOT in this list). */
|
downloadStates: Ref<Record<string, PackDownloadState>>;
|
||||||
installedPackIds: typeof installedPackIds;
|
|
||||||
/** Per-pack download state. */
|
|
||||||
downloadStates: typeof downloadStates;
|
|
||||||
/** Checks if a game is available (bundled OR installed). */
|
|
||||||
isGameAvailable: (gameName: string) => boolean;
|
isGameAvailable: (gameName: string) => boolean;
|
||||||
/** Returns the download state for a pack, or a default idle state. */
|
|
||||||
getDownloadState: (packId: string) => PackDownloadState;
|
getDownloadState: (packId: string) => PackDownloadState;
|
||||||
/** All games from the registry, enriched with availability info. */
|
getCharactersByGame: (gameName: string) => FightingCharacterOption[];
|
||||||
allGameOptions: ReturnType<typeof buildAllGameOptions>;
|
getDefaultCharactersByGame: (gameName: string) => DefaultCharacterPair | undefined;
|
||||||
/** Tells the extension to fetch the latest registry.json from Gitea. */
|
allGameOptions: Ref<GameSelectOption[]>;
|
||||||
fetchRegistry: () => void;
|
fetchRegistry: () => void;
|
||||||
/** Tells the extension to download and install a pack. */
|
startRegistryRefresh: (intervalMs?: number) => void;
|
||||||
|
stopRegistryRefresh: () => void;
|
||||||
downloadPack: (packId: string) => void;
|
downloadPack: (packId: string) => void;
|
||||||
/** Tells the extension to uninstall a pack and delete its files. */
|
|
||||||
uninstallPack: (packId: string) => void;
|
uninstallPack: (packId: string) => void;
|
||||||
/** Tells the extension to download and apply an update for an installed pack. */
|
|
||||||
updatePack: (packId: string) => void;
|
updatePack: (packId: string) => void;
|
||||||
/** Map of packId → version info for packs that have a newer version available. */
|
availableUpdates: Ref<Record<string, PackUpdateInfo>>;
|
||||||
availableUpdates: typeof availableUpdates;
|
updateCount: Ref<number>;
|
||||||
/** Total number of packs with available updates. */
|
formatBytes: (bytes: number) => string;
|
||||||
updateCount: ComputedRef<number>;
|
|
||||||
/** Human-readable file size. */
|
|
||||||
formatBytes: typeof formatBytes;
|
|
||||||
/** Returns the URL for the pack's logo served by NodeCG (installed packs only). */
|
|
||||||
getLocalLogoUrl: (packId: string) => string;
|
getLocalLogoUrl: (packId: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PACK_REGISTRY_KEY: InjectionKey<PackRegistryContext> = Symbol('packRegistry');
|
export const PACK_REGISTRY_KEY: InjectionKey<PackRegistryContext> = Symbol('packRegistry');
|
||||||
|
|
||||||
const buildAllGameOptions = () =>
|
|
||||||
computed<GameSelectOption[]>(() => {
|
|
||||||
// Registry not loaded yet — return empty list
|
|
||||||
if (!registry.value) return [];
|
|
||||||
|
|
||||||
return registry.value.packs.map((entry) => ({
|
|
||||||
label: entry.name,
|
|
||||||
value: entry.name,
|
|
||||||
available: installedPackIds.value.includes(entry.id),
|
|
||||||
registryEntry: entry,
|
|
||||||
updateInfo: availableUpdates.value[entry.id],
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
export function usePackRegistry(): PackRegistryContext {
|
export function usePackRegistry(): PackRegistryContext {
|
||||||
initReplicants();
|
const packsStore = usePacksStore();
|
||||||
|
packsStore.initialize();
|
||||||
|
|
||||||
const allGameOptions = buildAllGameOptions();
|
const {
|
||||||
|
registry,
|
||||||
const isGameAvailable = (gameName: string): boolean => {
|
installedPackIds,
|
||||||
const entry = registry.value?.packs.find((p) => p.name === gameName);
|
downloadStates,
|
||||||
if (!entry) return false;
|
availableUpdates,
|
||||||
return installedPackIds.value.includes(entry.id);
|
allGameOptions,
|
||||||
};
|
updateCount,
|
||||||
|
} = storeToRefs(packsStore);
|
||||||
const getDownloadState = (packId: string): PackDownloadState =>
|
|
||||||
downloadStates.value[packId] ?? { status: 'idle', progress: 0 };
|
|
||||||
|
|
||||||
const getLocalLogoUrl = (packId: string): string =>
|
|
||||||
`/packs/${packId}/logo.png`;
|
|
||||||
|
|
||||||
const fetchRegistry = (): void => {
|
|
||||||
nodecg.sendMessage('fetchPackRegistry', undefined, (err) => {
|
|
||||||
if (err) console.error('[usePackRegistry] fetchPackRegistry failed:', err);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadPack = (packId: string): void => {
|
|
||||||
nodecg.sendMessage('downloadPack', packId, (err) => {
|
|
||||||
if (err) console.error(`[usePackRegistry] downloadPack "${packId}" failed:`, err);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const uninstallPack = (packId: string): void => {
|
|
||||||
nodecg.sendMessage('uninstallPack', packId, (err) => {
|
|
||||||
if (err) console.error(`[usePackRegistry] uninstallPack "${packId}" failed:`, err);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatePack = (packId: string): void => {
|
|
||||||
nodecg.sendMessage('updatePack', packId, (err) => {
|
|
||||||
if (err) console.error(`[usePackRegistry] updatePack "${packId}" failed:`, err);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateCount = computed(() => Object.keys(availableUpdates.value).length);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
registry,
|
registry,
|
||||||
installedPackIds,
|
installedPackIds,
|
||||||
downloadStates,
|
downloadStates,
|
||||||
isGameAvailable,
|
isGameAvailable: packsStore.isGameAvailable,
|
||||||
getDownloadState,
|
getDownloadState: packsStore.getDownloadState,
|
||||||
|
getCharactersByGame: packsStore.getCharactersByGame,
|
||||||
|
getDefaultCharactersByGame: packsStore.getDefaultCharactersByGame,
|
||||||
allGameOptions,
|
allGameOptions,
|
||||||
fetchRegistry,
|
fetchRegistry: packsStore.fetchRegistry,
|
||||||
downloadPack,
|
startRegistryRefresh: packsStore.startRegistryRefresh,
|
||||||
uninstallPack,
|
stopRegistryRefresh: packsStore.stopRegistryRefresh,
|
||||||
updatePack,
|
downloadPack: packsStore.downloadPack,
|
||||||
|
uninstallPack: packsStore.uninstallPack,
|
||||||
|
updatePack: packsStore.updatePack,
|
||||||
availableUpdates,
|
availableUpdates,
|
||||||
updateCount,
|
updateCount,
|
||||||
formatBytes,
|
formatBytes: packsStore.formatBytes,
|
||||||
getLocalLogoUrl,
|
getLocalLogoUrl: packsStore.getLocalLogoUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { computed, ref, watch, watchEffect } from 'vue';
|
import { computed, ref, watch, watchEffect } from 'vue';
|
||||||
import { useScoreboardStore } from '../stores/scoreboard';
|
import { useScoreboardStore } from '../../stores/scoreboard';
|
||||||
import { usePlayersStore } from '../stores/players';
|
import { usePlayersStore } from '../../stores/players';
|
||||||
import type { Schemas } from '../../../types';
|
import type { Schemas } from '../../../types';
|
||||||
|
import { createPlayerId, normalizePlayerName } from '../../../shared/domain/players/state';
|
||||||
import { t } from '../i18n';
|
import { t } from '../i18n';
|
||||||
import { useCountryFilter } from './useCountryFilter';
|
import { useCountryFilter } from './useCountryFilter';
|
||||||
|
|
||||||
@@ -16,34 +17,6 @@ export const CUSTOM_RIGHT_PLAYER_ID = '__custom_right_player__';
|
|||||||
// Pure helpers (no Vue reactivity)
|
// Pure helpers (no Vue reactivity)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const normalizeName = (value: string) => value.trim().toLowerCase();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a unique slug-based player ID that does not collide with
|
|
||||||
* existing player keys in the store.
|
|
||||||
*/
|
|
||||||
const createPlayerId = (name: string, players: Schemas.Players): string => {
|
|
||||||
const base = name
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.normalize('NFD')
|
|
||||||
.replace(/[^\w\s-]/g, '')
|
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
|
||||||
.replace(/\s+/g, '-') || 'player';
|
|
||||||
|
|
||||||
let index = 1;
|
|
||||||
let candidate = base;
|
|
||||||
while (players[candidate]) {
|
|
||||||
index += 1;
|
|
||||||
candidate = `${base}-${index}`;
|
|
||||||
}
|
|
||||||
return candidate;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Composable
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encapsulates all reactive state and handlers for one side of the scoreboard
|
* Encapsulates all reactive state and handlers for one side of the scoreboard
|
||||||
* (left or right). Call once per side inside the corresponding component.
|
* (left or right). Call once per side inside the corresponding component.
|
||||||
@@ -63,32 +36,28 @@ export function usePlayerSide(side: 'left' | 'right') {
|
|||||||
const playerId = computed({
|
const playerId = computed({
|
||||||
get: () => (isLeft ? scoreboardStore.scoreboard.leftPlayerId : scoreboardStore.scoreboard.rightPlayerId),
|
get: () => (isLeft ? scoreboardStore.scoreboard.leftPlayerId : scoreboardStore.scoreboard.rightPlayerId),
|
||||||
set: (v) => {
|
set: (v) => {
|
||||||
if (isLeft) scoreboardStore.scoreboard.leftPlayerId = v;
|
scoreboardStore.setSidePlayerId(side, v);
|
||||||
else scoreboardStore.scoreboard.rightPlayerId = v;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const nameOverride = computed({
|
const nameOverride = computed({
|
||||||
get: () => (isLeft ? scoreboardStore.scoreboard.leftNameOverride : scoreboardStore.scoreboard.rightNameOverride),
|
get: () => (isLeft ? scoreboardStore.scoreboard.leftNameOverride : scoreboardStore.scoreboard.rightNameOverride),
|
||||||
set: (v) => {
|
set: (v) => {
|
||||||
if (isLeft) scoreboardStore.scoreboard.leftNameOverride = v;
|
scoreboardStore.setSideNameOverride(side, v);
|
||||||
else scoreboardStore.scoreboard.rightNameOverride = v;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const teamOverride = computed({
|
const teamOverride = computed({
|
||||||
get: () => (isLeft ? scoreboardStore.scoreboard.leftTeamOverride : scoreboardStore.scoreboard.rightTeamOverride),
|
get: () => (isLeft ? scoreboardStore.scoreboard.leftTeamOverride : scoreboardStore.scoreboard.rightTeamOverride),
|
||||||
set: (v) => {
|
set: (v) => {
|
||||||
if (isLeft) scoreboardStore.scoreboard.leftTeamOverride = v;
|
scoreboardStore.setSideTeamOverride(side, v);
|
||||||
else scoreboardStore.scoreboard.rightTeamOverride = v;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const countryOverride = computed({
|
const countryOverride = computed({
|
||||||
get: () => (isLeft ? scoreboardStore.scoreboard.leftCountryOverride : scoreboardStore.scoreboard.rightCountryOverride),
|
get: () => (isLeft ? scoreboardStore.scoreboard.leftCountryOverride : scoreboardStore.scoreboard.rightCountryOverride),
|
||||||
set: (v) => {
|
set: (v) => {
|
||||||
if (isLeft) scoreboardStore.scoreboard.leftCountryOverride = v;
|
scoreboardStore.setSideCountryOverride(side, v);
|
||||||
else scoreboardStore.scoreboard.rightCountryOverride = v;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,10 +114,10 @@ export function usePlayerSide(side: 'left' | 'right') {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const playerExistsByGamertag = (name: string): boolean => {
|
const playerExistsByGamertag = (name: string): boolean => {
|
||||||
const normalized = normalizeName(name);
|
const normalized = normalizePlayerName(name);
|
||||||
return Boolean(normalized)
|
return Boolean(normalized)
|
||||||
&& Object.values(playersStore.players).some(
|
&& Object.values(playersStore.players).some(
|
||||||
(p) => normalizeName(p.gamertag || '') === normalized,
|
(p) => normalizePlayerName(p.gamertag || '') === normalized,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { t } from './i18n';
|
import { t } from './i18n';
|
||||||
import { useScoreboardStore } from './stores/scoreboard';
|
import { useScoreboardStore } from '../stores/scoreboard';
|
||||||
import { isShortcutMatch, useShortcutSettingsStore } from './stores/shortcut-settings';
|
import { isShortcutMatch, useShortcutSettingsStore } from '../stores/shortcut-settings';
|
||||||
|
|
||||||
// ── Sidebar collapse ──────────────────────────────────────────────────────────
|
// ── Sidebar collapse ──────────────────────────────────────────────────────────
|
||||||
const LS_KEY = 'sidebar_collapsed';
|
const LS_KEY = 'sidebar_collapsed';
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
import { defineStore } from 'pinia';
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { commentaryReplicant } from '../../../browser_shared/replicants';
|
|
||||||
import type { Schemas } from '../../../types';
|
|
||||||
import { syncStateWithReplicant } from './store-sync';
|
|
||||||
|
|
||||||
type Commentary = Schemas.Commentary;
|
|
||||||
|
|
||||||
const defaultCommentary: Commentary = {
|
|
||||||
leftCommentator: '',
|
|
||||||
leftCommentatorTwitter: '',
|
|
||||||
rightCommentator: '',
|
|
||||||
rightCommentatorTwitter: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeCommentary = (input: unknown): Commentary => {
|
|
||||||
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
|
|
||||||
return {
|
|
||||||
leftCommentator: typeof candidate.leftCommentator === 'string' ? candidate.leftCommentator : '',
|
|
||||||
leftCommentatorTwitter: typeof candidate.leftCommentatorTwitter === 'string' ? candidate.leftCommentatorTwitter : '',
|
|
||||||
rightCommentator: typeof candidate.rightCommentator === 'string' ? candidate.rightCommentator : '',
|
|
||||||
rightCommentatorTwitter: typeof candidate.rightCommentatorTwitter === 'string' ? candidate.rightCommentatorTwitter : '',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useCommentaryStore = defineStore('commentary', () => {
|
|
||||||
const commentary = ref<Commentary>({ ...defaultCommentary });
|
|
||||||
const replicant = commentaryReplicant;
|
|
||||||
syncStateWithReplicant(commentary, replicant, normalizeCommentary);
|
|
||||||
|
|
||||||
const leftCommentator = computed({
|
|
||||||
get: () => commentary.value.leftCommentator,
|
|
||||||
set: (value: string) => {
|
|
||||||
commentary.value = {
|
|
||||||
...commentary.value,
|
|
||||||
leftCommentator: value,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const leftCommentatorTwitter = computed({
|
|
||||||
get: () => commentary.value.leftCommentatorTwitter,
|
|
||||||
set: (value: string) => {
|
|
||||||
commentary.value = {
|
|
||||||
...commentary.value,
|
|
||||||
leftCommentatorTwitter: value,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const rightCommentator = computed({
|
|
||||||
get: () => commentary.value.rightCommentator,
|
|
||||||
set: (value: string) => {
|
|
||||||
commentary.value = {
|
|
||||||
...commentary.value,
|
|
||||||
rightCommentator: value,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const rightCommentatorTwitter = computed({
|
|
||||||
get: () => commentary.value.rightCommentatorTwitter,
|
|
||||||
set: (value: string) => {
|
|
||||||
commentary.value = {
|
|
||||||
...commentary.value,
|
|
||||||
rightCommentatorTwitter: value,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const swapCommentators = () => {
|
|
||||||
commentary.value = {
|
|
||||||
leftCommentator: commentary.value.rightCommentator,
|
|
||||||
leftCommentatorTwitter: commentary.value.rightCommentatorTwitter,
|
|
||||||
rightCommentator: commentary.value.leftCommentator,
|
|
||||||
rightCommentatorTwitter: commentary.value.leftCommentatorTwitter,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
commentary,
|
|
||||||
leftCommentator,
|
|
||||||
leftCommentatorTwitter,
|
|
||||||
rightCommentator,
|
|
||||||
rightCommentatorTwitter,
|
|
||||||
swapCommentators,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { defineStore } from 'pinia';
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { playersReplicant } from '../../../browser_shared/replicants';
|
|
||||||
import type { Schemas } from '../../../types';
|
|
||||||
import { readStorageSnapshot, syncStateWithReplicant } from './store-sync';
|
|
||||||
|
|
||||||
type PlayersMap = Schemas.Players;
|
|
||||||
type Player = PlayersMap[string];
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'scoreko-dev.players';
|
|
||||||
|
|
||||||
const normalizePlayer = (input: unknown): Player => {
|
|
||||||
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
|
|
||||||
return {
|
|
||||||
gamertag: typeof candidate.gamertag === 'string' ? candidate.gamertag : '',
|
|
||||||
name: typeof candidate.name === 'string' ? candidate.name : '',
|
|
||||||
team: typeof candidate.team === 'string' ? candidate.team : '',
|
|
||||||
country: typeof candidate.country === 'string' ? candidate.country : '',
|
|
||||||
twitter: typeof candidate.twitter === 'string' ? candidate.twitter : '',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizePlayers = (input: unknown): PlayersMap => {
|
|
||||||
if (typeof input !== 'object' || input === null) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
const result: PlayersMap = {};
|
|
||||||
Object.entries(input as Record<string, unknown>).forEach(([id, value]) => {
|
|
||||||
if (!id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
result[id] = normalizePlayer(value);
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const usePlayersStore = defineStore('players', () => {
|
|
||||||
const players = ref<PlayersMap>({});
|
|
||||||
const replicant = playersReplicant;
|
|
||||||
const storageSnapshot = readStorageSnapshot(STORAGE_KEY, normalizePlayers);
|
|
||||||
if (storageSnapshot) {
|
|
||||||
players.value = storageSnapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
syncStateWithReplicant(players, replicant, normalizePlayers, STORAGE_KEY);
|
|
||||||
|
|
||||||
const setPlayers = (value: PlayersMap) => {
|
|
||||||
players.value = normalizePlayers(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const upsertPlayer = (id: string, player: Player) => {
|
|
||||||
players.value = {
|
|
||||||
...players.value,
|
|
||||||
[id]: normalizePlayer(player),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const removePlayer = (id: string) => {
|
|
||||||
const next = { ...players.value };
|
|
||||||
delete next[id];
|
|
||||||
players.value = next;
|
|
||||||
};
|
|
||||||
|
|
||||||
const rows = computed(() => Object.entries(players.value).map(([id, player]) => ({
|
|
||||||
id,
|
|
||||||
...player,
|
|
||||||
})));
|
|
||||||
|
|
||||||
return {
|
|
||||||
players,
|
|
||||||
rows,
|
|
||||||
setPlayers,
|
|
||||||
upsertPlayer,
|
|
||||||
removePlayer,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import { defineStore } from 'pinia';
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { scoreboardReplicant } from '../../../browser_shared/replicants';
|
|
||||||
import type { Schemas } from '../../../types';
|
|
||||||
import { readStorageSnapshot, syncStateWithReplicant } from './store-sync';
|
|
||||||
|
|
||||||
type Scoreboard = Schemas.Scoreboard;
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'scoreko-dev.scoreboard';
|
|
||||||
|
|
||||||
const defaultScoreboard: Scoreboard = {
|
|
||||||
leftPlayerId: '',
|
|
||||||
rightPlayerId: '',
|
|
||||||
leftNameOverride: '',
|
|
||||||
rightNameOverride: '',
|
|
||||||
leftTeamOverride: '',
|
|
||||||
rightTeamOverride: '',
|
|
||||||
leftCountryOverride: '',
|
|
||||||
rightCountryOverride: '',
|
|
||||||
leftCharacter: '',
|
|
||||||
rightCharacter: '',
|
|
||||||
leftScore: 0,
|
|
||||||
rightScore: 0,
|
|
||||||
round: '',
|
|
||||||
game: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeScoreboard = (input: unknown): Scoreboard => {
|
|
||||||
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
|
|
||||||
return {
|
|
||||||
leftPlayerId: typeof candidate.leftPlayerId === 'string' ? candidate.leftPlayerId : '',
|
|
||||||
rightPlayerId: typeof candidate.rightPlayerId === 'string' ? candidate.rightPlayerId : '',
|
|
||||||
leftNameOverride: typeof candidate.leftNameOverride === 'string' ? candidate.leftNameOverride : '',
|
|
||||||
rightNameOverride: typeof candidate.rightNameOverride === 'string' ? candidate.rightNameOverride : '',
|
|
||||||
leftTeamOverride: typeof candidate.leftTeamOverride === 'string' ? candidate.leftTeamOverride : '',
|
|
||||||
rightTeamOverride: typeof candidate.rightTeamOverride === 'string' ? candidate.rightTeamOverride : '',
|
|
||||||
leftCountryOverride: typeof candidate.leftCountryOverride === 'string' ? candidate.leftCountryOverride : '',
|
|
||||||
rightCountryOverride: typeof candidate.rightCountryOverride === 'string' ? candidate.rightCountryOverride : '',
|
|
||||||
leftCharacter: typeof candidate.leftCharacter === 'string' ? candidate.leftCharacter : '',
|
|
||||||
rightCharacter: typeof candidate.rightCharacter === 'string' ? candidate.rightCharacter : '',
|
|
||||||
leftScore: typeof candidate.leftScore === 'number' ? Math.max(0, Math.floor(candidate.leftScore)) : 0,
|
|
||||||
rightScore: typeof candidate.rightScore === 'number' ? Math.max(0, Math.floor(candidate.rightScore)) : 0,
|
|
||||||
round: typeof candidate.round === 'string' ? candidate.round : '',
|
|
||||||
game: typeof candidate.game === 'string' ? candidate.game : '',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useScoreboardStore = defineStore('scoreboard', () => {
|
|
||||||
const scoreboard = ref<Scoreboard>({ ...defaultScoreboard });
|
|
||||||
const replicant = scoreboardReplicant;
|
|
||||||
const storageSnapshot = readStorageSnapshot(STORAGE_KEY, normalizeScoreboard);
|
|
||||||
if (storageSnapshot) {
|
|
||||||
scoreboard.value = storageSnapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
syncStateWithReplicant(scoreboard, replicant, normalizeScoreboard, STORAGE_KEY);
|
|
||||||
|
|
||||||
const setScoreboard = (value: Scoreboard) => {
|
|
||||||
scoreboard.value = normalizeScoreboard(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const swapPlayers = () => {
|
|
||||||
scoreboard.value = {
|
|
||||||
...scoreboard.value,
|
|
||||||
leftPlayerId: scoreboard.value.rightPlayerId,
|
|
||||||
rightPlayerId: scoreboard.value.leftPlayerId,
|
|
||||||
leftNameOverride: scoreboard.value.rightNameOverride,
|
|
||||||
rightNameOverride: scoreboard.value.leftNameOverride,
|
|
||||||
leftTeamOverride: scoreboard.value.rightTeamOverride,
|
|
||||||
rightTeamOverride: scoreboard.value.leftTeamOverride,
|
|
||||||
leftCountryOverride: scoreboard.value.rightCountryOverride,
|
|
||||||
rightCountryOverride: scoreboard.value.leftCountryOverride,
|
|
||||||
leftCharacter: scoreboard.value.rightCharacter,
|
|
||||||
rightCharacter: scoreboard.value.leftCharacter,
|
|
||||||
leftScore: scoreboard.value.rightScore,
|
|
||||||
rightScore: scoreboard.value.leftScore,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetScores = () => {
|
|
||||||
scoreboard.value = {
|
|
||||||
...scoreboard.value,
|
|
||||||
leftScore: 0,
|
|
||||||
rightScore: 0,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const leftScore = computed({
|
|
||||||
get: () => scoreboard.value.leftScore,
|
|
||||||
set: (value: number) => {
|
|
||||||
scoreboard.value = {
|
|
||||||
...scoreboard.value,
|
|
||||||
leftScore: Math.max(0, Math.floor(value)),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const rightScore = computed({
|
|
||||||
get: () => scoreboard.value.rightScore,
|
|
||||||
set: (value: number) => {
|
|
||||||
scoreboard.value = {
|
|
||||||
...scoreboard.value,
|
|
||||||
rightScore: Math.max(0, Math.floor(value)),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
scoreboard,
|
|
||||||
leftScore,
|
|
||||||
rightScore,
|
|
||||||
setScoreboard,
|
|
||||||
swapPlayers,
|
|
||||||
resetScores,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { useHead } from '@unhead/vue';
|
import { useHead } from '@unhead/vue';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import bundlePackage from '../../../../package.json';
|
import bundlePackage from '../../../../package.json';
|
||||||
import { graphicsSettingsReplicant } from '../../../browser_shared/replicants';
|
import { useGraphicsSettingsStore } from '../../stores/graphics-settings';
|
||||||
import { t } from '../i18n';
|
import { t } from '../i18n';
|
||||||
|
|
||||||
defineOptions({ name: 'GraphicsView' });
|
defineOptions({ name: 'GraphicsView' });
|
||||||
@@ -24,6 +24,7 @@ type GraphicCard = {
|
|||||||
|
|
||||||
useHead(() => ({ title: t('graphicsTitle') }));
|
useHead(() => ({ title: t('graphicsTitle') }));
|
||||||
|
|
||||||
|
const graphicsSettingsStore = useGraphicsSettingsStore();
|
||||||
const graphics = computed<GraphicConfig[]>(() => bundlePackage.nodecg?.graphics ?? []);
|
const graphics = computed<GraphicConfig[]>(() => bundlePackage.nodecg?.graphics ?? []);
|
||||||
|
|
||||||
const baseUrl = computed(() => {
|
const baseUrl = computed(() => {
|
||||||
@@ -60,7 +61,7 @@ const commentaryGraphic = computed(() =>
|
|||||||
const selectedScoreboardSkin = ref<string>('');
|
const selectedScoreboardSkin = ref<string>('');
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[scoreboardGraphics, () => graphicsSettingsReplicant?.data?.scoreboardSkin],
|
[scoreboardGraphics, () => graphicsSettingsStore.settings.scoreboardSkin],
|
||||||
([availableSkins, replicatedSkin]) => {
|
([availableSkins, replicatedSkin]) => {
|
||||||
if (availableSkins.length === 0) {
|
if (availableSkins.length === 0) {
|
||||||
selectedScoreboardSkin.value = '';
|
selectedScoreboardSkin.value = '';
|
||||||
@@ -87,18 +88,15 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
selectedScoreboardSkin,
|
selectedScoreboardSkin,
|
||||||
(value) => {
|
(value) => {
|
||||||
if (!value || !graphicsSettingsReplicant) {
|
if (!value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (graphicsSettingsReplicant.data?.scoreboardSkin === value) {
|
if (graphicsSettingsStore.settings.scoreboardSkin === value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
graphicsSettingsReplicant.data = {
|
graphicsSettingsStore.setScoreboardSkin(value);
|
||||||
scoreboardSkin: value,
|
|
||||||
};
|
|
||||||
graphicsSettingsReplicant.save();
|
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
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/utils/countries';
|
import { getCountryLabel, getCountryOptions } from '../../../shared/domain/players/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';
|
||||||
import { usePlayersStore } from '../stores/players';
|
import { usePlayersStore } from '../../stores/players';
|
||||||
|
|
||||||
defineOptions({ name: 'PlayersView' });
|
defineOptions({ name: 'PlayersView' });
|
||||||
|
|
||||||
@@ -145,8 +145,13 @@ const openCreateDialog = () => {
|
|||||||
|
|
||||||
const openEditDialog = (row: PlayerRow) => {
|
const openEditDialog = (row: PlayerRow) => {
|
||||||
editingId.value = row.id;
|
editingId.value = row.id;
|
||||||
const { id: _id, ...playerData } = row;
|
Object.assign(form, {
|
||||||
Object.assign(form, playerData);
|
gamertag: row.gamertag,
|
||||||
|
name: row.name,
|
||||||
|
country: row.country,
|
||||||
|
team: row.team,
|
||||||
|
twitter: row.twitter,
|
||||||
|
});
|
||||||
isDialogOpen.value = true;
|
isDialogOpen.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
|||||||
import { useIntegration } from '../composables/useIntegration';
|
import { useIntegration } from '../composables/useIntegration';
|
||||||
import type { Locale } from '../i18n';
|
import type { Locale } from '../i18n';
|
||||||
import { locale, setLocale, t } from '../i18n';
|
import { locale, setLocale, t } from '../i18n';
|
||||||
import { usePlayersStore } from '../stores/players';
|
import { usePlayersStore } from '../../stores/players';
|
||||||
import {
|
import {
|
||||||
eventToShortcut,
|
eventToShortcut,
|
||||||
type ShortcutAction,
|
type ShortcutAction,
|
||||||
useShortcutSettingsStore,
|
useShortcutSettingsStore,
|
||||||
} from '../stores/shortcut-settings';
|
} from '../../stores/shortcut-settings';
|
||||||
|
|
||||||
defineOptions({ name: 'SettingsView' });
|
defineOptions({ name: 'SettingsView' });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { sendNodecgMessage } from '../../nodecg/browser/messages';
|
||||||
|
|
||||||
|
export const sendIntegrationMessage = <T>(
|
||||||
|
messagePrefix: string,
|
||||||
|
action: string,
|
||||||
|
payload: unknown,
|
||||||
|
): Promise<T> =>
|
||||||
|
sendNodecgMessage<T>(`${messagePrefix}:${action}`, payload);
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { sendNodecgCommand, sendNodecgMessage } from '../../nodecg/browser/messages';
|
||||||
|
import { createPackBrowserReplicants } from '../../nodecg/browser/packReplicants';
|
||||||
|
import { messageNames } from '../../nodecg/messageNames';
|
||||||
|
import type {
|
||||||
|
PackDownloadState,
|
||||||
|
PackManifest,
|
||||||
|
PackRegistry,
|
||||||
|
PackUpdateInfo,
|
||||||
|
} from '../../shared/domain/packs/types';
|
||||||
|
|
||||||
|
export interface PackReplicantHandlers {
|
||||||
|
onRegistryChanged: (value: PackRegistry | null) => void;
|
||||||
|
onInstalledPacksChanged: (value: string[], previousValue: string[]) => void;
|
||||||
|
onDownloadStatesChanged: (value: Record<string, PackDownloadState>) => void;
|
||||||
|
onAvailableUpdatesChanged: (value: Record<string, PackUpdateInfo>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackService {
|
||||||
|
subscribe: (handlers: PackReplicantHandlers) => Promise<() => void>;
|
||||||
|
fetchRegistry: () => Promise<void>;
|
||||||
|
downloadPack: (packId: string) => Promise<void>;
|
||||||
|
uninstallPack: (packId: string) => Promise<void>;
|
||||||
|
updatePack: (packId: string) => Promise<void>;
|
||||||
|
readLocalManifest: (packId: string) => Promise<PackManifest>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPackService = (): PackService => {
|
||||||
|
const subscribe = async (handlers: PackReplicantHandlers): Promise<() => void> => {
|
||||||
|
const {
|
||||||
|
registryRep,
|
||||||
|
installedRep,
|
||||||
|
statesRep,
|
||||||
|
updatesRep,
|
||||||
|
waitUntilReady,
|
||||||
|
} = createPackBrowserReplicants();
|
||||||
|
|
||||||
|
await waitUntilReady();
|
||||||
|
|
||||||
|
handlers.onRegistryChanged(registryRep.value ?? null);
|
||||||
|
handlers.onInstalledPacksChanged(installedRep.value ?? [], []);
|
||||||
|
handlers.onDownloadStatesChanged(statesRep.value ?? {});
|
||||||
|
handlers.onAvailableUpdatesChanged(updatesRep.value ?? {});
|
||||||
|
|
||||||
|
const onRegistryChanged = (value: PackRegistry | null): void => {
|
||||||
|
handlers.onRegistryChanged(value ?? null);
|
||||||
|
};
|
||||||
|
const onInstalledPacksChanged = (value: string[], previousValue?: string[]): void => {
|
||||||
|
handlers.onInstalledPacksChanged(value ?? [], previousValue ?? []);
|
||||||
|
};
|
||||||
|
const onDownloadStatesChanged = (value: Record<string, PackDownloadState>): void => {
|
||||||
|
handlers.onDownloadStatesChanged(value ?? {});
|
||||||
|
};
|
||||||
|
const onAvailableUpdatesChanged = (value: Record<string, PackUpdateInfo>): void => {
|
||||||
|
handlers.onAvailableUpdatesChanged(value ?? {});
|
||||||
|
};
|
||||||
|
|
||||||
|
registryRep.on('change', onRegistryChanged);
|
||||||
|
installedRep.on('change', onInstalledPacksChanged);
|
||||||
|
statesRep.on('change', onDownloadStatesChanged);
|
||||||
|
updatesRep.on('change', onAvailableUpdatesChanged);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
registryRep.off('change', onRegistryChanged);
|
||||||
|
installedRep.off('change', onInstalledPacksChanged);
|
||||||
|
statesRep.off('change', onDownloadStatesChanged);
|
||||||
|
updatesRep.off('change', onAvailableUpdatesChanged);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
fetchRegistry: () => sendNodecgCommand(messageNames.packs.fetchRegistry),
|
||||||
|
downloadPack: (packId: string) => sendNodecgCommand(messageNames.packs.download, packId),
|
||||||
|
uninstallPack: (packId: string) => sendNodecgCommand(messageNames.packs.uninstall, packId),
|
||||||
|
updatePack: (packId: string) => sendNodecgCommand(messageNames.packs.update, packId),
|
||||||
|
readLocalManifest: (packId: string) =>
|
||||||
|
sendNodecgMessage<PackManifest>(messageNames.packs.readLocalManifest, packId),
|
||||||
|
};
|
||||||
|
};
|
||||||
+29
-10
@@ -1,4 +1,10 @@
|
|||||||
import { ref, watch, type Ref } from 'vue';
|
import { ref, watch, type Ref } from 'vue';
|
||||||
|
import { commentaryReplicant, graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../nodecg/browser/replicants';
|
||||||
|
import { normalizeCommentary } from '../../shared/domain/commentary';
|
||||||
|
import { normalizeGraphicsSettings } from '../../shared/domain/graphics';
|
||||||
|
import { normalizePlayers } from '../../shared/domain/players/state';
|
||||||
|
import { normalizeScoreboard } from '../../shared/domain/scoreboard';
|
||||||
|
import type { Schemas } from '../../types';
|
||||||
|
|
||||||
interface ReplicantLike<T> {
|
interface ReplicantLike<T> {
|
||||||
data: T | undefined;
|
data: T | undefined;
|
||||||
@@ -36,7 +42,7 @@ export const writeStorageSnapshot = <T>(storageKey: string, value: T): void => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const syncStateWithReplicant = <T>(
|
const syncStateWithReplicant = <T>(
|
||||||
state: Ref<T>,
|
state: Ref<T>,
|
||||||
replicant: ReplicantLike<T> | undefined,
|
replicant: ReplicantLike<T> | undefined,
|
||||||
normalize: (input: unknown) => T,
|
normalize: (input: unknown) => T,
|
||||||
@@ -44,24 +50,21 @@ export const syncStateWithReplicant = <T>(
|
|||||||
): void => {
|
): void => {
|
||||||
const isApplyingReplicant = ref(false);
|
const isApplyingReplicant = ref(false);
|
||||||
const persistSnapshot = (value: T): void => {
|
const persistSnapshot = (value: T): void => {
|
||||||
if (!storageKey) {
|
if (storageKey) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
writeStorageSnapshot(storageKey, value);
|
writeStorageSnapshot(storageKey, value);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => replicant?.data,
|
() => replicant?.data,
|
||||||
(value) => {
|
(value) => {
|
||||||
if (!value) {
|
if (value === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isApplyingReplicant.value = true;
|
isApplyingReplicant.value = true;
|
||||||
state.value = normalize(value);
|
state.value = normalize(value);
|
||||||
isApplyingReplicant.value = false;
|
isApplyingReplicant.value = false;
|
||||||
|
|
||||||
persistSnapshot(state.value);
|
persistSnapshot(state.value);
|
||||||
},
|
},
|
||||||
{ deep: true, immediate: true },
|
{ deep: true, immediate: true },
|
||||||
@@ -70,16 +73,32 @@ export const syncStateWithReplicant = <T>(
|
|||||||
watch(
|
watch(
|
||||||
state,
|
state,
|
||||||
(value) => {
|
(value) => {
|
||||||
persistSnapshot(value);
|
const normalized = normalize(value);
|
||||||
|
persistSnapshot(normalized);
|
||||||
|
|
||||||
if (isApplyingReplicant.value || !replicant) {
|
if (isApplyingReplicant.value || !replicant) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replicants remain the source of truth for server/browser synchronization.
|
replicant.data = normalized;
|
||||||
replicant.data = normalize(value);
|
|
||||||
replicant.save();
|
replicant.save();
|
||||||
},
|
},
|
||||||
{ deep: true, flush: 'sync' },
|
{ deep: true, flush: 'sync' },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const syncScoreboardState = (state: Ref<Schemas.Scoreboard>, storageKey: string): void => {
|
||||||
|
syncStateWithReplicant(state, scoreboardReplicant, normalizeScoreboard, storageKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const syncPlayersState = (state: Ref<Schemas.Players>, storageKey: string): void => {
|
||||||
|
syncStateWithReplicant(state, playersReplicant, normalizePlayers, storageKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const syncCommentaryState = (state: Ref<Schemas.Commentary>): void => {
|
||||||
|
syncStateWithReplicant(state, commentaryReplicant, normalizeCommentary);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const syncGraphicsSettingsState = (state: Ref<Schemas.GraphicsSettings>): void => {
|
||||||
|
syncStateWithReplicant(state, graphicsSettingsReplicant, normalizeGraphicsSettings);
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import {
|
||||||
|
defaultCommentary,
|
||||||
|
normalizeCommentary,
|
||||||
|
swapCommentary,
|
||||||
|
type Commentary,
|
||||||
|
} from '../../shared/domain/commentary';
|
||||||
|
import { syncCommentaryState } from '../services/replicant-state-service';
|
||||||
|
|
||||||
|
export const useCommentaryStore = defineStore('commentary', () => {
|
||||||
|
const commentary = ref<Commentary>({ ...defaultCommentary });
|
||||||
|
syncCommentaryState(commentary);
|
||||||
|
|
||||||
|
const setCommentary = (value: Commentary): void => {
|
||||||
|
commentary.value = normalizeCommentary(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCommentary = (): void => {
|
||||||
|
commentary.value = { ...defaultCommentary };
|
||||||
|
};
|
||||||
|
|
||||||
|
const leftCommentator = computed({
|
||||||
|
get: () => commentary.value.leftCommentator,
|
||||||
|
set: (value: string) => {
|
||||||
|
commentary.value = {
|
||||||
|
...commentary.value,
|
||||||
|
leftCommentator: value,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const leftCommentatorTwitter = computed({
|
||||||
|
get: () => commentary.value.leftCommentatorTwitter,
|
||||||
|
set: (value: string) => {
|
||||||
|
commentary.value = {
|
||||||
|
...commentary.value,
|
||||||
|
leftCommentatorTwitter: value,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rightCommentator = computed({
|
||||||
|
get: () => commentary.value.rightCommentator,
|
||||||
|
set: (value: string) => {
|
||||||
|
commentary.value = {
|
||||||
|
...commentary.value,
|
||||||
|
rightCommentator: value,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rightCommentatorTwitter = computed({
|
||||||
|
get: () => commentary.value.rightCommentatorTwitter,
|
||||||
|
set: (value: string) => {
|
||||||
|
commentary.value = {
|
||||||
|
...commentary.value,
|
||||||
|
rightCommentatorTwitter: value,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const swapCommentators = (): void => {
|
||||||
|
commentary.value = swapCommentary(commentary.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
commentary,
|
||||||
|
leftCommentator,
|
||||||
|
leftCommentatorTwitter,
|
||||||
|
rightCommentator,
|
||||||
|
rightCommentatorTwitter,
|
||||||
|
setCommentary,
|
||||||
|
clearCommentary,
|
||||||
|
swapCommentators,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import {
|
||||||
|
defaultGraphicsSettings,
|
||||||
|
normalizeGraphicsSettings,
|
||||||
|
type GraphicsSettings,
|
||||||
|
} from '../../shared/domain/graphics';
|
||||||
|
import { syncGraphicsSettingsState } from '../services/replicant-state-service';
|
||||||
|
|
||||||
|
export const useGraphicsSettingsStore = defineStore('graphics-settings', () => {
|
||||||
|
const settings = ref<GraphicsSettings>({ ...defaultGraphicsSettings });
|
||||||
|
|
||||||
|
syncGraphicsSettingsState(settings);
|
||||||
|
|
||||||
|
const setSettings = (value: GraphicsSettings): void => {
|
||||||
|
settings.value = normalizeGraphicsSettings(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setScoreboardSkin = (scoreboardSkin: string): void => {
|
||||||
|
settings.value = normalizeGraphicsSettings({
|
||||||
|
...settings.value,
|
||||||
|
scoreboardSkin,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings,
|
||||||
|
setSettings,
|
||||||
|
setScoreboardSkin,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { createPackService } from '../services/pack-service';
|
||||||
|
import {
|
||||||
|
buildCharactersByGame,
|
||||||
|
buildDefaultCharactersByGame,
|
||||||
|
type DefaultCharacterPair,
|
||||||
|
type FightingCharacterOption,
|
||||||
|
} from '../../shared/domain/packs/characters';
|
||||||
|
import type {
|
||||||
|
GameSelectOption,
|
||||||
|
PackDownloadState,
|
||||||
|
PackManifest,
|
||||||
|
PackRegistry,
|
||||||
|
PackUpdateInfo,
|
||||||
|
} from '../../shared/domain/packs/types';
|
||||||
|
|
||||||
|
const packService = createPackService();
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) {
|
||||||
|
return `${bytes} B`;
|
||||||
|
}
|
||||||
|
if (bytes < 1024 * 1024) {
|
||||||
|
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
}
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLocalLogoUrl = (packId: string): string => `/packs/${packId}/logo.png`;
|
||||||
|
|
||||||
|
export const usePacksStore = defineStore('packs', () => {
|
||||||
|
const initialized = ref(false);
|
||||||
|
const registry = ref<PackRegistry | null>(null);
|
||||||
|
const installedPackIds = ref<string[]>([]);
|
||||||
|
const downloadStates = ref<Record<string, PackDownloadState>>({});
|
||||||
|
const availableUpdates = ref<Record<string, PackUpdateInfo>>({});
|
||||||
|
const installedManifests = ref<Record<string, PackManifest>>({});
|
||||||
|
const loadingManifestIds = new Set<string>();
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
let registryRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const installedManifestList = computed(() =>
|
||||||
|
installedPackIds.value
|
||||||
|
.map((packId) => installedManifests.value[packId])
|
||||||
|
.filter((manifest): manifest is PackManifest => Boolean(manifest)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const charactersByGame = computed(() => buildCharactersByGame(installedManifestList.value));
|
||||||
|
const defaultCharactersByGame = computed(() => buildDefaultCharactersByGame(installedManifestList.value));
|
||||||
|
|
||||||
|
const allGameOptions = computed<GameSelectOption[]>(() => {
|
||||||
|
if (!registry.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return registry.value.packs.map((entry) => ({
|
||||||
|
label: entry.name,
|
||||||
|
value: entry.name,
|
||||||
|
available: installedPackIds.value.includes(entry.id),
|
||||||
|
registryEntry: entry,
|
||||||
|
updateInfo: availableUpdates.value[entry.id],
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCount = computed(() => Object.keys(availableUpdates.value).length);
|
||||||
|
|
||||||
|
const loadInstalledManifest = (packId: string): void => {
|
||||||
|
if (installedManifests.value[packId] || loadingManifestIds.has(packId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingManifestIds.add(packId);
|
||||||
|
packService.readLocalManifest(packId)
|
||||||
|
.then((manifest) => {
|
||||||
|
installedManifests.value = {
|
||||||
|
...installedManifests.value,
|
||||||
|
[packId]: manifest,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error(`[packs] Failed to load manifest for "${packId}":`, error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
loadingManifestIds.delete(packId);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncInstalledManifests = (nextPackIds: string[]): void => {
|
||||||
|
const nextSet = new Set(nextPackIds);
|
||||||
|
const nextManifests: Record<string, PackManifest> = {};
|
||||||
|
|
||||||
|
Object.entries(installedManifests.value).forEach(([packId, manifest]) => {
|
||||||
|
if (nextSet.has(packId)) {
|
||||||
|
nextManifests[packId] = manifest;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
installedManifests.value = nextManifests;
|
||||||
|
|
||||||
|
nextPackIds.forEach(loadInstalledManifest);
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialize = (): void => {
|
||||||
|
if (initialized.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
initialized.value = true;
|
||||||
|
packService.subscribe({
|
||||||
|
onRegistryChanged: (value) => {
|
||||||
|
registry.value = value;
|
||||||
|
},
|
||||||
|
onInstalledPacksChanged: (value) => {
|
||||||
|
installedPackIds.value = [...value];
|
||||||
|
syncInstalledManifests(value);
|
||||||
|
},
|
||||||
|
onDownloadStatesChanged: (value) => {
|
||||||
|
downloadStates.value = { ...value };
|
||||||
|
},
|
||||||
|
onAvailableUpdatesChanged: (value) => {
|
||||||
|
availableUpdates.value = { ...value };
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((dispose) => {
|
||||||
|
unsubscribe = dispose;
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
initialized.value = false;
|
||||||
|
console.error('[packs] Failed to subscribe to pack replicants:', error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispose = (): void => {
|
||||||
|
unsubscribe?.();
|
||||||
|
unsubscribe = null;
|
||||||
|
if (registryRefreshTimer) {
|
||||||
|
clearInterval(registryRefreshTimer);
|
||||||
|
registryRefreshTimer = null;
|
||||||
|
}
|
||||||
|
initialized.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runCommand = (label: string, command: () => Promise<void>): void => {
|
||||||
|
command().catch((error: unknown) => {
|
||||||
|
console.error(`[packs] ${label} failed:`, error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchRegistry = (): void => {
|
||||||
|
runCommand('fetchRegistry', packService.fetchRegistry);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRegistryRefresh = (intervalMs = 15_000): void => {
|
||||||
|
fetchRegistry();
|
||||||
|
if (registryRefreshTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
registryRefreshTimer = setInterval(fetchRegistry, intervalMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopRegistryRefresh = (): void => {
|
||||||
|
if (!registryRefreshTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearInterval(registryRefreshTimer);
|
||||||
|
registryRefreshTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadPack = (packId: string): void => {
|
||||||
|
runCommand(`downloadPack "${packId}"`, () => packService.downloadPack(packId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const uninstallPack = (packId: string): void => {
|
||||||
|
runCommand(`uninstallPack "${packId}"`, () => packService.uninstallPack(packId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePack = (packId: string): void => {
|
||||||
|
runCommand(`updatePack "${packId}"`, () => packService.updatePack(packId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isGameAvailable = (gameName: string): boolean => {
|
||||||
|
const entry = registry.value?.packs.find((pack) => pack.name === gameName);
|
||||||
|
return entry ? installedPackIds.value.includes(entry.id) : false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDownloadState = (packId: string): PackDownloadState =>
|
||||||
|
downloadStates.value[packId] ?? { status: 'idle', progress: 0 };
|
||||||
|
|
||||||
|
const getCharactersByGame = (gameName: string): FightingCharacterOption[] =>
|
||||||
|
charactersByGame.value[gameName] ?? [];
|
||||||
|
|
||||||
|
const getDefaultCharactersByGame = (gameName: string): DefaultCharacterPair | undefined =>
|
||||||
|
defaultCharactersByGame.value[gameName];
|
||||||
|
|
||||||
|
return {
|
||||||
|
registry,
|
||||||
|
installedPackIds,
|
||||||
|
downloadStates,
|
||||||
|
availableUpdates,
|
||||||
|
installedManifests,
|
||||||
|
allGameOptions,
|
||||||
|
updateCount,
|
||||||
|
initialize,
|
||||||
|
dispose,
|
||||||
|
fetchRegistry,
|
||||||
|
startRegistryRefresh,
|
||||||
|
stopRegistryRefresh,
|
||||||
|
downloadPack,
|
||||||
|
uninstallPack,
|
||||||
|
updatePack,
|
||||||
|
isGameAvailable,
|
||||||
|
getDownloadState,
|
||||||
|
getCharactersByGame,
|
||||||
|
getDefaultCharactersByGame,
|
||||||
|
formatBytes,
|
||||||
|
getLocalLogoUrl,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import {
|
||||||
|
normalizePlayer,
|
||||||
|
normalizePlayers,
|
||||||
|
type Player,
|
||||||
|
type PlayersMap,
|
||||||
|
} from '../../shared/domain/players/state';
|
||||||
|
import { readStorageSnapshot, syncPlayersState } from '../services/replicant-state-service';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'scoreko-dev.players';
|
||||||
|
|
||||||
|
export const usePlayersStore = defineStore('players', () => {
|
||||||
|
const players = ref<PlayersMap>({});
|
||||||
|
const storageSnapshot = readStorageSnapshot(STORAGE_KEY, normalizePlayers);
|
||||||
|
if (storageSnapshot) {
|
||||||
|
players.value = storageSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncPlayersState(players, STORAGE_KEY);
|
||||||
|
|
||||||
|
const setPlayers = (value: PlayersMap): void => {
|
||||||
|
players.value = normalizePlayers(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const upsertPlayer = (id: string, player: Player): void => {
|
||||||
|
players.value = {
|
||||||
|
...players.value,
|
||||||
|
[id]: normalizePlayer(player),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePlayer = (id: string): void => {
|
||||||
|
const next = { ...players.value };
|
||||||
|
delete next[id];
|
||||||
|
players.value = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows = computed(() => Object.entries(players.value).map(([id, player]) => ({
|
||||||
|
id,
|
||||||
|
...player,
|
||||||
|
})));
|
||||||
|
|
||||||
|
return {
|
||||||
|
players,
|
||||||
|
rows,
|
||||||
|
setPlayers,
|
||||||
|
upsertPlayer,
|
||||||
|
removePlayer,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import {
|
||||||
|
adjustScoreboardScore,
|
||||||
|
defaultScoreboard,
|
||||||
|
normalizeScoreboard,
|
||||||
|
resetScoreboardScores,
|
||||||
|
setScoreboardScore,
|
||||||
|
swapScoreboardPlayers,
|
||||||
|
type Scoreboard,
|
||||||
|
type ScoreboardSide,
|
||||||
|
} from '../../shared/domain/scoreboard';
|
||||||
|
import { readStorageSnapshot, syncScoreboardState } from '../services/replicant-state-service';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'scoreko-dev.scoreboard';
|
||||||
|
|
||||||
|
export const useScoreboardStore = defineStore('scoreboard', () => {
|
||||||
|
const scoreboard = ref<Scoreboard>({ ...defaultScoreboard });
|
||||||
|
const storageSnapshot = readStorageSnapshot(STORAGE_KEY, normalizeScoreboard);
|
||||||
|
if (storageSnapshot) {
|
||||||
|
scoreboard.value = storageSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncScoreboardState(scoreboard, STORAGE_KEY);
|
||||||
|
|
||||||
|
const setScoreboard = (value: Scoreboard): void => {
|
||||||
|
scoreboard.value = normalizeScoreboard(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setGame = (value: string): void => {
|
||||||
|
scoreboard.value = { ...scoreboard.value, game: value };
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRound = (value: string): void => {
|
||||||
|
scoreboard.value = { ...scoreboard.value, round: value };
|
||||||
|
};
|
||||||
|
|
||||||
|
const setScore = (side: ScoreboardSide, value: number): void => {
|
||||||
|
scoreboard.value = setScoreboardScore(scoreboard.value, side, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const adjustScore = (side: ScoreboardSide, delta: number): void => {
|
||||||
|
scoreboard.value = adjustScoreboardScore(scoreboard.value, side, delta);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSidePlayerId = (side: ScoreboardSide, value: string): void => {
|
||||||
|
scoreboard.value = {
|
||||||
|
...scoreboard.value,
|
||||||
|
[side === 'left' ? 'leftPlayerId' : 'rightPlayerId']: value,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSideNameOverride = (side: ScoreboardSide, value: string): void => {
|
||||||
|
scoreboard.value = {
|
||||||
|
...scoreboard.value,
|
||||||
|
[side === 'left' ? 'leftNameOverride' : 'rightNameOverride']: value,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSideTeamOverride = (side: ScoreboardSide, value: string): void => {
|
||||||
|
scoreboard.value = {
|
||||||
|
...scoreboard.value,
|
||||||
|
[side === 'left' ? 'leftTeamOverride' : 'rightTeamOverride']: value,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSideCountryOverride = (side: ScoreboardSide, value: string): void => {
|
||||||
|
scoreboard.value = {
|
||||||
|
...scoreboard.value,
|
||||||
|
[side === 'left' ? 'leftCountryOverride' : 'rightCountryOverride']: value,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSideCharacter = (side: ScoreboardSide, value: string): void => {
|
||||||
|
scoreboard.value = {
|
||||||
|
...scoreboard.value,
|
||||||
|
[side === 'left' ? 'leftCharacter' : 'rightCharacter']: value,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const swapPlayers = (): void => {
|
||||||
|
scoreboard.value = swapScoreboardPlayers(scoreboard.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetScores = (): void => {
|
||||||
|
scoreboard.value = resetScoreboardScores(scoreboard.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const leftScore = computed({
|
||||||
|
get: () => scoreboard.value.leftScore,
|
||||||
|
set: (value: number) => {
|
||||||
|
setScore('left', value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rightScore = computed({
|
||||||
|
get: () => scoreboard.value.rightScore,
|
||||||
|
set: (value: number) => {
|
||||||
|
setScore('right', value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
scoreboard,
|
||||||
|
leftScore,
|
||||||
|
rightScore,
|
||||||
|
setScoreboard,
|
||||||
|
setGame,
|
||||||
|
setRound,
|
||||||
|
setScore,
|
||||||
|
adjustScore,
|
||||||
|
setSidePlayerId,
|
||||||
|
setSideNameOverride,
|
||||||
|
setSideTeamOverride,
|
||||||
|
setSideCountryOverride,
|
||||||
|
setSideCharacter,
|
||||||
|
swapPlayers,
|
||||||
|
resetScores,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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(),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
import { nodecg } from '../nodecg/extension/context.js';
|
||||||
|
import { listenForMessage } from '../nodecg/extension/messages.js';
|
||||||
|
import { messageNames } from '../nodecg/messageNames.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> => {
|
||||||
|
void _config;
|
||||||
|
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 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
listenForMessage(messageNames.integrations.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));
|
||||||
|
});
|
||||||
|
|
||||||
|
listenForMessage(messageNames.integrations.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);
|
||||||
|
});
|
||||||
|
|
||||||
|
listenForMessage(messageNames.integrations.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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
listenForMessage(messageNames.integrations.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');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import type { ExampleType } from '../types/index.js';
|
|
||||||
import { nodecg } from './util/nodecg.js';
|
|
||||||
import { exampleReplicant } from './util/replicants.js';
|
|
||||||
|
|
||||||
// Example code:
|
|
||||||
// Log to show things are working.
|
|
||||||
nodecg.log.info('Extension code working!');
|
|
||||||
// Access this bundle's configuration with no type assertion needed.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const configProperty = nodecg.bundleConfig.exampleProperty;
|
|
||||||
// Access/set a replicant (also see ./util/replicants).
|
|
||||||
exampleReplicant.value = { exampleProperty: `exampleString_Changed_${Date.now()}` };
|
|
||||||
// Accessing normal types.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const exampleType: ExampleType = { exampleProperty: 'exampleString' };
|
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
import type { NodeCGServerAPI } from '../types/index.js';
|
import type { NodeCGServerAPI } from '../types/index.js';
|
||||||
import { set } from './util/nodecg.js';
|
import { setNodecgContext } from '../nodecg/extension/context.js';
|
||||||
|
|
||||||
export default async (nodecg: NodeCGServerAPI) => {
|
export default async (nodecg: NodeCGServerAPI) => {
|
||||||
/**
|
/**
|
||||||
* Because of how top-level `import`s work, it helps to use `import`s here
|
* Because of how top-level `import`s work, it helps to use `import`s here
|
||||||
* to force things to be loaded *after* the NodeCG context is set.
|
* to force things to be loaded *after* the NodeCG context is set.
|
||||||
*/
|
*/
|
||||||
set(nodecg); // set nodecg "context" before anything else
|
setNodecgContext(nodecg); // set nodecg "context" before anything else
|
||||||
await import('./util/replicants.js'); // make sure replicants are set up
|
await import('./modules/replicants.js'); // make sure replicants are set up
|
||||||
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,9 @@
|
|||||||
|
import {
|
||||||
|
commentaryReplicant,
|
||||||
|
playersReplicant,
|
||||||
|
scoreboardReplicant,
|
||||||
|
} from '../../nodecg/extension/replicants.js';
|
||||||
|
|
||||||
|
playersReplicant();
|
||||||
|
scoreboardReplicant();
|
||||||
|
commentaryReplicant();
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
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');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
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');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
@@ -12,7 +12,15 @@ import * as fs from 'fs';
|
|||||||
import type { IncomingMessage, ServerResponse } from 'http';
|
import type { IncomingMessage, ServerResponse } from 'http';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { nodecg } from './util/nodecg.js';
|
import { nodecg } from '../nodecg/extension/context.js';
|
||||||
|
import { listenForMessage, reply, type Acknowledgement } from '../nodecg/extension/messages.js';
|
||||||
|
import { createPackExtensionReplicants } from '../nodecg/extension/packReplicants.js';
|
||||||
|
import { messageNames } from '../nodecg/messageNames.js';
|
||||||
|
import type {
|
||||||
|
PackDownloadState,
|
||||||
|
PackManifest,
|
||||||
|
PackRegistry,
|
||||||
|
} from '../shared/domain/packs/types.js';
|
||||||
|
|
||||||
// ── Configuración de Gitea ────────────────────────────────────────────────────
|
// ── Configuración de Gitea ────────────────────────────────────────────────────
|
||||||
// Edita estas constantes para apuntar a tu instancia.
|
// Edita estas constantes para apuntar a tu instancia.
|
||||||
@@ -33,54 +41,9 @@ const getCharacterImageRepoUrl = (id: string, slug: string, ext: string) =>
|
|||||||
|
|
||||||
// ── Tipos locales ─────────────────────────────────────────────────────────────
|
// ── Tipos locales ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface PackCharacter {
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
dlc?: boolean;
|
|
||||||
sizeBytes: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PackManifest {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
version: string;
|
|
||||||
palette: { start: string; end: string };
|
|
||||||
defaultPair?: { left: string; right: string };
|
|
||||||
characters: PackCharacter[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PackRegistry {
|
|
||||||
schemaVersion: number;
|
|
||||||
updatedAt: string;
|
|
||||||
packs: Array<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
version: string;
|
|
||||||
totalSizeBytes: number;
|
|
||||||
logoPath: string;
|
|
||||||
characterCount: number;
|
|
||||||
palette: { start: string; end: string };
|
|
||||||
bundled: boolean;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PackDownloadState {
|
|
||||||
status: 'idle' | 'fetching-manifest' | 'downloading' | 'done' | 'error';
|
|
||||||
progress: number;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replicamos la forma exacta del tipo Acknowledgement de NodeCG sin necesidad
|
// Replicamos la forma exacta del tipo Acknowledgement de NodeCG sin necesidad
|
||||||
// de importar @nodecg/types. HandledAcknowledgement NO es callable (es un objeto),
|
// de importar @nodecg/types. HandledAcknowledgement NO es callable (es un objeto),
|
||||||
// UnhandledAcknowledgement SÍ lo es. El helper reply() comprueba cuál es antes de llamar.
|
// UnhandledAcknowledgement SÍ lo es. El helper reply() comprueba cuál es antes de llamar.
|
||||||
type HandledAcknowledgement = { handled: true };
|
|
||||||
type UnhandledAcknowledgement = ((error?: Error | null, ...args: unknown[]) => void) & { handled: false };
|
|
||||||
type Acknowledgement = HandledAcknowledgement | UnhandledAcknowledgement;
|
|
||||||
|
|
||||||
const reply = (ack: Acknowledgement | undefined, err: Error | null, result?: unknown): void => {
|
|
||||||
if (ack && !ack.handled) ack(err ?? undefined, result);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Constantes ────────────────────────────────────────────────────────────────
|
// ── Constantes ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = ['png', 'webp', 'jpg', 'jpeg', 'avif'] as const;
|
const IMAGE_EXTENSIONS = ['png', 'webp', 'jpg', 'jpeg', 'avif'] as const;
|
||||||
@@ -93,26 +56,12 @@ const bundleDir = fileURLToPath(new URL('../', import.meta.url));
|
|||||||
|
|
||||||
// ── Replicants ────────────────────────────────────────────────────────────────
|
// ── Replicants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const installedPacksRep = nodecg.Replicant<string[]>('installedPacks', {
|
const {
|
||||||
defaultValue: [],
|
installedPacksRep,
|
||||||
persistent: true,
|
packRegistryRep,
|
||||||
});
|
downloadStatesRep,
|
||||||
|
availableUpdatesRep,
|
||||||
const packRegistryRep = nodecg.Replicant<PackRegistry | null>('packRegistry', {
|
} = createPackExtensionReplicants();
|
||||||
defaultValue: null,
|
|
||||||
persistent: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const downloadStatesRep = nodecg.Replicant<Record<string, PackDownloadState>>('downloadStates', {
|
|
||||||
defaultValue: {},
|
|
||||||
persistent: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Packs instalados para los que hay una versión más nueva en el registro. */
|
|
||||||
const availableUpdatesRep = nodecg.Replicant<Record<string, { installedVersion: string; latestVersion: string }>>('availableUpdates', {
|
|
||||||
defaultValue: {},
|
|
||||||
persistent: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Filesystem ────────────────────────────────────────────────────────────────
|
// ── Filesystem ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -249,7 +198,7 @@ checkForUpdates();
|
|||||||
|
|
||||||
// ── Mensaje: fetchPackRegistry ────────────────────────────────────────────────
|
// ── Mensaje: fetchPackRegistry ────────────────────────────────────────────────
|
||||||
|
|
||||||
nodecg.listenFor('fetchPackRegistry', async (_data: unknown, ack: Acknowledgement | undefined) => {
|
listenForMessage(messageNames.packs.fetchRegistry, async (_data: unknown, ack: Acknowledgement | undefined) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(REGISTRY_URL);
|
const response = await fetch(REGISTRY_URL);
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
@@ -266,7 +215,7 @@ nodecg.listenFor('fetchPackRegistry', async (_data: unknown, ack: Acknowledgemen
|
|||||||
|
|
||||||
// ── Mensaje: downloadPack ─────────────────────────────────────────────────────
|
// ── Mensaje: downloadPack ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
nodecg.listenFor('downloadPack', async (packId: unknown, ack: Acknowledgement | undefined) => {
|
listenForMessage(messageNames.packs.download, async (packId: unknown, ack: Acknowledgement | undefined) => {
|
||||||
if (typeof packId !== 'string' || !packId) {
|
if (typeof packId !== 'string' || !packId) {
|
||||||
return reply(ack, new Error('downloadPack requiere un packId no vacío.'));
|
return reply(ack, new Error('downloadPack requiere un packId no vacío.'));
|
||||||
}
|
}
|
||||||
@@ -327,7 +276,7 @@ nodecg.listenFor('downloadPack', async (packId: unknown, ack: Acknowledgement |
|
|||||||
|
|
||||||
// ── Mensaje: uninstallPack ────────────────────────────────────────────────────
|
// ── Mensaje: uninstallPack ────────────────────────────────────────────────────
|
||||||
|
|
||||||
nodecg.listenFor('uninstallPack', (packId: unknown, ack: Acknowledgement | undefined) => {
|
listenForMessage(messageNames.packs.uninstall, (packId: unknown, ack: Acknowledgement | undefined) => {
|
||||||
if (typeof packId !== 'string' || !packId) {
|
if (typeof packId !== 'string' || !packId) {
|
||||||
return reply(ack, new Error('uninstallPack requiere un packId no vacío.'));
|
return reply(ack, new Error('uninstallPack requiere un packId no vacío.'));
|
||||||
}
|
}
|
||||||
@@ -352,7 +301,7 @@ nodecg.listenFor('uninstallPack', (packId: unknown, ack: Acknowledgement | undef
|
|||||||
// Dashboard → Extension: "Actualiza el pack <packId> a la última versión."
|
// Dashboard → Extension: "Actualiza el pack <packId> a la última versión."
|
||||||
// Borra las imágenes antiguas y descarga las nuevas desde Gitea.
|
// Borra las imágenes antiguas y descarga las nuevas desde Gitea.
|
||||||
|
|
||||||
nodecg.listenFor('updatePack', async (packId: unknown, ack: Acknowledgement | undefined) => {
|
listenForMessage(messageNames.packs.update, async (packId: unknown, ack: Acknowledgement | undefined) => {
|
||||||
if (typeof packId !== 'string' || !packId) {
|
if (typeof packId !== 'string' || !packId) {
|
||||||
return reply(ack, new Error('updatePack requiere un packId no vacío.'));
|
return reply(ack, new Error('updatePack requiere un packId no vacío.'));
|
||||||
}
|
}
|
||||||
@@ -426,7 +375,7 @@ nodecg.listenFor('updatePack', async (packId: unknown, ack: Acknowledgement | un
|
|||||||
|
|
||||||
// ── Mensaje: readLocalManifest ────────────────────────────────────────────────
|
// ── Mensaje: readLocalManifest ────────────────────────────────────────────────
|
||||||
|
|
||||||
nodecg.listenFor('readLocalManifest', (packId: unknown, ack: Acknowledgement | undefined) => {
|
listenForMessage(messageNames.packs.readLocalManifest, (packId: unknown, ack: Acknowledgement | undefined) => {
|
||||||
if (typeof packId !== 'string' || !packId) {
|
if (typeof packId !== 'string' || !packId) {
|
||||||
return reply(ack, new Error('readLocalManifest requiere un packId no vacío.'));
|
return reply(ack, new Error('readLocalManifest requiere un packId no vacío.'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
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);
|
|
||||||
};
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
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());
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
import { getData, type CountryRecord } from 'country-list';
|
||||||
|
import { nodecg } from '../nodecg/extension/context.js';
|
||||||
|
import { listenForMessage } from '../nodecg/extension/messages.js';
|
||||||
|
import { messageNames } from '../nodecg/messageNames.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> => {
|
||||||
|
void _config;
|
||||||
|
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 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
listenForMessage(messageNames.integrations.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));
|
||||||
|
});
|
||||||
|
|
||||||
|
listenForMessage(messageNames.integrations.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);
|
||||||
|
});
|
||||||
|
|
||||||
|
listenForMessage(messageNames.integrations.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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
listenForMessage(messageNames.integrations.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,7 +0,0 @@
|
|||||||
import type { NodeCGServerAPI } from '../../types/index.js';
|
|
||||||
|
|
||||||
export let nodecg!: NodeCGServerAPI;
|
|
||||||
|
|
||||||
export function set(ctx: NodeCGServerAPI) {
|
|
||||||
nodecg = ctx;
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import type NodeCG from 'nodecg/types';
|
|
||||||
import type { Schemas } from '../../types/index.js';
|
|
||||||
import { nodecg } from './nodecg.js';
|
|
||||||
|
|
||||||
// Wrapper for replicants that have a default (based on schema).
|
|
||||||
function hasDefault<T>(name: string) {
|
|
||||||
return nodecg.Replicant<T>(name) as unknown as NodeCG.default.ServerReplicantWithSchemaDefault<T>;
|
|
||||||
}
|
|
||||||
// Wrapper for replicants that don't have a default (based on schema).
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
function hasNoDefault<T>(name: string) {
|
|
||||||
return nodecg.Replicant<T>(name) as NodeCG.default.ServerReplicant<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is where you can declare all of your replicants to import easily into other files,
|
|
||||||
* and to make sure they have any correct settings on startup.
|
|
||||||
*/
|
|
||||||
export const exampleReplicant = hasDefault<Schemas.ExampleReplicant>('exampleReplicant');
|
|
||||||
export const playersReplicant = hasDefault<Schemas.Players>('players');
|
|
||||||
export const scoreboardReplicant = hasDefault<Schemas.Scoreboard>('scoreboard');
|
|
||||||
|
|
||||||
export const commentaryReplicant = nodecg.Replicant<Schemas.Commentary>('commentary', {
|
|
||||||
defaultValue: {
|
|
||||||
leftCommentator: '',
|
|
||||||
leftCommentatorTwitter: '',
|
|
||||||
rightCommentator: '',
|
|
||||||
rightCommentatorTwitter: '',
|
|
||||||
},
|
|
||||||
persistent: false,
|
|
||||||
});
|
|
||||||
@@ -1,19 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useHead } from '@unhead/vue';
|
import { useHead } from '@unhead/vue';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { commentaryReplicant } from '../../browser_shared/replicants';
|
import { useCommentaryReplicatedState } from '../shared/services/replicated-state';
|
||||||
import type { Schemas } from '../../types';
|
|
||||||
|
|
||||||
useHead({ title: 'Commentary' });
|
useHead({ title: 'Commentary' });
|
||||||
|
|
||||||
const defaultCommentary: Schemas.Commentary = {
|
const { commentary } = useCommentaryReplicatedState();
|
||||||
leftCommentator: '',
|
|
||||||
leftCommentatorTwitter: '',
|
|
||||||
rightCommentator: '',
|
|
||||||
rightCommentatorTwitter: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const commentary = computed<Schemas.Commentary>(() => commentaryReplicant?.data ?? defaultCommentary);
|
|
||||||
|
|
||||||
const leftCommentator = computed(() => commentary.value.leftCommentator || 'COMMENTATOR 1');
|
const leftCommentator = computed(() => commentary.value.leftCommentator || 'COMMENTATOR 1');
|
||||||
const rightCommentator = computed(() => commentary.value.rightCommentator || 'COMMENTATOR 2');
|
const rightCommentator = computed(() => commentary.value.rightCommentator || 'COMMENTATOR 2');
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
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 { useScoreboardReplicatedState } from '../shared/services/replicated-state';
|
||||||
import { resolveCountryCode } from '../../shared/utils/countries';
|
import { resolveCountryCode } from '../../shared/domain/players/countries';
|
||||||
import { getCharactersByGame } from '../../shared/fighting-characters';
|
import { getCharactersByGame } from '../../shared/fighting-characters';
|
||||||
import type { Schemas } from '../../types';
|
|
||||||
|
|
||||||
useHead({ title: 'Scoreboard 2XKO' });
|
useHead({ title: 'Scoreboard 2XKO' });
|
||||||
|
|
||||||
const defaultScoreboard: Schemas.Scoreboard = {
|
const { players, scoreboard, scoreboardSkin } = useScoreboardReplicatedState('scoreboard-2xko/main.html');
|
||||||
leftPlayerId: '', rightPlayerId: '', leftNameOverride: '', rightNameOverride: '', leftTeamOverride: '', rightTeamOverride: '',
|
|
||||||
leftCountryOverride: '', rightCountryOverride: '', leftCharacter: '', rightCharacter: '', leftScore: 0, rightScore: 0, round: '', game: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const players = computed<Schemas.Players>(() => playersReplicant?.data ?? {});
|
|
||||||
const scoreboard = computed<Schemas.Scoreboard>(() => scoreboardReplicant?.data ?? defaultScoreboard);
|
|
||||||
const scoreboardSkin = computed(() => graphicsSettingsReplicant?.data?.scoreboardSkin ?? 'scoreboard-2xko/main.html');
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
scoreboardSkin,
|
scoreboardSkin,
|
||||||
|
|||||||
@@ -1,32 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
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 { useScoreboardReplicatedState } from '../shared/services/replicated-state';
|
||||||
import { resolveCountryCode } from '../../shared/utils/countries';
|
import { resolveCountryCode } from '../../shared/domain/players/countries';
|
||||||
import type { Schemas } from '../../types';
|
|
||||||
|
|
||||||
useHead({ title: 'Scoreboard' });
|
useHead({ title: 'Scoreboard' });
|
||||||
|
|
||||||
const defaultScoreboard: Schemas.Scoreboard = {
|
const { players, scoreboard, scoreboardSkin } = useScoreboardReplicatedState('scoreboard/main.html');
|
||||||
leftPlayerId: '',
|
|
||||||
rightPlayerId: '',
|
|
||||||
leftNameOverride: '',
|
|
||||||
rightNameOverride: '',
|
|
||||||
leftTeamOverride: '',
|
|
||||||
rightTeamOverride: '',
|
|
||||||
leftCountryOverride: '',
|
|
||||||
rightCountryOverride: '',
|
|
||||||
leftCharacter: '',
|
|
||||||
rightCharacter: '',
|
|
||||||
leftScore: 0,
|
|
||||||
rightScore: 0,
|
|
||||||
round: '',
|
|
||||||
game: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const players = computed<Schemas.Players>(() => playersReplicant?.data ?? {});
|
|
||||||
const scoreboard = computed<Schemas.Scoreboard>(() => scoreboardReplicant?.data ?? defaultScoreboard);
|
|
||||||
const scoreboardSkin = computed(() => graphicsSettingsReplicant?.data?.scoreboardSkin ?? 'scoreboard/main.html');
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
scoreboardSkin,
|
scoreboardSkin,
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import { commentaryReplicant, graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../../nodecg/browser/replicants';
|
||||||
|
import { defaultCommentary } from '../../../shared/domain/commentary';
|
||||||
|
import { defaultScoreboard } from '../../../shared/domain/scoreboard';
|
||||||
|
import type { Schemas } from '../../../types';
|
||||||
|
|
||||||
|
export const useScoreboardReplicatedState = (defaultSkin: string) => {
|
||||||
|
const players = computed<Schemas.Players>(() => playersReplicant?.data ?? {});
|
||||||
|
const scoreboard = computed<Schemas.Scoreboard>(() => scoreboardReplicant?.data ?? defaultScoreboard);
|
||||||
|
const scoreboardSkin = computed(() => graphicsSettingsReplicant?.data?.scoreboardSkin ?? defaultSkin);
|
||||||
|
|
||||||
|
return {
|
||||||
|
players,
|
||||||
|
scoreboard,
|
||||||
|
scoreboardSkin,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCommentaryReplicatedState = () => {
|
||||||
|
const commentary = computed<Schemas.Commentary>(() => commentaryReplicant?.data ?? defaultCommentary);
|
||||||
|
|
||||||
|
return {
|
||||||
|
commentary,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export const sendNodecgMessage = <T>(messageName: string, payload: unknown): Promise<T> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
nodecg.sendMessage(messageName, payload, (error: unknown, response: unknown) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(response as T);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sendNodecgCommand = (messageName: string, payload?: unknown): Promise<void> =>
|
||||||
|
sendNodecgMessage<void>(messageName, payload);
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { BUNDLE_NAME } from '../../shared/domain/packs/config';
|
||||||
|
import type {
|
||||||
|
PackDownloadState,
|
||||||
|
PackRegistry,
|
||||||
|
PackUpdateInfo,
|
||||||
|
} from '../../shared/domain/packs/types';
|
||||||
|
import { replicantNames } from '../replicantNames';
|
||||||
|
|
||||||
|
interface BrowserReplicant<T> {
|
||||||
|
value: T;
|
||||||
|
on(event: 'change', handler: (newVal: T, oldVal?: T) => void): void;
|
||||||
|
off(event: 'change', handler: (newVal: T, oldVal?: T) => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackBrowserReplicants {
|
||||||
|
registryRep: BrowserReplicant<PackRegistry | null>;
|
||||||
|
installedRep: BrowserReplicant<string[]>;
|
||||||
|
statesRep: BrowserReplicant<Record<string, PackDownloadState>>;
|
||||||
|
updatesRep: BrowserReplicant<Record<string, PackUpdateInfo>>;
|
||||||
|
waitUntilReady: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPackBrowserReplicants = (): PackBrowserReplicants => {
|
||||||
|
const registryRep = NodeCG.Replicant<PackRegistry | null>(
|
||||||
|
replicantNames.packRegistry,
|
||||||
|
BUNDLE_NAME,
|
||||||
|
{ defaultValue: null },
|
||||||
|
);
|
||||||
|
const installedRep = NodeCG.Replicant<string[]>(
|
||||||
|
replicantNames.installedPacks,
|
||||||
|
BUNDLE_NAME,
|
||||||
|
{ defaultValue: [] },
|
||||||
|
);
|
||||||
|
const statesRep = NodeCG.Replicant<Record<string, PackDownloadState>>(
|
||||||
|
replicantNames.downloadStates,
|
||||||
|
BUNDLE_NAME,
|
||||||
|
{ defaultValue: {} },
|
||||||
|
);
|
||||||
|
const updatesRep = NodeCG.Replicant<Record<string, PackUpdateInfo>>(
|
||||||
|
replicantNames.availableUpdates,
|
||||||
|
BUNDLE_NAME,
|
||||||
|
{ defaultValue: {} },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
registryRep,
|
||||||
|
installedRep,
|
||||||
|
statesRep,
|
||||||
|
updatesRep,
|
||||||
|
waitUntilReady: () => NodeCG.waitForReplicants(registryRep, installedRep, statesRep, updatesRep),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useReplicant } from 'nodecg-vue-composable';
|
||||||
|
import { BUNDLE_NAME } from '../../shared/domain/packs/config';
|
||||||
|
import type { Schemas } from '../../types';
|
||||||
|
import { replicantNames } from '../replicantNames';
|
||||||
|
|
||||||
|
export const playersReplicant = useReplicant<Schemas.Players>(
|
||||||
|
replicantNames.players,
|
||||||
|
BUNDLE_NAME,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const scoreboardReplicant = useReplicant<Schemas.Scoreboard>(
|
||||||
|
replicantNames.scoreboard,
|
||||||
|
BUNDLE_NAME,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const graphicsSettingsReplicant = useReplicant<Schemas.GraphicsSettings>(
|
||||||
|
replicantNames.graphicsSettings,
|
||||||
|
BUNDLE_NAME,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const commentaryReplicant = useReplicant<Schemas.Commentary>(
|
||||||
|
replicantNames.commentary,
|
||||||
|
BUNDLE_NAME,
|
||||||
|
);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { NodeCGServerAPI } from '../../types/index.js';
|
||||||
|
|
||||||
|
let currentNodecg: NodeCGServerAPI | null = null;
|
||||||
|
|
||||||
|
export function setNodecgContext(ctx: NodeCGServerAPI): void {
|
||||||
|
currentNodecg = ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodecgContext(): NodeCGServerAPI {
|
||||||
|
if (!currentNodecg) {
|
||||||
|
throw new Error('NodeCG context has not been initialized.');
|
||||||
|
}
|
||||||
|
return currentNodecg;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nodecgContext = {
|
||||||
|
get nodecg(): NodeCGServerAPI {
|
||||||
|
return getNodecgContext();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const nodecg = new Proxy({} as NodeCGServerAPI, {
|
||||||
|
get(_target, prop: string | symbol) {
|
||||||
|
return getNodecgContext()[prop as keyof NodeCGServerAPI];
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { getNodecgContext } from './context.js';
|
||||||
|
|
||||||
|
type HandledAcknowledgement = { handled: true };
|
||||||
|
type UnhandledAcknowledgement = ((error?: Error | null, ...args: unknown[]) => void) & {
|
||||||
|
handled: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Acknowledgement = HandledAcknowledgement | UnhandledAcknowledgement;
|
||||||
|
export type NodecgMessageHandler = (
|
||||||
|
payload: unknown,
|
||||||
|
ack?: Acknowledgement,
|
||||||
|
) => void | Promise<void>;
|
||||||
|
|
||||||
|
export const reply = (
|
||||||
|
ack: Acknowledgement | undefined,
|
||||||
|
error: Error | null,
|
||||||
|
result?: unknown,
|
||||||
|
): void => {
|
||||||
|
if (ack && !ack.handled) {
|
||||||
|
ack(error ?? undefined, result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listenForMessage = (messageName: string, handler: NodecgMessageHandler): void => {
|
||||||
|
getNodecgContext().listenFor(messageName, handler);
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type {
|
||||||
|
PackDownloadState,
|
||||||
|
PackRegistry,
|
||||||
|
PackUpdateInfo,
|
||||||
|
} from '../../shared/domain/packs/types.js';
|
||||||
|
import { replicantNames } from '../replicantNames.js';
|
||||||
|
import { getNodecgContext } from './context.js';
|
||||||
|
|
||||||
|
export const createPackExtensionReplicants = () => {
|
||||||
|
const nodecg = getNodecgContext();
|
||||||
|
|
||||||
|
return {
|
||||||
|
installedPacksRep: nodecg.Replicant<string[]>(replicantNames.installedPacks, {
|
||||||
|
defaultValue: [],
|
||||||
|
persistent: true,
|
||||||
|
}),
|
||||||
|
packRegistryRep: nodecg.Replicant<PackRegistry | null>(replicantNames.packRegistry, {
|
||||||
|
defaultValue: null,
|
||||||
|
persistent: true,
|
||||||
|
}),
|
||||||
|
downloadStatesRep: nodecg.Replicant<Record<string, PackDownloadState>>(
|
||||||
|
replicantNames.downloadStates,
|
||||||
|
{
|
||||||
|
defaultValue: {},
|
||||||
|
persistent: false,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
availableUpdatesRep: nodecg.Replicant<Record<string, PackUpdateInfo>>(
|
||||||
|
replicantNames.availableUpdates,
|
||||||
|
{
|
||||||
|
defaultValue: {},
|
||||||
|
persistent: false,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type NodeCG from 'nodecg/types';
|
||||||
|
import type { Schemas } from '../../types/index.js';
|
||||||
|
import { replicantNames } from '../replicantNames.js';
|
||||||
|
import { getNodecgContext } from './context.js';
|
||||||
|
|
||||||
|
type ServerReplicantWithDefault<T> = NodeCG.default.ServerReplicantWithSchemaDefault<T>;
|
||||||
|
type ServerReplicant<T> = NodeCG.default.ServerReplicant<T>;
|
||||||
|
|
||||||
|
export function getReplicantWithDefault<T>(name: string): ServerReplicantWithDefault<T> {
|
||||||
|
return getNodecgContext().Replicant<T>(name) as unknown as ServerReplicantWithDefault<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReplicant<T>(name: string): ServerReplicant<T> {
|
||||||
|
return getNodecgContext().Replicant<T>(name) as ServerReplicant<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const playersReplicant = (): ServerReplicantWithDefault<Schemas.Players> =>
|
||||||
|
getReplicantWithDefault<Schemas.Players>(replicantNames.players);
|
||||||
|
|
||||||
|
export const scoreboardReplicant = (): ServerReplicantWithDefault<Schemas.Scoreboard> =>
|
||||||
|
getReplicantWithDefault<Schemas.Scoreboard>(replicantNames.scoreboard);
|
||||||
|
|
||||||
|
export const commentaryReplicant = (): ServerReplicant<Schemas.Commentary> =>
|
||||||
|
getNodecgContext().Replicant<Schemas.Commentary>(replicantNames.commentary, {
|
||||||
|
defaultValue: {
|
||||||
|
leftCommentator: '',
|
||||||
|
leftCommentatorTwitter: '',
|
||||||
|
rightCommentator: '',
|
||||||
|
rightCommentatorTwitter: '',
|
||||||
|
},
|
||||||
|
persistent: false,
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export const messageNames = {
|
||||||
|
packs: {
|
||||||
|
fetchRegistry: 'fetchPackRegistry',
|
||||||
|
download: 'downloadPack',
|
||||||
|
uninstall: 'uninstallPack',
|
||||||
|
update: 'updatePack',
|
||||||
|
readLocalManifest: 'readLocalManifest',
|
||||||
|
},
|
||||||
|
integrations: {
|
||||||
|
startgg: {
|
||||||
|
createOAuthSession: 'startgg:createOAuthSession',
|
||||||
|
getOAuthSessionStatus: 'startgg:getOAuthSessionStatus',
|
||||||
|
fetchRecentTournaments: 'startgg:fetchRecentTournaments',
|
||||||
|
fetchTournamentPlayers: 'startgg:fetchTournamentPlayers',
|
||||||
|
},
|
||||||
|
challonge: {
|
||||||
|
createOAuthSession: 'challonge:createOAuthSession',
|
||||||
|
getOAuthSessionStatus: 'challonge:getOAuthSessionStatus',
|
||||||
|
fetchRecentTournaments: 'challonge:fetchRecentTournaments',
|
||||||
|
fetchTournamentPlayers: 'challonge:fetchTournamentPlayers',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type MessageName =
|
||||||
|
| typeof messageNames.packs[keyof typeof messageNames.packs]
|
||||||
|
| typeof messageNames.integrations.startgg[keyof typeof messageNames.integrations.startgg]
|
||||||
|
| typeof messageNames.integrations.challonge[keyof typeof messageNames.integrations.challonge];
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export const replicantNames = {
|
||||||
|
scoreboard: 'scoreboard',
|
||||||
|
players: 'players',
|
||||||
|
commentary: 'commentary',
|
||||||
|
graphicsSettings: 'graphicsSettings',
|
||||||
|
installedPacks: 'installedPacks',
|
||||||
|
packRegistry: 'packRegistry',
|
||||||
|
downloadStates: 'downloadStates',
|
||||||
|
availableUpdates: 'availableUpdates',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ReplicantName = typeof replicantNames[keyof typeof replicantNames];
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './state';
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Schemas } from '../../../types';
|
||||||
|
|
||||||
|
export type Commentary = Schemas.Commentary;
|
||||||
|
|
||||||
|
export const defaultCommentary: Commentary = {
|
||||||
|
leftCommentator: '',
|
||||||
|
leftCommentatorTwitter: '',
|
||||||
|
rightCommentator: '',
|
||||||
|
rightCommentatorTwitter: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const toString = (value: unknown): string => (typeof value === 'string' ? value : '');
|
||||||
|
|
||||||
|
export const normalizeCommentary = (input: unknown): Commentary => {
|
||||||
|
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
|
||||||
|
return {
|
||||||
|
leftCommentator: toString(candidate.leftCommentator),
|
||||||
|
leftCommentatorTwitter: toString(candidate.leftCommentatorTwitter),
|
||||||
|
rightCommentator: toString(candidate.rightCommentator),
|
||||||
|
rightCommentatorTwitter: toString(candidate.rightCommentatorTwitter),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const swapCommentary = (commentary: Commentary): Commentary => ({
|
||||||
|
leftCommentator: commentary.rightCommentator,
|
||||||
|
leftCommentatorTwitter: commentary.rightCommentatorTwitter,
|
||||||
|
rightCommentator: commentary.leftCommentator,
|
||||||
|
rightCommentatorTwitter: commentary.leftCommentatorTwitter,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const stripTwitterPrefix = (value: string): string =>
|
||||||
|
value.startsWith('@') ? value.slice(1) : value;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './state';
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Schemas } from '../../../types';
|
||||||
|
|
||||||
|
export type GraphicsSettings = Schemas.GraphicsSettings;
|
||||||
|
|
||||||
|
export const defaultGraphicsSettings: GraphicsSettings = {
|
||||||
|
scoreboardSkin: 'scoreboard/main.html',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeGraphicsSettings = (input: unknown): GraphicsSettings => {
|
||||||
|
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
|
||||||
|
return {
|
||||||
|
scoreboardSkin: typeof candidate.scoreboardSkin === 'string'
|
||||||
|
? candidate.scoreboardSkin
|
||||||
|
: defaultGraphicsSettings.scoreboardSkin,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { PackManifest } from './types';
|
||||||
|
|
||||||
|
export interface FightingCharacterOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
image: string;
|
||||||
|
dlc?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DefaultCharacterPair {
|
||||||
|
leftCharacter: string;
|
||||||
|
rightCharacter: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BUNDLED_GAME_NAMES = new Set<string>();
|
||||||
|
|
||||||
|
export const buildCharactersForManifest = (manifest: PackManifest): FightingCharacterOption[] =>
|
||||||
|
manifest.characters.map((character) => ({
|
||||||
|
label: character.name,
|
||||||
|
value: character.slug,
|
||||||
|
image: `/packs/${manifest.id}/characters/${character.slug}.png`,
|
||||||
|
dlc: character.dlc ?? false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const buildCharactersByGame = (
|
||||||
|
manifests: readonly PackManifest[],
|
||||||
|
): Record<string, FightingCharacterOption[]> => {
|
||||||
|
const charactersByGame: Record<string, FightingCharacterOption[]> = {};
|
||||||
|
manifests.forEach((manifest) => {
|
||||||
|
charactersByGame[manifest.name] = buildCharactersForManifest(manifest);
|
||||||
|
});
|
||||||
|
return charactersByGame;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildDefaultCharactersByGame = (
|
||||||
|
manifests: readonly PackManifest[],
|
||||||
|
): Record<string, DefaultCharacterPair> => {
|
||||||
|
const defaultsByGame: Record<string, DefaultCharacterPair> = {};
|
||||||
|
manifests.forEach((manifest) => {
|
||||||
|
if (manifest.defaultPair) {
|
||||||
|
defaultsByGame[manifest.name] = {
|
||||||
|
leftCharacter: manifest.defaultPair.left,
|
||||||
|
rightCharacter: manifest.defaultPair.right,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return defaultsByGame;
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export const GITEA_BASE_URL = 'http://10.0.0.10:3002';
|
||||||
|
export const GITEA_OWNER = 'Pandipipas';
|
||||||
|
export const GITEA_REPO = 'fighting-game-packs';
|
||||||
|
export const GITEA_BRANCH = 'main';
|
||||||
|
export const BUNDLE_NAME = 'scoreko-dev';
|
||||||
|
|
||||||
|
export const getGiteaRawUrl = (repoPath: string): string =>
|
||||||
|
`${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${repoPath}`;
|
||||||
|
|
||||||
|
export const REGISTRY_URL = getGiteaRawUrl('registry.json');
|
||||||
|
|
||||||
|
export const getManifestUrl = (packId: string): string =>
|
||||||
|
getGiteaRawUrl(`${packId}/manifest.json`);
|
||||||
|
|
||||||
|
export const getPackLogoUrl = (packId: string): string =>
|
||||||
|
getGiteaRawUrl(`${packId}/logo.png`);
|
||||||
|
|
||||||
|
export const getCharacterImageRepoUrl = (packId: string, slug: string, ext: string): string =>
|
||||||
|
getGiteaRawUrl(`${packId}/characters/${slug}.${ext}`);
|
||||||
|
|
||||||
|
export const getInstalledCharacterImageUrl = (
|
||||||
|
packId: string,
|
||||||
|
slug: string,
|
||||||
|
ext = 'png',
|
||||||
|
): string => `/packs/${packId}/characters/${slug}.${ext}`;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './config';
|
||||||
|
export * from './characters';
|
||||||
|
export * from './types';
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
export interface PackCharacter {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
dlc?: boolean;
|
||||||
|
sizeBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackPalette {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackRegistryEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
totalSizeBytes: number;
|
||||||
|
logoPath: string;
|
||||||
|
characterCount: number;
|
||||||
|
palette: PackPalette;
|
||||||
|
bundled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackManifest {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
palette: PackPalette;
|
||||||
|
defaultPair?: {
|
||||||
|
left: string;
|
||||||
|
right: string;
|
||||||
|
};
|
||||||
|
characters: PackCharacter[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackRegistry {
|
||||||
|
schemaVersion: number;
|
||||||
|
updatedAt: string;
|
||||||
|
packs: PackRegistryEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackDownloadState {
|
||||||
|
status: 'idle' | 'fetching-manifest' | 'downloading' | 'done' | 'error';
|
||||||
|
progress: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackUpdateInfo {
|
||||||
|
installedVersion: string;
|
||||||
|
latestVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameSelectOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
available: boolean;
|
||||||
|
registryEntry: PackRegistryEntry;
|
||||||
|
updateInfo?: PackUpdateInfo;
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ const countryByName = new Map(
|
|||||||
baseCountries.map((country) => [country.name.toLowerCase(), country.code]),
|
baseCountries.map((country) => [country.name.toLowerCase(), country.code]),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const resolveCountryCode = (value?: string) => {
|
export const resolveCountryCode = (value?: string): string => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ export const resolveCountryCode = (value?: string) => {
|
|||||||
return byName ?? '';
|
return byName ?? '';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getCountryLabel = (value?: string, locale = 'en') => {
|
export const getCountryLabel = (value?: string, locale = 'en'): string => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { Schemas } from '../../../types';
|
||||||
|
|
||||||
|
export type PlayersMap = Schemas.Players;
|
||||||
|
export type Player = PlayersMap[string];
|
||||||
|
|
||||||
|
const toString = (value: unknown): string => (typeof value === 'string' ? value : '');
|
||||||
|
|
||||||
|
export const normalizePlayer = (input: unknown): Player => {
|
||||||
|
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
|
||||||
|
return {
|
||||||
|
gamertag: toString(candidate.gamertag),
|
||||||
|
name: toString(candidate.name),
|
||||||
|
team: toString(candidate.team),
|
||||||
|
country: toString(candidate.country),
|
||||||
|
twitter: toString(candidate.twitter),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizePlayers = (input: unknown): PlayersMap => {
|
||||||
|
if (typeof input !== 'object' || input === null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: PlayersMap = {};
|
||||||
|
Object.entries(input as Record<string, unknown>).forEach(([id, value]) => {
|
||||||
|
if (id) {
|
||||||
|
result[id] = normalizePlayer(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizePlayerName = (value: string): string => value.trim().toLowerCase();
|
||||||
|
|
||||||
|
export const createPlayerId = (name: string, players: PlayersMap): string => {
|
||||||
|
const base = name
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[^\w\s-]/g, '')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/\s+/g, '-') || 'player';
|
||||||
|
|
||||||
|
let index = 1;
|
||||||
|
let candidate = base;
|
||||||
|
while (players[candidate]) {
|
||||||
|
index += 1;
|
||||||
|
candidate = `${base}-${index}`;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './state';
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { Schemas } from '../../../types';
|
||||||
|
|
||||||
|
export type Scoreboard = Schemas.Scoreboard;
|
||||||
|
export type ScoreboardSide = 'left' | 'right';
|
||||||
|
|
||||||
|
export const defaultScoreboard: Scoreboard = {
|
||||||
|
leftPlayerId: '',
|
||||||
|
rightPlayerId: '',
|
||||||
|
leftNameOverride: '',
|
||||||
|
rightNameOverride: '',
|
||||||
|
leftTeamOverride: '',
|
||||||
|
rightTeamOverride: '',
|
||||||
|
leftCountryOverride: '',
|
||||||
|
rightCountryOverride: '',
|
||||||
|
leftCharacter: '',
|
||||||
|
rightCharacter: '',
|
||||||
|
leftScore: 0,
|
||||||
|
rightScore: 0,
|
||||||
|
round: '',
|
||||||
|
game: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const toString = (value: unknown): string => (typeof value === 'string' ? value : '');
|
||||||
|
|
||||||
|
const normalizeScore = (value: unknown): number =>
|
||||||
|
typeof value === 'number' ? Math.max(0, Math.floor(value)) : 0;
|
||||||
|
|
||||||
|
export const normalizeScoreboard = (input: unknown): Scoreboard => {
|
||||||
|
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
|
||||||
|
return {
|
||||||
|
leftPlayerId: toString(candidate.leftPlayerId),
|
||||||
|
rightPlayerId: toString(candidate.rightPlayerId),
|
||||||
|
leftNameOverride: toString(candidate.leftNameOverride),
|
||||||
|
rightNameOverride: toString(candidate.rightNameOverride),
|
||||||
|
leftTeamOverride: toString(candidate.leftTeamOverride),
|
||||||
|
rightTeamOverride: toString(candidate.rightTeamOverride),
|
||||||
|
leftCountryOverride: toString(candidate.leftCountryOverride),
|
||||||
|
rightCountryOverride: toString(candidate.rightCountryOverride),
|
||||||
|
leftCharacter: toString(candidate.leftCharacter),
|
||||||
|
rightCharacter: toString(candidate.rightCharacter),
|
||||||
|
leftScore: normalizeScore(candidate.leftScore),
|
||||||
|
rightScore: normalizeScore(candidate.rightScore),
|
||||||
|
round: toString(candidate.round),
|
||||||
|
game: toString(candidate.game),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setScoreboardScore = (
|
||||||
|
scoreboard: Scoreboard,
|
||||||
|
side: ScoreboardSide,
|
||||||
|
value: number,
|
||||||
|
): Scoreboard => ({
|
||||||
|
...scoreboard,
|
||||||
|
[side === 'left' ? 'leftScore' : 'rightScore']: Math.max(0, Math.floor(value)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const adjustScoreboardScore = (
|
||||||
|
scoreboard: Scoreboard,
|
||||||
|
side: ScoreboardSide,
|
||||||
|
delta: number,
|
||||||
|
): Scoreboard =>
|
||||||
|
setScoreboardScore(
|
||||||
|
scoreboard,
|
||||||
|
side,
|
||||||
|
(side === 'left' ? scoreboard.leftScore : scoreboard.rightScore) + delta,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const swapScoreboardPlayers = (scoreboard: Scoreboard): Scoreboard => ({
|
||||||
|
...scoreboard,
|
||||||
|
leftPlayerId: scoreboard.rightPlayerId,
|
||||||
|
rightPlayerId: scoreboard.leftPlayerId,
|
||||||
|
leftNameOverride: scoreboard.rightNameOverride,
|
||||||
|
rightNameOverride: scoreboard.leftNameOverride,
|
||||||
|
leftTeamOverride: scoreboard.rightTeamOverride,
|
||||||
|
rightTeamOverride: scoreboard.leftTeamOverride,
|
||||||
|
leftCountryOverride: scoreboard.rightCountryOverride,
|
||||||
|
rightCountryOverride: scoreboard.leftCountryOverride,
|
||||||
|
leftCharacter: scoreboard.rightCharacter,
|
||||||
|
rightCharacter: scoreboard.leftCharacter,
|
||||||
|
leftScore: scoreboard.rightScore,
|
||||||
|
rightScore: scoreboard.leftScore,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resetScoreboardScores = (scoreboard: Scoreboard): Scoreboard => ({
|
||||||
|
...scoreboard,
|
||||||
|
leftScore: 0,
|
||||||
|
rightScore: 0,
|
||||||
|
});
|
||||||
@@ -1,112 +1,12 @@
|
|||||||
// src/shared/fighting-characters.ts
|
export {
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
BUNDLED_GAME_NAMES,
|
||||||
// Todo el contenido de personajes viene de packs descargados desde Gitea.
|
buildCharactersByGame,
|
||||||
// No hay datos bundled — el proyecto arranca vacío y se rellena en runtime.
|
buildCharactersForManifest,
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
buildDefaultCharactersByGame,
|
||||||
|
type DefaultCharacterPair,
|
||||||
|
type FightingCharacterOption,
|
||||||
|
} from './domain/packs/characters';
|
||||||
|
import type { DefaultCharacterPair, FightingCharacterOption } from './domain/packs/characters';
|
||||||
|
|
||||||
import { ref } from 'vue';
|
export const getCharactersByGame = (_game?: string): FightingCharacterOption[] => [];
|
||||||
import type { PackManifest } from './pack-types';
|
export const getDefaultCharactersByGame = (_game?: string): DefaultCharacterPair | undefined => undefined;
|
||||||
|
|
||||||
export interface FightingCharacterOption {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
image: string;
|
|
||||||
dlc?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Runtime registry ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const installedPackCharacters: Record<string, FightingCharacterOption[]> = {};
|
|
||||||
const installedPackDefaults: Record<string, { leftCharacter: string; rightCharacter: string }> = {};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Incrementado cada vez que se registra o elimina un pack.
|
|
||||||
* Los composables se suscriben a este ref para que Vue invalide los computed
|
|
||||||
* que dependen de installedPackCharacters (objeto plano, no reactivo).
|
|
||||||
*/
|
|
||||||
export const installedPacksRevision = ref(0);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Vacío — ya no hay juegos bundled.
|
|
||||||
* Mantenido por compatibilidad con usePackRegistry.
|
|
||||||
*/
|
|
||||||
export const BUNDLED_GAME_NAMES = new Set<string>();
|
|
||||||
|
|
||||||
// ── Placeholder SVG ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const toDataUrl = (svg: string): string =>
|
|
||||||
`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
|
||||||
|
|
||||||
const buildPlaceholder = (game: string, character: string, start: string, end: string): string => {
|
|
||||||
const initials = character
|
|
||||||
.split(/\s+/)
|
|
||||||
.map((p) => p[0])
|
|
||||||
.join('')
|
|
||||||
.slice(0, 2)
|
|
||||||
.toUpperCase();
|
|
||||||
|
|
||||||
const svg = `
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 220" role="img" aria-label="${character}">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stop-color="${start}"/>
|
|
||||||
<stop offset="100%" stop-color="${end}"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="480" height="220" fill="url(#bg)" rx="18"/>
|
|
||||||
<circle cx="90" cy="110" r="64" fill="rgba(255,255,255,0.13)"/>
|
|
||||||
<text x="90" y="130" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="56" font-weight="700">${initials}</text>
|
|
||||||
<text x="170" y="96" fill="#e2e8f0" font-family="Arial, sans-serif" font-size="20" font-weight="700">${game}</text>
|
|
||||||
<text x="170" y="145" fill="#ffffff" font-family="Arial, sans-serif" font-size="38" font-weight="700">${character}</text>
|
|
||||||
</svg>`.trim();
|
|
||||||
|
|
||||||
return toDataUrl(svg);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Pack registration ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registra un pack instalado para que getCharactersByGame() lo devuelva.
|
|
||||||
* Llamado por usePackRegistry cuando carga el manifest.json local de un pack.
|
|
||||||
*/
|
|
||||||
export const registerInstalledPack = (manifest: PackManifest): void => {
|
|
||||||
const { id, name, palette, characters, defaultPair } = manifest;
|
|
||||||
|
|
||||||
installedPackCharacters[name] = characters.map((char) => ({
|
|
||||||
label: char.name,
|
|
||||||
value: char.slug,
|
|
||||||
image: `/packs/${id}/characters/${char.slug}.png`,
|
|
||||||
dlc: char.dlc ?? false,
|
|
||||||
// Fallback inline por si la imagen no se encuentra en disco
|
|
||||||
_placeholder: buildPlaceholder(name, char.name, palette.start, palette.end),
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (defaultPair) {
|
|
||||||
installedPackDefaults[name] = {
|
|
||||||
leftCharacter: defaultPair.left,
|
|
||||||
rightCharacter: defaultPair.right,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
installedPacksRevision.value++;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Elimina un pack del registro en memoria.
|
|
||||||
* Llamado por usePackRegistry cuando el usuario desinstala un pack.
|
|
||||||
*/
|
|
||||||
export const unregisterInstalledPack = (gameName: string): void => {
|
|
||||||
delete installedPackCharacters[gameName];
|
|
||||||
delete installedPackDefaults[gameName];
|
|
||||||
installedPacksRevision.value++;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Public API ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const getCharactersByGame = (game: string): FightingCharacterOption[] =>
|
|
||||||
installedPackCharacters[game] ?? [];
|
|
||||||
|
|
||||||
export const getDefaultCharactersByGame = (
|
|
||||||
game: string,
|
|
||||||
): { leftCharacter: string; rightCharacter: string } | undefined =>
|
|
||||||
installedPackDefaults[game];
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
// src/shared/pack-config.ts
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Edit ONLY this file to point the pack system at your Gitea instance.
|
|
||||||
// All other files import their Gitea/NodeCG constants from here.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
/** Base URL of your Gitea instance — no trailing slash. */
|
|
||||||
export const GITEA_BASE_URL = 'http://10.0.0.10:3002';
|
|
||||||
/** Gitea owner (user or organisation) that owns the packs repository. */
|
|
||||||
export const GITEA_OWNER = 'Pandipipas';
|
|
||||||
/** Name of the repository that contains all game packs. */
|
|
||||||
export const GITEA_REPO = 'fighting-game-packs';
|
|
||||||
/** Branch to pull assets from. */
|
|
||||||
export const GITEA_BRANCH = 'main';
|
|
||||||
/**
|
|
||||||
* NodeCG bundle name.
|
|
||||||
* Must match the "name" field in your package.json / nodecg config.
|
|
||||||
*/
|
|
||||||
export const BUNDLE_NAME = 'scoreko-dev';
|
|
||||||
// ── Derived URL helpers (do not edit below this line) ────────────────────────
|
|
||||||
/** Returns the Gitea raw-file URL for any repo-relative path. */
|
|
||||||
export const getGiteaRawUrl = (repoPath) => `${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${repoPath}`;
|
|
||||||
/** URL of the master registry file that lists every available pack. */
|
|
||||||
export const REGISTRY_URL = getGiteaRawUrl('registry.json');
|
|
||||||
/** Returns the URL for a specific pack's manifest.json. */
|
|
||||||
export const getManifestUrl = (packId) => getGiteaRawUrl(`${packId}/manifest.json`);
|
|
||||||
/** Returns the URL for a pack's logo. */
|
|
||||||
export const getPackLogoUrl = (packId) => getGiteaRawUrl(`${packId}/logo.png`);
|
|
||||||
/**
|
|
||||||
* Returns the URL for a specific character image stored in the Gitea repo.
|
|
||||||
* Used during download; at runtime installed packs are served by NodeCG.
|
|
||||||
*/
|
|
||||||
export const getCharacterImageRepoUrl = (packId, slug, ext) => getGiteaRawUrl(`${packId}/characters/${slug}.${ext}`);
|
|
||||||
/**
|
|
||||||
* Returns the runtime URL for a character image from an *installed* (downloaded) pack.
|
|
||||||
* NodeCG serves everything under assets/ at /assets/<bundleName>/.
|
|
||||||
*/
|
|
||||||
export const getInstalledCharacterImageUrl = (packId, slug, ext = 'png') => `/assets/${BUNDLE_NAME}/packs/${packId}/characters/${slug}.${ext}`;
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
// src/shared/pack-config.ts
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Edit ONLY this file to point the pack system at your Gitea instance.
|
|
||||||
// All other files import their Gitea/NodeCG constants from here.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Base URL of your Gitea instance — no trailing slash. */
|
|
||||||
export const GITEA_BASE_URL = 'http://10.0.0.10:3002';
|
|
||||||
|
|
||||||
/** Gitea owner (user or organisation) that owns the packs repository. */
|
|
||||||
export const GITEA_OWNER = 'Pandipipas';
|
|
||||||
|
|
||||||
/** Name of the repository that contains all game packs. */
|
|
||||||
export const GITEA_REPO = 'fighting-game-packs';
|
|
||||||
|
|
||||||
/** Branch to pull assets from. */
|
|
||||||
export const GITEA_BRANCH = 'main';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* NodeCG bundle name.
|
|
||||||
* Must match the "name" field in your package.json / nodecg config.
|
|
||||||
*/
|
|
||||||
export const BUNDLE_NAME = 'scoreko-dev';
|
|
||||||
|
|
||||||
// ── Derived URL helpers (do not edit below this line) ────────────────────────
|
|
||||||
|
|
||||||
/** Returns the Gitea raw-file URL for any repo-relative path. */
|
|
||||||
export const getGiteaRawUrl = (repoPath: string): string =>
|
|
||||||
`${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${repoPath}`;
|
|
||||||
|
|
||||||
/** URL of the master registry file that lists every available pack. */
|
|
||||||
export const REGISTRY_URL = getGiteaRawUrl('registry.json');
|
|
||||||
|
|
||||||
/** Returns the URL for a specific pack's manifest.json. */
|
|
||||||
export const getManifestUrl = (packId: string): string =>
|
|
||||||
getGiteaRawUrl(`${packId}/manifest.json`);
|
|
||||||
|
|
||||||
/** Returns the URL for a pack's logo. */
|
|
||||||
export const getPackLogoUrl = (packId: string): string =>
|
|
||||||
getGiteaRawUrl(`${packId}/logo.png`);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the URL for a specific character image stored in the Gitea repo.
|
|
||||||
* Used during download; at runtime installed packs are served by NodeCG.
|
|
||||||
*/
|
|
||||||
export const getCharacterImageRepoUrl = (packId: string, slug: string, ext: string): string =>
|
|
||||||
getGiteaRawUrl(`${packId}/characters/${slug}.${ext}`);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the runtime URL for a character image from an *installed* (downloaded) pack.
|
|
||||||
* NodeCG serves everything under assets/ at /assets/<bundleName>/.
|
|
||||||
*/
|
|
||||||
export const getInstalledCharacterImageUrl = (packId: string, slug: string, ext = 'png'): string =>
|
|
||||||
`/packs/${packId}/characters/${slug}.${ext}`;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// src/shared/pack-types.ts
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Shared between the NodeCG extension (Node.js) and the dashboard (browser).
|
|
||||||
// Do NOT import anything that is browser-only or Node-only from this file.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
export {};
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
// src/shared/pack-types.ts
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Shared between the NodeCG extension (Node.js) and the dashboard (browser).
|
|
||||||
// Do NOT import anything that is browser-only or Node-only from this file.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** A single character entry inside a pack manifest. */
|
|
||||||
export interface PackCharacter {
|
|
||||||
/** Display name, e.g. "Chun-Li" */
|
|
||||||
name: string;
|
|
||||||
/** URL-safe slug that matches the image filename, e.g. "chun-li" */
|
|
||||||
slug: string;
|
|
||||||
/** True when the character is paid DLC (shown with the DLC badge in the UI). */
|
|
||||||
dlc?: boolean;
|
|
||||||
/** Approximate compressed size of the character image file in bytes. */
|
|
||||||
sizeBytes: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lightweight entry in the top-level registry.json.
|
|
||||||
* Enough for the UI to render the game list and the download dialog preview
|
|
||||||
* without having to fetch the full manifest.
|
|
||||||
*/
|
|
||||||
export interface PackRegistryEntry {
|
|
||||||
/** Unique identifier — must match the folder name in the repo, e.g. "street-fighter-6". */
|
|
||||||
id: string;
|
|
||||||
/** Human-readable game title shown in the selector, e.g. "Street Fighter 6". */
|
|
||||||
name: string;
|
|
||||||
/** Semantic version of this pack, e.g. "1.0.0". Bump when adding/updating characters. */
|
|
||||||
version: string;
|
|
||||||
/** Total download size (sum of all character images + logo) in bytes. */
|
|
||||||
totalSizeBytes: number;
|
|
||||||
/** Repo-relative path to the game's logo image, e.g. "street-fighter-6/logo.png". */
|
|
||||||
logoPath: string;
|
|
||||||
/** Pre-computed character count so the dialog can show it without loading the manifest. */
|
|
||||||
characterCount: number;
|
|
||||||
/** Gradient used for placeholder images when a character has no artwork. */
|
|
||||||
palette: { start: string; end: string };
|
|
||||||
/**
|
|
||||||
* True when the pack ships inside the application bundle (bundled via Vite's
|
|
||||||
* import.meta.glob). Bundled packs are always "installed" and never show the
|
|
||||||
* download button, but they still appear in the registry so the app can detect
|
|
||||||
* updates (version mismatch between bundle and registry).
|
|
||||||
*/
|
|
||||||
bundled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Full pack data — lives at <packId>/manifest.json in the repo. */
|
|
||||||
export interface PackManifest {
|
|
||||||
/** Must match PackRegistryEntry.id and the folder name. */
|
|
||||||
id: string;
|
|
||||||
/** Must match PackRegistryEntry.name. */
|
|
||||||
name: string;
|
|
||||||
version: string;
|
|
||||||
palette: { start: string; end: string };
|
|
||||||
/** Default characters pre-selected when this game is first chosen. */
|
|
||||||
defaultPair?: { left: string; right: string };
|
|
||||||
/** Full character roster, in the order they should appear in the selector. */
|
|
||||||
characters: PackCharacter[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Top-level registry.json structure. */
|
|
||||||
export interface PackRegistry {
|
|
||||||
schemaVersion: number;
|
|
||||||
updatedAt: string;
|
|
||||||
packs: PackRegistryEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tracks the download lifecycle of a single pack. */
|
|
||||||
export interface PackDownloadState {
|
|
||||||
status: 'idle' | 'fetching-manifest' | 'downloading' | 'done' | 'error';
|
|
||||||
/** Progress percentage 0–100. */
|
|
||||||
progress: number;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shape of the option objects surfaced by usePackRegistry.allGameOptions. */
|
|
||||||
export interface GameSelectOption {
|
|
||||||
/** Display label for the QSelect. */
|
|
||||||
label: string;
|
|
||||||
/** Value stored in the scoreboard (equals PackRegistryEntry.name for installed games). */
|
|
||||||
value: string;
|
|
||||||
/** Whether the pack can be used right now (bundled or already downloaded). */
|
|
||||||
available: boolean;
|
|
||||||
/** Mirrors PackRegistryEntry so the download dialog can be populated inline. */
|
|
||||||
registryEntry: PackRegistryEntry;
|
|
||||||
/** Present when there is a newer version of this pack available in the registry. */
|
|
||||||
updateInfo?: { installedVersion: string; latestVersion: string };
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export {};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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(/^\/+/, '');
|
|
||||||
};
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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(/^\/+/, '');
|
|
||||||
};
|
|
||||||
Vendored
-3
@@ -1,3 +0,0 @@
|
|||||||
export interface ExampleType {
|
|
||||||
exampleProperty: string;
|
|
||||||
}
|
|
||||||
Vendored
-1
@@ -2,5 +2,4 @@ import type NodeCG from 'nodecg/types';
|
|||||||
import type { Configschema } from './schemas.d.ts';
|
import type { Configschema } from './schemas.d.ts';
|
||||||
|
|
||||||
export type NodeCGServerAPI = NodeCG.default.ServerAPI<Configschema>;
|
export type NodeCGServerAPI = NodeCG.default.ServerAPI<Configschema>;
|
||||||
export type { ExampleType } from './ExampleType.d.ts';
|
|
||||||
export type * as Schemas from './schemas.d.ts';
|
export type * as Schemas from './schemas.d.ts';
|
||||||
|
|||||||
Vendored
+4
-1
@@ -6,7 +6,10 @@
|
|||||||
|
|
||||||
export type { Commentary } from './schemas/commentary.d.ts';
|
export type { Commentary } from './schemas/commentary.d.ts';
|
||||||
export type { Configschema } from './schemas/configschema.d.ts';
|
export type { Configschema } from './schemas/configschema.d.ts';
|
||||||
export type { ExampleReplicant } from './schemas/exampleReplicant.d.ts';
|
|
||||||
export type { GraphicsSettings } from './schemas/graphicsSettings.d.ts';
|
export type { GraphicsSettings } from './schemas/graphicsSettings.d.ts';
|
||||||
export type { Players } from './schemas/players.d.ts';
|
export type { Players } from './schemas/players.d.ts';
|
||||||
export type { Scoreboard } from './schemas/scoreboard.d.ts';
|
export type { Scoreboard } from './schemas/scoreboard.d.ts';
|
||||||
|
export type { InstalledPacks } from './schemas/installedPacks.d.ts';
|
||||||
|
export type { PackRegistry } from './schemas/packRegistry.d.ts';
|
||||||
|
export type { DownloadStates } from './schemas/downloadStates.d.ts';
|
||||||
|
export type { AvailableUpdates } from './schemas/availableUpdates.d.ts';
|
||||||
|
|||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
/* prettier-ignore */
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* This file was automatically generated by json-schema-to-typescript.
|
||||||
|
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
|
||||||
|
* and run json-schema-to-typescript to regenerate this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AvailableUpdates {
|
||||||
|
[k: string]: {
|
||||||
|
installedVersion: string;
|
||||||
|
latestVersion: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+4
-1
@@ -7,7 +7,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface Configschema {
|
export interface Configschema {
|
||||||
exampleProperty: string;
|
/**
|
||||||
|
* Sobreescribe la URL base del proxy OAuth.
|
||||||
|
*/
|
||||||
|
oauthProxyUrl?: string;
|
||||||
/**
|
/**
|
||||||
* Client ID de tu OAuth app de start.gg
|
* Client ID de tu OAuth app de start.gg
|
||||||
*/
|
*/
|
||||||
|
|||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
/* prettier-ignore */
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* This file was automatically generated by json-schema-to-typescript.
|
||||||
|
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
|
||||||
|
* and run json-schema-to-typescript to regenerate this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DownloadStates {
|
||||||
|
[k: string]: {
|
||||||
|
status: 'idle' | 'fetching-manifest' | 'downloading' | 'done' | 'error';
|
||||||
|
progress: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
-3
@@ -6,6 +6,4 @@
|
|||||||
* and run json-schema-to-typescript to regenerate this file.
|
* and run json-schema-to-typescript to regenerate this file.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface ExampleReplicant {
|
export type InstalledPacks = string[];
|
||||||
exampleProperty: string;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user