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

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

View file

@ -824,7 +824,8 @@ Type : `rngSimulator`
"costPerDraw": "150", "costPerDraw": "150",
"multiplier": 50, "multiplier": 50,
"occurrenceValue": 30, "occurrenceValue": 30,
"occurrenceUnit": "perHour" "occurrenceUnit": "perHour",
"realDraws": 120
}, },
{ {
"id": "rate2", "id": "rate2",
@ -847,12 +848,15 @@ Stockage compact :
- `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[].occurrenceValue` est numérique, positif ou nul, associé au taux, et vaut 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. - `rates[].occurrenceUnit` vaut `perHour` ou `perDay`, et vaut `perHour` par défaut.
- les anciennes données `occurrenceUnit: "perMonth"` sont converties en `perDay`, avec `occurrenceValue` divisé par 30.
- `rates[].realDraws` est le total persistant de tirages réellement effectués pour ce taux, borné de 0 à 1 000 000 000.
- `settings.simulationDraws` est borné de 1 à 1 000 000 et vaut 1000 par défaut. - `settings.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[].costPerDraw` est omis si vide ou nul. - `rates[].costPerDraw` est omis si vide ou nul.
- `rates[].multiplier` est omis si sa valeur vaut 0. - `rates[].multiplier` est omis si sa valeur vaut 0.
- `rates[].occurrenceValue` et `rates[].occurrenceUnit` sont omis si `occurrenceValue` vaut 0. - `rates[].occurrenceValue` et `rates[].occurrenceUnit` sont omis si `occurrenceValue` vaut 0.
- `rates[].realDraws` est omis si sa valeur vaut 0.
- l'historique des simulations n'est pas persisté en IndexedDB; il est limité aux 20 dernières entrées en `sessionStorage`. - l'historique des simulations n'est pas persisté en IndexedDB; il est limité aux 20 dernières entrées en `sessionStorage`.
Import/export texte : Import/export texte :
@ -860,7 +864,7 @@ Import/export texte :
```text ```text
@activeRateId: rate1 @activeRateId: rate1
[rates] [rates]
- Drop rare | rate=2.500 | active=true | costPerDraw=150 | multiplier=50 | occurrenceValue=30 | occurrenceUnit=perHour - Drop rare | rate=2.500 | active=true | costPerDraw=150 | multiplier=50 | occurrenceValue=30 | occurrenceUnit=perHour | realDraws=120
[settings] [settings]
simulationDraws: 1000 simulationDraws: 1000

View file

@ -327,13 +327,14 @@ export function validateSiteContent(site) {
"toolboxes.modules.rngSimulator.deleteTitle", "toolboxes.modules.rngSimulator.deleteTitle",
"toolboxes.modules.rngSimulator.noActiveRateTitle", "toolboxes.modules.rngSimulator.noActiveRateTitle",
"toolboxes.modules.rngSimulator.activeRateLabel", "toolboxes.modules.rngSimulator.activeRateLabel",
"toolboxes.modules.rngSimulator.commonSettingsTitle",
"toolboxes.modules.rngSimulator.effectiveRateLabel", "toolboxes.modules.rngSimulator.effectiveRateLabel",
"toolboxes.modules.rngSimulator.statsTabLabel", "toolboxes.modules.rngSimulator.forecastsTabLabel",
"toolboxes.modules.rngSimulator.simulationTabLabel", "toolboxes.modules.rngSimulator.simulationTabLabel",
"toolboxes.modules.rngSimulator.trackingTabLabel",
"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.multiplierLabel", "toolboxes.modules.rngSimulator.multiplierLabel",
"toolboxes.modules.rngSimulator.averageDrawsLabel", "toolboxes.modules.rngSimulator.averageDrawsLabel",
"toolboxes.modules.rngSimulator.simulationChanceLabel", "toolboxes.modules.rngSimulator.simulationChanceLabel",
@ -345,14 +346,14 @@ export function validateSiteContent(site) {
"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.durationChancesTitle",
"toolboxes.modules.rngSimulator.durationChancePrefix",
"toolboxes.modules.rngSimulator.chanceTargetsTitle", "toolboxes.modules.rngSimulator.chanceTargetsTitle",
"toolboxes.modules.rngSimulator.chanceLabel", "toolboxes.modules.rngSimulator.chanceLabel",
"toolboxes.modules.rngSimulator.costLabel", "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",
"toolboxes.modules.rngSimulator.monthsUnit",
"toolboxes.modules.rngSimulator.emptySummary", "toolboxes.modules.rngSimulator.emptySummary",
"toolboxes.modules.rngSimulator.simulationTitle", "toolboxes.modules.rngSimulator.simulationTitle",
"toolboxes.modules.rngSimulator.simulateButton", "toolboxes.modules.rngSimulator.simulateButton",
@ -370,6 +371,24 @@ export function validateSiteContent(site) {
"toolboxes.modules.rngSimulator.luckySimulationLabel", "toolboxes.modules.rngSimulator.luckySimulationLabel",
"toolboxes.modules.rngSimulator.neutralSimulationLabel", "toolboxes.modules.rngSimulator.neutralSimulationLabel",
"toolboxes.modules.rngSimulator.unluckySimulationLabel", "toolboxes.modules.rngSimulator.unluckySimulationLabel",
"toolboxes.modules.rngSimulator.realDrawsLabel",
"toolboxes.modules.rngSimulator.realCumulativeChanceLabel",
"toolboxes.modules.rngSimulator.afterDrawsPrefix",
"toolboxes.modules.rngSimulator.averagePositionLabel",
"toolboxes.modules.rngSimulator.averageStatusStartLabel",
"toolboxes.modules.rngSimulator.averageStatusFarBelowLabel",
"toolboxes.modules.rngSimulator.averageStatusBelowLabel",
"toolboxes.modules.rngSimulator.averageStatusAverageLabel",
"toolboxes.modules.rngSimulator.averageStatusSlightlyAboveLabel",
"toolboxes.modules.rngSimulator.averageStatusHighLabel",
"toolboxes.modules.rngSimulator.averageStatusCatastrophicLabel",
"toolboxes.modules.rngSimulator.drawsUntilAverageLabel",
"toolboxes.modules.rngSimulator.averageExceededLabel",
"toolboxes.modules.rngSimulator.averageCostDeltaLabel",
"toolboxes.modules.rngSimulator.trackingTargetsTitle",
"toolboxes.modules.rngSimulator.trackingProgressLabel",
"toolboxes.modules.rngSimulator.remainingDrawsLabel",
"toolboxes.modules.rngSimulator.remainingCostLabel",
"toolboxes.modules.rngSimulator.importPlaceholder", "toolboxes.modules.rngSimulator.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, calculateRngCostSummary, calculateRngSummary, getEffectiveRate, normalizeCostString, normalizeRateString, simulateRngDraws } from "../website/src/features/toolboxes/modules/rngSimulatorUtils.js"; import { calculateAtLeastOneProbability, calculateDrawsForChance, calculateRngCostSummary, calculateRngRealDrawsSummary, calculateRngSummary, getAveragePositionStatus, getDurationPresetsForAverageHours, getEffectiveRate, normalizeCostString, normalizeRateString, simulateRngDraws } from "../website/src/features/toolboxes/modules/rngSimulatorUtils.js";
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js"; import { 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", costPerDraw: "", multiplier: 25, occurrenceValue: 10, occurrenceUnit: "perHour" }, { id: "rate1", label: "Drop rare", rate: "2.500", costPerDraw: "", multiplier: 25, occurrenceValue: 10, occurrenceUnit: "perHour", realDraws: 0 },
{ id: "rate2", label: "Ultra rare", rate: "0.000004", costPerDraw: "12,500", multiplier: 50, occurrenceValue: 120, occurrenceUnit: "perDay" }, { id: "rate2", label: "Ultra rare", rate: "0.000004", costPerDraw: "12,500", multiplier: 50, occurrenceValue: 3600, occurrenceUnit: "perMonth", realDraws: "100 000" },
{ id: "bad", label: "", rate: "4" } { id: "bad", label: "", rate: "4" }
], ],
settings: { simulationDraws: 2000 }, settings: { simulationDraws: 2000 },
@ -313,7 +313,10 @@ 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.costPerDraw), ["", "12.500"]); assert.deepEqual(normalized.rates.map((rate) => rate.costPerDraw), ["", "12.500"]);
assert.deepEqual(normalized.rates.map((rate) => [rate.multiplier, rate.occurrenceValue, rate.occurrenceUnit]), [[25, 10, "perHour"], [50, 120, "perDay"]]); assert.deepEqual(normalized.rates.map((rate) => [rate.multiplier, rate.occurrenceValue, rate.occurrenceUnit, rate.realDraws]), [[25, 10, "perHour", 0], [50, 120, "perDay", 100000]]);
assert.equal(normalizeRngSimulatorData({ rates: [{ label: "Bad real", rate: "5", realDraws: -10 }] }).rates[0].realDraws, 0);
assert.equal(normalizeRngSimulatorData({ rates: [{ label: "Decimal real", rate: "5", realDraws: "42.9" }] }).rates[0].realDraws, 42);
assert.equal(normalizeRngSimulatorData({ rates: [{ label: "Huge real", rate: "5", realDraws: 2000000000 }] }).rates[0].realDraws, 1000000000);
assert.equal(normalized.history, undefined); assert.equal(normalized.history, undefined);
assert.equal(normalizeCostString("0"), ""); assert.equal(normalizeCostString("0"), "");
assert.equal(normalizeCostString("0012,500"), "12.500"); assert.equal(normalizeCostString("0012,500"), "12.500");
@ -324,6 +327,8 @@ test("rng simulator preserves rate precision and compacts storage", () => {
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].multiplier, 50); assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].multiplier, 50);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].occurrenceValue, 120); assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].occurrenceValue, 120);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].occurrenceUnit, "perDay"); assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].occurrenceUnit, "perDay");
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[0].realDraws, undefined);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].realDraws, 100000);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).settings.simulationDraws, 2000); assert.equal(compactModuleDataForStorage("rngSimulator", normalized).settings.simulationDraws, 2000);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).history, undefined); assert.equal(compactModuleDataForStorage("rngSimulator", normalized).history, undefined);
}); });
@ -339,10 +344,23 @@ test("rng simulator calculates probability milestones and deterministic simulati
const hourlySummary = calculateRngSummary({ rate: "25", simulationDraws: 8, occurrenceValue: 2, occurrenceUnit: "perHour" }); 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.deepEqual(hourlySummary.durationChances.map((preset) => preset.id), ["1h", "5h", "10h", "1j"]);
assert.equal(hourlySummary.durationChances.find((preset) => preset.id === "1h").draws, 2);
assert.equal(hourlySummary.durationChances.find((preset) => preset.id === "1h").chance, 0.4375);
const dailySummary = calculateRngSummary({ rate: "25", simulationDraws: 8, occurrenceValue: 2, occurrenceUnit: "perDay" }); const dailySummary = calculateRngSummary({ rate: "25", simulationDraws: 8, occurrenceValue: 2, occurrenceUnit: "perDay" });
assert.equal(dailySummary.timeByFrequency.hour, 48); assert.equal(dailySummary.timeByFrequency.hour, 48);
assert.equal(dailySummary.timeByFrequency.day, 2); assert.equal(dailySummary.timeByFrequency.day, 2);
assert.deepEqual(dailySummary.durationChances.map((preset) => preset.id), ["1j", "3j", "7j", "30j"]);
const legacyMonthSummary = calculateRngSummary({ rate: "25", simulationDraws: 8, occurrenceValue: 60, occurrenceUnit: "perMonth" });
assert.equal(legacyMonthSummary.timeByFrequency.day, 2);
assert.deepEqual(legacyMonthSummary.durationChances.map((preset) => preset.id), ["1j", "3j", "7j", "30j"]);
const fractionalDurationSummary = calculateRngSummary({ rate: "24", simulationDraws: 8, occurrenceValue: 1, occurrenceUnit: "perDay" });
assert.ok(fractionalDurationSummary.durationChances.find((preset) => preset.id === "1j").chance > 0);
const rareDurationSummary = calculateRngSummary({ rate: "0.000004", simulationDraws: 8, occurrenceValue: 60, occurrenceUnit: "perHour" });
assert.deepEqual(rareDurationSummary.durationChances.map((preset) => preset.id), ["1a", "5a", "10a", "25a"]);
assert.deepEqual(getDurationPresetsForAverageHours(24 * 30).map((preset) => preset.id), ["1j", "3j", "7j", "30j"]);
assert.deepEqual(getDurationPresetsForAverageHours(24 * 365 * 2).map((preset) => preset.id), ["30j", "90j", "1a", "5a"]);
assert.deepEqual(getDurationPresetsForAverageHours(0, "perDay").map((preset) => preset.id), ["1j", "3j", "7j", "15j"]);
const multipliedSummary = calculateRngSummary({ rate: "10", multiplier: 100, simulationDraws: 1, occurrenceValue: 0, occurrenceUnit: "perHour" }); 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);
@ -354,6 +372,25 @@ test("rng simulator calculates probability milestones and deterministic simulati
assert.equal(costSummary.simulationCostPerSuccess, 50); 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: "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); assert.equal(calculateRngCostSummary({ costPerDraw: "0", averageDraws: 5, drawsForChance: {}, simulationDraws: 8, simulationSuccesses: 2 }), null);
const realSummary = calculateRngRealDrawsSummary({ rate: "25", realDraws: 2, averageDraws: 4, drawsForChance: { 50: 3, 90: 9 }, costPerDraw: "10" });
assert.equal(realSummary.hasCost, true);
assert.equal(realSummary.cumulativeChance, 0.4375);
assert.equal(realSummary.noSuccessChance, 0.5625);
assert.equal(realSummary.averageRatio, 0.5);
assert.deepEqual(realSummary.averagePosition, { id: "below", tone: "positive" });
assert.equal(realSummary.drawsUntilAverage, 2);
assert.equal(realSummary.spentCost, 20);
assert.equal(realSummary.averageSuccessCost, 40);
assert.equal(realSummary.averageCostDelta, -20);
assert.equal(realSummary.costUntilAverage, 20);
assert.equal(realSummary.targets[50].remainingDraws, 1);
assert.deepEqual(getAveragePositionStatus(0.1), { id: "start", tone: "positive" });
assert.deepEqual(getAveragePositionStatus(0.3), { id: "farBelow", tone: "positive" });
assert.deepEqual(getAveragePositionStatus(0.75), { id: "below", tone: "positive" });
assert.deepEqual(getAveragePositionStatus(1), { id: "average", tone: "" });
assert.deepEqual(getAveragePositionStatus(1.25), { id: "slightlyAbove", tone: "negative" });
assert.deepEqual(getAveragePositionStatus(2), { id: "high", tone: "negative" });
assert.deepEqual(getAveragePositionStatus(3), { id: "catastrophic", tone: "negative" });
const result = simulateRngDraws("25", 8, (() => { const 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];
@ -386,8 +423,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", costPerDraw: "150", multiplier: 25, occurrenceValue: 20, occurrenceUnit: "perHour" }, { id: "rate1", label: "Drop rare", rate: "2.500", costPerDraw: "150", multiplier: 25, occurrenceValue: 20, occurrenceUnit: "perHour", realDraws: 60 },
{ id: "rate2", label: "Ultra rare", rate: "0.000004", costPerDraw: "", multiplier: 50, occurrenceValue: 120, occurrenceUnit: "perDay" } { id: "rate2", label: "Ultra rare", rate: "0.000004", costPerDraw: "", multiplier: 50, occurrenceValue: 120, occurrenceUnit: "perDay", realDraws: 0 }
], ],
settings: { simulationDraws: 5000 } settings: { simulationDraws: 5000 }
}); });
@ -403,6 +440,11 @@ test("rng simulator text exchange round trips rates and settings", () => {
assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.multiplier, 50); assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.multiplier, 50);
assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.occurrenceValue, 120); assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.occurrenceValue, 120);
assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.occurrenceUnit, "perDay"); assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.occurrenceUnit, "perDay");
assert.equal(imported.data.rates.find((rate) => rate.label === "Drop rare")?.realDraws, 60);
const legacyImported = importModuleText("rngSimulator", "[rates]\n- Legacy | rate=1 | occurrenceValue=300 | occurrenceUnit=perMonth", context);
assert.equal(legacyImported.ok, true);
assert.equal(legacyImported.data.rates[0].occurrenceValue, 10);
assert.equal(legacyImported.data.rates[0].occurrenceUnit, "perDay");
assert.equal(imported.data.settings.simulationDraws, 5000); assert.equal(imported.data.settings.simulationDraws, 5000);
assert.equal(imported.data.history, undefined); assert.equal(imported.data.history, undefined);
}); });

