sokko-g/website/src/features/toolboxes/modules/RngSimulatorModule.jsx
Shinuwa 7c0b72f914
Some checks failed
Deploy Sokko G / deploy (push) Failing after 3s
Initialize session state by page and module identity
2026-09-11 14:05:41 +02:00

738 lines
36 KiB
JavaScript

// Rôle : fournit le simulateur de chance pour calculer et simuler des taux enregistrés.
import { useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { Tabs } from "../../../components/Tabs.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { useKeyedState } from "../../../hooks/useKeyedState.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,
getEffectiveRate,
isValidRateString,
normalizeCostString,
normalizeRateString,
simulateRngDraws
} from "./rngSimulatorUtils.js";
const CHANCE_TARGETS = [50, 90, 95, 99];
const RNG_TABS = [
{ id: "tracking", label: "Suivi" },
{ id: "forecasts", label: "Prévisions" },
{ id: "simulation", label: "Simulation" }
];
function formatNumber(value, maximumFractionDigits = 2) {
if (!Number.isFinite(value)) return "—";
return value.toLocaleString("fr-FR", { maximumFractionDigits });
}
function formatPercent(value, maximumFractionDigits = 6) {
if (!Number.isFinite(value)) return "—";
return `${value.toLocaleString("fr-FR", { maximumFractionDigits })} %`;
}
function formatCost(value) {
if (!Number.isFinite(value)) return "—";
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) {
return `${value.toLocaleString("fr-FR", { notation: "compact", maximumFractionDigits: 2 })} ${unit}`;
}
return `${formatNumber(value, 0)} ${unit}`;
}
function formatSignedPercent(value, maximumFractionDigits = 2) {
if (!Number.isFinite(value)) return "0 %";
const prefix = value > 0 ? "+" : "";
return `${prefix}${formatNumber(value, maximumFractionDigits)} %`;
}
function formatTime(value, unit) {
if (!Number.isFinite(value) || value <= 0) return "—";
return `${formatNumber(value, 2)} ${unit}`;
}
function getSessionStorage() {
if (typeof window === "undefined") return null;
try {
return window.sessionStorage;
} catch {
return null;
}
}
function readSimulationHistory(storageKey) {
const storage = getSessionStorage();
if (!storage) return [];
try {
const parsed = JSON.parse(storage.getItem(storageKey) || "[]");
return Array.isArray(parsed) ? parsed.map(normalizeSessionHistoryEntry).filter(Boolean).slice(0, RNG_SIMULATOR_HISTORY_LIMIT) : [];
} catch {
return [];
}
}
function normalizeSessionHistoryEntry(entry, index) {
const draws = clampInteger(entry?.draws, RNG_SIMULATOR_LIMITS.simulationDrawsMin, RNG_SIMULATOR_LIMITS.simulationDrawsMax, 0);
if (!draws) return null;
const successes = clampInteger(entry?.successes, 0, draws, 0);
return {
id: entry?.id || `rng-session-${index}`,
rateId: String(entry?.rateId || "").trim(),
label: String(entry?.label || "").trim() || "Simulation",
rate: normalizeRateString(entry?.rate),
costPerDraw: normalizeCostString(entry?.costPerDraw),
multiplier: clampDecimal(entry?.multiplier, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0),
draws,
successes,
fails: draws - successes,
observedRate: draws > 0 ? (successes / draws) * 100 : 0,
longestFailStreak: clampInteger(entry?.longestFailStreak, 0, draws, 0),
longestSuccessStreak: clampInteger(entry?.longestSuccessStreak, 0, draws, 0),
firstSuccessDraw: clampInteger(entry?.firstSuccessDraw, 0, draws, 0)
};
}
function writeSimulationHistory(storageKey, history) {
const storage = getSessionStorage();
if (!storage) return;
try {
storage.setItem(storageKey, JSON.stringify(history.slice(0, RNG_SIMULATOR_HISTORY_LIMIT)));
} catch {
// L'historique de session est opportuniste; l'outil reste utilisable sans stockage.
}
}
function historyEntryMatchesRate(entry, rate) {
if (!entry || !rate) return false;
if (entry.rateId) return entry.rateId === rate.id;
return entry.label === rate.label && entry.rate === rate.rate;
}
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 rawData = context.getModuleData(toolboxId, moduleId, {});
const data = useMemo(() => context.normalizeRngSimulatorData(rawData), [context, rawData]);
const textContent = context.moduleText?.rngSimulator || {};
const tabs = useMemo(() => [
{ ...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("tracking");
const [history, setHistory] = useKeyedState(historyStorageKey, readSimulationHistory);
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 });
const rateFormOpen = editing || Boolean(editingRateId);
const activeRate = data.rates.find((rate) => rate.id === data.activeRateId) || data.rates[0] || null;
const summary = useMemo(() => activeRate ? calculateRngSummary({
rate: activeRate.rate,
multiplier: activeRate.multiplier,
simulationDraws: data.settings.simulationDraws,
occurrenceValue: activeRate.occurrenceValue,
occurrenceUnit: activeRate.occurrenceUnit
}) : null, [activeRate, data.settings.simulationDraws]);
const costSummary = useMemo(() => activeRate && summary ? calculateRngCostSummary({
costPerDraw: costDraft ?? activeRate.costPerDraw,
averageDraws: summary.averageDraws,
drawsForChance: summary.drawsForChance,
simulationDraws: data.settings.simulationDraws,
simulationSuccesses: 0
}) : null, [activeRate, costDraft, data.settings.simulationDraws, summary]);
const activeHistory = useMemo(() => history.filter((entry) => historyEntryMatchesRate(entry, activeRate)), [activeRate, history]);
const chanceWithoutMultiplier = activeRate ? calculateAtLeastOneProbability(activeRate.rate, data.settings.simulationDraws, 0) : 0;
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(() => {
const wasEditing = previousEditingRef.current;
if (wasEditing && !editing) setEditingRateId("");
if (!wasEditing && editing && !editingRateId) rateDraft.reset({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
previousEditingRef.current = editing;
// Le hook useDraftForm expose reset sans identité stable; cette transition ne doit dépendre que de l'ouverture.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editing, editingRateId]);
useEffect(() => {
if (!rateFormOpen) return undefined;
function closeOnOutsidePointer(event) {
if (event.target.closest?.(".module-edit-button")) return;
if (!rateFormRef.current?.contains(event.target)) {
setEditingRateId("");
setEditing(false);
}
}
document.addEventListener("pointerdown", closeOnOutsidePointer);
return () => document.removeEventListener("pointerdown", closeOnOutsidePointer);
}, [rateFormOpen, setEditing]);
useEffect(() => {
setMultiplierDraft(null);
setCostDraft(null);
setOccurrenceDraft(null);
setRealDrawsDraft(null);
setOccurrenceUnit(activeRate?.occurrenceUnit || "perHour");
}, [activeRate?.id, activeRate?.occurrenceUnit]);
function save(nextData) {
context.setModuleData(toolboxId, moduleId, nextData, "rngSimulator");
}
function updateSettings(field, value) {
const settings = { ...data.settings };
if (field === "simulationDraws") {
settings[field] = clampInteger(value, RNG_SIMULATOR_LIMITS.simulationDrawsMin, RNG_SIMULATOR_LIMITS.simulationDrawsMax, data.settings.simulationDraws);
}
save({ ...data, settings });
}
function updateActiveRateSettings(field, value) {
if (!activeRate) return;
const nextValue = field === "multiplier"
? clampDecimal(value, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0)
: clampDecimal(value, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, activeRate[field]);
save({
...data,
rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, [field]: nextValue } : rate)
});
}
function updateMultiplier(value) {
setMultiplierDraft(value);
if (String(value).trim() === "") {
updateActiveRateSettings("multiplier", 0);
return;
}
updateActiveRateSettings("multiplier", value);
}
function blurMultiplier() {
if (multiplierDraft === null) return;
const raw = multiplierDraft.trim();
const parsed = Number(raw.replace(",", "."));
if (!raw || !Number.isFinite(parsed)) updateActiveRateSettings("multiplier", 0);
setMultiplierDraft(null);
}
function updateCost(value) {
setCostDraft(value);
}
function blurCost() {
if (!activeRate || costDraft === null) return;
save({
...data,
rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, costPerDraw: normalizeCostString(costDraft) } : rate)
});
setCostDraft(null);
}
function updateOccurrence(value) {
setOccurrenceDraft(value);
if (!activeRate) return;
if (String(value).trim() === "") {
save({
...data,
rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, occurrenceValue: 0, occurrenceUnit } : rate)
});
return;
}
const nextValue = clampDecimal(value, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, activeRate.occurrenceValue);
save({
...data,
rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, occurrenceValue: nextValue, occurrenceUnit } : rate)
});
}
function changeOccurrenceUnit(unit) {
setOccurrenceUnit(unit);
setOccurrenceDraft(null);
if (!activeRate) return;
save({
...data,
rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, occurrenceValue: activeRate.occurrenceValue, occurrenceUnit: unit } : rate)
});
}
function blurOccurrence() {
if (occurrenceDraft === null) return;
const value = occurrenceDraft;
const raw = value.trim();
const parsed = Number(raw.replace(",", "."));
if (!raw || !Number.isFinite(parsed)) updateOccurrence("");
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();
if (!label) return textContent.rateLabelRequired || "Libellé requis.";
if (!isValidRateString(draft.rate)) return textContent.rateInvalid || "Taux invalide.";
const rate = normalizeRateString(draft.rate);
if (editingRateId) {
save({ ...data, rates: data.rates.map((item) => item.id === editingRateId ? { ...item, label, rate } : item) });
setEditingRateId("");
} else {
const id = context.uid("rng");
save({ ...data, activeRateId: id, rates: [...data.rates, { id, label, rate }] });
}
reset({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
setEditing(false);
return "";
});
}
function editRate(rate) {
setEditingRateId(rate.id);
setEditing(true);
rateDraft.reset({ label: rate.label, rate: rate.rate });
}
function cancelEdit() {
setEditingRateId("");
rateDraft.reset({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
setEditing(false);
}
function selectRate(rate) {
save({ ...data, activeRateId: rate.id });
}
function deleteRate(rateId) {
const rates = data.rates.filter((rate) => rate.id !== rateId);
save({ ...data, rates, activeRateId: data.activeRateId === rateId ? rates[0]?.id || "" : data.activeRateId });
if (editingRateId === rateId) cancelEdit();
}
function runSimulation() {
if (!activeRate) return;
const result = simulateRngDraws(activeRate.rate, data.settings.simulationDraws, Math.random, activeRate.multiplier);
const entry = {
id: context.uid("rng"),
rateId: activeRate.id,
label: activeRate.label,
rate: activeRate.rate,
costPerDraw: activeRate.costPerDraw,
multiplier: activeRate.multiplier,
...result
};
const nextHistory = addRngSimulationHistory(history, entry);
setHistory(nextHistory);
writeSimulationHistory(historyStorageKey, nextHistory);
}
function clearHistory() {
const nextHistory = activeRate ? history.filter((entry) => !historyEntryMatchesRate(entry, activeRate)) : [];
setHistory(nextHistory);
writeSimulationHistory(historyStorageKey, nextHistory);
}
return (
<>
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="rngSimulator" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "rngSimulator")} />
</div>
)}
<div className="rng-simulator-tool">
<section className="rng-simulator-panel rng-simulator-rates-panel">
<header>
<div>
<h3>{textContent.ratesTitle || "Taux enregistrés"}</h3>
<span className="rng-simulator-count">{data.rates.length}</span>
</div>
</header>
<div className="rng-simulator-rate-layout">
{rateFormOpen && (
<form className="rng-simulator-rate-form" ref={rateFormRef} onSubmit={submitRate}>
<label className="rng-simulator-field">
<span>{textContent.labelLabel || "Libellé"}</span>
<input {...rateDraft.getFieldProps("label", { className: "nds-input", "data-size": "sm", type: "text", placeholder: textContent.labelPlaceholder || "Drop rare" })} />
</label>
<label className="rng-simulator-field">
<span>{textContent.rateLabel || "Taux (%)"}</span>
<input {...rateDraft.getFieldProps("rate", { className: "nds-input", "data-size": "sm", type: "text", inputMode: "decimal", placeholder: RNG_SIMULATOR_DEFAULT_RATE })} />
</label>
{rateDraft.error && <p className="rng-simulator-error">{rateDraft.error}</p>}
<div className="rng-simulator-form-actions">
<button className="nds-button" data-size="sm" data-variant="primary" type="submit">
<Icon name={editingRateId ? "save" : "add"} />
<span>{editingRateId ? textContent.saveRateButton || "Enregistrer" : textContent.addRateButton || "Ajouter"}</span>
</button>
{data.rates.length > 0 && (
<button className="nds-button" data-size="sm" type="button" onClick={cancelEdit}>
{textContent.cancelButton || "Annuler"}
</button>
)}
</div>
</form>
)}
<div className="rng-simulator-rates nds-scrollbox">
{data.rates.length ? data.rates.map((rate) => (
<article className={`rng-simulator-rate-row ${rate.id === activeRate?.id ? "is-active" : ""}`} key={rate.id}>
<button type="button" onClick={() => selectRate(rate)}>
<strong>{rate.label}</strong>
<span>{rate.rate} %</span>
</button>
<div className="rng-simulator-rate-actions">
<button className="nds-icon-button" data-size="xs" type="button" onClick={() => editRate(rate)} aria-label={`${textContent.editRateTitle || "Modifier"} ${rate.label}`} title={textContent.editRateTitle || "Modifier"}>
<Icon name="edit" />
</button>
<button className="nds-icon-button" data-size="xs" data-variant="danger" type="button" onClick={() => deleteRate(rate.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${rate.label}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
</article>
)) : <p className="rng-simulator-empty">{textContent.emptyRates || "Aucun taux enregistré."}</p>}
</div>
{activeRate && summary && (
<div className="rng-simulator-active-summary">
<RngInfo label={textContent.activeRateLabel || "Taux brut"} value={`${activeRate.rate} %`} />
</div>
)}
</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 === "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="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>
</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")} />
</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 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} />
))}
</div>
</div>
</>
) : <p className="rng-simulator-empty">{textContent.emptySummary || "Ajoutez un taux pour afficher les calculs."}</p>}
</div>
)}
{activeTab === "simulation" && (
<div className="rng-simulator-tab-content">
<div className="rng-simulator-simulation-overview">
<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} 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" />
<span>{textContent.simulateButton || "Simuler"}</span>
</button>
</div>
{activeRate && summary && (
<div className="rng-simulator-simulation-summary">
<RngStat label={textContent.simulationChanceLabel || "Chance sur les tirages saisis"} value={formatPercent(summary.chanceForSimulationDraws * 100, 6)} />
<RngStat label={textContent.simulationChanceWithoutMultiplierLabel || "Chance sur les tirages saisis sans multiplicateur"} value={formatPercent(chanceWithoutMultiplier * 100, 6)} />
</div>
)}
</div>
<div className="rng-simulator-history-toolbar">
<button className="nds-button" data-size="sm" data-variant="danger" type="button" onClick={clearHistory} disabled={!activeHistory.length}>
<Icon name="rubber" />
<span>{textContent.clearHistoryButton || "Supprimer les simulations"}</span>
</button>
</div>
<div className="rng-simulator-history nds-scrollbox">
{activeHistory.length ? activeHistory.map((entry, index) => (
<RngHistoryRow entry={entry} index={index} key={entry.id} textContent={textContent} />
)) : <p className="rng-simulator-empty">{textContent.emptyHistory || "Aucune simulation."}</p>}
</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>
</>
);
}
function RngInfo({ label, value }) {
return (
<span>
<small>{label}</small>
<strong>{value}</strong>
</span>
);
}
function RngStat({ label, tone = "", value, title }) {
return (
<article className="rng-simulator-stat" data-tone={tone || undefined} title={title}>
<span>{label}</span>
<strong>{value}</strong>
</article>
);
}
function RngTargetRow({ costSummary, draws, target, textContent }) {
return (
<>
<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
? textContent.unluckySimulationLabel || "Sous l'attendu"
: textContent.neutralSimulationLabel || "Dans la cible";
const costs = calculateRngCostSummary({
costPerDraw: entry.costPerDraw,
averageDraws: 0,
drawsForChance: {},
simulationDraws: entry.draws,
simulationSuccesses: entry.successes,
firstSuccessDraw: entry.firstSuccessDraw
});
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" data-tone={successTone || undefined}>
<strong>{entry.label}</strong>
<small>{luckLabel} · {formatSignedPercent((successDelta / entry.draws) * 100, 4)}</small>
</div>
<div className="rng-simulator-history-score" data-tone={entry.successes > 0 ? "positive" : undefined}>
<b>{entry.successes}</b>
<small>{textContent.successesLabel || "Réussites"}</small>
</div>
<div className="rng-simulator-history-rate">
<span>{entry.rate} %</span>
<small>{textContent.multiplierShortLabel || "Bonus"} {formatSignedPercent(entry.multiplier ?? 0)}</small>
</div>
</div>
<div className="rng-simulator-history-metrics">
<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>
);
}