diff --git a/__tests__/simulation-impact-core.test.ts b/__tests__/simulation-impact-core.test.ts new file mode 100644 index 0000000..133e9e3 --- /dev/null +++ b/__tests__/simulation-impact-core.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "bun:test"; +import { + buildPrefixSums, + computeImpactContributions, +} from "@/utils/simulation-impact"; + +function buildWorldPoints(count: number) { + return Array.from({ length: count }, (_, index) => count - index); +} + +describe("simulation impact core", () => { + it("keeps contribution at zero for pilots outside the current top half", () => { + const allPilots = Array.from({ length: 81 }, (_, index) => ({ + key: `pilot-${index}`, + civlId: index + 1, + })); + const worldPoints = buildWorldPoints(200); + const rankedByCivlId = new Map( + worldPoints.map((points, index) => [index + 1, { points }]), + ); + const topWorldPrefixSums = buildPrefixSums(worldPoints); + const selectedPilotKeys = allPilots.map((pilot) => pilot.key); + + const impacts = computeImpactContributions({ + allPilots, + selectedPilotKeys, + rankedByCivlId, + topWorldPrefixSums, + }); + + expect((impacts["pilot-39"] ?? 0) > 0).toBe(true); + expect(impacts["pilot-40"]).toBe(0); + expect(impacts["pilot-60"]).toBe(0); + }); + + it("does not create lower-half contribution after deselecting a zero-contribution pilot", () => { + const allPilots = Array.from({ length: 81 }, (_, index) => ({ + key: `pilot-${index}`, + civlId: index + 1, + })); + const worldPoints = buildWorldPoints(200); + const rankedByCivlId = new Map( + worldPoints.map((points, index) => [index + 1, { points }]), + ); + const topWorldPrefixSums = buildPrefixSums(worldPoints); + + const selectedPilotKeys = allPilots.map((pilot) => pilot.key); + const impactsWithAll = computeImpactContributions({ + allPilots, + selectedPilotKeys, + rankedByCivlId, + topWorldPrefixSums, + }); + + const impactsWithoutTail = computeImpactContributions({ + allPilots, + selectedPilotKeys: selectedPilotKeys.filter((key) => key !== "pilot-80"), + rankedByCivlId, + topWorldPrefixSums, + }); + + expect(impactsWithAll["pilot-60"]).toBe(0); + expect(impactsWithoutTail["pilot-60"]).toBe(0); + }); + + it("never returns negative displayed impact", () => { + const allPilots = Array.from({ length: 90 }, (_, index) => ({ + key: `pilot-${index}`, + civlId: index % 3 === 0 ? undefined : index + 1, + })); + const worldPoints = buildWorldPoints(200); + const rankedByCivlId = new Map( + worldPoints.map((points, index) => [index + 1, { points }]), + ); + const topWorldPrefixSums = buildPrefixSums(worldPoints); + const selectedPilotKeys = allPilots.map((pilot) => pilot.key); + + const impacts = computeImpactContributions({ + allPilots, + selectedPilotKeys, + rankedByCivlId, + topWorldPrefixSums, + }); + + expect(Object.values(impacts).every((value) => value >= 0)).toBe(true); + }); + + it("returns positive would-add only for unselected pilots who can enter top half", () => { + const allPilots = Array.from({ length: 90 }, (_, index) => ({ + key: `pilot-${index}`, + civlId: index + 1, + })); + const worldPoints = buildWorldPoints(300); + const rankedByCivlId = new Map( + worldPoints.map((points, index) => [index + 1, { points }]), + ); + const topWorldPrefixSums = buildPrefixSums(worldPoints); + const selectedPilotKeys = allPilots.slice(5, 85).map((pilot) => pilot.key); + + const impacts = computeImpactContributions({ + allPilots, + selectedPilotKeys, + rankedByCivlId, + topWorldPrefixSums, + }); + + expect((impacts["pilot-0"] ?? 0) > 0).toBe(true); + expect(impacts["pilot-89"]).toBe(0); + }); + + it("keeps would-add and selected impact aligned for the same pilot", () => { + const allPilots = Array.from({ length: 110 }, (_, index) => ({ + key: `pilot-${index}`, + civlId: index + 1, + })); + const worldPoints = buildWorldPoints(300); + const rankedByCivlId = new Map( + worldPoints.map((points, index) => [index + 1, { points }]), + ); + const topWorldPrefixSums = buildPrefixSums(worldPoints); + const selectedPilotKeys = allPilots.slice(20, 80).map((pilot) => pilot.key); + const pilotKey = "pilot-0"; + + const beforeAdd = computeImpactContributions({ + allPilots, + selectedPilotKeys, + rankedByCivlId, + topWorldPrefixSums, + }); + + const afterAdd = computeImpactContributions({ + allPilots, + selectedPilotKeys: [...selectedPilotKeys, pilotKey], + rankedByCivlId, + topWorldPrefixSums, + }); + + expect((beforeAdd[pilotKey] ?? 0) > 0).toBe(true); + expect(afterAdd[pilotKey]).toBeCloseTo(beforeAdd[pilotKey] ?? 0, 12); + }); +}); diff --git a/src/components/ForecastInteractive.tsx b/src/components/ForecastInteractive.tsx new file mode 100644 index 0000000..085385d --- /dev/null +++ b/src/components/ForecastInteractive.tsx @@ -0,0 +1,436 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import Link from "next/link"; +import { + type Forecast, + type ForecastSimulation, + type Pilot, +} from "@/types/common"; +import { Button } from "@/components/ui/Button"; +import { ForecastDetails } from "@/components/ForecastDetails"; +import { ListRankings } from "@/components/ForecastListRankings"; +import { Nationalities } from "./ForecastNationalities"; +import { LevelChart } from "./ForecastLevelChart"; +import { Genders } from "./ForecastGenders"; +import { PilotSelfProjection } from "./PilotSelfProjection"; + +type RankedPilot = NonNullable< + NonNullable["pilots"] +>[number]; +type PilotEntry = { + key: string; + index: number; + pilot: Pilot; + isConfirmed: boolean; + ranking?: RankedPilot; +}; + +function comparePilotEntries(a: PilotEntry, b: PilotEntry) { + const rankA = a.ranking?.rank ?? Number.POSITIVE_INFINITY; + const rankB = b.ranking?.rank ?? Number.POSITIVE_INFINITY; + if (rankA !== rankB) return rankA - rankB; + const nameA = (a.pilot.name ?? "").toLowerCase(); + const nameB = (b.pilot.name ?? "").toLowerCase(); + if (nameA < nameB) return -1; + if (nameA > nameB) return 1; + return 0; +} + +export function ForecastInteractive({ data }: { data: Forecast }) { + const registeredPilots = useMemo( + () => data.registeredPilots ?? data.confirmedPilots ?? [], + [data.confirmedPilots, data.registeredPilots], + ); + + const [isRecalculating, setIsRecalculating] = useState(false); + const [simulationError, setSimulationError] = useState(null); + const simulationRequestIdRef = useRef(0); + const [simulation, setSimulation] = useState({ + confirmed: data.confirmed, + nationalities: data.nationalities, + genders: data.genders, + }); + + const rankedPilotByCivlId = useMemo(() => { + const pilots = data.all?.pilots ?? data.confirmed?.pilots ?? []; + return pilots.reduce((acc, pilot) => { + acc.set(pilot.id, pilot); + return acc; + }, new Map()); + }, [data.all?.pilots, data.confirmed?.pilots]); + + const allPilotEntries = useMemo(() => { + return registeredPilots.map((pilot, index) => { + const ranking = + typeof pilot.civlID === "number" + ? rankedPilotByCivlId.get(pilot.civlID) + : undefined; + + return { + key: `pilot-${index}`, + index, + pilot, + isConfirmed: Boolean(pilot.confirmed), + ranking, + } satisfies PilotEntry; + }); + }, [rankedPilotByCivlId, registeredPilots]); + + const confirmedEntries = useMemo( + () => + allPilotEntries + .filter((entry) => entry.isConfirmed) + .sort(comparePilotEntries), + [allPilotEntries], + ); + + const unconfirmedEntries = useMemo( + () => + allPilotEntries + .filter((entry) => !entry.isConfirmed) + .sort(comparePilotEntries), + [allPilotEntries], + ); + + const defaultSelectedPilotKeys = useMemo( + () => confirmedEntries.map((entry) => entry.key), + [confirmedEntries], + ); + + const [selectedPilotKeys, setSelectedPilotKeys] = useState( + defaultSelectedPilotKeys, + ); + + const selectedPilotSet = useMemo( + () => new Set(selectedPilotKeys), + [selectedPilotKeys], + ); + + const selectedPilots = useMemo( + () => + allPilotEntries + .filter((entry) => selectedPilotSet.has(entry.key)) + .map((entry) => entry.pilot), + [allPilotEntries, selectedPilotSet], + ); + + const isDefaultSelection = useMemo(() => { + if (selectedPilotKeys.length !== defaultSelectedPilotKeys.length) + return false; + const defaultSet = new Set(defaultSelectedPilotKeys); + return selectedPilotKeys.every((key) => defaultSet.has(key)); + }, [defaultSelectedPilotKeys, selectedPilotKeys]); + + useEffect(() => { + setSelectedPilotKeys(defaultSelectedPilotKeys); + setSimulation({ + confirmed: data.confirmed, + nationalities: data.nationalities, + genders: data.genders, + contributions: undefined, + }); + setSimulationError(null); + }, [ + data.confirmed, + data.genders, + data.nationalities, + defaultSelectedPilotKeys, + ]); + + useEffect(() => { + if (!allPilotEntries.length) return; + + const requestId = simulationRequestIdRef.current + 1; + simulationRequestIdRef.current = requestId; + const controller = new AbortController(); + setIsRecalculating(true); + setSimulationError(null); + + fetch("/api/forecast/simulate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + selectedPilotKeys, + allPilots: allPilotEntries.map((entry) => ({ + key: entry.key, + civlId: + typeof entry.pilot.civlID === "number" && entry.pilot.civlID > 0 + ? entry.pilot.civlID + : undefined, + })), + }), + signal: controller.signal, + }) + .then(async (response) => { + if (!response.ok) throw new Error("Failed to recompute forecast"); + return (await response.json()) as ForecastSimulation; + }) + .then((payload) => { + if (requestId !== simulationRequestIdRef.current) return; + setSimulation(payload); + }) + .catch((error: { name?: string }) => { + if (requestId !== simulationRequestIdRef.current) return; + if (error?.name === "AbortError") return; + setSimulationError("Could not recompute forecast."); + }) + .finally(() => { + if (requestId !== simulationRequestIdRef.current) return; + setIsRecalculating(false); + }); + + return () => controller.abort(); + }, [allPilotEntries, selectedPilotKeys]); + + const onSelectConfirmed = () => { + setSelectedPilotKeys(defaultSelectedPilotKeys); + }; + + const onClearAll = () => { + setSelectedPilotKeys([]); + }; + + const onTogglePilot = (key: string) => { + setSelectedPilotKeys((current) => { + if (current.includes(key)) { + return current.filter((item) => item !== key); + } + return [...current, key]; + }); + }; + + const displayData: Forecast = { + ...data, + confirmed: simulation.confirmed ?? data.confirmed, + nationalities: simulation.nationalities ?? data.nationalities, + genders: simulation.genders ?? data.genders, + }; + + const currentTa3 = displayData.confirmed?.WPRS?.[0]?.Ta3; + const hasCurrentTa3 = Number.isFinite(currentTa3); + const potentialTa3 = data.all?.WPRS?.[0]?.Ta3; + const hasPotentialTa3 = Number.isFinite(potentialTa3); + + const renderContribution = (entryKey: string, isSelected: boolean) => { + const rawValue = simulation.contributions?.[entryKey]; + const value = + typeof rawValue === "number" ? Math.max(0, rawValue) : rawValue; + if (typeof value !== "number") return isRecalculating ? "..." : "-"; + + const signedValue = + value > 0 && value < 0.01 ? "+<0.01" : `+${value.toFixed(2)}`; + + if (isSelected) return signedValue; + return `would add ${signedValue}`; + }; + + const contributionClass = (entryKey: string) => { + const rawValue = simulation.contributions?.[entryKey]; + const value = + typeof rawValue === "number" ? Math.max(0, rawValue) : rawValue; + if (typeof value !== "number") return "text-slate-400 dark:text-slate-400"; + return "text-green-600 dark:text-green-400"; + }; + + const pilotImpactSection = + allPilotEntries.length > 0 ? ( +
+ +
+ + New + + Pilot impact ({selectedPilots.length}/{allPilotEntries.length}) +
+
+
+ + + {isRecalculating && ( + + Recalculating... + + )} + {simulationError && ( + {simulationError} + )} +
+ +
event.stopPropagation()} + onTouchMove={(event) => event.stopPropagation()} + > + + + + + + + + + + {!!confirmedEntries.length && ( + + + + )} + {confirmedEntries.map((entry, rowIndex) => { + const isSelected = selectedPilotSet.has(entry.key); + return ( + + + + + + ); + })} + {!!unconfirmedEntries.length && ( + + + + )} + {unconfirmedEntries.map((entry, rowIndex) => { + const isSelected = selectedPilotSet.has(entry.key); + return ( + + + + + + ); + })} + +
PilotImpactInclude
+ Confirmed pilots +
+ {rowIndex + 1}.{" "} + {entry.pilot.name ?? `Pilot ${entry.index + 1}`} + + {renderContribution(entry.key, isSelected)} + + onTogglePilot(entry.key)} + className="checkbox checkbox-sm" + /> +
+ Registered but not confirmed pilots +
+ {confirmedEntries.length + rowIndex + 1}.{" "} + {entry.pilot.name ?? `Pilot ${entry.index + 1}`} + + New + + + {renderContribution(entry.key, isSelected)} + + onTogglePilot(entry.key)} + className="checkbox checkbox-sm" + /> +
+
+
+
+
+ ) : null; + + return ( + <> +
+ WPRS:{" "} + {hasCurrentTa3 ? ( + + {currentTa3?.toFixed(2)} + + ) : ( + No confirmed pilots yet. + )} +
+ + {data.maxPilots && data.maxPilots > 0 && ( +
+ Potential WPRS:{" "} + + {hasPotentialTa3 ? potentialTa3?.toFixed(2) : "-"} + +

+ If the top {data.maxPilots} registered pilots would be confirmed. +

+
+ )} + +

+ This forecast is based on the currently confirmed/registered pilots and + their CIVL rankings. The calculation will become more accurate as the + competition date approaches. +

+
+ + Details can be found in the FAI Sporting Code Section 7E + +
+ + {pilotImpactSection} + {hasCurrentTa3 && } + + {displayData.confirmed?.pilots && ( + + )} + {displayData.nationalities && ( + + )} + {displayData.genders && } + {(displayData.nationalities ?? displayData.genders) && ( +
+ The sum of pilots may not be equal to the number of confirmed pilots + because of lookup mismatches. +
+ )} + + {displayData.confirmed?.WPRS.length && ( + + )} + + ); +} diff --git a/src/components/ForecastView.tsx b/src/components/ForecastView.tsx index d55d308..2f01e7f 100644 --- a/src/components/ForecastView.tsx +++ b/src/components/ForecastView.tsx @@ -1,11 +1,6 @@ -import { ForecastDetails } from "@/components/ForecastDetails"; import Link from "next/link"; import { fetchForecastData } from "@/app/lib/data"; -import { ListRankings } from "@/components/ForecastListRankings"; -import { Nationalities } from "./ForecastNationalities"; -import { LevelChart } from "./ForecastLevelChart"; -import { Genders } from "./ForecastGenders"; -import { PilotSelfProjection } from "./PilotSelfProjection"; +import { ForecastInteractive } from "./ForecastInteractive"; export async function ForecastView({ url }: { url?: string }) { const data = await fetchForecastData(url); @@ -65,60 +60,7 @@ export async function ForecastView({ url }: { url?: string }) { {data.pilotsUrl}{" "} )} -
- WPRS:{" "} - {data?.confirmed?.WPRS[0]?.Ta3 ? ( - - {data?.confirmed?.WPRS[0]?.Ta3} - - ) : ( - No confirmed pilots yet. - )} -
- - {data.maxPilots && data.maxPilots > 0 && ( -
- Potential WPRS:{" "} - {data?.all?.WPRS[0]?.Ta3} -

- If the top {data.maxPilots} registered pilots would be confirmed. -

-
- )} - -

