From 10eb1b96e566737189656aba657cf1b67d4116a8 Mon Sep 17 00:00:00 2001 From: enkoder Date: Wed, 18 Feb 2026 12:27:09 -0800 Subject: [PATCH] Add handling for new CBI --- api/src/background.ts | 15 +-- api/src/lib/abr.ts | 37 ++++++++ api/src/lib/cbi.ts | 211 ++++++++++++++++++++++++++++++++++++------ 3 files changed, 227 insertions(+), 36 deletions(-) diff --git a/api/src/background.ts b/api/src/background.ts index 6621d79..7503f86 100644 --- a/api/src/background.ts +++ b/api/src/background.ts @@ -11,7 +11,7 @@ import { getTournamentsByType, getTournamentsByUserId, } from "./lib/abr.js"; -import { CBI_2024, handleMultiPodTournament } from "./lib/cbi.js"; +import { tryHandleMultiPodTournament } from "./lib/cbi.js"; import * as NRDB from "./lib/nrdb.js"; import { calculatePointDistribution, getSeasonConfig } from "./lib/ranking.js"; import { trace } from "./lib/tracer.js"; @@ -362,12 +362,13 @@ async function handleTournamentIngest( let tournamentBlob: Tournament; let cutTo: number; - // If we found the top cut for a multi-pod tournament - artisially hand craft the data - if (abrTournament.id === CBI_2024.top_cut.abr_tournament_id) { - ({ entries, cutTo, tournamentBlob } = await handleMultiPodTournament( - seasonId, - abrTournament, - )); + const multiPodResult = await tryHandleMultiPodTournament( + seasonId, + abrTournament, + ); + + if (multiPodResult) { + ({ entries, cutTo, tournamentBlob } = multiPodResult); } else { entries = await getEntries(abrTournament.id); cutTo = entries.filter((e) => e.rank_top !== null).length; diff --git a/api/src/lib/abr.ts b/api/src/lib/abr.ts index 3499d6b..43e6d0c 100644 --- a/api/src/lib/abr.ts +++ b/api/src/lib/abr.ts @@ -121,7 +121,32 @@ export function abrToResult(abr: ABREntryType, { ...args }): Partial { }; } +// Tjson schemas for the /tjsons/{id}.json endpoint +export const ABRTjsonPlayer = z.object({ + id: z.number(), + name: z.string(), + rank: z.number(), + corpIdentity: z.string(), + runnerIdentity: z.string(), + matchPoints: z.number(), + strengthOfSchedule: z.string(), + extendedStrengthOfSchedule: z.string(), +}); +export type ABRTjsonPlayerType = z.infer; + +export const ABRTjson = z.object({ + name: z.string(), + date: z.string(), + cutToTop: z.number(), + preliminaryRounds: z.number(), + players: z.array(ABRTjsonPlayer), + eliminationPlayers: z.array(ABRTjsonPlayer), + uploadedFrom: z.string(), +}); +export type ABRTjsonType = z.infer; + const ABR_BASE_URL = "https://alwaysberunning.net/api"; +const ABR_TJSON_BASE_URL = "https://alwaysberunning.net/tjsons"; async function _getTournaments(url: URL): Promise { const retArr: ABRTournamentType[] = []; @@ -198,3 +223,15 @@ export async function getEntries( } return retArr; } + +export async function getTournamentJson( + tournamentId: number, +): Promise { + const url = `${ABR_TJSON_BASE_URL}/${tournamentId}.json`; + const resp = await fetch(url); + if (!resp.ok) { + throw new Error(`Error (${resp.status}): ${await resp.text()}`); + } + const body = await resp.json(); + return ABRTjson.parse(body); +} diff --git a/api/src/lib/cbi.ts b/api/src/lib/cbi.ts index 70947bf..5932ef6 100644 --- a/api/src/lib/cbi.ts +++ b/api/src/lib/cbi.ts @@ -3,9 +3,11 @@ import { Tournaments } from "../models/tournament.js"; import type { Tournament } from "../schema.js"; import { type ABREntryType, + type ABRTjsonPlayerType, type ABRTournamentType, abrToTournament, getEntries, + getTournamentJson, } from "./abr.js"; import {} from "./nsg.js"; import { @@ -58,6 +60,149 @@ export const CBI_2024 = { }, } as CBIConfig; +// V2 config: uses ABR tjson endpoint only, no NSG dependency +export interface MultiPodConfig { + name: string; + pods: { abr_id: number }[]; + top_cut: { abr_id: number }; +} + +export const CBI_2025_V2: MultiPodConfig = { + name: "Circuit Breaker Invitational 2025", + pods: [ + { abr_id: 5304 }, // EMEA + { abr_id: 5302 }, // APAC + { abr_id: 5303 }, // Americas + ], + top_cut: { abr_id: 5301 }, +}; + +const MULTI_POD_CONFIGS: MultiPodConfig[] = [CBI_2025_V2]; + +interface MultiPodEntry extends ABREntryType { + points: number; + sos: number; + extended_sos: number; + rounds_played: number; +} + +export async function handleMultiPodTournamentV2( + seasonId: number, + abrTournament: ABRTournamentType, + config: MultiPodConfig, +): Promise<{ + entries: ABREntryType[]; + cutTo: number; + tournamentBlob: Tournament; +}> { + // Fetch top cut entries for rank_top assignment + const topCutEntries = await getEntries(config.top_cut.abr_id); + const cutTo = topCutEntries.filter((e) => e.rank_top !== null).length; + + const allEntries: MultiPodEntry[] = []; + + for (const pod of config.pods) { + // Fetch the tjson (has standings: matchPoints, sos, esos, preliminaryRounds) + const tjson = await getTournamentJson(pod.abr_id); + // Build a map of tjson players by name for quick lookup + const tjsonPlayersByName = new Map(); + for (const player of tjson.players) { + tjsonPlayersByName.set(player.name.toLowerCase(), player); + } + + // Fetch ABR entries (has deck info, user_id, user_name, user_import_name) + const abrEntries = await getEntries(pod.abr_id); + + for (const entry of abrEntries) { + // user_import_name is the Cobra name which matches tjson player.name + const importName = entry.user_import_name?.toLowerCase(); + const tjsonPlayer = importName + ? tjsonPlayersByName.get(importName) + : undefined; + + if (!tjsonPlayer) { + console.log( + `Could not find tjson player for ${ + entry.user_name || entry.user_import_name + } (${entry.user_id}), assigning 0 points`, + ); + } + + allEntries.push({ + ...entry, + points: tjsonPlayer?.matchPoints ?? 0, + sos: Number(tjsonPlayer?.strengthOfSchedule ?? 0), + extended_sos: Number(tjsonPlayer?.extendedStrengthOfSchedule ?? 0), + rounds_played: tjson.preliminaryRounds, + }); + } + } + + // Sort by normalized score, then sos, then esos + allEntries.sort((a, b) => { + const aNorm = a.points / (a.rounds_played * 3); + const bNorm = b.points / (b.rounds_played * 3); + if (aNorm !== bNorm) return bNorm - aNorm; + if (a.sos !== b.sos) return b.sos - a.sos; + return b.extended_sos - a.extended_sos; + }); + + // Assign swiss placement and top cut rank + const finalEntries: ABREntryType[] = allEntries.map((entry, i) => { + const topCutEntry = topCutEntries.find( + (p) => + (p.user_id && p.user_id === entry.user_id) || + (p.user_name && p.user_name === entry.user_name) || + (p.user_import_name && p.user_import_name === entry.user_import_name), + ); + return { + ...entry, + rank_swiss: i + 1, + rank_top: topCutEntry ? topCutEntry.rank_top : null, + }; + }); + + const baseAbrTournament = { + ...abrTournament, + title: config.name, + players_count: finalEntries.length, + }; + const tournamentBlob = abrToTournament(baseAbrTournament, seasonId, cutTo); + + return { + entries: finalEntries, + cutTo, + tournamentBlob: { + ...tournamentBlob, + players_count: finalEntries.length, + name: config.name, + multi_swiss: 1, + }, + }; +} + +export async function tryHandleMultiPodTournament( + seasonId: number, + abrTournament: ABRTournamentType, +): Promise<{ + entries: ABREntryType[]; + cutTo: number; + tournamentBlob: Tournament; +} | null> { + const multiPodConfig = MULTI_POD_CONFIGS.find( + (c) => c.top_cut.abr_id === abrTournament.id, + ); + if (multiPodConfig) { + return handleMultiPodTournamentV2(seasonId, abrTournament, multiPodConfig); + } + + if (CBI_2024.top_cut.abr_tournament_id === abrTournament.id) { + return handleMultiPodTournament(seasonId, abrTournament, CBI_2024); + } + + return null; +} + interface PodResult { points: number; sos: number; @@ -111,20 +256,21 @@ const STANDINGS: Record = { export async function handleMultiPodTournament( seasonId: number, abrTournament: ABRTournamentType, + config: CBIConfig, ): Promise<{ entries: ABREntryType[]; cutTo: number; tournamentBlob: Tournament; }> { // Get top cut entries - const topCutEntries = await getEntries(CBI_2024.top_cut.abr_tournament_id); + const topCutEntries = await getEntries(config.top_cut.abr_tournament_id); const cutTo = topCutEntries.filter((e) => e.rank_top !== null).length; // Get all pod entries unsorted const podEntries = { - emea: await getEntries(CBI_2024.pods.emea.abr_tournament_id), - apac: await getEntries(CBI_2024.pods.apac.abr_tournament_id), - americas: await getEntries(CBI_2024.pods.americas.abr_tournament_id), + emea: await getEntries(config.pods.emea.abr_tournament_id), + apac: await getEntries(config.pods.apac.abr_tournament_id), + americas: await getEntries(config.pods.americas.abr_tournament_id), }; const cbiEntries: CBIEntry[] = []; @@ -135,47 +281,54 @@ export async function handleMultiPodTournament( apac: TournamentStandings; americas: TournamentStandings; } = { - emea: await getTournamentStandings(CBI_2024.pods.emea.nsg_tournament_id), - apac: await getTournamentStandings(CBI_2024.pods.apac.nsg_tournament_id), + emea: await getTournamentStandings(config.pods.emea.nsg_tournament_id), + apac: await getTournamentStandings(config.pods.apac.nsg_tournament_id), americas: await getTournamentStandings( - CBI_2024.pods.americas.nsg_tournament_id, + config.pods.americas.nsg_tournament_id, ), }; // Join up the pod results with the abr entries for (const [pod, abrEntries] of Object.entries(podEntries)) { for (const abrEntry of abrEntries) { + const abrName = ( + abrEntry.user_name || + abrEntry.user_import_name || + "" + ).toLowerCase(); + // Find this player's cobra results const playerStanding = podStandings[pod].stages[0].standings.find( - (s: PlayerStanding) => - s.player?.id === abrEntry.user_id || - s.player?.name_with_pronouns + (s: PlayerStanding) => { + if (!s.player) return false; + // Strip pronouns from NSG name e.g. "The King (he/him)" -> "the king" + const nsgName = s.player.name_with_pronouns + .replace(/\s*\(.*?\)\s*$/, "") .toLowerCase() - .includes( - abrEntry.user_name?.toLowerCase() || - abrEntry.user_import_name?.toLowerCase(), - ), + .trim(); + return ( + nsgName === abrName || + nsgName.includes(abrName) || + (abrName.length > 0 && abrName.includes(nsgName)) + ); + }, // Need to hard code some of these as they used different accounts for their swiss and top cut rounds ) || STANDINGS[abrEntry.user_id]; if (!playerStanding) { - if (!playerStanding) { - console.log( - abrEntry.user_name || abrEntry.user_import_name, - abrEntry.user_id, - ); - throw new Error( - `Could not find player standing for ${abrEntry.user_name} (${abrEntry.user_id})`, - ); - } + console.log( + `Could not find player standing for ${ + abrEntry.user_name || abrEntry.user_import_name + } (${abrEntry.user_id}), assigning 0 points`, + ); } cbiEntries.push({ ...abrEntry, - points: playerStanding.points, - sos: Number(playerStanding.sos), - extended_sos: Number(playerStanding.extended_sos), - rounds_played: CBI_2024.pods[pod].rounds_played, + points: playerStanding?.points ?? 0, + sos: Number(playerStanding?.sos ?? 0), + extended_sos: Number(playerStanding?.extended_sos ?? 0), + rounds_played: config.pods[pod].rounds_played, }); } } @@ -223,7 +376,7 @@ export async function handleMultiPodTournament( // Create the tournament blob const baseAbrTournament = { ...abrTournament, - title: CBI_2024.name, + title: config.name, players_count: finalEntries.length, }; @@ -235,7 +388,7 @@ export async function handleMultiPodTournament( tournamentBlob: { ...finalTournament, players_count: finalEntries.length, - name: CBI_2024.name, + name: config.name, multi_swiss: 1, }, };