9 mintypescriptmodelagemproduto

Round Robin, Super 8 e Chave: modelando 4 formatos de torneio em TypeScript

Como representei Rei/Rainha da Praia, Super 8, Chave de Simples e Chave de Duplas em uma única estrutura de dados — e por que tentar uma 'super-classe' geral foi o erro inicial.


A primeira tentativa de modelar os formatos de torneio do BeachTennis Manager foi a clássica armadilha de generalização precoce. Eu queria uma única classe Tournament que cobrisse todos os formatos via flags. Round Robin? Liga roundRobin: true. Eliminação direta? Liga singleElimination: true. Super 8? Liga superEight: true.

Em duas semanas eu tinha um arquivo de 800 linhas que era impossível de entender e quase tão difícil de modificar. A função gerarPartidas() tinha cinco branches aninhadas, cada uma com sua própria lógica de geração de chave.

Joguei tudo fora.

Esse post é sobre como cheguei na modelagem que está em produção hoje — quatro tipos discriminados, com lógica isolada por formato — e por que isso foi muito mais fácil de manter.

Os 4 formatos

Antes de entrar em código, é útil explicar o que cada formato faz, porque a modelagem só fica clara depois.

Rei/Rainha da Praia — Round Robin em três fases progressivas:

  1. Grupos de 4 jogadores, todos contra todos
  2. Os 2 melhores de cada grupo avançam para a semifinal
  3. Final em chave eliminatória

Super 8 — Round Robin com 8 jogadores. Cada um joga 7 partidas (contra todos os outros). O vencedor é quem somar mais games.

Chave de Simples — Eliminação direta com 4 a 64 jogadores. Suporte a cabeças-de-chave (seeds).

Chave de Duplas — Eliminação direta com 4 a 64 duplas. Mesma estrutura da Simples, mas com pares ao invés de jogadores.

Notas:

  • Round Robin gera partidas de uma vez. Eliminação gera de fase em fase (vencedor da rodada N joga na rodada N+1).
  • Critério de desempate é diferente em cada um.
  • A unidade-base é diferente: jogadores na Simples, duplas na Duplas, jogadores em times rotativos no Super 8.

Tentar unificar tudo em uma estrutura única foi o erro.

A modelagem que funcionou: discriminated union

A virada foi tratar cada formato como um tipo separado, com discriminante:

type Tournament =
  | KingOfTheBeachTournament
  | SuperEightTournament
  | SinglesBracketTournament
  | DoublesBracketTournament;

type KingOfTheBeachTournament = {
  format: "king-of-the-beach";
  id: string;
  ownerId: string;
  name: string;
  createdAt: number;
  players: Player[];
  groups: Group[];
  semifinalMatches: Match[];
  finalMatch: Match | null;
};

type SuperEightTournament = {
  format: "super-eight";
  id: string;
  ownerId: string;
  name: string;
  createdAt: number;
  players: Player[]; // exatamente 8
  matches: Match[]; // exatamente 28
  rankings: SuperEightRanking[];
};

type SinglesBracketTournament = {
  format: "singles-bracket";
  id: string;
  ownerId: string;
  name: string;
  createdAt: number;
  players: Player[];
  bracket: BracketRound[];
  seeds: SeedAssignment[];
};

type DoublesBracketTournament = {
  format: "doubles-bracket";
  id: string;
  ownerId: string;
  name: string;
  createdAt: number;
  pairs: Pair[];
  bracket: BracketRound[];
  seeds: SeedAssignment[];
};

A discriminante (format) é uma string literal. TypeScript usa isso para narrowing automático. Em qualquer função que aceita Tournament, eu posso fazer:

function getCurrentRanking(t: Tournament): Ranking {
  switch (t.format) {
    case "king-of-the-beach":
      return calculateKingOfBeachRanking(t.groups, t.semifinalMatches, t.finalMatch);
    case "super-eight":
      return t.rankings[t.rankings.length - 1];
    case "singles-bracket":
      return calculateBracketRanking(t.bracket, t.players);
    case "doubles-bracket":
      return calculateBracketRanking(t.bracket, t.pairs);
  }
}

