Improve chance simulator forecasts and tracking UI
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-09-07 16:03:35 +02:00
parent 1d278ff551
commit 6e64cfacd5
10 changed files with 534 additions and 101 deletions

View file

@ -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

View file

@ -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",

View file

@ -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);
});

View file

@ -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",

View file

@ -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,22 +475,19 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal
</div>
</section>
<section className="rng-simulator-panel rng-simulator-tabs-panel">
<section className="rng-simulator-panel rng-simulator-common-panel">
<header>
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="rng-simulator-tabs" />
<div>
<h3>{textContent.commonSettingsTitle || "Paramètres du taux"}</h3>
</div>
</header>
{activeTab === "stats" && (
<div className="rng-simulator-tab-content">
{activeRate && summary ? (
<>
<div className="rng-simulator-section">
<div className="rng-simulator-primary-controls">
<div className="rng-simulator-primary-column">
<div className="rng-simulator-field rng-simulator-multiplier-field">
<span>{textContent.multiplierLabel || "Multiplicateur (%)"}</span>
<div className="rng-simulator-percent-input">
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={multiplierDraft ?? String(activeRate.multiplier ?? 0)} onChange={(event) => updateMultiplier(event.target.value)} onBlur={blurMultiplier} aria-label={textContent.multiplierLabel || "Multiplicateur (%)"} />
<input className="nds-input" data-size="sm" type="number" step="any" value={multiplierDraft ?? String(activeRate.multiplier ?? 0)} onChange={(event) => updateMultiplier(event.target.value)} onBlur={blurMultiplier} aria-label={textContent.multiplierLabel || "Multiplicateur (%)"} />
<span>%</span>
</div>
</div>
@ -437,42 +499,64 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal
<div className="rng-simulator-cost-column">
<label className="rng-simulator-field rng-simulator-cost-field">
<span>{textContent.costPerDrawLabel || "Coût d'un tirage"}</span>
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={costDraft ?? activeRate.costPerDraw ?? ""} onChange={(event) => updateCost(event.target.value)} onBlur={blurCost} placeholder={textContent.costPerDrawPlaceholder || "Optionnel"} />
<input className="nds-input" data-size="sm" type="number" step="any" value={costDraft ?? activeRate.costPerDraw ?? ""} onChange={(event) => updateCost(event.target.value)} onBlur={blurCost} placeholder={textContent.costPerDrawPlaceholder || "Optionnel"} />
</label>
<RngStat label={textContent.averageCostLabel || "Coût moyen avant réussite"} value={formatCost(costSummary?.averageSuccessCost)} />
</div>
</div>
</div>
) : <p className="rng-simulator-empty">{textContent.emptySummary || "Ajoutez un taux pour afficher les calculs."}</p>}
</section>
<section className="rng-simulator-panel rng-simulator-tabs-panel">
<header>
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="rng-simulator-tabs" />
</header>
{activeTab === "forecasts" && (
<div className="rng-simulator-tab-content">
{activeRate && summary ? (
<>
<div className="rng-simulator-section">
<span className="rng-simulator-section-title">{textContent.averageTimeTitle || "Temps moyen avant réussite"}</span>
<div className="rng-simulator-occurrence-control">
<label className="rng-simulator-field">
<span>{textContent.occurrenceCountLabel || "Nombre d'occurrences"}</span>
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDraft ?? String(activeRate.occurrenceValue ?? 0)} onChange={(event) => updateOccurrence(event.target.value)} onBlur={blurOccurrence} />
<input className="nds-input" data-size="sm" type="number" step="any" value={occurrenceDraft ?? String(activeRate.occurrenceValue ?? 0)} onChange={(event) => updateOccurrence(event.target.value)} onBlur={blurOccurrence} />
</label>
<label className="rng-simulator-field">
<span>{textContent.periodLabel || "Période"}</span>
<select className="nds-select" data-size="sm" value={occurrenceUnit} onChange={(event) => changeOccurrenceUnit(event.target.value)}>
<option value="perHour">{textContent.perHourLabel || "Par heure"}</option>
<option value="perDay">{textContent.perDayLabel || "Par jour"}</option>
<option value="perMonth">{textContent.perMonthLabel || "Par mois"}</option>
</select>
</label>
</div>
<div className="rng-simulator-time-results">
<RngStat label={textContent.hourTimeLabel || "En heures"} value={formatTime(summary.timeByFrequency.hour, textContent.hoursUnit || "h")} />
<RngStat label={textContent.dayTimeLabel || "En jours"} value={formatTime(summary.timeByFrequency.day, textContent.daysUnit || "j")} />
<RngStat label={textContent.monthTimeLabel || "En mois"} value={formatTime(summary.timeByFrequency.month, textContent.monthsUnit || "mois")} />
</div>
</div>
<div className="rng-simulator-section">
<span className="rng-simulator-section-title">{textContent.durationChancesTitle || "Chances selon une durée"}</span>
<div className="rng-simulator-duration-grid">
{(summary.durationChances || RNG_SIMULATOR_HOURLY_DURATION_PRESETS).map((preset) => (
<RngStat
key={preset.id}
label={`${textContent.durationChancePrefix || "Après"} ${preset.label}`}
value={formatPercent((preset.chance || 0) * 100, 6)}
title={`${formatNumber(preset.draws || 0, 2)} ${textContent.drawsUnit || "tirages"}`}
/>
))}
</div>
</div>
<div className="rng-simulator-section">
<span className="rng-simulator-section-title">{textContent.chanceTargetsTitle || "Tirages pour atteindre une chance cumulée"}</span>
<div className="rng-simulator-table rng-simulator-target-table">
<span>{textContent.chanceLabel || "Chance"}</span>
<span>{textContent.drawsUnit || "Tirages"}</span>
<span>{textContent.costLabel || "Coût"}</span>
<span className="rng-simulator-table-heading">{textContent.chanceLabel || "Chance"}</span>
<span className="rng-simulator-table-heading">{textContent.drawsUnit || "Tirages"}</span>
<span className="rng-simulator-table-heading">{textContent.costLabel || "Coût"}</span>
{CHANCE_TARGETS.map((target) => (
<RngTargetRow costSummary={costSummary} draws={summary.drawsForChance[target]} key={target} target={target} textContent={textContent} />
))}
@ -489,7 +573,7 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal
<div className="rng-simulator-simulation-actions">
<label className="rng-simulator-field rng-simulator-inline-field rng-simulator-simulation-draws">
<span>{textContent.simulationDrawsLabel || "Tirages simulés"}</span>
<input className="nds-input" data-size="sm" type="number" min="1" max={RNG_SIMULATOR_LIMITS.simulationDrawsMax} value={data.settings.simulationDraws} onChange={(event) => updateSettings("simulationDraws", event.target.value)} />
<input className="nds-input" data-size="sm" type="number" min="1" max={RNG_SIMULATOR_LIMITS.simulationDrawsMax} step="1" value={data.settings.simulationDraws} onChange={(event) => updateSettings("simulationDraws", event.target.value)} />
</label>
<button className="nds-button" data-size="sm" data-variant="primary" type="button" onClick={runSimulation} disabled={!activeRate}>
<Icon name="play-circle" />
@ -516,6 +600,42 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal
</div>
</div>
)}
{activeTab === "tracking" && (
<div className="rng-simulator-tab-content">
{activeRate && summary && realDrawsSummary ? (
<>
<div className="rng-simulator-section">
<div className="rng-simulator-tracking-control">
<label className="rng-simulator-field">
<span>{textContent.realDrawsLabel || "Tirages effectués"}</span>
<input className="nds-input" data-size="sm" type="number" min="0" max={RNG_SIMULATOR_LIMITS.realDrawsMax} step="1" value={realDrawsDraft ?? String(activeRate.realDraws ?? 0)} onChange={(event) => updateRealDraws(event.target.value)} onBlur={blurRealDraws} />
</label>
</div>
<div className="rng-simulator-tracking-grid">
<RngStat label={`${textContent.realCumulativeChanceLabel || "Chance d'avoir déjà réussi"} ${textContent.afterDrawsPrefix || "après"} ${formatNumber(realDrawsSummary.realDraws, 0)} ${textContent.drawsUnit || "tirages"}`} value={formatPercent(realDrawsSummary.cumulativeChance * 100, 6)} />
<RngStat label={textContent.averagePositionLabel || "Position vs moyenne"} tone={realDrawsSummary.averagePosition.tone} value={getAveragePositionLabel(realDrawsSummary.averagePosition, textContent)} />
<RngStat label={realDrawsSummary.averageExceeded ? textContent.averageExceededLabel || "Moyenne dépassée" : textContent.drawsUntilAverageLabel || "Tirages avant moyenne"} tone={averageDrawsTone} value={realDrawsSummary.averageExceeded ? `${formatNumber(averageExceededDraws, 0)} ${textContent.drawsUnit || "tirages"}` : `${formatNumber(realDrawsSummary.drawsUntilAverage, 0)} ${textContent.drawsUnit || "tirages"}`} />
<RngStat label={textContent.averageCostDeltaLabel || "Écart au coût moyen"} tone={realDrawsSummary.averageCostDelta > 0 ? "negative" : realDrawsSummary.averageCostDelta < 0 ? "positive" : ""} value={realDrawsSummary.hasCost ? formatSignedCost(realDrawsSummary.averageCostDelta) : "—"} />
</div>
</div>
<div className="rng-simulator-section">
<span className="rng-simulator-section-title">{textContent.trackingTargetsTitle || "Progression vers les chances cumulées"}</span>
<div className="rng-simulator-table rng-simulator-tracking-target-table">
<span className="rng-simulator-table-heading">{textContent.chanceLabel || "Chance"}</span>
<span className="rng-simulator-table-heading">{textContent.trackingProgressLabel || "Progression"}</span>
<span className="rng-simulator-table-heading">{textContent.remainingDrawsLabel || "Tirages restants"}</span>
<span className="rng-simulator-table-heading">{textContent.remainingCostLabel || "Coût restant"}</span>
{CHANCE_TARGETS.map((target) => (
<RngTrackingTargetRow hasCost={realDrawsSummary.hasCost} key={target} target={target} targetSummary={realDrawsSummary.targets[target]} textContent={textContent} />
))}
</div>
</div>
</>
) : <p className="rng-simulator-empty">{textContent.emptySummary || "Ajoutez un taux pour afficher les calculs."}</p>}
</div>
)}
</section>
</div>
</>
@ -531,9 +651,9 @@ function RngInfo({ label, value }) {
);
}
function RngStat({ label, value, title }) {
function RngStat({ label, tone = "", value, title }) {
return (
<article className="rng-simulator-stat" title={title}>
<article className="rng-simulator-stat" data-tone={tone || undefined} title={title}>
<span>{label}</span>
<strong>{value}</strong>
</article>
@ -543,16 +663,37 @@ function RngStat({ label, value, title }) {
function RngTargetRow({ costSummary, draws, target, textContent }) {
return (
<>
<strong>{target} %</strong>
<strong className="rng-simulator-chance-value">{target} %</strong>
<span title={`${formatNumber(draws, 0)} ${textContent.drawsUnit || "tirages"}`}>{formatDrawTarget(draws, textContent.drawsUnit || "tirages")}</span>
<span>{formatCost(costSummary?.targetCosts?.[target])}</span>
</>
);
}
function RngTrackingTargetRow({ hasCost, target, targetSummary, textContent }) {
return (
<>
<strong className="rng-simulator-chance-value">{target} %</strong>
<span>{formatPercent((targetSummary?.progress || 0) * 100, 2)}</span>
<span title={`${formatNumber(targetSummary?.remainingDraws || 0, 0)} ${textContent.drawsUnit || "tirages"}`}>{formatDrawTarget(targetSummary?.remainingDraws || 0, textContent.drawsUnit || "tirages")}</span>
<span>{hasCost ? formatCost(targetSummary?.remainingCost) : "—"}</span>
</>
);
}
function RngHistoryMetric({ label, tone = "", value }) {
return (
<span data-tone={tone || undefined}>
<small>{label}</small>
<b>{value}</b>
</span>
);
}
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 (
<article className={`rng-simulator-history-row ${index === 0 ? "is-latest" : ""}`}>
<div className="rng-simulator-history-heading">
<div className="rng-simulator-history-identity">
<div className="rng-simulator-history-identity" data-tone={successTone || undefined}>
<strong>{entry.label}</strong>
<small>{luckLabel} · {formatSignedPercent((successDelta / entry.draws) * 100, 4)}</small>
</div>
<div className="rng-simulator-history-score">
<div className="rng-simulator-history-score" data-tone={entry.successes > 0 ? "positive" : undefined}>
<b>{entry.successes}</b>
<small>{textContent.successesLabel || "Réussites"}</small>
</div>
@ -583,14 +726,14 @@ function RngHistoryRow({ entry, index, textContent }) {
</div>
</div>
<div className="rng-simulator-history-metrics">
<span><small>{textContent.successesLabel || "Réussites"}</small><b>{entry.successes}/{entry.draws}</b></span>
<span><small>{textContent.failsLabel || "Fails"}</small><b>{entry.fails}</b></span>
<span><small>{textContent.observedRateLabel || "Taux de réussite"}</small><b>{formatPercent((entry.successes / entry.draws) * 100, 6)}</b></span>
<span><small>{textContent.expectedSuccessesLabel || "Réussites attendues"}</small><b>{formatNumber(expectedSuccesses, 2)}</b></span>
<span><small>{textContent.longestFailStreakLabel || "Pire série d'échec"}</small><b>{entry.longestFailStreak}</b></span>
<span><small>{textContent.longestSuccessStreakLabel || "Réussite consécutive"}</small><b>{entry.longestSuccessStreak}</b></span>
<span><small>{textContent.firstSuccessCostLabel || "Coût de la 1ere réussite"}</small><b>{entry.firstSuccessDraw > 0 ? formatCost(costs?.simulationFirstSuccessCost) : "—"}</b></span>
<span><small>{textContent.costPerSuccessLabel || "Coût moyen/réussite"}</small><b>{entry.successes > 0 ? formatCost(costs?.simulationCostPerSuccess) : "—"}</b></span>
<RngHistoryMetric label={textContent.successesLabel || "Réussites"} tone={entry.successes > 0 ? "positive" : ""} value={`${entry.successes}/${entry.draws}`} />
<RngHistoryMetric label={textContent.failsLabel || "Fails"} tone={entry.fails > 0 ? "negative" : ""} value={entry.fails} />
<RngHistoryMetric label={textContent.observedRateLabel || "Taux de réussite"} tone={successTone} value={formatPercent((entry.successes / entry.draws) * 100, 6)} />
<RngHistoryMetric label={textContent.expectedSuccessesLabel || "Réussites attendues"} value={formatNumber(expectedSuccesses, 2)} />
<RngHistoryMetric label={textContent.longestFailStreakLabel || "Pire série d'échec"} tone={entry.longestFailStreak > 0 ? "negative" : ""} value={entry.longestFailStreak} />
<RngHistoryMetric label={textContent.longestSuccessStreakLabel || "Réussite consécutive"} tone={entry.longestSuccessStreak > 0 ? "positive" : ""} value={entry.longestSuccessStreak} />
<RngHistoryMetric label={textContent.firstSuccessCostLabel || "Coût de la 1ere réussite"} tone={hasFirstSuccessCost ? "negative" : ""} value={entry.firstSuccessDraw > 0 ? formatCost(costs?.simulationFirstSuccessCost) : "—"} />
<RngHistoryMetric label={textContent.costPerSuccessLabel || "Coût moyen/réussite"} tone={hasCostPerSuccess ? "negative" : ""} value={entry.successes > 0 ? formatCost(costs?.simulationCostPerSuccess) : "—"} />
</div>
</article>
);

View file

@ -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;

View file

@ -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;

View file

@ -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;
});
}

View file

@ -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) {
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)}`);
LIBRARY_ANCHOR_RETRY_DELAYS.forEach((delay, index) => {
window.setTimeout(() => {
const element = document.getElementById(anchorId);
if (!element) 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" });
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(() => ({

View file

@ -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;
}