From 6e64cfacd57108f2064d9bf5988711c10061e427 Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Mon, 7 Sep 2026 16:03:35 +0200 Subject: [PATCH] Improve chance simulator forecasts and tracking UI --- docs/STORAGE_SCHEMA.md | 10 +- tests/helpers/data-validation.mjs | 27 +- tests/toolbox-modules.test.mjs | 56 +++- website/public/data/site.json | 35 ++- .../toolboxes/modules/RngSimulatorModule.jsx | 245 ++++++++++++++---- .../toolboxes/modules/rngSimulatorUtils.js | 136 +++++++++- .../modules/textImport/rngSimulator.js | 6 +- .../toolboxes/storage/modules/rngSimulator.js | 9 +- website/src/pages/LibraryPage.jsx | 24 +- .../src/styles/toolboxes/_rng-simulator.scss | 87 ++++++- 10 files changed, 534 insertions(+), 101 deletions(-) diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index 50d22f7..6acf6f5 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -824,7 +824,8 @@ Type : `rngSimulator` "costPerDraw": "150", "multiplier": 50, "occurrenceValue": 30, - "occurrenceUnit": "perHour" + "occurrenceUnit": "perHour", + "realDraws": 120 }, { "id": "rate2", @@ -847,12 +848,15 @@ Stockage compact : - `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[].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. +- `rates[].occurrenceUnit` vaut `perHour` ou `perDay`, et vaut `perHour` par défaut. +- les anciennes données `occurrenceUnit: "perMonth"` sont converties en `perDay`, avec `occurrenceValue` divisé par 30. +- `rates[].realDraws` est le total persistant de tirages réellement effectués pour ce taux, borné de 0 à 1 000 000 000. - `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[].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. +- `rates[].realDraws` est omis si sa valeur 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 : @@ -860,7 +864,7 @@ Import/export texte : ```text @activeRateId: rate1 [rates] -- Drop rare | rate=2.500 | active=true | costPerDraw=150 | multiplier=50 | occurrenceValue=30 | occurrenceUnit=perHour +- Drop rare | rate=2.500 | active=true | costPerDraw=150 | multiplier=50 | occurrenceValue=30 | occurrenceUnit=perHour | realDraws=120 [settings] simulationDraws: 1000 diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index 1eb8b86..e9e4cda 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -327,13 +327,14 @@ export function validateSiteContent(site) { "toolboxes.modules.rngSimulator.deleteTitle", "toolboxes.modules.rngSimulator.noActiveRateTitle", "toolboxes.modules.rngSimulator.activeRateLabel", + "toolboxes.modules.rngSimulator.commonSettingsTitle", "toolboxes.modules.rngSimulator.effectiveRateLabel", - "toolboxes.modules.rngSimulator.statsTabLabel", + "toolboxes.modules.rngSimulator.forecastsTabLabel", "toolboxes.modules.rngSimulator.simulationTabLabel", + "toolboxes.modules.rngSimulator.trackingTabLabel", "toolboxes.modules.rngSimulator.simulationDrawsLabel", "toolboxes.modules.rngSimulator.perHourLabel", "toolboxes.modules.rngSimulator.perDayLabel", - "toolboxes.modules.rngSimulator.perMonthLabel", "toolboxes.modules.rngSimulator.multiplierLabel", "toolboxes.modules.rngSimulator.averageDrawsLabel", "toolboxes.modules.rngSimulator.simulationChanceLabel", @@ -345,14 +346,14 @@ export function validateSiteContent(site) { "toolboxes.modules.rngSimulator.occurrenceCountLabel", "toolboxes.modules.rngSimulator.hourTimeLabel", "toolboxes.modules.rngSimulator.dayTimeLabel", - "toolboxes.modules.rngSimulator.monthTimeLabel", + "toolboxes.modules.rngSimulator.durationChancesTitle", + "toolboxes.modules.rngSimulator.durationChancePrefix", "toolboxes.modules.rngSimulator.chanceTargetsTitle", "toolboxes.modules.rngSimulator.chanceLabel", "toolboxes.modules.rngSimulator.costLabel", "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", @@ -370,6 +371,24 @@ export function validateSiteContent(site) { "toolboxes.modules.rngSimulator.luckySimulationLabel", "toolboxes.modules.rngSimulator.neutralSimulationLabel", "toolboxes.modules.rngSimulator.unluckySimulationLabel", + "toolboxes.modules.rngSimulator.realDrawsLabel", + "toolboxes.modules.rngSimulator.realCumulativeChanceLabel", + "toolboxes.modules.rngSimulator.afterDrawsPrefix", + "toolboxes.modules.rngSimulator.averagePositionLabel", + "toolboxes.modules.rngSimulator.averageStatusStartLabel", + "toolboxes.modules.rngSimulator.averageStatusFarBelowLabel", + "toolboxes.modules.rngSimulator.averageStatusBelowLabel", + "toolboxes.modules.rngSimulator.averageStatusAverageLabel", + "toolboxes.modules.rngSimulator.averageStatusSlightlyAboveLabel", + "toolboxes.modules.rngSimulator.averageStatusHighLabel", + "toolboxes.modules.rngSimulator.averageStatusCatastrophicLabel", + "toolboxes.modules.rngSimulator.drawsUntilAverageLabel", + "toolboxes.modules.rngSimulator.averageExceededLabel", + "toolboxes.modules.rngSimulator.averageCostDeltaLabel", + "toolboxes.modules.rngSimulator.trackingTargetsTitle", + "toolboxes.modules.rngSimulator.trackingProgressLabel", + "toolboxes.modules.rngSimulator.remainingDrawsLabel", + "toolboxes.modules.rngSimulator.remainingCostLabel", "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 68cfb27..98fbdf8 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, calculateRngCostSummary, calculateRngSummary, getEffectiveRate, normalizeCostString, normalizeRateString, simulateRngDraws } from "../website/src/features/toolboxes/modules/rngSimulatorUtils.js"; +import { calculateAtLeastOneProbability, calculateDrawsForChance, calculateRngCostSummary, calculateRngRealDrawsSummary, calculateRngSummary, getAveragePositionStatus, getDurationPresetsForAverageHours, 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", 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: "rate1", label: "Drop rare", rate: "2.500", costPerDraw: "", multiplier: 25, occurrenceValue: 10, occurrenceUnit: "perHour", realDraws: 0 }, + { id: "rate2", label: "Ultra rare", rate: "0.000004", costPerDraw: "12,500", multiplier: 50, occurrenceValue: 3600, occurrenceUnit: "perMonth", realDraws: "100 000" }, { id: "bad", label: "", rate: "4" } ], settings: { simulationDraws: 2000 }, @@ -313,7 +313,10 @@ 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.costPerDraw), ["", "12.500"]); - assert.deepEqual(normalized.rates.map((rate) => [rate.multiplier, rate.occurrenceValue, rate.occurrenceUnit]), [[25, 10, "perHour"], [50, 120, "perDay"]]); + assert.deepEqual(normalized.rates.map((rate) => [rate.multiplier, rate.occurrenceValue, rate.occurrenceUnit, rate.realDraws]), [[25, 10, "perHour", 0], [50, 120, "perDay", 100000]]); + assert.equal(normalizeRngSimulatorData({ rates: [{ label: "Bad real", rate: "5", realDraws: -10 }] }).rates[0].realDraws, 0); + assert.equal(normalizeRngSimulatorData({ rates: [{ label: "Decimal real", rate: "5", realDraws: "42.9" }] }).rates[0].realDraws, 42); + assert.equal(normalizeRngSimulatorData({ rates: [{ label: "Huge real", rate: "5", realDraws: 2000000000 }] }).rates[0].realDraws, 1000000000); assert.equal(normalized.history, undefined); assert.equal(normalizeCostString("0"), ""); assert.equal(normalizeCostString("0012,500"), "12.500"); @@ -324,6 +327,8 @@ test("rng simulator preserves rate precision and compacts storage", () => { assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].multiplier, 50); assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].occurrenceValue, 120); assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].occurrenceUnit, "perDay"); + assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[0].realDraws, undefined); + assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].realDraws, 100000); assert.equal(compactModuleDataForStorage("rngSimulator", normalized).settings.simulationDraws, 2000); assert.equal(compactModuleDataForStorage("rngSimulator", normalized).history, undefined); }); @@ -339,10 +344,23 @@ test("rng simulator calculates probability milestones and deterministic simulati 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); + assert.deepEqual(hourlySummary.durationChances.map((preset) => preset.id), ["1h", "5h", "10h", "1j"]); + assert.equal(hourlySummary.durationChances.find((preset) => preset.id === "1h").draws, 2); + assert.equal(hourlySummary.durationChances.find((preset) => preset.id === "1h").chance, 0.4375); const dailySummary = calculateRngSummary({ rate: "25", simulationDraws: 8, occurrenceValue: 2, occurrenceUnit: "perDay" }); assert.equal(dailySummary.timeByFrequency.hour, 48); assert.equal(dailySummary.timeByFrequency.day, 2); + assert.deepEqual(dailySummary.durationChances.map((preset) => preset.id), ["1j", "3j", "7j", "30j"]); + const legacyMonthSummary = calculateRngSummary({ rate: "25", simulationDraws: 8, occurrenceValue: 60, occurrenceUnit: "perMonth" }); + assert.equal(legacyMonthSummary.timeByFrequency.day, 2); + assert.deepEqual(legacyMonthSummary.durationChances.map((preset) => preset.id), ["1j", "3j", "7j", "30j"]); + const fractionalDurationSummary = calculateRngSummary({ rate: "24", simulationDraws: 8, occurrenceValue: 1, occurrenceUnit: "perDay" }); + assert.ok(fractionalDurationSummary.durationChances.find((preset) => preset.id === "1j").chance > 0); + const rareDurationSummary = calculateRngSummary({ rate: "0.000004", simulationDraws: 8, occurrenceValue: 60, occurrenceUnit: "perHour" }); + assert.deepEqual(rareDurationSummary.durationChances.map((preset) => preset.id), ["1a", "5a", "10a", "25a"]); + assert.deepEqual(getDurationPresetsForAverageHours(24 * 30).map((preset) => preset.id), ["1j", "3j", "7j", "30j"]); + assert.deepEqual(getDurationPresetsForAverageHours(24 * 365 * 2).map((preset) => preset.id), ["30j", "90j", "1a", "5a"]); + assert.deepEqual(getDurationPresetsForAverageHours(0, "perDay").map((preset) => preset.id), ["1j", "3j", "7j", "15j"]); const multipliedSummary = calculateRngSummary({ rate: "10", multiplier: 100, simulationDraws: 1, occurrenceValue: 0, occurrenceUnit: "perHour" }); assert.equal(multipliedSummary.effectiveRate, 20); assert.equal(multipliedSummary.averageDraws, 5); @@ -354,6 +372,25 @@ test("rng simulator calculates probability milestones and deterministic simulati 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 realSummary = calculateRngRealDrawsSummary({ rate: "25", realDraws: 2, averageDraws: 4, drawsForChance: { 50: 3, 90: 9 }, costPerDraw: "10" }); + assert.equal(realSummary.hasCost, true); + assert.equal(realSummary.cumulativeChance, 0.4375); + assert.equal(realSummary.noSuccessChance, 0.5625); + assert.equal(realSummary.averageRatio, 0.5); + assert.deepEqual(realSummary.averagePosition, { id: "below", tone: "positive" }); + assert.equal(realSummary.drawsUntilAverage, 2); + assert.equal(realSummary.spentCost, 20); + assert.equal(realSummary.averageSuccessCost, 40); + assert.equal(realSummary.averageCostDelta, -20); + assert.equal(realSummary.costUntilAverage, 20); + assert.equal(realSummary.targets[50].remainingDraws, 1); + assert.deepEqual(getAveragePositionStatus(0.1), { id: "start", tone: "positive" }); + assert.deepEqual(getAveragePositionStatus(0.3), { id: "farBelow", tone: "positive" }); + assert.deepEqual(getAveragePositionStatus(0.75), { id: "below", tone: "positive" }); + assert.deepEqual(getAveragePositionStatus(1), { id: "average", tone: "" }); + assert.deepEqual(getAveragePositionStatus(1.25), { id: "slightlyAbove", tone: "negative" }); + assert.deepEqual(getAveragePositionStatus(2), { id: "high", tone: "negative" }); + assert.deepEqual(getAveragePositionStatus(3), { id: "catastrophic", tone: "negative" }); const result = simulateRngDraws("25", 8, (() => { const values = [0.1, 0.9, 0.2, 0.8, 0.7, 0.1, 0.6, 0.5]; @@ -386,8 +423,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", costPerDraw: "150", multiplier: 25, occurrenceValue: 20, occurrenceUnit: "perHour" }, - { id: "rate2", label: "Ultra rare", rate: "0.000004", costPerDraw: "", multiplier: 50, occurrenceValue: 120, occurrenceUnit: "perDay" } + { id: "rate1", label: "Drop rare", rate: "2.500", costPerDraw: "150", multiplier: 25, occurrenceValue: 20, occurrenceUnit: "perHour", realDraws: 60 }, + { id: "rate2", label: "Ultra rare", rate: "0.000004", costPerDraw: "", multiplier: 50, occurrenceValue: 120, occurrenceUnit: "perDay", realDraws: 0 } ], settings: { simulationDraws: 5000 } }); @@ -403,6 +440,11 @@ test("rng simulator text exchange round trips rates and settings", () => { assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.multiplier, 50); 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.rates.find((rate) => rate.label === "Drop rare")?.realDraws, 60); + const legacyImported = importModuleText("rngSimulator", "[rates]\n- Legacy | rate=1 | occurrenceValue=300 | occurrenceUnit=perMonth", context); + assert.equal(legacyImported.ok, true); + assert.equal(legacyImported.data.rates[0].occurrenceValue, 10); + assert.equal(legacyImported.data.rates[0].occurrenceUnit, "perDay"); assert.equal(imported.data.settings.simulationDraws, 5000); assert.equal(imported.data.history, undefined); }); diff --git a/website/public/data/site.json b/website/public/data/site.json index 18ab69d..3dbe46e 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -542,11 +542,11 @@ }, { "title": "Lecture probabiliste", - "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.", + "text": "Les paramètres communs affichent la moyenne d'essais avant réussite, tandis que l'onglet Prévisions détaille les durées 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 occurrences par heure ou par jour servent à estimer le temps moyen et les chances après une durée donnée.", "Les paliers 50%, 90%, 95% et 99% donnent un repère concret sur la variance.", - "L'onglet Simulation agrège réussites, échecs, pire série d'échecs et coûts sans stocker chaque tirage." + "L'onglet Suivi permet d'enregistrer ses tirages réels et de les situer par rapport à la moyenne." ] } ], @@ -2125,13 +2125,14 @@ "deleteTitle": "Supprimer", "noActiveRateTitle": "Aucun taux actif", "activeRateLabel": "Taux brut", + "commonSettingsTitle": "Paramètres du taux", "effectiveRateLabel": "Taux final", - "statsTabLabel": "Calculs", + "forecastsTabLabel": "Prévisions", "simulationTabLabel": "Simulation", + "trackingTabLabel": "Suivi", "simulationDrawsLabel": "Tirages simulés", "perHourLabel": "Par heure", "perDayLabel": "Par jour", - "perMonthLabel": "Par mois", "multiplierLabel": "Multiplicateur (%)", "averageDrawsLabel": "Moyenne avant réussite", "simulationChanceLabel": "Chance sur les tirages saisis", @@ -2143,14 +2144,14 @@ "occurrenceCountLabel": "Nombre d'occurrences", "hourTimeLabel": "En heures", "dayTimeLabel": "En jours", - "monthTimeLabel": "En mois", + "durationChancesTitle": "Chances selon une durée", + "durationChancePrefix": "Après", "chanceTargetsTitle": "Tirages pour atteindre une chance cumulée", "chanceLabel": "Chance", "costLabel": "Coût", "drawsUnit": "tirages", "hoursUnit": "h", "daysUnit": "j", - "monthsUnit": "mois", "emptySummary": "Ajoutez un taux pour afficher les calculs.", "simulationTitle": "Résultats simulés", "simulateButton": "Simuler", @@ -2168,7 +2169,25 @@ "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", + "realDrawsLabel": "Tirages effectués", + "realCumulativeChanceLabel": "Chance d'avoir déjà réussi", + "afterDrawsPrefix": "après", + "averagePositionLabel": "Position vs moyenne", + "averageStatusStartLabel": "Début de suivi", + "averageStatusFarBelowLabel": "Loin sous la moyenne", + "averageStatusBelowLabel": "Un peu moins que la moyenne", + "averageStatusAverageLabel": "Dans la moyenne", + "averageStatusSlightlyAboveLabel": "Un peu au-dessus de la moyenne", + "averageStatusHighLabel": "Très au-dessus de la moyenne", + "averageStatusCatastrophicLabel": "Catastrophique", + "drawsUntilAverageLabel": "Tirages avant moyenne", + "averageExceededLabel": "Moyenne dépassée", + "averageCostDeltaLabel": "Écart au coût moyen", + "trackingTargetsTitle": "Progression vers les chances cumulées", + "trackingProgressLabel": "Progression", + "remainingDrawsLabel": "Tirages restants", + "remainingCostLabel": "Coût restant", + "importPlaceholder": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true | costPerDraw=150 | multiplier=320 | occurrenceValue=60 | occurrenceUnit=perHour | realDraws=1000\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 3532216..0c741cd 100644 --- a/website/src/features/toolboxes/modules/RngSimulatorModule.jsx +++ b/website/src/features/toolboxes/modules/RngSimulatorModule.jsx @@ -6,11 +6,13 @@ import { useDraftForm } from "../../../hooks/useDraftForm.js"; import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { RNG_SIMULATOR_DEFAULT_RATE, + RNG_SIMULATOR_HOURLY_DURATION_PRESETS, RNG_SIMULATOR_HISTORY_LIMIT, RNG_SIMULATOR_LIMITS, addRngSimulationHistory, calculateAtLeastOneProbability, calculateRngCostSummary, + calculateRngRealDrawsSummary, calculateRngSummary, clampDecimal, clampInteger, @@ -23,7 +25,8 @@ import { const CHANCE_TARGETS = [50, 90, 95, 99]; const RNG_TABS = [ - { id: "stats", label: "Calculs" }, + { id: "tracking", label: "Suivi" }, + { id: "forecasts", label: "Prévisions" }, { id: "simulation", label: "Simulation" } ]; @@ -42,6 +45,12 @@ function formatCost(value) { return formatNumber(value, 0); } +function formatSignedCost(value) { + if (!Number.isFinite(value)) return "—"; + if (value === 0) return "0"; + return `${value > 0 ? "+" : ""}${formatCost(value)}`; +} + function formatDrawTarget(value, unit) { if (!Number.isFinite(value)) return `— ${unit}`; if (value >= 1000000) { @@ -118,21 +127,41 @@ function historyEntryMatchesRate(entry, rate) { return entry.label === rate.label && entry.rate === rate.rate; } +function getAveragePositionLabel(status, textContent) { + const labels = { + start: textContent.averageStatusStartLabel || "Début de suivi", + farBelow: textContent.averageStatusFarBelowLabel || "Loin sous la moyenne", + below: textContent.averageStatusBelowLabel || "Un peu moins que la moyenne", + average: textContent.averageStatusAverageLabel || "Dans la moyenne", + slightlyAbove: textContent.averageStatusSlightlyAboveLabel || "Un peu au-dessus de la moyenne", + high: textContent.averageStatusHighLabel || "Très au-dessus de la moyenne", + catastrophic: textContent.averageStatusCatastrophicLabel || "Catastrophique" + }; + return labels[status?.id] || labels.start; +} + +function getToneFromDelta(value) { + if (!Number.isFinite(value) || value === 0) return ""; + return value > 0 ? "positive" : "negative"; +} + 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]); + { ...RNG_TABS[0], label: textContent.trackingTabLabel || RNG_TABS[0].label }, + { ...RNG_TABS[1], label: textContent.forecastsTabLabel || RNG_TABS[1].label }, + { ...RNG_TABS[2], label: textContent.simulationTabLabel || RNG_TABS[2].label } + ], [textContent.forecastsTabLabel, textContent.simulationTabLabel, textContent.trackingTabLabel]); const historyStorageKey = `rng-simulator:${toolboxId}:${moduleId}:history`; - const [activeTab, setActiveTab] = useState("stats"); + const [activeTab, setActiveTab] = useState("tracking"); const [history, setHistory] = useState(() => readSimulationHistory(historyStorageKey)); const [editingRateId, setEditingRateId] = useState(""); const [multiplierDraft, setMultiplierDraft] = useState(null); const [costDraft, setCostDraft] = useState(null); const [occurrenceUnit, setOccurrenceUnit] = useState("perHour"); const [occurrenceDraft, setOccurrenceDraft] = useState(null); + const [realDrawsDraft, setRealDrawsDraft] = useState(null); const rateFormRef = useRef(null); const previousEditingRef = useRef(editing); const rateDraft = useDraftForm({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE }); @@ -154,6 +183,17 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal }) : 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; + const realDrawsSummary = useMemo(() => activeRate && summary ? calculateRngRealDrawsSummary({ + rate: activeRate.rate, + multiplier: activeRate.multiplier, + realDraws: activeRate.realDraws, + averageDraws: summary.averageDraws, + drawsForChance: summary.drawsForChance, + costPerDraw: activeRate.costPerDraw + }) : null, [activeRate, summary]); + const averageDrawsDelta = activeRate && summary ? activeRate.realDraws - summary.averageDraws : 0; + const averageExceededDraws = Math.max(0, Math.round(averageDrawsDelta)); + const averageDrawsTone = realDrawsSummary?.averageExceeded ? (averageExceededDraws > 0 ? "negative" : "") : "positive"; useEffect(() => { setHistory(readSimulationHistory(historyStorageKey)); @@ -185,6 +225,7 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal setMultiplierDraft(null); setCostDraft(null); setOccurrenceDraft(null); + setRealDrawsDraft(null); setOccurrenceUnit(activeRate?.occurrenceUnit || "perHour"); }, [activeRate?.id, activeRate?.occurrenceUnit]); @@ -277,6 +318,30 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal setOccurrenceDraft(null); } + function updateRealDraws(value) { + setRealDrawsDraft(value); + if (!activeRate) return; + if (String(value).trim() === "") { + save({ + ...data, + rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, realDraws: 0 } : rate) + }); + return; + } + save({ + ...data, + rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, realDraws: clampInteger(value, 0, RNG_SIMULATOR_LIMITS.realDrawsMax, activeRate.realDraws) } : rate) + }); + } + + function blurRealDraws() { + if (realDrawsDraft === null) return; + const raw = realDrawsDraft.trim(); + const parsed = Number.parseInt(raw.replace(/[\s\u00a0]/g, ""), 10); + if (!raw || !Number.isFinite(parsed)) updateRealDraws(""); + setRealDrawsDraft(null); + } + function submitRate(event) { rateDraft.handleSubmit(event, (draft, { reset }) => { const label = draft.label.trim(); @@ -410,69 +475,88 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal +
+
+
+

{textContent.commonSettingsTitle || "Paramètres du taux"}

+
+
+ {activeRate && summary ? ( +
+
+
+ {textContent.multiplierLabel || "Multiplicateur (%)"} +
+ updateMultiplier(event.target.value)} onBlur={blurMultiplier} aria-label={textContent.multiplierLabel || "Multiplicateur (%)"} /> + % +
+
+
+ + +
+
+
+ + +
+
+ ) :

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

} +
+
- {activeTab === "stats" && ( + {activeTab === "forecasts" && (
{activeRate && summary ? ( <> -
-
-
-
- {textContent.multiplierLabel || "Multiplicateur (%)"} -
- updateMultiplier(event.target.value)} onBlur={blurMultiplier} aria-label={textContent.multiplierLabel || "Multiplicateur (%)"} /> - % -
-
-
- - -
-
-
- - -
-
-
-
{textContent.averageTimeTitle || "Temps moyen avant réussite"}
- +
+
+ +
+ {textContent.durationChancesTitle || "Chances selon une durée"} +
+ {(summary.durationChances || RNG_SIMULATOR_HOURLY_DURATION_PRESETS).map((preset) => ( + + ))}
{textContent.chanceTargetsTitle || "Tirages pour atteindre une chance cumulée"}
- {textContent.chanceLabel || "Chance"} - {textContent.drawsUnit || "Tirages"} - {textContent.costLabel || "Coût"} + {textContent.chanceLabel || "Chance"} + {textContent.drawsUnit || "Tirages"} + {textContent.costLabel || "Coût"} {CHANCE_TARGETS.map((target) => ( ))} @@ -489,7 +573,7 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal
)} + + {activeTab === "tracking" && ( +
+ {activeRate && summary && realDrawsSummary ? ( + <> +
+
+ +
+
+ + + + 0 ? "negative" : realDrawsSummary.averageCostDelta < 0 ? "positive" : ""} value={realDrawsSummary.hasCost ? formatSignedCost(realDrawsSummary.averageCostDelta) : "—"} /> +
+
+ +
+ {textContent.trackingTargetsTitle || "Progression vers les chances cumulées"} +
+ {textContent.chanceLabel || "Chance"} + {textContent.trackingProgressLabel || "Progression"} + {textContent.remainingDrawsLabel || "Tirages restants"} + {textContent.remainingCostLabel || "Coût restant"} + {CHANCE_TARGETS.map((target) => ( + + ))} +
+
+ + ) :

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

} +
+ )}
@@ -531,9 +651,9 @@ function RngInfo({ label, value }) { ); } -function RngStat({ label, value, title }) { +function RngStat({ label, tone = "", value, title }) { return ( -
+
{label} {value}
@@ -543,16 +663,37 @@ function RngStat({ label, value, title }) { function RngTargetRow({ costSummary, draws, target, textContent }) { return ( <> - {target} % + {target} % {formatDrawTarget(draws, textContent.drawsUnit || "tirages")} {formatCost(costSummary?.targetCosts?.[target])} ); } +function RngTrackingTargetRow({ hasCost, target, targetSummary, textContent }) { + return ( + <> + {target} % + {formatPercent((targetSummary?.progress || 0) * 100, 2)} + {formatDrawTarget(targetSummary?.remainingDraws || 0, textContent.drawsUnit || "tirages")} + {hasCost ? formatCost(targetSummary?.remainingCost) : "—"} + + ); +} + +function RngHistoryMetric({ label, tone = "", value }) { + return ( + + {label} + {value} + + ); +} + function RngHistoryRow({ entry, index, textContent }) { const expectedSuccesses = entry.draws * (getEffectiveRate(entry.rate, entry.multiplier) / 100); const successDelta = entry.successes - expectedSuccesses; + const successTone = getToneFromDelta(successDelta); const luckLabel = successDelta > 0 ? textContent.luckySimulationLabel || "Au-dessus" : successDelta < 0 @@ -566,14 +707,16 @@ function RngHistoryRow({ entry, index, textContent }) { simulationSuccesses: entry.successes, firstSuccessDraw: entry.firstSuccessDraw }); + const hasFirstSuccessCost = entry.firstSuccessDraw > 0 && Number.isFinite(costs?.simulationFirstSuccessCost) && costs.simulationFirstSuccessCost > 0; + const hasCostPerSuccess = entry.successes > 0 && Number.isFinite(costs?.simulationCostPerSuccess) && costs.simulationCostPerSuccess > 0; return (
-
+
{entry.label} {luckLabel} · {formatSignedPercent((successDelta / entry.draws) * 100, 4)}
-
+
0 ? "positive" : undefined}> {entry.successes} {textContent.successesLabel || "Réussites"}
@@ -583,14 +726,14 @@ function RngHistoryRow({ entry, index, textContent }) {
- {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) : "—"} + 0 ? "positive" : ""} value={`${entry.successes}/${entry.draws}`} /> + 0 ? "negative" : ""} value={entry.fails} /> + + + 0 ? "negative" : ""} value={entry.longestFailStreak} /> + 0 ? "positive" : ""} value={entry.longestSuccessStreak} /> + 0 ? formatCost(costs?.simulationFirstSuccessCost) : "—"} /> + 0 ? formatCost(costs?.simulationCostPerSuccess) : "—"} />
); diff --git a/website/src/features/toolboxes/modules/rngSimulatorUtils.js b/website/src/features/toolboxes/modules/rngSimulatorUtils.js index 8d3d6ba..e74bad2 100644 --- a/website/src/features/toolboxes/modules/rngSimulatorUtils.js +++ b/website/src/features/toolboxes/modules/rngSimulatorUtils.js @@ -8,14 +8,45 @@ export const RNG_SIMULATOR_LIMITS = { multiplierMax: 1000000, simulationDrawsMin: 1, simulationDrawsMax: 1000000, + realDrawsMax: 1000000000, occurrenceRateMin: 0, occurrenceRateMax: 1000000000 }; -export const RNG_SIMULATOR_OCCURRENCE_UNITS = ["perHour", "perDay", "perMonth"]; +export const RNG_SIMULATOR_OCCURRENCE_UNITS = ["perHour", "perDay"]; +export const RNG_SIMULATOR_HOURLY_DURATION_PRESETS = [ + { id: "1h", label: "1h", hours: 1 }, + { id: "5h", label: "5h", hours: 5 }, + { id: "10h", label: "10h", hours: 10 }, + { id: "1j", label: "1j", hours: 24 } +]; +export const RNG_SIMULATOR_DAILY_DURATION_PRESETS = [ + { id: "1j", label: "1j", hours: 24 }, + { id: "3j", label: "3j", hours: 72 }, + { id: "7j", label: "7j", hours: 168 }, + { id: "15j", label: "15j", hours: 360 } +]; +export const RNG_SIMULATOR_MONTHLY_DURATION_PRESETS = [ + { id: "1j", label: "1j", hours: 24 }, + { id: "3j", label: "3j", hours: 72 }, + { id: "7j", label: "7j", hours: 168 }, + { id: "30j", label: "30j", hours: 720 } +]; +export const RNG_SIMULATOR_YEARLY_DURATION_PRESETS = [ + { id: "30j", label: "30j", hours: 720 }, + { id: "90j", label: "90j", hours: 2160 }, + { id: "1a", label: "1 an", hours: 8760 }, + { id: "5a", label: "5 ans", hours: 43800 } +]; +export const RNG_SIMULATOR_LONG_DURATION_PRESETS = [ + { id: "1a", label: "1 an", hours: 8760 }, + { id: "5a", label: "5 ans", hours: 43800 }, + { id: "10a", label: "10 ans", hours: 87600 }, + { id: "25a", label: "25 ans", hours: 219000 } +]; export function clampInteger(value, min, max, fallback = min) { - const parsed = Number.parseInt(value, 10); + const parsed = Number.parseInt(String(value ?? "").replace(/[\s\u00a0]/g, ""), 10); if (!Number.isFinite(parsed)) return fallback; return Math.min(max, Math.max(min, parsed)); } @@ -76,6 +107,11 @@ export function getEffectiveRate(rate, multiplier = 0) { export function calculateAtLeastOneProbability(rate, draws, multiplier = 0) { const probability = getEffectiveRate(rate, multiplier) / 100; const cleanDraws = clampInteger(draws, 0, Number.MAX_SAFE_INTEGER, 0); + return calculateProbabilityForDrawCount(probability, cleanDraws); +} + +function calculateProbabilityForDrawCount(probability, draws) { + const cleanDraws = clampDecimal(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)); @@ -91,19 +127,32 @@ export function calculateDrawsForChance(rate, targetChance, multiplier = 0) { } export function normalizeOccurrenceUnit(value) { + if (value === "perMonth") return "perDay"; return RNG_SIMULATOR_OCCURRENCE_UNITS.includes(value) ? value : "perHour"; } +export function normalizeOccurrenceSettings(value, unit) { + const cleanValue = clampDecimal(value, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0); + if (unit === "perMonth") { + return { + occurrenceValue: cleanValue / 30, + occurrenceUnit: "perDay" + }; + } + return { + occurrenceValue: cleanValue, + occurrenceUnit: normalizeOccurrenceUnit(unit) + }; +} + 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 cleanOccurrenceValue = clampDecimal(occurrenceValue, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0); - const cleanOccurrenceUnit = normalizeOccurrenceUnit(occurrenceUnit); + const { occurrenceValue: cleanOccurrenceValue, occurrenceUnit: cleanOccurrenceUnit } = normalizeOccurrenceSettings(occurrenceValue, occurrenceUnit); let averageHours = 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, @@ -112,9 +161,9 @@ export function calculateRngSummary({ rate, multiplier = 0, simulationDraws, occ chanceForSimulationDraws: calculateAtLeastOneProbability(rate, simulationDraws, multiplier), timeByFrequency: { hour: averageHours, - day: averageHours > 0 ? averageHours / 24 : 0, - month: averageHours > 0 ? averageHours / 24 / 30 : 0 + day: averageHours > 0 ? averageHours / 24 : 0 }, + durationChances: calculateDurationChances(rate, multiplier, cleanOccurrenceValue, cleanOccurrenceUnit), drawsForChance: { 50: calculateDrawsForChance(rate, 0.5, multiplier), 90: calculateDrawsForChance(rate, 0.9, multiplier), @@ -124,6 +173,79 @@ export function calculateRngSummary({ rate, multiplier = 0, simulationDraws, occ }; } +export function calculateDurationChances(rate, multiplier = 0, occurrenceValue = 0, occurrenceUnit = "perHour") { + const { occurrenceValue: cleanOccurrenceValue, occurrenceUnit: cleanOccurrenceUnit } = normalizeOccurrenceSettings(occurrenceValue, occurrenceUnit); + const drawsPerHour = cleanOccurrenceUnit === "perDay" ? cleanOccurrenceValue / 24 : cleanOccurrenceValue; + const averageHours = drawsPerHour > 0 ? (1 / (getEffectiveRate(rate, multiplier) / 100)) / drawsPerHour : 0; + const presets = getDurationPresetsForAverageHours(averageHours, cleanOccurrenceUnit); + return presets.map((preset) => ({ + ...preset, + draws: drawsPerHour > 0 ? drawsPerHour * preset.hours : 0, + chance: drawsPerHour > 0 ? calculateProbabilityForDrawCount(getEffectiveRate(rate, multiplier) / 100, drawsPerHour * preset.hours) : 0 + })); +} + +export function getDurationPresetsForAverageHours(averageHours, occurrenceUnit = "perHour") { + if (!Number.isFinite(averageHours) || averageHours <= 0) { + return occurrenceUnit === "perDay" ? RNG_SIMULATOR_DAILY_DURATION_PRESETS : RNG_SIMULATOR_HOURLY_DURATION_PRESETS; + } + if (occurrenceUnit === "perDay" && averageHours <= 24 * 365) return RNG_SIMULATOR_MONTHLY_DURATION_PRESETS; + if (averageHours <= 24 * 7) return RNG_SIMULATOR_HOURLY_DURATION_PRESETS; + if (averageHours <= 24 * 365) return RNG_SIMULATOR_MONTHLY_DURATION_PRESETS; + if (averageHours <= 24 * 365 * 10) return RNG_SIMULATOR_YEARLY_DURATION_PRESETS; + return RNG_SIMULATOR_LONG_DURATION_PRESETS; +} + +export function getAveragePositionStatus(averageRatio) { + const ratio = Number.isFinite(averageRatio) && averageRatio >= 0 ? averageRatio : 0; + if (ratio < 0.25) return { id: "start", tone: "positive" }; + if (ratio < 0.5) return { id: "farBelow", tone: "positive" }; + if (ratio < 0.9) return { id: "below", tone: "positive" }; + if (ratio < 1.1) return { id: "average", tone: "" }; + if (ratio < 1.5) return { id: "slightlyAbove", tone: "negative" }; + if (ratio < 3) return { id: "high", tone: "negative" }; + return { id: "catastrophic", tone: "negative" }; +} + +export function calculateRngRealDrawsSummary({ rate, multiplier = 0, realDraws = 0, averageDraws = 0, drawsForChance = {}, costPerDraw = "" }) { + const cleanRealDraws = clampInteger(realDraws, 0, RNG_SIMULATOR_LIMITS.realDrawsMax, 0); + const cumulativeChance = calculateAtLeastOneProbability(rate, cleanRealDraws, multiplier); + const noSuccessChance = 1 - cumulativeChance; + const cleanAverageDraws = Number.isFinite(averageDraws) && averageDraws > 0 ? averageDraws : 0; + const averageRatio = cleanAverageDraws > 0 ? cleanRealDraws / cleanAverageDraws : 0; + const averagePosition = getAveragePositionStatus(averageRatio); + const drawsUntilAverage = cleanAverageDraws > cleanRealDraws ? Math.ceil(cleanAverageDraws - cleanRealDraws) : 0; + const cleanCost = Number(normalizeCostString(costPerDraw)); + const hasCost = Number.isFinite(cleanCost) && cleanCost > 0; + const averageSuccessCost = hasCost && cleanAverageDraws > 0 ? cleanAverageDraws * cleanCost : 0; + const spentCost = hasCost ? cleanRealDraws * cleanCost : 0; + const targets = {}; + Object.entries(drawsForChance || {}).forEach(([target, draws]) => { + const remainingDraws = Number.isFinite(draws) ? Math.max(0, draws - cleanRealDraws) : Number.POSITIVE_INFINITY; + targets[target] = { + draws, + remainingDraws, + progress: Number.isFinite(draws) && draws > 0 ? Math.min(1, cleanRealDraws / draws) : 0, + remainingCost: hasCost && Number.isFinite(remainingDraws) ? remainingDraws * cleanCost : 0 + }; + }); + return { + realDraws: cleanRealDraws, + hasCost, + cumulativeChance, + noSuccessChance, + averageRatio, + averagePosition, + drawsUntilAverage, + averageExceeded: cleanAverageDraws > 0 && cleanRealDraws >= cleanAverageDraws, + spentCost, + averageSuccessCost, + averageCostDelta: hasCost && cleanAverageDraws > 0 ? spentCost - averageSuccessCost : 0, + costUntilAverage: hasCost && drawsUntilAverage > 0 ? drawsUntilAverage * cleanCost : 0, + targets + }; +} + export function calculateRngCostSummary({ costPerDraw, averageDraws, drawsForChance, simulationDraws, simulationSuccesses, firstSuccessDraw = 0 }) { const cleanCost = Number(normalizeCostString(costPerDraw)); if (!Number.isFinite(cleanCost) || cleanCost <= 0) return null; diff --git a/website/src/features/toolboxes/modules/textImport/rngSimulator.js b/website/src/features/toolboxes/modules/textImport/rngSimulator.js index ec8d819..9825026 100644 --- a/website/src/features/toolboxes/modules/textImport/rngSimulator.js +++ b/website/src/features/toolboxes/modules/textImport/rngSimulator.js @@ -8,7 +8,8 @@ export function exportRngSimulatorText(data) { (data.rates || []).forEach((rate) => { 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}`); + const realDraws = rate.realDraws ? ` | realDraws=${rate.realDraws}` : ""; + lines.push(`- ${rate.label} | rate=${rate.rate}${rate.id === data.activeRateId ? " | active=true" : ""}${cost} | multiplier=${rate.multiplier ?? 0}${occurrences}${realDraws}`); }); lines.push( "", @@ -56,7 +57,8 @@ export function importRngSimulatorText(text, context) { costPerDraw: pipeMeta.costPerDraw, multiplier: pipeMeta.multiplier, occurrenceValue: pipeMeta.occurrenceValue, - occurrenceUnit: pipeMeta.occurrenceUnit + occurrenceUnit: pipeMeta.occurrenceUnit, + realDraws: pipeMeta.realDraws }); 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 b3bfbf8..e35c195 100644 --- a/website/src/features/toolboxes/storage/modules/rngSimulator.js +++ b/website/src/features/toolboxes/storage/modules/rngSimulator.js @@ -4,7 +4,7 @@ import { clampDecimal, clampInteger, normalizeCostString, - normalizeOccurrenceUnit, + normalizeOccurrenceSettings, normalizeRateString } from "../../modules/rngSimulatorUtils.js"; import { uid } from "./shared.js"; @@ -12,14 +12,16 @@ import { uid } from "./shared.js"; function normalizeRateEntry(entry) { const label = String(entry?.label || "").trim(); if (!label) return null; + const occurrence = normalizeOccurrenceSettings(entry?.occurrenceValue, entry?.occurrenceUnit); return { 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), - occurrenceValue: clampDecimal(entry?.occurrenceValue, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0), - occurrenceUnit: normalizeOccurrenceUnit(entry?.occurrenceUnit) + occurrenceValue: occurrence.occurrenceValue, + occurrenceUnit: occurrence.occurrenceUnit, + realDraws: clampInteger(entry?.realDraws, 0, RNG_SIMULATOR_LIMITS.realDrawsMax, 0) }; } @@ -54,6 +56,7 @@ export function compactRngSimulatorDataForStorage(value) { compactRate.occurrenceValue = rate.occurrenceValue; compactRate.occurrenceUnit = rate.occurrenceUnit; } + if (rate.realDraws !== 0) compactRate.realDraws = rate.realDraws; return compactRate; }); } diff --git a/website/src/pages/LibraryPage.jsx b/website/src/pages/LibraryPage.jsx index c8c79af..053db14 100644 --- a/website/src/pages/LibraryPage.jsx +++ b/website/src/pages/LibraryPage.jsx @@ -27,6 +27,7 @@ import { } from "../features/toolboxes/storage/toolboxStorage.js"; const LIBRARY_TOOLBOX_ID = "library"; +const LIBRARY_ANCHOR_RETRY_DELAYS = [0, 80, 240, 600]; const MODULE_COMPONENTS = { memos: lazy(() => import("../features/toolboxes/modules/MemosModule.jsx").then((module) => ({ default: module.MemosModule }))), @@ -83,12 +84,23 @@ function getCurrentLibraryAnchor() { return match ? decodeURIComponent(match[1]) : ""; } -function scrollToLibraryTool(anchorId, replace = false) { - const element = document.getElementById(anchorId); - if (!element) return; +function getLibraryAnchorOffset() { + const topbar = document.querySelector(".topbar"); + const topbarHeight = topbar?.getBoundingClientRect?.().height || 0; + return topbarHeight + 16; +} + +function scrollToLibraryTool(anchorId, replace = false, behavior = "smooth") { + if (!anchorId) return; if (replace) history.replaceState(null, "", `#/library#${encodeURIComponent(anchorId)}`); - const top = element.getBoundingClientRect().top + window.scrollY - 24; - window.scrollTo({ top: Math.max(0, top), behavior: "smooth" }); + LIBRARY_ANCHOR_RETRY_DELAYS.forEach((delay, index) => { + window.setTimeout(() => { + const element = document.getElementById(anchorId); + if (!element) return; + const top = element.getBoundingClientRect().top + window.scrollY - getLibraryAnchorOffset(); + window.scrollTo({ top: Math.max(0, top), behavior: index === 0 ? behavior : "auto" }); + }, delay); + }); } function findCategoryForAnchor(categories, anchorId) { @@ -149,7 +161,7 @@ export function LibraryPage({ siteContent, actions }) { const category = findCategoryForAnchor(libraryCategories, anchorId) || libraryCategories[0]; setActiveCategoryKey(category.key); if (!anchorId) return; - requestAnimationFrame(() => scrollToLibraryTool(anchorId)); + requestAnimationFrame(() => scrollToLibraryTool(anchorId, false, "auto")); }, [libraryPayload, libraryCategories]); const moduleContext = useMemo(() => ({ diff --git a/website/src/styles/toolboxes/_rng-simulator.scss b/website/src/styles/toolboxes/_rng-simulator.scss index c2f9632..cfc55e0 100644 --- a/website/src/styles/toolboxes/_rng-simulator.scss +++ b/website/src/styles/toolboxes/_rng-simulator.scss @@ -179,11 +179,29 @@ text-align: center; } +.rng-simulator-history-score small, +.rng-simulator-history-rate small { + overflow: hidden; + color: var(--color-text-primary); + font-size: var(--font-size-xs); + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + .rng-simulator-active-summary small, -.rng-simulator-table > span:nth-child(-n + 3), +.rng-simulator-table-heading { + overflow: hidden; + color: var(--color-text-primary); + font-size: var(--font-size-xs); + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + .rng-simulator-history-metrics small { overflow: hidden; - color: var(--color-text-secondary); + color: var(--color-accent-gold); font-size: var(--font-size-xs); font-weight: 800; text-overflow: ellipsis; @@ -191,7 +209,7 @@ } .rng-simulator-stat span { - color: var(--color-text-secondary); + color: var(--color-accent-gold); font-size: var(--font-size-xs); font-weight: 800; line-height: 1.25; @@ -203,11 +221,19 @@ .rng-simulator-history-row b, .rng-simulator-history-rate > span, .rng-simulator-history-metrics b { - color: var(--color-accent-gold); + color: var(--color-text-primary); font-weight: 900; font-variant-numeric: tabular-nums; } +.rng-simulator-history-rate > span { + color: var(--color-accent-gold); +} + +.rng-simulator-table .rng-simulator-chance-value { + color: var(--color-accent-gold); +} + .rng-simulator-active-summary strong { color: var(--color-accent-gold); font-size: 24px; @@ -222,7 +248,7 @@ display: grid; gap: 6px; min-width: 0; - color: var(--color-text-secondary); + color: var(--color-accent-gold); font-size: var(--font-size-xs); font-weight: 800; } @@ -316,7 +342,7 @@ } .rng-simulator-percent-input > span { - color: var(--color-text-secondary); + color: var(--color-accent-gold); font-size: var(--font-size-xs); font-weight: 900; } @@ -331,14 +357,28 @@ .rng-simulator-time-results { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.rng-simulator-stat-grid, +.rng-simulator-duration-grid, +.rng-simulator-tracking-grid { + display: grid; gap: 8px; } .rng-simulator-stat-grid { - display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; +} + +.rng-simulator-duration-grid, +.rng-simulator-tracking-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.rng-simulator-tracking-control { + max-width: 360px; } .rng-simulator-stat strong { @@ -347,6 +387,14 @@ font-size: var(--font-size-sm); } +.rng-simulator-stat[data-tone="positive"] strong { + color: var(--color-accent-cyan); +} + +.rng-simulator-stat[data-tone="negative"] strong { + color: #fca5a5; +} + .rng-simulator-table { display: grid; align-items: center; @@ -374,6 +422,10 @@ grid-template-columns: minmax(88px, 0.6fr) minmax(140px, 1fr) minmax(120px, 1fr); } +.rng-simulator-tracking-target-table { + grid-template-columns: minmax(88px, 0.55fr) minmax(120px, 0.8fr) minmax(140px, 1fr) minmax(120px, 1fr); +} + .rng-simulator-simulation-overview { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -478,6 +530,18 @@ line-height: 1; } +.rng-simulator-history-identity[data-tone="positive"] small, +.rng-simulator-history-metrics > span[data-tone="positive"] b, +.rng-simulator-history-score[data-tone="positive"] b { + color: var(--color-accent-cyan); +} + +.rng-simulator-history-identity[data-tone="negative"] small, +.rng-simulator-history-metrics > span[data-tone="negative"] b, +.rng-simulator-history-score[data-tone="negative"] b { + color: #fca5a5; +} + .rng-simulator-history-rate { display: grid; gap: 2px; @@ -513,10 +577,13 @@ .rng-simulator-active-summary, .rng-simulator-primary-controls, .rng-simulator-stat-grid, + .rng-simulator-duration-grid, + .rng-simulator-tracking-grid, .rng-simulator-simulation-summary, .rng-simulator-occurrence-control, .rng-simulator-time-results, - .rng-simulator-target-table { + .rng-simulator-target-table, + .rng-simulator-tracking-target-table { grid-template-columns: 1fr; }