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

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