# Spec di design — Fase 3.1: Engine Bolgia (funzioni pure)

**Data:** 2026-06-09 · **Stato:** approvato in autonomia (Paolo ha delegato: "fai tutto quello che riesci in autonomia") · **Autore:** Claude
**Base:** master spec [`2026-06-07-tta-piattaforma-design.md`](2026-06-07-tta-piattaforma-design.md) §6/§8/§10/§11/§18 · dominio [`../../02-dominio-tornei.md`](../../02-dominio-tornei.md) · fondamenta [`2026-06-09-fase-3-0-fondamenta-design.md`](2026-06-09-fase-3-0-fondamenta-design.md)
**Premessa:** F3.0 (fondamenta: schema + config Zod + infra) è completa e in produzione. Questo spec copre **F3.1 — l'engine della Bolgia come insieme di funzioni pure**, senza DB né UI (la persistenza/UI è F3.2). Nota: design redatto in autonomia per la revisione successiva di Paolo; ogni scelta non banale è documentata qui.

---

## 1. Obiettivo e ambito
Implementare il **cuore algoritmico** della Bolgia come **funzioni pure, deterministiche e idempotenti** (master spec §6): dato l'input (iscritti+ranking, risultati partite, config) restituiscono output (gironi, calendario, classifiche, proposte promo/retro) **senza toccare DB né stato**. Massima testabilità offline.

