From 84dfe6a54bd80f7a182bb29ce77eb9a02893df81 Mon Sep 17 00:00:00 2001 From: Joshua Oosthuizen Date: Sat, 3 Oct 2026 13:33:21 +0200 Subject: [PATCH] Phase 2: scoring engine with full branch coverage Pure functions in src/lib/scoring implementing docs/SCORING.md: cell values (override > judge average), weighted raw totals, raw and placement points with tie averaging, and competition-ranked standings. Coverage thresholds are enforced at 100% for the module. Also silences the hydration warning caused by browser extensions modifying . Co-Authored-By: Claude Opus 5.5 --- docs/SCORING.md | 3 +- package.json | 3 +- src/app/layout.tsx | 7 +- src/lib/scoring/cell.ts | 38 ++++ src/lib/scoring/game.ts | 93 ++++++++ src/lib/scoring/index.ts | 20 ++ src/lib/scoring/round.ts | 7 + src/lib/scoring/scoring.test.ts | 371 ++++++++++++++++++++++++++++++++ src/lib/scoring/standings.ts | 45 ++++ src/lib/scoring/types.ts | 84 ++++++++ vitest.config.mts | 2 + 11 files changed, 670 insertions(+), 3 deletions(-) create mode 100644 src/lib/scoring/cell.ts create mode 100644 src/lib/scoring/game.ts create mode 100644 src/lib/scoring/index.ts create mode 100644 src/lib/scoring/round.ts create mode 100644 src/lib/scoring/scoring.test.ts create mode 100644 src/lib/scoring/standings.ts create mode 100644 src/lib/scoring/types.ts diff --git a/docs/SCORING.md b/docs/SCORING.md index 7083b68..a7c1eee 100644 --- a/docs/SCORING.md +++ b/docs/SCORING.md @@ -3,7 +3,7 @@ All implemented in `src/lib/scoring/` as pure functions. Tests must cover every rule below. ## 1. Cell value (team × criterion) -- **Direct game:** the admin score row. Missing → not scored. +- **Direct game:** the admin score row. Missing → not scored. Judge rows (e.g. left over from a mode switch) are ignored. - **Judged game:** - If an admin override row exists → use it. - Else average of non-revoked judges' values for that cell. Missing judges are ignored (not zero). @@ -12,6 +12,7 @@ All implemented in `src/lib/scoring/` as pure functions. Tests must cover every ## 2. Team raw score for a game `raw = Σ (cellValue × criterion.weight)` over scored criteria. A team with zero scored cells is **unscored** for that game (not 0). +`raw` is rounded to 6dp before ranking so float noise from averaging can't break a real tie. ## 3. Game points - **raw mode:** `points = round2(raw × multiplier)`. diff --git a/package.json b/package.json index 92be7ea..0bd990f 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "test": "vitest run", "test:watch": "vitest", "check:rls": "node --env-file=.env.local scripts/check-rls.mjs", - "gen:types": "node --env-file=.env.local scripts/gen-types.mjs" + "gen:types": "node --env-file=.env.local scripts/gen-types.mjs", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@supabase/ssr": "^0.12.7", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f01ce4d..c2ab1d3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -26,7 +26,12 @@ export const metadata: Metadata = { export default function RootLayout({ children }: LayoutProps<"/">) { return ( - + // suppressHydrationWarning: browser extensions add attributes to before React loads. + {children} ); diff --git a/src/lib/scoring/cell.ts b/src/lib/scoring/cell.ts new file mode 100644 index 0000000..1bd66fa --- /dev/null +++ b/src/lib/scoring/cell.ts @@ -0,0 +1,38 @@ +import type { CellResult, ScoreInput, ScoringMode } from "./types"; + +/** + * SCORING.md §1: the value of one team × criterion cell. + * `cellScores` are all score rows for that cell; `activeJudgeIds` are the game's non-revoked judges. + */ +export function computeCell( + mode: ScoringMode, + cellScores: ScoreInput[], + activeJudgeIds: ReadonlySet, +): CellResult { + const adminRow = cellScores.find((score) => score.judgeId === null); + + if (mode === "direct") { + // Judge rows are ignored in direct games (e.g. left over from a mode switch). + return adminRow + ? { value: adminRow.value, source: "admin", judgeAverage: null, judgeCount: 0 } + : { value: null, source: "none", judgeAverage: null, judgeCount: 0 }; + } + + // Missing judges are ignored, not counted as zero. + const judgeValues = cellScores + .filter((score) => score.judgeId !== null && activeJudgeIds.has(score.judgeId)) + .map((score) => score.value); + const judgeCount = judgeValues.length; + const judgeAverage = + judgeCount > 0 ? judgeValues.reduce((sum, value) => sum + value, 0) / judgeCount : null; + + if (adminRow) { + return { value: adminRow.value, source: "override", judgeAverage, judgeCount }; + } + return { + value: judgeAverage, + source: judgeAverage === null ? "none" : "judges", + judgeAverage, + judgeCount, + }; +} diff --git a/src/lib/scoring/game.ts b/src/lib/scoring/game.ts new file mode 100644 index 0000000..87f3ceb --- /dev/null +++ b/src/lib/scoring/game.ts @@ -0,0 +1,93 @@ +import { computeCell } from "./cell"; +import { round, round2 } from "./round"; +import type { + CellResult, + CriterionInput, + GameInput, + GameResult, + JudgeInput, + ScoreInput, + TeamGameResult, + TeamInput, +} from "./types"; + +const cellKey = (teamId: string, criterionId: string) => `${teamId}|${criterionId}`; + +/** SCORING.md §1–3 for one game. Pass the whole event's rows; they are filtered by game here. */ +export function computeGame( + game: GameInput, + teams: TeamInput[], + criteria: CriterionInput[], + judges: JudgeInput[], + scores: ScoreInput[], +): GameResult { + const gameCriteria = criteria.filter((criterion) => criterion.gameId === game.id); + const activeJudgeIds = new Set( + judges.filter((judge) => judge.gameId === game.id && !judge.revoked).map((judge) => judge.id), + ); + + const scoresByCell = new Map(); + for (const score of scores) { + if (score.gameId !== game.id) continue; + const key = cellKey(score.teamId, score.criterionId); + const rows = scoresByCell.get(key); + if (rows) rows.push(score); + else scoresByCell.set(key, [score]); + } + + const results: TeamGameResult[] = teams.map((team) => { + const cells: Record = {}; + let weighted = 0; + let scoredCells = 0; + for (const criterion of gameCriteria) { + const cell = computeCell( + game.scoringMode, + scoresByCell.get(cellKey(team.id, criterion.id)) ?? [], + activeJudgeIds, + ); + cells[criterion.id] = cell; + if (cell.value !== null) { + weighted += cell.value * criterion.weight; + scoredCells += 1; + } + } + // Rounded to 6dp so float noise from averaging can't break a real tie. + // Zero scored cells means unscored, which is not the same as scoring 0. + const raw = scoredCells > 0 ? round(weighted, 6) : null; + return { teamId: team.id, cells, raw, placement: null, points: 0 }; + }); + + const scored = results + .filter((result): result is TeamGameResult & { raw: number } => result.raw !== null) + .sort((a, b) => b.raw - a.raw); + + // Walk groups of tied teams. Each group shares a rank and, in placement mode, + // the average of the placement points for the places it spans. + let start = 0; + while (start < scored.length) { + let end = start; + while (end + 1 < scored.length && scored[end + 1].raw === scored[start].raw) end += 1; + + let spannedPoints = 0; + for (let place = start; place <= end; place += 1) { + spannedPoints += game.placementPoints[place] ?? 0; + } + const sharedPoints = round2(spannedPoints / (end - start + 1)); + + for (let index = start; index <= end; index += 1) { + const result = scored[index]; + result.placement = start + 1; + result.points = + game.pointsMode === "placement" + ? round2(sharedPoints * game.multiplier) + : round2(result.raw * game.multiplier); + } + start = end + 1; + } + + return { + gameId: game.id, + countsTowardTotal: game.status !== "upcoming", + teams: Object.fromEntries(results.map((result) => [result.teamId, result])), + }; +} diff --git a/src/lib/scoring/index.ts b/src/lib/scoring/index.ts new file mode 100644 index 0000000..5d1f55d --- /dev/null +++ b/src/lib/scoring/index.ts @@ -0,0 +1,20 @@ +import { computeGame } from "./game"; +import { computeStandings } from "./standings"; +import type { EventResult, ScoringInput } from "./types"; + +export { computeCell } from "./cell"; +export { computeGame } from "./game"; +export { round2 } from "./round"; +export { computeStandings } from "./standings"; +export type * from "./types"; + +/** The one entry point: admin view, scoreboard API and judge view all call this. */ +export function computeEvent(input: ScoringInput): EventResult { + const gameResults = input.games.map((game) => + computeGame(game, input.teams, input.criteria, input.judges, input.scores), + ); + return { + games: Object.fromEntries(gameResults.map((result) => [result.gameId, result])), + standings: computeStandings(input.teams, gameResults, input.adjustments), + }; +} diff --git a/src/lib/scoring/round.ts b/src/lib/scoring/round.ts new file mode 100644 index 0000000..5fa943c --- /dev/null +++ b/src/lib/scoring/round.ts @@ -0,0 +1,7 @@ +/** Rounds to `places` decimals, nudging past float error so 1.005 rounds to 1.01. */ +export function round(value: number, places: number): number { + const factor = 10 ** places; + return Math.round((value + Math.sign(value) * Number.EPSILON) * factor) / factor; +} + +export const round2 = (value: number) => round(value, 2); diff --git a/src/lib/scoring/scoring.test.ts b/src/lib/scoring/scoring.test.ts new file mode 100644 index 0000000..af75b98 --- /dev/null +++ b/src/lib/scoring/scoring.test.ts @@ -0,0 +1,371 @@ +import { describe, expect, it } from "vitest"; + +import { computeEvent, computeGame, round2 } from "./index"; +import type { CriterionInput, GameInput, JudgeInput, ScoreInput, ScoringInput, TeamInput } from "./index"; + +// ---------- builders ---------- +const teams = (...ids: string[]): TeamInput[] => ids.map((id, index) => ({ id, sortOrder: index })); + +const game = (overrides: Partial = {}): GameInput => ({ + id: "g1", + scoringMode: "direct", + pointsMode: "raw", + multiplier: 1, + placementPoints: [10, 7, 5, 3], + status: "live", + ...overrides, +}); + +const criterion = (id: string, weight = 1, gameId = "g1"): CriterionInput => ({ id, gameId, weight }); + +const judge = (id: string, revoked = false, gameId = "g1"): JudgeInput => ({ id, gameId, revoked }); + +const score = ( + teamId: string, + criterionId: string, + value: number, + judgeId: string | null = null, + gameId = "g1", +): ScoreInput => ({ gameId, teamId, criterionId, judgeId, value }); + +const event = (overrides: Partial): ScoringInput => ({ + teams: [], + games: [], + criteria: [], + judges: [], + scores: [], + adjustments: [], + ...overrides, +}); + +/** One direct game with a single criterion "c" and the given team → value map. */ +const directGame = (values: Record, overrides: Partial = {}) => + computeGame( + game(overrides), + teams(...Object.keys(values)), + [criterion("c")], + [], + Object.entries(values) + .filter(([, value]) => !Number.isNaN(value)) + .map(([teamId, value]) => score(teamId, "c", value)), + ); + +const points = (result: ReturnType) => + Object.fromEntries(Object.values(result.teams).map((team) => [team.teamId, team.points])); + +// ---------- SCORING.md minimum cases ---------- +describe("SCORING.md test cases", () => { + it("1. Karaoke: a judge missing a team is ignored, not counted as zero", () => { + const karaoke = ["pitch", "energy", "lyrics", "stage", "crowd"]; + const scores = karaoke.flatMap((c) => [ + score("lions", c, 4, "j1"), + score("lions", c, 5, "j2"), // j3 never scored Lions + score("eagles", c, 3, "j1"), + score("eagles", c, 4, "j2"), + score("eagles", c, 5, "j3"), + ]); + const result = computeGame( + game({ scoringMode: "judged" }), + teams("lions", "eagles"), + karaoke.map((c) => criterion(c)), + [judge("j1"), judge("j2"), judge("j3")], + scores, + ); + + expect(result.teams.lions.cells.pitch).toEqual({ + value: 4.5, + source: "judges", + judgeAverage: 4.5, + judgeCount: 2, + }); + expect(result.teams.lions.raw).toBe(22.5); + expect(result.teams.eagles.cells.pitch.judgeCount).toBe(3); + expect(result.teams.eagles.raw).toBe(20); + expect(points(result)).toEqual({ lions: 22.5, eagles: 20 }); + }); + + it("2. An admin override beats the judge average", () => { + const result = computeGame( + game({ scoringMode: "judged" }), + teams("a"), + [criterion("c")], + [judge("j1"), judge("j2")], + [score("a", "c", 2, "j1"), score("a", "c", 4, "j2"), score("a", "c", 5)], + ); + expect(result.teams.a.cells.c).toEqual({ + value: 5, + source: "override", + judgeAverage: 3, + judgeCount: 2, + }); + expect(result.teams.a.raw).toBe(5); + }); + + it("3. A revoked judge is excluded", () => { + const result = computeGame( + game({ scoringMode: "judged" }), + teams("a", "b"), + [criterion("c")], + [judge("j1"), judge("j2", true)], + [score("a", "c", 5, "j1"), score("a", "c", 1, "j2"), score("b", "c", 4, "j2")], + ); + expect(result.teams.a.cells.c.value).toBe(5); + expect(result.teams.a.cells.c.judgeCount).toBe(1); + // Team b was only scored by the revoked judge, so it is unscored. + expect(result.teams.b.cells.c).toEqual({ + value: null, + source: "none", + judgeAverage: null, + judgeCount: 0, + }); + expect(result.teams.b.raw).toBeNull(); + }); + + it("4. Weights are applied", () => { + const result = computeGame( + game(), + teams("a"), + [criterion("c1", 2), criterion("c2", 0.5)], + [], + [score("a", "c1", 3), score("a", "c2", 4)], + ); + expect(result.teams.a.raw).toBe(8); + }); + + it("5. Placement: a 2-way tie at 1st shares the average of 1st and 2nd", () => { + const result = directGame( + { a: 9, b: 9, c: 4 }, + { pointsMode: "placement", placementPoints: [10, 7, 5] }, + ); + expect(points(result)).toEqual({ a: 8.5, b: 8.5, c: 5 }); + expect(result.teams.a.placement).toBe(1); + expect(result.teams.b.placement).toBe(1); + expect(result.teams.c.placement).toBe(3); + }); + + it("6. Placement: teams beyond placement_points get 0", () => { + const result = directGame({ a: 5, b: 4, c: 3, d: 2, e: 1 }, { pointsMode: "placement" }); + expect(points(result)).toEqual({ a: 10, b: 7, c: 5, d: 3, e: 0 }); + }); + + it("7. The multiplier applies in both modes", () => { + expect(points(directGame({ a: 8 }, { multiplier: 1.5 }))).toEqual({ a: 12 }); + expect( + points(directGame({ a: 9, b: 9, c: 4 }, { pointsMode: "placement", multiplier: 2 })), + ).toEqual({ a: 17, b: 17, c: 10 }); + }); + + it("8. An upcoming game is ignored in totals but still computed for preview", () => { + const result = computeEvent( + event({ + teams: teams("a"), + games: [game({ id: "g1", status: "done" }), game({ id: "g2", status: "upcoming" })], + criteria: [criterion("c1", 1, "g1"), criterion("c2", 1, "g2")], + scores: [score("a", "c1", 6, null, "g1"), score("a", "c2", 9, null, "g2")], + }), + ); + expect(result.games.g2.countsTowardTotal).toBe(false); + expect(result.games.g2.teams.a.points).toBe(9); + expect(result.standings[0]).toMatchObject({ + total: 6, + gamePoints: 6, + perGame: [{ gameId: "g1", points: 6 }], + }); + }); + + it("9. Adjustments (+ and −) are added, even when the only game is upcoming", () => { + const result = computeEvent( + event({ + teams: teams("a", "b"), + games: [game({ status: "upcoming" })], + criteria: [criterion("c")], + scores: [score("a", "c", 9)], + adjustments: [ + { teamId: "a", points: 5 }, + { teamId: "a", points: -2 }, + { teamId: "b", points: -1.5 }, + ], + }), + ); + expect(result.standings).toMatchObject([ + { teamId: "a", total: 3, gamePoints: 0, adjustmentPoints: 3 }, + { teamId: "b", total: -1.5, adjustmentPoints: -1.5 }, + ]); + }); + + it("10. Tied totals share a rank (1, 1, 3) and display in team sort order", () => { + const result = computeEvent( + event({ + teams: [ + { id: "a", sortOrder: 2 }, + { id: "b", sortOrder: 1 }, + { id: "c", sortOrder: 0 }, + ], + games: [game()], + criteria: [criterion("c")], + scores: [score("a", "c", 10), score("b", "c", 10), score("c", "c", 5)], + }), + ); + expect(result.standings.map(({ teamId, rank }) => [teamId, rank])).toEqual([ + ["b", 1], + ["a", 1], + ["c", 3], + ]); + }); +}); + +// ---------- edge cases ---------- +describe("cells", () => { + it("direct game: missing admin row is not scored, and judge rows are ignored", () => { + const result = computeGame( + game(), + teams("a", "b"), + [criterion("c")], + [judge("j1")], + [score("a", "c", 3), score("a", "c", 5, "j1"), score("b", "c", 5, "j1")], + ); + expect(result.teams.a.cells.c).toEqual({ + value: 3, + source: "admin", + judgeAverage: null, + judgeCount: 0, + }); + expect(result.teams.b.cells.c.source).toBe("none"); + expect(result.teams.b.raw).toBeNull(); + }); + + it("judged game: ignores scores from unknown judges and judges of other games", () => { + const result = computeGame( + game({ scoringMode: "judged" }), + teams("a"), + [criterion("c")], + [judge("j1"), judge("other", false, "g2")], + [score("a", "c", 4, "j1"), score("a", "c", 1, "other"), score("a", "c", 1, "ghost")], + ); + expect(result.teams.a.cells.c.value).toBe(4); + }); + + it("an override with no judge scores still counts", () => { + const result = computeGame( + game({ scoringMode: "judged" }), + teams("a"), + [criterion("c")], + [], + [score("a", "c", 2)], + ); + expect(result.teams.a.cells.c).toEqual({ + value: 2, + source: "override", + judgeAverage: null, + judgeCount: 0, + }); + }); +}); + +describe("game points", () => { + it("only sums scored criteria, and ignores scores from other games", () => { + const result = computeGame( + game(), + teams("a"), + [criterion("c1"), criterion("c2"), criterion("x", 1, "g2")], + [], + [score("a", "c1", 3), score("a", "x", 100, null, "g2")], + ); + expect(result.teams.a.raw).toBe(3); + expect(Object.keys(result.teams.a.cells)).toEqual(["c1", "c2"]); + }); + + it("a real score of 0 is scored (ranked), unlike an unscored team", () => { + const result = directGame({ a: 0, b: Number.NaN }, { pointsMode: "placement" }); + expect(result.teams.a).toMatchObject({ raw: 0, placement: 1, points: 10 }); + expect(result.teams.b).toMatchObject({ raw: null, placement: null, points: 0 }); + }); + + it("a tie that spans the end of placement_points shares what is left", () => { + const result = directGame( + { a: 5, b: 3, c: 3 }, + { pointsMode: "placement", placementPoints: [10, 7] }, + ); + expect(points(result)).toEqual({ a: 10, b: 3.5, c: 3.5 }); + }); + + it("a 3-way tie rounds the shared points to 2dp", () => { + const result = directGame( + { a: 1, b: 1, c: 1 }, + { pointsMode: "placement", placementPoints: [10, 7, 5] }, + ); + expect(points(result)).toEqual({ a: 7.33, b: 7.33, c: 7.33 }); + }); + + it("raw mode rounds points to 2dp", () => { + const result = computeGame( + game({ scoringMode: "judged" }), + teams("a"), + [criterion("c")], + [judge("j1"), judge("j2"), judge("j3")], + [score("a", "c", 3, "j1"), score("a", "c", 3, "j2"), score("a", "c", 4, "j3")], + ); + expect(result.teams.a.points).toBe(3.33); + }); + + it("float noise from averaging does not break a real tie", () => { + // 1.1 + 2.2 + 3.3 and 3.3 + 2.2 + 1.1 differ in the last float digit. + const result = computeGame( + game({ scoringMode: "judged", pointsMode: "placement" }), + teams("a", "b"), + [criterion("c")], + [judge("j1"), judge("j2"), judge("j3")], + [ + score("a", "c", 1.1, "j1"), + score("a", "c", 2.2, "j2"), + score("a", "c", 3.3, "j3"), + score("b", "c", 3.3, "j1"), + score("b", "c", 2.2, "j2"), + score("b", "c", 1.1, "j3"), + ], + ); + expect(points(result)).toEqual({ a: 8.5, b: 8.5 }); + }); + + it("a game with no criteria leaves every team unscored", () => { + const result = computeGame(game(), teams("a"), [], [], []); + expect(result.teams.a).toMatchObject({ raw: null, points: 0, cells: {} }); + }); +}); + +describe("event", () => { + it("handles an empty event", () => { + expect(computeEvent(event({}))).toEqual({ games: {}, standings: [] }); + }); + + it("sums several games and gives every team rank 1 before anything is scored", () => { + const empty = computeEvent(event({ teams: teams("a", "b"), games: [game()] })); + expect(empty.standings.map((standing) => standing.rank)).toEqual([1, 1]); + + const result = computeEvent( + event({ + teams: teams("a", "b"), + games: [game({ id: "g1" }), game({ id: "g2", pointsMode: "placement", status: "done" })], + criteria: [criterion("c1", 1, "g1"), criterion("c2", 1, "g2")], + scores: [ + score("a", "c1", 4, null, "g1"), + score("b", "c1", 6, null, "g1"), + score("a", "c2", 9, null, "g2"), + score("b", "c2", 2, null, "g2"), + ], + }), + ); + expect(result.standings).toMatchObject([ + { teamId: "a", rank: 1, total: 14 }, + { teamId: "b", rank: 2, total: 13 }, + ]); + }); +}); + +describe("round2", () => { + it("rounds half up despite float representation", () => { + expect(round2(1.005)).toBe(1.01); + expect(round2(-1.005)).toBe(-1.01); + expect(round2(0)).toBe(0); + }); +}); diff --git a/src/lib/scoring/standings.ts b/src/lib/scoring/standings.ts new file mode 100644 index 0000000..4c4bba6 --- /dev/null +++ b/src/lib/scoring/standings.ts @@ -0,0 +1,45 @@ +import { round2 } from "./round"; +import type { AdjustmentInput, GameResult, Standing, TeamInput } from "./types"; + +/** SCORING.md §4–5: event totals and ranked standings. */ +export function computeStandings( + teams: TeamInput[], + gameResults: GameResult[], + adjustments: AdjustmentInput[], +): Standing[] { + const counted = gameResults.filter((result) => result.countsTowardTotal); + const sortOrder = new Map(teams.map((team) => [team.id, team.sortOrder])); + + const standings: Standing[] = teams.map((team) => { + const perGame = counted.map((result) => ({ + gameId: result.gameId, + points: result.teams[team.id].points, + })); + const gamePoints = round2(perGame.reduce((sum, game) => sum + game.points, 0)); + // Adjustments always count, even when tied to an upcoming game: they are explicit. + const adjustmentPoints = round2( + adjustments + .filter((adjustment) => adjustment.teamId === team.id) + .reduce((sum, adjustment) => sum + adjustment.points, 0), + ); + return { + teamId: team.id, + rank: 0, + total: round2(gamePoints + adjustmentPoints), + gamePoints, + adjustmentPoints, + perGame, + }; + }); + + standings.sort( + (a, b) => b.total - a.total || sortOrder.get(a.teamId)! - sortOrder.get(b.teamId)!, + ); + + // Standard competition ranking (1, 1, 3): a team's rank is the position of the + // first team with the same total. + for (const standing of standings) { + standing.rank = standings.findIndex((other) => other.total === standing.total) + 1; + } + return standings; +} diff --git a/src/lib/scoring/types.ts b/src/lib/scoring/types.ts new file mode 100644 index 0000000..dca1ebb --- /dev/null +++ b/src/lib/scoring/types.ts @@ -0,0 +1,84 @@ +// Plain-data input and output of the scoring engine. No DB types here on purpose: +// loaders map rows into these shapes, so the engine stays pure and easy to test. + +export type ScoringMode = "judged" | "direct"; +export type PointsMode = "raw" | "placement"; +export type GameStatus = "upcoming" | "live" | "done"; + +export type TeamInput = { id: string; sortOrder: number }; + +export type GameInput = { + id: string; + scoringMode: ScoringMode; + pointsMode: PointsMode; + multiplier: number; + placementPoints: number[]; + status: GameStatus; +}; + +export type CriterionInput = { id: string; gameId: string; weight: number }; + +export type JudgeInput = { id: string; gameId: string; revoked: boolean }; + +export type ScoreInput = { + gameId: string; + teamId: string; + criterionId: string; + /** null = admin row: the score itself in a direct game, an override in a judged game. */ + judgeId: string | null; + value: number; +}; + +export type AdjustmentInput = { teamId: string; points: number }; + +export type ScoringInput = { + teams: TeamInput[]; + games: GameInput[]; + criteria: CriterionInput[]; + judges: JudgeInput[]; + scores: ScoreInput[]; + adjustments: AdjustmentInput[]; +}; + +export type CellSource = "admin" | "override" | "judges" | "none"; + +export type CellResult = { + /** The value that counts, or null when the cell is not scored. */ + value: number | null; + source: CellSource; + /** Average of non-revoked judges, kept even when an override wins so admin can compare. */ + judgeAverage: number | null; + judgeCount: number; +}; + +export type TeamGameResult = { + teamId: string; + cells: Record; + /** Weighted criteria total, or null when the team has no scored cells. */ + raw: number | null; + /** Rank within this game (ties share a rank), or null when unscored. */ + placement: number | null; + points: number; +}; + +export type GameResult = { + gameId: string; + /** False for upcoming games: results are computed for preview but excluded from totals. */ + countsTowardTotal: boolean; + teams: Record; +}; + +export type Standing = { + teamId: string; + rank: number; + total: number; + gamePoints: number; + adjustmentPoints: number; + perGame: Array<{ gameId: string; points: number }>; +}; + +export type EventResult = { + games: Record; + /** Sorted for display: total desc, ties in team sort order. */ + standings: Standing[]; +}; diff --git a/vitest.config.mts b/vitest.config.mts index fc74ab5..3794e35 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -12,6 +12,8 @@ export default defineConfig({ coverage: { provider: "v8", include: ["src/lib/scoring/**"], + // The scoring engine decides who wins: every branch must be tested. + thresholds: { branches: 100, functions: 100, lines: 100, statements: 100 }, }, }, });