Cada caso só vê os campos que aquele tipo específico tem. Não tem t.groups em torneio de Chave (não compila). Não tem t.bracket em Super 8 (não compila). O compilador me protege.

A consequência: lógica isolada por formato

A modelagem por união discriminada me forçou a separar a lógica em arquivos por formato:

src/lib/tournaments/
├── types.ts                    // Discriminated union
├── shared.ts                   // Tipos compartilhados (Player, Match, Score)
├── king-of-the-beach/
│   ├── generate-matches.ts
│   ├── advance-phase.ts
│   ├── ranking.ts
│   └── tiebreak.ts
├── super-eight/
│   ├── generate-matches.ts
│   ├── ranking.ts
│   └── tiebreak.ts
├── singles-bracket/
│   ├── generate-bracket.ts
│   ├── advance-winner.ts
│   ├── ranking.ts
│   └── seeds.ts
└── doubles-bracket/
    ├── generate-bracket.ts
    ├── advance-winner.ts
    ├── ranking.ts
    └── seeds.ts

Cada formato tem suas próprias funções, isoladas. A função "gerar partidas iniciais" para Rei/Rainha da Praia não compartilha código com a função correspondente da Chave de Simples — porque a lógica é fundamentalmente diferente.

A tentação no início foi tentar fatorar isso em uma classe-base com hooks. Em geral, não vale. A duplicação aparente desaparece quando você olha de perto: as funções têm nomes parecidos e assinaturas parecidas, mas a implementação é diferente o suficiente para que tentar unificar gere mais código do que economize.

A ressalva: tipos compartilhados de verdade

Algumas coisas são universais:

// shared.ts
export type Player = {
  id: string;
  name: string;
  email?: string;
};

export type Pair = {
  id: string;
  playerA: Player;
  playerB: Player;
  name: string; // "João/Maria"
};

export type Score = {
  setA: number;
  setB: number;
};

export type Match = {
  id: string;
  participantA: string; // playerId ou pairId
  participantB: string;
  score: Score | null; // null = ainda não jogada
  scheduledAt?: number;
  completedAt?: number;
};

Esses tipos viajam por todos os formatos. Player, Match, Score são abstrações limpas que cada formato consome do seu jeito.

A regra: abstraia o que é igual em comportamento, não o que é igual em forma.

Geração de partidas: Round Robin

A geração de Round Robin (usada em Super 8 e nos grupos de Rei/Rainha) usa o algoritmo do círculo:

export function generateRoundRobinMatches(
  participants: string[],
): Match[] {
  if (participants.length % 2 !== 0) {
    participants = [...participants, "BYE"];
  }

  const n = participants.length;
  const rounds = n - 1;
  const matchesPerRound = n / 2;
  const result: Match[] = [];

  // Algoritmo do círculo: fixa o primeiro, rotaciona o resto
  const players = [...participants];

  for (let round = 0; round < rounds; round++) {
    for (let i = 0; i < matchesPerRound; i++) {
      const a = players[i];
      const b = players[n - 1 - i];

      if (a !== "BYE" && b !== "BYE") {
        result.push({
          id: crypto.randomUUID(),
          participantA: a,
          participantB: b,
          score: null,
        });
      }
    }

    // Rotaciona, mantendo o primeiro fixo
    const last = players.pop()!;
    players.splice(1, 0, last);
  }

  return result;
}

Com 8 jogadores, isso gera exatamente 28 partidas em 7 rodadas, todos contra todos.

Geração de chave eliminatória

Eliminação direta tem uma sutileza importante: distribuição de cabeças-de-chave.

Você não quer que os dois melhores jogadores se enfrentem na primeira rodada. Eles devem cair em metades opostas da chave. O padrão é o standard seeding:

