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 },
},
},
});