From 09122de16b34625bbce01718aa72ffe5daf45402 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:51:11 +0300 Subject: [PATCH] Fix tournament team page score ordering edge cases --- .../TournamentMatchRepository.server.test.ts | 19 +++ .../TournamentMatchRepository.server.ts | 4 + .../tournament/core/sets.server.test.ts | 127 +++++++++++++++++- app/features/tournament/core/sets.server.ts | 35 +++-- 4 files changed, 164 insertions(+), 21 deletions(-) diff --git a/app/features/tournament-match/TournamentMatchRepository.server.test.ts b/app/features/tournament-match/TournamentMatchRepository.server.test.ts index 166e97572..66e6d4603 100644 --- a/app/features/tournament-match/TournamentMatchRepository.server.test.ts +++ b/app/features/tournament-match/TournamentMatchRepository.server.test.ts @@ -66,4 +66,23 @@ describe("findByTournamentTeamId", () => { finalMatch.id, ]); }); + + test("resolves which side of the match the team is on", async () => { + const tournament = await TournamentFactory.createPlayed( + { authorId: users.id(1), minMembersPerTeam: 1 }, + { teamRosters: [[users.id(1)], [users.id(2)]] }, + ); + const match = tournament.matches[0]; + + const [winnerSet] = await TournamentMatchRepository.findByTournamentTeamId( + match.winnerTeamId, + ); + const [loserSet] = await TournamentMatchRepository.findByTournamentTeamId( + match.loserTeamId, + ); + + expect(winnerSet.teamSide).toBe(winnerSet.winnerSide); + expect(loserSet.teamSide).not.toBe(loserSet.winnerSide); + expect(winnerSet.teamSide).not.toBe(loserSet.teamSide); + }); }); diff --git a/app/features/tournament-match/TournamentMatchRepository.server.ts b/app/features/tournament-match/TournamentMatchRepository.server.ts index f33195310..8f88c4076 100644 --- a/app/features/tournament-match/TournamentMatchRepository.server.ts +++ b/app/features/tournament-match/TournamentMatchRepository.server.ts @@ -431,6 +431,10 @@ export function findByTournamentTeamId(tournamentTeamId: number) { ) .select(({ eb }) => [ "TournamentMatch.id as tournamentMatchId", + "TournamentMatch.winnerSide", + sql`iif(${opponentOneId} = ${tournamentTeamId}, 'opponent1', 'opponent2')`.as( + "teamSide", + ), opponentOneScore.as("opponentOneScore"), opponentTwoScore.as("opponentTwoScore"), "otherTeam.name as otherTeamName", diff --git a/app/features/tournament/core/sets.server.test.ts b/app/features/tournament/core/sets.server.test.ts index c45022ff7..2a3ed7289 100644 --- a/app/features/tournament/core/sets.server.test.ts +++ b/app/features/tournament/core/sets.server.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "vitest"; -import { winCounts } from "./sets.server"; +import type { FindByTournamentTeamIdItem } from "~/features/tournament-match/TournamentMatchRepository.server"; +import { + type AllRoundsItem, + tournamentTeamSets, + winCounts, +} from "./sets.server"; describe("winCounts", () => { test("returns 0% (not NaN) when there are no played sets", () => { @@ -8,4 +13,124 @@ describe("winCounts", () => { expect(result.sets.percentage).toBe(0); expect(result.maps.percentage).toBe(0); }); + + test("counts a set the team won on the bracket but lost on maps as a win", () => { + // e.g. a set the opponent forfeited after winning games, awarded 2-1 by the + // organizer — the bracket, and so the set score, says the team won it + const result = winCounts([ + { + tournamentMatchId: 1, + score: [2, 1], + result: "win", + round: { type: "winners", round: 1 }, + stageName: "Main bracket", + maps: [ + { stageId: 1, modeShort: "SZ", result: "loss", source: "BOTH" }, + { stageId: 2, modeShort: "TC", result: "loss", source: "BOTH" }, + { stageId: 3, modeShort: "RM", result: "win", source: "BOTH" }, + ], + opponent: { id: 2, name: "Opponent", roster: [] }, + }, + ]); + + expect(result.sets.won).toBe(1); + }); + + test("counts a set that ended early with the maps split as a win", () => { + const result = winCounts([ + { + tournamentMatchId: 1, + score: [1, 1], + result: "win", + round: { type: "winners", round: 1 }, + stageName: "Main bracket", + maps: [ + { stageId: 1, modeShort: "SZ", result: "win", source: "BOTH" }, + { stageId: 2, modeShort: "TC", result: "loss", source: "BOTH" }, + ], + opponent: { id: 2, name: "Opponent", roster: [] }, + }, + ]); + + expect(result.sets.won).toBe(1); + }); + + test("counts a set the team lost on the bracket but won on maps as a loss", () => { + const result = winCounts([ + { + tournamentMatchId: 1, + score: [2, 1], + result: "loss", + round: { type: "winners", round: 1 }, + stageName: "Main bracket", + maps: [ + { stageId: 1, modeShort: "SZ", result: "win", source: "BOTH" }, + { stageId: 2, modeShort: "TC", result: "loss", source: "BOTH" }, + { stageId: 3, modeShort: "RM", result: "win", source: "BOTH" }, + ], + opponent: { id: 2, name: "Opponent", roster: [] }, + }, + ]); + + expect(result.sets.won).toBe(0); + expect(result.maps.won).toBe(2); + }); +}); + +const ALL_ROUNDS: AllRoundsItem[] = [ + { + stageId: 1, + stageName: "Main bracket", + stageType: "single_elimination", + roundNumber: 1, + groupNumber: 1, + }, +]; + +/** A played Bo3 the team being viewed lost 1-2 on the maps. */ +function playedSetRow( + overrides: Partial, +): FindByTournamentTeamIdItem { + return { + tournamentMatchId: 1, + winnerSide: "opponent1", + teamSide: "opponent1", + opponentOneScore: 1, + opponentTwoScore: 2, + otherTeamId: 2, + otherTeamName: "Opponent", + roundNumber: 1, + stageId: 1, + groupNumber: 1, + matches: [ + { mode: "SZ", stageId: 1, source: "BOTH", wasWinner: 1 }, + { mode: "TC", stageId: 2, source: "BOTH", wasWinner: 0 }, + { mode: "RM", stageId: 3, source: "BOTH", wasWinner: 0 }, + ], + players: [], + ...overrides, + }; +} + +describe("tournamentTeamSets", () => { + test("orders the score by the slot the team being viewed is in", () => { + const [set] = tournamentTeamSets({ + sets: [playedSetRow({ teamSide: "opponent2" })], + allRounds: ALL_ROUNDS, + }); + + expect(set.score).toEqual([2, 1]); + }); + + test("takes the set result from the bracket winner even when the maps disagree", () => { + // organizer overrode the winner after the games were reported, so the team + // won the set on the bracket while losing 1-2 on the maps + const [set] = tournamentTeamSets({ + sets: [playedSetRow({ teamSide: "opponent1", winnerSide: "opponent1" })], + allRounds: ALL_ROUNDS, + }); + + expect(set.result).toBe("win"); + expect(set.score).toEqual([1, 2]); + }); }); diff --git a/app/features/tournament/core/sets.server.ts b/app/features/tournament/core/sets.server.ts index 01f4cae7c..7e757b85f 100644 --- a/app/features/tournament/core/sets.server.ts +++ b/app/features/tournament/core/sets.server.ts @@ -16,6 +16,12 @@ export interface AllRoundsItem { export interface PlayedSet { tournamentMatchId: number; score: [teamBeingViewed: number, opponent: number]; + /** + * Who won the set according to the bracket. Can disagree with the maps and + * the score e.g. when an organizer overrode the winner after games were + * already reported. + */ + result: "win" | "loss"; round: { type: "winners" | "losers" | "single_elim" | "round_robin" | "swiss"; round: number | "finals" | "grand_finals" | "bracket_reset"; @@ -63,7 +69,7 @@ export function winCounts(sets: PlayedSet[]) { } totalSets++; - if (mapsWonThisSet > totalMapsThisSet / 2) { + if (set.result === "win") { setsWon++; } @@ -140,7 +146,8 @@ export function tournamentTeamSets({ result: match.wasWinner ? "win" : "loss", source: parseMaplistSource(match.source), })), - score: flipScoreIfNeeded(set), + result: set.winnerSide === set.teamSide ? "win" : "loss", + score: scoreFromTeamPerspective(set), opponent: { id: set.otherTeamId, name: set.otherTeamName, @@ -150,24 +157,12 @@ export function tournamentTeamSets({ }); } -function flipScoreIfNeeded(set: FindByTournamentTeamIdItem): [number, number] { - const score: [number, number] = [ - set.opponentOneScore ?? 0, - set.opponentTwoScore ?? 0, - ]; - - const wonTheSet = - set.matches.reduce((acc, cur) => cur.wasWinner + acc, 0) > - set.matches.length / 2; - - if ( - (wonTheSet && score[0] < score[1]) || - (!wonTheSet && score[0] > score[1]) - ) { - return [score[1], score[0]]; - } - - return score; +function scoreFromTeamPerspective( + set: FindByTournamentTeamIdItem, +): [number, number] { + return set.teamSide === "opponent1" + ? [set.opponentOneScore ?? 0, set.opponentTwoScore ?? 0] + : [set.opponentTwoScore ?? 0, set.opponentOneScore ?? 0]; } function resolveRoundType({