KO refactor

This commit is contained in:
Kalle
2026-07-25 07:50:28 +03:00
parent fb9307c451
commit 13b7d7e2a7
10 changed files with 72 additions and 110 deletions

View File

@@ -21,7 +21,7 @@
}
}
.withPoints {
.withKo {
grid-template-areas:
"header header header"
"actions actions actions"

View File

@@ -39,8 +39,8 @@ interface MatchActionTabProps {
ownTeamId: number | null;
stageId: StageId;
mode: ModeShort;
withPoints: boolean;
onSubmit?: (data: { winnerId: number; points?: [number, number] }) => void;
withKo: boolean;
onSubmit?: (data: { winnerId: number; ko?: boolean }) => void;
isSubmitting?: boolean;
setEnding?: SetEndingData;
actionButtons?: React.ReactNode;
@@ -52,7 +52,7 @@ export function MatchActionTab({
ownTeamId,
stageId,
mode,
withPoints,
withKo,
onSubmit,
isSubmitting,
setEnding,
@@ -73,14 +73,7 @@ export function MatchActionTab({
const submit = () => {
if (winnerId === null) return;
const submitPoints: [number, number] | undefined = withPoints
? isKo
? winnerId === teams[0].id
? [100, 0]
: [0, 100]
: [0, 0]
: undefined;
onSubmit?.({ winnerId, points: submitPoints });
onSubmit?.({ winnerId, ko: withKo ? isKo : undefined });
};
return (
@@ -92,14 +85,14 @@ export function MatchActionTab({
mode={mode}
winnerId={winnerId}
teams={teams}
withPoints={withPoints}
withKo={withKo}
isKo={isKo}
isSubmitting={isSubmitting}
onBack={() => setConfirming(false)}
onConfirm={submit}
/>
) : (
<div className={clsx(styles.root, { [styles.withPoints]: withPoints })}>
<div className={clsx(styles.root, { [styles.withKo]: withKo })}>
<div className={styles.title}>{t("q:match.action.selectWinner")}</div>
{actionButtons ? (
<div className={styles.actionButtons}>{actionButtons}</div>
@@ -156,7 +149,7 @@ export function MatchActionTab({
/>
</RadioGroup>
{withPoints ? (
{withKo ? (
<div className={styles.ko}>
<label className={styles.koLabel}>
<input
@@ -199,7 +192,7 @@ function SetEndingConfirmation({
mode,
winnerId,
teams,
withPoints,
withKo,
isKo,
isSubmitting,
onBack,
@@ -210,7 +203,7 @@ function SetEndingConfirmation({
mode: ModeShort;
winnerId: number;
teams: [ActionTabTeam, ActionTabTeam];
withPoints: boolean;
withKo: boolean;
isKo: boolean;
isSubmitting?: boolean;
onBack: () => void;
@@ -226,7 +219,7 @@ function SetEndingConfirmation({
timestamp: Date.now(),
winner: winnerSide,
rosters: setEnding.currentRosters,
points: withPoints
points: withKo
? isKo
? [winnerSide === "ALPHA" ? 100 : 0, winnerSide === "BRAVO" ? 100 : 0]
: [0, 0]

View File

@@ -290,7 +290,7 @@ export default function MatchPageTestRoute() {
ownTeamId={1}
stageId={4}
mode="SZ"
withPoints={true}
withKo={true}
actionButtons={
<SendouButton
variant="minimal-destructive"

View File

@@ -74,7 +74,7 @@ function ReportMapSection({ viewerSide }: { viewerSide: ScrimSide }) {
ownTeamId={ownTeamId}
stageId={map.stageId}
mode={map.mode}
withPoints={false}
withKo={false}
isSubmitting={fetcher.state !== "idle"}
onSubmit={({ winnerId }) => {
fetcher.submit(

View File

@@ -422,7 +422,7 @@ function InProgressTab({
ownTeamId={ownTeamId}
stageId={currentMap.stageId}
mode={currentMap.mode}
withPoints={false}
withKo={false}
isSubmitting={fetcher.state !== "idle"}
setEnding={setEnding}
onSubmit={({ winnerId }) => {

View File

@@ -37,7 +37,7 @@ export type SeedOrdering =
/** The seeding for a stage. Each element is a participant id or a BYE: `null`. */
export type Seeding = (number | null)[];
// xxx: are all of these really in use?
// xxx: are all of these really in use? particularly what is the difference between Locked and Waiting for us. Waiting sems redundant. Also difference between Ready and Running, surely Running is enough?
/** Same values as the old brackets-model Status — persisted in TournamentMatch.status. */
export const MatchStatus = {
/** The two matches leading to this one are not completed yet. */

View File

@@ -31,36 +31,14 @@ const reportedMatchPosition = z.preprocess(
.max(Math.max(...TOURNAMENT.AVAILABLE_BEST_OF) - 1),
);
const point = z.number().int().min(0).max(100);
const points = z.preprocess(
safeJSONParse,
z
.tuple([point, point])
.nullish()
.refine(
(val) => {
if (!val) return true;
const [p1, p2] = val;
// KO
if (p1 === 100 && p2 === 0) return true;
if (p2 === 100 && p1 === 0) return true;
// ...or no points sent at all (TODO: if we decide that this KO only approach is solid then we can do a proper data model migration)
if (p1 === 0 && p2 === 0) return true;
return false;
},
{
message: "Invalid points. Valid: 100-0, 0-100 or 0-0.",
},
),
);
// 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({
_action: _action("REPORT_SCORE"),
winnerTeamId: id,
position: reportedMatchPosition,
points,
ko,
}),
z.object({
_action: _action("SET_ACTIVE_ROSTER"),
@@ -80,7 +58,7 @@ export const matchSchema = z.union([
_action: _action("UPDATE_REPORTED_SCORE"),
rosters: bothTeamPlayerIds,
resultId: id,
points,
ko,
}),
z.object({
_action: _action("REOPEN_MATCH"),

View File

@@ -160,23 +160,18 @@ export const action: ActionFunction = async ({ params, request }) => {
const winnerSide = data.winnerTeamId === match.opponentOne.id ? 0 : 1;
errorToastIfFalsy(
!data.points ||
data.points[0] === data.points[1] ||
(winnerSide === 0 && data.points[0] > data.points[1]) ||
(winnerSide === 1 && data.points[1] > data.points[0]),
"Points are invalid (winner must have more points than loser)",
);
const bracket = tournament.bracketByIdx(
tournament.matchIdToBracketIdx(match.id)!,
)!;
errorToastIfFalsy(
// xxx: just have data.ko from now on? and update error
!bracket.collectsKos || data.points,
"Points are required for this bracket",
!bracket.collectsKos || typeof data.ko === "boolean",
"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,
@@ -215,8 +210,8 @@ export const action: ActionFunction = async ({ params, request }) => {
winnerTeamId: data.winnerTeamId,
number: data.position + 1,
source: String(currentMap.source),
opponentOnePoints: data.points?.[0] ?? null,
opponentTwoPoints: data.points?.[1] ?? null,
opponentOnePoints: points?.[0] ?? null,
opponentTwoPoints: points?.[1] ?? null,
});
for (const userId of teamOneRoster) {
@@ -395,41 +390,31 @@ export const action: ActionFunction = async ({ params, request }) => {
"Invalid roster",
);
const hadPoints = typeof result.opponentOnePoints === "number";
const willHavePoints = typeof data.points?.[0] === "number";
errorToastIfFalsy(
(hadPoints && willHavePoints) || (!hadPoints && !willHavePoints),
"Points mismatch",
);
const hadKoRecorded = typeof result.opponentOnePoints === "number";
const hasKoSubmitted = typeof data.ko === "boolean";
errorToastIfFalsy(hadKoRecorded === hasKoSubmitted, "KO status mismatch");
if (data.points) {
if (data.points[0] !== result.opponentOnePoints) {
// changing points at this point could retroactively change who advanced from the group
errorToastIfFalsy(
tournament.matchCanBeReopened(match.id),
"Bracket has progressed",
);
}
if (data.points[0] === 100) {
errorToastIfFalsy(
result.winnerTeamId === match.opponentOne!.id,
"KO winner must match the result winner",
);
} else if (data.points[1] === 100) {
errorToastIfFalsy(
result.winnerTeamId === match.opponentTwo!.id,
"KO winner must match the result winner",
);
}
const wasKo =
result.opponentOnePoints === 100 || result.opponentTwoPoints === 100;
if (typeof data.ko === "boolean" && data.ko !== wasKo) {
// changing the KO status at this point could retroactively change who advanced from the group
errorToastIfFalsy(
tournament.matchCanBeReopened(match.id),
"Bracket has progressed",
);
}
sql.transaction(() => {
if (data.points) {
if (typeof data.ko === "boolean") {
const points = koToPoints({
ko: data.ko,
winnerSide: result.winnerTeamId === match.opponentOne!.id ? 0 : 1,
});
updateMatchGameResultPoints({
matchGameResultId: result.id,
opponentOnePoints: data.points[0],
opponentTwoPoints: data.points[1],
opponentOnePoints: points[0],
opponentTwoPoints: points[1],
});
}
@@ -868,6 +853,19 @@ 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

@@ -54,7 +54,7 @@ export function TournamentMatchActionTab({
if (!teamOne || !teamTwo) return null;
const withPoints = tournament.bracketByIdxOrDefault(
const withKo = tournament.bracketByIdxOrDefault(
tournament.matchIdToBracketIdx(data.match.id) ?? 0,
).collectsKos;
@@ -113,16 +113,16 @@ export function TournamentMatchActionTab({
ownTeamId={ownTeamId}
stageId={currentMap.stageId}
mode={currentMap.mode}
withPoints={withPoints}
withKo={withKo}
setEnding={setEnding}
isSubmitting={reportFetcher.state !== "idle"}
onSubmit={({ winnerId, points }) => {
onSubmit={({ winnerId, ko }) => {
reportFetcher.submit(
{
_action: "REPORT_SCORE",
winnerTeamId: String(winnerId),
position: String(scoreSum),
...(points ? { points: JSON.stringify(points) } : {}),
...(typeof ko === "boolean" ? { ko: String(ko) } : {}),
},
{ method: "post" },
);

View File

@@ -349,7 +349,7 @@ function EditReportedScoresSection({
const { t } = useTranslation(["tournament"]);
const tournament = useTournament();
const withPoints = tournament.bracketByIdxOrDefault(
const withKo = tournament.bracketByIdxOrDefault(
tournament.matchIdToBracketIdx(data.match.id) ?? 0,
).collectsKos;
@@ -363,7 +363,7 @@ function EditReportedScoresSection({
index={index}
result={result}
teams={teams}
withPoints={withPoints}
withKo={withKo}
/>
))}
</div>
@@ -375,12 +375,12 @@ function EditReportedScoreRow({
index,
result,
teams,
withPoints,
withKo,
}: {
index: number;
result: TournamentMatchLoaderData["results"][number];
teams: [TournamentDataTeam, TournamentDataTeam];
withPoints: boolean;
withKo: boolean;
}) {
const { t } = useTranslation(["common", "game-misc", "tournament"]);
const tournament = useTournament();
@@ -433,7 +433,7 @@ function EditReportedScoreRow({
fetcher={fetcher}
result={result}
teams={teams}
withPoints={withPoints}
withKo={withKo}
minMembersPerTeam={tournament.minMembersPerTeam}
onCancel={() => setEditing(false)}
index={index}
@@ -445,7 +445,7 @@ function EditReportedScoreForm({
fetcher,
result,
teams,
withPoints,
withKo,
minMembersPerTeam,
onCancel,
index,
@@ -453,7 +453,7 @@ function EditReportedScoreForm({
fetcher: ReturnType<typeof useFetcher>;
result: TournamentMatchLoaderData["results"][number];
teams: [TournamentDataTeam, TournamentDataTeam];
withPoints: boolean;
withKo: boolean;
minMembersPerTeam: number;
onCancel: () => void;
index: number;
@@ -475,13 +475,6 @@ function EditReportedScoreForm({
result.opponentOnePoints === 100 || result.opponentTwoPoints === 100,
);
const team0Won = result.winnerTeamId === teams[0].id;
const points: [number, number] = isKO
? team0Won
? [100, 0]
: [0, 100]
: [0, 0];
const formValid = checkedPlayers.every(
(team) => team.length === minMembersPerTeam,
);
@@ -536,9 +529,9 @@ function EditReportedScoreForm({
name="rosters"
value={JSON.stringify(checkedPlayers)}
/>
{withPoints ? (
{withKo ? (
<>
<input type="hidden" name="points" value={JSON.stringify(points)} />
<input type="hidden" name="ko" value={String(isKO)} />
<label className="stack horizontal sm items-center mx-auto">
<input
type="checkbox"