Refine RNG simulator layout and simulation stats
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-09-05 00:19:08 +02:00
parent 055fb78d60
commit f8231c748f
11 changed files with 825 additions and 394 deletions

View file

@ -4771,20 +4771,21 @@
{
"id": "fnn88zy",
"label": "Elten Piece",
"rate": "0.000004"
"rate": "0.000004",
"costPerDraw": "150",
"multiplier": 320,
"occurrenceValue": 60,
"occurrenceUnit": "perHour"
},
{
"id": "pen-eklecta",
"label": "Pen Eklecta",
"rate": "20.02"
"rate": "20.02",
"costPerDraw": "12.5"
}
],
"settings": {
"multiplier": 320,
"simulationDraws": 100000,
"perHour": 60,
"perDay": 0,
"perMonth": 0
"simulationDraws": 100000
}
},
"m9": {

View file

@ -498,8 +498,17 @@
"icon": "clover",
"title": "Enregistrer un taux",
"preview": {
"kind": "textButton",
"label": "%"
"kind": "iconButton",
"icon": "add"
}
},
{
"group": "Taux",
"icon": "edit",
"title": "Modifier un taux",
"preview": {
"kind": "iconButton",
"icon": "edit"
}
},
{
@ -524,26 +533,26 @@
"features": [
{
"title": "Taux sauvegardés",
"text": "Chaque taux possède un libellé et garde la précision saisie, même pour des probabilités très faibles.",
"text": "Chaque taux possède un libellé, un coût optionnel et garde la précision saisie, même pour des probabilités très faibles.",
"details": [
"La saisie par défaut utilise trois décimales, mais le stockage conserve la chaîne exacte normalisée.",
"Un taux comme 0.000004% reste disponible tel quel pour les calculs et les simulations.",
"La sélection active permet de comparer rapidement plusieurs drops ou chances de réussite."
"Le coût d'un tirage permet d'estimer le coût moyen ou simulé sans imposer d'unité."
]
},
{
"title": "Lecture probabiliste",
"text": "L'outil affiche la moyenne d'essais avant réussite, le temps moyen selon votre rythme et les paliers de chance cumulée.",
"text": "L'onglet Calculs affiche la moyenne d'essais avant réussite, le temps moyen selon votre rythme, les coûts et les paliers de chance cumulée.",
"details": [
"Les occurrences par heure, jour et mois sont indépendantes pour s'adapter à différents usages.",
"Les paliers 50%, 90%, 95% et 99% donnent un repère concret sur la variance.",
"La simulation agrège réussites, échecs et pire série d'échecs sans stocker chaque tirage."
"L'onglet Simulation agrège réussites, échecs, pire série d'échecs et coûts sans stocker chaque tirage."
]
}
],
"importFormat": {
"text": "Le format texte contient les sections [rates] et [settings]. Les taux sont exprimés en pourcentage.",
"example": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true | multiplier=320 | perHour=60 | perDay=0 | perMonth=0\n- Pen Eklecta | rate=20.02 | multiplier=0 | perHour=0 | perDay=0 | perMonth=0\n\n[settings]\nsimulationDraws: 1000"
"text": "Le format texte contient les sections [rates] et [settings]. Les taux sont exprimés en pourcentage et le coût d'un tirage reste optionnel.",
"example": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true | costPerDraw=150 | multiplier=320 | occurrenceValue=60 | occurrenceUnit=perHour\n- Pen Eklecta | rate=20.02 | costPerDraw=12.5 | multiplier=0\n\n[settings]\nsimulationDraws: 1000"
}
},
"images": {
@ -2106,6 +2115,8 @@
"labelLabel": "Libellé",
"labelPlaceholder": "Drop rare",
"rateLabel": "Taux (%)",
"costPerDrawLabel": "Coût d'un tirage",
"costPerDrawPlaceholder": "Optionnel",
"rateLabelRequired": "Libellé requis.",
"rateInvalid": "Taux invalide. Utilisez une valeur supérieure à 0 et inférieure ou égale à 100.",
"addRateButton": "Ajouter",
@ -2113,21 +2124,29 @@
"cancelButton": "Annuler",
"deleteTitle": "Supprimer",
"noActiveRateTitle": "Aucun taux actif",
"activeRateLabel": "Taux brut",
"effectiveRateLabel": "Taux final",
"statsTabLabel": "Calculs",
"simulationTabLabel": "Simulation",
"simulationDrawsLabel": "Tirages simulés",
"perHourLabel": "Par heure",
"perDayLabel": "Par jour",
"perMonthLabel": "Par mois",
"probabilitySectionTitle": "Probabilités avec multiplicateur",
"multiplierLabel": "Multiplicateur (%)",
"clearMultiplierButton": "Réinitialiser le multiplicateur",
"averageDrawsLabel": "Moyenne avant réussite après multiplicateur",
"simulationChanceLabel": "Chance sur les tirages saisis après multiplicateur",
"averageDrawsLabel": "Moyenne avant réussite",
"simulationChanceLabel": "Chance sur les tirages saisis",
"simulationChanceWithoutMultiplierLabel": "Chance sur les tirages saisis sans multiplicateur",
"averageCostLabel": "Coût moyen avant réussite",
"averageTimeTitle": "Temps moyen avant réussite",
"periodLabel": "Période",
"averageTimeLabel": "Temps moyen",
"occurrenceCountLabel": "Nombre d'occurrences",
"hourTimeLabel": "En heures",
"dayTimeLabel": "En jours",
"monthTimeLabel": "En mois",
"chanceTargetsTitle": "Tirages pour atteindre une chance cumulée",
"chanceLabel": "Chance",
"costLabel": "Coût",
"drawsUnit": "tirages",
"hoursUnit": "h",
"daysUnit": "j",
@ -2140,9 +2159,16 @@
"successesLabel": "Réussites",
"failsLabel": "Fails",
"observedRateLabel": "Taux de réussite",
"expectedSuccessesLabel": "Réussites attendues",
"multiplierShortLabel": "Bonus",
"longestFailStreakLabel": "Pire série",
"importPlaceholder": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true | multiplier=320 | perHour=60 | perDay=0 | perMonth=0\n- Pen Eklecta | rate=20.02 | multiplier=0 | perHour=0 | perDay=0 | perMonth=0\n\n[settings]\nsimulationDraws: 1000",
"longestFailStreakLabel": "Pire série d'échec",
"longestSuccessStreakLabel": "Réussite consécutive",
"firstSuccessCostLabel": "Coût de la 1ere réussite",
"costPerSuccessLabel": "Coût moyen/réussite",
"luckySimulationLabel": "Au-dessus",
"neutralSimulationLabel": "Dans la cible",
"unluckySimulationLabel": "Sous l'attendu",
"importPlaceholder": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true | costPerDraw=150 | multiplier=320 | occurrenceValue=60 | occurrenceUnit=perHour\n- Pen Eklecta | rate=20.02 | costPerDraw=12.5 | multiplier=0\n\n[settings]\nsimulationDraws: 1000",
"importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte",
"importModalTitle": "Importer un simulateur de chance",

View file

@ -1,6 +1,7 @@
// Rôle : fournit le simulateur de chance pour calculer et simuler des taux enregistrés.
import { useEffect, useMemo, useState } from "react";
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 { TextExchangeActions } from "./TextExchangeActions.jsx";
import {
@ -8,15 +9,23 @@ import {
RNG_SIMULATOR_HISTORY_LIMIT,
RNG_SIMULATOR_LIMITS,
addRngSimulationHistory,
calculateAtLeastOneProbability,
calculateRngCostSummary,
calculateRngSummary,
clampDecimal,
clampInteger,
getEffectiveRate,
isValidRateString,
normalizeCostString,
normalizeRateString,
simulateRngDraws
} from "./rngSimulatorUtils.js";
const CHANCE_TARGETS = [50, 90, 95, 99];
const RNG_TABS = [
{ id: "stats", label: "Calculs" },
{ id: "simulation", label: "Simulation" }
];
function formatNumber(value, maximumFractionDigits = 2) {
if (!Number.isFinite(value)) return "—";
@ -28,6 +37,11 @@ function formatPercent(value, maximumFractionDigits = 6) {
return `${value.toLocaleString("fr-FR", { maximumFractionDigits })} %`;
}
function formatCost(value) {
if (!Number.isFinite(value)) return "—";
return formatNumber(value, 0);
}
function formatDrawTarget(value, unit) {
if (!Number.isFinite(value)) return `— ${unit}`;
if (value >= 1000000) {
@ -73,14 +87,18 @@ function normalizeSessionHistoryEntry(entry, index) {
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)
longestFailStreak: clampInteger(entry?.longestFailStreak, 0, draws, 0),
longestSuccessStreak: clampInteger(entry?.longestSuccessStreak, 0, draws, 0),
firstSuccessDraw: clampInteger(entry?.firstSuccessDraw, 0, draws, 0)
};
}
@ -94,33 +112,81 @@ function writeSimulationHistory(storageKey, history) {
}
}
export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeOpen }) {
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;
}
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]);
const historyStorageKey = `rng-simulator:${toolboxId}:${moduleId}:history`;
const [activeTab, setActiveTab] = useState("stats");
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 [costDraft, setCostDraft] = useState(null);
const [occurrenceUnit, setOccurrenceUnit] = useState("perHour");
const [occurrenceDraft, setOccurrenceDraft] = 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,
perHour: activeRate.perHour,
perDay: activeRate.perDay,
perMonth: activeRate.perMonth
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;
useEffect(() => {
setHistory(readSimulationHistory(historyStorageKey));
}, [historyStorageKey]);
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);
setOccurrenceDrafts({ perHour: null, perDay: null, perMonth: null });
}, [activeRate?.id]);
setCostDraft(null);
setOccurrenceDraft(null);
setOccurrenceUnit(activeRate?.occurrenceUnit || "perHour");
}, [activeRate?.id, activeRate?.occurrenceUnit]);
function save(nextData) {
context.setModuleData(toolboxId, moduleId, nextData, "rngSimulator");
@ -158,35 +224,57 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
if (multiplierDraft === null) return;
const raw = multiplierDraft.trim();
const parsed = Number(raw.replace(",", "."));
if (!raw || !Number.isFinite(parsed)) {
setMultiplierDraft(null);
updateActiveRateSettings("multiplier", 0);
return;
}
if (!raw || !Number.isFinite(parsed)) updateActiveRateSettings("multiplier", 0);
setMultiplierDraft(null);
}
function clearMultiplier() {
setMultiplierDraft(null);
updateActiveRateSettings("multiplier", 0);
function updateCost(value) {
setCostDraft(value);
}
function updateOccurrence(field, value) {
setOccurrenceDrafts((drafts) => ({ ...drafts, [field]: 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() === "") {
updateActiveRateSettings(field, 0);
save({
...data,
rates: data.rates.map((rate) => rate.id === activeRate.id ? { ...rate, occurrenceValue: 0, occurrenceUnit } : rate)
});
return;
}
updateActiveRateSettings(field, value);
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 blurOccurrence(field) {
const value = occurrenceDrafts[field];
if (value === null) return;
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)) updateActiveRateSettings(field, 0);
setOccurrenceDrafts((drafts) => ({ ...drafts, [field]: null }));
if (!raw || !Number.isFinite(parsed)) updateOccurrence("");
setOccurrenceDraft(null);
}
function submitRate(event) {
@ -200,22 +288,28 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
setEditingRateId("");
} else {
const id = context.uid("rng");
save({ ...data, activeRateId: data.activeRateId || id, rates: [...data.rates, { id, label, rate }] });
save({ ...data, activeRateId: id, rates: [...data.rates, { id, label, rate }] });
}
reset({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
setEditing(false);
return "";
});
}
function editRate(rate) {
save({ ...data, activeRateId: rate.id });
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) {
@ -229,8 +323,10 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
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
};
@ -240,8 +336,9 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
}
function clearHistory() {
setHistory([]);
writeSimulationHistory(historyStorageKey, []);
const nextHistory = activeRate ? history.filter((entry) => !historyEntryMatchesRate(entry, activeRate)) : [];
setHistory(nextHistory);
writeSimulationHistory(historyStorageKey, nextHistory);
}
return (
@ -252,170 +349,188 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeO
</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>
<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>
<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>
</header>
<div className="rng-simulator-panel rng-simulator-summary-panel">
<header>
<div>
<h3>{activeRate?.label || textContent.noActiveRateTitle || "Aucun taux actif"}</h3>
<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>
{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(activeRate.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(activeRate.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(activeRate.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(activeRate.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">
<section className="rng-simulator-panel rng-simulator-tabs-panel">
<header>
<div>
<h3>{textContent.simulationTitle || "Résultats simulés"}</h3>
</div>
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="rng-simulator-tabs" />
</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>
{activeTab === "stats" && (
<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} />
</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.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>
{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>
<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>
)}
{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} 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>
<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>
{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>
)}
</section>
</div>
</>
);
}
function RngInfo({ label, value }) {
return (
<span>
<small>{label}</small>
<strong>{value}</strong>
</span>
);
}
function RngStat({ label, value, title }) {
return (
<article className="rng-simulator-stat" title={title}>
@ -424,3 +539,59 @@ function RngStat({ label, value, title }) {
</article>
);
}
function RngTargetRow({ costSummary, draws, target, textContent }) {
return (
<>
<strong>{target} %</strong>
<span title={`${formatNumber(draws, 0)} ${textContent.drawsUnit || "tirages"}`}>{formatDrawTarget(draws, textContent.drawsUnit || "tirages")}</span>
<span>{formatCost(costSummary?.targetCosts?.[target])}</span>
</>
);
}
function RngHistoryRow({ entry, index, textContent }) {
const expectedSuccesses = entry.draws * (getEffectiveRate(entry.rate, entry.multiplier) / 100);
const successDelta = entry.successes - expectedSuccesses;
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
});
return (
<article className={`rng-simulator-history-row ${index === 0 ? "is-latest" : ""}`}>
<div className="rng-simulator-history-heading">
<div className="rng-simulator-history-identity">
<strong>{entry.label}</strong>
<small>{luckLabel} · {formatSignedPercent((successDelta / entry.draws) * 100, 4)}</small>
</div>
<div className="rng-simulator-history-score">
<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">
<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>
</div>
</article>
);
}

View file

@ -3,6 +3,7 @@ export const RNG_SIMULATOR_HISTORY_LIMIT = 20;
export const RNG_SIMULATOR_DEFAULT_RATE = "2.500";
export const RNG_SIMULATOR_LIMITS = {
rateMax: 100,
costPerDrawMax: 1000000000000,
multiplierMin: -1000000,
multiplierMax: 1000000,
simulationDrawsMin: 1,
@ -11,6 +12,8 @@ export const RNG_SIMULATOR_LIMITS = {
occurrenceRateMax: 1000000000
};
export const RNG_SIMULATOR_OCCURRENCE_UNITS = ["perHour", "perDay", "perMonth"];
export function clampInteger(value, min, max, fallback = min) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) return fallback;
@ -40,6 +43,21 @@ export function normalizeRateString(value, fallback = RNG_SIMULATOR_DEFAULT_RATE
return cleanInteger;
}
export function normalizeCostString(value, fallback = "") {
const raw = String(value ?? "").trim().replace(",", ".");
if (!raw) return fallback;
if (!/^\d*(?:\.\d+)?$/.test(raw) || raw === ".") return fallback;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
if (parsed > RNG_SIMULATOR_LIMITS.costPerDrawMax) return String(RNG_SIMULATOR_LIMITS.costPerDrawMax);
const [integerPart = "0", decimalPart] = raw.split(".");
const cleanInteger = String(Number.parseInt(integerPart || "0", 10));
if (decimalPart !== undefined) return `${cleanInteger}.${decimalPart}`;
return cleanInteger;
}
export function isValidRateString(value) {
const raw = String(value ?? "").trim().replace(",", ".").replace(/%$/, "").trim();
if (!raw || !/^\d*(?:\.\d+)?$/.test(raw) || raw === ".") return false;
@ -72,21 +90,20 @@ export function calculateDrawsForChance(rate, targetChance, multiplier = 0) {
return Math.ceil(Math.log1p(-target) / Math.log1p(-probability));
}
export function calculateRngSummary({ rate, multiplier = 0, simulationDraws, perHour, perDay, perMonth }) {
export function normalizeOccurrenceUnit(value) {
return RNG_SIMULATOR_OCCURRENCE_UNITS.includes(value) ? value : "perHour";
}
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 cleanPerHour = clampDecimal(perHour, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0);
const cleanPerDay = clampDecimal(perDay, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0);
const cleanPerMonth = clampDecimal(perMonth, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0);
const cleanOccurrenceValue = clampDecimal(occurrenceValue, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0);
const cleanOccurrenceUnit = normalizeOccurrenceUnit(occurrenceUnit);
let averageHours = 0;
if (cleanPerHour > 0) averageHours = averageDraws / cleanPerHour;
else if (cleanPerDay > 0) averageHours = (averageDraws / cleanPerDay) * 24;
else if (cleanPerMonth > 0) averageHours = (averageDraws / cleanPerMonth) * 24 * 30;
const explicitHourTime = cleanPerHour > 0 && averageDraws > 0 ? averageDraws / cleanPerHour : 0;
const explicitDayTime = cleanPerDay > 0 && averageDraws > 0 ? averageDraws / cleanPerDay : 0;
const explicitMonthTime = cleanPerMonth > 0 && averageDraws > 0 ? averageDraws / cleanPerMonth : 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,
@ -94,9 +111,9 @@ export function calculateRngSummary({ rate, multiplier = 0, simulationDraws, per
averageDraws,
chanceForSimulationDraws: calculateAtLeastOneProbability(rate, simulationDraws, multiplier),
timeByFrequency: {
hour: explicitHourTime || averageHours,
day: explicitDayTime || (averageHours > 0 ? averageHours / 24 : 0),
month: explicitMonthTime || (averageHours > 0 ? averageHours / 24 / 30 : 0)
hour: averageHours,
day: averageHours > 0 ? averageHours / 24 : 0,
month: averageHours > 0 ? averageHours / 24 / 30 : 0
},
drawsForChance: {
50: calculateDrawsForChance(rate, 0.5, multiplier),
@ -107,19 +124,45 @@ export function calculateRngSummary({ rate, multiplier = 0, simulationDraws, per
};
}
export function calculateRngCostSummary({ costPerDraw, averageDraws, drawsForChance, simulationDraws, simulationSuccesses, firstSuccessDraw = 0 }) {
const cleanCost = Number(normalizeCostString(costPerDraw));
if (!Number.isFinite(cleanCost) || cleanCost <= 0) return null;
const cleanSimulationDraws = clampInteger(simulationDraws, RNG_SIMULATOR_LIMITS.simulationDrawsMin, RNG_SIMULATOR_LIMITS.simulationDrawsMax, 0);
const cleanSuccesses = clampInteger(simulationSuccesses, 0, cleanSimulationDraws, 0);
const cleanFirstSuccessDraw = clampInteger(firstSuccessDraw, 0, cleanSimulationDraws, 0);
const targetCosts = {};
Object.entries(drawsForChance || {}).forEach(([target, draws]) => {
targetCosts[target] = Number.isFinite(draws) ? draws * cleanCost : Number.POSITIVE_INFINITY;
});
return {
costPerDraw: cleanCost,
averageSuccessCost: Number.isFinite(averageDraws) ? averageDraws * cleanCost : 0,
targetCosts,
simulationCostPerSuccess: cleanSuccesses > 0 ? (cleanSimulationDraws * cleanCost) / cleanSuccesses : 0,
simulationFirstSuccessCost: cleanFirstSuccessDraw > 0 ? cleanFirstSuccessDraw * cleanCost : 0
};
}
export function simulateRngDraws(rate, draws, random = Math.random, multiplier = 0) {
const probability = getEffectiveRate(rate, multiplier) / 100;
const cleanDraws = clampInteger(draws, RNG_SIMULATOR_LIMITS.simulationDrawsMin, RNG_SIMULATOR_LIMITS.simulationDrawsMax, 1);
let successes = 0;
let currentFailStreak = 0;
let currentSuccessStreak = 0;
let firstSuccessDraw = 0;
let longestFailStreak = 0;
let longestSuccessStreak = 0;
for (let index = 0; index < cleanDraws; index += 1) {
if (random() < probability) {
successes += 1;
if (!firstSuccessDraw) firstSuccessDraw = index + 1;
currentSuccessStreak += 1;
longestSuccessStreak = Math.max(longestSuccessStreak, currentSuccessStreak);
currentFailStreak = 0;
} else {
currentFailStreak += 1;
currentSuccessStreak = 0;
longestFailStreak = Math.max(longestFailStreak, currentFailStreak);
}
}
@ -129,7 +172,9 @@ export function simulateRngDraws(rate, draws, random = Math.random, multiplier =
successes,
fails: cleanDraws - successes,
observedRate: cleanDraws > 0 ? (successes / cleanDraws) * 100 : 0,
longestFailStreak
longestFailStreak,
longestSuccessStreak,
firstSuccessDraw
};
}

View file

@ -6,7 +6,9 @@ export function exportRngSimulatorText(data) {
lines.push("@activeRateId: " + (data.activeRateId || ""));
lines.push("[rates]");
(data.rates || []).forEach((rate) => {
lines.push(`- ${rate.label} | rate=${rate.rate}${rate.id === data.activeRateId ? " | active=true" : ""} | multiplier=${rate.multiplier ?? 0} | perHour=${rate.perHour || 0} | perDay=${rate.perDay || 0} | perMonth=${rate.perMonth || 0}`);
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}`);
});
lines.push(
"",
@ -51,10 +53,10 @@ export function importRngSimulatorText(text, context) {
id,
label: title,
rate: pipeMeta.rate,
costPerDraw: pipeMeta.costPerDraw,
multiplier: pipeMeta.multiplier,
perHour: pipeMeta.perHour,
perDay: pipeMeta.perDay,
perMonth: pipeMeta.perMonth
occurrenceValue: pipeMeta.occurrenceValue,
occurrenceUnit: pipeMeta.occurrenceUnit
});
if (pipeMeta.active === "true") data.activeRateId = id;
return;

View file

@ -3,6 +3,8 @@ import {
RNG_SIMULATOR_LIMITS,
clampDecimal,
clampInteger,
normalizeCostString,
normalizeOccurrenceUnit,
normalizeRateString
} from "../../modules/rngSimulatorUtils.js";
import { uid } from "./shared.js";
@ -14,10 +16,10 @@ function normalizeRateEntry(entry) {
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),
perHour: clampDecimal(entry?.perHour, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0),
perDay: clampDecimal(entry?.perDay, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0),
perMonth: clampDecimal(entry?.perMonth, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0)
occurrenceValue: clampDecimal(entry?.occurrenceValue, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0),
occurrenceUnit: normalizeOccurrenceUnit(entry?.occurrenceUnit)
};
}
@ -46,10 +48,12 @@ export function compactRngSimulatorDataForStorage(value) {
if (normalized.rates.length) {
compact.rates = normalized.rates.map((rate) => {
const compactRate = { id: rate.id, label: rate.label, rate: rate.rate };
if (rate.costPerDraw) compactRate.costPerDraw = rate.costPerDraw;
if (rate.multiplier !== 0) compactRate.multiplier = rate.multiplier;
if (rate.perHour !== 0) compactRate.perHour = rate.perHour;
if (rate.perDay !== 0) compactRate.perDay = rate.perDay;
if (rate.perMonth !== 0) compactRate.perMonth = rate.perMonth;
if (rate.occurrenceValue !== 0) {
compactRate.occurrenceValue = rate.occurrenceValue;
compactRate.occurrenceUnit = rate.occurrenceUnit;
}
return compactRate;
});
}

View file

@ -71,7 +71,6 @@ export const TOOLBOX_MODULE_DEFINITIONS = {
mode: "Taux + simulations",
icon: "clover",
editable: true,
addable: false,
scrollable: true,
libraryCategory: "calculationData"
},

View file

@ -1,26 +1,18 @@
// Rôle : porte les surfaces et grilles propres au simulateur de chance.
.rng-simulator-tool {
display: grid;
gap: var(--space-4);
padding: var(--space-4);
}
.rng-simulator-grid {
display: grid;
grid-template-columns: minmax(260px, 0.9fr) minmax(0, 1.4fr);
gap: var(--space-4);
align-items: start;
min-width: 0;
gap: var(--space-3);
padding: var(--space-3);
}
.rng-simulator-panel {
display: grid;
gap: 10px;
gap: var(--space-3);
min-width: 0;
padding: 10px;
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(16, 20, 38, 0.48);
background: rgba(16, 20, 38, 0.44);
}
.rng-simulator-panel > header {
@ -30,14 +22,15 @@
gap: var(--space-3);
min-width: 0;
min-height: 0;
border-bottom: 1px solid rgba(150, 165, 205, 0.12);
padding-bottom: var(--space-2);
border-bottom: 1px solid rgba(150, 165, 205, 0.1);
text-align: left;
}
.rng-simulator-panel > header > div:first-child {
display: flex;
flex-wrap: wrap;
align-items: baseline;
align-items: center;
gap: 8px;
min-width: 0;
}
@ -50,10 +43,9 @@
text-align: left;
}
.rng-simulator-count,
.rng-simulator-active-rate {
.rng-simulator-count {
flex: 0 0 auto;
padding: 4px 8px;
padding: 3px 7px;
border: 1px solid rgba(246, 196, 83, 0.28);
border-radius: var(--radius-sm);
color: var(--color-accent-gold);
@ -62,20 +54,35 @@
font-variant-numeric: tabular-nums;
}
.rng-simulator-rate-layout {
display: grid;
grid-template-columns: minmax(220px, 0.9fr) minmax(260px, 1.3fr);
gap: var(--space-3);
align-items: start;
min-width: 0;
}
.rng-simulator-rate-form {
display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(2, minmax(0, 1fr)) auto;
gap: var(--space-3);
align-items: end;
min-width: 0;
padding: var(--space-3);
border: 1px solid rgba(150, 165, 205, 0.12);
border-radius: var(--radius-sm);
background: rgba(5, 7, 17, 0.24);
}
.rng-simulator-rates {
display: grid;
gap: 6px;
max-height: 220px;
min-height: 72px;
max-height: 176px;
min-height: 52px;
padding-right: 4px;
}
.rng-simulator-rates-stack {
display: grid;
gap: var(--space-4);
min-width: 0;
}
.rng-simulator-rate-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@ -84,7 +91,7 @@
min-width: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: rgba(11, 14, 27, 0.56);
background: rgba(11, 14, 27, 0.54);
overflow: hidden;
}
@ -104,10 +111,10 @@
}
.rng-simulator-rate-row > button {
display: flex;
align-items: center;
justify-content: flex-start;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
min-width: 0;
padding: 7px 8px;
border: 0;
@ -144,44 +151,57 @@
padding: 3px;
}
.rng-simulator-rate-form,
.rng-simulator-settings {
.rng-simulator-active-summary {
display: grid;
gap: var(--space-3);
}
.rng-simulator-settings {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rng-simulator-three-columns {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.rng-simulator-section {
display: grid;
gap: var(--space-3);
grid-template-columns: minmax(0, 1fr);
gap: 8px;
min-width: 0;
padding-top: var(--space-3);
border-top: 1px solid rgba(150, 165, 205, 0.1);
}
.rng-simulator-summary-panel > header + .rng-simulator-section {
padding-top: var(--space-2);
border-top: 0;
.rng-simulator-active-summary > span,
.rng-simulator-stat {
display: grid;
align-content: space-between;
gap: 8px;
min-width: 0;
min-height: 90px;
padding: 9px;
border: 1px solid rgba(150, 165, 205, 0.1);
border-radius: var(--radius-sm);
background: rgba(5, 7, 17, 0.22);
}
.rng-simulator-section-title {
color: var(--color-text-primary);
font-size: var(--font-size-sm);
font-weight: 900;
}
.rng-simulator-subtitle {
.rng-simulator-active-summary small,
.rng-simulator-table > span:nth-child(-n + 3),
.rng-simulator-history-metrics small {
overflow: hidden;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.rng-simulator-stat span {
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 800;
line-height: 1.25;
}
.rng-simulator-active-summary strong,
.rng-simulator-stat strong,
.rng-simulator-table strong,
.rng-simulator-history-row b,
.rng-simulator-history-rate > span,
.rng-simulator-history-metrics b {
color: var(--color-accent-gold);
font-weight: 900;
text-transform: uppercase;
font-variant-numeric: tabular-nums;
}
.rng-simulator-rate-form .rng-simulator-form-actions {
justify-content: flex-end;
}
.rng-simulator-field {
@ -198,9 +218,85 @@
min-width: 0;
}
.rng-simulator-form-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: end;
}
.rng-simulator-error {
grid-column: 1 / -1;
margin: 0;
color: #fca5a5;
font-size: var(--font-size-xs);
font-weight: 800;
}
.rng-simulator-tabs-panel > header {
padding-bottom: 0;
border-bottom: 0;
}
.rng-simulator-tabs {
width: fit-content;
}
.rng-simulator-tabs.tabs.nds-radio-button-group[data-size="sm"] > button {
min-width: 96px;
min-height: 34px;
padding-block: 8px;
padding-inline: var(--space-3);
}
.rng-simulator-tab-content {
display: grid;
gap: var(--space-3);
min-width: 0;
}
.rng-simulator-section {
display: grid;
gap: var(--space-3);
min-width: 0;
padding-top: var(--space-3);
border-top: 1px solid rgba(150, 165, 205, 0.1);
}
.rng-simulator-tab-content > .rng-simulator-section:first-child {
padding-top: 0;
border-top: 0;
}
.rng-simulator-section-title {
color: var(--color-text-primary);
font-size: var(--font-size-sm);
font-weight: 900;
}
.rng-simulator-primary-controls {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(180px, 1fr);
gap: var(--space-4);
align-items: stretch;
}
.rng-simulator-primary-column,
.rng-simulator-cost-column {
display: grid;
gap: 8px;
min-width: 0;
align-content: start;
}
.rng-simulator-cost-column {
padding-left: var(--space-4);
border-left: 1px solid rgba(150, 165, 205, 0.12);
}
.rng-simulator-percent-input {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px;
align-items: center;
}
@ -211,90 +307,88 @@
font-weight: 900;
}
.rng-simulator-error {
margin: 0;
color: #fca5a5;
font-size: var(--font-size-xs);
font-weight: 800;
}
.rng-simulator-form-actions {
display: flex;
flex-wrap: wrap;
.rng-simulator-occurrence-control {
display: grid;
grid-template-columns: minmax(160px, 1fr) minmax(130px, 0.7fr);
gap: 8px;
align-items: end;
justify-content: flex-end;
max-width: 420px;
}
.rng-simulator-stat-grid,
.rng-simulator-targets,
.rng-simulator-time-grid {
.rng-simulator-time-results {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.rng-simulator-stat-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rng-simulator-targets {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.rng-simulator-targets .rng-simulator-stat strong {
overflow-wrap: normal;
word-break: normal;
}
.rng-simulator-time-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.rng-simulator-stat {
display: grid;
gap: 5px;
min-width: 0;
padding: 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: rgba(5, 7, 17, 0.32);
}
.rng-simulator-stat span {
overflow: hidden;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.rng-simulator-stat strong {
overflow-wrap: anywhere;
color: var(--color-text-primary);
font-size: var(--font-size-sm);
font-variant-numeric: tabular-nums;
}
.rng-simulator-simulation-panel {
gap: var(--space-3);
}
.rng-simulator-simulation-bar {
display: flex;
flex-wrap: wrap;
.rng-simulator-table {
display: grid;
align-items: center;
justify-content: space-between;
gap: 8px;
gap: 1px;
min-width: 0;
overflow: hidden;
border: 1px solid rgba(150, 165, 205, 0.1);
border-radius: var(--radius-sm);
background: rgba(150, 165, 205, 0.08);
}
.rng-simulator-table > * {
min-width: 0;
padding: 8px;
background: rgba(5, 7, 17, 0.28);
}
.rng-simulator-table > span:not(:nth-child(-n + 3)) {
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 800;
}
.rng-simulator-target-table {
grid-template-columns: minmax(88px, 0.6fr) minmax(140px, 1fr) minmax(120px, 1fr);
}
.rng-simulator-simulation-overview {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
align-items: stretch;
padding-bottom: var(--space-3);
border-bottom: 1px solid rgba(150, 165, 205, 0.12);
}
.rng-simulator-simulation-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
.rng-simulator-simulation-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.rng-simulator-simulation-actions {
display: grid;
justify-items: start;
gap: 8px;
min-width: 0;
}
.rng-simulator-simulation-actions > .nds-button {
width: 100%;
}
.rng-simulator-history-toolbar {
display: flex;
justify-content: flex-end;
min-width: 0;
}
@ -312,6 +406,14 @@
width: 150px;
}
.rng-simulator-simulation-draws {
width: 100%;
}
.rng-simulator-simulation-draws > .nds-input {
flex: 1 1 auto;
}
.rng-simulator-history {
display: grid;
gap: 8px;
@ -320,8 +422,7 @@
.rng-simulator-history-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px 12px;
gap: 10px;
align-items: center;
min-width: 0;
padding: 10px;
@ -332,26 +433,43 @@
.rng-simulator-history-row.is-latest {
border-color: rgba(139, 92, 246, 0.38);
background: rgba(139, 92, 246, 0.1);
background:
linear-gradient(135deg, rgba(139, 92, 246, 0.18), rgba(246, 196, 83, 0.06)),
rgba(11, 14, 27, 0.52);
}
.rng-simulator-history-row > div {
.rng-simulator-history-row > .rng-simulator-history-heading {
display: grid;
gap: 2px;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: var(--space-3);
min-width: 0;
}
.rng-simulator-history-rate {
justify-items: end;
text-align: right;
.rng-simulator-history-row .rng-simulator-history-identity {
display: grid;
gap: 3px;
min-width: 0;
}
.rng-simulator-history-row b,
.rng-simulator-history-rate > span,
.rng-simulator-history-metrics b {
color: var(--color-accent-gold);
font-weight: 900;
font-variant-numeric: tabular-nums;
.rng-simulator-history-row .rng-simulator-history-score {
display: grid;
justify-items: end;
gap: 0;
}
.rng-simulator-history-row .rng-simulator-history-score b {
color: var(--color-text-primary);
font-size: 24px;
line-height: 1;
}
.rng-simulator-history-rate {
display: grid;
gap: 2px;
justify-items: end;
min-width: 72px;
text-align: right;
}
.rng-simulator-history-metrics {
@ -371,44 +489,54 @@
background: rgba(5, 7, 17, 0.24);
}
.rng-simulator-history-metrics small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rng-simulator-empty {
margin: 0;
}
@media (max-width: 820px) {
.rng-simulator-grid,
.rng-simulator-settings,
.rng-simulator-three-columns,
.rng-simulator-rate-layout,
.rng-simulator-rate-form,
.rng-simulator-active-summary,
.rng-simulator-primary-controls,
.rng-simulator-stat-grid,
.rng-simulator-targets,
.rng-simulator-time-grid {
.rng-simulator-simulation-summary,
.rng-simulator-occurrence-control,
.rng-simulator-time-results,
.rng-simulator-target-table {
grid-template-columns: 1fr;
}
.rng-simulator-cost-column {
padding-top: var(--space-3);
padding-left: 0;
border-top: 1px solid rgba(150, 165, 205, 0.12);
border-left: 0;
}
.rng-simulator-history-row {
grid-template-columns: 1fr;
}
.rng-simulator-history-rate {
justify-items: start;
text-align: left;
.rng-simulator-history-row > .rng-simulator-history-heading {
grid-template-columns: minmax(0, 1fr) auto;
}
.rng-simulator-history-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rng-simulator-simulation-bar {
justify-content: flex-start;
.rng-simulator-history-rate {
grid-column: 1 / -1;
justify-items: start;
text-align: left;
}
.rng-simulator-simulation-overview,
.rng-simulator-simulation-actions {
width: 100%;
}
.rng-simulator-simulation-overview {
grid-template-columns: 1fr;
}
}