Refine RNG simulator layout and simulation stats
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-09-05 00:19:08 +02:00
parent 055fb78d60
commit f8231c748f
11 changed files with 825 additions and 394 deletions

View file

@ -819,10 +819,10 @@ Type : `rngSimulator`
"id": "rate1",
"label": "Drop rare",
"rate": "2.500",
"costPerDraw": "150",
"multiplier": 50,
"perHour": 30,
"perDay": 120,
"perMonth": 3600
"occurrenceValue": 30,
"occurrenceUnit": "perHour"
},
{
"id": "rate2",
@ -841,14 +841,29 @@ Stockage compact :
- `rates` contient les taux enregistrés du module; un taux sans libellé est ignoré.
- `rate` est une chaîne en pourcentage afin de conserver la précision saisie, par exemple `"2.500"` ou `"0.000004"`.
- un taux doit être strictement supérieur à `0` et inférieur ou égal à `100`; les valeurs supérieures sont bornées à `"100"`.
- `costPerDraw` est une chaîne décimale optionnelle, associée au taux, utilisée comme unité libre pour les calculs de coût.
- `activeRateId` référence un taux existant; sinon le premier taux est sélectionné.
- `rates[].multiplier` est un bonus ou malus en pourcentage associé au taux, vaut 0 par défaut et le taux effectif est borné entre 0% et 100%.
- `rates[].perHour`, `rates[].perDay` et `rates[].perMonth` sont numériques, positifs ou nuls, associés au taux, et valent 0 par défaut.
- `rates[].occurrenceValue` est numérique, positif ou nul, associé au taux, et vaut 0 par défaut.
- `rates[].occurrenceUnit` vaut `perHour`, `perDay` ou `perMonth`, et vaut `perHour` par défaut.
- `settings.simulationDraws` est borné de 1 à 1 000 000 et vaut 1000 par défaut.
- `settings` est omis si `simulationDraws` vaut sa valeur par défaut.
- `rates[].multiplier`, `rates[].perHour`, `rates[].perDay` et `rates[].perMonth` sont omis si leur valeur vaut 0.
- `rates[].costPerDraw` est omis si vide ou nul.
- `rates[].multiplier` est omis si sa valeur vaut 0.
- `rates[].occurrenceValue` et `rates[].occurrenceUnit` sont omis si `occurrenceValue` vaut 0.
- l'historique des simulations n'est pas persisté en IndexedDB; il est limité aux 20 dernières entrées en `sessionStorage`.
Import/export texte :
```text
@activeRateId: rate1
[rates]
- Drop rare | rate=2.500 | active=true | costPerDraw=150 | multiplier=50 | occurrenceValue=30 | occurrenceUnit=perHour
[settings]
simulationDraws: 1000
```
## Outil Combos
Type : `combos`

View file

@ -317,6 +317,8 @@ export function validateSiteContent(site) {
"toolboxes.modules.rngSimulator.labelLabel",
"toolboxes.modules.rngSimulator.labelPlaceholder",
"toolboxes.modules.rngSimulator.rateLabel",
"toolboxes.modules.rngSimulator.costPerDrawLabel",
"toolboxes.modules.rngSimulator.costPerDrawPlaceholder",
"toolboxes.modules.rngSimulator.rateLabelRequired",
"toolboxes.modules.rngSimulator.rateInvalid",
"toolboxes.modules.rngSimulator.addRateButton",
@ -324,21 +326,29 @@ export function validateSiteContent(site) {
"toolboxes.modules.rngSimulator.cancelButton",
"toolboxes.modules.rngSimulator.deleteTitle",
"toolboxes.modules.rngSimulator.noActiveRateTitle",
"toolboxes.modules.rngSimulator.activeRateLabel",
"toolboxes.modules.rngSimulator.effectiveRateLabel",
"toolboxes.modules.rngSimulator.statsTabLabel",
"toolboxes.modules.rngSimulator.simulationTabLabel",
"toolboxes.modules.rngSimulator.simulationDrawsLabel",
"toolboxes.modules.rngSimulator.perHourLabel",
"toolboxes.modules.rngSimulator.perDayLabel",
"toolboxes.modules.rngSimulator.perMonthLabel",
"toolboxes.modules.rngSimulator.probabilitySectionTitle",
"toolboxes.modules.rngSimulator.multiplierLabel",
"toolboxes.modules.rngSimulator.clearMultiplierButton",
"toolboxes.modules.rngSimulator.averageDrawsLabel",
"toolboxes.modules.rngSimulator.simulationChanceLabel",
"toolboxes.modules.rngSimulator.simulationChanceWithoutMultiplierLabel",
"toolboxes.modules.rngSimulator.averageCostLabel",
"toolboxes.modules.rngSimulator.averageTimeTitle",
"toolboxes.modules.rngSimulator.periodLabel",
"toolboxes.modules.rngSimulator.averageTimeLabel",
"toolboxes.modules.rngSimulator.occurrenceCountLabel",
"toolboxes.modules.rngSimulator.hourTimeLabel",
"toolboxes.modules.rngSimulator.dayTimeLabel",
"toolboxes.modules.rngSimulator.monthTimeLabel",
"toolboxes.modules.rngSimulator.chanceTargetsTitle",
"toolboxes.modules.rngSimulator.chanceLabel",
"toolboxes.modules.rngSimulator.costLabel",
"toolboxes.modules.rngSimulator.drawsUnit",
"toolboxes.modules.rngSimulator.hoursUnit",
"toolboxes.modules.rngSimulator.daysUnit",
@ -351,8 +361,15 @@ export function validateSiteContent(site) {
"toolboxes.modules.rngSimulator.successesLabel",
"toolboxes.modules.rngSimulator.failsLabel",
"toolboxes.modules.rngSimulator.observedRateLabel",
"toolboxes.modules.rngSimulator.expectedSuccessesLabel",
"toolboxes.modules.rngSimulator.multiplierShortLabel",
"toolboxes.modules.rngSimulator.longestFailStreakLabel",
"toolboxes.modules.rngSimulator.longestSuccessStreakLabel",
"toolboxes.modules.rngSimulator.firstSuccessCostLabel",
"toolboxes.modules.rngSimulator.costPerSuccessLabel",
"toolboxes.modules.rngSimulator.luckySimulationLabel",
"toolboxes.modules.rngSimulator.neutralSimulationLabel",
"toolboxes.modules.rngSimulator.unluckySimulationLabel",
"toolboxes.modules.rngSimulator.importPlaceholder",
"toolboxes.modules.rngSimulator.importOpenButton",
"toolboxes.modules.rngSimulator.exportOpenButton",

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, calculateRngSummary, getEffectiveRate, normalizeRateString, simulateRngDraws } from "../website/src/features/toolboxes/modules/rngSimulatorUtils.js";
import { calculateAtLeastOneProbability, calculateDrawsForChance, calculateRngCostSummary, calculateRngSummary, getEffectiveRate, normalizeCostString, normalizeRateString, simulateRngDraws } from "../website/src/features/toolboxes/modules/rngSimulatorUtils.js";
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js";
import { compactModuleDataForStorage, createToolboxExportPayload, moduleStorageKey, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeGoalTreeData, normalizeImageAnnotationData, normalizeLinksData, normalizeMemosData, normalizeModuleOrder, normalizeNotepadData, normalizeRandomizerData, normalizeRngSimulatorData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeToolbox, normalizeUrl, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
import { applyGroupedReorderOperation, completeGroupOrder, getBoundaryItemId, getGroupedEntries, moveGroupOrder, moveGroupOrderToEnd, moveGroupOrderToStart, moveItem, moveItemGroup } from "../website/src/hooks/useGroupedReorder.js";
@ -300,8 +300,8 @@ test("rng simulator preserves rate precision and compacts storage", () => {
const normalized = normalizeRngSimulatorData({
activeRateId: "rate2",
rates: [
{ id: "rate1", label: "Drop rare", rate: "2.500", multiplier: 25, perHour: 10 },
{ id: "rate2", label: "Ultra rare", rate: "0.000004", multiplier: 50, perHour: 30, perDay: 120, perMonth: 3600 },
{ id: "rate1", label: "Drop rare", rate: "2.500", costPerDraw: "", multiplier: 25, occurrenceValue: 10, occurrenceUnit: "perHour" },
{ id: "rate2", label: "Ultra rare", rate: "0.000004", costPerDraw: "12,500", multiplier: 50, occurrenceValue: 120, occurrenceUnit: "perDay" },
{ id: "bad", label: "", rate: "4" }
],
settings: { simulationDraws: 2000 },
@ -312,11 +312,17 @@ test("rng simulator preserves rate precision and compacts storage", () => {
assert.equal(normalized.activeRateId, "rate2");
assert.deepEqual(normalized.rates.map((rate) => [rate.label, rate.rate]), [["Drop rare", "2.500"], ["Ultra rare", "0.000004"]]);
assert.deepEqual(normalized.rates.map((rate) => [rate.multiplier, rate.perHour, rate.perDay, rate.perMonth]), [[25, 10, 0, 0], [50, 30, 120, 3600]]);
assert.deepEqual(normalized.rates.map((rate) => rate.costPerDraw), ["", "12.500"]);
assert.deepEqual(normalized.rates.map((rate) => [rate.multiplier, rate.occurrenceValue, rate.occurrenceUnit]), [[25, 10, "perHour"], [50, 120, "perDay"]]);
assert.equal(normalized.history, undefined);
assert.equal(normalizeCostString("0"), "");
assert.equal(normalizeCostString("0012,500"), "12.500");
assert.equal(compactModuleDataForStorage("rngSimulator", normalizeRngSimulatorData({})), null);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[0].costPerDraw, undefined);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].costPerDraw, "12.500");
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].multiplier, 50);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].perMonth, 3600);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].occurrenceValue, 120);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].occurrenceUnit, "perDay");
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).settings.simulationDraws, 2000);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).history, undefined);
});
@ -329,13 +335,24 @@ test("rng simulator calculates probability milestones and deterministic simulati
assert.equal(getEffectiveRate("10", -50), 5);
assert.equal(getEffectiveRate("75", 100), 100);
const hourlySummary = calculateRngSummary({ rate: "25", simulationDraws: 8, perHour: 2, perDay: 0, perMonth: 0 });
const hourlySummary = calculateRngSummary({ rate: "25", simulationDraws: 8, occurrenceValue: 2, occurrenceUnit: "perHour" });
assert.equal(hourlySummary.timeByFrequency.hour, 2);
assert.equal(hourlySummary.timeByFrequency.day, 2 / 24);
assert.equal(hourlySummary.timeByFrequency.month, 2 / 24 / 30);
const multipliedSummary = calculateRngSummary({ rate: "10", multiplier: 100, simulationDraws: 1, perHour: 0, perDay: 0, perMonth: 0 });
const dailySummary = calculateRngSummary({ rate: "25", simulationDraws: 8, occurrenceValue: 2, occurrenceUnit: "perDay" });
assert.equal(dailySummary.timeByFrequency.hour, 48);
assert.equal(dailySummary.timeByFrequency.day, 2);
const multipliedSummary = calculateRngSummary({ rate: "10", multiplier: 100, simulationDraws: 1, occurrenceValue: 0, occurrenceUnit: "perHour" });
assert.equal(multipliedSummary.effectiveRate, 20);
assert.equal(multipliedSummary.averageDraws, 5);
const costSummary = calculateRngCostSummary({ costPerDraw: "12,5", averageDraws: 5, drawsForChance: { 50: 4, 90: 12 }, simulationDraws: 8, simulationSuccesses: 2 });
assert.equal(costSummary.costPerDraw, 12.5);
assert.equal(costSummary.averageSuccessCost, 62.5);
assert.equal(costSummary.targetCosts[50], 50);
assert.equal(costSummary.targetCosts[90], 150);
assert.equal(costSummary.simulationCostPerSuccess, 50);
assert.equal(calculateRngCostSummary({ costPerDraw: "12,5", averageDraws: 5, drawsForChance: {}, simulationDraws: 8, simulationSuccesses: 2, firstSuccessDraw: 3 }).simulationFirstSuccessCost, 37.5);
assert.equal(calculateRngCostSummary({ costPerDraw: "0", averageDraws: 5, drawsForChance: {}, simulationDraws: 8, simulationSuccesses: 2 }), null);
const result = simulateRngDraws("25", 8, (() => {
const values = [0.1, 0.9, 0.2, 0.8, 0.7, 0.1, 0.6, 0.5];
@ -348,7 +365,9 @@ test("rng simulator calculates probability milestones and deterministic simulati
successes: 3,
fails: 5,
observedRate: 37.5,
longestFailStreak: 2
longestFailStreak: 2,
longestSuccessStreak: 1,
firstSuccessDraw: 1
});
const multipliedResult = simulateRngDraws("10", 4, (() => {
@ -357,6 +376,8 @@ test("rng simulator calculates probability milestones and deterministic simulati
return () => values[index++];
})(), 100);
assert.equal(multipliedResult.successes, 2);
assert.equal(multipliedResult.longestSuccessStreak, 1);
assert.equal(multipliedResult.firstSuccessDraw, 1);
});
test("rng simulator text exchange round trips rates and settings", () => {
@ -364,8 +385,8 @@ test("rng simulator text exchange round trips rates and settings", () => {
const rngSimulator = normalizeRngSimulatorData({
activeRateId: "rate2",
rates: [
{ id: "rate1", label: "Drop rare", rate: "2.500", multiplier: 25, perHour: 20 },
{ id: "rate2", label: "Ultra rare", rate: "0.000004", multiplier: 50, perHour: 40, perDay: 120, perMonth: 3600 }
{ id: "rate1", label: "Drop rare", rate: "2.500", costPerDraw: "150", multiplier: 25, occurrenceValue: 20, occurrenceUnit: "perHour" },
{ id: "rate2", label: "Ultra rare", rate: "0.000004", costPerDraw: "", multiplier: 50, occurrenceValue: 120, occurrenceUnit: "perDay" }
],
settings: { simulationDraws: 5000 }
});
@ -374,11 +395,13 @@ test("rng simulator text exchange round trips rates and settings", () => {
const imported = importModuleText("rngSimulator", exported, context);
assert.equal(imported.ok, true);
assert.deepEqual(imported.data.rates.map((rate) => [rate.label, rate.rate]), [["Drop rare", "2.500"], ["Ultra rare", "0.000004"]]);
assert.equal(imported.data.rates.find((rate) => rate.label === "Drop rare")?.costPerDraw, "150");
assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.costPerDraw, "");
assert.equal(imported.data.rates.find((rate) => rate.id === imported.data.activeRateId)?.label, "Ultra rare");
assert.equal(imported.data.rates.find((rate) => rate.label === "Drop rare")?.multiplier, 25);
assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.multiplier, 50);
assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.perHour, 40);
assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.perMonth, 3600);
assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.occurrenceValue, 120);
assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.occurrenceUnit, "perDay");
assert.equal(imported.data.settings.simulationDraws, 5000);
assert.equal(imported.data.history, undefined);
});

