// 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 && (
{textContent.emptyRates || "Aucun taux enregistré."}
}{textContent.emptySummary || "Ajoutez un taux pour afficher les calculs."}
}{textContent.emptySummary || "Ajoutez un taux pour afficher les calculs."}
}{textContent.emptyHistory || "Aucune simulation."}
}{textContent.emptySummary || "Ajoutez un taux pour afficher les calculs."}
}