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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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