From 6b85e58765d99a4fbcdca682f10f28721cc8cedb Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Mon, 31 Aug 2026 15:16:36 +0200 Subject: [PATCH] Add chance simulator toolbox and refine checklist interactions --- docs/STORAGE_SCHEMA.md | 41 ++ tests/helpers/data-validation.mjs | 56 ++- tests/toolbox-modules.test.mjs | 92 +++- website/public/data/library.json | 28 ++ website/public/data/site.json | 113 ++++- website/public/static/icons/clover.svg | 18 + .../src/features/toolboxes/ToolboxPages.jsx | 2 + .../toolboxes/modules/ChecklistParts.jsx | 19 +- .../toolboxes/modules/RngSimulatorModule.jsx | 407 +++++++++++++++++ .../toolboxes/modules/rngSimulatorUtils.js | 138 ++++++ .../features/toolboxes/modules/textImport.js | 6 + .../modules/textImport/rngSimulator.js | 66 +++ .../modules/toolboxModuleComponents.jsx | 2 + .../toolboxes/storage/modules/index.js | 2 + .../toolboxes/storage/modules/rngSimulator.js | 55 +++ .../toolboxes/storage/modules/shared.js | 3 +- .../toolboxes/storage/toolboxStorage.js | 11 + .../toolboxes/toolboxModuleRegistry.js | 12 +- website/src/pages/LibraryPage.jsx | 5 +- .../src/styles/design-system/_attributes.scss | 2 +- website/src/styles/icons/_toolbox.scss | 5 + website/src/styles/toolboxes/_checklist.scss | 33 +- website/src/styles/toolboxes/_core.scss | 1 + .../src/styles/toolboxes/_module-shell.scss | 5 + .../src/styles/toolboxes/_rng-simulator.scss | 414 ++++++++++++++++++ 25 files changed, 1518 insertions(+), 18 deletions(-) create mode 100644 website/public/static/icons/clover.svg create mode 100644 website/src/features/toolboxes/modules/RngSimulatorModule.jsx create mode 100644 website/src/features/toolboxes/modules/rngSimulatorUtils.js create mode 100644 website/src/features/toolboxes/modules/textImport/rngSimulator.js create mode 100644 website/src/features/toolboxes/storage/modules/rngSimulator.js create mode 100644 website/src/styles/toolboxes/_rng-simulator.scss diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index 01a75b7..f2cf6b3 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -800,6 +800,47 @@ Stockage compact : - `mode: "group"` interdit chaque nom de la combinaison avec tous les autres noms de cette combinaison. - les exclusions invalides, dupliquées ou qui référencent un joueur absent sont ignorées. +## Outil Simulateur de chance + +Type : `rngSimulator` + +```json +{ + "activeRateId": "rate1", + "rates": [ + { + "id": "rate1", + "label": "Drop rare", + "rate": "2.500" + }, + { + "id": "rate2", + "label": "Ultra rare", + "rate": "0.000004" + } + ], + "settings": { + "multiplier": 50, + "simulationDraws": 1000, + "perHour": 30, + "perDay": 120, + "perMonth": 3600 + } +} +``` + +Stockage compact : + +- `rates` contient les taux enregistrés du module; un taux sans libellé est ignoré. +- `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%. +- `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. +- l'historique des simulations n'est pas persisté en IndexedDB; il est limité aux 20 dernières entrées en `sessionStorage`. + ## Outil Combos Type : `combos` diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index 0d411db..56bbdbf 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -310,6 +310,60 @@ export function validateSiteContent(site) { "toolboxes.modules.randomizer.exportSuccess", "toolboxes.modules.randomizer.importError", "toolboxes.modules.randomizer.exportInfo", + "toolboxes.modules.rngSimulator.ratesTitle", + "toolboxes.modules.rngSimulator.addRateTitle", + "toolboxes.modules.rngSimulator.editRateTitle", + "toolboxes.modules.rngSimulator.emptyRates", + "toolboxes.modules.rngSimulator.labelLabel", + "toolboxes.modules.rngSimulator.labelPlaceholder", + "toolboxes.modules.rngSimulator.rateLabel", + "toolboxes.modules.rngSimulator.rateLabelRequired", + "toolboxes.modules.rngSimulator.rateInvalid", + "toolboxes.modules.rngSimulator.addRateButton", + "toolboxes.modules.rngSimulator.saveRateButton", + "toolboxes.modules.rngSimulator.cancelButton", + "toolboxes.modules.rngSimulator.deleteTitle", + "toolboxes.modules.rngSimulator.noActiveRateTitle", + "toolboxes.modules.rngSimulator.simulationDrawsLabel", + "toolboxes.modules.rngSimulator.perHourLabel", + "toolboxes.modules.rngSimulator.perDayLabel", + "toolboxes.modules.rngSimulator.perMonthLabel", + "toolboxes.modules.rngSimulator.probabilitySectionTitle", + "toolboxes.modules.rngSimulator.multiplierLabel", + "toolboxes.modules.rngSimulator.clearMultiplierButton", + "toolboxes.modules.rngSimulator.averageDrawsLabel", + "toolboxes.modules.rngSimulator.simulationChanceLabel", + "toolboxes.modules.rngSimulator.averageTimeTitle", + "toolboxes.modules.rngSimulator.occurrenceCountLabel", + "toolboxes.modules.rngSimulator.hourTimeLabel", + "toolboxes.modules.rngSimulator.dayTimeLabel", + "toolboxes.modules.rngSimulator.monthTimeLabel", + "toolboxes.modules.rngSimulator.chanceTargetsTitle", + "toolboxes.modules.rngSimulator.drawsUnit", + "toolboxes.modules.rngSimulator.hoursUnit", + "toolboxes.modules.rngSimulator.daysUnit", + "toolboxes.modules.rngSimulator.monthsUnit", + "toolboxes.modules.rngSimulator.emptySummary", + "toolboxes.modules.rngSimulator.simulationTitle", + "toolboxes.modules.rngSimulator.simulateButton", + "toolboxes.modules.rngSimulator.clearHistoryButton", + "toolboxes.modules.rngSimulator.emptyHistory", + "toolboxes.modules.rngSimulator.successesLabel", + "toolboxes.modules.rngSimulator.failsLabel", + "toolboxes.modules.rngSimulator.observedRateLabel", + "toolboxes.modules.rngSimulator.multiplierShortLabel", + "toolboxes.modules.rngSimulator.longestFailStreakLabel", + "toolboxes.modules.rngSimulator.importPlaceholder", + "toolboxes.modules.rngSimulator.importOpenButton", + "toolboxes.modules.rngSimulator.exportOpenButton", + "toolboxes.modules.rngSimulator.importModalTitle", + "toolboxes.modules.rngSimulator.exportModalTitle", + "toolboxes.modules.rngSimulator.importButton", + "toolboxes.modules.rngSimulator.exportButton", + "toolboxes.modules.rngSimulator.importSuccess", + "toolboxes.modules.rngSimulator.exportSuccess", + "toolboxes.modules.rngSimulator.importError", + "toolboxes.modules.rngSimulator.exportInfo", "toolboxes.modules.combos.deviceLabel", "toolboxes.modules.combos.keyboardLayoutLabel", "toolboxes.modules.combos.categoryPlaceholder", @@ -711,7 +765,7 @@ export function validateSiteContent(site) { }); const libraryToolDocs = valueAt(site, "library.toolDocs"); - ["memos", "notepad", "checklist", "counters", "randomizer", "images", "imageAnnotation", "links", "calculator", "table", "goalTree", "timer", "calendar", "taskPlanner", "combos", "equipmentPlanner"].forEach((toolType) => { + ["memos", "notepad", "checklist", "counters", "randomizer", "rngSimulator", "images", "imageAnnotation", "links", "calculator", "table", "goalTree", "timer", "calendar", "taskPlanner", "combos", "equipmentPlanner"].forEach((toolType) => { const docs = libraryToolDocs?.[toolType]; assert.equal(typeof docs, "object", `library.toolDocs.${toolType} must be an object`); assert.ok(Array.isArray(docs.controls), `library.toolDocs.${toolType}.controls must be an array`); diff --git a/tests/toolbox-modules.test.mjs b/tests/toolbox-modules.test.mjs index be5d2f3..f521dcb 100644 --- a/tests/toolbox-modules.test.mjs +++ b/tests/toolbox-modules.test.mjs @@ -12,8 +12,9 @@ import { getMonthOccurrenceSegments, getWeekGridLayout, getWeekMinutePosition, l import { appendChecklistItems, moveChecklistItem, moveChecklistItemToCategory, renameChecklistSection } from "../website/src/features/toolboxes/modules/checklistUtils.js"; import { formatUpdatedAt, isHtmlEmpty, queryBlockFormat, queryCommandState } from "../website/src/features/toolboxes/modules/notepadUtils.js"; import { createBalancedTeams, drawWeightedEntries, filterExclusionsForPlayers, getDiceShape, rollDice } from "../website/src/features/toolboxes/modules/randomizerUtils.js"; +import { calculateAtLeastOneProbability, calculateDrawsForChance, calculateRngSummary, getEffectiveRate, normalizeRateString, simulateRngDraws } from "../website/src/features/toolboxes/modules/rngSimulatorUtils.js"; import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js"; -import { compactModuleDataForStorage, createToolboxExportPayload, moduleStorageKey, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeGoalTreeData, normalizeImageAnnotationData, normalizeLinksData, normalizeMemosData, normalizeModuleOrder, normalizeNotepadData, normalizeRandomizerData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeToolbox, normalizeUrl, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js"; +import { compactModuleDataForStorage, createToolboxExportPayload, moduleStorageKey, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeGoalTreeData, normalizeImageAnnotationData, normalizeLinksData, normalizeMemosData, normalizeModuleOrder, normalizeNotepadData, normalizeRandomizerData, normalizeRngSimulatorData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeToolbox, normalizeUrl, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js"; import { applyGroupedReorderOperation, completeGroupOrder, getBoundaryItemId, getGroupedEntries, moveGroupOrder, moveGroupOrderToEnd, moveGroupOrderToStart, moveItem, moveItemGroup } from "../website/src/hooks/useGroupedReorder.js"; function createTextImportContext() { @@ -34,6 +35,7 @@ function createTextImportContext() { normalizeEquipmentPlannerData, normalizeImageAnnotationData, normalizeRandomizerData, + normalizeRngSimulatorData, normalizeUrl }; } @@ -284,6 +286,94 @@ test("randomizer text exchange round trips full configuration", () => { ); }); +test("rng simulator preserves rate precision and compacts storage", () => { + assert.equal(normalizeRateString("2.500"), "2.500"); + assert.equal(normalizeRateString("0.001"), "0.001"); + assert.equal(normalizeRateString("0.000004%"), "0.000004"); + assert.equal(normalizeRateString("100.500"), "100"); + + const normalized = normalizeRngSimulatorData({ + activeRateId: "rate2", + rates: [ + { id: "rate1", label: "Drop rare", rate: "2.500" }, + { id: "rate2", label: "Ultra rare", rate: "0.000004" }, + { id: "bad", label: "", rate: "4" } + ], + settings: { multiplier: 50, simulationDraws: 2000, perHour: 30, perDay: 120, perMonth: 3600 }, + 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 } + ] + }); + + assert.equal(normalized.activeRateId, "rate2"); + assert.deepEqual(normalized.rates.map((rate) => [rate.label, rate.rate]), [["Drop rare", "2.500"], ["Ultra rare", "0.000004"]]); + 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).history, undefined); +}); + +test("rng simulator calculates probability milestones and deterministic simulations", () => { + assert.equal(calculateAtLeastOneProbability("100", 50), 1); + assert.equal(calculateDrawsForChance("50", 0.5), 1); + assert.equal(calculateDrawsForChance("1", 0.5), 69); + assert.equal(getEffectiveRate("10", 50), 15); + assert.equal(getEffectiveRate("10", -50), 5); + assert.equal(getEffectiveRate("75", 100), 100); + + const hourlySummary = calculateRngSummary({ rate: "25", simulationDraws: 8, perHour: 2, perDay: 0, perMonth: 0 }); + assert.equal(hourlySummary.timeByFrequency.hour, 2); + assert.equal(hourlySummary.timeByFrequency.day, 2 / 24); + assert.equal(hourlySummary.timeByFrequency.month, 2 / 24 / 30); + const multipliedSummary = calculateRngSummary({ rate: "10", multiplier: 100, simulationDraws: 1, perHour: 0, perDay: 0, perMonth: 0 }); + assert.equal(multipliedSummary.effectiveRate, 20); + assert.equal(multipliedSummary.averageDraws, 5); + + const result = simulateRngDraws("25", 8, (() => { + const values = [0.1, 0.9, 0.2, 0.8, 0.7, 0.1, 0.6, 0.5]; + let index = 0; + return () => values[index++]; + })()); + + assert.deepEqual(result, { + draws: 8, + successes: 3, + fails: 5, + observedRate: 37.5, + longestFailStreak: 2 + }); + + const multipliedResult = simulateRngDraws("10", 4, (() => { + const values = [0.15, 0.25, 0.05, 0.9]; + let index = 0; + return () => values[index++]; + })(), 100); + assert.equal(multipliedResult.successes, 2); +}); + +test("rng simulator text exchange round trips rates and settings", () => { + const context = createTextImportContext(); + const rngSimulator = normalizeRngSimulatorData({ + activeRateId: "rate2", + rates: [ + { id: "rate1", label: "Drop rare", rate: "2.500" }, + { id: "rate2", label: "Ultra rare", rate: "0.000004" } + ], + settings: { multiplier: 50, simulationDraws: 5000, perHour: 40, perDay: 120, perMonth: 3600 } + }); + const exported = exportModuleText("rngSimulator", rngSimulator); + assert.doesNotMatch(exported, /\[history\]/); + const imported = importModuleText("rngSimulator", exported, context); + 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.settings.simulationDraws, 5000); + assert.equal(imported.data.settings.perHour, 40); + assert.equal(imported.data.history, undefined); +}); + test("notepad helpers keep empty html and invalid command states stable", () => { assert.equal(isHtmlEmpty("


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

 

