diff --git a/README.md b/README.md index 70c18d1..d2dab4a 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,67 @@ Every card walks out in a finish:
+## πŸ†  Achievements & Awards + +Standout cards earn trophies, shown on the scout report and carried into duels like a boxer's belts β€” a duel showcases what each player brings, it never awards anything itself. Click a trophy for its story: every win is attached to a year (or a World Cup edition) with the reason it was given. Rendered from pre-baked rotating sprites (no 3D runtime β€” see `scripts/bake-award-sprites.ts`): + +| Award | Won for | +| :---: | :--- | +| **Ballon d'Or** | A calendar year that towers over the rest of your career β€” judged against your own history (log-scaled across commits, PRs, reviews and issues, so volume spam can't buy it). Repeatable, but every extra one is harder to win: up to 3, and Icons can take a 4th. | +| **WC Golden Ball** | Best player of a World Cup β€” rising above your own pace while a tournament ran (2010 β†’ 2026, era-adjusted: shipping on GitHub in 2010 already counted). One per edition. | +| **Golden Boot** | The top scorer, held on current form β€” elite Shooting (`SHO` 80+) from a scoring position (`ST`/`RW`/`CAM`). | +| **World Cup Trophy** | Β‘Campeones! Spain won WC26 β€” a gift carried by every profile whose GitHub location is Spain. | + +### The rulebook + +Every scout fetches the profile's full history as per-year totals: commits, PRs, reviews, issues and private contributions, for every calendar year since the account was born. Yearly awards are judged on one score per year: + +``` +yearScore = 4Β·log(commits) + 3Β·log(PRs) + 3Β·log(reviews) + 2Β·log(issues) + 2Β·log(private) +``` + +The logs are the anti-spam mechanism. Each signal saturates: 20,000 commits score 17.2, barely ahead of the 13.2 that 2,000 earn. Meanwhile a year of 800 commits, 80 PRs, 80 reviews and 40 issues scores 26. Breadth collects from every term while volume on one axis stalls. There is no balance requirement, though: a commits-only kernel maintainer still scores on the volume terms alone. + +Every trophy except the Spanish gift also requires **overall 80+**. That's a floor, not a driver. It keeps thin profiles off the podium and decides nothing else. + +#### Ballon d'Or + +Judged against your own career, so it needs at least two years of history. A year wins when it clears all three: + +1. **2,000+ raw contributions.** No Ballon d'Or on a quiet year, however spiky it looks against the rest. +2. **Score 16+.** Strong in absolute terms, not just relative ones. +3. **One of two paths:** + - *The spike*: 1.2Γ— your career-median year. One undeniable season in a modest career. + - *The elite*: score 19.5+, ratio waived. A sustained-great career has a huge median no single year can tower over, and those years are exactly what the award exists for. + +Repeats climb a ladder on the card's overall: **80** holds one, **87** unlocks a second, **94** a third. Icons can take a fourth, and only for a monster year (score 28+). The current year competes while still in progress. It can only under-score, never over-score, so nothing is ever awarded on a projection. + +#### WC Golden Ball + +One per World Cup edition. For candidates (overall 80+, activity in a WC year) the scout fetches contributions inside each tournament window and judges them era-adjusted, because GitHub in 2010 was a different sport: + +| Edition | Window | The bar | +| :-- | :-- | :-- | +| South Africa 2010 | Jun 11 – Jul 11 | Presence. Shipping on GitHub at all was the distinction. | +| Brazil 2014 | Jun 12 – Jul 13 | A handful of contributions. | +| Russia 2018 | Jun 14 – Jul 15 | A real window, at **1.2Γ—** your usual pace. | +| Qatar 2022 | Nov 20 – Dec 18 | A strong window (~10 contributions/day), at **1.3Γ—** your usual pace. | +| North America 2026 | Jun 11 – Jul 19 | A strong window (~10 contributions/day), at **1.3Γ—** your usual pace. | + +The pace test compares your daily rate inside the window to your daily rate across that same year. Tournament form means rising above *your own* pace, not an absolute quota. The current year is paced over its elapsed days, not 365, so a routine mid-year can't masquerade as tournament form against a diluted average. + +#### Golden Boot + +The one trophy held on current form, not kept for life. No year attached, re-judged on every scout: a scoring position (`ST`/`RW`/`CAM`) and Shooting 80+. Star power *is* the shooting stat, so this is the finisher whose repos keep hitting the net. Lose the form, lose the boot. + +#### World Cup Trophy + +Spain won WC26, and every profile whose GitHub location reads Spain lifts the trophy with them: bronze cards included, no floors, no other conditions. It keys off the country GitHub reports, decided before the flag picker exists, so switching your flag to πŸ‡ͺπŸ‡Έ mints nothing. + +The whole cabinet is computed server-side at scout time and cached with the card. Duels never award anything. A corner showcases what each card walked in with. + +
+
**Built with** Next.js Β· TypeScript Β· Tailwind Β· Redis diff --git a/app/globals.css b/app/globals.css index d07707b..e321a87 100644 --- a/app/globals.css +++ b/app/globals.css @@ -386,3 +386,15 @@ transition-duration: 0.001ms !important; } } + +/* Ball-cursor toy (components/BallCursor): the real cursor hides only while + the user is actively dribbling β€” never by default. */ +html.gf-ball-cursor, +html.gf-ball-cursor * { + cursor: none !important; +} +html.gf-ball-cursor input, +html.gf-ball-cursor textarea, +html.gf-ball-cursor [contenteditable="true"] { + cursor: text !important; +} diff --git a/app/layout.tsx b/app/layout.tsx index 7e17d93..e37e996 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import { Bebas_Neue, Inter, JetBrains_Mono } from "next/font/google"; import localFont from "next/font/local"; import Script from "next/script"; import "./globals.css"; +import BallCursor from "@/components/BallCursor"; // Display β€” ultra-condensed all-caps for the WC26 "tournament" impact. const display = Bebas_Neue({ @@ -74,6 +75,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) className={`${display.variable} ${sans.variable} ${mono.variable} ${dinCond.variable} ${dinBold.variable} ${dinMedium.variable} antialiased`} > + {children} `; + +async function main() { + const root = process.cwd(); + // --only key,key re-bakes a subset; metadata for the others is preserved. + const onlyArg = process.argv.indexOf("--only"); + const only = onlyArg >= 0 ? process.argv[onlyArg + 1].split(",") : null; + const assets = only ? ASSETS.filter((a) => only.includes(a.key)) : ASSETS; + const server = createServer(async (req, res) => { + const path = (req.url ?? "/").split("?")[0]; + if (path === "/__bake") { + res.writeHead(200, { "Content-Type": "text/html" }); + return res.end(PAGE); + } + try { + const body = await readFile(join(root, decodeURIComponent(path))); + res.writeHead(200, { "Content-Type": MIME[extname(path)] ?? "application/octet-stream" }); + res.end(body); + } catch { + res.writeHead(404); + res.end(); + } + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const { port } = server.address() as { port: number }; + + const browser = await chromium.launch(); + const page = await browser.newPage(); + page.on("pageerror", (e) => console.error("page error:", e.message)); + await page.goto(`http://127.0.0.1:${port}/__bake`); + + await mkdir(join(root, "public/awards"), { recursive: true }); + await mkdir(join(root, "data"), { recursive: true }); + const meta: Record = await readFile( + join(root, "data/award-sprites.json"), + "utf8", + ) + .then(JSON.parse) + .catch(() => ({})); + + for (const asset of assets) { + console.error(`baking ${asset.key} (${asset.frames} frames @ ${asset.size}px)...`); + const urls: string[] = await page.evaluate( + // @ts-expect-error window.bake is defined by the bake page + (a) => window.bake(a), + { model: asset.model, frames: asset.frames, size: asset.size, mode: asset.mode, lights: asset.lights, env: asset.env }, + ); + + const cols = Math.ceil(Math.sqrt(asset.frames)); + const rows = Math.ceil(asset.frames / cols); + const sheet = sharp({ + create: { width: cols * asset.size, height: rows * asset.size, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, + }).composite( + urls.map((u, i) => ({ + input: Buffer.from(u.split(",")[1], "base64"), + left: (i % cols) * asset.size, + top: Math.floor(i / cols) * asset.size, + })), + ); + const file = join(root, "public/awards", `${asset.key}.webp`); + // Write via tmp + rename: the dev server (or a preview) can hold a lock on + // the live file on Windows; rename retries ride it out. + const buf = await sheet.webp({ quality: 90, alphaQuality: 95, effort: 6 }).toBuffer(); + const { writeFileSync, renameSync, rmSync } = await import("node:fs").then((m) => m.default ?? m); + writeFileSync(file + ".tmp", buf); + let swapped = false; + for (let i = 0; i < 10 && !swapped; i++) { + try { + rmSync(file, { force: true }); + renameSync(file + ".tmp", file); + swapped = true; + } catch { + await new Promise((r) => setTimeout(r, 500)); + } + } + if (!swapped) console.error(` !! ${file} is locked β€” new bake left at ${file}.tmp`); + meta[asset.key] = { frames: asset.frames, cols, size: asset.size, fps: asset.fps }; + const { size: bytes } = await import("node:fs").then((fs) => fs.promises.stat(file)); + console.error(` -> ${file} (${Math.round(bytes / 1024)}KB)`); + } + + await writeFile(join(root, "data/award-sprites.json"), JSON.stringify(meta, null, 2) + "\n"); + await browser.close(); + server.close(); + console.error("done: data/award-sprites.json written"); +} + +main().catch((e: Error) => { + console.error(e.message); + process.exit(1); +}); diff --git a/scripts/distribution-runner.ts b/scripts/distribution-runner.ts index ebfbbb4..b5e591f 100644 --- a/scripts/distribution-runner.ts +++ b/scripts/distribution-runner.ts @@ -242,6 +242,7 @@ async function fetchPayload(login: string): Promise { recentRestricted: user.recent.restrictedContributionsCount, recentActiveDays, lifetimeContributions, + years: [], // per-year breakdown feeds awards, not scoring β€” the runner only scores }; } diff --git a/tests/awards.test.ts b/tests/awards.test.ts new file mode 100644 index 0000000..08ab6e7 --- /dev/null +++ b/tests/awards.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from "vitest"; +import { + computeAwards, + groupAwards, + wcBallCandidate, + yearScore, + type WcWindowTotals, +} from "@/lib/awards"; +import type { Card, YearBreakdown } from "@/lib/scoring/types"; + +// The awards engine is pure card+years -> instances; these lock the gates, the +// log physics, the self-relative Ballon d'Or selector and the era-adjusted +// World Cup rules. + +const year = (y: number, over: Partial = {}): YearBreakdown => ({ + year: y, + commits: 0, + prs: 0, + reviews: 0, + issues: 0, + restricted: 0, + ...over, +}); + +const card = (over: Partial = {}): Card => + ({ + overall: 85, + finish: "gold", + position: "CM", + country: "", + stats: { pac: 70, sho: 70, pas: 70, dri: 70, def: 70, phy: 70 }, + years: [], + ...over, + }) as Card; + +const keys = (c: Card, wc: WcWindowTotals[] = []) => computeAwards(c, wc).map((a) => a.key); + +describe("yearScore log physics", () => { + it("dampens volume: 10Γ— the commits is nowhere near 10Γ— the score", () => { + const small = yearScore(year(2020, { commits: 2000 })); + const spam = yearScore(year(2020, { commits: 20000 })); + expect(spam).toBeGreaterThan(small); + expect(spam / small).toBeLessThan(1.4); + }); + + it("rewards breadth: spread beats the same raw total on one axis", () => { + const spread = yearScore(year(2020, { commits: 800, prs: 80, reviews: 80, issues: 40 })); + const single = yearScore(year(2020, { commits: 1000 })); + expect(spread).toBeGreaterThan(single); + }); +}); + +describe("Ballon d'Or", () => { + // A career: quiet years around one towering year β€” the DembΓ©lΓ© shape. + const spikeYears = [ + year(2019, { commits: 300, issues: 10 }), + year(2020, { commits: 350, issues: 12 }), + year(2021, { commits: 4000, prs: 300, reviews: 400, issues: 150 }), + year(2022, { commits: 320, issues: 9 }), + ]; + + it("awards the spike year, attached to that year, with a reason naming rank and fronts", () => { + const got = computeAwards(card({ years: spikeYears })).filter((a) => a.key === "ballon_dor"); + expect(got).toHaveLength(1); + expect(got[0].year).toBe(2021); + expect(got[0].reason).toContain("Γ— the career-median season"); + expect(got[0].reason).toContain("spread over commits, PRs, reviews and issues"); + }); + + it("stays prestigious: a spiky but small year (under 2k contributions) wins nothing", () => { + // The mawsis case: real breadth, real spike, modest volume. + const modest = [ + year(2023, { commits: 150, issues: 20 }), + year(2024, { commits: 300, prs: 60, issues: 48 }), + year(2025, { commits: 200, issues: 15 }), + year(2026, { commits: 600, prs: 200, reviews: 80, issues: 39 }), + ]; + expect(keys(card({ years: modest }))).not.toContain("ballon_dor"); + }); + + it("gates on overall rating", () => { + expect(keys(card({ years: spikeYears, overall: 79 }))).not.toContain("ballon_dor"); + }); + + it("gives a flat career nothing β€” even a spammer's flat 20k-commit career", () => { + const flat = [2019, 2020, 2021, 2022].map((y) => year(y, { commits: 20000 })); + expect(keys(card({ years: flat }))).not.toContain("ballon_dor"); + }); + + // Escalating career: later years tower over the early median, every one of + // them a qualifying monster β€” the pool for the cap and repeat-ladder tests. + const escalating = [ + year(2016, { commits: 100 }), + year(2017, { commits: 120 }), + year(2018, { commits: 150 }), + year(2019, { commits: 4000, prs: 200, reviews: 300, issues: 100 }), + year(2020, { commits: 5000, prs: 250, reviews: 350, issues: 120 }), + year(2021, { commits: 6000, prs: 300, reviews: 400, issues: 140 }), + year(2022, { commits: 7000, prs: 350, reviews: 450, issues: 160 }), + year(2023, { commits: 8000, prs: 400, reviews: 500, issues: 180 }), + ]; + + it("repeat trophies demand a stronger profile: OVR 80 holds one, 87 two, 94 three", () => { + const count = (overall: number) => + computeAwards(card({ years: escalating, overall })).filter((a) => a.key === "ballon_dor") + .length; + expect(count(80)).toBe(1); + expect(count(86)).toBe(1); + expect(count(87)).toBe(2); + expect(count(93)).toBe(2); + expect(count(94)).toBe(3); + }); + + it("never exceeds the cap, and icons get one more", () => { + const gold = computeAwards(card({ years: escalating, finish: "gold", overall: 95 })); + expect(gold.filter((a) => a.key === "ballon_dor")).toHaveLength(3); + const icon = computeAwards(card({ years: escalating, finish: "icon", overall: 95 })); + expect(icon.filter((a) => a.key === "ballon_dor")).toHaveLength(4); + }); + + it("a commits-only legend still wins (no balance requirement)", () => { + const kernel = [ + year(2019, { commits: 900, restricted: 300 }), + year(2020, { commits: 950, restricted: 320 }), + year(2021, { commits: 4500, restricted: 2100 }), + year(2022, { commits: 1000, restricted: 350 }), + ]; + const got = computeAwards(card({ years: kernel })).filter((a) => a.key === "ballon_dor"); + expect(got.map((a) => a.year)).toEqual([2021]); + }); + + it("needs history: a single-year account gets none", () => { + expect(keys(card({ years: [year(2026, { commits: 9000, prs: 500 })] }))).not.toContain("ballon_dor"); + }); +}); + +describe("WC Golden Ball", () => { + const win = (edition: string, y: number, over: Partial = {}): WcWindowTotals => ({ + ...year(y, over), + edition, + }); + + it("2010: bare presence wins β€” being on GitHub then was the distinction", () => { + const c = card({ years: [year(2010, { commits: 60 }), year(2011, { commits: 80 })] }); + const got = computeAwards(c, [win("South Africa 2010", 2010, { commits: 5 })]); + expect(got.map((a) => a.edition)).toContain("South Africa 2010"); + }); + + it("2022: needs real tournament form, not routine pace", () => { + // 3650 contributions over the year = 10/day; the 29-day window must beat 1.3Γ—. + const years = [year(2022, { commits: 3650 })]; + const routine = computeAwards(card({ years }), [win("Qatar 2022", 2022, { commits: 300 })]); // ~10.3/day + expect(routine.map((a) => a.edition)).not.toContain("Qatar 2022"); + const onFire = computeAwards(card({ years }), [win("Qatar 2022", 2022, { commits: 500 })]); // ~17/day β‰ˆ 1.7Γ— + expect(onFire.map((a) => a.edition)).toContain("Qatar 2022"); + }); + + it("paces the current partial year over elapsed days, not 365", () => { + // 2026 by late July: ~2000 contributions in ~200 days β‰ˆ 10/day. The WC26 + // window at ~10/day is routine β€” a 365-day divisor would call it 1.9Γ— form. + const years = [year(2026, { commits: 2000 })]; + const win26 = win("North America 2026", 2026, { commits: 390 }); // 10/day, clears the floor + const now = new Date(Date.UTC(2026, 6, 21)); + const got = computeAwards(card({ years }), [win26], now); + expect(got.map((a) => a.edition)).not.toContain("North America 2026"); + }); + + it("modern editions without that year's breakdown are not judged", () => { + const got = computeAwards(card({ years: [] }), [win("Qatar 2022", 2022, { commits: 900 })]); + expect(got.map((a) => a.edition)).not.toContain("Qatar 2022"); + }); + + it("collects multiple editions", () => { + const c = card({ + years: [year(2010, { commits: 40 }), year(2022, { commits: 400 })], + }); + const got = computeAwards(c, [ + win("South Africa 2010", 2010, { commits: 6 }), + win("Qatar 2022", 2022, { commits: 330 }), // clears the floor, ~11/day vs ~1/day year pace + ]); + expect(got.filter((a) => a.key === "wc_golden_ball")).toHaveLength(2); + }); +}); + +describe("Golden Boot", () => { + const boot = (position: Card["position"], sho: number, overall = 85) => + card({ position, overall, stats: { pac: 70, sho, pas: 70, dri: 70, def: 70, phy: 70 } }); + + it("scoring posts only", () => { + expect(keys(boot("ST", 85))).toContain("golden_boot"); + expect(keys(boot("RW", 85))).toContain("golden_boot"); + expect(keys(boot("CAM", 85))).toContain("golden_boot"); + expect(keys(boot("CB", 99))).not.toContain("golden_boot"); + expect(keys(boot("CDM", 99))).not.toContain("golden_boot"); + }); + + it("needs elite shooting and the profile floor", () => { + expect(keys(boot("ST", 79))).not.toContain("golden_boot"); + expect(keys(boot("ST", 85, 79))).not.toContain("golden_boot"); + }); +}); + +describe("World Cup trophy (Spain gift)", () => { + it("every Spain-native profile lifts it, no other condition", () => { + expect(keys(card({ country: "es", overall: 55, finish: "bronze" }))).toContain("world_cup"); + }); + + it("everyone else does not", () => { + expect(keys(card({ country: "fr", overall: 99 }))).not.toContain("world_cup"); + expect(keys(card({ country: "" }))).not.toContain("world_cup"); + }); +}); + +describe("candidacy + grouping", () => { + it("wcBallCandidate is false below the floor or without WC-year activity", () => { + expect(wcBallCandidate(card({ overall: 79, years: [year(2022, { commits: 100 })] }))).toBe(false); + expect(wcBallCandidate(card({ years: [year(2020, { commits: 100 })] }))).toBe(false); + expect(wcBallCandidate(card({ years: [year(2022, { commits: 100 })] }))).toBe(true); + }); + + it("groups instances per award type in shelf order", () => { + const groups = groupAwards([ + { key: "golden_boot", reason: "r" }, + { key: "ballon_dor", year: 2020, reason: "r" }, + { key: "ballon_dor", year: 2021, reason: "r" }, + ]); + expect(groups.map((g) => g.key)).toEqual(["ballon_dor", "golden_boot"]); + expect(groups[0].instances).toHaveLength(2); + }); +}); diff --git a/tests/client.test.ts b/tests/client.test.ts index eff7898..8893a12 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -15,7 +15,7 @@ vi.mock("@/lib/redis", () => ({ }, })); -import { fetchProfile } from "@/lib/github/client"; +import { fetchProfile, fetchWindows } from "@/lib/github/client"; import { hashLogin } from "@/lib/github/tokens"; const POOL = ["tokA", "tokB", "tokC", "tokD"]; @@ -222,6 +222,39 @@ describe("fetchProfile request timeout", () => { }); }); +describe("fetchWindows (WC tournament periods)", () => { + const W = { totalCommitContributions: 7, totalIssueContributions: 1, totalPullRequestContributions: 2, totalPullRequestReviewContributions: 1, restrictedContributionsCount: 3 }; + const windows = [ + { id: "Qatar 2022", from: "2022-11-20T00:00:00Z", to: "2022-12-18T23:59:59Z" }, + { id: "North America 2026", from: "2026-06-11T00:00:00Z", to: "2026-07-19T23:59:59Z" }, + ]; + + it("fetches all windows in one aliased request, keyed back by id", async () => { + scriptFetch((_t, body) => + body.includes("query Windows(") ? ok({ data: { user: { w0: W, w1: W } } }) : okFor(body), + ); + const got = await fetchWindows(LOGIN, windows); + expect(calls.filter((c) => c.body.includes("query Windows(")).length).toBe(1); + expect(got["Qatar 2022"]).toMatchObject({ commits: 7, prs: 2, reviews: 1, issues: 1, restricted: 3 }); + expect(got["North America 2026"]).toBeDefined(); + }); + + it("retries windows individually when the pooled request is resource-rejected", async () => { + let first = true; + scriptFetch((_t, body) => { + if (!body.includes("query Windows(")) return okFor(body); + if (first) { + first = false; + return ok({ data: { user: null }, errors: [{ type: "RESOURCE_LIMITS_EXCEEDED", message: "too heavy" }] }); + } + return ok({ data: { user: { w0: W } } }); + }); + const got = await fetchWindows(LOGIN, windows); + expect(calls.filter((c) => c.body.includes("query Windows(")).length).toBe(3); // 1 pooled + 2 singles + expect(Object.keys(got)).toHaveLength(2); + }); +}); + describe("fetchProfile GraphQL error triage + resource-limit fallback", () => { // Basics = the USER node minus its contributions block, what basicsQuery returns. const BASICS = Object.fromEntries(Object.entries(USER).filter(([k]) => k !== "recent")); @@ -381,6 +414,9 @@ describe("fetchProfile GraphQL error triage + resource-limit fallback", () => { // createdAt 2023 -> years 2023..2026: 1 failed batch + 4 single-year retries. expect(calls.filter((c) => c.body.includes("query Lifetime(")).length).toBe(5); expect(payload.lifetimeContributions).toBe(10 * 4); + // The per-year breakdown behind it survives the retry, oldest first. + expect(payload.years.map((y) => y.year)).toEqual([2023, 2024, 2025, 2026]); + expect(payload.years[0]).toMatchObject({ commits: 5, prs: 2, reviews: 1, issues: 1, restricted: 1 }); }); }); diff --git a/tests/signals.test.ts b/tests/signals.test.ts index 646d934..cd1591d 100644 --- a/tests/signals.test.ts +++ b/tests/signals.test.ts @@ -32,6 +32,7 @@ const payload = (over: Partial = {}): RawPayload => ({ recentRestricted: 0, recentActiveDays: 0, lifetimeContributions: 0, + years: [], ...over, });