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", "id": "rate1",
"label": "Drop rare", "label": "Drop rare",
"rate": "2.500", "rate": "2.500",
"costPerDraw": "150",
"multiplier": 50, "multiplier": 50,
"perHour": 30, "occurrenceValue": 30,
"perDay": 120, "occurrenceUnit": "perHour"
"perMonth": 3600
}, },
{ {
"id": "rate2", "id": "rate2",
@ -841,14 +841,29 @@ Stockage compact :
- `rates` contient les taux enregistrés du module; un taux sans libellé est ignoré. - `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"`. - `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"`. - 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é. - `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[].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.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. - `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`. - 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 ## Outil Combos
Type : `combos` Type : `combos`

View file

@ -317,6 +317,8 @@ export function validateSiteContent(site) {
"toolboxes.modules.rngSimulator.labelLabel", "toolboxes.modules.rngSimulator.labelLabel",
"toolboxes.modules.rngSimulator.labelPlaceholder", "toolboxes.modules.rngSimulator.labelPlaceholder",
"toolboxes.modules.rngSimulator.rateLabel", "toolboxes.modules.rngSimulator.rateLabel",
"toolboxes.modules.rngSimulator.costPerDrawLabel",
"toolboxes.modules.rngSimulator.costPerDrawPlaceholder",
"toolboxes.modules.rngSimulator.rateLabelRequired", "toolboxes.modules.rngSimulator.rateLabelRequired",
"toolboxes.modules.rngSimulator.rateInvalid", "toolboxes.modules.rngSimulator.rateInvalid",
"toolboxes.modules.rngSimulator.addRateButton", "toolboxes.modules.rngSimulator.addRateButton",
@ -324,21 +326,29 @@ export function validateSiteContent(site) {
"toolboxes.modules.rngSimulator.cancelButton", "toolboxes.modules.rngSimulator.cancelButton",
"toolboxes.modules.rngSimulator.deleteTitle", "toolboxes.modules.rngSimulator.deleteTitle",
"toolboxes.modules.rngSimulator.noActiveRateTitle", "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.simulationDrawsLabel",
"toolboxes.modules.rngSimulator.perHourLabel", "toolboxes.modules.rngSimulator.perHourLabel",
"toolboxes.modules.rngSimulator.perDayLabel", "toolboxes.modules.rngSimulator.perDayLabel",
"toolboxes.modules.rngSimulator.perMonthLabel", "toolboxes.modules.rngSimulator.perMonthLabel",
"toolboxes.modules.rngSimulator.probabilitySectionTitle",
"toolboxes.modules.rngSimulator.multiplierLabel", "toolboxes.modules.rngSimulator.multiplierLabel",
"toolboxes.modules.rngSimulator.clearMultiplierButton",
"toolboxes.modules.rngSimulator.averageDrawsLabel", "toolboxes.modules.rngSimulator.averageDrawsLabel",
"toolboxes.modules.rngSimulator.simulationChanceLabel", "toolboxes.modules.rngSimulator.simulationChanceLabel",
"toolboxes.modules.rngSimulator.simulationChanceWithoutMultiplierLabel",
"toolboxes.modules.rngSimulator.averageCostLabel",
"toolboxes.modules.rngSimulator.averageTimeTitle", "toolboxes.modules.rngSimulator.averageTimeTitle",
"toolboxes.modules.rngSimulator.periodLabel",
"toolboxes.modules.rngSimulator.averageTimeLabel",
"toolboxes.modules.rngSimulator.occurrenceCountLabel", "toolboxes.modules.rngSimulator.occurrenceCountLabel",
"toolboxes.modules.rngSimulator.hourTimeLabel", "toolboxes.modules.rngSimulator.hourTimeLabel",
"toolboxes.modules.rngSimulator.dayTimeLabel", "toolboxes.modules.rngSimulator.dayTimeLabel",
"toolboxes.modules.rngSimulator.monthTimeLabel", "toolboxes.modules.rngSimulator.monthTimeLabel",
"toolboxes.modules.rngSimulator.chanceTargetsTitle", "toolboxes.modules.rngSimulator.chanceTargetsTitle",
"toolboxes.modules.rngSimulator.chanceLabel",
"toolboxes.modules.rngSimulator.costLabel",
"toolboxes.modules.rngSimulator.drawsUnit", "toolboxes.modules.rngSimulator.drawsUnit",
"toolboxes.modules.rngSimulator.hoursUnit", "toolboxes.modules.rngSimulator.hoursUnit",
"toolboxes.modules.rngSimulator.daysUnit", "toolboxes.modules.rngSimulator.daysUnit",
@ -351,8 +361,15 @@ export function validateSiteContent(site) {
"toolboxes.modules.rngSimulator.successesLabel", "toolboxes.modules.rngSimulator.successesLabel",
"toolboxes.modules.rngSimulator.failsLabel", "toolboxes.modules.rngSimulator.failsLabel",
"toolboxes.modules.rngSimulator.observedRateLabel", "toolboxes.modules.rngSimulator.observedRateLabel",
"toolboxes.modules.rngSimulator.expectedSuccessesLabel",
"toolboxes.modules.rngSimulator.multiplierShortLabel", "toolboxes.modules.rngSimulator.multiplierShortLabel",
"toolboxes.modules.rngSimulator.longestFailStreakLabel", "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.importPlaceholder",
"toolboxes.modules.rngSimulator.importOpenButton", "toolboxes.modules.rngSimulator.importOpenButton",
"toolboxes.modules.rngSimulator.exportOpenButton", "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 { 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 { 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 { 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 { 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 { 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"; 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({ const normalized = normalizeRngSimulatorData({
activeRateId: "rate2", activeRateId: "rate2",
rates: [ rates: [
{ id: "rate1", label: "Drop rare", rate: "2.500", multiplier: 25, perHour: 10 }, { id: "rate1", label: "Drop rare", rate: "2.500", costPerDraw: "", multiplier: 25, occurrenceValue: 10, occurrenceUnit: "perHour" },
{ id: "rate2", label: "Ultra rare", rate: "0.000004", multiplier: 50, perHour: 30, perDay: 120, perMonth: 3600 }, { id: "rate2", label: "Ultra rare", rate: "0.000004", costPerDraw: "12,500", multiplier: 50, occurrenceValue: 120, occurrenceUnit: "perDay" },
{ id: "bad", label: "", rate: "4" } { id: "bad", label: "", rate: "4" }
], ],
settings: { simulationDraws: 2000 }, settings: { simulationDraws: 2000 },
@ -312,11 +312,17 @@ test("rng simulator preserves rate precision and compacts storage", () => {
assert.equal(normalized.activeRateId, "rate2"); 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.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(normalized.history, undefined);
assert.equal(normalizeCostString("0"), "");
assert.equal(normalizeCostString("0012,500"), "12.500");
assert.equal(compactModuleDataForStorage("rngSimulator", normalizeRngSimulatorData({})), null); 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].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).settings.simulationDraws, 2000);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).history, undefined); 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("10", -50), 5);
assert.equal(getEffectiveRate("75", 100), 100); 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.hour, 2);
assert.equal(hourlySummary.timeByFrequency.day, 2 / 24); assert.equal(hourlySummary.timeByFrequency.day, 2 / 24);
assert.equal(hourlySummary.timeByFrequency.month, 2 / 24 / 30); 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.effectiveRate, 20);
assert.equal(multipliedSummary.averageDraws, 5); 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 result = simulateRngDraws("25", 8, (() => {
const values = [0.1, 0.9, 0.2, 0.8, 0.7, 0.1, 0.6, 0.5]; 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, successes: 3,
fails: 5, fails: 5,
observedRate: 37.5, observedRate: 37.5,
longestFailStreak: 2 longestFailStreak: 2,
longestSuccessStreak: 1,
firstSuccessDraw: 1
}); });
const multipliedResult = simulateRngDraws("10", 4, (() => { const multipliedResult = simulateRngDraws("10", 4, (() => {
@ -357,6 +376,8 @@ test("rng simulator calculates probability milestones and deterministic simulati
return () => values[index++]; return () => values[index++];
})(), 100); })(), 100);
assert.equal(multipliedResult.successes, 2); 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", () => { 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({ const rngSimulator = normalizeRngSimulatorData({
activeRateId: "rate2", activeRateId: "rate2",
rates: [ rates: [
{ id: "rate1", label: "Drop rare", rate: "2.500", multiplier: 25, perHour: 20 }, { id: "rate1", label: "Drop rare", rate: "2.500", costPerDraw: "150", multiplier: 25, occurrenceValue: 20, occurrenceUnit: "perHour" },
{ id: "rate2", label: "Ultra rare", rate: "0.000004", multiplier: 50, perHour: 40, perDay: 120, perMonth: 3600 } { id: "rate2", label: "Ultra rare", rate: "0.000004", costPerDraw: "", multiplier: 50, occurrenceValue: 120, occurrenceUnit: "perDay" }
], ],
settings: { simulationDraws: 5000 } settings: { simulationDraws: 5000 }
}); });
@ -374,11 +395,13 @@ test("rng simulator text exchange round trips rates and settings", () => {
const imported = importModuleText("rngSimulator", exported, context); const imported = importModuleText("rngSimulator", exported, context);
assert.equal(imported.ok, true); 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.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.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 === "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")?.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")?.occurrenceValue, 120);
assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.perMonth, 3600); assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.occurrenceUnit, "perDay");
assert.equal(imported.data.settings.simulationDraws, 5000); assert.equal(imported.data.settings.simulationDraws, 5000);
assert.equal(imported.data.history, undefined); assert.equal(imported.data.history, undefined);
}); });

View file

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

View file

@ -498,8 +498,17 @@
"icon": "clover", "icon": "clover",
"title": "Enregistrer un taux", "title": "Enregistrer un taux",
"preview": { "preview": {
"kind": "textButton", "kind": "iconButton",
"label": "%" "icon": "add"
}
},
{
"group": "Taux",
"icon": "edit",
"title": "Modifier un taux",
"preview": {
"kind": "iconButton",
"icon": "edit"
} }
}, },
{ {
@ -524,26 +533,26 @@
"features": [ "features": [
{ {
"title": "Taux sauvegardés", "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": [ "details": [
"La saisie par défaut utilise trois décimales, mais le stockage conserve la chaîne exacte normalisée.", "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.", "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", "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": [ "details": [
"Les occurrences par heure, jour et mois sont indépendantes pour s'adapter à différents usages.", "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.", "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": { "importFormat": {
"text": "Le format texte contient les sections [rates] et [settings]. Les taux sont exprimés en pourcentage.", "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 | 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" "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": { "images": {
@ -2106,6 +2115,8 @@
"labelLabel": "Libellé", "labelLabel": "Libellé",
"labelPlaceholder": "Drop rare", "labelPlaceholder": "Drop rare",
"rateLabel": "Taux (%)", "rateLabel": "Taux (%)",
"costPerDrawLabel": "Coût d'un tirage",
"costPerDrawPlaceholder": "Optionnel",
"rateLabelRequired": "Libellé requis.", "rateLabelRequired": "Libellé requis.",
"rateInvalid": "Taux invalide. Utilisez une valeur supérieure à 0 et inférieure ou égale à 100.", "rateInvalid": "Taux invalide. Utilisez une valeur supérieure à 0 et inférieure ou égale à 100.",
"addRateButton": "Ajouter", "addRateButton": "Ajouter",
@ -2113,21 +2124,29 @@
"cancelButton": "Annuler", "cancelButton": "Annuler",
"deleteTitle": "Supprimer", "deleteTitle": "Supprimer",
"noActiveRateTitle": "Aucun taux actif", "noActiveRateTitle": "Aucun taux actif",
"activeRateLabel": "Taux brut",
"effectiveRateLabel": "Taux final",
"statsTabLabel": "Calculs",
"simulationTabLabel": "Simulation",
"simulationDrawsLabel": "Tirages simulés", "simulationDrawsLabel": "Tirages simulés",
"perHourLabel": "Par heure", "perHourLabel": "Par heure",
"perDayLabel": "Par jour", "perDayLabel": "Par jour",
"perMonthLabel": "Par mois", "perMonthLabel": "Par mois",
"probabilitySectionTitle": "Probabilités avec multiplicateur",
"multiplierLabel": "Multiplicateur (%)", "multiplierLabel": "Multiplicateur (%)",
"clearMultiplierButton": "Réinitialiser le multiplicateur", "averageDrawsLabel": "Moyenne avant réussite",
"averageDrawsLabel": "Moyenne avant réussite après multiplicateur", "simulationChanceLabel": "Chance sur les tirages saisis",
"simulationChanceLabel": "Chance sur les tirages saisis après multiplicateur", "simulationChanceWithoutMultiplierLabel": "Chance sur les tirages saisis sans multiplicateur",
"averageCostLabel": "Coût moyen avant réussite",
"averageTimeTitle": "Temps moyen avant réussite", "averageTimeTitle": "Temps moyen avant réussite",
"periodLabel": "Période",
"averageTimeLabel": "Temps moyen",
"occurrenceCountLabel": "Nombre d'occurrences", "occurrenceCountLabel": "Nombre d'occurrences",
"hourTimeLabel": "En heures", "hourTimeLabel": "En heures",
"dayTimeLabel": "En jours", "dayTimeLabel": "En jours",
"monthTimeLabel": "En mois", "monthTimeLabel": "En mois",
"chanceTargetsTitle": "Tirages pour atteindre une chance cumulée", "chanceTargetsTitle": "Tirages pour atteindre une chance cumulée",
"chanceLabel": "Chance",
"costLabel": "Coût",
"drawsUnit": "tirages", "drawsUnit": "tirages",
"hoursUnit": "h", "hoursUnit": "h",
"daysUnit": "j", "daysUnit": "j",
@ -2140,9 +2159,16 @@
"successesLabel": "Réussites", "successesLabel": "Réussites",
"failsLabel": "Fails", "failsLabel": "Fails",
"observedRateLabel": "Taux de réussite", "observedRateLabel": "Taux de réussite",
"expectedSuccessesLabel": "Réussites attendues",
"multiplierShortLabel": "Bonus", "multiplierShortLabel": "Bonus",
"longestFailStreakLabel": "Pire série", "longestFailStreakLabel": "Pire série d'échec",
"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", "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", "importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte", "exportOpenButton": "Exporter en format texte",
"importModalTitle": "Importer un simulateur de chance", "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. // 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 { Icon } from "../../../components/Icon.jsx";
import { Tabs } from "../../../components/Tabs.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js"; import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { import {
@ -8,15 +9,23 @@ import {
RNG_SIMULATOR_HISTORY_LIMIT, RNG_SIMULATOR_HISTORY_LIMIT,
RNG_SIMULATOR_LIMITS, RNG_SIMULATOR_LIMITS,
addRngSimulationHistory, addRngSimulationHistory,
calculateAtLeastOneProbability,
calculateRngCostSummary,
calculateRngSummary, calculateRngSummary,
clampDecimal, clampDecimal,
clampInteger, clampInteger,
getEffectiveRate,
isValidRateString, isValidRateString,
normalizeCostString,
normalizeRateString, normalizeRateString,
simulateRngDraws simulateRngDraws
} from "./rngSimulatorUtils.js"; } from "./rngSimulatorUtils.js";
const CHANCE_TARGETS = [50, 90, 95, 99]; const CHANCE_TARGETS = [50, 90, 95, 99];
const RNG_TABS = [
{ id: "stats", label: "Calculs" },
{ id: "simulation", label: "Simulation" }
];
function formatNumber(value, maximumFractionDigits = 2) { function formatNumber(value, maximumFractionDigits = 2) {
if (!Number.isFinite(value)) return "—"; if (!Number.isFinite(value)) return "—";
@ -28,6 +37,11 @@ function formatPercent(value, maximumFractionDigits = 6) {
return `${value.toLocaleString("fr-FR", { maximumFractionDigits })} %`; return `${value.toLocaleString("fr-FR", { maximumFractionDigits })} %`;
} }
function formatCost(value) {
if (!Number.isFinite(value)) return "—";
return formatNumber(value, 0);
}
function formatDrawTarget(value, unit) { function formatDrawTarget(value, unit) {
if (!Number.isFinite(value)) return `— ${unit}`; if (!Number.isFinite(value)) return `— ${unit}`;
if (value >= 1000000) { if (value >= 1000000) {
@ -73,14 +87,18 @@ function normalizeSessionHistoryEntry(entry, index) {
const successes = clampInteger(entry?.successes, 0, draws, 0); const successes = clampInteger(entry?.successes, 0, draws, 0);
return { return {
id: entry?.id || `rng-session-${index}`, id: entry?.id || `rng-session-${index}`,
rateId: String(entry?.rateId || "").trim(),
label: String(entry?.label || "").trim() || "Simulation", label: String(entry?.label || "").trim() || "Simulation",
rate: normalizeRateString(entry?.rate), rate: normalizeRateString(entry?.rate),
costPerDraw: normalizeCostString(entry?.costPerDraw),
multiplier: clampDecimal(entry?.multiplier, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0), multiplier: clampDecimal(entry?.multiplier, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0),
draws, draws,
successes, successes,
fails: draws - successes, fails: draws - successes,
observedRate: draws > 0 ? (successes / draws) * 100 : 0, 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 data = context.normalizeRngSimulatorData(context.getModuleData(toolboxId, moduleId, {}));
const textContent = context.moduleText?.rngSimulator || {}; 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 historyStorageKey = `rng-simulator:${toolboxId}:${moduleId}:history`;
const [activeTab, setActiveTab] = useState("stats");
const [history, setHistory] = useState(() => readSimulationHistory(historyStorageKey)); const [history, setHistory] = useState(() => readSimulationHistory(historyStorageKey));
const [editingRateId, setEditingRateId] = useState(""); const [editingRateId, setEditingRateId] = useState("");
const [multiplierDraft, setMultiplierDraft] = useState(null); 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 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 activeRate = data.rates.find((rate) => rate.id === data.activeRateId) || data.rates[0] || null;
const summary = useMemo(() => activeRate ? calculateRngSummary({ const summary = useMemo(() => activeRate ? calculateRngSummary({
rate: activeRate.rate, rate: activeRate.rate,
multiplier: activeRate.multiplier, multiplier: activeRate.multiplier,
simulationDraws: data.settings.simulationDraws, simulationDraws: data.settings.simulationDraws,
perHour: activeRate.perHour, occurrenceValue: activeRate.occurrenceValue,
perDay: activeRate.perDay, occurrenceUnit: activeRate.occurrenceUnit
perMonth: activeRate.perMonth
}) : null, [activeRate, data.settings.simulationDraws]); }) : 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(() => { useEffect(() => {
setHistory(readSimulationHistory(historyStorageKey)); setHistory(readSimulationHistory(historyStorageKey));
}, [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(() => { useEffect(() => {
setMultiplierDraft(null); setMultiplierDraft(null);
setOccurrenceDrafts({ perHour: null, perDay: null, perMonth: null }); setCostDraft(null);
}, [activeRate?.id]); setOccurrenceDraft(null);
setOccurrenceUnit(activeRate?.occurrenceUnit || "perHour");
}, [activeRate?.id, activeRate?.occurrenceUnit]);
function save(nextData) { function save(nextData) {
context.setModuleData(toolboxId, moduleId, nextData, "rngSimulator"); context.setModuleData(toolboxId, moduleId, nextData, "rngSimulator");
@ -158,35 +224,57 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
if (multiplierDraft === null) return; if (multiplierDraft === null) return;
const raw = multiplierDraft.trim(); const raw = multiplierDraft.trim();
const parsed = Number(raw.replace(",", ".")); const parsed = Number(raw.replace(",", "."));
if (!raw || !Number.isFinite(parsed)) { if (!raw || !Number.isFinite(parsed)) updateActiveRateSettings("multiplier", 0);
setMultiplierDraft(null);
updateActiveRateSettings("multiplier", 0);
return;
}
setMultiplierDraft(null); setMultiplierDraft(null);
} }
function clearMultiplier() { function updateCost(value) {
setMultiplierDraft(null); setCostDraft(value);
updateActiveRateSettings("multiplier", 0);
} }
function updateOccurrence(field, value) { function blurCost() {
setOccurrenceDrafts((drafts) => ({ ...drafts, [field]: value })); 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() === "") { if (String(value).trim() === "") {
updateActiveRateSettings(field, 0); save({
...data,
rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, occurrenceValue: 0, occurrenceUnit } : rate)
});
return; 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) { function changeOccurrenceUnit(unit) {
const value = occurrenceDrafts[field]; setOccurrenceUnit(unit);
if (value === null) return; 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 raw = value.trim();
const parsed = Number(raw.replace(",", ".")); const parsed = Number(raw.replace(",", "."));
if (!raw || !Number.isFinite(parsed)) updateActiveRateSettings(field, 0); if (!raw || !Number.isFinite(parsed)) updateOccurrence("");
setOccurrenceDrafts((drafts) => ({ ...drafts, [field]: null })); setOccurrenceDraft(null);
} }
function submitRate(event) { function submitRate(event) {
@ -200,22 +288,28 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
setEditingRateId(""); setEditingRateId("");
} else { } else {
const id = context.uid("rng"); 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 }); reset({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
setEditing(false);
return ""; return "";
}); });
} }
function editRate(rate) { function editRate(rate) {
save({ ...data, activeRateId: rate.id });
setEditingRateId(rate.id); setEditingRateId(rate.id);
setEditing(true);
rateDraft.reset({ label: rate.label, rate: rate.rate }); rateDraft.reset({ label: rate.label, rate: rate.rate });
} }
function cancelEdit() { function cancelEdit() {
setEditingRateId(""); setEditingRateId("");
rateDraft.reset({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE }); rateDraft.reset({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
setEditing(false);
}
function selectRate(rate) {
save({ ...data, activeRateId: rate.id });
} }
function deleteRate(rateId) { 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 result = simulateRngDraws(activeRate.rate, data.settings.simulationDraws, Math.random, activeRate.multiplier);
const entry = { const entry = {
id: context.uid("rng"), id: context.uid("rng"),
rateId: activeRate.id,
label: activeRate.label, label: activeRate.label,
rate: activeRate.rate, rate: activeRate.rate,
costPerDraw: activeRate.costPerDraw,
multiplier: activeRate.multiplier, multiplier: activeRate.multiplier,
...result ...result
}; };
@ -240,8 +336,9 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
} }
function clearHistory() { function clearHistory() {
setHistory([]); const nextHistory = activeRate ? history.filter((entry) => !historyEntryMatchesRate(entry, activeRate)) : [];
writeSimulationHistory(historyStorageKey, []); setHistory(nextHistory);
writeSimulationHistory(historyStorageKey, nextHistory);
} }
return ( return (
@ -252,15 +349,17 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
</div> </div>
)} )}
<div className="rng-simulator-tool"> <div className="rng-simulator-tool">
<section className="rng-simulator-grid"> <section className="rng-simulator-panel rng-simulator-rates-panel">
<div className="rng-simulator-rates-stack">
<div className="rng-simulator-panel rng-simulator-rate-add-panel">
<header> <header>
<div> <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> </div>
</header> </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"> <label className="rng-simulator-field">
<span>{textContent.labelLabel || "Libellé"}</span> <span>{textContent.labelLabel || "Libellé"}</span>
<input {...rateDraft.getFieldProps("label", { className: "nds-input", "data-size": "sm", type: "text", placeholder: textContent.labelPlaceholder || "Drop rare" })} /> <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"} /> <Icon name={editingRateId ? "save" : "add"} />
<span>{editingRateId ? textContent.saveRateButton || "Enregistrer" : textContent.addRateButton || "Ajouter"}</span> <span>{editingRateId ? textContent.saveRateButton || "Enregistrer" : textContent.addRateButton || "Ajouter"}</span>
</button> </button>
{editingRateId && ( {data.rates.length > 0 && (
<button className="nds-button" data-size="sm" type="button" onClick={cancelEdit}> <button className="nds-button" data-size="sm" type="button" onClick={cancelEdit}>
{textContent.cancelButton || "Annuler"} {textContent.cancelButton || "Annuler"}
</button> </button>
)} )}
</div> </div>
</form> </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"> <div className="rng-simulator-rates nds-scrollbox">
{data.rates.length ? data.rates.map((rate) => ( {data.rates.length ? data.rates.map((rate) => (
<article className={`rng-simulator-rate-row ${rate.id === activeRate?.id ? "is-active" : ""}`} key={rate.id}> <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> <strong>{rate.label}</strong>
<span>{rate.rate} %</span>
</button> </button>
<div className="rng-simulator-rate-actions"> <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"}> <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" /> <Icon name="trash" />
</button> </button>
@ -304,78 +401,91 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
</article> </article>
)) : <p className="rng-simulator-empty">{textContent.emptyRates || "Aucun taux enregistré."}</p>} )) : <p className="rng-simulator-empty">{textContent.emptyRates || "Aucun taux enregistré."}</p>}
</div> </div>
</div>
</div>
<div className="rng-simulator-panel rng-simulator-summary-panel"> {activeRate && summary && (
<header> <div className="rng-simulator-active-summary">
<div> <RngInfo label={textContent.activeRateLabel || "Taux brut"} value={`${activeRate.rate} %`} />
<h3>{activeRate?.label || textContent.noActiveRateTitle || "Aucun taux actif"}</h3>
</div> </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> </header>
{activeTab === "stats" && (
<div className="rng-simulator-tab-content">
{activeRate && summary ? ( {activeRate && summary ? (
<> <>
<div className="rng-simulator-section"> <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"> <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"> <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> <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> </div>
<div className="rng-simulator-stat-grid"> <div className="rng-simulator-stat-grid">
<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"}`} /> <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)} />
</div> </div>
</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"> <div className="rng-simulator-section">
<span className="rng-simulator-section-title">{textContent.averageTimeTitle || "Temps moyen avant réussite"}</span> <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-occurrence-control">
<div className="rng-simulator-settings rng-simulator-three-columns">
<label className="rng-simulator-field"> <label className="rng-simulator-field">
<span>{textContent.perHourLabel || "Par heure"}</span> <span>{textContent.occurrenceCountLabel || "Nombre d'occurrences"}</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")} /> <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>
<label className="rng-simulator-field"> <label className="rng-simulator-field">
<span>{textContent.perDayLabel || "Par jour"}</span> <span>{textContent.periodLabel || "Période"}</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")} /> <select className="nds-select" data-size="sm" value={occurrenceUnit} onChange={(event) => changeOccurrenceUnit(event.target.value)}>
</label> <option value="perHour">{textContent.perHourLabel || "Par heure"}</option>
<label className="rng-simulator-field"> <option value="perDay">{textContent.perDayLabel || "Par jour"}</option>
<span>{textContent.perMonthLabel || "Par mois"}</span> <option value="perMonth">{textContent.perMonthLabel || "Par mois"}</option>
<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")} /> </select>
</label> </label>
</div> </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.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.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")} /> <RngStat label={textContent.monthTimeLabel || "En mois"} value={formatTime(summary.timeByFrequency.month, textContent.monthsUnit || "mois")} />
</div> </div>
</div> </div>
<div className="rng-simulator-section"> <div className="rng-simulator-section">
<span className="rng-simulator-section-title">{textContent.chanceTargetsTitle || "Tirages pour atteindre une chance cumulée"}</span> <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) => ( {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>
</div> </div>
</> </>
) : <p className="rng-simulator-empty">{textContent.emptySummary || "Ajoutez un taux pour afficher les calculs."}</p>} ) : <p className="rng-simulator-empty">{textContent.emptySummary || "Ajoutez un taux pour afficher les calculs."}</p>}
</div> </div>
</section> )}
<section className="rng-simulator-panel rng-simulator-simulation-panel"> {activeTab === "simulation" && (
<header> <div className="rng-simulator-tab-content">
<div> <div className="rng-simulator-simulation-overview">
<h3>{textContent.simulationTitle || "Résultats simulés"}</h3>
</div>
</header>
<div className="rng-simulator-simulation-bar">
<div className="rng-simulator-simulation-actions"> <div className="rng-simulator-simulation-actions">
<label className="rng-simulator-field rng-simulator-inline-field rng-simulator-simulation-draws"> <label className="rng-simulator-field rng-simulator-inline-field rng-simulator-simulation-draws">
<span>{textContent.simulationDrawsLabel || "Tirages simulés"}</span> <span>{textContent.simulationDrawsLabel || "Tirages simulés"}</span>
@ -386,36 +496,41 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
<span>{textContent.simulateButton || "Simuler"}</span> <span>{textContent.simulateButton || "Simuler"}</span>
</button> </button>
</div> </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" /> <Icon name="rubber" />
<span>{textContent.clearHistoryButton || "Supprimer les simulations"}</span> <span>{textContent.clearHistoryButton || "Supprimer les simulations"}</span>
</button> </button>
</div> </div>
<div className="rng-simulator-history nds-scrollbox"> <div className="rng-simulator-history nds-scrollbox">
{history.length ? history.map((entry, index) => ( {activeHistory.length ? activeHistory.map((entry, index) => (
<article className={`rng-simulator-history-row ${index === 0 ? "is-latest" : ""}`} key={entry.id}> <RngHistoryRow entry={entry} index={index} key={entry.id} textContent={textContent} />
<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>
)) : <p className="rng-simulator-empty">{textContent.emptyHistory || "Aucune simulation."}</p>} )) : <p className="rng-simulator-empty">{textContent.emptyHistory || "Aucune simulation."}</p>}
</div> </div>
</div>
)}
</section> </section>
</div> </div>
</> </>
); );
} }
function RngInfo({ label, value }) {
return (
<span>
<small>{label}</small>
<strong>{value}</strong>
</span>
);
}
function RngStat({ label, value, title }) { function RngStat({ label, value, title }) {
return ( return (
<article className="rng-simulator-stat" title={title}> <article className="rng-simulator-stat" title={title}>
@ -424,3 +539,59 @@ function RngStat({ label, value, title }) {
</article> </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_DEFAULT_RATE = "2.500";
export const RNG_SIMULATOR_LIMITS = { export const RNG_SIMULATOR_LIMITS = {
rateMax: 100, rateMax: 100,
costPerDrawMax: 1000000000000,
multiplierMin: -1000000, multiplierMin: -1000000,
multiplierMax: 1000000, multiplierMax: 1000000,
simulationDrawsMin: 1, simulationDrawsMin: 1,
@ -11,6 +12,8 @@ export const RNG_SIMULATOR_LIMITS = {
occurrenceRateMax: 1000000000 occurrenceRateMax: 1000000000
}; };
export const RNG_SIMULATOR_OCCURRENCE_UNITS = ["perHour", "perDay", "perMonth"];
export function clampInteger(value, min, max, fallback = min) { export function clampInteger(value, min, max, fallback = min) {
const parsed = Number.parseInt(value, 10); const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) return fallback; if (!Number.isFinite(parsed)) return fallback;
@ -40,6 +43,21 @@ export function normalizeRateString(value, fallback = RNG_SIMULATOR_DEFAULT_RATE
return cleanInteger; 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) { export function isValidRateString(value) {
const raw = String(value ?? "").trim().replace(",", ".").replace(/%$/, "").trim(); const raw = String(value ?? "").trim().replace(",", ".").replace(/%$/, "").trim();
if (!raw || !/^\d*(?:\.\d+)?$/.test(raw) || raw === ".") return false; 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)); 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 effectiveRate = getEffectiveRate(rate, multiplier);
const probability = effectiveRate / 100; const probability = effectiveRate / 100;
const averageDraws = probability > 0 ? 1 / probability : 0; const averageDraws = probability > 0 ? 1 / probability : 0;
const cleanPerHour = clampDecimal(perHour, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0); const cleanOccurrenceValue = clampDecimal(occurrenceValue, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0);
const cleanPerDay = clampDecimal(perDay, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0); const cleanOccurrenceUnit = normalizeOccurrenceUnit(occurrenceUnit);
const cleanPerMonth = clampDecimal(perMonth, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0);
let averageHours = 0; let averageHours = 0;
if (cleanPerHour > 0) averageHours = averageDraws / cleanPerHour; if (cleanOccurrenceValue > 0 && cleanOccurrenceUnit === "perHour") averageHours = averageDraws / cleanOccurrenceValue;
else if (cleanPerDay > 0) averageHours = (averageDraws / cleanPerDay) * 24; else if (cleanOccurrenceValue > 0 && cleanOccurrenceUnit === "perDay") averageHours = (averageDraws / cleanOccurrenceValue) * 24;
else if (cleanPerMonth > 0) averageHours = (averageDraws / cleanPerMonth) * 24 * 30; else if (cleanOccurrenceValue > 0 && cleanOccurrenceUnit === "perMonth") averageHours = (averageDraws / cleanOccurrenceValue) * 24 * 30;
const explicitHourTime = cleanPerHour > 0 && averageDraws > 0 ? averageDraws / cleanPerHour : 0;
const explicitDayTime = cleanPerDay > 0 && averageDraws > 0 ? averageDraws / cleanPerDay : 0;
const explicitMonthTime = cleanPerMonth > 0 && averageDraws > 0 ? averageDraws / cleanPerMonth : 0;
return { return {
probability, probability,
@ -94,9 +111,9 @@ export function calculateRngSummary({ rate, multiplier = 0, simulationDraws, per
averageDraws, averageDraws,
chanceForSimulationDraws: calculateAtLeastOneProbability(rate, simulationDraws, multiplier), chanceForSimulationDraws: calculateAtLeastOneProbability(rate, simulationDraws, multiplier),
timeByFrequency: { timeByFrequency: {
hour: explicitHourTime || averageHours, hour: averageHours,
day: explicitDayTime || (averageHours > 0 ? averageHours / 24 : 0), day: averageHours > 0 ? averageHours / 24 : 0,
month: explicitMonthTime || (averageHours > 0 ? averageHours / 24 / 30 : 0) month: averageHours > 0 ? averageHours / 24 / 30 : 0
}, },
drawsForChance: { drawsForChance: {
50: calculateDrawsForChance(rate, 0.5, multiplier), 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) { export function simulateRngDraws(rate, draws, random = Math.random, multiplier = 0) {
const probability = getEffectiveRate(rate, multiplier) / 100; const probability = getEffectiveRate(rate, multiplier) / 100;
const cleanDraws = clampInteger(draws, RNG_SIMULATOR_LIMITS.simulationDrawsMin, RNG_SIMULATOR_LIMITS.simulationDrawsMax, 1); const cleanDraws = clampInteger(draws, RNG_SIMULATOR_LIMITS.simulationDrawsMin, RNG_SIMULATOR_LIMITS.simulationDrawsMax, 1);
let successes = 0; let successes = 0;
let currentFailStreak = 0; let currentFailStreak = 0;
let currentSuccessStreak = 0;
let firstSuccessDraw = 0;
let longestFailStreak = 0; let longestFailStreak = 0;
let longestSuccessStreak = 0;
for (let index = 0; index < cleanDraws; index += 1) { for (let index = 0; index < cleanDraws; index += 1) {
if (random() < probability) { if (random() < probability) {
successes += 1; successes += 1;
if (!firstSuccessDraw) firstSuccessDraw = index + 1;
currentSuccessStreak += 1;
longestSuccessStreak = Math.max(longestSuccessStreak, currentSuccessStreak);
currentFailStreak = 0; currentFailStreak = 0;
} else { } else {
currentFailStreak += 1; currentFailStreak += 1;
currentSuccessStreak = 0;
longestFailStreak = Math.max(longestFailStreak, currentFailStreak); longestFailStreak = Math.max(longestFailStreak, currentFailStreak);
} }
} }
@ -129,7 +172,9 @@ export function simulateRngDraws(rate, draws, random = Math.random, multiplier =
successes, successes,
fails: cleanDraws - successes, fails: cleanDraws - successes,
observedRate: cleanDraws > 0 ? (successes / cleanDraws) * 100 : 0, 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("@activeRateId: " + (data.activeRateId || ""));
lines.push("[rates]"); lines.push("[rates]");
(data.rates || []).forEach((rate) => { (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( lines.push(
"", "",
@ -51,10 +53,10 @@ export function importRngSimulatorText(text, context) {
id, id,
label: title, label: title,
rate: pipeMeta.rate, rate: pipeMeta.rate,
costPerDraw: pipeMeta.costPerDraw,
multiplier: pipeMeta.multiplier, multiplier: pipeMeta.multiplier,
perHour: pipeMeta.perHour, occurrenceValue: pipeMeta.occurrenceValue,
perDay: pipeMeta.perDay, occurrenceUnit: pipeMeta.occurrenceUnit
perMonth: pipeMeta.perMonth
}); });
if (pipeMeta.active === "true") data.activeRateId = id; if (pipeMeta.active === "true") data.activeRateId = id;
return; return;

View file

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

View file

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

View file

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