Skip to content

Tournament runner

Orchestrates the three-phase tournament structure:

  • Phase 1 -- full round robin within each Smogon tier
  • Phase 2 -- adjacent-tier playoffs between tier champions
  • Phase 3 -- grand final round robin among all playoff winners

Battle processing is parallelized with ProcessPoolExecutor.

runner

Tournament engine -- runs round robins, playoffs, and the grand final.

Phase 1: Full round robin within each Smogon tier (N*(N-1)/2 matchups). Phase 2: Adjacent tier playoffs (champion of lower tier vs champion of upper). Phase 3: Grand final -- all playoff winners in a full round robin.

All phases use multiprocessing for parallelism.

MatchupRecord dataclass

MatchupRecord(
    pokemon_a: str,
    pokemon_b: str,
    battles: int = 0,
    wins_a: int = 0,
    wins_b: int = 0,
    avg_hp_pct_winner: float = 0.0,
    type_advantage_wins: int = 0,
)

Aggregated results for a specific A-vs-B matchup.

win_rate_a property

win_rate_a: float

Win rate of pokemon_a across all recorded battles.

win_rate_b property

win_rate_b: float

Win rate of pokemon_b across all recorded battles.

TierLeaderboard dataclass

TierLeaderboard(
    tier: str,
    gen: int,
    entries: list[LeaderboardEntry] = list(),
    champion: str | None = None,
    champion_win_rate: float = 0.0,
)

Leaderboard for a single tier tournament.

LeaderboardEntry dataclass

LeaderboardEntry(
    rank: int,
    name: str,
    wins: int,
    losses: int,
    battles: int,
    win_rate: float,
    avg_hp_pct: float,
    tier: str,
    bst: int,
    types: str,
)

Single row in a tier leaderboard, ranked by win rate.

PlayoffResult dataclass

PlayoffResult(
    lower_tier: str,
    upper_tier: str,
    lower_champion: str,
    upper_champion: str,
    lower_wins: int,
    upper_wins: int,
    battles: int,
    upset: bool,
)

Outcome of a Phase 2 adjacent-tier playoff.

winner property

winner: str

Return the name of the winning champion.

lower_win_rate property

lower_win_rate: float

Win rate of the lower-tier champion in this playoff.

GrandFinalResult dataclass

GrandFinalResult(
    gen: int,
    entries: list[GrandFinalEntry] = list(),
    champion: str | None = None,
    matchup_matrix: dict[str, dict[str, float]] = dict(),
)

Results of the Phase 3 grand final round robin.

GrandFinalEntry dataclass

GrandFinalEntry(
    rank: int,
    name: str,
    win_rate: float,
    source_tier: str,
    smogon_tier: str,
)

Single ranked entry in the grand final leaderboard.

run_tier_tournament

run_tier_tournament(
    tier: str,
    pokemon: list[Pokemon],
    n_battles: int = 20,
    rand_ivs: bool = False,
    seed: int | None = None,
    workers: int = 4,
    gen: int = 6,
    progress: Any = None,
) -> tuple[TierLeaderboard, list[MatchupRecord]]

Run a full round robin within a tier. Returns (leaderboard, all matchup records). If progress is a tqdm bar, it is advanced by n_battles per completed matchup.

Source code in pokerena/tournament/runner.py
def run_tier_tournament(
    tier: str,
    pokemon: list[Pokemon],
    n_battles: int = 20,
    rand_ivs: bool = False,
    seed: int | None = None,
    workers: int = 4,
    gen: int = 6,
    progress: Any = None,
) -> tuple[TierLeaderboard, list[MatchupRecord]]:
    """
    Run a full round robin within a tier.
    Returns (leaderboard, all matchup records).
    If progress is a tqdm bar, it is advanced by n_battles per completed matchup.
    """
    if len(pokemon) < 2:
        log.warning("Tier %s has fewer than 2 Pokemon -- skipping.", tier)
        lb = TierLeaderboard(tier=tier, gen=0)
        return lb, []

    pairs = list(combinations(pokemon, 2))
    log.info(
        "Tier %s: %d Pokemon, %d matchups, %d battles each",
        tier,
        len(pokemon),
        len(pairs),
        n_battles,
    )

    # Build worker args
    base_seed = seed if seed is not None else random.randint(0, 2**32)
    tasks = [
        (
            _pokemon_to_dict(a),
            _pokemon_to_dict(b),
            n_battles,
            rand_ivs,
            base_seed + i,
            gen,
        )
        for i, (a, b) in enumerate(pairs)
    ]

    records: list[MatchupRecord] = []
    pool = ProcessPoolExecutor(max_workers=workers)
    try:
        futures = {pool.submit(_run_matchup_worker, t): t for t in tasks}
        for fut in as_completed(futures):
            try:
                records.append(fut.result())
            except Exception as exc:  # noqa: BLE001
                log.error("Matchup failed: %s", exc)
            if progress is not None:
                progress.update(n_battles)
    except KeyboardInterrupt:
        pool.shutdown(wait=False, cancel_futures=True)
        raise
    else:
        pool.shutdown(wait=True)

    leaderboard = _build_leaderboard(tier, pokemon, records)
    return leaderboard, records

