This commit is contained in:
parent
07de342fc2
commit
35fe877d58
32 changed files with 3822 additions and 200 deletions
66
website/AGENTS.md
Normal file
66
website/AGENTS.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Agent Instructions
|
||||
|
||||
Cette zone contient l'application React + Vite et les assets publics.
|
||||
|
||||
## Références
|
||||
|
||||
Avant de modifier un outil toolbox, le stockage IndexedDB, l'import/export ou les formats de données, lire :
|
||||
|
||||
- `../docs/STORAGE_SCHEMA.md`
|
||||
- `../docs/FEATURE_CHECKLIST.md`
|
||||
|
||||
Avant de modifier le style, les composants UI, les layouts, les boutons, cards, panels, hovers, scrollbars ou notifications, lire :
|
||||
|
||||
- `../DESIGN_SYSTEM.md`
|
||||
|
||||
Après ce type de modification, mettre à jour ces documents si le format, le workflow ou le pattern visuel change.
|
||||
|
||||
## Contenu éditable
|
||||
|
||||
Le contenu éditorial du site doit rester dans :
|
||||
|
||||
- `public/data/site.json`
|
||||
- `public/data/<gameId>/...`
|
||||
|
||||
`site.json` est la source de vérité. `npm run check` doit échouer si le contenu requis est invalide.
|
||||
|
||||
## En-têtes de fichiers
|
||||
|
||||
Chaque nouveau fichier source ou test doit commencer par un commentaire court `Rôle : ...` décrivant ce qu'il gère.
|
||||
|
||||
- Mettre cet en-tête à jour si la responsabilité du fichier change.
|
||||
- Garder l'intro concise, une à trois lignes maximum selon la complexité du fichier.
|
||||
- Enrichir l'en-tête seulement quand le fichier porte une logique transverse, un hook partagé, un format de données ou un comportement réutilisable.
|
||||
- Utiliser `// Rôle : ...` dans les fichiers JS, JSX, MJS et SCSS.
|
||||
|
||||
## Serveur de développement
|
||||
|
||||
Si le port `5173` est déjà occupé, considérer qu'un `npm run dev` est probablement déjà en cours. Ne pas lancer automatiquement un nouveau serveur sur `5174`.
|
||||
|
||||
Quand une vérification navigateur est nécessaire, utiliser l'instance déjà ouverte sur `http://localhost:5173` si elle répond.
|
||||
|
||||
## Validation
|
||||
|
||||
Avant de terminer une modification significative, lancer :
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
```
|
||||
|
||||
Le check couvre la génération des index de listes, les tests et le build de production.
|
||||
|
||||
## Pré-push check
|
||||
|
||||
Quand l'utilisateur indique qu'il va push, faire une passe globale avant de conclure :
|
||||
|
||||
- code mort, imports inutilisés, fonctions inutilisées, styles obsolètes ;
|
||||
- duplication de code ou de style pouvant être factorisée sans complexifier ;
|
||||
- cohérence entre boutons, badges, cards, panels, hovers, focus states et scrollbars ;
|
||||
- cohérence entre `public/data/site.json`, les validations et les textes utilisés ;
|
||||
- cohérence entre `../docs/STORAGE_SCHEMA.md` et les normalisations IndexedDB réelles ;
|
||||
- import/export des toolboxes quand la structure de données a changé ;
|
||||
- champs inutiles persistés dans IndexedDB ou dans les exports ;
|
||||
- fichiers générés attendus, notamment les index de listes ;
|
||||
- optimisations simples de performance : recalculs au render, listeners non nettoyés, observers, effets CSS lourds appliqués en masse.
|
||||
|
||||
Après lecture du diff pré-push, proposer un message de commit anglais, concis et représentatif.
|
||||
|
|
@ -45,6 +45,10 @@
|
|||
"id": "mf",
|
||||
"type": "goalTree"
|
||||
},
|
||||
{
|
||||
"id": "mg",
|
||||
"type": "randomizer"
|
||||
},
|
||||
{
|
||||
"id": "m9",
|
||||
"type": "timer"
|
||||
|
|
@ -233,6 +237,7 @@
|
|||
"m7",
|
||||
"m8",
|
||||
"mf",
|
||||
"mg",
|
||||
"m9",
|
||||
"md",
|
||||
"me",
|
||||
|
|
@ -255,6 +260,7 @@
|
|||
"m7",
|
||||
"m8",
|
||||
"mf",
|
||||
"mg",
|
||||
"m9",
|
||||
"md",
|
||||
"me",
|
||||
|
|
@ -4073,6 +4079,686 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"mg": {
|
||||
"activeTab": "teams",
|
||||
"dice": {
|
||||
"faces": 6,
|
||||
"rolls": 5,
|
||||
"results": [
|
||||
1,
|
||||
2,
|
||||
4,
|
||||
5,
|
||||
6
|
||||
],
|
||||
"history": [
|
||||
{
|
||||
"id": "x-dice-history-1",
|
||||
"at": "2026-08-31T06:52:00.000Z",
|
||||
"results": [
|
||||
1,
|
||||
2,
|
||||
4,
|
||||
5,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x-dice-history-2",
|
||||
"at": "2026-08-31T06:49:00.000Z",
|
||||
"results": [
|
||||
6,
|
||||
3,
|
||||
2,
|
||||
2,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x-dice-history-3",
|
||||
"at": "2026-08-31T06:46:00.000Z",
|
||||
"results": [
|
||||
4,
|
||||
4,
|
||||
1,
|
||||
3,
|
||||
6
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"wheel": {
|
||||
"draws": 3,
|
||||
"entries": [
|
||||
{
|
||||
"id": "x1",
|
||||
"label": "Oui",
|
||||
"weight": 2
|
||||
},
|
||||
{
|
||||
"id": "x2",
|
||||
"label": "Non",
|
||||
"weight": 2
|
||||
},
|
||||
{
|
||||
"id": "x3",
|
||||
"label": "Peut etre",
|
||||
"weight": 1
|
||||
}
|
||||
],
|
||||
"results": [
|
||||
"Oui",
|
||||
"Non",
|
||||
"Peut etre"
|
||||
],
|
||||
"history": [
|
||||
{
|
||||
"id": "x-wheel-history-1",
|
||||
"at": "2026-08-31T06:51:00.000Z",
|
||||
"results": [
|
||||
"Oui",
|
||||
"Non",
|
||||
"Peut etre"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x-wheel-history-2",
|
||||
"at": "2026-08-31T06:47:00.000Z",
|
||||
"results": [
|
||||
"Non",
|
||||
"Oui",
|
||||
"Non"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x-wheel-history-3",
|
||||
"at": "2026-08-31T06:44:00.000Z",
|
||||
"results": [
|
||||
"Peut etre",
|
||||
"Oui",
|
||||
"Non"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"teams": {
|
||||
"teamCount": 4,
|
||||
"playerSelectionMode": "all",
|
||||
"playerSelectionCount": 6,
|
||||
"players": [
|
||||
{
|
||||
"id": "x4",
|
||||
"name": "Dracaufeu",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x5",
|
||||
"name": "Tortank",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x6",
|
||||
"name": "Florizarre",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x7",
|
||||
"name": "Dracolosse",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x8",
|
||||
"name": "Ectoplasma",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x9",
|
||||
"name": "Alakazam",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x10",
|
||||
"name": "Mackogneur",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x11",
|
||||
"name": "Cizayox",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x12",
|
||||
"name": "Tyranocif",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x13",
|
||||
"name": "Gardevoir",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x14",
|
||||
"name": "Drattak",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x15",
|
||||
"name": "Métalosse",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x16",
|
||||
"name": "Carchacrok",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x17",
|
||||
"name": "Lucario",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x18",
|
||||
"name": "Trioxhydre",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x19",
|
||||
"name": "Amphinobi",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x20",
|
||||
"name": "Archéduc",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x21",
|
||||
"name": "Félinferno",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x22",
|
||||
"name": "Corvaillus",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x23",
|
||||
"name": "Lanssorien",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x24",
|
||||
"name": "Raichu",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x25",
|
||||
"name": "Artikodin",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x26",
|
||||
"name": "Électhor",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x27",
|
||||
"name": "Sulfura",
|
||||
"score": 0
|
||||
}
|
||||
],
|
||||
"exclusions": [
|
||||
{
|
||||
"id": "x28",
|
||||
"playerAId": "x7",
|
||||
"playerBIds": [
|
||||
"x14",
|
||||
"x16",
|
||||
"x23"
|
||||
],
|
||||
"mode": "group"
|
||||
},
|
||||
{
|
||||
"id": "x29",
|
||||
"playerAId": "x25",
|
||||
"playerBIds": [
|
||||
"x26",
|
||||
"x27"
|
||||
],
|
||||
"mode": "group"
|
||||
}
|
||||
],
|
||||
"teams": [
|
||||
{
|
||||
"id": "team-1",
|
||||
"name": "Équipe 1",
|
||||
"score": 0,
|
||||
"players": [
|
||||
{
|
||||
"id": "x9",
|
||||
"name": "Alakazam",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x24",
|
||||
"name": "Raichu",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x20",
|
||||
"name": "Archéduc",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x7",
|
||||
"name": "Dracolosse",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x5",
|
||||
"name": "Tortank",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x26",
|
||||
"name": "Électhor",
|
||||
"score": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "team-2",
|
||||
"name": "Équipe 2",
|
||||
"score": 0,
|
||||
"players": [
|
||||
{
|
||||
"id": "x25",
|
||||
"name": "Artikodin",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x8",
|
||||
"name": "Ectoplasma",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x13",
|
||||
"name": "Gardevoir",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x15",
|
||||
"name": "Métalosse",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x17",
|
||||
"name": "Lucario",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x16",
|
||||
"name": "Carchacrok",
|
||||
"score": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "team-3",
|
||||
"name": "Équipe 3",
|
||||
"score": 0,
|
||||
"players": [
|
||||
{
|
||||
"id": "x12",
|
||||
"name": "Tyranocif",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x11",
|
||||
"name": "Cizayox",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x10",
|
||||
"name": "Mackogneur",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x18",
|
||||
"name": "Trioxhydre",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x6",
|
||||
"name": "Florizarre",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x23",
|
||||
"name": "Lanssorien",
|
||||
"score": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "team-4",
|
||||
"name": "Équipe 4",
|
||||
"score": 0,
|
||||
"players": [
|
||||
{
|
||||
"id": "x27",
|
||||
"name": "Sulfura",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x21",
|
||||
"name": "Félinferno",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x19",
|
||||
"name": "Amphinobi",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x22",
|
||||
"name": "Corvaillus",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x14",
|
||||
"name": "Drattak",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x4",
|
||||
"name": "Dracaufeu",
|
||||
"score": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"history": [
|
||||
{
|
||||
"id": "x-teams-history-1",
|
||||
"at": "2026-08-31T06:50:00.000Z",
|
||||
"teams": [
|
||||
{
|
||||
"id": "team-1",
|
||||
"name": "Équipe 1",
|
||||
"score": 0,
|
||||
"players": [
|
||||
{
|
||||
"id": "x9",
|
||||
"name": "Alakazam",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x24",
|
||||
"name": "Raichu",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x20",
|
||||
"name": "Archéduc",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x7",
|
||||
"name": "Dracolosse",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x5",
|
||||
"name": "Tortank",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x26",
|
||||
"name": "Électhor",
|
||||
"score": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "team-2",
|
||||
"name": "Équipe 2",
|
||||
"score": 0,
|
||||
"players": [
|
||||
{
|
||||
"id": "x25",
|
||||
"name": "Artikodin",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x8",
|
||||
"name": "Ectoplasma",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x13",
|
||||
"name": "Gardevoir",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x15",
|
||||
"name": "Métalosse",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x17",
|
||||
"name": "Lucario",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x16",
|
||||
"name": "Carchacrok",
|
||||
"score": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "team-3",
|
||||
"name": "Équipe 3",
|
||||
"score": 0,
|
||||
"players": [
|
||||
{
|
||||
"id": "x12",
|
||||
"name": "Tyranocif",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x11",
|
||||
"name": "Cizayox",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x10",
|
||||
"name": "Mackogneur",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x18",
|
||||
"name": "Trioxhydre",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x6",
|
||||
"name": "Florizarre",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x23",
|
||||
"name": "Lanssorien",
|
||||
"score": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "team-4",
|
||||
"name": "Équipe 4",
|
||||
"score": 0,
|
||||
"players": [
|
||||
{
|
||||
"id": "x27",
|
||||
"name": "Sulfura",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x21",
|
||||
"name": "Félinferno",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x19",
|
||||
"name": "Amphinobi",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x22",
|
||||
"name": "Corvaillus",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x14",
|
||||
"name": "Drattak",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x4",
|
||||
"name": "Dracaufeu",
|
||||
"score": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x-teams-history-2",
|
||||
"at": "2026-08-31T06:48:00.000Z",
|
||||
"teams": [
|
||||
{
|
||||
"id": "team-1",
|
||||
"name": "Équipe 1",
|
||||
"score": 0,
|
||||
"players": [
|
||||
{
|
||||
"id": "x19",
|
||||
"name": "Amphinobi",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x13",
|
||||
"name": "Gardevoir",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x4",
|
||||
"name": "Dracaufeu",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x9",
|
||||
"name": "Alakazam",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x6",
|
||||
"name": "Florizarre",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x11",
|
||||
"name": "Cizayox",
|
||||
"score": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x-teams-history-3",
|
||||
"at": "2026-08-31T06:48:00.000Z",
|
||||
"teams": [
|
||||
{
|
||||
"id": "team-1",
|
||||
"name": "Équipe 1",
|
||||
"score": 0,
|
||||
"players": [
|
||||
{
|
||||
"id": "x13",
|
||||
"name": "Gardevoir",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x19",
|
||||
"name": "Amphinobi",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x16",
|
||||
"name": "Carchacrok",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x10",
|
||||
"name": "Mackogneur",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x21",
|
||||
"name": "Félinferno",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x9",
|
||||
"name": "Alakazam",
|
||||
"score": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "team-2",
|
||||
"name": "Équipe 2",
|
||||
"score": 0,
|
||||
"players": [
|
||||
{
|
||||
"id": "x5",
|
||||
"name": "Tortank",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x11",
|
||||
"name": "Cizayox",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x18",
|
||||
"name": "Trioxhydre",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x22",
|
||||
"name": "Corvaillus",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x12",
|
||||
"name": "Tyranocif",
|
||||
"score": 0
|
||||
},
|
||||
{
|
||||
"id": "x20",
|
||||
"name": "Archéduc",
|
||||
"score": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"m9": {
|
||||
"activeTab": "countdown",
|
||||
"stopwatch": {
|
||||
|
|
|
|||
|
|
@ -436,6 +436,61 @@
|
|||
"example": "Top 1: 3\nTop 2: 6\nTop 3: 2\nTop 4: 1"
|
||||
}
|
||||
},
|
||||
"randomizer": {
|
||||
"controls": [
|
||||
{
|
||||
"group": "Tirage",
|
||||
"icon": "play-circle",
|
||||
"title": "Lancer",
|
||||
"preview": {
|
||||
"kind": "iconButton",
|
||||
"icon": "play-circle"
|
||||
}
|
||||
},
|
||||
{
|
||||
"group": "Paramètres",
|
||||
"icon": "dice",
|
||||
"title": "Faces, poids et scores",
|
||||
"preview": {
|
||||
"kind": "textButton",
|
||||
"label": "N"
|
||||
}
|
||||
},
|
||||
{
|
||||
"group": "Historique",
|
||||
"icon": "rubber",
|
||||
"title": "Effacer l'historique",
|
||||
"preview": {
|
||||
"kind": "button",
|
||||
"label": "Effacer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"title": "Trois modes fixes",
|
||||
"text": "Dé numérique, roue pondérée et équipes équilibrées restent disponibles dans le même outil.",
|
||||
"details": [
|
||||
"Le dé tire une ou plusieurs valeurs entre 1 et le nombre de faces.",
|
||||
"La roue utilise des poids pour rendre certaines valeurs plus probables.",
|
||||
"Les équipes répartissent les joueurs actifs en équilibrant les scores."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Contraintes d'équipes",
|
||||
"text": "Les exclusions paire-à-paire empêchent deux joueurs de tomber dans la même équipe quand une solution existe.",
|
||||
"details": [
|
||||
"Chaque joueur peut être désactivé sans être supprimé.",
|
||||
"Le score sert uniquement à équilibrer les équipes.",
|
||||
"Si les contraintes sont impossibles, le dernier résultat valide reste affiché."
|
||||
]
|
||||
}
|
||||
],
|
||||
"importFormat": {
|
||||
"text": "Le format texte contient les sections [dice], [wheel] et [teams]. Les exclusions d'équipes commencent par !.",
|
||||
"example": "@activeTab: teams\n[dice]\nfaces: 6\nrolls: 5\n\n[wheel]\ndraws: 3\n\n- Oui | weight=2\n- Non | weight=2\n- Peut etre | weight=1\n\n[teams]\nteamCount: 4\nplayerSelectionMode: all\nplayerSelectionCount: 6\n\n- Dracaufeu | score=0\n- Tortank | score=0\n- Florizarre | score=0\n- Dracolosse | score=0\n- Ectoplasma | score=0\n- Alakazam | score=0\n- Mackogneur | score=0\n- Cizayox | score=0\n- Tyranocif | score=0\n- Gardevoir | score=0\n- Drattak | score=0\n- Métalosse | score=0\n- Carchacrok | score=0\n- Lucario | score=0\n- Trioxhydre | score=0\n- Amphinobi | score=0\n- Archéduc | score=0\n- Félinferno | score=0\n- Corvaillus | score=0\n- Lanssorien | score=0\n- Raichu | score=0\n- Artikodin | score=0\n- Électhor | score=0\n- Sulfura | score=0\n! Dracolosse / Drattak / Carchacrok / Lanssorien | mode=group\n! Artikodin / Électhor / Sulfura | mode=group"
|
||||
}
|
||||
},
|
||||
"images": {
|
||||
"controls": [
|
||||
{
|
||||
|
|
@ -1602,8 +1657,8 @@
|
|||
{
|
||||
"icon": "table",
|
||||
"title": "Données et calculs",
|
||||
"text": "Comparer des valeurs, préparer des tableaux, calculer des besoins de craft ou structurer des objectifs.",
|
||||
"examples": ["Calculateur", "Tableau", "Arbre d'objectifs"]
|
||||
"text": "Comparer des valeurs, préparer des tableaux, calculer des besoins de craft, structurer des objectifs ou tirer des choix aléatoires.",
|
||||
"examples": ["Calculateur", "Tableau", "Arbre d'objectifs", "Randomizer"]
|
||||
},
|
||||
{
|
||||
"icon": "clock",
|
||||
|
|
@ -1919,6 +1974,70 @@
|
|||
"resetTitle": "Réinitialiser",
|
||||
"deleteTitle": "Supprimer"
|
||||
},
|
||||
"randomizer": {
|
||||
"diceTab": "Dé",
|
||||
"wheelTab": "Roue",
|
||||
"teamsTab": "Équipes",
|
||||
"facesLabel": "Faces",
|
||||
"rollsLabel": "Lancers",
|
||||
"rollButton": "Lancer",
|
||||
"stopButton": "Arrêter",
|
||||
"totalLabel": "Total",
|
||||
"drawsLabel": "Tirages",
|
||||
"drawButton": "Tirer",
|
||||
"valuesTitle": "Valeurs",
|
||||
"valuePlaceholder": "Valeur",
|
||||
"savedValuesLabel": "Valeurs enregistrées",
|
||||
"weightLabel": "Poids",
|
||||
"addButton": "Ajouter",
|
||||
"clearValuesButton": "Supprimer toutes les valeurs",
|
||||
"teamCountLabel": "Équipes",
|
||||
"createTeamsButton": "Créer les équipes",
|
||||
"playerSelectionModeLabel": "Répartition",
|
||||
"playerSelectionAll": "Tous",
|
||||
"playerSelectionCount": "Nombre défini",
|
||||
"playerSelectionCountLabel": "Nombre",
|
||||
"nameLabel": "Nom",
|
||||
"namesImportLabel": "Liste de noms",
|
||||
"namesImportPlaceholder": "Un nom par ligne",
|
||||
"importNamesButton": "Ajouter",
|
||||
"clearNamesButton": "Supprimer tous les noms",
|
||||
"valuesImportLabel": "Liste de valeurs",
|
||||
"valuesImportPlaceholder": "Une valeur par ligne",
|
||||
"scoreLabel": "Score",
|
||||
"activeLabel": "Actif",
|
||||
"firstPlayerLabel": "Nom source",
|
||||
"secondPlayerLabel": "Noms exclus",
|
||||
"selectNamesButton": "Choisir",
|
||||
"selectedNamesLabel": "noms",
|
||||
"exclusionModeLabel": "Type",
|
||||
"exclusionModeSource": "Exclusions granulaires",
|
||||
"exclusionModeGroup": "Tous séparés",
|
||||
"addExclusionButton": "Exclure",
|
||||
"exclusionsListLabel": "Combinaisons exclues",
|
||||
"teamsSingularLabel": "équipe",
|
||||
"teamsPluralLabel": "équipes",
|
||||
"teamScoreLabel": "Score",
|
||||
"emptyTeams": "Aucune équipe générée.",
|
||||
"historyTitle": "Historique",
|
||||
"emptyHistory": "Aucun historique.",
|
||||
"clearHistoryButton": "Effacer tout",
|
||||
"deleteTitle": "Supprimer",
|
||||
"wheelInvalid": "Ajoutez au moins deux valeurs.",
|
||||
"notEnoughPlayers": "Pas assez de joueurs actifs.",
|
||||
"constraintsImpossible": "Contraintes impossibles à satisfaire.",
|
||||
"importPlaceholder": "@activeTab: teams\n[dice]\nfaces: 6\nrolls: 5\n\n[wheel]\ndraws: 3\n\n- Oui | weight=2\n- Non | weight=2\n- Peut etre | weight=1\n\n[teams]\nteamCount: 4\nplayerSelectionMode: all\nplayerSelectionCount: 6\n\n- Dracaufeu | score=0\n- Tortank | score=0\n- Florizarre | score=0\n- Dracolosse | score=0\n- Ectoplasma | score=0\n- Alakazam | score=0\n- Mackogneur | score=0\n- Cizayox | score=0\n- Tyranocif | score=0\n- Gardevoir | score=0\n- Drattak | score=0\n- Métalosse | score=0\n- Carchacrok | score=0\n- Lucario | score=0\n- Trioxhydre | score=0\n- Amphinobi | score=0\n- Archéduc | score=0\n- Félinferno | score=0\n- Corvaillus | score=0\n- Lanssorien | score=0\n- Raichu | score=0\n- Artikodin | score=0\n- Électhor | score=0\n- Sulfura | score=0\n! Dracolosse / Drattak / Carchacrok / Lanssorien | mode=group\n! Artikodin / Électhor / Sulfura | mode=group",
|
||||
"importOpenButton": "Importer du texte",
|
||||
"exportOpenButton": "Exporter en format texte",
|
||||
"importModalTitle": "Importer un randomizer",
|
||||
"exportModalTitle": "Exporter un randomizer",
|
||||
"importButton": "Importer le texte",
|
||||
"exportButton": "Copier le texte",
|
||||
"importSuccess": "Import randomizer terminé.",
|
||||
"exportSuccess": "Export randomizer copié.",
|
||||
"importError": "Format de randomizer invalide.",
|
||||
"exportInfo": "Le format texte conserve les paramètres du dé, de la roue et des équipes."
|
||||
},
|
||||
"combos": {
|
||||
"importPlaceholder": "@device: playstation\n# Neutral\nHadoken | device=playstation | direction:down > direction:right > button:cross",
|
||||
"importOpenButton": "Importer du texte",
|
||||
|
|
|
|||
19
website/public/static/icons/dice.svg
Normal file
19
website/public/static/icons/dice.svg
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
|
||||
<title>dice [#25]</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
|
||||
</defs>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Dribbble-Light-Preview" transform="translate(-180.000000, -8079.000000)" fill="#000000">
|
||||
<g id="icons" transform="translate(56.000000, 160.000000)">
|
||||
<path d="M138,7927.1047 C136.896,7927.1047 136,7927.9997 136,7929.1047 C136,7930.2097 136.896,7931.1047 138,7931.1047 C139.104,7931.1047 140,7930.2097 140,7929.1047 C140,7927.9997 139.104,7927.1047 138,7927.1047 L138,7927.1047 Z M130,7927.1047 C128.896,7927.1047 128,7927.9997 128,7929.1047 C128,7930.2097 128.896,7931.1047 130,7931.1047 C131.104,7931.1047 132,7930.2097 132,7929.1047 C132,7927.9997 131.104,7927.1047 130,7927.1047 L130,7927.1047 Z M142,7921.9997 C142,7921.4477 141.552,7920.9997 141,7920.9997 L127,7920.9997 C126.448,7920.9997 126,7921.4477 126,7921.9997 L126,7935.9997 C126,7936.5527 126.448,7936.9997 127,7936.9997 L141,7936.9997 C141.552,7936.9997 142,7936.5527 142,7935.9997 L142,7921.9997 Z M144,7920.9997 L144,7936.9997 C144,7938.1047 143.104,7938.9997 142,7938.9997 L126,7938.9997 C124.896,7938.9997 124,7938.1047 124,7936.9997 L124,7920.9997 C124,7919.8957 124.896,7918.9997 126,7918.9997 L142,7918.9997 C143.104,7918.9997 144,7919.8957 144,7920.9997 L144,7920.9997 Z M130,7922.1047 C128.896,7922.1047 128,7922.9997 128,7924.1047 C128,7925.2097 128.896,7926.1047 130,7926.1047 C131.104,7926.1047 132,7925.2097 132,7924.1047 C132,7922.9997 131.104,7922.1047 130,7922.1047 L130,7922.1047 Z M130,7932.1047 C128.896,7932.1047 128,7932.9997 128,7934.1047 C128,7935.2097 128.896,7936.1047 130,7936.1047 C131.104,7936.1047 132,7935.2097 132,7934.1047 C132,7932.9997 131.104,7932.1047 130,7932.1047 L130,7932.1047 Z M138,7922.1047 C136.896,7922.1047 136,7922.9997 136,7924.1047 C136,7925.2097 136.896,7926.1047 138,7926.1047 C139.104,7926.1047 140,7925.2097 140,7924.1047 C140,7922.9997 139.104,7922.1047 138,7922.1047 L138,7922.1047 Z M140,7934.1047 C140,7935.2097 139.104,7936.1047 138,7936.1047 C136.896,7936.1047 136,7935.2097 136,7934.1047 C136,7932.9997 136.896,7932.1047 138,7932.1047 C139.104,7932.1047 140,7932.9997 140,7934.1047 L140,7934.1047 Z" id="dice-[#25]">
|
||||
|
||||
</path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
27
website/public/static/icons/wheel.svg
Normal file
27
website/public/static/icons/wheel.svg
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 512 512" xml:space="preserve">
|
||||
<g>
|
||||
<g>
|
||||
<path d="M437.034,437.002C483.352,390.676,512,326.683,512,256c0-70.601-28.583-134.526-74.806-180.839
|
||||
c-0.059-0.061-0.109-0.127-0.169-0.188c-0.06-0.06-0.127-0.11-0.188-0.17C390.523,28.582,326.599,0,256,0
|
||||
S121.477,28.582,75.164,74.804c-0.061,0.06-0.128,0.11-0.188,0.17c-0.06,0.06-0.11,0.127-0.169,0.188
|
||||
C28.583,121.474,0,185.399,0,256c0,70.683,28.648,134.676,74.966,181.002c0.005,0.005,0.009,0.01,0.014,0.015
|
||||
c0.005,0.005,0.01,0.009,0.015,0.013C121.321,483.351,185.315,512,256,512s134.679-28.649,181.005-74.969
|
||||
c0.005-0.005,0.01-0.009,0.015-0.013C437.025,437.013,437.029,437.007,437.034,437.002z M43.726,277.333h107.744
|
||||
c2.765,13.617,8.127,26.288,15.542,37.483l-76.188,76.188C64.959,359.398,47.986,320.237,43.726,277.333z M90.825,120.993
|
||||
l76.188,76.188c-7.415,11.196-12.779,23.868-15.544,37.486H43.726C47.986,191.762,64.96,152.6,90.825,120.993z M468.274,234.667
|
||||
H360.531c-2.765-13.618-8.128-26.29-15.544-37.486l76.188-76.187C447.04,152.6,464.014,191.762,468.274,234.667z M301.25,301.248
|
||||
c-0.022,0.022-0.04,0.046-0.062,0.068C289.611,312.861,273.639,320,256,320s-33.611-7.139-45.188-18.684
|
||||
c-0.022-0.022-0.04-0.046-0.062-0.068c-0.022-0.022-0.046-0.039-0.067-0.061C199.139,289.61,192,273.638,192,256
|
||||
c0-35.343,28.657-64,64-64s64,28.657,64,64c0,17.638-7.139,33.61-18.683,45.187C301.296,301.209,301.272,301.226,301.25,301.248z
|
||||
M314.817,167.011c-11.195-7.414-23.867-12.777-37.483-15.542V43.726c42.904,4.26,82.065,21.234,113.671,47.098L314.817,167.011z
|
||||
M234.667,151.469c-13.617,2.765-26.288,8.127-37.483,15.542l-76.188-76.188c31.606-25.864,70.767-42.838,113.671-47.098V151.469z
|
||||
M197.181,344.987c11.196,7.415,23.868,12.779,37.486,15.544v107.744c-42.905-4.26-82.067-21.235-113.674-47.1L197.181,344.987z
|
||||
M277.333,360.531c13.618-2.765,26.29-8.128,37.486-15.544l76.188,76.188c-31.607,25.865-70.769,42.839-113.674,47.1V360.531z
|
||||
M344.989,314.817c7.414-11.195,12.777-23.867,15.542-37.483h107.744c-4.26,42.904-21.234,82.065-47.098,113.671L344.989,314.817z
|
||||
"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
|
@ -13,6 +13,7 @@ export function CompactDropdown({
|
|||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef(null);
|
||||
const internalPointerDownRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
|
|
@ -48,7 +49,14 @@ export function CompactDropdown({
|
|||
onMouseEnter={openOnHover ? () => setOpen(true) : undefined}
|
||||
onMouseLeave={closeOnMouseLeave ? close : undefined}
|
||||
onFocusCapture={openOnHover ? () => setOpen(true) : undefined}
|
||||
onPointerDownCapture={() => {
|
||||
internalPointerDownRef.current = true;
|
||||
window.setTimeout(() => {
|
||||
internalPointerDownRef.current = false;
|
||||
}, 0);
|
||||
}}
|
||||
onBlurCapture={(event) => {
|
||||
if (internalPointerDownRef.current) return;
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) close();
|
||||
}}
|
||||
onMouseDown={preventMouseDown ? (event) => event.preventDefault() : undefined}
|
||||
|
|
|
|||
40
website/src/features/toolboxes/AGENTS.md
Normal file
40
website/src/features/toolboxes/AGENTS.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Agent Instructions
|
||||
|
||||
Cette zone contient les outils toolbox, leur stockage local et leurs comportements partagés.
|
||||
|
||||
## Références obligatoires
|
||||
|
||||
Avant de modifier un outil toolbox, le stockage IndexedDB, l'import/export ou les formats de données, lire :
|
||||
|
||||
- `../../../../docs/STORAGE_SCHEMA.md`
|
||||
- `../../../../docs/FEATURE_CHECKLIST.md`
|
||||
|
||||
Après changement de format ou de workflow, mettre ces documents à jour.
|
||||
|
||||
## Modules toolbox
|
||||
|
||||
Chaque outil toolbox doit rester dans son propre fichier dans :
|
||||
|
||||
```text
|
||||
website/src/features/toolboxes/modules/
|
||||
```
|
||||
|
||||
Quand un outil est ajouté ou modifié :
|
||||
|
||||
- déclarer l'outil dans `modules/index.jsx` ;
|
||||
- ajouter les textes dans `../../../public/data/site.json` ;
|
||||
- mettre à jour la validation dans `../../../../tests/helpers/data-validation.mjs` ;
|
||||
- mettre à jour `../../../../docs/STORAGE_SCHEMA.md` si la structure IndexedDB change ;
|
||||
- vérifier l'affichage page toolbox et panneau latéral.
|
||||
|
||||
Quand la structure de données d'un outil change, ne pas prévoir de compatibilité legacy, de migration ou de tolérance d'anciens formats, sauf demande explicite. Le format documenté dans `STORAGE_SCHEMA.md` reste le format cible strict.
|
||||
|
||||
## Hooks et comportements partagés
|
||||
|
||||
Quand un comportement est déjà couvert par un hook ou un helper partagé, privilégier son utilisation plutôt qu'une réimplémentation locale.
|
||||
|
||||
- Pour la réorganisation par drag & drop, utiliser `useGroupedReorder` dès qu'il s'agit d'items, groupes, catégories, boundaries, parent/enfant ou listes horizontales/verticales.
|
||||
- Réserver `usePointerReorder` au moteur bas niveau ou aux cas DOM très spécifiques qui ne correspondent pas au modèle applicatif de `useGroupedReorder`.
|
||||
- Si une variation est nécessaire, vérifier d'abord si elle doit devenir une option du hook partagé.
|
||||
- Demander ou expliciter le choix uniquement quand la variation est réellement métier et pourrait alourdir le hook.
|
||||
- Éviter de dupliquer dans un outil une règle de reorder générique déjà prise en charge par le hook.
|
||||
|
|
@ -26,6 +26,7 @@ import {
|
|||
normalizeMemosData,
|
||||
normalizeModuleOrder,
|
||||
normalizeNotepadData,
|
||||
normalizeRandomizerData,
|
||||
normalizeTableData,
|
||||
normalizeTaskPlannerData,
|
||||
normalizeTimerData,
|
||||
|
|
@ -200,6 +201,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
|
|||
normalizeCalendarData,
|
||||
normalizeImageAnnotationData,
|
||||
normalizeNotepadData,
|
||||
normalizeRandomizerData,
|
||||
normalizeTableData,
|
||||
normalizeTimerData,
|
||||
normalizeTaskPlannerData,
|
||||
|
|
|
|||
873
website/src/features/toolboxes/modules/RandomizerModule.jsx
Normal file
873
website/src/features/toolboxes/modules/RandomizerModule.jsx
Normal file
|
|
@ -0,0 +1,873 @@
|
|||
// Rôle : fournit l'outil Randomizer avec dé, roue pondérée et équipes équilibrées.
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { Tabs } from "../../../components/Tabs.jsx";
|
||||
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
import {
|
||||
RANDOMIZER_LIMITS,
|
||||
RANDOMIZER_TABS,
|
||||
addRandomizerHistory,
|
||||
buildWheelSegments,
|
||||
clampNumber,
|
||||
createBalancedTeams,
|
||||
drawWeightedEntries,
|
||||
filterExclusionsForPlayers,
|
||||
getDiceShape,
|
||||
rollDice
|
||||
} from "./randomizerUtils.js";
|
||||
|
||||
const WHEEL_COLORS = ["#8b5cf6", "#f6c453", "#22d3ee", "#d946ef", "#6366f1", "#22c55e"];
|
||||
const DRAW_PAUSE_MS = 500;
|
||||
const DICE_ROLL_DELAYS = [62, 70, 82, 98, 120, 148, 182, 224, 276, 338, 410];
|
||||
const TEAM_REEL_DELAYS = [58, 66, 78, 94, 116, 144, 180, 226, 286, 360];
|
||||
const WHEEL_SPIN_MS = 1120;
|
||||
const DEFAULT_EXCLUSION_DRAFT = { playerAId: "", playerBIds: [], mode: "group" };
|
||||
|
||||
function hasReducedMotion() {
|
||||
return window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
|
||||
}
|
||||
|
||||
function polarToCartesian(radius, angle) {
|
||||
const radians = (angle * Math.PI) / 180;
|
||||
return {
|
||||
x: 50 + radius * Math.cos(radians),
|
||||
y: 50 + radius * Math.sin(radians)
|
||||
};
|
||||
}
|
||||
|
||||
function describeWheelArc(startAngle, endAngle) {
|
||||
const start = polarToCartesian(48, startAngle);
|
||||
const end = polarToCartesian(48, endAngle);
|
||||
const largeArc = endAngle - startAngle > 180 ? 1 : 0;
|
||||
return `M 50 50 L ${start.x} ${start.y} A 48 48 0 ${largeArc} 1 ${end.x} ${end.y} Z`;
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
const date = new Date(value);
|
||||
return Number.isFinite(date.getTime()) ? date.toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }) : "";
|
||||
}
|
||||
|
||||
function getWheelTargetRotation(currentRotation, targetAngle) {
|
||||
const pointerAngle = -90;
|
||||
const targetRotation = pointerAngle - targetAngle;
|
||||
const currentModulo = ((currentRotation % 360) + 360) % 360;
|
||||
const targetModulo = ((targetRotation % 360) + 360) % 360;
|
||||
const delta = (targetModulo - currentModulo + 360) % 360;
|
||||
return currentRotation + 1080 + delta;
|
||||
}
|
||||
|
||||
function pickRandomItems(items, count) {
|
||||
const nextItems = [...items];
|
||||
for (let index = nextItems.length - 1; index > 0; index -= 1) {
|
||||
const targetIndex = Math.floor(Math.random() * (index + 1));
|
||||
[nextItems[index], nextItems[targetIndex]] = [nextItems[targetIndex], nextItems[index]];
|
||||
}
|
||||
return nextItems.slice(0, count);
|
||||
}
|
||||
|
||||
export function RandomizerModule({ toolboxId, moduleId, context, textExchangeOpen }) {
|
||||
const data = context.normalizeRandomizerData(context.getModuleData(toolboxId, moduleId, {}));
|
||||
const textContent = context.moduleText?.randomizer || {};
|
||||
|
||||
function save(nextData) {
|
||||
context.setModuleData(toolboxId, moduleId, nextData);
|
||||
}
|
||||
|
||||
function updateSection(section, nextSection) {
|
||||
save({ ...data, [section]: nextSection });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{textExchangeOpen && (
|
||||
<div className="module-text-exchange-panel">
|
||||
<TextExchangeActions type="randomizer" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "randomizer")} />
|
||||
</div>
|
||||
)}
|
||||
<div className="randomizer-tool">
|
||||
<Tabs
|
||||
tabs={RANDOMIZER_TABS.map((tab) => ({ ...tab, label: textContent[`${tab.id}Tab`] || tab.label }))}
|
||||
activeTab={data.activeTab}
|
||||
onChange={(activeTab) => save({ ...data, activeTab })}
|
||||
className="randomizer-tabs"
|
||||
size="md"
|
||||
/>
|
||||
{data.activeTab === "dice" && <DiceRandomizer data={data.dice} textContent={textContent} onChange={(dice) => updateSection("dice", dice)} context={context} />}
|
||||
{data.activeTab === "wheel" && <WheelRandomizer data={data.wheel} textContent={textContent} onChange={(wheel) => updateSection("wheel", wheel)} context={context} />}
|
||||
{data.activeTab === "teams" && <TeamsRandomizer data={data.teams} textContent={textContent} onChange={(teams) => updateSection("teams", teams)} context={context} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DiceRandomizer({ data, textContent, onChange, context }) {
|
||||
const [displayValue, setDisplayValue] = useState(data.results[0] || 1);
|
||||
const [displayResults, setDisplayResults] = useState(data.results);
|
||||
const [rolling, setRolling] = useState(false);
|
||||
const [rollingIndex, setRollingIndex] = useState(-1);
|
||||
const [rollDuration, setRollDuration] = useState(DICE_ROLL_DELAYS[0]);
|
||||
const timersRef = useRef([]);
|
||||
const skipDiceSyncRef = useRef(false);
|
||||
const shape = getDiceShape(data.faces);
|
||||
|
||||
useEffect(() => {
|
||||
if (rolling) return;
|
||||
if (skipDiceSyncRef.current) {
|
||||
skipDiceSyncRef.current = false;
|
||||
return;
|
||||
}
|
||||
setDisplayValue(data.results[0] || 1);
|
||||
setDisplayResults(data.results);
|
||||
}, [data.results, rolling]);
|
||||
|
||||
useEffect(() => () => {
|
||||
timersRef.current.forEach((timerId) => window.clearTimeout(timerId));
|
||||
timersRef.current = [];
|
||||
}, []);
|
||||
|
||||
function queueTimer(callback, delay) {
|
||||
const timerId = window.setTimeout(callback, delay);
|
||||
timersRef.current.push(timerId);
|
||||
return timerId;
|
||||
}
|
||||
|
||||
function clearDiceTimers() {
|
||||
timersRef.current.forEach((timerId) => window.clearTimeout(timerId));
|
||||
timersRef.current = [];
|
||||
}
|
||||
|
||||
function stopDice() {
|
||||
clearDiceTimers();
|
||||
skipDiceSyncRef.current = true;
|
||||
setRolling(false);
|
||||
setRollingIndex(-1);
|
||||
}
|
||||
|
||||
function updateDiceField(field, value) {
|
||||
const limitMin = field === "faces" ? RANDOMIZER_LIMITS.diceFacesMin : RANDOMIZER_LIMITS.diceRollsMin;
|
||||
const limitMax = field === "faces" ? RANDOMIZER_LIMITS.diceFacesMax : RANDOMIZER_LIMITS.diceRollsMax;
|
||||
onChange({ ...data, [field]: clampNumber(value, limitMin, limitMax, data[field]) });
|
||||
}
|
||||
|
||||
function launchDice() {
|
||||
clearDiceTimers();
|
||||
skipDiceSyncRef.current = false;
|
||||
const results = rollDice(data.faces, data.rolls);
|
||||
const historyEntry = { id: context.uid("randomizer"), at: new Date().toISOString(), results };
|
||||
if (hasReducedMotion()) {
|
||||
onChange({ ...data, results, history: addRandomizerHistory(data.history, historyEntry) });
|
||||
return;
|
||||
}
|
||||
setRolling(true);
|
||||
setRollingIndex(0);
|
||||
setDisplayResults([]);
|
||||
|
||||
function animateRoll(index) {
|
||||
let ticks = 0;
|
||||
const tick = () => {
|
||||
ticks += 1;
|
||||
const isFinalTick = ticks >= DICE_ROLL_DELAYS.length;
|
||||
const tickDelay = DICE_ROLL_DELAYS[Math.min(ticks, DICE_ROLL_DELAYS.length - 1)];
|
||||
setDisplayValue(isFinalTick ? results[index] : rollDice(data.faces, 1)[0]);
|
||||
setRollDuration(Math.max(54, Math.round(tickDelay * 0.86)));
|
||||
if (!isFinalTick) {
|
||||
queueTimer(tick, tickDelay);
|
||||
return;
|
||||
}
|
||||
setDisplayResults((current) => [...current, results[index]]);
|
||||
if (index < results.length - 1) {
|
||||
setRollingIndex(index + 1);
|
||||
queueTimer(() => animateRoll(index + 1), DRAW_PAUSE_MS);
|
||||
return;
|
||||
}
|
||||
setRolling(false);
|
||||
setRollingIndex(-1);
|
||||
onChange({ ...data, results, history: addRandomizerHistory(data.history, historyEntry) });
|
||||
};
|
||||
tick();
|
||||
}
|
||||
|
||||
animateRoll(0);
|
||||
}
|
||||
|
||||
function clearDiceHistory() {
|
||||
onChange({ ...data, history: [] });
|
||||
}
|
||||
|
||||
function removeDiceHistoryEntry(entryId) {
|
||||
onChange({ ...data, history: data.history.filter((entry) => entry.id !== entryId) });
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="randomizer-panel randomizer-dice-panel">
|
||||
<div className="randomizer-dice-stage">
|
||||
<RandomizerDie shape={shape} rolling={rolling} value={displayValue} duration={rollDuration} faces={data.faces} />
|
||||
<div className="randomizer-live-results" aria-live="polite">
|
||||
<ResultChips results={displayResults} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="randomizer-dice-grid">
|
||||
<div className="randomizer-dice-form">
|
||||
<div className="randomizer-controls">
|
||||
<label>
|
||||
<span>{textContent.facesLabel || "Faces"}</span>
|
||||
<input className="nds-input" data-size="sm" type="number" min="2" max="999" value={data.faces} onChange={(event) => updateDiceField("faces", event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{textContent.rollsLabel || "Lancers"}</span>
|
||||
<input className="nds-input" data-size="sm" type="number" min="1" max="100" value={data.rolls} onChange={(event) => updateDiceField("rolls", event.target.value)} />
|
||||
</label>
|
||||
<button className="nds-button" data-size="sm" data-variant={rolling ? "danger" : "primary"} type="button" onClick={rolling ? stopDice : launchDice}>
|
||||
<Icon name={rolling ? "stop-circle" : "play-circle"} />
|
||||
<span>{rolling ? `${textContent.stopButton || "Arrêter"} ${rollingIndex + 1}/${data.rolls}` : textContent.rollButton || "Lancer"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<RandomizerHistory
|
||||
title={textContent.historyTitle || "Historique"}
|
||||
history={data.history}
|
||||
renderEntry={(entry) => `${entry.results.join(" - ")} · ${textContent.totalLabel || "Total"} ${entry.results.reduce((sum, result) => sum + result, 0)}`}
|
||||
onClear={clearDiceHistory}
|
||||
onRemove={removeDiceHistoryEntry}
|
||||
disabled={rolling}
|
||||
textContent={textContent}
|
||||
showEmpty
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RandomizerDie({ shape, rolling, value, duration, faces }) {
|
||||
const cleanFaces = clampNumber(faces, RANDOMIZER_LIMITS.diceFacesMin, RANDOMIZER_LIMITS.diceFacesMax, 6);
|
||||
const valueIndex = clampNumber(value, 1, cleanFaces, 1) - 1;
|
||||
const values = useMemo(() => Array.from({ length: cleanFaces }, (_, index) => index + 1), [cleanFaces]);
|
||||
|
||||
return (
|
||||
<div className={`randomizer-die is-${shape} ${rolling ? "is-rolling" : ""}`} aria-live="polite">
|
||||
<svg className="randomizer-die-shape" viewBox="0 0 120 120" aria-hidden="true" focusable="false">
|
||||
{shape === "circle" && <circle cx="60" cy="60" r="52" />}
|
||||
{shape === "triangle" && <path d="M60 9 Q66 9 70 17 L111 96 Q115 105 106 110 L14 110 Q5 105 9 96 L50 17 Q54 9 60 9 Z" />}
|
||||
{shape === "square" && <rect x="10" y="10" width="100" height="100" rx="16" />}
|
||||
{shape === "hexagon" && <path d="M36 9 L84 9 Q92 9 96 16 L115 52 Q119 60 115 68 L96 104 Q92 111 84 111 L36 111 Q28 111 24 104 L5 68 Q1 60 5 52 L24 16 Q28 9 36 9 Z" />}
|
||||
</svg>
|
||||
<span className="randomizer-die-value">
|
||||
<span className="randomizer-die-value-track" style={{ "--randomizer-die-index": valueIndex, "--randomizer-die-duration": `${duration}ms` }} aria-hidden="true">
|
||||
{values.map((item) => <span key={item}>{item}</span>)}
|
||||
</span>
|
||||
<span className="sr-only">{value}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WheelRandomizer({ data, textContent, onChange, context }) {
|
||||
const [rotation, setRotation] = useState(0);
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const [drawing, setDrawing] = useState(false);
|
||||
const [displayResults, setDisplayResults] = useState(data.results);
|
||||
const [entriesText, setEntriesText] = useState("");
|
||||
const timersRef = useRef([]);
|
||||
const skipWheelSyncRef = useRef(false);
|
||||
const segments = useMemo(() => buildWheelSegments(data.entries), [data.entries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (drawing) return;
|
||||
if (skipWheelSyncRef.current) {
|
||||
skipWheelSyncRef.current = false;
|
||||
return;
|
||||
}
|
||||
setDisplayResults(data.results);
|
||||
}, [data.results, drawing]);
|
||||
|
||||
useEffect(() => () => {
|
||||
timersRef.current.forEach((timerId) => window.clearTimeout(timerId));
|
||||
timersRef.current = [];
|
||||
}, []);
|
||||
|
||||
function queueTimer(callback, delay) {
|
||||
const timerId = window.setTimeout(callback, delay);
|
||||
timersRef.current.push(timerId);
|
||||
return timerId;
|
||||
}
|
||||
|
||||
function clearWheelTimers() {
|
||||
timersRef.current.forEach((timerId) => window.clearTimeout(timerId));
|
||||
timersRef.current = [];
|
||||
}
|
||||
|
||||
function stopWheel() {
|
||||
clearWheelTimers();
|
||||
skipWheelSyncRef.current = true;
|
||||
setSpinning(false);
|
||||
setDrawing(false);
|
||||
}
|
||||
|
||||
function addEntries(event) {
|
||||
event.preventDefault();
|
||||
const availableSlots = RANDOMIZER_LIMITS.wheelEntriesMax - data.entries.length;
|
||||
const labels = entriesText
|
||||
.split(/\r?\n/)
|
||||
.map((label) => label.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, availableSlots);
|
||||
if (!labels.length) return;
|
||||
onChange({
|
||||
...data,
|
||||
entries: [
|
||||
...data.entries,
|
||||
...labels.map((label) => ({ id: context.uid("randomizer"), label, weight: 1 }))
|
||||
]
|
||||
});
|
||||
setEntriesText("");
|
||||
}
|
||||
|
||||
function updateEntry(entryId, patch) {
|
||||
onChange({ ...data, entries: data.entries.map((entry) => entry.id === entryId ? { ...entry, ...patch } : entry) });
|
||||
}
|
||||
|
||||
function removeEntry(entryId) {
|
||||
onChange({ ...data, entries: data.entries.filter((entry) => entry.id !== entryId) });
|
||||
}
|
||||
|
||||
function clearEntries() {
|
||||
onChange({ ...data, entries: [], results: [] });
|
||||
}
|
||||
|
||||
function clearWheelHistory() {
|
||||
onChange({ ...data, history: [] });
|
||||
}
|
||||
|
||||
function removeWheelHistoryEntry(entryId) {
|
||||
onChange({ ...data, history: data.history.filter((entry) => entry.id !== entryId) });
|
||||
}
|
||||
|
||||
function launchWheel() {
|
||||
clearWheelTimers();
|
||||
skipWheelSyncRef.current = false;
|
||||
const drawnEntries = drawWeightedEntries(data.entries, data.draws);
|
||||
if (!drawnEntries.length) {
|
||||
context.notify?.(textContent.wheelInvalid || "Ajoutez au moins deux valeurs.");
|
||||
return;
|
||||
}
|
||||
const results = drawnEntries.map((entry) => entry.label);
|
||||
const historyEntry = { id: context.uid("randomizer"), at: new Date().toISOString(), results };
|
||||
if (hasReducedMotion()) {
|
||||
setDisplayResults(results);
|
||||
onChange({ ...data, results, history: addRandomizerHistory(data.history, historyEntry) });
|
||||
return;
|
||||
}
|
||||
setDrawing(true);
|
||||
setDisplayResults([]);
|
||||
let rotationCursor = rotation;
|
||||
|
||||
function animateDraw(index) {
|
||||
const drawnEntry = drawnEntries[index];
|
||||
const winnerSegment = segments.find((segment) => segment.entry.id === drawnEntry.id) || segments[0];
|
||||
rotationCursor = getWheelTargetRotation(rotationCursor, winnerSegment.midAngle);
|
||||
setSpinning(true);
|
||||
setRotation(rotationCursor);
|
||||
queueTimer(() => {
|
||||
setDisplayResults((current) => [...current, drawnEntry.label]);
|
||||
setSpinning(false);
|
||||
if (index < drawnEntries.length - 1) {
|
||||
queueTimer(() => animateDraw(index + 1), DRAW_PAUSE_MS);
|
||||
return;
|
||||
}
|
||||
setDrawing(false);
|
||||
onChange({ ...data, results, history: addRandomizerHistory(data.history, historyEntry) });
|
||||
}, WHEEL_SPIN_MS);
|
||||
}
|
||||
|
||||
animateDraw(0);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="randomizer-panel randomizer-wheel-panel">
|
||||
<div className="randomizer-wheel-stage" aria-live="polite">
|
||||
<span className="randomizer-wheel-spacer" aria-hidden="true" />
|
||||
<div className="randomizer-wheel-center">
|
||||
<span className="randomizer-wheel-pointer" aria-hidden="true" />
|
||||
<svg className={`randomizer-wheel ${spinning ? "is-spinning" : ""}`} viewBox="0 0 100 100" style={{ "--randomizer-wheel-rotation": `${rotation}deg` }}>
|
||||
{segments.length ? segments.map((segment) => (
|
||||
<path key={segment.entry.id} d={describeWheelArc(segment.startAngle, segment.endAngle)} fill={WHEEL_COLORS[segment.index % WHEEL_COLORS.length]} />
|
||||
)) : <circle cx="50" cy="50" r="48" fill="rgba(139, 92, 246, 0.18)" />}
|
||||
<circle cx="50" cy="50" r="12" fill="rgba(7, 10, 24, 0.92)" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="randomizer-wheel-legend nds-scrollbox" data-axis="vertical" aria-label={textContent.valuesTitle || "Valeurs"}>
|
||||
{segments.length ? segments.map((segment) => (
|
||||
<div className="randomizer-wheel-legend-row" key={segment.entry.id}>
|
||||
<span className="randomizer-wheel-swatch" style={{ "--randomizer-swatch": WHEEL_COLORS[segment.index % WHEEL_COLORS.length] }} aria-hidden="true" />
|
||||
<span className="randomizer-wheel-label">{segment.entry.label}</span>
|
||||
</div>
|
||||
)) : <p className="randomizer-empty">{textContent.wheelInvalid || "Ajoutez au moins deux valeurs."}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="randomizer-live-results randomizer-wheel-results" aria-live="polite">
|
||||
<ResultChips results={displayResults} />
|
||||
</div>
|
||||
|
||||
<div className="randomizer-wheel-grid">
|
||||
<section className="randomizer-wheel-form">
|
||||
<form className="randomizer-entry-form" onSubmit={addEntries}>
|
||||
<label className="randomizer-field">
|
||||
<span>{textContent.valuesImportLabel || "Liste de valeurs"}</span>
|
||||
<textarea className="nds-textarea" data-size="sm" value={entriesText} onChange={(event) => setEntriesText(event.target.value)} placeholder={textContent.valuesImportPlaceholder || "Une valeur par ligne"} />
|
||||
</label>
|
||||
<button className="nds-button" data-size="sm" data-variant="primary" type="submit">{textContent.addButton || "Ajouter"}</button>
|
||||
</form>
|
||||
<div className="randomizer-controls">
|
||||
<label>
|
||||
<span>{textContent.drawsLabel || "Tirages"}</span>
|
||||
<input className="nds-input" data-size="sm" type="number" min="1" max="100" value={data.draws} onChange={(event) => onChange({ ...data, draws: clampNumber(event.target.value, 1, 100, data.draws) })} />
|
||||
</label>
|
||||
<button className="nds-button" data-size="sm" data-variant={drawing ? "danger" : "primary"} type="button" onClick={drawing ? stopWheel : launchWheel}>
|
||||
<Icon name={drawing ? "stop-circle" : "play-circle"} />
|
||||
<span>{drawing ? textContent.stopButton || "Arrêter" : textContent.drawButton || "Tirer"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section className="randomizer-wheel-values-section">
|
||||
<div className="randomizer-list-header randomizer-value-row randomizer-wheel-value-row" aria-hidden="true">
|
||||
<span>{textContent.savedValuesLabel || "Valeurs enregistrées"}</span>
|
||||
<span>{textContent.weightLabel || "Poids"}</span>
|
||||
<span />
|
||||
</div>
|
||||
<div className="randomizer-value-list nds-scrollbox" data-axis="vertical" data-variant="ghost" aria-label={textContent.valuesTitle || "Valeurs"}>
|
||||
{data.entries.map((entry) => (
|
||||
<div className="randomizer-value-row randomizer-wheel-value-row" key={entry.id}>
|
||||
<input className="nds-input" data-size="sm" value={entry.label} onChange={(event) => updateEntry(entry.id, { label: event.target.value })} aria-label={textContent.valuePlaceholder || "Valeur"} />
|
||||
<input className="nds-input" data-size="sm" type="number" min="1" max="999" value={entry.weight} onChange={(event) => updateEntry(entry.id, { weight: clampNumber(event.target.value, 1, 999, entry.weight) })} aria-label={`${textContent.weightLabel || "Poids"} ${entry.label}`} />
|
||||
<button className="nds-icon-button" data-size="sm" data-variant="danger" type="button" onClick={() => removeEntry(entry.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${entry.label}`} title={textContent.deleteTitle || "Supprimer"}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="nds-button" data-size="sm" type="button" onClick={clearEntries} disabled={!data.entries.length || drawing}>{textContent.clearValuesButton || "Supprimer toutes les valeurs"}</button>
|
||||
</section>
|
||||
<RandomizerHistory
|
||||
title={textContent.historyTitle || "Historique"}
|
||||
history={data.history}
|
||||
renderEntry={(entry) => entry.results.join(" - ")}
|
||||
onClear={clearWheelHistory}
|
||||
onRemove={removeWheelHistoryEntry}
|
||||
disabled={drawing}
|
||||
textContent={textContent}
|
||||
showEmpty
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamsRandomizer({ data, textContent, onChange, context }) {
|
||||
const exclusionDraft = useDraftForm(DEFAULT_EXCLUSION_DRAFT);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
const [animationTeams, setAnimationTeams] = useState([]);
|
||||
const [reelPlayers, setReelPlayers] = useState([]);
|
||||
const [reelIndex, setReelIndex] = useState(0);
|
||||
const [reelDuration, setReelDuration] = useState(TEAM_REEL_DELAYS[0]);
|
||||
const [namesText, setNamesText] = useState("");
|
||||
const timersRef = useRef([]);
|
||||
const activePlayers = data.players.filter((player) => player.active);
|
||||
const storedSelectionCount = clampNumber(data.playerSelectionCount, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, data.teamCount);
|
||||
const [selectionCountDraft, setSelectionCountDraft] = useState(String(storedSelectionCount));
|
||||
const selectionCount = clampNumber(selectionCountDraft, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, storedSelectionCount);
|
||||
const displayedTeams = animating ? animationTeams : data.teams;
|
||||
const exclusionMode = exclusionDraft.values.mode === "source" ? "source" : "group";
|
||||
|
||||
useEffect(() => {
|
||||
setSelectionCountDraft(String(storedSelectionCount));
|
||||
}, [storedSelectionCount]);
|
||||
|
||||
useEffect(() => () => {
|
||||
timersRef.current.forEach((timerId) => window.clearTimeout(timerId));
|
||||
timersRef.current = [];
|
||||
}, []);
|
||||
|
||||
function queueTimer(callback, delay) {
|
||||
const timerId = window.setTimeout(callback, delay);
|
||||
timersRef.current.push(timerId);
|
||||
return timerId;
|
||||
}
|
||||
|
||||
function clearTeamTimers() {
|
||||
timersRef.current.forEach((timerId) => window.clearTimeout(timerId));
|
||||
timersRef.current = [];
|
||||
}
|
||||
|
||||
function commitSelectionCount(value = selectionCountDraft) {
|
||||
const nextSelectionCount = clampNumber(value, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, storedSelectionCount);
|
||||
setSelectionCountDraft(String(nextSelectionCount));
|
||||
onChange({ ...data, playerSelectionCount: nextSelectionCount });
|
||||
}
|
||||
|
||||
function importPlayers(event) {
|
||||
event.preventDefault();
|
||||
const availableSlots = RANDOMIZER_LIMITS.teamPlayersMax - data.players.length;
|
||||
const names = namesText
|
||||
.split(/\r?\n/)
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, availableSlots);
|
||||
if (!names.length) return;
|
||||
onChange({
|
||||
...data,
|
||||
players: [
|
||||
...data.players,
|
||||
...names.map((name) => ({ id: context.uid("randomizer"), name, score: 0, active: true }))
|
||||
]
|
||||
});
|
||||
setNamesText("");
|
||||
}
|
||||
|
||||
function updatePlayer(playerId, patch) {
|
||||
onChange({ ...data, players: data.players.map((player) => player.id === playerId ? { ...player, ...patch } : player) });
|
||||
}
|
||||
|
||||
function removePlayer(playerId) {
|
||||
onChange({
|
||||
...data,
|
||||
players: data.players.filter((player) => player.id !== playerId),
|
||||
exclusions: data.exclusions
|
||||
.filter((exclusion) => exclusion.playerAId !== playerId)
|
||||
.map((exclusion) => ({ ...exclusion, playerBIds: (exclusion.playerBIds || []).filter((targetId) => targetId !== playerId) }))
|
||||
.filter((exclusion) => exclusion.playerBIds.length)
|
||||
});
|
||||
}
|
||||
|
||||
function clearPlayers() {
|
||||
onChange({ ...data, players: [], exclusions: [], teams: [] });
|
||||
}
|
||||
|
||||
function toggleExclusionTarget(playerId) {
|
||||
const currentIds = exclusionDraft.values.playerBIds || [];
|
||||
exclusionDraft.setField(
|
||||
"playerBIds",
|
||||
currentIds.includes(playerId) ? currentIds.filter((targetId) => targetId !== playerId) : [...currentIds, playerId]
|
||||
);
|
||||
}
|
||||
|
||||
function addExclusion(event) {
|
||||
exclusionDraft.handleSubmit(event, (draft, { reset }) => {
|
||||
const mode = draft.mode === "source" ? "source" : "group";
|
||||
const selectedIds = [...new Set(Array.isArray(draft.playerBIds) ? draft.playerBIds.filter(Boolean) : [])];
|
||||
const playerAId = mode === "group" ? selectedIds[0] : draft.playerAId;
|
||||
const playerBIds = mode === "group" ? selectedIds.slice(1) : selectedIds.filter((playerId) => playerId !== draft.playerAId);
|
||||
if (!playerAId || !playerBIds.length) return;
|
||||
const exists = data.exclusions.some((exclusion) => exclusion.playerAId === playerAId && (exclusion.mode || "source") === mode && [...(exclusion.playerBIds || [])].sort().join(":") === [...playerBIds].sort().join(":"));
|
||||
if (exists) return;
|
||||
onChange({ ...data, exclusions: [...data.exclusions, { id: context.uid("randomizer"), playerAId, playerBIds, mode }] });
|
||||
reset();
|
||||
});
|
||||
}
|
||||
|
||||
function launchTeams() {
|
||||
clearTeamTimers();
|
||||
const selectedPlayers = data.playerSelectionMode === "count" ? pickRandomItems(activePlayers, Math.min(selectionCount, activePlayers.length)) : activePlayers;
|
||||
const selectedExclusions = filterExclusionsForPlayers(data.exclusions, selectedPlayers);
|
||||
const result = createBalancedTeams(selectedPlayers, data.teamCount, selectedExclusions);
|
||||
if (!result.ok) {
|
||||
context.notify?.(result.error === "not_enough_players" ? textContent.notEnoughPlayers || "Pas assez de joueurs actifs." : textContent.constraintsImpossible || "Contraintes impossibles à satisfaire.");
|
||||
return;
|
||||
}
|
||||
const historyEntry = { id: context.uid("randomizer"), at: new Date().toISOString(), teams: result.teams };
|
||||
if (hasReducedMotion()) {
|
||||
onChange({ ...data, teams: result.teams, history: addRandomizerHistory(data.history, historyEntry) });
|
||||
return;
|
||||
}
|
||||
const emptyTeams = result.teams.map((team) => ({ ...team, score: 0, players: [] }));
|
||||
const assignments = result.teams.flatMap((team) => team.players.map((player) => ({ teamId: team.id, player })));
|
||||
setAnimating(true);
|
||||
setAnimationTeams(emptyTeams);
|
||||
setReelPlayers(selectedPlayers);
|
||||
setReelIndex(0);
|
||||
setReelDuration(TEAM_REEL_DELAYS[0]);
|
||||
|
||||
function animateAssignment(index) {
|
||||
const assignment = assignments[index];
|
||||
if (!assignment) {
|
||||
setAnimating(false);
|
||||
setReelPlayers([]);
|
||||
setReelIndex(0);
|
||||
onChange({ ...data, teams: result.teams, history: addRandomizerHistory(data.history, historyEntry) });
|
||||
return;
|
||||
}
|
||||
|
||||
let ticks = 0;
|
||||
const tick = () => {
|
||||
ticks += 1;
|
||||
const isFinalTick = ticks >= TEAM_REEL_DELAYS.length;
|
||||
const tickDelay = TEAM_REEL_DELAYS[Math.min(ticks, TEAM_REEL_DELAYS.length - 1)];
|
||||
const nextIndex = isFinalTick
|
||||
? Math.max(0, selectedPlayers.findIndex((player) => player.id === assignment.player.id))
|
||||
: Math.floor(Math.random() * selectedPlayers.length);
|
||||
setReelIndex(nextIndex);
|
||||
setReelDuration(Math.max(54, Math.round(tickDelay * 0.86)));
|
||||
if (!isFinalTick) {
|
||||
queueTimer(tick, tickDelay);
|
||||
return;
|
||||
}
|
||||
queueTimer(() => {
|
||||
setAnimationTeams((currentTeams) => currentTeams.map((team) => {
|
||||
if (team.id !== assignment.teamId) return team;
|
||||
return {
|
||||
...team,
|
||||
score: team.score + assignment.player.score,
|
||||
players: [...team.players, { ...assignment.player, arriving: true }]
|
||||
};
|
||||
}));
|
||||
queueTimer(() => animateAssignment(index + 1), 260);
|
||||
}, 220);
|
||||
};
|
||||
tick();
|
||||
}
|
||||
|
||||
animateAssignment(0);
|
||||
}
|
||||
|
||||
function clearTeamsHistory() {
|
||||
onChange({ ...data, history: [] });
|
||||
}
|
||||
|
||||
function removeTeamsHistoryEntry(entryId) {
|
||||
onChange({ ...data, history: data.history.filter((entry) => entry.id !== entryId) });
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="randomizer-panel randomizer-teams-panel">
|
||||
<div className="randomizer-team-stage">
|
||||
<div className={`randomizer-team-reel ${animating ? "is-spinning" : ""}`} aria-live="polite">
|
||||
{reelPlayers.length ? (
|
||||
<>
|
||||
<div className="randomizer-team-reel-track" style={{ "--randomizer-reel-index": reelIndex, "--randomizer-reel-duration": `${reelDuration}ms` }} aria-hidden="true">
|
||||
{reelPlayers.map((player) => <span key={player.id}>{player.name}</span>)}
|
||||
</div>
|
||||
<span className="sr-only">{reelPlayers[reelIndex]?.name || ""}</span>
|
||||
</>
|
||||
) : <span className="randomizer-team-reel-placeholder" />}
|
||||
</div>
|
||||
<div className={`randomizer-teams ${displayedTeams.length === 1 ? "is-single-team" : ""} ${animating ? "is-animating" : ""}`} aria-live="polite">
|
||||
{displayedTeams.length ? displayedTeams.map((team) => (
|
||||
<article className="randomizer-team" key={team.id}>
|
||||
<h3>{team.name}</h3>
|
||||
<span>{textContent.teamScoreLabel || "Score"} {team.score}</span>
|
||||
{team.players.map((player) => <b className={player.arriving ? "is-arriving" : ""} key={player.id}>{player.name}</b>)}
|
||||
</article>
|
||||
)) : <p className="randomizer-empty">{textContent.emptyTeams ?? ""}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="randomizer-team-settings-grid">
|
||||
<section className="randomizer-team-basic">
|
||||
<form className="randomizer-player-import-form" onSubmit={importPlayers}>
|
||||
<label className="randomizer-field">
|
||||
<span>{textContent.namesImportLabel || "Liste de noms"}</span>
|
||||
<textarea className="nds-textarea" data-size="sm" value={namesText} onChange={(event) => setNamesText(event.target.value)} placeholder={textContent.namesImportPlaceholder || "Un nom par ligne"} />
|
||||
</label>
|
||||
<button className="nds-button" data-size="sm" data-variant="primary" type="submit">{textContent.importNamesButton || "Ajouter"}</button>
|
||||
</form>
|
||||
<div className="randomizer-controls">
|
||||
<label>
|
||||
<span>{textContent.teamCountLabel || "Équipes"}</span>
|
||||
<input className="nds-input" data-size="sm" type="number" min={RANDOMIZER_LIMITS.teamCountMin} max={RANDOMIZER_LIMITS.teamCountMax} value={data.teamCount} onChange={(event) => onChange({ ...data, teamCount: clampNumber(event.target.value, RANDOMIZER_LIMITS.teamCountMin, RANDOMIZER_LIMITS.teamCountMax, data.teamCount) })} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{textContent.playerSelectionModeLabel || "Répartition"}</span>
|
||||
<select className="nds-select" data-size="sm" value={data.playerSelectionMode} onChange={(event) => onChange({ ...data, playerSelectionMode: event.target.value })}>
|
||||
<option value="all">{textContent.playerSelectionAll || "Tous"}</option>
|
||||
<option value="count">{textContent.playerSelectionCount || "Nombre défini"}</option>
|
||||
</select>
|
||||
</label>
|
||||
{data.playerSelectionMode === "count" && (
|
||||
<label>
|
||||
<span>{textContent.playerSelectionCountLabel || "Nombre"}</span>
|
||||
<input className="nds-input" data-size="sm" type="number" min={data.teamCount} max={RANDOMIZER_LIMITS.teamPlayersMax} value={selectionCountDraft} onChange={(event) => setSelectionCountDraft(event.target.value)} onBlur={(event) => commitSelectionCount(event.target.value)} />
|
||||
</label>
|
||||
)}
|
||||
<button className="nds-button" data-size="sm" data-variant="primary" type="button" onClick={launchTeams} disabled={animating}>
|
||||
<Icon name="play-circle" />
|
||||
<span>{textContent.createTeamsButton || "Créer les équipes"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="randomizer-team-advanced">
|
||||
<section className="randomizer-team-advanced-section">
|
||||
<div className="randomizer-list-header randomizer-value-row randomizer-player-row" aria-hidden="true">
|
||||
<span>{textContent.activeLabel || "Actif"}</span>
|
||||
<span>{textContent.nameLabel || "Nom"}</span>
|
||||
<span>{textContent.scoreLabel || "Score"}</span>
|
||||
<span />
|
||||
</div>
|
||||
<div className="randomizer-value-list nds-scrollbox" data-axis="vertical" data-variant="ghost">
|
||||
{data.players.map((player) => (
|
||||
<div className="randomizer-value-row randomizer-player-row" key={player.id}>
|
||||
<input className="nds-checkbox" data-size="sm" type="checkbox" checked={player.active} onChange={(event) => updatePlayer(player.id, { active: event.target.checked })} aria-label={`${textContent.activeLabel || "Actif"} ${player.name}`} />
|
||||
<input className="nds-input" data-size="sm" value={player.name} onChange={(event) => updatePlayer(player.id, { name: event.target.value })} aria-label={textContent.nameLabel || "Nom"} />
|
||||
<input className="nds-input" data-size="sm" type="number" min="0" max="9999" value={player.score} onChange={(event) => updatePlayer(player.id, { score: clampNumber(event.target.value, 0, 9999, player.score) })} aria-label={textContent.scoreLabel || "Score"} />
|
||||
<button className="nds-icon-button" data-size="sm" data-variant="danger" type="button" onClick={() => removePlayer(player.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${player.name}`} title={textContent.deleteTitle || "Supprimer"}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="nds-button" data-size="sm" type="button" onClick={clearPlayers} disabled={!data.players.length || animating}>{textContent.clearNamesButton || "Supprimer tous les noms"}</button>
|
||||
</section>
|
||||
<section className="randomizer-team-advanced-section randomizer-exclusion-block">
|
||||
<form className={`inline-form randomizer-exclusion-form ${exclusionMode === "group" ? "is-group-mode" : ""}`} onSubmit={addExclusion}>
|
||||
<label className="randomizer-field randomizer-exclusion-mode-field">
|
||||
<span>{textContent.exclusionModeLabel || "Type"}</span>
|
||||
<select className="nds-select" data-size="sm" value={exclusionMode} onChange={(event) => exclusionDraft.updateValues({ mode: event.target.value, playerAId: event.target.value === "group" ? "" : exclusionDraft.values.playerAId })} aria-label={textContent.exclusionModeLabel || "Type"}>
|
||||
<option value="group">{textContent.exclusionModeGroup || "Tous séparés"}</option>
|
||||
<option value="source">{textContent.exclusionModeSource || "Exclusions granulaires"}</option>
|
||||
</select>
|
||||
</label>
|
||||
{exclusionMode === "source" && (
|
||||
<label className="randomizer-field">
|
||||
<span>{textContent.firstPlayerLabel || "Nom source"}</span>
|
||||
<select className="nds-select" data-size="sm" value={exclusionDraft.values.playerAId} onChange={(event) => exclusionDraft.updateValues({ playerAId: event.target.value, playerBIds: (exclusionDraft.values.playerBIds || []).filter((playerId) => playerId !== event.target.value) })} aria-label={textContent.firstPlayerLabel || "Nom source"}>
|
||||
<option value="">{textContent.selectNamesButton || "Choisir"}</option>
|
||||
{activePlayers.map((player) => <option key={player.id} value={player.id}>{player.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="randomizer-field">
|
||||
<span>{textContent.secondPlayerLabel || "Noms exclus"}</span>
|
||||
<CompactDropdown
|
||||
className="randomizer-checkbox-dropdown"
|
||||
menuClassName="randomizer-checkbox-menu nds-scrollbox"
|
||||
label={textContent.secondPlayerLabel || "Noms exclus"}
|
||||
renderTrigger={({ open, toggle }) => (
|
||||
<button className={`randomizer-multi-trigger nds-select-trigger ${open ? "active" : ""}`} data-size="sm" type="button" onClick={toggle} aria-expanded={open}>
|
||||
<span className="randomizer-multi-trigger-label">{exclusionDraft.values.playerBIds?.length ? `${exclusionDraft.values.playerBIds.length} ${textContent.selectedNamesLabel || "noms"}` : textContent.selectNamesButton || "Choisir"}</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{() => (
|
||||
<>
|
||||
{activePlayers.map((player) => (
|
||||
<label className="randomizer-checkbox-option" key={player.id}>
|
||||
<input className="nds-checkbox" data-size="sm" type="checkbox" checked={(exclusionDraft.values.playerBIds || []).includes(player.id)} disabled={exclusionMode === "source" && player.id === exclusionDraft.values.playerAId} onChange={() => toggleExclusionTarget(player.id)} />
|
||||
<span>{player.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CompactDropdown>
|
||||
</div>
|
||||
<button className="nds-button" data-size="sm" type="submit">{textContent.addExclusionButton || "Exclure"}</button>
|
||||
</form>
|
||||
<span className="randomizer-section-label">{textContent.exclusionsListLabel || "Combinaisons exclues"}</span>
|
||||
<div className="randomizer-exclusions">
|
||||
{data.exclusions.map((exclusion) => {
|
||||
const playerA = data.players.find((player) => player.id === exclusion.playerAId);
|
||||
const playerBs = (exclusion.playerBIds || []).map((playerId) => data.players.find((player) => player.id === playerId)).filter(Boolean);
|
||||
if (!playerA || !playerBs.length) return null;
|
||||
const exclusionLabel = exclusion.mode === "group"
|
||||
? [playerA, ...playerBs].map((player) => player.name).join(" / ")
|
||||
: `${playerA.name} / ${playerBs.map((player) => player.name).join(" ou ")}`;
|
||||
return (
|
||||
<span className="randomizer-chip" key={exclusion.id}>
|
||||
<span className="randomizer-chip-label">{exclusionLabel}</span>
|
||||
<button className="nds-icon-button" data-size="xxs" data-variant="danger" type="button" onClick={() => onChange({ ...data, exclusions: data.exclusions.filter((item) => item.id !== exclusion.id) })} aria-label={`${textContent.deleteTitle || "Supprimer"} ${playerA.name}`}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RandomizerHistory
|
||||
title={textContent.historyTitle || "Historique"}
|
||||
history={data.history}
|
||||
renderEntry={(entry) => `${entry.teams.length} ${entry.teams.length > 1 ? textContent.teamsPluralLabel || "équipes" : textContent.teamsSingularLabel || "équipe"}`}
|
||||
renderDetails={(entry) => (
|
||||
<div className={`randomizer-history-teams ${entry.teams.length > 4 ? "has-team-rows" : ""}`}>
|
||||
{entry.teams.map((team) => (
|
||||
<div className="randomizer-history-team" key={team.id}>
|
||||
<strong>{team.name}</strong>
|
||||
<span>{textContent.teamScoreLabel || "Score"} {team.score}</span>
|
||||
<ul>
|
||||
{team.players.map((player) => <li key={player.id}>{player.name}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
onClear={clearTeamsHistory}
|
||||
onRemove={removeTeamsHistoryEntry}
|
||||
disabled={animating}
|
||||
textContent={textContent}
|
||||
showEmpty
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultChips({ results, onRemove, disabled = false, textContent = {} }) {
|
||||
if (!results?.length) return null;
|
||||
return (
|
||||
<div className="randomizer-results">
|
||||
{results.map((result, index) => (
|
||||
<span className="randomizer-chip" key={`${result}-${index}`}>
|
||||
{result}
|
||||
{onRemove && (
|
||||
<button className="nds-icon-button" data-size="xs" data-variant="danger" type="button" onClick={() => onRemove(index)} disabled={disabled} aria-label={`${textContent.deleteTitle || "Supprimer"} ${result}`}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RandomizerHistory({ title, history, renderEntry, renderDetails, onClear, onRemove, disabled = false, textContent = {}, showEmpty = false }) {
|
||||
const hasHistory = Boolean(history?.length);
|
||||
if (!hasHistory && !showEmpty) return null;
|
||||
return (
|
||||
<section className="randomizer-history nds-scrollbox" data-axis="vertical">
|
||||
<header>
|
||||
<h3>{title}</h3>
|
||||
<button className="nds-button randomizer-history-clear" data-size="xs" type="button" onClick={onClear} disabled={disabled || !hasHistory}>{textContent.clearHistoryButton || "Effacer tout"}</button>
|
||||
</header>
|
||||
{hasHistory ? (
|
||||
<div className="randomizer-list">
|
||||
{history.map((entry, index) => (
|
||||
<div className={`randomizer-history-row ${index === 0 ? "is-latest" : ""} ${onRemove ? "has-action" : ""}`} key={entry.id}>
|
||||
{renderDetails ? (
|
||||
<details className="randomizer-history-main randomizer-history-accordion">
|
||||
<summary>
|
||||
<Icon name="chevron-down" />
|
||||
<span className="randomizer-history-value">{renderEntry(entry)}</span>
|
||||
<time dateTime={entry.at}>{formatDateTime(entry.at)}</time>
|
||||
</summary>
|
||||
{renderDetails(entry)}
|
||||
</details>
|
||||
) : (
|
||||
<div className="randomizer-history-main">
|
||||
<span className="randomizer-history-value">{renderEntry(entry)}</span>
|
||||
<time dateTime={entry.at}>{formatDateTime(entry.at)}</time>
|
||||
</div>
|
||||
)}
|
||||
{onRemove && (
|
||||
<button className="nds-icon-button" data-size="md" data-variant="danger" type="button" onClick={() => onRemove(entry.id)} disabled={disabled} aria-label={`${textContent.deleteTitle || "Supprimer"} ${formatDateTime(entry.at)}`}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <p className="randomizer-empty">{textContent.emptyHistory || "Aucun historique."}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
182
website/src/features/toolboxes/modules/randomizerUtils.js
Normal file
182
website/src/features/toolboxes/modules/randomizerUtils.js
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
// Rôle : fournit les calculs de tirage et d'équilibrage pour l'outil Randomizer.
|
||||
export const RANDOMIZER_TABS = [
|
||||
{ id: "dice", label: "Dé", icon: "dice" },
|
||||
{ id: "wheel", label: "Roue", icon: "wheel" },
|
||||
{ id: "teams", label: "Équipes", icon: "parent" }
|
||||
];
|
||||
|
||||
export const RANDOMIZER_HISTORY_LIMIT = 20;
|
||||
export const RANDOMIZER_EXCLUSION_MODES = new Set(["source", "group"]);
|
||||
export const RANDOMIZER_LIMITS = {
|
||||
diceFacesMin: 2,
|
||||
diceFacesMax: 999,
|
||||
diceRollsMin: 1,
|
||||
diceRollsMax: 100,
|
||||
wheelEntriesMax: 60,
|
||||
wheelWeightMin: 1,
|
||||
wheelWeightMax: 999,
|
||||
teamCountMin: 1,
|
||||
teamCountMax: 12,
|
||||
teamPlayersMax: 200,
|
||||
teamScoreMin: 0,
|
||||
teamScoreMax: 9999
|
||||
};
|
||||
|
||||
export function clampNumber(value, min, max, fallback = min) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.min(max, Math.max(min, parsed));
|
||||
}
|
||||
|
||||
export function getDiceShape(faces) {
|
||||
if (faces === 2) return "circle";
|
||||
if (faces === 3) return "triangle";
|
||||
if (faces >= 7) return "hexagon";
|
||||
return "square";
|
||||
}
|
||||
|
||||
export function rollDice(faces, rolls, random = Math.random) {
|
||||
const cleanFaces = clampNumber(faces, RANDOMIZER_LIMITS.diceFacesMin, RANDOMIZER_LIMITS.diceFacesMax, 6);
|
||||
const cleanRolls = clampNumber(rolls, RANDOMIZER_LIMITS.diceRollsMin, RANDOMIZER_LIMITS.diceRollsMax, 1);
|
||||
return Array.from({ length: cleanRolls }, () => Math.floor(random() * cleanFaces) + 1);
|
||||
}
|
||||
|
||||
export function getValidWheelEntries(entries) {
|
||||
return (Array.isArray(entries) ? entries : [])
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
label: String(entry?.label || "").trim(),
|
||||
weight: clampNumber(entry?.weight, RANDOMIZER_LIMITS.wheelWeightMin, RANDOMIZER_LIMITS.wheelWeightMax, 1)
|
||||
}))
|
||||
.filter((entry) => entry.label)
|
||||
.slice(0, RANDOMIZER_LIMITS.wheelEntriesMax);
|
||||
}
|
||||
|
||||
export function drawWeightedEntries(entries, draws, random = Math.random) {
|
||||
const validEntries = getValidWheelEntries(entries);
|
||||
const totalWeight = validEntries.reduce((sum, entry) => sum + entry.weight, 0);
|
||||
const cleanDraws = clampNumber(draws, RANDOMIZER_LIMITS.diceRollsMin, RANDOMIZER_LIMITS.diceRollsMax, 1);
|
||||
if (validEntries.length < 2 || totalWeight <= 0) return [];
|
||||
|
||||
return Array.from({ length: cleanDraws }, () => {
|
||||
let cursor = random() * totalWeight;
|
||||
return validEntries.find((entry) => {
|
||||
cursor -= entry.weight;
|
||||
return cursor < 0;
|
||||
}) || validEntries.at(-1);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildWheelSegments(entries) {
|
||||
const validEntries = getValidWheelEntries(entries);
|
||||
const totalWeight = validEntries.reduce((sum, entry) => sum + entry.weight, 0);
|
||||
let startAngle = -90;
|
||||
if (!totalWeight) return [];
|
||||
|
||||
return validEntries.map((entry, index) => {
|
||||
const angle = (entry.weight / totalWeight) * 360;
|
||||
const segment = {
|
||||
entry,
|
||||
index,
|
||||
startAngle,
|
||||
endAngle: startAngle + angle,
|
||||
midAngle: startAngle + angle / 2
|
||||
};
|
||||
startAngle += angle;
|
||||
return segment;
|
||||
});
|
||||
}
|
||||
|
||||
function shuffleItems(items, random) {
|
||||
const nextItems = [...items];
|
||||
for (let index = nextItems.length - 1; index > 0; index -= 1) {
|
||||
const targetIndex = Math.floor(random() * (index + 1));
|
||||
[nextItems[index], nextItems[targetIndex]] = [nextItems[targetIndex], nextItems[index]];
|
||||
}
|
||||
return nextItems;
|
||||
}
|
||||
|
||||
function getExcludedPlayerIds(playerId, exclusions) {
|
||||
return new Set((exclusions || []).flatMap((exclusion) => {
|
||||
const playerBIds = Array.isArray(exclusion.playerBIds) ? exclusion.playerBIds : [exclusion.playerBId];
|
||||
if (exclusion.mode === "group") {
|
||||
const groupIds = [exclusion.playerAId, ...playerBIds].filter(Boolean);
|
||||
return groupIds.includes(playerId) ? groupIds.filter((groupId) => groupId !== playerId) : [];
|
||||
}
|
||||
if (exclusion.playerAId === playerId) return playerBIds;
|
||||
if (playerBIds.includes(playerId)) return [exclusion.playerAId];
|
||||
return [];
|
||||
}));
|
||||
}
|
||||
|
||||
function canAddPlayerToTeam(player, team, exclusions) {
|
||||
const excludedIds = getExcludedPlayerIds(player.id, exclusions);
|
||||
return !team.players.some((teamPlayer) => excludedIds.has(teamPlayer.id));
|
||||
}
|
||||
|
||||
export function createBalancedTeams(players, teamCount, exclusions, random = Math.random, attempts = 120) {
|
||||
const activePlayers = (Array.isArray(players) ? players : [])
|
||||
.filter((player) => player?.active !== false && String(player?.name || "").trim())
|
||||
.map((player) => ({
|
||||
...player,
|
||||
name: String(player.name).trim(),
|
||||
score: clampNumber(player.score, RANDOMIZER_LIMITS.teamScoreMin, RANDOMIZER_LIMITS.teamScoreMax, 0)
|
||||
}))
|
||||
.slice(0, RANDOMIZER_LIMITS.teamPlayersMax);
|
||||
const cleanTeamCount = clampNumber(teamCount, RANDOMIZER_LIMITS.teamCountMin, RANDOMIZER_LIMITS.teamCountMax, 2);
|
||||
if (activePlayers.length < cleanTeamCount) return { ok: false, teams: [], error: "not_enough_players" };
|
||||
|
||||
let bestTeams = null;
|
||||
let bestSpread = Number.POSITIVE_INFINITY;
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
const teams = Array.from({ length: cleanTeamCount }, (_, index) => ({ id: `team-${index + 1}`, name: `Équipe ${index + 1}`, score: 0, players: [] }));
|
||||
const orderedPlayers = shuffleItems(activePlayers, random).sort((a, b) => b.score - a.score || random() - 0.5);
|
||||
let failed = false;
|
||||
|
||||
orderedPlayers.forEach((player) => {
|
||||
if (failed) return;
|
||||
const candidates = teams
|
||||
.filter((team) => canAddPlayerToTeam(player, team, exclusions))
|
||||
.sort((a, b) => a.score - b.score || a.players.length - b.players.length || random() - 0.5);
|
||||
const targetTeam = candidates[0];
|
||||
if (!targetTeam) {
|
||||
failed = true;
|
||||
return;
|
||||
}
|
||||
targetTeam.players.push(player);
|
||||
targetTeam.score += player.score;
|
||||
});
|
||||
|
||||
if (!failed) {
|
||||
const scores = teams.map((team) => team.score);
|
||||
const sizes = teams.map((team) => team.players.length);
|
||||
const spread = Math.max(...scores) - Math.min(...scores) + (Math.max(...sizes) - Math.min(...sizes)) * 10000;
|
||||
if (spread < bestSpread) {
|
||||
bestTeams = teams;
|
||||
bestSpread = spread;
|
||||
}
|
||||
if (spread === 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
return bestTeams ? { ok: true, teams: bestTeams, error: "" } : { ok: false, teams: [], error: "constraints_impossible" };
|
||||
}
|
||||
|
||||
export function filterExclusionsForPlayers(exclusions, players) {
|
||||
const playerIds = new Set((Array.isArray(players) ? players : []).map((player) => player.id));
|
||||
return (Array.isArray(exclusions) ? exclusions : [])
|
||||
.map((exclusion) => {
|
||||
if (exclusion?.mode === "group") {
|
||||
const groupIds = [exclusion.playerAId, ...(exclusion.playerBIds || [])].filter((playerId) => playerIds.has(playerId));
|
||||
return groupIds.length > 1 ? { ...exclusion, playerAId: groupIds[0], playerBIds: groupIds.slice(1) } : null;
|
||||
}
|
||||
if (!playerIds.has(exclusion?.playerAId)) return null;
|
||||
const playerBIds = (exclusion.playerBIds || [exclusion.playerBId]).filter((playerId) => playerIds.has(playerId));
|
||||
return playerBIds.length ? { ...exclusion, playerBIds } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function addRandomizerHistory(history, entry) {
|
||||
return [entry, ...(Array.isArray(history) ? history : [])].slice(0, RANDOMIZER_HISTORY_LIMIT);
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import { exportCombosText, importCombosText } from "./textImport/combos.js";
|
|||
import { exportEquipmentPlannerText, importEquipmentPlannerText } from "./textImport/equipment.js";
|
||||
import { exportImageAnnotationText, exportImagesText, importImageAnnotationText, importImagesText } from "./textImport/media.js";
|
||||
import { exportGoalTreeText, exportTaskPlannerText, importGoalTreeText, importTaskPlannerText } from "./textImport/planning.js";
|
||||
import { exportRandomizerText, importRandomizerText } from "./textImport/randomizer.js";
|
||||
import { fail, parseColonImportLines } from "./textImport/shared.js";
|
||||
import { exportCalendarText, exportTimerText, importCalendarText, importTimerText } from "./textImport/time.js";
|
||||
|
||||
|
|
@ -50,6 +51,11 @@ const TEXT_EXCHANGE_HANDLERS = {
|
|||
exportText: exportCountersText,
|
||||
importText: importCountersText
|
||||
},
|
||||
randomizer: {
|
||||
hasContent: (data) => Boolean(data?.dice?.history?.length || data?.wheel?.entries?.length || data?.teams?.players?.length),
|
||||
exportText: exportRandomizerText,
|
||||
importText: importRandomizerText
|
||||
},
|
||||
combos: {
|
||||
hasContent: (data) => Boolean(data?.combos?.length),
|
||||
exportText: exportCombosText,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
// Rôle : gère l'import/export texte des configurations Randomizer.
|
||||
import { lineError, parseInteger, parseMetaLine, parsePipeParts, splitLines } from "./shared.js";
|
||||
|
||||
function findPlayerIdByName(players, name) {
|
||||
const cleanName = String(name || "").trim().toLowerCase();
|
||||
return players.find((player) => player.name.toLowerCase() === cleanName)?.id || "";
|
||||
}
|
||||
|
||||
export function exportRandomizerText(data) {
|
||||
const lines = [`@activeTab: ${data.activeTab || "dice"}`];
|
||||
lines.push("[dice]", `faces: ${data.dice?.faces || 6}`, `rolls: ${data.dice?.rolls || 1}`);
|
||||
lines.push("", "[wheel]", `draws: ${data.wheel?.draws || 1}`);
|
||||
(data.wheel?.entries || []).forEach((entry) => {
|
||||
lines.push(`- ${entry.label} | weight=${entry.weight || 1}`);
|
||||
});
|
||||
lines.push(
|
||||
"",
|
||||
"[teams]",
|
||||
`teamCount: ${data.teams?.teamCount || 2}`,
|
||||
`playerSelectionMode: ${data.teams?.playerSelectionMode || "all"}`,
|
||||
`playerSelectionCount: ${data.teams?.playerSelectionCount || 2}`
|
||||
);
|
||||
(data.teams?.players || []).forEach((player) => {
|
||||
lines.push(`- ${player.name} | score=${player.score || 0}${player.active === false ? " | active=false" : ""}`);
|
||||
});
|
||||
(data.teams?.exclusions || []).forEach((exclusion) => {
|
||||
const playerA = data.teams.players.find((player) => player.id === exclusion.playerAId);
|
||||
const playerBs = (exclusion.playerBIds || [exclusion.playerBId])
|
||||
.map((playerId) => data.teams.players.find((player) => player.id === playerId))
|
||||
.filter(Boolean);
|
||||
if (playerA && playerBs.length) lines.push(`! ${[playerA.name, ...playerBs.map((player) => player.name)].join(" / ")}${exclusion.mode === "group" ? " | mode=group" : ""}`);
|
||||
});
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
||||
export function importRandomizerText(text, context) {
|
||||
const data = {
|
||||
activeTab: "dice",
|
||||
dice: { faces: 6, rolls: 1 },
|
||||
wheel: { draws: 1, entries: [] },
|
||||
teams: { teamCount: 2, playerSelectionMode: "all", playerSelectionCount: 2, players: [], exclusions: [] }
|
||||
};
|
||||
let section = "";
|
||||
|
||||
splitLines(text).forEach((rawLine, index) => {
|
||||
const cleanLine = rawLine.trim();
|
||||
if (!cleanLine) return;
|
||||
const meta = parseMetaLine(cleanLine);
|
||||
if (meta?.key === "activeTab") {
|
||||
data.activeTab = meta.value;
|
||||
return;
|
||||
}
|
||||
if (/^\[[a-z]+\]$/i.test(cleanLine)) {
|
||||
section = cleanLine.slice(1, -1).toLowerCase();
|
||||
return;
|
||||
}
|
||||
const separatorIndex = cleanLine.indexOf(":");
|
||||
if (separatorIndex > 0 && !cleanLine.startsWith("-") && !cleanLine.startsWith("!")) {
|
||||
const key = cleanLine.slice(0, separatorIndex).trim();
|
||||
const value = cleanLine.slice(separatorIndex + 1).trim();
|
||||
if (section === "dice" && key === "faces") data.dice.faces = parseInteger(value, 6);
|
||||
if (section === "dice" && key === "rolls") data.dice.rolls = parseInteger(value, 1);
|
||||
if (section === "wheel" && key === "draws") data.wheel.draws = parseInteger(value, 1);
|
||||
if (section === "teams" && key === "teamCount") data.teams.teamCount = parseInteger(value, 2);
|
||||
if (section === "teams" && key === "playerSelectionMode") data.teams.playerSelectionMode = value;
|
||||
if (section === "teams" && key === "playerSelectionCount") data.teams.playerSelectionCount = parseInteger(value, 2);
|
||||
return;
|
||||
}
|
||||
if (section === "wheel" && cleanLine.startsWith("-")) {
|
||||
const { title, meta: pipeMeta } = parsePipeParts(cleanLine.replace(/^[-*]\s*/, ""));
|
||||
if (!title) throw lineError(index, "valeur de roue manquante.");
|
||||
data.wheel.entries.push({ id: context.uid("randomizer"), label: title, weight: parseInteger(pipeMeta.weight, 1) });
|
||||
return;
|
||||
}
|
||||
if (section === "teams" && cleanLine.startsWith("-")) {
|
||||
const { title, meta: pipeMeta } = parsePipeParts(cleanLine.replace(/^[-*]\s*/, ""));
|
||||
if (!title) throw lineError(index, "joueur manquant.");
|
||||
data.teams.players.push({
|
||||
id: context.uid("randomizer"),
|
||||
name: title,
|
||||
score: parseInteger(pipeMeta.score, 0),
|
||||
active: pipeMeta.active !== "false"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (section === "teams" && cleanLine.startsWith("!")) {
|
||||
const { title, meta: pipeMeta } = parsePipeParts(cleanLine.replace(/^!\s*/, ""));
|
||||
const names = title.split("/").map((name) => name.trim()).filter(Boolean);
|
||||
const playerAId = findPlayerIdByName(data.teams.players, names[0]);
|
||||
const playerBIds = names.slice(1).map((name) => findPlayerIdByName(data.teams.players, name)).filter(Boolean);
|
||||
if (!playerAId || !playerBIds.length) throw lineError(index, "exclusion avec joueur inconnu.");
|
||||
data.teams.exclusions.push({ id: context.uid("randomizer"), playerAId, playerBIds, mode: pipeMeta.mode === "group" ? "group" : "source" });
|
||||
return;
|
||||
}
|
||||
throw lineError(index, `ligne randomizer invalide: ${cleanLine}`);
|
||||
});
|
||||
|
||||
return context.normalizeRandomizerData(data);
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import { ImagesModule } from "./ImagesModule.jsx";
|
|||
import { LinksModule } from "./LinksModule.jsx";
|
||||
import { MemosModule } from "./MemosModule.jsx";
|
||||
import { NotepadModule } from "./NotepadModule.jsx";
|
||||
import { RandomizerModule } from "./RandomizerModule.jsx";
|
||||
import { TableModule } from "./TableModule.jsx";
|
||||
import { TaskPlannerModule } from "./TaskPlannerModule.jsx";
|
||||
import { TimerModule } from "./TimerModule.jsx";
|
||||
|
|
@ -26,6 +27,7 @@ export const MODULE_COMPONENTS = {
|
|||
images: { ...TOOLBOX_MODULE_DEFINITIONS.images, Component: ImagesModule },
|
||||
links: { ...TOOLBOX_MODULE_DEFINITIONS.links, Component: LinksModule },
|
||||
counters: { ...TOOLBOX_MODULE_DEFINITIONS.counters, Component: CountersModule },
|
||||
randomizer: { ...TOOLBOX_MODULE_DEFINITIONS.randomizer, Component: RandomizerModule },
|
||||
combos: { ...TOOLBOX_MODULE_DEFINITIONS.combos, Component: CombosModule },
|
||||
calculator: { ...TOOLBOX_MODULE_DEFINITIONS.calculator, Component: CalculatorModule },
|
||||
calendar: { ...TOOLBOX_MODULE_DEFINITIONS.calendar, Component: CalendarModule },
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { compactImageAnnotationDataForStorage, compactImagesDataForStorage } fro
|
|||
import { compactLinksDataForStorage } from "./links.js";
|
||||
import { compactMemosDataForStorage } from "./memos.js";
|
||||
import { compactNotepadDataForStorage } from "./notepad.js";
|
||||
import { compactRandomizerDataForStorage } from "./randomizer.js";
|
||||
import { compactTableDataForStorage } from "./table.js";
|
||||
import { compactTaskPlannerDataForStorage } from "./taskPlanner.js";
|
||||
import { compactTimerDataForStorage } from "./timer.js";
|
||||
|
|
@ -22,6 +23,7 @@ export const MODULE_DATA_COMPACTORS = {
|
|||
imageAnnotation: compactImageAnnotationDataForStorage,
|
||||
links: compactLinksDataForStorage,
|
||||
counters: compactCountersDataForStorage,
|
||||
randomizer: compactRandomizerDataForStorage,
|
||||
combos: compactCombosDataForStorage,
|
||||
calculator: compactCalculatorDataForStorage,
|
||||
calendar: compactCalendarDataForStorage,
|
||||
|
|
|
|||
172
website/src/features/toolboxes/storage/modules/randomizer.js
Normal file
172
website/src/features/toolboxes/storage/modules/randomizer.js
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
// Rôle : normalise et compacte les configurations de l'outil Randomizer.
|
||||
import { RANDOMIZER_EXCLUSION_MODES, RANDOMIZER_LIMITS, clampNumber } from "../../modules/randomizerUtils.js";
|
||||
import { normalizeIsoDate, uid } from "./shared.js";
|
||||
|
||||
const TAB_IDS = new Set(["dice", "wheel", "teams"]);
|
||||
const TEAM_SELECTION_MODES = new Set(["all", "count"]);
|
||||
|
||||
function normalizeHistoryEntry(entry, type) {
|
||||
const at = normalizeIsoDate(entry?.at);
|
||||
if (!at) return null;
|
||||
if (type === "dice") {
|
||||
const results = (Array.isArray(entry?.results) ? entry.results : [])
|
||||
.map((result) => clampNumber(result, 1, RANDOMIZER_LIMITS.diceFacesMax, 0))
|
||||
.filter((result) => result > 0);
|
||||
return results.length ? { id: entry?.id || uid("randomizer"), at, results } : null;
|
||||
}
|
||||
if (type === "wheel") {
|
||||
const results = (Array.isArray(entry?.results) ? entry.results : [])
|
||||
.map((result) => String(result || "").trim())
|
||||
.filter(Boolean);
|
||||
return results.length ? { id: entry?.id || uid("randomizer"), at, results } : null;
|
||||
}
|
||||
const teams = normalizeTeamResults(entry?.teams);
|
||||
return teams.length ? { id: entry?.id || uid("randomizer"), at, teams } : null;
|
||||
}
|
||||
|
||||
function normalizeHistory(history, type) {
|
||||
return (Array.isArray(history) ? history : [])
|
||||
.map((entry) => normalizeHistoryEntry(entry, type))
|
||||
.filter(Boolean)
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
function normalizeDice(data) {
|
||||
return {
|
||||
faces: clampNumber(data?.faces, RANDOMIZER_LIMITS.diceFacesMin, RANDOMIZER_LIMITS.diceFacesMax, 6),
|
||||
rolls: clampNumber(data?.rolls, RANDOMIZER_LIMITS.diceRollsMin, RANDOMIZER_LIMITS.diceRollsMax, 1),
|
||||
results: (Array.isArray(data?.results) ? data.results : []).map((result) => clampNumber(result, 1, RANDOMIZER_LIMITS.diceFacesMax, 1)),
|
||||
history: normalizeHistory(data?.history, "dice")
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWheelEntry(entry) {
|
||||
const label = String(entry?.label || "").trim();
|
||||
if (!label) return null;
|
||||
return {
|
||||
id: entry?.id || uid("randomizer"),
|
||||
label,
|
||||
weight: clampNumber(entry?.weight, RANDOMIZER_LIMITS.wheelWeightMin, RANDOMIZER_LIMITS.wheelWeightMax, 1)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWheel(data) {
|
||||
const entries = (Array.isArray(data?.entries) ? data.entries : [])
|
||||
.map(normalizeWheelEntry)
|
||||
.filter(Boolean)
|
||||
.slice(0, RANDOMIZER_LIMITS.wheelEntriesMax);
|
||||
return {
|
||||
draws: clampNumber(data?.draws, RANDOMIZER_LIMITS.diceRollsMin, RANDOMIZER_LIMITS.diceRollsMax, 1),
|
||||
entries,
|
||||
results: (Array.isArray(data?.results) ? data.results : []).map((result) => String(result || "").trim()).filter(Boolean),
|
||||
history: normalizeHistory(data?.history, "wheel")
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlayer(player) {
|
||||
const name = String(player?.name || "").trim();
|
||||
if (!name) return null;
|
||||
return {
|
||||
id: player?.id || uid("randomizer"),
|
||||
name,
|
||||
score: clampNumber(player?.score, RANDOMIZER_LIMITS.teamScoreMin, RANDOMIZER_LIMITS.teamScoreMax, 0),
|
||||
active: player?.active !== false
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeExclusions(exclusions, players) {
|
||||
const playerIds = new Set(players.map((player) => player.id));
|
||||
const seenPairs = new Set();
|
||||
const seenGroups = new Set();
|
||||
return (Array.isArray(exclusions) ? exclusions : [])
|
||||
.map((exclusion) => {
|
||||
const playerAId = String(exclusion?.playerAId || "");
|
||||
if (!playerIds.has(playerAId)) return null;
|
||||
const mode = RANDOMIZER_EXCLUSION_MODES.has(exclusion?.mode) ? exclusion.mode : "source";
|
||||
const playerBIds = (Array.isArray(exclusion?.playerBIds) ? exclusion.playerBIds : [exclusion?.playerBId])
|
||||
.map((playerId) => String(playerId || ""))
|
||||
.filter((playerId) => playerIds.has(playerId) && playerId !== playerAId);
|
||||
let uniquePlayerBIds = [...new Set(playerBIds)];
|
||||
if (mode === "group") {
|
||||
const groupIds = [playerAId, ...uniquePlayerBIds];
|
||||
const groupKey = [...groupIds].sort().join(":");
|
||||
if (seenGroups.has(groupKey)) return null;
|
||||
seenGroups.add(groupKey);
|
||||
groupIds.forEach((sourceId, sourceIndex) => {
|
||||
groupIds.slice(sourceIndex + 1).forEach((targetId) => {
|
||||
seenPairs.add([sourceId, targetId].sort().join(":"));
|
||||
});
|
||||
});
|
||||
} else {
|
||||
uniquePlayerBIds = uniquePlayerBIds.filter((playerBId) => {
|
||||
const pairKey = [playerAId, playerBId].sort().join(":");
|
||||
if (seenPairs.has(pairKey)) return false;
|
||||
seenPairs.add(pairKey);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
if (!uniquePlayerBIds.length) return null;
|
||||
return { id: exclusion?.id || uid("randomizer"), playerAId, playerBIds: uniquePlayerBIds, mode };
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeTeamResults(teams) {
|
||||
return (Array.isArray(teams) ? teams : [])
|
||||
.map((team, index) => ({
|
||||
id: team?.id || `team-${index + 1}`,
|
||||
name: String(team?.name || `Équipe ${index + 1}`).trim() || `Équipe ${index + 1}`,
|
||||
score: clampNumber(team?.score, 0, Number.MAX_SAFE_INTEGER, 0),
|
||||
players: (Array.isArray(team?.players) ? team.players : [])
|
||||
.map((player) => ({
|
||||
id: player?.id || uid("randomizer"),
|
||||
name: String(player?.name || "").trim(),
|
||||
score: clampNumber(player?.score, RANDOMIZER_LIMITS.teamScoreMin, RANDOMIZER_LIMITS.teamScoreMax, 0)
|
||||
}))
|
||||
.filter((player) => player.name)
|
||||
}))
|
||||
.filter((team) => team.players.length);
|
||||
}
|
||||
|
||||
function normalizeTeams(data) {
|
||||
const players = (Array.isArray(data?.players) ? data.players : [])
|
||||
.map(normalizePlayer)
|
||||
.filter(Boolean)
|
||||
.slice(0, RANDOMIZER_LIMITS.teamPlayersMax);
|
||||
return {
|
||||
teamCount: clampNumber(data?.teamCount, RANDOMIZER_LIMITS.teamCountMin, RANDOMIZER_LIMITS.teamCountMax, 2),
|
||||
playerSelectionMode: TEAM_SELECTION_MODES.has(data?.playerSelectionMode) ? data.playerSelectionMode : "all",
|
||||
playerSelectionCount: clampNumber(data?.playerSelectionCount, RANDOMIZER_LIMITS.teamCountMin, RANDOMIZER_LIMITS.teamPlayersMax, 2),
|
||||
players,
|
||||
exclusions: normalizeExclusions(data?.exclusions, players),
|
||||
teams: normalizeTeamResults(data?.teams),
|
||||
history: normalizeHistory(data?.history, "teams")
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeRandomizerData(data) {
|
||||
return {
|
||||
activeTab: TAB_IDS.has(data?.activeTab) ? data.activeTab : "dice",
|
||||
dice: normalizeDice(data?.dice),
|
||||
wheel: normalizeWheel(data?.wheel),
|
||||
teams: normalizeTeams(data?.teams)
|
||||
};
|
||||
}
|
||||
|
||||
export function compactRandomizerDataForStorage(value) {
|
||||
const normalized = normalizeRandomizerData(value);
|
||||
const compact = {};
|
||||
if (normalized.activeTab !== "dice") compact.activeTab = normalized.activeTab;
|
||||
if (normalized.dice.faces !== 6 || normalized.dice.rolls !== 1 || normalized.dice.results.length || normalized.dice.history.length) compact.dice = normalized.dice;
|
||||
if (normalized.wheel.entries.length || normalized.wheel.draws !== 1 || normalized.wheel.results.length || normalized.wheel.history.length) compact.wheel = normalized.wheel;
|
||||
if (
|
||||
normalized.teams.players.length ||
|
||||
normalized.teams.exclusions.length ||
|
||||
normalized.teams.teamCount !== 2 ||
|
||||
normalized.teams.playerSelectionMode !== "all" ||
|
||||
normalized.teams.playerSelectionCount !== 2 ||
|
||||
normalized.teams.teams.length ||
|
||||
normalized.teams.history.length
|
||||
) compact.teams = normalized.teams;
|
||||
return Object.keys(compact).length ? compact : null;
|
||||
}
|
||||
|
|
@ -24,7 +24,8 @@ export const ID_PREFIXES = {
|
|||
material: "n",
|
||||
socketItem: "j",
|
||||
socketLink: "u",
|
||||
socketBonus: "b"
|
||||
socketBonus: "b",
|
||||
randomizer: "x"
|
||||
};
|
||||
|
||||
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export { labelFromFileName, normalizeImageAnnotationData } from "./modules/image
|
|||
export { hostnameFromUrl, normalizeLinksData, normalizeUrl } from "./modules/links.js";
|
||||
export { normalizeMemosData } from "./modules/memos.js";
|
||||
export { normalizeNotepadData, sanitizeNotepadHtml } from "./modules/notepad.js";
|
||||
export { normalizeRandomizerData } from "./modules/randomizer.js";
|
||||
export { uid } from "./modules/shared.js";
|
||||
export { normalizeTableData } from "./modules/table.js";
|
||||
export { normalizeTaskPlannerData } from "./modules/taskPlanner.js";
|
||||
|
|
@ -216,6 +217,27 @@ function remapModuleDataForExport(type, data, nextId) {
|
|||
return { counters: compact.counters.map((counter) => ({ ...counter, id: nextId("counter") })) };
|
||||
}
|
||||
|
||||
if (type === "randomizer") {
|
||||
const remapped = { ...compact };
|
||||
if (compact.wheel?.entries) remapped.wheel = { ...compact.wheel, entries: compact.wheel.entries.map((entry) => ({ ...entry, id: nextId("randomizer") })) };
|
||||
if (compact.teams?.players) {
|
||||
const playerIdMap = new Map(compact.teams.players.map((player) => [player.id, nextId("randomizer")]));
|
||||
remapped.teams = {
|
||||
...compact.teams,
|
||||
players: compact.teams.players.map((player) => ({ ...player, id: playerIdMap.get(player.id) })),
|
||||
exclusions: (compact.teams.exclusions || []).map((exclusion) => ({
|
||||
...exclusion,
|
||||
id: nextId("randomizer"),
|
||||
playerAId: playerIdMap.get(exclusion.playerAId),
|
||||
playerBIds: (exclusion.playerBIds || [exclusion.playerBId])
|
||||
.map((playerId) => playerIdMap.get(playerId))
|
||||
.filter(Boolean)
|
||||
})).filter((exclusion) => exclusion.playerAId && exclusion.playerBIds.length)
|
||||
};
|
||||
}
|
||||
return remapped;
|
||||
}
|
||||
|
||||
if (type === "memos") {
|
||||
const remapped = {};
|
||||
if (compact.tickerEnabled === false) remapped.tickerEnabled = false;
|
||||
|
|
|
|||
|
|
@ -55,6 +55,16 @@ export const TOOLBOX_MODULE_DEFINITIONS = {
|
|||
scrollable: true,
|
||||
libraryCategory: "notesTracking"
|
||||
},
|
||||
randomizer: {
|
||||
label: "Randomizer",
|
||||
defaultTitle: "Randomizer",
|
||||
mode: "Dé + roue + équipes",
|
||||
icon: "dice",
|
||||
editable: true,
|
||||
addable: false,
|
||||
scrollable: true,
|
||||
libraryCategory: "calculationData"
|
||||
},
|
||||
combos: {
|
||||
label: "Combos",
|
||||
defaultTitle: "Combos",
|
||||
|
|
@ -146,7 +156,7 @@ export const TOOLBOX_MODULES = Object.fromEntries(Object.entries(TOOLBOX_MODULE_
|
|||
export const LIBRARY_CATEGORY_DEFINITIONS = [
|
||||
{ key: "notesTracking", types: ["memos", "notepad", "checklist", "counters"] },
|
||||
{ key: "references", types: ["images", "imageAnnotation", "links"] },
|
||||
{ key: "calculationData", types: ["calculator", "table", "goalTree"] },
|
||||
{ key: "calculationData", types: ["calculator", "table", "goalTree", "randomizer"] },
|
||||
{ key: "timeRoutines", types: ["timer", "calendar", "taskPlanner"] },
|
||||
{ key: "buildsCommands", types: ["combos", "equipmentPlanner"] }
|
||||
];
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
normalizeLinksData,
|
||||
normalizeMemosData,
|
||||
normalizeNotepadData,
|
||||
normalizeRandomizerData,
|
||||
normalizeTableData,
|
||||
normalizeTaskPlannerData,
|
||||
normalizeTimerData,
|
||||
|
|
@ -41,7 +42,8 @@ const MODULE_COMPONENTS = {
|
|||
timer: lazy(() => import("../features/toolboxes/modules/TimerModule.jsx").then((module) => ({ default: module.TimerModule }))),
|
||||
taskPlanner: lazy(() => import("../features/toolboxes/modules/TaskPlannerModule.jsx").then((module) => ({ default: module.TaskPlannerModule }))),
|
||||
equipmentPlanner: lazy(() => import("../features/toolboxes/modules/EquipmentPlannerModule.jsx").then((module) => ({ default: module.EquipmentPlannerModule }))),
|
||||
imageAnnotation: lazy(() => import("../features/toolboxes/modules/ImageAnnotationModule.jsx").then((module) => ({ default: module.ImageAnnotationModule })))
|
||||
imageAnnotation: lazy(() => import("../features/toolboxes/modules/ImageAnnotationModule.jsx").then((module) => ({ default: module.ImageAnnotationModule }))),
|
||||
randomizer: lazy(() => import("../features/toolboxes/modules/RandomizerModule.jsx").then((module) => ({ default: module.RandomizerModule })))
|
||||
};
|
||||
|
||||
function cloneData(value) {
|
||||
|
|
@ -163,6 +165,7 @@ export function LibraryPage({ siteContent, actions }) {
|
|||
normalizeCalendarData,
|
||||
normalizeImageAnnotationData,
|
||||
normalizeNotepadData,
|
||||
normalizeRandomizerData,
|
||||
normalizeTableData,
|
||||
normalizeTimerData,
|
||||
normalizeTaskPlannerData,
|
||||
|
|
|
|||
21
website/src/styles/AGENTS.md
Normal file
21
website/src/styles/AGENTS.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Agent Instructions
|
||||
|
||||
Cette zone contient le style Sass de l'application.
|
||||
|
||||
## Référence visuelle
|
||||
|
||||
Avant de modifier le style, les composants UI, les layouts, les boutons, cards, panels, hovers, scrollbars ou notifications, lire :
|
||||
|
||||
- `../../../DESIGN_SYSTEM.md`
|
||||
|
||||
Après modification d'un pattern visuel, mettre `DESIGN_SYSTEM.md` à jour si le pattern réel change.
|
||||
|
||||
## Style
|
||||
|
||||
Privilégier :
|
||||
|
||||
- les tokens existants ;
|
||||
- les classes de boutons existantes ;
|
||||
- les patterns visuels déjà présents pour les cards, panels, badges, scrollbars et hovers.
|
||||
|
||||
Éviter les styles isolés qui ne réutilisent pas le thème.
|
||||
|
|
@ -1075,6 +1075,13 @@
|
|||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.nds-scrollbox[data-variant="ghost"] {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nds-scrollbox[data-axis="horizontal"] {
|
||||
max-height: none;
|
||||
overflow-x: auto;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,16 @@
|
|||
-webkit-mask-image: url("/static/icons/calculator.svg");
|
||||
}
|
||||
|
||||
.ui-icon-dice {
|
||||
mask-image: url("/static/icons/dice.svg");
|
||||
-webkit-mask-image: url("/static/icons/dice.svg");
|
||||
}
|
||||
|
||||
.ui-icon-wheel {
|
||||
mask-image: url("/static/icons/wheel.svg");
|
||||
-webkit-mask-image: url("/static/icons/wheel.svg");
|
||||
}
|
||||
|
||||
.ui-icon-clock {
|
||||
mask-image: url("/static/icons/clock.svg");
|
||||
-webkit-mask-image: url("/static/icons/clock.svg");
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
@use "equipment-planner";
|
||||
@use "combos";
|
||||
@use "counters";
|
||||
@use "randomizer";
|
||||
@use "calculator";
|
||||
@use "goal-tree";
|
||||
@use "timer";
|
||||
|
|
|
|||
|
|
@ -244,6 +244,26 @@
|
|||
-webkit-mask-image: url("/static/icons/cube.svg");
|
||||
}
|
||||
|
||||
.module-icon-dice {
|
||||
mask-image: url("/static/icons/dice.svg");
|
||||
-webkit-mask-image: url("/static/icons/dice.svg");
|
||||
}
|
||||
|
||||
.module-icon-wheel {
|
||||
mask-image: url("/static/icons/wheel.svg");
|
||||
-webkit-mask-image: url("/static/icons/wheel.svg");
|
||||
}
|
||||
|
||||
.module-icon-parent {
|
||||
mask-image: url("/static/icons/parent.svg");
|
||||
-webkit-mask-image: url("/static/icons/parent.svg");
|
||||
}
|
||||
|
||||
.module-icon-rotate {
|
||||
mask-image: url("/static/icons/rotate.svg");
|
||||
-webkit-mask-image: url("/static/icons/rotate.svg");
|
||||
}
|
||||
|
||||
.module-icon-settings {
|
||||
mask-image: url("/static/icons/settings.svg");
|
||||
-webkit-mask-image: url("/static/icons/settings.svg");
|
||||
|
|
|
|||
1055
website/src/styles/toolboxes/_randomizer.scss
Normal file
1055
website/src/styles/toolboxes/_randomizer.scss
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue