point -> ko DB migration

This commit is contained in:
Kalle
2026-07-25 08:09:08 +03:00
parent 13b7d7e2a7
commit d0c456cf3d
20 changed files with 77 additions and 120 deletions

View File

@@ -219,11 +219,7 @@ function SetEndingConfirmation({
timestamp: Date.now(),
winner: winnerSide,
rosters: setEnding.currentRosters,
points: withKo
? isKo
? [winnerSide === "ALPHA" ? 100 : 0, winnerSide === "BRAVO" ? 100 : 0]
: [0, 0]
: undefined,
ko: withKo ? isKo : undefined,
};
const updatedScore = {

View File

@@ -48,8 +48,8 @@ export interface TimelineMap {
alpha: Array<MainWeaponId | null>;
bravo: Array<MainWeaponId | null>;
};
/** Optional point values [alpha, bravo] */
points?: [number, number];
/** Whether the game ended in a knockout. Undefined if not collected. */
ko?: boolean;
/** Side that picked this map (counterpick / postGame map PICK). Renders a click indicator next to that side's WIN/LOSS label. */
pickedBy?: MatchSide;
}
@@ -210,15 +210,12 @@ function TimelineHeader({
function TimelineMapRow({ map }: { map: TimelineMap }) {
const { t } = useTranslation(["game-misc"]);
const alphaPoints = map.points?.[0];
const bravoPoints = map.points?.[1];
return (
<div className={styles.mapEvent}>
<div className={styles.mapSide}>
<SideResult
result={map.winner === "ALPHA" ? "WIN" : "LOSS"}
points={alphaPoints}
isKo={map.ko && map.winner === "ALPHA"}
weapons={map.weapons?.alpha}
isPicked={map.pickedBy === "ALPHA"}
/>
@@ -242,7 +239,7 @@ function TimelineMapRow({ map }: { map: TimelineMap }) {
<div className={styles.mapSide}>
<SideResult
result={map.winner === "BRAVO" ? "WIN" : "LOSS"}
points={bravoPoints}
isKo={map.ko && map.winner === "BRAVO"}
weapons={map.weapons?.bravo}
isPicked={map.pickedBy === "BRAVO"}
/>
@@ -253,12 +250,12 @@ function TimelineMapRow({ map }: { map: TimelineMap }) {
function SideResult({
result,
points,
isKo,
weapons,
isPicked,
}: {
result: "WIN" | "LOSS";
points?: number;
isKo?: boolean;
weapons?: Array<MainWeaponId | null>;
isPicked?: boolean;
}) {
@@ -288,7 +285,7 @@ function SideResult({
? t("q:match.timeline.win")
: t("q:match.timeline.loss")}
</span>
{points === 100 ? (
{isKo ? (
<span className={styles.resultPoints}>{t("q:match.action.ko")}</span>
) : null}
</div>

View File

@@ -692,6 +692,8 @@ export interface TournamentMatchPickBanEvent {
export interface TournamentMatchGameResult {
createdAt: Generated<number>;
id: GeneratedAlways<number>;
/** Whether the game ended in a knockout. `null` if not collected for this bracket. */
ko: DBBoolean | null;
matchId: number;
mode: ModeShort;
number: number;
@@ -699,8 +701,6 @@ export interface TournamentMatchGameResult {
source: string;
stageId: StageId;
winnerTeamId: number;
opponentOnePoints: number | null;
opponentTwoPoints: number | null;
}
export interface TournamentMatchGameResultParticipant {

View File

@@ -58,7 +58,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
? (map.source as MapListMap["source"])
: Number(map.source),
participatedUserIds: null,
points: null,
ko: null,
})),
teamAlpha: {
id: match.groupAlpha.id,

View File

@@ -52,8 +52,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
"TournamentMatchGameResult.mode",
"TournamentMatchGameResult.winnerTeamId",
"TournamentMatchGameResult.source",
"TournamentMatchGameResult.opponentOnePoints",
"TournamentMatchGameResult.opponentTwoPoints",
"TournamentMatchGameResult.ko",
jsonArrayFrom(
innerEb
.selectFrom("TournamentMatchGameResultParticipant")
@@ -106,10 +105,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
participatedUserIds: playedMap.participants.map((p) => p.userId),
winnerTeamId: playedMap.winnerTeamId,
source: parseSource(playedMap.source),
points:
playedMap.opponentOnePoints && playedMap.opponentTwoPoints
? [playedMap.opponentOnePoints, playedMap.opponentTwoPoints]
: null,
ko: playedMap.ko !== null ? Boolean(playedMap.ko) : null,
}));
}
@@ -147,7 +143,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
participatedUserIds: null,
winnerTeamId: null,
source: mapListMap.source,
points: null,
ko: null,
};
});
};

View File

@@ -526,8 +526,8 @@ export type MapListMap = {
| "ROLL";
winnerTeamId: number | null;
participatedUserIds: Array<number> | null;
/** (round robin only) points of the match used for tiebreaker purposes. e.g. [100, 0] indicates a knockout. */
points: [number, number] | null;
/** (round robin only) whether the map ended in a knockout. `null` if not tracked. */
ko: boolean | null;
};
type TournamentMatchTeam = {

View File

@@ -688,7 +688,7 @@ export default function MatchPageTestRoute() {
mode: "RM",
timestamp: 1712856200,
winner: "ALPHA",
points: [100, 42],
ko: true,
weapons: {
alpha: [40, null, 1100, 3040],
bravo: [null, 210, null, 4010],

View File

@@ -128,18 +128,13 @@ export async function findByTournamentId(
function serializedOpponentWithKos(
column: "opponentOne" | "opponentTwo",
): RawBuilder<ParticipantResult | null> {
const [winnerPoints, loserPoints] =
column === "opponentOne"
? (["opponentOnePoints", "opponentTwoPoints"] as const)
: (["opponentTwoPoints", "opponentOnePoints"] as const);
return kyselySql<ParticipantResult | null>`json_set(
json_remove(${kyselySql.ref(`TournamentMatch.${column}`)}, '$.totalPoints'),
'$.totalKos',
sum(
case
when ${kyselySql.ref(`TournamentMatchGameResult.${winnerPoints}`)} = 100
and ${kyselySql.ref(`TournamentMatchGameResult.${loserPoints}`)} = 0
when "TournamentMatchGameResult"."ko" = 1
and "TournamentMatchGameResult"."winnerTeamId" = ${kyselySql.ref(`TournamentMatch.${column}`)} ->> '$.id'
then 1
else 0
end

View File

@@ -31,7 +31,6 @@ const reportedMatchPosition = z.preprocess(
.max(Math.max(...TOURNAMENT.AVAILABLE_BEST_OF) - 1),
);
// TODO: KO is stored as points (100-0, 0-100 or 0-0). If we decide that this KO only approach is solid then we can do a proper data model migration
const ko = z.preprocess(safeJSONParse, z.boolean().nullish());
export const matchSchema = z.union([
z.object({

View File

@@ -105,8 +105,7 @@ export function findResultById(id: number) {
.select([
"TournamentMatchGameResult.id",
"TournamentMatchGameResult.matchId",
"TournamentMatchGameResult.opponentOnePoints",
"TournamentMatchGameResult.opponentTwoPoints",
"TournamentMatchGameResult.ko",
"TournamentMatchGameResult.winnerTeamId",
])
.where("TournamentMatchGameResult.id", "=", id)
@@ -123,8 +122,7 @@ export function findResultsByMatchId(matchId: number) {
"TournamentMatchGameResult.mode",
"TournamentMatchGameResult.source",
"TournamentMatchGameResult.createdAt",
"TournamentMatchGameResult.opponentOnePoints",
"TournamentMatchGameResult.opponentTwoPoints",
"TournamentMatchGameResult.ko",
jsonArrayFrom(
eb
.selectFrom("TournamentMatchGameResultParticipant")

View File

@@ -42,7 +42,7 @@ import { resolveMapList } from "../core/mapList.server";
import { deleteParticipantsByMatchGameResultId } from "../queries/deleteParticipantsByMatchGameResultId.server";
import { insertTournamentMatchGameResult } from "../queries/insertTournamentMatchGameResult.server";
import { insertTournamentMatchGameResultParticipant } from "../queries/insertTournamentMatchGameResultParticipant.server";
import { updateMatchGameResultPoints } from "../queries/updateMatchGameResultPoints.server";
import { updateMatchGameResultKo } from "../queries/updateMatchGameResultKo.server";
import type { FindMatchById } from "../TournamentMatchRepository.server";
import {
matchIsLocked,
@@ -158,8 +158,6 @@ export const action: ActionFunction = async ({ params, request }) => {
];
invariant(currentMap, "Can't resolve current map");
const winnerSide = data.winnerTeamId === match.opponentOne.id ? 0 : 1;
const bracket = tournament.bracketByIdx(
tournament.matchIdToBracketIdx(match.id)!,
)!;
@@ -168,10 +166,6 @@ export const action: ActionFunction = async ({ params, request }) => {
"KO status is required for this bracket",
);
const points = bracket.collectsKos
? koToPoints({ ko: Boolean(data.ko), winnerSide })
: null;
const teamOneRoster = tournamentTeamToActiveRosterUserIds(
tournament.teamById(match.opponentOne.id!)!,
tournament.minMembersPerTeam,
@@ -210,8 +204,7 @@ export const action: ActionFunction = async ({ params, request }) => {
winnerTeamId: data.winnerTeamId,
number: data.position + 1,
source: String(currentMap.source),
opponentOnePoints: points?.[0] ?? null,
opponentTwoPoints: points?.[1] ?? null,
ko: bracket.collectsKos ? Number(Boolean(data.ko)) : null,
});
for (const userId of teamOneRoster) {
@@ -390,12 +383,15 @@ export const action: ActionFunction = async ({ params, request }) => {
"Invalid roster",
);
const hadKoRecorded = typeof result.opponentOnePoints === "number";
const hasKoSubmitted = typeof data.ko === "boolean";
errorToastIfFalsy(hadKoRecorded === hasKoSubmitted, "KO status mismatch");
const bracket = tournament.bracketByIdx(
tournament.matchIdToBracketIdx(match.id)!,
)!;
errorToastIfFalsy(
!bracket.collectsKos || typeof data.ko === "boolean",
"KO status is required for this bracket",
);
const wasKo =
result.opponentOnePoints === 100 || result.opponentTwoPoints === 100;
const wasKo = Boolean(result.ko);
if (typeof data.ko === "boolean" && data.ko !== wasKo) {
// changing the KO status at this point could retroactively change who advanced from the group
errorToastIfFalsy(
@@ -406,15 +402,9 @@ export const action: ActionFunction = async ({ params, request }) => {
sql.transaction(() => {
if (typeof data.ko === "boolean") {
const points = koToPoints({
ko: data.ko,
winnerSide: result.winnerTeamId === match.opponentOne!.id ? 0 : 1,
});
updateMatchGameResultPoints({
updateMatchGameResultKo({
matchGameResultId: result.id,
opponentOnePoints: points[0],
opponentTwoPoints: points[1],
ko: data.ko,
});
}
@@ -853,19 +843,6 @@ export const action: ActionFunction = async ({ params, request }) => {
return null;
};
/** KO status is stored as points: the KO winner gets 100 and the loser 0, a game that was not a KO is stored as 0-0. */
function koToPoints({
ko,
winnerSide,
}: {
ko: boolean;
winnerSide: 0 | 1;
}): [number, number] {
if (!ko) return [0, 0];
return winnerSide === 0 ? [100, 0] : [0, 100];
}
function canReportTournamentScore({
match,
isMemberOfATeamInTheMatch,

View File

@@ -271,13 +271,7 @@ function buildSetEndingData({
alpha: alphaParticipants,
bravo: bravoParticipants,
},
points:
result.opponentOnePoints != null && result.opponentTwoPoints != null
? ([result.opponentOnePoints, result.opponentTwoPoints] as [
number,
number,
])
: undefined,
ko: result.ko != null ? Boolean(result.ko) : undefined,
};
});

View File

@@ -399,8 +399,7 @@ function EditReportedScoreRow({
previousFetcherStateRef.current = fetcher.state;
}, [fetcher.state, fetcher.data]);
const isKo =
result.opponentOnePoints === 100 || result.opponentTwoPoints === 100;
const isKo = Boolean(result.ko);
if (!editing) {
return (
@@ -471,9 +470,7 @@ function EditReportedScoreForm({
.map((p) => p.userId),
];
});
const [isKO, setIsKO] = React.useState(
result.opponentOnePoints === 100 || result.opponentTwoPoints === 100,
);
const [isKO, setIsKO] = React.useState(Boolean(result.ko));
const formValid = checkedPlayers.every(
(team) => team.length === minMembersPerTeam,

View File

@@ -153,9 +153,6 @@ function resolveTimelineMaps(
}));
return data.results.map((result, mapIndex) => {
const hasPoints =
result.opponentOnePoints !== null && result.opponentTwoPoints !== null;
const alphaRoster = resolveRoster(result.participants, opponentOneId);
const bravoRoster = resolveRoster(result.participants, opponentTwoId);
@@ -185,12 +182,7 @@ function resolveTimelineMaps(
weapons: hasAnyWeapon
? { alpha: alphaWeapons, bravo: bravoWeapons }
: undefined,
points: hasPoints
? ([result.opponentOnePoints, result.opponentTwoPoints] as [
number,
number,
])
: undefined,
ko: result.ko != null ? Boolean(result.ko) : undefined,
};
});
}

View File

@@ -3,9 +3,9 @@ import type { Tables } from "~/db/tables";
const stm = sql.prepare(/* sql */ `
insert into "TournamentMatchGameResult"
("matchId", "stageId", "mode", "winnerTeamId", "reporterId", "number", "source", "opponentOnePoints", "opponentTwoPoints")
("matchId", "stageId", "mode", "winnerTeamId", "reporterId", "number", "source", "ko")
values
(@matchId, @stageId, @mode, @winnerTeamId, @reporterId, @number, @source, @opponentOnePoints, @opponentTwoPoints)
(@matchId, @stageId, @mode, @winnerTeamId, @reporterId, @number, @source, @ko)
returning *
`);

View File

@@ -0,0 +1,17 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
update "TournamentMatchGameResult"
set "ko" = @ko
where "id" = @id
`);
export function updateMatchGameResultKo({
matchGameResultId,
ko,
}: {
matchGameResultId: number;
ko: boolean;
}) {
stm.run({ id: matchGameResultId, ko: Number(ko) });
}

View File

@@ -1,20 +0,0 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
update "TournamentMatchGameResult"
set "opponentOnePoints" = @opponentOnePoints,
"opponentTwoPoints" = @opponentTwoPoints
where "id" = @id
`);
export function updateMatchGameResultPoints({
matchGameResultId,
opponentOnePoints,
opponentTwoPoints,
}: {
matchGameResultId: number;
opponentOnePoints: number;
opponentTwoPoints: number;
}) {
stm.run({ id: matchGameResultId, opponentOnePoints, opponentTwoPoints });
}

View File

@@ -130,8 +130,7 @@ describe("Tournament match page", () => {
),
"Result participants should only include active roster user ids",
).toBeTruthy();
expect(result.opponentOnePoints).toBe(null);
expect(result.opponentTwoPoints).toBe(null);
expect(result.ko).toBe(null);
expect(result.winnerTeamId).toBe(1);
});

Binary file not shown.

View File

@@ -110,6 +110,26 @@ export function up(db) {
`,
).run();
db.prepare(
/* sql */ `alter table "TournamentMatchGameResult" add "ko" integer`,
).run();
db.prepare(
/* sql */ `
update "TournamentMatchGameResult"
set "ko" = 1
where ("opponentOnePoints" = 100 and "opponentTwoPoints" = 0)
or ("opponentOnePoints" = 0 and "opponentTwoPoints" = 100)
`,
).run();
db.prepare(
/* sql */ `alter table "TournamentMatchGameResult" drop column "opponentOnePoints"`,
).run();
db.prepare(
/* sql */ `alter table "TournamentMatchGameResult" drop column "opponentTwoPoints"`,
).run();
db.pragma("foreign_key_check");
})();