6 Commits

56 changed files with 1442 additions and 1707 deletions
@@ -7,12 +7,16 @@ import { useScoreboardStore } from '../stores/scoreboard';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const ALL_FIGHTING_GAME_OPTIONS = [ export const ALL_FIGHTING_GAME_OPTIONS = [
'2XKO', '2XKO',
'FATAL FURY: City of the Wolves',
'Guilty Gear -Strive-',
'Invincible VS',
'Mortal Kombat 1', 'Mortal Kombat 1',
'Street Fighter 6', 'Street Fighter 6',
'TEKKEN 8', 'TEKKEN 8',
'Guilty Gear -Strive-',
'THE KING OF FIGHTERS XV', 'THE KING OF FIGHTERS XV',
].map((game) => ({ label: game, value: game })); ].map((game) => ({ label: game, value: game }));
export type CharacterOption = ReturnType<typeof getCharactersByGame>[number]; export type CharacterOption = ReturnType<typeof getCharactersByGame>[number];
@@ -0,0 +1,444 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
// ─── Tipos ─────────────────────────────────────────────────────────────────────
export interface IntegrationTournament {
id: string | number;
name: string;
slug: string;
startAt: number | null;
endAt: number | null;
}
export interface IntegrationPlayer {
id: string;
gamertag: string;
name: string;
team: string;
country: string;
twitter: string;
}
export interface TemporaryPlayerMeta {
expiresAt: number;
tournamentSlug: string;
}
export type TemporaryPlayersMap = Record<string, TemporaryPlayerMeta>;
interface TournamentOption {
label: string;
value: string;
caption: string;
}
interface OAuthSessionResponse {
sessionId: string;
authUrl: string;
}
interface OAuthStatusResponse {
status: 'pending' | 'completed' | 'error' | 'expired';
token?: string;
error?: string;
}
export interface PlayersStore {
upsertPlayer: (id: string, data: Omit<IntegrationPlayer, 'id'>) => void;
removePlayer: (id: string) => void;
}
export interface UseIntegrationOptions {
/** Prefijo de los mensajes NodeCG, p.ej. 'startgg' | 'challonge' */
messagePrefix: string;
/** Nombre legible del proveedor para mensajes de error */
providerLabel: string;
/** Clave de localStorage para el token */
tokenStorageKey: string;
/** Clave de localStorage para los jugadores temporales */
tempPlayersStorageKey: string;
/** Segundos que duran los jugadores temporales si el torneo no tiene endAt */
tempFallbackDurationSeconds: number;
/** Mensaje de error personalizado cuando la API devuelve 401 */
on401Message?: string;
/** Store de jugadores */
playersStore: PlayersStore;
}
// ─── Utilidad para mensajes NodeCG ─────────────────────────────────────────────
const sendNodeCGMessage = <T>(messageName: string, payload: unknown): Promise<T> =>
new Promise((resolve, reject) => {
nodecg.sendMessage(messageName, payload, (error: unknown, response: unknown) => {
if (error) {
reject(new Error(String(error)));
return;
}
resolve(response as T);
});
});
// ─── Composable ────────────────────────────────────────────────────────────────
export function useIntegration(options: UseIntegrationOptions) {
const {
messagePrefix,
providerLabel,
tokenStorageKey,
tempPlayersStorageKey,
tempFallbackDurationSeconds,
on401Message,
playersStore,
} = options;
// ── Token ───────────────────────────────────────────────────────────────────
const token = ref(localStorage.getItem(tokenStorageKey) ?? '');
const hasValidatedToken = ref(false);
watch(token, (value) => {
localStorage.setItem(tokenStorageKey, value);
hasValidatedToken.value = false;
if (!value.trim()) {
recentTournaments.value = [];
selectedTournamentSlug.value = '';
tournamentInput.value = '';
tournamentsError.value = '';
}
});
// ── Lista de torneos ────────────────────────────────────────────────────────
const recentTournaments = ref<IntegrationTournament[]>([]);
const loadingTournaments = ref(false);
const tournamentsError = ref('');
const selectedTournamentSlug = ref('');
const tournamentInput = ref('');
const tournamentOptions = computed<TournamentOption[]>(() =>
recentTournaments.value.map((t) => ({
label: t.name,
value: t.slug,
caption: t.slug,
})),
);
const filteredTournamentOptions = ref<TournamentOption[]>(tournamentOptions.value);
watch(tournamentOptions, (value) => {
filteredTournamentOptions.value = value;
if (
selectedTournamentSlug.value &&
!recentTournaments.value.some((t) => t.slug === selectedTournamentSlug.value)
) {
selectedTournamentSlug.value = '';
tournamentInput.value = '';
}
});
const filterTournaments = (value: string, update: (cb: () => void) => void) => {
update(() => {
const needle = value.toLowerCase().trim();
filteredTournamentOptions.value = needle
? tournamentOptions.value.filter(
(o) =>
o.label.toLowerCase().includes(needle) ||
o.caption.toLowerCase().includes(needle),
)
: tournamentOptions.value;
});
};
const selectedTournamentOption = computed<IntegrationTournament | null>(
() => recentTournaments.value.find((t) => t.slug === selectedTournamentSlug.value) ?? null,
);
const canImportSelectedTournament = computed(() => Boolean(selectedTournamentOption.value));
const hasTokenConfigured = computed(() => Boolean(token.value.trim()));
const loadRecentTournaments = async () => {
const currentToken = token.value.trim();
if (!currentToken) {
tournamentsError.value = `Add your ${providerLabel} token to load tournaments.`;
recentTournaments.value = [];
return;
}
tournamentsError.value = '';
loadingTournaments.value = true;
try {
const tournaments = await sendNodeCGMessage<IntegrationTournament[]>(
`${messagePrefix}:fetchRecentTournaments`,
{ token: currentToken },
);
hasValidatedToken.value = true;
recentTournaments.value = tournaments;
if (!tournaments.length) {
tournamentsError.value = 'There are no recent tournaments for this account.';
}
} catch (error) {
hasValidatedToken.value = false;
const message = error instanceof Error ? error.message : 'Could not load tournaments.';
tournamentsError.value =
on401Message && message.includes('401') ? on401Message : message;
recentTournaments.value = [];
} finally {
loadingTournaments.value = false;
}
};
// ── Importación de jugadores ────────────────────────────────────────────────
const players = ref<IntegrationPlayer[]>([]);
const selectedPlayerIds = ref<string[]>([]);
const importDialogOpen = ref(false);
const importDialogError = ref('');
const loadingPlayers = ref(false);
const importingTournament = ref<IntegrationTournament | null>(null);
const openImportDialog = async (tournament: IntegrationTournament): Promise<void> => {
importingTournament.value = tournament;
importDialogOpen.value = true;
importDialogError.value = '';
loadingPlayers.value = true;
selectedPlayerIds.value = [];
selectedTournamentSlug.value = tournament.slug;
tournamentInput.value = tournament.name;
players.value = [];
try {
const importedPlayers = await sendNodeCGMessage<IntegrationPlayer[]>(
`${messagePrefix}:fetchTournamentPlayers`,
{ token: token.value.trim(), slug: tournament.slug },
);
players.value = importedPlayers;
selectedPlayerIds.value = importedPlayers.map((p) => p.id);
} catch (error) {
importDialogError.value =
error instanceof Error ? error.message : 'Could not load players';
importDialogOpen.value = false;
} finally {
loadingPlayers.value = false;
}
};
const openSelectedTournamentImportDialog = () => {
if (selectedTournamentOption.value) {
void openImportDialog(selectedTournamentOption.value);
}
};
const toggleAllPlayers = () => {
selectedPlayerIds.value =
selectedPlayerIds.value.length === players.value.length
? []
: players.value.map((p) => p.id);
};
const importSelectedPlayers = () => {
const selected = players.value.filter((p) => selectedPlayerIds.value.includes(p.id));
const tournament = importingTournament.value;
const fallbackEndAt =
(tournament?.startAt ?? Math.floor(Date.now() / 1000)) + tempFallbackDurationSeconds;
const expiresAt = tournament?.endAt ?? fallbackEndAt;
const nextMeta = { ...temporaryPlayers.value };
for (const player of selected) {
playersStore.upsertPlayer(player.id, {
gamertag: player.gamertag,
name: player.name,
team: player.team,
country: player.country,
twitter: player.twitter,
});
if (tournament) {
nextMeta[player.id] = { expiresAt, tournamentSlug: tournament.slug };
}
}
temporaryPlayers.value = nextMeta;
persistTemporaryPlayers();
importDialogOpen.value = false;
};
// ── Jugadores temporales ────────────────────────────────────────────────────
const loadTemporaryPlayers = (): TemporaryPlayersMap => {
try {
const raw = localStorage.getItem(tempPlayersStorageKey);
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed !== 'object' || parsed === null) return {};
const result: TemporaryPlayersMap = {};
Object.entries(parsed as Record<string, unknown>).forEach(([playerId, value]) => {
if (!playerId || typeof value !== 'object' || value === null) return;
const candidate = value as Record<string, unknown>;
const expiresAt = Number(candidate.expiresAt);
const tournamentSlug = String(candidate.tournamentSlug ?? '').trim();
if (!Number.isFinite(expiresAt) || expiresAt <= 0 || !tournamentSlug) return;
result[playerId] = { expiresAt, tournamentSlug };
});
return result;
} catch {
return {};
}
};
const temporaryPlayers = ref<TemporaryPlayersMap>({});
const persistTemporaryPlayers = () => {
localStorage.setItem(tempPlayersStorageKey, JSON.stringify(temporaryPlayers.value));
};
/**
* Elimina del store y del mapa los jugadores temporales cuyo expiresAt
* ha pasado. Se llama periódicamente en onMounted.
*/
const cleanupExpiredTemporaryPlayers = () => {
const now = Math.floor(Date.now() / 1000);
const expiredIds = Object.entries(temporaryPlayers.value)
.filter(([, meta]) => meta.expiresAt <= now)
.map(([id]) => id);
if (!expiredIds.length) return;
const nextMeta = { ...temporaryPlayers.value };
for (const id of expiredIds) {
playersStore.removePlayer(id);
delete nextMeta[id];
}
temporaryPlayers.value = nextMeta;
persistTemporaryPlayers();
};
// ── OAuth ───────────────────────────────────────────────────────────────────
const oauthLoading = ref(false);
const oauthSessionId = ref('');
let oauthPollingTimer: ReturnType<typeof setInterval> | null = null;
const stopPolling = () => {
if (oauthPollingTimer) {
clearInterval(oauthPollingTimer);
oauthPollingTimer = null;
}
};
const checkOAuthStatus = async () => {
if (!oauthSessionId.value) return;
try {
const status = await sendNodeCGMessage<OAuthStatusResponse>(
`${messagePrefix}:getOAuthSessionStatus`,
{ sessionId: oauthSessionId.value },
);
if (status.status === 'completed' && status.token) {
token.value = status.token;
oauthLoading.value = false;
stopPolling();
oauthSessionId.value = '';
tournamentsError.value = '';
await loadRecentTournaments();
return;
}
if (status.status === 'error' || status.status === 'expired') {
oauthLoading.value = false;
stopPolling();
oauthSessionId.value = '';
tournamentsError.value =
status.error ?? `Could not complete OAuth login with ${providerLabel}.`;
}
} catch (error) {
oauthLoading.value = false;
stopPolling();
oauthSessionId.value = '';
tournamentsError.value =
error instanceof Error ? error.message : 'Could not verify OAuth status.';
}
};
const connectWithOAuth = async () => {
oauthLoading.value = true;
tournamentsError.value = '';
stopPolling();
try {
const session = await sendNodeCGMessage<OAuthSessionResponse>(
`${messagePrefix}:createOAuthSession`,
{},
);
oauthSessionId.value = session.sessionId;
window.open(session.authUrl, '_blank', 'noopener,noreferrer');
oauthPollingTimer = setInterval(() => {
void checkOAuthStatus();
}, 1500);
} catch (error) {
oauthLoading.value = false;
tournamentsError.value =
error instanceof Error ? error.message : `Could not start OAuth with ${providerLabel}.`;
}
};
// ── Ciclo de vida ───────────────────────────────────────────────────────────
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
temporaryPlayers.value = loadTemporaryPlayers();
cleanupExpiredTemporaryPlayers();
cleanupTimer = setInterval(cleanupExpiredTemporaryPlayers, 60 * 1000);
if (token.value.trim()) {
void loadRecentTournaments();
}
});
onBeforeUnmount(() => {
stopPolling();
if (cleanupTimer) {
clearInterval(cleanupTimer);
cleanupTimer = null;
}
});
// ── Retorno como reactive para auto-unwrap en templates ─────────────────────
return reactive({
// Token
token,
hasTokenConfigured,
hasValidatedToken,
// Torneos
recentTournaments,
loadingTournaments,
tournamentsError,
selectedTournamentSlug,
tournamentInput,
tournamentOptions,
filteredTournamentOptions,
selectedTournamentOption,
canImportSelectedTournament,
filterTournaments,
loadRecentTournaments,
// Importación
players,
selectedPlayerIds,
importDialogOpen,
importDialogError,
loadingPlayers,
importingTournament,
openImportDialog,
openSelectedTournamentImportDialog,
importSelectedPlayers,
toggleAllPlayers,
// Jugadores temporales
temporaryPlayers,
// OAuth
oauthLoading,
connectWithOAuth,
});
}
export type IntegrationHandle = ReturnType<typeof useIntegration>;
File diff suppressed because it is too large Load Diff
+184 -312
View File
@@ -1,6 +1,7 @@
import { createServer, type Server, type ServerResponse } from 'node:http';
import { randomUUID } from 'node:crypto';
import { nodecg } from './util/nodecg.js'; import { nodecg } from './util/nodecg.js';
import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js';
// ─── Constantes ────────────────────────────────────────────────────────────────
const CHALLONGE_API_BASE = 'https://api.challonge.com/v2.1'; const CHALLONGE_API_BASE = 'https://api.challonge.com/v2.1';
const CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT = 'https://api.challonge.com/oauth/authorize'; const CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT = 'https://api.challonge.com/oauth/authorize';
@@ -17,21 +18,9 @@ const CHALLONGE_OAUTH_SCOPES = [
const CHALLONGE_OAUTH_CALLBACK_PATH = '/challonge/callback'; const CHALLONGE_OAUTH_CALLBACK_PATH = '/challonge/callback';
const CHALLONGE_OAUTH_DEFAULT_PORT = 34921; const CHALLONGE_OAUTH_DEFAULT_PORT = 34921;
const CHALLONGE_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000; const CHALLONGE_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
const RECENT_TOURNAMENTS_LIMIT = 20;
interface OAuthConfig { // ─── Tipos ─────────────────────────────────────────────────────────────────────
clientId: string;
clientSecret: string;
callbackPort: number;
}
interface OAuthSession {
sessionId: string;
state: string;
expiresAt: number;
status: 'pending' | 'completed' | 'error' | 'expired';
token?: string;
error?: string;
}
interface OAuthTokenResponse { interface OAuthTokenResponse {
access_token?: string; access_token?: string;
@@ -57,157 +46,90 @@ interface ImportedPlayer {
twitter: string; twitter: string;
} }
const oauthSessions = new Map<string, OAuthSession>(); // ─── Config OAuth ──────────────────────────────────────────────────────────────
let oauthCallbackServer: Server | null = null;
const getStringProp = (payload: unknown, key: string): string => {
if (typeof payload !== 'object' || payload === null || !(key in payload)) {
return '';
}
const value = (payload as Record<string, unknown>)[key];
return typeof value === 'string' ? value.trim() : String(value || '').trim();
};
const getNumberProp = (payload: Record<string, unknown>, keys: string[]): number | null => {
for (const key of keys) {
const raw = payload[key];
if (typeof raw === 'number' && Number.isFinite(raw)) {
return raw;
}
if (typeof raw === 'string') {
const parsed = Number(raw);
if (Number.isFinite(parsed)) {
return parsed;
}
}
}
return null;
};
const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
if (typeof ack !== 'function') {
return;
}
ack(error, response);
};
const getOAuthConfig = (): OAuthConfig | null => { const getOAuthConfig = (): OAuthConfig | null => {
const bundleConfig = nodecg.bundleConfig as unknown as Record<string, unknown>; const bundleConfig = nodecg.bundleConfig as unknown as Record<string, unknown>;
const clientId = String(bundleConfig.challongeClientId || '').trim(); const clientId = String(bundleConfig.challongeClientId ?? '').trim();
const clientSecret = String(bundleConfig.challongeClientSecret || '').trim(); const clientSecret = String(bundleConfig.challongeClientSecret ?? '').trim();
const rawPort = Number(bundleConfig.challongeOAuthPort ?? CHALLONGE_OAUTH_DEFAULT_PORT); const rawPort = Number(bundleConfig.challongeOAuthPort ?? CHALLONGE_OAUTH_DEFAULT_PORT);
const callbackPort = Number.isFinite(rawPort) && rawPort > 0 ? rawPort : CHALLONGE_OAUTH_DEFAULT_PORT; const callbackPort =
Number.isFinite(rawPort) && rawPort > 0 ? rawPort : CHALLONGE_OAUTH_DEFAULT_PORT;
if (!clientId || !clientSecret) { if (!clientId || !clientSecret) return null;
return null;
}
return { return { clientId, clientSecret, callbackPort };
clientId,
clientSecret,
callbackPort,
};
}; };
const getCallbackUrl = (callbackPort: number) => `http://127.0.0.1:${callbackPort}${CHALLONGE_OAUTH_CALLBACK_PATH}`; // ─── Intercambio de token ──────────────────────────────────────────────────────
const updateOAuthSession = (sessionId: string, update: Partial<OAuthSession>) => {
const session = oauthSessions.get(sessionId);
if (!session) {
return;
}
oauthSessions.set(sessionId, {
...session,
...update,
});
};
const cleanupExpiredOAuthSessions = () => {
const now = Date.now();
oauthSessions.forEach((session, sessionId) => {
if (session.expiresAt <= now && session.status === 'pending') {
updateOAuthSession(sessionId, { status: 'expired' });
}
});
};
const renderCallbackHtml = (title: string, message: string) => `<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>${title}</title>
<style>
body { font-family: Arial, sans-serif; margin: 2rem; background: #121212; color: #fff; }
.box { max-width: 680px; padding: 1rem 1.2rem; border: 1px solid #444; border-radius: 8px; }
</style>
</head>
<body>
<div class="box">
<h2>${title}</h2>
<p>${message}</p>
<p>You can close this tab and return to Scoreko.</p>
</div>
</body>
</html>`;
const respondWithCallbackHtml = (res: ServerResponse, statusCode: number, title: string, message: string) => {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(renderCallbackHtml(title, message));
};
const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => {
const rawBody = await response.text();
try {
return JSON.parse(rawBody) as OAuthTokenResponse;
} catch {
return { message: rawBody };
}
};
const exchangeOAuthCodeForToken = async ( const exchangeOAuthCodeForToken = async (
code: string, code: string,
redirectUri: string, redirectUri: string,
oauthConfig: OAuthConfig, config: OAuthConfig,
): Promise<string> => { ): Promise<string> => {
const params = new URLSearchParams({ const params = new URLSearchParams({
grant_type: 'authorization_code', grant_type: 'authorization_code',
code, code,
client_id: oauthConfig.clientId, client_id: config.clientId,
client_secret: oauthConfig.clientSecret, client_secret: config.clientSecret,
redirect_uri: redirectUri, redirect_uri: redirectUri,
}); });
const response = await fetch(CHALLONGE_OAUTH_TOKEN_ENDPOINT, { const response = await fetch(CHALLONGE_OAUTH_TOKEN_ENDPOINT, {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(), body: params.toString(),
}); });
const payload = await parseOAuthTokenPayload(response); const rawBody = await response.text();
let payload: OAuthTokenResponse;
if (!response.ok) { try {
throw new Error(payload.error_description || payload.error || payload.message || `OAuth token request failed (${response.status})`); payload = JSON.parse(rawBody) as OAuthTokenResponse;
} catch {
payload = { message: rawBody };
} }
const token = String(payload.access_token || '').trim(); if (!response.ok) {
throw new Error(
payload.error_description ??
payload.error ??
payload.message ??
`OAuth token request failed (${response.status})`,
);
}
const token = String(payload.access_token ?? '').trim();
if (!token) { if (!token) {
throw new Error(payload.error_description || payload.error || payload.message || 'OAuth token response did not include an access token'); throw new Error(
payload.error_description ??
payload.error ??
payload.message ??
'OAuth token response did not include an access token',
);
} }
return token; return token;
}; };
// ─── Servidor OAuth ────────────────────────────────────────────────────────────
const oauthServer = createOAuthServer({
provider: 'Challonge',
callbackPath: CHALLONGE_OAUTH_CALLBACK_PATH,
authorizeEndpoint: CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT,
scope: CHALLONGE_OAUTH_SCOPES,
sessionTtlMs: CHALLONGE_OAUTH_SESSION_TTL_MS,
exchangeToken: exchangeOAuthCodeForToken,
});
// ─── API de Challonge ──────────────────────────────────────────────────────────
type ChallongeErrorPayload = { errors?: { detail?: string }; error?: string } | null;
const parseJsonResponse = async (response: Response): Promise<unknown> => { const parseJsonResponse = async (response: Response): Promise<unknown> => {
const rawBody = await response.text(); const rawBody = await response.text();
if (!rawBody) { if (!rawBody) return null;
return null;
}
try { try {
return JSON.parse(rawBody) as unknown; return JSON.parse(rawBody) as unknown;
} catch { } catch {
@@ -215,9 +137,21 @@ const parseJsonResponse = async (response: Response): Promise<unknown> => {
} }
}; };
/**
* Realiza una petición autenticada a la API de Challonge.
*
* Intenta primero con OAuth v2 (Bearer token).
* Si recibe 401, reintenta con autenticación v1 (API key personal pegada manualmente).
* En cualquier otro error no-2xx, lanza inmediatamente.
*
* CORRECCIÓN: en la versión anterior, el bloque de error final era dead code
* porque el body de v2 ya había sido consumido y la condición `!v2Response.ok`
* nunca se alcanzaba tras el fallback v1.
*/
const requestChallonge = async (path: string, token: string): Promise<unknown> => { const requestChallonge = async (path: string, token: string): Promise<unknown> => {
const requestUrl = `${CHALLONGE_API_BASE}${path}`; const requestUrl = `${CHALLONGE_API_BASE}${path}`;
// ── Intento v2 (OAuth Bearer) ─────────────────────────────────────────────
const v2Response = await fetch(requestUrl, { const v2Response = await fetch(requestUrl, {
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@@ -226,14 +160,13 @@ const requestChallonge = async (path: string, token: string): Promise<unknown> =
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
}); });
const v2Payload = await parseJsonResponse(v2Response); const v2Payload = await parseJsonResponse(v2Response);
if (v2Response.ok) { if (v2Response.ok) {
return v2Payload; return v2Payload;
} }
// Fallback for personal API keys pasted manually (v1 auth style). // ── Fallback v1 (API key personal pegada manualmente) ─────────────────────
if (v2Response.status === 401) { if (v2Response.status === 401) {
const v1Response = await fetch(requestUrl, { const v1Response = await fetch(requestUrl, {
headers: { headers: {
@@ -243,46 +176,68 @@ const requestChallonge = async (path: string, token: string): Promise<unknown> =
Authorization: token, Authorization: token,
}, },
}); });
const v1Payload = await parseJsonResponse(v1Response); const v1Payload = await parseJsonResponse(v1Response);
if (v1Response.ok) { if (v1Response.ok) {
return v1Payload; return v1Payload;
} }
}
const maybeError = v2Payload as { errors?: { detail?: string }; error?: string } | null; const v1Error = v1Payload as ChallongeErrorPayload;
if (!v2Response.ok) {
throw new Error( throw new Error(
maybeError?.errors?.detail || maybeError?.error || `Challonge responded with ${v2Response.status} ${v2Response.statusText}`.trim(), v1Error?.errors?.detail ??
v1Error?.error ??
`Challonge responded with ${v1Response.status} ${v1Response.statusText}`.trim(),
); );
} }
return v2Payload; // ── Otros errores v2 (4xx/5xx que no sean 401) ────────────────────────────
const v2Error = v2Payload as ChallongeErrorPayload;
throw new Error(
v2Error?.errors?.detail ??
v2Error?.error ??
`Challonge responded with ${v2Response.status} ${v2Response.statusText}`.trim(),
);
}; };
// ─── Parsers de respuesta ──────────────────────────────────────────────────────
const normalizeTournamentSlug = (value: string): string => { const normalizeTournamentSlug = (value: string): string => {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) { if (!trimmed) return '';
return ''; return trimmed
.replace(/^https?:\/\/[^/]+\//i, '')
.replace(/^tournaments\//i, '')
.replace(/^\/+/, '');
};
const getNumberProp = (payload: Record<string, unknown>, keys: string[]): number | null => {
for (const key of keys) {
const raw = payload[key];
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
if (typeof raw === 'string') {
const parsed = Number(raw);
if (Number.isFinite(parsed)) return parsed;
} }
return trimmed.replace(/^https?:\/\/[^/]+\//i, '').replace(/^tournaments\//i, '').replace(/^\/+/, ''); }
return null;
}; };
const parseRecentTournaments = (payload: unknown): RecentTournament[] => { const parseRecentTournaments = (payload: unknown): RecentTournament[] => {
const rows: RecentTournament[] = []; const rows: RecentTournament[] = [];
const push = (candidate: Record<string, unknown>) => { const push = (candidate: Record<string, unknown>) => {
const attributes = (typeof candidate.attributes === 'object' && candidate.attributes !== null) const attributes =
typeof candidate.attributes === 'object' && candidate.attributes !== null
? (candidate.attributes as Record<string, unknown>) ? (candidate.attributes as Record<string, unknown>)
: candidate; : candidate;
const id = String(candidate.id || attributes.id || attributes.tournament_id || '').trim(); const id = String(candidate.id ?? attributes.id ?? attributes.tournament_id ?? '').trim();
const name = String(attributes.name || attributes.full_name || '').trim(); const name = String(attributes.name ?? attributes.full_name ?? '').trim();
const slug = normalizeTournamentSlug(String(attributes.url || attributes.slug || attributes.identifier || id)); const slug = normalizeTournamentSlug(
String(attributes.url ?? attributes.slug ?? attributes.identifier ?? id),
);
if (!id || !name || !slug) { if (!id || !name || !slug) return;
return;
}
rows.push({ rows.push({
id, id,
@@ -294,26 +249,25 @@ const parseRecentTournaments = (payload: unknown): RecentTournament[] => {
}; };
if (Array.isArray(payload)) { if (Array.isArray(payload)) {
payload.forEach((row) => { for (const row of payload) {
const wrapper = row as Record<string, unknown>; const wrapper = row as Record<string, unknown>;
const tournament = (typeof wrapper.tournament === 'object' && wrapper.tournament !== null) const tournament =
typeof wrapper.tournament === 'object' && wrapper.tournament !== null
? (wrapper.tournament as Record<string, unknown>) ? (wrapper.tournament as Record<string, unknown>)
: wrapper; : wrapper;
push(tournament); push(tournament);
}); }
return rows; return rows;
} }
if (typeof payload === 'object' && payload !== null) { if (typeof payload === 'object' && payload !== null) {
const root = payload as Record<string, unknown>; const data = (payload as Record<string, unknown>).data;
const data = root.data;
if (Array.isArray(data)) { if (Array.isArray(data)) {
data.forEach((row) => { for (const row of data) {
if (typeof row === 'object' && row !== null) { if (typeof row === 'object' && row !== null) {
push(row as Record<string, unknown>); push(row as Record<string, unknown>);
} }
}); }
return rows;
} }
} }
@@ -324,201 +278,127 @@ const parseImportedPlayers = (payload: unknown): ImportedPlayer[] => {
const map = new Map<string, ImportedPlayer>(); const map = new Map<string, ImportedPlayer>();
const push = (candidate: Record<string, unknown>) => { const push = (candidate: Record<string, unknown>) => {
const attributes = (typeof candidate.attributes === 'object' && candidate.attributes !== null) const attributes =
typeof candidate.attributes === 'object' && candidate.attributes !== null
? (candidate.attributes as Record<string, unknown>) ? (candidate.attributes as Record<string, unknown>)
: candidate; : candidate;
const id = String(candidate.id || attributes.id || attributes.participant_id || '').trim(); const id = String(
const gamertag = String( candidate.id ?? attributes.id ?? attributes.participant_id ?? '',
attributes.display_name
|| attributes.name
|| attributes.username
|| attributes.gamer_tag
|| '',
).trim(); ).trim();
if (!id || !gamertag) { const rawDisplayName = String(
return; attributes.display_name ??
} attributes.name ??
attributes.username ??
attributes.gamer_tag ??
'',
).trim();
if (!id || !rawDisplayName) return;
// Detectar patrón "TEAM | Gamertag" o "TEAM |Gamertag" (muy común en fighting games).
// Si se detecta, extraer el equipo del propio nombre y limpiar el gamertag.
const PIPE_PATTERN = /^(.+?)\s*\|\s*(.+)$/;
const pipeMatch = PIPE_PATTERN.exec(rawDisplayName);
const teamFromName = pipeMatch ? pipeMatch[1].trim() : '';
const gamertag = pipeMatch ? pipeMatch[2].trim() : rawDisplayName;
// team_name de la API tiene prioridad; si no existe, usar el extraído del nombre.
const team = String(attributes.team_name ?? '').trim() || teamFromName;
// Challonge no expone un campo de nombre real separado del username/display_name.
// Se deja vacío para no duplicar el gamertag en el campo name.
map.set(id, { map.set(id, {
id, id,
gamertag, gamertag,
name: gamertag, name: '',
team: String(attributes.group_player_ids || attributes.team_name || '').trim(), team,
country: '', country: '',
twitter: String(attributes.twitter_handle || attributes.twitter || '').trim(), twitter: String(attributes.twitter_handle ?? attributes.twitter ?? '').trim(),
}); });
}; };
if (Array.isArray(payload)) { if (Array.isArray(payload)) {
payload.forEach((row) => { for (const row of payload) {
const wrapper = row as Record<string, unknown>; const wrapper = row as Record<string, unknown>;
const participant = (typeof wrapper.participant === 'object' && wrapper.participant !== null) const participant =
typeof wrapper.participant === 'object' && wrapper.participant !== null
? (wrapper.participant as Record<string, unknown>) ? (wrapper.participant as Record<string, unknown>)
: wrapper; : wrapper;
push(participant); push(participant);
}); }
return Array.from(map.values()); return Array.from(map.values());
} }
if (typeof payload === 'object' && payload !== null) { if (typeof payload === 'object' && payload !== null) {
const root = payload as Record<string, unknown>; const data = (payload as Record<string, unknown>).data;
const data = root.data;
if (Array.isArray(data)) { if (Array.isArray(data)) {
data.forEach((row) => { for (const row of data) {
if (typeof row === 'object' && row !== null) { if (typeof row === 'object' && row !== null) {
push(row as Record<string, unknown>); push(row as Record<string, unknown>);
} }
}); }
} }
} }
return Array.from(map.values()); return Array.from(map.values());
}; };
const ensureOAuthCallbackServer = async (oauthConfig: OAuthConfig) => { // ─── Utilidades ────────────────────────────────────────────────────────────────
if (oauthCallbackServer) {
return;
}
const callbackUrl = getCallbackUrl(oauthConfig.callbackPort); const getStringProp = (payload: unknown, key: string): string => {
if (typeof payload !== 'object' || payload === null || !(key in payload)) return '';
const server = createServer((req, res) => { const value = (payload as Record<string, unknown>)[key];
if (!req.url) { return typeof value === 'string' ? value.trim() : String(value ?? '').trim();
res.statusCode = 400;
res.end('Bad request');
return;
}
const requestUrl = new URL(req.url, callbackUrl);
if (requestUrl.pathname !== CHALLONGE_OAUTH_CALLBACK_PATH) {
res.statusCode = 404;
res.end('Not found');
return;
}
cleanupExpiredOAuthSessions();
const state = requestUrl.searchParams.get('state') || '';
const code = requestUrl.searchParams.get('code') || '';
const error = requestUrl.searchParams.get('error') || '';
const session = Array.from(oauthSessions.values()).find((candidate) => candidate.state === state);
if (!session) {
respondWithCallbackHtml(res, 400, 'Invalid OAuth', 'No active session was found for this authorization.');
return;
}
if (session.expiresAt <= Date.now()) {
updateOAuthSession(session.sessionId, { status: 'expired' });
respondWithCallbackHtml(res, 400, 'Session expired', 'The OAuth session expired. Start the process again from Scoreko.');
return;
}
if (error) {
updateOAuthSession(session.sessionId, { status: 'error', error });
respondWithCallbackHtml(res, 400, 'OAuth canceled', `Challonge returned this error: ${error}`);
return;
}
if (!code) {
updateOAuthSession(session.sessionId, {
status: 'error',
error: 'Missing authorization code',
});
respondWithCallbackHtml(res, 400, 'Incomplete OAuth', 'No authorization code was received.');
return;
}
void exchangeOAuthCodeForToken(code, callbackUrl, oauthConfig)
.then((token) => {
updateOAuthSession(session.sessionId, { status: 'completed', token, error: undefined });
})
.catch((exchangeError) => {
const message = exchangeError instanceof Error ? exchangeError.message : 'Failed to exchange authorization code';
updateOAuthSession(session.sessionId, { status: 'error', error: message });
});
respondWithCallbackHtml(res, 200, 'Authorization received', 'Your authorization was received. Finishing sign-in in the background...');
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(oauthConfig.callbackPort, '127.0.0.1', () => {
server.off('error', reject);
resolve();
});
});
oauthCallbackServer = server;
}; };
const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
if (typeof ack === 'function') ack(error, response);
};
// ─── Listeners de NodeCG ───────────────────────────────────────────────────────
nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) => { nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) => {
const oauthConfig = getOAuthConfig(); const config = getOAuthConfig();
if (!oauthConfig) { if (!config) {
sendAck(ack, 'OAuth is not configured in this installation (missing challongeClientId/challongeClientSecret). Use the Client ID and Client Secret from a Challonge OAuth app.'); sendAck(
ack,
'OAuth is not configured in this installation (missing challongeClientId/challongeClientSecret). Use the Client ID and Client Secret from a Challonge OAuth app.',
);
return; return;
} }
try { try {
await ensureOAuthCallbackServer(oauthConfig); await oauthServer.ensureServer(config);
} catch (serverError) { } catch (err) {
const message = serverError instanceof Error ? serverError.message : 'Could not start the local OAuth callback'; sendAck(ack, err instanceof Error ? err.message : 'Could not start the local OAuth callback');
sendAck(ack, message);
return; return;
} }
cleanupExpiredOAuthSessions(); const session = oauthServer.createSession(config);
sendAck(ack, null, session);
const sessionId = randomUUID();
const state = randomUUID();
oauthSessions.set(sessionId, {
sessionId,
state,
expiresAt: Date.now() + CHALLONGE_OAUTH_SESSION_TTL_MS,
status: 'pending',
});
const params = new URLSearchParams({
response_type: 'code',
client_id: oauthConfig.clientId,
redirect_uri: getCallbackUrl(oauthConfig.callbackPort),
scope: CHALLONGE_OAUTH_SCOPES,
state,
});
sendAck(ack, null, {
sessionId,
authUrl: `${CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT}?${params.toString()}`,
});
}); });
nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => { nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => {
cleanupExpiredOAuthSessions();
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');
return; return;
} }
const session = oauthSessions.get(sessionId); const status = oauthServer.getSessionStatus(sessionId);
if (!session) { if (!status) {
sendAck(ack, 'OAuth session not found'); sendAck(ack, 'OAuth session not found');
return; return;
} }
sendAck(ack, null, { sendAck(ack, null, status);
status: session.status,
token: session.status === 'completed' ? session.token : undefined,
error: session.status === 'error' ? session.error : undefined,
});
}); });
nodecg.listenFor('challonge:fetchRecentTournaments', async (payload: unknown, ack) => { nodecg.listenFor('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');
return; return;
@@ -528,12 +408,10 @@ nodecg.listenFor('challonge:fetchRecentTournaments', async (payload: unknown, ac
const raw = await requestChallonge('/tournaments.json', token); const raw = await requestChallonge('/tournaments.json', token);
const tournaments = parseRecentTournaments(raw) const tournaments = parseRecentTournaments(raw)
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0)) .sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
.slice(0, 20); .slice(0, RECENT_TOURNAMENTS_LIMIT);
sendAck(ack, null, tournaments); sendAck(ack, null, tournaments);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error while loading tournaments'; sendAck(ack, error instanceof Error ? error.message : 'Unknown error while loading tournaments');
sendAck(ack, message);
} }
}); });
@@ -541,22 +419,16 @@ nodecg.listenFor('challonge:fetchTournamentPlayers', async (payload: unknown, ac
const token = getStringProp(payload, 'token'); const token = getStringProp(payload, 'token');
const slug = normalizeTournamentSlug(getStringProp(payload, 'slug')); const slug = normalizeTournamentSlug(getStringProp(payload, 'slug'));
if (!token) { if (!token) { sendAck(ack, 'Missing Challonge API token'); return; }
sendAck(ack, 'Missing Challonge API token'); if (!slug) { sendAck(ack, 'Missing tournament slug'); return; }
return;
}
if (!slug) {
sendAck(ack, 'Missing tournament slug');
return;
}
try { try {
const raw = await requestChallonge(`/tournaments/${encodeURIComponent(slug)}/participants.json`, token); const raw = await requestChallonge(
const players = parseImportedPlayers(raw); `/tournaments/${encodeURIComponent(slug)}/participants.json`,
sendAck(ack, null, players); token,
);
sendAck(ack, null, parseImportedPlayers(raw));
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error while importing players'; sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players');
sendAck(ack, message);
} }
}); });
+140 -322
View File
@@ -1,8 +1,9 @@
import { createServer, type Server, type ServerResponse } from 'node:http';
import { randomUUID } from 'node:crypto';
import { getData, type CountryRecord } from 'country-list'; import { getData, type CountryRecord } from 'country-list';
import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js';
import { nodecg } from './util/nodecg.js'; import { nodecg } from './util/nodecg.js';
// ─── Constantes ────────────────────────────────────────────────────────────────
const STARTGG_ENDPOINT = 'https://api.start.gg/gql/alpha'; const STARTGG_ENDPOINT = 'https://api.start.gg/gql/alpha';
const STARTGG_OAUTH_AUTHORIZE_ENDPOINT = 'https://www.start.gg/api/-/rest/oauth/authorize'; const STARTGG_OAUTH_AUTHORIZE_ENDPOINT = 'https://www.start.gg/api/-/rest/oauth/authorize';
const STARTGG_OAUTH_TOKEN_ENDPOINTS = [ const STARTGG_OAUTH_TOKEN_ENDPOINTS = [
@@ -17,6 +18,8 @@ const STARTGG_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
const RECENT_TOURNAMENTS_LIMIT = 12; const RECENT_TOURNAMENTS_LIMIT = 12;
const PARTICIPANTS_PAGE_SIZE = 120; const PARTICIPANTS_PAGE_SIZE = 120;
// ─── Tipos ─────────────────────────────────────────────────────────────────────
interface StartGGGraphQLResponse<T> { interface StartGGGraphQLResponse<T> {
data?: T; data?: T;
errors?: Array<{ message?: string }>; errors?: Array<{ message?: string }>;
@@ -39,21 +42,6 @@ interface ImportedPlayer {
twitter: string; twitter: string;
} }
interface OAuthConfig {
clientId: string;
clientSecret: string;
callbackPort: number;
}
interface OAuthSession {
sessionId: string;
state: string;
expiresAt: number;
status: 'pending' | 'completed' | 'error' | 'expired';
token?: string;
error?: string;
}
interface OAuthTokenResponse { interface OAuthTokenResponse {
access_token?: string; access_token?: string;
error?: string; error?: string;
@@ -61,31 +49,98 @@ interface OAuthTokenResponse {
message?: string; message?: string;
} }
const oauthSessions = new Map<string, OAuthSession>(); // ─── Config OAuth ──────────────────────────────────────────────────────────────
let oauthCallbackServer: Server | null = null;
const getStringProp = (payload: unknown, key: string): string => { const getOAuthConfig = (): OAuthConfig | null => {
if (typeof payload !== 'object' || payload === null || !(key in payload)) { const bundleConfig = nodecg.bundleConfig as unknown as Record<string, unknown>;
return ''; const clientId = String(bundleConfig.startggClientId ?? '').trim();
} const clientSecret = String(bundleConfig.startggClientSecret ?? '').trim();
const rawPort = Number(bundleConfig.startggOAuthPort ?? STARTGG_OAUTH_DEFAULT_PORT);
const callbackPort =
Number.isFinite(rawPort) && rawPort > 0 ? rawPort : STARTGG_OAUTH_DEFAULT_PORT;
const value = (payload as Record<string, unknown>)[key]; if (!clientId || !clientSecret) return null;
return typeof value === 'string' ? value.trim() : String(value || '').trim();
return { clientId, clientSecret, callbackPort };
}; };
const updateOAuthSession = (sessionId: string, update: Partial<OAuthSession>) => { // ─── Intercambio de token (multi-endpoint) ─────────────────────────────────────
const session = oauthSessions.get(sessionId);
if (!session) {
return;
}
oauthSessions.set(sessionId, { const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => {
...session, const rawBody = await response.text();
...update, try {
return JSON.parse(rawBody) as OAuthTokenResponse;
} catch {
return { message: rawBody };
}
};
const exchangeOAuthCodeForToken = async (
code: string,
redirectUri: string,
config: OAuthConfig,
): Promise<string> => {
const params = new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: config.clientId,
client_secret: config.clientSecret,
redirect_uri: redirectUri,
}); });
let lastError = 'Unknown OAuth token exchange error';
for (const tokenEndpoint of STARTGG_OAUTH_TOKEN_ENDPOINTS) {
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
const payload = await parseOAuthTokenPayload(response);
if (response.ok) {
const token = String(payload.access_token ?? '').trim();
if (token) return token;
lastError =
payload.error_description ??
payload.error ??
payload.message ??
'OAuth token response did not include an access token';
continue;
}
lastError =
payload.error_description ??
payload.error ??
payload.message ??
`OAuth token request failed (${response.status})`;
// Solo 404 justifica probar el siguiente endpoint
if (response.status !== 404) break;
}
throw new Error(lastError);
}; };
const requestStartGG = async <T>(query: string, variables: Record<string, unknown>, token: string): Promise<T> => { // ─── Servidor OAuth ────────────────────────────────────────────────────────────
const oauthServer = createOAuthServer({
provider: 'start.gg',
callbackPath: STARTGG_OAUTH_CALLBACK_PATH,
authorizeEndpoint: STARTGG_OAUTH_AUTHORIZE_ENDPOINT,
scope: STARTGG_OAUTH_SCOPES,
sessionTtlMs: STARTGG_OAUTH_SESSION_TTL_MS,
exchangeToken: exchangeOAuthCodeForToken,
});
// ─── GraphQL ───────────────────────────────────────────────────────────────────
const requestStartGG = async <T>(
query: string,
variables: Record<string, unknown>,
token: string,
): Promise<T> => {
const response = await fetch(STARTGG_ENDPOINT, { const response = await fetch(STARTGG_ENDPOINT, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -107,7 +162,7 @@ const requestStartGG = async <T>(query: string, variables: Record<string, unknow
} }
if (payload.errors?.length) { if (payload.errors?.length) {
throw new Error(payload.errors[0]?.message || 'Unknown start.gg error'); throw new Error(payload.errors[0]?.message ?? 'Unknown start.gg error');
} }
if (!payload.data) { if (!payload.data) {
@@ -117,286 +172,75 @@ const requestStartGG = async <T>(query: string, variables: Record<string, unknow
return payload.data; return payload.data;
}; };
// ─── Resolución de países ──────────────────────────────────────────────────────
const countries = getData(); const countries = getData();
const countryByCode = new Set(countries.map((country: CountryRecord) => country.code.toUpperCase())); const countryByCode = new Set(countries.map((c: CountryRecord) => c.code.toUpperCase()));
const countryByName = new Map(countries.map((country: CountryRecord) => [country.name.toLowerCase(), country.code.toUpperCase()])); const countryByName = new Map(
countries.map((c: CountryRecord) => [c.name.toLowerCase(), c.code.toUpperCase()]),
);
const resolveCountryCodeFromStartGG = (country: string | null | undefined): string => { const resolveCountryCodeFromStartGG = (country: string | null | undefined): string => {
const raw = (country || '').trim(); const raw = (country ?? '').trim();
if (!raw) { if (!raw) return '';
return '';
}
const upper = raw.toUpperCase(); const upper = raw.toUpperCase();
if (countryByCode.has(upper)) { if (countryByCode.has(upper)) return upper;
return upper;
}
return countryByName.get(raw.toLowerCase()) ?? ''; return countryByName.get(raw.toLowerCase()) ?? '';
}; };
// ─── Utilidades ────────────────────────────────────────────────────────────────
const getStringProp = (payload: unknown, key: string): string => {
if (typeof payload !== 'object' || payload === null || !(key in payload)) return '';
const value = (payload as Record<string, unknown>)[key];
return typeof value === 'string' ? value.trim() : String(value ?? '').trim();
};
const sendAck = (ack: unknown, error: string | null, response?: unknown) => { const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
if (typeof ack !== 'function') { if (typeof ack === 'function') ack(error, response);
return;
}
ack(error, response);
}; };
const getOAuthConfig = (): OAuthConfig | null => { // ─── Listeners de NodeCG ───────────────────────────────────────────────────────
const bundleConfig = nodecg.bundleConfig as unknown as Record<string, unknown>;
const clientId = String(bundleConfig.startggClientId || '').trim();
const clientSecret = String(bundleConfig.startggClientSecret || '').trim();
const rawPort = Number(bundleConfig.startggOAuthPort ?? STARTGG_OAUTH_DEFAULT_PORT);
const callbackPort = Number.isFinite(rawPort) && rawPort > 0 ? rawPort : STARTGG_OAUTH_DEFAULT_PORT;
if (!clientId || !clientSecret) {
return null;
}
return {
clientId,
clientSecret,
callbackPort,
};
};
const getCallbackUrl = (callbackPort: number) => `http://127.0.0.1:${callbackPort}${STARTGG_OAUTH_CALLBACK_PATH}`;
const cleanupExpiredOAuthSessions = () => {
const now = Date.now();
oauthSessions.forEach((session, sessionId) => {
if (session.expiresAt <= now && session.status === 'pending') {
updateOAuthSession(sessionId, { status: 'expired' });
}
});
};
const respondWithCallbackHtml = (res: ServerResponse, statusCode: number, title: string, message: string) => {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(renderCallbackHtml(title, message));
};
const renderCallbackHtml = (title: string, message: string) => `<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>${title}</title>
<style>
body { font-family: Arial, sans-serif; margin: 2rem; background: #121212; color: #fff; }
.box { max-width: 680px; padding: 1rem 1.2rem; border: 1px solid #444; border-radius: 8px; }
.ok { color: #66bb6a; }
.ko { color: #ef5350; }
</style>
</head>
<body>
<div class="box">
<h2>${title}</h2>
<p>${message}</p>
<p>You can close this tab and return to Scoreko.</p>
</div>
</body>
</html>`;
const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => {
const rawBody = await response.text();
try {
return JSON.parse(rawBody) as OAuthTokenResponse;
} catch {
return { message: rawBody };
}
};
const exchangeOAuthCodeForToken = async (
code: string,
redirectUri: string,
oauthConfig: OAuthConfig,
): Promise<string> => {
const params = new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: oauthConfig.clientId,
client_secret: oauthConfig.clientSecret,
redirect_uri: redirectUri,
});
let lastError = 'Unknown OAuth token exchange error';
for (const tokenEndpoint of STARTGG_OAUTH_TOKEN_ENDPOINTS) {
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
});
const payload = await parseOAuthTokenPayload(response);
if (response.ok) {
const token = String(payload.access_token || '').trim();
if (token) {
return token;
}
lastError = payload.error_description || payload.error || payload.message || 'OAuth token response did not include an access token';
continue;
}
lastError = payload.error_description || payload.error || payload.message || `OAuth token request failed (${response.status})`;
if (response.status !== 404) {
break;
}
}
throw new Error(lastError);
};
const ensureOAuthCallbackServer = async (oauthConfig: OAuthConfig) => {
if (oauthCallbackServer) {
return;
}
const callbackUrl = getCallbackUrl(oauthConfig.callbackPort);
const server = createServer((req, res) => {
if (!req.url) {
res.statusCode = 400;
res.end('Bad request');
return;
}
const requestUrl = new URL(req.url, callbackUrl);
if (requestUrl.pathname !== STARTGG_OAUTH_CALLBACK_PATH) {
res.statusCode = 404;
res.end('Not found');
return;
}
cleanupExpiredOAuthSessions();
const state = requestUrl.searchParams.get('state') || '';
const code = requestUrl.searchParams.get('code') || '';
const error = requestUrl.searchParams.get('error') || '';
const session = Array.from(oauthSessions.values()).find((candidate) => candidate.state === state);
if (!session) {
respondWithCallbackHtml(res, 400, 'Invalid OAuth', 'No active session was found for this authorization.');
return;
}
if (session.expiresAt <= Date.now()) {
updateOAuthSession(session.sessionId, { status: 'expired' });
respondWithCallbackHtml(res, 400, 'Session expired', 'The OAuth session expired. Start the process again from Scoreko.');
return;
}
if (error) {
updateOAuthSession(session.sessionId, { status: 'error', error });
respondWithCallbackHtml(res, 400, 'OAuth canceled', `start.gg returned this error: ${error}`);
return;
}
if (!code) {
updateOAuthSession(session.sessionId, {
status: 'error',
error: 'Missing authorization code',
});
respondWithCallbackHtml(res, 400, 'Incomplete OAuth', 'No authorization code was received.');
return;
}
void exchangeOAuthCodeForToken(code, callbackUrl, oauthConfig)
.then((token) => {
updateOAuthSession(session.sessionId, { status: 'completed', token, error: undefined });
})
.catch((exchangeError) => {
const message = exchangeError instanceof Error ? exchangeError.message : 'Failed to exchange authorization code';
updateOAuthSession(session.sessionId, { status: 'error', error: message });
});
respondWithCallbackHtml(res, 200, 'Authorization received', 'Your authorization was received. Finishing sign-in in the background...');
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(oauthConfig.callbackPort, '127.0.0.1', () => {
server.off('error', reject);
resolve();
});
});
oauthCallbackServer = server;
};
nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => { nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => {
const oauthConfig = getOAuthConfig(); const config = getOAuthConfig();
if (!oauthConfig) { if (!config) {
sendAck(ack, 'OAuth is not configured in this installation (missing startggClientId/startggClientSecret). Use the Client ID and Client Secret from a start.gg OAuth app.'); sendAck(
ack,
'OAuth is not configured in this installation (missing startggClientId/startggClientSecret). Use the Client ID and Client Secret from a start.gg OAuth app.',
);
return; return;
} }
try { try {
await ensureOAuthCallbackServer(oauthConfig); await oauthServer.ensureServer(config);
} catch (serverError) { } catch (err) {
const message = serverError instanceof Error ? serverError.message : 'Could not start the local OAuth callback'; sendAck(ack, err instanceof Error ? err.message : 'Could not start the local OAuth callback');
sendAck(ack, message);
return; return;
} }
cleanupExpiredOAuthSessions(); const session = oauthServer.createSession(config);
sendAck(ack, null, session);
const sessionId = randomUUID();
const state = randomUUID();
const session: OAuthSession = {
sessionId,
state,
expiresAt: Date.now() + STARTGG_OAUTH_SESSION_TTL_MS,
status: 'pending',
};
oauthSessions.set(sessionId, session);
const params = new URLSearchParams({
response_type: 'code',
client_id: oauthConfig.clientId,
redirect_uri: getCallbackUrl(oauthConfig.callbackPort),
scope: STARTGG_OAUTH_SCOPES,
state,
});
sendAck(ack, null, {
sessionId,
authUrl: `${STARTGG_OAUTH_AUTHORIZE_ENDPOINT}?${params.toString()}`,
});
}); });
nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => { nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => {
cleanupExpiredOAuthSessions();
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');
return; return;
} }
const session = oauthSessions.get(sessionId); const status = oauthServer.getSessionStatus(sessionId);
if (!session) { if (!status) {
sendAck(ack, 'OAuth session not found'); sendAck(ack, 'OAuth session not found');
return; return;
} }
sendAck(ack, null, { sendAck(ack, null, status);
status: session.status,
token: session.status === 'completed' ? session.token : undefined,
error: session.status === 'error' ? session.error : undefined,
});
}); });
nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack) => { nodecg.listenFor('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');
return; return;
@@ -423,21 +267,15 @@ nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack)
currentUser: { tournaments: { nodes: RecentTournament[] } } | null; currentUser: { tournaments: { nodes: RecentTournament[] } } | null;
}>(query, { perPage: RECENT_TOURNAMENTS_LIMIT }, token); }>(query, { perPage: RECENT_TOURNAMENTS_LIMIT }, token);
const tournaments = data.currentUser?.tournaments.nodes const tournaments =
data.currentUser?.tournaments.nodes
.filter((item) => item.slug) .filter((item) => item.slug)
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0)) .sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
.map((item) => ({ .map(({ id, name, slug, startAt, endAt }) => ({ id, name, slug, startAt, endAt })) ?? [];
id: item.id,
name: item.name,
slug: item.slug,
startAt: item.startAt,
endAt: item.endAt,
})) ?? [];
sendAck(ack, null, tournaments); sendAck(ack, null, tournaments);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error while loading tournaments'; sendAck(ack, error instanceof Error ? error.message : 'Unknown error while loading tournaments');
sendAck(ack, message);
} }
}); });
@@ -445,15 +283,8 @@ nodecg.listenFor('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');
if (!token) { if (!token) { sendAck(ack, 'Missing start.gg API token'); return; }
sendAck(ack, 'Missing start.gg API token'); if (!slug) { sendAck(ack, 'Missing tournament slug'); return; }
return;
}
if (!slug) {
sendAck(ack, 'Missing tournament slug');
return;
}
const query = ` const query = `
query TournamentParticipants($slug: String!, $page: Int!, $perPage: Int!) { query TournamentParticipants($slug: String!, $page: Int!, $perPage: Int!) {
@@ -491,50 +322,37 @@ nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack)
id: number; id: number;
gamerTag: string | null; gamerTag: string | null;
prefix: string | null; prefix: string | null;
user: { user: { location: { country: string | null } | null } | null;
location: {
country: string | null;
} | null;
} | null;
}>; }>;
}; };
} | null; } | null;
}>(query, { }>(query, { slug, page: currentPage, perPage: PARTICIPANTS_PAGE_SIZE }, token);
slug,
page: currentPage,
perPage: PARTICIPANTS_PAGE_SIZE,
}, token);
if (!data.tournament) { if (!data.tournament) throw new Error('Tournament not found');
throw new Error('Tournament not found');
}
const apiTotalPages = Number(data.tournament.participants.pageInfo.totalPages); const apiTotalPages = Number(data.tournament.participants.pageInfo.totalPages);
totalPages = Number.isFinite(apiTotalPages) ? Math.max(apiTotalPages, 1) : 1; totalPages = Number.isFinite(apiTotalPages) ? Math.max(apiTotalPages, 1) : 1;
data.tournament.participants.nodes.forEach((participant) => { for (const participant of data.tournament.participants.nodes) {
const playerId = String(participant.id); const playerId = String(participant.id);
const gamertag = (participant.gamerTag || '').trim(); const gamertag = (participant.gamerTag ?? '').trim();
if (!gamertag) { if (!gamertag) continue;
return;
}
const country = resolveCountryCodeFromStartGG(participant.user?.location?.country);
playersMap.set(playerId, { playersMap.set(playerId, {
id: playerId, id: playerId,
gamertag, gamertag,
name: gamertag, name: gamertag,
team: (participant.prefix || '').trim(), team: (participant.prefix ?? '').trim(),
country, country: resolveCountryCodeFromStartGG(participant.user?.location?.country),
twitter: '', twitter: '',
}); });
}); }
currentPage += 1; currentPage += 1;
} }
sendAck(ack, null, Array.from(playersMap.values())); sendAck(ack, null, Array.from(playersMap.values()));
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error while importing players'; sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players');
sendAck(ack, message);
} }
}); });
+273
View File
@@ -0,0 +1,273 @@
import { createServer, type Server, type ServerResponse } from 'node:http';
import { randomUUID } from 'node:crypto';
// ─── Tipos públicos ────────────────────────────────────────────────────────────
export interface OAuthConfig {
clientId: string;
clientSecret: string;
callbackPort: number;
}
export interface OAuthSessionStatus {
status: 'pending' | 'completed' | 'error' | 'expired';
token?: string;
error?: string;
}
export interface CreateSessionResult {
sessionId: string;
authUrl: string;
}
export interface OAuthServerOptions {
/** Nombre legible del proveedor, usado en mensajes y HTML del callback */
provider: string;
/** Ruta del callback OAuth, p.ej. '/startgg/callback' */
callbackPath: string;
/** URL del endpoint de autorización del proveedor */
authorizeEndpoint: string;
/** Scopes separados por espacio */
scope: string;
/** Milisegundos antes de que una sesión pendiente expire */
sessionTtlMs: number;
/**
* Intercambia un código de autorización por un access token.
* Lanza un error si el intercambio falla.
*/
exchangeToken: (code: string, redirectUri: string, config: OAuthConfig) => Promise<string>;
}
export interface OAuthServerHandle {
/** Arranca el servidor de callback si aún no está corriendo */
ensureServer(config: OAuthConfig): Promise<void>;
/** Crea una nueva sesión OAuth y devuelve sessionId + URL de autorización */
createSession(config: OAuthConfig): CreateSessionResult;
/** Devuelve el estado actual de una sesión, o null si no existe */
getSessionStatus(sessionId: string): OAuthSessionStatus | null;
}
// ─── Tipos internos ────────────────────────────────────────────────────────────
interface OAuthSession {
sessionId: string;
state: string;
expiresAt: number;
status: 'pending' | 'completed' | 'error' | 'expired';
token?: string;
error?: string;
}
// ─── HTML de callback ──────────────────────────────────────────────────────────
const renderCallbackHtml = (title: string, message: string) => `<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>${title}</title>
<style>
body { font-family: Arial, sans-serif; margin: 2rem; background: #121212; color: #fff; }
.box { max-width: 680px; padding: 1rem 1.2rem; border: 1px solid #444; border-radius: 8px; }
.ok { color: #66bb6a; }
.ko { color: #ef5350; }
</style>
</head>
<body>
<div class="box">
<h2>${title}</h2>
<p>${message}</p>
<p>You can close this tab and return to Scoreko.</p>
</div>
</body>
</html>`;
const respondWithCallbackHtml = (
res: ServerResponse,
statusCode: number,
title: string,
message: string,
) => {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(renderCallbackHtml(title, message));
};
// ─── Factory principal ─────────────────────────────────────────────────────────
export const createOAuthServer = (options: OAuthServerOptions): OAuthServerHandle => {
const sessions = new Map<string, OAuthSession>();
let server: Server | null = null;
const getCallbackUrl = (port: number) =>
`http://127.0.0.1:${port}${options.callbackPath}`;
const updateSession = (sessionId: string, update: Partial<OAuthSession>) => {
const session = sessions.get(sessionId);
if (!session) return;
sessions.set(sessionId, { ...session, ...update });
};
/**
* Marca como expiradas las sesiones pendientes que han superado su TTL,
* y elimina del Map las sesiones ya terminadas (completed / error / expired)
* que también hayan superado su TTL.
*/
const cleanupSessions = () => {
const now = Date.now();
sessions.forEach((session, sessionId) => {
if (session.expiresAt > now) return;
if (session.status === 'pending') {
updateSession(sessionId, { status: 'expired' });
}
// Eliminar sesiones terminadas que ya hayan expirado para no crecer sin límite
if (session.status !== 'pending') {
sessions.delete(sessionId);
}
});
};
const ensureServer = async (config: OAuthConfig): Promise<void> => {
if (server) return;
const callbackUrl = getCallbackUrl(config.callbackPort);
const newServer = createServer((req, res) => {
if (!req.url) {
res.statusCode = 400;
res.end('Bad request');
return;
}
const requestUrl = new URL(req.url, callbackUrl);
if (requestUrl.pathname !== options.callbackPath) {
res.statusCode = 404;
res.end('Not found');
return;
}
cleanupSessions();
const state = requestUrl.searchParams.get('state') ?? '';
const code = requestUrl.searchParams.get('code') ?? '';
const error = requestUrl.searchParams.get('error') ?? '';
const session = Array.from(sessions.values()).find((s) => s.state === state);
if (!session) {
respondWithCallbackHtml(
res, 400,
'Invalid OAuth',
'No active session was found for this authorization.',
);
return;
}
if (session.expiresAt <= Date.now()) {
updateSession(session.sessionId, { status: 'expired' });
respondWithCallbackHtml(
res, 400,
'Session expired',
'The OAuth session expired. Start the process again from Scoreko.',
);
return;
}
if (error) {
updateSession(session.sessionId, { status: 'error', error });
respondWithCallbackHtml(
res, 400,
'OAuth canceled',
`${options.provider} returned this error: ${error}`,
);
return;
}
if (!code) {
updateSession(session.sessionId, { status: 'error', error: 'Missing authorization code' });
respondWithCallbackHtml(
res, 400,
'Incomplete OAuth',
'No authorization code was received.',
);
return;
}
void options
.exchangeToken(code, callbackUrl, config)
.then((token) => {
updateSession(session.sessionId, { status: 'completed', token, error: undefined });
})
.catch((err: unknown) => {
const message =
err instanceof Error ? err.message : 'Failed to exchange authorization code';
updateSession(session.sessionId, { status: 'error', error: message });
});
respondWithCallbackHtml(
res, 200,
'Authorization received',
'Your authorization was received. Finishing sign-in in the background...',
);
});
// Si el servidor sufre un error tras arrancar, resetear la referencia
// para que la próxima llamada a ensureServer() pueda reiniciarlo.
newServer.on('error', (err) => {
console.error(`[${options.provider}] OAuth callback server error:`, err);
server = null;
});
await new Promise<void>((resolve, reject) => {
newServer.once('error', reject);
newServer.listen(config.callbackPort, '127.0.0.1', () => {
newServer.off('error', reject);
resolve();
});
});
server = newServer;
};
const createSession = (config: OAuthConfig): CreateSessionResult => {
cleanupSessions();
const sessionId = randomUUID();
const state = randomUUID();
sessions.set(sessionId, {
sessionId,
state,
expiresAt: Date.now() + options.sessionTtlMs,
status: 'pending',
});
const params = new URLSearchParams({
response_type: 'code',
client_id: config.clientId,
redirect_uri: getCallbackUrl(config.callbackPort),
scope: options.scope,
state,
});
return {
sessionId,
authUrl: `${options.authorizeEndpoint}?${params.toString()}`,
};
};
const getSessionStatus = (sessionId: string): OAuthSessionStatus | null => {
cleanupSessions();
const session = sessions.get(sessionId);
if (!session) return null;
return {
status: session.status,
token: session.status === 'completed' ? session.token : undefined,
error: session.status === 'error' ? session.error : undefined,
};
};
return { ensureServer, createSession, getSessionStatus };
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

+103 -51
View File
@@ -10,6 +10,51 @@ const DEFAULT_PLACEHOLDER_PALETTE: GamePalette = ['#334155', '#0f172a'];
const MAX_INITIALS = 2; const MAX_INITIALS = 2;
const characterNamesByGame: Record<string, string[]> = { 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-': [ 'Guilty Gear -Strive-': [
'A.B.A', 'A.B.A',
'Anji Mito', 'Anji Mito',
@@ -44,6 +89,64 @@ const characterNamesByGame: Record<string, string[]> = {
'Venom', 'Venom',
'Zato-1', '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',
'Power Plex',
'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': [ 'Street Fighter 6': [
'A.K.I.', 'A.K.I.',
'Akuma', 'Akuma',
@@ -120,57 +223,6 @@ const characterNamesByGame: Record<string, string[]> = {
'Yoshimitsu', 'Yoshimitsu',
'Zafina', 'Zafina',
], ],
'2XKO': [
'Ahri',
'Akali',
'Braum',
'Caitlyn',
'Darius',
'Ekko',
'Illaoi',
'Jinx',
'Senna',
'Teemo',
'Vi',
'Warwick',
'Yasuo',
],
'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',
],
'THE KING OF FIGHTERS XV': [ 'THE KING OF FIGHTERS XV': [
'Angel', 'Angel',
'Antonov', 'Antonov',