add randomizer module
All checks were successful
Deploy Sokko G / deploy (push) Successful in 17s

This commit is contained in:
Shinuwa 2026-08-31 09:10:38 +02:00
parent 07de342fc2
commit 35fe877d58
32 changed files with 3822 additions and 200 deletions

209
AGENTS.md
View file

@ -2,203 +2,26 @@
Sokko G est une webapp React + Vite + Sass centrée sur des toolboxes locales et des pages de jeu.
## Références projet
## Portée
Avant de modifier un outil toolbox, le stockage IndexedDB, l'import/export ou les formats de données, lire :
Les règles détaillées sont réparties par zone pour limiter le contexte chargé à chaque tâche :
- `docs/STORAGE_SCHEMA.md`
- `docs/FEATURE_CHECKLIST.md`
- `website/AGENTS.md` : application React, contenu éditable, serveur de développement, validation.
- `website/src/features/toolboxes/AGENTS.md` : outils toolbox, stockage IndexedDB, import/export, hooks partagés.
- `website/src/styles/AGENTS.md` : Sass, design system, composants visuels et patterns UI.
- `docs/AGENTS.md` : documentation projet, schémas de stockage et checklists.
- `tests/AGENTS.md` : validations de données et tests.
Avant de modifier le style, les composants UI, les layouts, les boutons, cards, panels, hovers, scrollbars ou notifications, lire :
Lire le fichier d'instructions le plus proche du code modifié avant d'intervenir.
- `DESIGN_SYSTEM.md`
## Règles globales
Après ce type de modification, mettre à jour ces documents si le format ou le workflow change.
- Utiliser `rg`, `git status --short`, `git diff --stat` et les commandes ciblées avant les recherches larges.
- Ne pas réintroduire de fallback massif type `DEFAULT_SITE_CONTENT` dans le code React.
- Chaque nouveau fichier source ou test commence par un commentaire court `Rôle : ...` dans le style du langage.
- Ne pas lancer automatiquement un serveur sur `5174` si `5173` répond déjà.
- Avant de terminer une modification significative, lancer `npm run check`.
## Contenu éditable
## Beads
Le contenu éditorial du site doit rester dans :
- `website/public/data/site.json`
- `website/public/data/<gameId>/...`
Ne pas réintroduire de fallback massif type `DEFAULT_SITE_CONTENT` dans le code React. `site.json` est la source de vérité et `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 ;
- quand un fichier existant est modifié, vérifier si son en-tête mérite d'être précisé au-delà de la ligne générique initiale ;
- enrichir l'en-tête quand le fichier porte une logique transverse, un hook partagé, un format de données ou un comportement réutilisable ;
- ne pas allonger mécaniquement les en-têtes des fichiers simples : la précision doit aider à comprendre la responsabilité réelle du fichier ;
- utiliser `// Rôle : ...` dans les fichiers JS, JSX, MJS et SCSS.
## 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 afin de garder une expérience homogène.
- 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.
## Outils 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 `website/src/features/toolboxes/modules/index.jsx` ;
- ajouter les textes dans `website/public/data/site.json` ;
- mettre à jour la validation dans `tests/helpers/data-validation.mjs` ;
- mettre à jour la structure IndexedDB dans `docs/STORAGE_SCHEMA.md` si nécessaire ;
- 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 `docs/STORAGE_SCHEMA.md` doit rester le format cible strict.
## Style
Le style est en Sass dans `website/src/styles/`.
`DESIGN_SYSTEM.md` est la référence visuelle du projet et doit rester synchronisé avec les patterns réellement utilisés.
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.
## 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` dans ce cas.
Quand une vérification navigateur est nécessaire après des modifications, 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.
Vérifier et corriger si nécessaire :
- code mort, imports inutilisés, fonctions inutilisées, styles devenus obsolètes ;
- duplication de code ou de style pouvant être factorisée sans complexifier le projet ;
- composants qui utilisent des classes trop spécifiques alors qu'un pattern général existe déjà ;
- incohérences entre boutons, badges, cards, panels, hovers, focus states et scrollbars ;
- cohérence entre `website/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.
Utiliser en priorité :
```bash
rg
git status --short
git diff --stat
npm run check
```
Après lecture du diff pré-push, proposer un exemple de message de commit en anglais, concis et représentatif des changements réellement présents.
Ne pas faire de refactor large sans bénéfice clair. Garder les corrections pré-push ciblées, vérifiables et faciles à relire.
<!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:970c3bf2 -->
## Beads Issue Tracker
This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands.
### Quick Reference
```bash
bd ready # Find available work
bd show <id> # View issue details
bd update <id> --claim # Claim work
bd close <id> # Complete work
```
### Rules
- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists
- Run `bd prime` for detailed command reference and session close protocol
- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files
**Architecture in one line:** issues live in a local Dolt DB; sync uses `refs/dolt/data` on your git remote; `.beads/issues.jsonl` is a passive export. See https://github.com/gastownhall/beads/blob/main/docs/SYNC_CONCEPTS.md for details and anti-patterns.
## Agent Context Profiles
The managed Beads block is task-tracking guidance, not permission to override repository, user, or orchestrator instructions.
- **Conservative (default)**: Use `bd` for task tracking. Do not run git commits, git pushes, or Dolt remote sync unless explicitly asked. At handoff, report changed files, validation, and suggested next commands.
- **Minimal**: Keep tool instruction files as pointers to `bd prime`; use the same conservative git policy unless active instructions say otherwise.
- **Team-maintainer**: Only when the repository explicitly opts in, agents may close beads, run quality gates, commit, and push as part of session close. A current "do not commit" or "do not push" instruction still wins.
## Session Completion
This protocol applies when ending a Beads implementation workflow. It is subordinate to explicit user, repository, and orchestrator instructions.
1. **File issues for remaining work** - Create beads for anything that needs follow-up
2. **Run quality gates** (if code changed) - Tests, linters, builds
3. **Update issue status** - Close finished work, update in-progress items
4. **Handle git/sync by active profile**:
```bash
# Conservative/minimal/default: report status and proposed commands; wait for approval.
git status
# Team-maintainer opt-in only, unless current instructions forbid it:
git pull --rebase
bd dolt push
git push
git status
```
5. **Hand off** - Summarize changes, validation, issue status, and any blocked sync/commit/push step
**Critical rules:**
- Explicit user or orchestrator instructions override this Beads block.
- Do not commit or push without clear authority from the active profile or the current user request.
- If a required sync or push is blocked, stop and report the exact command and error.
<!-- END BEADS INTEGRATION -->
<!-- BEGIN BEADS CODEX SETUP: generated by bd setup codex -->
## Beads Issue Tracker
Use Beads (`bd`) for durable task tracking in repositories that include it. Use the `beads` skill at `.agents/skills/beads/SKILL.md` (project install) or `~/.agents/skills/beads/SKILL.md` (global install) for Beads workflow guidance, then use the `bd` CLI for issue operations.
### Quick Reference
```bash
bd ready # Find available work
bd show <id> # View issue details
bd update <id> --claim # Claim work
bd close <id> # Complete work
bd prime # Refresh Beads context
```
### Rules
- Use `bd` for all task tracking; do not create markdown TODO lists.
- Run `bd prime` when Beads context is missing or stale. Codex 0.129.0+ can load Beads context automatically through native hooks; use `/hooks` to inspect or toggle them.
- Keep persistent project memory in Beads via `bd remember`; do not create ad hoc memory files.
**Architecture in one line:** issues live in a local Dolt DB; sync uses `refs/dolt/data` on your git remote; `.beads/issues.jsonl` is a passive export. See https://github.com/gastownhall/beads/blob/main/docs/SYNC_CONCEPTS.md for details and anti-patterns.
<!-- END BEADS CODEX SETUP -->
Ce dépôt utilise Beads (`bd`) pour le suivi durable des tâches. Utiliser `bd prime` quand un workflow Beads est nécessaire, et ne pas commit/push/sync sans demande explicite.

16
docs/AGENTS.md Normal file
View file

@ -0,0 +1,16 @@
# Agent Instructions
Cette zone contient les références projet.
## Stockage et workflows
`STORAGE_SCHEMA.md` est la référence des formats IndexedDB et export/import.
`FEATURE_CHECKLIST.md` décrit les attentes fonctionnelles des toolboxes.
Mettre ces documents à jour quand un changement modifie :
- la structure persistée ;
- les formats d'import/export ;
- le workflow attendu d'un outil ;
- les validations associées.

View file

@ -729,6 +729,77 @@ Notes :
- Les valeurs négatives sont acceptées.
- Les compteurs sans libellé ne sont pas conservés.
## Outil Randomizer
Type : `randomizer`
```json
{
"activeTab": "wheel",
"dice": {
"faces": 6,
"rolls": 3,
"results": [2, 5, 1],
"history": [
{
"id": "random1",
"at": "2026-08-30T12:00:00.000Z",
"results": [2, 5, 1]
}
]
},
"wheel": {
"draws": 2,
"entries": [
{
"id": "entry1",
"label": "Route farm",
"weight": 3
}
],
"results": ["Route farm"],
"history": []
},
"teams": {
"teamCount": 2,
"playerSelectionMode": "all",
"playerSelectionCount": 2,
"players": [
{
"id": "player1",
"name": "Alice",
"score": 10,
"active": true
}
],
"exclusions": [
{
"id": "exclusion1",
"playerAId": "player1",
"playerBIds": ["player2", "player3"],
"mode": "source"
}
],
"teams": [],
"history": []
}
}
```
Stockage compact :
- `activeTab` est omis quand l'onglet actif est `dice`.
- `dice`, `wheel` et `teams` sont omis quand ils ne contiennent que leurs valeurs par défaut et aucun résultat.
- l'historique de chaque onglet est limité aux 20 derniers tirages.
- les entrées de roue sans libellé sont ignorées, et `weight` est borné de 1 à 999.
- les joueurs sans nom sont ignorés, et `score` est borné de 0 à 9999.
- `active` vaut `true` par défaut sur un joueur.
- `playerSelectionMode` vaut `all` ou `count`; en mode `count`, `playerSelectionCount` indique combien de joueurs actifs sont choisis aléatoirement avant la répartition.
- chaque exclusion décrit un nom principal `playerAId`, une ou plusieurs cibles `playerBIds` et un `mode`.
- `mode: "source"` interdit uniquement `playerAId` avec chaque cible; les cibles peuvent rester ensemble.
- `mode: "group"` interdit chaque nom de la combinaison avec tous les autres noms de cette combinaison.
- les exclusions invalides, dupliquées ou qui référencent un joueur absent sont ignorées.
## Outil Combos
Type : `combos`

9
tests/AGENTS.md Normal file
View file

@ -0,0 +1,9 @@
# Agent Instructions
Cette zone contient les tests et helpers de validation.
## Validations de données
Quand `website/public/data/site.json`, `website/public/data/<gameId>/...` ou les déclarations de toolboxes changent, vérifier si `helpers/data-validation.mjs` doit être mis à jour.
Les validations doivent rester strictes : `site.json` est la source de vérité et `npm run check` doit échouer si le contenu requis est invalide.

View file

@ -248,6 +248,68 @@ export function validateSiteContent(site) {
"toolboxes.modules.counters.incrementLabel",
"toolboxes.modules.counters.resetTitle",
"toolboxes.modules.counters.deleteTitle",
"toolboxes.modules.randomizer.diceTab",
"toolboxes.modules.randomizer.wheelTab",
"toolboxes.modules.randomizer.teamsTab",
"toolboxes.modules.randomizer.facesLabel",
"toolboxes.modules.randomizer.rollsLabel",
"toolboxes.modules.randomizer.rollButton",
"toolboxes.modules.randomizer.stopButton",
"toolboxes.modules.randomizer.totalLabel",
"toolboxes.modules.randomizer.drawsLabel",
"toolboxes.modules.randomizer.drawButton",
"toolboxes.modules.randomizer.valuesTitle",
"toolboxes.modules.randomizer.valuePlaceholder",
"toolboxes.modules.randomizer.savedValuesLabel",
"toolboxes.modules.randomizer.weightLabel",
"toolboxes.modules.randomizer.addButton",
"toolboxes.modules.randomizer.clearValuesButton",
"toolboxes.modules.randomizer.teamCountLabel",
"toolboxes.modules.randomizer.createTeamsButton",
"toolboxes.modules.randomizer.playerSelectionModeLabel",
"toolboxes.modules.randomizer.playerSelectionAll",
"toolboxes.modules.randomizer.playerSelectionCount",
"toolboxes.modules.randomizer.playerSelectionCountLabel",
"toolboxes.modules.randomizer.nameLabel",
"toolboxes.modules.randomizer.namesImportLabel",
"toolboxes.modules.randomizer.namesImportPlaceholder",
"toolboxes.modules.randomizer.importNamesButton",
"toolboxes.modules.randomizer.clearNamesButton",
"toolboxes.modules.randomizer.valuesImportLabel",
"toolboxes.modules.randomizer.valuesImportPlaceholder",
"toolboxes.modules.randomizer.scoreLabel",
"toolboxes.modules.randomizer.activeLabel",
"toolboxes.modules.randomizer.firstPlayerLabel",
"toolboxes.modules.randomizer.secondPlayerLabel",
"toolboxes.modules.randomizer.selectNamesButton",
"toolboxes.modules.randomizer.selectedNamesLabel",
"toolboxes.modules.randomizer.exclusionModeLabel",
"toolboxes.modules.randomizer.exclusionModeSource",
"toolboxes.modules.randomizer.exclusionModeGroup",
"toolboxes.modules.randomizer.addExclusionButton",
"toolboxes.modules.randomizer.exclusionsListLabel",
"toolboxes.modules.randomizer.teamsSingularLabel",
"toolboxes.modules.randomizer.teamsPluralLabel",
"toolboxes.modules.randomizer.teamScoreLabel",
"toolboxes.modules.randomizer.emptyTeams",
"toolboxes.modules.randomizer.historyTitle",
"toolboxes.modules.randomizer.emptyHistory",
"toolboxes.modules.randomizer.clearHistoryButton",
"toolboxes.modules.randomizer.deleteTitle",
"toolboxes.modules.randomizer.wheelInvalid",
"toolboxes.modules.randomizer.notEnoughPlayers",
"toolboxes.modules.randomizer.constraintsImpossible",
"toolboxes.modules.randomizer.importPlaceholder",
"toolboxes.modules.randomizer.importOpenButton",
"toolboxes.modules.randomizer.exportOpenButton",
"toolboxes.modules.randomizer.importModalTitle",
"toolboxes.modules.randomizer.exportModalTitle",
"toolboxes.modules.randomizer.importButton",
"toolboxes.modules.randomizer.exportButton",
"toolboxes.modules.randomizer.importSuccess",
"toolboxes.modules.randomizer.exportSuccess",
"toolboxes.modules.randomizer.importError",
"toolboxes.modules.randomizer.exportInfo",
"toolboxes.modules.combos.deviceLabel",
"toolboxes.modules.combos.keyboardLayoutLabel",
"toolboxes.modules.combos.categoryPlaceholder",
@ -649,7 +711,7 @@ export function validateSiteContent(site) {
});
const libraryToolDocs = valueAt(site, "library.toolDocs");
["memos", "notepad", "checklist", "counters", "images", "imageAnnotation", "links", "calculator", "table", "goalTree", "timer", "calendar", "taskPlanner", "combos", "equipmentPlanner"].forEach((toolType) => {
["memos", "notepad", "checklist", "counters", "randomizer", "images", "imageAnnotation", "links", "calculator", "table", "goalTree", "timer", "calendar", "taskPlanner", "combos", "equipmentPlanner"].forEach((toolType) => {
const docs = libraryToolDocs?.[toolType];
assert.equal(typeof docs, "object", `library.toolDocs.${toolType} must be an object`);
assert.ok(Array.isArray(docs.controls), `library.toolDocs.${toolType}.controls must be an array`);

View file

@ -100,6 +100,7 @@ test("project documentation tracks storage and feature updates", async () => {
assert.match(storageSchema, /Outil Images/);
assert.match(storageSchema, /Outil Annotation d'images/);
assert.match(storageSchema, /Outil Calculateur/);
assert.match(storageSchema, /Outil Randomizer/);
assert.match(featureChecklist, /Mettre à jour `docs\/STORAGE_SCHEMA\.md`/);
assert.match(featureChecklist, /npm run check/);
});
@ -145,6 +146,7 @@ test("legacy icons and primary images are available", async () => {
await readFile("website/public/static/icons/warning.svg", "utf8");
await readFile("website/public/static/icons/axe.svg", "utf8");
await readFile("website/public/static/icons/cube.svg", "utf8");
await readFile("website/public/static/icons/dice.svg", "utf8");
await readFile("website/public/static/icons/fire.svg", "utf8");
await readFile("website/public/static/icons/shield.svg", "utf8");
await readFile("website/public/static/icons/wing.svg", "utf8");

View file

@ -11,8 +11,9 @@ import { createDefaultDraft, getDateSpanDays, getDraftSelection, normalizeDraftA
import { getMonthOccurrenceSegments, getWeekGridLayout, getWeekMinutePosition, layoutConcurrentOccurrences } from "../website/src/features/toolboxes/modules/calendarLayout.js";
import { appendChecklistItems, moveChecklistItem, moveChecklistItemToCategory, renameChecklistSection } from "../website/src/features/toolboxes/modules/checklistUtils.js";
import { formatUpdatedAt, isHtmlEmpty, queryBlockFormat, queryCommandState } from "../website/src/features/toolboxes/modules/notepadUtils.js";
import { createBalancedTeams, drawWeightedEntries, filterExclusionsForPlayers, getDiceShape, rollDice } from "../website/src/features/toolboxes/modules/randomizerUtils.js";
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js";
import { compactModuleDataForStorage, createToolboxExportPayload, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeGoalTreeData, normalizeImageAnnotationData, normalizeLinksData, normalizeMemosData, normalizeModuleOrder, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeToolbox, normalizeUrl, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
import { compactModuleDataForStorage, createToolboxExportPayload, moduleStorageKey, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeGoalTreeData, normalizeImageAnnotationData, normalizeLinksData, normalizeMemosData, normalizeModuleOrder, normalizeNotepadData, normalizeRandomizerData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeToolbox, normalizeUrl, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
import { applyGroupedReorderOperation, completeGroupOrder, getBoundaryItemId, getGroupedEntries, moveGroupOrder, moveGroupOrderToEnd, moveGroupOrderToStart, moveItem, moveItemGroup } from "../website/src/hooks/useGroupedReorder.js";
function createTextImportContext() {
@ -32,6 +33,7 @@ function createTextImportContext() {
normalizeGoalTreeData,
normalizeEquipmentPlannerData,
normalizeImageAnnotationData,
normalizeRandomizerData,
normalizeUrl
};
}
@ -132,6 +134,156 @@ test("text exchange round trips simple toolbox modules and resets checklist prog
assert.deepEqual(memosImport.data.items.map((memo) => [memo.text, memo.color]), [["Route", "#f6c453"], ["Potion", "#22d3ee"]]);
});
test("randomizer helpers roll dice, draw weighted values and balance teams", () => {
assert.equal(getDiceShape(2), "circle");
assert.equal(getDiceShape(3), "triangle");
assert.equal(getDiceShape(6), "square");
assert.equal(getDiceShape(12), "hexagon");
assert.deepEqual(rollDice(6, 3, () => 0), [1, 1, 1]);
assert.deepEqual(rollDice(6, 2, () => 0.99), [6, 6]);
const wheelDraws = drawWeightedEntries([
{ id: "a", label: "A", weight: 1 },
{ id: "b", label: "B", weight: 3 }
], 2, () => 0.9);
assert.deepEqual(wheelDraws.map((entry) => entry.label), ["B", "B"]);
const result = createBalancedTeams([
{ id: "p1", name: "Alice", score: 10 },
{ id: "p2", name: "Bob", score: 8 },
{ id: "p3", name: "Chloé", score: 7 },
{ id: "p4", name: "Dorian", score: 5 }
], 2, [{ id: "e1", playerAId: "p1", playerBId: "p2" }], () => 0.4);
assert.equal(result.ok, true);
assert.equal(result.teams.length, 2);
assert.ok(result.teams.every((team) => team.players.length === 2));
assert.ok(!result.teams.some((team) => team.players.some((player) => player.id === "p1") && team.players.some((player) => player.id === "p2")));
const singleTeam = createBalancedTeams([
{ id: "p1", name: "Bulbizarre", score: 0 },
{ id: "p2", name: "Salamèche", score: 0 },
{ id: "p3", name: "Carapuce", score: 0 }
], 1, [], () => 0.4);
assert.equal(singleTeam.ok, true);
assert.equal(singleTeam.teams.length, 1);
assert.equal(singleTeam.teams[0].players.length, 3);
});
test("randomizer storage compacts values and detects impossible teams", () => {
const normalized = normalizeRandomizerData({
activeTab: "teams",
dice: { faces: 2000, rolls: 200, history: [{ id: "old", at: "2026-08-30T12:00:00.000Z", results: [1, 2, 6] }] },
wheel: { entries: [{ id: "w1", label: "Rare", weight: 1000 }, { label: "", weight: 1 }] },
teams: {
teamCount: 2,
playerSelectionMode: "count",
playerSelectionCount: 3,
players: [
{ id: "p1", name: "Alice", score: 10 },
{ id: "p2", name: "Bob", score: 8 },
{ id: "p3", name: "Chloé", score: 7 }
],
exclusions: [
{ id: "e1", playerAId: "p1", playerBId: "p2", mode: "group" },
{ id: "e2", playerAId: "p2", playerBId: "p1" },
{ id: "e3", playerAId: "p1", playerBId: "missing" }
]
}
});
assert.equal(normalized.dice.faces, 999);
assert.equal(normalized.dice.rolls, 100);
assert.deepEqual(normalized.dice.history[0].results, [1, 2, 6]);
assert.equal(normalized.wheel.entries[0].weight, 999);
assert.equal(normalized.teams.exclusions.length, 1);
assert.deepEqual(normalized.teams.exclusions[0].playerBIds, ["p2"]);
assert.equal(normalized.teams.exclusions[0].mode, "group");
assert.equal(normalized.teams.playerSelectionMode, "count");
assert.equal(normalized.teams.playerSelectionCount, 3);
assert.equal(compactModuleDataForStorage("randomizer", normalizeRandomizerData({})), null);
assert.equal(compactModuleDataForStorage("randomizer", normalized).activeTab, "teams");
const impossible = createBalancedTeams([
{ id: "p1", name: "Alice", score: 10 },
{ id: "p2", name: "Bob", score: 8 },
{ id: "p3", name: "Chloé", score: 7 }
], 2, [
{ id: "e1", playerAId: "p1", playerBId: "p2" },
{ id: "e2", playerAId: "p1", playerBId: "p3" },
{ id: "e3", playerAId: "p2", playerBId: "p3" }
], () => 0.2);
assert.equal(impossible.ok, false);
assert.equal(impossible.error, "constraints_impossible");
const impossibleGroup = createBalancedTeams([
{ id: "p1", name: "Dracolosse", score: 10 },
{ id: "p2", name: "Drattak", score: 9 },
{ id: "p3", name: "Carchacrok", score: 8 },
{ id: "p4", name: "Lanssorien", score: 7 }
], 3, [
{ id: "e1", playerAId: "p1", playerBIds: ["p2", "p3", "p4"], mode: "group" }
], () => 0.2);
assert.equal(impossibleGroup.ok, false);
assert.equal(impossibleGroup.error, "constraints_impossible");
const scopedGroupExclusions = filterExclusionsForPlayers([
{ id: "e1", playerAId: "p1", playerBIds: ["p2", "p3", "p4"], mode: "group" }
], [
{ id: "p3", name: "Carchacrok", score: 8 },
{ id: "p4", name: "Lanssorien", score: 7 },
{ id: "p5", name: "Corvaillus", score: 6 }
]);
assert.equal(scopedGroupExclusions.length, 1);
assert.equal(scopedGroupExclusions[0].playerAId, "p3");
assert.deepEqual(scopedGroupExclusions[0].playerBIds, ["p4"]);
const scopedGroupResult = createBalancedTeams([
{ id: "p3", name: "Carchacrok", score: 8 },
{ id: "p4", name: "Lanssorien", score: 7 },
{ id: "p5", name: "Corvaillus", score: 6 }
], 2, scopedGroupExclusions, () => 0.2);
assert.equal(scopedGroupResult.ok, true);
assert.ok(!scopedGroupResult.teams.some((team) => team.players.some((player) => player.id === "p3") && team.players.some((player) => player.id === "p4")));
});
test("randomizer text exchange round trips full configuration", () => {
const context = createTextImportContext();
const randomizer = normalizeRandomizerData({
activeTab: "wheel",
dice: { faces: 6, rolls: 3 },
wheel: {
draws: 2,
entries: [
{ id: "w1", label: "Route farm", weight: 3 },
{ id: "w2", label: "Boss rare", weight: 1 }
]
},
teams: {
teamCount: 2,
playerSelectionMode: "count",
playerSelectionCount: 3,
players: [
{ id: "p1", name: "Alice", score: 10 },
{ id: "p2", name: "Bob", score: 8 },
{ id: "p3", name: "Chloé", score: 7 }
],
exclusions: [{ id: "e1", playerAId: "p1", playerBIds: ["p2", "p3"], mode: "group" }]
}
});
const imported = importModuleText("randomizer", exportModuleText("randomizer", randomizer), context);
assert.equal(imported.ok, true);
assert.equal(imported.data.activeTab, "wheel");
assert.deepEqual(imported.data.wheel.entries.map((entry) => [entry.label, entry.weight]), [["Route farm", 3], ["Boss rare", 1]]);
assert.equal(imported.data.teams.playerSelectionMode, "count");
assert.equal(imported.data.teams.playerSelectionCount, 3);
assert.deepEqual(imported.data.teams.players.map((player) => [player.name, player.score]), [["Alice", 10], ["Bob", 8], ["Chloé", 7]]);
assert.equal(imported.data.teams.exclusions.length, 1);
assert.equal(imported.data.teams.exclusions[0].mode, "group");
assert.deepEqual(
imported.data.teams.exclusions[0].playerBIds.map((playerId) => imported.data.teams.players.find((player) => player.id === playerId)?.name),
["Bob", "Chloé"]
);
});
test("notepad helpers keep empty html and invalid command states stable", () => {
assert.equal(isHtmlEmpty("<p><br></p>", ""), true);
assert.equal(isHtmlEmpty("<p>&nbsp;</p>", ""), true);
@ -673,6 +825,40 @@ test("toolbox export remaps module order ids", () => {
assert.deepEqual(normalizeToolbox(payload.toolbox).moduleOrder, payload.toolbox.moduleOrder);
});
test("toolbox export remaps randomizer multi-target exclusions", () => {
const payload = createToolboxExportPayload(
{
id: "toolbox1",
name: "Randomizer",
modules: [{ id: "module1", type: "randomizer" }],
moduleOrder: {
one: ["module1"],
two: [["module1"], []]
}
},
{
[moduleStorageKey("toolbox1", "module1")]: normalizeRandomizerData({
teams: {
players: [
{ id: "p1", name: "Dracolosse" },
{ id: "p2", name: "Drattak" },
{ id: "p3", name: "Carchacrok" },
{ id: "p4", name: "Lanssorien" }
],
exclusions: [{ id: "e1", playerAId: "p1", playerBIds: ["p2", "p3", "p4"], mode: "group" }]
}
})
}
);
const moduleData = Object.values(payload.modules)[0];
assert.equal(moduleData.teams.exclusions.length, 1);
assert.equal(moduleData.teams.exclusions[0].mode, "group");
assert.equal(moduleData.teams.exclusions[0].playerBIds.length, 3);
assert.ok(!moduleData.teams.exclusions[0].playerBIds.includes(undefined));
assert.ok(moduleData.teams.exclusions[0].playerBIds.every((playerId) => playerId !== "p2" && playerId !== "p3" && playerId !== "p4"));
});
test("grouped reorder helpers group flat items and complete group order", () => {
const items = [
{ id: "a", category: "" },

66
website/AGENTS.md Normal file
View 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.

View file

@ -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": {

View file

@ -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",

View 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

View 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

View file

@ -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}

View 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.

View file

@ -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,

View 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>
);
}

View 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);
}

View file

@ -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,

View file

@ -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);
}

View file

@ -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 },

View file

@ -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,

View 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;
}

View file

@ -24,7 +24,8 @@ export const ID_PREFIXES = {
material: "n",
socketItem: "j",
socketLink: "u",
socketBonus: "b"
socketBonus: "b",
randomizer: "x"
};
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";

View file

@ -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;

View file

@ -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"] }
];

View file

@ -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,

View 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.

View file

@ -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;

View file

@ -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");

View file

@ -10,6 +10,7 @@
@use "equipment-planner";
@use "combos";
@use "counters";
@use "randomizer";
@use "calculator";
@use "goal-tree";
@use "timer";

View file

@ -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");

File diff suppressed because it is too large Load diff