Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions api/app.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
};
}
Expand All @@ -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,
}
);

Expand All @@ -1970,6 +1981,7 @@ function serializeGlobalLeaderboardPlayer(user, scores = []) {
groupScore: totals.groupScore,
knockoutScore: totals.knockoutScore,
totalScore: totals.totalScore,
forfeitedKnockoutPoints: totals.forfeitedKnockoutPoints,
};
}

Expand Down Expand Up @@ -2054,6 +2066,7 @@ async function calculateGlobalLeaderboard() {
groupScore: true,
knockoutScore: true,
totalScore: true,
forfeitedKnockoutPoints: true,
},
},
},
Expand Down Expand Up @@ -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,
Expand All @@ -2146,6 +2160,7 @@ async function persistScopeScores(tournamentId, scoringContext, options = {}) {
groupScore: score.groupScore,
knockoutScore: score.knockoutScore,
totalScore: score.totalScore,
forfeitedKnockoutPoints: score.forfeitedKnockoutPoints || 0,
},
});
});
Expand Down
73 changes: 57 additions & 16 deletions api/scoring.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/

/**
Expand All @@ -27,53 +36,76 @@ 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<string>} [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,
};
}

/**
* Calculate total score for a user in a tournament
* @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,
Expand All @@ -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);
Expand All @@ -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;
}
}
});
}
Expand All @@ -123,6 +163,7 @@ function calculateTotalScore(
knockoutScore,
totalScore: groupScore + knockoutScore,
roundBreakdown,
forfeitedKnockoutPoints,
};
}

Expand Down
10 changes: 10 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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`.
Expand Down
6 changes: 6 additions & 0 deletions docs/IMPLEMENTATION_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
30 changes: 19 additions & 11 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
Expand Down
23 changes: 22 additions & 1 deletion src/pages/Predict.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
? [
{
Expand Down Expand Up @@ -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}
Expand Down
Loading
Loading