run_tiebreaker

run_tiebreaker(
    a: Pokemon,
    b: Pokemon,
    n_battles: int = 50,
    rand_ivs: bool = False,
    seed: int | None = None,
    gen: int = 6,
) -> str

Run a tiebreaker between two tied Pokemon. Returns the name of the winner.

Source code in pokerena/tournament/runner.py
def run_tiebreaker(
    a: Pokemon,
    b: Pokemon,
    n_battles: int = 50,
    rand_ivs: bool = False,
    seed: int | None = None,
    gen: int = 6,
) -> str:
    """
    Run a tiebreaker between two tied Pokemon.
    Returns the name of the winner.
    """
    from pokerena.engine.rules import RULES_BY_GEN, Gen6Rules

    rules = RULES_BY_GEN.get(gen, Gen6Rules())
    rng = random.Random(seed)
    wins_a = wins_b = 0
    for _ in range(n_battles):
        result = run_battle(a, b, rand_ivs=rand_ivs, rng=rng, rules=rules)
        if result.winner == a.name:
            wins_a += 1
        else:
            wins_b += 1
    return a.name if wins_a >= wins_b else b.name

run_playoff

run_playoff(
    lower_tier: str,
    upper_tier: str,
    lower_champion: Pokemon,
    upper_champion: Pokemon,
    n_battles: int = 50,
    rand_ivs: bool = False,
    seed: int | None = None,
    gen: int = 6,
) -> PlayoffResult

Run a best-of-N playoff between two tier champions.

Source code in pokerena/tournament/runner.py
def run_playoff(
    lower_tier: str,
    upper_tier: str,
    lower_champion: Pokemon,
    upper_champion: Pokemon,
    n_battles: int = 50,
    rand_ivs: bool = False,
    seed: int | None = None,
    gen: int = 6,
) -> PlayoffResult:
    """
    Run a best-of-N playoff between two tier champions.
    """
    from pokerena.engine.rules import RULES_BY_GEN, Gen6Rules

    rules = RULES_BY_GEN.get(gen, Gen6Rules())
    rng = random.Random(seed)
    wins_lower = wins_upper = 0
    for _ in range(n_battles):
        result = run_battle(
            lower_champion,
            upper_champion,
            rand_ivs=rand_ivs,
            rng=rng,
            rules=rules,
        )
        if result.winner == lower_champion.name:
            wins_lower += 1
        else:
            wins_upper += 1

    upset = wins_lower > wins_upper
    return PlayoffResult(
        lower_tier=lower_tier,
        upper_tier=upper_tier,
        lower_champion=lower_champion.name,
        upper_champion=upper_champion.name,
        lower_wins=wins_lower,
        upper_wins=wins_upper,
        battles=n_battles,
        upset=upset,
    )

run_grand_final

run_grand_final(
    gen: int,
    finalists: list[tuple[Pokemon, str]],
    tier_leaderboards: dict[str, TierLeaderboard],
    n_battles: int = 100,
    rand_ivs: bool = False,
    seed: int | None = None,
    workers: int = 4,
    progress: Any = None,
) -> GrandFinalResult

Full round robin among playoff winners. Returns a GrandFinalResult with rankings and win-rate matrix. If progress is a tqdm bar, it is advanced by n_battles per completed matchup.