export function generateSeedingPositions(numParticipants: number): number[] {
  const size = nextPowerOfTwo(numParticipants);
  const positions: number[] = new Array(size);

  function fill(start: number, end: number, seedFrom: number, seedTo: number) {
    if (start === end) {
      positions[start] = seedFrom;
      return;
    }
    const mid = Math.floor((start + end) / 2);
    fill(start, mid, seedFrom, seedFrom + (seedTo - seedFrom + 1) / 2 - 1);
    fill(mid + 1, end, seedFrom + (seedTo - seedFrom + 1) / 2, seedTo);
  }

  fill(0, size - 1, 1, size);
  return positions;
}

Para 8 jogadores, o resultado é [1, 8, 5, 4, 3, 6, 7, 2]. Significa: o 1º cabeça encara o 8º, o 5º encara o 4º, etc. Isso garante que o 1 e o 2 só se enfrentariam (se vencerem todos os jogos) na final.

Tem variações (random within seed group, snake seeding) — o app suporta opções, mas o default é standard.

Avanço de fase: Rei/Rainha da Praia

O caso mais complexo é o Rei/Rainha, porque tem três fases sequenciais. A lógica de avanço:

export function tryAdvanceFromGroups(
  t: KingOfTheBeachTournament,
): KingOfTheBeachTournament {
  const allGroupsComplete = t.groups.every((g) =>
    g.matches.every((m) => m.score !== null),
  );

  if (!allGroupsComplete) return t; // Não avança

  // Pega os 2 melhores de cada grupo
  const semifinalists = t.groups.flatMap((g) =>
    rankInGroup(g).slice(0, 2).map((r) => r.playerId),
  );

  // Distribui em semifinais cruzadas: 1ºA vs 2ºB, 1ºB vs 2ºA
  const semifinalMatches: Match[] = [
    {
      id: crypto.randomUUID(),
      participantA: semifinalists[0], // 1º do grupo A
      participantB: semifinalists[3], // 2º do grupo B
      score: null,
    },
    {
      id: crypto.randomUUID(),
      participantA: semifinalists[2], // 1º do grupo B
      participantB: semifinalists[1], // 2º do grupo A
      score: null,
    },
  ];

  return { ...t, semifinalMatches };
}

Note a imutabilidade: a função recebe um torneio e retorna um novo. Isso é crítico, porque o estado é serializado pra Redis a cada mudança.

O que essa modelagem me deu

Três coisas:

1. Compilador como guarda. Erros que antes eram silenciosos (esquecer de tratar um caso de formato) agora são erros de compilação. O switch exaustivo do TypeScript me força a tratar todos os formatos.

2. Refatoração segura. Quando adiciono um campo a um formato, só mexo no arquivo daquele formato. Os outros são intocados. Nenhum efeito colateral.

3. Onboarding mais simples. Se eu (ou outro dev no futuro) quero entender como Rei/Rainha funciona, vou direto na pasta king-of-the-beach/. Não preciso decifrar 800 linhas de polimorfismo virando flag.

A regra que levei

A modelagem prematura é uma armadilha clássica que pega quem está começando. Mas a generalização prematura também é — e essa pega gente experiente também. A diferença é sutil:

  • Modelagem prematura = criar entidades para coisas que ainda não existem.
  • Generalização prematura = unificar entidades que existem em uma única abstração antes de saber se elas se beneficiam disso.

Com 4 formatos de torneio diferentes, a generalização não pagou. Cada formato tem regras suficientemente diferentes para que o custo de manter uma abstração unificada seja maior que o custo de manter quatro implementações isoladas.

A regra de bolso: antes de unificar, escreva os casos separados primeiro. Se depois de implementados ficar claro que boa parte do código se repete, aí você fatora. Se não ficar claro, deixa separado.

Em muitos casos, deixar separado acaba sendo a resposta certa.