sendou.ink/app/features/bracket-test/routes/bracket-test.tsx
2026-07-23 18:36:46 +03:00

307 lines
8.5 KiB
TypeScript

import { useState } from "react";
import { Outlet } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { SendouSwitch } from "~/components/elements/Switch";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import type { Tables } from "~/db/tables";
import type { Bracket as BracketType } from "~/features/tournament-bracket/core/Bracket";
import * as Engine from "~/features/tournament-bracket/core/engine";
import type { TournamentManagerDataSet } from "~/features/tournament-bracket/core/engine/types";
import styles from "../bracket-test.module.css";
type FormatType = Tables["TournamentStage"]["type"];
const FORMAT_OPTIONS: { value: FormatType; label: string }[] = [
{ value: "single_elimination", label: "Single Elim" },
{ value: "double_elimination", label: "Double Elim" },
{ value: "round_robin", label: "Round Robin" },
{ value: "swiss", label: "Swiss" },
];
export default function BracketTestLayout() {
const [format, setFormat] = useState<FormatType>("double_elimination");
const [teamCount, setTeamCount] = useState(8);
const [bracketExpanded, setBracketExpanded] = useState(true);
const [completedRounds, setCompletedRounds] = useState(0);
const [completedWbRounds, setCompletedWbRounds] = useState(0);
const [completedLbRounds, setCompletedLbRounds] = useState(0);
const clampedTeamCount = Math.max(2, teamCount);
const teams = generateTeams(clampedTeamCount);
const teamIds = teams.map((t) => t.id);
const data = generateBracketData(format, teamIds);
const isDoubleElim = format === "double_elimination";
const { totalRounds, wbRounds, lbRounds } = countRounds(data, isDoubleElim);
if (isDoubleElim) {
simulateCompletedRoundsByGroup(data, completedWbRounds, completedLbRounds);
} else {
simulateCompletedRounds(data, Math.min(completedRounds, totalRounds));
}
const mockTournament = {
ctx: {
id: 1,
name: "Bracket Test",
isFinalized: 0,
castedMatchesInfo: null,
teams,
settings: {
bracketProgression: [
{
name: "Test Bracket",
type: format,
requiresCheckIn: false,
settings: {},
},
],
},
bracketProgressionOverrides: [],
participatedUsers: teamIds,
},
brackets: [] as unknown[],
teamById: (id: number) => teams.find((t) => t.id === id) ?? null,
teamMemberOfByUser: () => null,
isOrganizer: () => false,
tournamentTeamLogoSrc: () => null,
streamingParticipantIds: [] as number[],
streams: [] as unknown[],
isLeagueDivision: false,
};
const mockBracket = {
id: 1,
idx: 0,
preview: false,
data,
type: format,
name: "Test Bracket",
canBeStarted: false,
tournament: mockTournament,
settings: format === "swiss" ? { roundCount: 5 } : null,
sources: undefined,
seeding: undefined,
createdAt: null,
requiresCheckIn: false,
startTime: null,
simulatedMatch: () => undefined,
currentStandings: () => [],
participantTournamentTeamIds: teamIds,
everyMatchOver: false,
isUnderground: false,
} as unknown as BracketType;
mockTournament.brackets = [mockBracket];
return (
<Main bigger>
<h1 className="text-lg">Bracket Test</h1>
<div className={styles.settings}>
<div className={styles.settingGroup}>
<Label>Format</Label>
<div className="stack horizontal sm">
{FORMAT_OPTIONS.map((opt) => (
<SendouButton
key={opt.value}
variant={format === opt.value ? undefined : "outlined"}
size="small"
onPress={() => setFormat(opt.value)}
>
{opt.label}
</SendouButton>
))}
</div>
</div>
<div className={styles.settingGroup}>
<Label htmlFor="team-count">Teams</Label>
<Input
id="team-count"
type="number"
min={2}
max={128}
value={String(teamCount)}
onChange={(e) => setTeamCount(Number(e.target.value))}
className={styles.teamCountInput}
/>
</div>
{isDoubleElim ? (
<>
<div className={styles.settingGroup}>
<Label htmlFor="completed-wb">Completed WB rounds</Label>
<Input
id="completed-wb"
type="number"
min={0}
max={wbRounds}
value={String(Math.min(completedWbRounds, wbRounds))}
onChange={(e) => setCompletedWbRounds(Number(e.target.value))}
className={styles.teamCountInput}
/>
</div>
<div className={styles.settingGroup}>
<Label htmlFor="completed-lb">Completed LB rounds</Label>
<Input
id="completed-lb"
type="number"
min={0}
max={lbRounds}
value={String(Math.min(completedLbRounds, lbRounds))}
onChange={(e) => setCompletedLbRounds(Number(e.target.value))}
className={styles.teamCountInput}
/>
</div>
</>
) : (
<div className={styles.settingGroup}>
<Label htmlFor="completed-rounds">Completed rounds</Label>
<Input
id="completed-rounds"
type="number"
min={0}
max={totalRounds}
value={String(Math.min(completedRounds, totalRounds))}
onChange={(e) => setCompletedRounds(Number(e.target.value))}
className={styles.teamCountInput}
/>
</div>
)}
<div className={styles.settingGroup}>
<SendouSwitch
id="expanded"
isSelected={bracketExpanded}
onChange={setBracketExpanded}
>
Expanded
</SendouSwitch>
</div>
</div>
<Outlet
context={{
tournament: mockTournament,
bracketExpanded,
setBracketExpanded,
hasChildTournaments: false,
preparedMaps: null,
bracket: mockBracket,
}}
/>
</Main>
);
}
function generateTeams(count: number) {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `Team ${i + 1}`,
seed: i + 1,
members: [{ userId: i + 1, username: `Player${i + 1}` }],
droppedOut: 0,
}));
}
function countRounds(data: TournamentManagerDataSet, isDoubleElim: boolean) {
const totalRounds = Math.max(...data.round.map((r) => r.number));
if (!isDoubleElim) return { totalRounds, wbRounds: 0, lbRounds: 0 };
const wbGroupId = data.group.find((g) => g.number === 1)?.id;
const lbGroupId = data.group.find((g) => g.number === 2)?.id;
const wbRounds = data.round.filter((r) => r.group_id === wbGroupId).length;
const lbRounds = data.round.filter((r) => r.group_id === lbGroupId).length;
return { totalRounds, wbRounds, lbRounds };
}
function simulateCompletedRoundsByGroup(
data: TournamentManagerDataSet,
wbCompleted: number,
lbCompleted: number,
) {
const wbGroupId = data.group.find((g) => g.number === 1)?.id;
const lbGroupId = data.group.find((g) => g.number === 2)?.id;
const completedRoundIds = new Set<number>();
for (const round of data.round) {
if (round.group_id === wbGroupId && round.number <= wbCompleted) {
completedRoundIds.add(round.id);
}
if (round.group_id === lbGroupId && round.number <= lbCompleted) {
completedRoundIds.add(round.id);
}
}
markMatchesCompleted(data, completedRoundIds);
}
function simulateCompletedRounds(
data: TournamentManagerDataSet,
completedRounds: number,
) {
if (completedRounds <= 0) return;
const roundsByNumber = new Map<number, number[]>();
for (const round of data.round) {
const existing = roundsByNumber.get(round.number) ?? [];
existing.push(round.id);
roundsByNumber.set(round.number, existing);
}
const completedRoundIds = new Set<number>();
for (let n = 1; n <= completedRounds; n++) {
for (const id of roundsByNumber.get(n) ?? []) {
completedRoundIds.add(id);
}
}
markMatchesCompleted(data, completedRoundIds);
}
function markMatchesCompleted(
data: TournamentManagerDataSet,
completedRoundIds: Set<number>,
) {
for (const match of data.match) {
if (!completedRoundIds.has(match.round_id)) continue;
// skip BYE matches (opponent slot is null entirely)
if (match.opponent1 === null || match.opponent2 === null) continue;
match.opponent1 = { ...match.opponent1, score: 2, result: "win" };
match.opponent2 = { ...match.opponent2, score: 0, result: "loss" };
match.status = 4;
}
}
function generateBracketData(
format: FormatType,
teamIds: number[],
): TournamentManagerDataSet {
if (format === "swiss") {
return Engine.create({
tournamentId: 1,
name: "Test Bracket",
type: "swiss",
seeding: teamIds,
settings: { groupCount: 1, roundCount: 5 },
});
}
const settings =
format === "single_elimination"
? { thirdPlaceMatch: false }
: format === "double_elimination"
? null
: { teamsPerGroup: 4 };
return Engine.create({
tournamentId: 1,
name: "Test Bracket",
type: format,
seeding: teamIds,
settings,
});
}