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

@ -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;
});
}