Source code in pokerena/tournament/runner.py
def run_grand_final(
    gen: int,
    finalists: list[tuple[Pokemon, str]],  # (pokemon, source_tier)
    tier_leaderboards: dict[str, TierLeaderboard],
    n_battles: int = 100,
    rand_ivs: bool = False,
    seed: int | None = None,
    workers: int = 4,
    progress: Any = None,
) -> GrandFinalResult:
    """
    Full round robin among playoff winners.
    Returns a GrandFinalResult with rankings and win-rate matrix.
    If progress is a tqdm bar, it is advanced by n_battles per completed matchup.
    """
    pokemon_list = [p for p, _ in finalists]
    source_map = {p.name: t for p, t in finalists}
    smogon_map = {p.name: p.smogon_tier for p, _ in finalists}

    pairs = list(combinations(pokemon_list, 2))
    base_seed = seed if seed is not None else random.randint(0, 2**32)
    tasks = [
        (
            _pokemon_to_dict(a),
            _pokemon_to_dict(b),
            n_battles,
            rand_ivs,
            base_seed + i,
            gen,
        )
        for i, (a, b) in enumerate(pairs)
    ]

    records: list[MatchupRecord] = []
    pool = ProcessPoolExecutor(max_workers=workers)
    try:
        futures = {pool.submit(_run_matchup_worker, t): t for t in tasks}
        for fut in as_completed(futures):
            try:
                records.append(fut.result())
            except Exception as exc:  # noqa: BLE001
                log.error("Grand final matchup failed: %s", exc)
            if progress is not None:
                progress.update(n_battles)
    except KeyboardInterrupt:
        pool.shutdown(wait=False, cancel_futures=True)
        raise
    else:
        pool.shutdown(wait=True)

    # Build win totals
    wins: dict[str, int] = defaultdict(int)
    total: dict[str, int] = defaultdict(int)
    matrix: dict[str, dict[str, float]] = {p.name: {} for p in pokemon_list}

    for rec in records:
        wins[rec.pokemon_a] += rec.wins_a
        wins[rec.pokemon_b] += rec.wins_b
        total[rec.pokemon_a] += rec.battles
        total[rec.pokemon_b] += rec.battles
        matrix[rec.pokemon_a][rec.pokemon_b] = rec.win_rate_a
        matrix[rec.pokemon_b][rec.pokemon_a] = rec.win_rate_b

    entries: list[GrandFinalEntry] = []
    for p in pokemon_list:
        wr = wins[p.name] / total[p.name] if total[p.name] else 0.0
        entries.append(
            GrandFinalEntry(
                rank=0,
                name=p.name,
                win_rate=wr,
                source_tier=source_map.get(p.name, "unknown"),
                smogon_tier=smogon_map.get(p.name, "unknown"),
            )
        )

    entries.sort(key=lambda e: e.win_rate, reverse=True)
    for i, entry in enumerate(entries):
        entry.rank = i + 1

    result = GrandFinalResult(gen=gen, entries=entries, matchup_matrix=matrix)
    if entries:
        result.champion = entries[0].name
    return result

run_full_tournament

run_full_tournament(
    gen: int,
    pokemon_by_tier: dict[str, list[Pokemon]],
    n_battles_phase1: int = 20,
    n_battles_phase2: int = 50,
    n_battles_phase3: int = 100,
    rand_ivs: bool = False,
    seed: int | None = None,
    workers: int = 4,
    progress: Any = None,
) -> dict

Run the full 3-phase tournament for one generation. Returns a results dict with all leaderboards, playoff results, and grand final. If progress is a tqdm bar, it is advanced throughout all three phases.

