# Fase 3.1 — Engine Bolgia · Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development o executing-plans. Step con checkbox.

**Goal:** Implementare l'engine della Bolgia come funzioni pure testate (round-robin, classifiche, seeding, promo/retro), senza DB né UI.

**Architecture:** Funzioni pure in `src/lib/server/engine/`, tipi propri, niente import di `db`. Round-robin generato da design combinatori ciclici (PG(2,3)+STS(13) per 13; 2 base-block per 7), verificato da `verifyRoundRobin`. Tutti i test sono unit offline.

**Tech Stack:** TypeScript, Vitest. Spec: [`../specs/2026-06-09-fase-3-1-engine-bolgia-design.md`](../specs/2026-06-09-fase-3-1-engine-bolgia-design.md).

**Note:** branch `fase-3-1-engine-bolgia` (creato). Comandi `pnpm -C "/Users/paolo/Server/Siti Web/Lavoro/TtA" ...`. Tutto offline (nessuno step DB).

## File Structure
- Create: `src/lib/server/engine/{types,round-robin,standings,seeding,promo-retro,index}.ts` + relativi `*.test.ts`.
- Modify: `src/lib/server/config/templates.ts` (`BOLGIA_7.incontriPerCoppia` 1→2) + `templates.test.ts`.

---

## Task 1: Correzione template BOLGIA_7

**Files:** Modify `src/lib/server/config/templates.ts`, `src/lib/server/config/templates.test.ts`

- [ ] **Step 1: test** — in `templates.test.ts` aggiungi:
```ts
it('BOLGIA_7 incontra ogni coppia due volte (incontriPerCoppia=2)', () => {
  expect(BOLGIA_7.incontriPerCoppia).toBe(2);
});
```
- [ ] **Step 2: run** → FAIL (oggi è 1). `pnpm test`
- [ ] **Step 3: fix** — in `templates.ts`, `BOLGIA_7.incontriPerCoppia: 1` → `2`.
- [ ] **Step 4: run** → PASS. `pnpm test`
- [ ] **Step 5: commit**
```bash
git add src/lib/server/config/templates.ts src/lib/server/config/templates.test.ts
git commit -m "fix(fase3.1): BOLGIA_7 incontriPerCoppia 1->2 (regolamento: ogni coppia 2 volte)"
```

---

## Task 2: Tipi engine + `verifyRoundRobin`

**Files:** Create `src/lib/server/engine/types.ts`, `src/lib/server/engine/round-robin.ts`, `src/lib/server/engine/round-robin.test.ts`

- [ ] **Step 1: tipi** — `src/lib/server/engine/types.ts` (come da spec §4): `RoundRobinMatch`, `RoundRobinSchedule`, `ParticipantResult`, `MatchResult`, `StandingRow`, `SeedInput`, `SeedGirone`, `PromoRetroProposal`.

- [ ] **Step 2: test verifier** — `round-robin.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import { verifyRoundRobin } from './round-robin';
import { BOLGIA_7 } from '$lib/server/config/templates';

describe('verifyRoundRobin', () => {
  it('accetta uno schedule valido (2 giocatori, 1 incontro)', () => {
    const s = [{ nGiocatori: 2, playerIndices: [0, 1] }];
    const cfg = { dimensione: 2, incontriPerCoppia: 1, composizione: [{ n: 1, giocatori: 2 }] };
    expect(verifyRoundRobin(s, cfg).ok).toBe(true);
  });
  it('rifiuta coppia mancante', () => {
    const s = [{ nGiocatori: 2, playerIndices: [0, 1] }];
    const cfg = { dimensione: 3, incontriPerCoppia: 1, composizione: [{ n: 1, giocatori: 2 }] };
    expect(verifyRoundRobin(s, cfg).ok).toBe(false);
  });
  it('rifiuta taglia partita errata', () => {
    const s = [{ nGiocatori: 3, playerIndices: [0, 1] }];
    const cfg = { dimensione: 2, incontriPerCoppia: 1, composizione: [{ n: 1, giocatori: 2 }] };
    expect(verifyRoundRobin(s, cfg).ok).toBe(false);
  });
});
```

- [ ] **Step 3: run** → FAIL (modulo assente).

