diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index 997535f..0c5da89 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -819,10 +819,10 @@ Type : `rngSimulator` "id": "rate1", "label": "Drop rare", "rate": "2.500", + "costPerDraw": "150", "multiplier": 50, - "perHour": 30, - "perDay": 120, - "perMonth": 3600 + "occurrenceValue": 30, + "occurrenceUnit": "perHour" }, { "id": "rate2", @@ -841,14 +841,29 @@ 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"`. +- `costPerDraw` est une chaîne décimale optionnelle, associée au taux, utilisée comme unité libre pour les calculs de coût. - `activeRateId` référence un taux existant; sinon le premier taux est sélectionné. - `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. +- `rates[].occurrenceValue` est numérique, positif ou nul, associé au taux, et vaut 0 par défaut. +- `rates[].occurrenceUnit` vaut `perHour`, `perDay` ou `perMonth`, et vaut `perHour` par défaut. - `settings.simulationDraws` est borné de 1 à 1 000 000 et vaut 1000 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. +- `rates[].costPerDraw` est omis si vide ou nul. +- `rates[].multiplier` est omis si sa valeur vaut 0. +- `rates[].occurrenceValue` et `rates[].occurrenceUnit` sont omis si `occurrenceValue` vaut 0. - l'historique des simulations n'est pas persisté en IndexedDB; il est limité aux 20 dernières entrées en `sessionStorage`. +Import/export texte : + +```text +@activeRateId: rate1 +[rates] +- Drop rare | rate=2.500 | active=true | costPerDraw=150 | multiplier=50 | occurrenceValue=30 | occurrenceUnit=perHour + +[settings] +simulationDraws: 1000 +``` + ## Outil Combos Type : `combos` diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index 56bbdbf..1eb8b86 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -317,6 +317,8 @@ export function validateSiteContent(site) { "toolboxes.modules.rngSimulator.labelLabel", "toolboxes.modules.rngSimulator.labelPlaceholder", "toolboxes.modules.rngSimulator.rateLabel", + "toolboxes.modules.rngSimulator.costPerDrawLabel", + "toolboxes.modules.rngSimulator.costPerDrawPlaceholder", "toolboxes.modules.rngSimulator.rateLabelRequired", "toolboxes.modules.rngSimulator.rateInvalid", "toolboxes.modules.rngSimulator.addRateButton", @@ -324,21 +326,29 @@ export function validateSiteContent(site) { "toolboxes.modules.rngSimulator.cancelButton", "toolboxes.modules.rngSimulator.deleteTitle", "toolboxes.modules.rngSimulator.noActiveRateTitle", + "toolboxes.modules.rngSimulator.activeRateLabel", + "toolboxes.modules.rngSimulator.effectiveRateLabel", + "toolboxes.modules.rngSimulator.statsTabLabel", + "toolboxes.modules.rngSimulator.simulationTabLabel", "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.simulationChanceWithoutMultiplierLabel", + "toolboxes.modules.rngSimulator.averageCostLabel", "toolboxes.modules.rngSimulator.averageTimeTitle", + "toolboxes.modules.rngSimulator.periodLabel", + "toolboxes.modules.rngSimulator.averageTimeLabel", "toolboxes.modules.rngSimulator.occurrenceCountLabel", "toolboxes.modules.rngSimulator.hourTimeLabel", "toolboxes.modules.rngSimulator.dayTimeLabel", "toolboxes.modules.rngSimulator.monthTimeLabel", "toolboxes.modules.rngSimulator.chanceTargetsTitle", + "toolboxes.modules.rngSimulator.chanceLabel", + "toolboxes.modules.rngSimulator.costLabel", "toolboxes.modules.rngSimulator.drawsUnit", "toolboxes.modules.rngSimulator.hoursUnit", "toolboxes.modules.rngSimulator.daysUnit", @@ -351,8 +361,15 @@ export function validateSiteContent(site) { "toolboxes.modules.rngSimulator.successesLabel", "toolboxes.modules.rngSimulator.failsLabel", "toolboxes.modules.rngSimulator.observedRateLabel", + "toolboxes.modules.rngSimulator.expectedSuccessesLabel", "toolboxes.modules.rngSimulator.multiplierShortLabel", "toolboxes.modules.rngSimulator.longestFailStreakLabel", + "toolboxes.modules.rngSimulator.longestSuccessStreakLabel", + "toolboxes.modules.rngSimulator.firstSuccessCostLabel", + "toolboxes.modules.rngSimulator.costPerSuccessLabel", + "toolboxes.modules.rngSimulator.luckySimulationLabel", + "toolboxes.modules.rngSimulator.neutralSimulationLabel", + "toolboxes.modules.rngSimulator.unluckySimulationLabel", "toolboxes.modules.rngSimulator.importPlaceholder", "toolboxes.modules.rngSimulator.importOpenButton", "toolboxes.modules.rngSimulator.exportOpenButton", diff --git a/tests/toolbox-modules.test.mjs b/tests/toolbox-modules.test.mjs index 11d7a4e..f02423e 100644 --- a/tests/toolbox-modules.test.mjs +++ b/tests/toolbox-modules.test.mjs @@ -12,7 +12,7 @@ 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 { calculateAtLeastOneProbability, calculateDrawsForChance, calculateRngCostSummary, calculateRngSummary, getEffectiveRate, normalizeCostString, 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, 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"; @@ -300,8 +300,8 @@ test("rng simulator preserves rate precision and compacts storage", () => { const normalized = normalizeRngSimulatorData({ activeRateId: "rate2", rates: [ - { 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: "rate1", label: "Drop rare", rate: "2.500", costPerDraw: "", multiplier: 25, occurrenceValue: 10, occurrenceUnit: "perHour" }, + { id: "rate2", label: "Ultra rare", rate: "0.000004", costPerDraw: "12,500", multiplier: 50, occurrenceValue: 120, occurrenceUnit: "perDay" }, { id: "bad", label: "", rate: "4" } ], settings: { simulationDraws: 2000 }, @@ -312,11 +312,17 @@ 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.deepEqual(normalized.rates.map((rate) => rate.costPerDraw), ["", "12.500"]); + assert.deepEqual(normalized.rates.map((rate) => [rate.multiplier, rate.occurrenceValue, rate.occurrenceUnit]), [[25, 10, "perHour"], [50, 120, "perDay"]]); assert.equal(normalized.history, undefined); + assert.equal(normalizeCostString("0"), ""); + assert.equal(normalizeCostString("0012,500"), "12.500"); assert.equal(compactModuleDataForStorage("rngSimulator", normalizeRngSimulatorData({})), null); + assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[0].costPerDraw, undefined); + assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].costPerDraw, "12.500"); assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].multiplier, 50); - assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].perMonth, 3600); + assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].occurrenceValue, 120); + assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].occurrenceUnit, "perDay"); assert.equal(compactModuleDataForStorage("rngSimulator", normalized).settings.simulationDraws, 2000); assert.equal(compactModuleDataForStorage("rngSimulator", normalized).history, undefined); }); @@ -329,13 +335,24 @@ test("rng simulator calculates probability milestones and deterministic simulati 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 }); + const hourlySummary = calculateRngSummary({ rate: "25", simulationDraws: 8, occurrenceValue: 2, occurrenceUnit: "perHour" }); 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 }); + const dailySummary = calculateRngSummary({ rate: "25", simulationDraws: 8, occurrenceValue: 2, occurrenceUnit: "perDay" }); + assert.equal(dailySummary.timeByFrequency.hour, 48); + assert.equal(dailySummary.timeByFrequency.day, 2); + const multipliedSummary = calculateRngSummary({ rate: "10", multiplier: 100, simulationDraws: 1, occurrenceValue: 0, occurrenceUnit: "perHour" }); assert.equal(multipliedSummary.effectiveRate, 20); assert.equal(multipliedSummary.averageDraws, 5); + const costSummary = calculateRngCostSummary({ costPerDraw: "12,5", averageDraws: 5, drawsForChance: { 50: 4, 90: 12 }, simulationDraws: 8, simulationSuccesses: 2 }); + assert.equal(costSummary.costPerDraw, 12.5); + assert.equal(costSummary.averageSuccessCost, 62.5); + assert.equal(costSummary.targetCosts[50], 50); + assert.equal(costSummary.targetCosts[90], 150); + assert.equal(costSummary.simulationCostPerSuccess, 50); + assert.equal(calculateRngCostSummary({ costPerDraw: "12,5", averageDraws: 5, drawsForChance: {}, simulationDraws: 8, simulationSuccesses: 2, firstSuccessDraw: 3 }).simulationFirstSuccessCost, 37.5); + assert.equal(calculateRngCostSummary({ costPerDraw: "0", averageDraws: 5, drawsForChance: {}, simulationDraws: 8, simulationSuccesses: 2 }), null); const result = simulateRngDraws("25", 8, (() => { const values = [0.1, 0.9, 0.2, 0.8, 0.7, 0.1, 0.6, 0.5]; @@ -348,7 +365,9 @@ test("rng simulator calculates probability milestones and deterministic simulati successes: 3, fails: 5, observedRate: 37.5, - longestFailStreak: 2 + longestFailStreak: 2, + longestSuccessStreak: 1, + firstSuccessDraw: 1 }); const multipliedResult = simulateRngDraws("10", 4, (() => { @@ -357,6 +376,8 @@ test("rng simulator calculates probability milestones and deterministic simulati return () => values[index++]; })(), 100); assert.equal(multipliedResult.successes, 2); + assert.equal(multipliedResult.longestSuccessStreak, 1); + assert.equal(multipliedResult.firstSuccessDraw, 1); }); test("rng simulator text exchange round trips rates and settings", () => { @@ -364,8 +385,8 @@ test("rng simulator text exchange round trips rates and settings", () => { const rngSimulator = normalizeRngSimulatorData({ activeRateId: "rate2", rates: [ - { 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 } + { id: "rate1", label: "Drop rare", rate: "2.500", costPerDraw: "150", multiplier: 25, occurrenceValue: 20, occurrenceUnit: "perHour" }, + { id: "rate2", label: "Ultra rare", rate: "0.000004", costPerDraw: "", multiplier: 50, occurrenceValue: 120, occurrenceUnit: "perDay" } ], settings: { simulationDraws: 5000 } }); @@ -374,11 +395,13 @@ test("rng simulator text exchange round trips rates and settings", () => { 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.label === "Drop rare")?.costPerDraw, "150"); + assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.costPerDraw, ""); assert.equal(imported.data.rates.find((rate) => rate.id === imported.data.activeRateId)?.label, "Ultra rare"); 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.rates.find((rate) => rate.label === "Ultra rare")?.occurrenceValue, 120); + assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.occurrenceUnit, "perDay"); assert.equal(imported.data.settings.simulationDraws, 5000); assert.equal(imported.data.history, undefined); }); diff --git a/website/public/data/library.json b/website/public/data/library.json index d3a446b..aafbbf1 100644 --- a/website/public/data/library.json +++ b/website/public/data/library.json @@ -4771,20 +4771,21 @@ { "id": "fnn88zy", "label": "Elten Piece", - "rate": "0.000004" + "rate": "0.000004", + "costPerDraw": "150", + "multiplier": 320, + "occurrenceValue": 60, + "occurrenceUnit": "perHour" }, { "id": "pen-eklecta", "label": "Pen Eklecta", - "rate": "20.02" + "rate": "20.02", + "costPerDraw": "12.5" } ], "settings": { - "multiplier": 320, - "simulationDraws": 100000, - "perHour": 60, - "perDay": 0, - "perMonth": 0 + "simulationDraws": 100000 } }, "m9": { diff --git a/website/public/data/site.json b/website/public/data/site.json index 8a740ad..18ab69d 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -498,8 +498,17 @@ "icon": "clover", "title": "Enregistrer un taux", "preview": { - "kind": "textButton", - "label": "%" + "kind": "iconButton", + "icon": "add" + } + }, + { + "group": "Taux", + "icon": "edit", + "title": "Modifier un taux", + "preview": { + "kind": "iconButton", + "icon": "edit" } }, { @@ -524,26 +533,26 @@ "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.", + "text": "Chaque taux possède un libellé, un coût optionnel 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." + "Le coût d'un tirage permet d'estimer le coût moyen ou simulé sans imposer d'unité." ] }, { "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.", + "text": "L'onglet Calculs affiche la moyenne d'essais avant réussite, le temps moyen selon votre rythme, les coûts 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." + "L'onglet Simulation agrège réussites, échecs, pire série d'échecs et coûts 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 | 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" + "text": "Le format texte contient les sections [rates] et [settings]. Les taux sont exprimés en pourcentage et le coût d'un tirage reste optionnel.", + "example": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true | costPerDraw=150 | multiplier=320 | occurrenceValue=60 | occurrenceUnit=perHour\n- Pen Eklecta | rate=20.02 | costPerDraw=12.5 | multiplier=0\n\n[settings]\nsimulationDraws: 1000" } }, "images": { @@ -2106,6 +2115,8 @@ "labelLabel": "Libellé", "labelPlaceholder": "Drop rare", "rateLabel": "Taux (%)", + "costPerDrawLabel": "Coût d'un tirage", + "costPerDrawPlaceholder": "Optionnel", "rateLabelRequired": "Libellé requis.", "rateInvalid": "Taux invalide. Utilisez une valeur supérieure à 0 et inférieure ou égale à 100.", "addRateButton": "Ajouter", @@ -2113,21 +2124,29 @@ "cancelButton": "Annuler", "deleteTitle": "Supprimer", "noActiveRateTitle": "Aucun taux actif", + "activeRateLabel": "Taux brut", + "effectiveRateLabel": "Taux final", + "statsTabLabel": "Calculs", + "simulationTabLabel": "Simulation", "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", + "averageDrawsLabel": "Moyenne avant réussite", + "simulationChanceLabel": "Chance sur les tirages saisis", + "simulationChanceWithoutMultiplierLabel": "Chance sur les tirages saisis sans multiplicateur", + "averageCostLabel": "Coût moyen avant réussite", "averageTimeTitle": "Temps moyen avant réussite", + "periodLabel": "Période", + "averageTimeLabel": "Temps moyen", "occurrenceCountLabel": "Nombre d'occurrences", "hourTimeLabel": "En heures", "dayTimeLabel": "En jours", "monthTimeLabel": "En mois", "chanceTargetsTitle": "Tirages pour atteindre une chance cumulée", + "chanceLabel": "Chance", + "costLabel": "Coût", "drawsUnit": "tirages", "hoursUnit": "h", "daysUnit": "j", @@ -2140,9 +2159,16 @@ "successesLabel": "Réussites", "failsLabel": "Fails", "observedRateLabel": "Taux de réussite", + "expectedSuccessesLabel": "Réussites attendues", "multiplierShortLabel": "Bonus", - "longestFailStreakLabel": "Pire série", - "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", + "longestFailStreakLabel": "Pire série d'échec", + "longestSuccessStreakLabel": "Réussite consécutive", + "firstSuccessCostLabel": "Coût de la 1ere réussite", + "costPerSuccessLabel": "Coût moyen/réussite", + "luckySimulationLabel": "Au-dessus", + "neutralSimulationLabel": "Dans la cible", + "unluckySimulationLabel": "Sous l'attendu", + "importPlaceholder": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true | costPerDraw=150 | multiplier=320 | occurrenceValue=60 | occurrenceUnit=perHour\n- Pen Eklecta | rate=20.02 | costPerDraw=12.5 | multiplier=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/features/toolboxes/modules/RngSimulatorModule.jsx b/website/src/features/toolboxes/modules/RngSimulatorModule.jsx index 4986cae..3532216 100644 --- a/website/src/features/toolboxes/modules/RngSimulatorModule.jsx +++ b/website/src/features/toolboxes/modules/RngSimulatorModule.jsx @@ -1,6 +1,7 @@ // Rôle : fournit le simulateur de chance pour calculer et simuler des taux enregistrés. -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; +import { Tabs } from "../../../components/Tabs.jsx"; import { useDraftForm } from "../../../hooks/useDraftForm.js"; import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { @@ -8,15 +9,23 @@ import { RNG_SIMULATOR_HISTORY_LIMIT, RNG_SIMULATOR_LIMITS, addRngSimulationHistory, + calculateAtLeastOneProbability, + calculateRngCostSummary, calculateRngSummary, clampDecimal, clampInteger, + getEffectiveRate, isValidRateString, + normalizeCostString, normalizeRateString, simulateRngDraws } from "./rngSimulatorUtils.js"; const CHANCE_TARGETS = [50, 90, 95, 99]; +const RNG_TABS = [ + { id: "stats", label: "Calculs" }, + { id: "simulation", label: "Simulation" } +]; function formatNumber(value, maximumFractionDigits = 2) { if (!Number.isFinite(value)) return "—"; @@ -28,6 +37,11 @@ function formatPercent(value, maximumFractionDigits = 6) { return `${value.toLocaleString("fr-FR", { maximumFractionDigits })} %`; } +function formatCost(value) { + if (!Number.isFinite(value)) return "—"; + return formatNumber(value, 0); +} + function formatDrawTarget(value, unit) { if (!Number.isFinite(value)) return `— ${unit}`; if (value >= 1000000) { @@ -73,14 +87,18 @@ function normalizeSessionHistoryEntry(entry, index) { const successes = clampInteger(entry?.successes, 0, draws, 0); return { id: entry?.id || `rng-session-${index}`, + rateId: String(entry?.rateId || "").trim(), label: String(entry?.label || "").trim() || "Simulation", rate: normalizeRateString(entry?.rate), + costPerDraw: normalizeCostString(entry?.costPerDraw), multiplier: clampDecimal(entry?.multiplier, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0), draws, successes, fails: draws - successes, observedRate: draws > 0 ? (successes / draws) * 100 : 0, - longestFailStreak: clampInteger(entry?.longestFailStreak, 0, draws, 0) + longestFailStreak: clampInteger(entry?.longestFailStreak, 0, draws, 0), + longestSuccessStreak: clampInteger(entry?.longestSuccessStreak, 0, draws, 0), + firstSuccessDraw: clampInteger(entry?.firstSuccessDraw, 0, draws, 0) }; } @@ -94,33 +112,81 @@ function writeSimulationHistory(storageKey, history) { } } -export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeOpen }) { +function historyEntryMatchesRate(entry, rate) { + if (!entry || !rate) return false; + if (entry.rateId) return entry.rateId === rate.id; + return entry.label === rate.label && entry.rate === rate.rate; +} + +export function RngSimulatorModule({ toolboxId, moduleId, context, editing = false, setEditing = () => {}, textExchangeOpen }) { const data = context.normalizeRngSimulatorData(context.getModuleData(toolboxId, moduleId, {})); const textContent = context.moduleText?.rngSimulator || {}; + const tabs = useMemo(() => [ + { ...RNG_TABS[0], label: textContent.statsTabLabel || RNG_TABS[0].label }, + { ...RNG_TABS[1], label: textContent.simulationTabLabel || RNG_TABS[1].label } + ], [textContent.simulationTabLabel, textContent.statsTabLabel]); const historyStorageKey = `rng-simulator:${toolboxId}:${moduleId}:history`; + const [activeTab, setActiveTab] = useState("stats"); const [history, setHistory] = useState(() => readSimulationHistory(historyStorageKey)); const [editingRateId, setEditingRateId] = useState(""); const [multiplierDraft, setMultiplierDraft] = useState(null); - const [occurrenceDrafts, setOccurrenceDrafts] = useState({ perHour: null, perDay: null, perMonth: null }); + const [costDraft, setCostDraft] = useState(null); + const [occurrenceUnit, setOccurrenceUnit] = useState("perHour"); + const [occurrenceDraft, setOccurrenceDraft] = useState(null); + const rateFormRef = useRef(null); + const previousEditingRef = useRef(editing); const rateDraft = useDraftForm({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE }); + const rateFormOpen = editing || Boolean(editingRateId); const activeRate = data.rates.find((rate) => rate.id === data.activeRateId) || data.rates[0] || null; const summary = useMemo(() => activeRate ? calculateRngSummary({ rate: activeRate.rate, multiplier: activeRate.multiplier, simulationDraws: data.settings.simulationDraws, - perHour: activeRate.perHour, - perDay: activeRate.perDay, - perMonth: activeRate.perMonth + occurrenceValue: activeRate.occurrenceValue, + occurrenceUnit: activeRate.occurrenceUnit }) : null, [activeRate, data.settings.simulationDraws]); + const costSummary = useMemo(() => activeRate && summary ? calculateRngCostSummary({ + costPerDraw: costDraft ?? activeRate.costPerDraw, + averageDraws: summary.averageDraws, + drawsForChance: summary.drawsForChance, + simulationDraws: data.settings.simulationDraws, + simulationSuccesses: 0 + }) : null, [activeRate, costDraft, data.settings.simulationDraws, summary]); + const activeHistory = useMemo(() => history.filter((entry) => historyEntryMatchesRate(entry, activeRate)), [activeRate, history]); + const chanceWithoutMultiplier = activeRate ? calculateAtLeastOneProbability(activeRate.rate, data.settings.simulationDraws, 0) : 0; useEffect(() => { setHistory(readSimulationHistory(historyStorageKey)); }, [historyStorageKey]); + useEffect(() => { + const wasEditing = previousEditingRef.current; + if (wasEditing && !editing) setEditingRateId(""); + if (!wasEditing && editing && !editingRateId) rateDraft.reset({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE }); + previousEditingRef.current = editing; + // Le hook useDraftForm expose reset sans identité stable; cette transition ne doit dépendre que de l'ouverture. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editing, editingRateId]); + + useEffect(() => { + if (!rateFormOpen) return undefined; + function closeOnOutsidePointer(event) { + if (event.target.closest?.(".module-edit-button")) return; + if (!rateFormRef.current?.contains(event.target)) { + setEditingRateId(""); + setEditing(false); + } + } + document.addEventListener("pointerdown", closeOnOutsidePointer); + return () => document.removeEventListener("pointerdown", closeOnOutsidePointer); + }, [rateFormOpen, setEditing]); + useEffect(() => { setMultiplierDraft(null); - setOccurrenceDrafts({ perHour: null, perDay: null, perMonth: null }); - }, [activeRate?.id]); + setCostDraft(null); + setOccurrenceDraft(null); + setOccurrenceUnit(activeRate?.occurrenceUnit || "perHour"); + }, [activeRate?.id, activeRate?.occurrenceUnit]); function save(nextData) { context.setModuleData(toolboxId, moduleId, nextData, "rngSimulator"); @@ -158,35 +224,57 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO if (multiplierDraft === null) return; const raw = multiplierDraft.trim(); const parsed = Number(raw.replace(",", ".")); - if (!raw || !Number.isFinite(parsed)) { - setMultiplierDraft(null); - updateActiveRateSettings("multiplier", 0); - return; - } + if (!raw || !Number.isFinite(parsed)) updateActiveRateSettings("multiplier", 0); setMultiplierDraft(null); } - function clearMultiplier() { - setMultiplierDraft(null); - updateActiveRateSettings("multiplier", 0); + function updateCost(value) { + setCostDraft(value); } - function updateOccurrence(field, value) { - setOccurrenceDrafts((drafts) => ({ ...drafts, [field]: value })); + function blurCost() { + if (!activeRate || costDraft === null) return; + save({ + ...data, + rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, costPerDraw: normalizeCostString(costDraft) } : rate) + }); + setCostDraft(null); + } + + function updateOccurrence(value) { + setOccurrenceDraft(value); + if (!activeRate) return; if (String(value).trim() === "") { - updateActiveRateSettings(field, 0); + save({ + ...data, + rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, occurrenceValue: 0, occurrenceUnit } : rate) + }); return; } - updateActiveRateSettings(field, value); + const nextValue = clampDecimal(value, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, activeRate.occurrenceValue); + save({ + ...data, + rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, occurrenceValue: nextValue, occurrenceUnit } : rate) + }); } - function blurOccurrence(field) { - const value = occurrenceDrafts[field]; - if (value === null) return; + function changeOccurrenceUnit(unit) { + setOccurrenceUnit(unit); + setOccurrenceDraft(null); + if (!activeRate) return; + save({ + ...data, + rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, occurrenceValue: activeRate.occurrenceValue, occurrenceUnit: unit } : rate) + }); + } + + function blurOccurrence() { + if (occurrenceDraft === null) return; + const value = occurrenceDraft; const raw = value.trim(); const parsed = Number(raw.replace(",", ".")); - if (!raw || !Number.isFinite(parsed)) updateActiveRateSettings(field, 0); - setOccurrenceDrafts((drafts) => ({ ...drafts, [field]: null })); + if (!raw || !Number.isFinite(parsed)) updateOccurrence(""); + setOccurrenceDraft(null); } function submitRate(event) { @@ -200,22 +288,28 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO setEditingRateId(""); } else { const id = context.uid("rng"); - save({ ...data, activeRateId: data.activeRateId || id, rates: [...data.rates, { id, label, rate }] }); + save({ ...data, activeRateId: id, rates: [...data.rates, { id, label, rate }] }); } reset({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE }); + setEditing(false); return ""; }); } function editRate(rate) { - save({ ...data, activeRateId: rate.id }); setEditingRateId(rate.id); + setEditing(true); rateDraft.reset({ label: rate.label, rate: rate.rate }); } function cancelEdit() { setEditingRateId(""); rateDraft.reset({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE }); + setEditing(false); + } + + function selectRate(rate) { + save({ ...data, activeRateId: rate.id }); } function deleteRate(rateId) { @@ -229,8 +323,10 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO const result = simulateRngDraws(activeRate.rate, data.settings.simulationDraws, Math.random, activeRate.multiplier); const entry = { id: context.uid("rng"), + rateId: activeRate.id, label: activeRate.label, rate: activeRate.rate, + costPerDraw: activeRate.costPerDraw, multiplier: activeRate.multiplier, ...result }; @@ -240,8 +336,9 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO } function clearHistory() { - setHistory([]); - writeSimulationHistory(historyStorageKey, []); + const nextHistory = activeRate ? history.filter((entry) => !historyEntryMatchesRate(entry, activeRate)) : []; + setHistory(nextHistory); + writeSimulationHistory(historyStorageKey, nextHistory); } return ( @@ -252,170 +349,188 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO )}
-
-
-
-
-
-

{editingRateId ? textContent.editRateTitle || "Modifier un taux" : textContent.addRateTitle || "Ajouter un taux"}

-
-
-
- - - {rateDraft.error &&

{rateDraft.error}

} -
- - {editingRateId && ( - - )} -
-
+
+
+
+

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

+ {data.rates.length}
-
-
-
-

{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"}

+
+ {rateFormOpen && ( +
+ + + {rateDraft.error &&

{rateDraft.error}

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

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

} +
+ + {activeRate && summary && ( +
+
- {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"}

-
+
-
-
- - + + {activeTab === "stats" && ( +
+ {activeRate && summary ? ( + <> +
+
+
+
+ {textContent.multiplierLabel || "Multiplicateur (%)"} +
+ updateMultiplier(event.target.value)} onBlur={blurMultiplier} aria-label={textContent.multiplierLabel || "Multiplicateur (%)"} /> + % +
+
+
+ + +
+
+
+ + +
+
+
+ +
+ {textContent.averageTimeTitle || "Temps moyen avant réussite"} +
+ + +
+
+ + + +
+
+ +
+ {textContent.chanceTargetsTitle || "Tirages pour atteindre une chance cumulée"} +
+ {textContent.chanceLabel || "Chance"} + {textContent.drawsUnit || "Tirages"} + {textContent.costLabel || "Coût"} + {CHANCE_TARGETS.map((target) => ( + + ))} +
+
+ + ) :

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

}
- -
-
- {history.length ? history.map((entry, index) => ( -
-
- {entry.label} + )} + + {activeTab === "simulation" && ( +
+
+
+ +
-
- {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."}

} -
+ {activeRate && summary && ( +
+ + +
+ )} +
+
+ +
+
+ {activeHistory.length ? activeHistory.map((entry, index) => ( + + )) :

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

} +
+
+ )} ); } +function RngInfo({ label, value }) { + return ( + + {label} + {value} + + ); +} + function RngStat({ label, value, title }) { return (
@@ -424,3 +539,59 @@ function RngStat({ label, value, title }) {
); } + +function RngTargetRow({ costSummary, draws, target, textContent }) { + return ( + <> + {target} % + {formatDrawTarget(draws, textContent.drawsUnit || "tirages")} + {formatCost(costSummary?.targetCosts?.[target])} + + ); +} + +function RngHistoryRow({ entry, index, textContent }) { + const expectedSuccesses = entry.draws * (getEffectiveRate(entry.rate, entry.multiplier) / 100); + const successDelta = entry.successes - expectedSuccesses; + const luckLabel = successDelta > 0 + ? textContent.luckySimulationLabel || "Au-dessus" + : successDelta < 0 + ? textContent.unluckySimulationLabel || "Sous l'attendu" + : textContent.neutralSimulationLabel || "Dans la cible"; + const costs = calculateRngCostSummary({ + costPerDraw: entry.costPerDraw, + averageDraws: 0, + drawsForChance: {}, + simulationDraws: entry.draws, + simulationSuccesses: entry.successes, + firstSuccessDraw: entry.firstSuccessDraw + }); + return ( +
+
+
+ {entry.label} + {luckLabel} · {formatSignedPercent((successDelta / entry.draws) * 100, 4)} +
+
+ {entry.successes} + {textContent.successesLabel || "Réussites"} +
+
+ {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.expectedSuccessesLabel || "Réussites attendues"}{formatNumber(expectedSuccesses, 2)} + {textContent.longestFailStreakLabel || "Pire série d'échec"}{entry.longestFailStreak} + {textContent.longestSuccessStreakLabel || "Réussite consécutive"}{entry.longestSuccessStreak} + {textContent.firstSuccessCostLabel || "Coût de la 1ere réussite"}{entry.firstSuccessDraw > 0 ? formatCost(costs?.simulationFirstSuccessCost) : "—"} + {textContent.costPerSuccessLabel || "Coût moyen/réussite"}{entry.successes > 0 ? formatCost(costs?.simulationCostPerSuccess) : "—"} +
+
+ ); +} diff --git a/website/src/features/toolboxes/modules/rngSimulatorUtils.js b/website/src/features/toolboxes/modules/rngSimulatorUtils.js index c2cc58e..bbd7374 100644 --- a/website/src/features/toolboxes/modules/rngSimulatorUtils.js +++ b/website/src/features/toolboxes/modules/rngSimulatorUtils.js @@ -3,6 +3,7 @@ export const RNG_SIMULATOR_HISTORY_LIMIT = 20; export const RNG_SIMULATOR_DEFAULT_RATE = "2.500"; export const RNG_SIMULATOR_LIMITS = { rateMax: 100, + costPerDrawMax: 1000000000000, multiplierMin: -1000000, multiplierMax: 1000000, simulationDrawsMin: 1, @@ -11,6 +12,8 @@ export const RNG_SIMULATOR_LIMITS = { occurrenceRateMax: 1000000000 }; +export const RNG_SIMULATOR_OCCURRENCE_UNITS = ["perHour", "perDay", "perMonth"]; + export function clampInteger(value, min, max, fallback = min) { const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed)) return fallback; @@ -40,6 +43,21 @@ export function normalizeRateString(value, fallback = RNG_SIMULATOR_DEFAULT_RATE return cleanInteger; } +export function normalizeCostString(value, fallback = "") { + const raw = String(value ?? "").trim().replace(",", "."); + 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.costPerDrawMax) return String(RNG_SIMULATOR_LIMITS.costPerDrawMax); + + 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; @@ -72,21 +90,20 @@ export function calculateDrawsForChance(rate, targetChance, multiplier = 0) { return Math.ceil(Math.log1p(-target) / Math.log1p(-probability)); } -export function calculateRngSummary({ rate, multiplier = 0, simulationDraws, perHour, perDay, perMonth }) { +export function normalizeOccurrenceUnit(value) { + return RNG_SIMULATOR_OCCURRENCE_UNITS.includes(value) ? value : "perHour"; +} + +export function calculateRngSummary({ rate, multiplier = 0, simulationDraws, occurrenceValue = 0, occurrenceUnit = "perHour" }) { 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); + const cleanOccurrenceValue = clampDecimal(occurrenceValue, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0); + const cleanOccurrenceUnit = normalizeOccurrenceUnit(occurrenceUnit); 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; + if (cleanOccurrenceValue > 0 && cleanOccurrenceUnit === "perHour") averageHours = averageDraws / cleanOccurrenceValue; + else if (cleanOccurrenceValue > 0 && cleanOccurrenceUnit === "perDay") averageHours = (averageDraws / cleanOccurrenceValue) * 24; + else if (cleanOccurrenceValue > 0 && cleanOccurrenceUnit === "perMonth") averageHours = (averageDraws / cleanOccurrenceValue) * 24 * 30; return { probability, @@ -94,9 +111,9 @@ export function calculateRngSummary({ rate, multiplier = 0, simulationDraws, per 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) + hour: averageHours, + day: averageHours > 0 ? averageHours / 24 : 0, + month: averageHours > 0 ? averageHours / 24 / 30 : 0 }, drawsForChance: { 50: calculateDrawsForChance(rate, 0.5, multiplier), @@ -107,19 +124,45 @@ export function calculateRngSummary({ rate, multiplier = 0, simulationDraws, per }; } +export function calculateRngCostSummary({ costPerDraw, averageDraws, drawsForChance, simulationDraws, simulationSuccesses, firstSuccessDraw = 0 }) { + const cleanCost = Number(normalizeCostString(costPerDraw)); + if (!Number.isFinite(cleanCost) || cleanCost <= 0) return null; + const cleanSimulationDraws = clampInteger(simulationDraws, RNG_SIMULATOR_LIMITS.simulationDrawsMin, RNG_SIMULATOR_LIMITS.simulationDrawsMax, 0); + const cleanSuccesses = clampInteger(simulationSuccesses, 0, cleanSimulationDraws, 0); + const cleanFirstSuccessDraw = clampInteger(firstSuccessDraw, 0, cleanSimulationDraws, 0); + const targetCosts = {}; + Object.entries(drawsForChance || {}).forEach(([target, draws]) => { + targetCosts[target] = Number.isFinite(draws) ? draws * cleanCost : Number.POSITIVE_INFINITY; + }); + return { + costPerDraw: cleanCost, + averageSuccessCost: Number.isFinite(averageDraws) ? averageDraws * cleanCost : 0, + targetCosts, + simulationCostPerSuccess: cleanSuccesses > 0 ? (cleanSimulationDraws * cleanCost) / cleanSuccesses : 0, + simulationFirstSuccessCost: cleanFirstSuccessDraw > 0 ? cleanFirstSuccessDraw * cleanCost : 0 + }; +} + 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 currentSuccessStreak = 0; + let firstSuccessDraw = 0; let longestFailStreak = 0; + let longestSuccessStreak = 0; for (let index = 0; index < cleanDraws; index += 1) { if (random() < probability) { successes += 1; + if (!firstSuccessDraw) firstSuccessDraw = index + 1; + currentSuccessStreak += 1; + longestSuccessStreak = Math.max(longestSuccessStreak, currentSuccessStreak); currentFailStreak = 0; } else { currentFailStreak += 1; + currentSuccessStreak = 0; longestFailStreak = Math.max(longestFailStreak, currentFailStreak); } } @@ -129,7 +172,9 @@ export function simulateRngDraws(rate, draws, random = Math.random, multiplier = successes, fails: cleanDraws - successes, observedRate: cleanDraws > 0 ? (successes / cleanDraws) * 100 : 0, - longestFailStreak + longestFailStreak, + longestSuccessStreak, + firstSuccessDraw }; } diff --git a/website/src/features/toolboxes/modules/textImport/rngSimulator.js b/website/src/features/toolboxes/modules/textImport/rngSimulator.js index a5d389d..ec8d819 100644 --- a/website/src/features/toolboxes/modules/textImport/rngSimulator.js +++ b/website/src/features/toolboxes/modules/textImport/rngSimulator.js @@ -6,7 +6,9 @@ export function exportRngSimulatorText(data) { 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" : ""} | multiplier=${rate.multiplier ?? 0} | perHour=${rate.perHour || 0} | perDay=${rate.perDay || 0} | perMonth=${rate.perMonth || 0}`); + const cost = rate.costPerDraw ? ` | costPerDraw=${rate.costPerDraw}` : ""; + const occurrences = rate.occurrenceValue ? ` | occurrenceValue=${rate.occurrenceValue} | occurrenceUnit=${rate.occurrenceUnit || "perHour"}` : ""; + lines.push(`- ${rate.label} | rate=${rate.rate}${rate.id === data.activeRateId ? " | active=true" : ""}${cost} | multiplier=${rate.multiplier ?? 0}${occurrences}`); }); lines.push( "", @@ -51,10 +53,10 @@ export function importRngSimulatorText(text, context) { id, label: title, rate: pipeMeta.rate, + costPerDraw: pipeMeta.costPerDraw, multiplier: pipeMeta.multiplier, - perHour: pipeMeta.perHour, - perDay: pipeMeta.perDay, - perMonth: pipeMeta.perMonth + occurrenceValue: pipeMeta.occurrenceValue, + occurrenceUnit: pipeMeta.occurrenceUnit }); if (pipeMeta.active === "true") data.activeRateId = id; return; diff --git a/website/src/features/toolboxes/storage/modules/rngSimulator.js b/website/src/features/toolboxes/storage/modules/rngSimulator.js index df8079f..b3bfbf8 100644 --- a/website/src/features/toolboxes/storage/modules/rngSimulator.js +++ b/website/src/features/toolboxes/storage/modules/rngSimulator.js @@ -3,6 +3,8 @@ import { RNG_SIMULATOR_LIMITS, clampDecimal, clampInteger, + normalizeCostString, + normalizeOccurrenceUnit, normalizeRateString } from "../../modules/rngSimulatorUtils.js"; import { uid } from "./shared.js"; @@ -14,10 +16,10 @@ function normalizeRateEntry(entry) { id: entry?.id || uid("rng"), label, rate: normalizeRateString(entry?.rate), + costPerDraw: normalizeCostString(entry?.costPerDraw), multiplier: clampDecimal(entry?.multiplier, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0), - perHour: clampDecimal(entry?.perHour, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0), - perDay: clampDecimal(entry?.perDay, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0), - perMonth: clampDecimal(entry?.perMonth, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0) + occurrenceValue: clampDecimal(entry?.occurrenceValue, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0), + occurrenceUnit: normalizeOccurrenceUnit(entry?.occurrenceUnit) }; } @@ -46,10 +48,12 @@ export function compactRngSimulatorDataForStorage(value) { if (normalized.rates.length) { compact.rates = normalized.rates.map((rate) => { const compactRate = { id: rate.id, label: rate.label, rate: rate.rate }; + if (rate.costPerDraw) compactRate.costPerDraw = rate.costPerDraw; if (rate.multiplier !== 0) compactRate.multiplier = rate.multiplier; - if (rate.perHour !== 0) compactRate.perHour = rate.perHour; - if (rate.perDay !== 0) compactRate.perDay = rate.perDay; - if (rate.perMonth !== 0) compactRate.perMonth = rate.perMonth; + if (rate.occurrenceValue !== 0) { + compactRate.occurrenceValue = rate.occurrenceValue; + compactRate.occurrenceUnit = rate.occurrenceUnit; + } return compactRate; }); } diff --git a/website/src/features/toolboxes/toolboxModuleRegistry.js b/website/src/features/toolboxes/toolboxModuleRegistry.js index 128055e..571a4a0 100644 --- a/website/src/features/toolboxes/toolboxModuleRegistry.js +++ b/website/src/features/toolboxes/toolboxModuleRegistry.js @@ -71,7 +71,6 @@ export const TOOLBOX_MODULE_DEFINITIONS = { mode: "Taux + simulations", icon: "clover", editable: true, - addable: false, scrollable: true, libraryCategory: "calculationData" }, diff --git a/website/src/styles/toolboxes/_rng-simulator.scss b/website/src/styles/toolboxes/_rng-simulator.scss index 0a7b0be..436de53 100644 --- a/website/src/styles/toolboxes/_rng-simulator.scss +++ b/website/src/styles/toolboxes/_rng-simulator.scss @@ -1,26 +1,18 @@ // 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; + gap: var(--space-3); + padding: var(--space-3); } .rng-simulator-panel { display: grid; - gap: 10px; + gap: var(--space-3); min-width: 0; - padding: 10px; + padding: var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); - background: rgba(16, 20, 38, 0.48); + background: rgba(16, 20, 38, 0.44); } .rng-simulator-panel > header { @@ -30,14 +22,15 @@ gap: var(--space-3); min-width: 0; min-height: 0; - border-bottom: 1px solid rgba(150, 165, 205, 0.12); + padding-bottom: var(--space-2); + border-bottom: 1px solid rgba(150, 165, 205, 0.1); text-align: left; } .rng-simulator-panel > header > div:first-child { display: flex; flex-wrap: wrap; - align-items: baseline; + align-items: center; gap: 8px; min-width: 0; } @@ -50,10 +43,9 @@ text-align: left; } -.rng-simulator-count, -.rng-simulator-active-rate { +.rng-simulator-count { flex: 0 0 auto; - padding: 4px 8px; + padding: 3px 7px; border: 1px solid rgba(246, 196, 83, 0.28); border-radius: var(--radius-sm); color: var(--color-accent-gold); @@ -62,20 +54,35 @@ font-variant-numeric: tabular-nums; } +.rng-simulator-rate-layout { + display: grid; + grid-template-columns: minmax(220px, 0.9fr) minmax(260px, 1.3fr); + gap: var(--space-3); + align-items: start; + min-width: 0; +} + +.rng-simulator-rate-form { + display: grid; + grid-column: 1 / -1; + grid-template-columns: repeat(2, minmax(0, 1fr)) auto; + gap: var(--space-3); + align-items: end; + min-width: 0; + padding: var(--space-3); + border: 1px solid rgba(150, 165, 205, 0.12); + border-radius: var(--radius-sm); + background: rgba(5, 7, 17, 0.24); +} + .rng-simulator-rates { display: grid; gap: 6px; - max-height: 220px; - min-height: 72px; + max-height: 176px; + min-height: 52px; 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; @@ -84,7 +91,7 @@ min-width: 0; border: 1px solid var(--color-border); border-radius: var(--radius-sm); - background: rgba(11, 14, 27, 0.56); + background: rgba(11, 14, 27, 0.54); overflow: hidden; } @@ -104,10 +111,10 @@ } .rng-simulator-rate-row > button { - display: flex; - align-items: center; - justify-content: flex-start; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; gap: 8px; + align-items: center; min-width: 0; padding: 7px 8px; border: 0; @@ -144,44 +151,57 @@ padding: 3px; } -.rng-simulator-rate-form, -.rng-simulator-settings { +.rng-simulator-active-summary { 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); + grid-template-columns: minmax(0, 1fr); + gap: 8px; 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-active-summary > span, +.rng-simulator-stat { + display: grid; + align-content: space-between; + gap: 8px; + min-width: 0; + min-height: 90px; + padding: 9px; + border: 1px solid rgba(150, 165, 205, 0.1); + border-radius: var(--radius-sm); + background: rgba(5, 7, 17, 0.22); } -.rng-simulator-section-title { - color: var(--color-text-primary); - font-size: var(--font-size-sm); - font-weight: 900; -} - -.rng-simulator-subtitle { +.rng-simulator-active-summary small, +.rng-simulator-table > span:nth-child(-n + 3), +.rng-simulator-history-metrics small { + 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 span { + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: 800; + line-height: 1.25; +} + +.rng-simulator-active-summary strong, +.rng-simulator-stat strong, +.rng-simulator-table strong, +.rng-simulator-history-row b, +.rng-simulator-history-rate > span, +.rng-simulator-history-metrics b { + color: var(--color-accent-gold); font-weight: 900; - text-transform: uppercase; + font-variant-numeric: tabular-nums; +} + +.rng-simulator-rate-form .rng-simulator-form-actions { + justify-content: flex-end; } .rng-simulator-field { @@ -198,9 +218,85 @@ min-width: 0; } +.rng-simulator-form-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: end; +} + +.rng-simulator-error { + grid-column: 1 / -1; + margin: 0; + color: #fca5a5; + font-size: var(--font-size-xs); + font-weight: 800; +} + +.rng-simulator-tabs-panel > header { + padding-bottom: 0; + border-bottom: 0; +} + +.rng-simulator-tabs { + width: fit-content; +} + +.rng-simulator-tabs.tabs.nds-radio-button-group[data-size="sm"] > button { + min-width: 96px; + min-height: 34px; + padding-block: 8px; + padding-inline: var(--space-3); +} + +.rng-simulator-tab-content { + display: grid; + gap: var(--space-3); + min-width: 0; +} + +.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-tab-content > .rng-simulator-section:first-child { + padding-top: 0; + border-top: 0; +} + +.rng-simulator-section-title { + color: var(--color-text-primary); + font-size: var(--font-size-sm); + font-weight: 900; +} + +.rng-simulator-primary-controls { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(180px, 1fr); + gap: var(--space-4); + align-items: stretch; +} + +.rng-simulator-primary-column, +.rng-simulator-cost-column { + display: grid; + gap: 8px; + min-width: 0; + align-content: start; +} + +.rng-simulator-cost-column { + padding-left: var(--space-4); + border-left: 1px solid rgba(150, 165, 205, 0.12); +} + .rng-simulator-percent-input { display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; + grid-template-columns: minmax(0, 1fr) auto; gap: 6px; align-items: center; } @@ -211,90 +307,88 @@ 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; +.rng-simulator-occurrence-control { + display: grid; + grid-template-columns: minmax(160px, 1fr) minmax(130px, 0.7fr); gap: 8px; align-items: end; - justify-content: flex-end; + max-width: 420px; } -.rng-simulator-stat-grid, -.rng-simulator-targets, -.rng-simulator-time-grid { +.rng-simulator-time-results { display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); 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; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; } .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; +.rng-simulator-table { + display: grid; align-items: center; - justify-content: space-between; - gap: 8px; + gap: 1px; + min-width: 0; + overflow: hidden; + border: 1px solid rgba(150, 165, 205, 0.1); + border-radius: var(--radius-sm); + background: rgba(150, 165, 205, 0.08); +} + +.rng-simulator-table > * { + min-width: 0; + padding: 8px; + background: rgba(5, 7, 17, 0.28); +} + +.rng-simulator-table > span:not(:nth-child(-n + 3)) { + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: 800; +} + +.rng-simulator-target-table { + grid-template-columns: minmax(88px, 0.6fr) minmax(140px, 1fr) minmax(120px, 1fr); +} + +.rng-simulator-simulation-overview { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); + align-items: stretch; 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; +.rng-simulator-simulation-summary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; +} + +.rng-simulator-simulation-actions { + display: grid; + justify-items: start; + gap: 8px; + min-width: 0; +} + +.rng-simulator-simulation-actions > .nds-button { + width: 100%; +} + +.rng-simulator-history-toolbar { + display: flex; + justify-content: flex-end; min-width: 0; } @@ -312,6 +406,14 @@ width: 150px; } +.rng-simulator-simulation-draws { + width: 100%; +} + +.rng-simulator-simulation-draws > .nds-input { + flex: 1 1 auto; +} + .rng-simulator-history { display: grid; gap: 8px; @@ -320,8 +422,7 @@ .rng-simulator-history-row { display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 8px 12px; + gap: 10px; align-items: center; min-width: 0; padding: 10px; @@ -332,26 +433,43 @@ .rng-simulator-history-row.is-latest { border-color: rgba(139, 92, 246, 0.38); - background: rgba(139, 92, 246, 0.1); + background: + linear-gradient(135deg, rgba(139, 92, 246, 0.18), rgba(246, 196, 83, 0.06)), + rgba(11, 14, 27, 0.52); } -.rng-simulator-history-row > div { +.rng-simulator-history-row > .rng-simulator-history-heading { display: grid; - gap: 2px; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: var(--space-3); min-width: 0; } -.rng-simulator-history-rate { - justify-items: end; - text-align: right; +.rng-simulator-history-row .rng-simulator-history-identity { + display: grid; + gap: 3px; + min-width: 0; } -.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-row .rng-simulator-history-score { + display: grid; + justify-items: end; + gap: 0; +} + +.rng-simulator-history-row .rng-simulator-history-score b { + color: var(--color-text-primary); + font-size: 24px; + line-height: 1; +} + +.rng-simulator-history-rate { + display: grid; + gap: 2px; + justify-items: end; + min-width: 72px; + text-align: right; } .rng-simulator-history-metrics { @@ -371,44 +489,54 @@ 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-rate-layout, + .rng-simulator-rate-form, + .rng-simulator-active-summary, + .rng-simulator-primary-controls, .rng-simulator-stat-grid, - .rng-simulator-targets, - .rng-simulator-time-grid { + .rng-simulator-simulation-summary, + .rng-simulator-occurrence-control, + .rng-simulator-time-results, + .rng-simulator-target-table { grid-template-columns: 1fr; } + .rng-simulator-cost-column { + padding-top: var(--space-3); + padding-left: 0; + border-top: 1px solid rgba(150, 165, 205, 0.12); + border-left: 0; + } + .rng-simulator-history-row { grid-template-columns: 1fr; } - .rng-simulator-history-rate { - justify-items: start; - text-align: left; + .rng-simulator-history-row > .rng-simulator-history-heading { + grid-template-columns: minmax(0, 1fr) auto; } .rng-simulator-history-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .rng-simulator-simulation-bar { - justify-content: flex-start; + .rng-simulator-history-rate { + grid-column: 1 / -1; + justify-items: start; + text-align: left; } + .rng-simulator-simulation-overview, .rng-simulator-simulation-actions { width: 100%; } + + .rng-simulator-simulation-overview { + grid-template-columns: 1fr; + } }