brackt/app/lib/draft-eligibility.ts

234 lines
6.4 KiB
TypeScript
Raw Permalink Normal View History

/**
* Draft Eligibility Calculation for Omni Leagues
*
* Implements two key constraints:
* 1. Team Flex Pick Limit: Teams can only use (totalRounds - totalSports) flex picks
* 2. Global Sport Availability: Cannot draft from a sport if it prevents other teams
* from fulfilling their "at least one from each sport" requirement
*/
export interface SportAvailability {
sportId: string;
sportName: string;
totalParticipants: number;
draftedCount: number;
undraftedCount: number;
teamsStillNeeding: number;
canDraftAsFirst: boolean; // undraftedCount >= teamsStillNeeding
canDraftAsFlex: boolean; // undraftedCount > teamsStillNeeding
}
export type DraftIneligibilityCode =
| "no_flex_spots"
| "no_extra_participants"
| "unavailable";
export interface DraftIneligibilityReason {
code: DraftIneligibilityCode;
message: string;
}
export interface DraftEligibility {
teamId: string;
eligibleSportIds: Set<string>;
ineligibleReasons: Record<string, DraftIneligibilityReason>; // sportId -> reason
flexPicksUsed: number;
flexPicksAvailable: number;
picksBySport: Record<string, number>;
sportAvailability: Record<string, SportAvailability>;
}
interface PickData {
teamId: string;
participant: {
id: string;
sport: {
id: string;
name: string;
};
};
}
interface ParticipantData {
id: string;
sport: {
id: string;
name: string;
};
}
interface SportData {
id: string;
name: string;
}
interface TeamData {
id: string;
}
function getFlexSpotsMessage(
flexPicksUsed: number,
flexPicksAvailable: number
): DraftIneligibilityReason {
return {
code: "no_flex_spots",
message: `Out of flex spots (${flexPicksUsed}/${flexPicksAvailable})`,
};
}
function getNoExtrasMessage(sportName: string): DraftIneligibilityReason {
return {
code: "no_extra_participants",
message: `Not enough ${sportName} participants for remaining teams`,
};
}
function getUnavailableMessage(
sportName: string,
undraftedCount: number,
teamsStillNeeding: number
): DraftIneligibilityReason {
return {
code: "unavailable",
message: `Only ${undraftedCount} ${sportName} participant${undraftedCount === 1 ? "" : "s"} left for ${teamsStillNeeding} team${teamsStillNeeding === 1 ? "" : "s"}`,
};
}
/**
* Calculate which sports a team is eligible to draft from based on:
* - Team's flex pick usage
* - Global sport availability for all teams
*/
export function calculateDraftEligibility(
teamId: string,
teamPicks: PickData[],
allPicks: PickData[],
allParticipants: ParticipantData[],
seasonSports: SportData[],
totalRounds: number,
allTeams: TeamData[]
): DraftEligibility {
// Step 1: Calculate team's flex pick status
const picksBySport: Record<string, number> = {};
for (const pick of teamPicks) {
const sportId = pick.participant.sport.id;
picksBySport[sportId] = (picksBySport[sportId] || 0) + 1;
}
// Calculate flex picks used: sum of (picks - 1) for each sport with multiple picks
let flexPicksUsed = 0;
for (const sportId in picksBySport) {
if (picksBySport[sportId] > 1) {
flexPicksUsed += picksBySport[sportId] - 1;
}
}
const totalSports = seasonSports.length;
const flexPicksAvailable = totalRounds - totalSports;
const hasFlexesRemaining = flexPicksUsed < flexPicksAvailable;
// Step 2: Calculate global sport availability
const sportAvailability: Record<string, SportAvailability> = {};
for (const sport of seasonSports) {
// Count total participants in this sport
const totalParticipants = allParticipants.filter(
p => p.sport.id === sport.id
).length;
// Count drafted participants in this sport
const draftedCount = allPicks.filter(
pick => pick.participant.sport.id === sport.id
).length;
const undraftedCount = totalParticipants - draftedCount;
// Count teams that haven't drafted from this sport yet
const teamsSportPicks: Record<string, number> = {};
for (const pick of allPicks) {
if (pick.participant.sport.id === sport.id) {
teamsSportPicks[pick.teamId] = (teamsSportPicks[pick.teamId] || 0) + 1;
}
}
const teamsStillNeeding = allTeams.filter(
team => !teamsSportPicks[team.id] || teamsSportPicks[team.id] === 0
).length;
sportAvailability[sport.id] = {
sportId: sport.id,
sportName: sport.name,
totalParticipants,
draftedCount,
undraftedCount,
teamsStillNeeding,
canDraftAsFirst: undraftedCount >= teamsStillNeeding,
canDraftAsFlex: undraftedCount > teamsStillNeeding,
};
}
// Step 3: Combine constraints to determine eligibility
const eligibleSportIds = new Set<string>();
const ineligibleReasons: Record<string, DraftIneligibilityReason> = {};
for (const sport of seasonSports) {
const sportId = sport.id;
const availability = sportAvailability[sportId];
const currentTeamHasSport = (picksBySport[sportId] || 0) > 0;
let isEligible = false;
let reason: DraftIneligibilityReason | null = null;
if (!currentTeamHasSport) {
// Team hasn't drafted from this sport yet (would be first pick)
if (availability.canDraftAsFirst) {
isEligible = true;
} else {
reason = getUnavailableMessage(sport.name, availability.undraftedCount, availability.teamsStillNeeding);
}
} else {
// Team has drafted from this sport (would be flex pick)
if (!hasFlexesRemaining) {
reason = getFlexSpotsMessage(flexPicksUsed, flexPicksAvailable);
} else if (!availability.canDraftAsFlex) {
reason = getNoExtrasMessage(sport.name);
} else {
isEligible = true;
}
}
if (isEligible) {
eligibleSportIds.add(sportId);
} else if (reason) {
ineligibleReasons[sportId] = reason;
}
}
return {
teamId,
eligibleSportIds,
ineligibleReasons,
flexPicksUsed,
flexPicksAvailable,
picksBySport,
sportAvailability,
};
}
/**
* Get a human-readable summary of draft eligibility status
*/
export function getEligibilitySummary(eligibility: DraftEligibility): string {
const { flexPicksUsed, flexPicksAvailable, picksBySport, sportAvailability } = eligibility;
const sportBreakdown = Object.entries(picksBySport)
.map(([sportId, count]) => {
const sport = sportAvailability[sportId];
return `${sport?.sportName || sportId}: ${count}`;
})
.join(", ");
return `Flex picks: ${flexPicksUsed}/${flexPicksAvailable} | Picks by sport: ${sportBreakdown || "None"}`;
}