From 055fb78d60a055995a9489f0df711ab3192c95da Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Fri, 4 Sep 2026 14:09:08 +0200 Subject: [PATCH] Unify category autocomplete fields --- docs/STORAGE_SCHEMA.md | 28 +- tests/toolbox-modules.test.mjs | 31 +- website/public/data/site.json | 9 +- .../components/CategoryAutocompleteInput.jsx | 146 +++++++++ .../toolboxes/modules/ChecklistModule.jsx | 5 +- .../toolboxes/modules/ChecklistParts.jsx | 10 +- .../toolboxes/modules/CombosEditor.jsx | 4 +- .../toolboxes/modules/CombosModule.jsx | 5 + .../toolboxes/modules/LinksModule.jsx | 289 ++++++++++++++---- .../toolboxes/modules/RngSimulatorModule.jsx | 55 ++-- .../toolboxes/modules/TaskPlannerModule.jsx | 30 +- .../toolboxes/modules/TaskPlannerTopbar.jsx | 3 + .../toolboxes/modules/textImport/basic.js | 38 ++- .../modules/textImport/rngSimulator.js | 24 +- .../toolboxes/storage/modules/links.js | 45 ++- .../toolboxes/storage/modules/rngSimulator.js | 31 +- website/src/hooks/useToolboxAlerts.js | 1 + .../design-system/attributes/_fields.scss | 54 ++++ website/src/styles/toolboxes/_checklist.scss | 12 +- website/src/styles/toolboxes/_links.scss | 85 ++++++ .../task-planner/_topbar-settings.scss | 2 +- 21 files changed, 739 insertions(+), 168 deletions(-) create mode 100644 website/src/components/CategoryAutocompleteInput.jsx diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index 959ad30..997535f 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -686,10 +686,13 @@ Type : `links` ```json { + "categoryOrder": ["Builds", "Guides"], + "collapsedCategories": ["Guides"], "links": [ { "id": "link1", "title": "Build rogue", + "category": "Builds", "url": "https://example.com/build" } ] @@ -700,7 +703,11 @@ Stockage compact : - `title` est facultatif. - `title` est omis si vide. +- `category` est facultatif et sert à l'autocomplete de création. +- `category` est omis si vide. - `url` est obligatoire. +- `categoryOrder` stocke l'ordre d'affichage des catégories, y compris les catégories vides. +- `collapsedCategories` stocke les noms des catégories repliées. ## Outil Compteurs @@ -811,7 +818,11 @@ Type : `rngSimulator` { "id": "rate1", "label": "Drop rare", - "rate": "2.500" + "rate": "2.500", + "multiplier": 50, + "perHour": 30, + "perDay": 120, + "perMonth": 3600 }, { "id": "rate2", @@ -820,11 +831,7 @@ Type : `rngSimulator` } ], "settings": { - "multiplier": 50, - "simulationDraws": 1000, - "perHour": 30, - "perDay": 120, - "perMonth": 3600 + "simulationDraws": 1000 } } ``` @@ -835,10 +842,11 @@ Stockage compact : - `rate` est une chaîne en pourcentage afin de conserver la précision saisie, par exemple `"2.500"` ou `"0.000004"`. - un taux doit être strictement supérieur à `0` et inférieur ou égal à `100`; les valeurs supérieures sont bornées à `"100"`. - `activeRateId` référence un taux existant; sinon le premier taux est sélectionné. -- `settings.multiplier` est un bonus ou malus en pourcentage appliqué au taux actif pour les calculs et simulations, vaut 0 par défaut et le taux effectif est borné entre 0% et 100%. +- `rates[].multiplier` est un bonus ou malus en pourcentage associé au taux, vaut 0 par défaut et le taux effectif est borné entre 0% et 100%. +- `rates[].perHour`, `rates[].perDay` et `rates[].perMonth` sont numériques, positifs ou nuls, associés au taux, et valent 0 par défaut. - `settings.simulationDraws` est borné de 1 à 1 000 000 et vaut 1000 par défaut. -- `settings.perHour`, `settings.perDay` et `settings.perMonth` sont numériques, positifs ou nuls, et valent 0 par défaut. -- `settings` est omis si tous ses champs ont leurs valeurs par défaut. +- `settings` est omis si `simulationDraws` vaut sa valeur par défaut. +- `rates[].multiplier`, `rates[].perHour`, `rates[].perDay` et `rates[].perMonth` sont omis si leur valeur vaut 0. - l'historique des simulations n'est pas persisté en IndexedDB; il est limité aux 20 dernières entrées en `sessionStorage`. ## Outil Combos @@ -1026,7 +1034,7 @@ Formats : - `notepad` : format structuré `@notepad`, blocs `@page: Titre`, `@html` / `@endhtml` pour le HTML riche nettoyé, meta `@drawingMode: permanent` et `@drawings: {...}` pour les dessins permanents. Un collage texte simple reste accepté et devient une page sans mise en forme. - `memos` : une ligne par mémo, avec préfixe `-` optionnel et suffixe couleur `| color=#f6c453` ; les anciens préfixes `*` sont acceptés puis normalisés. - `checklist` : sections `# Catégorie`, lignes `Item: quantité cible`. -- `links` : lignes `Titre: https://...` ou URL seule. +- `links` : sections `# Catégorie`, lignes `Titre: https://...` ou URL seule; le suffixe optionnel `| category=Catégorie` reste accepté à l'import. - `counters` : lignes `Libellé: valeur`, valeurs négatives acceptées. - `calculator` : lignes `Libellé: valeur | formula=base*5`, indentation de deux espaces pour les enfants, meta `@scrollResults: true`. Le suffixe `formula=...` est optionnel et les anciens exports `Libellé: valeur` restent acceptés. - `table` : TSV pur accepté ; l'export peut ajouter `@size`, `@columns`, `@rows` puis `@tsv` pour préserver dimensions et intitulés. diff --git a/tests/toolbox-modules.test.mjs b/tests/toolbox-modules.test.mjs index f521dcb..11d7a4e 100644 --- a/tests/toolbox-modules.test.mjs +++ b/tests/toolbox-modules.test.mjs @@ -120,10 +120,15 @@ test("text exchange round trips simple toolbox modules and resets checklist prog ["Mega potion", 5, 0] ]); - const links = normalizeLinksData({ links: [{ id: "l1", title: "Build", url: "https://example.com/build" }] }); + const links = normalizeLinksData({ + categoryOrder: ["Builds", "Guides"], + links: [{ id: "l1", title: "Build", url: "https://example.com/build", category: "Builds" }] + }); const linksImport = importModuleText("links", exportModuleText("links", links), context); assert.equal(linksImport.ok, true); assert.equal(linksImport.data.links[0].url, "https://example.com/build"); + assert.equal(linksImport.data.links[0].category, "Builds"); + assert.deepEqual(linksImport.data.categoryOrder, ["Builds", "Guides"]); const counters = normalizeCountersData({ counters: [{ id: "c1", label: "Win", value: 12 }, { id: "c2", label: "Loss", value: -2 }] }); const countersImport = importModuleText("counters", exportModuleText("counters", counters), context); @@ -295,11 +300,11 @@ test("rng simulator preserves rate precision and compacts storage", () => { const normalized = normalizeRngSimulatorData({ activeRateId: "rate2", rates: [ - { id: "rate1", label: "Drop rare", rate: "2.500" }, - { id: "rate2", label: "Ultra rare", rate: "0.000004" }, + { id: "rate1", label: "Drop rare", rate: "2.500", multiplier: 25, perHour: 10 }, + { id: "rate2", label: "Ultra rare", rate: "0.000004", multiplier: 50, perHour: 30, perDay: 120, perMonth: 3600 }, { id: "bad", label: "", rate: "4" } ], - settings: { multiplier: 50, simulationDraws: 2000, perHour: 30, perDay: 120, perMonth: 3600 }, + settings: { simulationDraws: 2000 }, history: [ { id: "h1", at: "2026-08-30T12:00:00.000Z", label: "Drop rare", rate: "2.500", multiplier: 50, draws: 100, successes: 4, fails: 400, longestFailStreak: 40 } ] @@ -307,10 +312,12 @@ test("rng simulator preserves rate precision and compacts storage", () => { assert.equal(normalized.activeRateId, "rate2"); assert.deepEqual(normalized.rates.map((rate) => [rate.label, rate.rate]), [["Drop rare", "2.500"], ["Ultra rare", "0.000004"]]); + assert.deepEqual(normalized.rates.map((rate) => [rate.multiplier, rate.perHour, rate.perDay, rate.perMonth]), [[25, 10, 0, 0], [50, 30, 120, 3600]]); assert.equal(normalized.history, undefined); assert.equal(compactModuleDataForStorage("rngSimulator", normalizeRngSimulatorData({})), null); - assert.equal(compactModuleDataForStorage("rngSimulator", normalized).settings.multiplier, 50); - assert.equal(compactModuleDataForStorage("rngSimulator", normalized).settings.perMonth, 3600); + assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].multiplier, 50); + assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].perMonth, 3600); + assert.equal(compactModuleDataForStorage("rngSimulator", normalized).settings.simulationDraws, 2000); assert.equal(compactModuleDataForStorage("rngSimulator", normalized).history, undefined); }); @@ -357,10 +364,10 @@ test("rng simulator text exchange round trips rates and settings", () => { const rngSimulator = normalizeRngSimulatorData({ activeRateId: "rate2", rates: [ - { id: "rate1", label: "Drop rare", rate: "2.500" }, - { id: "rate2", label: "Ultra rare", rate: "0.000004" } + { id: "rate1", label: "Drop rare", rate: "2.500", multiplier: 25, perHour: 20 }, + { id: "rate2", label: "Ultra rare", rate: "0.000004", multiplier: 50, perHour: 40, perDay: 120, perMonth: 3600 } ], - settings: { multiplier: 50, simulationDraws: 5000, perHour: 40, perDay: 120, perMonth: 3600 } + settings: { simulationDraws: 5000 } }); const exported = exportModuleText("rngSimulator", rngSimulator); assert.doesNotMatch(exported, /\[history\]/); @@ -368,9 +375,11 @@ test("rng simulator text exchange round trips rates and settings", () => { assert.equal(imported.ok, true); assert.deepEqual(imported.data.rates.map((rate) => [rate.label, rate.rate]), [["Drop rare", "2.500"], ["Ultra rare", "0.000004"]]); assert.equal(imported.data.rates.find((rate) => rate.id === imported.data.activeRateId)?.label, "Ultra rare"); - assert.equal(imported.data.settings.multiplier, 50); + assert.equal(imported.data.rates.find((rate) => rate.label === "Drop rare")?.multiplier, 25); + assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.multiplier, 50); + assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.perHour, 40); + assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.perMonth, 3600); assert.equal(imported.data.settings.simulationDraws, 5000); - assert.equal(imported.data.settings.perHour, 40); assert.equal(imported.data.history, undefined); }); diff --git a/website/public/data/site.json b/website/public/data/site.json index f1f40f3..8a740ad 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -543,7 +543,7 @@ ], "importFormat": { "text": "Le format texte contient les sections [rates] et [settings]. Les taux sont exprimés en pourcentage.", - "example": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true\n- Pen Eklecta | rate=20.02\n\n[settings]\nmultiplier: 320\nsimulationDraws: 100000\nperHour: 60\nperDay: 0\nperMonth: 0" + "example": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true | multiplier=320 | perHour=60 | perDay=0 | perMonth=0\n- Pen Eklecta | rate=20.02 | multiplier=0 | perHour=0 | perDay=0 | perMonth=0\n\n[settings]\nsimulationDraws: 1000" } }, "images": { @@ -1996,7 +1996,12 @@ "links": { "titlePlaceholder": "Nom du lien", "urlPlaceholder": "https://...", + "categoryPlaceholder": "Catégorie", "addButton": "Ajouter", + "reorderCategoryTitle": "Déplacer la catégorie", + "showCategoryTitle": "Afficher la catégorie", + "hideCategoryTitle": "Réduire la catégorie", + "deleteCategoryTitle": "Supprimer la catégorie", "importPlaceholder": "Importer plusieurs liens...\nBuild:https://example.com/build?class=rogue\nMap:https://example.com/map", "importOpenButton": "Importer du texte", "exportOpenButton": "Exporter en format texte", @@ -2137,7 +2142,7 @@ "observedRateLabel": "Taux de réussite", "multiplierShortLabel": "Bonus", "longestFailStreakLabel": "Pire série", - "importPlaceholder": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true\n- Pen Eklecta | rate=20.02\n\n[settings]\nmultiplier: 320\nsimulationDraws: 100000\nperHour: 60\nperDay: 0\nperMonth: 0", + "importPlaceholder": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true | multiplier=320 | perHour=60 | perDay=0 | perMonth=0\n- Pen Eklecta | rate=20.02 | multiplier=0 | perHour=0 | perDay=0 | perMonth=0\n\n[settings]\nsimulationDraws: 1000", "importOpenButton": "Importer du texte", "exportOpenButton": "Exporter en format texte", "importModalTitle": "Importer un simulateur de chance", diff --git a/website/src/components/CategoryAutocompleteInput.jsx b/website/src/components/CategoryAutocompleteInput.jsx new file mode 100644 index 0000000..56e309d --- /dev/null +++ b/website/src/components/CategoryAutocompleteInput.jsx @@ -0,0 +1,146 @@ +// Rôle : fournit un champ NDS d'autocomplete libre pour les catégories. +import { forwardRef, useId, useMemo, useRef, useState } from "react"; + +function normalizeOptions(options) { + const seen = new Set(); + return (options || []) + .map((option) => String(option || "").trim()) + .filter((option) => { + if (!option || seen.has(option)) return false; + seen.add(option); + return true; + }); +} + +export const CategoryAutocompleteInput = forwardRef(function CategoryAutocompleteInput({ + className = "nds-input", + id, + name, + options, + placeholder, + value, + onChange, + onFocus, + onBlur, + onKeyDown, + autoComplete = "off", + maxOptions = 8, + ...props +}, forwardedRef) { + const generatedId = useId(); + const inputId = id || `category-autocomplete-${generatedId}`; + const rootRef = useRef(null); + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const cleanOptions = useMemo(() => normalizeOptions(options), [options]); + const cleanValue = String(value || ""); + const filteredOptions = useMemo(() => { + const query = cleanValue.trim().toLowerCase(); + const matches = query + ? cleanOptions.filter((option) => option.toLowerCase().includes(query)) + : cleanOptions; + return matches.slice(0, maxOptions); + }, [cleanOptions, cleanValue, maxOptions]); + const menuId = `${inputId}-menu`; + const showMenu = open && filteredOptions.length > 0; + + function setInputRef(element) { + if (typeof forwardedRef === "function") forwardedRef(element); + else if (forwardedRef) forwardedRef.current = element; + } + + function emitChange(nextValue) { + onChange?.({ target: { name, value: nextValue } }); + } + + function selectOption(option) { + emitChange(option); + setOpen(false); + setActiveIndex(-1); + } + + function handleChange(event) { + onChange?.(event); + setOpen(true); + setActiveIndex(-1); + } + + function handleFocus(event) { + setOpen(true); + onFocus?.(event); + } + + function handleBlur(event) { + if (rootRef.current?.contains(event.relatedTarget)) return; + setOpen(false); + setActiveIndex(-1); + onBlur?.(event); + } + + function handleKeyDown(event) { + if (!filteredOptions.length) { + onKeyDown?.(event); + return; + } + if (event.key === "ArrowDown") { + event.preventDefault(); + setOpen(true); + setActiveIndex((index) => Math.min(index + 1, filteredOptions.length - 1)); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setOpen(true); + setActiveIndex((index) => Math.max(index - 1, 0)); + } else if (event.key === "Enter" && open && activeIndex >= 0) { + event.preventDefault(); + selectOption(filteredOptions[activeIndex]); + } else if (event.key === "Escape" && open) { + setOpen(false); + setActiveIndex(-1); + } else { + onKeyDown?.(event); + } + } + + return ( +
+ = 0 ? `${menuId}-${activeIndex}` : undefined} + /> + {showMenu && ( +
+ {filteredOptions.map((option, index) => ( + + ))} +
+ )} +
+ ); +}); diff --git a/website/src/features/toolboxes/modules/ChecklistModule.jsx b/website/src/features/toolboxes/modules/ChecklistModule.jsx index f4dcd3a..011b143 100644 --- a/website/src/features/toolboxes/modules/ChecklistModule.jsx +++ b/website/src/features/toolboxes/modules/ChecklistModule.jsx @@ -1,5 +1,6 @@ // Rôle : fournit l'outil checklist avec quantités, catégories et échange texte. import { useState } from "react"; +import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompleteInput.jsx"; import { Icon } from "../../../components/Icon.jsx"; import { useGroupedReorder, moveItem } from "../../../hooks/useGroupedReorder.js"; import { ChecklistSection } from "./ChecklistParts.jsx"; @@ -22,6 +23,7 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing, textExc const [sectionTitle, setSectionTitle] = useState(""); const [qty, setQty] = useState(1); const namedSections = data.sections.filter((section) => section.title); + const categoryOptions = namedSections.map((section) => section.title); const completedSectionsMode = data.hideCompletedSectionsFully ? "hidden" : data.hideCompletedSections ? "reduced" : "visible"; const reorderItems = getChecklistReorderItems(data.sections); const { @@ -113,7 +115,7 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing, textExc {editing && (
- setSectionTitle(event.target.value)} /> + setSectionTitle(event.target.value)} options={categoryOptions} /> setLabel(event.target.value)} /> setQty(event.target.value)} aria-label={textContent.quantityLabel || "Quantité cible"} /> @@ -172,6 +174,7 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing, textExc onUpdateItem={updateItem} onMoveItemToCategory={moveItemToCategory} onDeleteItem={deleteItem} + categoryOptions={categoryOptions} />, showBoundaryDropZones && isLastSection ? (
  • setEditingItemId(editingItemId === item.id ? "" : item.id)} onCloseEditing={() => setEditingItemId("")} @@ -128,7 +130,7 @@ export function ChecklistSection({ section, context, data, reorder, onRenameSect ); } -function ChecklistItem({ item, section, context, reorder, items, save, onUpdateItem, onMoveItemToCategory, onDeleteItem, editing, onToggleEditing, onCloseEditing }) { +function ChecklistItem({ item, section, context, reorder, items, save, onUpdateItem, onMoveItemToCategory, onDeleteItem, categoryOptions, editing, onToggleEditing, onCloseEditing }) { const itemRef = useRef(null); const done = context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget; const textContent = context.moduleText?.checklist || {}; @@ -221,13 +223,13 @@ function ChecklistItem({ item, section, context, reorder, items, save, onUpdateI "aria-label": textContent.itemEditLabel || "Libellé" })} /> - ) : ( diff --git a/website/src/features/toolboxes/modules/CombosEditor.jsx b/website/src/features/toolboxes/modules/CombosEditor.jsx index 29fa0a9..f78018d 100644 --- a/website/src/features/toolboxes/modules/CombosEditor.jsx +++ b/website/src/features/toolboxes/modules/CombosEditor.jsx @@ -1,4 +1,5 @@ // Rôle : rend le formulaire de création et édition des combos. +import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompleteInput.jsx"; import { Icon } from "../../../components/Icon.jsx"; import { ComboInputToken, ComboSequence } from "./CombosParts.jsx"; import { @@ -10,6 +11,7 @@ import { export function CombosEditor({ data, textContent, + categoryOptions, comboCategory, comboName, customKey, @@ -78,7 +80,7 @@ export function CombosEditor({
    @@ -70,44 +174,125 @@ export function LinksModule({ toolboxId, moduleId, context, editing, textExchang context.setModuleData(toolboxId, moduleId, nextData, "links")} />
  • )} - + ); + + function renderLink(link) { + const className = [ + "link-item", + isItemDragging(link.id) ? "is-dragging" : "", + isItemDropTarget(link.id) ? "is-drop-target" : "", + getDropPlacement("item", link.id) === "after" ? "drop-after" : "" + ].filter(Boolean).join(" "); + + return ( +
    + + + {link.title || context.hostnameFromUrl(link.url)} + {link.url} + +
    + + +
    +
    + ); + } } diff --git a/website/src/features/toolboxes/modules/RngSimulatorModule.jsx b/website/src/features/toolboxes/modules/RngSimulatorModule.jsx index 825f6de..4986cae 100644 --- a/website/src/features/toolboxes/modules/RngSimulatorModule.jsx +++ b/website/src/features/toolboxes/modules/RngSimulatorModule.jsx @@ -104,12 +104,24 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO const [occurrenceDrafts, setOccurrenceDrafts] = useState({ perHour: null, perDay: null, perMonth: null }); const rateDraft = useDraftForm({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE }); const activeRate = data.rates.find((rate) => rate.id === data.activeRateId) || data.rates[0] || null; - const summary = useMemo(() => activeRate ? calculateRngSummary({ rate: activeRate.rate, ...data.settings }) : null, [activeRate, data.settings]); + const summary = useMemo(() => activeRate ? calculateRngSummary({ + rate: activeRate.rate, + multiplier: activeRate.multiplier, + simulationDraws: data.settings.simulationDraws, + perHour: activeRate.perHour, + perDay: activeRate.perDay, + perMonth: activeRate.perMonth + }) : null, [activeRate, data.settings.simulationDraws]); useEffect(() => { setHistory(readSimulationHistory(historyStorageKey)); }, [historyStorageKey]); + useEffect(() => { + setMultiplierDraft(null); + setOccurrenceDrafts({ perHour: null, perDay: null, perMonth: null }); + }, [activeRate?.id]); + function save(nextData) { context.setModuleData(toolboxId, moduleId, nextData, "rngSimulator"); } @@ -118,21 +130,28 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO const settings = { ...data.settings }; if (field === "simulationDraws") { settings[field] = clampInteger(value, RNG_SIMULATOR_LIMITS.simulationDrawsMin, RNG_SIMULATOR_LIMITS.simulationDrawsMax, data.settings.simulationDraws); - } else if (field === "multiplier") { - settings[field] = clampDecimal(value, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0); - } else { - settings[field] = clampDecimal(value, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, data.settings[field]); } save({ ...data, settings }); } + function updateActiveRateSettings(field, value) { + if (!activeRate) return; + const nextValue = field === "multiplier" + ? clampDecimal(value, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0) + : clampDecimal(value, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, activeRate[field]); + save({ + ...data, + rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, [field]: nextValue } : rate) + }); + } + function updateMultiplier(value) { setMultiplierDraft(value); if (String(value).trim() === "") { - updateSettings("multiplier", 0); + updateActiveRateSettings("multiplier", 0); return; } - updateSettings("multiplier", value); + updateActiveRateSettings("multiplier", value); } function blurMultiplier() { @@ -141,7 +160,7 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO const parsed = Number(raw.replace(",", ".")); if (!raw || !Number.isFinite(parsed)) { setMultiplierDraft(null); - updateSettings("multiplier", 0); + updateActiveRateSettings("multiplier", 0); return; } setMultiplierDraft(null); @@ -149,16 +168,16 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO function clearMultiplier() { setMultiplierDraft(null); - updateSettings("multiplier", 0); + updateActiveRateSettings("multiplier", 0); } function updateOccurrence(field, value) { setOccurrenceDrafts((drafts) => ({ ...drafts, [field]: value })); if (String(value).trim() === "") { - updateSettings(field, 0); + updateActiveRateSettings(field, 0); return; } - updateSettings(field, value); + updateActiveRateSettings(field, value); } function blurOccurrence(field) { @@ -166,7 +185,7 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO if (value === null) return; const raw = value.trim(); const parsed = Number(raw.replace(",", ".")); - if (!raw || !Number.isFinite(parsed)) updateSettings(field, 0); + if (!raw || !Number.isFinite(parsed)) updateActiveRateSettings(field, 0); setOccurrenceDrafts((drafts) => ({ ...drafts, [field]: null })); } @@ -207,12 +226,12 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO function runSimulation() { if (!activeRate) return; - const result = simulateRngDraws(activeRate.rate, data.settings.simulationDraws, Math.random, data.settings.multiplier); + const result = simulateRngDraws(activeRate.rate, data.settings.simulationDraws, Math.random, activeRate.multiplier); const entry = { id: context.uid("rng"), label: activeRate.label, rate: activeRate.rate, - multiplier: data.settings.multiplier, + multiplier: activeRate.multiplier, ...result }; const nextHistory = addRngSimulationHistory(history, entry); @@ -302,7 +321,7 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
    {textContent.multiplierLabel || "Multiplicateur"}
    - updateMultiplier(event.target.value)} onBlur={blurMultiplier} aria-label={textContent.multiplierLabel || "Multiplicateur"} /> + updateMultiplier(event.target.value)} onBlur={blurMultiplier} aria-label={textContent.multiplierLabel || "Multiplicateur"} /> %