", ""), true); diff --git a/website/public/data/library.json b/website/public/data/library.json index bcd3ed0..eba7b6c 100644 --- a/website/public/data/library.json +++ b/website/public/data/library.json @@ -49,6 +49,10 @@ "id": "mg", "type": "randomizer" }, + { + "id": "mh", + "type": "rngSimulator" + }, { "id": "m9", "type": "timer" @@ -238,6 +242,7 @@ "m8", "mf", "mg", + "mh", "m9", "md", "me", @@ -261,6 +266,7 @@ "m8", "mf", "mg", + "mh", "m9", "md", "me", @@ -4759,6 +4765,28 @@ ] } }, + "mh": { + "activeRateId": "fnn88zy", + "rates": [ + { + "id": "fnn88zy", + "label": "Elten Piece", + "rate": "0.000004" + }, + { + "id": "pen-eklecta", + "label": "Pen Eklecta", + "rate": "20.02" + } + ], + "settings": { + "multiplier": 320, + "simulationDraws": 100000, + "perHour": 60, + "perDay": 0, + "perMonth": 0 + } + }, "m9": { "activeTab": "countdown", "stopwatch": { diff --git a/website/public/data/site.json b/website/public/data/site.json index 91e6eed..96d31a2 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -459,7 +459,7 @@ { "group": "Historique", "icon": "rubber", - "title": "Effacer l'historique", + "title": "Supprimer les simulations", "preview": { "kind": "button", "label": "Effacer" @@ -491,6 +491,61 @@ "example": "@activeTab: teams\n[dice]\nfaces: 6\nrolls: 5\n\n[wheel]\ndraws: 3\n\n- Oui | weight=2\n- Non | weight=2\n- Peut etre | weight=1\n\n[teams]\nteamCount: 4\nplayerSelectionMode: all\nplayerSelectionCount: 6\n\n- Dracaufeu | score=0\n- Tortank | score=0\n- Florizarre | score=0\n- Dracolosse | score=0\n- Ectoplasma | score=0\n- Alakazam | score=0\n- Mackogneur | score=0\n- Cizayox | score=0\n- Tyranocif | score=0\n- Gardevoir | score=0\n- Drattak | score=0\n- Métalosse | score=0\n- Carchacrok | score=0\n- Lucario | score=0\n- Trioxhydre | score=0\n- Amphinobi | score=0\n- Archéduc | score=0\n- Félinferno | score=0\n- Corvaillus | score=0\n- Lanssorien | score=0\n- Raichu | score=0\n- Artikodin | score=0\n- Électhor | score=0\n- Sulfura | score=0\n! Dracolosse / Drattak / Carchacrok / Lanssorien | mode=group\n! Artikodin / Électhor / Sulfura | mode=group" } }, + "rngSimulator": { + "controls": [ + { + "group": "Taux", + "icon": "clover", + "title": "Enregistrer un taux", + "preview": { + "kind": "textButton", + "label": "%" + } + }, + { + "group": "Simulation", + "icon": "play-circle", + "title": "Simuler", + "preview": { + "kind": "iconButton", + "icon": "play-circle" + } + }, + { + "group": "Historique", + "icon": "rubber", + "title": "Effacer l'historique", + "preview": { + "kind": "iconButton", + "icon": "rubber" + } + } + ], + "features": [ + { + "title": "Taux sauvegardés", + "text": "Chaque taux possède un libellé et garde la précision saisie, même pour des probabilités très faibles.", + "details": [ + "La saisie par défaut utilise trois décimales, mais le stockage conserve la chaîne exacte normalisée.", + "Un taux comme 0.000004% reste disponible tel quel pour les calculs et les simulations.", + "La sélection active permet de comparer rapidement plusieurs drops ou chances de réussite." + ] + }, + { + "title": "Lecture probabiliste", + "text": "L'outil affiche la moyenne d'essais avant réussite, le temps moyen selon votre rythme et les paliers de chance cumulée.", + "details": [ + "Les occurrences par heure, jour et mois sont indépendantes pour s'adapter à différents usages.", + "Les paliers 50%, 90%, 95% et 99% donnent un repère concret sur la variance.", + "La simulation agrège réussites, échecs et pire série d'échecs sans stocker chaque tirage." + ] + } + ], + "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" + } + }, "images": { "controls": [ { @@ -2038,6 +2093,62 @@ "importError": "Format de randomizer invalide.", "exportInfo": "Le format texte conserve les paramètres du dé, de la roue et des équipes." }, + "rngSimulator": { + "ratesTitle": "Taux enregistrés", + "addRateTitle": "Ajouter un taux", + "editRateTitle": "Modifier un taux", + "emptyRates": "Aucun taux enregistré.", + "labelLabel": "Libellé", + "labelPlaceholder": "Drop rare", + "rateLabel": "Taux (%)", + "rateLabelRequired": "Libellé requis.", + "rateInvalid": "Taux invalide. Utilisez une valeur supérieure à 0 et inférieure ou égale à 100.", + "addRateButton": "Ajouter", + "saveRateButton": "Enregistrer", + "cancelButton": "Annuler", + "deleteTitle": "Supprimer", + "noActiveRateTitle": "Aucun taux actif", + "simulationDrawsLabel": "Tirages simulés", + "perHourLabel": "Par heure", + "perDayLabel": "Par jour", + "perMonthLabel": "Par mois", + "probabilitySectionTitle": "Probabilités avec multiplicateur", + "multiplierLabel": "Multiplicateur (%)", + "clearMultiplierButton": "Réinitialiser le multiplicateur", + "averageDrawsLabel": "Moyenne avant réussite après multiplicateur", + "simulationChanceLabel": "Chance sur les tirages saisis après multiplicateur", + "averageTimeTitle": "Temps moyen avant réussite", + "occurrenceCountLabel": "Nombre d'occurrences", + "hourTimeLabel": "En heures", + "dayTimeLabel": "En jours", + "monthTimeLabel": "En mois", + "chanceTargetsTitle": "Tirages pour atteindre une chance cumulée", + "drawsUnit": "tirages", + "hoursUnit": "h", + "daysUnit": "j", + "monthsUnit": "mois", + "emptySummary": "Ajoutez un taux pour afficher les calculs.", + "simulationTitle": "Résultats simulés", + "simulateButton": "Simuler", + "clearHistoryButton": "Supprimer les simulations", + "emptyHistory": "Aucune simulation.", + "successesLabel": "Réussites", + "failsLabel": "Fails", + "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", + "importOpenButton": "Importer du texte", + "exportOpenButton": "Exporter en format texte", + "importModalTitle": "Importer un simulateur de chance", + "exportModalTitle": "Exporter un simulateur de chance", + "importButton": "Importer le texte", + "exportButton": "Copier le texte", + "importSuccess": "Import du simulateur de chance terminé.", + "exportSuccess": "Export du simulateur de chance copié.", + "importError": "Format de simulateur de chance invalide.", + "exportInfo": "Le format texte conserve les taux enregistrés et les paramètres." + }, "combos": { "importPlaceholder": "@device: playstation\n# Neutral\nHadoken | device=playstation | direction:down > direction:right > button:cross", "importOpenButton": "Importer du texte", diff --git a/website/public/static/icons/clover.svg b/website/public/static/icons/clover.svg new file mode 100644 index 0000000..d162308 --- /dev/null +++ b/website/public/static/icons/clover.svg @@ -0,0 +1,18 @@ + + + + + + + + \ No newline at end of file diff --git a/website/src/features/toolboxes/ToolboxPages.jsx b/website/src/features/toolboxes/ToolboxPages.jsx index 821a498..48aa2e0 100644 --- a/website/src/features/toolboxes/ToolboxPages.jsx +++ b/website/src/features/toolboxes/ToolboxPages.jsx @@ -27,6 +27,7 @@ import { normalizeModuleOrder, normalizeNotepadData, normalizeRandomizerData, + normalizeRngSimulatorData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, @@ -202,6 +203,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto normalizeImageAnnotationData, normalizeNotepadData, normalizeRandomizerData, + normalizeRngSimulatorData, normalizeTableData, normalizeTimerData, normalizeTaskPlannerData, diff --git a/website/src/features/toolboxes/modules/ChecklistParts.jsx b/website/src/features/toolboxes/modules/ChecklistParts.jsx index 29bbdde..1546bdb 100644 --- a/website/src/features/toolboxes/modules/ChecklistParts.jsx +++ b/website/src/features/toolboxes/modules/ChecklistParts.jsx @@ -1,5 +1,5 @@ // Rôle : rend les sections et items de checklist avec édition inline et quantités. -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; import { @@ -119,6 +119,7 @@ export function ChecklistSection({ section, context, data, reorder, onRenameSect onDeleteItem={onDeleteItem} editing={editingItemId === item.id} onToggleEditing={() => setEditingItemId(editingItemId === item.id ? "" : item.id)} + onCloseEditing={() => setEditingItemId("")} /> ))} @@ -127,7 +128,8 @@ export function ChecklistSection({ section, context, data, reorder, onRenameSect ); } -function ChecklistItem({ item, section, context, reorder, items, save, onUpdateItem, onMoveItemToCategory, onDeleteItem, editing, onToggleEditing }) { +function ChecklistItem({ item, section, context, reorder, items, save, onUpdateItem, onMoveItemToCategory, onDeleteItem, editing, onToggleEditing, onCloseEditing }) { + const itemRef = useRef(null); const done = context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget; const textContent = context.moduleText?.checklist || {}; const className = [ @@ -149,6 +151,15 @@ function ChecklistItem({ item, section, context, reorder, items, save, onUpdateI onCommit: (title) => onMoveItemToCategory(item.id, title) }); + useEffect(() => { + if (!editing) return undefined; + function handleOutsidePointerDown(event) { + if (!itemRef.current?.contains(event.target)) onCloseEditing(); + } + document.addEventListener("pointerdown", handleOutsidePointerDown); + return () => document.removeEventListener("pointerdown", handleOutsidePointerDown); + }, [editing, onCloseEditing]); + function updateItem(updater) { save(items.map((entry) => entry.id === item.id ? updater(entry) : entry)); } @@ -161,7 +172,7 @@ function ChecklistItem({ item, section, context, reorder, items, save, onUpdateI } return ( -
  • +
  • - + {editingRateId && ( + + )} + + + +
    +
    +
    +

    {textContent.ratesTitle || "Taux enregistrés"}

    +
    + {data.rates.length} +
    +
    + {data.rates.length ? data.rates.map((rate) => ( +
    + +
    + +
    +
    + )) :

    {textContent.emptyRates || "Aucun taux enregistré."}

    } +
    +
    + + +
    +
    +
    +

    {activeRate?.label || textContent.noActiveRateTitle || "Aucun taux actif"}

    +
    + {activeRate && {activeRate.rate} %} +
    + {activeRate && summary ? ( + <> +
    + {textContent.probabilitySectionTitle || "Probabilités avec multiplicateur"} +
    + {textContent.multiplierLabel || "Multiplicateur"} +
    + updateMultiplier(event.target.value)} onBlur={blurMultiplier} aria-label={textContent.multiplierLabel || "Multiplicateur"} /> + % + +
    +
    +
    + + +
    +
    +
    + {textContent.averageTimeTitle || "Temps moyen avant réussite"} + {textContent.occurrenceCountLabel || "Nombre d'occurrences"} +
    + + + +
    +
    + + + +
    +
    +
    + {textContent.chanceTargetsTitle || "Tirages pour atteindre une chance cumulée"} +
    + {CHANCE_TARGETS.map((target) => ( + + ))} +
    +
    + + ) :

    {textContent.emptySummary || "Ajoutez un taux pour afficher les calculs."}

    } +
    + + +
    +
    +
    +

    {textContent.simulationTitle || "Résultats simulés"}

    +
    +
    +
    +
    + + +
    + +
    +
    + {history.length ? history.map((entry, index) => ( +
    +
    + {entry.label} +
    +
    + {entry.rate} % + {textContent.multiplierShortLabel || "Bonus"} {formatSignedPercent(entry.multiplier ?? 0)} +
    +
    + {textContent.successesLabel || "Réussites"}{entry.successes}/{entry.draws} + {textContent.failsLabel || "Fails"}{entry.fails} + {textContent.observedRateLabel || "Taux de réussite"}{formatPercent((entry.successes / entry.draws) * 100, 6)} + {textContent.longestFailStreakLabel || "Pire série"}{entry.longestFailStreak} +
    +
    + )) :

    {textContent.emptyHistory || "Aucune simulation."}

    } +
    +
    + + + ); +} + +function RngStat({ label, value, title }) { + return ( +
    + {label} + {value} +
    + ); +} diff --git a/website/src/features/toolboxes/modules/rngSimulatorUtils.js b/website/src/features/toolboxes/modules/rngSimulatorUtils.js new file mode 100644 index 0000000..c2cc58e --- /dev/null +++ b/website/src/features/toolboxes/modules/rngSimulatorUtils.js @@ -0,0 +1,138 @@ +// Rôle : fournit les calculs probabilistes et simulations pour l'outil simulateur de chance. +export const RNG_SIMULATOR_HISTORY_LIMIT = 20; +export const RNG_SIMULATOR_DEFAULT_RATE = "2.500"; +export const RNG_SIMULATOR_LIMITS = { + rateMax: 100, + multiplierMin: -1000000, + multiplierMax: 1000000, + simulationDrawsMin: 1, + simulationDrawsMax: 1000000, + occurrenceRateMin: 0, + occurrenceRateMax: 1000000000 +}; + +export function clampInteger(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 clampDecimal(value, min, max, fallback = min) { + const raw = String(value ?? "").trim(); + if (!raw) return fallback; + const parsed = Number(raw.replace(",", ".")); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(max, Math.max(min, parsed)); +} + +export function normalizeRateString(value, fallback = RNG_SIMULATOR_DEFAULT_RATE) { + const raw = String(value ?? "").trim().replace(",", ".").replace(/%$/, "").trim(); + if (!raw) return fallback; + if (!/^\d*(?:\.\d+)?$/.test(raw) || raw === ".") return fallback; + + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + if (parsed > RNG_SIMULATOR_LIMITS.rateMax) return "100"; + + const [integerPart = "0", decimalPart] = raw.split("."); + const cleanInteger = String(Number.parseInt(integerPart || "0", 10)); + if (decimalPart !== undefined) return `${cleanInteger}.${decimalPart}`; + return cleanInteger; +} + +export function isValidRateString(value) { + const raw = String(value ?? "").trim().replace(",", ".").replace(/%$/, "").trim(); + if (!raw || !/^\d*(?:\.\d+)?$/.test(raw) || raw === ".") return false; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 && parsed <= RNG_SIMULATOR_LIMITS.rateMax; +} + +export function getEffectiveRate(rate, multiplier = 0) { + const baseRate = Number(normalizeRateString(rate)); + const cleanMultiplier = clampDecimal(multiplier, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0); + if (!Number.isFinite(baseRate) || baseRate <= 0) return 0; + const factor = Math.max(0, 1 + cleanMultiplier / 100); + return Math.min(RNG_SIMULATOR_LIMITS.rateMax, baseRate * factor); +} + +export function calculateAtLeastOneProbability(rate, draws, multiplier = 0) { + const probability = getEffectiveRate(rate, multiplier) / 100; + const cleanDraws = clampInteger(draws, 0, Number.MAX_SAFE_INTEGER, 0); + if (probability <= 0 || cleanDraws <= 0) return 0; + if (probability >= 1) return 1; + return -Math.expm1(cleanDraws * Math.log1p(-probability)); +} + +export function calculateDrawsForChance(rate, targetChance, multiplier = 0) { + const probability = getEffectiveRate(rate, multiplier) / 100; + const target = clampDecimal(targetChance, 0, 1, 0); + if (probability <= 0 || target <= 0) return 0; + if (probability >= 1) return 1; + if (target >= 1) return Number.POSITIVE_INFINITY; + return Math.ceil(Math.log1p(-target) / Math.log1p(-probability)); +} + +export function calculateRngSummary({ rate, multiplier = 0, simulationDraws, perHour, perDay, perMonth }) { + const effectiveRate = getEffectiveRate(rate, multiplier); + const probability = effectiveRate / 100; + const averageDraws = probability > 0 ? 1 / probability : 0; + const cleanPerHour = clampDecimal(perHour, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0); + const cleanPerDay = clampDecimal(perDay, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0); + const cleanPerMonth = clampDecimal(perMonth, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0); + let averageHours = 0; + if (cleanPerHour > 0) averageHours = averageDraws / cleanPerHour; + else if (cleanPerDay > 0) averageHours = (averageDraws / cleanPerDay) * 24; + else if (cleanPerMonth > 0) averageHours = (averageDraws / cleanPerMonth) * 24 * 30; + + const explicitHourTime = cleanPerHour > 0 && averageDraws > 0 ? averageDraws / cleanPerHour : 0; + const explicitDayTime = cleanPerDay > 0 && averageDraws > 0 ? averageDraws / cleanPerDay : 0; + const explicitMonthTime = cleanPerMonth > 0 && averageDraws > 0 ? averageDraws / cleanPerMonth : 0; + + return { + probability, + effectiveRate, + averageDraws, + chanceForSimulationDraws: calculateAtLeastOneProbability(rate, simulationDraws, multiplier), + timeByFrequency: { + hour: explicitHourTime || averageHours, + day: explicitDayTime || (averageHours > 0 ? averageHours / 24 : 0), + month: explicitMonthTime || (averageHours > 0 ? averageHours / 24 / 30 : 0) + }, + drawsForChance: { + 50: calculateDrawsForChance(rate, 0.5, multiplier), + 90: calculateDrawsForChance(rate, 0.9, multiplier), + 95: calculateDrawsForChance(rate, 0.95, multiplier), + 99: calculateDrawsForChance(rate, 0.99, multiplier) + } + }; +} + +export function simulateRngDraws(rate, draws, random = Math.random, multiplier = 0) { + const probability = getEffectiveRate(rate, multiplier) / 100; + const cleanDraws = clampInteger(draws, RNG_SIMULATOR_LIMITS.simulationDrawsMin, RNG_SIMULATOR_LIMITS.simulationDrawsMax, 1); + let successes = 0; + let currentFailStreak = 0; + let longestFailStreak = 0; + + for (let index = 0; index < cleanDraws; index += 1) { + if (random() < probability) { + successes += 1; + currentFailStreak = 0; + } else { + currentFailStreak += 1; + longestFailStreak = Math.max(longestFailStreak, currentFailStreak); + } + } + + return { + draws: cleanDraws, + successes, + fails: cleanDraws - successes, + observedRate: cleanDraws > 0 ? (successes / cleanDraws) * 100 : 0, + longestFailStreak + }; +} + +export function addRngSimulationHistory(history, entry) { + return [entry, ...(Array.isArray(history) ? history : [])].slice(0, RNG_SIMULATOR_HISTORY_LIMIT); +} diff --git a/website/src/features/toolboxes/modules/textImport.js b/website/src/features/toolboxes/modules/textImport.js index 82fae8b..e4667c1 100644 --- a/website/src/features/toolboxes/modules/textImport.js +++ b/website/src/features/toolboxes/modules/textImport.js @@ -20,6 +20,7 @@ import { exportEquipmentPlannerText, importEquipmentPlannerText } from "./textIm 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 { exportRngSimulatorText, importRngSimulatorText } from "./textImport/rngSimulator.js"; import { fail, parseColonImportLines } from "./textImport/shared.js"; import { exportCalendarText, exportTimerText, importCalendarText, importTimerText } from "./textImport/time.js"; @@ -56,6 +57,11 @@ const TEXT_EXCHANGE_HANDLERS = { exportText: exportRandomizerText, importText: importRandomizerText }, + rngSimulator: { + hasContent: (data) => Boolean(data?.rates?.length), + exportText: exportRngSimulatorText, + importText: importRngSimulatorText + }, combos: { hasContent: (data) => Boolean(data?.combos?.length), exportText: exportCombosText, diff --git a/website/src/features/toolboxes/modules/textImport/rngSimulator.js b/website/src/features/toolboxes/modules/textImport/rngSimulator.js new file mode 100644 index 0000000..de896fd --- /dev/null +++ b/website/src/features/toolboxes/modules/textImport/rngSimulator.js @@ -0,0 +1,66 @@ +// Rôle : gère l'import/export texte des configurations du simulateur de chance. +import { lineError, parseInteger, parseMetaLine, parsePipeParts, splitLines } from "./shared.js"; + +export function exportRngSimulatorText(data) { + const lines = []; + lines.push("@activeRateId: " + (data.activeRateId || "")); + lines.push("[rates]"); + (data.rates || []).forEach((rate) => { + lines.push(`- ${rate.label} | rate=${rate.rate}${rate.id === data.activeRateId ? " | active=true" : ""}`); + }); + lines.push( + "", + "[settings]", + `multiplier: ${data.settings?.multiplier ?? 0}`, + `simulationDraws: ${data.settings?.simulationDraws || 1000}`, + `perHour: ${data.settings?.perHour || 0}`, + `perDay: ${data.settings?.perDay || 0}`, + `perMonth: ${data.settings?.perMonth || 0}` + ); + return lines.join("\n").trim(); +} + +export function importRngSimulatorText(text, context) { + const data = { + activeRateId: "", + rates: [], + settings: { multiplier: 0, simulationDraws: 1000, perHour: 0, perDay: 0, perMonth: 0 } + }; + let section = ""; + + splitLines(text).forEach((rawLine, index) => { + const cleanLine = rawLine.trim(); + if (!cleanLine) return; + const meta = parseMetaLine(cleanLine); + if (meta?.key === "activeRateId") { + data.activeRateId = 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("-")) { + const key = cleanLine.slice(0, separatorIndex).trim(); + const value = cleanLine.slice(separatorIndex + 1).trim(); + if (section === "settings" && key === "multiplier") data.settings.multiplier = value; + if (section === "settings" && key === "simulationDraws") data.settings.simulationDraws = parseInteger(value, 1000); + if (section === "settings" && key === "perHour") data.settings.perHour = value; + if (section === "settings" && key === "perDay") data.settings.perDay = value; + if (section === "settings" && key === "perMonth") data.settings.perMonth = value; + return; + } + if (section === "rates" && cleanLine.startsWith("-")) { + const { title, meta: pipeMeta } = parsePipeParts(cleanLine.replace(/^[-*]\s*/, "")); + if (!title) throw lineError(index, "libellé de taux manquant."); + const id = context.uid("rng"); + data.rates.push({ id, label: title, rate: pipeMeta.rate }); + if (pipeMeta.active === "true") data.activeRateId = id; + return; + } + throw lineError(index, `ligne Simulateur de chance invalide: ${cleanLine}`); + }); + + return context.normalizeRngSimulatorData(data); +} diff --git a/website/src/features/toolboxes/modules/toolboxModuleComponents.jsx b/website/src/features/toolboxes/modules/toolboxModuleComponents.jsx index 22e39b2..7e44c30 100644 --- a/website/src/features/toolboxes/modules/toolboxModuleComponents.jsx +++ b/website/src/features/toolboxes/modules/toolboxModuleComponents.jsx @@ -11,6 +11,7 @@ import { LinksModule } from "./LinksModule.jsx"; import { MemosModule } from "./MemosModule.jsx"; import { NotepadModule } from "./NotepadModule.jsx"; import { RandomizerModule } from "./RandomizerModule.jsx"; +import { RngSimulatorModule } from "./RngSimulatorModule.jsx"; import { TableModule } from "./TableModule.jsx"; import { TaskPlannerModule } from "./TaskPlannerModule.jsx"; import { TimerModule } from "./TimerModule.jsx"; @@ -28,6 +29,7 @@ export const MODULE_COMPONENTS = { links: { ...TOOLBOX_MODULE_DEFINITIONS.links, Component: LinksModule }, counters: { ...TOOLBOX_MODULE_DEFINITIONS.counters, Component: CountersModule }, randomizer: { ...TOOLBOX_MODULE_DEFINITIONS.randomizer, Component: RandomizerModule }, + rngSimulator: { ...TOOLBOX_MODULE_DEFINITIONS.rngSimulator, Component: RngSimulatorModule }, combos: { ...TOOLBOX_MODULE_DEFINITIONS.combos, Component: CombosModule }, calculator: { ...TOOLBOX_MODULE_DEFINITIONS.calculator, Component: CalculatorModule }, calendar: { ...TOOLBOX_MODULE_DEFINITIONS.calendar, Component: CalendarModule }, diff --git a/website/src/features/toolboxes/storage/modules/index.js b/website/src/features/toolboxes/storage/modules/index.js index 4161691..7c1171c 100644 --- a/website/src/features/toolboxes/storage/modules/index.js +++ b/website/src/features/toolboxes/storage/modules/index.js @@ -11,6 +11,7 @@ import { compactLinksDataForStorage } from "./links.js"; import { compactMemosDataForStorage } from "./memos.js"; import { compactNotepadDataForStorage } from "./notepad.js"; import { compactRandomizerDataForStorage } from "./randomizer.js"; +import { compactRngSimulatorDataForStorage } from "./rngSimulator.js"; import { compactTableDataForStorage } from "./table.js"; import { compactTaskPlannerDataForStorage } from "./taskPlanner.js"; import { compactTimerDataForStorage } from "./timer.js"; @@ -24,6 +25,7 @@ export const MODULE_DATA_COMPACTORS = { links: compactLinksDataForStorage, counters: compactCountersDataForStorage, randomizer: compactRandomizerDataForStorage, + rngSimulator: compactRngSimulatorDataForStorage, combos: compactCombosDataForStorage, calculator: compactCalculatorDataForStorage, calendar: compactCalendarDataForStorage, diff --git a/website/src/features/toolboxes/storage/modules/rngSimulator.js b/website/src/features/toolboxes/storage/modules/rngSimulator.js new file mode 100644 index 0000000..2387086 --- /dev/null +++ b/website/src/features/toolboxes/storage/modules/rngSimulator.js @@ -0,0 +1,55 @@ +// Rôle : normalise et compacte les données persistées de l'outil simulateur de chance. +import { + RNG_SIMULATOR_LIMITS, + clampDecimal, + clampInteger, + normalizeRateString +} from "../../modules/rngSimulatorUtils.js"; +import { uid } from "./shared.js"; + +function normalizeRateEntry(entry) { + const label = String(entry?.label || "").trim(); + if (!label) return null; + return { + id: entry?.id || uid("rng"), + label, + rate: normalizeRateString(entry?.rate) + }; +} + +function normalizeSettings(settings) { + return { + multiplier: clampDecimal(settings?.multiplier, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0), + simulationDraws: clampInteger(settings?.simulationDraws, RNG_SIMULATOR_LIMITS.simulationDrawsMin, RNG_SIMULATOR_LIMITS.simulationDrawsMax, 1000), + perHour: clampDecimal(settings?.perHour, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0), + perDay: clampDecimal(settings?.perDay, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0), + perMonth: clampDecimal(settings?.perMonth, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0) + }; +} + +export function normalizeRngSimulatorData(data) { + const rates = (Array.isArray(data?.rates) ? data.rates : []) + .map(normalizeRateEntry) + .filter(Boolean); + const activeRateId = rates.some((rate) => rate.id === data?.activeRateId) ? data.activeRateId : rates[0]?.id || ""; + return { + activeRateId, + rates, + settings: normalizeSettings(data?.settings) + }; +} + +export function compactRngSimulatorDataForStorage(value) { + const normalized = normalizeRngSimulatorData(value); + const compact = {}; + if (normalized.activeRateId) compact.activeRateId = normalized.activeRateId; + if (normalized.rates.length) compact.rates = normalized.rates; + if ( + normalized.settings.simulationDraws !== 1000 || + normalized.settings.multiplier !== 0 || + normalized.settings.perHour !== 0 || + normalized.settings.perDay !== 0 || + normalized.settings.perMonth !== 0 + ) compact.settings = normalized.settings; + return Object.keys(compact).length ? compact : null; +} diff --git a/website/src/features/toolboxes/storage/modules/shared.js b/website/src/features/toolboxes/storage/modules/shared.js index 4c0ed5f..386d127 100644 --- a/website/src/features/toolboxes/storage/modules/shared.js +++ b/website/src/features/toolboxes/storage/modules/shared.js @@ -25,7 +25,8 @@ export const ID_PREFIXES = { socketItem: "j", socketLink: "u", socketBonus: "b", - randomizer: "x" + randomizer: "x", + rng: "f" }; const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; diff --git a/website/src/features/toolboxes/storage/toolboxStorage.js b/website/src/features/toolboxes/storage/toolboxStorage.js index 1a6d9a0..9116ca9 100644 --- a/website/src/features/toolboxes/storage/toolboxStorage.js +++ b/website/src/features/toolboxes/storage/toolboxStorage.js @@ -16,6 +16,7 @@ export { hostnameFromUrl, normalizeLinksData, normalizeUrl } from "./modules/lin export { normalizeMemosData } from "./modules/memos.js"; export { normalizeNotepadData, sanitizeNotepadHtml } from "./modules/notepad.js"; export { normalizeRandomizerData } from "./modules/randomizer.js"; +export { normalizeRngSimulatorData } from "./modules/rngSimulator.js"; export { uid } from "./modules/shared.js"; export { normalizeTableData } from "./modules/table.js"; export { normalizeTaskPlannerData } from "./modules/taskPlanner.js"; @@ -238,6 +239,16 @@ function remapModuleDataForExport(type, data, nextId) { return remapped; } + if (type === "rngSimulator") { + const remapped = { ...compact }; + if (compact.rates) remapped.rates = compact.rates.map((rate) => ({ ...rate, id: nextId("rng") })); + if (compact.activeRateId && compact.rates) { + const activeIndex = compact.rates.findIndex((rate) => rate.id === compact.activeRateId); + remapped.activeRateId = activeIndex >= 0 ? remapped.rates[activeIndex].id : ""; + } + return remapped; + } + if (type === "memos") { const remapped = {}; if (compact.tickerEnabled === false) remapped.tickerEnabled = false; diff --git a/website/src/features/toolboxes/toolboxModuleRegistry.js b/website/src/features/toolboxes/toolboxModuleRegistry.js index 9ca79a7..eef24cf 100644 --- a/website/src/features/toolboxes/toolboxModuleRegistry.js +++ b/website/src/features/toolboxes/toolboxModuleRegistry.js @@ -65,6 +65,16 @@ export const TOOLBOX_MODULE_DEFINITIONS = { scrollable: true, libraryCategory: "calculationData" }, + rngSimulator: { + label: "Simulateur de chance", + defaultTitle: "Simulateur de chance", + mode: "Taux + simulations", + icon: "clover", + editable: true, + addable: false, + scrollable: true, + libraryCategory: "calculationData" + }, combos: { label: "Combos", defaultTitle: "Combos", @@ -156,7 +166,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", "randomizer"] }, + { key: "calculationData", types: ["calculator", "table", "goalTree", "randomizer", "rngSimulator"] }, { key: "timeRoutines", types: ["timer", "calendar", "taskPlanner"] }, { key: "buildsCommands", types: ["combos", "equipmentPlanner"] } ]; diff --git a/website/src/pages/LibraryPage.jsx b/website/src/pages/LibraryPage.jsx index 8a0a3a5..b5cf59f 100644 --- a/website/src/pages/LibraryPage.jsx +++ b/website/src/pages/LibraryPage.jsx @@ -17,6 +17,7 @@ import { normalizeMemosData, normalizeNotepadData, normalizeRandomizerData, + normalizeRngSimulatorData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, @@ -43,7 +44,8 @@ const MODULE_COMPONENTS = { 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 }))), - randomizer: lazy(() => import("../features/toolboxes/modules/RandomizerModule.jsx").then((module) => ({ default: module.RandomizerModule }))) + randomizer: lazy(() => import("../features/toolboxes/modules/RandomizerModule.jsx").then((module) => ({ default: module.RandomizerModule }))), + rngSimulator: lazy(() => import("../features/toolboxes/modules/RngSimulatorModule.jsx").then((module) => ({ default: module.RngSimulatorModule }))) }; function cloneData(value) { @@ -166,6 +168,7 @@ export function LibraryPage({ siteContent, actions }) { normalizeImageAnnotationData, normalizeNotepadData, normalizeRandomizerData, + normalizeRngSimulatorData, normalizeTableData, normalizeTimerData, normalizeTaskPlannerData, diff --git a/website/src/styles/design-system/_attributes.scss b/website/src/styles/design-system/_attributes.scss index b46b0b7..754eb85 100644 --- a/website/src/styles/design-system/_attributes.scss +++ b/website/src/styles/design-system/_attributes.scss @@ -1066,7 +1066,7 @@ } .nds-scrollbox { - max-height: 150px; + max-height: 400px; overflow: auto; padding: 12px; border: 1px solid rgba(165, 180, 252, 0.12); diff --git a/website/src/styles/icons/_toolbox.scss b/website/src/styles/icons/_toolbox.scss index a52b75f..9369762 100644 --- a/website/src/styles/icons/_toolbox.scss +++ b/website/src/styles/icons/_toolbox.scss @@ -24,6 +24,11 @@ -webkit-mask-image: url("/static/icons/checklist.svg"); } +.ui-icon-clover { + mask-image: url("/static/icons/clover.svg"); + -webkit-mask-image: url("/static/icons/clover.svg"); +} + .ui-icon-diagram { mask-image: url("/static/icons/diagram.svg"); -webkit-mask-image: url("/static/icons/diagram.svg"); diff --git a/website/src/styles/toolboxes/_checklist.scss b/website/src/styles/toolboxes/_checklist.scss index fb60a14..6373b4a 100644 --- a/website/src/styles/toolboxes/_checklist.scss +++ b/website/src/styles/toolboxes/_checklist.scss @@ -145,6 +145,17 @@ grid-template-columns: 1fr 34px 34px; gap: 8px; align-items: center; + min-width: 0; + padding: 3px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: rgba(5, 7, 17, 0.44); + transition: border-color 160ms ease, background 160ms ease, box-shadow 160ms ease; +} + +.checklist-item:hover { + border-color: rgba(196, 181, 253, 0.22); + background: rgba(10, 13, 30, 0.54); } .checklist-item-main { @@ -153,15 +164,15 @@ min-height: 42px; align-items: center; gap: 10px; - padding: 0 12px; - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - background: rgba(5, 7, 17, 0.44); + padding: 0 9px; + border: 0; + border-radius: var(--radius-sm); + background: transparent; } +.checklist-item:hover .checklist-item-main, .checklist-item-main:hover { - border-color: rgba(196, 181, 253, 0.22); - background: rgba(10, 13, 30, 0.54); + background: transparent; } .checklist-item-main.is-editing { @@ -182,7 +193,7 @@ border-color: rgba(246, 196, 83, 0.72); } -.checklist-item.is-dragging .checklist-item-main { +.checklist-item.is-dragging { opacity: 0.58; } @@ -223,6 +234,14 @@ box-shadow: none; } +.checklist .checklist-item-summary:hover:not(:disabled, .active, .is-active, [aria-pressed="true"]) { + border-color: transparent; + background: transparent; + background-color: transparent; + background-image: none; + box-shadow: none; +} + .checklist-item-summary span { min-width: 0; overflow: hidden; diff --git a/website/src/styles/toolboxes/_core.scss b/website/src/styles/toolboxes/_core.scss index 4ebcaa3..e633465 100644 --- a/website/src/styles/toolboxes/_core.scss +++ b/website/src/styles/toolboxes/_core.scss @@ -11,6 +11,7 @@ @use "combos"; @use "counters"; @use "randomizer"; +@use "rng-simulator"; @use "calculator"; @use "goal-tree"; @use "timer"; diff --git a/website/src/styles/toolboxes/_module-shell.scss b/website/src/styles/toolboxes/_module-shell.scss index 02c77aa..02724d4 100644 --- a/website/src/styles/toolboxes/_module-shell.scss +++ b/website/src/styles/toolboxes/_module-shell.scss @@ -249,6 +249,11 @@ -webkit-mask-image: url("/static/icons/dice.svg"); } +.module-icon-clover { + mask-image: url("/static/icons/clover.svg"); + -webkit-mask-image: url("/static/icons/clover.svg"); +} + .module-icon-wheel { mask-image: url("/static/icons/wheel.svg"); -webkit-mask-image: url("/static/icons/wheel.svg"); diff --git a/website/src/styles/toolboxes/_rng-simulator.scss b/website/src/styles/toolboxes/_rng-simulator.scss new file mode 100644 index 0000000..0a7b0be --- /dev/null +++ b/website/src/styles/toolboxes/_rng-simulator.scss @@ -0,0 +1,414 @@ +// Rôle : porte les surfaces et grilles propres au simulateur de chance. +.rng-simulator-tool { + display: grid; + gap: var(--space-4); + padding: var(--space-4); +} + +.rng-simulator-grid { + display: grid; + grid-template-columns: minmax(260px, 0.9fr) minmax(0, 1.4fr); + gap: var(--space-4); + align-items: start; + min-width: 0; +} + +.rng-simulator-panel { + display: grid; + gap: 10px; + min-width: 0; + padding: 10px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: rgba(16, 20, 38, 0.48); +} + +.rng-simulator-panel > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + min-width: 0; + min-height: 0; + border-bottom: 1px solid rgba(150, 165, 205, 0.12); + text-align: left; +} + +.rng-simulator-panel > header > div:first-child { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.rng-simulator-panel h3 { + margin: 0; + color: var(--color-text-primary); + font-size: var(--font-size-sm); + line-height: 1.2; + text-align: left; +} + +.rng-simulator-count, +.rng-simulator-active-rate { + flex: 0 0 auto; + padding: 4px 8px; + border: 1px solid rgba(246, 196, 83, 0.28); + border-radius: var(--radius-sm); + color: var(--color-accent-gold); + font-size: var(--font-size-xs); + font-weight: 900; + font-variant-numeric: tabular-nums; +} + +.rng-simulator-rates { + display: grid; + gap: 6px; + max-height: 220px; + min-height: 72px; + padding-right: 4px; +} + +.rng-simulator-rates-stack { + display: grid; + gap: var(--space-4); + min-width: 0; +} + +.rng-simulator-rate-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0; + align-items: stretch; + min-width: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: rgba(11, 14, 27, 0.56); + overflow: hidden; +} + +.rng-simulator-rate-row:hover { + border-color: rgba(196, 181, 253, 0.24); + background: rgba(10, 13, 30, 0.68); +} + +.rng-simulator-rate-row.is-active { + border-color: rgba(246, 196, 83, 0.48); + background: rgba(246, 196, 83, 0.08); +} + +.rng-simulator-rate-row.is-active:hover { + border-color: rgba(246, 196, 83, 0.62); + background: rgba(246, 196, 83, 0.12); +} + +.rng-simulator-rate-row > button { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + min-width: 0; + padding: 7px 8px; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.rng-simulator-rate-row > button:hover { + background: transparent; +} + +.rng-simulator-rate-row strong, +.rng-simulator-history-row strong { + overflow: hidden; + color: var(--color-text-primary); + text-overflow: ellipsis; + white-space: nowrap; +} + +.rng-simulator-rate-row span, +.rng-simulator-history-row span, +.rng-simulator-history-row small, +.rng-simulator-empty { + color: var(--color-text-secondary); + font-size: var(--font-size-xs); +} + +.rng-simulator-rate-actions { + display: flex; + align-items: center; + gap: 3px; + padding: 3px; +} + +.rng-simulator-rate-form, +.rng-simulator-settings { + display: grid; + gap: var(--space-3); +} + +.rng-simulator-settings { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.rng-simulator-three-columns { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.rng-simulator-section { + display: grid; + gap: var(--space-3); + min-width: 0; + padding-top: var(--space-3); + border-top: 1px solid rgba(150, 165, 205, 0.1); +} + +.rng-simulator-summary-panel > header + .rng-simulator-section { + padding-top: var(--space-2); + border-top: 0; +} + +.rng-simulator-section-title { + color: var(--color-text-primary); + font-size: var(--font-size-sm); + font-weight: 900; +} + +.rng-simulator-subtitle { + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: 900; + text-transform: uppercase; +} + +.rng-simulator-field { + display: grid; + gap: 6px; + min-width: 0; + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: 800; +} + +.rng-simulator-field > .nds-input { + width: 100%; + min-width: 0; +} + +.rng-simulator-percent-input { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 6px; + align-items: center; +} + +.rng-simulator-percent-input > span { + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: 900; +} + +.rng-simulator-error { + margin: 0; + color: #fca5a5; + font-size: var(--font-size-xs); + font-weight: 800; +} + +.rng-simulator-form-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: end; + justify-content: flex-end; +} + +.rng-simulator-stat-grid, +.rng-simulator-targets, +.rng-simulator-time-grid { + display: grid; + gap: 8px; +} + +.rng-simulator-stat-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.rng-simulator-targets { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.rng-simulator-targets .rng-simulator-stat strong { + overflow-wrap: normal; + word-break: normal; +} + +.rng-simulator-time-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.rng-simulator-stat { + display: grid; + gap: 5px; + min-width: 0; + padding: 10px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: rgba(5, 7, 17, 0.32); +} + +.rng-simulator-stat span { + overflow: hidden; + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rng-simulator-stat strong { + overflow-wrap: anywhere; + color: var(--color-text-primary); + font-size: var(--font-size-sm); + font-variant-numeric: tabular-nums; +} + +.rng-simulator-simulation-panel { + gap: var(--space-3); +} + +.rng-simulator-simulation-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + padding-bottom: var(--space-3); + border-bottom: 1px solid rgba(150, 165, 205, 0.12); +} + +.rng-simulator-simulation-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + min-width: 0; +} + +.rng-simulator-inline-field { + display: flex; + align-items: center; + gap: 8px; +} + +.rng-simulator-inline-field > span { + flex: 0 0 auto; +} + +.rng-simulator-inline-field > .nds-input { + width: 150px; +} + +.rng-simulator-history { + display: grid; + gap: 8px; + padding-right: 4px; +} + +.rng-simulator-history-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px 12px; + align-items: center; + min-width: 0; + padding: 10px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: rgba(11, 14, 27, 0.52); +} + +.rng-simulator-history-row.is-latest { + border-color: rgba(139, 92, 246, 0.38); + background: rgba(139, 92, 246, 0.1); +} + +.rng-simulator-history-row > div { + display: grid; + gap: 2px; + min-width: 0; +} + +.rng-simulator-history-rate { + justify-items: end; + text-align: right; +} + +.rng-simulator-history-row b, +.rng-simulator-history-rate > span, +.rng-simulator-history-metrics b { + color: var(--color-accent-gold); + font-weight: 900; + font-variant-numeric: tabular-nums; +} + +.rng-simulator-history-metrics { + display: grid; + grid-column: 1 / -1; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.rng-simulator-history-metrics > span { + display: grid; + gap: 2px; + min-width: 0; + padding: 8px; + border: 1px solid rgba(150, 165, 205, 0.12); + border-radius: var(--radius-sm); + background: rgba(5, 7, 17, 0.24); +} + +.rng-simulator-history-metrics small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rng-simulator-empty { + margin: 0; +} + +@media (max-width: 820px) { + .rng-simulator-grid, + .rng-simulator-settings, + .rng-simulator-three-columns, + .rng-simulator-stat-grid, + .rng-simulator-targets, + .rng-simulator-time-grid { + grid-template-columns: 1fr; + } + + .rng-simulator-history-row { + grid-template-columns: 1fr; + } + + .rng-simulator-history-rate { + justify-items: start; + text-align: left; + } + + .rng-simulator-history-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .rng-simulator-simulation-bar { + justify-content: flex-start; + } + + .rng-simulator-simulation-actions { + width: 100%; + } +}