- [ ] **Step 4: impl** — `round-robin.ts` (parte verifier):
```ts
import type { RoundRobinSchedule } from './types';

type VerifyConfig = { dimensione: number; incontriPerCoppia: number; composizione: { n: number; giocatori: number }[] };

const pairKey = (a: number, b: number) => (a < b ? `${a}-${b}` : `${b}-${a}`);

export function verifyRoundRobin(schedule: RoundRobinSchedule, cfg: VerifyConfig): { ok: boolean; errori: string[] } {
  const errori: string[] = [];
  const pairCount = new Map<string, number>();
  const perPlayerSize = new Map<number, Map<number, number>>(); // player -> size -> count

  for (const m of schedule) {
    if (m.playerIndices.length !== m.nGiocatori) errori.push(`partita con ${m.playerIndices.length} indici ma nGiocatori=${m.nGiocatori}`);
    const set = new Set(m.playerIndices);
    if (set.size !== m.playerIndices.length) errori.push(`indici duplicati in una partita: ${m.playerIndices}`);
    for (const p of m.playerIndices) {
      if (p < 0 || p >= cfg.dimensione) errori.push(`indice fuori range: ${p}`);
      const bySize = perPlayerSize.get(p) ?? new Map<number, number>();
      bySize.set(m.nGiocatori, (bySize.get(m.nGiocatori) ?? 0) + 1);
      perPlayerSize.set(p, bySize);
    }
    for (let i = 0; i < m.playerIndices.length; i++)
      for (let j = i + 1; j < m.playerIndices.length; j++) {
        const k = pairKey(m.playerIndices[i], m.playerIndices[j]);
        pairCount.set(k, (pairCount.get(k) ?? 0) + 1);
      }
  }

  // ogni coppia esattamente incontriPerCoppia volte
  for (let i = 0; i < cfg.dimensione; i++)
    for (let j = i + 1; j < cfg.dimensione; j++) {
      const c = pairCount.get(pairKey(i, j)) ?? 0;
      if (c !== cfg.incontriPerCoppia) errori.push(`coppia ${i}-${j} si incontra ${c} volte (atteso ${cfg.incontriPerCoppia})`);
    }

  // composizione per giocatore
  for (let p = 0; p < cfg.dimensione; p++) {
    const bySize = perPlayerSize.get(p) ?? new Map<number, number>();
    for (const { n, giocatori } of cfg.composizione) {
      const got = bySize.get(giocatori) ?? 0;
      if (got !== n) errori.push(`giocatore ${p} gioca ${got} partite a ${giocatori} (attese ${n})`);
    }
  }

  return { ok: errori.length === 0, errori };
}
```

- [ ] **Step 5: run** → PASS. **Step 6: commit**
```bash
git add src/lib/server/engine/types.ts src/lib/server/engine/round-robin.ts src/lib/server/engine/round-robin.test.ts
git commit -m "feat(fase3.1): tipi engine + verifyRoundRobin (invariante coppie + composizione)"
```

---

## Task 3: `generateRoundRobin` (costruzioni 13 e 7)

**Files:** Modify `src/lib/server/engine/round-robin.ts`, `round-robin.test.ts`

- [ ] **Step 1: test** — aggiungi a `round-robin.test.ts`:
```ts
import { generateRoundRobin } from './round-robin';
import { BOLGIA_13 } from '$lib/server/config/templates';

describe('generateRoundRobin', () => {
  it('Bolgia-13 passa il verifier (ogni coppia 2x, 6x3 + 4x4)', () => {
    const s = generateRoundRobin(13, BOLGIA_13);
    expect(s).toHaveLength(39);
    expect(verifyRoundRobin(s, BOLGIA_13).ok).toBe(true);
  });
  it('Bolgia-7 passa il verifier (ogni coppia 2x, 6x3)', () => {
    const s = generateRoundRobin(7, BOLGIA_7);
    expect(s).toHaveLength(14);
    expect(verifyRoundRobin(s, BOLGIA_7).ok).toBe(true);
  });
  it('è deterministico', () => {
    expect(generateRoundRobin(13, BOLGIA_13)).toEqual(generateRoundRobin(13, BOLGIA_13));
  });
  it('dimensione non supportata → throw', () => {
    expect(() => generateRoundRobin(4, { dimensione: 4, incontriPerCoppia: 5, composizione: [] } as never)).toThrow();
  });
});
```