- This forecast is based on the currently confirmed/registered pilots and - their CIVL rankings. The calculation will become more accurate as the - competition date approaches. -

-
- - Details can be found in the FAI Sporting Code Section 7E - -
- - {!!data?.confirmed?.WPRS[0]?.Ta3 && } - - {data.confirmed?.pilots && } - {data.nationalities && } - {data.genders && } - {(data.nationalities ?? data.genders) && ( -
- The sum of pilots may not be equal to the number of confirmed pilots - because of lookup mismatches. -
- )} - - {data.confirmed?.WPRS.length && } + ); } diff --git a/src/pages/api/forecast/simulate.ts b/src/pages/api/forecast/simulate.ts new file mode 100644 index 0000000..82422f5 --- /dev/null +++ b/src/pages/api/forecast/simulate.ts @@ -0,0 +1,111 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { z } from "zod"; +import { asc, inArray } from "drizzle-orm"; +import * as Sentry from "@sentry/nextjs"; +import { db } from "@/server/db"; +import { ranking } from "@/server/db/schema"; +import { calculateWPRS } from "@/utils/calculate-wprs"; +import { calculateGender, calculateNationalities } from "@/utils/get-forecast"; +import { buildPrefixSums, computeImpactContributions } from "@/utils/simulation-impact"; +import { type ForecastSimulation } from "@/types/common"; + +const bodySchema = z.object({ + selectedPilotKeys: z.array(z.string().min(1)).max(1000), + allPilots: z + .array( + z.object({ + key: z.string().min(1), + civlId: z.number().int().positive().optional(), + }), + ) + .max(1000), +}); + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if (req.method !== "POST") { + return res.status(405).json({ message: "Method not allowed" }); + } + + const parsed = bodySchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ message: "Invalid payload" }); + } + + try { + const selectedPilotSet = new Set(parsed.data.selectedPilotKeys); + const selectedPilots = parsed.data.allPilots.filter((pilot) => + selectedPilotSet.has(pilot.key), + ); + const selectedCount = selectedPilots.length; + const maxTopHalfCount = Math.floor((selectedCount + 1) / 2); + + const allCivlIds = [ + ...new Set( + parsed.data.allPilots + .map((pilot) => pilot.civlId) + .filter( + (civlId): civlId is number => + typeof civlId === "number" && civlId > 0, + ), + ), + ]; + + const rankedPilots = + allCivlIds.length > 0 + ? await db.select().from(ranking).where(inArray(ranking.id, allCivlIds)).execute() + : []; + + const rankedByCivlId = new Map(rankedPilots.map((pilot) => [pilot.id, pilot])); + + const selectedUniqueCivlIds = [ + ...new Set( + selectedPilots + .map((pilot) => pilot.civlId) + .filter( + (civlId): civlId is number => + typeof civlId === "number" && civlId > 0, + ), + ), + ]; + + const selectedRankedPilots = selectedUniqueCivlIds + .map((civlId) => rankedByCivlId.get(civlId)) + .filter((pilot): pilot is (typeof rankedPilots)[number] => !!pilot); + + const topWorldRankings = + maxTopHalfCount > 0 + ? await db + .select({ points: ranking.points }) + .from(ranking) + .orderBy(asc(ranking.rank)) + .limit(maxTopHalfCount) + : []; + const topWorldPrefixSums = buildPrefixSums( + topWorldRankings.map((pilot) => pilot.points), + ); + + const contributions = computeImpactContributions({ + allPilots: parsed.data.allPilots, + selectedPilotKeys: parsed.data.selectedPilotKeys, + rankedByCivlId, + topWorldPrefixSums, + }); + + const confirmed = await calculateWPRS(selectedRankedPilots, selectedCount); + + return res.status(200).json({ + confirmed, + nationalities: calculateNationalities(selectedRankedPilots), + genders: calculateGender(selectedRankedPilots), + contributions, + }); + } catch (error) { + console.error("Error simulating forecast"); + console.error(error); + Sentry.captureException(error); + return res.status(500).json({ message: "Internal error" }); + } +} diff --git a/src/types/common.ts b/src/types/common.ts index 685f04b..bcfa10b 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -32,6 +32,8 @@ export type Forecast = { pilotsUrl?: string; all?: CompForecast; confirmed?: CompForecast; + confirmedPilots?: Pilot[]; + registeredPilots?: Pilot[]; compUrl: string; meta?: Statistics; compDate?: { @@ -64,3 +66,10 @@ export type Statistics = { civlSearchDurationInMs: number; pilotsNotfound: string[]; }; + +export type ForecastSimulation = { + confirmed?: CompForecast; + nationalities?: Nationalities; + genders?: { male: number; female: number }; + contributions?: Record; +}; diff --git a/src/utils/get-forecast.ts b/src/utils/get-forecast.ts index 6cb6c3c..538bc98 100644 --- a/src/utils/get-forecast.ts +++ b/src/utils/get-forecast.ts @@ -63,6 +63,8 @@ export async function getForecast( compTitle: comp.compTitle, all: await calculateWPRS(pilots, comp.maxPilots), confirmed: await calculateWPRS(confirmedPilots, numberOfConfirmed), + confirmedPilots: confirmed, + registeredPilots: comp.pilots, compUrl: url, pilotsUrl: comp.pilotsUrl, meta: comp.statistics, @@ -72,7 +74,7 @@ export async function getForecast( }; } -function calculateNationalities(pilots: Ranking[]) { +export function calculateNationalities(pilots: Ranking[]) { if (pilots.length === 0) return; const nationalitiesCount: Record = {}; pilots.forEach((pilot) => { @@ -97,7 +99,7 @@ function calculateNationalities(pilots: Ranking[]) { return { count: nationalitiesCount, percentage: nationalitiesPercentage }; } -function calculateGender(pilots: Ranking[]) { +export function calculateGender(pilots: Ranking[]) { if (pilots.length === 0) return; let female = 0; let male = 0; @@ -109,7 +111,7 @@ function calculateGender(pilots: Ranking[]) { return { male, female }; } -async function getPilotRankings(pilots: Pilot[]) { +export async function getPilotRankings(pilots: Pilot[]) { if (pilots.length === 0) return []; const civlIds = pilots .map((pilot) => pilot.civlID) diff --git a/src/utils/simulation-impact.ts b/src/utils/simulation-impact.ts new file mode 100644 index 0000000..c265345 --- /dev/null +++ b/src/utils/simulation-impact.ts @@ -0,0 +1,200 @@ +const AVG_NUM_PARTICIPANTS = 76; +const PQ_MIN = 0.2; +const PN_MAX = 1.2; + +type SimPilot = { + key: string; + civlId?: number; +}; + +type RankedPilot = { + points: number; +}; + +type WinnerScoreArgs = { + allPilots: SimPilot[]; + scenarioPilotKeys: Set; + rankedByCivlId: Map; + topWorldPrefixSums: number[]; +}; + +function getScenarioRankedPoints( + allPilots: SimPilot[], + scenarioPilotKeys: Set, + rankedByCivlId: Map, +) { + const seenCivlIds = new Set(); + const rankedPoints: number[] = []; + + for (const pilot of allPilots) { + if (!scenarioPilotKeys.has(pilot.key)) continue; + if (typeof pilot.civlId !== "number" || pilot.civlId <= 0) continue; + if (seenCivlIds.has(pilot.civlId)) continue; + + seenCivlIds.add(pilot.civlId); + + const ranked = rankedByCivlId.get(pilot.civlId); + if (!ranked) continue; + rankedPoints.push(ranked.points); + } + + return rankedPoints.sort((a, b) => b - a); +} + +export function computeScenarioWinnerTa3({ + allPilots, + scenarioPilotKeys, + rankedByCivlId, + topWorldPrefixSums, +}: WinnerScoreArgs) { + const scenarioCount = scenarioPilotKeys.size; + if (scenarioCount < 2) return 0; + + const topHalfCount = Math.floor(scenarioCount / 2); + if (topHalfCount < 1) return 0; + + const pqsrtp = topWorldPrefixSums[topHalfCount] ?? 0; + if (pqsrtp <= 0) return 0; + + const pqsrp = getScenarioRankedPoints( + allPilots, + scenarioPilotKeys, + rankedByCivlId, + ) + .slice(0, topHalfCount) + .reduce((sum, points) => sum + points, 0); + + const pq = (pqsrp / pqsrtp) * (1 - PQ_MIN) + PQ_MIN; + const pnTmp = Math.sqrt(scenarioCount / Math.min(AVG_NUM_PARTICIPANTS, 55)); + const pn = pnTmp > PN_MAX ? PN_MAX : pnTmp; + + return 100 * pq * pn; +} + +type ImpactArgs = { + allPilots: SimPilot[]; + selectedPilotKeys: string[]; + rankedByCivlId: Map; + topWorldPrefixSums: number[]; +}; + +type FixedFieldScoreArgs = { + allPilots: SimPilot[]; + scenarioPilotKeys: Set; + rankedByCivlId: Map; + fixedTopHalfCount: number; + fixedPqsrtp: number; + fixedPn: number; +}; + +function computeFixedFieldWinnerTa3({ + allPilots, + scenarioPilotKeys, + rankedByCivlId, + fixedTopHalfCount, + fixedPqsrtp, + fixedPn, +}: FixedFieldScoreArgs) { + if (fixedTopHalfCount < 1 || fixedPqsrtp <= 0) return 0; + + const pqsrp = getScenarioRankedPoints( + allPilots, + scenarioPilotKeys, + rankedByCivlId, + ) + .slice(0, fixedTopHalfCount) + .reduce((sum, points) => sum + points, 0); + + const pq = (pqsrp / fixedPqsrtp) * (1 - PQ_MIN) + PQ_MIN; + return 100 * pq * fixedPn; +} + +export function computeImpactContributions({ + allPilots, + selectedPilotKeys, + rankedByCivlId, + topWorldPrefixSums, +}: ImpactArgs) { + const selectedPilotSet = new Set(selectedPilotKeys); + const selectedCount = selectedPilotSet.size; + const scenarioScoreCache = new Map(); + const pairContextCache = new Map< + number, + { topHalfCount: number; pqsrtp: number; pn: number } + >(); + const epsilon = 0.0000001; + + const getPairContext = (pairCount: number) => { + const cached = pairContextCache.get(pairCount); + if (cached) return cached; + + const topHalfCount = Math.floor(pairCount / 2); + const pqsrtp = topWorldPrefixSums[topHalfCount] ?? 0; + const pnTmp = Math.sqrt(pairCount / Math.min(AVG_NUM_PARTICIPANTS, 55)); + const pn = pnTmp > PN_MAX ? PN_MAX : pnTmp; + const context = { topHalfCount, pqsrtp, pn }; + pairContextCache.set(pairCount, context); + return context; + }; + + const getScenarioScore = ( + scenarioPilotKeys: Set, + pairCount: number, + ) => { + const cacheKey = `${pairCount}|${[...scenarioPilotKeys].sort().join("|")}`; + const cached = scenarioScoreCache.get(cacheKey); + if (typeof cached === "number") return cached; + + const context = getPairContext(pairCount); + const winnerTa3 = computeFixedFieldWinnerTa3({ + allPilots, + scenarioPilotKeys, + rankedByCivlId, + fixedTopHalfCount: context.topHalfCount, + fixedPqsrtp: context.pqsrtp, + fixedPn: context.pn, + }); + scenarioScoreCache.set(cacheKey, winnerTa3); + return winnerTa3; + }; + + const contributions: Record = {}; + + for (const pilot of allPilots) { + const isSelected = selectedPilotSet.has(pilot.key); + + const scenarioPilotKeys = isSelected + ? new Set( + [...selectedPilotSet].filter( + (selectedPilotKey) => selectedPilotKey !== pilot.key, + ), + ) + : new Set([...selectedPilotSet, pilot.key]); + + const pairCount = Math.max(selectedCount, scenarioPilotKeys.size); + const currentScore = getScenarioScore(selectedPilotSet, pairCount); + const toggledScore = getScenarioScore(scenarioPilotKeys, pairCount); + const contribution = isSelected + ? currentScore - toggledScore + : toggledScore - currentScore; + + if (contribution <= epsilon) { + contributions[pilot.key] = 0; + continue; + } + contributions[pilot.key] = contribution; + } + + return contributions; +} + +export function buildPrefixSums(points: number[]) { + const prefix = [0]; + + for (const point of points) { + const previous = prefix[prefix.length - 1] ?? 0; + prefix.push(previous + point); + } + + return prefix; +}