Add chance simulator toolbox and refine checklist interactions
All checks were successful
Deploy Sokko G / deploy (push) Successful in 17s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 17s
This commit is contained in:
parent
35fe877d58
commit
6b85e58765
25 changed files with 1518 additions and 18 deletions
407
website/src/features/toolboxes/modules/RngSimulatorModule.jsx
Normal file
407
website/src/features/toolboxes/modules/RngSimulatorModule.jsx
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
// Rôle : fournit le simulateur de chance pour calculer et simuler des taux enregistrés.
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
import {
|
||||
RNG_SIMULATOR_DEFAULT_RATE,
|
||||
RNG_SIMULATOR_HISTORY_LIMIT,
|
||||
RNG_SIMULATOR_LIMITS,
|
||||
addRngSimulationHistory,
|
||||
calculateRngSummary,
|
||||
clampDecimal,
|
||||
clampInteger,
|
||||
isValidRateString,
|
||||
normalizeRateString,
|
||||
simulateRngDraws
|
||||
} from "./rngSimulatorUtils.js";
|
||||
|
||||
const CHANCE_TARGETS = [50, 90, 95, 99];
|
||||
|
||||
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 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}`,
|
||||
label: String(entry?.label || "").trim() || "Simulation",
|
||||
rate: normalizeRateString(entry?.rate),
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeOpen }) {
|
||||
const data = context.normalizeRngSimulatorData(context.getModuleData(toolboxId, moduleId, {}));
|
||||
const textContent = context.moduleText?.rngSimulator || {};
|
||||
const historyStorageKey = `rng-simulator:${toolboxId}:${moduleId}:history`;
|
||||
const [history, setHistory] = useState(() => readSimulationHistory(historyStorageKey));
|
||||
const [editingRateId, setEditingRateId] = useState("");
|
||||
const [multiplierDraft, setMultiplierDraft] = useState(null);
|
||||
const [occurrenceDrafts, setOccurrenceDrafts] = useState({ perHour: null, perDay: null, perMonth: null });
|
||||
const rateDraft = useDraftForm({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
|
||||
const activeRate = data.rates.find((rate) => rate.id === data.activeRateId) || data.rates[0] || null;
|
||||
const summary = useMemo(() => activeRate ? calculateRngSummary({ rate: activeRate.rate, ...data.settings }) : null, [activeRate, data.settings]);
|
||||
|
||||
useEffect(() => {
|
||||
setHistory(readSimulationHistory(historyStorageKey));
|
||||
}, [historyStorageKey]);
|
||||
|
||||
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);
|
||||
} else if (field === "multiplier") {
|
||||
settings[field] = clampDecimal(value, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0);
|
||||
} else {
|
||||
settings[field] = clampDecimal(value, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, data.settings[field]);
|
||||
}
|
||||
save({ ...data, settings });
|
||||
}
|
||||
|
||||
function updateMultiplier(value) {
|
||||
setMultiplierDraft(value);
|
||||
if (String(value).trim() === "") {
|
||||
updateSettings("multiplier", 0);
|
||||
return;
|
||||
}
|
||||
updateSettings("multiplier", value);
|
||||
}
|
||||
|
||||
function blurMultiplier() {
|
||||
if (multiplierDraft === null) return;
|
||||
const raw = multiplierDraft.trim();
|
||||
const parsed = Number(raw.replace(",", "."));
|
||||
if (!raw || !Number.isFinite(parsed)) {
|
||||
setMultiplierDraft(null);
|
||||
updateSettings("multiplier", 0);
|
||||
return;
|
||||
}
|
||||
setMultiplierDraft(null);
|
||||
}
|
||||
|
||||
function clearMultiplier() {
|
||||
setMultiplierDraft(null);
|
||||
updateSettings("multiplier", 0);
|
||||
}
|
||||
|
||||
function updateOccurrence(field, value) {
|
||||
setOccurrenceDrafts((drafts) => ({ ...drafts, [field]: value }));
|
||||
if (String(value).trim() === "") {
|
||||
updateSettings(field, 0);
|
||||
return;
|
||||
}
|
||||
updateSettings(field, value);
|
||||
}
|
||||
|
||||
function blurOccurrence(field) {
|
||||
const value = occurrenceDrafts[field];
|
||||
if (value === null) return;
|
||||
const raw = value.trim();
|
||||
const parsed = Number(raw.replace(",", "."));
|
||||
if (!raw || !Number.isFinite(parsed)) updateSettings(field, 0);
|
||||
setOccurrenceDrafts((drafts) => ({ ...drafts, [field]: 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: data.activeRateId || id, rates: [...data.rates, { id, label, rate }] });
|
||||
}
|
||||
reset({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
|
||||
return "";
|
||||
});
|
||||
}
|
||||
|
||||
function editRate(rate) {
|
||||
save({ ...data, activeRateId: rate.id });
|
||||
setEditingRateId(rate.id);
|
||||
rateDraft.reset({ label: rate.label, rate: rate.rate });
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditingRateId("");
|
||||
rateDraft.reset({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
|
||||
}
|
||||
|
||||
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, data.settings.multiplier);
|
||||
const entry = {
|
||||
id: context.uid("rng"),
|
||||
label: activeRate.label,
|
||||
rate: activeRate.rate,
|
||||
multiplier: data.settings.multiplier,
|
||||
...result
|
||||
};
|
||||
const nextHistory = addRngSimulationHistory(history, entry);
|
||||
setHistory(nextHistory);
|
||||
writeSimulationHistory(historyStorageKey, nextHistory);
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
setHistory([]);
|
||||
writeSimulationHistory(historyStorageKey, []);
|
||||
}
|
||||
|
||||
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-grid">
|
||||
<div className="rng-simulator-rates-stack">
|
||||
<div className="rng-simulator-panel rng-simulator-rate-add-panel">
|
||||
<header>
|
||||
<div>
|
||||
<h3>{editingRateId ? textContent.editRateTitle || "Modifier un taux" : textContent.addRateTitle || "Ajouter un taux"}</h3>
|
||||
</div>
|
||||
</header>
|
||||
<form className="rng-simulator-rate-form" 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>
|
||||
{editingRateId && (
|
||||
<button className="nds-button" data-size="sm" type="button" onClick={cancelEdit}>
|
||||
{textContent.cancelButton || "Annuler"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="rng-simulator-panel rng-simulator-rates-panel">
|
||||
<header>
|
||||
<div>
|
||||
<h3>{textContent.ratesTitle || "Taux enregistrés"}</h3>
|
||||
</div>
|
||||
<span className="rng-simulator-count">{data.rates.length}</span>
|
||||
</header>
|
||||
<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={() => editRate(rate)}>
|
||||
<strong>{rate.label}</strong>
|
||||
</button>
|
||||
<div className="rng-simulator-rate-actions">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rng-simulator-panel rng-simulator-summary-panel">
|
||||
<header>
|
||||
<div>
|
||||
<h3>{activeRate?.label || textContent.noActiveRateTitle || "Aucun taux actif"}</h3>
|
||||
</div>
|
||||
{activeRate && <span className="rng-simulator-active-rate">{activeRate.rate} %</span>}
|
||||
</header>
|
||||
{activeRate && summary ? (
|
||||
<>
|
||||
<div className="rng-simulator-section">
|
||||
<span className="rng-simulator-section-title">{textContent.probabilitySectionTitle || "Probabilités avec multiplicateur"}</span>
|
||||
<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(data.settings.multiplier ?? 0)} onChange={(event) => updateMultiplier(event.target.value)} onBlur={blurMultiplier} aria-label={textContent.multiplierLabel || "Multiplicateur"} />
|
||||
<span>%</span>
|
||||
<button className="nds-icon-button" data-size="xs" type="button" onClick={clearMultiplier} aria-label={textContent.clearMultiplierButton || "Réinitialiser le multiplicateur"} title={textContent.clearMultiplierButton || "Réinitialiser le multiplicateur"}>
|
||||
<Icon name="rubber" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rng-simulator-stat-grid">
|
||||
<RngStat label={textContent.averageDrawsLabel || "Moyenne avant réussite" } value={`${formatNumber(summary.averageDraws, 2)} ${textContent.drawsUnit || "tirages"}`} />
|
||||
<RngStat label={textContent.simulationChanceLabel || "Chance sur les tirages saisis"} value={formatPercent(summary.chanceForSimulationDraws * 100, 6)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rng-simulator-section">
|
||||
<span className="rng-simulator-section-title">{textContent.averageTimeTitle || "Temps moyen avant réussite"}</span>
|
||||
<span className="rng-simulator-subtitle">{textContent.occurrenceCountLabel || "Nombre d'occurrences"}</span>
|
||||
<div className="rng-simulator-settings rng-simulator-three-columns">
|
||||
<label className="rng-simulator-field">
|
||||
<span>{textContent.perHourLabel || "Par heure"}</span>
|
||||
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDrafts.perHour ?? String(data.settings.perHour ?? 0)} onChange={(event) => updateOccurrence("perHour", event.target.value)} onBlur={() => blurOccurrence("perHour")} />
|
||||
</label>
|
||||
<label className="rng-simulator-field">
|
||||
<span>{textContent.perDayLabel || "Par jour"}</span>
|
||||
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDrafts.perDay ?? String(data.settings.perDay ?? 0)} onChange={(event) => updateOccurrence("perDay", event.target.value)} onBlur={() => blurOccurrence("perDay")} />
|
||||
</label>
|
||||
<label className="rng-simulator-field">
|
||||
<span>{textContent.perMonthLabel || "Par mois"}</span>
|
||||
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDrafts.perMonth ?? String(data.settings.perMonth ?? 0)} onChange={(event) => updateOccurrence("perMonth", event.target.value)} onBlur={() => blurOccurrence("perMonth")} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="rng-simulator-time-grid">
|
||||
<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.chanceTargetsTitle || "Tirages pour atteindre une chance cumulée"}</span>
|
||||
<div className="rng-simulator-targets">
|
||||
{CHANCE_TARGETS.map((target) => (
|
||||
<RngStat key={target} label={`${target} %`} value={formatDrawTarget(summary.drawsForChance[target], textContent.drawsUnit || "tirages")} title={`${formatNumber(summary.drawsForChance[target], 0)} ${textContent.drawsUnit || "tirages"}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : <p className="rng-simulator-empty">{textContent.emptySummary || "Ajoutez un taux pour afficher les calculs."}</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rng-simulator-panel rng-simulator-simulation-panel">
|
||||
<header>
|
||||
<div>
|
||||
<h3>{textContent.simulationTitle || "Résultats simulés"}</h3>
|
||||
</div>
|
||||
</header>
|
||||
<div className="rng-simulator-simulation-bar">
|
||||
<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)} />
|
||||
</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>
|
||||
<button className="nds-button" data-size="sm" data-variant="danger" type="button" onClick={clearHistory} disabled={!history.length}>
|
||||
<Icon name="rubber" />
|
||||
<span>{textContent.clearHistoryButton || "Supprimer les simulations"}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="rng-simulator-history nds-scrollbox">
|
||||
{history.length ? history.map((entry, index) => (
|
||||
<article className={`rng-simulator-history-row ${index === 0 ? "is-latest" : ""}`} key={entry.id}>
|
||||
<div>
|
||||
<strong>{entry.label}</strong>
|
||||
</div>
|
||||
<div className="rng-simulator-history-rate">
|
||||
<span>{entry.rate} %</span>
|
||||
<small>{textContent.multiplierShortLabel || "Bonus"} {formatSignedPercent(entry.multiplier ?? 0)}</small>
|
||||
</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.longestFailStreakLabel || "Pire série"}</small><b>{entry.longestFailStreak}</b></span>
|
||||
</div>
|
||||
</article>
|
||||
)) : <p className="rng-simulator-empty">{textContent.emptyHistory || "Aucune simulation."}</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RngStat({ label, value, title }) {
|
||||
return (
|
||||
<article className="rng-simulator-stat" title={title}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue