diff --git a/AGENTS.md b/AGENTS.md index dc20152..556de15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//...` - -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. - - -## 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 # View issue details -bd update --claim # Claim work -bd close # 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. - - - -## 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 # View issue details -bd update --claim # Claim work -bd close # 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. - +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. diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000..35b3af9 --- /dev/null +++ b/docs/AGENTS.md @@ -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. diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index 22261d6..01a75b7 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -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` diff --git a/tests/AGENTS.md b/tests/AGENTS.md new file mode 100644 index 0000000..4c8c1b1 --- /dev/null +++ b/tests/AGENTS.md @@ -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//...` 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. diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index 46aeaf6..0d411db 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -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`); diff --git a/tests/static-app.test.mjs b/tests/static-app.test.mjs index b53fdec..8c6fa4c 100644 --- a/tests/static-app.test.mjs +++ b/tests/static-app.test.mjs @@ -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"); diff --git a/tests/toolbox-modules.test.mjs b/tests/toolbox-modules.test.mjs index f16a63f..be5d2f3 100644 --- a/tests/toolbox-modules.test.mjs +++ b/tests/toolbox-modules.test.mjs @@ -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("


", ""), true); assert.equal(isHtmlEmpty("

 

", ""), 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: "" }, diff --git a/website/AGENTS.md b/website/AGENTS.md new file mode 100644 index 0000000..81cbfd3 --- /dev/null +++ b/website/AGENTS.md @@ -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//...` + +`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. diff --git a/website/public/data/library.json b/website/public/data/library.json index b8bebf3..bcd3ed0 100644 --- a/website/public/data/library.json +++ b/website/public/data/library.json @@ -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": { diff --git a/website/public/data/site.json b/website/public/data/site.json index d5b030c..91e6eed 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -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", diff --git a/website/public/static/icons/dice.svg b/website/public/static/icons/dice.svg new file mode 100644 index 0000000..89e27cb --- /dev/null +++ b/website/public/static/icons/dice.svg @@ -0,0 +1,19 @@ + + + + + dice [#25] + Created with Sketch. + + + + + + + + + + + + + \ No newline at end of file diff --git a/website/public/static/icons/wheel.svg b/website/public/static/icons/wheel.svg new file mode 100644 index 0000000..e950cf4 --- /dev/null +++ b/website/public/static/icons/wheel.svg @@ -0,0 +1,27 @@ + + + + + + + + + \ No newline at end of file diff --git a/website/src/components/CompactDropdown.jsx b/website/src/components/CompactDropdown.jsx index 177545c..ab6f39a 100644 --- a/website/src/components/CompactDropdown.jsx +++ b/website/src/components/CompactDropdown.jsx @@ -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} diff --git a/website/src/features/toolboxes/AGENTS.md b/website/src/features/toolboxes/AGENTS.md new file mode 100644 index 0000000..81a9e6d --- /dev/null +++ b/website/src/features/toolboxes/AGENTS.md @@ -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. diff --git a/website/src/features/toolboxes/ToolboxPages.jsx b/website/src/features/toolboxes/ToolboxPages.jsx index 998a33a..821a498 100644 --- a/website/src/features/toolboxes/ToolboxPages.jsx +++ b/website/src/features/toolboxes/ToolboxPages.jsx @@ -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, diff --git a/website/src/features/toolboxes/modules/RandomizerModule.jsx b/website/src/features/toolboxes/modules/RandomizerModule.jsx new file mode 100644 index 0000000..0ff93e5 --- /dev/null +++ b/website/src/features/toolboxes/modules/RandomizerModule.jsx @@ -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 && ( +
+ context.setModuleData(toolboxId, moduleId, nextData, "randomizer")} /> +
+ )} +
+ ({ ...tab, label: textContent[`${tab.id}Tab`] || tab.label }))} + activeTab={data.activeTab} + onChange={(activeTab) => save({ ...data, activeTab })} + className="randomizer-tabs" + size="md" + /> + {data.activeTab === "dice" && updateSection("dice", dice)} context={context} />} + {data.activeTab === "wheel" && updateSection("wheel", wheel)} context={context} />} + {data.activeTab === "teams" && updateSection("teams", teams)} context={context} />} +
+ + ); +} + +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 ( +
+
+ +
+ +
+
+ +
+
+
+ + + +
+
+ `${entry.results.join(" - ")} · ${textContent.totalLabel || "Total"} ${entry.results.reduce((sum, result) => sum + result, 0)}`} + onClear={clearDiceHistory} + onRemove={removeDiceHistoryEntry} + disabled={rolling} + textContent={textContent} + showEmpty + /> +
+
+ ); +} + +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 ( +
+ + + + {value} + +
+ ); +} + +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 ( +
+
+
+
+ +
+ +
+
+
+