View file

@ -4771,20 +4771,21 @@
{
"id": "fnn88zy",
"label": "Elten Piece",
"rate": "0.000004"
"rate": "0.000004",
"costPerDraw": "150",
"multiplier": 320,
"occurrenceValue": 60,
"occurrenceUnit": "perHour"
},
{
"id": "pen-eklecta",
"label": "Pen Eklecta",
"rate": "20.02"
"rate": "20.02",
"costPerDraw": "12.5"
}
],
"settings": {
"multiplier": 320,
"simulationDraws": 100000,
"perHour": 60,
"perDay": 0,
"perMonth": 0
"simulationDraws": 100000
}
},
"m9": {

View file

@ -498,8 +498,17 @@
"icon": "clover",
"title": "Enregistrer un taux",
"preview": {
"kind": "textButton",
"label": "%"
"kind": "iconButton",
"icon": "add"
}
},
{
"group": "Taux",
"icon": "edit",
"title": "Modifier un taux",
"preview": {
"kind": "iconButton",
"icon": "edit"
}
},
{
@ -524,26 +533,26 @@
"features": [
{
"title": "Taux sauvegardés",
"text": "Chaque taux possède un libellé et garde la précision saisie, même pour des probabilités très faibles.",
"text": "Chaque taux possède un libellé, un coût optionnel et garde la précision saisie, même pour des probabilités très faibles.",
"details": [
"La saisie par défaut utilise trois décimales, mais le stockage conserve la chaîne exacte normalisée.",
"Un taux comme 0.000004% reste disponible tel quel pour les calculs et les simulations.",
"La sélection active permet de comparer rapidement plusieurs drops ou chances de réussite."
"Le coût d'un tirage permet d'estimer le coût moyen ou simulé sans imposer d'unité."
]
},
{
"title": "Lecture probabiliste",
"text": "L'outil affiche la moyenne d'essais avant réussite, le temps moyen selon votre rythme et les paliers de chance cumulée.",
"text": "L'onglet Calculs affiche la moyenne d'essais avant réussite, le temps moyen selon votre rythme, les coûts et les paliers de chance cumulée.",
"details": [
"Les occurrences par heure, jour et mois sont indépendantes pour s'adapter à différents usages.",
"Les paliers 50%, 90%, 95% et 99% donnent un repère concret sur la variance.",
"La simulation agrège réussites, échecs et pire série d'échecs sans stocker chaque tirage."
"L'onglet Simulation agrège réussites, échecs, pire série d'échecs et coûts sans stocker chaque tirage."
]
}
],
"importFormat": {
"text": "Le format texte contient les sections [rates] et [settings]. Les taux sont exprimés en pourcentage.",
"example": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true | multiplier=320 | perHour=60 | perDay=0 | perMonth=0\n- Pen Eklecta | rate=20.02 | multiplier=0 | perHour=0 | perDay=0 | perMonth=0\n\n[settings]\nsimulationDraws: 1000"
"text": "Le format texte contient les sections [rates] et [settings]. Les taux sont exprimés en pourcentage et le coût d'un tirage reste optionnel.",
"example": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true | costPerDraw=150 | multiplier=320 | occurrenceValue=60 | occurrenceUnit=perHour\n- Pen Eklecta | rate=20.02 | costPerDraw=12.5 | multiplier=0\n\n[settings]\nsimulationDraws: 1000"
}
},
"images": {
@ -2106,6 +2115,8 @@
"labelLabel": "Libellé",
"labelPlaceholder": "Drop rare",
"rateLabel": "Taux (%)",
"costPerDrawLabel": "Coût d'un tirage",
"costPerDrawPlaceholder": "Optionnel",
"rateLabelRequired": "Libellé requis.",
"rateInvalid": "Taux invalide. Utilisez une valeur supérieure à 0 et inférieure ou égale à 100.",
"addRateButton": "Ajouter",
@ -2113,21 +2124,29 @@
"cancelButton": "Annuler",
"deleteTitle": "Supprimer",
"noActiveRateTitle": "Aucun taux actif",
"activeRateLabel": "Taux brut",
"effectiveRateLabel": "Taux final",
"statsTabLabel": "Calculs",
"simulationTabLabel": "Simulation",
"simulationDrawsLabel": "Tirages simulés",
"perHourLabel": "Par heure",
"perDayLabel": "Par jour",
"perMonthLabel": "Par mois",
"probabilitySectionTitle": "Probabilités avec multiplicateur",
"multiplierLabel": "Multiplicateur (%)",
"clearMultiplierButton": "Réinitialiser le multiplicateur",
"averageDrawsLabel": "Moyenne avant réussite après multiplicateur",
"simulationChanceLabel": "Chance sur les tirages saisis après multiplicateur",
"averageDrawsLabel": "Moyenne avant réussite",
"simulationChanceLabel": "Chance sur les tirages saisis",
"simulationChanceWithoutMultiplierLabel": "Chance sur les tirages saisis sans multiplicateur",
"averageCostLabel": "Coût moyen avant réussite",
"averageTimeTitle": "Temps moyen avant réussite",
"periodLabel": "Période",
"averageTimeLabel": "Temps moyen",
"occurrenceCountLabel": "Nombre d'occurrences",
"hourTimeLabel": "En heures",
"dayTimeLabel": "En jours",
"monthTimeLabel": "En mois",
"chanceTargetsTitle": "Tirages pour atteindre une chance cumulée",
"chanceLabel": "Chance",
"costLabel": "Coût",
"drawsUnit": "tirages",
"hoursUnit": "h",
"daysUnit": "j",
@ -2140,9 +2159,16 @@
"successesLabel": "Réussites",
"failsLabel": "Fails",
"observedRateLabel": "Taux de réussite",
"expectedSuccessesLabel": "Réussites attendues",
"multiplierShortLabel": "Bonus",
"longestFailStreakLabel": "Pire série",
"importPlaceholder": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true | multiplier=320 | perHour=60 | perDay=0 | perMonth=0\n- Pen Eklecta | rate=20.02 | multiplier=0 | perHour=0 | perDay=0 | perMonth=0\n\n[settings]\nsimulationDraws: 1000",
"longestFailStreakLabel": "Pire série d'échec",
"longestSuccessStreakLabel": "Réussite consécutive",
"firstSuccessCostLabel": "Coût de la 1ere réussite",
"costPerSuccessLabel": "Coût moyen/réussite",
"luckySimulationLabel": "Au-dessus",
"neutralSimulationLabel": "Dans la cible",
"unluckySimulationLabel": "Sous l'attendu",
"importPlaceholder": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true | costPerDraw=150 | multiplier=320 | occurrenceValue=60 | occurrenceUnit=perHour\n- Pen Eklecta | rate=20.02 | costPerDraw=12.5 | multiplier=0\n\n[settings]\nsimulationDraws: 1000",
"importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte",
"importModalTitle": "Importer un simulateur de chance",

View file

@ -1,6 +1,7 @@
// Rôle : fournit le simulateur de chance pour calculer et simuler des taux enregistrés.
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { Tabs } from "../../../components/Tabs.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import {
@ -8,15 +9,23 @@ import {
RNG_SIMULATOR_HISTORY_LIMIT,
RNG_SIMULATOR_LIMITS,
addRngSimulationHistory,
calculateAtLeastOneProbability,
calculateRngCostSummary,
calculateRngSummary,
clampDecimal,
clampInteger,
getEffectiveRate,
isValidRateString,
normalizeCostString,
normalizeRateString,
simulateRngDraws
} from "./rngSimulatorUtils.js";
const CHANCE_TARGETS = [50, 90, 95, 99];
const RNG_TABS = [
{ id: "stats", label: "Calculs" },
{ id: "simulation", label: "Simulation" }
];
function formatNumber(value, maximumFractionDigits = 2) {
if (!Number.isFinite(value)) return "—";
@ -28,6 +37,11 @@ function formatPercent(value, maximumFractionDigits = 6) {
return `${value.toLocaleString("fr-FR", { maximumFractionDigits })} %`;
}
function formatCost(value) {
if (!Number.isFinite(value)) return "—";
return formatNumber(value, 0);
}
function formatDrawTarget(value, unit) {
if (!Number.isFinite(value)) return `— ${unit}`;
if (value >= 1000000) {
@ -73,14 +87,18 @@ function normalizeSessionHistoryEntry(entry, index) {
const successes = clampInteger(entry?.successes, 0, draws, 0);
return {
id: entry?.id || `rng-session-${index}`,
rateId: String(entry?.rateId || "").trim(),
label: String(entry?.label || "").trim() || "Simulation",
rate: normalizeRateString(entry?.rate),
costPerDraw: normalizeCostString(entry?.costPerDraw),
multiplier: clampDecimal(entry?.multiplier, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0),
draws,
successes,
fails: draws - successes,
observedRate: draws > 0 ? (successes / draws) * 100 : 0,
longestFailStreak: clampInteger(entry?.longestFailStreak, 0, draws, 0)
longestFailStreak: clampInteger(entry?.longestFailStreak, 0, draws, 0),
longestSuccessStreak: clampInteger(entry?.longestSuccessStreak, 0, draws, 0),
firstSuccessDraw: clampInteger(entry?.firstSuccessDraw, 0, draws, 0)
};
}
@ -94,33 +112,81 @@ function writeSimulationHistory(storageKey, history) {
}
}
export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeOpen }) {
function historyEntryMatchesRate(entry, rate) {
if (!entry || !rate) return false;
if (entry.rateId) return entry.rateId === rate.id;
return entry.label === rate.label && entry.rate === rate.rate;
}
export function RngSimulatorModule({ toolboxId, moduleId, context, editing = false, setEditing = () => {}, textExchangeOpen }) {
const data = context.normalizeRngSimulatorData(context.getModuleData(toolboxId, moduleId, {}));
const textContent = context.moduleText?.rngSimulator || {};
const tabs = useMemo(() => [
{ ...RNG_TABS[0], label: textContent.statsTabLabel || RNG_TABS[0].label },
{ ...RNG_TABS[1], label: textContent.simulationTabLabel || RNG_TABS[1].label }
], [textContent.simulationTabLabel, textContent.statsTabLabel]);
const historyStorageKey = `rng-simulator:${toolboxId}:${moduleId}:history`;
const [activeTab, setActiveTab] = useState("stats");
const [history, setHistory] = useState(() => readSimulationHistory(historyStorageKey));
const [editingRateId, setEditingRateId] = useState("");
const [multiplierDraft, setMultiplierDraft] = useState(null);
const [occurrenceDrafts, setOccurrenceDrafts] = useState({ perHour: null, perDay: null, perMonth: null });
const [costDraft, setCostDraft] = useState(null);
const [occurrenceUnit, setOccurrenceUnit] = useState("perHour");
const [occurrenceDraft, setOccurrenceDraft] = useState(null);
const rateFormRef = useRef(null);
const previousEditingRef = useRef(editing);
const rateDraft = useDraftForm({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
const rateFormOpen = editing || Boolean(editingRateId);
const activeRate = data.rates.find((rate) => rate.id === data.activeRateId) || data.rates[0] || null;
const summary = useMemo(() => activeRate ? calculateRngSummary({
rate: activeRate.rate,
multiplier: activeRate.multiplier,
simulationDraws: data.settings.simulationDraws,
perHour: activeRate.perHour,
perDay: activeRate.perDay,
perMonth: activeRate.perMonth
occurrenceValue: activeRate.occurrenceValue,
occurrenceUnit: activeRate.occurrenceUnit
}) : null, [activeRate, data.settings.simulationDraws]);
const costSummary = useMemo(() => activeRate && summary ? calculateRngCostSummary({
costPerDraw: costDraft ?? activeRate.costPerDraw,
averageDraws: summary.averageDraws,
drawsForChance: summary.drawsForChance,
simulationDraws: data.settings.simulationDraws,
simulationSuccesses: 0
}) : null, [activeRate, costDraft, data.settings.simulationDraws, summary]);
const activeHistory = useMemo(() => history.filter((entry) => historyEntryMatchesRate(entry, activeRate)), [activeRate, history]);
const chanceWithoutMultiplier = activeRate ? calculateAtLeastOneProbability(activeRate.rate, data.settings.simulationDraws, 0) : 0;
useEffect(() => {
setHistory(readSimulationHistory(historyStorageKey));
}, [historyStorageKey]);
useEffect(() => {
const wasEditing = previousEditingRef.current;
if (wasEditing && !editing) setEditingRateId("");
if (!wasEditing && editing && !editingRateId) rateDraft.reset({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
previousEditingRef.current = editing;
// Le hook useDraftForm expose reset sans identité stable; cette transition ne doit dépendre que de l'ouverture.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editing, editingRateId]);
useEffect(() => {
if (!rateFormOpen) return undefined;
function closeOnOutsidePointer(event) {
if (event.target.closest?.(".module-edit-button")) return;
if (!rateFormRef.current?.contains(event.target)) {
setEditingRateId("");
setEditing(false);
}
}
document.addEventListener("pointerdown", closeOnOutsidePointer);
return () => document.removeEventListener("pointerdown", closeOnOutsidePointer);
}, [rateFormOpen, setEditing]);
useEffect(() => {
setMultiplierDraft(null);
setOccurrenceDrafts({ perHour: null, perDay: null, perMonth: null });
}, [activeRate?.id]);
setCostDraft(null);
setOccurrenceDraft(null);
setOccurrenceUnit(activeRate?.occurrenceUnit || "perHour");
}, [activeRate?.id, activeRate?.occurrenceUnit]);
function save(nextData) {
context.setModuleData(toolboxId, moduleId, nextData, "rngSimulator");
@ -158,35 +224,57 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
if (multiplierDraft === null) return;
const raw = multiplierDraft.trim();
const parsed = Number(raw.replace(",", "."));
if (!raw || !Number.isFinite(parsed)) {
setMultiplierDraft(null);
updateActiveRateSettings("multiplier", 0);
return;
}
if (!raw || !Number.isFinite(parsed)) updateActiveRateSettings("multiplier", 0);
setMultiplierDraft(null);
}
function clearMultiplier() {
setMultiplierDraft(null);
updateActiveRateSettings("multiplier", 0);
function updateCost(value) {
setCostDraft(value);
}
function updateOccurrence(field, value) {
setOccurrenceDrafts((drafts) => ({ ...drafts, [field]: value }));
function blurCost() {
if (!activeRate || costDraft === null) return;
save({
...data,
rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, costPerDraw: normalizeCostString(costDraft) } : rate)
});
setCostDraft(null);
}
function updateOccurrence(value) {
setOccurrenceDraft(value);
if (!activeRate) return;
if (String(value).trim() === "") {
updateActiveRateSettings(field, 0);
save({
...data,
rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, occurrenceValue: 0, occurrenceUnit } : rate)
});
return;
}
updateActiveRateSettings(field, value);
const nextValue = clampDecimal(value, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, activeRate.occurrenceValue);
save({
...data,
rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, occurrenceValue: nextValue, occurrenceUnit } : rate)
});
}
function blurOccurrence(field) {
const value = occurrenceDrafts[field];
if (value === null) return;
function changeOccurrenceUnit(unit) {
setOccurrenceUnit(unit);
setOccurrenceDraft(null);
if (!activeRate) return;
save({
...data,
rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, occurrenceValue: activeRate.occurrenceValue, occurrenceUnit: unit } : rate)
});
}
function blurOccurrence() {
if (occurrenceDraft === null) return;
const value = occurrenceDraft;
const raw = value.trim();
const parsed = Number(raw.replace(",", "."));
if (!raw || !Number.isFinite(parsed)) updateActiveRateSettings(field, 0);
setOccurrenceDrafts((drafts) => ({ ...drafts, [field]: null }));
if (!raw || !Number.isFinite(parsed)) updateOccurrence("");
setOccurrenceDraft(null);
}
function submitRate(event) {
@ -200,22 +288,28 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
setEditingRateId("");
} else {
const id = context.uid("rng");
save({ ...data, activeRateId: data.activeRateId || id, rates: [...data.rates, { id, label, rate }] });
save({ ...data, activeRateId: id, rates: [...data.rates, { id, label, rate }] });
}
reset({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
setEditing(false);
return "";
});
}
function editRate(rate) {
save({ ...data, activeRateId: rate.id });
setEditingRateId(rate.id);
setEditing(true);
rateDraft.reset({ label: rate.label, rate: rate.rate });
}
function cancelEdit() {
setEditingRateId("");
rateDraft.reset({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
setEditing(false);
}
function selectRate(rate) {
save({ ...data, activeRateId: rate.id });
}
function deleteRate(rateId) {
@ -229,8 +323,10 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
const result = simulateRngDraws(activeRate.rate, data.settings.simulationDraws, Math.random, activeRate.multiplier);
const entry = {
id: context.uid("rng"),
rateId: activeRate.id,
label: activeRate.label,
rate: activeRate.rate,
costPerDraw: activeRate.costPerDraw,
multiplier: activeRate.multiplier,
...result
};
@ -240,8 +336,9 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
}
function clearHistory() {
setHistory([]);
writeSimulationHistory(historyStorageKey, []);
const nextHistory = activeRate ? history.filter((entry) => !historyEntryMatchesRate(entry, activeRate)) : [];
setHistory(nextHistory);
writeSimulationHistory(historyStorageKey, nextHistory);
}
return (
@ -252,15 +349,17 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
</div>
)}
<div className="rng-simulator-tool">
<section className="rng-simulator-grid">
<div className="rng-simulator-rates-stack">
<div className="rng-simulator-panel rng-simulator-rate-add-panel">
<section className="rng-simulator-panel rng-simulator-rates-panel">
<header>
<div>
<h3>{editingRateId ? textContent.editRateTitle || "Modifier un taux" : textContent.addRateTitle || "Ajouter un taux"}</h3>
<h3>{textContent.ratesTitle || "Taux enregistrés"}</h3>
<span className="rng-simulator-count">{data.rates.length}</span>
</div>
</header>
<form className="rng-simulator-rate-form" onSubmit={submitRate}>
<div className="rng-simulator-rate-layout">
{rateFormOpen && (
<form className="rng-simulator-rate-form" ref={rateFormRef} onSubmit={submitRate}>
<label className="rng-simulator-field">
<span>{textContent.labelLabel || "Libellé"}</span>
<input {...rateDraft.getFieldProps("label", { className: "nds-input", "data-size": "sm", type: "text", placeholder: textContent.labelPlaceholder || "Drop rare" })} />
@ -275,28 +374,26 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
<Icon name={editingRateId ? "save" : "add"} />
<span>{editingRateId ? textContent.saveRateButton || "Enregistrer" : textContent.addRateButton || "Ajouter"}</span>
</button>
{editingRateId && (
{data.rates.length > 0 && (
<button className="nds-button" data-size="sm" type="button" onClick={cancelEdit}>
{textContent.cancelButton || "Annuler"}
</button>
)}
</div>
</form>
</div>
<div className="rng-simulator-panel rng-simulator-rates-panel">
<header>
<div>
<h3>{textContent.ratesTitle || "Taux enregistrés"}</h3>
</div>
<span className="rng-simulator-count">{data.rates.length}</span>
</header>
)}
<div className="rng-simulator-rates nds-scrollbox">
{data.rates.length ? data.rates.map((rate) => (
<article className={`rng-simulator-rate-row ${rate.id === activeRate?.id ? "is-active" : ""}`} key={rate.id}>
<button type="button" onClick={() => editRate(rate)}>
<button type="button" onClick={() => selectRate(rate)}>
<strong>{rate.label}</strong>
<span>{rate.rate} %</span>
</button>
<div className="rng-simulator-rate-actions">
<button className="nds-icon-button" data-size="xs" type="button" onClick={() => editRate(rate)} aria-label={`${textContent.editRateTitle || "Modifier"} ${rate.label}`} title={textContent.editRateTitle || "Modifier"}>
<Icon name="edit" />
</button>
<button className="nds-icon-button" data-size="xs" data-variant="danger" type="button" onClick={() => deleteRate(rate.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${rate.label}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
@ -304,78 +401,91 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
</article>
)) : <p className="rng-simulator-empty">{textContent.emptyRates || "Aucun taux enregistré."}</p>}
</div>
</div>
</div>
<div className="rng-simulator-panel rng-simulator-summary-panel">
<header>
<div>
<h3>{activeRate?.label || textContent.noActiveRateTitle || "Aucun taux actif"}</h3>
{activeRate && summary && (
<div className="rng-simulator-active-summary">
<RngInfo label={textContent.activeRateLabel || "Taux brut"} value={`${activeRate.rate} %`} />
</div>
{activeRate && <span className="rng-simulator-active-rate">{activeRate.rate} %</span>}
)}
</div>
</section>
<section className="rng-simulator-panel rng-simulator-tabs-panel">
<header>
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="rng-simulator-tabs" />
</header>
{activeTab === "stats" && (
<div className="rng-simulator-tab-content">
{activeRate && summary ? (
<>
<div className="rng-simulator-section">
<span className="rng-simulator-section-title">{textContent.probabilitySectionTitle || "Probabilités avec multiplicateur"}</span>
<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>
<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="text" inputMode="decimal" value={multiplierDraft ?? String(activeRate.multiplier ?? 0)} onChange={(event) => updateMultiplier(event.target.value)} onBlur={blurMultiplier} aria-label={textContent.multiplierLabel || "Multiplicateur (%)"} />
<span>%</span>
<button className="nds-icon-button" data-size="xs" type="button" onClick={clearMultiplier} aria-label={textContent.clearMultiplierButton || "Réinitialiser le multiplicateur"} title={textContent.clearMultiplierButton || "Réinitialiser le multiplicateur"}>
<Icon name="rubber" />
</button>
</div>
</div>
<div className="rng-simulator-stat-grid">
<RngStat label={textContent.averageDrawsLabel || "Moyenne avant réussite" } value={`${formatNumber(summary.averageDraws, 2)} ${textContent.drawsUnit || "tirages"}`} />
<RngStat label={textContent.simulationChanceLabel || "Chance sur les tirages saisis"} value={formatPercent(summary.chanceForSimulationDraws * 100, 6)} />
<RngStat label={textContent.effectiveRateLabel || "Taux final"} value={formatPercent(summary.effectiveRate, 6)} />
<RngStat label={textContent.averageDrawsLabel || "Moyenne avant réussite"} value={`${formatNumber(summary.averageDraws, 2)} ${textContent.drawsUnit || "tirages"}`} />
</div>
</div>
<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"} />
</label>
<RngStat label={textContent.averageCostLabel || "Coût moyen avant réussite"} value={formatCost(costSummary?.averageSuccessCost)} />
</div>
</div>
</div>
<div className="rng-simulator-section">
<span className="rng-simulator-section-title">{textContent.averageTimeTitle || "Temps moyen avant réussite"}</span>
<span className="rng-simulator-subtitle">{textContent.occurrenceCountLabel || "Nombre d'occurrences"}</span>
<div className="rng-simulator-settings rng-simulator-three-columns">
<div className="rng-simulator-occurrence-control">
<label className="rng-simulator-field">
<span>{textContent.perHourLabel || "Par heure"}</span>
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDrafts.perHour ?? String(activeRate.perHour ?? 0)} onChange={(event) => updateOccurrence("perHour", event.target.value)} onBlur={() => blurOccurrence("perHour")} />
<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} />
</label>
<label className="rng-simulator-field">
<span>{textContent.perDayLabel || "Par jour"}</span>
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDrafts.perDay ?? String(activeRate.perDay ?? 0)} onChange={(event) => updateOccurrence("perDay", event.target.value)} onBlur={() => blurOccurrence("perDay")} />
</label>
<label className="rng-simulator-field">
<span>{textContent.perMonthLabel || "Par mois"}</span>
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDrafts.perMonth ?? String(activeRate.perMonth ?? 0)} onChange={(event) => updateOccurrence("perMonth", event.target.value)} onBlur={() => blurOccurrence("perMonth")} />
<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-grid">
<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.chanceTargetsTitle || "Tirages pour atteindre une chance cumulée"}</span>
<div className="rng-simulator-targets">
<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>
{CHANCE_TARGETS.map((target) => (
<RngStat key={target} label={`${target} %`} value={formatDrawTarget(summary.drawsForChance[target], textContent.drawsUnit || "tirages")} title={`${formatNumber(summary.drawsForChance[target], 0)} ${textContent.drawsUnit || "tirages"}`} />
<RngTargetRow costSummary={costSummary} draws={summary.drawsForChance[target]} key={target} target={target} textContent={textContent} />
))}
</div>
</div>
</>
) : <p className="rng-simulator-empty">{textContent.emptySummary || "Ajoutez un taux pour afficher les calculs."}</p>}
</div>
</section>
)}
<section className="rng-simulator-panel rng-simulator-simulation-panel">
<header>
<div>
<h3>{textContent.simulationTitle || "Résultats simulés"}</h3>
</div>
</header>
<div className="rng-simulator-simulation-bar">
{activeTab === "simulation" && (
<div className="rng-simulator-tab-content">
<div className="rng-simulator-simulation-overview">
<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>
@ -386,36 +496,41 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
<span>{textContent.simulateButton || "Simuler"}</span>
</button>
</div>
<button className="nds-button" data-size="sm" data-variant="danger" type="button" onClick={clearHistory} disabled={!history.length}>
{activeRate && summary && (
<div className="rng-simulator-simulation-summary">
<RngStat label={textContent.simulationChanceLabel || "Chance sur les tirages saisis"} value={formatPercent(summary.chanceForSimulationDraws * 100, 6)} />
<RngStat label={textContent.simulationChanceWithoutMultiplierLabel || "Chance sur les tirages saisis sans multiplicateur"} value={formatPercent(chanceWithoutMultiplier * 100, 6)} />
</div>
)}
</div>
<div className="rng-simulator-history-toolbar">
<button className="nds-button" data-size="sm" data-variant="danger" type="button" onClick={clearHistory} disabled={!activeHistory.length}>
<Icon name="rubber" />
<span>{textContent.clearHistoryButton || "Supprimer les simulations"}</span>
</button>
</div>
<div className="rng-simulator-history nds-scrollbox">
{history.length ? history.map((entry, index) => (
<article className={`rng-simulator-history-row ${index === 0 ? "is-latest" : ""}`} key={entry.id}>
<div>
<strong>{entry.label}</strong>
</div>
<div className="rng-simulator-history-rate">
<span>{entry.rate} %</span>
<small>{textContent.multiplierShortLabel || "Bonus"} {formatSignedPercent(entry.multiplier ?? 0)}</small>
</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.longestFailStreakLabel || "Pire série"}</small><b>{entry.longestFailStreak}</b></span>
</div>
</article>
{activeHistory.length ? activeHistory.map((entry, index) => (
<RngHistoryRow entry={entry} index={index} key={entry.id} textContent={textContent} />
)) : <p className="rng-simulator-empty">{textContent.emptyHistory || "Aucune simulation."}</p>}
</div>
</div>
)}
</section>
</div>
</>
);
}
function RngInfo({ label, value }) {
return (
<span>
<small>{label}</small>
<strong>{value}</strong>
</span>
);
}
function RngStat({ label, value, title }) {
return (
<article className="rng-simulator-stat" title={title}>
@ -424,3 +539,59 @@ function RngStat({ label, value, title }) {
</article>
);
}
function RngTargetRow({ costSummary, draws, target, textContent }) {
return (
<>
<strong>{target} %</strong>
<span title={`${formatNumber(draws, 0)} ${textContent.drawsUnit || "tirages"}`}>{formatDrawTarget(draws, textContent.drawsUnit || "tirages")}</span>
<span>{formatCost(costSummary?.targetCosts?.[target])}</span>
</>
);
}
function RngHistoryRow({ entry, index, textContent }) {
const expectedSuccesses = entry.draws * (getEffectiveRate(entry.rate, entry.multiplier) / 100);
const successDelta = entry.successes - expectedSuccesses;
const luckLabel = successDelta > 0
? textContent.luckySimulationLabel || "Au-dessus"
: successDelta < 0
? textContent.unluckySimulationLabel || "Sous l'attendu"
: textContent.neutralSimulationLabel || "Dans la cible";
const costs = calculateRngCostSummary({
costPerDraw: entry.costPerDraw,
averageDraws: 0,
drawsForChance: {},
simulationDraws: entry.draws,
simulationSuccesses: entry.successes,
firstSuccessDraw: entry.firstSuccessDraw
});
return (
<article className={`rng-simulator-history-row ${index === 0 ? "is-latest" : ""}`}>
<div className="rng-simulator-history-heading">
<div className="rng-simulator-history-identity">
<strong>{entry.label}</strong>
<small>{luckLabel} · {formatSignedPercent((successDelta / entry.draws) * 100, 4)}</small>
</div>
<div className="rng-simulator-history-score">
<b>{entry.successes}</b>
<small>{textContent.successesLabel || "Réussites"}</small>
</div>
<div className="rng-simulator-history-rate">
<span>{entry.rate} %</span>
<small>{textContent.multiplierShortLabel || "Bonus"} {formatSignedPercent(entry.multiplier ?? 0)}</small>
</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>
</div>
</article>
);
}

View file

@ -3,6 +3,7 @@ export const RNG_SIMULATOR_HISTORY_LIMIT = 20;
export const RNG_SIMULATOR_DEFAULT_RATE = "2.500";
export const RNG_SIMULATOR_LIMITS = {
rateMax: 100,
costPerDrawMax: 1000000000000,
multiplierMin: -1000000,
multiplierMax: 1000000,
simulationDrawsMin: 1,
@ -11,6 +12,8 @@ export const RNG_SIMULATOR_LIMITS = {
occurrenceRateMax: 1000000000
};
export const RNG_SIMULATOR_OCCURRENCE_UNITS = ["perHour", "perDay", "perMonth"];
export function clampInteger(value, min, max, fallback = min) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) return fallback;
@ -40,6 +43,21 @@ export function normalizeRateString(value, fallback = RNG_SIMULATOR_DEFAULT_RATE
return cleanInteger;
}
export function normalizeCostString(value, fallback = "") {
const raw = String(value ?? "").trim().replace(",", ".");
if (!raw) return fallback;
if (!/^\d*(?:\.\d+)?$/.test(raw) || raw === ".") return fallback;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
if (parsed > RNG_SIMULATOR_LIMITS.costPerDrawMax) return String(RNG_SIMULATOR_LIMITS.costPerDrawMax);
const [integerPart = "0", decimalPart] = raw.split(".");
const cleanInteger = String(Number.parseInt(integerPart || "0", 10));
if (decimalPart !== undefined) return `${cleanInteger}.${decimalPart}`;
return cleanInteger;
}
export function isValidRateString(value) {
const raw = String(value ?? "").trim().replace(",", ".").replace(/%$/, "").trim();
if (!raw || !/^\d*(?:\.\d+)?$/.test(raw) || raw === ".") return false;
@ -72,21 +90,20 @@ export function calculateDrawsForChance(rate, targetChance, multiplier = 0) {
return Math.ceil(Math.log1p(-target) / Math.log1p(-probability));
}
export function calculateRngSummary({ rate, multiplier = 0, simulationDraws, perHour, perDay, perMonth }) {
export function normalizeOccurrenceUnit(value) {
return RNG_SIMULATOR_OCCURRENCE_UNITS.includes(value) ? value : "perHour";
}
export function calculateRngSummary({ rate, multiplier = 0, simulationDraws, occurrenceValue = 0, occurrenceUnit = "perHour" }) {
const effectiveRate = getEffectiveRate(rate, multiplier);
const probability = effectiveRate / 100;
const averageDraws = probability > 0 ? 1 / probability : 0;
const cleanPerHour = clampDecimal(perHour, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0);
const cleanPerDay = clampDecimal(perDay, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0);
const cleanPerMonth = clampDecimal(perMonth, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0);
const cleanOccurrenceValue = clampDecimal(occurrenceValue, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0);
const cleanOccurrenceUnit = normalizeOccurrenceUnit(occurrenceUnit);
let averageHours = 0;
if (cleanPerHour > 0) averageHours = averageDraws / cleanPerHour;
else if (cleanPerDay > 0) averageHours = (averageDraws / cleanPerDay) * 24;
else if (cleanPerMonth > 0) averageHours = (averageDraws / cleanPerMonth) * 24 * 30;
const explicitHourTime = cleanPerHour > 0 && averageDraws > 0 ? averageDraws / cleanPerHour : 0;
const explicitDayTime = cleanPerDay > 0 && averageDraws > 0 ? averageDraws / cleanPerDay : 0;
const explicitMonthTime = cleanPerMonth > 0 && averageDraws > 0 ? averageDraws / cleanPerMonth : 0;
if (cleanOccurrenceValue > 0 && cleanOccurrenceUnit === "perHour") averageHours = averageDraws / cleanOccurrenceValue;
else if (cleanOccurrenceValue > 0 && cleanOccurrenceUnit === "perDay") averageHours = (averageDraws / cleanOccurrenceValue) * 24;
else if (cleanOccurrenceValue > 0 && cleanOccurrenceUnit === "perMonth") averageHours = (averageDraws / cleanOccurrenceValue) * 24 * 30;
return {
probability,
@ -94,9 +111,9 @@ export function calculateRngSummary({ rate, multiplier = 0, simulationDraws, per
averageDraws,
chanceForSimulationDraws: calculateAtLeastOneProbability(rate, simulationDraws, multiplier),
timeByFrequency: {
hour: explicitHourTime || averageHours,
day: explicitDayTime || (averageHours > 0 ? averageHours / 24 : 0),
month: explicitMonthTime || (averageHours > 0 ? averageHours / 24 / 30 : 0)
hour: averageHours,
day: averageHours > 0 ? averageHours / 24 : 0,
month: averageHours > 0 ? averageHours / 24 / 30 : 0
},
drawsForChance: {
50: calculateDrawsForChance(rate, 0.5, multiplier),
@ -107,19 +124,45 @@ export function calculateRngSummary({ rate, multiplier = 0, simulationDraws, per
};
}
export function calculateRngCostSummary({ costPerDraw, averageDraws, drawsForChance, simulationDraws, simulationSuccesses, firstSuccessDraw = 0 }) {
const cleanCost = Number(normalizeCostString(costPerDraw));
if (!Number.isFinite(cleanCost) || cleanCost <= 0) return null;
const cleanSimulationDraws = clampInteger(simulationDraws, RNG_SIMULATOR_LIMITS.simulationDrawsMin, RNG_SIMULATOR_LIMITS.simulationDrawsMax, 0);
const cleanSuccesses = clampInteger(simulationSuccesses, 0, cleanSimulationDraws, 0);
const cleanFirstSuccessDraw = clampInteger(firstSuccessDraw, 0, cleanSimulationDraws, 0);
const targetCosts = {};
Object.entries(drawsForChance || {}).forEach(([target, draws]) => {
targetCosts[target] = Number.isFinite(draws) ? draws * cleanCost : Number.POSITIVE_INFINITY;
});
return {
costPerDraw: cleanCost,
averageSuccessCost: Number.isFinite(averageDraws) ? averageDraws * cleanCost : 0,
targetCosts,
simulationCostPerSuccess: cleanSuccesses > 0 ? (cleanSimulationDraws * cleanCost) / cleanSuccesses : 0,
simulationFirstSuccessCost: cleanFirstSuccessDraw > 0 ? cleanFirstSuccessDraw * cleanCost : 0
};
}
export function simulateRngDraws(rate, draws, random = Math.random, multiplier = 0) {
const probability = getEffectiveRate(rate, multiplier) / 100;
const cleanDraws = clampInteger(draws, RNG_SIMULATOR_LIMITS.simulationDrawsMin, RNG_SIMULATOR_LIMITS.simulationDrawsMax, 1);
let successes = 0;
let currentFailStreak = 0;
let currentSuccessStreak = 0;
let firstSuccessDraw = 0;
let longestFailStreak = 0;
let longestSuccessStreak = 0;
for (let index = 0; index < cleanDraws; index += 1) {
if (random() < probability) {
successes += 1;
if (!firstSuccessDraw) firstSuccessDraw = index + 1;
currentSuccessStreak += 1;
longestSuccessStreak = Math.max(longestSuccessStreak, currentSuccessStreak);
currentFailStreak = 0;
} else {
currentFailStreak += 1;
currentSuccessStreak = 0;
longestFailStreak = Math.max(longestFailStreak, currentFailStreak);
}
}
@ -129,7 +172,9 @@ export function simulateRngDraws(rate, draws, random = Math.random, multiplier =
successes,
fails: cleanDraws - successes,
observedRate: cleanDraws > 0 ? (successes / cleanDraws) * 100 : 0,
longestFailStreak
longestFailStreak,
longestSuccessStreak,
firstSuccessDraw
};
}

View file

@ -6,7 +6,9 @@ export function exportRngSimulatorText(data) {
lines.push("@activeRateId: " + (data.activeRateId || ""));
lines.push("[rates]");
(data.rates || []).forEach((rate) => {
lines.push(`- ${rate.label} | rate=${rate.rate}${rate.id === data.activeRateId ? " | active=true" : ""} | multiplier=${rate.multiplier ?? 0} | perHour=${rate.perHour || 0} | perDay=${rate.perDay || 0} | perMonth=${rate.perMonth || 0}`);
const cost = rate.costPerDraw ? ` | costPerDraw=${rate.costPerDraw}` : "";
const occurrences = rate.occurrenceValue ? ` | occurrenceValue=${rate.occurrenceValue} | occurrenceUnit=${rate.occurrenceUnit || "perHour"}` : "";
lines.push(`- ${rate.label} | rate=${rate.rate}${rate.id === data.activeRateId ? " | active=true" : ""}${cost} | multiplier=${rate.multiplier ?? 0}${occurrences}`);
});
lines.push(
"",
@ -51,10 +53,10 @@ export function importRngSimulatorText(text, context) {
id,
label: title,
rate: pipeMeta.rate,
costPerDraw: pipeMeta.costPerDraw,
multiplier: pipeMeta.multiplier,
perHour: pipeMeta.perHour,
perDay: pipeMeta.perDay,
perMonth: pipeMeta.perMonth
occurrenceValue: pipeMeta.occurrenceValue,
occurrenceUnit: pipeMeta.occurrenceUnit
});
if (pipeMeta.active === "true") data.activeRateId = id;
return;

View file

@ -3,6 +3,8 @@ import {
RNG_SIMULATOR_LIMITS,
clampDecimal,
clampInteger,
normalizeCostString,
normalizeOccurrenceUnit,
normalizeRateString
} from "../../modules/rngSimulatorUtils.js";
import { uid } from "./shared.js";
@ -14,10 +16,10 @@ function normalizeRateEntry(entry) {
id: entry?.id || uid("rng"),
label,
rate: normalizeRateString(entry?.rate),
costPerDraw: normalizeCostString(entry?.costPerDraw),
multiplier: clampDecimal(entry?.multiplier, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0),
perHour: clampDecimal(entry?.perHour, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0),
perDay: clampDecimal(entry?.perDay, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0),
perMonth: clampDecimal(entry?.perMonth, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0)
occurrenceValue: clampDecimal(entry?.occurrenceValue, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0),
occurrenceUnit: normalizeOccurrenceUnit(entry?.occurrenceUnit)
};
}
@ -46,10 +48,12 @@ export function compactRngSimulatorDataForStorage(value) {
if (normalized.rates.length) {
compact.rates = normalized.rates.map((rate) => {
const compactRate = { id: rate.id, label: rate.label, rate: rate.rate };
if (rate.costPerDraw) compactRate.costPerDraw = rate.costPerDraw;
if (rate.multiplier !== 0) compactRate.multiplier = rate.multiplier;
if (rate.perHour !== 0) compactRate.perHour = rate.perHour;
if (rate.perDay !== 0) compactRate.perDay = rate.perDay;
if (rate.perMonth !== 0) compactRate.perMonth = rate.perMonth;
if (rate.occurrenceValue !== 0) {
compactRate.occurrenceValue = rate.occurrenceValue;
compactRate.occurrenceUnit = rate.occurrenceUnit;
}
return compactRate;
});
}

View file

@ -71,7 +71,6 @@ export const TOOLBOX_MODULE_DEFINITIONS = {
mode: "Taux + simulations",
icon: "clover",
editable: true,
addable: false,
scrollable: true,
libraryCategory: "calculationData"
},

View file

@ -1,26 +1,18 @@
// Rôle : porte les surfaces et grilles propres au simulateur de chance.
.rng-simulator-tool {
display: grid;
gap: var(--space-4);
padding: var(--space-4);
}
.rng-simulator-grid {
display: grid;
grid-template-columns: minmax(260px, 0.9fr) minmax(0, 1.4fr);
gap: var(--space-4);
align-items: start;
min-width: 0;
gap: var(--space-3);
padding: var(--space-3);
}
.rng-simulator-panel {
display: grid;
gap: 10px;
gap: var(--space-3);
min-width: 0;
padding: 10px;
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(16, 20, 38, 0.48);
background: rgba(16, 20, 38, 0.44);
}
.rng-simulator-panel > header {
@ -30,14 +22,15 @@
gap: var(--space-3);
min-width: 0;
min-height: 0;
border-bottom: 1px solid rgba(150, 165, 205, 0.12);
padding-bottom: var(--space-2);
border-bottom: 1px solid rgba(150, 165, 205, 0.1);
text-align: left;
}
.rng-simulator-panel > header > div:first-child {
display: flex;
flex-wrap: wrap;
align-items: baseline;
align-items: center;
gap: 8px;
min-width: 0;
}
@ -50,10 +43,9 @@
text-align: left;
}
.rng-simulator-count,
.rng-simulator-active-rate {
.rng-simulator-count {
flex: 0 0 auto;
padding: 4px 8px;
padding: 3px 7px;
border: 1px solid rgba(246, 196, 83, 0.28);
border-radius: var(--radius-sm);
color: var(--color-accent-gold);
@ -62,20 +54,35 @@
font-variant-numeric: tabular-nums;
}
.rng-simulator-rate-layout {
display: grid;
grid-template-columns: minmax(220px, 0.9fr) minmax(260px, 1.3fr);
gap: var(--space-3);
align-items: start;
min-width: 0;
}
.rng-simulator-rate-form {
display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(2, minmax(0, 1fr)) auto;
gap: var(--space-3);
align-items: end;
min-width: 0;
padding: var(--space-3);
border: 1px solid rgba(150, 165, 205, 0.12);
border-radius: var(--radius-sm);
background: rgba(5, 7, 17, 0.24);
}
.rng-simulator-rates {
display: grid;
gap: 6px;
max-height: 220px;
min-height: 72px;
max-height: 176px;
min-height: 52px;
padding-right: 4px;
}
.rng-simulator-rates-stack {
display: grid;
gap: var(--space-4);
min-width: 0;
}
.rng-simulator-rate-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@ -84,7 +91,7 @@
min-width: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: rgba(11, 14, 27, 0.56);
background: rgba(11, 14, 27, 0.54);
overflow: hidden;
}
@ -104,10 +111,10 @@
}
.rng-simulator-rate-row > button {
display: flex;
align-items: center;
justify-content: flex-start;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
min-width: 0;
padding: 7px 8px;
border: 0;
@ -144,44 +151,57 @@
padding: 3px;
}
.rng-simulator-rate-form,
.rng-simulator-settings {
.rng-simulator-active-summary {
display: grid;
gap: var(--space-3);
}
.rng-simulator-settings {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rng-simulator-three-columns {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.rng-simulator-section {
display: grid;
gap: var(--space-3);
grid-template-columns: minmax(0, 1fr);
gap: 8px;
min-width: 0;
padding-top: var(--space-3);
border-top: 1px solid rgba(150, 165, 205, 0.1);
}
.rng-simulator-summary-panel > header + .rng-simulator-section {
padding-top: var(--space-2);
border-top: 0;
.rng-simulator-active-summary > span,
.rng-simulator-stat {
display: grid;
align-content: space-between;
gap: 8px;
min-width: 0;
min-height: 90px;
padding: 9px;
border: 1px solid rgba(150, 165, 205, 0.1);
border-radius: var(--radius-sm);
background: rgba(5, 7, 17, 0.22);
}
.rng-simulator-section-title {
color: var(--color-text-primary);
font-size: var(--font-size-sm);
font-weight: 900;
}
.rng-simulator-subtitle {
.rng-simulator-active-summary small,
.rng-simulator-table > span:nth-child(-n + 3),
.rng-simulator-history-metrics small {
overflow: hidden;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.rng-simulator-stat span {
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 800;
line-height: 1.25;
}
.rng-simulator-active-summary strong,
.rng-simulator-stat strong,
.rng-simulator-table strong,
.rng-simulator-history-row b,
.rng-simulator-history-rate > span,
.rng-simulator-history-metrics b {
color: var(--color-accent-gold);
font-weight: 900;
text-transform: uppercase;
font-variant-numeric: tabular-nums;
}
.rng-simulator-rate-form .rng-simulator-form-actions {
justify-content: flex-end;
}
.rng-simulator-field {
@ -198,9 +218,85 @@
min-width: 0;
}
.rng-simulator-form-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: end;
}
.rng-simulator-error {
grid-column: 1 / -1;
margin: 0;
color: #fca5a5;
font-size: var(--font-size-xs);
font-weight: 800;
}
.rng-simulator-tabs-panel > header {
padding-bottom: 0;
border-bottom: 0;
}
.rng-simulator-tabs {
width: fit-content;
}
.rng-simulator-tabs.tabs.nds-radio-button-group[data-size="sm"] > button {
min-width: 96px;
min-height: 34px;
padding-block: 8px;
padding-inline: var(--space-3);
}
.rng-simulator-tab-content {
display: grid;
gap: var(--space-3);
min-width: 0;
}
.rng-simulator-section {
display: grid;
gap: var(--space-3);
min-width: 0;
padding-top: var(--space-3);
border-top: 1px solid rgba(150, 165, 205, 0.1);
}
.rng-simulator-tab-content > .rng-simulator-section:first-child {
padding-top: 0;
border-top: 0;
}
.rng-simulator-section-title {
color: var(--color-text-primary);
font-size: var(--font-size-sm);
font-weight: 900;
}
.rng-simulator-primary-controls {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(180px, 1fr);
gap: var(--space-4);
align-items: stretch;
}
.rng-simulator-primary-column,
.rng-simulator-cost-column {
display: grid;
gap: 8px;
min-width: 0;
align-content: start;
}
.rng-simulator-cost-column {
padding-left: var(--space-4);
border-left: 1px solid rgba(150, 165, 205, 0.12);
}
.rng-simulator-percent-input {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px;
align-items: center;
}
@ -211,90 +307,88 @@
font-weight: 900;
}
.rng-simulator-error {
margin: 0;
color: #fca5a5;
font-size: var(--font-size-xs);
font-weight: 800;
}
.rng-simulator-form-actions {
display: flex;
flex-wrap: wrap;
.rng-simulator-occurrence-control {
display: grid;
grid-template-columns: minmax(160px, 1fr) minmax(130px, 0.7fr);
gap: 8px;
align-items: end;
justify-content: flex-end;
max-width: 420px;
}
.rng-simulator-stat-grid,
.rng-simulator-targets,
.rng-simulator-time-grid {
.rng-simulator-time-results {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.rng-simulator-stat-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rng-simulator-targets {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.rng-simulator-targets .rng-simulator-stat strong {
overflow-wrap: normal;
word-break: normal;
}
.rng-simulator-time-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.rng-simulator-stat {
display: grid;
gap: 5px;
min-width: 0;
padding: 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: rgba(5, 7, 17, 0.32);
}
.rng-simulator-stat span {
overflow: hidden;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.rng-simulator-stat strong {
overflow-wrap: anywhere;
color: var(--color-text-primary);
font-size: var(--font-size-sm);
font-variant-numeric: tabular-nums;
}
.rng-simulator-simulation-panel {
gap: var(--space-3);
}
.rng-simulator-simulation-bar {
display: flex;
flex-wrap: wrap;
.rng-simulator-table {
display: grid;
align-items: center;
justify-content: space-between;
gap: 8px;
gap: 1px;
min-width: 0;
overflow: hidden;
border: 1px solid rgba(150, 165, 205, 0.1);
border-radius: var(--radius-sm);
background: rgba(150, 165, 205, 0.08);
}
.rng-simulator-table > * {
min-width: 0;
padding: 8px;
background: rgba(5, 7, 17, 0.28);
}
.rng-simulator-table > span:not(:nth-child(-n + 3)) {
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 800;
}
.rng-simulator-target-table {
grid-template-columns: minmax(88px, 0.6fr) minmax(140px, 1fr) minmax(120px, 1fr);
}
.rng-simulator-simulation-overview {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
align-items: stretch;
padding-bottom: var(--space-3);
border-bottom: 1px solid rgba(150, 165, 205, 0.12);
}
.rng-simulator-simulation-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
.rng-simulator-simulation-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.rng-simulator-simulation-actions {
display: grid;
justify-items: start;
gap: 8px;
min-width: 0;
}
.rng-simulator-simulation-actions > .nds-button {
width: 100%;
}
.rng-simulator-history-toolbar {
display: flex;
justify-content: flex-end;
min-width: 0;
}
@ -312,6 +406,14 @@
width: 150px;
}
.rng-simulator-simulation-draws {
width: 100%;
}
.rng-simulator-simulation-draws > .nds-input {
flex: 1 1 auto;
}
.rng-simulator-history {
display: grid;
gap: 8px;
@ -320,8 +422,7 @@
.rng-simulator-history-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px 12px;
gap: 10px;
align-items: center;
min-width: 0;
padding: 10px;
@ -332,26 +433,43 @@
.rng-simulator-history-row.is-latest {
border-color: rgba(139, 92, 246, 0.38);
background: rgba(139, 92, 246, 0.1);
background:
linear-gradient(135deg, rgba(139, 92, 246, 0.18), rgba(246, 196, 83, 0.06)),
rgba(11, 14, 27, 0.52);
}
.rng-simulator-history-row > div {
.rng-simulator-history-row > .rng-simulator-history-heading {
display: grid;
gap: 2px;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: var(--space-3);
min-width: 0;
}
.rng-simulator-history-rate {
justify-items: end;
text-align: right;
.rng-simulator-history-row .rng-simulator-history-identity {
display: grid;
gap: 3px;
min-width: 0;
}
.rng-simulator-history-row b,
.rng-simulator-history-rate > span,
.rng-simulator-history-metrics b {
color: var(--color-accent-gold);
font-weight: 900;
font-variant-numeric: tabular-nums;
.rng-simulator-history-row .rng-simulator-history-score {
display: grid;
justify-items: end;
gap: 0;
}
.rng-simulator-history-row .rng-simulator-history-score b {
color: var(--color-text-primary);
font-size: 24px;
line-height: 1;
}
.rng-simulator-history-rate {
display: grid;
gap: 2px;
justify-items: end;
min-width: 72px;
text-align: right;
}
.rng-simulator-history-metrics {
@ -371,44 +489,54 @@
background: rgba(5, 7, 17, 0.24);
}
.rng-simulator-history-metrics small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rng-simulator-empty {
margin: 0;
}
@media (max-width: 820px) {
.rng-simulator-grid,
.rng-simulator-settings,
.rng-simulator-three-columns,
.rng-simulator-rate-layout,
.rng-simulator-rate-form,
.rng-simulator-active-summary,
.rng-simulator-primary-controls,
.rng-simulator-stat-grid,
.rng-simulator-targets,
.rng-simulator-time-grid {
.rng-simulator-simulation-summary,
.rng-simulator-occurrence-control,
.rng-simulator-time-results,
.rng-simulator-target-table {
grid-template-columns: 1fr;
}
.rng-simulator-cost-column {
padding-top: var(--space-3);
padding-left: 0;
border-top: 1px solid rgba(150, 165, 205, 0.12);
border-left: 0;
}
.rng-simulator-history-row {
grid-template-columns: 1fr;
}
.rng-simulator-history-rate {
justify-items: start;
text-align: left;
.rng-simulator-history-row > .rng-simulator-history-heading {
grid-template-columns: minmax(0, 1fr) auto;
}
.rng-simulator-history-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rng-simulator-simulation-bar {
justify-content: flex-start;
.rng-simulator-history-rate {
grid-column: 1 / -1;
justify-items: start;
text-align: left;
}
.rng-simulator-simulation-overview,
.rng-simulator-simulation-actions {
width: 100%;
}
.rng-simulator-simulation-overview {
grid-template-columns: 1fr;
}
}