diff --git a/app/api/org-info/route.ts b/app/api/org-info/route.ts new file mode 100644 index 0000000..4b1231f --- /dev/null +++ b/app/api/org-info/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { fetchOrganization, fetchOrganizationMembers } from "@/lib/github/client"; + +// Fast endpoint: returns org info + member logins (no scouting). +// The client then calls /api/org-scout?login=X for each member progressively. +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const login = searchParams.get("login"); + if (!login) return NextResponse.json({ error: "Missing login" }, { status: 400 }); + + try { + const [org, members] = await Promise.all([ + fetchOrganization(login), + fetchOrganizationMembers(login), + ]); + return NextResponse.json({ org, members }); + } catch (e) { + const err = e as { type?: string; message?: string }; + return NextResponse.json({ error: err.message ?? "Failed to fetch org" }, { status: 404 }); + } +} diff --git a/app/api/org-scout/route.ts b/app/api/org-scout/route.ts new file mode 100644 index 0000000..c052d66 --- /dev/null +++ b/app/api/org-scout/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { scoutCard } from "@/lib/scout"; + +// Scout a single member — called progressively by the client to show live +// progress. Returns the Card JSON for one GitHub user. +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const login = searchParams.get("login"); + if (!login) return NextResponse.json({ error: "Missing login" }, { status: 400 }); + + try { + const card = await scoutCard(login); + return NextResponse.json({ card }); + } catch (e) { + const err = e as { type?: string; message?: string }; + return NextResponse.json({ error: err.message ?? "Scout failed" }, { status: 404 }); + } +} diff --git a/app/org/[login]/page.tsx b/app/org/[login]/page.tsx new file mode 100644 index 0000000..247810c --- /dev/null +++ b/app/org/[login]/page.tsx @@ -0,0 +1,75 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import Background from "@/components/Background"; +import { fetchOrganization, fetchOrganizationMembers } from "@/lib/github/client"; +import { getRepoStars } from "@/lib/github/stars"; +import SquadView from "@/components/SquadView"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata({ params }: { params: Promise<{ login: string }> }): Promise { + const { login } = await params; + try { + const org = await fetchOrganization(login); + const clubName = org.name || org.login; + return { + title: `${clubName} FC · GitFut`, + description: `${clubName} scouted on GitFut: build the best XI from GitHub organization members.`, + alternates: { canonical: `/org/${org.login}` }, + twitter: { card: "summary_large_image" }, + }; + } catch { + return { title: `${login} FC · GitFut`, robots: { index: false } }; + } +} + +function OrgError({ login, message }: { login: string; message: string }) { + return ( +
+
CLUB SCOUT
+

+ Club not found +

+

{message}