- [ ] **Step 2: run** → FAIL. **Step 3: impl** — aggiungi a `round-robin.ts`:
```ts
function develop(base: number[], n: number): number[][] {
  return Array.from({ length: n }, (_, i) => base.map((x) => (x + i) % n).sort((a, b) => a - b));
}

// Costruzioni cicliche verificate (vedi spec §2). Chiave = dimensione.
const CONSTRUCTIONS: Record<number, { size: number; bases: number[][] }[]> = {
  13: [
    { size: 4, bases: [[0, 1, 3, 9]] }, // PG(2,3): ogni coppia 1x
    { size: 3, bases: [[0, 1, 4], [0, 2, 7]] } // STS(13): ogni coppia 1x
  ],
  7: [{ size: 3, bases: [[0, 1, 3], [0, 1, 5]] }] // 2-(7,3,2)
};

export function generateRoundRobin(dimensione: number, cfg: VerifyConfig): RoundRobinSchedule {
  const groups = CONSTRUCTIONS[dimensione];
  if (!groups) throw new Error(`Round-robin non supportato per dimensione ${dimensione} (formati supportati: 7, 13)`);
  const schedule: RoundRobinSchedule = [];
  for (const g of groups)
    for (const base of g.bases)
      for (const block of develop(base, dimensione)) schedule.push({ nGiocatori: g.size, playerIndices: block });
  const v = verifyRoundRobin(schedule, cfg);
  if (!v.ok) throw new Error(`schedule generato non valido: ${v.errori.join('; ')}`);
  return schedule;
}
```
(Nota: `VerifyConfig` è già nel file; aggiungi `import type { RoundRobinSchedule } from './types'` se non presente.)

- [ ] **Step 4: run** → PASS. **Step 5: commit**
```bash
git add src/lib/server/engine/round-robin.ts src/lib/server/engine/round-robin.test.ts
git commit -m "feat(fase3.1): generateRoundRobin via design combinatori (PG(2,3)+STS(13), 2-(7,3,2))"
```

---

## Task 4: `computeStandings`

**Files:** Create `src/lib/server/engine/standings.ts`, `standings.test.ts`

- [ ] **Step 1: test** — `standings.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import { computeStandings } from './standings';
import type { MatchResult } from './types';

const tab = { '2': [4, 0], '3': [10, 4, 0], '4': [11, 6, 2, 0] };

describe('computeStandings', () => {
  it('assegna i punti per posizione (3 giocatori)', () => {
    const matches: MatchResult[] = [
      { nGiocatori: 3, partecipanti: [
        { playerId: 1, isBot: false, puntiCultura: 200, resign: false },
        { playerId: 2, isBot: false, puntiCultura: 150, resign: false },
        { playerId: 3, isBot: false, puntiCultura: 100, resign: false }
      ] }
    ];
    const r = computeStandings(matches, { tabellaPunti: tab });
    expect(r.find((x) => x.playerId === 1)!.puntiTorneo).toBe(10);
    expect(r.find((x) => x.playerId === 2)!.puntiTorneo).toBe(4);
    expect(r.find((x) => x.playerId === 3)!.puntiTorneo).toBe(0);
  });
  it('spartisce i punti in caso di parità di cultura', () => {
    const matches: MatchResult[] = [
      { nGiocatori: 3, partecipanti: [
        { playerId: 1, isBot: false, puntiCultura: 200, resign: false },
        { playerId: 2, isBot: false, puntiCultura: 200, resign: false },
        { playerId: 3, isBot: false, puntiCultura: 100, resign: false }
      ] }
    ];
    const r = computeStandings(matches, { tabellaPunti: tab });
    expect(r.find((x) => x.playerId === 1)!.puntiTorneo).toBe(7); // (10+4)/2
    expect(r.find((x) => x.playerId === 2)!.puntiTorneo).toBe(7);
  });
  it('il bot conta come giocatore (toglie punti)', () => {
    const matches: MatchResult[] = [
      { nGiocatori: 3, partecipanti: [
        { playerId: null, isBot: true, puntiCultura: 250, resign: false },
        { playerId: 1, isBot: false, puntiCultura: 200, resign: false },
        { playerId: 2, isBot: false, puntiCultura: 100, resign: false }
      ] }
    ];
    const r = computeStandings(matches, { tabellaPunti: tab });
    // bot 1°, quindi player1 prende i punti del 2° posto
    expect(r.find((x) => x.playerId === 1)!.puntiTorneo).toBe(4);
    expect(r.some((x) => x.isBot)).toBe(false); // bot escluso dalla classifica
  });
  it('spareggio = % cultura vs vincitore, resign=0.5, ordina per (torneo, spareggio)', () => {
    const matches: MatchResult[] = [
      { nGiocatori: 3, partecipanti: [
        { playerId: 1, isBot: false, puntiCultura: 100, resign: false },
        { playerId: 2, isBot: false, puntiCultura: 100, resign: false },
        { playerId: 3, isBot: false, puntiCultura: 0, resign: true }
      ] }
    ];
    const r = computeStandings(matches, { tabellaPunti: tab });
    expect(r.find((x) => x.playerId === 1)!.puntiSpareggio).toBe(1); // 100/100
    expect(r.find((x) => x.playerId === 3)!.puntiSpareggio).toBe(0.5); // resign
  });
});
```

