# Spec di design — Piattaforma TtA Goblin **Data:** 2026-06-07 · **Stato:** approvato (design) · **Autore:** Claude + Paolo **Riferimenti:** [`docs/01-contesto`](../../01-contesto.md) · [`02-dominio-tornei`](../../02-dominio-tornei.md) · [`03-stato-attuale`](../../03-stato-attuale.md) · [`04-riforma-formula`](../../04-riforma-formula.md) · [`05-requisiti-prodotto`](../../05-requisiti-prodotto.md) · [`06-modello-dati`](../../06-modello-dati.md) · [`decisioni/2026-06-07-stack-architettura`](../../decisioni/2026-06-07-stack-architettura.md) Questo documento consolida il design validato. È la base per il piano di implementazione (writing-plans). --- ## 1. Obiettivo e ambito Piattaforma web **pro bono** per i tornei online di *Through the Ages* (app CGE) della Tana dei Goblin. Due facce: 1. **Vetrina pubblica** (multi-pagina): home, eventi, classifiche live, albo d'oro, regolamento, community. 2. **Portale gestionale** (autenticato): i giocatori si **auto-registrano** e si **auto-iscrivono**; gli admin **generano eventi/gironi/partite**; calendario + **notifiche email**; ogni giocatore ha la propria dashboard (partite, calendario, storico, ranking). Vincolo guida: **ridurre il lavoro manuale** degli organizzatori e restare **manutenibile da un solo dev**. ## 2. Principio cardine — motore eventi GENERICO Il gestionale non modella "la Bolgia": modella **eventi configurabili**. L'admin può creare **qualunque** tipo di evento con parametri liberi. La **formula** (gironi 13/7/4, composizione partite, punti, promo/retro) è **configurazione**, non codice. → La decisione indecisa dell'organizzazione **non blocca lo sviluppo**. ## 3. Stack tecnologico SvelteKit (app unica, `adapter-vercel`) · **Neon Postgres** + **Drizzle ORM** · **Better Auth** · **Resend** (email) + **Vercel Cron** · **Tailwind + shadcn-svelte** · PWA/push in fase 2. Produzione su **Vercel**; il NAS resta solo dev locale/backup. Dettagli e razionale: [ADR stack](../../decisioni/2026-06-07-stack-architettura.md). ## 4. Ruoli e permessi Gerarchia **Developer > Super Admin > Admin** (ognuno include i poteri di quello sotto), più i due livelli pubblici: | Ruolo | Poteri | |---|---| | **Visitatore** | Solo vetrina pubblica. | | **Giocatore** (registrato) | Dashboard personale; auto-iscrizione agli eventi. | | **Admin** | Gestisce **tutti** gli eventi: crea/avvia, genera gironi/partite, inserisce risultati, gestisce iscrizioni e sanzioni. Non gestisce utenti/ruoli né contenuti istituzionali. | | **Super Admin** | Tutto dell'Admin **+** gestione utenti e **ruoli admin** (così i soci si abilitano admin in autonomia), anagrafiche, contenuti vetrina/regolamento. | | **Developer** (Paolo) | Tutto **+** configurazioni di sistema, integrazioni, migrazioni, gestione super admin. "Root". | Auto-registrazione → ruolo **Giocatore**; salire di ruolo richiede nomina da super-admin/developer. ## 5. Registrazione e identità - **Email** univoca, **modificabile** (con ri-verifica del nuovo indirizzo), verificata alla registrazione via **link di attivazione**; **password** con **reset via email**. - **Nick TtA** (`nome_app_tta`) **univoco ma modificabile** (l'app CGE consente il cambio nick → si mantiene l'unicità ad ogni cambio). - **Nick Tana dei Goblin** (`nick_tdg`) opzionale. - **Collegamento allo storico (opzione A)**: se il nick TtA in registrazione **non** è nello storico → account pronto, **zero lavoro admin**; se **coincide** con un giocatore storico importato → "sei tu?" e il collegamento al palmarès scatta dopo **conferma rapida di un admin** (anti furto d'identità). ## 6. Architettura del motore eventi (approccio scelto: C + innesti da B) Esito di un'esplorazione di 3 architetture + valutazione critica. Scelto l'approccio **"Fasi tipizzate + JSONB validato, Bolgia-first"** (scartato l'interprete-DSL puro: troppa astrazione non richiesta e ingestibile da solo). - **Evento = sequenza di Fasi**; ogni **Fase** ha un `kind` ∈ `{GIRONI_ROUND_ROBIN, BRACKET_ELIMINAZIONE, A_SQUADRE, PARTITA_SINGOLA}` → **una funzione "engine" pura per kind** (firma comune stile `PhaseEngine`, **non** una gerarchia di classi). Funzioni pure, deterministiche, **idempotenti** (rigenerabili). - **La formula vive in `Phase.config` (JSONB) validato da Zod** (schema discriminato su `kind`); `Event.format_config` è template/default ereditabile. - **🔑 Regola d'oro (manutenibilità):** nel JSONB va **solo ciò che non si interroga mai in SQL** (composizione partite, tabella punti, regole promo/retro, multipli ottimali). Tutto ciò che si filtra/ordina/aggrega (punti, posizione, serie, livello, dimensione) resta **colonna relazionale**. Drizzle tipizza il JSONB con `$type>()` → **unica fonte di verità** TS+Zod, validazione fail-fast al salvataggio. - **FormatTemplate**: preset clonabili ("Bolgia 13", "Bolgia 7", "Bolgia 4"). Cambiare formula = creare/duplicare un evento da un template → **zero deploy, zero migrazione di codice**. Lo storico eterogeneo (gironi 7 fino a INV22, 13 da EST23) si migra con config diverse per stagione, senza rami nel codice. - **Season** entità esplicita (ancora il ranking cross-stagione e l'eredità di serie). **Ranking** storico **append-only**, disaccoppiato dagli Standing correnti (rigenerare una fase non corrompe lo storico). > ⚠️ **Invariante da non sbagliare:** la composizione partite è un **programma per giocatore** (es. ognuno gioca 6 partite a 3 + 4 a 4, incontrando gli altri **due volte**), non un conteggio di tavoli. `generateRoundRobin` deve garantire "ogni coppia si incontra `incontri_per_coppia` volte"; Zod valida la coerenza composizione ↔ dimensione ↔ incontri. ## 7. Modello dati (~15 tabelle) **Persone / Auth** - **User/Account** (Better Auth): `id, email, email_verified, ruolo (player|admin|superadmin|developer), consensi_gdpr JSONB`. 1:1 opzionale con Player. - **Player**: `id, user_id?, nick_tdg (unique), nome_app_tta (unique), attivo, note`. Vive anche senza User (storici migrati). - **PlayerAlias**: `id, player_id, alias_nick, alias_nome_app, fonte` — dedup dei ~177 nick tra i 26 fogli (solo per l'importer). **Struttura eventi** - **Season**: `id, identificativo (EST26…), tipo_periodo (estiva|autunnale|invernale), anno, is_current, started_at, ended_at`. - **Event**: `id, slug, tipo (enum APERTO: BOLGIA|TORNEO_GENERALE|DUELLO|PALIO|SUPERCOPPA|CUSTOM — solo hint UI), season_id?, nome, stato (bozza|iscrizioni|in_corso|concluso), regolamento_versione, format_config JSONB, created_by`. - **Phase**: `id, event_id, ordine, kind (enum), nome, stato (pending|seeding|running|closed), config JSONB (Zod), seeding_source JSONB`. - **Group** (girone/serie/gruppo): `id, phase_id, livello (STRINGA: A|B1|B2|…|H), nome_tematico, dimensione (int), ordine, rank_base (offset nel ranking globale: A=1, B=14…)`. **Gioco** - **Match**: `id, phase_id, group_id?, bracket_node_id?, serie_index? (best-of), n_giocatori (2|3|4), stato, link_app_cge, data_avvio_prevista, data_avvio_effettiva`. - **MatchParticipant**: `id, match_id, player_id?, team_id?, is_bot, posizione (1..N), punti_cultura, punti_torneo (derivato), resign`. - **Standing**: `id, phase_id, group_id, player_id, punti_torneo, punti_spareggio, posizione_finale, esito JSONB` — cache ricomputabile dai MatchParticipant. **Storico / disciplina** - **Ranking** (append-only): `id, player_id, season_id, serie, posizione_globale, is_coda, note (rit/sql/-13/amm), source_phase_id?` — materializza il foglio "Andamento Ranking"; **sorgente del seeding** futuro. - **Sanction**: `id, player_id, event_id?, tipo (timeout|ammonizione|squalifica|penalizzazione|ban), valore JSONB ({ranking_delta:-5,…}), giustificato, note` — si applicano alla **chiusura stagione** e impattano il **Ranking della stagione SUCCESSIVA**, non gli Standing correnti. **Iscrizioni / trasversali** - **EventRegistration**: `id, event_id, player_id, stato (richiesta|confermata|rifiutata|rinuncia|in_coda), created_at`. - **Notification**: `id, user_id, tipo, payload JSONB, canale (email|push), inviata_at, letta_at`. - **Content**: `id, titolo, corpo, tipo (notizia|pagina), pubblicata_at, autore_id`. **Non in V1 (Palio):** **Team** `(id, event_id, nome, zona_geografica)` + **TeamMembership** `(id, team_id, player_id, ruolo)`; `MatchParticipant.team_id` li referenzia. ## 8. Mappatura archetipi → fasi + config - **Bolgia**: 1 Phase `GIRONI_ROUND_ROBIN`. seeding da Ranking (per serie, `rank_base`); groups `dimensione=13` (overflow → serie H); `incontri_per_coppia=2`; composizione `[{n:6,giocatori:3},{n:4,giocatori:4}]`; tabella punti 3p 10/4/0 · 4p 11/6/2/0 · 2p 4/0 (bot conta); spareggio % cultura vs vincitore (resign 0.5); promo/retro per livello (+1 retrocessione d'inverno); `write_ranking` proietta A=1-13, B=14-26… **Cambiando `dimensione` a 7 o 4 e la composizione → altra formula, stesso codice.** - **Torneo Generale**: 2 Phase — `GIRONI_ROUND_ROBIN` → (qualificati) `BRACKET_ELIMINAZIONE`. - **Duello**: `BRACKET_ELIMINAZIONE` 1v1 (eventuale Phase `qualification`), seed dai quartisti anno prima, 2 partite/round, finale al meglio di 3 (`serie_index`). *(engine bracket = post-V1)* - **Palio**: `A_SQUADRE` (gironi di squadre → girone finale). *(post-V1)* - **Supercoppa**: `PARTITA_SINGOLA` (1 evento = 1 fase = 1 match). **Smoke-test del modello generico in V1.** ## 9. Flusso iscrizioni (ibrido) Ciclo evento: **bozza → iscrizioni aperte → in_corso → concluso**. Ad "iscrizioni aperte": **invito automatico** ai partecipanti dell'edizione precedente (accettano con un click) **+** i nuovi si **auto-iscrivono**; eccedenze gestite con stato `in_coda`. Poi l'admin **chiude**, fa **seeding** dal Ranking e **genera gironi/calendario**. ## 10. Calcolo classifiche e spareggi `computeStandings` (funzione pura): `punti_torneo` dalla tabella per `n_giocatori` (il **bot conta come giocatore**); parità di punti cultura in partita → punti torneo **spartiti**; ordinamento per **(1) punti_torneo, (2) punti_spareggio** = % cultura rispetto al vincitore della partita; **resign = 0,5** punti spareggio. ## 11. Promozioni/retrocessioni e sanzioni - In **V1: assistita** — il sistema **propone** promo/retro e applicazione sanzioni, l'**admin conferma** (le regole definitive sono D3, ancora in mano all'organizzazione). Scrive il **Ranking della stagione successiva**. - Sanzioni timeout/ammonizioni/squalifiche impattano il **ranking della stagione successiva** (regole §2.x del regolamento), incluso il reinserimento −13 (regola 4.2). ## 12. Migrazione dello storico (progetto a sé) - **Priorità agli snapshot di Ranking** (foglio "Andamento Ranking", ~177 giocatori) + **albo d'oro**, **indipendenti dai Match storici** (spesso incompleti; EST26 ha i punti vuoti). - I **Match dettagliati** si tracciano **dalle stagioni nuove in poi**; per le storiche si importa la **posizione finale** (i `punti_spareggio` storici sono **lossy**, accettato). - Import **idempotente** con **riconciliazione sui totali noti per stagione**; **PlayerAlias** per de-dup dei nick tra i 26 fogli eterogenei. ## 13. Notifiche V1: **email** (Resend) su **avvio partita** + **reminder timeout**, schedulate via **Vercel Cron** che interroga il calendario. **PWA + push** in fase 2. ## 14. Design / tema Direzione approvata: **Pergamena + Civiltà/le Ere** — base pergamena, inchiostro caldo, **bronzo** e **verderame** come accenti, *oxblood* per le enfasi; titoli **serif** (codice miniato) + testo/tabelle **sans**. I **4 colori dei giocatori** (giallo/blu/rosso/verde, leggermente smorzati) hanno **ruolo solo funzionale** (pedine, posti partita, badge), non di marchio. Palette di riferimento in `.superpowers/brainstorm/…/design-proposal.html`. Rifinitura con la skill di design in implementazione. ## 15. Ambito V1 (Bolgia-first) **Dentro:** tutte le tabelle + enum `kind` completo, ma implementati davvero solo gli engine **`GIRONI_ROUND_ROBIN`** (completo) e **`PARTITA_SINGOLA`** (banale: 1 match, nessuna generazione) · **Bolgia end-to-end** (seeding, round-robin parametrico 13/7/4 con invariante, classifiche+spareggi, promo/retro **assistita**) · **migrazione storico** (snapshot Ranking + albo d'oro, idempotente, alias dedup) · **vetrina** di lettura (classifiche live, ranking, albo) · **auto-registrazione/auto-iscrizione** · **Supercoppa** (`PARTITA_SINGOLA`) come smoke-test del modello generico. **Rimandato:** engine `BRACKET_ELIMINAZIONE` (Duello / finali Generale) — con eventuale refactor del bracket da JSONB a tabella nodi · `A_SQUADRE` + Team/TeamMembership (Palio) · automazione completa promo/retro & regole-ranking · push/PWA · editor visuale del qualification-mapping. ## 16. Decisioni confermate (2026-06-07) 1. Migrazione = **snapshot** classifica/ranking per le storiche; match dettagliati solo dalle nuove stagioni. ✅ 2. Promo/retro in V1 = **assistita**. ✅ 3. `format_config` **congelato** (snapshot immutabile) al passaggio evento → *in_corso*. ✅ 4. **IT-only** + **dominio dedicato** gestito da Paolo. ✅ + Collegamento storico = **opzione A**; iscrizioni = **ibrido C**; identità email/nick **univoche ma modificabili**. ## 17. Questioni aperte (in mano all'organizzazione — NON bloccano lo sviluppo) - **D2** dimensione gironi definitiva (13/7/4) → determina quale FormatTemplate preparare per le stagioni reali 2027. - **D3** regole promo/retro definitive + casi limite → finché aperte, promo/retro resta assistita. - **D4** sorte del Duello (separato vs assorbito dai gironi-4) → dipende da D2; impatta cosa implementare dopo la Bolgia. ## 18. Approccio di verifica/test - **Engine = funzioni pure** → unit test su: seeding (rank_base/serie/Coda H), `generateRoundRobin` (invariante "ogni coppia N volte", coerenza con la dimensione), `computeStandings` (tabella punti, bot, parità, spareggio %, resign 0.5), `applyPromoRetro+Sanction` (proiezione sul Ranking successivo). - **Migrazione**: test di **idempotenza** + **riconciliazione** coi totali noti per stagione (es. EST26 = 86, AUT25 = 86…). - **Validazione config**: Zod fail-fast; test che una config incoerente (composizione ↔ dimensione) venga rifiutata.