**Dentro (4 funzioni + verifier):**
- `generateRoundRobin` — genera il calendario partite di un girone secondo la composizione, garantendo **l'invariante "ogni coppia si incontra `incontriPerCoppia` volte"**.
- `verifyRoundRobin` — verifica indipendente dell'invariante (rete di sicurezza + uso nei test).
- `computeStandings` — classifica di un girone dai risultati partita (punti torneo + spareggi).
- `seeding` — assegna gli iscritti a gironi/serie dal ranking persistente (+ Coda/H).
- `applyPromoRetro` — **proposta assistita** di promozioni/retrocessioni per serie (l'admin conferma).

**Formati supportati in F3.1:** **Bolgia-13** (attuale) e **Bolgia-7** (storico). **Bolgia-4** (riforma ipotetica, decisioni D2/D4 aperte) è **deferito**: non è un design bilanciato uniforme e la formula non è decisa → `generateRoundRobin` solleva un errore esplicito per dimensioni non supportate.

**Fuori:** persistenza su DB, generazione record `match`/`standing`, UI admin → **F3.2**. Engine `BRACKET_ELIMINAZIONE`/`A_SQUADRE` → post-V1. Applicazione sanzioni al ranking → con F3.2/chiusura stagione (qui solo promo/retro). Automazione completa promo/retro (regole D3 indecise) → resta **assistita**.

## 2. Le costruzioni round-robin (verificate)
La generazione **non** usa ricerca combinatoria: usa **famiglie di differenze cicliche** (design classici), sviluppate `mod n` e poi mappate sui giocatori in ordine di seeding. **Tutte verificate** con un prototipo (ogni coppia esattamente 2×, composizione per-giocatore esatta):

- **Bolgia-13** (`dimensione 13`, `incontriPerCoppia 2`, composizione `6×3 + 4×4`):
  - **Partite a 4** = piano proiettivo **PG(2,3)**: sviluppo del difference set `[0,1,3,9] mod 13` → 13 partite, ogni coppia **1×**, 4 partite/giocatore.
  - **Partite a 3** = sistema di Steiner **STS(13)**: sviluppo di `[0,1,4]` e `[0,2,7]` `mod 13` → 26 triple, ogni coppia **1×**, 6 partite/giocatore.
  - **Totale**: 39 partite, ogni coppia **2×**, composizione 6×3+4×4. ✓
- **Bolgia-7** (`dimensione 7`, `incontriPerCoppia 2`, composizione `6×3`):
  - sviluppo di `[0,1,3]` e `[0,1,5]` `mod 7` → 14 triple distinte, ogni coppia **2×**, 6 partite/giocatore. ✓

Le costruzioni vivono in un **registry** chiave→base-blocks. `verifyRoundRobin` resta l'autorità sulla correttezza (i test sviluppano e verificano; se un domani si aggiunge una costruzione, il test la valida).

> **Correzione template (da F3.0):** `BOLGIA_7.incontriPerCoppia` passa da `1` → **`2`** (il regolamento: "ognuno incontra ciascun altro **due volte**"). `BOLGIA_13` resta `2` (già corretto). `BOLGIA_4` resta come config strutturale ma **non supportato** dall'engine (riforma non decisa); il suo `incontriPerCoppia` non è un invariante uniforme.

## 3. Architettura — moduli (tutti puri, niente import di `db`)
`src/lib/server/engine/`:
- `types.ts` — tipi engine (indipendenti dalle row Drizzle, per purezza/testabilità).
- `round-robin.ts` — `generateRoundRobin`, `verifyRoundRobin`, registry costruzioni.
- `standings.ts` — `computeStandings`.
- `seeding.ts` — `seeding`.
- `promo-retro.ts` — `applyPromoRetro`.
- `index.ts` — re-export.
Ogni modulo ha il suo `*.test.ts` (unit **offline**, nel `pnpm test` di default). **Nessun test d'integrazione**: l'engine non tocca il DB.

## 4. Tipi (contratti)
```ts
// Calendario
export type RoundRobinMatch = { nGiocatori: number; playerIndices: number[] }; // indici 0..n-1 in ordine seeding
export type RoundRobinSchedule = RoundRobinMatch[];

// Classifica (input risultati)
export type ParticipantResult = {
  playerId: number | null;   // null = bot (conta come giocatore)
  isBot: boolean;
  puntiCultura: number;       // cultura della partita
  resign: boolean;
};
export type MatchResult = { nGiocatori: number; partecipanti: ParticipantResult[] };
export type StandingRow = {
  playerId: number | null; isBot: boolean;
  puntiTorneo: number;        // può essere frazionario (parità spartite)
  puntiSpareggio: number;     // somma dei % cultura vs vincitore (resign=0.5)
  posizione: number;          // 1-based dopo l'ordinamento
};

// Seeding
export type SeedInput = { playerId: number; rankingPosition: number | null }; // null = nuovo/senza storico
export type SeedGirone = { livello: string; rankBase: number; ordine: number; isCoda: boolean; playerIds: number[] };

// Promo/retro
export type PromoRetroProposal = { playerId: number | null; gironeOrdine: number; livello: string; posizione: number; mossa: 'promo' | 'retro' | 'stay' };
```

## 5. Funzioni — contratti e algoritmi
### 5.1 `verifyRoundRobin(schedule, { dimensione, incontriPerCoppia, composizione }) → { ok: boolean; errori: string[] }`
Controlla: (a) ogni coppia di indici `0..dimensione-1` compare in **esattamente `incontriPerCoppia`** partite; (b) ogni giocatore gioca **esattamente** il numero di partite per taglia indicato dalla composizione; (c) ogni partita ha la taglia dichiarata e indici validi/distinti. Pura, nessun side-effect.

### 5.2 `generateRoundRobin(dimensione, config) → RoundRobinSchedule`
Cerca nel registry la costruzione per `dimensione` (+ firma composizione). Sviluppa i base-block `mod dimensione`, costruendo le partite con `playerIndices` = punti del blocco (mappati 1:1 sull'ordine di seeding: punto `k` → k-esimo giocatore seedato). **Verifica con `verifyRoundRobin`** prima di restituire (assert interno). Se `dimensione` non è supportata → `throw new Error('Round-robin non supportato per dimensione N (formati supportati: 7, 13)')`. Deterministico (nessun random) → idempotente.

### 5.3 `computeStandings(matches: MatchResult[], { tabellaPunti }) → StandingRow[]`
Per ogni partita:
- **Punti torneo**: ordina i partecipanti per `puntiCultura` desc; assegna i valori di `tabellaPunti[String(nGiocatori)]` per posizione; **in caso di parità di cultura**, i punti delle posizioni a pari merito sono **spartiti equamente** (media dei punteggi delle posizioni coinvolte). Il **bot conta** come partecipante (occupa una posizione e i suoi punti vanno "persi" per i giocatori, ma il bot non entra in classifica finale).
- **Punti spareggio**: per ogni partecipante, `resign ? 0.5 : puntiCultura / culturaVincitore` (vincitore = max cultura della partita; il vincitore ottiene 1.0). Si **sommano** sulle partite del giocatore.
Aggrega per `playerId` (esclude i bot dall'output finale), ordina per **(1) puntiTorneo desc, (2) puntiSpareggio desc**, assegna `posizione` 1-based. Pura.

### 5.4 `seeding(iscritti: SeedInput[], { dimensione, codaLivello='H' }) → SeedGirone[]`
Ordina gli iscritti per `rankingPosition` asc (i `null` = nuovi/senza storico vanno in fondo, ordine stabile). Spezza in gironi pieni di `dimensione`: girone `i` → `livello` = lettera `A,B,C,…` (per indice), `rankBase = 1 + i*dimensione`, `ordine = i`, `isCoda=false`. L'eventuale **resto** (< dimensione) forma un girone **Coda**: `livello='H'`, `isCoda=true`, `rankBase` consecutivo. (Coincide con EST26 reale: 86 iscritti, dim 13 → 6 gironi + Coda da 8.) *Semplificazione documentata:* la nomenclatura a sotto-serie (B1/B2…) e il fine-tuning dei multipli ottimali (52/65/77) sono rinviati: qui conta il `rankBase` corretto per la proiezione ranking; lo split cosmetico arriverà con la struttura-serie configurabile.

### 5.5 `applyPromoRetro(gironi: { ordine; livello; standings: StandingRow[] }[], { promo=2, retro=2, isInverno=false }) → PromoRetroProposal[]`
Per ogni girone propone: i **primi `promo`** → `promo` (tranne il girone A, `ordine 0`: nessuna promozione); gli **ultimi `retro` (+1 se `isInverno`)** → `retro` (tranne l'ultimo girone non-Coda: nessuna retrocessione oltre, o verso Coda); gli altri → `stay`. **Solo proposta** (assistita): l'admin confermerà in F3.2. Pura. I conteggi default (2/2, +1 inverno) vengono dal regolamento (doc02); sovrascrivibili da `config.promoRetro`.

## 6. Test (gate, tutti offline)
- `round-robin.test.ts`: `verifyRoundRobin` riconosce schedule validi/invalidi; `generateRoundRobin(13)` e `(7)` producono schedule che **passano il verifier** (invariante coppie + composizione); `generateRoundRobin(4)` **throw**; determinismo (due chiamate = stesso output).
- `standings.test.ts`: tabella punti 3p/4p/2p; **parità → spartizione**; **bot conta** (toglie punti ai giocatori); spareggio = % cultura vs vincitore; **resign=0.5**; ordinamento (puntiTorneo, poi spareggio).
- `seeding.test.ts`: chunking per dimensione, `rankBase` corretto (A=1, B=14…), nuovi (`null`) in fondo, Coda per il resto; caso EST26 (86→6+Coda8).
- `promo-retro.test.ts`: conteggi promo/retro, bordo girone A (no promo) e ultimo (no retro), `isInverno` (+1 retro).
- Gate: `pnpm check` 0/0 · `pnpm test` verde · `pnpm build` ok.

## 7. Note e decisioni (autonome, da rivedere con Paolo)
- **Purezza**: l'engine non importa `db`; opera su tipi propri → F3.2 farà da "colla" tra DB e engine. Questo tiene l'engine 100% testabile offline e riusabile.
- **Bolgia-4 deferito**: la riforma (gironi da 4) non è decisa (D2/D4); costruire ora il suo scheduler sarebbe prematuro e il formato non è un design bilanciato uniforme. `generateRoundRobin` lo rifiuta con messaggio chiaro.
- **Spartizione punti in parità**: interpretazione del regolamento "parità di cultura → punti torneo spartiti" = media aritmetica dei punteggi delle posizioni a pari merito (es. due primi a 3p (10,4,0) → 7 e 7, terzo 0). Da confermare con Paolo se l'organizzazione intende diversamente.
- **Spareggio cumulativo**: somma dei rapporti `% vs vincitore` sulle partite del giocatore (vincitore=1.0, resign=0.5). Coerente con master spec §10.
- **Promo/retro assistita**: solo proposta; le regole definitive (D3) sono in mano all'organizzazione → nessuna automazione vincolante.
</content>