- [ ] **Step 2: run** → FAIL. **Step 3: impl** — `standings.ts`:
```ts
import type { MatchResult, StandingRow } from './types';

export function computeStandings(matches: MatchResult[], opts: { tabellaPunti: Record<string, number[]> }): StandingRow[] {
  const agg = new Map<number, { puntiTorneo: number; puntiSpareggio: number }>();

  for (const m of matches) {
    const punti = opts.tabellaPunti[String(m.nGiocatori)];
    if (!punti) throw new Error(`tabellaPunti manca per ${m.nGiocatori} giocatori`);
    const sorted = [...m.partecipanti].sort((a, b) => b.puntiCultura - a.puntiCultura);
    const winnerCultura = sorted.length ? sorted[0].puntiCultura : 0;

    // punti torneo per posizione, con spartizione in caso di parità
    const tp = new Array<number>(sorted.length).fill(0);
    let i = 0;
    while (i < sorted.length) {
      let j = i;
      while (j + 1 < sorted.length && sorted[j + 1].puntiCultura === sorted[i].puntiCultura) j++;
      let sum = 0;
      for (let k = i; k <= j; k++) sum += punti[k] ?? 0;
      const avg = sum / (j - i + 1);
      for (let k = i; k <= j; k++) tp[k] = avg;
      i = j + 1;
    }

    sorted.forEach((p, pos) => {
      if (p.isBot || p.playerId == null) return; // il bot conta per le posizioni ma non entra in classifica
      const spareggio = p.resign ? 0.5 : winnerCultura > 0 ? p.puntiCultura / winnerCultura : 0;
      const cur = agg.get(p.playerId) ?? { puntiTorneo: 0, puntiSpareggio: 0 };
      cur.puntiTorneo += tp[pos];
      cur.puntiSpareggio += spareggio;
      agg.set(p.playerId, cur);
    });
  }

  const rows: StandingRow[] = [...agg.entries()].map(([playerId, v]) => ({
    playerId, isBot: false, puntiTorneo: v.puntiTorneo, puntiSpareggio: v.puntiSpareggio, posizione: 0
  }));
  rows.sort((a, b) => b.puntiTorneo - a.puntiTorneo || b.puntiSpareggio - a.puntiSpareggio);
  rows.forEach((r, idx) => (r.posizione = idx + 1));
  return rows;
}
```

- [ ] **Step 4: run** → PASS. **Step 5: commit**
```bash
git add src/lib/server/engine/standings.ts src/lib/server/engine/standings.test.ts
git commit -m "feat(fase3.1): computeStandings (punti torneo, parità spartite, bot, spareggio % vs vincitore)"
```

---

## Task 5: `seeding`

**Files:** Create `src/lib/server/engine/seeding.ts`, `seeding.test.ts`

