mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-05 19:22:07 +00:00
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.
This commit is contained in:
@@ -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.
|
||||
@@ -83,12 +83,12 @@ const updateRound = () => {
|
||||
return;
|
||||
}
|
||||
if (customActive.value) {
|
||||
scoreboardStore.scoreboard.round = customText.value.trim();
|
||||
scoreboardStore.setRound(customText.value.trim());
|
||||
return;
|
||||
}
|
||||
|
||||
const prefix = bracketSide.value ? `${bracketSide.value} ` : '';
|
||||
scoreboardStore.scoreboard.round = `${prefix}${stage.value}`.trim();
|
||||
scoreboardStore.setRound(`${prefix}${stage.value}`.trim());
|
||||
};
|
||||
|
||||
watch([stage, bracketSide, customText, customActive], updateRound);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { stripTwitterPrefix } from '../../../shared/domain/commentary';
|
||||
import { t } from '../i18n';
|
||||
import { useCommentaryStore } from '../../stores/commentary';
|
||||
|
||||
@@ -17,25 +18,18 @@ const twitterRules = [
|
||||
!val || TWITTER_VALID_CHARS.test(val) || t('commentaryTwitterInvalidChars'),
|
||||
];
|
||||
|
||||
function stripAt(value: string): string {
|
||||
return value.startsWith('@') ? value.slice(1) : value;
|
||||
}
|
||||
|
||||
function handleLeftTwitterInput(value: string | number | null) {
|
||||
commentaryStore.leftCommentatorTwitter = value ? stripAt(String(value)) : '';
|
||||
commentaryStore.leftCommentatorTwitter = value ? stripTwitterPrefix(String(value)) : '';
|
||||
}
|
||||
|
||||
function handleRightTwitterInput(value: string | number | null) {
|
||||
commentaryStore.rightCommentatorTwitter = value ? stripAt(String(value)) : '';
|
||||
commentaryStore.rightCommentatorTwitter = value ? stripTwitterPrefix(String(value)) : '';
|
||||
}
|
||||
|
||||
// --- Clear ---
|
||||
|
||||
function clearAll() {
|
||||
commentaryStore.leftCommentator = '';
|
||||
commentaryStore.leftCommentatorTwitter = '';
|
||||
commentaryStore.rightCommentator = '';
|
||||
commentaryStore.rightCommentatorTwitter = '';
|
||||
commentaryStore.clearCommentary();
|
||||
}
|
||||
|
||||
const isAnythingFilled = computed(() =>
|
||||
|
||||
@@ -74,8 +74,7 @@ const character = computed({
|
||||
? scoreboardStore.scoreboard.leftCharacter
|
||||
: scoreboardStore.scoreboard.rightCharacter),
|
||||
set: (v) => {
|
||||
if (isLeft.value) scoreboardStore.scoreboard.leftCharacter = v;
|
||||
else scoreboardStore.scoreboard.rightCharacter = v;
|
||||
scoreboardStore.setSideCharacter(props.side, v);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -21,28 +21,24 @@ const {
|
||||
// Refresca el catálogo al montar y luego cada 15 segundos automáticamente.
|
||||
// Si Gitea no está disponible se usa la caché persistida del replicante.
|
||||
onMounted(() => {
|
||||
packRegistry.fetchRegistry();
|
||||
packRegistry.startRegistryRefresh();
|
||||
});
|
||||
|
||||
const refreshInterval = setInterval(() => {
|
||||
packRegistry.fetchRegistry();
|
||||
}, 15_000);
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(refreshInterval);
|
||||
packRegistry.stopRegistryRefresh();
|
||||
});
|
||||
|
||||
const adjustLeftScore = (delta: number) => {
|
||||
scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore + delta);
|
||||
scoreboardStore.adjustScore('left', delta);
|
||||
};
|
||||
|
||||
const adjustRightScore = (delta: number) => {
|
||||
scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore + delta);
|
||||
scoreboardStore.adjustScore('right', delta);
|
||||
};
|
||||
|
||||
/** Tras una descarga exitosa, activa el juego en el store. */
|
||||
const onPackDownloaded = (gameName: string) => {
|
||||
scoreboardStore.scoreboard.game = gameName;
|
||||
scoreboardStore.setGame(gameName);
|
||||
};
|
||||
|
||||
// ── Estado del diálogo de actualización ───────────────────────────────────────
|
||||
|
||||
@@ -13,14 +13,14 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
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/domain/packs/types';
|
||||
import { useScoreboardStore } from '../../stores/scoreboard';
|
||||
import { usePackRegistry } from './usePackRegistry';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type CharacterOption = ReturnType<typeof getCharactersByGame>[number];
|
||||
export type CharacterOption = FightingCharacterOption;
|
||||
export type CharacterGameContext = ReturnType<typeof useCharacterGame>;
|
||||
export const CHARACTER_GAME_KEY: InjectionKey<CharacterGameContext> = Symbol('characterGame');
|
||||
|
||||
@@ -62,7 +62,7 @@ export function useCharacterGame() {
|
||||
*/
|
||||
const handleGameSelect = (gameName: string) => {
|
||||
if (!gameName) {
|
||||
scoreboardStore.scoreboard.game = '';
|
||||
scoreboardStore.setGame('');
|
||||
return;
|
||||
}
|
||||
if (!packRegistry.isGameAvailable(gameName)) {
|
||||
@@ -72,17 +72,13 @@ export function useCharacterGame() {
|
||||
// Do NOT update the store — the game isn't installed
|
||||
return;
|
||||
}
|
||||
scoreboardStore.scoreboard.game = gameName;
|
||||
scoreboardStore.setGame(gameName);
|
||||
};
|
||||
|
||||
// ── Character state ───────────────────────────────────────────────────────
|
||||
|
||||
const characterOptions = computed(() => {
|
||||
// Subscribing to installedPacksRevision forces Vue to re-evaluate this
|
||||
// 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);
|
||||
return packRegistry.getCharactersByGame(scoreboardStore.scoreboard.game);
|
||||
});
|
||||
const leftCharacterOptions = 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
|
||||
// (installed pack whose registerInstalledPack() hasn't run yet).
|
||||
// Bail out — the installedPacksRevision watcher below will restore state
|
||||
// (installed pack whose manifest has not been loaded into the pack store yet).
|
||||
// Bail out — the characterOptions watcher below will restore state
|
||||
// once the pack becomes available.
|
||||
if (newGame && options.length === 0) return;
|
||||
|
||||
@@ -176,7 +172,7 @@ export function useCharacterGame() {
|
||||
if (!allowed.has(nextRight)) nextRight = '';
|
||||
|
||||
if ((!nextLeft || !nextRight) && (!curLeft || !curRight)) {
|
||||
const defaults = getDefaultCharactersByGame(newGame);
|
||||
const defaults = packRegistry.getDefaultCharactersByGame(newGame);
|
||||
if (defaults) {
|
||||
if (!nextLeft) nextLeft = allowed.has(defaults.leftCharacter) ? defaults.leftCharacter : '';
|
||||
if (!nextRight) nextRight = allowed.has(defaults.rightCharacter) ? defaults.rightCharacter : '';
|
||||
@@ -184,16 +180,16 @@ export function useCharacterGame() {
|
||||
}
|
||||
|
||||
if (allowed.has(nextLeft)) {
|
||||
scoreboardStore.scoreboard.leftCharacter = nextLeft;
|
||||
scoreboardStore.setSideCharacter('left', nextLeft);
|
||||
} else if (!allowed.has(scoreboardStore.scoreboard.leftCharacter)) {
|
||||
scoreboardStore.scoreboard.leftCharacter = '';
|
||||
scoreboardStore.setSideCharacter('left', '');
|
||||
leftCharacterInput.value = '';
|
||||
}
|
||||
|
||||
if (allowed.has(nextRight)) {
|
||||
scoreboardStore.scoreboard.rightCharacter = nextRight;
|
||||
scoreboardStore.setSideCharacter('right', nextRight);
|
||||
} else if (!allowed.has(scoreboardStore.scoreboard.rightCharacter)) {
|
||||
scoreboardStore.scoreboard.rightCharacter = '';
|
||||
scoreboardStore.setSideCharacter('right', '');
|
||||
rightCharacterInput.value = '';
|
||||
}
|
||||
},
|
||||
@@ -232,14 +228,12 @@ export function useCharacterGame() {
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// When an installed pack becomes available (e.g. after page refresh while
|
||||
// the pack loads asynchronously), re-validate and restore the characters
|
||||
// that are already in the store but couldn't be confirmed before.
|
||||
watch(installedPacksRevision, () => {
|
||||
// When an installed pack manifest becomes available, re-validate characters
|
||||
// already present in the replicated scoreboard state.
|
||||
watch(characterOptions, (options) => {
|
||||
const game = scoreboardStore.scoreboard.game;
|
||||
if (!game) return;
|
||||
|
||||
const options = getCharactersByGame(game);
|
||||
if (options.length === 0) return;
|
||||
|
||||
const allowed = new Set(options.map((o) => o.value));
|
||||
@@ -251,14 +245,14 @@ export function useCharacterGame() {
|
||||
if (leftCharacter && allowed.has(leftCharacter)) {
|
||||
leftCharacterInput.value = options.find((o) => o.value === leftCharacter)?.label ?? '';
|
||||
} else if (leftCharacter && !allowed.has(leftCharacter)) {
|
||||
scoreboardStore.scoreboard.leftCharacter = '';
|
||||
scoreboardStore.setSideCharacter('left', '');
|
||||
leftCharacterInput.value = '';
|
||||
}
|
||||
|
||||
if (rightCharacter && allowed.has(rightCharacter)) {
|
||||
rightCharacterInput.value = options.find((o) => o.value === rightCharacter)?.label ?? '';
|
||||
} else if (rightCharacter && !allowed.has(rightCharacter)) {
|
||||
scoreboardStore.scoreboard.rightCharacter = '';
|
||||
scoreboardStore.setSideCharacter('right', '');
|
||||
rightCharacterInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { sendNodecgMessage } from '../../../nodecg/browser/messages';
|
||||
import { sendIntegrationMessage } from '../../services/integration-message-service';
|
||||
|
||||
// ─── Tipos ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -66,8 +66,6 @@ export interface UseIntegrationOptions {
|
||||
playersStore: PlayersStore;
|
||||
}
|
||||
|
||||
// ─── Utilidad para mensajes NodeCG ─────────────────────────────────────────────
|
||||
|
||||
// ─── Composable ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useIntegration(options: UseIntegrationOptions) {
|
||||
@@ -155,8 +153,9 @@ export function useIntegration(options: UseIntegrationOptions) {
|
||||
tournamentsError.value = '';
|
||||
loadingTournaments.value = true;
|
||||
try {
|
||||
const tournaments = await sendNodecgMessage<IntegrationTournament[]>(
|
||||
`${messagePrefix}:fetchRecentTournaments`,
|
||||
const tournaments = await sendIntegrationMessage<IntegrationTournament[]>(
|
||||
messagePrefix,
|
||||
'fetchRecentTournaments',
|
||||
{ token: currentToken },
|
||||
);
|
||||
hasValidatedToken.value = true;
|
||||
@@ -194,8 +193,9 @@ export function useIntegration(options: UseIntegrationOptions) {
|
||||
players.value = [];
|
||||
|
||||
try {
|
||||
const importedPlayers = await sendNodecgMessage<IntegrationPlayer[]>(
|
||||
`${messagePrefix}:fetchTournamentPlayers`,
|
||||
const importedPlayers = await sendIntegrationMessage<IntegrationPlayer[]>(
|
||||
messagePrefix,
|
||||
'fetchTournamentPlayers',
|
||||
{ token: token.value.trim(), slug: tournament.slug },
|
||||
);
|
||||
players.value = importedPlayers;
|
||||
@@ -315,8 +315,9 @@ export function useIntegration(options: UseIntegrationOptions) {
|
||||
if (!oauthSessionId.value) return;
|
||||
|
||||
try {
|
||||
const status = await sendNodecgMessage<OAuthStatusResponse>(
|
||||
`${messagePrefix}:getOAuthSessionStatus`,
|
||||
const status = await sendIntegrationMessage<OAuthStatusResponse>(
|
||||
messagePrefix,
|
||||
'getOAuthSessionStatus',
|
||||
{ sessionId: oauthSessionId.value },
|
||||
);
|
||||
|
||||
@@ -352,8 +353,9 @@ export function useIntegration(options: UseIntegrationOptions) {
|
||||
stopPolling();
|
||||
|
||||
try {
|
||||
const session = await sendNodecgMessage<OAuthSessionResponse>(
|
||||
`${messagePrefix}:createOAuthSession`,
|
||||
const session = await sendIntegrationMessage<OAuthSessionResponse>(
|
||||
messagePrefix,
|
||||
'createOAuthSession',
|
||||
{},
|
||||
);
|
||||
oauthSessionId.value = session.sessionId;
|
||||
|
||||
@@ -1,235 +1,68 @@
|
||||
// src/dashboard/scoreboard/composables/usePackRegistry.ts
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Singleton composable. The first caller sets up NodeCG replicant listeners;
|
||||
// subsequent calls return the same reactive state. This avoids duplicate event
|
||||
// listeners when multiple components call usePackRegistry().
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { computed, ref, type ComputedRef, type InjectionKey } from 'vue';
|
||||
import {
|
||||
registerInstalledPack,
|
||||
unregisterInstalledPack,
|
||||
} from '../../../shared/fighting-characters';
|
||||
import { sendNodecgCommand, sendNodecgMessage } from '../../../nodecg/browser/messages';
|
||||
import { createPackBrowserReplicants } from '../../../nodecg/browser/packReplicants';
|
||||
import { messageNames } from '../../../nodecg/messageNames';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import type { InjectionKey, Ref } from 'vue';
|
||||
import { usePacksStore } from '../../stores/packs';
|
||||
import type { DefaultCharacterPair, FightingCharacterOption } from '../../../shared/domain/packs/characters';
|
||||
import type {
|
||||
GameSelectOption,
|
||||
PackDownloadState,
|
||||
PackManifest,
|
||||
PackRegistry,
|
||||
PackUpdateInfo,
|
||||
} from '../../../shared/domain/packs/types';
|
||||
|
||||
// ── NodeCG global type declarations ──────────────────────────────────────────
|
||||
// NodeCG injects these into the browser window via its bundle script.
|
||||
|
||||
// ── 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, PackUpdateInfo>>({});
|
||||
|
||||
// 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;
|
||||
|
||||
sendNodecgMessage<PackManifest>(messageNames.packs.readLocalManifest, packId)
|
||||
.then((manifest) => {
|
||||
registerInstalledPack(manifest);
|
||||
loadedManifestIds.add(packId);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error(`[usePackRegistry] Failed to load manifest for "${packId}":`, err);
|
||||
});
|
||||
};
|
||||
|
||||
// ── Replicant setup (runs once) ───────────────────────────────────────────────
|
||||
|
||||
const initReplicants = (): void => {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const { registryRep, installedRep, statesRep, updatesRep, waitUntilReady } =
|
||||
createPackBrowserReplicants();
|
||||
|
||||
waitUntilReady().then(() => {
|
||||
// Hydrate initial values
|
||||
registry.value = registryRep.value;
|
||||
installedPackIds.value = installedRep.value ?? [];
|
||||
downloadStates.value = statesRep.value ?? {};
|
||||
availableUpdates.value = updatesRep.value ?? {};
|
||||
|
||||
// Load manifests for all installed packs
|
||||
for (const id of installedPackIds.value) {
|
||||
loadInstalledManifest(id);
|
||||
}
|
||||
|
||||
// Subscribe to changes
|
||||
registryRep.on('change', (val) => {
|
||||
registry.value = val;
|
||||
});
|
||||
|
||||
installedRep.on('change', (newVal, oldVal) => {
|
||||
const next = newVal ?? [];
|
||||
const prev = oldVal ?? [];
|
||||
installedPackIds.value = next;
|
||||
|
||||
// Load manifests for newly installed packs
|
||||
const added = next.filter((id) => !prev.includes(id));
|
||||
for (const id of added) {
|
||||
loadInstalledManifest(id);
|
||||
}
|
||||
|
||||
// Unregister packs that were removed
|
||||
const removed = prev.filter((id) => !next.includes(id));
|
||||
for (const id of removed) {
|
||||
const gameName = getGameNameById(id);
|
||||
unregisterInstalledPack(gameName);
|
||||
loadedManifestIds.delete(id);
|
||||
}
|
||||
});
|
||||
|
||||
statesRep.on('change', (val) => {
|
||||
downloadStates.value = val ?? {};
|
||||
});
|
||||
|
||||
updatesRep.on('change', (val) => {
|
||||
availableUpdates.value = val ?? {};
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a pack ID (e.g. "street-fighter-6"), returns the matching game name
|
||||
* from the current registry, or an empty string if the registry isn't loaded.
|
||||
*/
|
||||
const getGameNameById = (packId: string): string =>
|
||||
registry.value?.packs.find((p) => p.id === packId)?.name ?? '';
|
||||
|
||||
// ── Public composable ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface PackRegistryContext {
|
||||
/** Full registry fetched from Gitea (null until first fetch). */
|
||||
registry: typeof registry;
|
||||
/** IDs of packs installed on disk (bundled packs are NOT in this list). */
|
||||
installedPackIds: typeof installedPackIds;
|
||||
/** Per-pack download state. */
|
||||
downloadStates: typeof downloadStates;
|
||||
/** Checks if a game is available (bundled OR installed). */
|
||||
registry: Ref<PackRegistry | null>;
|
||||
installedPackIds: Ref<string[]>;
|
||||
downloadStates: Ref<Record<string, PackDownloadState>>;
|
||||
isGameAvailable: (gameName: string) => boolean;
|
||||
/** Returns the download state for a pack, or a default idle state. */
|
||||
getDownloadState: (packId: string) => PackDownloadState;
|
||||
/** All games from the registry, enriched with availability info. */
|
||||
allGameOptions: ReturnType<typeof buildAllGameOptions>;
|
||||
/** Tells the extension to fetch the latest registry.json from Gitea. */
|
||||
getCharactersByGame: (gameName: string) => FightingCharacterOption[];
|
||||
getDefaultCharactersByGame: (gameName: string) => DefaultCharacterPair | undefined;
|
||||
allGameOptions: Ref<GameSelectOption[]>;
|
||||
fetchRegistry: () => void;
|
||||
/** Tells the extension to download and install a pack. */
|
||||
startRegistryRefresh: (intervalMs?: number) => void;
|
||||
stopRegistryRefresh: () => void;
|
||||
downloadPack: (packId: string) => void;
|
||||
/** Tells the extension to uninstall a pack and delete its files. */
|
||||
uninstallPack: (packId: string) => void;
|
||||
/** Tells the extension to download and apply an update for an installed pack. */
|
||||
updatePack: (packId: string) => void;
|
||||
/** Map of packId → version info for packs that have a newer version available. */
|
||||
availableUpdates: typeof availableUpdates;
|
||||
/** Total number of packs with available updates. */
|
||||
updateCount: ComputedRef<number>;
|
||||
/** Human-readable file size. */
|
||||
formatBytes: typeof formatBytes;
|
||||
/** Returns the URL for the pack's logo served by NodeCG (installed packs only). */
|
||||
availableUpdates: Ref<Record<string, PackUpdateInfo>>;
|
||||
updateCount: Ref<number>;
|
||||
formatBytes: (bytes: number) => string;
|
||||
getLocalLogoUrl: (packId: string) => string;
|
||||
}
|
||||
|
||||
export const PACK_REGISTRY_KEY: InjectionKey<PackRegistryContext> = Symbol('packRegistry');
|
||||
|
||||
const buildAllGameOptions = () =>
|
||||
computed<GameSelectOption[]>(() => {
|
||||
// Registry not loaded yet — return empty list
|
||||
if (!registry.value) return [];
|
||||
|
||||
return registry.value.packs.map((entry) => ({
|
||||
label: entry.name,
|
||||
value: entry.name,
|
||||
available: installedPackIds.value.includes(entry.id),
|
||||
registryEntry: entry,
|
||||
updateInfo: availableUpdates.value[entry.id],
|
||||
}));
|
||||
});
|
||||
|
||||
export function usePackRegistry(): PackRegistryContext {
|
||||
initReplicants();
|
||||
const packsStore = usePacksStore();
|
||||
packsStore.initialize();
|
||||
|
||||
const allGameOptions = buildAllGameOptions();
|
||||
|
||||
const isGameAvailable = (gameName: string): boolean => {
|
||||
const entry = registry.value?.packs.find((p) => p.name === gameName);
|
||||
if (!entry) return false;
|
||||
return installedPackIds.value.includes(entry.id);
|
||||
};
|
||||
|
||||
const getDownloadState = (packId: string): PackDownloadState =>
|
||||
downloadStates.value[packId] ?? { status: 'idle', progress: 0 };
|
||||
|
||||
const getLocalLogoUrl = (packId: string): string =>
|
||||
`/packs/${packId}/logo.png`;
|
||||
|
||||
const fetchRegistry = (): void => {
|
||||
sendNodecgCommand(messageNames.packs.fetchRegistry).catch((err: unknown) => {
|
||||
console.error('[usePackRegistry] fetchPackRegistry failed:', err);
|
||||
});
|
||||
};
|
||||
|
||||
const downloadPack = (packId: string): void => {
|
||||
sendNodecgCommand(messageNames.packs.download, packId).catch((err: unknown) => {
|
||||
console.error(`[usePackRegistry] downloadPack "${packId}" failed:`, err);
|
||||
});
|
||||
};
|
||||
|
||||
const uninstallPack = (packId: string): void => {
|
||||
sendNodecgCommand(messageNames.packs.uninstall, packId).catch((err: unknown) => {
|
||||
console.error(`[usePackRegistry] uninstallPack "${packId}" failed:`, err);
|
||||
});
|
||||
};
|
||||
|
||||
const updatePack = (packId: string): void => {
|
||||
sendNodecgCommand(messageNames.packs.update, packId).catch((err: unknown) => {
|
||||
console.error(`[usePackRegistry] updatePack "${packId}" failed:`, err);
|
||||
});
|
||||
};
|
||||
|
||||
const updateCount = computed(() => Object.keys(availableUpdates.value).length);
|
||||
const {
|
||||
registry,
|
||||
installedPackIds,
|
||||
downloadStates,
|
||||
availableUpdates,
|
||||
allGameOptions,
|
||||
updateCount,
|
||||
} = storeToRefs(packsStore);
|
||||
|
||||
return {
|
||||
registry,
|
||||
installedPackIds,
|
||||
downloadStates,
|
||||
isGameAvailable,
|
||||
getDownloadState,
|
||||
isGameAvailable: packsStore.isGameAvailable,
|
||||
getDownloadState: packsStore.getDownloadState,
|
||||
getCharactersByGame: packsStore.getCharactersByGame,
|
||||
getDefaultCharactersByGame: packsStore.getDefaultCharactersByGame,
|
||||
allGameOptions,
|
||||
fetchRegistry,
|
||||
downloadPack,
|
||||
uninstallPack,
|
||||
updatePack,
|
||||
fetchRegistry: packsStore.fetchRegistry,
|
||||
startRegistryRefresh: packsStore.startRegistryRefresh,
|
||||
stopRegistryRefresh: packsStore.stopRegistryRefresh,
|
||||
downloadPack: packsStore.downloadPack,
|
||||
uninstallPack: packsStore.uninstallPack,
|
||||
updatePack: packsStore.updatePack,
|
||||
availableUpdates,
|
||||
updateCount,
|
||||
formatBytes,
|
||||
getLocalLogoUrl,
|
||||
formatBytes: packsStore.formatBytes,
|
||||
getLocalLogoUrl: packsStore.getLocalLogoUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, ref, watch, watchEffect } from 'vue';
|
||||
import { useScoreboardStore } from '../../stores/scoreboard';
|
||||
import { usePlayersStore } from '../../stores/players';
|
||||
import type { Schemas } from '../../../types';
|
||||
import { createPlayerId, normalizePlayerName } from '../../../shared/domain/players/state';
|
||||
import { t } from '../i18n';
|
||||
import { useCountryFilter } from './useCountryFilter';
|
||||
|
||||
@@ -16,34 +17,6 @@ export const CUSTOM_RIGHT_PLAYER_ID = '__custom_right_player__';
|
||||
// Pure helpers (no Vue reactivity)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const normalizeName = (value: string) => value.trim().toLowerCase();
|
||||
|
||||
/**
|
||||
* Generates a unique slug-based player ID that does not collide with
|
||||
* existing player keys in the store.
|
||||
*/
|
||||
const createPlayerId = (name: string, players: Schemas.Players): string => {
|
||||
const base = name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, '-') || 'player';
|
||||
|
||||
let index = 1;
|
||||
let candidate = base;
|
||||
while (players[candidate]) {
|
||||
index += 1;
|
||||
candidate = `${base}-${index}`;
|
||||
}
|
||||
return candidate;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Encapsulates all reactive state and handlers for one side of the scoreboard
|
||||
* (left or right). Call once per side inside the corresponding component.
|
||||
@@ -63,32 +36,28 @@ export function usePlayerSide(side: 'left' | 'right') {
|
||||
const playerId = computed({
|
||||
get: () => (isLeft ? scoreboardStore.scoreboard.leftPlayerId : scoreboardStore.scoreboard.rightPlayerId),
|
||||
set: (v) => {
|
||||
if (isLeft) scoreboardStore.scoreboard.leftPlayerId = v;
|
||||
else scoreboardStore.scoreboard.rightPlayerId = v;
|
||||
scoreboardStore.setSidePlayerId(side, v);
|
||||
},
|
||||
});
|
||||
|
||||
const nameOverride = computed({
|
||||
get: () => (isLeft ? scoreboardStore.scoreboard.leftNameOverride : scoreboardStore.scoreboard.rightNameOverride),
|
||||
set: (v) => {
|
||||
if (isLeft) scoreboardStore.scoreboard.leftNameOverride = v;
|
||||
else scoreboardStore.scoreboard.rightNameOverride = v;
|
||||
scoreboardStore.setSideNameOverride(side, v);
|
||||
},
|
||||
});
|
||||
|
||||
const teamOverride = computed({
|
||||
get: () => (isLeft ? scoreboardStore.scoreboard.leftTeamOverride : scoreboardStore.scoreboard.rightTeamOverride),
|
||||
set: (v) => {
|
||||
if (isLeft) scoreboardStore.scoreboard.leftTeamOverride = v;
|
||||
else scoreboardStore.scoreboard.rightTeamOverride = v;
|
||||
scoreboardStore.setSideTeamOverride(side, v);
|
||||
},
|
||||
});
|
||||
|
||||
const countryOverride = computed({
|
||||
get: () => (isLeft ? scoreboardStore.scoreboard.leftCountryOverride : scoreboardStore.scoreboard.rightCountryOverride),
|
||||
set: (v) => {
|
||||
if (isLeft) scoreboardStore.scoreboard.leftCountryOverride = v;
|
||||
else scoreboardStore.scoreboard.rightCountryOverride = v;
|
||||
scoreboardStore.setSideCountryOverride(side, v);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -145,10 +114,10 @@ export function usePlayerSide(side: 'left' | 'right') {
|
||||
};
|
||||
|
||||
const playerExistsByGamertag = (name: string): boolean => {
|
||||
const normalized = normalizeName(name);
|
||||
const normalized = normalizePlayerName(name);
|
||||
return Boolean(normalized)
|
||||
&& Object.values(playersStore.players).some(
|
||||
(p) => normalizeName(p.gamertag || '') === normalized,
|
||||
(p) => normalizePlayerName(p.gamertag || '') === normalized,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useHead } from '@unhead/vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import bundlePackage from '../../../../package.json';
|
||||
import { graphicsSettingsReplicant } from '../../../nodecg/browser/replicants';
|
||||
import { useGraphicsSettingsStore } from '../../stores/graphics-settings';
|
||||
import { t } from '../i18n';
|
||||
|
||||
defineOptions({ name: 'GraphicsView' });
|
||||
@@ -24,6 +24,7 @@ type GraphicCard = {
|
||||
|
||||
useHead(() => ({ title: t('graphicsTitle') }));
|
||||
|
||||
const graphicsSettingsStore = useGraphicsSettingsStore();
|
||||
const graphics = computed<GraphicConfig[]>(() => bundlePackage.nodecg?.graphics ?? []);
|
||||
|
||||
const baseUrl = computed(() => {
|
||||
@@ -60,7 +61,7 @@ const commentaryGraphic = computed(() =>
|
||||
const selectedScoreboardSkin = ref<string>('');
|
||||
|
||||
watch(
|
||||
[scoreboardGraphics, () => graphicsSettingsReplicant?.data?.scoreboardSkin],
|
||||
[scoreboardGraphics, () => graphicsSettingsStore.settings.scoreboardSkin],
|
||||
([availableSkins, replicatedSkin]) => {
|
||||
if (availableSkins.length === 0) {
|
||||
selectedScoreboardSkin.value = '';
|
||||
@@ -87,18 +88,15 @@ watch(
|
||||
watch(
|
||||
selectedScoreboardSkin,
|
||||
(value) => {
|
||||
if (!value || !graphicsSettingsReplicant) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (graphicsSettingsReplicant.data?.scoreboardSkin === value) {
|
||||
if (graphicsSettingsStore.settings.scoreboardSkin === value) {
|
||||
return;
|
||||
}
|
||||
|
||||
graphicsSettingsReplicant.data = {
|
||||
scoreboardSkin: value,
|
||||
};
|
||||
graphicsSettingsReplicant.save();
|
||||
graphicsSettingsStore.setScoreboardSkin(value);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { sendNodecgMessage } from '../../nodecg/browser/messages';
|
||||
|
||||
export const sendIntegrationMessage = <T>(
|
||||
messagePrefix: string,
|
||||
action: string,
|
||||
payload: unknown,
|
||||
): Promise<T> =>
|
||||
sendNodecgMessage<T>(`${messagePrefix}:${action}`, payload);
|
||||
@@ -0,0 +1,79 @@
|
||||
import { sendNodecgCommand, sendNodecgMessage } from '../../nodecg/browser/messages';
|
||||
import { createPackBrowserReplicants } from '../../nodecg/browser/packReplicants';
|
||||
import { messageNames } from '../../nodecg/messageNames';
|
||||
import type {
|
||||
PackDownloadState,
|
||||
PackManifest,
|
||||
PackRegistry,
|
||||
PackUpdateInfo,
|
||||
} from '../../shared/domain/packs/types';
|
||||
|
||||
export interface PackReplicantHandlers {
|
||||
onRegistryChanged: (value: PackRegistry | null) => void;
|
||||
onInstalledPacksChanged: (value: string[], previousValue: string[]) => void;
|
||||
onDownloadStatesChanged: (value: Record<string, PackDownloadState>) => void;
|
||||
onAvailableUpdatesChanged: (value: Record<string, PackUpdateInfo>) => void;
|
||||
}
|
||||
|
||||
export interface PackService {
|
||||
subscribe: (handlers: PackReplicantHandlers) => Promise<() => void>;
|
||||
fetchRegistry: () => Promise<void>;
|
||||
downloadPack: (packId: string) => Promise<void>;
|
||||
uninstallPack: (packId: string) => Promise<void>;
|
||||
updatePack: (packId: string) => Promise<void>;
|
||||
readLocalManifest: (packId: string) => Promise<PackManifest>;
|
||||
}
|
||||
|
||||
export const createPackService = (): PackService => {
|
||||
const subscribe = async (handlers: PackReplicantHandlers): Promise<() => void> => {
|
||||
const {
|
||||
registryRep,
|
||||
installedRep,
|
||||
statesRep,
|
||||
updatesRep,
|
||||
waitUntilReady,
|
||||
} = createPackBrowserReplicants();
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
handlers.onRegistryChanged(registryRep.value ?? null);
|
||||
handlers.onInstalledPacksChanged(installedRep.value ?? [], []);
|
||||
handlers.onDownloadStatesChanged(statesRep.value ?? {});
|
||||
handlers.onAvailableUpdatesChanged(updatesRep.value ?? {});
|
||||
|
||||
const onRegistryChanged = (value: PackRegistry | null): void => {
|
||||
handlers.onRegistryChanged(value ?? null);
|
||||
};
|
||||
const onInstalledPacksChanged = (value: string[], previousValue?: string[]): void => {
|
||||
handlers.onInstalledPacksChanged(value ?? [], previousValue ?? []);
|
||||
};
|
||||
const onDownloadStatesChanged = (value: Record<string, PackDownloadState>): void => {
|
||||
handlers.onDownloadStatesChanged(value ?? {});
|
||||
};
|
||||
const onAvailableUpdatesChanged = (value: Record<string, PackUpdateInfo>): void => {
|
||||
handlers.onAvailableUpdatesChanged(value ?? {});
|
||||
};
|
||||
|
||||
registryRep.on('change', onRegistryChanged);
|
||||
installedRep.on('change', onInstalledPacksChanged);
|
||||
statesRep.on('change', onDownloadStatesChanged);
|
||||
updatesRep.on('change', onAvailableUpdatesChanged);
|
||||
|
||||
return () => {
|
||||
registryRep.off('change', onRegistryChanged);
|
||||
installedRep.off('change', onInstalledPacksChanged);
|
||||
statesRep.off('change', onDownloadStatesChanged);
|
||||
updatesRep.off('change', onAvailableUpdatesChanged);
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
fetchRegistry: () => sendNodecgCommand(messageNames.packs.fetchRegistry),
|
||||
downloadPack: (packId: string) => sendNodecgCommand(messageNames.packs.download, packId),
|
||||
uninstallPack: (packId: string) => sendNodecgCommand(messageNames.packs.uninstall, packId),
|
||||
updatePack: (packId: string) => sendNodecgCommand(messageNames.packs.update, packId),
|
||||
readLocalManifest: (packId: string) =>
|
||||
sendNodecgMessage<PackManifest>(messageNames.packs.readLocalManifest, packId),
|
||||
};
|
||||
};
|
||||
+29
-10
@@ -1,4 +1,10 @@
|
||||
import { ref, watch, type Ref } from 'vue';
|
||||
import { commentaryReplicant, graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../nodecg/browser/replicants';
|
||||
import { normalizeCommentary } from '../../shared/domain/commentary';
|
||||
import { normalizeGraphicsSettings } from '../../shared/domain/graphics';
|
||||
import { normalizePlayers } from '../../shared/domain/players/state';
|
||||
import { normalizeScoreboard } from '../../shared/domain/scoreboard';
|
||||
import type { Schemas } from '../../types';
|
||||
|
||||
interface ReplicantLike<T> {
|
||||
data: T | undefined;
|
||||
@@ -36,7 +42,7 @@ export const writeStorageSnapshot = <T>(storageKey: string, value: T): void => {
|
||||
}
|
||||
};
|
||||
|
||||
export const syncStateWithReplicant = <T>(
|
||||
const syncStateWithReplicant = <T>(
|
||||
state: Ref<T>,
|
||||
replicant: ReplicantLike<T> | undefined,
|
||||
normalize: (input: unknown) => T,
|
||||
@@ -44,24 +50,21 @@ export const syncStateWithReplicant = <T>(
|
||||
): void => {
|
||||
const isApplyingReplicant = ref(false);
|
||||
const persistSnapshot = (value: T): void => {
|
||||
if (!storageKey) {
|
||||
return;
|
||||
if (storageKey) {
|
||||
writeStorageSnapshot(storageKey, value);
|
||||
}
|
||||
|
||||
writeStorageSnapshot(storageKey, value);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => replicant?.data,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
isApplyingReplicant.value = true;
|
||||
state.value = normalize(value);
|
||||
isApplyingReplicant.value = false;
|
||||
|
||||
persistSnapshot(state.value);
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
@@ -70,16 +73,32 @@ export const syncStateWithReplicant = <T>(
|
||||
watch(
|
||||
state,
|
||||
(value) => {
|
||||
persistSnapshot(value);
|
||||
const normalized = normalize(value);
|
||||
persistSnapshot(normalized);
|
||||
|
||||
if (isApplyingReplicant.value || !replicant) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Replicants remain the source of truth for server/browser synchronization.
|
||||
replicant.data = normalize(value);
|
||||
replicant.data = normalized;
|
||||
replicant.save();
|
||||
},
|
||||
{ deep: true, flush: 'sync' },
|
||||
);
|
||||
};
|
||||
|
||||
export const syncScoreboardState = (state: Ref<Schemas.Scoreboard>, storageKey: string): void => {
|
||||
syncStateWithReplicant(state, scoreboardReplicant, normalizeScoreboard, storageKey);
|
||||
};
|
||||
|
||||
export const syncPlayersState = (state: Ref<Schemas.Players>, storageKey: string): void => {
|
||||
syncStateWithReplicant(state, playersReplicant, normalizePlayers, storageKey);
|
||||
};
|
||||
|
||||
export const syncCommentaryState = (state: Ref<Schemas.Commentary>): void => {
|
||||
syncStateWithReplicant(state, commentaryReplicant, normalizeCommentary);
|
||||
};
|
||||
|
||||
export const syncGraphicsSettingsState = (state: Ref<Schemas.GraphicsSettings>): void => {
|
||||
syncStateWithReplicant(state, graphicsSettingsReplicant, normalizeGraphicsSettings);
|
||||
};
|
||||
@@ -1,32 +1,24 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import { commentaryReplicant } from '../../nodecg/browser/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 : '',
|
||||
};
|
||||
};
|
||||
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 });
|
||||
const replicant = commentaryReplicant;
|
||||
syncStateWithReplicant(commentary, replicant, normalizeCommentary);
|
||||
syncCommentaryState(commentary);
|
||||
|
||||
const setCommentary = (value: Commentary): void => {
|
||||
commentary.value = normalizeCommentary(value);
|
||||
};
|
||||
|
||||
const clearCommentary = (): void => {
|
||||
commentary.value = { ...defaultCommentary };
|
||||
};
|
||||
|
||||
const leftCommentator = computed({
|
||||
get: () => commentary.value.leftCommentator,
|
||||
@@ -68,13 +60,8 @@ export const useCommentaryStore = defineStore('commentary', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const swapCommentators = () => {
|
||||
commentary.value = {
|
||||
leftCommentator: commentary.value.rightCommentator,
|
||||
leftCommentatorTwitter: commentary.value.rightCommentatorTwitter,
|
||||
rightCommentator: commentary.value.leftCommentator,
|
||||
rightCommentatorTwitter: commentary.value.leftCommentatorTwitter,
|
||||
};
|
||||
const swapCommentators = (): void => {
|
||||
commentary.value = swapCommentary(commentary.value);
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -83,6 +70,8 @@ export const useCommentaryStore = defineStore('commentary', () => {
|
||||
leftCommentatorTwitter,
|
||||
rightCommentator,
|
||||
rightCommentatorTwitter,
|
||||
setCommentary,
|
||||
clearCommentary,
|
||||
swapCommentators,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
defaultGraphicsSettings,
|
||||
normalizeGraphicsSettings,
|
||||
type GraphicsSettings,
|
||||
} from '../../shared/domain/graphics';
|
||||
import { syncGraphicsSettingsState } from '../services/replicant-state-service';
|
||||
|
||||
export const useGraphicsSettingsStore = defineStore('graphics-settings', () => {
|
||||
const settings = ref<GraphicsSettings>({ ...defaultGraphicsSettings });
|
||||
|
||||
syncGraphicsSettingsState(settings);
|
||||
|
||||
const setSettings = (value: GraphicsSettings): void => {
|
||||
settings.value = normalizeGraphicsSettings(value);
|
||||
};
|
||||
|
||||
const setScoreboardSkin = (scoreboardSkin: string): void => {
|
||||
settings.value = normalizeGraphicsSettings({
|
||||
...settings.value,
|
||||
scoreboardSkin,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
settings,
|
||||
setSettings,
|
||||
setScoreboardSkin,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import { createPackService } from '../services/pack-service';
|
||||
import {
|
||||
buildCharactersByGame,
|
||||
buildDefaultCharactersByGame,
|
||||
type DefaultCharacterPair,
|
||||
type FightingCharacterOption,
|
||||
} from '../../shared/domain/packs/characters';
|
||||
import type {
|
||||
GameSelectOption,
|
||||
PackDownloadState,
|
||||
PackManifest,
|
||||
PackRegistry,
|
||||
PackUpdateInfo,
|
||||
} from '../../shared/domain/packs/types';
|
||||
|
||||
const packService = createPackService();
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const getLocalLogoUrl = (packId: string): string => `/packs/${packId}/logo.png`;
|
||||
|
||||
export const usePacksStore = defineStore('packs', () => {
|
||||
const initialized = ref(false);
|
||||
const registry = ref<PackRegistry | null>(null);
|
||||
const installedPackIds = ref<string[]>([]);
|
||||
const downloadStates = ref<Record<string, PackDownloadState>>({});
|
||||
const availableUpdates = ref<Record<string, PackUpdateInfo>>({});
|
||||
const installedManifests = ref<Record<string, PackManifest>>({});
|
||||
const loadingManifestIds = new Set<string>();
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let registryRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const installedManifestList = computed(() =>
|
||||
installedPackIds.value
|
||||
.map((packId) => installedManifests.value[packId])
|
||||
.filter((manifest): manifest is PackManifest => Boolean(manifest)),
|
||||
);
|
||||
|
||||
const charactersByGame = computed(() => buildCharactersByGame(installedManifestList.value));
|
||||
const defaultCharactersByGame = computed(() => buildDefaultCharactersByGame(installedManifestList.value));
|
||||
|
||||
const allGameOptions = computed<GameSelectOption[]>(() => {
|
||||
if (!registry.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return registry.value.packs.map((entry) => ({
|
||||
label: entry.name,
|
||||
value: entry.name,
|
||||
available: installedPackIds.value.includes(entry.id),
|
||||
registryEntry: entry,
|
||||
updateInfo: availableUpdates.value[entry.id],
|
||||
}));
|
||||
});
|
||||
|
||||
const updateCount = computed(() => Object.keys(availableUpdates.value).length);
|
||||
|
||||
const loadInstalledManifest = (packId: string): void => {
|
||||
if (installedManifests.value[packId] || loadingManifestIds.has(packId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadingManifestIds.add(packId);
|
||||
packService.readLocalManifest(packId)
|
||||
.then((manifest) => {
|
||||
installedManifests.value = {
|
||||
...installedManifests.value,
|
||||
[packId]: manifest,
|
||||
};
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error(`[packs] Failed to load manifest for "${packId}":`, error);
|
||||
})
|
||||
.finally(() => {
|
||||
loadingManifestIds.delete(packId);
|
||||
});
|
||||
};
|
||||
|
||||
const syncInstalledManifests = (nextPackIds: string[]): void => {
|
||||
const nextSet = new Set(nextPackIds);
|
||||
const nextManifests: Record<string, PackManifest> = {};
|
||||
|
||||
Object.entries(installedManifests.value).forEach(([packId, manifest]) => {
|
||||
if (nextSet.has(packId)) {
|
||||
nextManifests[packId] = manifest;
|
||||
}
|
||||
});
|
||||
installedManifests.value = nextManifests;
|
||||
|
||||
nextPackIds.forEach(loadInstalledManifest);
|
||||
};
|
||||
|
||||
const initialize = (): void => {
|
||||
if (initialized.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
initialized.value = true;
|
||||
packService.subscribe({
|
||||
onRegistryChanged: (value) => {
|
||||
registry.value = value;
|
||||
},
|
||||
onInstalledPacksChanged: (value) => {
|
||||
installedPackIds.value = [...value];
|
||||
syncInstalledManifests(value);
|
||||
},
|
||||
onDownloadStatesChanged: (value) => {
|
||||
downloadStates.value = { ...value };
|
||||
},
|
||||
onAvailableUpdatesChanged: (value) => {
|
||||
availableUpdates.value = { ...value };
|
||||
},
|
||||
})
|
||||
.then((dispose) => {
|
||||
unsubscribe = dispose;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
initialized.value = false;
|
||||
console.error('[packs] Failed to subscribe to pack replicants:', error);
|
||||
});
|
||||
};
|
||||
|
||||
const dispose = (): void => {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
if (registryRefreshTimer) {
|
||||
clearInterval(registryRefreshTimer);
|
||||
registryRefreshTimer = null;
|
||||
}
|
||||
initialized.value = false;
|
||||
};
|
||||
|
||||
const runCommand = (label: string, command: () => Promise<void>): void => {
|
||||
command().catch((error: unknown) => {
|
||||
console.error(`[packs] ${label} failed:`, error);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchRegistry = (): void => {
|
||||
runCommand('fetchRegistry', packService.fetchRegistry);
|
||||
};
|
||||
|
||||
const startRegistryRefresh = (intervalMs = 15_000): void => {
|
||||
fetchRegistry();
|
||||
if (registryRefreshTimer) {
|
||||
return;
|
||||
}
|
||||
registryRefreshTimer = setInterval(fetchRegistry, intervalMs);
|
||||
};
|
||||
|
||||
const stopRegistryRefresh = (): void => {
|
||||
if (!registryRefreshTimer) {
|
||||
return;
|
||||
}
|
||||
clearInterval(registryRefreshTimer);
|
||||
registryRefreshTimer = null;
|
||||
};
|
||||
|
||||
const downloadPack = (packId: string): void => {
|
||||
runCommand(`downloadPack "${packId}"`, () => packService.downloadPack(packId));
|
||||
};
|
||||
|
||||
const uninstallPack = (packId: string): void => {
|
||||
runCommand(`uninstallPack "${packId}"`, () => packService.uninstallPack(packId));
|
||||
};
|
||||
|
||||
const updatePack = (packId: string): void => {
|
||||
runCommand(`updatePack "${packId}"`, () => packService.updatePack(packId));
|
||||
};
|
||||
|
||||
const isGameAvailable = (gameName: string): boolean => {
|
||||
const entry = registry.value?.packs.find((pack) => pack.name === gameName);
|
||||
return entry ? installedPackIds.value.includes(entry.id) : false;
|
||||
};
|
||||
|
||||
const getDownloadState = (packId: string): PackDownloadState =>
|
||||
downloadStates.value[packId] ?? { status: 'idle', progress: 0 };
|
||||
|
||||
const getCharactersByGame = (gameName: string): FightingCharacterOption[] =>
|
||||
charactersByGame.value[gameName] ?? [];
|
||||
|
||||
const getDefaultCharactersByGame = (gameName: string): DefaultCharacterPair | undefined =>
|
||||
defaultCharactersByGame.value[gameName];
|
||||
|
||||
return {
|
||||
registry,
|
||||
installedPackIds,
|
||||
downloadStates,
|
||||
availableUpdates,
|
||||
installedManifests,
|
||||
allGameOptions,
|
||||
updateCount,
|
||||
initialize,
|
||||
dispose,
|
||||
fetchRegistry,
|
||||
startRegistryRefresh,
|
||||
stopRegistryRefresh,
|
||||
downloadPack,
|
||||
uninstallPack,
|
||||
updatePack,
|
||||
isGameAvailable,
|
||||
getDownloadState,
|
||||
getCharactersByGame,
|
||||
getDefaultCharactersByGame,
|
||||
formatBytes,
|
||||
getLocalLogoUrl,
|
||||
};
|
||||
});
|
||||
@@ -1,61 +1,36 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import { playersReplicant } from '../../nodecg/browser/replicants';
|
||||
import type { Schemas } from '../../types';
|
||||
import { readStorageSnapshot, syncStateWithReplicant } from './store-sync';
|
||||
|
||||
type PlayersMap = Schemas.Players;
|
||||
type Player = PlayersMap[string];
|
||||
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';
|
||||
|
||||
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);
|
||||
syncPlayersState(players, STORAGE_KEY);
|
||||
|
||||
const setPlayers = (value: PlayersMap) => {
|
||||
const setPlayers = (value: PlayersMap): void => {
|
||||
players.value = normalizePlayers(value);
|
||||
};
|
||||
|
||||
const upsertPlayer = (id: string, player: Player) => {
|
||||
const upsertPlayer = (id: string, player: Player): void => {
|
||||
players.value = {
|
||||
...players.value,
|
||||
[id]: normalizePlayer(player),
|
||||
};
|
||||
};
|
||||
|
||||
const removePlayer = (id: string) => {
|
||||
const removePlayer = (id: string): void => {
|
||||
const next = { ...players.value };
|
||||
delete next[id];
|
||||
players.value = next;
|
||||
|
||||
@@ -1,107 +1,102 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import { scoreboardReplicant } from '../../nodecg/browser/replicants';
|
||||
import type { Schemas } from '../../types';
|
||||
import { readStorageSnapshot, syncStateWithReplicant } from './store-sync';
|
||||
|
||||
type Scoreboard = Schemas.Scoreboard;
|
||||
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';
|
||||
|
||||
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);
|
||||
syncScoreboardState(scoreboard, STORAGE_KEY);
|
||||
|
||||
const setScoreboard = (value: Scoreboard) => {
|
||||
const setScoreboard = (value: Scoreboard): void => {
|
||||
scoreboard.value = normalizeScoreboard(value);
|
||||
};
|
||||
|
||||
const swapPlayers = () => {
|
||||
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,
|
||||
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,
|
||||
[side === 'left' ? 'leftPlayerId' : 'rightPlayerId']: value,
|
||||
};
|
||||
};
|
||||
|
||||
const resetScores = () => {
|
||||
const setSideNameOverride = (side: ScoreboardSide, value: string): void => {
|
||||
scoreboard.value = {
|
||||
...scoreboard.value,
|
||||
leftScore: 0,
|
||||
rightScore: 0,
|
||||
[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) => {
|
||||
scoreboard.value = {
|
||||
...scoreboard.value,
|
||||
leftScore: Math.max(0, Math.floor(value)),
|
||||
};
|
||||
setScore('left', value);
|
||||
},
|
||||
});
|
||||
|
||||
const rightScore = computed({
|
||||
get: () => scoreboard.value.rightScore,
|
||||
set: (value: number) => {
|
||||
scoreboard.value = {
|
||||
...scoreboard.value,
|
||||
rightScore: Math.max(0, Math.floor(value)),
|
||||
};
|
||||
setScore('right', value);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -110,6 +105,15 @@ export const useScoreboardStore = defineStore('scoreboard', () => {
|
||||
leftScore,
|
||||
rightScore,
|
||||
setScoreboard,
|
||||
setGame,
|
||||
setRound,
|
||||
setScore,
|
||||
adjustScore,
|
||||
setSidePlayerId,
|
||||
setSideNameOverride,
|
||||
setSideTeamOverride,
|
||||
setSideCountryOverride,
|
||||
setSideCharacter,
|
||||
swapPlayers,
|
||||
resetScores,
|
||||
};
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useHead } from '@unhead/vue';
|
||||
import { computed } from 'vue';
|
||||
import { commentaryReplicant } from '../../nodecg/browser/replicants';
|
||||
import type { Schemas } from '../../types';
|
||||
import { useCommentaryReplicatedState } from '../shared/services/replicated-state';
|
||||
|
||||
useHead({ title: 'Commentary' });
|
||||
|
||||
const defaultCommentary: Schemas.Commentary = {
|
||||
leftCommentator: '',
|
||||
leftCommentatorTwitter: '',
|
||||
rightCommentator: '',
|
||||
rightCommentatorTwitter: '',
|
||||
};
|
||||
|
||||
const commentary = computed<Schemas.Commentary>(() => commentaryReplicant?.data ?? defaultCommentary);
|
||||
const { commentary } = useCommentaryReplicatedState();
|
||||
|
||||
const leftCommentator = computed(() => commentary.value.leftCommentator || 'COMMENTATOR 1');
|
||||
const rightCommentator = computed(() => commentary.value.rightCommentator || 'COMMENTATOR 2');
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useHead } from '@unhead/vue';
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../nodecg/browser/replicants';
|
||||
import { useScoreboardReplicatedState } from '../shared/services/replicated-state';
|
||||
import { resolveCountryCode } from '../../shared/domain/players/countries';
|
||||
import { getCharactersByGame } from '../../shared/fighting-characters';
|
||||
import type { Schemas } from '../../types';
|
||||
|
||||
useHead({ title: 'Scoreboard 2XKO' });
|
||||
|
||||
const defaultScoreboard: Schemas.Scoreboard = {
|
||||
leftPlayerId: '', rightPlayerId: '', leftNameOverride: '', rightNameOverride: '', leftTeamOverride: '', rightTeamOverride: '',
|
||||
leftCountryOverride: '', rightCountryOverride: '', leftCharacter: '', rightCharacter: '', leftScore: 0, rightScore: 0, round: '', game: '',
|
||||
};
|
||||
|
||||
const players = computed<Schemas.Players>(() => playersReplicant?.data ?? {});
|
||||
const scoreboard = computed<Schemas.Scoreboard>(() => scoreboardReplicant?.data ?? defaultScoreboard);
|
||||
const scoreboardSkin = computed(() => graphicsSettingsReplicant?.data?.scoreboardSkin ?? 'scoreboard-2xko/main.html');
|
||||
const { players, scoreboard, scoreboardSkin } = useScoreboardReplicatedState('scoreboard-2xko/main.html');
|
||||
|
||||
watch(
|
||||
scoreboardSkin,
|
||||
|
||||
@@ -1,32 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { useHead } from '@unhead/vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../nodecg/browser/replicants';
|
||||
import { useScoreboardReplicatedState } from '../shared/services/replicated-state';
|
||||
import { resolveCountryCode } from '../../shared/domain/players/countries';
|
||||
import type { Schemas } from '../../types';
|
||||
|
||||
useHead({ title: 'Scoreboard' });
|
||||
|
||||
const defaultScoreboard: Schemas.Scoreboard = {
|
||||
leftPlayerId: '',
|
||||
rightPlayerId: '',
|
||||
leftNameOverride: '',
|
||||
rightNameOverride: '',
|
||||
leftTeamOverride: '',
|
||||
rightTeamOverride: '',
|
||||
leftCountryOverride: '',
|
||||
rightCountryOverride: '',
|
||||
leftCharacter: '',
|
||||
rightCharacter: '',
|
||||
leftScore: 0,
|
||||
rightScore: 0,
|
||||
round: '',
|
||||
game: '',
|
||||
};
|
||||
|
||||
const players = computed<Schemas.Players>(() => playersReplicant?.data ?? {});
|
||||
const scoreboard = computed<Schemas.Scoreboard>(() => scoreboardReplicant?.data ?? defaultScoreboard);
|
||||
const scoreboardSkin = computed(() => graphicsSettingsReplicant?.data?.scoreboardSkin ?? 'scoreboard/main.html');
|
||||
const { players, scoreboard, scoreboardSkin } = useScoreboardReplicatedState('scoreboard/main.html');
|
||||
|
||||
watch(
|
||||
scoreboardSkin,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { computed } from 'vue';
|
||||
import { commentaryReplicant, graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../../nodecg/browser/replicants';
|
||||
import { defaultCommentary } from '../../../shared/domain/commentary';
|
||||
import { defaultScoreboard } from '../../../shared/domain/scoreboard';
|
||||
import type { Schemas } from '../../../types';
|
||||
|
||||
export const useScoreboardReplicatedState = (defaultSkin: string) => {
|
||||
const players = computed<Schemas.Players>(() => playersReplicant?.data ?? {});
|
||||
const scoreboard = computed<Schemas.Scoreboard>(() => scoreboardReplicant?.data ?? defaultScoreboard);
|
||||
const scoreboardSkin = computed(() => graphicsSettingsReplicant?.data?.scoreboardSkin ?? defaultSkin);
|
||||
|
||||
return {
|
||||
players,
|
||||
scoreboard,
|
||||
scoreboardSkin,
|
||||
};
|
||||
};
|
||||
|
||||
export const useCommentaryReplicatedState = () => {
|
||||
const commentary = computed<Schemas.Commentary>(() => commentaryReplicant?.data ?? defaultCommentary);
|
||||
|
||||
return {
|
||||
commentary,
|
||||
};
|
||||
};
|
||||
@@ -9,7 +9,7 @@ import { replicantNames } from '../replicantNames';
|
||||
interface BrowserReplicant<T> {
|
||||
value: T;
|
||||
on(event: 'change', handler: (newVal: T, oldVal?: T) => void): void;
|
||||
off(event: string, handler: (...args: unknown[]) => void): void;
|
||||
off(event: 'change', handler: (newVal: T, oldVal?: T) => void): void;
|
||||
}
|
||||
|
||||
export interface PackBrowserReplicants {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './state';
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Schemas } from '../../../types';
|
||||
|
||||
export type Commentary = Schemas.Commentary;
|
||||
|
||||
export const defaultCommentary: Commentary = {
|
||||
leftCommentator: '',
|
||||
leftCommentatorTwitter: '',
|
||||
rightCommentator: '',
|
||||
rightCommentatorTwitter: '',
|
||||
};
|
||||
|
||||
const toString = (value: unknown): string => (typeof value === 'string' ? value : '');
|
||||
|
||||
export const normalizeCommentary = (input: unknown): Commentary => {
|
||||
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
|
||||
return {
|
||||
leftCommentator: toString(candidate.leftCommentator),
|
||||
leftCommentatorTwitter: toString(candidate.leftCommentatorTwitter),
|
||||
rightCommentator: toString(candidate.rightCommentator),
|
||||
rightCommentatorTwitter: toString(candidate.rightCommentatorTwitter),
|
||||
};
|
||||
};
|
||||
|
||||
export const swapCommentary = (commentary: Commentary): Commentary => ({
|
||||
leftCommentator: commentary.rightCommentator,
|
||||
leftCommentatorTwitter: commentary.rightCommentatorTwitter,
|
||||
rightCommentator: commentary.leftCommentator,
|
||||
rightCommentatorTwitter: commentary.leftCommentatorTwitter,
|
||||
});
|
||||
|
||||
export const stripTwitterPrefix = (value: string): string =>
|
||||
value.startsWith('@') ? value.slice(1) : value;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './state';
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Schemas } from '../../../types';
|
||||
|
||||
export type GraphicsSettings = Schemas.GraphicsSettings;
|
||||
|
||||
export const defaultGraphicsSettings: GraphicsSettings = {
|
||||
scoreboardSkin: 'scoreboard/main.html',
|
||||
};
|
||||
|
||||
export const normalizeGraphicsSettings = (input: unknown): GraphicsSettings => {
|
||||
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
|
||||
return {
|
||||
scoreboardSkin: typeof candidate.scoreboardSkin === 'string'
|
||||
? candidate.scoreboardSkin
|
||||
: defaultGraphicsSettings.scoreboardSkin,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { PackManifest } from './types';
|
||||
|
||||
export interface FightingCharacterOption {
|
||||
label: string;
|
||||
value: string;
|
||||
image: string;
|
||||
dlc?: boolean;
|
||||
}
|
||||
|
||||
export interface DefaultCharacterPair {
|
||||
leftCharacter: string;
|
||||
rightCharacter: string;
|
||||
}
|
||||
|
||||
export const BUNDLED_GAME_NAMES = new Set<string>();
|
||||
|
||||
export const buildCharactersForManifest = (manifest: PackManifest): FightingCharacterOption[] =>
|
||||
manifest.characters.map((character) => ({
|
||||
label: character.name,
|
||||
value: character.slug,
|
||||
image: `/packs/${manifest.id}/characters/${character.slug}.png`,
|
||||
dlc: character.dlc ?? false,
|
||||
}));
|
||||
|
||||
export const buildCharactersByGame = (
|
||||
manifests: readonly PackManifest[],
|
||||
): Record<string, FightingCharacterOption[]> => {
|
||||
const charactersByGame: Record<string, FightingCharacterOption[]> = {};
|
||||
manifests.forEach((manifest) => {
|
||||
charactersByGame[manifest.name] = buildCharactersForManifest(manifest);
|
||||
});
|
||||
return charactersByGame;
|
||||
};
|
||||
|
||||
export const buildDefaultCharactersByGame = (
|
||||
manifests: readonly PackManifest[],
|
||||
): Record<string, DefaultCharacterPair> => {
|
||||
const defaultsByGame: Record<string, DefaultCharacterPair> = {};
|
||||
manifests.forEach((manifest) => {
|
||||
if (manifest.defaultPair) {
|
||||
defaultsByGame[manifest.name] = {
|
||||
leftCharacter: manifest.defaultPair.left,
|
||||
rightCharacter: manifest.defaultPair.right,
|
||||
};
|
||||
}
|
||||
});
|
||||
return defaultsByGame;
|
||||
};
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './config';
|
||||
export * from './characters';
|
||||
export * from './types';
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Schemas } from '../../../types';
|
||||
|
||||
export type PlayersMap = Schemas.Players;
|
||||
export type Player = PlayersMap[string];
|
||||
|
||||
const toString = (value: unknown): string => (typeof value === 'string' ? value : '');
|
||||
|
||||
export const normalizePlayer = (input: unknown): Player => {
|
||||
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
|
||||
return {
|
||||
gamertag: toString(candidate.gamertag),
|
||||
name: toString(candidate.name),
|
||||
team: toString(candidate.team),
|
||||
country: toString(candidate.country),
|
||||
twitter: toString(candidate.twitter),
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizePlayers = (input: unknown): PlayersMap => {
|
||||
if (typeof input !== 'object' || input === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: PlayersMap = {};
|
||||
Object.entries(input as Record<string, unknown>).forEach(([id, value]) => {
|
||||
if (id) {
|
||||
result[id] = normalizePlayer(value);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
export const normalizePlayerName = (value: string): string => value.trim().toLowerCase();
|
||||
|
||||
export const createPlayerId = (name: string, players: PlayersMap): string => {
|
||||
const base = name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, '-') || 'player';
|
||||
|
||||
let index = 1;
|
||||
let candidate = base;
|
||||
while (players[candidate]) {
|
||||
index += 1;
|
||||
candidate = `${base}-${index}`;
|
||||
}
|
||||
return candidate;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './state';
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Schemas } from '../../../types';
|
||||
|
||||
export type Scoreboard = Schemas.Scoreboard;
|
||||
export type ScoreboardSide = 'left' | 'right';
|
||||
|
||||
export const defaultScoreboard: Scoreboard = {
|
||||
leftPlayerId: '',
|
||||
rightPlayerId: '',
|
||||
leftNameOverride: '',
|
||||
rightNameOverride: '',
|
||||
leftTeamOverride: '',
|
||||
rightTeamOverride: '',
|
||||
leftCountryOverride: '',
|
||||
rightCountryOverride: '',
|
||||
leftCharacter: '',
|
||||
rightCharacter: '',
|
||||
leftScore: 0,
|
||||
rightScore: 0,
|
||||
round: '',
|
||||
game: '',
|
||||
};
|
||||
|
||||
const toString = (value: unknown): string => (typeof value === 'string' ? value : '');
|
||||
|
||||
const normalizeScore = (value: unknown): number =>
|
||||
typeof value === 'number' ? Math.max(0, Math.floor(value)) : 0;
|
||||
|
||||
export const normalizeScoreboard = (input: unknown): Scoreboard => {
|
||||
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
|
||||
return {
|
||||
leftPlayerId: toString(candidate.leftPlayerId),
|
||||
rightPlayerId: toString(candidate.rightPlayerId),
|
||||
leftNameOverride: toString(candidate.leftNameOverride),
|
||||
rightNameOverride: toString(candidate.rightNameOverride),
|
||||
leftTeamOverride: toString(candidate.leftTeamOverride),
|
||||
rightTeamOverride: toString(candidate.rightTeamOverride),
|
||||
leftCountryOverride: toString(candidate.leftCountryOverride),
|
||||
rightCountryOverride: toString(candidate.rightCountryOverride),
|
||||
leftCharacter: toString(candidate.leftCharacter),
|
||||
rightCharacter: toString(candidate.rightCharacter),
|
||||
leftScore: normalizeScore(candidate.leftScore),
|
||||
rightScore: normalizeScore(candidate.rightScore),
|
||||
round: toString(candidate.round),
|
||||
game: toString(candidate.game),
|
||||
};
|
||||
};
|
||||
|
||||
export const setScoreboardScore = (
|
||||
scoreboard: Scoreboard,
|
||||
side: ScoreboardSide,
|
||||
value: number,
|
||||
): Scoreboard => ({
|
||||
...scoreboard,
|
||||
[side === 'left' ? 'leftScore' : 'rightScore']: Math.max(0, Math.floor(value)),
|
||||
});
|
||||
|
||||
export const adjustScoreboardScore = (
|
||||
scoreboard: Scoreboard,
|
||||
side: ScoreboardSide,
|
||||
delta: number,
|
||||
): Scoreboard =>
|
||||
setScoreboardScore(
|
||||
scoreboard,
|
||||
side,
|
||||
(side === 'left' ? scoreboard.leftScore : scoreboard.rightScore) + delta,
|
||||
);
|
||||
|
||||
export const swapScoreboardPlayers = (scoreboard: Scoreboard): Scoreboard => ({
|
||||
...scoreboard,
|
||||
leftPlayerId: scoreboard.rightPlayerId,
|
||||
rightPlayerId: scoreboard.leftPlayerId,
|
||||
leftNameOverride: scoreboard.rightNameOverride,
|
||||
rightNameOverride: scoreboard.leftNameOverride,
|
||||
leftTeamOverride: scoreboard.rightTeamOverride,
|
||||
rightTeamOverride: scoreboard.leftTeamOverride,
|
||||
leftCountryOverride: scoreboard.rightCountryOverride,
|
||||
rightCountryOverride: scoreboard.leftCountryOverride,
|
||||
leftCharacter: scoreboard.rightCharacter,
|
||||
rightCharacter: scoreboard.leftCharacter,
|
||||
leftScore: scoreboard.rightScore,
|
||||
rightScore: scoreboard.leftScore,
|
||||
});
|
||||
|
||||
export const resetScoreboardScores = (scoreboard: Scoreboard): Scoreboard => ({
|
||||
...scoreboard,
|
||||
leftScore: 0,
|
||||
rightScore: 0,
|
||||
});
|
||||
@@ -1,112 +1,12 @@
|
||||
// src/shared/fighting-characters.ts
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Todo el contenido de personajes viene de packs descargados desde Gitea.
|
||||
// No hay datos bundled — el proyecto arranca vacío y se rellena en runtime.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export {
|
||||
BUNDLED_GAME_NAMES,
|
||||
buildCharactersByGame,
|
||||
buildCharactersForManifest,
|
||||
buildDefaultCharactersByGame,
|
||||
type DefaultCharacterPair,
|
||||
type FightingCharacterOption,
|
||||
} from './domain/packs/characters';
|
||||
import type { DefaultCharacterPair, FightingCharacterOption } from './domain/packs/characters';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import type { PackManifest } from './domain/packs/types';
|
||||
|
||||
export interface FightingCharacterOption {
|
||||
label: string;
|
||||
value: string;
|
||||
image: string;
|
||||
dlc?: boolean;
|
||||
}
|
||||
|
||||
// ── Runtime registry ──────────────────────────────────────────────────────────
|
||||
|
||||
const installedPackCharacters: Record<string, FightingCharacterOption[]> = {};
|
||||
const installedPackDefaults: Record<string, { leftCharacter: string; rightCharacter: string }> = {};
|
||||
|
||||
/**
|
||||
* Incrementado cada vez que se registra o elimina un pack.
|
||||
* Los composables se suscriben a este ref para que Vue invalide los computed
|
||||
* que dependen de installedPackCharacters (objeto plano, no reactivo).
|
||||
*/
|
||||
export const installedPacksRevision = ref(0);
|
||||
|
||||
/**
|
||||
* Vacío — ya no hay juegos bundled.
|
||||
* Mantenido por compatibilidad con usePackRegistry.
|
||||
*/
|
||||
export const BUNDLED_GAME_NAMES = new Set<string>();
|
||||
|
||||
// ── Placeholder SVG ───────────────────────────────────────────────────────────
|
||||
|
||||
const toDataUrl = (svg: string): string =>
|
||||
`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
|
||||
const buildPlaceholder = (game: string, character: string, start: string, end: string): string => {
|
||||
const initials = character
|
||||
.split(/\s+/)
|
||||
.map((p) => p[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 220" role="img" aria-label="${character}">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="${start}"/>
|
||||
<stop offset="100%" stop-color="${end}"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="480" height="220" fill="url(#bg)" rx="18"/>
|
||||
<circle cx="90" cy="110" r="64" fill="rgba(255,255,255,0.13)"/>
|
||||
<text x="90" y="130" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="56" font-weight="700">${initials}</text>
|
||||
<text x="170" y="96" fill="#e2e8f0" font-family="Arial, sans-serif" font-size="20" font-weight="700">${game}</text>
|
||||
<text x="170" y="145" fill="#ffffff" font-family="Arial, sans-serif" font-size="38" font-weight="700">${character}</text>
|
||||
</svg>`.trim();
|
||||
|
||||
return toDataUrl(svg);
|
||||
};
|
||||
|
||||
// ── Pack registration ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Registra un pack instalado para que getCharactersByGame() lo devuelva.
|
||||
* Llamado por usePackRegistry cuando carga el manifest.json local de un pack.
|
||||
*/
|
||||
export const registerInstalledPack = (manifest: PackManifest): void => {
|
||||
const { id, name, palette, characters, defaultPair } = manifest;
|
||||
|
||||
installedPackCharacters[name] = characters.map((char) => ({
|
||||
label: char.name,
|
||||
value: char.slug,
|
||||
image: `/packs/${id}/characters/${char.slug}.png`,
|
||||
dlc: char.dlc ?? false,
|
||||
// Fallback inline por si la imagen no se encuentra en disco
|
||||
_placeholder: buildPlaceholder(name, char.name, palette.start, palette.end),
|
||||
}));
|
||||
|
||||
if (defaultPair) {
|
||||
installedPackDefaults[name] = {
|
||||
leftCharacter: defaultPair.left,
|
||||
rightCharacter: defaultPair.right,
|
||||
};
|
||||
}
|
||||
|
||||
installedPacksRevision.value++;
|
||||
};
|
||||
|
||||
/**
|
||||
* Elimina un pack del registro en memoria.
|
||||
* Llamado por usePackRegistry cuando el usuario desinstala un pack.
|
||||
*/
|
||||
export const unregisterInstalledPack = (gameName: string): void => {
|
||||
delete installedPackCharacters[gameName];
|
||||
delete installedPackDefaults[gameName];
|
||||
installedPacksRevision.value++;
|
||||
};
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const getCharactersByGame = (game: string): FightingCharacterOption[] =>
|
||||
installedPackCharacters[game] ?? [];
|
||||
|
||||
export const getDefaultCharactersByGame = (
|
||||
game: string,
|
||||
): { leftCharacter: string; rightCharacter: string } | undefined =>
|
||||
installedPackDefaults[game];
|
||||
export const getCharactersByGame = (_game?: string): FightingCharacterOption[] => [];
|
||||
export const getDefaultCharactersByGame = (_game?: string): DefaultCharacterPair | undefined => undefined;
|
||||
|
||||
Reference in New Issue
Block a user