This commit is contained in:
yohlo
2025-10-01 13:26:42 -05:00
parent ce29c41bf3
commit 654041b6b6
18 changed files with 1381 additions and 7 deletions

View File

@@ -5,6 +5,7 @@ import { createTeamsService } from "./services/teams";
import { createMatchesService } from "./services/matches";
import { createReactionsService } from "./services/reactions";
import { createActivitiesService } from "./services/activities";
import { createBadgesService } from "./services/badges";
import dotenv from 'dotenv';
dotenv.config();
@@ -37,6 +38,7 @@ class PocketBaseAdminClient {
Object.assign(this, createMatchesService(this.pb));
Object.assign(this, createReactionsService(this.pb));
Object.assign(this, createActivitiesService(this.pb));
Object.assign(this, createBadgesService(this.pb));
});
}
@@ -57,7 +59,8 @@ interface AdminClient
ReturnType<typeof createTournamentsService>,
ReturnType<typeof createMatchesService>,
ReturnType<typeof createReactionsService>,
ReturnType<typeof createActivitiesService> {
ReturnType<typeof createActivitiesService>,
ReturnType<typeof createBadgesService> {
authPromise: Promise<void>;
}

View File

@@ -0,0 +1,407 @@
import PocketBase from "pocketbase";
import { Badge, BadgeProgress } from "@/features/badges/types";
import { transformBadge, transformBadgeProgress } from "@/lib/pocketbase/util/transform-types";
export interface PlayerStats {
player_id: string;
matches: number;
wins: number;
losses: number;
total_cups_made: number;
total_cups_against: number;
margin_of_victory: number;
}
export function createBadgesService(pb: PocketBase) {
return {
async getBadge(id: string): Promise<Badge> {
const result = await pb.collection("badges").getOne(id);
return transformBadge(result);
},
async listBadges(): Promise<Badge[]> {
const results = await pb.collection("badges").getFullList({
sort: 'name',
});
return results.map(transformBadge);
},
async getBadgeProgress(id: string): Promise<BadgeProgress> {
const result = await pb.collection("badge_progress").getOne(id, {
expand: 'badge,player',
});
return transformBadgeProgress(result);
},
async getPlayerBadgeProgress(playerId: string): Promise<BadgeProgress[]> {
const results = await pb.collection("badge_progress").getFullList({
filter: `player = "${playerId}"`,
expand: 'badge',
});
return results.map(transformBadgeProgress);
},
async createBadgeProgress(data: {
badge: string;
player: string;
progress: number;
earned: boolean;
}): Promise<BadgeProgress> {
return await pb.collection("badge_progress").create<BadgeProgress>(data);
},
async updateBadgeProgress(id: string, data: {
progress?: number;
earned?: boolean;
}): Promise<BadgeProgress> {
return await pb.collection("badge_progress").update<BadgeProgress>(id, data);
},
async deleteBadgeProgress(id: string): Promise<boolean> {
await pb.collection("badge_progress").delete(id);
return true;
},
async clearAllBadgeProgress(): Promise<number> {
const existingProgress = await pb.collection("badge_progress").getFullList();
for (const progress of existingProgress) {
await pb.collection("badge_progress").delete(progress.id);
}
return existingProgress.length;
},
async calculateBadgeProgress(playerId: string, badge: Badge): Promise<number> {
if (badge.type === "manual") {
return 0;
}
if (badge.type === "match") {
return await this.calculateMatchBadgeProgress(playerId, badge);
}
if (badge.type === "tournament") {
return await this.calculateTournamentBadgeProgress(playerId, badge);
}
return 0;
},
async calculateMatchBadgeProgress(playerId: string, badge: Badge): Promise<number> {
const criteria = badge.criteria;
const stats = await pb.collection("player_stats").getFirstListItem<PlayerStats>(
`player_id = "${playerId}"`
).catch(() => null);
if (!stats) return 0;
if (criteria.matches_played !== undefined) {
return stats.matches;
}
if (criteria.overtime_matches !== undefined || criteria.overtime_wins !== undefined) {
const matches = await pb.collection("matches").getFullList({
filter: `(home.players.id ?~ "${playerId}" || away.players.id ?~ "${playerId}") && status = "ended" && ot_count > 0`,
expand: 'home,away,home.players,away.players',
});
if (criteria.overtime_matches !== undefined) {
return matches.length;
}
if (criteria.overtime_wins !== undefined) {
const overtimeWins = matches.filter(m => {
const isHome = m.expand?.home?.expand?.players?.some((p: any) => p.id === playerId) ||
m.expand?.home?.players?.includes(playerId);
const isAway = m.expand?.away?.expand?.players?.some((p: any) => p.id === playerId) ||
m.expand?.away?.players?.includes(playerId);
if (isHome) {
return m.home_cups > m.away_cups;
} else if (isAway) {
return m.away_cups > m.home_cups;
}
return false;
});
return overtimeWins.length;
}
}
if (criteria.margin_of_victory !== undefined) {
const matches = await pb.collection("matches").getFullList({
filter: `(home.players.id ?~ "${playerId}" || away.players.id ?~ "${playerId}") && status = "ended"`,
expand: 'home,away,home.players,away.players',
});
const bigWins = matches.filter(m => {
const isHome = m.expand?.home?.expand?.players?.some((p: any) => p.id === playerId) ||
m.expand?.home?.players?.includes(playerId);
const isAway = m.expand?.away?.expand?.players?.some((p: any) => p.id === playerId) ||
m.expand?.away?.players?.includes(playerId);
if (isHome && m.home_cups > m.away_cups) {
return (m.home_cups - m.away_cups) >= criteria.margin_of_victory;
} else if (isAway && m.away_cups > m.home_cups) {
return (m.away_cups - m.home_cups) >= criteria.margin_of_victory;
}
return false;
});
return bigWins.length > 0 ? 1 : 0;
}
return 0;
},
async calculateTournamentBadgeProgress(playerId: string, badge: Badge): Promise<number> {
const criteria = badge.criteria;
const matches = await pb.collection("matches").getFullList({
filter: `(home.players.id ?~ "${playerId}" || away.players.id ?~ "${playerId}") && status = "ended"`,
expand: 'tournament,home,away,home.players,away.players',
});
const tournamentIds = new Set(matches.map(m => m.tournament));
const tournamentsAttended = tournamentIds.size;
if (criteria.tournaments_attended !== undefined) {
return tournamentsAttended;
}
if (criteria.tournament_wins !== undefined) {
if (tournamentIds.size === 0) return 0;
let tournamentWins = 0;
for (const tournamentId of tournamentIds) {
const tournamentMatches = await pb.collection("matches").getFullList({
filter: `tournament = "${tournamentId}" && status = "ended"`,
expand: 'home,away,home.players,away.players',
});
const winnersMatches = tournamentMatches.filter(m => !m.is_losers_bracket);
const finalsMatch = winnersMatches.reduce((highest: any, current: any) =>
(!highest || current.lid > highest.lid) ? current : highest, null);
if (finalsMatch && finalsMatch.status === 'ended') {
const finalsWinnerId = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.home : finalsMatch.away;
const winningTeam = finalsMatch.expand?.[finalsWinnerId === finalsMatch.home ? 'home' : 'away'];
const winningPlayers = winningTeam?.expand?.players || winningTeam?.players || [];
const playerWon = winningPlayers.some((p: any) =>
(typeof p === 'string' ? p : p.id) === playerId
);
if (playerWon) {
tournamentWins++;
}
}
}
return tournamentWins;
}
if (criteria.placement !== undefined && typeof criteria.placement === 'number') {
let placementCount = 0;
for (const tournamentId of tournamentIds) {
const tournamentMatches = await pb.collection("matches").getFullList({
filter: `tournament = "${tournamentId}" && status = "ended"`,
expand: 'home,away,home.players,away.players',
});
if (criteria.placement === 2) {
const winnersMatches = tournamentMatches.filter(m => !m.is_losers_bracket);
const finalsMatch = winnersMatches.reduce((highest: any, current: any) =>
(!highest || current.lid > highest.lid) ? current : highest, null);
if (finalsMatch && finalsMatch.status === 'ended') {
const finalsLoserId = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.away : finalsMatch.home;
const losingTeam = finalsMatch.expand?.[finalsLoserId === finalsMatch.home ? 'home' : 'away'];
const losingPlayers = losingTeam?.expand?.players || losingTeam?.players || [];
const playerLost = losingPlayers.some((p: any) =>
(typeof p === 'string' ? p : p.id) === playerId
);
if (playerLost) {
placementCount++;
}
}
}
if (criteria.placement === 3) {
const losersMatches = tournamentMatches.filter(m => m.is_losers_bracket);
const losersFinale = losersMatches.reduce((highest: any, current: any) =>
(!highest || current.lid > highest.lid) ? current : highest, null);
if (losersFinale && losersFinale.status === 'ended') {
const losersFinaleLoserId = (losersFinale.home_cups > losersFinale.away_cups) ? losersFinale.away : losersFinale.home;
const losingTeam = losersFinale.expand?.[losersFinaleLoserId === losersFinale.home ? 'home' : 'away'];
const losingPlayers = losingTeam?.expand?.players || losingTeam?.players || [];
const playerLost = losingPlayers.some((p: any) =>
(typeof p === 'string' ? p : p.id) === playerId
);
if (playerLost) {
placementCount++;
}
}
}
}
return placementCount;
}
if (criteria.tournament_record !== undefined) {
const tournaments = await pb.collection("tournaments").getFullList({
sort: 'start_time',
});
let timesWent02 = 0;
for (const tournamentId of tournamentIds) {
const tournament = tournaments.find(t => t.id === tournamentId);
if (!tournament) continue;
const tournamentMatches = matches.filter(m => m.tournament === tournamentId);
let wins = 0;
let losses = 0;
for (const match of tournamentMatches) {
const isHome = match.expand?.home?.expand?.players?.some((p: any) => p.id === playerId) ||
match.expand?.home?.players?.includes(playerId);
const isAway = match.expand?.away?.expand?.players?.some((p: any) => p.id === playerId) ||
match.expand?.away?.players?.includes(playerId);
if (isHome && match.home_cups > match.away_cups) {
wins++;
} else if (isAway && match.away_cups > match.home_cups) {
wins++;
} else {
losses++;
}
}
const record = `${wins}-${losses}`;
if (record === criteria.tournament_record) {
if (criteria.won_previous !== undefined && criteria.won_previous === true) {
const currentIndex = tournaments.findIndex(t => t.id === tournamentId);
if (currentIndex > 0) {
const previousTournament = tournaments[currentIndex - 1];
if (previousTournament.winner_id === playerId) {
timesWent02++;
}
}
} else {
timesWent02++;
}
}
}
return timesWent02 > 0 ? 1 : 0;
}
if (criteria.consecutive_wins !== undefined) {
const tournaments = await pb.collection("tournaments").getFullList({
sort: 'start_time',
});
let consecutiveWins = 0;
let maxConsecutiveWins = 0;
for (const tournament of tournaments) {
if (!tournamentIds.has(tournament.id)) continue;
if (tournament.winner_id === playerId) {
consecutiveWins++;
maxConsecutiveWins = Math.max(maxConsecutiveWins, consecutiveWins);
} else {
consecutiveWins = 0;
}
}
return maxConsecutiveWins >= criteria.consecutive_wins ? 1 : 0;
}
return 0;
},
getTargetProgress(badge: Badge): number {
if (badge.type === "manual") {
return 1;
}
const criteria = badge.criteria;
return (
criteria.matches_played ||
criteria.tournament_wins ||
criteria.tournaments_attended ||
criteria.overtime_matches ||
criteria.overtime_wins ||
criteria.consecutive_wins ||
1
);
},
async migrateBadgeProgress(): Promise<{
success: boolean;
playersProcessed: number;
progressRecordsCreated: number;
totalBadgesEarned: number;
averageBadgesPerPlayer: string;
}> {
await this.clearAllBadgeProgress();
const badges = await this.listBadges();
const playerStats = await pb.collection("player_stats").getFullList<PlayerStats>();
const uniquePlayers = new Set(playerStats.map(s => s.player_id));
let totalProgressRecords = 0;
let totalBadgesEarned = 0;
for (const playerId of uniquePlayers) {
for (const badge of badges) {
try {
const progress = await this.calculateBadgeProgress(playerId, badge);
const target = this.getTargetProgress(badge);
const earned = progress >= target;
if (progress > 0 || earned) {
await this.createBadgeProgress({
badge: badge.id,
player: playerId,
progress: progress,
earned: earned,
});
totalProgressRecords++;
if (earned) {
totalBadgesEarned++;
}
}
} catch (error: any) {
console.error(`Error processing badge "${badge.name}" for player ${playerId}:`, error.message);
}
}
}
return {
success: true,
playersProcessed: uniquePlayers.size,
progressRecordsCreated: totalProgressRecords,
totalBadgesEarned: totalBadgesEarned,
averageBadgesPerPlayer: (totalBadgesEarned / uniquePlayers.size).toFixed(2),
};
},
};
}

View File

@@ -3,6 +3,7 @@ import { Match } from "@/features/matches/types";
import { Player, PlayerInfo } from "@/features/players/types";
import { Team, TeamInfo } from "@/features/teams/types";
import { Tournament, TournamentInfo } from "@/features/tournaments/types";
import { Badge, BadgeInfo, BadgeProgress } from "@/features/badges/types";
// pocketbase does this weird thing with relations where it puts them under a seperate "expand" field
// this file transforms raw pocketbase results to our types
@@ -278,3 +279,36 @@ export function transformReaction(record: any) {
match: record.match
};
}
export function transformBadgeInfo(record: any): BadgeInfo {
return {
id: record.id,
name: record.name,
key: record.key,
description: record.description,
type: record.type,
progressive: record.progressive,
order: record.order ?? 999,
};
}
export function transformBadge(record: any): Badge {
return {
...transformBadgeInfo(record),
criteria: record.criteria,
created: record.created,
updated: record.updated,
};
}
export function transformBadgeProgress(record: any): BadgeProgress {
return {
id: record.id,
badge: record.expand?.badge ? transformBadgeInfo(record.expand.badge) : record.badge,
player: record.player,
progress: record.progress,
earned: record.earned,
created: record.created,
updated: record.updated,
};
}