Improve chance simulator forecasts and tracking UI
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s
This commit is contained in:
parent
1d278ff551
commit
6e64cfacd5
10 changed files with 534 additions and 101 deletions
|
|
@ -824,7 +824,8 @@ Type : `rngSimulator`
|
|||
"costPerDraw": "150",
|
||||
"multiplier": 50,
|
||||
"occurrenceValue": 30,
|
||||
"occurrenceUnit": "perHour"
|
||||
"occurrenceUnit": "perHour",
|
||||
"realDraws": 120
|
||||
},
|
||||
{
|
||||
"id": "rate2",
|
||||
|
|
@ -847,12 +848,15 @@ Stockage compact :
|
|||
- `activeRateId` référence un taux existant; sinon le premier taux est sélectionné.
|
||||
- `rates[].multiplier` est un bonus ou malus en pourcentage associé au taux, vaut 0 par défaut et le taux effectif est borné entre 0% et 100%.
|
||||
- `rates[].occurrenceValue` est numérique, positif ou nul, associé au taux, et vaut 0 par défaut.
|
||||
- `rates[].occurrenceUnit` vaut `perHour`, `perDay` ou `perMonth`, et vaut `perHour` par défaut.
|
||||
- `rates[].occurrenceUnit` vaut `perHour` ou `perDay`, et vaut `perHour` par défaut.
|
||||
- les anciennes données `occurrenceUnit: "perMonth"` sont converties en `perDay`, avec `occurrenceValue` divisé par 30.
|
||||
- `rates[].realDraws` est le total persistant de tirages réellement effectués pour ce taux, borné de 0 à 1 000 000 000.
|
||||
- `settings.simulationDraws` est borné de 1 à 1 000 000 et vaut 1000 par défaut.
|
||||
- `settings` est omis si `simulationDraws` vaut sa valeur par défaut.
|
||||
- `rates[].costPerDraw` est omis si vide ou nul.
|
||||
- `rates[].multiplier` est omis si sa valeur vaut 0.
|
||||
- `rates[].occurrenceValue` et `rates[].occurrenceUnit` sont omis si `occurrenceValue` vaut 0.
|
||||
- `rates[].realDraws` est omis si sa valeur vaut 0.
|
||||
- l'historique des simulations n'est pas persisté en IndexedDB; il est limité aux 20 dernières entrées en `sessionStorage`.
|
||||
|
||||
Import/export texte :
|
||||
|
|
@ -860,7 +864,7 @@ Import/export texte :
|
|||
```text
|
||||
@activeRateId: rate1
|
||||
[rates]
|
||||
- Drop rare | rate=2.500 | active=true | costPerDraw=150 | multiplier=50 | occurrenceValue=30 | occurrenceUnit=perHour
|
||||
- Drop rare | rate=2.500 | active=true | costPerDraw=150 | multiplier=50 | occurrenceValue=30 | occurrenceUnit=perHour | realDraws=120
|
||||
|
||||
[settings]
|
||||
simulationDraws: 1000
|
||||
|
|
|
|||
|
|
@ -327,13 +327,14 @@ export function validateSiteContent(site) {
|
|||
"toolboxes.modules.rngSimulator.deleteTitle",
|
||||
"toolboxes.modules.rngSimulator.noActiveRateTitle",
|
||||
"toolboxes.modules.rngSimulator.activeRateLabel",
|
||||
"toolboxes.modules.rngSimulator.commonSettingsTitle",
|
||||
"toolboxes.modules.rngSimulator.effectiveRateLabel",
|
||||
"toolboxes.modules.rngSimulator.statsTabLabel",
|
||||
"toolboxes.modules.rngSimulator.forecastsTabLabel",
|
||||
"toolboxes.modules.rngSimulator.simulationTabLabel",
|
||||
"toolboxes.modules.rngSimulator.trackingTabLabel",
|
||||
"toolboxes.modules.rngSimulator.simulationDrawsLabel",
|
||||
"toolboxes.modules.rngSimulator.perHourLabel",
|
||||
"toolboxes.modules.rngSimulator.perDayLabel",
|
||||
"toolboxes.modules.rngSimulator.perMonthLabel",
|
||||
"toolboxes.modules.rngSimulator.multiplierLabel",
|
||||
"toolboxes.modules.rngSimulator.averageDrawsLabel",
|
||||
"toolboxes.modules.rngSimulator.simulationChanceLabel",
|
||||
|
|
@ -345,14 +346,14 @@ export function validateSiteContent(site) {
|
|||
"toolboxes.modules.rngSimulator.occurrenceCountLabel",
|
||||
"toolboxes.modules.rngSimulator.hourTimeLabel",
|
||||
"toolboxes.modules.rngSimulator.dayTimeLabel",
|
||||
"toolboxes.modules.rngSimulator.monthTimeLabel",
|
||||
"toolboxes.modules.rngSimulator.durationChancesTitle",
|
||||
"toolboxes.modules.rngSimulator.durationChancePrefix",
|
||||
"toolboxes.modules.rngSimulator.chanceTargetsTitle",
|
||||
"toolboxes.modules.rngSimulator.chanceLabel",
|
||||
"toolboxes.modules.rngSimulator.costLabel",
|
||||
"toolboxes.modules.rngSimulator.drawsUnit",
|
||||
"toolboxes.modules.rngSimulator.hoursUnit",
|
||||
"toolboxes.modules.rngSimulator.daysUnit",
|
||||
"toolboxes.modules.rngSimulator.monthsUnit",
|
||||
"toolboxes.modules.rngSimulator.emptySummary",
|
||||
"toolboxes.modules.rngSimulator.simulationTitle",
|
||||
"toolboxes.modules.rngSimulator.simulateButton",
|
||||
|
|
@ -370,6 +371,24 @@ export function validateSiteContent(site) {
|
|||
"toolboxes.modules.rngSimulator.luckySimulationLabel",
|
||||
"toolboxes.modules.rngSimulator.neutralSimulationLabel",
|
||||
"toolboxes.modules.rngSimulator.unluckySimulationLabel",
|
||||
"toolboxes.modules.rngSimulator.realDrawsLabel",
|
||||
"toolboxes.modules.rngSimulator.realCumulativeChanceLabel",
|
||||
"toolboxes.modules.rngSimulator.afterDrawsPrefix",
|
||||
"toolboxes.modules.rngSimulator.averagePositionLabel",
|
||||
"toolboxes.modules.rngSimulator.averageStatusStartLabel",
|
||||
"toolboxes.modules.rngSimulator.averageStatusFarBelowLabel",
|
||||
"toolboxes.modules.rngSimulator.averageStatusBelowLabel",
|
||||
"toolboxes.modules.rngSimulator.averageStatusAverageLabel",
|
||||
"toolboxes.modules.rngSimulator.averageStatusSlightlyAboveLabel",
|
||||
"toolboxes.modules.rngSimulator.averageStatusHighLabel",
|
||||
"toolboxes.modules.rngSimulator.averageStatusCatastrophicLabel",
|
||||
"toolboxes.modules.rngSimulator.drawsUntilAverageLabel",
|
||||
"toolboxes.modules.rngSimulator.averageExceededLabel",
|
||||
"toolboxes.modules.rngSimulator.averageCostDeltaLabel",
|
||||
"toolboxes.modules.rngSimulator.trackingTargetsTitle",
|
||||
"toolboxes.modules.rngSimulator.trackingProgressLabel",
|
||||
"toolboxes.modules.rngSimulator.remainingDrawsLabel",
|
||||
"toolboxes.modules.rngSimulator.remainingCostLabel",
|
||||
"toolboxes.modules.rngSimulator.importPlaceholder",
|
||||
"toolboxes.modules.rngSimulator.importOpenButton",
|
||||
"toolboxes.modules.rngSimulator.exportOpenButton",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { getMonthOccurrenceSegments, getWeekGridLayout, getWeekMinutePosition, l
|
|||
import { appendChecklistItems, moveChecklistItem, moveChecklistItemToCategory, renameChecklistSection } from "../website/src/features/toolboxes/modules/checklistUtils.js";
|
||||
import { formatUpdatedAt, isHtmlEmpty, queryBlockFormat, queryCommandState } from "../website/src/features/toolboxes/modules/notepadUtils.js";
|
||||
import { createBalancedTeams, drawWeightedEntries, filterExclusionsForPlayers, getDiceShape, rollDice } from "../website/src/features/toolboxes/modules/randomizerUtils.js";
|
||||
import { calculateAtLeastOneProbability, calculateDrawsForChance, calculateRngCostSummary, calculateRngSummary, getEffectiveRate, normalizeCostString, normalizeRateString, simulateRngDraws } from "../website/src/features/toolboxes/modules/rngSimulatorUtils.js";
|
||||
import { calculateAtLeastOneProbability, calculateDrawsForChance, calculateRngCostSummary, calculateRngRealDrawsSummary, calculateRngSummary, getAveragePositionStatus, getDurationPresetsForAverageHours, getEffectiveRate, normalizeCostString, normalizeRateString, simulateRngDraws } from "../website/src/features/toolboxes/modules/rngSimulatorUtils.js";
|
||||
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js";
|
||||
import { compactModuleDataForStorage, createToolboxExportPayload, moduleStorageKey, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeGoalTreeData, normalizeImageAnnotationData, normalizeLinksData, normalizeMemosData, normalizeModuleOrder, normalizeNotepadData, normalizeRandomizerData, normalizeRngSimulatorData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeToolbox, normalizeUrl, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
|
||||
import { applyGroupedReorderOperation, completeGroupOrder, getBoundaryItemId, getGroupedEntries, moveGroupOrder, moveGroupOrderToEnd, moveGroupOrderToStart, moveItem, moveItemGroup } from "../website/src/hooks/useGroupedReorder.js";
|
||||
|
|
@ -300,8 +300,8 @@ test("rng simulator preserves rate precision and compacts storage", () => {
|
|||
const normalized = normalizeRngSimulatorData({
|
||||
activeRateId: "rate2",
|
||||
rates: [
|
||||
{ id: "rate1", label: "Drop rare", rate: "2.500", costPerDraw: "", multiplier: 25, occurrenceValue: 10, occurrenceUnit: "perHour" },
|
||||
{ id: "rate2", label: "Ultra rare", rate: "0.000004", costPerDraw: "12,500", multiplier: 50, occurrenceValue: 120, occurrenceUnit: "perDay" },
|
||||
{ id: "rate1", label: "Drop rare", rate: "2.500", costPerDraw: "", multiplier: 25, occurrenceValue: 10, occurrenceUnit: "perHour", realDraws: 0 },
|
||||
{ id: "rate2", label: "Ultra rare", rate: "0.000004", costPerDraw: "12,500", multiplier: 50, occurrenceValue: 3600, occurrenceUnit: "perMonth", realDraws: "100 000" },
|
||||
{ id: "bad", label: "", rate: "4" }
|
||||
],
|
||||
settings: { simulationDraws: 2000 },
|
||||
|
|
@ -313,7 +313,10 @@ test("rng simulator preserves rate precision and compacts storage", () => {
|
|||
assert.equal(normalized.activeRateId, "rate2");
|
||||
assert.deepEqual(normalized.rates.map((rate) => [rate.label, rate.rate]), [["Drop rare", "2.500"], ["Ultra rare", "0.000004"]]);
|
||||
assert.deepEqual(normalized.rates.map((rate) => rate.costPerDraw), ["", "12.500"]);
|
||||
assert.deepEqual(normalized.rates.map((rate) => [rate.multiplier, rate.occurrenceValue, rate.occurrenceUnit]), [[25, 10, "perHour"], [50, 120, "perDay"]]);
|
||||
assert.deepEqual(normalized.rates.map((rate) => [rate.multiplier, rate.occurrenceValue, rate.occurrenceUnit, rate.realDraws]), [[25, 10, "perHour", 0], [50, 120, "perDay", 100000]]);
|
||||
assert.equal(normalizeRngSimulatorData({ rates: [{ label: "Bad real", rate: "5", realDraws: -10 }] }).rates[0].realDraws, 0);
|
||||
assert.equal(normalizeRngSimulatorData({ rates: [{ label: "Decimal real", rate: "5", realDraws: "42.9" }] }).rates[0].realDraws, 42);
|
||||
assert.equal(normalizeRngSimulatorData({ rates: [{ label: "Huge real", rate: "5", realDraws: 2000000000 }] }).rates[0].realDraws, 1000000000);
|
||||
assert.equal(normalized.history, undefined);
|
||||
assert.equal(normalizeCostString("0"), "");
|
||||
assert.equal(normalizeCostString("0012,500"), "12.500");
|
||||
|
|
@ -324,6 +327,8 @@ test("rng simulator preserves rate precision and compacts storage", () => {
|
|||
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].multiplier, 50);
|
||||
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].occurrenceValue, 120);
|
||||
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].occurrenceUnit, "perDay");
|
||||
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[0].realDraws, undefined);
|
||||
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].realDraws, 100000);
|
||||
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).settings.simulationDraws, 2000);
|
||||
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).history, undefined);
|
||||
});
|
||||
|
|
@ -339,10 +344,23 @@ test("rng simulator calculates probability milestones and deterministic simulati
|
|||
const hourlySummary = calculateRngSummary({ rate: "25", simulationDraws: 8, occurrenceValue: 2, occurrenceUnit: "perHour" });
|
||||
assert.equal(hourlySummary.timeByFrequency.hour, 2);
|
||||
assert.equal(hourlySummary.timeByFrequency.day, 2 / 24);
|
||||
assert.equal(hourlySummary.timeByFrequency.month, 2 / 24 / 30);
|
||||
assert.deepEqual(hourlySummary.durationChances.map((preset) => preset.id), ["1h", "5h", "10h", "1j"]);
|
||||
assert.equal(hourlySummary.durationChances.find((preset) => preset.id === "1h").draws, 2);
|
||||
assert.equal(hourlySummary.durationChances.find((preset) => preset.id === "1h").chance, 0.4375);
|
||||
const dailySummary = calculateRngSummary({ rate: "25", simulationDraws: 8, occurrenceValue: 2, occurrenceUnit: "perDay" });
|
||||
assert.equal(dailySummary.timeByFrequency.hour, 48);
|
||||
assert.equal(dailySummary.timeByFrequency.day, 2);
|
||||
assert.deepEqual(dailySummary.durationChances.map((preset) => preset.id), ["1j", "3j", "7j", "30j"]);
|
||||
const legacyMonthSummary = calculateRngSummary({ rate: "25", simulationDraws: 8, occurrenceValue: 60, occurrenceUnit: "perMonth" });
|
||||
assert.equal(legacyMonthSummary.timeByFrequency.day, 2);
|
||||
assert.deepEqual(legacyMonthSummary.durationChances.map((preset) => preset.id), ["1j", "3j", "7j", "30j"]);
|
||||
const fractionalDurationSummary = calculateRngSummary({ rate: "24", simulationDraws: 8, occurrenceValue: 1, occurrenceUnit: "perDay" });
|
||||
assert.ok(fractionalDurationSummary.durationChances.find((preset) => preset.id === "1j").chance > 0);
|
||||
const rareDurationSummary = calculateRngSummary({ rate: "0.000004", simulationDraws: 8, occurrenceValue: 60, occurrenceUnit: "perHour" });
|
||||
assert.deepEqual(rareDurationSummary.durationChances.map((preset) => preset.id), ["1a", "5a", "10a", "25a"]);
|
||||
assert.deepEqual(getDurationPresetsForAverageHours(24 * 30).map((preset) => preset.id), ["1j", "3j", "7j", "30j"]);
|
||||
assert.deepEqual(getDurationPresetsForAverageHours(24 * 365 * 2).map((preset) => preset.id), ["30j", "90j", "1a", "5a"]);
|
||||
assert.deepEqual(getDurationPresetsForAverageHours(0, "perDay").map((preset) => preset.id), ["1j", "3j", "7j", "15j"]);
|
||||
const multipliedSummary = calculateRngSummary({ rate: "10", multiplier: 100, simulationDraws: 1, occurrenceValue: 0, occurrenceUnit: "perHour" });
|
||||
assert.equal(multipliedSummary.effectiveRate, 20);
|
||||
assert.equal(multipliedSummary.averageDraws, 5);
|
||||
|
|
@ -354,6 +372,25 @@ test("rng simulator calculates probability milestones and deterministic simulati
|
|||
assert.equal(costSummary.simulationCostPerSuccess, 50);
|
||||
assert.equal(calculateRngCostSummary({ costPerDraw: "12,5", averageDraws: 5, drawsForChance: {}, simulationDraws: 8, simulationSuccesses: 2, firstSuccessDraw: 3 }).simulationFirstSuccessCost, 37.5);
|
||||
assert.equal(calculateRngCostSummary({ costPerDraw: "0", averageDraws: 5, drawsForChance: {}, simulationDraws: 8, simulationSuccesses: 2 }), null);
|
||||
const realSummary = calculateRngRealDrawsSummary({ rate: "25", realDraws: 2, averageDraws: 4, drawsForChance: { 50: 3, 90: 9 }, costPerDraw: "10" });
|
||||
assert.equal(realSummary.hasCost, true);
|
||||
assert.equal(realSummary.cumulativeChance, 0.4375);
|
||||
assert.equal(realSummary.noSuccessChance, 0.5625);
|
||||
assert.equal(realSummary.averageRatio, 0.5);
|
||||
assert.deepEqual(realSummary.averagePosition, { id: "below", tone: "positive" });
|
||||
assert.equal(realSummary.drawsUntilAverage, 2);
|
||||
assert.equal(realSummary.spentCost, 20);
|
||||
assert.equal(realSummary.averageSuccessCost, 40);
|
||||
assert.equal(realSummary.averageCostDelta, -20);
|
||||
assert.equal(realSummary.costUntilAverage, 20);
|
||||
assert.equal(realSummary.targets[50].remainingDraws, 1);
|
||||
assert.deepEqual(getAveragePositionStatus(0.1), { id: "start", tone: "positive" });
|
||||
assert.deepEqual(getAveragePositionStatus(0.3), { id: "farBelow", tone: "positive" });
|
||||
assert.deepEqual(getAveragePositionStatus(0.75), { id: "below", tone: "positive" });
|
||||
assert.deepEqual(getAveragePositionStatus(1), { id: "average", tone: "" });
|
||||
assert.deepEqual(getAveragePositionStatus(1.25), { id: "slightlyAbove", tone: "negative" });
|
||||
assert.deepEqual(getAveragePositionStatus(2), { id: "high", tone: "negative" });
|
||||
assert.deepEqual(getAveragePositionStatus(3), { id: "catastrophic", tone: "negative" });
|
||||
|
||||
const result = simulateRngDraws("25", 8, (() => {
|
||||
const values = [0.1, 0.9, 0.2, 0.8, 0.7, 0.1, 0.6, 0.5];
|
||||
|
|
@ -386,8 +423,8 @@ test("rng simulator text exchange round trips rates and settings", () => {
|
|||
const rngSimulator = normalizeRngSimulatorData({
|
||||
activeRateId: "rate2",
|
||||
rates: [
|
||||
{ id: "rate1", label: "Drop rare", rate: "2.500", costPerDraw: "150", multiplier: 25, occurrenceValue: 20, occurrenceUnit: "perHour" },
|
||||
{ id: "rate2", label: "Ultra rare", rate: "0.000004", costPerDraw: "", multiplier: 50, occurrenceValue: 120, occurrenceUnit: "perDay" }
|
||||
{ id: "rate1", label: "Drop rare", rate: "2.500", costPerDraw: "150", multiplier: 25, occurrenceValue: 20, occurrenceUnit: "perHour", realDraws: 60 },
|
||||
{ id: "rate2", label: "Ultra rare", rate: "0.000004", costPerDraw: "", multiplier: 50, occurrenceValue: 120, occurrenceUnit: "perDay", realDraws: 0 }
|
||||
],
|
||||
settings: { simulationDraws: 5000 }
|
||||
});
|
||||
|
|
@ -403,6 +440,11 @@ test("rng simulator text exchange round trips rates and settings", () => {
|
|||
assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.multiplier, 50);
|
||||
assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.occurrenceValue, 120);
|
||||
assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.occurrenceUnit, "perDay");
|
||||
assert.equal(imported.data.rates.find((rate) => rate.label === "Drop rare")?.realDraws, 60);
|
||||
const legacyImported = importModuleText("rngSimulator", "[rates]\n- Legacy | rate=1 | occurrenceValue=300 | occurrenceUnit=perMonth", context);
|
||||
assert.equal(legacyImported.ok, true);
|
||||
assert.equal(legacyImported.data.rates[0].occurrenceValue, 10);
|
||||
assert.equal(legacyImported.data.rates[0].occurrenceUnit, "perDay");
|
||||
assert.equal(imported.data.settings.simulationDraws, 5000);
|
||||
assert.equal(imported.data.history, undefined);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -542,11 +542,11 @@
|
|||
},
|
||||
{
|
||||
"title": "Lecture probabiliste",
|
||||
"text": "L'onglet Calculs affiche la moyenne d'essais avant réussite, le temps moyen selon votre rythme, les coûts et les paliers de chance cumulée.",
|
||||
"text": "Les paramètres communs affichent la moyenne d'essais avant réussite, tandis que l'onglet Prévisions détaille les durées et les paliers de chance cumulée.",
|
||||
"details": [
|
||||
"Les occurrences par heure, jour et mois sont indépendantes pour s'adapter à différents usages.",
|
||||
"Les occurrences par heure ou par jour servent à estimer le temps moyen et les chances après une durée donnée.",
|
||||
"Les paliers 50%, 90%, 95% et 99% donnent un repère concret sur la variance.",
|
||||
"L'onglet Simulation agrège réussites, échecs, pire série d'échecs et coûts sans stocker chaque tirage."
|
||||
"L'onglet Suivi permet d'enregistrer ses tirages réels et de les situer par rapport à la moyenne."
|
||||
]
|
||||
}
|
||||
],
|
||||
|
|
@ -2125,13 +2125,14 @@
|
|||
"deleteTitle": "Supprimer",
|
||||
"noActiveRateTitle": "Aucun taux actif",
|
||||
"activeRateLabel": "Taux brut",
|
||||
"commonSettingsTitle": "Paramètres du taux",
|
||||
"effectiveRateLabel": "Taux final",
|
||||
"statsTabLabel": "Calculs",
|
||||
"forecastsTabLabel": "Prévisions",
|
||||
"simulationTabLabel": "Simulation",
|
||||
"trackingTabLabel": "Suivi",
|
||||
"simulationDrawsLabel": "Tirages simulés",
|
||||
"perHourLabel": "Par heure",
|
||||
"perDayLabel": "Par jour",
|
||||
"perMonthLabel": "Par mois",
|
||||
"multiplierLabel": "Multiplicateur (%)",
|
||||
"averageDrawsLabel": "Moyenne avant réussite",
|
||||
"simulationChanceLabel": "Chance sur les tirages saisis",
|
||||
|
|
@ -2143,14 +2144,14 @@
|
|||
"occurrenceCountLabel": "Nombre d'occurrences",
|
||||
"hourTimeLabel": "En heures",
|
||||
"dayTimeLabel": "En jours",
|
||||
"monthTimeLabel": "En mois",
|
||||
"durationChancesTitle": "Chances selon une durée",
|
||||
"durationChancePrefix": "Après",
|
||||
"chanceTargetsTitle": "Tirages pour atteindre une chance cumulée",
|
||||
"chanceLabel": "Chance",
|
||||
"costLabel": "Coût",
|
||||
"drawsUnit": "tirages",
|
||||
"hoursUnit": "h",
|
||||
"daysUnit": "j",
|
||||
"monthsUnit": "mois",
|
||||
"emptySummary": "Ajoutez un taux pour afficher les calculs.",
|
||||
"simulationTitle": "Résultats simulés",
|
||||
"simulateButton": "Simuler",
|
||||
|
|
@ -2168,7 +2169,25 @@
|
|||
"luckySimulationLabel": "Au-dessus",
|
||||
"neutralSimulationLabel": "Dans la cible",
|
||||
"unluckySimulationLabel": "Sous l'attendu",
|
||||
"importPlaceholder": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true | costPerDraw=150 | multiplier=320 | occurrenceValue=60 | occurrenceUnit=perHour\n- Pen Eklecta | rate=20.02 | costPerDraw=12.5 | multiplier=0\n\n[settings]\nsimulationDraws: 1000",
|
||||
"realDrawsLabel": "Tirages effectués",
|
||||
"realCumulativeChanceLabel": "Chance d'avoir déjà réussi",
|
||||
"afterDrawsPrefix": "après",
|
||||
"averagePositionLabel": "Position vs moyenne",
|
||||
"averageStatusStartLabel": "Début de suivi",
|
||||
"averageStatusFarBelowLabel": "Loin sous la moyenne",
|
||||
"averageStatusBelowLabel": "Un peu moins que la moyenne",
|
||||
"averageStatusAverageLabel": "Dans la moyenne",
|
||||
"averageStatusSlightlyAboveLabel": "Un peu au-dessus de la moyenne",
|
||||
"averageStatusHighLabel": "Très au-dessus de la moyenne",
|
||||
"averageStatusCatastrophicLabel": "Catastrophique",
|
||||
"drawsUntilAverageLabel": "Tirages avant moyenne",
|
||||
"averageExceededLabel": "Moyenne dépassée",
|
||||
"averageCostDeltaLabel": "Écart au coût moyen",
|
||||
"trackingTargetsTitle": "Progression vers les chances cumulées",
|
||||
"trackingProgressLabel": "Progression",
|
||||
"remainingDrawsLabel": "Tirages restants",
|
||||
"remainingCostLabel": "Coût restant",
|
||||
"importPlaceholder": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true | costPerDraw=150 | multiplier=320 | occurrenceValue=60 | occurrenceUnit=perHour | realDraws=1000\n- Pen Eklecta | rate=20.02 | costPerDraw=12.5 | multiplier=0\n\n[settings]\nsimulationDraws: 1000",
|
||||
"importOpenButton": "Importer du texte",
|
||||
"exportOpenButton": "Exporter en format texte",
|
||||
"importModalTitle": "Importer un simulateur de chance",
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
|||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
import {
|
||||
RNG_SIMULATOR_DEFAULT_RATE,
|
||||
RNG_SIMULATOR_HOURLY_DURATION_PRESETS,
|
||||
RNG_SIMULATOR_HISTORY_LIMIT,
|
||||
RNG_SIMULATOR_LIMITS,
|
||||
addRngSimulationHistory,
|
||||
calculateAtLeastOneProbability,
|
||||
calculateRngCostSummary,
|
||||
calculateRngRealDrawsSummary,
|
||||
calculateRngSummary,
|
||||
clampDecimal,
|
||||
clampInteger,
|
||||
|
|
@ -23,7 +25,8 @@ import {
|
|||
|
||||
const CHANCE_TARGETS = [50, 90, 95, 99];
|
||||
const RNG_TABS = [
|
||||
{ id: "stats", label: "Calculs" },
|
||||
{ id: "tracking", label: "Suivi" },
|
||||
{ id: "forecasts", label: "Prévisions" },
|
||||
{ id: "simulation", label: "Simulation" }
|
||||
];
|
||||
|
||||
|
|
@ -42,6 +45,12 @@ function formatCost(value) {
|
|||
return formatNumber(value, 0);
|
||||
}
|
||||
|
||||
function formatSignedCost(value) {
|
||||
if (!Number.isFinite(value)) return "—";
|
||||
if (value === 0) return "0";
|
||||
return `${value > 0 ? "+" : ""}${formatCost(value)}`;
|
||||
}
|
||||
|
||||
function formatDrawTarget(value, unit) {
|
||||
if (!Number.isFinite(value)) return `— ${unit}`;
|
||||
if (value >= 1000000) {
|
||||
|
|
@ -118,21 +127,41 @@ function historyEntryMatchesRate(entry, rate) {
|
|||
return entry.label === rate.label && entry.rate === rate.rate;
|
||||
}
|
||||
|
||||
function getAveragePositionLabel(status, textContent) {
|
||||
const labels = {
|
||||
start: textContent.averageStatusStartLabel || "Début de suivi",
|
||||
farBelow: textContent.averageStatusFarBelowLabel || "Loin sous la moyenne",
|
||||
below: textContent.averageStatusBelowLabel || "Un peu moins que la moyenne",
|
||||
average: textContent.averageStatusAverageLabel || "Dans la moyenne",
|
||||
slightlyAbove: textContent.averageStatusSlightlyAboveLabel || "Un peu au-dessus de la moyenne",
|
||||
high: textContent.averageStatusHighLabel || "Très au-dessus de la moyenne",
|
||||
catastrophic: textContent.averageStatusCatastrophicLabel || "Catastrophique"
|
||||
};
|
||||
return labels[status?.id] || labels.start;
|
||||
}
|
||||
|
||||
function getToneFromDelta(value) {
|
||||
if (!Number.isFinite(value) || value === 0) return "";
|
||||
return value > 0 ? "positive" : "negative";
|
||||
}
|
||||
|
||||
export function RngSimulatorModule({ toolboxId, moduleId, context, editing = false, setEditing = () => {}, textExchangeOpen }) {
|
||||
const data = context.normalizeRngSimulatorData(context.getModuleData(toolboxId, moduleId, {}));
|
||||
const textContent = context.moduleText?.rngSimulator || {};
|
||||
const tabs = useMemo(() => [
|
||||
{ ...RNG_TABS[0], label: textContent.statsTabLabel || RNG_TABS[0].label },
|
||||
{ ...RNG_TABS[1], label: textContent.simulationTabLabel || RNG_TABS[1].label }
|
||||
], [textContent.simulationTabLabel, textContent.statsTabLabel]);
|
||||
{ ...RNG_TABS[0], label: textContent.trackingTabLabel || RNG_TABS[0].label },
|
||||
{ ...RNG_TABS[1], label: textContent.forecastsTabLabel || RNG_TABS[1].label },
|
||||
{ ...RNG_TABS[2], label: textContent.simulationTabLabel || RNG_TABS[2].label }
|
||||
], [textContent.forecastsTabLabel, textContent.simulationTabLabel, textContent.trackingTabLabel]);
|
||||
const historyStorageKey = `rng-simulator:${toolboxId}:${moduleId}:history`;
|
||||
const [activeTab, setActiveTab] = useState("stats");
|
||||
const [activeTab, setActiveTab] = useState("tracking");
|
||||
const [history, setHistory] = useState(() => readSimulationHistory(historyStorageKey));
|
||||
const [editingRateId, setEditingRateId] = useState("");
|
||||
const [multiplierDraft, setMultiplierDraft] = useState(null);
|
||||
const [costDraft, setCostDraft] = useState(null);
|
||||
const [occurrenceUnit, setOccurrenceUnit] = useState("perHour");
|
||||
const [occurrenceDraft, setOccurrenceDraft] = useState(null);
|
||||
const [realDrawsDraft, setRealDrawsDraft] = useState(null);
|
||||
const rateFormRef = useRef(null);
|
||||
const previousEditingRef = useRef(editing);
|
||||
const rateDraft = useDraftForm({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
|
||||
|
|
@ -154,6 +183,17 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal
|
|||
}) : null, [activeRate, costDraft, data.settings.simulationDraws, summary]);
|
||||
const activeHistory = useMemo(() => history.filter((entry) => historyEntryMatchesRate(entry, activeRate)), [activeRate, history]);
|
||||
const chanceWithoutMultiplier = activeRate ? calculateAtLeastOneProbability(activeRate.rate, data.settings.simulationDraws, 0) : 0;
|
||||
const realDrawsSummary = useMemo(() => activeRate && summary ? calculateRngRealDrawsSummary({
|
||||
rate: activeRate.rate,
|
||||
multiplier: activeRate.multiplier,
|
||||
realDraws: activeRate.realDraws,
|
||||
averageDraws: summary.averageDraws,
|
||||
drawsForChance: summary.drawsForChance,
|
||||
costPerDraw: activeRate.costPerDraw
|
||||
}) : null, [activeRate, summary]);
|
||||
const averageDrawsDelta = activeRate && summary ? activeRate.realDraws - summary.averageDraws : 0;
|
||||
const averageExceededDraws = Math.max(0, Math.round(averageDrawsDelta));
|
||||
const averageDrawsTone = realDrawsSummary?.averageExceeded ? (averageExceededDraws > 0 ? "negative" : "") : "positive";
|
||||
|
||||
useEffect(() => {
|
||||
setHistory(readSimulationHistory(historyStorageKey));
|
||||
|
|
@ -185,6 +225,7 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal
|
|||
setMultiplierDraft(null);
|
||||
setCostDraft(null);
|
||||
setOccurrenceDraft(null);
|
||||
setRealDrawsDraft(null);
|
||||
setOccurrenceUnit(activeRate?.occurrenceUnit || "perHour");
|
||||
}, [activeRate?.id, activeRate?.occurrenceUnit]);
|
||||
|
||||
|
|
@ -277,6 +318,30 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal
|
|||
setOccurrenceDraft(null);
|
||||
}
|
||||
|
||||
function updateRealDraws(value) {
|
||||
setRealDrawsDraft(value);
|
||||
if (!activeRate) return;
|
||||
if (String(value).trim() === "") {
|
||||
save({
|
||||
...data,
|
||||
rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, realDraws: 0 } : rate)
|
||||
});
|
||||
return;
|
||||
}
|
||||
save({
|
||||
...data,
|
||||
rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, realDraws: clampInteger(value, 0, RNG_SIMULATOR_LIMITS.realDrawsMax, activeRate.realDraws) } : rate)
|
||||
});
|
||||
}
|
||||
|
||||
function blurRealDraws() {
|
||||
if (realDrawsDraft === null) return;
|
||||
const raw = realDrawsDraft.trim();
|
||||
const parsed = Number.parseInt(raw.replace(/[\s\u00a0]/g, ""), 10);
|
||||
if (!raw || !Number.isFinite(parsed)) updateRealDraws("");
|
||||
setRealDrawsDraft(null);
|
||||
}
|
||||
|
||||
function submitRate(event) {
|
||||
rateDraft.handleSubmit(event, (draft, { reset }) => {
|
||||
const label = draft.label.trim();
|
||||
|
|
@ -410,22 +475,19 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rng-simulator-panel rng-simulator-tabs-panel">
|
||||
<section className="rng-simulator-panel rng-simulator-common-panel">
|
||||
<header>
|
||||
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="rng-simulator-tabs" />
|
||||
<div>
|
||||
<h3>{textContent.commonSettingsTitle || "Paramètres du taux"}</h3>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{activeTab === "stats" && (
|
||||
<div className="rng-simulator-tab-content">
|
||||
{activeRate && summary ? (
|
||||
<>
|
||||
<div className="rng-simulator-section">
|
||||
<div className="rng-simulator-primary-controls">
|
||||
<div className="rng-simulator-primary-column">
|
||||
<div className="rng-simulator-field rng-simulator-multiplier-field">
|
||||
<span>{textContent.multiplierLabel || "Multiplicateur (%)"}</span>
|
||||
<div className="rng-simulator-percent-input">
|
||||
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={multiplierDraft ?? String(activeRate.multiplier ?? 0)} onChange={(event) => updateMultiplier(event.target.value)} onBlur={blurMultiplier} aria-label={textContent.multiplierLabel || "Multiplicateur (%)"} />
|
||||
<input className="nds-input" data-size="sm" type="number" step="any" value={multiplierDraft ?? String(activeRate.multiplier ?? 0)} onChange={(event) => updateMultiplier(event.target.value)} onBlur={blurMultiplier} aria-label={textContent.multiplierLabel || "Multiplicateur (%)"} />
|
||||
<span>%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -437,42 +499,64 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal
|
|||
<div className="rng-simulator-cost-column">
|
||||
<label className="rng-simulator-field rng-simulator-cost-field">
|
||||
<span>{textContent.costPerDrawLabel || "Coût d'un tirage"}</span>
|
||||
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={costDraft ?? activeRate.costPerDraw ?? ""} onChange={(event) => updateCost(event.target.value)} onBlur={blurCost} placeholder={textContent.costPerDrawPlaceholder || "Optionnel"} />
|
||||
<input className="nds-input" data-size="sm" type="number" step="any" value={costDraft ?? activeRate.costPerDraw ?? ""} onChange={(event) => updateCost(event.target.value)} onBlur={blurCost} placeholder={textContent.costPerDrawPlaceholder || "Optionnel"} />
|
||||
</label>
|
||||
<RngStat label={textContent.averageCostLabel || "Coût moyen avant réussite"} value={formatCost(costSummary?.averageSuccessCost)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : <p className="rng-simulator-empty">{textContent.emptySummary || "Ajoutez un taux pour afficher les calculs."}</p>}
|
||||
</section>
|
||||
|
||||
<section className="rng-simulator-panel rng-simulator-tabs-panel">
|
||||
<header>
|
||||
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="rng-simulator-tabs" />
|
||||
</header>
|
||||
|
||||
{activeTab === "forecasts" && (
|
||||
<div className="rng-simulator-tab-content">
|
||||
{activeRate && summary ? (
|
||||
<>
|
||||
<div className="rng-simulator-section">
|
||||
<span className="rng-simulator-section-title">{textContent.averageTimeTitle || "Temps moyen avant réussite"}</span>
|
||||
<div className="rng-simulator-occurrence-control">
|
||||
<label className="rng-simulator-field">
|
||||
<span>{textContent.occurrenceCountLabel || "Nombre d'occurrences"}</span>
|
||||
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDraft ?? String(activeRate.occurrenceValue ?? 0)} onChange={(event) => updateOccurrence(event.target.value)} onBlur={blurOccurrence} />
|
||||
<input className="nds-input" data-size="sm" type="number" step="any" value={occurrenceDraft ?? String(activeRate.occurrenceValue ?? 0)} onChange={(event) => updateOccurrence(event.target.value)} onBlur={blurOccurrence} />
|
||||
</label>
|
||||
<label className="rng-simulator-field">
|
||||
<span>{textContent.periodLabel || "Période"}</span>
|
||||
<select className="nds-select" data-size="sm" value={occurrenceUnit} onChange={(event) => changeOccurrenceUnit(event.target.value)}>
|
||||
<option value="perHour">{textContent.perHourLabel || "Par heure"}</option>
|
||||
<option value="perDay">{textContent.perDayLabel || "Par jour"}</option>
|
||||
<option value="perMonth">{textContent.perMonthLabel || "Par mois"}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="rng-simulator-time-results">
|
||||
<RngStat label={textContent.hourTimeLabel || "En heures"} value={formatTime(summary.timeByFrequency.hour, textContent.hoursUnit || "h")} />
|
||||
<RngStat label={textContent.dayTimeLabel || "En jours"} value={formatTime(summary.timeByFrequency.day, textContent.daysUnit || "j")} />
|
||||
<RngStat label={textContent.monthTimeLabel || "En mois"} value={formatTime(summary.timeByFrequency.month, textContent.monthsUnit || "mois")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rng-simulator-section">
|
||||
<span className="rng-simulator-section-title">{textContent.durationChancesTitle || "Chances selon une durée"}</span>
|
||||
<div className="rng-simulator-duration-grid">
|
||||
{(summary.durationChances || RNG_SIMULATOR_HOURLY_DURATION_PRESETS).map((preset) => (
|
||||
<RngStat
|
||||
key={preset.id}
|
||||
label={`${textContent.durationChancePrefix || "Après"} ${preset.label}`}
|
||||
value={formatPercent((preset.chance || 0) * 100, 6)}
|
||||
title={`${formatNumber(preset.draws || 0, 2)} ${textContent.drawsUnit || "tirages"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rng-simulator-section">
|
||||
<span className="rng-simulator-section-title">{textContent.chanceTargetsTitle || "Tirages pour atteindre une chance cumulée"}</span>
|
||||
<div className="rng-simulator-table rng-simulator-target-table">
|
||||
<span>{textContent.chanceLabel || "Chance"}</span>
|
||||
<span>{textContent.drawsUnit || "Tirages"}</span>
|
||||
<span>{textContent.costLabel || "Coût"}</span>
|
||||
<span className="rng-simulator-table-heading">{textContent.chanceLabel || "Chance"}</span>
|
||||
<span className="rng-simulator-table-heading">{textContent.drawsUnit || "Tirages"}</span>
|
||||
<span className="rng-simulator-table-heading">{textContent.costLabel || "Coût"}</span>
|
||||
{CHANCE_TARGETS.map((target) => (
|
||||
<RngTargetRow costSummary={costSummary} draws={summary.drawsForChance[target]} key={target} target={target} textContent={textContent} />
|
||||
))}
|
||||
|
|
@ -489,7 +573,7 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal
|
|||
<div className="rng-simulator-simulation-actions">
|
||||
<label className="rng-simulator-field rng-simulator-inline-field rng-simulator-simulation-draws">
|
||||
<span>{textContent.simulationDrawsLabel || "Tirages simulés"}</span>
|
||||
<input className="nds-input" data-size="sm" type="number" min="1" max={RNG_SIMULATOR_LIMITS.simulationDrawsMax} value={data.settings.simulationDraws} onChange={(event) => updateSettings("simulationDraws", event.target.value)} />
|
||||
<input className="nds-input" data-size="sm" type="number" min="1" max={RNG_SIMULATOR_LIMITS.simulationDrawsMax} step="1" value={data.settings.simulationDraws} onChange={(event) => updateSettings("simulationDraws", event.target.value)} />
|
||||
</label>
|
||||
<button className="nds-button" data-size="sm" data-variant="primary" type="button" onClick={runSimulation} disabled={!activeRate}>
|
||||
<Icon name="play-circle" />
|
||||
|
|
@ -516,6 +600,42 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "tracking" && (
|
||||
<div className="rng-simulator-tab-content">
|
||||
{activeRate && summary && realDrawsSummary ? (
|
||||
<>
|
||||
<div className="rng-simulator-section">
|
||||
<div className="rng-simulator-tracking-control">
|
||||
<label className="rng-simulator-field">
|
||||
<span>{textContent.realDrawsLabel || "Tirages effectués"}</span>
|
||||
<input className="nds-input" data-size="sm" type="number" min="0" max={RNG_SIMULATOR_LIMITS.realDrawsMax} step="1" value={realDrawsDraft ?? String(activeRate.realDraws ?? 0)} onChange={(event) => updateRealDraws(event.target.value)} onBlur={blurRealDraws} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="rng-simulator-tracking-grid">
|
||||
<RngStat label={`${textContent.realCumulativeChanceLabel || "Chance d'avoir déjà réussi"} ${textContent.afterDrawsPrefix || "après"} ${formatNumber(realDrawsSummary.realDraws, 0)} ${textContent.drawsUnit || "tirages"}`} value={formatPercent(realDrawsSummary.cumulativeChance * 100, 6)} />
|
||||
<RngStat label={textContent.averagePositionLabel || "Position vs moyenne"} tone={realDrawsSummary.averagePosition.tone} value={getAveragePositionLabel(realDrawsSummary.averagePosition, textContent)} />
|
||||
<RngStat label={realDrawsSummary.averageExceeded ? textContent.averageExceededLabel || "Moyenne dépassée" : textContent.drawsUntilAverageLabel || "Tirages avant moyenne"} tone={averageDrawsTone} value={realDrawsSummary.averageExceeded ? `${formatNumber(averageExceededDraws, 0)} ${textContent.drawsUnit || "tirages"}` : `${formatNumber(realDrawsSummary.drawsUntilAverage, 0)} ${textContent.drawsUnit || "tirages"}`} />
|
||||
<RngStat label={textContent.averageCostDeltaLabel || "Écart au coût moyen"} tone={realDrawsSummary.averageCostDelta > 0 ? "negative" : realDrawsSummary.averageCostDelta < 0 ? "positive" : ""} value={realDrawsSummary.hasCost ? formatSignedCost(realDrawsSummary.averageCostDelta) : "—"} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rng-simulator-section">
|
||||
<span className="rng-simulator-section-title">{textContent.trackingTargetsTitle || "Progression vers les chances cumulées"}</span>
|
||||
<div className="rng-simulator-table rng-simulator-tracking-target-table">
|
||||
<span className="rng-simulator-table-heading">{textContent.chanceLabel || "Chance"}</span>
|
||||
<span className="rng-simulator-table-heading">{textContent.trackingProgressLabel || "Progression"}</span>
|
||||
<span className="rng-simulator-table-heading">{textContent.remainingDrawsLabel || "Tirages restants"}</span>
|
||||
<span className="rng-simulator-table-heading">{textContent.remainingCostLabel || "Coût restant"}</span>
|
||||
{CHANCE_TARGETS.map((target) => (
|
||||
<RngTrackingTargetRow hasCost={realDrawsSummary.hasCost} key={target} target={target} targetSummary={realDrawsSummary.targets[target]} textContent={textContent} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : <p className="rng-simulator-empty">{textContent.emptySummary || "Ajoutez un taux pour afficher les calculs."}</p>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -531,9 +651,9 @@ function RngInfo({ label, value }) {
|
|||
);
|
||||
}
|
||||
|
||||
function RngStat({ label, value, title }) {
|
||||
function RngStat({ label, tone = "", value, title }) {
|
||||
return (
|
||||
<article className="rng-simulator-stat" title={title}>
|
||||
<article className="rng-simulator-stat" data-tone={tone || undefined} title={title}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</article>
|
||||
|
|
@ -543,16 +663,37 @@ function RngStat({ label, value, title }) {
|
|||
function RngTargetRow({ costSummary, draws, target, textContent }) {
|
||||
return (
|
||||
<>
|
||||
<strong>{target} %</strong>
|
||||
<strong className="rng-simulator-chance-value">{target} %</strong>
|
||||
<span title={`${formatNumber(draws, 0)} ${textContent.drawsUnit || "tirages"}`}>{formatDrawTarget(draws, textContent.drawsUnit || "tirages")}</span>
|
||||
<span>{formatCost(costSummary?.targetCosts?.[target])}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RngTrackingTargetRow({ hasCost, target, targetSummary, textContent }) {
|
||||
return (
|
||||
<>
|
||||
<strong className="rng-simulator-chance-value">{target} %</strong>
|
||||
<span>{formatPercent((targetSummary?.progress || 0) * 100, 2)}</span>
|
||||
<span title={`${formatNumber(targetSummary?.remainingDraws || 0, 0)} ${textContent.drawsUnit || "tirages"}`}>{formatDrawTarget(targetSummary?.remainingDraws || 0, textContent.drawsUnit || "tirages")}</span>
|
||||
<span>{hasCost ? formatCost(targetSummary?.remainingCost) : "—"}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RngHistoryMetric({ label, tone = "", value }) {
|
||||
return (
|
||||
<span data-tone={tone || undefined}>
|
||||
<small>{label}</small>
|
||||
<b>{value}</b>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RngHistoryRow({ entry, index, textContent }) {
|
||||
const expectedSuccesses = entry.draws * (getEffectiveRate(entry.rate, entry.multiplier) / 100);
|
||||
const successDelta = entry.successes - expectedSuccesses;
|
||||
const successTone = getToneFromDelta(successDelta);
|
||||
const luckLabel = successDelta > 0
|
||||
? textContent.luckySimulationLabel || "Au-dessus"
|
||||
: successDelta < 0
|
||||
|
|
@ -566,14 +707,16 @@ function RngHistoryRow({ entry, index, textContent }) {
|
|||
simulationSuccesses: entry.successes,
|
||||
firstSuccessDraw: entry.firstSuccessDraw
|
||||
});
|
||||
const hasFirstSuccessCost = entry.firstSuccessDraw > 0 && Number.isFinite(costs?.simulationFirstSuccessCost) && costs.simulationFirstSuccessCost > 0;
|
||||
const hasCostPerSuccess = entry.successes > 0 && Number.isFinite(costs?.simulationCostPerSuccess) && costs.simulationCostPerSuccess > 0;
|
||||
return (
|
||||
<article className={`rng-simulator-history-row ${index === 0 ? "is-latest" : ""}`}>
|
||||
<div className="rng-simulator-history-heading">
|
||||
<div className="rng-simulator-history-identity">
|
||||
<div className="rng-simulator-history-identity" data-tone={successTone || undefined}>
|
||||
<strong>{entry.label}</strong>
|
||||
<small>{luckLabel} · {formatSignedPercent((successDelta / entry.draws) * 100, 4)}</small>
|
||||
</div>
|
||||
<div className="rng-simulator-history-score">
|
||||
<div className="rng-simulator-history-score" data-tone={entry.successes > 0 ? "positive" : undefined}>
|
||||
<b>{entry.successes}</b>
|
||||
<small>{textContent.successesLabel || "Réussites"}</small>
|
||||
</div>
|
||||
|
|
@ -583,14 +726,14 @@ function RngHistoryRow({ entry, index, textContent }) {
|
|||
</div>
|
||||
</div>
|
||||
<div className="rng-simulator-history-metrics">
|
||||
<span><small>{textContent.successesLabel || "Réussites"}</small><b>{entry.successes}/{entry.draws}</b></span>
|
||||
<span><small>{textContent.failsLabel || "Fails"}</small><b>{entry.fails}</b></span>
|
||||
<span><small>{textContent.observedRateLabel || "Taux de réussite"}</small><b>{formatPercent((entry.successes / entry.draws) * 100, 6)}</b></span>
|
||||
<span><small>{textContent.expectedSuccessesLabel || "Réussites attendues"}</small><b>{formatNumber(expectedSuccesses, 2)}</b></span>
|
||||
<span><small>{textContent.longestFailStreakLabel || "Pire série d'échec"}</small><b>{entry.longestFailStreak}</b></span>
|
||||
<span><small>{textContent.longestSuccessStreakLabel || "Réussite consécutive"}</small><b>{entry.longestSuccessStreak}</b></span>
|
||||
<span><small>{textContent.firstSuccessCostLabel || "Coût de la 1ere réussite"}</small><b>{entry.firstSuccessDraw > 0 ? formatCost(costs?.simulationFirstSuccessCost) : "—"}</b></span>
|
||||
<span><small>{textContent.costPerSuccessLabel || "Coût moyen/réussite"}</small><b>{entry.successes > 0 ? formatCost(costs?.simulationCostPerSuccess) : "—"}</b></span>
|
||||
<RngHistoryMetric label={textContent.successesLabel || "Réussites"} tone={entry.successes > 0 ? "positive" : ""} value={`${entry.successes}/${entry.draws}`} />
|
||||
<RngHistoryMetric label={textContent.failsLabel || "Fails"} tone={entry.fails > 0 ? "negative" : ""} value={entry.fails} />
|
||||
<RngHistoryMetric label={textContent.observedRateLabel || "Taux de réussite"} tone={successTone} value={formatPercent((entry.successes / entry.draws) * 100, 6)} />
|
||||
<RngHistoryMetric label={textContent.expectedSuccessesLabel || "Réussites attendues"} value={formatNumber(expectedSuccesses, 2)} />
|
||||
<RngHistoryMetric label={textContent.longestFailStreakLabel || "Pire série d'échec"} tone={entry.longestFailStreak > 0 ? "negative" : ""} value={entry.longestFailStreak} />
|
||||
<RngHistoryMetric label={textContent.longestSuccessStreakLabel || "Réussite consécutive"} tone={entry.longestSuccessStreak > 0 ? "positive" : ""} value={entry.longestSuccessStreak} />
|
||||
<RngHistoryMetric label={textContent.firstSuccessCostLabel || "Coût de la 1ere réussite"} tone={hasFirstSuccessCost ? "negative" : ""} value={entry.firstSuccessDraw > 0 ? formatCost(costs?.simulationFirstSuccessCost) : "—"} />
|
||||
<RngHistoryMetric label={textContent.costPerSuccessLabel || "Coût moyen/réussite"} tone={hasCostPerSuccess ? "negative" : ""} value={entry.successes > 0 ? formatCost(costs?.simulationCostPerSuccess) : "—"} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,14 +8,45 @@ export const RNG_SIMULATOR_LIMITS = {
|
|||
multiplierMax: 1000000,
|
||||
simulationDrawsMin: 1,
|
||||
simulationDrawsMax: 1000000,
|
||||
realDrawsMax: 1000000000,
|
||||
occurrenceRateMin: 0,
|
||||
occurrenceRateMax: 1000000000
|
||||
};
|
||||
|
||||
export const RNG_SIMULATOR_OCCURRENCE_UNITS = ["perHour", "perDay", "perMonth"];
|
||||
export const RNG_SIMULATOR_OCCURRENCE_UNITS = ["perHour", "perDay"];
|
||||
export const RNG_SIMULATOR_HOURLY_DURATION_PRESETS = [
|
||||
{ id: "1h", label: "1h", hours: 1 },
|
||||
{ id: "5h", label: "5h", hours: 5 },
|
||||
{ id: "10h", label: "10h", hours: 10 },
|
||||
{ id: "1j", label: "1j", hours: 24 }
|
||||
];
|
||||
export const RNG_SIMULATOR_DAILY_DURATION_PRESETS = [
|
||||
{ id: "1j", label: "1j", hours: 24 },
|
||||
{ id: "3j", label: "3j", hours: 72 },
|
||||
{ id: "7j", label: "7j", hours: 168 },
|
||||
{ id: "15j", label: "15j", hours: 360 }
|
||||
];
|
||||
export const RNG_SIMULATOR_MONTHLY_DURATION_PRESETS = [
|
||||
{ id: "1j", label: "1j", hours: 24 },
|
||||
{ id: "3j", label: "3j", hours: 72 },
|
||||
{ id: "7j", label: "7j", hours: 168 },
|
||||
{ id: "30j", label: "30j", hours: 720 }
|
||||
];
|
||||
export const RNG_SIMULATOR_YEARLY_DURATION_PRESETS = [
|
||||
{ id: "30j", label: "30j", hours: 720 },
|
||||
{ id: "90j", label: "90j", hours: 2160 },
|
||||
{ id: "1a", label: "1 an", hours: 8760 },
|
||||
{ id: "5a", label: "5 ans", hours: 43800 }
|
||||
];
|
||||
export const RNG_SIMULATOR_LONG_DURATION_PRESETS = [
|
||||
{ id: "1a", label: "1 an", hours: 8760 },
|
||||
{ id: "5a", label: "5 ans", hours: 43800 },
|
||||
{ id: "10a", label: "10 ans", hours: 87600 },
|
||||
{ id: "25a", label: "25 ans", hours: 219000 }
|
||||
];
|
||||
|
||||
export function clampInteger(value, min, max, fallback = min) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
const parsed = Number.parseInt(String(value ?? "").replace(/[\s\u00a0]/g, ""), 10);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.min(max, Math.max(min, parsed));
|
||||
}
|
||||
|
|
@ -76,6 +107,11 @@ export function getEffectiveRate(rate, multiplier = 0) {
|
|||
export function calculateAtLeastOneProbability(rate, draws, multiplier = 0) {
|
||||
const probability = getEffectiveRate(rate, multiplier) / 100;
|
||||
const cleanDraws = clampInteger(draws, 0, Number.MAX_SAFE_INTEGER, 0);
|
||||
return calculateProbabilityForDrawCount(probability, cleanDraws);
|
||||
}
|
||||
|
||||
function calculateProbabilityForDrawCount(probability, draws) {
|
||||
const cleanDraws = clampDecimal(draws, 0, Number.MAX_SAFE_INTEGER, 0);
|
||||
if (probability <= 0 || cleanDraws <= 0) return 0;
|
||||
if (probability >= 1) return 1;
|
||||
return -Math.expm1(cleanDraws * Math.log1p(-probability));
|
||||
|
|
@ -91,19 +127,32 @@ export function calculateDrawsForChance(rate, targetChance, multiplier = 0) {
|
|||
}
|
||||
|
||||
export function normalizeOccurrenceUnit(value) {
|
||||
if (value === "perMonth") return "perDay";
|
||||
return RNG_SIMULATOR_OCCURRENCE_UNITS.includes(value) ? value : "perHour";
|
||||
}
|
||||
|
||||
export function normalizeOccurrenceSettings(value, unit) {
|
||||
const cleanValue = clampDecimal(value, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0);
|
||||
if (unit === "perMonth") {
|
||||
return {
|
||||
occurrenceValue: cleanValue / 30,
|
||||
occurrenceUnit: "perDay"
|
||||
};
|
||||
}
|
||||
return {
|
||||
occurrenceValue: cleanValue,
|
||||
occurrenceUnit: normalizeOccurrenceUnit(unit)
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateRngSummary({ rate, multiplier = 0, simulationDraws, occurrenceValue = 0, occurrenceUnit = "perHour" }) {
|
||||
const effectiveRate = getEffectiveRate(rate, multiplier);
|
||||
const probability = effectiveRate / 100;
|
||||
const averageDraws = probability > 0 ? 1 / probability : 0;
|
||||
const cleanOccurrenceValue = clampDecimal(occurrenceValue, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0);
|
||||
const cleanOccurrenceUnit = normalizeOccurrenceUnit(occurrenceUnit);
|
||||
const { occurrenceValue: cleanOccurrenceValue, occurrenceUnit: cleanOccurrenceUnit } = normalizeOccurrenceSettings(occurrenceValue, occurrenceUnit);
|
||||
let averageHours = 0;
|
||||
if (cleanOccurrenceValue > 0 && cleanOccurrenceUnit === "perHour") averageHours = averageDraws / cleanOccurrenceValue;
|
||||
else if (cleanOccurrenceValue > 0 && cleanOccurrenceUnit === "perDay") averageHours = (averageDraws / cleanOccurrenceValue) * 24;
|
||||
else if (cleanOccurrenceValue > 0 && cleanOccurrenceUnit === "perMonth") averageHours = (averageDraws / cleanOccurrenceValue) * 24 * 30;
|
||||
|
||||
return {
|
||||
probability,
|
||||
|
|
@ -112,9 +161,9 @@ export function calculateRngSummary({ rate, multiplier = 0, simulationDraws, occ
|
|||
chanceForSimulationDraws: calculateAtLeastOneProbability(rate, simulationDraws, multiplier),
|
||||
timeByFrequency: {
|
||||
hour: averageHours,
|
||||
day: averageHours > 0 ? averageHours / 24 : 0,
|
||||
month: averageHours > 0 ? averageHours / 24 / 30 : 0
|
||||
day: averageHours > 0 ? averageHours / 24 : 0
|
||||
},
|
||||
durationChances: calculateDurationChances(rate, multiplier, cleanOccurrenceValue, cleanOccurrenceUnit),
|
||||
drawsForChance: {
|
||||
50: calculateDrawsForChance(rate, 0.5, multiplier),
|
||||
90: calculateDrawsForChance(rate, 0.9, multiplier),
|
||||
|
|
@ -124,6 +173,79 @@ export function calculateRngSummary({ rate, multiplier = 0, simulationDraws, occ
|
|||
};
|
||||
}
|
||||
|
||||
export function calculateDurationChances(rate, multiplier = 0, occurrenceValue = 0, occurrenceUnit = "perHour") {
|
||||
const { occurrenceValue: cleanOccurrenceValue, occurrenceUnit: cleanOccurrenceUnit } = normalizeOccurrenceSettings(occurrenceValue, occurrenceUnit);
|
||||
const drawsPerHour = cleanOccurrenceUnit === "perDay" ? cleanOccurrenceValue / 24 : cleanOccurrenceValue;
|
||||
const averageHours = drawsPerHour > 0 ? (1 / (getEffectiveRate(rate, multiplier) / 100)) / drawsPerHour : 0;
|
||||
const presets = getDurationPresetsForAverageHours(averageHours, cleanOccurrenceUnit);
|
||||
return presets.map((preset) => ({
|
||||
...preset,
|
||||
draws: drawsPerHour > 0 ? drawsPerHour * preset.hours : 0,
|
||||
chance: drawsPerHour > 0 ? calculateProbabilityForDrawCount(getEffectiveRate(rate, multiplier) / 100, drawsPerHour * preset.hours) : 0
|
||||
}));
|
||||
}
|
||||
|
||||
export function getDurationPresetsForAverageHours(averageHours, occurrenceUnit = "perHour") {
|
||||
if (!Number.isFinite(averageHours) || averageHours <= 0) {
|
||||
return occurrenceUnit === "perDay" ? RNG_SIMULATOR_DAILY_DURATION_PRESETS : RNG_SIMULATOR_HOURLY_DURATION_PRESETS;
|
||||
}
|
||||
if (occurrenceUnit === "perDay" && averageHours <= 24 * 365) return RNG_SIMULATOR_MONTHLY_DURATION_PRESETS;
|
||||
if (averageHours <= 24 * 7) return RNG_SIMULATOR_HOURLY_DURATION_PRESETS;
|
||||
if (averageHours <= 24 * 365) return RNG_SIMULATOR_MONTHLY_DURATION_PRESETS;
|
||||
if (averageHours <= 24 * 365 * 10) return RNG_SIMULATOR_YEARLY_DURATION_PRESETS;
|
||||
return RNG_SIMULATOR_LONG_DURATION_PRESETS;
|
||||
}
|
||||
|
||||
export function getAveragePositionStatus(averageRatio) {
|
||||
const ratio = Number.isFinite(averageRatio) && averageRatio >= 0 ? averageRatio : 0;
|
||||
if (ratio < 0.25) return { id: "start", tone: "positive" };
|
||||
if (ratio < 0.5) return { id: "farBelow", tone: "positive" };
|
||||
if (ratio < 0.9) return { id: "below", tone: "positive" };
|
||||
if (ratio < 1.1) return { id: "average", tone: "" };
|
||||
if (ratio < 1.5) return { id: "slightlyAbove", tone: "negative" };
|
||||
if (ratio < 3) return { id: "high", tone: "negative" };
|
||||
return { id: "catastrophic", tone: "negative" };
|
||||
}
|
||||
|
||||
export function calculateRngRealDrawsSummary({ rate, multiplier = 0, realDraws = 0, averageDraws = 0, drawsForChance = {}, costPerDraw = "" }) {
|
||||
const cleanRealDraws = clampInteger(realDraws, 0, RNG_SIMULATOR_LIMITS.realDrawsMax, 0);
|
||||
const cumulativeChance = calculateAtLeastOneProbability(rate, cleanRealDraws, multiplier);
|
||||
const noSuccessChance = 1 - cumulativeChance;
|
||||
const cleanAverageDraws = Number.isFinite(averageDraws) && averageDraws > 0 ? averageDraws : 0;
|
||||
const averageRatio = cleanAverageDraws > 0 ? cleanRealDraws / cleanAverageDraws : 0;
|
||||
const averagePosition = getAveragePositionStatus(averageRatio);
|
||||
const drawsUntilAverage = cleanAverageDraws > cleanRealDraws ? Math.ceil(cleanAverageDraws - cleanRealDraws) : 0;
|
||||
const cleanCost = Number(normalizeCostString(costPerDraw));
|
||||
const hasCost = Number.isFinite(cleanCost) && cleanCost > 0;
|
||||
const averageSuccessCost = hasCost && cleanAverageDraws > 0 ? cleanAverageDraws * cleanCost : 0;
|
||||
const spentCost = hasCost ? cleanRealDraws * cleanCost : 0;
|
||||
const targets = {};
|
||||
Object.entries(drawsForChance || {}).forEach(([target, draws]) => {
|
||||
const remainingDraws = Number.isFinite(draws) ? Math.max(0, draws - cleanRealDraws) : Number.POSITIVE_INFINITY;
|
||||
targets[target] = {
|
||||
draws,
|
||||
remainingDraws,
|
||||
progress: Number.isFinite(draws) && draws > 0 ? Math.min(1, cleanRealDraws / draws) : 0,
|
||||
remainingCost: hasCost && Number.isFinite(remainingDraws) ? remainingDraws * cleanCost : 0
|
||||
};
|
||||
});
|
||||
return {
|
||||
realDraws: cleanRealDraws,
|
||||
hasCost,
|
||||
cumulativeChance,
|
||||
noSuccessChance,
|
||||
averageRatio,
|
||||
averagePosition,
|
||||
drawsUntilAverage,
|
||||
averageExceeded: cleanAverageDraws > 0 && cleanRealDraws >= cleanAverageDraws,
|
||||
spentCost,
|
||||
averageSuccessCost,
|
||||
averageCostDelta: hasCost && cleanAverageDraws > 0 ? spentCost - averageSuccessCost : 0,
|
||||
costUntilAverage: hasCost && drawsUntilAverage > 0 ? drawsUntilAverage * cleanCost : 0,
|
||||
targets
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateRngCostSummary({ costPerDraw, averageDraws, drawsForChance, simulationDraws, simulationSuccesses, firstSuccessDraw = 0 }) {
|
||||
const cleanCost = Number(normalizeCostString(costPerDraw));
|
||||
if (!Number.isFinite(cleanCost) || cleanCost <= 0) return null;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ export function exportRngSimulatorText(data) {
|
|||
(data.rates || []).forEach((rate) => {
|
||||
const cost = rate.costPerDraw ? ` | costPerDraw=${rate.costPerDraw}` : "";
|
||||
const occurrences = rate.occurrenceValue ? ` | occurrenceValue=${rate.occurrenceValue} | occurrenceUnit=${rate.occurrenceUnit || "perHour"}` : "";
|
||||
lines.push(`- ${rate.label} | rate=${rate.rate}${rate.id === data.activeRateId ? " | active=true" : ""}${cost} | multiplier=${rate.multiplier ?? 0}${occurrences}`);
|
||||
const realDraws = rate.realDraws ? ` | realDraws=${rate.realDraws}` : "";
|
||||
lines.push(`- ${rate.label} | rate=${rate.rate}${rate.id === data.activeRateId ? " | active=true" : ""}${cost} | multiplier=${rate.multiplier ?? 0}${occurrences}${realDraws}`);
|
||||
});
|
||||
lines.push(
|
||||
"",
|
||||
|
|
@ -56,7 +57,8 @@ export function importRngSimulatorText(text, context) {
|
|||
costPerDraw: pipeMeta.costPerDraw,
|
||||
multiplier: pipeMeta.multiplier,
|
||||
occurrenceValue: pipeMeta.occurrenceValue,
|
||||
occurrenceUnit: pipeMeta.occurrenceUnit
|
||||
occurrenceUnit: pipeMeta.occurrenceUnit,
|
||||
realDraws: pipeMeta.realDraws
|
||||
});
|
||||
if (pipeMeta.active === "true") data.activeRateId = id;
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import {
|
|||
clampDecimal,
|
||||
clampInteger,
|
||||
normalizeCostString,
|
||||
normalizeOccurrenceUnit,
|
||||
normalizeOccurrenceSettings,
|
||||
normalizeRateString
|
||||
} from "../../modules/rngSimulatorUtils.js";
|
||||
import { uid } from "./shared.js";
|
||||
|
|
@ -12,14 +12,16 @@ import { uid } from "./shared.js";
|
|||
function normalizeRateEntry(entry) {
|
||||
const label = String(entry?.label || "").trim();
|
||||
if (!label) return null;
|
||||
const occurrence = normalizeOccurrenceSettings(entry?.occurrenceValue, entry?.occurrenceUnit);
|
||||
return {
|
||||
id: entry?.id || uid("rng"),
|
||||
label,
|
||||
rate: normalizeRateString(entry?.rate),
|
||||
costPerDraw: normalizeCostString(entry?.costPerDraw),
|
||||
multiplier: clampDecimal(entry?.multiplier, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0),
|
||||
occurrenceValue: clampDecimal(entry?.occurrenceValue, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0),
|
||||
occurrenceUnit: normalizeOccurrenceUnit(entry?.occurrenceUnit)
|
||||
occurrenceValue: occurrence.occurrenceValue,
|
||||
occurrenceUnit: occurrence.occurrenceUnit,
|
||||
realDraws: clampInteger(entry?.realDraws, 0, RNG_SIMULATOR_LIMITS.realDrawsMax, 0)
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -54,6 +56,7 @@ export function compactRngSimulatorDataForStorage(value) {
|
|||
compactRate.occurrenceValue = rate.occurrenceValue;
|
||||
compactRate.occurrenceUnit = rate.occurrenceUnit;
|
||||
}
|
||||
if (rate.realDraws !== 0) compactRate.realDraws = rate.realDraws;
|
||||
return compactRate;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
} from "../features/toolboxes/storage/toolboxStorage.js";
|
||||
|
||||
const LIBRARY_TOOLBOX_ID = "library";
|
||||
const LIBRARY_ANCHOR_RETRY_DELAYS = [0, 80, 240, 600];
|
||||
|
||||
const MODULE_COMPONENTS = {
|
||||
memos: lazy(() => import("../features/toolboxes/modules/MemosModule.jsx").then((module) => ({ default: module.MemosModule }))),
|
||||
|
|
@ -83,12 +84,23 @@ function getCurrentLibraryAnchor() {
|
|||
return match ? decodeURIComponent(match[1]) : "";
|
||||
}
|
||||
|
||||
function scrollToLibraryTool(anchorId, replace = false) {
|
||||
function getLibraryAnchorOffset() {
|
||||
const topbar = document.querySelector(".topbar");
|
||||
const topbarHeight = topbar?.getBoundingClientRect?.().height || 0;
|
||||
return topbarHeight + 16;
|
||||
}
|
||||
|
||||
function scrollToLibraryTool(anchorId, replace = false, behavior = "smooth") {
|
||||
if (!anchorId) return;
|
||||
if (replace) history.replaceState(null, "", `#/library#${encodeURIComponent(anchorId)}`);
|
||||
LIBRARY_ANCHOR_RETRY_DELAYS.forEach((delay, index) => {
|
||||
window.setTimeout(() => {
|
||||
const element = document.getElementById(anchorId);
|
||||
if (!element) return;
|
||||
if (replace) history.replaceState(null, "", `#/library#${encodeURIComponent(anchorId)}`);
|
||||
const top = element.getBoundingClientRect().top + window.scrollY - 24;
|
||||
window.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
|
||||
const top = element.getBoundingClientRect().top + window.scrollY - getLibraryAnchorOffset();
|
||||
window.scrollTo({ top: Math.max(0, top), behavior: index === 0 ? behavior : "auto" });
|
||||
}, delay);
|
||||
});
|
||||
}
|
||||
|
||||
function findCategoryForAnchor(categories, anchorId) {
|
||||
|
|
@ -149,7 +161,7 @@ export function LibraryPage({ siteContent, actions }) {
|
|||
const category = findCategoryForAnchor(libraryCategories, anchorId) || libraryCategories[0];
|
||||
setActiveCategoryKey(category.key);
|
||||
if (!anchorId) return;
|
||||
requestAnimationFrame(() => scrollToLibraryTool(anchorId));
|
||||
requestAnimationFrame(() => scrollToLibraryTool(anchorId, false, "auto"));
|
||||
}, [libraryPayload, libraryCategories]);
|
||||
|
||||
const moduleContext = useMemo(() => ({
|
||||
|
|
|
|||
|
|
@ -179,11 +179,29 @@
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.rng-simulator-history-score small,
|
||||
.rng-simulator-history-rate small {
|
||||
overflow: hidden;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rng-simulator-active-summary small,
|
||||
.rng-simulator-table > span:nth-child(-n + 3),
|
||||
.rng-simulator-table-heading {
|
||||
overflow: hidden;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rng-simulator-history-metrics small {
|
||||
overflow: hidden;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--color-accent-gold);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
|
|
@ -191,7 +209,7 @@
|
|||
}
|
||||
|
||||
.rng-simulator-stat span {
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--color-accent-gold);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
|
|
@ -203,11 +221,19 @@
|
|||
.rng-simulator-history-row b,
|
||||
.rng-simulator-history-rate > span,
|
||||
.rng-simulator-history-metrics b {
|
||||
color: var(--color-accent-gold);
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 900;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rng-simulator-history-rate > span {
|
||||
color: var(--color-accent-gold);
|
||||
}
|
||||
|
||||
.rng-simulator-table .rng-simulator-chance-value {
|
||||
color: var(--color-accent-gold);
|
||||
}
|
||||
|
||||
.rng-simulator-active-summary strong {
|
||||
color: var(--color-accent-gold);
|
||||
font-size: 24px;
|
||||
|
|
@ -222,7 +248,7 @@
|
|||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--color-accent-gold);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
|
@ -316,7 +342,7 @@
|
|||
}
|
||||
|
||||
.rng-simulator-percent-input > span {
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--color-accent-gold);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
|
@ -331,14 +357,28 @@
|
|||
|
||||
.rng-simulator-time-results {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rng-simulator-stat-grid,
|
||||
.rng-simulator-duration-grid,
|
||||
.rng-simulator-tracking-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rng-simulator-stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rng-simulator-duration-grid,
|
||||
.rng-simulator-tracking-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.rng-simulator-tracking-control {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.rng-simulator-stat strong {
|
||||
|
|
@ -347,6 +387,14 @@
|
|||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.rng-simulator-stat[data-tone="positive"] strong {
|
||||
color: var(--color-accent-cyan);
|
||||
}
|
||||
|
||||
.rng-simulator-stat[data-tone="negative"] strong {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.rng-simulator-table {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
|
|
@ -374,6 +422,10 @@
|
|||
grid-template-columns: minmax(88px, 0.6fr) minmax(140px, 1fr) minmax(120px, 1fr);
|
||||
}
|
||||
|
||||
.rng-simulator-tracking-target-table {
|
||||
grid-template-columns: minmax(88px, 0.55fr) minmax(120px, 0.8fr) minmax(140px, 1fr) minmax(120px, 1fr);
|
||||
}
|
||||
|
||||
.rng-simulator-simulation-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
|
@ -478,6 +530,18 @@
|
|||
line-height: 1;
|
||||
}
|
||||
|
||||
.rng-simulator-history-identity[data-tone="positive"] small,
|
||||
.rng-simulator-history-metrics > span[data-tone="positive"] b,
|
||||
.rng-simulator-history-score[data-tone="positive"] b {
|
||||
color: var(--color-accent-cyan);
|
||||
}
|
||||
|
||||
.rng-simulator-history-identity[data-tone="negative"] small,
|
||||
.rng-simulator-history-metrics > span[data-tone="negative"] b,
|
||||
.rng-simulator-history-score[data-tone="negative"] b {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.rng-simulator-history-rate {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
|
|
@ -513,10 +577,13 @@
|
|||
.rng-simulator-active-summary,
|
||||
.rng-simulator-primary-controls,
|
||||
.rng-simulator-stat-grid,
|
||||
.rng-simulator-duration-grid,
|
||||
.rng-simulator-tracking-grid,
|
||||
.rng-simulator-simulation-summary,
|
||||
.rng-simulator-occurrence-control,
|
||||
.rng-simulator-time-results,
|
||||
.rng-simulator-target-table {
|
||||
.rng-simulator-target-table,
|
||||
.rng-simulator-tracking-target-table {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue