Compare commits
7 Commits
04f2c2037a
..
codex
| Author | SHA1 | Date | |
|---|---|---|---|
| b32c0e4560 | |||
| 02a108f983 | |||
| 225b2b36a2 | |||
| 8c270feb5b | |||
| 618d18d8fb | |||
| 0bc6f60b2c | |||
| 88aeedb5ff |
@@ -136,9 +136,12 @@ dist
|
||||
/dashboard/
|
||||
/extension/
|
||||
/graphics/
|
||||
/nodecg/
|
||||
/shared/domain/
|
||||
/shared/dist/
|
||||
|
||||
# Local runtime database
|
||||
/db/
|
||||
*.sqlite3
|
||||
/scoreko-electron-dev/
|
||||
/packs/
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Architecture Audit
|
||||
|
||||
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.
|
||||
|
||||
## Estado Actual
|
||||
|
||||
La repo es un bundle NodeCG con Vite, Vue 3, Quasar, Pinia y `vite-plugin-nodecg`.
|
||||
|
||||
| Á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. |
|
||||
|
||||
## Replicants
|
||||
|
||||
### Declarados por Schema
|
||||
|
||||
- `scoreboard`
|
||||
- `players`
|
||||
- `commentary`
|
||||
- `graphicsSettings`
|
||||
- `exampleReplicant`
|
||||
|
||||
### Declarados Solo en Código
|
||||
|
||||
- `installedPacks`
|
||||
- `packRegistry`
|
||||
- `downloadStates`
|
||||
- `availableUpdates`
|
||||
|
||||
### Problema Principal
|
||||
|
||||
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.
|
||||
|
||||
## Flujo de Datos
|
||||
|
||||
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`.
|
||||
|
||||
## Zonas Grandes o de Riesgo
|
||||
|
||||
| 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`. |
|
||||
|
||||
## Hallazgos Técnicos
|
||||
|
||||
- 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.
|
||||
|
||||
## Problemas Reales
|
||||
|
||||
- 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. |
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Architecture Rules
|
||||
|
||||
Estas reglas son obligatorias para cualquier refactor posterior. Están pensadas para mantener boundaries claros, reducir acoplamiento y preservar comportamiento durante la migración.
|
||||
|
||||
## TypeScript
|
||||
|
||||
- No usar `any`.
|
||||
- Usar `unknown` solo en boundaries.
|
||||
- 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.
|
||||
|
||||
## Boundaries NodeCG
|
||||
|
||||
- No acceder directamente a replicants fuera de `nodecg/browser` o `nodecg/extension`.
|
||||
- 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.
|
||||
|
||||
## Shared y Dominio
|
||||
|
||||
- `shared/domain` solo puede contener funciones puras, tipos, normalizadores y mapping.
|
||||
- `shared/domain` no puede importar Vue.
|
||||
- `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.
|
||||
|
||||
## Dashboard
|
||||
|
||||
- Los stores mantienen estado de aplicación y sync con replicants.
|
||||
- Los stores no deben contener UI compleja.
|
||||
- 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.
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
# Migration Plan
|
||||
|
||||
Este plan define el orden de migración. Debe ejecutarse de forma secuencial para reducir riesgo, preservar comportamiento y evitar reescrituras amplias sin baseline.
|
||||
|
||||
## Principios
|
||||
|
||||
- Mantener nombres públicos y comportamiento hasta completar la migración.
|
||||
- Congelar comportamiento antes de mover responsabilidades.
|
||||
- Eliminar legacy muerto antes de envolverlo.
|
||||
- Separar boundaries antes de reescribir módulos complejos.
|
||||
- Tocar overlays al final y con verificación visual.
|
||||
|
||||
## Secuencia
|
||||
|
||||
| Paso | Objetivo | Tipo |
|
||||
| --- | --- | --- |
|
||||
| 1 | Congelar comportamiento con screenshots de overlays, fixtures de replicants, build, typecheck y lint baseline. | Baseline |
|
||||
| 2 | Quitar `example*`, regenerar schema types y eliminar `.js` redundantes en `src/shared` si no se usan. | Limpieza |
|
||||
| 3 | Crear `nodecg/browser` y `nodecg/extension` sin cambiar comportamiento. | Boundary |
|
||||
| 4 | Añadir schemas para replicants de packs manteniendo nombres y defaults exactos. | Contratos |
|
||||
| 5 | Extraer tipos/config de packs a `shared` y ajustar `tsconfig.extension` para no duplicar. | Shared |
|
||||
| 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. |
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Phase 1 Summary
|
||||
|
||||
## Scope
|
||||
|
||||
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.
|
||||
|
||||
## Completed
|
||||
|
||||
- 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.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Session Handoff
|
||||
|
||||
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 de la Sesión
|
||||
|
||||
- No se habían modificado archivos antes de crear esta documentación.
|
||||
- Se leyó la estructura del proyecto, configs, schemas, extensión, dashboard, overlays y shared.
|
||||
- `vue-tsc` pasa.
|
||||
- `tsc` pasa.
|
||||
- `lint` falla con 3 errores reales y 243 warnings de formato.
|
||||
|
||||
## Documentación Creada
|
||||
|
||||
| Documento | Propósito |
|
||||
| --- | --- |
|
||||
| `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. |
|
||||
|
||||
## Source of Truth
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# Target Architecture
|
||||
|
||||
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`.
|
||||
|
||||
## Objetivo
|
||||
|
||||
Crear una arquitectura simple y realista que:
|
||||
|
||||
- Centralice la frontera NodeCG.
|
||||
- Centralice contratos realtime.
|
||||
- Separe dominio puro de UI y runtime.
|
||||
- Permita añadir providers, packs y skins sin duplicación.
|
||||
- Preserve el comportamiento visual de overlays durante la migración.
|
||||
|
||||
## Estructura Objetivo
|
||||
|
||||
```text
|
||||
src/
|
||||
shared/
|
||||
schemas/
|
||||
types/
|
||||
domain/
|
||||
scoreboard/
|
||||
players/
|
||||
commentary/
|
||||
packs/
|
||||
integrations/
|
||||
utils/
|
||||
nodecg/
|
||||
browser/
|
||||
replicants.ts
|
||||
messages.ts
|
||||
extension/
|
||||
context.ts
|
||||
replicants.ts
|
||||
messages.ts
|
||||
extension/
|
||||
modules/
|
||||
packs/
|
||||
integrations/
|
||||
startgg/
|
||||
challonge/
|
||||
oauth/
|
||||
dashboard/
|
||||
app/
|
||||
features/
|
||||
scoreboard/
|
||||
players/
|
||||
graphics/
|
||||
settings/
|
||||
integrations/
|
||||
packs/
|
||||
stores/
|
||||
ui/
|
||||
graphics/
|
||||
shared/
|
||||
composables/
|
||||
view-models/
|
||||
assets/
|
||||
scoreboard/
|
||||
scoreboard-2xko/
|
||||
commentary/
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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",
|
||||
"lint": "eslint",
|
||||
"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,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">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { t } from '../i18n';
|
||||
import { useScoreboardStore } from '../stores/scoreboard';
|
||||
import { useScoreboardStore } from '../../stores/scoreboard';
|
||||
|
||||
const scoreboardStore = useScoreboardStore();
|
||||
|
||||
@@ -83,12 +83,12 @@ const updateRound = () => {
|
||||
return;
|
||||
}
|
||||
if (customActive.value) {
|
||||
scoreboardStore.scoreboard.round = customText.value.trim();
|
||||
scoreboardStore.setRound(customText.value.trim());
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -245,4 +245,4 @@ onMounted(() => {
|
||||
.bracket-panel__preview-text--custom {
|
||||
color: var(--q-secondary, #26a69a);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { stripTwitterPrefix } from '../../../shared/domain/commentary';
|
||||
import { t } from '../i18n';
|
||||
import { useCommentaryStore } from '../stores/commentary';
|
||||
import { useCommentaryStore } from '../../stores/commentary';
|
||||
|
||||
const commentaryStore = useCommentaryStore();
|
||||
|
||||
@@ -17,25 +18,18 @@ const twitterRules = [
|
||||
!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) {
|
||||
commentaryStore.leftCommentatorTwitter = value ? stripAt(String(value)) : '';
|
||||
commentaryStore.leftCommentatorTwitter = value ? stripTwitterPrefix(String(value)) : '';
|
||||
}
|
||||
|
||||
function handleRightTwitterInput(value: string | number | null) {
|
||||
commentaryStore.rightCommentatorTwitter = value ? stripAt(String(value)) : '';
|
||||
commentaryStore.rightCommentatorTwitter = value ? stripTwitterPrefix(String(value)) : '';
|
||||
}
|
||||
|
||||
// --- Clear ---
|
||||
|
||||
function clearAll() {
|
||||
commentaryStore.leftCommentator = '';
|
||||
commentaryStore.leftCommentatorTwitter = '';
|
||||
commentaryStore.rightCommentator = '';
|
||||
commentaryStore.rightCommentatorTwitter = '';
|
||||
commentaryStore.clearCommentary();
|
||||
}
|
||||
|
||||
const isAnythingFilled = computed(() =>
|
||||
@@ -280,4 +274,4 @@ const rightHandlePreview = computed(() =>
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
<script setup lang="ts">
|
||||
// src/dashboard/scoreboard/components/GamePackDownloadDialog.vue
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Shown when the user clicks a game that is not yet installed.
|
||||
// Displays size, character roster, and a download progress bar.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { computed, watch } from 'vue';
|
||||
import { getPackLogoUrl } from '../../../shared/domain/packs/config';
|
||||
import type { PackRegistryEntry } from '../../../shared/domain/packs/types';
|
||||
import { usePackRegistry } from '../composables/usePackRegistry';
|
||||
|
||||
// ── Props / emits ─────────────────────────────────────────────────────────────
|
||||
|
||||
const props = defineProps<{
|
||||
/** v-model visibility */
|
||||
modelValue: boolean;
|
||||
/** The registry entry for the game the user wants to download/update */
|
||||
packEntry: PackRegistryEntry | null;
|
||||
/** When true the dialog shows "update" language and calls updatePack instead of downloadPack */
|
||||
isUpdate?: boolean;
|
||||
/** Version info shown in update mode */
|
||||
updateInfo?: { installedVersion: string; latestVersion: string };
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
/** Emitted after a successful download/update so the parent can switch to the game */
|
||||
downloaded: [gameName: string];
|
||||
}>();
|
||||
|
||||
// ── Pack registry ─────────────────────────────────────────────────────────────
|
||||
|
||||
const packRegistry = usePackRegistry();
|
||||
|
||||
// ── Computed ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const downloadState = computed(() =>
|
||||
props.packEntry ? packRegistry.getDownloadState(props.packEntry.id) : null,
|
||||
);
|
||||
|
||||
const isDownloading = computed(() =>
|
||||
downloadState.value?.status === 'downloading' ||
|
||||
downloadState.value?.status === 'fetching-manifest',
|
||||
);
|
||||
|
||||
const isDone = computed(() => downloadState.value?.status === 'done');
|
||||
const isError = computed(() => downloadState.value?.status === 'error');
|
||||
|
||||
const progress = computed(() => downloadState.value?.progress ?? 0);
|
||||
|
||||
// Pre-install: show logo directly from Gitea (pack not on disk yet).
|
||||
// Update mode: pack is installed, serve from local /packs/ route.
|
||||
const logoSrc = computed(() => {
|
||||
if (!props.packEntry) return '';
|
||||
if (props.isUpdate) return packRegistry.getLocalLogoUrl(props.packEntry.id);
|
||||
return getPackLogoUrl(props.packEntry.id);
|
||||
});
|
||||
|
||||
// Close automatically once download completes and emit so parent sets the game
|
||||
watch(isDone, (done) => {
|
||||
if (done && props.packEntry) {
|
||||
emit('downloaded', props.packEntry.name);
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const startDownload = () => {
|
||||
if (!props.packEntry) return;
|
||||
if (props.isUpdate) {
|
||||
packRegistry.updatePack(props.packEntry.id);
|
||||
} else {
|
||||
packRegistry.downloadPack(props.packEntry.id);
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => emit('update:modelValue', false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<QDialog
|
||||
:model-value="modelValue"
|
||||
persistent
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<QCard
|
||||
v-if="packEntry"
|
||||
class="pack-download-dialog"
|
||||
>
|
||||
<!-- ── Header ─────────────────────────────────────────────────────── -->
|
||||
<QCardSection class="pack-download-dialog__header">
|
||||
<div class="pack-download-dialog__title-row">
|
||||
<div>
|
||||
<div class="text-h6 text-weight-bold">
|
||||
{{ packEntry.name }}
|
||||
</div>
|
||||
<div class="text-caption text-grey-5">
|
||||
<template v-if="isUpdate && updateInfo">
|
||||
Bundled v{{ updateInfo.installedVersion }} →
|
||||
<span class="text-positive">v{{ updateInfo.latestVersion }}</span>
|
||||
· {{ packEntry.characterCount }} personajes
|
||||
</template>
|
||||
<template v-else>
|
||||
v{{ packEntry.version }} · {{ packEntry.characterCount }} personajes ·
|
||||
{{ packRegistry.formatBytes(packEntry.totalSizeBytes) }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<QBtn
|
||||
v-if="!isDownloading"
|
||||
flat
|
||||
round
|
||||
dense
|
||||
icon="close"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Banner: logo del juego con gradiente de fallback -->
|
||||
<div
|
||||
class="pack-download-dialog__banner"
|
||||
:style="{
|
||||
background: `linear-gradient(135deg, ${packEntry.palette.start}, ${packEntry.palette.end})`,
|
||||
}"
|
||||
>
|
||||
<img
|
||||
v-if="logoSrc"
|
||||
:src="logoSrc"
|
||||
class="pack-download-dialog__logo"
|
||||
alt=""
|
||||
@error="($event.target as HTMLImageElement).style.display = 'none'"
|
||||
/>
|
||||
<QIcon
|
||||
:name="isUpdate ? 'upgrade' : 'sports_esports'"
|
||||
size="40px"
|
||||
color="white"
|
||||
class="pack-download-dialog__banner-icon"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Version info shown only in update mode -->
|
||||
<div
|
||||
v-if="isUpdate && updateInfo"
|
||||
class="pack-download-dialog__version-badge"
|
||||
>
|
||||
<span class="text-grey-5">v{{ updateInfo.installedVersion }}</span>
|
||||
<QIcon name="arrow_forward" size="14px" color="grey-5" />
|
||||
<span class="text-positive text-weight-bold">v{{ updateInfo.latestVersion }}</span>
|
||||
</div>
|
||||
</QCardSection>
|
||||
|
||||
<QSeparator />
|
||||
|
||||
<!-- ── Progress / error ───────────────────────────────────────────── -->
|
||||
<QCardSection
|
||||
v-if="isDownloading || isDone || isError"
|
||||
class="pack-download-dialog__progress-section"
|
||||
>
|
||||
<div
|
||||
v-if="isError"
|
||||
class="pack-download-dialog__error"
|
||||
>
|
||||
<QIcon
|
||||
name="error"
|
||||
color="negative"
|
||||
size="20px"
|
||||
/>
|
||||
<span>{{ downloadState?.error ?? 'Error desconocido' }}</span>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="pack-download-dialog__progress-label">
|
||||
<span>{{ isDownloading ? 'Descargando…' : '¡Listo!' }}</span>
|
||||
<span>{{ progress }}%</span>
|
||||
</div>
|
||||
<QLinearProgress
|
||||
:value="progress / 100"
|
||||
:color="isDone ? 'positive' : 'primary'"
|
||||
rounded
|
||||
size="8px"
|
||||
/>
|
||||
</template>
|
||||
</QCardSection>
|
||||
|
||||
<!-- ── Character list ─────────────────────────────────────────────── -->
|
||||
<QCardSection class="pack-download-dialog__char-section">
|
||||
<div class="text-caption text-grey-5 q-mb-sm">
|
||||
Personajes incluidos
|
||||
</div>
|
||||
<!-- We only have the count in the registry entry; the full list lives
|
||||
in the manifest. Show a placeholder grid until the registry has
|
||||
a characters array (future enhancement: include it in registry.json). -->
|
||||
<div class="pack-download-dialog__char-count">
|
||||
<QIcon
|
||||
name="sports_martial_arts"
|
||||
size="16px"
|
||||
/>
|
||||
{{ packEntry.characterCount }} personajes en este pack
|
||||
</div>
|
||||
</QCardSection>
|
||||
|
||||
<QSeparator />
|
||||
|
||||
<!-- ── Actions ────────────────────────────────────────────────────── -->
|
||||
<QCardActions
|
||||
align="right"
|
||||
class="q-pa-md"
|
||||
>
|
||||
<QBtn
|
||||
v-if="!isDownloading"
|
||||
flat
|
||||
label="Cancelar"
|
||||
color="grey-5"
|
||||
@click="close"
|
||||
/>
|
||||
<QBtn
|
||||
v-if="!isDownloading && !isDone"
|
||||
unelevated
|
||||
:label="isError ? 'Reintentar' : isUpdate ? 'Actualizar pack' : 'Descargar pack'"
|
||||
:color="isUpdate ? 'positive' : 'primary'"
|
||||
:icon="isUpdate ? 'upgrade' : 'download'"
|
||||
@click="startDownload"
|
||||
/>
|
||||
<QBtn
|
||||
v-if="isDownloading"
|
||||
flat
|
||||
:label="isUpdate ? 'Actualizando…' : 'Descargando…'"
|
||||
:color="isUpdate ? 'positive' : 'primary'"
|
||||
loading
|
||||
disable
|
||||
/>
|
||||
</QCardActions>
|
||||
</QCard>
|
||||
</QDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pack-download-dialog {
|
||||
width: 420px;
|
||||
max-width: 95vw;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pack-download-dialog__header {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.pack-download-dialog__title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.pack-download-dialog__banner {
|
||||
position: relative;
|
||||
height: 88px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pack-download-dialog__logo {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.pack-download-dialog__banner-icon {
|
||||
position: relative; /* above the logo */
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.pack-download-dialog__progress-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.pack-download-dialog__progress-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.pack-download-dialog__error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--q-negative);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pack-download-dialog__char-section {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.pack-download-dialog__char-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.pack-download-dialog__version-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@ import { computed, inject } from 'vue';
|
||||
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
|
||||
import { usePlayerSide } from '../composables/usePlayerSide';
|
||||
import { t } from '../i18n';
|
||||
import { useScoreboardStore } from '../stores/scoreboard';
|
||||
import { useScoreboardStore } from '../../stores/scoreboard';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
@@ -74,8 +74,7 @@ const character = computed({
|
||||
? scoreboardStore.scoreboard.leftCharacter
|
||||
: scoreboardStore.scoreboard.rightCharacter),
|
||||
set: (v) => {
|
||||
if (isLeft.value) scoreboardStore.scoreboard.leftCharacter = v;
|
||||
else scoreboardStore.scoreboard.rightCharacter = v;
|
||||
scoreboardStore.setSideCharacter(props.side, v);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,25 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue';
|
||||
import { useScoreboardStore } from '../stores/scoreboard';
|
||||
import { inject, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
|
||||
import { usePackRegistry } from '../composables/usePackRegistry';
|
||||
import { t } from '../i18n';
|
||||
import { useScoreboardStore } from '../../stores/scoreboard';
|
||||
import GamePackDownloadDialog from './GamePackDownloadDialog.vue';
|
||||
|
||||
const scoreboardStore = useScoreboardStore();
|
||||
const { gameInput, fightingGameOptions, onGameFilter } = inject(CHARACTER_GAME_KEY)!;
|
||||
const packRegistry = usePackRegistry();
|
||||
|
||||
const {
|
||||
gameInput,
|
||||
fightingGameOptions,
|
||||
onGameFilter,
|
||||
handleGameSelect,
|
||||
pendingDownloadEntry,
|
||||
showDownloadDialog,
|
||||
} = inject(CHARACTER_GAME_KEY)!;
|
||||
|
||||
// Refresca el catálogo al montar y luego cada 15 segundos automáticamente.
|
||||
// Si Gitea no está disponible se usa la caché persistida del replicante.
|
||||
onMounted(() => {
|
||||
packRegistry.startRegistryRefresh();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
packRegistry.stopRegistryRefresh();
|
||||
});
|
||||
|
||||
const adjustLeftScore = (delta: number) => {
|
||||
scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore + delta);
|
||||
scoreboardStore.adjustScore('left', delta);
|
||||
};
|
||||
|
||||
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. */
|
||||
const onPackDownloaded = (gameName: string) => {
|
||||
scoreboardStore.setGame(gameName);
|
||||
};
|
||||
|
||||
// ── Estado del diálogo de actualización ───────────────────────────────────────
|
||||
const pendingUpdateEntry = ref<import('../../../shared/domain/packs/types').PackRegistryEntry | null>(null);
|
||||
const pendingUpdateInfo = ref<{ installedVersion: string; latestVersion: string } | undefined>(undefined);
|
||||
const showUpdateDialog = ref(false);
|
||||
|
||||
const openUpdateDialog = (opt: import('../../../shared/domain/packs/types').GameSelectOption, event: Event) => {
|
||||
event.stopPropagation(); // evitar que el QItem cambie la selección
|
||||
pendingUpdateEntry.value = opt.registryEntry;
|
||||
pendingUpdateInfo.value = opt.updateInfo;
|
||||
showUpdateDialog.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scoreboard-preview__center">
|
||||
<!--
|
||||
v-model → :model-value + @update:model-value para interceptar la
|
||||
selección de juegos no instalados antes de escribir en el store.
|
||||
-->
|
||||
<QSelect
|
||||
v-model="scoreboardStore.scoreboard.game"
|
||||
:model-value="scoreboardStore.scoreboard.game"
|
||||
v-model:input-value="gameInput"
|
||||
:options="fightingGameOptions"
|
||||
:label="t('scoreboardLabelGame')"
|
||||
@@ -32,10 +74,59 @@ const adjustRightScore = (delta: number) => {
|
||||
fill-input
|
||||
class="scoreboard-preview__field scoreboard-preview__game-field"
|
||||
@filter="onGameFilter"
|
||||
@update:model-value="handleGameSelect"
|
||||
>
|
||||
<template #prepend>
|
||||
<QIcon name="sports_esports" />
|
||||
</template>
|
||||
|
||||
<!-- Slot personalizado: muestra iconos de descarga o actualización según el estado -->
|
||||
<template #option="scope">
|
||||
<QItem
|
||||
v-bind="scope.itemProps"
|
||||
:class="{ 'pack-option--unavailable': !scope.opt.available }"
|
||||
>
|
||||
<QItemSection>
|
||||
<QItemLabel>{{ scope.opt.label }}</QItemLabel>
|
||||
</QItemSection>
|
||||
|
||||
<!-- Icono de actualización disponible (pack instalado, versión nueva en repo) -->
|
||||
<QItemSection
|
||||
v-if="scope.opt.available && scope.opt.updateInfo"
|
||||
side
|
||||
>
|
||||
<QBtn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
size="xs"
|
||||
icon="upgrade"
|
||||
color="positive"
|
||||
@click="openUpdateDialog(scope.opt, $event)"
|
||||
>
|
||||
<QTooltip>
|
||||
Actualización disponible:
|
||||
v{{ scope.opt.updateInfo.installedVersion }} →
|
||||
v{{ scope.opt.updateInfo.latestVersion }}
|
||||
</QTooltip>
|
||||
</QBtn>
|
||||
</QItemSection>
|
||||
|
||||
<!-- Icono de descarga (pack no instalado) -->
|
||||
<QItemSection
|
||||
v-else-if="!scope.opt.available"
|
||||
side
|
||||
>
|
||||
<QIcon
|
||||
name="download"
|
||||
size="16px"
|
||||
color="grey-5"
|
||||
>
|
||||
<QTooltip>Pack no instalado — haz clic para descargarlo</QTooltip>
|
||||
</QIcon>
|
||||
</QItemSection>
|
||||
</QItem>
|
||||
</template>
|
||||
</QSelect>
|
||||
|
||||
<div class="scoreboard-preview__score-controls">
|
||||
@@ -101,8 +192,25 @@ const adjustRightScore = (delta: number) => {
|
||||
class="scoreboard-preview__action-btn"
|
||||
@click="scoreboardStore.resetScores"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog de descarga — se abre automáticamente al seleccionar un juego no instalado -->
|
||||
<GamePackDownloadDialog
|
||||
v-model="showDownloadDialog"
|
||||
:pack-entry="pendingDownloadEntry"
|
||||
@downloaded="onPackDownloaded"
|
||||
/>
|
||||
|
||||
<!-- Dialog de actualización — se abre al hacer clic en el icono de upgrade -->
|
||||
<GamePackDownloadDialog
|
||||
v-model="showUpdateDialog"
|
||||
:pack-entry="pendingUpdateEntry"
|
||||
:is-update="true"
|
||||
:update-info="pendingUpdateInfo"
|
||||
@downloaded="onPackDownloaded"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -188,4 +296,13 @@ const adjustRightScore = (delta: number) => {
|
||||
.scoreboard-preview__field :deep(.q-field__label) {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
/* Atenúa visualmente los juegos no instalados en el desplegable */
|
||||
.pack-option--unavailable {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.pack-option--unavailable:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,60 +1,92 @@
|
||||
// src/dashboard/scoreboard/composables/useCharacterGame.ts
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Manages game selection and character state for both PlayerSidePanels.
|
||||
// Must be called ONCE in ScoreboardPanel and provided via CHARACTER_GAME_KEY.
|
||||
//
|
||||
// Changes from original:
|
||||
// - fightingGameOptions is now driven by the pack registry (allGameOptions)
|
||||
// rather than a static hardcoded list. It falls back to bundled names
|
||||
// while the registry loads.
|
||||
// - Game selection is intercepted: selecting an unavailable game triggers
|
||||
// the download dialog instead of updating the store.
|
||||
// - pendingDownloadEntry / showDownloadDialog are exposed for ScoreCenterPanel.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { computed, ref, watch, type InjectionKey, type Ref } from 'vue';
|
||||
import { getCharactersByGame, getDefaultCharactersByGame } from '../../../shared/fighting-characters';
|
||||
import { useScoreboardStore } from '../stores/scoreboard';
|
||||
import type { FightingCharacterOption } from '../../../shared/domain/packs/characters';
|
||||
import type { GameSelectOption, PackRegistryEntry } from '../../../shared/domain/packs/types';
|
||||
import { useScoreboardStore } from '../../stores/scoreboard';
|
||||
import { usePackRegistry } from './usePackRegistry';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ALL_FIGHTING_GAME_OPTIONS = [
|
||||
|
||||
'2XKO',
|
||||
'FATAL FURY: City of the Wolves',
|
||||
'Guilty Gear -Strive-',
|
||||
'Invincible VS',
|
||||
'Mortal Kombat 1',
|
||||
'Street Fighter 6',
|
||||
'TEKKEN 8',
|
||||
'THE KING OF FIGHTERS XV',
|
||||
|
||||
].map((game) => ({ label: game, value: game }));
|
||||
|
||||
export type CharacterOption = ReturnType<typeof getCharactersByGame>[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Injection key (type-safe provide/inject)
|
||||
// ---------------------------------------------------------------------------
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type CharacterOption = FightingCharacterOption;
|
||||
export type CharacterGameContext = ReturnType<typeof useCharacterGame>;
|
||||
export const CHARACTER_GAME_KEY: InjectionKey<CharacterGameContext> = Symbol('characterGame');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composable
|
||||
// ---------------------------------------------------------------------------
|
||||
// ── Composable ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Manages game selection and character state for both sides.
|
||||
* Must be called ONCE in the parent (ScoreboardPanel) and provided via
|
||||
* CHARACTER_GAME_KEY so both PlayerSidePanel instances share the same state.
|
||||
*/
|
||||
export function useCharacterGame() {
|
||||
const scoreboardStore = useScoreboardStore();
|
||||
const packRegistry = usePackRegistry();
|
||||
|
||||
// ── Game selector state ───────────────────────────────────────────────────
|
||||
|
||||
// Game selector
|
||||
const gameInput = ref('');
|
||||
const fightingGameOptions = ref(ALL_FIGHTING_GAME_OPTIONS);
|
||||
|
||||
// Per-side character state
|
||||
const characterOptions = computed(() => getCharactersByGame(scoreboardStore.scoreboard.game));
|
||||
/**
|
||||
* Game options surfaced to the QSelect.
|
||||
* Populated from the pack registry when available; falls back to bundled games.
|
||||
* GameSelectOption includes an `available` flag used to show the download icon.
|
||||
*/
|
||||
const fightingGameOptions = ref<GameSelectOption[]>([]);
|
||||
|
||||
// Keep fightingGameOptions in sync when the registry updates
|
||||
watch(
|
||||
packRegistry.allGameOptions,
|
||||
(options) => {
|
||||
fightingGameOptions.value = options;
|
||||
},
|
||||
);
|
||||
|
||||
// ── Download dialog state ─────────────────────────────────────────────────
|
||||
|
||||
/** Set when the user selects a game that isn't installed yet. */
|
||||
const pendingDownloadEntry = ref<PackRegistryEntry | null>(null);
|
||||
const showDownloadDialog = ref(false);
|
||||
|
||||
/**
|
||||
* Intercepting setter for the game selector.
|
||||
* If the selected game is not available, opens the download dialog instead
|
||||
* of writing to the store.
|
||||
*/
|
||||
const handleGameSelect = (gameName: string) => {
|
||||
if (!gameName) {
|
||||
scoreboardStore.setGame('');
|
||||
return;
|
||||
}
|
||||
if (!packRegistry.isGameAvailable(gameName)) {
|
||||
const entry = fightingGameOptions.value.find((o) => o.value === gameName)?.registryEntry ?? null;
|
||||
pendingDownloadEntry.value = entry;
|
||||
showDownloadDialog.value = true;
|
||||
// Do NOT update the store — the game isn't installed
|
||||
return;
|
||||
}
|
||||
scoreboardStore.setGame(gameName);
|
||||
};
|
||||
|
||||
// ── Character state ───────────────────────────────────────────────────────
|
||||
|
||||
const characterOptions = computed(() => {
|
||||
return packRegistry.getCharactersByGame(scoreboardStore.scoreboard.game);
|
||||
});
|
||||
const leftCharacterOptions = ref<CharacterOption[]>([]);
|
||||
const rightCharacterOptions = ref<CharacterOption[]>([]);
|
||||
const leftCharacterInput = ref('');
|
||||
const rightCharacterInput = ref('');
|
||||
|
||||
// Remembers selected characters per game so swapping games restores them
|
||||
const charactersByGame = ref<Record<string, { leftCharacter: string; rightCharacter: string }>>({});
|
||||
|
||||
// Character images for preview
|
||||
const leftCharacterImage = computed(() => {
|
||||
const match = characterOptions.value.find(
|
||||
(o) => o.value === scoreboardStore.scoreboard.leftCharacter,
|
||||
@@ -69,20 +101,21 @@ export function useCharacterGame() {
|
||||
return match?.image ?? '';
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
// ── Filter handlers ───────────────────────────────────────────────────────
|
||||
|
||||
const onGameFilter = (value: string, update: (fn: () => void) => void) => {
|
||||
update(() => {
|
||||
const needle = value.toLowerCase().trim();
|
||||
fightingGameOptions.value = needle
|
||||
? ALL_FIGHTING_GAME_OPTIONS.filter((g) => g.label.toLowerCase().includes(needle))
|
||||
: ALL_FIGHTING_GAME_OPTIONS;
|
||||
? packRegistry.allGameOptions.value.filter((g) =>
|
||||
g.label.toLowerCase().includes(needle),
|
||||
)
|
||||
: packRegistry.allGameOptions.value;
|
||||
});
|
||||
};
|
||||
|
||||
const makeCharacterFilter = (target: Ref<CharacterOption[]>) =>
|
||||
const makeCharacterFilter =
|
||||
(target: Ref<CharacterOption[]>) =>
|
||||
(value: string, update: (fn: () => void) => void) => {
|
||||
update(() => {
|
||||
const needle = value.toLowerCase().trim();
|
||||
@@ -95,16 +128,14 @@ export function useCharacterGame() {
|
||||
const onLeftCharacterFilter = makeCharacterFilter(leftCharacterOptions);
|
||||
const onRightCharacterFilter = makeCharacterFilter(rightCharacterOptions);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watchers
|
||||
// ---------------------------------------------------------------------------
|
||||
// ── Watchers ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Keep gameInput display value in sync
|
||||
// Keep gameInput display value in sync with the store
|
||||
watch(
|
||||
() => scoreboardStore.scoreboard.game,
|
||||
(value) => {
|
||||
const match = ALL_FIGHTING_GAME_OPTIONS.find((o) => o.value === value);
|
||||
gameInput.value = match?.label ?? '';
|
||||
const match = fightingGameOptions.value.find((o) => o.value === value);
|
||||
gameInput.value = match?.label ?? value;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
@@ -120,7 +151,14 @@ 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
|
||||
// (installed pack whose manifest has not been loaded into the pack store yet).
|
||||
// Bail out — the characterOptions watcher below will restore state
|
||||
// once the pack becomes available.
|
||||
if (newGame && options.length === 0) return;
|
||||
|
||||
leftCharacterOptions.value = options;
|
||||
rightCharacterOptions.value = options;
|
||||
const allowed = new Set(options.map((o) => o.value));
|
||||
@@ -133,9 +171,8 @@ export function useCharacterGame() {
|
||||
if (!allowed.has(nextLeft)) nextLeft = '';
|
||||
if (!allowed.has(nextRight)) nextRight = '';
|
||||
|
||||
// Apply defaults only when neither side had a character yet
|
||||
if ((!nextLeft || !nextRight) && (!curLeft || !curRight)) {
|
||||
const defaults = getDefaultCharactersByGame(newGame);
|
||||
const defaults = packRegistry.getDefaultCharactersByGame(newGame);
|
||||
if (defaults) {
|
||||
if (!nextLeft) nextLeft = allowed.has(defaults.leftCharacter) ? defaults.leftCharacter : '';
|
||||
if (!nextRight) nextRight = allowed.has(defaults.rightCharacter) ? defaults.rightCharacter : '';
|
||||
@@ -143,23 +180,22 @@ export function useCharacterGame() {
|
||||
}
|
||||
|
||||
if (allowed.has(nextLeft)) {
|
||||
scoreboardStore.scoreboard.leftCharacter = nextLeft;
|
||||
scoreboardStore.setSideCharacter('left', nextLeft);
|
||||
} else if (!allowed.has(scoreboardStore.scoreboard.leftCharacter)) {
|
||||
scoreboardStore.scoreboard.leftCharacter = '';
|
||||
scoreboardStore.setSideCharacter('left', '');
|
||||
leftCharacterInput.value = '';
|
||||
}
|
||||
|
||||
if (allowed.has(nextRight)) {
|
||||
scoreboardStore.scoreboard.rightCharacter = nextRight;
|
||||
scoreboardStore.setSideCharacter('right', nextRight);
|
||||
} else if (!allowed.has(scoreboardStore.scoreboard.rightCharacter)) {
|
||||
scoreboardStore.scoreboard.rightCharacter = '';
|
||||
scoreboardStore.setSideCharacter('right', '');
|
||||
rightCharacterInput.value = '';
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Keep left character display input and charactersByGame cache in sync
|
||||
watch(
|
||||
() => scoreboardStore.scoreboard.leftCharacter,
|
||||
(value) => {
|
||||
@@ -176,7 +212,6 @@ export function useCharacterGame() {
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Keep right character display input and charactersByGame cache in sync
|
||||
watch(
|
||||
() => scoreboardStore.scoreboard.rightCharacter,
|
||||
(value) => {
|
||||
@@ -193,16 +228,53 @@ export function useCharacterGame() {
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// When an installed pack manifest becomes available, re-validate characters
|
||||
// already present in the replicated scoreboard state.
|
||||
watch(characterOptions, (options) => {
|
||||
const game = scoreboardStore.scoreboard.game;
|
||||
if (!game) return;
|
||||
|
||||
if (options.length === 0) return;
|
||||
|
||||
const allowed = new Set(options.map((o) => o.value));
|
||||
leftCharacterOptions.value = options;
|
||||
rightCharacterOptions.value = options;
|
||||
|
||||
const { leftCharacter, rightCharacter } = scoreboardStore.scoreboard;
|
||||
|
||||
if (leftCharacter && allowed.has(leftCharacter)) {
|
||||
leftCharacterInput.value = options.find((o) => o.value === leftCharacter)?.label ?? '';
|
||||
} else if (leftCharacter && !allowed.has(leftCharacter)) {
|
||||
scoreboardStore.setSideCharacter('left', '');
|
||||
leftCharacterInput.value = '';
|
||||
}
|
||||
|
||||
if (rightCharacter && allowed.has(rightCharacter)) {
|
||||
rightCharacterInput.value = options.find((o) => o.value === rightCharacter)?.label ?? '';
|
||||
} else if (rightCharacter && !allowed.has(rightCharacter)) {
|
||||
scoreboardStore.setSideCharacter('right', '');
|
||||
rightCharacterInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// ── Return ────────────────────────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
// Game selector
|
||||
gameInput,
|
||||
fightingGameOptions,
|
||||
onGameFilter,
|
||||
handleGameSelect,
|
||||
// Download dialog
|
||||
pendingDownloadEntry,
|
||||
showDownloadDialog,
|
||||
// Character state
|
||||
leftCharacterOptions,
|
||||
rightCharacterOptions,
|
||||
leftCharacterInput,
|
||||
rightCharacterInput,
|
||||
leftCharacterImage,
|
||||
rightCharacterImage,
|
||||
onGameFilter,
|
||||
onLeftCharacterFilter,
|
||||
onRightCharacterFilter,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { getCountryLabel, getCountryOptions } from '../../../shared/countries';
|
||||
import { getCountryLabel, getCountryOptions } from '../../../shared/domain/players/countries';
|
||||
import { locale } from '../i18n';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { sendIntegrationMessage } from '../../services/integration-message-service';
|
||||
|
||||
// ─── Tipos ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -65,19 +66,6 @@ export interface UseIntegrationOptions {
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useIntegration(options: UseIntegrationOptions) {
|
||||
@@ -165,8 +153,9 @@ export function useIntegration(options: UseIntegrationOptions) {
|
||||
tournamentsError.value = '';
|
||||
loadingTournaments.value = true;
|
||||
try {
|
||||
const tournaments = await sendNodeCGMessage<IntegrationTournament[]>(
|
||||
`${messagePrefix}:fetchRecentTournaments`,
|
||||
const tournaments = await sendIntegrationMessage<IntegrationTournament[]>(
|
||||
messagePrefix,
|
||||
'fetchRecentTournaments',
|
||||
{ token: currentToken },
|
||||
);
|
||||
hasValidatedToken.value = true;
|
||||
@@ -204,8 +193,9 @@ export function useIntegration(options: UseIntegrationOptions) {
|
||||
players.value = [];
|
||||
|
||||
try {
|
||||
const importedPlayers = await sendNodeCGMessage<IntegrationPlayer[]>(
|
||||
`${messagePrefix}:fetchTournamentPlayers`,
|
||||
const importedPlayers = await sendIntegrationMessage<IntegrationPlayer[]>(
|
||||
messagePrefix,
|
||||
'fetchTournamentPlayers',
|
||||
{ token: token.value.trim(), slug: tournament.slug },
|
||||
);
|
||||
players.value = importedPlayers;
|
||||
@@ -325,8 +315,9 @@ export function useIntegration(options: UseIntegrationOptions) {
|
||||
if (!oauthSessionId.value) return;
|
||||
|
||||
try {
|
||||
const status = await sendNodeCGMessage<OAuthStatusResponse>(
|
||||
`${messagePrefix}:getOAuthSessionStatus`,
|
||||
const status = await sendIntegrationMessage<OAuthStatusResponse>(
|
||||
messagePrefix,
|
||||
'getOAuthSessionStatus',
|
||||
{ sessionId: oauthSessionId.value },
|
||||
);
|
||||
|
||||
@@ -362,8 +353,9 @@ export function useIntegration(options: UseIntegrationOptions) {
|
||||
stopPolling();
|
||||
|
||||
try {
|
||||
const session = await sendNodeCGMessage<OAuthSessionResponse>(
|
||||
`${messagePrefix}:createOAuthSession`,
|
||||
const session = await sendIntegrationMessage<OAuthSessionResponse>(
|
||||
messagePrefix,
|
||||
'createOAuthSession',
|
||||
{},
|
||||
);
|
||||
oauthSessionId.value = session.sessionId;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { storeToRefs } from 'pinia';
|
||||
import type { InjectionKey, Ref } from 'vue';
|
||||
import { usePacksStore } from '../../stores/packs';
|
||||
import type { DefaultCharacterPair, FightingCharacterOption } from '../../../shared/domain/packs/characters';
|
||||
import type {
|
||||
GameSelectOption,
|
||||
PackDownloadState,
|
||||
PackRegistry,
|
||||
PackUpdateInfo,
|
||||
} from '../../../shared/domain/packs/types';
|
||||
|
||||
export interface PackRegistryContext {
|
||||
registry: Ref<PackRegistry | null>;
|
||||
installedPackIds: Ref<string[]>;
|
||||
downloadStates: Ref<Record<string, PackDownloadState>>;
|
||||
isGameAvailable: (gameName: string) => boolean;
|
||||
getDownloadState: (packId: string) => PackDownloadState;
|
||||
getCharactersByGame: (gameName: string) => FightingCharacterOption[];
|
||||
getDefaultCharactersByGame: (gameName: string) => DefaultCharacterPair | undefined;
|
||||
allGameOptions: Ref<GameSelectOption[]>;
|
||||
fetchRegistry: () => void;
|
||||
startRegistryRefresh: (intervalMs?: number) => void;
|
||||
stopRegistryRefresh: () => void;
|
||||
downloadPack: (packId: string) => void;
|
||||
uninstallPack: (packId: string) => void;
|
||||
updatePack: (packId: string) => void;
|
||||
availableUpdates: Ref<Record<string, PackUpdateInfo>>;
|
||||
updateCount: Ref<number>;
|
||||
formatBytes: (bytes: number) => string;
|
||||
getLocalLogoUrl: (packId: string) => string;
|
||||
}
|
||||
|
||||
export const PACK_REGISTRY_KEY: InjectionKey<PackRegistryContext> = Symbol('packRegistry');
|
||||
|
||||
export function usePackRegistry(): PackRegistryContext {
|
||||
const packsStore = usePacksStore();
|
||||
packsStore.initialize();
|
||||
|
||||
const {
|
||||
registry,
|
||||
installedPackIds,
|
||||
downloadStates,
|
||||
availableUpdates,
|
||||
allGameOptions,
|
||||
updateCount,
|
||||
} = storeToRefs(packsStore);
|
||||
|
||||
return {
|
||||
registry,
|
||||
installedPackIds,
|
||||
downloadStates,
|
||||
isGameAvailable: packsStore.isGameAvailable,
|
||||
getDownloadState: packsStore.getDownloadState,
|
||||
getCharactersByGame: packsStore.getCharactersByGame,
|
||||
getDefaultCharactersByGame: packsStore.getDefaultCharactersByGame,
|
||||
allGameOptions,
|
||||
fetchRegistry: packsStore.fetchRegistry,
|
||||
startRegistryRefresh: packsStore.startRegistryRefresh,
|
||||
stopRegistryRefresh: packsStore.stopRegistryRefresh,
|
||||
downloadPack: packsStore.downloadPack,
|
||||
uninstallPack: packsStore.uninstallPack,
|
||||
updatePack: packsStore.updatePack,
|
||||
availableUpdates,
|
||||
updateCount,
|
||||
formatBytes: packsStore.formatBytes,
|
||||
getLocalLogoUrl: packsStore.getLocalLogoUrl,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { computed, ref, watch, watchEffect } from 'vue';
|
||||
import { useScoreboardStore } from '../stores/scoreboard';
|
||||
import { usePlayersStore } from '../stores/players';
|
||||
import { useScoreboardStore } from '../../stores/scoreboard';
|
||||
import { usePlayersStore } from '../../stores/players';
|
||||
import type { Schemas } from '../../../types';
|
||||
import { createPlayerId, normalizePlayerName } from '../../../shared/domain/players/state';
|
||||
import { t } from '../i18n';
|
||||
import { useCountryFilter } from './useCountryFilter';
|
||||
|
||||
@@ -16,34 +17,6 @@ export const CUSTOM_RIGHT_PLAYER_ID = '__custom_right_player__';
|
||||
// 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
|
||||
* (left or right). Call once per side inside the corresponding component.
|
||||
@@ -63,32 +36,28 @@ export function usePlayerSide(side: 'left' | 'right') {
|
||||
const playerId = computed({
|
||||
get: () => (isLeft ? scoreboardStore.scoreboard.leftPlayerId : scoreboardStore.scoreboard.rightPlayerId),
|
||||
set: (v) => {
|
||||
if (isLeft) scoreboardStore.scoreboard.leftPlayerId = v;
|
||||
else scoreboardStore.scoreboard.rightPlayerId = v;
|
||||
scoreboardStore.setSidePlayerId(side, v);
|
||||
},
|
||||
});
|
||||
|
||||
const nameOverride = computed({
|
||||
get: () => (isLeft ? scoreboardStore.scoreboard.leftNameOverride : scoreboardStore.scoreboard.rightNameOverride),
|
||||
set: (v) => {
|
||||
if (isLeft) scoreboardStore.scoreboard.leftNameOverride = v;
|
||||
else scoreboardStore.scoreboard.rightNameOverride = v;
|
||||
scoreboardStore.setSideNameOverride(side, v);
|
||||
},
|
||||
});
|
||||
|
||||
const teamOverride = computed({
|
||||
get: () => (isLeft ? scoreboardStore.scoreboard.leftTeamOverride : scoreboardStore.scoreboard.rightTeamOverride),
|
||||
set: (v) => {
|
||||
if (isLeft) scoreboardStore.scoreboard.leftTeamOverride = v;
|
||||
else scoreboardStore.scoreboard.rightTeamOverride = v;
|
||||
scoreboardStore.setSideTeamOverride(side, v);
|
||||
},
|
||||
});
|
||||
|
||||
const countryOverride = computed({
|
||||
get: () => (isLeft ? scoreboardStore.scoreboard.leftCountryOverride : scoreboardStore.scoreboard.rightCountryOverride),
|
||||
set: (v) => {
|
||||
if (isLeft) scoreboardStore.scoreboard.leftCountryOverride = v;
|
||||
else scoreboardStore.scoreboard.rightCountryOverride = v;
|
||||
scoreboardStore.setSideCountryOverride(side, v);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -145,10 +114,10 @@ export function usePlayerSide(side: 'left' | 'right') {
|
||||
};
|
||||
|
||||
const playerExistsByGamertag = (name: string): boolean => {
|
||||
const normalized = normalizeName(name);
|
||||
const normalized = normalizePlayerName(name);
|
||||
return Boolean(normalized)
|
||||
&& Object.values(playersStore.players).some(
|
||||
(p) => normalizeName(p.gamertag || '') === normalized,
|
||||
(p) => normalizePlayerName(p.gamertag || '') === normalized,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -343,4 +312,4 @@ export function usePlayerSide(side: 'left' | 'right') {
|
||||
saveCountryChange,
|
||||
onCountryFilter,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { t } from './i18n';
|
||||
import { useScoreboardStore } from './stores/scoreboard';
|
||||
import { isShortcutMatch, useShortcutSettingsStore } from './stores/shortcut-settings';
|
||||
import { useScoreboardStore } from '../stores/scoreboard';
|
||||
import { isShortcutMatch, useShortcutSettingsStore } from '../stores/shortcut-settings';
|
||||
|
||||
// ── Sidebar collapse ──────────────────────────────────────────────────────────
|
||||
const LS_KEY = 'sidebar_collapsed';
|
||||
@@ -383,4 +383,4 @@ onUnmounted(() => {
|
||||
70% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -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 { computed, ref, watch } from 'vue';
|
||||
import bundlePackage from '../../../../package.json';
|
||||
import { graphicsSettingsReplicant } from '../../../browser_shared/replicants';
|
||||
import { useGraphicsSettingsStore } from '../../stores/graphics-settings';
|
||||
import { t } from '../i18n';
|
||||
|
||||
defineOptions({ name: 'GraphicsView' });
|
||||
@@ -24,6 +24,7 @@ type GraphicCard = {
|
||||
|
||||
useHead(() => ({ title: t('graphicsTitle') }));
|
||||
|
||||
const graphicsSettingsStore = useGraphicsSettingsStore();
|
||||
const graphics = computed<GraphicConfig[]>(() => bundlePackage.nodecg?.graphics ?? []);
|
||||
|
||||
const baseUrl = computed(() => {
|
||||
@@ -60,7 +61,7 @@ const commentaryGraphic = computed(() =>
|
||||
const selectedScoreboardSkin = ref<string>('');
|
||||
|
||||
watch(
|
||||
[scoreboardGraphics, () => graphicsSettingsReplicant?.data?.scoreboardSkin],
|
||||
[scoreboardGraphics, () => graphicsSettingsStore.settings.scoreboardSkin],
|
||||
([availableSkins, replicatedSkin]) => {
|
||||
if (availableSkins.length === 0) {
|
||||
selectedScoreboardSkin.value = '';
|
||||
@@ -87,18 +88,15 @@ watch(
|
||||
watch(
|
||||
selectedScoreboardSkin,
|
||||
(value) => {
|
||||
if (!value || !graphicsSettingsReplicant) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (graphicsSettingsReplicant.data?.scoreboardSkin === value) {
|
||||
if (graphicsSettingsStore.settings.scoreboardSkin === value) {
|
||||
return;
|
||||
}
|
||||
|
||||
graphicsSettingsReplicant.data = {
|
||||
scoreboardSkin: value,
|
||||
};
|
||||
graphicsSettingsReplicant.save();
|
||||
graphicsSettingsStore.setScoreboardSkin(value);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
@@ -262,4 +260,4 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
|
||||
</div>
|
||||
</div>
|
||||
</QPage>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import { useHead } from '@unhead/vue';
|
||||
import { useQuasar, type QTableColumn } from 'quasar';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { getCountryLabel, getCountryOptions } from '../../../shared/countries';
|
||||
import { getCountryLabel, getCountryOptions } from '../../../shared/domain/players/countries';
|
||||
import type { Schemas } from '../../../types';
|
||||
import { useIntegration } from '../composables/useIntegration';
|
||||
import { locale, t } from '../i18n';
|
||||
import { usePlayersStore } from '../stores/players';
|
||||
import { usePlayersStore } from '../../stores/players';
|
||||
|
||||
defineOptions({ name: 'PlayersView' });
|
||||
|
||||
@@ -145,8 +145,13 @@ const openCreateDialog = () => {
|
||||
|
||||
const openEditDialog = (row: PlayerRow) => {
|
||||
editingId.value = row.id;
|
||||
const { id: _id, ...playerData } = row;
|
||||
Object.assign(form, playerData);
|
||||
Object.assign(form, {
|
||||
gamertag: row.gamertag,
|
||||
name: row.name,
|
||||
country: row.country,
|
||||
team: row.team,
|
||||
twitter: row.twitter,
|
||||
});
|
||||
isDialogOpen.value = true;
|
||||
};
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useIntegration } from '../composables/useIntegration';
|
||||
import type { Locale } from '../i18n';
|
||||
import { locale, setLocale, t } from '../i18n';
|
||||
import { usePlayersStore } from '../stores/players';
|
||||
import { usePlayersStore } from '../../stores/players';
|
||||
import {
|
||||
eventToShortcut,
|
||||
type ShortcutAction,
|
||||
useShortcutSettingsStore,
|
||||
} from '../stores/shortcut-settings';
|
||||
} from '../../stores/shortcut-settings';
|
||||
|
||||
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),
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,10 @@
|
||||
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> {
|
||||
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>,
|
||||
replicant: ReplicantLike<T> | undefined,
|
||||
normalize: (input: unknown) => T,
|
||||
@@ -44,24 +50,21 @@ export const syncStateWithReplicant = <T>(
|
||||
): void => {
|
||||
const isApplyingReplicant = ref(false);
|
||||
const persistSnapshot = (value: T): void => {
|
||||
if (!storageKey) {
|
||||
return;
|
||||
if (storageKey) {
|
||||
writeStorageSnapshot(storageKey, value);
|
||||
}
|
||||
|
||||
writeStorageSnapshot(storageKey, value);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => replicant?.data,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
isApplyingReplicant.value = true;
|
||||
state.value = normalize(value);
|
||||
isApplyingReplicant.value = false;
|
||||
|
||||
persistSnapshot(state.value);
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
@@ -70,16 +73,32 @@ export const syncStateWithReplicant = <T>(
|
||||
watch(
|
||||
state,
|
||||
(value) => {
|
||||
persistSnapshot(value);
|
||||
const normalized = normalize(value);
|
||||
persistSnapshot(normalized);
|
||||
|
||||
if (isApplyingReplicant.value || !replicant) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Replicants remain the source of truth for server/browser synchronization.
|
||||
replicant.data = normalize(value);
|
||||
replicant.data = normalized;
|
||||
replicant.save();
|
||||
},
|
||||
{ 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,4 +1,6 @@
|
||||
import { nodecg } from './util/nodecg.js';
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
@@ -177,6 +179,7 @@ const exchangeOAuthCodeForToken = async (
|
||||
redirectUri: string,
|
||||
_config: OAuthConfig,
|
||||
): Promise<string> => {
|
||||
void _config;
|
||||
const mode = getOAuthMode();
|
||||
if (mode.type === 'dev') {
|
||||
return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret);
|
||||
@@ -415,7 +418,7 @@ const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
|
||||
|
||||
// ─── Listeners de NodeCG ───────────────────────────────────────────────────────
|
||||
|
||||
nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) => {
|
||||
listenForMessage(messageNames.integrations.challonge.createOAuthSession, async (_payload: unknown, ack) => {
|
||||
const mode = getOAuthMode();
|
||||
let serverConfig: OAuthConfig;
|
||||
|
||||
@@ -452,7 +455,7 @@ nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack)
|
||||
sendAck(ack, null, oauthServer.createSession(serverConfig));
|
||||
});
|
||||
|
||||
nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => {
|
||||
listenForMessage(messageNames.integrations.challonge.getOAuthSessionStatus, (payload: unknown, ack) => {
|
||||
const sessionId = getStringProp(payload, 'sessionId');
|
||||
if (!sessionId) {
|
||||
sendAck(ack, 'Missing OAuth session id');
|
||||
@@ -468,7 +471,7 @@ nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => {
|
||||
sendAck(ack, null, status);
|
||||
});
|
||||
|
||||
nodecg.listenFor('challonge:fetchRecentTournaments', async (payload: unknown, ack) => {
|
||||
listenForMessage(messageNames.integrations.challonge.fetchRecentTournaments, async (payload: unknown, ack) => {
|
||||
const token = getStringProp(payload, 'token');
|
||||
if (!token) {
|
||||
sendAck(ack, 'Missing Challonge API token');
|
||||
@@ -486,7 +489,7 @@ nodecg.listenFor('challonge:fetchRecentTournaments', async (payload: unknown, ac
|
||||
}
|
||||
});
|
||||
|
||||
nodecg.listenFor('challonge:fetchTournamentPlayers', async (payload: unknown, ack) => {
|
||||
listenForMessage(messageNames.integrations.challonge.fetchTournamentPlayers, async (payload: unknown, ack) => {
|
||||
const token = getStringProp(payload, 'token');
|
||||
const slug = normalizeTournamentSlug(getStringProp(payload, 'slug'));
|
||||
|
||||
|
||||
@@ -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,14 +1,14 @@
|
||||
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) => {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
set(nodecg); // set nodecg "context" before anything else
|
||||
await import('./util/replicants.js'); // make sure replicants are set up
|
||||
await import('./example.js');
|
||||
setNodecgContext(nodecg); // set nodecg "context" before anything else
|
||||
await import('./modules/replicants.js'); // make sure replicants are set up
|
||||
await import('./startgg.js');
|
||||
await import('./challonge.js');
|
||||
await import('./pack-manager.js');
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import {
|
||||
commentaryReplicant,
|
||||
playersReplicant,
|
||||
scoreboardReplicant,
|
||||
} from '../../nodecg/extension/replicants.js';
|
||||
|
||||
playersReplicant();
|
||||
scoreboardReplicant();
|
||||
commentaryReplicant();
|
||||
@@ -0,0 +1,390 @@
|
||||
// src/extension/pack-manager.ts
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Módulo autocontenido: no importa nada de src/shared/ para respetar el
|
||||
// rootDir del tsconfig de la extensión. Las constantes de Gitea y los tipos
|
||||
// necesarios están definidos aquí directamente.
|
||||
//
|
||||
// Para activarlo, añade UNA línea en src/extension/index.ts:
|
||||
// await import('./pack-manager.js');
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import * as fs from 'fs';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { nodecg } from '../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 ────────────────────────────────────────────────────
|
||||
// Edita estas constantes para apuntar a tu instancia.
|
||||
|
||||
const GITEA_BASE_URL = 'http://10.0.0.10:3002';
|
||||
const GITEA_OWNER = 'Pandipipas';
|
||||
const GITEA_REPO = 'fighting-game-packs';
|
||||
const GITEA_BRANCH = 'main';
|
||||
|
||||
const rawUrl = (repoPath: string) =>
|
||||
`${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${repoPath}`;
|
||||
|
||||
const REGISTRY_URL = rawUrl('registry.json');
|
||||
const getManifestUrl = (id: string) => rawUrl(`${id}/manifest.json`);
|
||||
const getPackLogoUrl = (id: string) => rawUrl(`${id}/logo.png`);
|
||||
const getCharacterImageRepoUrl = (id: string, slug: string, ext: string) =>
|
||||
rawUrl(`${id}/characters/${slug}.${ext}`);
|
||||
|
||||
// ── Tipos locales ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Replicamos la forma exacta del tipo Acknowledgement de NodeCG sin necesidad
|
||||
// de importar @nodecg/types. HandledAcknowledgement NO es callable (es un objeto),
|
||||
// UnhandledAcknowledgement SÍ lo es. El helper reply() comprueba cuál es antes de llamar.
|
||||
// ── Constantes ────────────────────────────────────────────────────────────────
|
||||
|
||||
const IMAGE_EXTENSIONS = ['png', 'webp', 'jpg', 'jpeg', 'avif'] as const;
|
||||
|
||||
|
||||
// Raíz del proyecto: 2 niveles por encima de extension/pack-manager.js
|
||||
// Usamos import.meta.url porque nodecg.bundleDir no está disponible cuando
|
||||
// NodeCG se usa como dependencia en lugar de servidor standalone.
|
||||
const bundleDir = fileURLToPath(new URL('../', import.meta.url));
|
||||
|
||||
// ── Replicants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const {
|
||||
installedPacksRep,
|
||||
packRegistryRep,
|
||||
downloadStatesRep,
|
||||
availableUpdatesRep,
|
||||
} = createPackExtensionReplicants();
|
||||
|
||||
// ── Filesystem ────────────────────────────────────────────────────────────────
|
||||
|
||||
const packsDir = path.join(bundleDir, 'packs');
|
||||
fs.mkdirSync(packsDir, { recursive: true });
|
||||
nodecg.log.info(`[pack-manager] Packs directory: ${packsDir}`);
|
||||
|
||||
// Registrar el directorio de packs como ruta estática usando nodecg.mount().
|
||||
// Las imágenes quedan accesibles en /packs/<packId>/characters/<slug>.png
|
||||
// independientemente de cómo NodeCG configure el resto de rutas del bundle.
|
||||
const packsMiddleware = (req: IncomingMessage, res: ServerResponse) => {
|
||||
const urlPath = decodeURIComponent(req.url ?? '/');
|
||||
const safe = path.normalize(urlPath).replace(/^(\.\.[/\\])+/, '');
|
||||
const file = path.join(packsDir, safe);
|
||||
|
||||
// Security: only serve files inside packsDir
|
||||
if (!file.startsWith(packsDir)) {
|
||||
res.writeHead(403);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
fs.stat(file, (statErr, stat) => {
|
||||
if (statErr || !stat.isFile()) {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.json': 'application/json',
|
||||
};
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
res.setHeader('Content-Type', mimeTypes[ext] ?? 'application/octet-stream');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
fs.createReadStream(file).pipe(res);
|
||||
});
|
||||
};
|
||||
|
||||
// nodecg.mount registra el middleware en el servidor Express de NodeCG
|
||||
(nodecg as unknown as { mount: (p: string, h: typeof packsMiddleware) => void })
|
||||
.mount('/packs', packsMiddleware);
|
||||
|
||||
// Verificación de integridad al arrancar
|
||||
const installedAtStart = installedPacksRep.value ?? [];
|
||||
const verified = installedAtStart.filter((id) =>
|
||||
fs.existsSync(path.join(packsDir, id, 'manifest.json')),
|
||||
);
|
||||
if (verified.length !== installedAtStart.length) {
|
||||
nodecg.log.warn('[pack-manager] Algunos packs instalados no estaban en disco y se han eliminado del registro.');
|
||||
installedPacksRep.value = verified;
|
||||
}
|
||||
|
||||
// ── Helpers internos ──────────────────────────────────────────────────────────
|
||||
|
||||
const setDownloadState = (packId: string, patch: Partial<PackDownloadState>): void => {
|
||||
const current = downloadStatesRep.value?.[packId] ?? { status: 'idle', progress: 0 };
|
||||
downloadStatesRep.value = {
|
||||
...downloadStatesRep.value,
|
||||
[packId]: { ...current, ...patch },
|
||||
};
|
||||
};
|
||||
|
||||
const fetchBuffer = async (url: string): Promise<Buffer> => {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status} — ${url}`);
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
};
|
||||
|
||||
const trySaveImage = async (
|
||||
destDir: string,
|
||||
filename: string,
|
||||
extensions: readonly string[],
|
||||
buildUrl: (ext: string) => string,
|
||||
): Promise<boolean> => {
|
||||
for (const ext of extensions) {
|
||||
try {
|
||||
const buffer = await fetchBuffer(buildUrl(ext));
|
||||
// Siempre guardamos como .png para que la URL del dashboard sea predecible.
|
||||
// Los navegadores modernos identifican el formato por el contenido (magic bytes),
|
||||
// no por la extensión, así que WebP/AVIF/JPEG se renderizan correctamente.
|
||||
fs.writeFileSync(path.join(destDir, `${filename}.png`), buffer);
|
||||
return true;
|
||||
} catch { /* prueba siguiente extensión */ }
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// ── Detección de actualizaciones ─────────────────────────────────────────────
|
||||
// Compara la versión en el manifest.json local de cada pack instalado contra
|
||||
// la versión en el registro de Gitea. Solo aplica a packs descargados (no bundled).
|
||||
|
||||
const checkForUpdates = (): void => {
|
||||
const registry = packRegistryRep.value;
|
||||
const installed = installedPacksRep.value ?? [];
|
||||
|
||||
if (!registry || installed.length === 0) {
|
||||
availableUpdatesRep.value = {};
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: Record<string, { installedVersion: string; latestVersion: string }> = {};
|
||||
|
||||
for (const packId of installed) {
|
||||
const registryEntry = registry.packs.find((p) => p.id === packId);
|
||||
if (!registryEntry) continue;
|
||||
|
||||
const manifestPath = path.join(packsDir, packId, 'manifest.json');
|
||||
try {
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as PackManifest;
|
||||
if (manifest.version !== registryEntry.version) {
|
||||
updates[packId] = {
|
||||
installedVersion: manifest.version,
|
||||
latestVersion: registryEntry.version,
|
||||
};
|
||||
nodecg.log.info(
|
||||
`[pack-manager] Actualización disponible para "${packId}": ${manifest.version} → ${registryEntry.version}`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Manifest ilegible — ignorar este pack
|
||||
}
|
||||
}
|
||||
|
||||
availableUpdatesRep.value = updates;
|
||||
};
|
||||
|
||||
// Comprobar al arrancar si ya hay un registro cacheado
|
||||
checkForUpdates();
|
||||
|
||||
// ── Mensaje: fetchPackRegistry ────────────────────────────────────────────────
|
||||
|
||||
listenForMessage(messageNames.packs.fetchRegistry, async (_data: unknown, ack: Acknowledgement | undefined) => {
|
||||
try {
|
||||
const response = await fetch(REGISTRY_URL);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const registry = await response.json() as PackRegistry;
|
||||
packRegistryRep.value = registry;
|
||||
checkForUpdates(); // re-evaluar actualizaciones con el registro nuevo
|
||||
reply(ack, null, registry);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
nodecg.log.error(`[pack-manager] Error al obtener el registro: ${message}`);
|
||||
reply(ack, new Error(message));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Mensaje: downloadPack ─────────────────────────────────────────────────────
|
||||
|
||||
listenForMessage(messageNames.packs.download, async (packId: unknown, ack: Acknowledgement | undefined) => {
|
||||
if (typeof packId !== 'string' || !packId) {
|
||||
return reply(ack, new Error('downloadPack requiere un packId no vacío.'));
|
||||
}
|
||||
if (installedPacksRep.value?.includes(packId)) {
|
||||
return reply(ack, null, { alreadyInstalled: true });
|
||||
}
|
||||
if (downloadStatesRep.value?.[packId]?.status === 'downloading') {
|
||||
return reply(ack, new Error(`El pack "${packId}" ya se está descargando.`));
|
||||
}
|
||||
|
||||
setDownloadState(packId, { status: 'fetching-manifest', progress: 0, error: undefined });
|
||||
|
||||
try {
|
||||
const manifestRes = await fetch(getManifestUrl(packId));
|
||||
if (!manifestRes.ok) throw new Error(`No se puede obtener el manifest: HTTP ${manifestRes.status}`);
|
||||
const manifest = await manifestRes.json() as PackManifest;
|
||||
|
||||
const packDir = path.join(packsDir, packId);
|
||||
const charsDir = path.join(packDir, 'characters');
|
||||
fs.mkdirSync(charsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(packDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||
|
||||
setDownloadState(packId, { status: 'downloading', progress: 2 });
|
||||
try {
|
||||
const logoBuffer = await fetchBuffer(getPackLogoUrl(packId));
|
||||
fs.writeFileSync(path.join(packDir, 'logo.png'), logoBuffer);
|
||||
} catch {
|
||||
nodecg.log.warn(`[pack-manager] No se encontró logo para "${packId}" — se omite.`);
|
||||
}
|
||||
|
||||
const total = manifest.characters.length;
|
||||
for (let i = 0; i < total; i++) {
|
||||
const char = manifest.characters[i]!;
|
||||
const saved = await trySaveImage(
|
||||
charsDir,
|
||||
char.slug,
|
||||
IMAGE_EXTENSIONS,
|
||||
(ext) => getCharacterImageRepoUrl(packId, char.slug, ext),
|
||||
);
|
||||
if (!saved) {
|
||||
nodecg.log.warn(`[pack-manager] Sin imagen para "${packId}/${char.slug}" — se usará placeholder.`);
|
||||
}
|
||||
setDownloadState(packId, { progress: 5 + Math.round(((i + 1) / total) * 93) });
|
||||
}
|
||||
|
||||
const current = installedPacksRep.value ?? [];
|
||||
if (!current.includes(packId)) installedPacksRep.value = [...current, packId];
|
||||
|
||||
setDownloadState(packId, { status: 'done', progress: 100 });
|
||||
reply(ack, null, { packId, characterCount: manifest.characters.length });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
nodecg.log.error(`[pack-manager] Error al descargar "${packId}": ${message}`);
|
||||
setDownloadState(packId, { status: 'error', error: message });
|
||||
reply(ack, new Error(message));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Mensaje: uninstallPack ────────────────────────────────────────────────────
|
||||
|
||||
listenForMessage(messageNames.packs.uninstall, (packId: unknown, ack: Acknowledgement | undefined) => {
|
||||
if (typeof packId !== 'string' || !packId) {
|
||||
return reply(ack, new Error('uninstallPack requiere un packId no vacío.'));
|
||||
}
|
||||
try {
|
||||
fs.rmSync(path.join(packsDir, packId), { recursive: true, force: true });
|
||||
installedPacksRep.value = (installedPacksRep.value ?? []).filter((id) => id !== packId);
|
||||
const states = { ...downloadStatesRep.value };
|
||||
delete states[packId];
|
||||
downloadStatesRep.value = states;
|
||||
const updates = { ...availableUpdatesRep.value };
|
||||
delete updates[packId];
|
||||
availableUpdatesRep.value = updates;
|
||||
reply(ack, null);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
nodecg.log.error(`[pack-manager] Error al desinstalar "${packId}": ${message}`);
|
||||
reply(ack, new Error(message));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Mensaje: updatePack ──────────────────────────────────────────────────────
|
||||
// Dashboard → Extension: "Actualiza el pack <packId> a la última versión."
|
||||
// Borra las imágenes antiguas y descarga las nuevas desde Gitea.
|
||||
|
||||
listenForMessage(messageNames.packs.update, async (packId: unknown, ack: Acknowledgement | undefined) => {
|
||||
if (typeof packId !== 'string' || !packId) {
|
||||
return reply(ack, new Error('updatePack requiere un packId no vacío.'));
|
||||
}
|
||||
if (!installedPacksRep.value?.includes(packId)) {
|
||||
return reply(ack, new Error(`El pack "${packId}" no está instalado. Usa downloadPack primero.`));
|
||||
}
|
||||
if (downloadStatesRep.value?.[packId]?.status === 'downloading') {
|
||||
return reply(ack, new Error(`El pack "${packId}" ya se está actualizando.`));
|
||||
}
|
||||
|
||||
setDownloadState(packId, { status: 'fetching-manifest', progress: 0, error: undefined });
|
||||
|
||||
try {
|
||||
// 1. Obtener nuevo manifest
|
||||
const manifestRes = await fetch(getManifestUrl(packId));
|
||||
if (!manifestRes.ok) throw new Error(`No se puede obtener el manifest: HTTP ${manifestRes.status}`);
|
||||
const manifest = await manifestRes.json() as PackManifest;
|
||||
|
||||
const packDir = path.join(packsDir, packId);
|
||||
const charsDir = path.join(packDir, 'characters');
|
||||
|
||||
// 2. Limpiar imágenes antiguas para evitar residuos de personajes renombrados
|
||||
if (fs.existsSync(charsDir)) {
|
||||
fs.rmSync(charsDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(charsDir, { recursive: true });
|
||||
|
||||
// 3. Guardar nuevo manifest en disco
|
||||
fs.writeFileSync(path.join(packDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||
|
||||
// 4. Logo
|
||||
setDownloadState(packId, { status: 'downloading', progress: 2 });
|
||||
try {
|
||||
const logoBuffer = await fetchBuffer(getPackLogoUrl(packId));
|
||||
fs.writeFileSync(path.join(packDir, 'logo.png'), logoBuffer);
|
||||
} catch {
|
||||
nodecg.log.warn(`[pack-manager] No se encontró logo para "${packId}" — se omite.`);
|
||||
}
|
||||
|
||||
// 5. Imágenes de personajes
|
||||
const total = manifest.characters.length;
|
||||
for (let i = 0; i < total; i++) {
|
||||
const char = manifest.characters[i]!;
|
||||
const saved = await trySaveImage(
|
||||
charsDir,
|
||||
char.slug,
|
||||
IMAGE_EXTENSIONS,
|
||||
(ext) => getCharacterImageRepoUrl(packId, char.slug, ext),
|
||||
);
|
||||
if (!saved) {
|
||||
nodecg.log.warn(`[pack-manager] Sin imagen para "${packId}/${char.slug}" — se usará placeholder.`);
|
||||
}
|
||||
setDownloadState(packId, { progress: 5 + Math.round(((i + 1) / total) * 93) });
|
||||
}
|
||||
|
||||
// 6. Quitar de availableUpdates
|
||||
const updates = { ...availableUpdatesRep.value };
|
||||
delete updates[packId];
|
||||
availableUpdatesRep.value = updates;
|
||||
|
||||
setDownloadState(packId, { status: 'done', progress: 100 });
|
||||
nodecg.log.info(`[pack-manager] Pack "${packId}" actualizado a v${manifest.version}.`);
|
||||
reply(ack, null, { packId, version: manifest.version });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
nodecg.log.error(`[pack-manager] Error al actualizar "${packId}": ${message}`);
|
||||
setDownloadState(packId, { status: 'error', error: message });
|
||||
reply(ack, new Error(message));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Mensaje: readLocalManifest ────────────────────────────────────────────────
|
||||
|
||||
listenForMessage(messageNames.packs.readLocalManifest, (packId: unknown, ack: Acknowledgement | undefined) => {
|
||||
if (typeof packId !== 'string' || !packId) {
|
||||
return reply(ack, new Error('readLocalManifest requiere un packId no vacío.'));
|
||||
}
|
||||
const manifestPath = path.join(packsDir, packId, 'manifest.json');
|
||||
try {
|
||||
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
||||
reply(ack, null, JSON.parse(raw) as PackManifest);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
reply(ack, new Error(`No se puede leer el manifest de "${packId}": ${message}`));
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { getData, type CountryRecord } from 'country-list';
|
||||
import { nodecg } from './util/nodecg.js';
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
@@ -188,6 +190,7 @@ const exchangeOAuthCodeForToken = async (
|
||||
redirectUri: string,
|
||||
_config: OAuthConfig,
|
||||
): Promise<string> => {
|
||||
void _config;
|
||||
const mode = getOAuthMode();
|
||||
if (mode.type === 'dev') {
|
||||
return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret);
|
||||
@@ -274,7 +277,7 @@ const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
|
||||
|
||||
// ─── Listeners de NodeCG ───────────────────────────────────────────────────────
|
||||
|
||||
nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => {
|
||||
listenForMessage(messageNames.integrations.startgg.createOAuthSession, async (_payload: unknown, ack) => {
|
||||
const mode = getOAuthMode();
|
||||
let serverConfig: OAuthConfig;
|
||||
|
||||
@@ -312,7 +315,7 @@ nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) =>
|
||||
sendAck(ack, null, oauthServer.createSession(serverConfig));
|
||||
});
|
||||
|
||||
nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => {
|
||||
listenForMessage(messageNames.integrations.startgg.getOAuthSessionStatus, (payload: unknown, ack) => {
|
||||
const sessionId = getStringProp(payload, 'sessionId');
|
||||
if (!sessionId) {
|
||||
sendAck(ack, 'Missing OAuth session id');
|
||||
@@ -328,7 +331,7 @@ nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => {
|
||||
sendAck(ack, null, status);
|
||||
});
|
||||
|
||||
nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack) => {
|
||||
listenForMessage(messageNames.integrations.startgg.fetchRecentTournaments, async (payload: unknown, ack) => {
|
||||
const token = getStringProp(payload, 'token');
|
||||
if (!token) {
|
||||
sendAck(ack, 'Missing start.gg API token');
|
||||
@@ -368,7 +371,7 @@ nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack)
|
||||
}
|
||||
});
|
||||
|
||||
nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack) => {
|
||||
listenForMessage(messageNames.integrations.startgg.fetchTournamentPlayers, async (payload: unknown, ack) => {
|
||||
const token = getStringProp(payload, 'token');
|
||||
const slug = getStringProp(payload, 'slug');
|
||||
|
||||
|
||||
@@ -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">
|
||||
import { useHead } from '@unhead/vue';
|
||||
import { computed } from 'vue';
|
||||
import { commentaryReplicant } from '../../browser_shared/replicants';
|
||||
import type { Schemas } from '../../types';
|
||||
import { useCommentaryReplicatedState } from '../shared/services/replicated-state';
|
||||
|
||||
useHead({ title: 'Commentary' });
|
||||
|
||||
const defaultCommentary: Schemas.Commentary = {
|
||||
leftCommentator: '',
|
||||
leftCommentatorTwitter: '',
|
||||
rightCommentator: '',
|
||||
rightCommentatorTwitter: '',
|
||||
};
|
||||
|
||||
const commentary = computed<Schemas.Commentary>(() => commentaryReplicant?.data ?? defaultCommentary);
|
||||
const { commentary } = useCommentaryReplicatedState();
|
||||
|
||||
const leftCommentator = computed(() => commentary.value.leftCommentator || 'COMMENTATOR 1');
|
||||
const rightCommentator = computed(() => commentary.value.rightCommentator || 'COMMENTATOR 2');
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useHead } from '@unhead/vue';
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
|
||||
import { resolveCountryCode } from '../../shared/countries';
|
||||
import { useScoreboardReplicatedState } from '../shared/services/replicated-state';
|
||||
import { resolveCountryCode } from '../../shared/domain/players/countries';
|
||||
import { getCharactersByGame } from '../../shared/fighting-characters';
|
||||
import type { Schemas } from '../../types';
|
||||
|
||||
useHead({ title: 'Scoreboard 2XKO' });
|
||||
|
||||
const defaultScoreboard: Schemas.Scoreboard = {
|
||||
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');
|
||||
const { players, scoreboard, scoreboardSkin } = useScoreboardReplicatedState('scoreboard-2xko/main.html');
|
||||
|
||||
watch(
|
||||
scoreboardSkin,
|
||||
|
||||
@@ -1,32 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { useHead } from '@unhead/vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
|
||||
import { resolveCountryCode } from '../../shared/countries';
|
||||
import type { Schemas } from '../../types';
|
||||
import { useScoreboardReplicatedState } from '../shared/services/replicated-state';
|
||||
import { resolveCountryCode } from '../../shared/domain/players/countries';
|
||||
|
||||
useHead({ title: 'Scoreboard' });
|
||||
|
||||
const defaultScoreboard: Schemas.Scoreboard = {
|
||||
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');
|
||||
const { players, scoreboard, scoreboardSkin } = useScoreboardReplicatedState('scoreboard/main.html');
|
||||
|
||||
watch(
|
||||
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];
|
||||
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 527 KiB |
|
Before Width: | Height: | Size: 4.4 MiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 3.9 MiB After Width: | Height: | Size: 311 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 441 KiB |
|
Before Width: | Height: | Size: 3.9 MiB After Width: | Height: | Size: 410 KiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 586 KiB |
|
Before Width: | Height: | Size: 4.6 MiB After Width: | Height: | Size: 606 KiB |
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 16 MiB |
|
Before Width: | Height: | Size: 4.5 MiB After Width: | Height: | Size: 920 KiB |
|
Before Width: | Height: | Size: 4.0 MiB After Width: | Height: | Size: 744 KiB |
|
Before Width: | Height: | Size: 4.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 4.6 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 5.2 MiB After Width: | Height: | Size: 898 KiB |
|
Before Width: | Height: | Size: 4.9 MiB After Width: | Height: | Size: 12 MiB |
|
Before Width: | Height: | Size: 4.5 MiB After Width: | Height: | Size: 522 KiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 972 KiB |
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 611 KiB |
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 580 KiB |
|
Before Width: | Height: | Size: 4.0 MiB After Width: | Height: | Size: 606 KiB |
|
Before Width: | Height: | Size: 4.3 MiB After Width: | Height: | Size: 877 KiB |
|
Before Width: | Height: | Size: 5.6 MiB After Width: | Height: | Size: 967 KiB |
|
Before Width: | Height: | Size: 4.0 MiB After Width: | Height: | Size: 826 KiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 561 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 775 KiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 476 KiB |
|
Before Width: | Height: | Size: 4.5 MiB After Width: | Height: | Size: 708 KiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 464 KiB |
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 584 KiB |
|
Before Width: | Height: | Size: 5.0 MiB After Width: | Height: | Size: 965 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 657 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 687 KiB |
|
Before Width: | Height: | Size: 3.7 MiB After Width: | Height: | Size: 445 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 772 KiB |
|
Before Width: | Height: | Size: 4.0 MiB After Width: | Height: | Size: 673 KiB |
|
Before Width: | Height: | Size: 3.7 MiB After Width: | Height: | Size: 577 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 418 KiB |
|
Before Width: | Height: | Size: 4.6 MiB After Width: | Height: | Size: 807 KiB |