3 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
89 changed files with 2386 additions and 1195 deletions
+2
View File
@@ -136,6 +136,8 @@ dist
/dashboard/ /dashboard/
/extension/ /extension/
/graphics/ /graphics/
/nodecg/
/shared/domain/
/shared/dist/ /shared/dist/
# Local runtime database # Local runtime database
+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);
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { stripTwitterPrefix } from '../../../shared/domain/commentary';
import { t } from '../i18n'; import { t } from '../i18n';
import { useCommentaryStore } from '../stores/commentary'; import { useCommentaryStore } from '../../stores/commentary';
const commentaryStore = useCommentaryStore(); const commentaryStore = useCommentaryStore();
@@ -17,25 +18,18 @@ const twitterRules = [
!val || TWITTER_VALID_CHARS.test(val) || t('commentaryTwitterInvalidChars'), !val || TWITTER_VALID_CHARS.test(val) || t('commentaryTwitterInvalidChars'),
]; ];
function stripAt(value: string): string {
return value.startsWith('@') ? value.slice(1) : value;
}
function handleLeftTwitterInput(value: string | number | null) { function handleLeftTwitterInput(value: string | number | null) {
commentaryStore.leftCommentatorTwitter = value ? stripAt(String(value)) : ''; commentaryStore.leftCommentatorTwitter = value ? stripTwitterPrefix(String(value)) : '';
} }
function handleRightTwitterInput(value: string | number | null) { function handleRightTwitterInput(value: string | number | null) {
commentaryStore.rightCommentatorTwitter = value ? stripAt(String(value)) : ''; commentaryStore.rightCommentatorTwitter = value ? stripTwitterPrefix(String(value)) : '';
} }
// --- Clear --- // --- Clear ---
function clearAll() { function clearAll() {
commentaryStore.leftCommentator = ''; commentaryStore.clearCommentary();
commentaryStore.leftCommentatorTwitter = '';
commentaryStore.rightCommentator = '';
commentaryStore.rightCommentatorTwitter = '';
} }
const isAnythingFilled = computed(() => const isAnythingFilled = computed(() =>
@@ -6,8 +6,8 @@
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
import { computed, watch } from 'vue'; import { computed, watch } from 'vue';
import { getPackLogoUrl } from '../../../shared/pack-config'; import { getPackLogoUrl } from '../../../shared/domain/packs/config';
import type { PackRegistryEntry } from '../../../shared/pack-types'; import type { PackRegistryEntry } from '../../../shared/domain/packs/types';
import { usePackRegistry } from '../composables/usePackRegistry'; import { usePackRegistry } from '../composables/usePackRegistry';
// ── Props / emits ───────────────────────────────────────────────────────────── // ── Props / emits ─────────────────────────────────────────────────────────────
@@ -3,7 +3,7 @@ import { computed, inject } from 'vue';
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame'; import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
import { usePlayerSide } from '../composables/usePlayerSide'; import { usePlayerSide } from '../composables/usePlayerSide';
import { t } from '../i18n'; import { t } from '../i18n';
import { useScoreboardStore } from '../stores/scoreboard'; import { useScoreboardStore } from '../../stores/scoreboard';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Props // Props
@@ -74,8 +74,7 @@ const character = computed({
? scoreboardStore.scoreboard.leftCharacter ? scoreboardStore.scoreboard.leftCharacter
: scoreboardStore.scoreboard.rightCharacter), : scoreboardStore.scoreboard.rightCharacter),
set: (v) => { set: (v) => {
if (isLeft.value) scoreboardStore.scoreboard.leftCharacter = v; scoreboardStore.setSideCharacter(props.side, v);
else scoreboardStore.scoreboard.rightCharacter = v;
}, },
}); });
@@ -3,7 +3,7 @@ import { inject, onMounted, onUnmounted, ref } from 'vue';
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame'; import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
import { usePackRegistry } from '../composables/usePackRegistry'; import { usePackRegistry } from '../composables/usePackRegistry';
import { t } from '../i18n'; import { t } from '../i18n';
import { useScoreboardStore } from '../stores/scoreboard'; import { useScoreboardStore } from '../../stores/scoreboard';
import GamePackDownloadDialog from './GamePackDownloadDialog.vue'; import GamePackDownloadDialog from './GamePackDownloadDialog.vue';
const scoreboardStore = useScoreboardStore(); const scoreboardStore = useScoreboardStore();
@@ -21,36 +21,32 @@ const {
// Refresca el catálogo al montar y luego cada 15 segundos automáticamente. // Refresca el catálogo al montar y luego cada 15 segundos automáticamente.
// Si Gitea no está disponible se usa la caché persistida del replicante. // Si Gitea no está disponible se usa la caché persistida del replicante.
onMounted(() => { onMounted(() => {
packRegistry.fetchRegistry(); packRegistry.startRegistryRefresh();
}); });
const refreshInterval = setInterval(() => {
packRegistry.fetchRegistry();
}, 15_000);
onUnmounted(() => { onUnmounted(() => {
clearInterval(refreshInterval); packRegistry.stopRegistryRefresh();
}); });
const adjustLeftScore = (delta: number) => { const adjustLeftScore = (delta: number) => {
scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore + delta); scoreboardStore.adjustScore('left', delta);
}; };
const adjustRightScore = (delta: number) => { const adjustRightScore = (delta: number) => {
scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore + delta); scoreboardStore.adjustScore('right', delta);
}; };
/** Tras una descarga exitosa, activa el juego en el store. */ /** Tras una descarga exitosa, activa el juego en el store. */
const onPackDownloaded = (gameName: string) => { const onPackDownloaded = (gameName: string) => {
scoreboardStore.scoreboard.game = gameName; scoreboardStore.setGame(gameName);
}; };
// ── Estado del diálogo de actualización ─────────────────────────────────────── // ── Estado del diálogo de actualización ───────────────────────────────────────
const pendingUpdateEntry = ref<import('../../../shared/pack-types').PackRegistryEntry | null>(null); const pendingUpdateEntry = ref<import('../../../shared/domain/packs/types').PackRegistryEntry | null>(null);
const pendingUpdateInfo = ref<{ installedVersion: string; latestVersion: string } | undefined>(undefined); const pendingUpdateInfo = ref<{ installedVersion: string; latestVersion: string } | undefined>(undefined);
const showUpdateDialog = ref(false); const showUpdateDialog = ref(false);
const openUpdateDialog = (opt: import('../../../shared/pack-types').GameSelectOption, event: Event) => { const openUpdateDialog = (opt: import('../../../shared/domain/packs/types').GameSelectOption, event: Event) => {
event.stopPropagation(); // evitar que el QItem cambie la selección event.stopPropagation(); // evitar que el QItem cambie la selección
pendingUpdateEntry.value = opt.registryEntry; pendingUpdateEntry.value = opt.registryEntry;
pendingUpdateInfo.value = opt.updateInfo; pendingUpdateInfo.value = opt.updateInfo;
@@ -13,14 +13,14 @@
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
import { computed, ref, watch, type InjectionKey, type Ref } from 'vue'; import { computed, ref, watch, type InjectionKey, type Ref } from 'vue';
import { getCharactersByGame, getDefaultCharactersByGame, installedPacksRevision } from '../../../shared/fighting-characters'; import type { FightingCharacterOption } from '../../../shared/domain/packs/characters';
import type { GameSelectOption, PackRegistryEntry } from '../../../shared/pack-types'; import type { GameSelectOption, PackRegistryEntry } from '../../../shared/domain/packs/types';
import { useScoreboardStore } from '../stores/scoreboard'; import { useScoreboardStore } from '../../stores/scoreboard';
import { usePackRegistry } from './usePackRegistry'; import { usePackRegistry } from './usePackRegistry';
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
export type CharacterOption = ReturnType<typeof getCharactersByGame>[number]; export type CharacterOption = FightingCharacterOption;
export type CharacterGameContext = ReturnType<typeof useCharacterGame>; export type CharacterGameContext = ReturnType<typeof useCharacterGame>;
export const CHARACTER_GAME_KEY: InjectionKey<CharacterGameContext> = Symbol('characterGame'); export const CHARACTER_GAME_KEY: InjectionKey<CharacterGameContext> = Symbol('characterGame');
@@ -62,7 +62,7 @@ export function useCharacterGame() {
*/ */
const handleGameSelect = (gameName: string) => { const handleGameSelect = (gameName: string) => {
if (!gameName) { if (!gameName) {
scoreboardStore.scoreboard.game = ''; scoreboardStore.setGame('');
return; return;
} }
if (!packRegistry.isGameAvailable(gameName)) { if (!packRegistry.isGameAvailable(gameName)) {
@@ -72,17 +72,13 @@ export function useCharacterGame() {
// Do NOT update the store — the game isn't installed // Do NOT update the store — the game isn't installed
return; return;
} }
scoreboardStore.scoreboard.game = gameName; scoreboardStore.setGame(gameName);
}; };
// ── Character state ─────────────────────────────────────────────────────── // ── Character state ───────────────────────────────────────────────────────
const characterOptions = computed(() => { const characterOptions = computed(() => {
// Subscribing to installedPacksRevision forces Vue to re-evaluate this return packRegistry.getCharactersByGame(scoreboardStore.scoreboard.game);
// computed whenever a pack is registered/unregistered at runtime, even
// though scoreboardStore.scoreboard.game itself hasn't changed.
void installedPacksRevision.value;
return getCharactersByGame(scoreboardStore.scoreboard.game);
}); });
const leftCharacterOptions = ref<CharacterOption[]>([]); const leftCharacterOptions = ref<CharacterOption[]>([]);
const rightCharacterOptions = ref<CharacterOption[]>([]); const rightCharacterOptions = ref<CharacterOption[]>([]);
@@ -155,11 +151,11 @@ export function useCharacterGame() {
}; };
} }
const options = getCharactersByGame(newGame); const options = packRegistry.getCharactersByGame(newGame);
// If the game is set but has no options yet, the pack is still loading // If the game is set but has no options yet, the pack is still loading
// (installed pack whose registerInstalledPack() hasn't run yet). // (installed pack whose manifest has not been loaded into the pack store yet).
// Bail out — the installedPacksRevision watcher below will restore state // Bail out — the characterOptions watcher below will restore state
// once the pack becomes available. // once the pack becomes available.
if (newGame && options.length === 0) return; if (newGame && options.length === 0) return;
@@ -176,7 +172,7 @@ export function useCharacterGame() {
if (!allowed.has(nextRight)) nextRight = ''; if (!allowed.has(nextRight)) nextRight = '';
if ((!nextLeft || !nextRight) && (!curLeft || !curRight)) { if ((!nextLeft || !nextRight) && (!curLeft || !curRight)) {
const defaults = getDefaultCharactersByGame(newGame); const defaults = packRegistry.getDefaultCharactersByGame(newGame);
if (defaults) { if (defaults) {
if (!nextLeft) nextLeft = allowed.has(defaults.leftCharacter) ? defaults.leftCharacter : ''; if (!nextLeft) nextLeft = allowed.has(defaults.leftCharacter) ? defaults.leftCharacter : '';
if (!nextRight) nextRight = allowed.has(defaults.rightCharacter) ? defaults.rightCharacter : ''; if (!nextRight) nextRight = allowed.has(defaults.rightCharacter) ? defaults.rightCharacter : '';
@@ -184,16 +180,16 @@ export function useCharacterGame() {
} }
if (allowed.has(nextLeft)) { if (allowed.has(nextLeft)) {
scoreboardStore.scoreboard.leftCharacter = nextLeft; scoreboardStore.setSideCharacter('left', nextLeft);
} else if (!allowed.has(scoreboardStore.scoreboard.leftCharacter)) { } else if (!allowed.has(scoreboardStore.scoreboard.leftCharacter)) {
scoreboardStore.scoreboard.leftCharacter = ''; scoreboardStore.setSideCharacter('left', '');
leftCharacterInput.value = ''; leftCharacterInput.value = '';
} }
if (allowed.has(nextRight)) { if (allowed.has(nextRight)) {
scoreboardStore.scoreboard.rightCharacter = nextRight; scoreboardStore.setSideCharacter('right', nextRight);
} else if (!allowed.has(scoreboardStore.scoreboard.rightCharacter)) { } else if (!allowed.has(scoreboardStore.scoreboard.rightCharacter)) {
scoreboardStore.scoreboard.rightCharacter = ''; scoreboardStore.setSideCharacter('right', '');
rightCharacterInput.value = ''; rightCharacterInput.value = '';
} }
}, },
@@ -232,14 +228,12 @@ export function useCharacterGame() {
{ immediate: true }, { immediate: true },
); );
// When an installed pack becomes available (e.g. after page refresh while // When an installed pack manifest becomes available, re-validate characters
// the pack loads asynchronously), re-validate and restore the characters // already present in the replicated scoreboard state.
// that are already in the store but couldn't be confirmed before. watch(characterOptions, (options) => {
watch(installedPacksRevision, () => {
const game = scoreboardStore.scoreboard.game; const game = scoreboardStore.scoreboard.game;
if (!game) return; if (!game) return;
const options = getCharactersByGame(game);
if (options.length === 0) return; if (options.length === 0) return;
const allowed = new Set(options.map((o) => o.value)); const allowed = new Set(options.map((o) => o.value));
@@ -251,14 +245,14 @@ export function useCharacterGame() {
if (leftCharacter && allowed.has(leftCharacter)) { if (leftCharacter && allowed.has(leftCharacter)) {
leftCharacterInput.value = options.find((o) => o.value === leftCharacter)?.label ?? ''; leftCharacterInput.value = options.find((o) => o.value === leftCharacter)?.label ?? '';
} else if (leftCharacter && !allowed.has(leftCharacter)) { } else if (leftCharacter && !allowed.has(leftCharacter)) {
scoreboardStore.scoreboard.leftCharacter = ''; scoreboardStore.setSideCharacter('left', '');
leftCharacterInput.value = ''; leftCharacterInput.value = '';
} }
if (rightCharacter && allowed.has(rightCharacter)) { if (rightCharacter && allowed.has(rightCharacter)) {
rightCharacterInput.value = options.find((o) => o.value === rightCharacter)?.label ?? ''; rightCharacterInput.value = options.find((o) => o.value === rightCharacter)?.label ?? '';
} else if (rightCharacter && !allowed.has(rightCharacter)) { } else if (rightCharacter && !allowed.has(rightCharacter)) {
scoreboardStore.scoreboard.rightCharacter = ''; scoreboardStore.setSideCharacter('right', '');
rightCharacterInput.value = ''; rightCharacterInput.value = '';
} }
}); });
@@ -1,5 +1,5 @@
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { getCountryLabel, getCountryOptions } from '../../../shared/countries'; import { getCountryLabel, getCountryOptions } from '../../../shared/domain/players/countries';
import { locale } from '../i18n'; import { locale } from '../i18n';
/** /**
@@ -1,4 +1,5 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { sendIntegrationMessage } from '../../services/integration-message-service';
// ─── Tipos ───────────────────────────────────────────────────────────────────── // ─── Tipos ─────────────────────────────────────────────────────────────────────
@@ -65,19 +66,6 @@ export interface UseIntegrationOptions {
playersStore: PlayersStore; playersStore: PlayersStore;
} }
// ─── Utilidad para mensajes NodeCG ─────────────────────────────────────────────
const sendNodeCGMessage = <T>(messageName: string, payload: unknown): Promise<T> =>
new Promise((resolve, reject) => {
nodecg.sendMessage(messageName, payload, (error: unknown, response: unknown) => {
if (error) {
reject(new Error(String(error)));
return;
}
resolve(response as T);
});
});
// ─── Composable ──────────────────────────────────────────────────────────────── // ─── Composable ────────────────────────────────────────────────────────────────
export function useIntegration(options: UseIntegrationOptions) { export function useIntegration(options: UseIntegrationOptions) {
@@ -165,8 +153,9 @@ export function useIntegration(options: UseIntegrationOptions) {
tournamentsError.value = ''; tournamentsError.value = '';
loadingTournaments.value = true; loadingTournaments.value = true;
try { try {
const tournaments = await sendNodeCGMessage<IntegrationTournament[]>( const tournaments = await sendIntegrationMessage<IntegrationTournament[]>(
`${messagePrefix}:fetchRecentTournaments`, messagePrefix,
'fetchRecentTournaments',
{ token: currentToken }, { token: currentToken },
); );
hasValidatedToken.value = true; hasValidatedToken.value = true;
@@ -204,8 +193,9 @@ export function useIntegration(options: UseIntegrationOptions) {
players.value = []; players.value = [];
try { try {
const importedPlayers = await sendNodeCGMessage<IntegrationPlayer[]>( const importedPlayers = await sendIntegrationMessage<IntegrationPlayer[]>(
`${messagePrefix}:fetchTournamentPlayers`, messagePrefix,
'fetchTournamentPlayers',
{ token: token.value.trim(), slug: tournament.slug }, { token: token.value.trim(), slug: tournament.slug },
); );
players.value = importedPlayers; players.value = importedPlayers;
@@ -325,8 +315,9 @@ export function useIntegration(options: UseIntegrationOptions) {
if (!oauthSessionId.value) return; if (!oauthSessionId.value) return;
try { try {
const status = await sendNodeCGMessage<OAuthStatusResponse>( const status = await sendIntegrationMessage<OAuthStatusResponse>(
`${messagePrefix}:getOAuthSessionStatus`, messagePrefix,
'getOAuthSessionStatus',
{ sessionId: oauthSessionId.value }, { sessionId: oauthSessionId.value },
); );
@@ -362,8 +353,9 @@ export function useIntegration(options: UseIntegrationOptions) {
stopPolling(); stopPolling();
try { try {
const session = await sendNodeCGMessage<OAuthSessionResponse>( const session = await sendIntegrationMessage<OAuthSessionResponse>(
`${messagePrefix}:createOAuthSession`, messagePrefix,
'createOAuthSession',
{}, {},
); );
oauthSessionId.value = session.sessionId; oauthSessionId.value = session.sessionId;
@@ -1,266 +1,68 @@
// src/dashboard/scoreboard/composables/usePackRegistry.ts import { storeToRefs } from 'pinia';
// ───────────────────────────────────────────────────────────────────────────── import type { InjectionKey, Ref } from 'vue';
// Singleton composable. The first caller sets up NodeCG replicant listeners; import { usePacksStore } from '../../stores/packs';
// subsequent calls return the same reactive state. This avoids duplicate event import type { DefaultCharacterPair, FightingCharacterOption } from '../../../shared/domain/packs/characters';
// listeners when multiple components call usePackRegistry().
// ─────────────────────────────────────────────────────────────────────────────
import { computed, ref, type ComputedRef, type InjectionKey } from 'vue';
import {
registerInstalledPack,
unregisterInstalledPack,
} from '../../../shared/fighting-characters';
import { BUNDLE_NAME } from '../../../shared/pack-config';
import type { import type {
GameSelectOption, GameSelectOption,
PackDownloadState, PackDownloadState,
PackManifest, PackRegistry,
PackRegistry PackUpdateInfo,
} from '../../../shared/pack-types'; } from '../../../shared/domain/packs/types';
// ── NodeCG global type declarations ──────────────────────────────────────────
// NodeCG injects these into the browser window via its bundle script.
declare const NodeCG: {
Replicant: <T>(
name: string,
bundleName: string,
opts?: { defaultValue?: T },
) => {
value: T;
on(event: 'change', handler: (newVal: T, oldVal?: T) => void): void;
off(event: string, handler: (...args: unknown[]) => void): void;
};
waitForReplicants: (...reps: unknown[]) => Promise<void>;
};
declare const nodecg: {
sendMessage(name: string, data?: unknown): void;
sendMessage(
name: string,
data: unknown,
cb: (err: Error | null, result?: unknown) => void,
): void;
};
// ── Module-level singleton state ──────────────────────────────────────────────
let initialized = false;
const registry = ref<PackRegistry | null>(null);
const installedPackIds = ref<string[]>([]);
const downloadStates = ref<Record<string, PackDownloadState>>({});
const availableUpdates = ref<Record<string, { installedVersion: string; latestVersion: string }>>({});
// Tracks which installed pack manifests have been loaded into fighting-characters.ts
const loadedManifestIds = new Set<string>();
// ── Helpers ───────────────────────────────────────────────────────────────────
const formatBytes = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
/**
* Asks the NodeCG extension to read the local manifest.json for an installed
* pack and registers the characters in fighting-characters.ts.
*/
const loadInstalledManifest = (packId: string): void => {
if (loadedManifestIds.has(packId)) return;
nodecg.sendMessage('readLocalManifest', packId, (err, result) => {
if (err) {
console.error(`[usePackRegistry] Failed to load manifest for "${packId}":`, err);
return;
}
const manifest = result as PackManifest;
registerInstalledPack(manifest);
loadedManifestIds.add(packId);
});
};
// ── Replicant setup (runs once) ───────────────────────────────────────────────
const initReplicants = (): void => {
if (initialized) return;
initialized = true;
const registryRep = NodeCG.Replicant<PackRegistry | null>('packRegistry', BUNDLE_NAME, {
defaultValue: null,
});
const installedRep = NodeCG.Replicant<string[]>('installedPacks', BUNDLE_NAME, {
defaultValue: [],
});
const statesRep = NodeCG.Replicant<Record<string, PackDownloadState>>('downloadStates', BUNDLE_NAME, {
defaultValue: {},
});
const updatesRep = NodeCG.Replicant<Record<string, { installedVersion: string; latestVersion: string }>>('availableUpdates', BUNDLE_NAME, {
defaultValue: {},
});
NodeCG.waitForReplicants(registryRep, installedRep, statesRep, updatesRep).then(() => {
// Hydrate initial values
registry.value = registryRep.value;
installedPackIds.value = installedRep.value ?? [];
downloadStates.value = statesRep.value ?? {};
availableUpdates.value = updatesRep.value ?? {};
// Load manifests for all installed packs
for (const id of installedPackIds.value) {
loadInstalledManifest(id);
}
// Subscribe to changes
registryRep.on('change', (val) => {
registry.value = val;
});
installedRep.on('change', (newVal, oldVal) => {
const next = newVal ?? [];
const prev = oldVal ?? [];
installedPackIds.value = next;
// Load manifests for newly installed packs
const added = next.filter((id) => !prev.includes(id));
for (const id of added) {
loadInstalledManifest(id);
}
// Unregister packs that were removed
const removed = prev.filter((id) => !next.includes(id));
for (const id of removed) {
const gameName = getGameNameById(id);
unregisterInstalledPack(gameName);
loadedManifestIds.delete(id);
}
});
statesRep.on('change', (val) => {
downloadStates.value = val ?? {};
});
updatesRep.on('change', (val) => {
availableUpdates.value = val ?? {};
});
});
};
/**
* Given a pack ID (e.g. "street-fighter-6"), returns the matching game name
* from the current registry, or an empty string if the registry isn't loaded.
*/
const getGameNameById = (packId: string): string =>
registry.value?.packs.find((p) => p.id === packId)?.name ?? '';
// ── Public composable ─────────────────────────────────────────────────────────
export interface PackRegistryContext { export interface PackRegistryContext {
/** Full registry fetched from Gitea (null until first fetch). */ registry: Ref<PackRegistry | null>;
registry: typeof registry; installedPackIds: Ref<string[]>;
/** IDs of packs installed on disk (bundled packs are NOT in this list). */ downloadStates: Ref<Record<string, PackDownloadState>>;
installedPackIds: typeof installedPackIds;
/** Per-pack download state. */
downloadStates: typeof downloadStates;
/** Checks if a game is available (bundled OR installed). */
isGameAvailable: (gameName: string) => boolean; isGameAvailable: (gameName: string) => boolean;
/** Returns the download state for a pack, or a default idle state. */
getDownloadState: (packId: string) => PackDownloadState; getDownloadState: (packId: string) => PackDownloadState;
/** All games from the registry, enriched with availability info. */ getCharactersByGame: (gameName: string) => FightingCharacterOption[];
allGameOptions: ReturnType<typeof buildAllGameOptions>; getDefaultCharactersByGame: (gameName: string) => DefaultCharacterPair | undefined;
/** Tells the extension to fetch the latest registry.json from Gitea. */ allGameOptions: Ref<GameSelectOption[]>;
fetchRegistry: () => void; fetchRegistry: () => void;
/** Tells the extension to download and install a pack. */ startRegistryRefresh: (intervalMs?: number) => void;
stopRegistryRefresh: () => void;
downloadPack: (packId: string) => void; downloadPack: (packId: string) => void;
/** Tells the extension to uninstall a pack and delete its files. */
uninstallPack: (packId: string) => void; uninstallPack: (packId: string) => void;
/** Tells the extension to download and apply an update for an installed pack. */
updatePack: (packId: string) => void; updatePack: (packId: string) => void;
/** Map of packId → version info for packs that have a newer version available. */ availableUpdates: Ref<Record<string, PackUpdateInfo>>;
availableUpdates: typeof availableUpdates; updateCount: Ref<number>;
/** Total number of packs with available updates. */ formatBytes: (bytes: number) => string;
updateCount: ComputedRef<number>;
/** Human-readable file size. */
formatBytes: typeof formatBytes;
/** Returns the URL for the pack's logo served by NodeCG (installed packs only). */
getLocalLogoUrl: (packId: string) => string; getLocalLogoUrl: (packId: string) => string;
} }
export const PACK_REGISTRY_KEY: InjectionKey<PackRegistryContext> = Symbol('packRegistry'); export const PACK_REGISTRY_KEY: InjectionKey<PackRegistryContext> = Symbol('packRegistry');
const buildAllGameOptions = () =>
computed<GameSelectOption[]>(() => {
// Registry not loaded yet — return empty list
if (!registry.value) return [];
return registry.value.packs.map((entry) => ({
label: entry.name,
value: entry.name,
available: installedPackIds.value.includes(entry.id),
registryEntry: entry,
updateInfo: availableUpdates.value[entry.id],
}));
});
export function usePackRegistry(): PackRegistryContext { export function usePackRegistry(): PackRegistryContext {
initReplicants(); const packsStore = usePacksStore();
packsStore.initialize();
const allGameOptions = buildAllGameOptions(); const {
registry,
const isGameAvailable = (gameName: string): boolean => { installedPackIds,
const entry = registry.value?.packs.find((p) => p.name === gameName); downloadStates,
if (!entry) return false; availableUpdates,
return installedPackIds.value.includes(entry.id); allGameOptions,
}; updateCount,
} = storeToRefs(packsStore);
const getDownloadState = (packId: string): PackDownloadState =>
downloadStates.value[packId] ?? { status: 'idle', progress: 0 };
const getLocalLogoUrl = (packId: string): string =>
`/packs/${packId}/logo.png`;
const fetchRegistry = (): void => {
nodecg.sendMessage('fetchPackRegistry', undefined, (err) => {
if (err) console.error('[usePackRegistry] fetchPackRegistry failed:', err);
});
};
const downloadPack = (packId: string): void => {
nodecg.sendMessage('downloadPack', packId, (err) => {
if (err) console.error(`[usePackRegistry] downloadPack "${packId}" failed:`, err);
});
};
const uninstallPack = (packId: string): void => {
nodecg.sendMessage('uninstallPack', packId, (err) => {
if (err) console.error(`[usePackRegistry] uninstallPack "${packId}" failed:`, err);
});
};
const updatePack = (packId: string): void => {
nodecg.sendMessage('updatePack', packId, (err) => {
if (err) console.error(`[usePackRegistry] updatePack "${packId}" failed:`, err);
});
};
const updateCount = computed(() => Object.keys(availableUpdates.value).length);
return { return {
registry, registry,
installedPackIds, installedPackIds,
downloadStates, downloadStates,
isGameAvailable, isGameAvailable: packsStore.isGameAvailable,
getDownloadState, getDownloadState: packsStore.getDownloadState,
getCharactersByGame: packsStore.getCharactersByGame,
getDefaultCharactersByGame: packsStore.getDefaultCharactersByGame,
allGameOptions, allGameOptions,
fetchRegistry, fetchRegistry: packsStore.fetchRegistry,
downloadPack, startRegistryRefresh: packsStore.startRegistryRefresh,
uninstallPack, stopRegistryRefresh: packsStore.stopRegistryRefresh,
updatePack, downloadPack: packsStore.downloadPack,
uninstallPack: packsStore.uninstallPack,
updatePack: packsStore.updatePack,
availableUpdates, availableUpdates,
updateCount, updateCount,
formatBytes, formatBytes: packsStore.formatBytes,
getLocalLogoUrl, getLocalLogoUrl: packsStore.getLocalLogoUrl,
}; };
} }
@@ -1,7 +1,8 @@
import { computed, ref, watch, watchEffect } from 'vue'; import { computed, ref, watch, watchEffect } from 'vue';
import { useScoreboardStore } from '../stores/scoreboard'; import { useScoreboardStore } from '../../stores/scoreboard';
import { usePlayersStore } from '../stores/players'; import { usePlayersStore } from '../../stores/players';
import type { Schemas } from '../../../types'; import type { Schemas } from '../../../types';
import { createPlayerId, normalizePlayerName } from '../../../shared/domain/players/state';
import { t } from '../i18n'; import { t } from '../i18n';
import { useCountryFilter } from './useCountryFilter'; import { useCountryFilter } from './useCountryFilter';
@@ -16,34 +17,6 @@ export const CUSTOM_RIGHT_PLAYER_ID = '__custom_right_player__';
// Pure helpers (no Vue reactivity) // Pure helpers (no Vue reactivity)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const normalizeName = (value: string) => value.trim().toLowerCase();
/**
* Generates a unique slug-based player ID that does not collide with
* existing player keys in the store.
*/
const createPlayerId = (name: string, players: Schemas.Players): string => {
const base = name
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[^\w\s-]/g, '')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '-') || 'player';
let index = 1;
let candidate = base;
while (players[candidate]) {
index += 1;
candidate = `${base}-${index}`;
}
return candidate;
};
// ---------------------------------------------------------------------------
// Composable
// ---------------------------------------------------------------------------
/** /**
* Encapsulates all reactive state and handlers for one side of the scoreboard * Encapsulates all reactive state and handlers for one side of the scoreboard
* (left or right). Call once per side inside the corresponding component. * (left or right). Call once per side inside the corresponding component.
@@ -63,32 +36,28 @@ export function usePlayerSide(side: 'left' | 'right') {
const playerId = computed({ const playerId = computed({
get: () => (isLeft ? scoreboardStore.scoreboard.leftPlayerId : scoreboardStore.scoreboard.rightPlayerId), get: () => (isLeft ? scoreboardStore.scoreboard.leftPlayerId : scoreboardStore.scoreboard.rightPlayerId),
set: (v) => { set: (v) => {
if (isLeft) scoreboardStore.scoreboard.leftPlayerId = v; scoreboardStore.setSidePlayerId(side, v);
else scoreboardStore.scoreboard.rightPlayerId = v;
}, },
}); });
const nameOverride = computed({ const nameOverride = computed({
get: () => (isLeft ? scoreboardStore.scoreboard.leftNameOverride : scoreboardStore.scoreboard.rightNameOverride), get: () => (isLeft ? scoreboardStore.scoreboard.leftNameOverride : scoreboardStore.scoreboard.rightNameOverride),
set: (v) => { set: (v) => {
if (isLeft) scoreboardStore.scoreboard.leftNameOverride = v; scoreboardStore.setSideNameOverride(side, v);
else scoreboardStore.scoreboard.rightNameOverride = v;
}, },
}); });
const teamOverride = computed({ const teamOverride = computed({
get: () => (isLeft ? scoreboardStore.scoreboard.leftTeamOverride : scoreboardStore.scoreboard.rightTeamOverride), get: () => (isLeft ? scoreboardStore.scoreboard.leftTeamOverride : scoreboardStore.scoreboard.rightTeamOverride),
set: (v) => { set: (v) => {
if (isLeft) scoreboardStore.scoreboard.leftTeamOverride = v; scoreboardStore.setSideTeamOverride(side, v);
else scoreboardStore.scoreboard.rightTeamOverride = v;
}, },
}); });
const countryOverride = computed({ const countryOverride = computed({
get: () => (isLeft ? scoreboardStore.scoreboard.leftCountryOverride : scoreboardStore.scoreboard.rightCountryOverride), get: () => (isLeft ? scoreboardStore.scoreboard.leftCountryOverride : scoreboardStore.scoreboard.rightCountryOverride),
set: (v) => { set: (v) => {
if (isLeft) scoreboardStore.scoreboard.leftCountryOverride = v; scoreboardStore.setSideCountryOverride(side, v);
else scoreboardStore.scoreboard.rightCountryOverride = v;
}, },
}); });
@@ -145,10 +114,10 @@ export function usePlayerSide(side: 'left' | 'right') {
}; };
const playerExistsByGamertag = (name: string): boolean => { const playerExistsByGamertag = (name: string): boolean => {
const normalized = normalizeName(name); const normalized = normalizePlayerName(name);
return Boolean(normalized) return Boolean(normalized)
&& Object.values(playersStore.players).some( && Object.values(playersStore.players).some(
(p) => normalizeName(p.gamertag || '') === normalized, (p) => normalizePlayerName(p.gamertag || '') === normalized,
); );
}; };
+2 -2
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';
@@ -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,
};
});
+6 -8
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 },
); );
+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' };
+3 -4
View File
@@ -1,14 +1,13 @@
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'); await import('./pack-manager.js');
+9
View File
@@ -0,0 +1,9 @@
import {
commentaryReplicant,
playersReplicant,
scoreboardReplicant,
} from '../../nodecg/extension/replicants.js';
playersReplicant();
scoreboardReplicant();
commentaryReplicant();
+20 -71
View File
@@ -12,7 +12,15 @@ import * as fs from 'fs';
import type { IncomingMessage, ServerResponse } from 'http'; import type { IncomingMessage, ServerResponse } from 'http';
import * as path from 'path'; import * as path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { nodecg } from './util/nodecg.js'; import { nodecg } from '../nodecg/extension/context.js';
import { listenForMessage, reply, type Acknowledgement } from '../nodecg/extension/messages.js';
import { createPackExtensionReplicants } from '../nodecg/extension/packReplicants.js';
import { messageNames } from '../nodecg/messageNames.js';
import type {
PackDownloadState,
PackManifest,
PackRegistry,
} from '../shared/domain/packs/types.js';
// ── Configuración de Gitea ──────────────────────────────────────────────────── // ── Configuración de Gitea ────────────────────────────────────────────────────
// Edita estas constantes para apuntar a tu instancia. // Edita estas constantes para apuntar a tu instancia.
@@ -33,54 +41,9 @@ const getCharacterImageRepoUrl = (id: string, slug: string, ext: string) =>
// ── Tipos locales ───────────────────────────────────────────────────────────── // ── Tipos locales ─────────────────────────────────────────────────────────────
interface PackCharacter {
name: string;
slug: string;
dlc?: boolean;
sizeBytes: number;
}
interface PackManifest {
id: string;
name: string;
version: string;
palette: { start: string; end: string };
defaultPair?: { left: string; right: string };
characters: PackCharacter[];
}
interface PackRegistry {
schemaVersion: number;
updatedAt: string;
packs: Array<{
id: string;
name: string;
version: string;
totalSizeBytes: number;
logoPath: string;
characterCount: number;
palette: { start: string; end: string };
bundled: boolean;
}>;
}
interface PackDownloadState {
status: 'idle' | 'fetching-manifest' | 'downloading' | 'done' | 'error';
progress: number;
error?: string;
}
// Replicamos la forma exacta del tipo Acknowledgement de NodeCG sin necesidad // Replicamos la forma exacta del tipo Acknowledgement de NodeCG sin necesidad
// de importar @nodecg/types. HandledAcknowledgement NO es callable (es un objeto), // de importar @nodecg/types. HandledAcknowledgement NO es callable (es un objeto),
// UnhandledAcknowledgement SÍ lo es. El helper reply() comprueba cuál es antes de llamar. // UnhandledAcknowledgement SÍ lo es. El helper reply() comprueba cuál es antes de llamar.
type HandledAcknowledgement = { handled: true };
type UnhandledAcknowledgement = ((error?: Error | null, ...args: unknown[]) => void) & { handled: false };
type Acknowledgement = HandledAcknowledgement | UnhandledAcknowledgement;
const reply = (ack: Acknowledgement | undefined, err: Error | null, result?: unknown): void => {
if (ack && !ack.handled) ack(err ?? undefined, result);
};
// ── Constantes ──────────────────────────────────────────────────────────────── // ── Constantes ────────────────────────────────────────────────────────────────
const IMAGE_EXTENSIONS = ['png', 'webp', 'jpg', 'jpeg', 'avif'] as const; const IMAGE_EXTENSIONS = ['png', 'webp', 'jpg', 'jpeg', 'avif'] as const;
@@ -93,26 +56,12 @@ const bundleDir = fileURLToPath(new URL('../', import.meta.url));
// ── Replicants ──────────────────────────────────────────────────────────────── // ── Replicants ────────────────────────────────────────────────────────────────
const installedPacksRep = nodecg.Replicant<string[]>('installedPacks', { const {
defaultValue: [], installedPacksRep,
persistent: true, packRegistryRep,
}); downloadStatesRep,
availableUpdatesRep,
const packRegistryRep = nodecg.Replicant<PackRegistry | null>('packRegistry', { } = createPackExtensionReplicants();
defaultValue: null,
persistent: true,
});
const downloadStatesRep = nodecg.Replicant<Record<string, PackDownloadState>>('downloadStates', {
defaultValue: {},
persistent: false,
});
/** Packs instalados para los que hay una versión más nueva en el registro. */
const availableUpdatesRep = nodecg.Replicant<Record<string, { installedVersion: string; latestVersion: string }>>('availableUpdates', {
defaultValue: {},
persistent: false,
});
// ── Filesystem ──────────────────────────────────────────────────────────────── // ── Filesystem ────────────────────────────────────────────────────────────────
@@ -249,7 +198,7 @@ checkForUpdates();
// ── Mensaje: fetchPackRegistry ──────────────────────────────────────────────── // ── Mensaje: fetchPackRegistry ────────────────────────────────────────────────
nodecg.listenFor('fetchPackRegistry', async (_data: unknown, ack: Acknowledgement | undefined) => { listenForMessage(messageNames.packs.fetchRegistry, async (_data: unknown, ack: Acknowledgement | undefined) => {
try { try {
const response = await fetch(REGISTRY_URL); const response = await fetch(REGISTRY_URL);
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -266,7 +215,7 @@ nodecg.listenFor('fetchPackRegistry', async (_data: unknown, ack: Acknowledgemen
// ── Mensaje: downloadPack ───────────────────────────────────────────────────── // ── Mensaje: downloadPack ─────────────────────────────────────────────────────
nodecg.listenFor('downloadPack', async (packId: unknown, ack: Acknowledgement | undefined) => { listenForMessage(messageNames.packs.download, async (packId: unknown, ack: Acknowledgement | undefined) => {
if (typeof packId !== 'string' || !packId) { if (typeof packId !== 'string' || !packId) {
return reply(ack, new Error('downloadPack requiere un packId no vacío.')); return reply(ack, new Error('downloadPack requiere un packId no vacío.'));
} }
@@ -327,7 +276,7 @@ nodecg.listenFor('downloadPack', async (packId: unknown, ack: Acknowledgement |
// ── Mensaje: uninstallPack ──────────────────────────────────────────────────── // ── Mensaje: uninstallPack ────────────────────────────────────────────────────
nodecg.listenFor('uninstallPack', (packId: unknown, ack: Acknowledgement | undefined) => { listenForMessage(messageNames.packs.uninstall, (packId: unknown, ack: Acknowledgement | undefined) => {
if (typeof packId !== 'string' || !packId) { if (typeof packId !== 'string' || !packId) {
return reply(ack, new Error('uninstallPack requiere un packId no vacío.')); return reply(ack, new Error('uninstallPack requiere un packId no vacío.'));
} }
@@ -352,7 +301,7 @@ nodecg.listenFor('uninstallPack', (packId: unknown, ack: Acknowledgement | undef
// Dashboard → Extension: "Actualiza el pack <packId> a la última versión." // Dashboard → Extension: "Actualiza el pack <packId> a la última versión."
// Borra las imágenes antiguas y descarga las nuevas desde Gitea. // Borra las imágenes antiguas y descarga las nuevas desde Gitea.
nodecg.listenFor('updatePack', async (packId: unknown, ack: Acknowledgement | undefined) => { listenForMessage(messageNames.packs.update, async (packId: unknown, ack: Acknowledgement | undefined) => {
if (typeof packId !== 'string' || !packId) { if (typeof packId !== 'string' || !packId) {
return reply(ack, new Error('updatePack requiere un packId no vacío.')); return reply(ack, new Error('updatePack requiere un packId no vacío.'));
} }
@@ -426,7 +375,7 @@ nodecg.listenFor('updatePack', async (packId: unknown, ack: Acknowledgement | un
// ── Mensaje: readLocalManifest ──────────────────────────────────────────────── // ── Mensaje: readLocalManifest ────────────────────────────────────────────────
nodecg.listenFor('readLocalManifest', (packId: unknown, ack: Acknowledgement | undefined) => { listenForMessage(messageNames.packs.readLocalManifest, (packId: unknown, ack: Acknowledgement | undefined) => {
if (typeof packId !== 'string' || !packId) { if (typeof packId !== 'string' || !packId) {
return reply(ack, new Error('readLocalManifest requiere un packId no vacío.')); return reply(ack, new Error('readLocalManifest requiere un packId no vacío.'));
} }
+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];
+1
View File
@@ -0,0 +1 @@
export * from './state';
+32
View File
@@ -0,0 +1,32 @@
import type { Schemas } from '../../../types';
export type Commentary = Schemas.Commentary;
export const defaultCommentary: Commentary = {
leftCommentator: '',
leftCommentatorTwitter: '',
rightCommentator: '',
rightCommentatorTwitter: '',
};
const toString = (value: unknown): string => (typeof value === 'string' ? value : '');
export const normalizeCommentary = (input: unknown): Commentary => {
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
return {
leftCommentator: toString(candidate.leftCommentator),
leftCommentatorTwitter: toString(candidate.leftCommentatorTwitter),
rightCommentator: toString(candidate.rightCommentator),
rightCommentatorTwitter: toString(candidate.rightCommentatorTwitter),
};
};
export const swapCommentary = (commentary: Commentary): Commentary => ({
leftCommentator: commentary.rightCommentator,
leftCommentatorTwitter: commentary.rightCommentatorTwitter,
rightCommentator: commentary.leftCommentator,
rightCommentatorTwitter: commentary.leftCommentatorTwitter,
});
export const stripTwitterPrefix = (value: string): string =>
value.startsWith('@') ? value.slice(1) : value;
+1
View File
@@ -0,0 +1 @@
export * from './state';
+16
View File
@@ -0,0 +1,16 @@
import type { Schemas } from '../../../types';
export type GraphicsSettings = Schemas.GraphicsSettings;
export const defaultGraphicsSettings: GraphicsSettings = {
scoreboardSkin: 'scoreboard/main.html',
};
export const normalizeGraphicsSettings = (input: unknown): GraphicsSettings => {
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
return {
scoreboardSkin: typeof candidate.scoreboardSkin === 'string'
? candidate.scoreboardSkin
: defaultGraphicsSettings.scoreboardSkin,
};
};
+48
View File
@@ -0,0 +1,48 @@
import type { PackManifest } from './types';
export interface FightingCharacterOption {
label: string;
value: string;
image: string;
dlc?: boolean;
}
export interface DefaultCharacterPair {
leftCharacter: string;
rightCharacter: string;
}
export const BUNDLED_GAME_NAMES = new Set<string>();
export const buildCharactersForManifest = (manifest: PackManifest): FightingCharacterOption[] =>
manifest.characters.map((character) => ({
label: character.name,
value: character.slug,
image: `/packs/${manifest.id}/characters/${character.slug}.png`,
dlc: character.dlc ?? false,
}));
export const buildCharactersByGame = (
manifests: readonly PackManifest[],
): Record<string, FightingCharacterOption[]> => {
const charactersByGame: Record<string, FightingCharacterOption[]> = {};
manifests.forEach((manifest) => {
charactersByGame[manifest.name] = buildCharactersForManifest(manifest);
});
return charactersByGame;
};
export const buildDefaultCharactersByGame = (
manifests: readonly PackManifest[],
): Record<string, DefaultCharacterPair> => {
const defaultsByGame: Record<string, DefaultCharacterPair> = {};
manifests.forEach((manifest) => {
if (manifest.defaultPair) {
defaultsByGame[manifest.name] = {
leftCharacter: manifest.defaultPair.left,
rightCharacter: manifest.defaultPair.right,
};
}
});
return defaultsByGame;
};
+25
View File
@@ -0,0 +1,25 @@
export const GITEA_BASE_URL = 'http://10.0.0.10:3002';
export const GITEA_OWNER = 'Pandipipas';
export const GITEA_REPO = 'fighting-game-packs';
export const GITEA_BRANCH = 'main';
export const BUNDLE_NAME = 'scoreko-dev';
export const getGiteaRawUrl = (repoPath: string): string =>
`${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${repoPath}`;
export const REGISTRY_URL = getGiteaRawUrl('registry.json');
export const getManifestUrl = (packId: string): string =>
getGiteaRawUrl(`${packId}/manifest.json`);
export const getPackLogoUrl = (packId: string): string =>
getGiteaRawUrl(`${packId}/logo.png`);
export const getCharacterImageRepoUrl = (packId: string, slug: string, ext: string): string =>
getGiteaRawUrl(`${packId}/characters/${slug}.${ext}`);
export const getInstalledCharacterImageUrl = (
packId: string,
slug: string,
ext = 'png',
): string => `/packs/${packId}/characters/${slug}.${ext}`;
+3
View File
@@ -0,0 +1,3 @@
export * from './config';
export * from './characters';
export * from './types';
+59
View File
@@ -0,0 +1,59 @@
export interface PackCharacter {
name: string;
slug: string;
dlc?: boolean;
sizeBytes: number;
}
export interface PackPalette {
start: string;
end: string;
}
export interface PackRegistryEntry {
id: string;
name: string;
version: string;
totalSizeBytes: number;
logoPath: string;
characterCount: number;
palette: PackPalette;
bundled: boolean;
}
export interface PackManifest {
id: string;
name: string;
version: string;
palette: PackPalette;
defaultPair?: {
left: string;
right: string;
};
characters: PackCharacter[];
}
export interface PackRegistry {
schemaVersion: number;
updatedAt: string;
packs: PackRegistryEntry[];
}
export interface PackDownloadState {
status: 'idle' | 'fetching-manifest' | 'downloading' | 'done' | 'error';
progress: number;
error?: string;
}
export interface PackUpdateInfo {
installedVersion: string;
latestVersion: string;
}
export interface GameSelectOption {
label: string;
value: string;
available: boolean;
registryEntry: PackRegistryEntry;
updateInfo?: PackUpdateInfo;
}
@@ -41,7 +41,7 @@ const countryByName = new Map(
baseCountries.map((country) => [country.name.toLowerCase(), country.code]), baseCountries.map((country) => [country.name.toLowerCase(), country.code]),
); );
export const resolveCountryCode = (value?: string) => { export const resolveCountryCode = (value?: string): string => {
if (!value) { if (!value) {
return ''; return '';
} }
@@ -57,7 +57,7 @@ export const resolveCountryCode = (value?: string) => {
return byName ?? ''; return byName ?? '';
}; };
export const getCountryLabel = (value?: string, locale = 'en') => { export const getCountryLabel = (value?: string, locale = 'en'): string => {
if (!value) { if (!value) {
return ''; return '';
} }
+51
View File
@@ -0,0 +1,51 @@
import type { Schemas } from '../../../types';
export type PlayersMap = Schemas.Players;
export type Player = PlayersMap[string];
const toString = (value: unknown): string => (typeof value === 'string' ? value : '');
export const normalizePlayer = (input: unknown): Player => {
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
return {
gamertag: toString(candidate.gamertag),
name: toString(candidate.name),
team: toString(candidate.team),
country: toString(candidate.country),
twitter: toString(candidate.twitter),
};
};
export const normalizePlayers = (input: unknown): PlayersMap => {
if (typeof input !== 'object' || input === null) {
return {};
}
const result: PlayersMap = {};
Object.entries(input as Record<string, unknown>).forEach(([id, value]) => {
if (id) {
result[id] = normalizePlayer(value);
}
});
return result;
};
export const normalizePlayerName = (value: string): string => value.trim().toLowerCase();
export const createPlayerId = (name: string, players: PlayersMap): string => {
const base = name
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[^\w\s-]/g, '')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '-') || 'player';
let index = 1;
let candidate = base;
while (players[candidate]) {
index += 1;
candidate = `${base}-${index}`;
}
return candidate;
};
+1
View File
@@ -0,0 +1 @@
export * from './state';
+88
View File
@@ -0,0 +1,88 @@
import type { Schemas } from '../../../types';
export type Scoreboard = Schemas.Scoreboard;
export type ScoreboardSide = 'left' | 'right';
export const defaultScoreboard: Scoreboard = {
leftPlayerId: '',
rightPlayerId: '',
leftNameOverride: '',
rightNameOverride: '',
leftTeamOverride: '',
rightTeamOverride: '',
leftCountryOverride: '',
rightCountryOverride: '',
leftCharacter: '',
rightCharacter: '',
leftScore: 0,
rightScore: 0,
round: '',
game: '',
};
const toString = (value: unknown): string => (typeof value === 'string' ? value : '');
const normalizeScore = (value: unknown): number =>
typeof value === 'number' ? Math.max(0, Math.floor(value)) : 0;
export const normalizeScoreboard = (input: unknown): Scoreboard => {
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
return {
leftPlayerId: toString(candidate.leftPlayerId),
rightPlayerId: toString(candidate.rightPlayerId),
leftNameOverride: toString(candidate.leftNameOverride),
rightNameOverride: toString(candidate.rightNameOverride),
leftTeamOverride: toString(candidate.leftTeamOverride),
rightTeamOverride: toString(candidate.rightTeamOverride),
leftCountryOverride: toString(candidate.leftCountryOverride),
rightCountryOverride: toString(candidate.rightCountryOverride),
leftCharacter: toString(candidate.leftCharacter),
rightCharacter: toString(candidate.rightCharacter),
leftScore: normalizeScore(candidate.leftScore),
rightScore: normalizeScore(candidate.rightScore),
round: toString(candidate.round),
game: toString(candidate.game),
};
};
export const setScoreboardScore = (
scoreboard: Scoreboard,
side: ScoreboardSide,
value: number,
): Scoreboard => ({
...scoreboard,
[side === 'left' ? 'leftScore' : 'rightScore']: Math.max(0, Math.floor(value)),
});
export const adjustScoreboardScore = (
scoreboard: Scoreboard,
side: ScoreboardSide,
delta: number,
): Scoreboard =>
setScoreboardScore(
scoreboard,
side,
(side === 'left' ? scoreboard.leftScore : scoreboard.rightScore) + delta,
);
export const swapScoreboardPlayers = (scoreboard: Scoreboard): Scoreboard => ({
...scoreboard,
leftPlayerId: scoreboard.rightPlayerId,
rightPlayerId: scoreboard.leftPlayerId,
leftNameOverride: scoreboard.rightNameOverride,
rightNameOverride: scoreboard.leftNameOverride,
leftTeamOverride: scoreboard.rightTeamOverride,
rightTeamOverride: scoreboard.leftTeamOverride,
leftCountryOverride: scoreboard.rightCountryOverride,
rightCountryOverride: scoreboard.leftCountryOverride,
leftCharacter: scoreboard.rightCharacter,
rightCharacter: scoreboard.leftCharacter,
leftScore: scoreboard.rightScore,
rightScore: scoreboard.leftScore,
});
export const resetScoreboardScores = (scoreboard: Scoreboard): Scoreboard => ({
...scoreboard,
leftScore: 0,
rightScore: 0,
});
+11 -111
View File
@@ -1,112 +1,12 @@
// src/shared/fighting-characters.ts export {
// ───────────────────────────────────────────────────────────────────────────── BUNDLED_GAME_NAMES,
// Todo el contenido de personajes viene de packs descargados desde Gitea. buildCharactersByGame,
// No hay datos bundled — el proyecto arranca vacío y se rellena en runtime. buildCharactersForManifest,
// ───────────────────────────────────────────────────────────────────────────── buildDefaultCharactersByGame,
type DefaultCharacterPair,
type FightingCharacterOption,
} from './domain/packs/characters';
import type { DefaultCharacterPair, FightingCharacterOption } from './domain/packs/characters';
import { ref } from 'vue'; export const getCharactersByGame = (_game?: string): FightingCharacterOption[] => [];
import type { PackManifest } from './pack-types'; export const getDefaultCharactersByGame = (_game?: string): DefaultCharacterPair | undefined => undefined;
export interface FightingCharacterOption {
label: string;
value: string;
image: string;
dlc?: boolean;
}
// ── Runtime registry ──────────────────────────────────────────────────────────
const installedPackCharacters: Record<string, FightingCharacterOption[]> = {};
const installedPackDefaults: Record<string, { leftCharacter: string; rightCharacter: string }> = {};
/**
* Incrementado cada vez que se registra o elimina un pack.
* Los composables se suscriben a este ref para que Vue invalide los computed
* que dependen de installedPackCharacters (objeto plano, no reactivo).
*/
export const installedPacksRevision = ref(0);
/**
* Vacío — ya no hay juegos bundled.
* Mantenido por compatibilidad con usePackRegistry.
*/
export const BUNDLED_GAME_NAMES = new Set<string>();
// ── Placeholder SVG ───────────────────────────────────────────────────────────
const toDataUrl = (svg: string): string =>
`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
const buildPlaceholder = (game: string, character: string, start: string, end: string): string => {
const initials = character
.split(/\s+/)
.map((p) => p[0])
.join('')
.slice(0, 2)
.toUpperCase();
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 220" role="img" aria-label="${character}">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${start}"/>
<stop offset="100%" stop-color="${end}"/>
</linearGradient>
</defs>
<rect width="480" height="220" fill="url(#bg)" rx="18"/>
<circle cx="90" cy="110" r="64" fill="rgba(255,255,255,0.13)"/>
<text x="90" y="130" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="56" font-weight="700">${initials}</text>
<text x="170" y="96" fill="#e2e8f0" font-family="Arial, sans-serif" font-size="20" font-weight="700">${game}</text>
<text x="170" y="145" fill="#ffffff" font-family="Arial, sans-serif" font-size="38" font-weight="700">${character}</text>
</svg>`.trim();
return toDataUrl(svg);
};
// ── Pack registration ─────────────────────────────────────────────────────────
/**
* Registra un pack instalado para que getCharactersByGame() lo devuelva.
* Llamado por usePackRegistry cuando carga el manifest.json local de un pack.
*/
export const registerInstalledPack = (manifest: PackManifest): void => {
const { id, name, palette, characters, defaultPair } = manifest;
installedPackCharacters[name] = characters.map((char) => ({
label: char.name,
value: char.slug,
image: `/packs/${id}/characters/${char.slug}.png`,
dlc: char.dlc ?? false,
// Fallback inline por si la imagen no se encuentra en disco
_placeholder: buildPlaceholder(name, char.name, palette.start, palette.end),
}));
if (defaultPair) {
installedPackDefaults[name] = {
leftCharacter: defaultPair.left,
rightCharacter: defaultPair.right,
};
}
installedPacksRevision.value++;
};
/**
* Elimina un pack del registro en memoria.
* Llamado por usePackRegistry cuando el usuario desinstala un pack.
*/
export const unregisterInstalledPack = (gameName: string): void => {
delete installedPackCharacters[gameName];
delete installedPackDefaults[gameName];
installedPacksRevision.value++;
};
// ── Public API ────────────────────────────────────────────────────────────────
export const getCharactersByGame = (game: string): FightingCharacterOption[] =>
installedPackCharacters[game] ?? [];
export const getDefaultCharactersByGame = (
game: string,
): { leftCharacter: string; rightCharacter: string } | undefined =>
installedPackDefaults[game];
-37
View File
@@ -1,37 +0,0 @@
// src/shared/pack-config.ts
// ─────────────────────────────────────────────────────────────────────────────
// Edit ONLY this file to point the pack system at your Gitea instance.
// All other files import their Gitea/NodeCG constants from here.
// ─────────────────────────────────────────────────────────────────────────────
/** Base URL of your Gitea instance — no trailing slash. */
export const GITEA_BASE_URL = 'http://10.0.0.10:3002';
/** Gitea owner (user or organisation) that owns the packs repository. */
export const GITEA_OWNER = 'Pandipipas';
/** Name of the repository that contains all game packs. */
export const GITEA_REPO = 'fighting-game-packs';
/** Branch to pull assets from. */
export const GITEA_BRANCH = 'main';
/**
* NodeCG bundle name.
* Must match the "name" field in your package.json / nodecg config.
*/
export const BUNDLE_NAME = 'scoreko-dev';
// ── Derived URL helpers (do not edit below this line) ────────────────────────
/** Returns the Gitea raw-file URL for any repo-relative path. */
export const getGiteaRawUrl = (repoPath) => `${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${repoPath}`;
/** URL of the master registry file that lists every available pack. */
export const REGISTRY_URL = getGiteaRawUrl('registry.json');
/** Returns the URL for a specific pack's manifest.json. */
export const getManifestUrl = (packId) => getGiteaRawUrl(`${packId}/manifest.json`);
/** Returns the URL for a pack's logo. */
export const getPackLogoUrl = (packId) => getGiteaRawUrl(`${packId}/logo.png`);
/**
* Returns the URL for a specific character image stored in the Gitea repo.
* Used during download; at runtime installed packs are served by NodeCG.
*/
export const getCharacterImageRepoUrl = (packId, slug, ext) => getGiteaRawUrl(`${packId}/characters/${slug}.${ext}`);
/**
* Returns the runtime URL for a character image from an *installed* (downloaded) pack.
* NodeCG serves everything under assets/ at /assets/<bundleName>/.
*/
export const getInstalledCharacterImageUrl = (packId, slug, ext = 'png') => `/assets/${BUNDLE_NAME}/packs/${packId}/characters/${slug}.${ext}`;
-54
View File
@@ -1,54 +0,0 @@
// src/shared/pack-config.ts
// ─────────────────────────────────────────────────────────────────────────────
// Edit ONLY this file to point the pack system at your Gitea instance.
// All other files import their Gitea/NodeCG constants from here.
// ─────────────────────────────────────────────────────────────────────────────
/** Base URL of your Gitea instance — no trailing slash. */
export const GITEA_BASE_URL = 'http://10.0.0.10:3002';
/** Gitea owner (user or organisation) that owns the packs repository. */
export const GITEA_OWNER = 'Pandipipas';
/** Name of the repository that contains all game packs. */
export const GITEA_REPO = 'fighting-game-packs';
/** Branch to pull assets from. */
export const GITEA_BRANCH = 'main';
/**
* NodeCG bundle name.
* Must match the "name" field in your package.json / nodecg config.
*/
export const BUNDLE_NAME = 'scoreko-dev';
// ── Derived URL helpers (do not edit below this line) ────────────────────────
/** Returns the Gitea raw-file URL for any repo-relative path. */
export const getGiteaRawUrl = (repoPath: string): string =>
`${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${repoPath}`;
/** URL of the master registry file that lists every available pack. */
export const REGISTRY_URL = getGiteaRawUrl('registry.json');
/** Returns the URL for a specific pack's manifest.json. */
export const getManifestUrl = (packId: string): string =>
getGiteaRawUrl(`${packId}/manifest.json`);
/** Returns the URL for a pack's logo. */
export const getPackLogoUrl = (packId: string): string =>
getGiteaRawUrl(`${packId}/logo.png`);
/**
* Returns the URL for a specific character image stored in the Gitea repo.
* Used during download; at runtime installed packs are served by NodeCG.
*/
export const getCharacterImageRepoUrl = (packId: string, slug: string, ext: string): string =>
getGiteaRawUrl(`${packId}/characters/${slug}.${ext}`);
/**
* Returns the runtime URL for a character image from an *installed* (downloaded) pack.
* NodeCG serves everything under assets/ at /assets/<bundleName>/.
*/
export const getInstalledCharacterImageUrl = (packId: string, slug: string, ext = 'png'): string =>
`/packs/${packId}/characters/${slug}.${ext}`;
-6
View File
@@ -1,6 +0,0 @@
// src/shared/pack-types.ts
// ─────────────────────────────────────────────────────────────────────────────
// Shared between the NodeCG extension (Node.js) and the dashboard (browser).
// Do NOT import anything that is browser-only or Node-only from this file.
// ─────────────────────────────────────────────────────────────────────────────
export {};
-89
View File
@@ -1,89 +0,0 @@
// src/shared/pack-types.ts
// ─────────────────────────────────────────────────────────────────────────────
// Shared between the NodeCG extension (Node.js) and the dashboard (browser).
// Do NOT import anything that is browser-only or Node-only from this file.
// ─────────────────────────────────────────────────────────────────────────────
/** A single character entry inside a pack manifest. */
export interface PackCharacter {
/** Display name, e.g. "Chun-Li" */
name: string;
/** URL-safe slug that matches the image filename, e.g. "chun-li" */
slug: string;
/** True when the character is paid DLC (shown with the DLC badge in the UI). */
dlc?: boolean;
/** Approximate compressed size of the character image file in bytes. */
sizeBytes: number;
}
/**
* Lightweight entry in the top-level registry.json.
* Enough for the UI to render the game list and the download dialog preview
* without having to fetch the full manifest.
*/
export interface PackRegistryEntry {
/** Unique identifier — must match the folder name in the repo, e.g. "street-fighter-6". */
id: string;
/** Human-readable game title shown in the selector, e.g. "Street Fighter 6". */
name: string;
/** Semantic version of this pack, e.g. "1.0.0". Bump when adding/updating characters. */
version: string;
/** Total download size (sum of all character images + logo) in bytes. */
totalSizeBytes: number;
/** Repo-relative path to the game's logo image, e.g. "street-fighter-6/logo.png". */
logoPath: string;
/** Pre-computed character count so the dialog can show it without loading the manifest. */
characterCount: number;
/** Gradient used for placeholder images when a character has no artwork. */
palette: { start: string; end: string };
/**
* True when the pack ships inside the application bundle (bundled via Vite's
* import.meta.glob). Bundled packs are always "installed" and never show the
* download button, but they still appear in the registry so the app can detect
* updates (version mismatch between bundle and registry).
*/
bundled: boolean;
}
/** Full pack data — lives at <packId>/manifest.json in the repo. */
export interface PackManifest {
/** Must match PackRegistryEntry.id and the folder name. */
id: string;
/** Must match PackRegistryEntry.name. */
name: string;
version: string;
palette: { start: string; end: string };
/** Default characters pre-selected when this game is first chosen. */
defaultPair?: { left: string; right: string };
/** Full character roster, in the order they should appear in the selector. */
characters: PackCharacter[];
}
/** Top-level registry.json structure. */
export interface PackRegistry {
schemaVersion: number;
updatedAt: string;
packs: PackRegistryEntry[];
}
/** Tracks the download lifecycle of a single pack. */
export interface PackDownloadState {
status: 'idle' | 'fetching-manifest' | 'downloading' | 'done' | 'error';
/** Progress percentage 0100. */
progress: number;
error?: string;
}
/** Shape of the option objects surfaced by usePackRegistry.allGameOptions. */
export interface GameSelectOption {
/** Display label for the QSelect. */
label: string;
/** Value stored in the scoreboard (equals PackRegistryEntry.name for installed games). */
value: string;
/** Whether the pack can be used right now (bundled or already downloaded). */
available: boolean;
/** Mirrors PackRegistryEntry so the download dialog can be populated inline. */
registryEntry: PackRegistryEntry;
/** Present when there is a newer version of this pack available in the registry. */
updateInfo?: { installedVersion: string; latestVersion: string };
}
-3
View File
@@ -1,3 +0,0 @@
export interface ExampleType {
exampleProperty: string;
}
-1
View File
@@ -2,5 +2,4 @@ import type NodeCG from 'nodecg/types';
import type { Configschema } from './schemas.d.ts'; import type { Configschema } from './schemas.d.ts';
export type NodeCGServerAPI = NodeCG.default.ServerAPI<Configschema>; export type NodeCGServerAPI = NodeCG.default.ServerAPI<Configschema>;
export type { ExampleType } from './ExampleType.d.ts';
export type * as Schemas from './schemas.d.ts'; export type * as Schemas from './schemas.d.ts';
+4 -1
View File
@@ -6,7 +6,10 @@
export type { Commentary } from './schemas/commentary.d.ts'; export type { Commentary } from './schemas/commentary.d.ts';
export type { Configschema } from './schemas/configschema.d.ts'; export type { Configschema } from './schemas/configschema.d.ts';
export type { ExampleReplicant } from './schemas/exampleReplicant.d.ts';
export type { GraphicsSettings } from './schemas/graphicsSettings.d.ts'; export type { GraphicsSettings } from './schemas/graphicsSettings.d.ts';
export type { Players } from './schemas/players.d.ts'; export type { Players } from './schemas/players.d.ts';
export type { Scoreboard } from './schemas/scoreboard.d.ts'; export type { Scoreboard } from './schemas/scoreboard.d.ts';
export type { InstalledPacks } from './schemas/installedPacks.d.ts';
export type { PackRegistry } from './schemas/packRegistry.d.ts';
export type { DownloadStates } from './schemas/downloadStates.d.ts';
export type { AvailableUpdates } from './schemas/availableUpdates.d.ts';
+14
View File
@@ -0,0 +1,14 @@
/* prettier-ignore */
/* eslint-disable */
/**
* This file was automatically generated by json-schema-to-typescript.
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
* and run json-schema-to-typescript to regenerate this file.
*/
export interface AvailableUpdates {
[k: string]: {
installedVersion: string;
latestVersion: string;
};
}
+4 -1
View File
@@ -7,7 +7,10 @@
*/ */
export interface Configschema { export interface Configschema {
exampleProperty: string; /**
* Sobreescribe la URL base del proxy OAuth.
*/
oauthProxyUrl?: string;
/** /**
* Client ID de tu OAuth app de start.gg * Client ID de tu OAuth app de start.gg
*/ */
+15
View File
@@ -0,0 +1,15 @@
/* prettier-ignore */
/* eslint-disable */
/**
* This file was automatically generated by json-schema-to-typescript.
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
* and run json-schema-to-typescript to regenerate this file.
*/
export interface DownloadStates {
[k: string]: {
status: 'idle' | 'fetching-manifest' | 'downloading' | 'done' | 'error';
progress: number;
error?: string;
};
}
@@ -6,6 +6,4 @@
* and run json-schema-to-typescript to regenerate this file. * and run json-schema-to-typescript to regenerate this file.
*/ */
export interface ExampleReplicant { export type InstalledPacks = string[];
exampleProperty: string;
}
+25
View File
@@ -0,0 +1,25 @@
/* prettier-ignore */
/* eslint-disable */
/**
* This file was automatically generated by json-schema-to-typescript.
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
* and run json-schema-to-typescript to regenerate this file.
*/
export type PackRegistry = {
schemaVersion: number;
updatedAt: string;
packs: {
id: string;
name: string;
version: string;
totalSizeBytes: number;
logoPath: string;
characterCount: number;
palette: {
start: string;
end: string;
};
bundled: boolean;
}[];
} | null;
+3 -2
View File
@@ -15,12 +15,13 @@
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.browser.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.browser.tsbuildinfo",
}, },
"include": [ "include": [
"./src/browser_shared/**/*.ts",
"./src/browser_shared/**/*.vue",
"./src/dashboard/**/*.ts", "./src/dashboard/**/*.ts",
"./src/dashboard/**/*.vue", "./src/dashboard/**/*.vue",
"./src/graphics/**/*.ts", "./src/graphics/**/*.ts",
"./src/graphics/**/*.vue", "./src/graphics/**/*.vue",
"./src/nodecg/browser/**/*.ts",
"./src/nodecg/*.ts",
"./src/shared/domain/**/*.ts",
"./src/types/**/*.d.ts" "./src/types/**/*.d.ts"
] ]
} }
+4 -2
View File
@@ -7,15 +7,17 @@
"./node_modules/@types" "./node_modules/@types"
], ],
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.extension.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.extension.tsbuildinfo",
"rootDir": "./src/extension", "rootDir": "./src",
"outDir": "./extension", "outDir": ".",
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
}, },
"include": [ "include": [
"./src/extension/**/*.ts", "./src/extension/**/*.ts",
"./src/nodecg/**/*.ts",
"./src/types/**/*.d.ts" "./src/types/**/*.d.ts"
], ],
"exclude": [ "exclude": [
"./src/nodecg/browser/**/*.ts",
"./src/types/augment-window.d.ts" "./src/types/augment-window.d.ts"
] ]
} }