4 Commits

Author SHA1 Message Date
Pandipipas b32c0e4560 feat: implement replicant state synchronization for commentary, players, scoreboard, and graphics settings
- Added a new service for synchronizing state with replicants in `replicant-state-service.ts`.
- Refactored commentary store to utilize the new synchronization service.
- Created a new graphics settings store that syncs with replicants.
- Introduced a packs store for managing installed packs and their states.
- Updated players and scoreboard stores to use the new synchronization service.
- Created shared services for managing replicated state in graphics components.
- Refactored existing components to use the new shared services for replicant state.
- Added normalization and default values for commentary, graphics settings, players, and scoreboard.
- Improved type safety and organization in shared domain files for better maintainability.
2026-05-30 21:22:48 +02:00
Pandipipas 02a108f983 refactor: architecture base
- Moved NodeCG context management to a dedicated context module.
- Introduced message handling utilities for better message listening and sending.
- Updated startgg integration to use new message handling methods.
- Removed deprecated replicant utilities and replaced them with a new structure.
- Refactored replicant imports in graphics components to align with new structure.
- Added new pack-related types and schemas for better type safety.
- Cleaned up unused files and consolidated pack configuration into a single module.
- Updated TypeScript configurations to reflect new directory structure.
2026-05-23 21:52:07 +02:00
Pandipipas 225b2b36a2 feat: add architectural documentation for refactor; include audit, migration plan, rules, target architecture, and session handoff 2026-05-23 20:48:31 +02:00
Pandipipas 8c270feb5b feat: enhance pack management and character handling; implement automatic registry refresh and logo display updates 2026-05-22 21:19:45 +02:00
89 changed files with 2433 additions and 1479 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,7 +6,8 @@
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
import { computed, watch } from 'vue'; import { computed, watch } from 'vue';
import type { PackRegistryEntry } from '../../../shared/pack-types'; import { getPackLogoUrl } from '../../../shared/domain/packs/config';
import type { PackRegistryEntry } from '../../../shared/domain/packs/types';
import { usePackRegistry } from '../composables/usePackRegistry'; import { usePackRegistry } from '../composables/usePackRegistry';
// ── Props / emits ───────────────────────────────────────────────────────────── // ── Props / emits ─────────────────────────────────────────────────────────────
@@ -48,15 +49,13 @@ const isError = computed(() => downloadState.value?.status === 'error');
const progress = computed(() => downloadState.value?.progress ?? 0); const progress = computed(() => downloadState.value?.progress ?? 0);
const logoUrl = computed(() => // Pre-install: show logo directly from Gitea (pack not on disk yet).
props.packEntry ? packRegistry.getLocalLogoUrl(props.packEntry.id) : '', // Update mode: pack is installed, serve from local /packs/ route.
); const logoSrc = computed(() => {
if (!props.packEntry) return '';
const giteaLogoUrl = computed(() => if (props.isUpdate) return packRegistry.getLocalLogoUrl(props.packEntry.id);
props.packEntry return getPackLogoUrl(props.packEntry.id);
? `${packRegistry.registry.value ? '' : ''}` // resolved from packEntry.logoPath via Gitea });
: '',
);
// Close automatically once download completes and emit so parent sets the game // Close automatically once download completes and emit so parent sets the game
watch(isDone, (done) => { watch(isDone, (done) => {
@@ -98,8 +97,15 @@ const close = () => emit('update:modelValue', false);
{{ packEntry.name }} {{ packEntry.name }}
</div> </div>
<div class="text-caption text-grey-5"> <div class="text-caption text-grey-5">
<template v-if="isUpdate && updateInfo">
Bundled v{{ updateInfo.installedVersion }}
<span class="text-positive">v{{ updateInfo.latestVersion }}</span>
· {{ packEntry.characterCount }} personajes
</template>
<template v-else>
v{{ packEntry.version }} · {{ packEntry.characterCount }} personajes · v{{ packEntry.version }} · {{ packEntry.characterCount }} personajes ·
{{ packRegistry.formatBytes(packEntry.totalSizeBytes) }} {{ packRegistry.formatBytes(packEntry.totalSizeBytes) }}
</template>
</div> </div>
</div> </div>
<QBtn <QBtn
@@ -112,16 +118,23 @@ const close = () => emit('update:modelValue', false);
/> />
</div> </div>
<!-- Gradient banner using the pack's palette --> <!-- Banner: logo del juego con gradiente de fallback -->
<div <div
class="pack-download-dialog__banner" class="pack-download-dialog__banner"
:style="{ :style="{
background: `linear-gradient(135deg, ${packEntry.palette.start}, ${packEntry.palette.end})`, background: `linear-gradient(135deg, ${packEntry.palette.start}, ${packEntry.palette.end})`,
}" }"
> >
<img
v-if="logoSrc"
:src="logoSrc"
class="pack-download-dialog__logo"
alt=""
@error="($event.target as HTMLImageElement).style.display = 'none'"
/>
<QIcon <QIcon
:name="isUpdate ? 'upgrade' : 'sports_esports'" :name="isUpdate ? 'upgrade' : 'sports_esports'"
size="48px" size="40px"
color="white" color="white"
class="pack-download-dialog__banner-icon" class="pack-download-dialog__banner-icon"
/> />
@@ -253,8 +266,18 @@ const close = () => emit('update:modelValue', false);
overflow: hidden; overflow: hidden;
} }
.pack-download-dialog__logo {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 10px;
}
.pack-download-dialog__banner-icon { .pack-download-dialog__banner-icon {
opacity: 0.35; position: relative; /* above the logo */
opacity: 0.25;
} }
.pack-download-dialog__progress-section { .pack-download-dialog__progress-section {
@@ -3,7 +3,7 @@ import { computed, inject } from 'vue';
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame'; import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
import { usePlayerSide } from '../composables/usePlayerSide'; import { usePlayerSide } from '../composables/usePlayerSide';
import { t } from '../i18n'; import { t } from '../i18n';
import { useScoreboardStore } from '../stores/scoreboard'; import { useScoreboardStore } from '../../stores/scoreboard';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Props // Props
@@ -74,8 +74,7 @@ const character = computed({
? scoreboardStore.scoreboard.leftCharacter ? scoreboardStore.scoreboard.leftCharacter
: scoreboardStore.scoreboard.rightCharacter), : scoreboardStore.scoreboard.rightCharacter),
set: (v) => { set: (v) => {
if (isLeft.value) scoreboardStore.scoreboard.leftCharacter = v; scoreboardStore.setSideCharacter(props.side, v);
else scoreboardStore.scoreboard.rightCharacter = v;
}, },
}); });
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { inject, onMounted, ref } from 'vue'; import { inject, onMounted, onUnmounted, ref } from 'vue';
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame'; import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
import { usePackRegistry } from '../composables/usePackRegistry'; import { usePackRegistry } from '../composables/usePackRegistry';
import { t } from '../i18n'; import { t } from '../i18n';
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();
@@ -18,31 +18,35 @@ const {
showDownloadDialog, showDownloadDialog,
} = inject(CHARACTER_GAME_KEY)!; } = inject(CHARACTER_GAME_KEY)!;
// Refresca el catálogo de Gitea al montar el panel. // Refresca el catálogo al montar y luego cada 15 segundos automáticamente.
// Si Gitea no está disponible se usa la caché persistida del replicante. // Si Gitea no está disponible se usa la caché persistida del replicante.
onMounted(() => { onMounted(() => {
packRegistry.fetchRegistry(); packRegistry.startRegistryRefresh();
});
onUnmounted(() => {
packRegistry.stopRegistryRefresh();
}); });
const adjustLeftScore = (delta: number) => { const adjustLeftScore = (delta: number) => {
scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore + delta); scoreboardStore.adjustScore('left', delta);
}; };
const adjustRightScore = (delta: number) => { const adjustRightScore = (delta: number) => {
scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore + delta); scoreboardStore.adjustScore('right', delta);
}; };
/** Tras una descarga exitosa, activa el juego en el store. */ /** 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;
@@ -188,27 +192,7 @@ const openUpdateDialog = (opt: import('../../../shared/pack-types').GameSelectOp
class="scoreboard-preview__action-btn" class="scoreboard-preview__action-btn"
@click="scoreboardStore.resetScores" @click="scoreboardStore.resetScores"
/> />
<!-- Botón para refrescar el catálogo de juegos desde Gitea -->
<QBtn
flat
dense
round
size="sm"
icon="refresh"
class="scoreboard-preview__action-btn"
@click="packRegistry.fetchRegistry()"
>
<QTooltip>Actualizar catálogo de juegos</QTooltip>
<!-- Badge con el número de packs que tienen actualización pendiente -->
<QBadge
v-if="packRegistry.updateCount.value > 0"
color="positive"
floating
rounded
>
{{ packRegistry.updateCount.value }}
</QBadge>
</QBtn>
</div> </div>
</div> </div>
@@ -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');
@@ -39,7 +39,7 @@ export function useCharacterGame() {
* Populated from the pack registry when available; falls back to bundled games. * Populated from the pack registry when available; falls back to bundled games.
* GameSelectOption includes an `available` flag used to show the download icon. * GameSelectOption includes an `available` flag used to show the download icon.
*/ */
const fightingGameOptions = ref<GameSelectOption[]>(packRegistry.allGameOptions.value); const fightingGameOptions = ref<GameSelectOption[]>([]);
// Keep fightingGameOptions in sync when the registry updates // Keep fightingGameOptions in sync when the registry updates
watch( watch(
@@ -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,288 +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 {
BUNDLED_GAME_NAMES,
registerInstalledPack,
unregisterInstalledPack,
} from '../../../shared/fighting-characters';
import { BUNDLE_NAME } from '../../../shared/pack-config';
import type { import type {
GameSelectOption, GameSelectOption,
PackDownloadState, PackDownloadState,
PackManifest,
PackRegistry, PackRegistry,
PackRegistryEntry, 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 packs already installed before this session
for (const id of installedPackIds.value) {
if (!BUNDLED_GAME_NAMES.has(getGameNameById(id))) {
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) {
if (!BUNDLED_GAME_NAMES.has(getGameNameById(id))) {
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[]>(() => {
if (!registry.value) {
// Registry not loaded yet — surface only the bundled games as available
return Array.from(BUNDLED_GAME_NAMES).map((name) => ({
label: name,
value: name,
available: true,
registryEntry: {
id: '',
name,
version: '',
totalSizeBytes: 0,
logoPath: '',
characterCount: 0,
palette: { start: '#334155', end: '#0f172a' },
bundled: true,
} satisfies PackRegistryEntry,
}));
}
return registry.value.packs.map((entry) => ({
label: entry.name,
value: entry.name,
available: entry.bundled || 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 BUNDLED_GAME_NAMES.has(gameName); availableUpdates,
return entry.bundled || 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();
+23 -72
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,58 +41,14 @@ 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;
// Raíz del proyecto: 2 niveles por encima de extension/pack-manager.js // Raíz del proyecto: 2 niveles por encima de extension/pack-manager.js
// Usamos import.meta.url porque nodecg.bundleDir no está disponible cuando // Usamos import.meta.url porque nodecg.bundleDir no está disponible cuando
// NodeCG se usa como dependencia en lugar de servidor standalone. // NodeCG se usa como dependencia en lugar de servidor standalone.
@@ -92,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 ────────────────────────────────────────────────────────────────
@@ -211,6 +161,7 @@ const trySaveImage = async (
const checkForUpdates = (): void => { const checkForUpdates = (): void => {
const registry = packRegistryRep.value; const registry = packRegistryRep.value;
const installed = installedPacksRep.value ?? []; const installed = installedPacksRep.value ?? [];
if (!registry || installed.length === 0) { if (!registry || installed.length === 0) {
availableUpdatesRep.value = {}; availableUpdatesRep.value = {};
return; return;
@@ -247,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}`);
@@ -264,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.'));
} }
@@ -325,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.'));
} }
@@ -350,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.'));
} }
@@ -372,7 +323,7 @@ nodecg.listenFor('updatePack', async (packId: unknown, ack: Acknowledgement | un
const packDir = path.join(packsDir, packId); const packDir = path.join(packsDir, packId);
const charsDir = path.join(packDir, 'characters'); const charsDir = path.join(packDir, 'characters');
// 2. Limpiar imágenes antiguas (evita residuos de personajes renombrados/eliminados) // 2. Limpiar imágenes antiguas para evitar residuos de personajes renombrados
if (fs.existsSync(charsDir)) { if (fs.existsSync(charsDir)) {
fs.rmSync(charsDir, { recursive: true, force: true }); fs.rmSync(charsDir, { recursive: true, force: true });
} }
@@ -424,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 -339
View File
@@ -1,340 +1,12 @@
// src/shared/fighting-characters.ts export {
// ───────────────────────────────────────────────────────────────────────────── BUNDLED_GAME_NAMES,
// Two sources of character data: buildCharactersByGame,
// 1. BUNDLED — shipped with the app, images loaded at build time via buildCharactersForManifest,
// import.meta.glob (unchanged from before). buildDefaultCharactersByGame,
// 2. INSTALLED — downloaded from Gitea at runtime, registered via type DefaultCharacterPair,
// registerInstalledPack(). Images served by NodeCG from type FightingCharacterOption,
// /assets/<bundleName>/packs/<packId>/characters/<slug>.<ext> } 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;
}
type GamePalette = readonly [startColor: string, endColor: string];
const DEFAULT_PLACEHOLDER_PALETTE: GamePalette = ['#334155', '#0f172a'];
const MAX_INITIALS = 2;
// ─────────────────────────────────────────────────────────────────────────────
// BUNDLED DATA
// ─────────────────────────────────────────────────────────────────────────────
const characterNamesByGame: Record<string, string[]> = {
'2XKO': [
'Ahri', 'Akali', 'Braum', 'Caitlyn', 'Darius', 'Ekko',
'Illaoi', 'Jinx', 'Senna', 'Teemo', 'Vi', 'Warwick', 'Yasuo',
],
'FATAL FURY: City of the Wolves': [
'Andy Bogard', 'B. Jenet', 'Billy Kane', 'Blue Mary', 'Chun-Li',
'Cristiano Ronaldo', 'Gato', 'Hokutomaru', 'Hotaru Futaba', 'Joe Higashi',
'Kain R. Heinlein', 'Ken Masters', 'Kenshiro', 'Kevin Rian',
'Kim Dong Hwan', 'Kim Jae Hoon', 'Mai Shiranui', 'Marco Rodrigues',
'Mr. Big', 'Mr. Karate', 'Nightmare Geese', 'Preecha', 'Rock Howard',
'Salvatore Ganacci', 'Terry Bogard', 'Tizoc', 'Vox Reaper', 'Wolfgang Krauser',
],
'Guilty Gear -Strive-': [
'A.B.A', 'Anji Mito', 'Asuka R.', 'Axl Low', 'Baiken', 'Bedman?',
'Bridget', 'Chipp Zanuff', 'Dizzy', 'Elphelt Valentine', 'Faust',
'Giovanna', 'Goldlewis Dickinson', 'Happy Chaos', 'I-No', 'Jack-O',
'Johnny', 'Ky Kiske', 'Leo Whitefang', 'Lucy', 'May', 'Millia Rage',
'Nagoriyuki', 'Potemkin', 'Ramlethal Valentine', 'Sin Kiske', 'Slayer',
'Sol Badguy', 'Testament', 'Unika', 'Venom', 'Zato-1',
],
'Invincible VS': [
'Allen the Alien', 'Anissa', 'Atom Eve', 'Battle Beast', 'Bulletproof',
'Cecil', 'Conquest', 'Dupli-Kate', 'Ella Mental', 'Immortal', 'Invincible',
'Lucan', 'Monster Girl', 'Omni-Man', 'Powerplex', 'Rex Splode', 'Robot',
'Thula', 'Titan', 'Universa',
],
'Mortal Kombat 1': [
'Ashrah', 'Baraka', 'Conan the Barbarian', 'Cyrax', 'Ermac', 'Geras',
'Ghostface', 'Havik', 'Homelander', 'Johnny Cage', 'Kenshi', 'Kitana',
'Kung Lao', 'Li Mei', 'Liu Kang', 'Mileena', 'Nitara', 'Noob Saibot',
'Omni-Man', 'Peacemaker', 'Quan Chi', 'Raiden', 'Rain', 'Reiko', 'Reptile',
'Scorpion', 'Sektor', 'Shang Tsung', 'Sindel', 'Smoke', 'Sub-Zero',
'Takeda', 'Tanya', 'T-1000',
],
'Street Fighter 6': [
'A.K.I.', 'Akuma', 'Alex', 'Bison', 'Blanka', 'Cammy', 'Chun-Li',
'Dee Jay', 'Dhalsim', 'E. Honda', 'Ed', 'Elena', 'Guile', 'Jamie', 'JP',
'Juri', 'Ken', 'Kimberly', 'Lily', 'Luke', 'Mai', 'Manon', 'Marisa',
'Rashid', 'Ryu', 'Sagat', 'Terry', 'Viper', 'Zangief',
],
'TEKKEN 8': [
'Alisa', 'Anna', 'Armor King', 'Asuka', 'Azucena', 'Bob', 'Bryan',
'Claudio', 'Clive', 'Devil Jin', 'Dragunov', 'Eddy', 'Fahkumram', 'Feng',
'Heihachi', 'Hwoarang', 'Jack-8', 'Jin', 'Jun', 'Kazuya', 'King', 'Kuma',
'Kunimitsu', 'Lars', 'Law', 'Lee', 'Leo', 'Leroy', 'Lidia', 'Lili',
'Miary Zo', 'Nina', 'Panda', 'Paul', 'Raven', 'Reina', 'Roger Jr',
'Shaheen', 'Steve', 'Victor', 'Xiaoyu', 'Yoshimitsu', 'Zafina',
],
'THE KING OF FIGHTERS XV': [
'Angel', 'Antonov', 'Ash Crimson', 'Athena Asamiya', 'Benimaru Nikaido',
'Billy Kane', 'Blue Mary', 'Chizuru Kagura', 'Chris', 'Clark Still',
'Dolores', 'Duo Lon', 'Elisabeth Blanctorche', 'Gato', 'Geese Howard',
'Goenitz', 'Heidern', 'Hinako Shijo', 'Iori Yagami', 'Isla', 'Joe Higashi',
"K'", 'Kim Kaphwan', 'King', 'King of Dinosaurs', 'Krohnen McDougall',
'Kula Diamond', 'Kukri', 'Kyo Kusanagi', 'Leona Heidern', 'Luong',
'Mai Shiranui', 'Maxima', 'Meitenkun', 'Najd', 'Orochi Chris',
'Orochi Shermie', 'Orochi Yashiro', 'Ralf Jones', 'Ramón', 'Robert Garcia',
'Rock Howard', 'Ryo Sakazaki', 'Ryuji Yamazaki', 'Shermie', 'Shingo Yabuki',
'Sylvie Paula Paula', 'Terry Bogard', 'Vanessa', 'Whip', 'Yashiro Nanakase',
'Yuri Sakazaki',
],
};
const defaultCharacterPairByGame: Record<string, { leftCharacter: string; rightCharacter: string }> = {
'Guilty Gear -Strive-': { leftCharacter: 'sol-badguy', rightCharacter: 'ky-kiske' },
'Street Fighter 6': { leftCharacter: 'ryu', rightCharacter: 'chun-li' },
'TEKKEN 8': { leftCharacter: 'jin', rightCharacter: 'kazuya' },
'2XKO': { leftCharacter: 'ahri', rightCharacter: 'yasuo' },
'Mortal Kombat 1': { leftCharacter: 'scorpion', rightCharacter: 'sub-zero' },
'THE KING OF FIGHTERS XV': { leftCharacter: 'kyo-kusanagi', rightCharacter: 'iori-yagami' },
};
const paletteByGame: Record<string, GamePalette> = {
'Street Fighter 6': ['#f97316', '#b91c1c'],
'TEKKEN 8': ['#2563eb', '#111827'],
'Guilty Gear -Strive-': ['#a855f7', '#312e81'],
'2XKO': ['#7c3aed', '#1d4ed8'],
'Mortal Kombat 1': ['#f59e0b', '#7f1d1d'],
'THE KING OF FIGHTERS XV': ['#0ea5e9', '#1e3a8a'],
};
const dlcCharactersByGame: Record<string, ReadonlySet<string>> = {
'FATAL FURY: City of the Wolves': new Set([
'Chun-Li', 'Cristiano Ronaldo', 'Ken Masters', 'Kenshiro',
'Nightmare Geese', 'Salvatore Ganacci', 'Vox Reaper',
]),
'Guilty Gear -Strive-': new Set([
'Goldlewis Dickinson', 'Jack-O', 'Happy Chaos', 'Baiken', 'Testament',
'Bridget', 'Sin Kiske', 'Bedman?', 'Asuka R. Kreutz', 'Johnny',
'Elphelt Valentine', 'A.B.A', 'Slayer', 'Dizzy', 'Venom',
'Lucy', 'Unika',
]),
'Mortal Kombat 1': new Set([
'Ermac', 'Homelander', 'Omni-Man', 'Peacemaker', 'Quan Chi', 'Tanya',
'Conan the Barbarian', 'Cyrax', 'Ghostface', 'Noob Saibot', 'Sektor',
'Shang Tsung', 'Takeda', 'T-1000',
]),
'Street Fighter 6': new Set([
'A.K.I.', 'Akuma', 'Bison', 'Ed',
'Alex', 'Elena', 'Mai', 'Sagat', 'Terry', 'Viper',
]),
'TEKKEN 8': new Set([
'Clive', 'Eddy', 'Heihachi', 'Lidia',
'Anna', 'Fahkumram', 'Kunimitsu', 'Miary Zo', 'Roger Jr',
]),
'THE KING OF FIGHTERS XV': new Set([
'Antonov', 'Elisabeth Blanctorche', 'Gato', 'Geese Howard', 'Goenitz',
'Hinako Shijo', 'Krohnen McDougall', 'Kukri', 'Luong', 'Najd',
'Orochi Chris', 'Orochi Shermie', 'Orochi Yashiro', 'Rock Howard',
'Sylvie Paula Paula',
]),
};
// ─────────────────────────────────────────────────────────────────────────────
// Image resolution — BUNDLED
// ─────────────────────────────────────────────────────────────────────────────
const toSlug = (value: string): string =>
value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
const toDataUrl = (svg: string): string =>
`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
const buildCharacterPlaceholder = (game: string, character: string): string => {
const [startColor, endColor] = paletteByGame[game] ?? DEFAULT_PLACEHOLDER_PALETTE;
const initials = character
.split(/\s+/)
.map((part) => part[0])
.join('')
.slice(0, MAX_INITIALS)
.toUpperCase();
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 220" role="img" aria-label="${character}">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${startColor}"/>
<stop offset="100%" stop-color="${endColor}"/>
</linearGradient>
</defs>
<rect width="480" height="220" fill="url(#bg)" rx="18"/>
<circle cx="90" cy="110" r="64" fill="rgba(255,255,255,0.13)"/>
<text x="90" y="130" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="56" font-weight="700">${initials}</text>
<text x="170" y="96" fill="#e2e8f0" font-family="Arial, sans-serif" font-size="20" font-weight="700">${game}</text>
<text x="170" y="145" fill="#ffffff" font-family="Arial, sans-serif" font-size="38" font-weight="700">${character}</text>
</svg>`;
return toDataUrl(svg.trim());
};
const characterImageModules = import.meta.glob(
'/src/shared/character-images/**/*.{png,jpg,jpeg,webp,avif,svg}',
{ eager: true, import: 'default', query: '?url' },
) as Record<string, string>;
const resolveImageKey = (path: string): string | null => {
const segments = path.split('/');
const gameFolder = segments.at(-2);
const filename = segments.at(-1);
if (!gameFolder || !filename) return null;
return `${gameFolder}/${filename.replace(/\.[^.]+$/, '')}`;
};
const characterImageByKey = Object.entries(characterImageModules).reduce<Record<string, string>>(
(acc, [path, url]) => {
const key = resolveImageKey(path);
if (key) acc[key] = url;
return acc;
},
{},
);
const getBundledCharacterImage = (game: string, character: string, slug: string): string => {
const gameSlug = toSlug(game);
const key = `${gameSlug}/${slug}`;
return characterImageByKey[key] ?? buildCharacterPlaceholder(game, character);
};
// ─────────────────────────────────────────────────────────────────────────────
// Compile bundled game options
// ─────────────────────────────────────────────────────────────────────────────
export const fightingCharactersByGame: Record<string, FightingCharacterOption[]> = Object.fromEntries(
Object.entries(characterNamesByGame).map(([game, characterNames]) => [
game,
characterNames.map((character) => {
const value = toSlug(character);
return {
label: character,
value,
image: getBundledCharacterImage(game, character, value),
dlc: dlcCharactersByGame[game]?.has(character) ?? false,
};
}),
]),
);
/**
* The set of game names that are bundled with the application.
* Used by usePackRegistry to determine if a pack needs to be downloaded.
*/
export const BUNDLED_GAME_NAMES = new Set(Object.keys(characterNamesByGame));
// ─────────────────────────────────────────────────────────────────────────────
// INSTALLED PACK REGISTRY (runtime, populated by usePackRegistry)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Runtime character data for packs that have been downloaded from Gitea.
* Keyed by game display name (same as PackManifest.name) so that
* getCharactersByGame() can look them up with the same key as bundled games.
*/
const installedPackCharacters: Record<string, FightingCharacterOption[]> = {};
const installedPackDefaults: Record<string, { leftCharacter: string; rightCharacter: string }> = {};
/**
* Incremented every time a pack is registered or unregistered.
* Composables subscribe to this ref so Vue re-evaluates computed values
* that depend on installedPackCharacters (which is a plain object, not reactive).
*/
export const installedPacksRevision = ref(0);
/**
* Registers an installed (downloaded) pack so that getCharactersByGame() and
* getDefaultCharactersByGame() return its data.
*
* Called by usePackRegistry when:
* - The composable mounts and an installed pack's manifest is read from disk.
* - A new pack finishes downloading.
*
* Images are served by NodeCG from /assets/<BUNDLE_NAME>/packs/<packId>/characters/.
* The function tries the most common extension; the browser will 404 gracefully
* for missing files (placeholder is shown by the img error handler in the template).
*/
export const registerInstalledPack = (manifest: PackManifest): void => {
const { id, name, palette, characters, defaultPair } = manifest;
const [startColor, endColor] = [palette.start, palette.end];
installedPackCharacters[name] = characters.map((char) => ({
label: char.name,
value: char.slug,
// Images are served at runtime by NodeCG's static asset handler
image: `/packs/${id}/characters/${char.slug}.png`,
dlc: char.dlc ?? false,
// Fallback placeholder uses the same palette as the manifest
_placeholder: buildInstalledPlaceholder(name, char.name, startColor, endColor),
}));
if (defaultPair) {
installedPackDefaults[name] = {
leftCharacter: defaultPair.left,
rightCharacter: defaultPair.right,
};
}
installedPacksRevision.value++;
};
/**
* Removes a previously registered installed pack.
* Called by usePackRegistry when a pack is uninstalled.
*/
export const unregisterInstalledPack = (gameName: string): void => {
delete installedPackCharacters[gameName];
delete installedPackDefaults[gameName];
installedPacksRevision.value++;
};
const buildInstalledPlaceholder = (
game: string,
character: string,
startColor: string,
endColor: string,
): string => {
const initials = character
.split(/\s+/)
.map((p) => p[0])
.join('')
.slice(0, MAX_INITIALS)
.toUpperCase();
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 220" role="img" aria-label="${character}">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${startColor}"/>
<stop offset="100%" stop-color="${endColor}"/>
</linearGradient>
</defs>
<rect width="480" height="220" fill="url(#bg)" rx="18"/>
<circle cx="90" cy="110" r="64" fill="rgba(255,255,255,0.13)"/>
<text x="90" y="130" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="56" font-weight="700">${initials}</text>
<text x="170" y="96" fill="#e2e8f0" font-family="Arial, sans-serif" font-size="20" font-weight="700">${game}</text>
<text x="170" y="145" fill="#ffffff" font-family="Arial, sans-serif" font-size="38" font-weight="700">${character}</text>
</svg>`;
return toDataUrl(svg.trim());
};
// ─────────────────────────────────────────────────────────────────────────────
// Public API
// ─────────────────────────────────────────────────────────────────────────────
/** Returns the character list for a game, checking both bundled and installed packs. */
export const getCharactersByGame = (game: string): FightingCharacterOption[] =>
fightingCharactersByGame[game] ?? installedPackCharacters[game] ?? [];
/** Returns the default character pair for a game, checking both bundled and installed packs. */
export const getDefaultCharactersByGame = (
game: string,
): { leftCharacter: string; rightCharacter: string } | undefined =>
defaultCharacterPairByGame[game] ?? 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"
] ]
} }