diff --git a/api/app.cjs b/api/app.cjs index 1e77f13..493ffd6 100644 --- a/api/app.cjs +++ b/api/app.cjs @@ -1919,6 +1919,11 @@ async function getScoringContext(tournamentId) { id: match.id, winner: match.winner, round: match.round.name, + // Carry the actual participants so the scoring engine can apply the + // skip rule for matches whose seeding turned out different from the + // user's predicted group placement (see api/scoring.cjs). + selectedHomeTeamId: match.selectedHomeTeamId || null, + selectedAwayTeamId: match.selectedAwayTeamId || null, })); return { @@ -1942,6 +1947,10 @@ function serializeLeaderboardPlayer(user, score) { groupScore: score.groupScore, knockoutScore: score.knockoutScore, totalScore: score.totalScore, + // Surfaced so UIs can compute a per-user effective max + // (tournament.rules.totalMaximumPoints - forfeitedKnockoutPoints). + // Older Score rows that predate this column read back as 0. + forfeitedKnockoutPoints: score.forfeitedKnockoutPoints || 0, roundScores: score.roundBreakdown, }; } @@ -1952,12 +1961,14 @@ function serializeGlobalLeaderboardPlayer(user, scores = []) { acc.groupScore += score.groupScore || 0; acc.knockoutScore += score.knockoutScore || 0; acc.totalScore += score.totalScore || 0; + acc.forfeitedKnockoutPoints += score.forfeitedKnockoutPoints || 0; return acc; }, { groupScore: 0, knockoutScore: 0, totalScore: 0, + forfeitedKnockoutPoints: 0, } ); @@ -1970,6 +1981,7 @@ function serializeGlobalLeaderboardPlayer(user, scores = []) { groupScore: totals.groupScore, knockoutScore: totals.knockoutScore, totalScore: totals.totalScore, + forfeitedKnockoutPoints: totals.forfeitedKnockoutPoints, }; } @@ -2054,6 +2066,7 @@ async function calculateGlobalLeaderboard() { groupScore: true, knockoutScore: true, totalScore: true, + forfeitedKnockoutPoints: true, }, }, }, @@ -2138,6 +2151,7 @@ async function persistScopeScores(tournamentId, scoringContext, options = {}) { groupScore: score.groupScore, knockoutScore: score.knockoutScore, totalScore: score.totalScore, + forfeitedKnockoutPoints: score.forfeitedKnockoutPoints || 0, }, create: { userId: user.id, @@ -2146,6 +2160,7 @@ async function persistScopeScores(tournamentId, scoringContext, options = {}) { groupScore: score.groupScore, knockoutScore: score.knockoutScore, totalScore: score.totalScore, + forfeitedKnockoutPoints: score.forfeitedKnockoutPoints || 0, }, }); }); diff --git a/api/scoring.cjs b/api/scoring.cjs index 33d3b47..ea200de 100644 --- a/api/scoring.cjs +++ b/api/scoring.cjs @@ -11,6 +11,15 @@ * Knockout Rounds: * - Points are driven by the tournament's round configuration * - For the classic Prode mode, rounds increase linearly by +2 + * - A knockout match is *unscoreable* when the user's stored + * `predictedWinner` is a team that did not actually play in that + * match (typically because their upstream group placement turned + * out wrong and corrected seeding routed different teams into the + * slot). Unscoreable matches add zero to the user's earned score + * AND zero to their maximum-possible score — the user is neither + * rewarded nor penalised for a pick they could not have made + * correctly. The forfeited points are tracked separately so + * callers can report a per-user effective max. */ /** @@ -27,43 +36,63 @@ function scoreGroupPrediction(prediction, result) { const { first: predFirst, second: predSecond } = prediction; const { first: resultFirst, second: resultSecond } = result; - // Both teams in correct positions if (predFirst === resultFirst && predSecond === resultSecond) { return 4; } - // Both teams correct but inverted if (predFirst === resultSecond && predSecond === resultFirst) { return 3; } - // One team in correct position if (predFirst === resultFirst || predSecond === resultSecond) { return 2; } - // One team correct but wrong position if (predFirst === resultSecond || predSecond === resultFirst) { return 1; } - // No points return 0; } /** - * Calculate score for a knockout match prediction + * Calculate score for a knockout match prediction. + * + * Returns an object so callers can distinguish "wrong pick" (score 0, + * scoreable true, maxPoints = pointsPerCorrect) from "skipped because + * the picked team did not even play" (score 0, scoreable false, + * maxPoints 0). + * + * `actualParticipants` is optional. When omitted (or empty), the + * scoring falls back to the legacy literal-compare behaviour and + * treats the match as scoreable; this keeps tests and any caller that + * does not have participant data working. + * * @param {string} predictedWinner - Predicted winner team ID * @param {string} actualWinner - Actual winner team ID * @param {number} pointsPerCorrect - Points for correct prediction in this round - * @returns {number} Points earned (0 or pointsPerCorrect) + * @param {Array} [actualParticipants] - Team IDs of the two teams that actually played the match + * @returns {{ score: number, scoreable: boolean, maxPoints: number }} */ -function scoreKnockoutPrediction(predictedWinner, actualWinner, pointsPerCorrect) { +function scoreKnockoutPrediction(predictedWinner, actualWinner, pointsPerCorrect, actualParticipants) { if (!predictedWinner || !actualWinner) { - return 0; + return { score: 0, scoreable: false, maxPoints: 0 }; } - return predictedWinner === actualWinner ? pointsPerCorrect : 0; + const participants = Array.isArray(actualParticipants) + ? actualParticipants.filter(Boolean) + : []; + + if (participants.length > 0 && !participants.includes(predictedWinner)) { + return { score: 0, scoreable: false, maxPoints: 0 }; + } + + const score = predictedWinner === actualWinner ? pointsPerCorrect : 0; + return { + score, + scoreable: true, + maxPoints: pointsPerCorrect, + }; } /** @@ -71,9 +100,12 @@ function scoreKnockoutPrediction(predictedWinner, actualWinner, pointsPerCorrect * @param {Array} groupPredictions - Array of group predictions {groupId, predictions} * @param {Array} groupResults - Array of group results {groupId, first, second} * @param {Array} knockoutPredictions - Array of knockout predictions {matchId, predictedWinner} - * @param {Array} knockoutMatches - Array of knockout matches with results {id, winner, round} + * @param {Array} knockoutMatches - Array of knockout matches with results + * `{id, winner, round, selectedHomeTeamId?, selectedAwayTeamId?}` + * When the optional participant fields are present, the participant-skip + * rule from `scoreKnockoutPrediction` is applied. * @param {Map} roundPointsMap - Map of round names to points per correct for the tournament - * @returns {Object} {groupScore, knockoutScore, totalScore} + * @returns {Object} {groupScore, knockoutScore, totalScore, roundBreakdown, forfeitedKnockoutPoints} */ function calculateTotalScore( groupPredictions, @@ -84,9 +116,9 @@ function calculateTotalScore( ) { let groupScore = 0; let knockoutScore = 0; + let forfeitedKnockoutPoints = 0; const roundBreakdown = {}; - // Score group predictions if (groupPredictions && groupResults) { groupPredictions.forEach((pred) => { const result = groupResults.find((r) => r.groupId === pred.groupId); @@ -101,19 +133,27 @@ function calculateTotalScore( roundBreakdown.group_stage = groupScore; - // Score knockout predictions if (knockoutPredictions && knockoutMatches) { knockoutPredictions.forEach((pred) => { const match = knockoutMatches.find((m) => m.id === pred.matchId); if (match && match.winner) { const pointsPerCorrect = roundPointsMap.get(match.round) || 0; - const score = scoreKnockoutPrediction( + const participants = [match.selectedHomeTeamId, match.selectedAwayTeamId].filter(Boolean); + const { score, scoreable } = scoreKnockoutPrediction( pred.predictedWinner, match.winner, - pointsPerCorrect + pointsPerCorrect, + participants ); knockoutScore += score; roundBreakdown[match.round] = (roundBreakdown[match.round] || 0) + score; + if (!scoreable) { + // The user's stored pick is not eligible for this match (either + // empty, or for a team that did not actually play it). Track + // the forfeited maximum so callers can subtract it from the + // tournament-wide max-possible when reporting a per-user cap. + forfeitedKnockoutPoints += pointsPerCorrect; + } } }); } @@ -123,6 +163,7 @@ function calculateTotalScore( knockoutScore, totalScore: groupScore + knockoutScore, roundBreakdown, + forfeitedKnockoutPoints, }; } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0b4f4da..5bd5f03 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -414,6 +414,7 @@ The Prisma schema currently contains: `Score` - persisted group, knockout, and total scores by scope +- also persists `forfeitedKnockoutPoints` — `pointsPerCorrect` summed across knockout matches whose recorded participants did not include the user's stored pick (see §10.2) `TournamentPrimaryEntry` @@ -483,6 +484,15 @@ The current seeded tournaments use linearly increasing values through the bracke - `groupCount * 4` - the sum of all `round.matches.length * round.pointsPerCorrect` +A knockout match is treated as **unscoreable** for a given user when their stored `predictedWinner` is a team that did not actually play the match. This happens when the user's upstream group placement turned out wrong and the corrected seeding routed different teams into the bracket slot — for example, the user predicted `1A = MEX` and picked MEX to win `1A vs 3B/C/D`, but the real `1A` was Korea, so the match was played as `KOR vs HAI`. MEX never played. The match contributes: + +- `0` to the user's earned `knockoutScore` +- `0` to the user's per-user maximum-possible score (`pointsPerCorrect` is added to `forfeitedKnockoutPoints` instead) + +The user is therefore neither rewarded nor penalised for a pick they could not have made correctly. The effective per-user max is `tournament.rules.totalMaximumPoints - score.forfeitedKnockoutPoints`. The user's literal stored prediction is preserved — this is purely a scoring-time skip — and the Predict wizard's bracket view re-resolves the slot using the official `group.result` so the user sees the real matchup going forward. + +The skip rule only fires once a knockout match has both a recorded `winner` and known participants (`selectedHomeTeamId`/`selectedAwayTeamId`). Matches that have not been played yet are not affected, regardless of upstream group state. + ### 10.3 Score Calculation Scoring lives in `api/scoring.cjs`. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index f8a1313..57c1fed 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -62,6 +62,12 @@ Working: - live group standings table derived from finished match scores (P / W / D / L / GF / GA / GD / Pts), with an optional toggle to view the same table populated by the current user's predicted ordering +- once a group has an official saved result, the Predict wizard's + downstream knockout slots (1A, 2B, 3A/B/C/D, ...) re-resolve to the + real finishers so the user picks a winner among teams that actually + advanced; knockout matches whose participants did not include the + user's stored pick are tracked as `forfeitedKnockoutPoints` and + excluded from their per-user maximum-possible score ### 2.4 League Participation diff --git a/prisma/migrations/20260615000000_score_forfeited_knockout_points/migration.sql b/prisma/migrations/20260615000000_score_forfeited_knockout_points/migration.sql new file mode 100644 index 0000000..30ccd82 --- /dev/null +++ b/prisma/migrations/20260615000000_score_forfeited_knockout_points/migration.sql @@ -0,0 +1,6 @@ +-- Adds Score.forfeitedKnockoutPoints to track the max-possible points that +-- a user has skipped over because their stored knockout pick is for a team +-- that did not actually play the match (corrected group seeding routed +-- different teams into the bracket slot). See api/scoring.cjs. +ALTER TABLE "Score" +ADD COLUMN IF NOT EXISTS "forfeitedKnockoutPoints" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dbacafe..a3e14a4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -164,17 +164,25 @@ model GroupResult { } model Score { - id String @id @default(cuid()) - userId String - tournamentId String - scopeKey String @default("tournament") - groupScore Int @default(0) - knockoutScore Int @default(0) - totalScore Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) - tournament Tournament @relation(fields: [tournamentId], references: [id]) + id String @id @default(cuid()) + userId String + tournamentId String + scopeKey String @default("tournament") + groupScore Int @default(0) + knockoutScore Int @default(0) + totalScore Int @default(0) + // Sum of `pointsPerCorrect` for knockout matches where this user's + // stored `predictedWinner` was a team that did not actually play the + // match (typically because their upstream group placement turned out + // wrong and corrected seeding routed different teams into the slot). + // These matches add zero to the user's earned `knockoutScore` AND + // are excluded from their effective max-possible score — they cannot + // earn nor lose those points. See api/scoring.cjs. + forfeitedKnockoutPoints Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id]) + tournament Tournament @relation(fields: [tournamentId], references: [id]) @@unique([userId, tournamentId, scopeKey]) } diff --git a/src/pages/Predict.jsx b/src/pages/Predict.jsx index b42c32e..ca2d862 100644 --- a/src/pages/Predict.jsx +++ b/src/pages/Predict.jsx @@ -100,6 +100,27 @@ export default function Predict() { prediction?.predictedWinner || '', ]) ); + // Group placement card stays editable only while the group is open. Once + // the admin has published the official `group.result`, downstream bracket + // slots (1A, 2B, 3A/B/C/D, ...) must resolve to the *real* finishers so + // the user is not asked to pick a winner among teams who did not advance. + // The user's stored placement pick is preserved untouched — scoring still + // reads from `groupPredictions` — this only changes which teams fill the + // knockout slots in the wizard's display. + const effectiveGroupSelections = (() => { + const merged = { ...groupPredictions }; + for (const group of groups) { + const officialResult = group.result; + if (officialResult?.first && officialResult?.second) { + merged[group.id] = { + first: officialResult.first, + second: officialResult.second, + third: officialResult.third || '', + }; + } + } + return merged; + })(); const steps = tournament ? [ { @@ -388,7 +409,7 @@ export default function Predict() { round={activeStep.round} groups={groups} rounds={rounds} - groupPredictions={groupPredictions} + groupPredictions={effectiveGroupSelections} knockoutPredictions={knockoutPredictions} knockoutWinnerSelections={knockoutWinnerSelections} teamMap={teamMap} diff --git a/tests/scoring.test.mjs b/tests/scoring.test.mjs index fedd082..e6d0cde 100644 --- a/tests/scoring.test.mjs +++ b/tests/scoring.test.mjs @@ -33,9 +33,52 @@ test('scoreGroupPrediction returns expected scores for classic prode outcomes', }); test('scoreKnockoutPrediction awards round points only for correct winners', () => { - assert.equal(scoreKnockoutPrediction('ARG', 'ARG', 6), 6); - assert.equal(scoreKnockoutPrediction('BRA', 'ARG', 6), 0); - assert.equal(scoreKnockoutPrediction('', 'ARG', 6), 0); + assert.deepEqual(scoreKnockoutPrediction('ARG', 'ARG', 6), { + score: 6, + scoreable: true, + maxPoints: 6, + }); + assert.deepEqual(scoreKnockoutPrediction('BRA', 'ARG', 6), { + score: 0, + scoreable: true, + maxPoints: 6, + }); + assert.deepEqual(scoreKnockoutPrediction('', 'ARG', 6), { + score: 0, + scoreable: false, + maxPoints: 0, + }); +}); + +test('scoreKnockoutPrediction skips matches whose actual participants do not include the picked team', () => { + // User picked ARG but the corrected seeding placed KOR and HAI in this + // bracket slot. ARG never played → the match is unscoreable. + assert.deepEqual(scoreKnockoutPrediction('ARG', 'KOR', 6, ['KOR', 'HAI']), { + score: 0, + scoreable: false, + maxPoints: 0, + }); + + // Picked team did play and won. + assert.deepEqual(scoreKnockoutPrediction('KOR', 'KOR', 6, ['KOR', 'HAI']), { + score: 6, + scoreable: true, + maxPoints: 6, + }); + + // Picked team did play but lost. + assert.deepEqual(scoreKnockoutPrediction('HAI', 'KOR', 6, ['KOR', 'HAI']), { + score: 0, + scoreable: true, + maxPoints: 6, + }); + + // Empty participants array falls back to legacy literal-compare behaviour. + assert.deepEqual(scoreKnockoutPrediction('ARG', 'ARG', 6, []), { + score: 6, + scoreable: true, + maxPoints: 6, + }); }); test('calculateTotalScore aggregates group and knockout scores with a round breakdown', () => { @@ -66,6 +109,7 @@ test('calculateTotalScore aggregates group and knockout scores with a round brea groupScore: 7, knockoutScore: 6, totalScore: 13, + forfeitedKnockoutPoints: 0, roundBreakdown: { group_stage: 7, quarter_finals: 6, @@ -73,3 +117,59 @@ test('calculateTotalScore aggregates group and knockout scores with a round brea }, }); }); + +test('calculateTotalScore forfeits points when the picked team did not play the knockout match', () => { + // User's group placement for Group A was wrong: they predicted ARG = 1A + // and so picked ARG to win the R16 match in slot 1A vs 2B. The corrected + // seeding routed KOR and HAI into that slot, KOR won. ARG never played. + // → match scored as: score 0, scoreable false, forfeits the 6 round pts. + // The semi-finals match has KOR in it and the user picked KOR, so it + // scores normally (0 because FRA actually won). + const score = calculateTotalScore( + [ + { groupId: 'A', predictions: { first: 'ARG', second: 'GER' } }, + ], + [ + { groupId: 'A', first: 'KOR', second: 'GER' }, + ], + [ + { matchId: 'r16', predictedWinner: 'ARG' }, + { matchId: 'sf', predictedWinner: 'KOR' }, + ], + [ + { + id: 'r16', + winner: 'KOR', + round: 'round_of_16', + selectedHomeTeamId: 'KOR', + selectedAwayTeamId: 'HAI', + }, + { + id: 'sf', + winner: 'FRA', + round: 'semi_finals', + selectedHomeTeamId: 'KOR', + selectedAwayTeamId: 'FRA', + }, + ], + new Map([ + ['round_of_16', 6], + ['semi_finals', 12], + ]) + ); + + assert.deepEqual(score, { + // Group score: predicted (ARG, GER) vs actual (KOR, GER) → 2 (GER 2nd). + groupScore: 2, + // r16 skipped (ARG not in match), sf scored wrong → 0. + knockoutScore: 0, + totalScore: 2, + // r16 was 6pts that the user could not earn nor lose. + forfeitedKnockoutPoints: 6, + roundBreakdown: { + group_stage: 2, + round_of_16: 0, + semi_finals: 0, + }, + }); +});