View file

@ -542,11 +542,11 @@
}, },
{ {
"title": "Lecture probabiliste", "title": "Lecture probabiliste",
"text": "L'onglet Calculs affiche la moyenne d'essais avant réussite, le temps moyen selon votre rythme, les coûts et les paliers de chance cumulée.", "text": "Les paramètres communs affichent la moyenne d'essais avant réussite, tandis que l'onglet Prévisions détaille les durées et les paliers de chance cumulée.",
"details": [ "details": [
"Les occurrences par heure, jour et mois sont indépendantes pour s'adapter à différents usages.", "Les occurrences par heure ou par jour servent à estimer le temps moyen et les chances après une durée donnée.",
"Les paliers 50%, 90%, 95% et 99% donnent un repère concret sur la variance.", "Les paliers 50%, 90%, 95% et 99% donnent un repère concret sur la variance.",
"L'onglet Simulation agrège réussites, échecs, pire série d'échecs et coûts sans stocker chaque tirage." "L'onglet Suivi permet d'enregistrer ses tirages réels et de les situer par rapport à la moyenne."
] ]
} }
], ],
@ -2125,13 +2125,14 @@
"deleteTitle": "Supprimer", "deleteTitle": "Supprimer",
"noActiveRateTitle": "Aucun taux actif", "noActiveRateTitle": "Aucun taux actif",
"activeRateLabel": "Taux brut", "activeRateLabel": "Taux brut",
"commonSettingsTitle": "Paramètres du taux",
"effectiveRateLabel": "Taux final", "effectiveRateLabel": "Taux final",
"statsTabLabel": "Calculs", "forecastsTabLabel": "Prévisions",
"simulationTabLabel": "Simulation", "simulationTabLabel": "Simulation",
"trackingTabLabel": "Suivi",
"simulationDrawsLabel": "Tirages simulés", "simulationDrawsLabel": "Tirages simulés",
"perHourLabel": "Par heure", "perHourLabel": "Par heure",
"perDayLabel": "Par jour", "perDayLabel": "Par jour",
"perMonthLabel": "Par mois",
"multiplierLabel": "Multiplicateur (%)", "multiplierLabel": "Multiplicateur (%)",
"averageDrawsLabel": "Moyenne avant réussite", "averageDrawsLabel": "Moyenne avant réussite",
"simulationChanceLabel": "Chance sur les tirages saisis", "simulationChanceLabel": "Chance sur les tirages saisis",
@ -2143,14 +2144,14 @@
"occurrenceCountLabel": "Nombre d'occurrences", "occurrenceCountLabel": "Nombre d'occurrences",
"hourTimeLabel": "En heures", "hourTimeLabel": "En heures",
"dayTimeLabel": "En jours", "dayTimeLabel": "En jours",
"monthTimeLabel": "En mois", "durationChancesTitle": "Chances selon une durée",
"durationChancePrefix": "Après",
"chanceTargetsTitle": "Tirages pour atteindre une chance cumulée", "chanceTargetsTitle": "Tirages pour atteindre une chance cumulée",
"chanceLabel": "Chance", "chanceLabel": "Chance",
"costLabel": "Coût", "costLabel": "Coût",
"drawsUnit": "tirages", "drawsUnit": "tirages",
"hoursUnit": "h", "hoursUnit": "h",
"daysUnit": "j", "daysUnit": "j",
"monthsUnit": "mois",
"emptySummary": "Ajoutez un taux pour afficher les calculs.", "emptySummary": "Ajoutez un taux pour afficher les calculs.",
"simulationTitle": "Résultats simulés", "simulationTitle": "Résultats simulés",
"simulateButton": "Simuler", "simulateButton": "Simuler",
@ -2168,7 +2169,25 @@
"luckySimulationLabel": "Au-dessus", "luckySimulationLabel": "Au-dessus",
"neutralSimulationLabel": "Dans la cible", "neutralSimulationLabel": "Dans la cible",
"unluckySimulationLabel": "Sous l'attendu", "unluckySimulationLabel": "Sous l'attendu",
"importPlaceholder": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true | costPerDraw=150 | multiplier=320 | occurrenceValue=60 | occurrenceUnit=perHour\n- Pen Eklecta | rate=20.02 | costPerDraw=12.5 | multiplier=0\n\n[settings]\nsimulationDraws: 1000", "realDrawsLabel": "Tirages effectués",
"realCumulativeChanceLabel": "Chance d'avoir déjà réussi",
"afterDrawsPrefix": "après",
"averagePositionLabel": "Position vs moyenne",
"averageStatusStartLabel": "Début de suivi",
"averageStatusFarBelowLabel": "Loin sous la moyenne",
"averageStatusBelowLabel": "Un peu moins que la moyenne",
"averageStatusAverageLabel": "Dans la moyenne",
"averageStatusSlightlyAboveLabel": "Un peu au-dessus de la moyenne",
"averageStatusHighLabel": "Très au-dessus de la moyenne",
"averageStatusCatastrophicLabel": "Catastrophique",
"drawsUntilAverageLabel": "Tirages avant moyenne",
"averageExceededLabel": "Moyenne dépassée",
"averageCostDeltaLabel": "Écart au coût moyen",
"trackingTargetsTitle": "Progression vers les chances cumulées",
"trackingProgressLabel": "Progression",
"remainingDrawsLabel": "Tirages restants",
"remainingCostLabel": "Coût restant",
"importPlaceholder": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true | costPerDraw=150 | multiplier=320 | occurrenceValue=60 | occurrenceUnit=perHour | realDraws=1000\n- Pen Eklecta | rate=20.02 | costPerDraw=12.5 | multiplier=0\n\n[settings]\nsimulationDraws: 1000",
"importOpenButton": "Importer du texte", "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