- [ ] **Step 1: test** — `seeding.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import { seeding } from './seeding';
import type { SeedInput } from './types';

describe('seeding', () => {
  it('chunk per dimensione con rankBase corretto', () => {
    const iscritti: SeedInput[] = Array.from({ length: 26 }, (_, i) => ({ playerId: i + 1, rankingPosition: i + 1 }));
    const g = seeding(iscritti, { dimensione: 13 });
    expect(g).toHaveLength(2);
    expect(g[0]).toMatchObject({ livello: 'A', rankBase: 1, ordine: 0, isCoda: false });
    expect(g[1]).toMatchObject({ livello: 'B', rankBase: 14, ordine: 1, isCoda: false });
    expect(g[0].playerIds[0]).toBe(1);
  });
  it('i nuovi (rankingPosition null) vanno in fondo', () => {
    const iscritti: SeedInput[] = [
      { playerId: 1, rankingPosition: null },
      { playerId: 2, rankingPosition: 5 },
      { playerId: 3, rankingPosition: 1 }
    ];
    const g = seeding(iscritti, { dimensione: 13 });
    expect(g[0].playerIds).toEqual([3, 2, 1]);
  });
  it('caso EST26: 86 iscritti, dim 13 → 6 gironi + Coda(8)', () => {
    const iscritti: SeedInput[] = Array.from({ length: 86 }, (_, i) => ({ playerId: i + 1, rankingPosition: i + 1 }));
    const g = seeding(iscritti, { dimensione: 13 });
    expect(g).toHaveLength(7);
    expect(g[6]).toMatchObject({ livello: 'H', isCoda: true });
    expect(g[6].playerIds).toHaveLength(8);
  });
});
```

- [ ] **Step 2: run** → FAIL. **Step 3: impl** — `seeding.ts`:
```ts
import type { SeedInput, SeedGirone } from './types';

const letter = (i: number) => String.fromCharCode(65 + i); // A,B,C,...

export function seeding(iscritti: SeedInput[], opts: { dimensione: number; codaLivello?: string }): SeedGirone[] {
  const coda = opts.codaLivello ?? 'H';
  const sorted = [...iscritti].sort((a, b) => {
    if (a.rankingPosition == null && b.rankingPosition == null) return 0;
    if (a.rankingPosition == null) return 1;
    if (b.rankingPosition == null) return -1;
    return a.rankingPosition - b.rankingPosition;
  });

  const gironi: SeedGirone[] = [];
  const full = Math.floor(sorted.length / opts.dimensione);

  if (full === 0) {
    // meno di un girone pieno: serie A incompleta (non Coda)
    if (sorted.length > 0)
      gironi.push({ livello: 'A', rankBase: 1, ordine: 0, isCoda: false, playerIds: sorted.map((p) => p.playerId) });
    return gironi;
  }

  let idx = 0;
  for (let i = 0; i < full; i++) {
    gironi.push({
      livello: letter(i),
      rankBase: 1 + i * opts.dimensione,
      ordine: i,
      isCoda: false,
      playerIds: sorted.slice(idx, idx + opts.dimensione).map((p) => p.playerId)
    });
    idx += opts.dimensione;
  }
  const rest = sorted.slice(idx);
  if (rest.length > 0)
    gironi.push({
      livello: coda,
      rankBase: 1 + full * opts.dimensione,
      ordine: full,
      isCoda: true,
      playerIds: rest.map((p) => p.playerId)
    });
  return gironi;
}
```

- [ ] **Step 4: run** → PASS. **Step 5: commit**
```bash
git add src/lib/server/engine/seeding.ts src/lib/server/engine/seeding.test.ts
git commit -m "feat(fase3.1): seeding (chunk per dimensione, rankBase, nuovi in fondo, Coda/H)"
```

---

## Task 6: `applyPromoRetro`

**Files:** Create `src/lib/server/engine/promo-retro.ts`, `promo-retro.test.ts`

