Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions api/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
37 changes: 37 additions & 0 deletions api/src/lib/abr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,32 @@ export function abrToResult(abr: ABREntryType, { ...args }): Partial<Result> {
};
}

// 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<typeof ABRTjsonPlayer>;

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<typeof ABRTjson>;

const ABR_BASE_URL = "https://alwaysberunning.net/api";
const ABR_TJSON_BASE_URL = "https://alwaysberunning.net/tjsons";

async function _getTournaments(url: URL): Promise<ABRTournamentType[]> {
const retArr: ABRTournamentType[] = [];
Expand Down Expand Up @@ -198,3 +223,15 @@ export async function getEntries(
}
return retArr;
}

export async function getTournamentJson(
tournamentId: number,
): Promise<ABRTjsonType> {
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);
}
211 changes: 182 additions & 29 deletions api/src/lib/cbi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, ABRTjsonPlayerType>();
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;
Expand Down Expand Up @@ -111,20 +256,21 @@ const STANDINGS: Record<number, PlayerStanding> = {
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[] = [];
Expand All @@ -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,
});
}
}
Expand Down Expand Up @@ -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,
};

Expand All @@ -235,7 +388,7 @@ export async function handleMultiPodTournament(
tournamentBlob: {
...finalTournament,
players_count: finalEntries.length,
name: CBI_2024.name,
name: config.name,
multi_swiss: 1,
},
};
Expand Down