+ + BACK TO THE BENCH + +
+ ); +} + +export default async function OrgPage({ + params, +}: { + params: Promise<{ login: string }>; +}) { + const { login } = await params; + const stars = await getRepoStars(); + + let orgInfo; + let members; + try { + [orgInfo, members] = await Promise.all([ + fetchOrganization(login), + fetchOrganizationMembers(login), + ]); + } catch (e) { + const msg = (e as { message?: string }).message ?? "Something went wrong."; + return ( +
+ + +
+ ); + } + + return ( +
+ + +
+ ); +} diff --git a/app/u/[username]/opengraph-image.tsx b/app/u/[username]/opengraph-image.tsx index 3575645..3af0163 100644 --- a/app/u/[username]/opengraph-image.tsx +++ b/app/u/[username]/opengraph-image.tsx @@ -77,7 +77,7 @@ export default async function Image({ params }: { params: Promise<{ username: st } const card = { ...raw, country: pickFlag(null, raw.country) ?? "" }; // GitHub-derived flag only - const accent = card.founder?.accent ?? TIER_ACCENT[card.finish] ?? "#39d353"; + const accent = card.founder?.accent ?? card.patron?.accent ?? TIER_ACCENT[card.finish] ?? "#39d353"; const groups = groupAwards(card.awards); const [assets, stills] = await Promise.all([loadCardAssets(card, CARD_W), loadTrophyStills(groups)]); const shelf = groups.length ? trophyShelf({ groups, stills, tall: 92 }) : null; diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 35e2e0a..e2d16c3 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -3,8 +3,10 @@ import { useEffect, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import ScoutForm from "@/components/ScoutForm"; +import type { ScoutMode } from "@/components/ScoutForm"; import CardFan from "@/components/CardFan"; import LoadingScreen from "@/components/LoadingScreen"; +import OrgLoadingScreen from "@/components/OrgLoadingScreen"; import dynamic from "next/dynamic"; import FooterCredit from "@/components/FooterCredit"; import BuyMeACoffee from "@/components/BuyMeACoffee"; @@ -35,6 +37,7 @@ export default function AppShell({ const router = useRouter(); const [isPending, startTransition] = useTransition(); const [pending, setPending] = useState(null); + const [pendingMode, setPendingMode] = useState("player"); const [modalOpen, setModalOpen] = useState(false); const [tourOpen, setTourOpen] = useState(false); @@ -62,17 +65,23 @@ export default function AppShell({ return () => clearTimeout(t); }, []); - // Scouting navigates to the canonical / route. The transition keeps - // the loading screen up (with the mascot + puns) while the report is fetched - // and server-rendered; the route then plays its own reveal. - const handleScout = (name: string) => { + // Scouting navigates to the canonical / route for players, + // or /org/ for organizations. The transition keeps the loading + // screen up while the report is fetched and server-rendered. + const handleScout = (name: string, mode: ScoutMode = "player") => { const login = name.trim().replace(/^@/, ""); if (!login) return; setPending(login); - startTransition(() => router.push(`/${encodeURIComponent(login)}`)); + setPendingMode(mode); + const path = mode === "club" ? `/org/${encodeURIComponent(login)}` : `/${encodeURIComponent(login)}`; + startTransition(() => router.push(path)); }; - if (isPending && pending) return ; + if (isPending && pending) { + return pendingMode === "club" + ? + : ; + } return ( <> diff --git a/components/OrgLoadingScreen.tsx b/components/OrgLoadingScreen.tsx new file mode 100644 index 0000000..acfcefe --- /dev/null +++ b/components/OrgLoadingScreen.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Mascot from "./Mascot"; + +// Loading screen for organization scouting — shows progressive status. +export default function OrgLoadingScreen({ login }: { login: string }) { + const [tick, setTick] = useState(0); + + useEffect(() => { + const id = setInterval(() => setTick((t) => t + 1), 2200); + return () => clearInterval(id); + }, []); + + const steps = [ + "Finding organization…", + "Gathering squad…", + "Scouting players…", + "Comparing formations…", + "Building best XI…", + ]; + + return ( +
+ + +
+ SCOUTING{" "} + {login}{" "} + FC +
+ +
+ {steps[tick % steps.length]} +
+ + {/* indeterminate progress sliver */} +
+
+
+ + +
+ ); +} diff --git a/components/PlayerCard.tsx b/components/PlayerCard.tsx index 114930d..97bc82c 100644 --- a/components/PlayerCard.tsx +++ b/components/PlayerCard.tsx @@ -63,7 +63,7 @@ const hideOnError: React.ReactEventHandler = (e) => { e.currentTarget.style.visibility = "hidden"; }; -function PlayerCard({ card }: { card: Card }) { +function PlayerCard({ card, adjustedOVR }: { card: Card; adjustedOVR?: number }) { const t = resolveCardTheme(card); const ink = t.ink; const displayName = cardDisplayName(card.name).toUpperCase(); @@ -204,7 +204,7 @@ function PlayerCard({ card }: { card: Card }) { color: ink, }} > - {pad2(card.overall)} + {pad2(adjustedOVR ?? card.overall)} {/* position (centered around the left column) */} diff --git a/components/ScoutForm.tsx b/components/ScoutForm.tsx index 50a5d13..200b0ad 100644 --- a/components/ScoutForm.tsx +++ b/components/ScoutForm.tsx @@ -4,11 +4,13 @@ import { useState } from "react"; import { ArrowRight } from "lucide-react"; import Mascot from "./Mascot"; +export type ScoutMode = "player" | "club"; + interface Props { loading: boolean; error: string | null; scoutCount: number | null; - onScout: (name: string) => void; + onScout: (name: string, mode: ScoutMode) => void; onOpenModal: () => void; } @@ -23,10 +25,11 @@ export default function ScoutForm({ onOpenModal, }: Props) { const [name, setName] = useState(""); + const [mode, setMode] = useState("player"); const submit = (e: React.FormEvent) => { e.preventDefault(); - if (name.trim()) onScout(name); + if (name.trim()) onScout(name, mode); }; return ( @@ -59,6 +62,24 @@ export default function ScoutForm({ of 99.

+ {/* Player / Club toggle */} +
+ {(["player", "club"] as const).map((m) => ( + + ))} +
+
setName(e.target.value)} - placeholder="github username" + placeholder={mode === "player" ? "github username" : "organization name"} autoComplete="off" spellCheck={false} - aria-label="GitHub username" + aria-label={mode === "player" ? "GitHub username" : "GitHub organization"} className="font-mono h-14 w-full rounded-[14px] border-[1.5px] border-line bg-surface/70 pl-[34px] pr-5 text-[16px] font-medium text-white outline-none backdrop-blur-[4px] transition focus:border-brand focus:bg-surface focus:shadow-[0_0_0_4px_rgba(57,211,83,.16),0_0_42px_rgba(57,211,83,.24)]" /> @@ -101,23 +122,47 @@ export default function ScoutForm({ )}
- try{" "} - {" "} - ·{" "} - {" "} - · or your own + {mode === "player" ? ( + <> + try{" "} + {" "} + ·{" "} + {" "} + · or your own + + ) : ( + <> + try{" "} + {" "} + ·{" "} + {" "} + · or any org + + )}
{/* live tally — a broadcast-style scoreboard count; the number is the diff --git a/components/ScoutReport.tsx b/components/ScoutReport.tsx index 8c691d1..7d91881 100644 --- a/components/ScoutReport.tsx +++ b/components/ScoutReport.tsx @@ -50,6 +50,7 @@ const VERDICTS: Record = { silver: "Squad rotation", bronze: "One to watch", founder: "The architect", + patron: "The one who runs the show", }; // Lightweight hover popup explaining why a value was given. @@ -290,21 +291,24 @@ export function ReportHeader({ card, still = false }: { card: Card; still?: bool > {card.position} - {card.founder && ( - - - - {card.founder.label} - - - )} + {(card.founder || card.patron) && (() => { + const meta = card.founder ?? card.patron!; + return ( + + + + {meta.label} + + + ); + })()} {card.archetype} {/* @login + language travel together as one flex item so they wrap to a new line as a unit — keeps a divider from ever landing orphaned diff --git a/components/SquadView.tsx b/components/SquadView.tsx new file mode 100644 index 0000000..0154474 --- /dev/null +++ b/components/SquadView.tsx @@ -0,0 +1,462 @@ +"use client"; + +import { useEffect, useState, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, Loader2 } from "lucide-react"; +import type { Card } from "@/lib/scoring/types"; +import type { OrgInfo, OrgMember } from "@/lib/github/client"; +import { buildBestXI, FORMATIONS, type SquadResult } from "@/lib/squad-builder"; +import PlayerCard from "./PlayerCard"; +import FooterCredit from "./FooterCredit"; +import BuyMeACoffee from "./BuyMeACoffee"; +import GithubStar from "./GithubStar"; + +interface PitchSlot { + card: Card | null; + label: string; + x: number; + y: number; + fit: number; + adjustedOVR: number; + empty: boolean; +} + +const CARD_SIZE = "clamp(80px, 14vw, 130px)"; + +// Progressive loading phases +type Phase = "loading-members" | "scouting" | "building" | "done"; + +export default function SquadView({ + orgInfo, + members: initialMembers, + stars, +}: { + orgInfo: OrgInfo; + members: OrgMember[]; + stars: number | null; +}) { + const router = useRouter(); + const [selectedCard, setSelectedCard] = useState(null); + const [cards, setCards] = useState([]); + const [scouted, setScouted] = useState(0); + const [failed, setFailed] = useState(0); + const [phase, setPhase] = useState("scouting"); + const [squad, setSquad] = useState(null); + + const clubName = orgInfo.name || orgInfo.login; + const total = Math.min(initialMembers.length, 60); + const started = useRef(false); + + // Scout members progressively — simple loop, no recursion + useEffect(() => { + if (started.current) return; + started.current = true; + + let cancelled = false; + + (async () => { + for (let i = 0; i < total; i++) { + if (cancelled) break; + const member = initialMembers[i]; + try { + const res = await fetch(`/api/org-scout?login=${encodeURIComponent(member.login)}`); + if (cancelled) break; + if (res.ok) { + const data = await res.json(); + if (data.card && !cancelled) { + setCards((prev) => { + // Deduplicate by login + if (prev.some((c) => c.login === data.card.login)) return prev; + return [...prev, data.card]; + }); + } else if (!cancelled) { + setFailed((f) => f + 1); + } + } else if (!cancelled) { + setFailed((f) => f + 1); + } + } catch { + if (!cancelled) setFailed((f) => f + 1); + } + setScouted(i + 1); + } + if (!cancelled) setPhase("building"); + })(); + + return () => { cancelled = true; }; + }, [initialMembers, total]); + + // Build squad once all members are scouted + useEffect(() => { + if (phase === "building" && cards.length > 0) { + const { best } = buildBestXI(cards); + setSquad(best); + setPhase("done"); + } + }, [phase, cards]); + + const pitchSlots = squad + ? (() => { + const formation = FORMATIONS.find((f) => f.name === squad.formation); + if (!formation) return []; + // Map by index, not label — formations have duplicate labels (2× CB, 2× CM…) + const filledByIndex = new Map(squad.xi.map((x, i) => [i, x])); + return formation.slots.map((slot, i) => { + const match = filledByIndex.get(i); + return { + card: match?.card ?? null, + label: slot.label, + x: slot.x, + y: slot.y, + fit: match?.fit ?? 0, + adjustedOVR: match?.adjustedOVR ?? 0, + empty: !match, + }; + }); + })() + : []; + + return ( + <> +
+ {/* Top bar */} +
+ + +
+ + {/* Scouting phase: full-width header + grid */} + {phase === "scouting" && ( +
+ {clubName} +

+ {clubName} FC +

+
+ + Scouting {scouted} / {total} players… +
+
+
+
+ {failed > 0 && ( + {failed} unavailable + )} +
+ {cards.map((card) => ( + + ))} + {Array.from({ length: Math.max(0, total - cards.length) }).map((_, i) => ( +
+
+
+
+ ))} +
+
+ )} + + {/* Done: two columns — pitch left, info + squad right */} + {squad && ( +
+ {/* LEFT — pitch */} +
+
+
+
+
+
+
+
+
+ +
+ {pitchSlots.map((slot, i) => ( +
+ {slot.empty ? ( + /* Empty slot: ghost card with bronze background */ +
+
+ + {/* Silhouette overlay */} +
+ + + + +
+
+ + {slot.label} + +
+ ) : ( + + )} +
+ ))} +
+
+
+ + {/* RIGHT — club info + squad */} +
+ {/* Club identity */} +
+ {clubName} +
+

+ {clubName} FC +

+

+ github.com/{orgInfo.login} +

+
+
+ + {/* Formation + OVR */} +
+
+ + {squad.formation} + +
+
+ + {squad.teamOVR} OVR + +
+
+ {cards.length} players scouted + {failed > 0 && · {failed} unavailable} +
+
+ +
+ {/* Starting XI */} +
+

+ Starting XI +

+
+ {squad.xi.map((x) => ( + + ))} +
+
+ + {/* Bench */} + {squad.bench.length > 0 && ( +
+

+ Bench +

+
+ {squad.bench.map((card) => ( + + ))} +
+
+ )} +
+
+
+ )} + +
+ +
+
+ + + + {selectedCard && ( + setSelectedCard(null)} /> + )} + + ); +} + +function PlayerModal({ card, onClose }: { card: Card; onClose: () => void }) { + const router = useRouter(); + return ( +
+
e.stopPropagation()} + className="relative flex flex-col items-center gap-[16px] rounded-2xl border border-line bg-[linear-gradient(180deg,var(--color-surface-2),var(--color-panel))] p-[24px] shadow-[0_40px_120px_rgba(0,0,0,.6)]" + > + + {card.login} +
+
{card.name}
+
@{card.login}
+
+
+
+
{card.overall}
+
OVR
+
+
+
{card.position}
+
POS
+
+
+ +
+
+ ); +} diff --git a/components/finishTheme.ts b/components/finishTheme.ts index 5346606..30f4841 100644 --- a/components/finishTheme.ts +++ b/components/finishTheme.ts @@ -75,17 +75,25 @@ export const CARD_THEME: Record = { avatarTint: "radial-gradient(ellipse 72% 76% at 52% 40%, transparent 50%, rgba(0,0,0,.30))", avatarHalo: "rgba(255,47,69,.42)", }, + patron: { + bg: "/cards/patron.png", + ink: "#f6f8fb", + glow: "rgba(59,130,246,.55)", + avatarTint: "radial-gradient(ellipse 72% 76% at 52% 40%, transparent 50%, rgba(0,0,0,.30))", + avatarHalo: "rgba(59,130,246,.42)", + }, }; // Per-card theme: identical to CARD_THEME for everyone except founders, who get // their own art, a near-white ink, and a glow/halo derived from their accent. export function resolveCardTheme(card: Card): CardTheme { const base = CARD_THEME[card.finish]; - if (!card.founder) return base; - const a = card.founder.accent; + const meta = card.founder ?? card.patron; + if (!meta) return base; + const a = meta.accent; return { - bg: card.founder.art, - ink: card.founder.ink ?? "#f6f8fb", + bg: meta.art, + ink: meta.ink ?? "#f6f8fb", glow: rgba(a, 0.55), avatarTint: "radial-gradient(ellipse 72% 76% at 52% 40%, transparent 50%, rgba(0,0,0,.30))", avatarHalo: rgba(a, 0.42), @@ -106,14 +114,16 @@ export const RESULT_THEME: Record = { toty: { glow: "rgba(90,140,255,.5)", chip: "#10254F", ink: "#CADBFF" }, icon: { glow: "rgba(243,213,128,.45)", chip: "#2A1A45", ink: "#F3D688" }, founder: { glow: "rgba(255,47,69,.4)", chip: "#221016", ink: "#ff6273" }, + patron: { glow: "rgba(59,130,246,.4)", chip: "#0c1a2e", ink: "#60a5fa" }, }; // Per-card result accent: founders tint the whole scout report to their own // accent (red for Younes, chrome for Mawsis); everyone else uses RESULT_THEME. export function resolveResultTheme(card: Card): ResultTheme { const base = RESULT_THEME[card.finish]; - if (!card.founder) return base; - return { ink: card.founder.accent, glow: rgba(card.founder.accent, 0.34), chip: base.chip }; + const meta = card.founder ?? card.patron; + if (!meta) return base; + return { ink: meta.accent, glow: rgba(meta.accent, 0.34), chip: base.chip }; } // ---- Duel kit clash: TOTY/TOTW vs silver, and nothing else ---- @@ -145,5 +155,6 @@ const CONFETTI: Partial> = { export function confettiPalette(card: Card): string[] { if (card.founder) return [card.founder.accent, "#ffffff", "#39d353"]; + if (card.patron) return [card.patron.accent, "#ffffff", "#39d353"]; return CONFETTI[card.finish] ?? ["#39d353", "#e9cc74", "#ffffff"]; } diff --git a/lib/awards.ts b/lib/awards.ts index 1566630..46b5509 100644 --- a/lib/awards.ts +++ b/lib/awards.ts @@ -124,7 +124,7 @@ function ballonDors(card: Card): AwardInstance[] { (s.score >= BDO_RATIO * mid || s.score >= BDO_ELITE), ) .sort((a, b) => b.score - a.score); - const icon = card.finish === "icon" || card.finish === "founder"; + const icon = card.finish === "icon" || card.finish === "founder" || card.finish === "patron"; const iconCap = icon && qualifying[BDO_CAP]?.score >= BDO_FOURTH ? BDO_CAP + 1 : BDO_CAP; const ovrCap = BDO_OVR_LADDER.filter((floor) => card.overall >= floor).length; // Reasons are factual and third-person (cards are mostly viewed by OTHERS), diff --git a/lib/countries.ts b/lib/countries.ts index 1cbc56e..50f0def 100644 --- a/lib/countries.ts +++ b/lib/countries.ts @@ -1019,6 +1019,7 @@ const UK_NATIONS: readonly Country[] = [ { code: "sct", name: "Scotland" }, { code: "wls", name: "Wales" }, { code: "nir", name: "Northern Ireland" }, + { code: "sav", name: "Savoie" }, ]; // The full pickable list: generated sovereigns + home nations, re-sorted by name diff --git a/lib/geo.ts b/lib/geo.ts index c2de5bf..eef3362 100644 --- a/lib/geo.ts +++ b/lib/geo.ts @@ -31,6 +31,7 @@ const COUNTRY: Record = { algeria: "dz", "sri lanka": "lk", nepal: "np", cambodia: "kh", myanmar: "mm", ecuador: "ec", uruguay: "uy", "costa rica": "cr", cuba: "cu", "dominican republic": "do", guatemala: "gt", bolivia: "bo", paraguay: "py", armenia: "am", azerbaijan: "az", cyprus: "cy", malta: "mt", moldova: "md", + savoie: "sav", }; const CITY: Record = { @@ -109,6 +110,7 @@ const PINNED: Record = { theprimeagen: "us", // Michael Paulson — USA "pewdiepie-archdaemon": "se", t3dotgg: "us", // Theo Browne — USA + timeochatelain: "sav", // Savoie }; // Country for a profile: a pinned origin (showcased accounts) wins, otherwise the diff --git a/lib/github/client.ts b/lib/github/client.ts index f061a38..def5796 100644 --- a/lib/github/client.ts +++ b/lib/github/client.ts @@ -675,3 +675,168 @@ function normalize(user: UserNode, years: YearBreakdown[]): RawPayload { years, }; } + +// ── Organization fetching ───────────────────────────────────────────── + +export interface OrgInfo { + login: string; + name: string | null; + avatarUrl: string; + description: string | null; + membersWithRole: { totalCount: number }; +} + +export interface OrgMember { + login: string; + avatarUrl: string; +} + +interface OrgNode { + login: string; + name: string | null; + avatarUrl: string; + description: string | null; + membersWithRole: { totalCount: number; nodes: { login: string; avatarUrl: string }[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } }; +} + +const orgQuery = ` + query Org($login: String!) { + organization(login: $login) { + login + name + avatarUrl(size: 480) + description + membersWithRole(first: 100) { + totalCount + nodes { login avatarUrl(size: 480) } + pageInfo { hasNextPage endCursor } + } + } + } +`; + +const orgMembersQuery = (after: string | null) => ` + query OrgMembers($login: String!) { + organization(login: $login) { + membersWithRole(first: 100${after ? `, after: "${after}"` : ""}) { + nodes { login avatarUrl(size: 480) } + pageInfo { hasNextPage endCursor } + } + } + } +`; + +// Simple bot filter: skip logins that look like known bot patterns. +const BOT_PATTERN = /\b(bot|dependabot|renovate|github-actions)\b/i; + +// Like gql but returns the `organization` root field instead of `user`. +async function gqlOrg( + query: string, + login: string, + tok: PoolToken, +): Promise<{ organization: T | null }> { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS); + try { + const res = await fetch(ENDPOINT, { + method: "POST", + headers: { + Authorization: `Bearer ${tok.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query, variables: { login } }), + signal: ctrl.signal, + }); + clearTimeout(timer); + recordTokenHealth(tok.idx, res.headers); + if (res.status === 401) return fail("config", "GitHub token is invalid or expired."); + if (res.status === 403 || res.status === 429) { + benchToken(tok.idx, res.headers); + return fail("ratelimit", "GitHub rate limit hit. Try again shortly."); + } + if (!res.ok) return fail("network", `GitHub returned an error (${res.status}).`); + + let body: { data?: { organization: T | null }; errors?: { type?: string; message?: string }[] }; + try { + body = await res.json(); + } catch { + return fail("network", "GitHub returned a malformed response."); + } + + if (body.errors?.some((e) => e.type === "RATE_LIMITED" || e.type === "RATE_LIMIT")) { + benchToken(tok.idx, res.headers); + return fail("ratelimit", "GitHub rate limit hit. Try again shortly."); + } + if (body.errors?.length && !body.data?.organization) { + if (body.errors.every((e) => e.type === "NOT_FOUND")) + return { organization: null }; + return fail("network", body.errors[0]?.message ?? "GitHub returned a GraphQL error."); + } + + return { organization: body.data?.organization ?? null }; + } catch (e) { + clearTimeout(timer); + if ((e as GithubError).type === "config" || (e as GithubError).type === "ratelimit" || (e as GithubError).type === "network") throw e; + return fail("network", "Couldn't reach GitHub."); + } +} + +export async function fetchOrganization(login: string): Promise { + const norm = login.trim().replace(/^@/, ""); + const pool = tokenPool(); + if (!pool.length) fail("config", "Server is missing a GitHub token."); + const tok = pickToken(norm, pool) as PoolToken; + + const { organization } = await gqlOrg(orgQuery, norm, tok); + if (!organization) fail("notfound", "No GitHub organization by that name."); + return { + login: organization.login, + name: organization.name, + avatarUrl: organization.avatarUrl, + description: organization.description, + membersWithRole: { totalCount: organization.membersWithRole.totalCount }, + }; +} + +export async function fetchOrganizationMembers(login: string): Promise { + const norm = login.trim().replace(/^@/, ""); + const pool = tokenPool(); + if (!pool.length) fail("config", "Server is missing a GitHub token."); + let tok = pickToken(norm, pool) as PoolToken; + + const members: OrgMember[] = []; + let cursor: string | null = null; + let pages = 0; + const MAX_PAGES = 5; // cap at 500 members to protect API limits + + try { + for (let attempt = 0; attempt <= 1; attempt++) { + try { + while (pages < MAX_PAGES) { + const { organization } = await gqlOrg(orgMembersQuery(cursor), norm, tok); + if (!organization?.membersWithRole) break; + + for (const m of organization.membersWithRole.nodes) { + if (!BOT_PATTERN.test(m.login)) { + members.push({ login: m.login, avatarUrl: m.avatarUrl }); + } + } + + if (!organization.membersWithRole.pageInfo.hasNextPage) break; + cursor = organization.membersWithRole.pageInfo.endCursor; + pages++; + } + break; // success + } catch (e) { + if ((e as GithubError).type !== "ratelimit" || attempt > 0) throw e; + const fallback = await pickFailover(tok.idx, pool); + if (!fallback) throw e; + tok = fallback; + } + } + } catch { + // Best-effort: return what we have rather than failing the whole org + } + + return members; +} diff --git a/lib/org-scout.ts b/lib/org-scout.ts new file mode 100644 index 0000000..0b8e650 --- /dev/null +++ b/lib/org-scout.ts @@ -0,0 +1,112 @@ +import "server-only"; +import { cache } from "react"; +import { redis } from "./redis"; +import { fetchOrganization, fetchOrganizationMembers, type OrgInfo, type OrgMember } from "./github/client"; +import { scoutCard } from "./scout"; +import { buildBestXI, type SquadResult } from "./squad-builder"; +import type { Card } from "./scoring/types"; + +// Organization cache: stores the full squad result to avoid re-scouting +// the entire org on every page load. Individual member cards are already +// cached by scoutCard's own Redis layer, so this only caches the +// org-level aggregation (member list + squad build). +const CACHE_VERSION = "v1"; +const ORG_TTL_SECONDS = 30 * 60; // 30min — org membership changes less than stats + +const keyFor = (login: string) => `gitfut:org:${CACHE_VERSION}:${login}`; + +interface OrgCacheEntry { + org: OrgInfo; + members: OrgMember[]; + cards: Card[]; + squad: SquadResult; +} + +async function readOrgCache(login: string): Promise { + if (!redis) return null; + try { + const raw = await redis.get(keyFor(login)); + return raw ? (JSON.parse(raw) as OrgCacheEntry) : null; + } catch { + return null; + } +} + +async function writeOrgCache(login: string, entry: OrgCacheEntry): Promise { + if (!redis) return; + try { + await redis.set(keyFor(login), JSON.stringify(entry), "EX", ORG_TTL_SECONDS); + } catch {} +} + +// In-flight dedup for org scouts (same as scoutCard's single-flight pattern) +const inflight = new Map>(); + +// Bounded concurrency: scout members in parallel batches to stay within +// GitHub API limits while still being fast. +// ponytail: batch of 10, 50ms delay — fast enough for orgs <50 members. +// For larger orgs, consider streaming results or pre-warming the card cache. +const BATCH_SIZE = 10; + +async function scoutMembers(members: OrgMember[]): Promise { + const cards: Card[] = []; + for (let i = 0; i < members.length; i += BATCH_SIZE) { + const batch = members.slice(i, i + BATCH_SIZE); + const results = await Promise.allSettled( + batch.map((m) => scoutCard(m.login)), + ); + for (const r of results) { + if (r.status === "fulfilled") cards.push(r.value); + } + if (i + BATCH_SIZE < members.length) { + await new Promise((r) => setTimeout(r, 50)); + } + } + return cards; +} + +async function buildOrg(login: string): Promise { + const org = await fetchOrganization(login); + const members = await fetchOrganizationMembers(login); + // Cap at 60 scouted members to keep response times reasonable. + // ponytail: 60 is enough to fill a strong XI; beyond that, diminishing returns. + const cards = await scoutMembers(members.slice(0, 60)); + const { best } = buildBestXI(cards); + const entry = { org, members, cards, squad: best }; + await writeOrgCache(login, entry); + return entry; +} + +export interface OrgResult { + org: OrgInfo; + members: OrgMember[]; + cards: Card[]; + squad: SquadResult; +} + +export async function scoutOrganization(login: string): Promise { + const norm = login.trim().replace(/^@/, "").toLowerCase(); + + // Check org-level cache first + const cached = await readOrgCache(norm); + if (cached) return cached; + + // Coalesce concurrent misses + const existing = inflight.get(norm); + if (existing) return existing; + + const pending = buildOrg(norm).finally(() => inflight.delete(norm)); + inflight.set(norm, pending); + return pending; +} + +// Request-memoised version (same pattern as loadCard in lib/scout.ts) +export const loadOrg = cache( + async (login: string): Promise<{ org: OrgResult } | { error: { type: string; message: string } }> => { + try { + return { org: await scoutOrganization(login) }; + } catch (e) { + return { error: e as { type: string; message: string } }; + } + }, +); diff --git a/lib/reveal.ts b/lib/reveal.ts index 60ba7cb..b66614d 100644 --- a/lib/reveal.ts +++ b/lib/reveal.ts @@ -21,6 +21,7 @@ const BURST_TIERS: ReadonlySet = new Set([ "icon", "totw", "founder", + "patron", ]); export function hasBurst(finish: Finish): boolean { diff --git a/lib/scoring/constants.ts b/lib/scoring/constants.ts index 3f79e04..d36721b 100644 --- a/lib/scoring/constants.ts +++ b/lib/scoring/constants.ts @@ -1,4 +1,4 @@ -import type { Family, Finish, FounderMeta, StatKey, Stats } from "./types"; +import type { Family, Finish, FounderMeta, PatronMeta, StatKey, Stats } from "./types"; export const STATS: StatKey[] = ["pac", "sho", "pas", "dri", "def", "phy"]; @@ -50,6 +50,7 @@ export const FINISH_LABELS: Record = { toty: "TOTY", icon: "ICON", founder: "FOUNDER", + patron: "PATRON", }; // The people who built gitfut. Keyed by LOWERCASE GitHub login; matched @@ -76,3 +77,18 @@ export const FOUNDER_OVERALL: Record = { younesfdj: 93, mawsis: 91, }; + +// Org leaders who enabled GitFut's growth at scale. Same shape as founders +// but distinct branding (blue patron card). +export const PATRONS: Record = { + timeochatelain: { + art: "/cards/patron.png", + accent: "#3b82f6", + label: "PATRON", + tagline: "He proved himself on the pitch — now he runs things from the sideline, a true mastermind.", + }, +}; + +export const PATRON_OVERALL: Record = { + timeochatelain: 88, +}; diff --git a/lib/scoring/engine.ts b/lib/scoring/engine.ts index 6d06244..d8f5dc8 100644 --- a/lib/scoring/engine.ts +++ b/lib/scoring/engine.ts @@ -1,7 +1,7 @@ import { countryForLogin } from "../geo"; import { topLanguageLogo } from "../github/languages"; import { deriveMetrics, deriveSkillMoves, deriveStyle, deriveWeakFoot, deriveWorkRate } from "./attributes"; -import { ATTACK_STATS, FINISH_LABELS, FOUNDER_OVERALL, FOUNDERS, K, STATS, WEIGHTS } from "./constants"; +import { ATTACK_STATS, FINISH_LABELS, FOUNDER_OVERALL, FOUNDERS, K, PATRONS, PATRON_OVERALL, STATS, WEIGHTS } from "./constants"; import { derivePlaystyles } from "./playstyles"; import type { Archetype, @@ -179,13 +179,19 @@ export function buildCard(s: Signals): Card { // `finish` directly rather than via pickFinish: any overall >= 90 would // otherwise auto-promote to ICON (and flip club/archetype), hijacking the look. const founder = FOUNDERS[s.login.toLowerCase()]; - const overall = founder + const patron = PATRONS[s.login.toLowerCase()]; + const forcedOVR = founder ? FOUNDER_OVERALL[s.login.toLowerCase()] - : clamp(baseOVR + Math.round(K.legacy.bonusMax * L), 1, 99); - const finish: Finish = founder ? "founder" : pickFinish(overall, L, s.recent_spike, s.login); + : patron + ? PATRON_OVERALL[s.login.toLowerCase()] + : undefined; + const overall = forcedOVR ?? clamp(baseOVR + Math.round(K.legacy.bonusMax * L), 1, 99); + const finish: Finish = founder ? "founder" : patron ? "patron" : pickFinish(overall, L, s.recent_spike, s.login); const archetype = founder ? { name: "Founder", blurb: "co-founder of GitFut — they built the very scout reading this card" } - : archetypeFromShape(stats, finish); + : patron + ? { name: "Patron", blurb: "He proved himself on the pitch — now he runs things from the sideline, a true mastermind." } + : archetypeFromShape(stats, finish); const skill = deriveSkillMoves(s); const weak = deriveWeakFoot(stats); const work = deriveWorkRate(stats); @@ -211,6 +217,7 @@ export function buildCard(s: Signals): Card { topLanguage: s.topLanguage ?? null, languageLogo, ...(founder ? { founder } : null), + ...(patron ? { patron } : null), ...(s.years ? { years: s.years } : null), legacy: { L }, report: { diff --git a/lib/scoring/types.ts b/lib/scoring/types.ts index c81b733..16214f9 100644 --- a/lib/scoring/types.ts +++ b/lib/scoring/types.ts @@ -2,8 +2,8 @@ export type StatKey = "pac" | "sho" | "pas" | "dri" | "def" | "phy"; export type Stats = Record; export type Profile = Record; -export type Finish = "bronze" | "silver" | "gold" | "totw" | "toty" | "icon" | "founder"; -export type Position = "ST" | "RW" | "CAM" | "CM" | "CDM" | "CB"; +export type Finish = "bronze" | "silver" | "gold" | "totw" | "toty" | "icon" | "founder" | "patron"; +export type Position = "ST" | "RW" | "LW" | "LM" | "RM" | "CAM" | "CM" | "CDM" | "CB" | "LB" | "RB" | "GK"; export type Family = "Forward" | "Playmaker" | "Anchor"; export interface Signals { @@ -101,6 +101,8 @@ export interface FounderMeta { tagline: string; // tooltip + flavor, e.g. "Co-founder of gitfut" } +export type PatronMeta = FounderMeta; + export interface Card { login: string; name: string; @@ -128,6 +130,8 @@ export interface Card { // Set only for gitfut founders — their bespoke card art/accent + hint metadata. // Optional so every other card (and previously serialized ones) stay valid. founder?: FounderMeta; + // Set only for gitfut patrons — org leaders who enabled GitFut's growth. + patron?: PatronMeta; // Per-year history behind the yearly awards (Ballon d'Or etc.). Optional so // cached/serialized cards from before the awards system stay valid. years?: YearBreakdown[]; diff --git a/lib/squad-builder.ts b/lib/squad-builder.ts new file mode 100644 index 0000000..7f4da4c --- /dev/null +++ b/lib/squad-builder.ts @@ -0,0 +1,271 @@ +import type { Card, Position, StatKey } from "./scoring/types"; + +// Formation definitions: each slot maps to a position requirement. +// Positions not in the original 6 (ST, RW, CAM, CM, CDM, CB) are +// synthetic — assigned to players whose stat profile fits, with a +// positional-fit penalty when they're out of their natural position. + +export interface FormationSlot { + position: Position; + label: string; // display label (e.g. "GK", "LB") + x: number; // pitch x 0-100 (left-right) + y: number; // pitch y 0-100 (bottom-top, GK=0) +} + +export interface Formation { + name: string; + slots: FormationSlot[]; +} + +// Stat weights per target position — how much each attribute matters +// for that role. Used to score a player's positional fit. +const POSITION_WEIGHTS: Record>> = { + GK: { pac: 0.05, sho: 0.05, pas: 0.15, dri: 0.15, def: 0.30, phy: 0.30 }, + CB: { pac: 0.10, sho: 0.05, pas: 0.10, dri: 0.10, def: 0.40, phy: 0.25 }, + LB: { pac: 0.25, sho: 0.05, pas: 0.20, dri: 0.20, def: 0.20, phy: 0.10 }, + RB: { pac: 0.25, sho: 0.05, pas: 0.20, dri: 0.20, def: 0.20, phy: 0.10 }, + CDM: { pac: 0.10, sho: 0.05, pas: 0.20, dri: 0.15, def: 0.35, phy: 0.15 }, + CM: { pac: 0.10, sho: 0.10, pas: 0.25, dri: 0.25, def: 0.15, phy: 0.15 }, + CAM: { pac: 0.15, sho: 0.15, pas: 0.25, dri: 0.25, def: 0.05, phy: 0.15 }, + LW: { pac: 0.25, sho: 0.15, pas: 0.15, dri: 0.25, def: 0.05, phy: 0.15 }, + LM: { pac: 0.25, sho: 0.10, pas: 0.20, dri: 0.25, def: 0.10, phy: 0.10 }, + RW: { pac: 0.25, sho: 0.15, pas: 0.15, dri: 0.25, def: 0.05, phy: 0.15 }, + RM: { pac: 0.25, sho: 0.10, pas: 0.20, dri: 0.25, def: 0.10, phy: 0.10 }, + ST: { pac: 0.20, sho: 0.30, pas: 0.10, dri: 0.20, def: 0.05, phy: 0.15 }, +}; + +// Natural position → group for compatibility scoring. +const POSITION_GROUP: Record = { + GK: "gk", + CB: "defense", + LB: "defense", + RB: "defense", + CDM: "midfield", + CM: "midfield", + CAM: "midfield", + LW: "attack", + LM: "attack", + RW: "attack", + RM: "attack", + ST: "attack", +}; + +// How well does a player fit a target position? +// Returns 0–1: 1.0 = natural position, lower = increasingly out of position. +export function positionFit(card: Card, target: Position): number { + if (card.position === target) return 1.0; + + const sameGroup = POSITION_GROUP[card.position] === POSITION_GROUP[target]; + if (!sameGroup) return 0.35; + + // Weighted stat match: how well do this player's stats align with the target position's needs? + const weights = POSITION_WEIGHTS[target]; + let fitScore = 0; + for (const [stat, w] of Object.entries(weights) as [StatKey, number][]) { + fitScore += (card.stats[stat] / 99) * w; + } + // Scale to 0.60–0.90 range for same-group, different-position + return 0.60 + fitScore * 0.30; +} + +// Score a player in a position: OVR * positional fit. +function slotScore(card: Card, target: Position): number { + return card.overall * positionFit(card, target); +} + +// --- Formations --- + +export const FORMATIONS: Formation[] = [ + { + name: "4-3-3", + slots: [ + { position: "GK", label: "GK", x: 50, y: 2 }, + { position: "LB", label: "LB", x: 15, y: 20 }, + { position: "CB", label: "CB", x: 38, y: 20 }, + { position: "CB", label: "CB", x: 62, y: 20 }, + { position: "RB", label: "RB", x: 85, y: 20 }, + { position: "CM", label: "CM", x: 35, y: 48 }, + { position: "CM", label: "CM", x: 50, y: 45 }, + { position: "CM", label: "CM", x: 65, y: 48 }, + { position: "LW", label: "LW", x: 20, y: 78 }, + { position: "ST", label: "ST", x: 50, y: 82 }, + { position: "RW", label: "RW", x: 80, y: 78 }, + ], + }, + { + name: "4-2-3-1", + slots: [ + { position: "GK", label: "GK", x: 50, y: 2 }, + { position: "LB", label: "LB", x: 15, y: 20 }, + { position: "CB", label: "CB", x: 38, y: 20 }, + { position: "CB", label: "CB", x: 62, y: 20 }, + { position: "RB", label: "RB", x: 85, y: 20 }, + { position: "CDM", label: "CDM", x: 38, y: 38 }, + { position: "CDM", label: "CDM", x: 62, y: 38 }, + { position: "CAM", label: "CAM", x: 50, y: 56 }, + { position: "LW", label: "LW", x: 20, y: 56 }, + { position: "RW", label: "RW", x: 80, y: 56 }, + { position: "ST", label: "ST", x: 50, y: 82 }, + ], + }, + { + name: "4-4-2", + slots: [ + { position: "GK", label: "GK", x: 50, y: 2 }, + { position: "LB", label: "LB", x: 15, y: 20 }, + { position: "CB", label: "CB", x: 38, y: 20 }, + { position: "CB", label: "CB", x: 62, y: 20 }, + { position: "RB", label: "RB", x: 85, y: 20 }, + { position: "LM", label: "LM", x: 15, y: 50 }, + { position: "CM", label: "CM", x: 38, y: 50 }, + { position: "CM", label: "CM", x: 62, y: 50 }, + { position: "RM", label: "RM", x: 85, y: 50 }, + { position: "ST", label: "ST", x: 38, y: 82 }, + { position: "ST", label: "ST", x: 62, y: 82 }, + ], + }, + { + name: "4-3-1-2", + slots: [ + { position: "GK", label: "GK", x: 50, y: 2 }, + { position: "LB", label: "LB", x: 15, y: 20 }, + { position: "CB", label: "CB", x: 38, y: 20 }, + { position: "CB", label: "CB", x: 62, y: 20 }, + { position: "RB", label: "RB", x: 85, y: 20 }, + { position: "CM", label: "CM", x: 30, y: 48 }, + { position: "CM", label: "CM", x: 50, y: 45 }, + { position: "CM", label: "CM", x: 70, y: 48 }, + { position: "CAM", label: "CAM", x: 50, y: 64 }, + { position: "ST", label: "ST", x: 38, y: 82 }, + { position: "ST", label: "ST", x: 62, y: 82 }, + ], + }, + { + name: "3-5-2", + slots: [ + { position: "GK", label: "GK", x: 50, y: 2 }, + { position: "CB", label: "CB", x: 25, y: 20 }, + { position: "CB", label: "CB", x: 50, y: 20 }, + { position: "CB", label: "CB", x: 75, y: 20 }, + { position: "LM", label: "LM", x: 10, y: 50 }, + { position: "CDM", label: "CDM", x: 38, y: 42 }, + { position: "CM", label: "CM", x: 50, y: 48 }, + { position: "CDM", label: "CDM", x: 62, y: 42 }, + { position: "RM", label: "RM", x: 90, y: 50 }, + { position: "ST", label: "ST", x: 38, y: 82 }, + { position: "ST", label: "ST", x: 62, y: 82 }, + ], + }, + { + name: "4-1-2-1-2", + slots: [ + { position: "GK", label: "GK", x: 50, y: 2 }, + { position: "LB", label: "LB", x: 15, y: 20 }, + { position: "CB", label: "CB", x: 38, y: 20 }, + { position: "CB", label: "CB", x: 62, y: 20 }, + { position: "RB", label: "RB", x: 85, y: 20 }, + { position: "CDM", label: "CDM", x: 50, y: 36 }, + { position: "LM", label: "LM", x: 25, y: 52 }, + { position: "RM", label: "RM", x: 75, y: 52 }, + { position: "CAM", label: "CAM", x: 50, y: 64 }, + { position: "ST", label: "ST", x: 38, y: 82 }, + { position: "ST", label: "ST", x: 62, y: 82 }, + ], + }, + { + name: "3-4-2-1", + slots: [ + { position: "GK", label: "GK", x: 50, y: 2 }, + { position: "CB", label: "CB", x: 25, y: 20 }, + { position: "CB", label: "CB", x: 50, y: 20 }, + { position: "CB", label: "CB", x: 75, y: 20 }, + { position: "LM", label: "LM", x: 10, y: 50 }, + { position: "CM", label: "CM", x: 38, y: 48 }, + { position: "CM", label: "CM", x: 62, y: 48 }, + { position: "RM", label: "RM", x: 90, y: 50 }, + { position: "LW", label: "LW", x: 30, y: 74 }, + { position: "RW", label: "RW", x: 70, y: 74 }, + { position: "ST", label: "ST", x: 50, y: 78 }, + ], + }, +]; + +// Alias LM as LW for position-fit scoring (they're the same wide-left role) +function normalizePosition(p: Position): Position { + return p === "LM" ? "LW" : p; +} + +export interface SquadResult { + formation: string; + xi: { card: Card; slot: FormationSlot; fit: number; adjustedOVR: number }[]; + bench: Card[]; + teamOVR: number; +} + +// Try every formation, pick the one with the highest total squad score. +// Handles: < 11 players (fill what we can), GK emergency, no duplicates. +export function buildBestXI(players: Card[]): { best: SquadResult; all: SquadResult[] } { + if (players.length === 0) { + return { + best: { formation: "4-3-3", xi: [], bench: [], teamOVR: 0 }, + all: [], + }; + } + + // Pre-sort players by OVR descending for greedy slot filling + const sorted = [...players].sort((a, b) => b.overall - a.overall); + + const results: SquadResult[] = []; + + for (const formation of FORMATIONS) { + const used = new Set(); // login keys + const xi: SquadResult["xi"] = []; + + for (const slot of formation.slots) { + const pos = normalizePosition(slot.position); + let bestCard: Card | null = null; + let bestFit = 0; + let bestScore = -1; + + for (const card of sorted) { + if (used.has(card.login)) continue; + const fit = positionFit(card, pos); + const score = slotScore(card, pos); + if (score > bestScore) { + bestScore = score; + bestFit = fit; + bestCard = card; + } + } + + if (bestCard) { + used.add(bestCard.login); + xi.push({ + card: bestCard, + slot, + fit: bestFit, + adjustedOVR: Math.round(bestCard.overall * bestFit), + }); + } + } + + const teamOVR = xi.length > 0 + ? Math.round(xi.reduce((s, x) => s + x.adjustedOVR, 0) / xi.length) + : 0; + + const bench = sorted.filter((c) => !used.has(c.login)).slice(0, 7); + + results.push({ formation: formation.name, xi, bench, teamOVR }); + } + + // Pick best by teamOVR (tiebreak: more XI slots filled, then fewer positional mismatches) + const best = results.reduce((a, b) => { + if (b.xi.length !== a.xi.length) return b.xi.length > a.xi.length ? b : a; + if (b.teamOVR !== a.teamOVR) return b.teamOVR > a.teamOVR ? b : a; + const aFit = a.xi.reduce((s, x) => s + x.fit, 0); + const bFit = b.xi.reduce((s, x) => s + x.fit, 0); + return bFit > aFit ? b : a; + }); + + return { best, all: results }; +} diff --git a/public/badges/flags/sav.png b/public/badges/flags/sav.png new file mode 100644 index 0000000..f596b1b Binary files /dev/null and b/public/badges/flags/sav.png differ diff --git a/public/cards/patron.png b/public/cards/patron.png new file mode 100644 index 0000000..6267d40 Binary files /dev/null and b/public/cards/patron.png differ diff --git a/tests/squad-builder.test.ts b/tests/squad-builder.test.ts new file mode 100644 index 0000000..bbe486c --- /dev/null +++ b/tests/squad-builder.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; +import { buildBestXI, positionFit } from "@/lib/squad-builder"; +import { buildCard } from "@/lib/scoring/engine"; +import type { Card, Signals } from "@/lib/scoring/types"; + +const makeCard = (overrides: Partial): Card => { + const base: Signals = { + login: "test-user", + name: "Test User", + avatarUrl: "https://example.com/avatar.png", + location: null, + followers: 50, + account_age_years: 3, + public_repos: 20, + total_stars_owned: 100, + max_repo_stars: 40, + languages: 5, + rankedLanguages: ["TypeScript"], + topLanguage: "TypeScript", + recent_contributions: 300, + active_days_recent: 100, + active_years: 3, + total_contributions_lifetime: 1500, + prs_to_others: 5, + reviews: 3, + issues_closed: 4, + recent_commits: 280, + recent_spike: false, + ...overrides, + }; + return buildCard(base); +}; + +describe("positionFit", () => { + it("returns 1.0 for natural position", () => { + const card = makeCard({ login: "forward-player" }); + // The card's position depends on stats; use whatever it is + expect(positionFit(card, card.position)).toBe(1.0); + }); + + it("returns < 1.0 for out-of-position", () => { + const card = makeCard({ login: "forward-player" }); + // A forward placed in GK should have low fit + const fit = positionFit(card, "GK"); + expect(fit).toBeLessThan(1.0); + }); + + it("returns higher fit for same group than cross-group", () => { + const card = makeCard({ + login: "midfielder", + recent_contributions: 400, + prs_to_others: 20, + reviews: 15, + }); + // An attack-ish player might be CM or CAM + const attackFit = positionFit(card, "ST"); + const gkFit = positionFit(card, "GK"); + expect(attackFit).toBeGreaterThanOrEqual(gkFit); + }); +}); + +describe("buildBestXI", () => { + it("returns empty result for empty input", () => { + const result = buildBestXI([]); + expect(result.best.xi).toHaveLength(0); + expect(result.best.teamOVR).toBe(0); + }); + + it("fills what it can with fewer than 11 players", () => { + const players = Array.from({ length: 5 }, (_, i) => + makeCard({ login: `player-${i}` }), + ); + const result = buildBestXI(players); + expect(result.best.xi.length).toBeLessThanOrEqual(5); + expect(result.best.xi.length).toBeGreaterThan(0); + }); + + it("does not duplicate players", () => { + const players = Array.from({ length: 15 }, (_, i) => + makeCard({ login: `player-${i}` }), + ); + const result = buildBestXI(players); + const logins = result.best.xi.map((x) => x.card.login); + expect(new Set(logins).size).toBe(logins.length); + }); + + it("picks exactly 11 when enough players exist", () => { + const players = Array.from({ length: 15 }, (_, i) => + makeCard({ login: `player-${i}` }), + ); + const result = buildBestXI(players); + expect(result.best.xi).toHaveLength(11); + }); + + it("always includes a GK slot", () => { + const players = Array.from({ length: 15 }, (_, i) => + makeCard({ login: `player-${i}` }), + ); + const result = buildBestXI(players); + const positions = result.best.xi.map((x) => x.slot.position); + expect(positions).toContain("GK"); + }); + + it("returns multiple formation candidates", () => { + const players = Array.from({ length: 15 }, (_, i) => + makeCard({ login: `player-${i}` }), + ); + const result = buildBestXI(players); + expect(result.all.length).toBeGreaterThan(1); + }); + + it("best formation has highest teamOVR", () => { + const players = Array.from({ length: 15 }, (_, i) => + makeCard({ login: `player-${i}` }), + ); + const result = buildBestXI(players); + for (const alt of result.all) { + expect(result.best.teamOVR).toBeGreaterThanOrEqual(alt.teamOVR); + } + }); + + it("bench contains unused players", () => { + const players = Array.from({ length: 15 }, (_, i) => + makeCard({ login: `player-${i}` }), + ); + const result = buildBestXI(players); + const xiLogins = new Set(result.best.xi.map((x) => x.card.login)); + for (const b of result.best.bench) { + expect(xiLogins.has(b.login)).toBe(false); + } + }); + + it("handles all attackers (no natural defenders/GK)", () => { + // All cards with high SHO/PAC — should still fill a lineup + const players = Array.from({ length: 12 }, (_, i) => + makeCard({ + login: `attacker-${i}`, + recent_contributions: 800, + total_stars_owned: 5000, + max_repo_stars: 3000, + }), + ); + const result = buildBestXI(players); + expect(result.best.xi.length).toBe(11); + // GK will be an emergency pick (out of position) + const gk = result.best.xi.find((x) => x.slot.position === "GK"); + expect(gk).toBeDefined(); + }); +});