7 Commits

Author SHA1 Message Date
Pandipipas b32c0e4560 feat: implement replicant state synchronization for commentary, players, scoreboard, and graphics settings
- Added a new service for synchronizing state with replicants in `replicant-state-service.ts`.
- Refactored commentary store to utilize the new synchronization service.
- Created a new graphics settings store that syncs with replicants.
- Introduced a packs store for managing installed packs and their states.
- Updated players and scoreboard stores to use the new synchronization service.
- Created shared services for managing replicated state in graphics components.
- Refactored existing components to use the new shared services for replicant state.
- Added normalization and default values for commentary, graphics settings, players, and scoreboard.
- Improved type safety and organization in shared domain files for better maintainability.
2026-05-30 21:22:48 +02:00
Pandipipas 02a108f983 refactor: architecture base
- Moved NodeCG context management to a dedicated context module.
- Introduced message handling utilities for better message listening and sending.
- Updated startgg integration to use new message handling methods.
- Removed deprecated replicant utilities and replaced them with a new structure.
- Refactored replicant imports in graphics components to align with new structure.
- Added new pack-related types and schemas for better type safety.
- Cleaned up unused files and consolidated pack configuration into a single module.
- Updated TypeScript configurations to reflect new directory structure.
2026-05-23 21:52:07 +02:00
Pandipipas 225b2b36a2 feat: add architectural documentation for refactor; include audit, migration plan, rules, target architecture, and session handoff 2026-05-23 20:48:31 +02:00
Pandipipas 8c270feb5b feat: enhance pack management and character handling; implement automatic registry refresh and logo display updates 2026-05-22 21:19:45 +02:00
Pandipipas 618d18d8fb feat: update pack handling and character image paths; implement installed packs revision tracking 2026-05-21 23:59:22 +02:00
Pandipipas 0bc6f60b2c feat: update Gitea configuration for base URL and owner; add updateInfo to GameSelectOption interface 2026-05-21 23:06:54 +02:00
Pandipipas 88aeedb5ff feat: update character images for Tekken 8 and enhance pack management
- Updated character images for Tekken 8, including Jin, Jun, Kazuya, and others.
- Introduced a new pack configuration system to manage character packs from a Gitea instance.
- Added types for pack management, including PackCharacter, PackManifest, and PackRegistry.
- Implemented functions to register and unregister installed packs, allowing dynamic character loading.
- Enhanced the character image retrieval system to support both bundled and installed packs.
2026-05-21 17:59:13 +02:00
124 changed files with 3334 additions and 1067 deletions
+3
View File
@@ -136,9 +136,12 @@ dist
/dashboard/ /dashboard/
/extension/ /extension/
/graphics/ /graphics/
/nodecg/
/shared/domain/
/shared/dist/ /shared/dist/
# Local runtime database # Local runtime database
/db/ /db/
*.sqlite3 *.sqlite3
/scoreko-electron-dev/ /scoreko-electron-dev/
/packs/
+108
View File
@@ -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. |
+97
View File
@@ -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.
+187
View File
@@ -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. |
+56
View File
@@ -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.
+85
View File
@@ -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.
+70
View File
@@ -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.
+203
View File
@@ -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.
+1 -1
View File
@@ -16,7 +16,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"autofix": "eslint --fix", "autofix": "eslint --fix",
"prebuild": "trash ./extension && trash ./node_modules/.vite && trash ./shared/dist && trash ./dashboard && trash ./graphics", "prebuild": "trash ./extension && trash ./nodecg && trash ./node_modules/.vite && trash ./shared/domain && trash ./shared/dist && trash ./dashboard && trash ./graphics",
"build": "vite build && tsc -b tsconfig.extension.json", "build": "vite build && tsc -b tsconfig.extension.json",
"lint": "eslint", "lint": "eslint",
"schema-types": "nodecg schema-types", "schema-types": "nodecg schema-types",
+18
View File
@@ -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": {}
}
+22
View File
@@ -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": {}
}
-14
View File
@@ -1,14 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"additionalProperties": false,
"properties": {
"exampleProperty": {
"type": "string",
"default": "exampleString"
}
},
"required": [
"exampleProperty"
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "array",
"items": {
"type": "string"
},
"default": []
}
+50
View File
@@ -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
}
-17
View File
@@ -1,17 +0,0 @@
import { useReplicant } from 'nodecg-vue-composable';
import type { Schemas } from '../types';
// YOU MUST CHANGE THIS TO YOUR BUNDLE'S NAME!
const thisBundle = 'scoreko-dev';
/**
* This is where you can declare all of your replicants to import easily into other (browser based) files.
* "useReplicant" is a helper composable to make accessing/modifying replicants easier.
* For more information see https://github.com/Dan-Shields/nodecg-vue-composable
*/
export const exampleReplicant = useReplicant<Schemas.ExampleReplicant>('exampleReplicant', thisBundle);
export const playersReplicant = useReplicant<Schemas.Players>('players', thisBundle);
export const scoreboardReplicant = useReplicant<Schemas.Scoreboard>('scoreboard', thisBundle);
export const graphicsSettingsReplicant = useReplicant<Schemas.GraphicsSettings>('graphicsSettings', thisBundle);
export const commentaryReplicant = useReplicant<Schemas.Commentary>('commentary', thisBundle);
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { t } from '../i18n'; import { t } from '../i18n';
import { useScoreboardStore } from '../stores/scoreboard'; import { useScoreboardStore } from '../../stores/scoreboard';
const scoreboardStore = useScoreboardStore(); const scoreboardStore = useScoreboardStore();
@@ -83,12 +83,12 @@ const updateRound = () => {
return; return;
} }
if (customActive.value) { if (customActive.value) {
scoreboardStore.scoreboard.round = customText.value.trim(); scoreboardStore.setRound(customText.value.trim());
return; return;
} }
const prefix = bracketSide.value ? `${bracketSide.value} ` : ''; const prefix = bracketSide.value ? `${bracketSide.value} ` : '';
scoreboardStore.scoreboard.round = `${prefix}${stage.value}`.trim(); scoreboardStore.setRound(`${prefix}${stage.value}`.trim());
}; };
watch([stage, bracketSide, customText, customActive], updateRound); watch([stage, bracketSide, customText, customActive], updateRound);
@@ -245,4 +245,4 @@ onMounted(() => {
.bracket-panel__preview-text--custom { .bracket-panel__preview-text--custom {
color: var(--q-secondary, #26a69a); color: var(--q-secondary, #26a69a);
} }
</style> </style>
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { stripTwitterPrefix } from '../../../shared/domain/commentary';
import { t } from '../i18n'; import { t } from '../i18n';
import { useCommentaryStore } from '../stores/commentary'; import { useCommentaryStore } from '../../stores/commentary';
const commentaryStore = useCommentaryStore(); const commentaryStore = useCommentaryStore();
@@ -17,25 +18,18 @@ const twitterRules = [
!val || TWITTER_VALID_CHARS.test(val) || t('commentaryTwitterInvalidChars'), !val || TWITTER_VALID_CHARS.test(val) || t('commentaryTwitterInvalidChars'),
]; ];
function stripAt(value: string): string {
return value.startsWith('@') ? value.slice(1) : value;
}
function handleLeftTwitterInput(value: string | number | null) { function handleLeftTwitterInput(value: string | number | null) {
commentaryStore.leftCommentatorTwitter = value ? stripAt(String(value)) : ''; commentaryStore.leftCommentatorTwitter = value ? stripTwitterPrefix(String(value)) : '';
} }
function handleRightTwitterInput(value: string | number | null) { function handleRightTwitterInput(value: string | number | null) {
commentaryStore.rightCommentatorTwitter = value ? stripAt(String(value)) : ''; commentaryStore.rightCommentatorTwitter = value ? stripTwitterPrefix(String(value)) : '';
} }
// --- Clear --- // --- Clear ---
function clearAll() { function clearAll() {
commentaryStore.leftCommentator = ''; commentaryStore.clearCommentary();
commentaryStore.leftCommentatorTwitter = '';
commentaryStore.rightCommentator = '';
commentaryStore.rightCommentatorTwitter = '';
} }
const isAnythingFilled = computed(() => const isAnythingFilled = computed(() =>
@@ -280,4 +274,4 @@ const rightHandlePreview = computed(() =>
flex-direction: row; 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 { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
import { usePlayerSide } from '../composables/usePlayerSide'; import { usePlayerSide } from '../composables/usePlayerSide';
import { t } from '../i18n'; import { t } from '../i18n';
import { useScoreboardStore } from '../stores/scoreboard'; import { useScoreboardStore } from '../../stores/scoreboard';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Props // Props
@@ -74,8 +74,7 @@ const character = computed({
? scoreboardStore.scoreboard.leftCharacter ? scoreboardStore.scoreboard.leftCharacter
: scoreboardStore.scoreboard.rightCharacter), : scoreboardStore.scoreboard.rightCharacter),
set: (v) => { set: (v) => {
if (isLeft.value) scoreboardStore.scoreboard.leftCharacter = v; scoreboardStore.setSideCharacter(props.side, v);
else scoreboardStore.scoreboard.rightCharacter = v;
}, },
}); });
@@ -1,25 +1,67 @@
<script setup lang="ts"> <script setup lang="ts">
import { inject } from 'vue'; import { inject, onMounted, onUnmounted, ref } from 'vue';
import { useScoreboardStore } from '../stores/scoreboard';
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame'; import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
import { usePackRegistry } from '../composables/usePackRegistry';
import { t } from '../i18n'; import { t } from '../i18n';
import { useScoreboardStore } from '../../stores/scoreboard';
import GamePackDownloadDialog from './GamePackDownloadDialog.vue';
const scoreboardStore = useScoreboardStore(); 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) => { const adjustLeftScore = (delta: number) => {
scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore + delta); scoreboardStore.adjustScore('left', delta);
}; };
const adjustRightScore = (delta: number) => { const adjustRightScore = (delta: number) => {
scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore + delta); scoreboardStore.adjustScore('right', delta);
};
/** Tras una descarga exitosa, activa el juego en el store. */
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> </script>
<template> <template>
<div class="scoreboard-preview__center"> <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 <QSelect
v-model="scoreboardStore.scoreboard.game" :model-value="scoreboardStore.scoreboard.game"
v-model:input-value="gameInput" v-model:input-value="gameInput"
:options="fightingGameOptions" :options="fightingGameOptions"
:label="t('scoreboardLabelGame')" :label="t('scoreboardLabelGame')"
@@ -32,10 +74,59 @@ const adjustRightScore = (delta: number) => {
fill-input fill-input
class="scoreboard-preview__field scoreboard-preview__game-field" class="scoreboard-preview__field scoreboard-preview__game-field"
@filter="onGameFilter" @filter="onGameFilter"
@update:model-value="handleGameSelect"
> >
<template #prepend> <template #prepend>
<QIcon name="sports_esports" /> <QIcon name="sports_esports" />
</template> </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> </QSelect>
<div class="scoreboard-preview__score-controls"> <div class="scoreboard-preview__score-controls">
@@ -101,8 +192,25 @@ const adjustRightScore = (delta: number) => {
class="scoreboard-preview__action-btn" class="scoreboard-preview__action-btn"
@click="scoreboardStore.resetScores" @click="scoreboardStore.resetScores"
/> />
</div> </div>
</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> </template>
<style scoped> <style scoped>
@@ -188,4 +296,13 @@ const adjustRightScore = (delta: number) => {
.scoreboard-preview__field :deep(.q-field__label) { .scoreboard-preview__field :deep(.q-field__label) {
color: rgba(255, 255, 255, 0.92); 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> </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 { computed, ref, watch, type InjectionKey, type Ref } from 'vue';
import { getCharactersByGame, getDefaultCharactersByGame } from '../../../shared/fighting-characters'; import type { FightingCharacterOption } from '../../../shared/domain/packs/characters';
import { useScoreboardStore } from '../stores/scoreboard'; import type { GameSelectOption, PackRegistryEntry } from '../../../shared/domain/packs/types';
import { useScoreboardStore } from '../../stores/scoreboard';
import { usePackRegistry } from './usePackRegistry';
// --------------------------------------------------------------------------- // ── Types ─────────────────────────────────────────────────────────────────────
// 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)
// ---------------------------------------------------------------------------
export type CharacterOption = FightingCharacterOption;
export type CharacterGameContext = ReturnType<typeof useCharacterGame>; export type CharacterGameContext = ReturnType<typeof useCharacterGame>;
export const CHARACTER_GAME_KEY: InjectionKey<CharacterGameContext> = Symbol('characterGame'); export const CHARACTER_GAME_KEY: InjectionKey<CharacterGameContext> = Symbol('characterGame');
// --------------------------------------------------------------------------- // ── 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() { export function useCharacterGame() {
const scoreboardStore = useScoreboardStore(); const scoreboardStore = useScoreboardStore();
const packRegistry = usePackRegistry();
// ── Game selector state ───────────────────────────────────────────────────
// Game selector
const gameInput = ref(''); 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 leftCharacterOptions = ref<CharacterOption[]>([]);
const rightCharacterOptions = ref<CharacterOption[]>([]); const rightCharacterOptions = ref<CharacterOption[]>([]);
const leftCharacterInput = ref(''); const leftCharacterInput = ref('');
const rightCharacterInput = ref(''); const rightCharacterInput = ref('');
// Remembers selected characters per game so swapping games restores them
const charactersByGame = ref<Record<string, { leftCharacter: string; rightCharacter: string }>>({}); const charactersByGame = ref<Record<string, { leftCharacter: string; rightCharacter: string }>>({});
// Character images for preview
const leftCharacterImage = computed(() => { const leftCharacterImage = computed(() => {
const match = characterOptions.value.find( const match = characterOptions.value.find(
(o) => o.value === scoreboardStore.scoreboard.leftCharacter, (o) => o.value === scoreboardStore.scoreboard.leftCharacter,
@@ -69,20 +101,21 @@ export function useCharacterGame() {
return match?.image ?? ''; return match?.image ?? '';
}); });
// --------------------------------------------------------------------------- // ── Filter handlers ───────────────────────────────────────────────────────
// Filter handlers
// ---------------------------------------------------------------------------
const onGameFilter = (value: string, update: (fn: () => void) => void) => { const onGameFilter = (value: string, update: (fn: () => void) => void) => {
update(() => { update(() => {
const needle = value.toLowerCase().trim(); const needle = value.toLowerCase().trim();
fightingGameOptions.value = needle fightingGameOptions.value = needle
? ALL_FIGHTING_GAME_OPTIONS.filter((g) => g.label.toLowerCase().includes(needle)) ? packRegistry.allGameOptions.value.filter((g) =>
: ALL_FIGHTING_GAME_OPTIONS; g.label.toLowerCase().includes(needle),
)
: packRegistry.allGameOptions.value;
}); });
}; };
const makeCharacterFilter = (target: Ref<CharacterOption[]>) => const makeCharacterFilter =
(target: Ref<CharacterOption[]>) =>
(value: string, update: (fn: () => void) => void) => { (value: string, update: (fn: () => void) => void) => {
update(() => { update(() => {
const needle = value.toLowerCase().trim(); const needle = value.toLowerCase().trim();
@@ -95,16 +128,14 @@ export function useCharacterGame() {
const onLeftCharacterFilter = makeCharacterFilter(leftCharacterOptions); const onLeftCharacterFilter = makeCharacterFilter(leftCharacterOptions);
const onRightCharacterFilter = makeCharacterFilter(rightCharacterOptions); const onRightCharacterFilter = makeCharacterFilter(rightCharacterOptions);
// --------------------------------------------------------------------------- // ── Watchers ──────────────────────────────────────────────────────────────
// Watchers
// ---------------------------------------------------------------------------
// Keep gameInput display value in sync // Keep gameInput display value in sync with the store
watch( watch(
() => scoreboardStore.scoreboard.game, () => scoreboardStore.scoreboard.game,
(value) => { (value) => {
const match = ALL_FIGHTING_GAME_OPTIONS.find((o) => o.value === value); const match = fightingGameOptions.value.find((o) => o.value === value);
gameInput.value = match?.label ?? ''; gameInput.value = match?.label ?? value;
}, },
{ immediate: true }, { 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; leftCharacterOptions.value = options;
rightCharacterOptions.value = options; rightCharacterOptions.value = options;
const allowed = new Set(options.map((o) => o.value)); const allowed = new Set(options.map((o) => o.value));
@@ -133,9 +171,8 @@ export function useCharacterGame() {
if (!allowed.has(nextLeft)) nextLeft = ''; if (!allowed.has(nextLeft)) nextLeft = '';
if (!allowed.has(nextRight)) nextRight = ''; if (!allowed.has(nextRight)) nextRight = '';
// Apply defaults only when neither side had a character yet
if ((!nextLeft || !nextRight) && (!curLeft || !curRight)) { if ((!nextLeft || !nextRight) && (!curLeft || !curRight)) {
const defaults = getDefaultCharactersByGame(newGame); const defaults = packRegistry.getDefaultCharactersByGame(newGame);
if (defaults) { if (defaults) {
if (!nextLeft) nextLeft = allowed.has(defaults.leftCharacter) ? defaults.leftCharacter : ''; if (!nextLeft) nextLeft = allowed.has(defaults.leftCharacter) ? defaults.leftCharacter : '';
if (!nextRight) nextRight = allowed.has(defaults.rightCharacter) ? defaults.rightCharacter : ''; if (!nextRight) nextRight = allowed.has(defaults.rightCharacter) ? defaults.rightCharacter : '';
@@ -143,23 +180,22 @@ export function useCharacterGame() {
} }
if (allowed.has(nextLeft)) { if (allowed.has(nextLeft)) {
scoreboardStore.scoreboard.leftCharacter = nextLeft; scoreboardStore.setSideCharacter('left', nextLeft);
} else if (!allowed.has(scoreboardStore.scoreboard.leftCharacter)) { } else if (!allowed.has(scoreboardStore.scoreboard.leftCharacter)) {
scoreboardStore.scoreboard.leftCharacter = ''; scoreboardStore.setSideCharacter('left', '');
leftCharacterInput.value = ''; leftCharacterInput.value = '';
} }
if (allowed.has(nextRight)) { if (allowed.has(nextRight)) {
scoreboardStore.scoreboard.rightCharacter = nextRight; scoreboardStore.setSideCharacter('right', nextRight);
} else if (!allowed.has(scoreboardStore.scoreboard.rightCharacter)) { } else if (!allowed.has(scoreboardStore.scoreboard.rightCharacter)) {
scoreboardStore.scoreboard.rightCharacter = ''; scoreboardStore.setSideCharacter('right', '');
rightCharacterInput.value = ''; rightCharacterInput.value = '';
} }
}, },
{ immediate: true }, { immediate: true },
); );
// Keep left character display input and charactersByGame cache in sync
watch( watch(
() => scoreboardStore.scoreboard.leftCharacter, () => scoreboardStore.scoreboard.leftCharacter,
(value) => { (value) => {
@@ -176,7 +212,6 @@ export function useCharacterGame() {
{ immediate: true }, { immediate: true },
); );
// Keep right character display input and charactersByGame cache in sync
watch( watch(
() => scoreboardStore.scoreboard.rightCharacter, () => scoreboardStore.scoreboard.rightCharacter,
(value) => { (value) => {
@@ -193,16 +228,53 @@ export function useCharacterGame() {
{ immediate: true }, { 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 { return {
// Game selector
gameInput, gameInput,
fightingGameOptions, fightingGameOptions,
onGameFilter,
handleGameSelect,
// Download dialog
pendingDownloadEntry,
showDownloadDialog,
// Character state
leftCharacterOptions, leftCharacterOptions,
rightCharacterOptions, rightCharacterOptions,
leftCharacterInput, leftCharacterInput,
rightCharacterInput, rightCharacterInput,
leftCharacterImage, leftCharacterImage,
rightCharacterImage, rightCharacterImage,
onGameFilter,
onLeftCharacterFilter, onLeftCharacterFilter,
onRightCharacterFilter, onRightCharacterFilter,
}; };
@@ -1,5 +1,5 @@
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { getCountryLabel, getCountryOptions } from '../../../shared/countries'; import { getCountryLabel, getCountryOptions } from '../../../shared/domain/players/countries';
import { locale } from '../i18n'; import { locale } from '../i18n';
/** /**
@@ -1,4 +1,5 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { sendIntegrationMessage } from '../../services/integration-message-service';
// ─── Tipos ───────────────────────────────────────────────────────────────────── // ─── Tipos ─────────────────────────────────────────────────────────────────────
@@ -65,19 +66,6 @@ export interface UseIntegrationOptions {
playersStore: PlayersStore; playersStore: PlayersStore;
} }
// ─── Utilidad para mensajes NodeCG ─────────────────────────────────────────────
const sendNodeCGMessage = <T>(messageName: string, payload: unknown): Promise<T> =>
new Promise((resolve, reject) => {
nodecg.sendMessage(messageName, payload, (error: unknown, response: unknown) => {
if (error) {
reject(new Error(String(error)));
return;
}
resolve(response as T);
});
});
// ─── Composable ──────────────────────────────────────────────────────────────── // ─── Composable ────────────────────────────────────────────────────────────────
export function useIntegration(options: UseIntegrationOptions) { export function useIntegration(options: UseIntegrationOptions) {
@@ -165,8 +153,9 @@ export function useIntegration(options: UseIntegrationOptions) {
tournamentsError.value = ''; tournamentsError.value = '';
loadingTournaments.value = true; loadingTournaments.value = true;
try { try {
const tournaments = await sendNodeCGMessage<IntegrationTournament[]>( const tournaments = await sendIntegrationMessage<IntegrationTournament[]>(
`${messagePrefix}:fetchRecentTournaments`, messagePrefix,
'fetchRecentTournaments',
{ token: currentToken }, { token: currentToken },
); );
hasValidatedToken.value = true; hasValidatedToken.value = true;
@@ -204,8 +193,9 @@ export function useIntegration(options: UseIntegrationOptions) {
players.value = []; players.value = [];
try { try {
const importedPlayers = await sendNodeCGMessage<IntegrationPlayer[]>( const importedPlayers = await sendIntegrationMessage<IntegrationPlayer[]>(
`${messagePrefix}:fetchTournamentPlayers`, messagePrefix,
'fetchTournamentPlayers',
{ token: token.value.trim(), slug: tournament.slug }, { token: token.value.trim(), slug: tournament.slug },
); );
players.value = importedPlayers; players.value = importedPlayers;
@@ -325,8 +315,9 @@ export function useIntegration(options: UseIntegrationOptions) {
if (!oauthSessionId.value) return; if (!oauthSessionId.value) return;
try { try {
const status = await sendNodeCGMessage<OAuthStatusResponse>( const status = await sendIntegrationMessage<OAuthStatusResponse>(
`${messagePrefix}:getOAuthSessionStatus`, messagePrefix,
'getOAuthSessionStatus',
{ sessionId: oauthSessionId.value }, { sessionId: oauthSessionId.value },
); );
@@ -362,8 +353,9 @@ export function useIntegration(options: UseIntegrationOptions) {
stopPolling(); stopPolling();
try { try {
const session = await sendNodeCGMessage<OAuthSessionResponse>( const session = await sendIntegrationMessage<OAuthSessionResponse>(
`${messagePrefix}:createOAuthSession`, messagePrefix,
'createOAuthSession',
{}, {},
); );
oauthSessionId.value = session.sessionId; oauthSessionId.value = session.sessionId;
@@ -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 { computed, ref, watch, watchEffect } from 'vue';
import { useScoreboardStore } from '../stores/scoreboard'; import { useScoreboardStore } from '../../stores/scoreboard';
import { usePlayersStore } from '../stores/players'; import { usePlayersStore } from '../../stores/players';
import type { Schemas } from '../../../types'; import type { Schemas } from '../../../types';
import { createPlayerId, normalizePlayerName } from '../../../shared/domain/players/state';
import { t } from '../i18n'; import { t } from '../i18n';
import { useCountryFilter } from './useCountryFilter'; import { useCountryFilter } from './useCountryFilter';
@@ -16,34 +17,6 @@ export const CUSTOM_RIGHT_PLAYER_ID = '__custom_right_player__';
// Pure helpers (no Vue reactivity) // Pure helpers (no Vue reactivity)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const normalizeName = (value: string) => value.trim().toLowerCase();
/**
* Generates a unique slug-based player ID that does not collide with
* existing player keys in the store.
*/
const createPlayerId = (name: string, players: Schemas.Players): string => {
const base = name
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[^\w\s-]/g, '')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '-') || 'player';
let index = 1;
let candidate = base;
while (players[candidate]) {
index += 1;
candidate = `${base}-${index}`;
}
return candidate;
};
// ---------------------------------------------------------------------------
// Composable
// ---------------------------------------------------------------------------
/** /**
* Encapsulates all reactive state and handlers for one side of the scoreboard * Encapsulates all reactive state and handlers for one side of the scoreboard
* (left or right). Call once per side inside the corresponding component. * (left or right). Call once per side inside the corresponding component.
@@ -63,32 +36,28 @@ export function usePlayerSide(side: 'left' | 'right') {
const playerId = computed({ const playerId = computed({
get: () => (isLeft ? scoreboardStore.scoreboard.leftPlayerId : scoreboardStore.scoreboard.rightPlayerId), get: () => (isLeft ? scoreboardStore.scoreboard.leftPlayerId : scoreboardStore.scoreboard.rightPlayerId),
set: (v) => { set: (v) => {
if (isLeft) scoreboardStore.scoreboard.leftPlayerId = v; scoreboardStore.setSidePlayerId(side, v);
else scoreboardStore.scoreboard.rightPlayerId = v;
}, },
}); });
const nameOverride = computed({ const nameOverride = computed({
get: () => (isLeft ? scoreboardStore.scoreboard.leftNameOverride : scoreboardStore.scoreboard.rightNameOverride), get: () => (isLeft ? scoreboardStore.scoreboard.leftNameOverride : scoreboardStore.scoreboard.rightNameOverride),
set: (v) => { set: (v) => {
if (isLeft) scoreboardStore.scoreboard.leftNameOverride = v; scoreboardStore.setSideNameOverride(side, v);
else scoreboardStore.scoreboard.rightNameOverride = v;
}, },
}); });
const teamOverride = computed({ const teamOverride = computed({
get: () => (isLeft ? scoreboardStore.scoreboard.leftTeamOverride : scoreboardStore.scoreboard.rightTeamOverride), get: () => (isLeft ? scoreboardStore.scoreboard.leftTeamOverride : scoreboardStore.scoreboard.rightTeamOverride),
set: (v) => { set: (v) => {
if (isLeft) scoreboardStore.scoreboard.leftTeamOverride = v; scoreboardStore.setSideTeamOverride(side, v);
else scoreboardStore.scoreboard.rightTeamOverride = v;
}, },
}); });
const countryOverride = computed({ const countryOverride = computed({
get: () => (isLeft ? scoreboardStore.scoreboard.leftCountryOverride : scoreboardStore.scoreboard.rightCountryOverride), get: () => (isLeft ? scoreboardStore.scoreboard.leftCountryOverride : scoreboardStore.scoreboard.rightCountryOverride),
set: (v) => { set: (v) => {
if (isLeft) scoreboardStore.scoreboard.leftCountryOverride = v; scoreboardStore.setSideCountryOverride(side, v);
else scoreboardStore.scoreboard.rightCountryOverride = v;
}, },
}); });
@@ -145,10 +114,10 @@ export function usePlayerSide(side: 'left' | 'right') {
}; };
const playerExistsByGamertag = (name: string): boolean => { const playerExistsByGamertag = (name: string): boolean => {
const normalized = normalizeName(name); const normalized = normalizePlayerName(name);
return Boolean(normalized) return Boolean(normalized)
&& Object.values(playersStore.players).some( && Object.values(playersStore.players).some(
(p) => normalizeName(p.gamertag || '') === normalized, (p) => normalizePlayerName(p.gamertag || '') === normalized,
); );
}; };
@@ -343,4 +312,4 @@ export function usePlayerSide(side: 'left' | 'right') {
saveCountryChange, saveCountryChange,
onCountryFilter, onCountryFilter,
}; };
} }
+3 -3
View File
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { t } from './i18n'; import { t } from './i18n';
import { useScoreboardStore } from './stores/scoreboard'; import { useScoreboardStore } from '../stores/scoreboard';
import { isShortcutMatch, useShortcutSettingsStore } from './stores/shortcut-settings'; import { isShortcutMatch, useShortcutSettingsStore } from '../stores/shortcut-settings';
// ── Sidebar collapse ────────────────────────────────────────────────────────── // ── Sidebar collapse ──────────────────────────────────────────────────────────
const LS_KEY = 'sidebar_collapsed'; const LS_KEY = 'sidebar_collapsed';
@@ -383,4 +383,4 @@ onUnmounted(() => {
70% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); } 70% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); }
100% { box-shadow: 0 0 0 0 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,
};
});
+7 -9
View File
@@ -2,7 +2,7 @@
import { useHead } from '@unhead/vue'; import { useHead } from '@unhead/vue';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import bundlePackage from '../../../../package.json'; import bundlePackage from '../../../../package.json';
import { graphicsSettingsReplicant } from '../../../browser_shared/replicants'; import { useGraphicsSettingsStore } from '../../stores/graphics-settings';
import { t } from '../i18n'; import { t } from '../i18n';
defineOptions({ name: 'GraphicsView' }); defineOptions({ name: 'GraphicsView' });
@@ -24,6 +24,7 @@ type GraphicCard = {
useHead(() => ({ title: t('graphicsTitle') })); useHead(() => ({ title: t('graphicsTitle') }));
const graphicsSettingsStore = useGraphicsSettingsStore();
const graphics = computed<GraphicConfig[]>(() => bundlePackage.nodecg?.graphics ?? []); const graphics = computed<GraphicConfig[]>(() => bundlePackage.nodecg?.graphics ?? []);
const baseUrl = computed(() => { const baseUrl = computed(() => {
@@ -60,7 +61,7 @@ const commentaryGraphic = computed(() =>
const selectedScoreboardSkin = ref<string>(''); const selectedScoreboardSkin = ref<string>('');
watch( watch(
[scoreboardGraphics, () => graphicsSettingsReplicant?.data?.scoreboardSkin], [scoreboardGraphics, () => graphicsSettingsStore.settings.scoreboardSkin],
([availableSkins, replicatedSkin]) => { ([availableSkins, replicatedSkin]) => {
if (availableSkins.length === 0) { if (availableSkins.length === 0) {
selectedScoreboardSkin.value = ''; selectedScoreboardSkin.value = '';
@@ -87,18 +88,15 @@ watch(
watch( watch(
selectedScoreboardSkin, selectedScoreboardSkin,
(value) => { (value) => {
if (!value || !graphicsSettingsReplicant) { if (!value) {
return; return;
} }
if (graphicsSettingsReplicant.data?.scoreboardSkin === value) { if (graphicsSettingsStore.settings.scoreboardSkin === value) {
return; return;
} }
graphicsSettingsReplicant.data = { graphicsSettingsStore.setScoreboardSkin(value);
scoreboardSkin: value,
};
graphicsSettingsReplicant.save();
}, },
{ immediate: true }, { immediate: true },
); );
@@ -262,4 +260,4 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
</div> </div>
</div> </div>
</QPage> </QPage>
</template> </template>
+9 -4
View File
@@ -2,11 +2,11 @@
import { useHead } from '@unhead/vue'; import { useHead } from '@unhead/vue';
import { useQuasar, type QTableColumn } from 'quasar'; import { useQuasar, type QTableColumn } from 'quasar';
import { computed, reactive, ref, watch } from 'vue'; import { computed, reactive, ref, watch } from 'vue';
import { getCountryLabel, getCountryOptions } from '../../../shared/countries'; import { getCountryLabel, getCountryOptions } from '../../../shared/domain/players/countries';
import type { Schemas } from '../../../types'; import type { Schemas } from '../../../types';
import { useIntegration } from '../composables/useIntegration'; import { useIntegration } from '../composables/useIntegration';
import { locale, t } from '../i18n'; import { locale, t } from '../i18n';
import { usePlayersStore } from '../stores/players'; import { usePlayersStore } from '../../stores/players';
defineOptions({ name: 'PlayersView' }); defineOptions({ name: 'PlayersView' });
@@ -145,8 +145,13 @@ const openCreateDialog = () => {
const openEditDialog = (row: PlayerRow) => { const openEditDialog = (row: PlayerRow) => {
editingId.value = row.id; editingId.value = row.id;
const { id: _id, ...playerData } = row; Object.assign(form, {
Object.assign(form, playerData); gamertag: row.gamertag,
name: row.name,
country: row.country,
team: row.team,
twitter: row.twitter,
});
isDialogOpen.value = true; isDialogOpen.value = true;
}; };
+2 -2
View File
@@ -5,12 +5,12 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useIntegration } from '../composables/useIntegration'; import { useIntegration } from '../composables/useIntegration';
import type { Locale } from '../i18n'; import type { Locale } from '../i18n';
import { locale, setLocale, t } from '../i18n'; import { locale, setLocale, t } from '../i18n';
import { usePlayersStore } from '../stores/players'; import { usePlayersStore } from '../../stores/players';
import { import {
eventToShortcut, eventToShortcut,
type ShortcutAction, type ShortcutAction,
useShortcutSettingsStore, useShortcutSettingsStore,
} from '../stores/shortcut-settings'; } from '../../stores/shortcut-settings';
defineOptions({ name: 'SettingsView' }); defineOptions({ name: 'SettingsView' });
@@ -0,0 +1,8 @@
import { sendNodecgMessage } from '../../nodecg/browser/messages';
export const sendIntegrationMessage = <T>(
messagePrefix: string,
action: string,
payload: unknown,
): Promise<T> =>
sendNodecgMessage<T>(`${messagePrefix}:${action}`, payload);
+79
View File
@@ -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 { ref, watch, type Ref } from 'vue';
import { commentaryReplicant, graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../nodecg/browser/replicants';
import { normalizeCommentary } from '../../shared/domain/commentary';
import { normalizeGraphicsSettings } from '../../shared/domain/graphics';
import { normalizePlayers } from '../../shared/domain/players/state';
import { normalizeScoreboard } from '../../shared/domain/scoreboard';
import type { Schemas } from '../../types';
interface ReplicantLike<T> { interface ReplicantLike<T> {
data: T | undefined; data: T | undefined;
@@ -36,7 +42,7 @@ export const writeStorageSnapshot = <T>(storageKey: string, value: T): void => {
} }
}; };
export const syncStateWithReplicant = <T>( const syncStateWithReplicant = <T>(
state: Ref<T>, state: Ref<T>,
replicant: ReplicantLike<T> | undefined, replicant: ReplicantLike<T> | undefined,
normalize: (input: unknown) => T, normalize: (input: unknown) => T,
@@ -44,24 +50,21 @@ export const syncStateWithReplicant = <T>(
): void => { ): void => {
const isApplyingReplicant = ref(false); const isApplyingReplicant = ref(false);
const persistSnapshot = (value: T): void => { const persistSnapshot = (value: T): void => {
if (!storageKey) { if (storageKey) {
return; writeStorageSnapshot(storageKey, value);
} }
writeStorageSnapshot(storageKey, value);
}; };
watch( watch(
() => replicant?.data, () => replicant?.data,
(value) => { (value) => {
if (!value) { if (value === undefined) {
return; return;
} }
isApplyingReplicant.value = true; isApplyingReplicant.value = true;
state.value = normalize(value); state.value = normalize(value);
isApplyingReplicant.value = false; isApplyingReplicant.value = false;
persistSnapshot(state.value); persistSnapshot(state.value);
}, },
{ deep: true, immediate: true }, { deep: true, immediate: true },
@@ -70,16 +73,32 @@ export const syncStateWithReplicant = <T>(
watch( watch(
state, state,
(value) => { (value) => {
persistSnapshot(value); const normalized = normalize(value);
persistSnapshot(normalized);
if (isApplyingReplicant.value || !replicant) { if (isApplyingReplicant.value || !replicant) {
return; return;
} }
// Replicants remain the source of truth for server/browser synchronization. replicant.data = normalized;
replicant.data = normalize(value);
replicant.save(); replicant.save();
}, },
{ deep: true, flush: 'sync' }, { deep: true, flush: 'sync' },
); );
}; };
export const syncScoreboardState = (state: Ref<Schemas.Scoreboard>, storageKey: string): void => {
syncStateWithReplicant(state, scoreboardReplicant, normalizeScoreboard, storageKey);
};
export const syncPlayersState = (state: Ref<Schemas.Players>, storageKey: string): void => {
syncStateWithReplicant(state, playersReplicant, normalizePlayers, storageKey);
};
export const syncCommentaryState = (state: Ref<Schemas.Commentary>): void => {
syncStateWithReplicant(state, commentaryReplicant, normalizeCommentary);
};
export const syncGraphicsSettingsState = (state: Ref<Schemas.GraphicsSettings>): void => {
syncStateWithReplicant(state, graphicsSettingsReplicant, normalizeGraphicsSettings);
};
+77
View File
@@ -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,
};
});
+31
View File
@@ -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,
};
});
+218
View File
@@ -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,
};
});
+51
View File
@@ -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,
};
});
+120
View File
@@ -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,
};
});
+8 -5
View File
@@ -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'; import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js';
// ─── Constantes ──────────────────────────────────────────────────────────────── // ─── Constantes ────────────────────────────────────────────────────────────────
@@ -177,6 +179,7 @@ const exchangeOAuthCodeForToken = async (
redirectUri: string, redirectUri: string,
_config: OAuthConfig, _config: OAuthConfig,
): Promise<string> => { ): Promise<string> => {
void _config;
const mode = getOAuthMode(); const mode = getOAuthMode();
if (mode.type === 'dev') { if (mode.type === 'dev') {
return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret); return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret);
@@ -415,7 +418,7 @@ const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
// ─── Listeners de NodeCG ─────────────────────────────────────────────────────── // ─── Listeners de NodeCG ───────────────────────────────────────────────────────
nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) => { listenForMessage(messageNames.integrations.challonge.createOAuthSession, async (_payload: unknown, ack) => {
const mode = getOAuthMode(); const mode = getOAuthMode();
let serverConfig: OAuthConfig; let serverConfig: OAuthConfig;
@@ -452,7 +455,7 @@ nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack)
sendAck(ack, null, oauthServer.createSession(serverConfig)); 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'); const sessionId = getStringProp(payload, 'sessionId');
if (!sessionId) { if (!sessionId) {
sendAck(ack, 'Missing OAuth session id'); sendAck(ack, 'Missing OAuth session id');
@@ -468,7 +471,7 @@ nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => {
sendAck(ack, null, status); 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'); const token = getStringProp(payload, 'token');
if (!token) { if (!token) {
sendAck(ack, 'Missing Challonge API 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 token = getStringProp(payload, 'token');
const slug = normalizeTournamentSlug(getStringProp(payload, 'slug')); const slug = normalizeTournamentSlug(getStringProp(payload, 'slug'));
-15
View File
@@ -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' };
+4 -4
View File
@@ -1,14 +1,14 @@
import type { NodeCGServerAPI } from '../types/index.js'; import type { NodeCGServerAPI } from '../types/index.js';
import { set } from './util/nodecg.js'; import { setNodecgContext } from '../nodecg/extension/context.js';
export default async (nodecg: NodeCGServerAPI) => { export default async (nodecg: NodeCGServerAPI) => {
/** /**
* Because of how top-level `import`s work, it helps to use `import`s here * Because of how top-level `import`s work, it helps to use `import`s here
* to force things to be loaded *after* the NodeCG context is set. * to force things to be loaded *after* the NodeCG context is set.
*/ */
set(nodecg); // set nodecg "context" before anything else setNodecgContext(nodecg); // set nodecg "context" before anything else
await import('./util/replicants.js'); // make sure replicants are set up await import('./modules/replicants.js'); // make sure replicants are set up
await import('./example.js');
await import('./startgg.js'); await import('./startgg.js');
await import('./challonge.js'); await import('./challonge.js');
await import('./pack-manager.js');
}; };
+9
View File
@@ -0,0 +1,9 @@
import {
commentaryReplicant,
playersReplicant,
scoreboardReplicant,
} from '../../nodecg/extension/replicants.js';
playersReplicant();
scoreboardReplicant();
commentaryReplicant();
+390
View File
@@ -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}`));
}
});
+8 -5
View File
@@ -1,5 +1,7 @@
import { getData, type CountryRecord } from 'country-list'; 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'; import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js';
// ─── Constantes ──────────────────────────────────────────────────────────────── // ─── Constantes ────────────────────────────────────────────────────────────────
@@ -188,6 +190,7 @@ const exchangeOAuthCodeForToken = async (
redirectUri: string, redirectUri: string,
_config: OAuthConfig, _config: OAuthConfig,
): Promise<string> => { ): Promise<string> => {
void _config;
const mode = getOAuthMode(); const mode = getOAuthMode();
if (mode.type === 'dev') { if (mode.type === 'dev') {
return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret); return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret);
@@ -274,7 +277,7 @@ const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
// ─── Listeners de NodeCG ─────────────────────────────────────────────────────── // ─── Listeners de NodeCG ───────────────────────────────────────────────────────
nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => { listenForMessage(messageNames.integrations.startgg.createOAuthSession, async (_payload: unknown, ack) => {
const mode = getOAuthMode(); const mode = getOAuthMode();
let serverConfig: OAuthConfig; let serverConfig: OAuthConfig;
@@ -312,7 +315,7 @@ nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) =>
sendAck(ack, null, oauthServer.createSession(serverConfig)); 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'); const sessionId = getStringProp(payload, 'sessionId');
if (!sessionId) { if (!sessionId) {
sendAck(ack, 'Missing OAuth session id'); sendAck(ack, 'Missing OAuth session id');
@@ -328,7 +331,7 @@ nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => {
sendAck(ack, null, status); 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'); const token = getStringProp(payload, 'token');
if (!token) { if (!token) {
sendAck(ack, 'Missing start.gg API 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 token = getStringProp(payload, 'token');
const slug = getStringProp(payload, 'slug'); const slug = getStringProp(payload, 'slug');
-7
View File
@@ -1,7 +0,0 @@
import type { NodeCGServerAPI } from '../../types/index.js';
export let nodecg!: NodeCGServerAPI;
export function set(ctx: NodeCGServerAPI) {
nodecg = ctx;
}
-31
View File
@@ -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,
});
+2 -10
View File
@@ -1,19 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { useHead } from '@unhead/vue'; import { useHead } from '@unhead/vue';
import { computed } from 'vue'; import { computed } from 'vue';
import { commentaryReplicant } from '../../browser_shared/replicants'; import { useCommentaryReplicatedState } from '../shared/services/replicated-state';
import type { Schemas } from '../../types';
useHead({ title: 'Commentary' }); useHead({ title: 'Commentary' });
const defaultCommentary: Schemas.Commentary = { const { commentary } = useCommentaryReplicatedState();
leftCommentator: '',
leftCommentatorTwitter: '',
rightCommentator: '',
rightCommentatorTwitter: '',
};
const commentary = computed<Schemas.Commentary>(() => commentaryReplicant?.data ?? defaultCommentary);
const leftCommentator = computed(() => commentary.value.leftCommentator || 'COMMENTATOR 1'); const leftCommentator = computed(() => commentary.value.leftCommentator || 'COMMENTATOR 1');
const rightCommentator = computed(() => commentary.value.rightCommentator || 'COMMENTATOR 2'); const rightCommentator = computed(() => commentary.value.rightCommentator || 'COMMENTATOR 2');
+3 -11
View File
@@ -1,21 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { useHead } from '@unhead/vue'; import { useHead } from '@unhead/vue';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants'; import { useScoreboardReplicatedState } from '../shared/services/replicated-state';
import { resolveCountryCode } from '../../shared/countries'; import { resolveCountryCode } from '../../shared/domain/players/countries';
import { getCharactersByGame } from '../../shared/fighting-characters'; import { getCharactersByGame } from '../../shared/fighting-characters';
import type { Schemas } from '../../types';
useHead({ title: 'Scoreboard 2XKO' }); useHead({ title: 'Scoreboard 2XKO' });
const defaultScoreboard: Schemas.Scoreboard = { const { players, scoreboard, scoreboardSkin } = useScoreboardReplicatedState('scoreboard-2xko/main.html');
leftPlayerId: '', rightPlayerId: '', leftNameOverride: '', rightNameOverride: '', leftTeamOverride: '', rightTeamOverride: '',
leftCountryOverride: '', rightCountryOverride: '', leftCharacter: '', rightCharacter: '', leftScore: 0, rightScore: 0, round: '', game: '',
};
const players = computed<Schemas.Players>(() => playersReplicant?.data ?? {});
const scoreboard = computed<Schemas.Scoreboard>(() => scoreboardReplicant?.data ?? defaultScoreboard);
const scoreboardSkin = computed(() => graphicsSettingsReplicant?.data?.scoreboardSkin ?? 'scoreboard-2xko/main.html');
watch( watch(
scoreboardSkin, scoreboardSkin,
+3 -23
View File
@@ -1,32 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { useHead } from '@unhead/vue'; import { useHead } from '@unhead/vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants'; import { useScoreboardReplicatedState } from '../shared/services/replicated-state';
import { resolveCountryCode } from '../../shared/countries'; import { resolveCountryCode } from '../../shared/domain/players/countries';
import type { Schemas } from '../../types';
useHead({ title: 'Scoreboard' }); useHead({ title: 'Scoreboard' });
const defaultScoreboard: Schemas.Scoreboard = { const { players, scoreboard, scoreboardSkin } = useScoreboardReplicatedState('scoreboard/main.html');
leftPlayerId: '',
rightPlayerId: '',
leftNameOverride: '',
rightNameOverride: '',
leftTeamOverride: '',
rightTeamOverride: '',
leftCountryOverride: '',
rightCountryOverride: '',
leftCharacter: '',
rightCharacter: '',
leftScore: 0,
rightScore: 0,
round: '',
game: '',
};
const players = computed<Schemas.Players>(() => playersReplicant?.data ?? {});
const scoreboard = computed<Schemas.Scoreboard>(() => scoreboardReplicant?.data ?? defaultScoreboard);
const scoreboardSkin = computed(() => graphicsSettingsReplicant?.data?.scoreboardSkin ?? 'scoreboard/main.html');
watch( watch(
scoreboardSkin, scoreboardSkin,
@@ -0,0 +1,25 @@
import { computed } from 'vue';
import { commentaryReplicant, graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../../nodecg/browser/replicants';
import { defaultCommentary } from '../../../shared/domain/commentary';
import { defaultScoreboard } from '../../../shared/domain/scoreboard';
import type { Schemas } from '../../../types';
export const useScoreboardReplicatedState = (defaultSkin: string) => {
const players = computed<Schemas.Players>(() => playersReplicant?.data ?? {});
const scoreboard = computed<Schemas.Scoreboard>(() => scoreboardReplicant?.data ?? defaultScoreboard);
const scoreboardSkin = computed(() => graphicsSettingsReplicant?.data?.scoreboardSkin ?? defaultSkin);
return {
players,
scoreboard,
scoreboardSkin,
};
};
export const useCommentaryReplicatedState = () => {
const commentary = computed<Schemas.Commentary>(() => commentaryReplicant?.data ?? defaultCommentary);
return {
commentary,
};
};
+13
View File
@@ -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);
+52
View File
@@ -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),
};
};
+24
View File
@@ -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,
);
+26
View File
@@ -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];
},
});
+26
View File
@@ -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);
};
+36
View File
@@ -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,
},
),
};
};
+32
View File
@@ -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,
});
+28
View File
@@ -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];
+12
View File
@@ -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];
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 527 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 MiB

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

After

Width:  |  Height:  |  Size: 16 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

After

Width:  |  Height:  |  Size: 920 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

After

Width:  |  Height:  |  Size: 744 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

After

Width:  |  Height:  |  Size: 898 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 MiB

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

After

Width:  |  Height:  |  Size: 877 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 MiB

After

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

After

Width:  |  Height:  |  Size: 826 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 775 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

After

Width:  |  Height:  |  Size: 708 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

After

Width:  |  Height:  |  Size: 584 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 MiB

After

Width:  |  Height:  |  Size: 965 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 657 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 687 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 772 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

After

Width:  |  Height:  |  Size: 673 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 MiB

After

Width:  |  Height:  |  Size: 807 KiB

Some files were not shown because too many files have changed in this diff Show More