- [ ] **Step 1: test** — `promo-retro.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import { applyPromoRetro } from './promo-retro';
import type { StandingRow } from './types';

const standings = (ids: number[]): StandingRow[] =>
  ids.map((id, i) => ({ playerId: id, isBot: false, puntiTorneo: 100 - i, puntiSpareggio: 0, posizione: i + 1 }));

describe('applyPromoRetro', () => {
  const gironi = [
    { ordine: 0, livello: 'A', standings: standings([1, 2, 3, 4, 5]) },
    { ordine: 1, livello: 'B', standings: standings([6, 7, 8, 9, 10]) }
  ];
  it('girone A non promuove; promuove top e retrocede bottom altrove', () => {
    const p = applyPromoRetro(gironi, { promo: 2, retro: 2 });
    // A: nessun promo, ma retro per gli ultimi 2 (non è l'ultimo girone)
    expect(p.filter((x) => x.gironeOrdine === 0 && x.mossa === 'promo')).toHaveLength(0);
    expect(p.filter((x) => x.gironeOrdine === 0 && x.mossa === 'retro')).toHaveLength(2);
    // B (ultimo): promo top2, nessun retro
    expect(p.filter((x) => x.gironeOrdine === 1 && x.mossa === 'promo')).toHaveLength(2);
    expect(p.filter((x) => x.gironeOrdine === 1 && x.mossa === 'retro')).toHaveLength(0);
  });
  it('isInverno aggiunge +1 retrocessione', () => {
    const p = applyPromoRetro(gironi, { promo: 2, retro: 2, isInverno: true });
    expect(p.filter((x) => x.gironeOrdine === 0 && x.mossa === 'retro')).toHaveLength(3);
  });
});
```

- [ ] **Step 2: run** → FAIL. **Step 3: impl** — `promo-retro.ts`:
```ts
import type { StandingRow, PromoRetroProposal } from './types';

type GironeStandings = { ordine: number; livello: string; standings: StandingRow[] };

export function applyPromoRetro(
  gironi: GironeStandings[],
  opts: { promo?: number; retro?: number; isInverno?: boolean }
): PromoRetroProposal[] {
  const promo = opts.promo ?? 2;
  const retro = (opts.retro ?? 2) + (opts.isInverno ? 1 : 0);
  const maxOrdine = Math.max(...gironi.map((g) => g.ordine));
  const out: PromoRetroProposal[] = [];

  for (const g of gironi) {
    const n = g.standings.length;
    g.standings.forEach((s, i) => {
      const pos = i + 1;
      let mossa: PromoRetroProposal['mossa'] = 'stay';
      if (g.ordine > 0 && pos <= promo) mossa = 'promo'; // non dal girone A
      else if (g.ordine < maxOrdine && pos > n - retro) mossa = 'retro'; // non dall'ultimo
      out.push({ playerId: s.playerId, gironeOrdine: g.ordine, livello: g.livello, posizione: pos, mossa });
    });
  }
  return out;
}
```

- [ ] **Step 4: run** → PASS. **Step 5: commit**
```bash
git add src/lib/server/engine/promo-retro.ts src/lib/server/engine/promo-retro.test.ts
git commit -m "feat(fase3.1): applyPromoRetro (proposta assistita promo/retro per serie, +1 inverno)"
```

---

## Task 7: index engine + gate finali + review/merge

**Files:** Create `src/lib/server/engine/index.ts`

- [ ] **Step 1: index** — `src/lib/server/engine/index.ts`:
```ts
export * from './types';
export * from './round-robin';
export * from './standings';
export * from './seeding';
export * from './promo-retro';
```
- [ ] **Step 2: gate** — `pnpm check && pnpm test && pnpm build` → tutto verde.
- [ ] **Step 3: commit**
```bash
git add src/lib/server/engine/index.ts
git commit -m "feat(fase3.1): barrel export engine"
```
- [ ] **Step 4: review avversariale** (workflow multi-dim su diff main...HEAD), applica fix reali, poi **finishing-a-development-branch** (merge su main + deploy).

---

## Self-review del piano
- **Copertura spec:** verifyRoundRobin (T2) ✓ · generateRoundRobin 13/7 + throw (T3) ✓ · computeStandings punti/parità/bot/spareggio/resign (T4) ✓ · seeding chunk/rankBase/nuovi/Coda (T5) ✓ · applyPromoRetro counts/bordi/inverno (T6) ✓ · fix BOLGIA_7 (T1) ✓ · Bolgia-4 deferita (throw) ✓.
- **Niente placeholder:** ogni step ha codice+comando.
- **Coerenza tipi:** `RoundRobinSchedule/MatchResult/StandingRow/SeedInput/SeedGirone/PromoRetroProposal` definiti in T2 (types.ts) e usati coerentemente in T3-T6; `VerifyConfig` interno a round-robin.ts.
- **Offline:** nessuno step DB; tutti i test sono unit nel `pnpm test` di default.
</content>
