Files
flxn-app/src/lib/pocketbase/services/badges.ts
2025-10-18 23:09:57 -05:00

520 lines
19 KiB
TypeScript

import PocketBase from "pocketbase";
import { Badge, BadgeProgress, EarnedBadge } from "@/features/badges/types";
import { transformBadge, transformBadgeProgress, transformEarnedBadge } 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 listEarnedBadges(): Promise<EarnedBadge[]> {
const results = await pb.collection("badge_progress").getFullList({
filter: `earned = true`,
expand: 'player',
});
return results.map(transformEarnedBadge);
},
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({
filter: 'badge.type != "manual"',
});
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_mainline_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 && (tournament.regional = false || tournament.regional = null)`,
expand: 'tournament,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" && (tournament.regional = false || tournament.regional = null)`,
expand: 'tournament,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" && (tournament.regional = false || tournament.regional = null)`,
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) {
if (tournamentsAttended === 0 && criteria.tournaments_attended === 0) {
const teams = await pb.collection("teams").getFullList({
filter: `players.id ?~ "${playerId}"`,
expand: 'tournaments',
});
const hasEnrollment = teams.some((team: any) => {
const tournaments = team.tournaments || [];
return tournaments.length > 0;
});
return 0;
}
return tournamentsAttended;
}
if (criteria.won_tournament !== undefined) {
const tournamentId = criteria.won_tournament;
try {
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
);
return playerWon ? 1 : 0;
}
return 0;
} catch (error) {
return 0;
}
}
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" && (tournament.regional = false || tournament.regional = null)`,
expand: 'tournament,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" && (tournament.regional = false || tournament.regional = null)`,
expand: 'tournament,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({
filter: 'regional = false || regional = null',
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({
filter: 'regional = false || regional = null',
sort: 'start_time',
});
let consecutiveWins = 0;
let maxConsecutiveWins = 0;
for (const tournament of tournaments) {
if (!tournamentIds.has(tournament.id)) continue;
const tournamentMatches = await pb.collection("matches").getFullList({
filter: `tournament = "${tournament.id}" && 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) {
consecutiveWins++;
maxConsecutiveWins = Math.max(maxConsecutiveWins, consecutiveWins);
} else {
consecutiveWins = 0;
}
} 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;
// Use explicit checks to handle 0 values correctly
if (criteria.matches_played !== undefined) return criteria.matches_played;
if (criteria.tournament_wins !== undefined) return criteria.tournament_wins;
if (criteria.tournaments_attended !== undefined) return criteria.tournaments_attended;
if (criteria.overtime_matches !== undefined) return criteria.overtime_matches;
if (criteria.overtime_wins !== undefined) return criteria.overtime_wins;
if (criteria.consecutive_wins !== undefined) return criteria.consecutive_wins;
if (criteria.won_tournament !== undefined) return 1;
if (criteria.placement !== undefined) return 1;
if (criteria.margin_of_victory !== undefined) return 1;
if (criteria.tournament_record !== undefined) return 1;
return 1;
},
async awardManualBadge(playerId: string, badgeId: string): Promise<BadgeProgress> {
const existingProgress = await pb.collection("badge_progress").getFirstListItem(
`player = "${playerId}" && badge = "${badgeId}"`,
{ expand: 'badge' }
).catch(() => null);
if (existingProgress) {
const updated = await pb.collection("badge_progress").update(existingProgress.id, {
progress: 1,
earned: true,
}, { expand: 'badge' });
return transformBadgeProgress(updated);
}
const created = await pb.collection("badge_progress").create({
badge: badgeId,
player: playerId,
progress: 1,
earned: true,
}, { expand: 'badge' });
return transformBadgeProgress(created);
},
async migrateBadgeProgress(): Promise<{
success: boolean;
playersProcessed: number;
progressRecordsCreated: number;
totalBadgesEarned: number;
averageBadgesPerPlayer: string;
}> {
await this.clearAllBadgeProgress();
const badges = await this.listBadges();
const allPlayers = await pb.collection("players").getFullList();
const uniquePlayers = new Set(allPlayers.map((p: any) => p.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 isPlacementBadge = badge.criteria.placement !== undefined;
const is8YearVeteran = badge.key === 'veteran_8_badge';
const earned = badge.progressive || isPlacementBadge || is8YearVeteran
? progress >= target
: 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),
};
},
};
}