Source code in pokerena/tournament/runner.py
def run_full_tournament(
    gen: int,
    pokemon_by_tier: dict[str, list[Pokemon]],
    n_battles_phase1: int = 20,
    n_battles_phase2: int = 50,
    n_battles_phase3: int = 100,
    rand_ivs: bool = False,
    seed: int | None = None,
    workers: int = 4,
    progress: Any = None,
) -> dict:
    """
    Run the full 3-phase tournament for one generation.
    Returns a results dict with all leaderboards, playoff results, and grand final.
    If progress is a tqdm bar, it is advanced throughout all three phases.
    """
    results: dict = {
        "gen": gen,
        "tier_leaderboards": {},
        "tier_records": {},
        "playoffs": [],
        "grand_final": None,
        "upsets": [],
    }

    # --- Phase 1: Tier round robins ---
    champions: dict[str, Pokemon] = {}
    poke_map = {p.name: p for tier_pokes in pokemon_by_tier.values() for p in tier_pokes}

    for tier in TIER_ORDER:
        pokes = pokemon_by_tier.get(tier, [])
        if not pokes:
            log.info("Tier %s: no Pokemon, skipping.", tier)
            continue

        lb, records = run_tier_tournament(
            tier=tier,
            pokemon=pokes,
            n_battles=n_battles_phase1,
            rand_ivs=rand_ivs,
            seed=seed,
            workers=workers,
            gen=gen,
            progress=progress,
        )
        lb.gen = gen
        results["tier_leaderboards"][tier] = lb
        results["tier_records"][tier] = records

        if not lb.champion:
            continue

        # Tiebreaker if top two tied
        if len(lb.entries) >= 2 and lb.entries[0].win_rate == lb.entries[1].win_rate:
            a = poke_map[lb.entries[0].name]
            b = poke_map[lb.entries[1].name]
            winner_name = run_tiebreaker(
                a,
                b,
                n_battles=50,
                rand_ivs=rand_ivs,
                seed=seed,
                gen=gen,
            )
            if winner_name == b.name:
                lb.entries[0], lb.entries[1] = lb.entries[1], lb.entries[0]
                lb.champion = winner_name
            log.info("Tiebreaker for %s champion: %s wins", tier, winner_name)

        champion_name = lb.champion
        champions[tier] = poke_map[champion_name]
        log.info(
            "  %s champion: %s (%.1f%%)",
            tier.upper(),
            champion_name,
            lb.champion_win_rate * 100,
        )

    # --- Phase 2: Adjacent tier playoffs ---
    playoff_winners: list[tuple[Pokemon, str]] = []  # (pokemon, source_tier)
    adjacent_pairs = list(zip(TIER_ORDER, TIER_ORDER[1:], strict=False))

    for lower_tier, upper_tier in adjacent_pairs:
        if lower_tier not in champions or upper_tier not in champions:
            log.info("Skipping playoff %s vs %s -- missing champion.", lower_tier, upper_tier)
            continue

        pr = run_playoff(
            lower_tier=lower_tier,
            upper_tier=upper_tier,
            lower_champion=champions[lower_tier],
            upper_champion=champions[upper_tier],
            n_battles=n_battles_phase2,
            rand_ivs=rand_ivs,
            seed=seed,
            gen=gen,
        )
        results["playoffs"].append(pr)
        if progress is not None:
            progress.update(n_battles_phase2)
        if pr.upset:
            results["upsets"].append(pr)
            log.info(
                "  UPSET: %s (%s) beats %s (%s) %d-%d",
                pr.lower_champion,
                lower_tier,
                pr.upper_champion,
                upper_tier,
                pr.lower_wins,
                pr.upper_wins,
            )

        winner_pokemon = champions[
            pr.winner.replace(pr.lower_champion, lower_tier)
            if pr.winner == pr.lower_champion
            else upper_tier
        ]
        # Simpler: look up by name
        winner_pokemon = champions.get(lower_tier if pr.winner == pr.lower_champion else upper_tier)
        if winner_pokemon:
            source_tier = lower_tier if pr.winner == pr.lower_champion else upper_tier
            playoff_winners.append((winner_pokemon, source_tier))

    # --- Phase 3: Grand final ---
    if len(playoff_winners) >= 2:
        gf = run_grand_final(
            gen=gen,
            finalists=playoff_winners,
            tier_leaderboards=results["tier_leaderboards"],
            n_battles=n_battles_phase3,
            rand_ivs=rand_ivs,
            seed=seed,
            workers=workers,
            progress=progress,
        )
        results["grand_final"] = gf
        log.info(
            "Gen %d Champion: %s (%.1f%%)",
            gen,
            gf.champion,
            (gf.entries[0].win_rate * 100) if gf.entries else 0,
        )
    else:
        log.warning("Not enough playoff winners for grand final.")

    return results