Add chance simulator toolbox and refine checklist interactions
All checks were successful
Deploy Sokko G / deploy (push) Successful in 17s

This commit is contained in:
Shinuwa 2026-08-31 15:16:36 +02:00
parent 35fe877d58
commit 6b85e58765
25 changed files with 1518 additions and 18 deletions

View file

@ -27,6 +27,7 @@ import {
normalizeModuleOrder,
normalizeNotepadData,
normalizeRandomizerData,
normalizeRngSimulatorData,
normalizeTableData,
normalizeTaskPlannerData,
normalizeTimerData,
@ -202,6 +203,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
normalizeImageAnnotationData,
normalizeNotepadData,
normalizeRandomizerData,
normalizeRngSimulatorData,
normalizeTableData,
normalizeTimerData,
normalizeTaskPlannerData,

View file

@ -1,5 +1,5 @@
// Rôle : rend les sections et items de checklist avec édition inline et quantités.
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import {
@ -119,6 +119,7 @@ export function ChecklistSection({ section, context, data, reorder, onRenameSect
onDeleteItem={onDeleteItem}
editing={editingItemId === item.id}
onToggleEditing={() => setEditingItemId(editingItemId === item.id ? "" : item.id)}
onCloseEditing={() => setEditingItemId("")}
/>
))}
</ul>
@ -127,7 +128,8 @@ export function ChecklistSection({ section, context, data, reorder, onRenameSect
);
}
function ChecklistItem({ item, section, context, reorder, items, save, onUpdateItem, onMoveItemToCategory, onDeleteItem, editing, onToggleEditing }) {
function ChecklistItem({ item, section, context, reorder, items, save, onUpdateItem, onMoveItemToCategory, onDeleteItem, editing, onToggleEditing, onCloseEditing }) {
const itemRef = useRef(null);
const done = context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget;
const textContent = context.moduleText?.checklist || {};
const className = [
@ -149,6 +151,15 @@ function ChecklistItem({ item, section, context, reorder, items, save, onUpdateI
onCommit: (title) => onMoveItemToCategory(item.id, title)
});
useEffect(() => {
if (!editing) return undefined;
function handleOutsidePointerDown(event) {
if (!itemRef.current?.contains(event.target)) onCloseEditing();
}
document.addEventListener("pointerdown", handleOutsidePointerDown);
return () => document.removeEventListener("pointerdown", handleOutsidePointerDown);
}, [editing, onCloseEditing]);
function updateItem(updater) {
save(items.map((entry) => entry.id === item.id ? updater(entry) : entry));
}
@ -161,7 +172,7 @@ function ChecklistItem({ item, section, context, reorder, items, save, onUpdateI
}
return (
<li className={className} {...reorder.getItemProps({ itemId: item.id, groupId: section.id })}>
<li ref={itemRef} className={className} {...reorder.getItemProps({ itemId: item.id, groupId: section.id })}>
<div className={`checklist-item-main ${editing ? "is-editing" : ""}`}>
<button
className="checklist-drag-handle nds-icon-button"
@ -225,7 +236,7 @@ function ChecklistItem({ item, section, context, reorder, items, save, onUpdateI
</button>
)}
</div>
<button type="button" className="checklist-edit-button nds-icon-button" data-size="sm" onClick={onToggleEditing} aria-label={`${textContent.editTitle || "Modifier"} ${item.label}`} title={textContent.editTitle || "Modifier"}>
<button type="button" className="checklist-edit-button nds-icon-button" data-size="sm" onClick={onToggleEditing} aria-pressed={editing} aria-label={`${textContent.editTitle || "Modifier"} ${item.label}`} title={textContent.editTitle || "Modifier"}>
<Icon name="edit" />
</button>
<button className="checklist-delete-button nds-icon-button" data-size="sm" data-variant="danger" onClick={() => onDeleteItem(item.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${item.label}`} title={textContent.deleteTitle || "Supprimer"}>

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

View file

@ -0,0 +1,138 @@
// Rôle : fournit les calculs probabilistes et simulations pour l'outil simulateur de chance.
export const RNG_SIMULATOR_HISTORY_LIMIT = 20;
export const RNG_SIMULATOR_DEFAULT_RATE = "2.500";
export const RNG_SIMULATOR_LIMITS = {
rateMax: 100,
multiplierMin: -1000000,
multiplierMax: 1000000,
simulationDrawsMin: 1,
simulationDrawsMax: 1000000,
occurrenceRateMin: 0,
occurrenceRateMax: 1000000000
};
export function clampInteger(value, min, max, fallback = min) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
}
export function clampDecimal(value, min, max, fallback = min) {
const raw = String(value ?? "").trim();
if (!raw) return fallback;
const parsed = Number(raw.replace(",", "."));
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
}
export function normalizeRateString(value, fallback = RNG_SIMULATOR_DEFAULT_RATE) {
const raw = String(value ?? "").trim().replace(",", ".").replace(/%$/, "").trim();
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.rateMax) return "100";
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;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 && parsed <= RNG_SIMULATOR_LIMITS.rateMax;
}
export function getEffectiveRate(rate, multiplier = 0) {
const baseRate = Number(normalizeRateString(rate));
const cleanMultiplier = clampDecimal(multiplier, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0);
if (!Number.isFinite(baseRate) || baseRate <= 0) return 0;
const factor = Math.max(0, 1 + cleanMultiplier / 100);
return Math.min(RNG_SIMULATOR_LIMITS.rateMax, baseRate * factor);
}
export function calculateAtLeastOneProbability(rate, draws, multiplier = 0) {
const probability = getEffectiveRate(rate, multiplier) / 100;
const cleanDraws = clampInteger(draws, 0, Number.MAX_SAFE_INTEGER, 0);
if (probability <= 0 || cleanDraws <= 0) return 0;
if (probability >= 1) return 1;
return -Math.expm1(cleanDraws * Math.log1p(-probability));
}
export function calculateDrawsForChance(rate, targetChance, multiplier = 0) {
const probability = getEffectiveRate(rate, multiplier) / 100;
const target = clampDecimal(targetChance, 0, 1, 0);
if (probability <= 0 || target <= 0) return 0;
if (probability >= 1) return 1;
if (target >= 1) return Number.POSITIVE_INFINITY;
return Math.ceil(Math.log1p(-target) / Math.log1p(-probability));
}
export function calculateRngSummary({ rate, multiplier = 0, simulationDraws, perHour, perDay, perMonth }) {
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);
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;
return {
probability,
effectiveRate,
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)
},
drawsForChance: {
50: calculateDrawsForChance(rate, 0.5, multiplier),
90: calculateDrawsForChance(rate, 0.9, multiplier),
95: calculateDrawsForChance(rate, 0.95, multiplier),
99: calculateDrawsForChance(rate, 0.99, multiplier)
}
};
}
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 longestFailStreak = 0;
for (let index = 0; index < cleanDraws; index += 1) {
if (random() < probability) {
successes += 1;
currentFailStreak = 0;
} else {
currentFailStreak += 1;
longestFailStreak = Math.max(longestFailStreak, currentFailStreak);
}
}
return {
draws: cleanDraws,
successes,
fails: cleanDraws - successes,
observedRate: cleanDraws > 0 ? (successes / cleanDraws) * 100 : 0,
longestFailStreak
};
}
export function addRngSimulationHistory(history, entry) {
return [entry, ...(Array.isArray(history) ? history : [])].slice(0, RNG_SIMULATOR_HISTORY_LIMIT);
}

View file

@ -20,6 +20,7 @@ import { exportEquipmentPlannerText, importEquipmentPlannerText } from "./textIm
import { exportImageAnnotationText, exportImagesText, importImageAnnotationText, importImagesText } from "./textImport/media.js";
import { exportGoalTreeText, exportTaskPlannerText, importGoalTreeText, importTaskPlannerText } from "./textImport/planning.js";
import { exportRandomizerText, importRandomizerText } from "./textImport/randomizer.js";
import { exportRngSimulatorText, importRngSimulatorText } from "./textImport/rngSimulator.js";
import { fail, parseColonImportLines } from "./textImport/shared.js";
import { exportCalendarText, exportTimerText, importCalendarText, importTimerText } from "./textImport/time.js";
@ -56,6 +57,11 @@ const TEXT_EXCHANGE_HANDLERS = {
exportText: exportRandomizerText,
importText: importRandomizerText
},
rngSimulator: {
hasContent: (data) => Boolean(data?.rates?.length),
exportText: exportRngSimulatorText,
importText: importRngSimulatorText
},
combos: {
hasContent: (data) => Boolean(data?.combos?.length),
exportText: exportCombosText,

View file

@ -0,0 +1,66 @@
// Rôle : gère l'import/export texte des configurations du simulateur de chance.
import { lineError, parseInteger, parseMetaLine, parsePipeParts, splitLines } from "./shared.js";
export function exportRngSimulatorText(data) {
const lines = [];
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" : ""}`);
});
lines.push(
"",
"[settings]",
`multiplier: ${data.settings?.multiplier ?? 0}`,
`simulationDraws: ${data.settings?.simulationDraws || 1000}`,
`perHour: ${data.settings?.perHour || 0}`,
`perDay: ${data.settings?.perDay || 0}`,
`perMonth: ${data.settings?.perMonth || 0}`
);
return lines.join("\n").trim();
}
export function importRngSimulatorText(text, context) {
const data = {
activeRateId: "",
rates: [],
settings: { multiplier: 0, simulationDraws: 1000, perHour: 0, perDay: 0, perMonth: 0 }
};
let section = "";
splitLines(text).forEach((rawLine, index) => {
const cleanLine = rawLine.trim();
if (!cleanLine) return;
const meta = parseMetaLine(cleanLine);
if (meta?.key === "activeRateId") {
data.activeRateId = meta.value;
return;
}
if (/^\[[a-z]+\]$/i.test(cleanLine)) {
section = cleanLine.slice(1, -1).toLowerCase();
return;
}
const separatorIndex = cleanLine.indexOf(":");
if (separatorIndex > 0 && !cleanLine.startsWith("-")) {
const key = cleanLine.slice(0, separatorIndex).trim();
const value = cleanLine.slice(separatorIndex + 1).trim();
if (section === "settings" && key === "multiplier") data.settings.multiplier = value;
if (section === "settings" && key === "simulationDraws") data.settings.simulationDraws = parseInteger(value, 1000);
if (section === "settings" && key === "perHour") data.settings.perHour = value;
if (section === "settings" && key === "perDay") data.settings.perDay = value;
if (section === "settings" && key === "perMonth") data.settings.perMonth = value;
return;
}
if (section === "rates" && cleanLine.startsWith("-")) {
const { title, meta: pipeMeta } = parsePipeParts(cleanLine.replace(/^[-*]\s*/, ""));
if (!title) throw lineError(index, "libellé de taux manquant.");
const id = context.uid("rng");
data.rates.push({ id, label: title, rate: pipeMeta.rate });
if (pipeMeta.active === "true") data.activeRateId = id;
return;
}
throw lineError(index, `ligne Simulateur de chance invalide: ${cleanLine}`);
});
return context.normalizeRngSimulatorData(data);
}

View file

@ -11,6 +11,7 @@ import { LinksModule } from "./LinksModule.jsx";
import { MemosModule } from "./MemosModule.jsx";
import { NotepadModule } from "./NotepadModule.jsx";
import { RandomizerModule } from "./RandomizerModule.jsx";
import { RngSimulatorModule } from "./RngSimulatorModule.jsx";
import { TableModule } from "./TableModule.jsx";
import { TaskPlannerModule } from "./TaskPlannerModule.jsx";
import { TimerModule } from "./TimerModule.jsx";
@ -28,6 +29,7 @@ export const MODULE_COMPONENTS = {
links: { ...TOOLBOX_MODULE_DEFINITIONS.links, Component: LinksModule },
counters: { ...TOOLBOX_MODULE_DEFINITIONS.counters, Component: CountersModule },
randomizer: { ...TOOLBOX_MODULE_DEFINITIONS.randomizer, Component: RandomizerModule },
rngSimulator: { ...TOOLBOX_MODULE_DEFINITIONS.rngSimulator, Component: RngSimulatorModule },
combos: { ...TOOLBOX_MODULE_DEFINITIONS.combos, Component: CombosModule },
calculator: { ...TOOLBOX_MODULE_DEFINITIONS.calculator, Component: CalculatorModule },
calendar: { ...TOOLBOX_MODULE_DEFINITIONS.calendar, Component: CalendarModule },

View file

@ -11,6 +11,7 @@ import { compactLinksDataForStorage } from "./links.js";
import { compactMemosDataForStorage } from "./memos.js";
import { compactNotepadDataForStorage } from "./notepad.js";
import { compactRandomizerDataForStorage } from "./randomizer.js";
import { compactRngSimulatorDataForStorage } from "./rngSimulator.js";
import { compactTableDataForStorage } from "./table.js";
import { compactTaskPlannerDataForStorage } from "./taskPlanner.js";
import { compactTimerDataForStorage } from "./timer.js";
@ -24,6 +25,7 @@ export const MODULE_DATA_COMPACTORS = {
links: compactLinksDataForStorage,
counters: compactCountersDataForStorage,
randomizer: compactRandomizerDataForStorage,
rngSimulator: compactRngSimulatorDataForStorage,
combos: compactCombosDataForStorage,
calculator: compactCalculatorDataForStorage,
calendar: compactCalendarDataForStorage,

View file

@ -0,0 +1,55 @@
// Rôle : normalise et compacte les données persistées de l'outil simulateur de chance.
import {
RNG_SIMULATOR_LIMITS,
clampDecimal,
clampInteger,
normalizeRateString
} from "../../modules/rngSimulatorUtils.js";
import { uid } from "./shared.js";
function normalizeRateEntry(entry) {
const label = String(entry?.label || "").trim();
if (!label) return null;
return {
id: entry?.id || uid("rng"),
label,
rate: normalizeRateString(entry?.rate)
};
}
function normalizeSettings(settings) {
return {
multiplier: clampDecimal(settings?.multiplier, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0),
simulationDraws: clampInteger(settings?.simulationDraws, RNG_SIMULATOR_LIMITS.simulationDrawsMin, RNG_SIMULATOR_LIMITS.simulationDrawsMax, 1000),
perHour: clampDecimal(settings?.perHour, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0),
perDay: clampDecimal(settings?.perDay, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0),
perMonth: clampDecimal(settings?.perMonth, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, 0)
};
}
export function normalizeRngSimulatorData(data) {
const rates = (Array.isArray(data?.rates) ? data.rates : [])
.map(normalizeRateEntry)
.filter(Boolean);
const activeRateId = rates.some((rate) => rate.id === data?.activeRateId) ? data.activeRateId : rates[0]?.id || "";
return {
activeRateId,
rates,
settings: normalizeSettings(data?.settings)
};
}
export function compactRngSimulatorDataForStorage(value) {
const normalized = normalizeRngSimulatorData(value);
const compact = {};
if (normalized.activeRateId) compact.activeRateId = normalized.activeRateId;
if (normalized.rates.length) compact.rates = normalized.rates;
if (
normalized.settings.simulationDraws !== 1000 ||
normalized.settings.multiplier !== 0 ||
normalized.settings.perHour !== 0 ||
normalized.settings.perDay !== 0 ||
normalized.settings.perMonth !== 0
) compact.settings = normalized.settings;
return Object.keys(compact).length ? compact : null;
}

View file

@ -25,7 +25,8 @@ export const ID_PREFIXES = {
socketItem: "j",
socketLink: "u",
socketBonus: "b",
randomizer: "x"
randomizer: "x",
rng: "f"
};
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";

View file

@ -16,6 +16,7 @@ export { hostnameFromUrl, normalizeLinksData, normalizeUrl } from "./modules/lin
export { normalizeMemosData } from "./modules/memos.js";
export { normalizeNotepadData, sanitizeNotepadHtml } from "./modules/notepad.js";
export { normalizeRandomizerData } from "./modules/randomizer.js";
export { normalizeRngSimulatorData } from "./modules/rngSimulator.js";
export { uid } from "./modules/shared.js";
export { normalizeTableData } from "./modules/table.js";
export { normalizeTaskPlannerData } from "./modules/taskPlanner.js";
@ -238,6 +239,16 @@ function remapModuleDataForExport(type, data, nextId) {
return remapped;
}
if (type === "rngSimulator") {
const remapped = { ...compact };
if (compact.rates) remapped.rates = compact.rates.map((rate) => ({ ...rate, id: nextId("rng") }));
if (compact.activeRateId && compact.rates) {
const activeIndex = compact.rates.findIndex((rate) => rate.id === compact.activeRateId);
remapped.activeRateId = activeIndex >= 0 ? remapped.rates[activeIndex].id : "";
}
return remapped;
}
if (type === "memos") {
const remapped = {};
if (compact.tickerEnabled === false) remapped.tickerEnabled = false;

View file

@ -65,6 +65,16 @@ export const TOOLBOX_MODULE_DEFINITIONS = {
scrollable: true,
libraryCategory: "calculationData"
},
rngSimulator: {
label: "Simulateur de chance",
defaultTitle: "Simulateur de chance",
mode: "Taux + simulations",
icon: "clover",
editable: true,
addable: false,
scrollable: true,
libraryCategory: "calculationData"
},
combos: {
label: "Combos",
defaultTitle: "Combos",
@ -156,7 +166,7 @@ export const TOOLBOX_MODULES = Object.fromEntries(Object.entries(TOOLBOX_MODULE_
export const LIBRARY_CATEGORY_DEFINITIONS = [
{ key: "notesTracking", types: ["memos", "notepad", "checklist", "counters"] },
{ key: "references", types: ["images", "imageAnnotation", "links"] },
{ key: "calculationData", types: ["calculator", "table", "goalTree", "randomizer"] },
{ key: "calculationData", types: ["calculator", "table", "goalTree", "randomizer", "rngSimulator"] },
{ key: "timeRoutines", types: ["timer", "calendar", "taskPlanner"] },
{ key: "buildsCommands", types: ["combos", "equipmentPlanner"] }
];