@ -6,11 +6,13 @@ import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { import {
RNG_SIMULATOR_DEFAULT_RATE, RNG_SIMULATOR_DEFAULT_RATE,
RNG_SIMULATOR_HOURLY_DURATION_PRESETS,
RNG_SIMULATOR_HISTORY_LIMIT, RNG_SIMULATOR_HISTORY_LIMIT,
RNG_SIMULATOR_LIMITS, RNG_SIMULATOR_LIMITS,
addRngSimulationHistory, addRngSimulationHistory,
calculateAtLeastOneProbability, calculateAtLeastOneProbability,
calculateRngCostSummary, calculateRngCostSummary,
calculateRngRealDrawsSummary,
calculateRngSummary, calculateRngSummary,
clampDecimal, clampDecimal,
clampInteger, clampInteger,
@ -23,7 +25,8 @@ import {
const CHANCE_TARGETS = [50, 90, 95, 99]; const CHANCE_TARGETS = [50, 90, 95, 99];
const RNG_TABS = [ const RNG_TABS = [
{ id: "stats", label: "Calculs" }, { id: "tracking", label: "Suivi" },
{ id: "forecasts", label: "Prévisions" },
{ id: "simulation", label: "Simulation" } { id: "simulation", label: "Simulation" }
]; ];
@ -42,6 +45,12 @@ function formatCost(value) {
return formatNumber(value, 0); return formatNumber(value, 0);
} }
function formatSignedCost(value) {
if (!Number.isFinite(value)) return "—";
if (value === 0) return "0";
return `${value > 0 ? "+" : ""}${formatCost(value)}`;
}
function formatDrawTarget(value, unit) { function formatDrawTarget(value, unit) {
if (!Number.isFinite(value)) return `— ${unit}`; if (!Number.isFinite(value)) return `— ${unit}`;
if (value >= 1000000) { if (value >= 1000000) {
@ -118,21 +127,41 @@ function historyEntryMatchesRate(entry, rate) {
return entry.label === rate.label && entry.rate === rate.rate; return entry.label === rate.label && entry.rate === rate.rate;
} }
function getAveragePositionLabel(status, textContent) {
const labels = {
start: textContent.averageStatusStartLabel || "Début de suivi",
farBelow: textContent.averageStatusFarBelowLabel || "Loin sous la moyenne",
below: textContent.averageStatusBelowLabel || "Un peu moins que la moyenne",
average: textContent.averageStatusAverageLabel || "Dans la moyenne",
slightlyAbove: textContent.averageStatusSlightlyAboveLabel || "Un peu au-dessus de la moyenne",
high: textContent.averageStatusHighLabel || "Très au-dessus de la moyenne",
catastrophic: textContent.averageStatusCatastrophicLabel || "Catastrophique"
};
return labels[status?.id] || labels.start;
}
function getToneFromDelta(value) {
if (!Number.isFinite(value) || value === 0) return "";
return value > 0 ? "positive" : "negative";
}
export function RngSimulatorModule({ toolboxId, moduleId, context, editing = false, setEditing = () => {}, textExchangeOpen }) { 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(() => [ const tabs = useMemo(() => [
{ ...RNG_TABS[0], label: textContent.statsTabLabel || RNG_TABS[0].label }, { ...RNG_TABS[0], label: textContent.trackingTabLabel || RNG_TABS[0].label },
{ ...RNG_TABS[1], label: textContent.simulationTabLabel || RNG_TABS[1].label } { ...RNG_TABS[1], label: textContent.forecastsTabLabel || RNG_TABS[1].label },
], [textContent.simulationTabLabel, textContent.statsTabLabel]); { ...RNG_TABS[2], label: textContent.simulationTabLabel || RNG_TABS[2].label }
], [textContent.forecastsTabLabel, textContent.simulationTabLabel, textContent.trackingTabLabel]);
const historyStorageKey = `rng-simulator:${toolboxId}:${moduleId}:history`; const historyStorageKey = `rng-simulator:${toolboxId}:${moduleId}:history`;
const [activeTab, setActiveTab] = useState("stats"); const [activeTab, setActiveTab] = useState("tracking");
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 [costDraft, setCostDraft] = useState(null); const [costDraft, setCostDraft] = useState(null);
const [occurrenceUnit, setOccurrenceUnit] = useState("perHour"); const [occurrenceUnit, setOccurrenceUnit] = useState("perHour");
const [occurrenceDraft, setOccurrenceDraft] = useState(null); const [occurrenceDraft, setOccurrenceDraft] = useState(null);
const [realDrawsDraft, setRealDrawsDraft] = useState(null);
const rateFormRef = useRef(null); const rateFormRef = useRef(null);
const previousEditingRef = useRef(editing); const previousEditingRef = useRef(editing);
const rateDraft = useDraftForm({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE }); const rateDraft = useDraftForm({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
@ -154,6 +183,17 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal
}) : null, [activeRate, costDraft, data.settings.simulationDraws, summary]); }) : null, [activeRate, costDraft, data.settings.simulationDraws, summary]);
const activeHistory = useMemo(() => history.filter((entry) => historyEntryMatchesRate(entry, activeRate)), [activeRate, history]); const activeHistory = useMemo(() => history.filter((entry) => historyEntryMatchesRate(entry, activeRate)), [activeRate, history]);
const chanceWithoutMultiplier = activeRate ? calculateAtLeastOneProbability(activeRate.rate, data.settings.simulationDraws, 0) : 0; const chanceWithoutMultiplier = activeRate ? calculateAtLeastOneProbability(activeRate.rate, data.settings.simulationDraws, 0) : 0;
const realDrawsSummary = useMemo(() => activeRate && summary ? calculateRngRealDrawsSummary({
rate: activeRate.rate,
multiplier: activeRate.multiplier,
realDraws: activeRate.realDraws,
averageDraws: summary.averageDraws,
drawsForChance: summary.drawsForChance,
costPerDraw: activeRate.costPerDraw
}) : null, [activeRate, summary]);
const averageDrawsDelta = activeRate && summary ? activeRate.realDraws - summary.averageDraws : 0;
const averageExceededDraws = Math.max(0, Math.round(averageDrawsDelta));
const averageDrawsTone = realDrawsSummary?.averageExceeded ? (averageExceededDraws > 0 ? "negative" : "") : "positive";
useEffect(() => { useEffect(() => {
setHistory(readSimulationHistory(historyStorageKey)); setHistory(readSimulationHistory(historyStorageKey));
@ -185,6 +225,7 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal
setMultiplierDraft(null); setMultiplierDraft(null);
setCostDraft(null); setCostDraft(null);
setOccurrenceDraft(null); setOccurrenceDraft(null);
setRealDrawsDraft(null);
setOccurrenceUnit(activeRate?.occurrenceUnit || "perHour"); setOccurrenceUnit(activeRate?.occurrenceUnit || "perHour");
}, [activeRate?.id, activeRate?.occurrenceUnit]); }, [activeRate?.id, activeRate?.occurrenceUnit]);
@ -277,6 +318,30 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal
setOccurrenceDraft(null); setOccurrenceDraft(null);
} }
function updateRealDraws(value) {
setRealDrawsDraft(value);
if (!activeRate) return;
if (String(value).trim() === "") {
save({
...data,
rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, realDraws: 0 } : rate)
});
return;
}
save({
...data,
rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, realDraws: clampInteger(value, 0, RNG_SIMULATOR_LIMITS.realDrawsMax, activeRate.realDraws) } : rate)
});
}
function blurRealDraws() {
if (realDrawsDraft === null) return;
const raw = realDrawsDraft.trim();
const parsed = Number.parseInt(raw.replace(/[\s\u00a0]/g, ""), 10);
if (!raw || !Number.isFinite(parsed)) updateRealDraws("");
setRealDrawsDraft(null);
}
function submitRate(event) { function submitRate(event) {
rateDraft.handleSubmit(event, (draft, { reset }) => { rateDraft.handleSubmit(event, (draft, { reset }) => {
const label = draft.label.trim(); const label = draft.label.trim();
@ -410,69 +475,88 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal
</div> </div>
</section> </section>
<section className="rng-simulator-panel rng-simulator-common-panel">
<header>
<div>
<h3>{textContent.commonSettingsTitle || "Paramètres du taux"}</h3>
</div>
</header>
{activeRate && summary ? (
<div className="rng-simulator-primary-controls">
<div className="rng-simulator-primary-column">
<div className="rng-simulator-field rng-simulator-multiplier-field">
<span>{textContent.multiplierLabel || "Multiplicateur (%)"}</span>
<div className="rng-simulator-percent-input">
<input className="nds-input" data-size="sm" type="number" step="any" value={multiplierDraft ?? String(activeRate.multiplier ?? 0)} onChange={(event) => updateMultiplier(event.target.value)} onBlur={blurMultiplier} aria-label={textContent.multiplierLabel || "Multiplicateur (%)"} />
<span>%</span>
</div>
</div>
<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"}`} />
</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="number" step="any" value={costDraft ?? activeRate.costPerDraw ?? ""} onChange={(event) => updateCost(event.target.value)} onBlur={blurCost} placeholder={textContent.costPerDrawPlaceholder || "Optionnel"} />
</label>
<RngStat label={textContent.averageCostLabel || "Coût moyen avant réussite"} value={formatCost(costSummary?.averageSuccessCost)} />
</div>
</div>
) : <p className="rng-simulator-empty">{textContent.emptySummary || "Ajoutez un taux pour afficher les calculs."}</p>}
</section>
<section className="rng-simulator-panel rng-simulator-tabs-panel"> <section className="rng-simulator-panel rng-simulator-tabs-panel">
<header> <header>
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="rng-simulator-tabs" /> <Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="rng-simulator-tabs" />
</header> </header>
{activeTab === "stats" && ( {activeTab === "forecasts" && (
<div className="rng-simulator-tab-content"> <div className="rng-simulator-tab-content">
{activeRate && summary ? ( {activeRate && summary ? (
<> <>
<div className="rng-simulator-section">
<div className="rng-simulator-primary-controls">
<div className="rng-simulator-primary-column">
<div className="rng-simulator-field rng-simulator-multiplier-field">
<span>{textContent.multiplierLabel || "Multiplicateur (%)"}</span>
<div className="rng-simulator-percent-input">
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={multiplierDraft ?? String(activeRate.multiplier ?? 0)} onChange={(event) => updateMultiplier(event.target.value)} onBlur={blurMultiplier} aria-label={textContent.multiplierLabel || "Multiplicateur (%)"} />
<span>%</span>
</div>
</div>
<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"}`} />
</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>
<div className="rng-simulator-occurrence-control"> <div className="rng-simulator-occurrence-control">
<label className="rng-simulator-field"> <label className="rng-simulator-field">
<span>{textContent.occurrenceCountLabel || "Nombre d'occurrences"}</span> <span>{textContent.occurrenceCountLabel || "Nombre d'occurrences"}</span>
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDraft ?? String(activeRate.occurrenceValue ?? 0)} onChange={(event) => updateOccurrence(event.target.value)} onBlur={blurOccurrence} /> <input className="nds-input" data-size="sm" type="number" step="any" value={occurrenceDraft ?? String(activeRate.occurrenceValue ?? 0)} onChange={(event) => updateOccurrence(event.target.value)} onBlur={blurOccurrence} />
</label> </label>
<label className="rng-simulator-field"> <label className="rng-simulator-field">
<span>{textContent.periodLabel || "Période"}</span> <span>{textContent.periodLabel || "Période"}</span>
<select className="nds-select" data-size="sm" value={occurrenceUnit} onChange={(event) => changeOccurrenceUnit(event.target.value)}> <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="perHour">{textContent.perHourLabel || "Par heure"}</option>
<option value="perDay">{textContent.perDayLabel || "Par jour"}</option> <option value="perDay">{textContent.perDayLabel || "Par jour"}</option>
<option value="perMonth">{textContent.perMonthLabel || "Par mois"}</option>
</select> </select>
</label> </label>
</div> </div>
<div className="rng-simulator-time-results"> <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")} /> </div>
</div>
<div className="rng-simulator-section">
<span className="rng-simulator-section-title">{textContent.durationChancesTitle || "Chances selon une durée"}</span>
<div className="rng-simulator-duration-grid">
{(summary.durationChances || RNG_SIMULATOR_HOURLY_DURATION_PRESETS).map((preset) => (
<RngStat
key={preset.id}
label={`${textContent.durationChancePrefix || "Après"} ${preset.label}`}
value={formatPercent((preset.chance || 0) * 100, 6)}
title={`${formatNumber(preset.draws || 0, 2)} ${textContent.drawsUnit || "tirages"}`}
/>
))}
</div> </div>
</div> </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-table rng-simulator-target-table"> <div className="rng-simulator-table rng-simulator-target-table">
<span>{textContent.chanceLabel || "Chance"}</span> <span className="rng-simulator-table-heading">{textContent.chanceLabel || "Chance"}</span>
<span>{textContent.drawsUnit || "Tirages"}</span> <span className="rng-simulator-table-heading">{textContent.drawsUnit || "Tirages"}</span>
<span>{textContent.costLabel || "Coût"}</span> <span className="rng-simulator-table-heading">{textContent.costLabel || "Coût"}</span>
{CHANCE_TARGETS.map((target) => ( {CHANCE_TARGETS.map((target) => (
<RngTargetRow costSummary={costSummary} draws={summary.drawsForChance[target]} key={target} target={target} textContent={textContent} /> <RngTargetRow costSummary={costSummary} draws={summary.drawsForChance[target]} key={target} target={target} textContent={textContent} />
))} ))}
@ -489,7 +573,7 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal
<div className="rng-simulator-simulation-actions"> <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>
<input className="nds-input" data-size="sm" type="number" min="1" max={RNG_SIMULATOR_LIMITS.simulationDrawsMax} value={data.settings.simulationDraws} onChange={(event) => updateSettings("simulationDraws", event.target.value)} /> <input className="nds-input" data-size="sm" type="number" min="1" max={RNG_SIMULATOR_LIMITS.simulationDrawsMax} step="1" value={data.settings.simulationDraws} onChange={(event) => updateSettings("simulationDraws", event.target.value)} />
</label> </label>
<button className="nds-button" data-size="sm" data-variant="primary" type="button" onClick={runSimulation} disabled={!activeRate}> <button className="nds-button" data-size="sm" data-variant="primary" type="button" onClick={runSimulation} disabled={!activeRate}>
<Icon name="play-circle" /> <Icon name="play-circle" />
@ -516,6 +600,42 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal
</div> </div>
</div> </div>
)} )}
{activeTab === "tracking" && (
<div className="rng-simulator-tab-content">
{activeRate && summary && realDrawsSummary ? (
<>
<div className="rng-simulator-section">
<div className="rng-simulator-tracking-control">
<label className="rng-simulator-field">
<span>{textContent.realDrawsLabel || "Tirages effectués"}</span>
<input className="nds-input" data-size="sm" type="number" min="0" max={RNG_SIMULATOR_LIMITS.realDrawsMax} step="1" value={realDrawsDraft ?? String(activeRate.realDraws ?? 0)} onChange={(event) => updateRealDraws(event.target.value)} onBlur={blurRealDraws} />
</label>
</div>
<div className="rng-simulator-tracking-grid">
<RngStat label={`${textContent.realCumulativeChanceLabel || "Chance d'avoir déjà réussi"} ${textContent.afterDrawsPrefix || "après"} ${formatNumber(realDrawsSummary.realDraws, 0)} ${textContent.drawsUnit || "tirages"}`} value={formatPercent(realDrawsSummary.cumulativeChance * 100, 6)} />
<RngStat label={textContent.averagePositionLabel || "Position vs moyenne"} tone={realDrawsSummary.averagePosition.tone} value={getAveragePositionLabel(realDrawsSummary.averagePosition, textContent)} />
<RngStat label={realDrawsSummary.averageExceeded ? textContent.averageExceededLabel || "Moyenne dépassée" : textContent.drawsUntilAverageLabel || "Tirages avant moyenne"} tone={averageDrawsTone} value={realDrawsSummary.averageExceeded ? `${formatNumber(averageExceededDraws, 0)} ${textContent.drawsUnit || "tirages"}` : `${formatNumber(realDrawsSummary.drawsUntilAverage, 0)} ${textContent.drawsUnit || "tirages"}`} />
<RngStat label={textContent.averageCostDeltaLabel || "Écart au coût moyen"} tone={realDrawsSummary.averageCostDelta > 0 ? "negative" : realDrawsSummary.averageCostDelta < 0 ? "positive" : ""} value={realDrawsSummary.hasCost ? formatSignedCost(realDrawsSummary.averageCostDelta) : "—"} />
</div>
</div>
<div className="rng-simulator-section">
<span className="rng-simulator-section-title">{textContent.trackingTargetsTitle || "Progression vers les chances cumulées"}</span>
<div className="rng-simulator-table rng-simulator-tracking-target-table">
<span className="rng-simulator-table-heading">{textContent.chanceLabel || "Chance"}</span>
<span className="rng-simulator-table-heading">{textContent.trackingProgressLabel || "Progression"}</span>
<span className="rng-simulator-table-heading">{textContent.remainingDrawsLabel || "Tirages restants"}</span>
<span className="rng-simulator-table-heading">{textContent.remainingCostLabel || "Coût restant"}</span>
{CHANCE_TARGETS.map((target) => (
<RngTrackingTargetRow hasCost={realDrawsSummary.hasCost} key={target} target={target} targetSummary={realDrawsSummary.targets[target]} textContent={textContent} />
))}
</div>
</div>
</>
) : <p className="rng-simulator-empty">{textContent.emptySummary || "Ajoutez un taux pour afficher les calculs."}</p>}
</div>
)}
</section> </section>
</div> </div>
</> </>
@ -531,9 +651,9 @@ function RngInfo({ label, value }) {
); );
} }
function RngStat({ label, value, title }) { function RngStat({ label, tone = "", value, title }) {
return ( return (
<article className="rng-simulator-stat" title={title}> <article className="rng-simulator-stat" data-tone={tone || undefined} title={title}>
<span>{label}</span> <span>{label}</span>
<strong>{value}</strong> <strong>{value}</strong>
</article> </article>
@ -543,16 +663,37 @@ function RngStat({ label, value, title }) {
function RngTargetRow({ costSummary, draws, target, textContent }) { function RngTargetRow({ costSummary, draws, target, textContent }) {
return ( return (
<> <>
<strong>{target} %</strong> <strong className="rng-simulator-chance-value">{target} %</strong>
<span title={`${formatNumber(draws, 0)} ${textContent.drawsUnit || "tirages"}`}>{formatDrawTarget(draws, textContent.drawsUnit || "tirages")}</span> <span title={`${formatNumber(draws, 0)} ${textContent.drawsUnit || "tirages"}`}>{formatDrawTarget(draws, textContent.drawsUnit || "tirages")}</span>
<span>{formatCost(costSummary?.targetCosts?.[target])}</span> <span>{formatCost(costSummary?.targetCosts?.[target])}</span>
</> </>
); );
} }
function RngTrackingTargetRow({ hasCost, target, targetSummary, textContent }) {
return (
<>
<strong className="rng-simulator-chance-value">{target} %</strong>
<span>{formatPercent((targetSummary?.progress || 0) * 100, 2)}</span>
<span title={`${formatNumber(targetSummary?.remainingDraws || 0, 0)} ${textContent.drawsUnit || "tirages"}`}>{formatDrawTarget(targetSummary?.remainingDraws || 0, textContent.drawsUnit || "tirages")}</span>
<span>{hasCost ? formatCost(targetSummary?.remainingCost) : "—"}</span>
</>
);
}
function RngHistoryMetric({ label, tone = "", value }) {
return (
<span data-tone={tone || undefined}>
<small>{label}</small>
<b>{value}</b>
</span>
);
}
function RngHistoryRow({ entry, index, textContent }) { function RngHistoryRow({ entry, index, textContent }) {
const expectedSuccesses = entry.draws * (getEffectiveRate(entry.rate, entry.multiplier) / 100); const expectedSuccesses = entry.draws * (getEffectiveRate(entry.rate, entry.multiplier) / 100);
const successDelta = entry.successes - expectedSuccesses; const successDelta = entry.successes - expectedSuccesses;
const successTone = getToneFromDelta(successDelta);
const luckLabel = successDelta > 0 const luckLabel = successDelta > 0
? textContent.luckySimulationLabel || "Au-dessus" ? textContent.luckySimulationLabel || "Au-dessus"
: successDelta < 0 : successDelta < 0
@ -566,14 +707,16 @@ function RngHistoryRow({ entry, index, textContent }) {
simulationSuccesses: entry.successes, simulationSuccesses: entry.successes,
firstSuccessDraw: entry.firstSuccessDraw firstSuccessDraw: entry.firstSuccessDraw
}); });
const hasFirstSuccessCost = entry.firstSuccessDraw > 0 && Number.isFinite(costs?.simulationFirstSuccessCost) && costs.simulationFirstSuccessCost > 0;
const hasCostPerSuccess = entry.successes > 0 && Number.isFinite(costs?.simulationCostPerSuccess) && costs.simulationCostPerSuccess > 0;
return ( return (
<article className={`rng-simulator-history-row ${index === 0 ? "is-latest" : ""}`}> <article className={`rng-simulator-history-row ${index === 0 ? "is-latest" : ""}`}>
<div className="rng-simulator-history-heading"> <div className="rng-simulator-history-heading">
<div className="rng-simulator-history-identity"> <div className="rng-simulator-history-identity" data-tone={successTone || undefined}>
<strong>{entry.label}</strong> <strong>{entry.label}</strong>
<small>{luckLabel} · {formatSignedPercent((successDelta / entry.draws) * 100, 4)}</small> <small>{luckLabel} · {formatSignedPercent((successDelta / entry.draws) * 100, 4)}</small>
</div> </div>
<div className="rng-simulator-history-score"> <div className="rng-simulator-history-score" data-tone={entry.successes > 0 ? "positive" : undefined}>
<b>{entry.successes}</b> <b>{entry.successes}</b>
<small>{textContent.successesLabel || "Réussites"}</small> <small>{textContent.successesLabel || "Réussites"}</small>
</div> </div>
@ -583,14 +726,14 @@ function RngHistoryRow({ entry, index, textContent }) {
</div> </div>
</div> </div>
<div className="rng-simulator-history-metrics"> <div className="rng-simulator-history-metrics">
<span><small>{textContent.successesLabel || "Réussites"}</small><b>{entry.successes}/{entry.draws}</b></span> <RngHistoryMetric label={textContent.successesLabel || "Réussites"} tone={entry.successes > 0 ? "positive" : ""} value={`${entry.successes}/${entry.draws}`} />
<span><small>{textContent.failsLabel || "Fails"}</small><b>{entry.fails}</b></span> <RngHistoryMetric label={textContent.failsLabel || "Fails"} tone={entry.fails > 0 ? "negative" : ""} value={entry.fails} />
<span><small>{textContent.observedRateLabel || "Taux de réussite"}</small><b>{formatPercent((entry.successes / entry.draws) * 100, 6)}</b></span> <RngHistoryMetric label={textContent.observedRateLabel || "Taux de réussite"} tone={successTone} value={formatPercent((entry.successes / entry.draws) * 100, 6)} />
<span><small>{textContent.expectedSuccessesLabel || "Réussites attendues"}</small><b>{formatNumber(expectedSuccesses, 2)}</b></span> <RngHistoryMetric label={textContent.expectedSuccessesLabel || "Réussites attendues"} value={formatNumber(expectedSuccesses, 2)} />
<span><small>{textContent.longestFailStreakLabel || "Pire série d'échec"}</small><b>{entry.longestFailStreak}</b></span> <RngHistoryMetric label={textContent.longestFailStreakLabel || "Pire série d'échec"} tone={entry.longestFailStreak > 0 ? "negative" : ""} value={entry.longestFailStreak} />
<span><small>{textContent.longestSuccessStreakLabel || "Réussite consécutive"}</small><b>{entry.longestSuccessStreak}</b></span> <RngHistoryMetric label={textContent.longestSuccessStreakLabel || "Réussite consécutive"} tone={entry.longestSuccessStreak > 0 ? "positive" : ""} value={entry.longestSuccessStreak} />
<span><small>{textContent.firstSuccessCostLabel || "Coût de la 1ere réussite"}</small><b>{entry.firstSuccessDraw > 0 ? formatCost(costs?.simulationFirstSuccessCost) : "—"}</b></span> <RngHistoryMetric label={textContent.firstSuccessCostLabel || "Coût de la 1ere réussite"} tone={hasFirstSuccessCost ? "negative" : ""} value={entry.firstSuccessDraw > 0 ? formatCost(costs?.simulationFirstSuccessCost) : "—"} />
<span><small>{textContent.costPerSuccessLabel || "Coût moyen/réussite"}</small><b>{entry.successes > 0 ? formatCost(costs?.simulationCostPerSuccess) : "—"}</b></span> <RngHistoryMetric label={textContent.costPerSuccessLabel || "Coût moyen/réussite"} tone={hasCostPerSuccess ? "negative" : ""} value={entry.successes > 0 ? formatCost(costs?.simulationCostPerSuccess) : "—"} />
</div> </div>
</article> </article>
); );

View file

@ -8,14 +8,45 @@ export const RNG_SIMULATOR_LIMITS = {
multiplierMax: 1000000, multiplierMax: 1000000,
simulationDrawsMin: 1, simulationDrawsMin: 1,
simulationDrawsMax: 1000000, simulationDrawsMax: 1000000,
realDrawsMax: 1000000000,
occurrenceRateMin: 0, occurrenceRateMin: 0,
occurrenceRateMax: 1000000000 occurrenceRateMax: 1000000000
}; };
export const RNG_SIMULATOR_OCCURRENCE_UNITS = ["perHour", "perDay", "perMonth"]; export const RNG_SIMULATOR_OCCURRENCE_UNITS = ["perHour", "perDay"];
export const RNG_SIMULATOR_HOURLY_DURATION_PRESETS = [
{ id: "1h", label: "1h", hours: 1 },
{ id: "5h", label: "5h", hours: 5 },
{ id: "10h", label: "10h", hours: 10 },
{ id: "1j", label: "1j", hours: 24 }
];
export const RNG_SIMULATOR_DAILY_DURATION_PRESETS = [
{ id: "1j", label: "1j", hours: 24 },
{ id: "3j", label: "3j", hours: 72 },
{ id: "7j", label: "7j", hours: 168 },
{ id: "15j", label: "15j", hours: 360 }
];
export const RNG_SIMULATOR_MONTHLY_DURATION_PRESETS = [
{ id: "1j", label: "1j", hours: 24 },
{ id: "3j", label: "3j", hours: 72 },
{ id: "7j", label: "7j", hours: 168 },
{ id: "30j", label: "30j", hours: 720 }
];
export const RNG_SIMULATOR_YEARLY_DURATION_PRESETS = [
{ id: "30j", label: "30j", hours: 720 },
{ id: "90j", label: "90j", hours: 2160 },
{ id: "1a", label: "1 an", hours: 8760 },
{ id: "5a", label: "5 ans", hours: 43800 }
];
export const RNG_SIMULATOR_LONG_DURATION_PRESETS = [
{ id: "1a", label: "1 an", hours: 8760 },
{ id: "5a", label: "5 ans", hours: 43800 },
{ id: "10a", label: "10 ans", hours: 87600 },
{ id: "25a", label: "25 ans", hours: 219000 }
];
export function clampInteger(value, min, max, fallback = min) { export function clampInteger(value, min, max, fallback = min) {
const parsed = Number.parseInt(value, 10); const parsed = Number.parseInt(String(value ?? "").replace(/[\s\u00a0]/g, ""), 10);
if (!Number.isFinite(parsed)) return fallback; if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed)); return Math.min(max, Math.max(min, parsed));
} }
@ -76,6 +107,11 @@ export function getEffectiveRate(rate, multiplier = 0) {
export function calculateAtLeastOneProbability(rate, draws, multiplier = 0) { export function calculateAtLeastOneProbability(rate, draws, multiplier = 0) {
const probability = getEffectiveRate(rate, multiplier) / 100; const probability = getEffectiveRate(rate, multiplier) / 100;
const cleanDraws = clampInteger(draws, 0, Number.MAX_SAFE_INTEGER, 0); const cleanDraws = clampInteger(draws, 0, Number.MAX_SAFE_INTEGER, 0);
return calculateProbabilityForDrawCount(probability, cleanDraws);
}
function calculateProbabilityForDrawCount(probability, draws) {
const cleanDraws = clampDecimal(draws, 0, Number.MAX_SAFE_INTEGER, 0);
if (probability <= 0 || cleanDraws <= 0) return 0; if (probability <= 0 || cleanDraws <= 0) return 0;
if (probability >= 1) return 1; if (probability >= 1) return 1;
return -Math.expm1(cleanDraws * Math.log1p(-probability)); return -Math.expm1(cleanDraws * Math.log1p(-probability));
@ -91,19 +127,32 @@ export function calculateDrawsForChance(rate, targetChance, multiplier = 0) {
} }
export function normalizeOccurrenceUnit(value) { export function normalizeOccurrenceUnit(value) {
if (value === "perMonth") return "perDay";
return RNG_SIMULATOR_OCCURRENCE_UNITS.includes(value) ? value : "perHour"; return RNG_SIMULATOR_OCCURRENCE_UNITS.includes(value) ? value : "perHour";
} }
export function normalizeOccurrenceSettings(value, unit) {
const cleanValue = clampDecimal(value, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0);
if (unit === "perMonth") {
return {
occurrenceValue: cleanValue / 30,
occurrenceUnit: "perDay"
};
}
return {
occurrenceValue: cleanValue,
occurrenceUnit: normalizeOccurrenceUnit(unit)
};
}
export function calculateRngSummary({ rate, multiplier = 0, simulationDraws, occurrenceValue = 0, occurrenceUnit = "perHour" }) { 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 cleanOccurrenceValue = clampDecimal(occurrenceValue, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0); const { occurrenceValue: cleanOccurrenceValue, occurrenceUnit: cleanOccurrenceUnit } = normalizeOccurrenceSettings(occurrenceValue, occurrenceUnit);
const cleanOccurrenceUnit = normalizeOccurrenceUnit(occurrenceUnit);
let averageHours = 0; let averageHours = 0;
if (cleanOccurrenceValue > 0 && cleanOccurrenceUnit === "perHour") averageHours = averageDraws / cleanOccurrenceValue; if (cleanOccurrenceValue > 0 && cleanOccurrenceUnit === "perHour") averageHours = averageDraws / cleanOccurrenceValue;
else if (cleanOccurrenceValue > 0 && cleanOccurrenceUnit === "perDay") averageHours = (averageDraws / cleanOccurrenceValue) * 24; else if (cleanOccurrenceValue > 0 && cleanOccurrenceUnit === "perDay") averageHours = (averageDraws / cleanOccurrenceValue) * 24;
else if (cleanOccurrenceValue > 0 && cleanOccurrenceUnit === "perMonth") averageHours = (averageDraws / cleanOccurrenceValue) * 24 * 30;
return { return {
probability, probability,
@ -112,9 +161,9 @@ export function calculateRngSummary({ rate, multiplier = 0, simulationDraws, occ
chanceForSimulationDraws: calculateAtLeastOneProbability(rate, simulationDraws, multiplier), chanceForSimulationDraws: calculateAtLeastOneProbability(rate, simulationDraws, multiplier),
timeByFrequency: { timeByFrequency: {
hour: averageHours, hour: averageHours,
day: averageHours > 0 ? averageHours / 24 : 0, day: averageHours > 0 ? averageHours / 24 : 0
month: averageHours > 0 ? averageHours / 24 / 30 : 0
}, },
durationChances: calculateDurationChances(rate, multiplier, cleanOccurrenceValue, cleanOccurrenceUnit),
drawsForChance: { drawsForChance: {
50: calculateDrawsForChance(rate, 0.5, multiplier), 50: calculateDrawsForChance(rate, 0.5, multiplier),
90: calculateDrawsForChance(rate, 0.9, multiplier), 90: calculateDrawsForChance(rate, 0.9, multiplier),
@ -124,6 +173,79 @@ export function calculateRngSummary({ rate, multiplier = 0, simulationDraws, occ
}; };
} }
export function calculateDurationChances(rate, multiplier = 0, occurrenceValue = 0, occurrenceUnit = "perHour") {
const { occurrenceValue: cleanOccurrenceValue, occurrenceUnit: cleanOccurrenceUnit } = normalizeOccurrenceSettings(occurrenceValue, occurrenceUnit);
const drawsPerHour = cleanOccurrenceUnit === "perDay" ? cleanOccurrenceValue / 24 : cleanOccurrenceValue;
const averageHours = drawsPerHour > 0 ? (1 / (getEffectiveRate(rate, multiplier) / 100)) / drawsPerHour : 0;
const presets = getDurationPresetsForAverageHours(averageHours, cleanOccurrenceUnit);
return presets.map((preset) => ({
...preset,
draws: drawsPerHour > 0 ? drawsPerHour * preset.hours : 0,
chance: drawsPerHour > 0 ? calculateProbabilityForDrawCount(getEffectiveRate(rate, multiplier) / 100, drawsPerHour * preset.hours) : 0
}));
}
export function getDurationPresetsForAverageHours(averageHours, occurrenceUnit = "perHour") {
if (!Number.isFinite(averageHours) || averageHours <= 0) {
return occurrenceUnit === "perDay" ? RNG_SIMULATOR_DAILY_DURATION_PRESETS : RNG_SIMULATOR_HOURLY_DURATION_PRESETS;
}
if (occurrenceUnit === "perDay" && averageHours <= 24 * 365) return RNG_SIMULATOR_MONTHLY_DURATION_PRESETS;
if (averageHours <= 24 * 7) return RNG_SIMULATOR_HOURLY_DURATION_PRESETS;
if (averageHours <= 24 * 365) return RNG_SIMULATOR_MONTHLY_DURATION_PRESETS;
if (averageHours <= 24 * 365 * 10) return RNG_SIMULATOR_YEARLY_DURATION_PRESETS;
return RNG_SIMULATOR_LONG_DURATION_PRESETS;
}
export function getAveragePositionStatus(averageRatio) {
const ratio = Number.isFinite(averageRatio) && averageRatio >= 0 ? averageRatio : 0;
if (ratio < 0.25) return { id: "start", tone: "positive" };
if (ratio < 0.5) return { id: "farBelow", tone: "positive" };
if (ratio < 0.9) return { id: "below", tone: "positive" };
if (ratio < 1.1) return { id: "average", tone: "" };
if (ratio < 1.5) return { id: "slightlyAbove", tone: "negative" };
if (ratio < 3) return { id: "high", tone: "negative" };
return { id: "catastrophic", tone: "negative" };
}
export function calculateRngRealDrawsSummary({ rate, multiplier = 0, realDraws = 0, averageDraws = 0, drawsForChance = {}, costPerDraw = "" }) {
const cleanRealDraws = clampInteger(realDraws, 0, RNG_SIMULATOR_LIMITS.realDrawsMax, 0);
const cumulativeChance = calculateAtLeastOneProbability(rate, cleanRealDraws, multiplier);
const noSuccessChance = 1 - cumulativeChance;
const cleanAverageDraws = Number.isFinite(averageDraws) && averageDraws > 0 ? averageDraws : 0;
const averageRatio = cleanAverageDraws > 0 ? cleanRealDraws / cleanAverageDraws : 0;
const averagePosition = getAveragePositionStatus(averageRatio);
const drawsUntilAverage = cleanAverageDraws > cleanRealDraws ? Math.ceil(cleanAverageDraws - cleanRealDraws) : 0;
const cleanCost = Number(normalizeCostString(costPerDraw));
const hasCost = Number.isFinite(cleanCost) && cleanCost > 0;
const averageSuccessCost = hasCost && cleanAverageDraws > 0 ? cleanAverageDraws * cleanCost : 0;
const spentCost = hasCost ? cleanRealDraws * cleanCost : 0;
const targets = {};
Object.entries(drawsForChance || {}).forEach(([target, draws]) => {
const remainingDraws = Number.isFinite(draws) ? Math.max(0, draws - cleanRealDraws) : Number.POSITIVE_INFINITY;
targets[target] = {
draws,
remainingDraws,
progress: Number.isFinite(draws) && draws > 0 ? Math.min(1, cleanRealDraws / draws) : 0,
remainingCost: hasCost && Number.isFinite(remainingDraws) ? remainingDraws * cleanCost : 0
};
});
return {
realDraws: cleanRealDraws,
hasCost,
cumulativeChance,
noSuccessChance,
averageRatio,
averagePosition,
drawsUntilAverage,
averageExceeded: cleanAverageDraws > 0 && cleanRealDraws >= cleanAverageDraws,
spentCost,
averageSuccessCost,
averageCostDelta: hasCost && cleanAverageDraws > 0 ? spentCost - averageSuccessCost : 0,
costUntilAverage: hasCost && drawsUntilAverage > 0 ? drawsUntilAverage * cleanCost : 0,
targets
};
}
export function calculateRngCostSummary({ costPerDraw, averageDraws, drawsForChance, simulationDraws, simulationSuccesses, firstSuccessDraw = 0 }) { export function calculateRngCostSummary({ costPerDraw, averageDraws, drawsForChance, simulationDraws, simulationSuccesses, firstSuccessDraw = 0 }) {
const cleanCost = Number(normalizeCostString(costPerDraw)); const cleanCost = Number(normalizeCostString(costPerDraw));
if (!Number.isFinite(cleanCost) || cleanCost <= 0) return null; if (!Number.isFinite(cleanCost) || cleanCost <= 0) return null;

View file

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

View file

@ -4,7 +4,7 @@ import {
clampDecimal, clampDecimal,
clampInteger, clampInteger,
normalizeCostString, normalizeCostString,
normalizeOccurrenceUnit, normalizeOccurrenceSettings,
normalizeRateString normalizeRateString
} from "../../modules/rngSimulatorUtils.js"; } from "../../modules/rngSimulatorUtils.js";
import { uid } from "./shared.js"; import { uid } from "./shared.js";
@ -12,14 +12,16 @@ import { uid } from "./shared.js";
function normalizeRateEntry(entry) { function normalizeRateEntry(entry) {
const label = String(entry?.label || "").trim(); const label = String(entry?.label || "").trim();
if (!label) return null; if (!label) return null;
const occurrence = normalizeOccurrenceSettings(entry?.occurrenceValue, entry?.occurrenceUnit);
return { return {
id: entry?.id || uid("rng"), id: entry?.id || uid("rng"),
label, label,
rate: normalizeRateString(entry?.rate), rate: normalizeRateString(entry?.rate),
costPerDraw: normalizeCostString(entry?.costPerDraw), 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),
occurrenceValue: clampDecimal(entry?.occurrenceValue, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0), occurrenceValue: occurrence.occurrenceValue,
occurrenceUnit: normalizeOccurrenceUnit(entry?.occurrenceUnit) occurrenceUnit: occurrence.occurrenceUnit,
realDraws: clampInteger(entry?.realDraws, 0, RNG_SIMULATOR_LIMITS.realDrawsMax, 0)
}; };
} }
@ -54,6 +56,7 @@ export function compactRngSimulatorDataForStorage(value) {
compactRate.occurrenceValue = rate.occurrenceValue; compactRate.occurrenceValue = rate.occurrenceValue;
compactRate.occurrenceUnit = rate.occurrenceUnit; compactRate.occurrenceUnit = rate.occurrenceUnit;
} }
if (rate.realDraws !== 0) compactRate.realDraws = rate.realDraws;
return compactRate; return compactRate;
}); });
} }

View file

@ -27,6 +27,7 @@ import {
} from "../features/toolboxes/storage/toolboxStorage.js"; } from "../features/toolboxes/storage/toolboxStorage.js";
const LIBRARY_TOOLBOX_ID = "library"; const LIBRARY_TOOLBOX_ID = "library";
const LIBRARY_ANCHOR_RETRY_DELAYS = [0, 80, 240, 600];
const MODULE_COMPONENTS = { const MODULE_COMPONENTS = {
memos: lazy(() => import("../features/toolboxes/modules/MemosModule.jsx").then((module) => ({ default: module.MemosModule }))), memos: lazy(() => import("../features/toolboxes/modules/MemosModule.jsx").then((module) => ({ default: module.MemosModule }))),
@ -83,12 +84,23 @@ function getCurrentLibraryAnchor() {
return match ? decodeURIComponent(match[1]) : ""; return match ? decodeURIComponent(match[1]) : "";
} }
function scrollToLibraryTool(anchorId, replace = false) { function getLibraryAnchorOffset() {
const element = document.getElementById(anchorId); const topbar = document.querySelector(".topbar");
if (!element) return; const topbarHeight = topbar?.getBoundingClientRect?.().height || 0;
return topbarHeight + 16;
}
function scrollToLibraryTool(anchorId, replace = false, behavior = "smooth") {
if (!anchorId) return;
if (replace) history.replaceState(null, "", `#/library#${encodeURIComponent(anchorId)}`); if (replace) history.replaceState(null, "", `#/library#${encodeURIComponent(anchorId)}`);
const top = element.getBoundingClientRect().top + window.scrollY - 24; LIBRARY_ANCHOR_RETRY_DELAYS.forEach((delay, index) => {
window.scrollTo({ top: Math.max(0, top), behavior: "smooth" }); window.setTimeout(() => {
const element = document.getElementById(anchorId);
if (!element) return;
const top = element.getBoundingClientRect().top + window.scrollY - getLibraryAnchorOffset();
window.scrollTo({ top: Math.max(0, top), behavior: index === 0 ? behavior : "auto" });
}, delay);
});
} }
function findCategoryForAnchor(categories, anchorId) { function findCategoryForAnchor(categories, anchorId) {
@ -149,7 +161,7 @@ export function LibraryPage({ siteContent, actions }) {
const category = findCategoryForAnchor(libraryCategories, anchorId) || libraryCategories[0]; const category = findCategoryForAnchor(libraryCategories, anchorId) || libraryCategories[0];
setActiveCategoryKey(category.key); setActiveCategoryKey(category.key);
if (!anchorId) return; if (!anchorId) return;
requestAnimationFrame(() => scrollToLibraryTool(anchorId)); requestAnimationFrame(() => scrollToLibraryTool(anchorId, false, "auto"));
}, [libraryPayload, libraryCategories]); }, [libraryPayload, libraryCategories]);
const moduleContext = useMemo(() => ({ const moduleContext = useMemo(() => ({

View file

@ -179,11 +179,29 @@
text-align: center; text-align: center;
} }
.rng-simulator-history-score small,
.rng-simulator-history-rate small {
overflow: hidden;
color: var(--color-text-primary);
font-size: var(--font-size-xs);
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.rng-simulator-active-summary small, .rng-simulator-active-summary small,
.rng-simulator-table > span:nth-child(-n + 3), .rng-simulator-table-heading {
overflow: hidden;
color: var(--color-text-primary);
font-size: var(--font-size-xs);
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.rng-simulator-history-metrics small { .rng-simulator-history-metrics small {
overflow: hidden; overflow: hidden;
color: var(--color-text-secondary); color: var(--color-accent-gold);
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
font-weight: 800; font-weight: 800;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -191,7 +209,7 @@
} }
.rng-simulator-stat span { .rng-simulator-stat span {
color: var(--color-text-secondary); color: var(--color-accent-gold);
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
font-weight: 800; font-weight: 800;
line-height: 1.25; line-height: 1.25;
@ -203,11 +221,19 @@
.rng-simulator-history-row b, .rng-simulator-history-row b,
.rng-simulator-history-rate > span, .rng-simulator-history-rate > span,
.rng-simulator-history-metrics b { .rng-simulator-history-metrics b {
color: var(--color-accent-gold); color: var(--color-text-primary);
font-weight: 900; font-weight: 900;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.rng-simulator-history-rate > span {
color: var(--color-accent-gold);
}
.rng-simulator-table .rng-simulator-chance-value {
color: var(--color-accent-gold);
}
.rng-simulator-active-summary strong { .rng-simulator-active-summary strong {
color: var(--color-accent-gold); color: var(--color-accent-gold);
font-size: 24px; font-size: 24px;
@ -222,7 +248,7 @@
display: grid; display: grid;
gap: 6px; gap: 6px;
min-width: 0; min-width: 0;
color: var(--color-text-secondary); color: var(--color-accent-gold);
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
font-weight: 800; font-weight: 800;
} }
@ -316,7 +342,7 @@
} }
.rng-simulator-percent-input > span { .rng-simulator-percent-input > span {
color: var(--color-text-secondary); color: var(--color-accent-gold);
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
font-weight: 900; font-weight: 900;
} }
@ -331,14 +357,28 @@
.rng-simulator-time-results { .rng-simulator-time-results {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.rng-simulator-stat-grid,
.rng-simulator-duration-grid,
.rng-simulator-tracking-grid {
display: grid;
gap: 8px; gap: 8px;
} }
.rng-simulator-stat-grid { .rng-simulator-stat-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px; }
.rng-simulator-duration-grid,
.rng-simulator-tracking-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.rng-simulator-tracking-control {
max-width: 360px;
} }
.rng-simulator-stat strong { .rng-simulator-stat strong {
@ -347,6 +387,14 @@
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
.rng-simulator-stat[data-tone="positive"] strong {
color: var(--color-accent-cyan);
}
.rng-simulator-stat[data-tone="negative"] strong {
color: #fca5a5;
}
.rng-simulator-table { .rng-simulator-table {
display: grid; display: grid;
align-items: center; align-items: center;
@ -374,6 +422,10 @@
grid-template-columns: minmax(88px, 0.6fr) minmax(140px, 1fr) minmax(120px, 1fr); grid-template-columns: minmax(88px, 0.6fr) minmax(140px, 1fr) minmax(120px, 1fr);
} }
.rng-simulator-tracking-target-table {
grid-template-columns: minmax(88px, 0.55fr) minmax(120px, 0.8fr) minmax(140px, 1fr) minmax(120px, 1fr);
}
.rng-simulator-simulation-overview { .rng-simulator-simulation-overview {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@ -478,6 +530,18 @@
line-height: 1; line-height: 1;
} }
.rng-simulator-history-identity[data-tone="positive"] small,
.rng-simulator-history-metrics > span[data-tone="positive"] b,
.rng-simulator-history-score[data-tone="positive"] b {
color: var(--color-accent-cyan);
}
.rng-simulator-history-identity[data-tone="negative"] small,
.rng-simulator-history-metrics > span[data-tone="negative"] b,
.rng-simulator-history-score[data-tone="negative"] b {
color: #fca5a5;
}
.rng-simulator-history-rate { .rng-simulator-history-rate {
display: grid; display: grid;
gap: 2px; gap: 2px;
@ -513,10 +577,13 @@
.rng-simulator-active-summary, .rng-simulator-active-summary,
.rng-simulator-primary-controls, .rng-simulator-primary-controls,
.rng-simulator-stat-grid, .rng-simulator-stat-grid,
.rng-simulator-duration-grid,
.rng-simulator-tracking-grid,
.rng-simulator-simulation-summary, .rng-simulator-simulation-summary,
.rng-simulator-occurrence-control, .rng-simulator-occurrence-control,
.rng-simulator-time-results, .rng-simulator-time-results,
.rng-simulator-target-table { .rng-simulator-target-table,
.rng-simulator-tracking-target-table {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }