Add chance simulator toolbox and refine checklist interactions
All checks were successful
Deploy Sokko G / deploy (push) Successful in 17s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 17s
This commit is contained in:
parent
35fe877d58
commit
6b85e58765
25 changed files with 1518 additions and 18 deletions
|
|
@ -800,6 +800,47 @@ Stockage compact :
|
||||||
- `mode: "group"` interdit chaque nom de la combinaison avec tous les autres noms de cette combinaison.
|
- `mode: "group"` interdit chaque nom de la combinaison avec tous les autres noms de cette combinaison.
|
||||||
- les exclusions invalides, dupliquées ou qui référencent un joueur absent sont ignorées.
|
- les exclusions invalides, dupliquées ou qui référencent un joueur absent sont ignorées.
|
||||||
|
|
||||||
|
## Outil Simulateur de chance
|
||||||
|
|
||||||
|
Type : `rngSimulator`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"activeRateId": "rate1",
|
||||||
|
"rates": [
|
||||||
|
{
|
||||||
|
"id": "rate1",
|
||||||
|
"label": "Drop rare",
|
||||||
|
"rate": "2.500"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rate2",
|
||||||
|
"label": "Ultra rare",
|
||||||
|
"rate": "0.000004"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"multiplier": 50,
|
||||||
|
"simulationDraws": 1000,
|
||||||
|
"perHour": 30,
|
||||||
|
"perDay": 120,
|
||||||
|
"perMonth": 3600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Stockage compact :
|
||||||
|
|
||||||
|
- `rates` contient les taux enregistrés du module; un taux sans libellé est ignoré.
|
||||||
|
- `rate` est une chaîne en pourcentage afin de conserver la précision saisie, par exemple `"2.500"` ou `"0.000004"`.
|
||||||
|
- un taux doit être strictement supérieur à `0` et inférieur ou égal à `100`; les valeurs supérieures sont bornées à `"100"`.
|
||||||
|
- `activeRateId` référence un taux existant; sinon le premier taux est sélectionné.
|
||||||
|
- `settings.multiplier` est un bonus ou malus en pourcentage appliqué au taux actif pour les calculs et simulations, vaut 0 par défaut et le taux effectif est borné entre 0% et 100%.
|
||||||
|
- `settings.simulationDraws` est borné de 1 à 1 000 000 et vaut 1000 par défaut.
|
||||||
|
- `settings.perHour`, `settings.perDay` et `settings.perMonth` sont numériques, positifs ou nuls, et valent 0 par défaut.
|
||||||
|
- `settings` est omis si tous ses champs ont leurs valeurs par défaut.
|
||||||
|
- l'historique des simulations n'est pas persisté en IndexedDB; il est limité aux 20 dernières entrées en `sessionStorage`.
|
||||||
|
|
||||||
## Outil Combos
|
## Outil Combos
|
||||||
|
|
||||||
Type : `combos`
|
Type : `combos`
|
||||||
|
|
|
||||||
|
|
@ -310,6 +310,60 @@ export function validateSiteContent(site) {
|
||||||
"toolboxes.modules.randomizer.exportSuccess",
|
"toolboxes.modules.randomizer.exportSuccess",
|
||||||
"toolboxes.modules.randomizer.importError",
|
"toolboxes.modules.randomizer.importError",
|
||||||
"toolboxes.modules.randomizer.exportInfo",
|
"toolboxes.modules.randomizer.exportInfo",
|
||||||
|
"toolboxes.modules.rngSimulator.ratesTitle",
|
||||||
|
"toolboxes.modules.rngSimulator.addRateTitle",
|
||||||
|
"toolboxes.modules.rngSimulator.editRateTitle",
|
||||||
|
"toolboxes.modules.rngSimulator.emptyRates",
|
||||||
|
"toolboxes.modules.rngSimulator.labelLabel",
|
||||||
|
"toolboxes.modules.rngSimulator.labelPlaceholder",
|
||||||
|
"toolboxes.modules.rngSimulator.rateLabel",
|
||||||
|
"toolboxes.modules.rngSimulator.rateLabelRequired",
|
||||||
|
"toolboxes.modules.rngSimulator.rateInvalid",
|
||||||
|
"toolboxes.modules.rngSimulator.addRateButton",
|
||||||
|
"toolboxes.modules.rngSimulator.saveRateButton",
|
||||||
|
"toolboxes.modules.rngSimulator.cancelButton",
|
||||||
|
"toolboxes.modules.rngSimulator.deleteTitle",
|
||||||
|
"toolboxes.modules.rngSimulator.noActiveRateTitle",
|
||||||
|
"toolboxes.modules.rngSimulator.simulationDrawsLabel",
|
||||||
|
"toolboxes.modules.rngSimulator.perHourLabel",
|
||||||
|
"toolboxes.modules.rngSimulator.perDayLabel",
|
||||||
|
"toolboxes.modules.rngSimulator.perMonthLabel",
|
||||||
|
"toolboxes.modules.rngSimulator.probabilitySectionTitle",
|
||||||
|
"toolboxes.modules.rngSimulator.multiplierLabel",
|
||||||
|
"toolboxes.modules.rngSimulator.clearMultiplierButton",
|
||||||
|
"toolboxes.modules.rngSimulator.averageDrawsLabel",
|
||||||
|
"toolboxes.modules.rngSimulator.simulationChanceLabel",
|
||||||
|
"toolboxes.modules.rngSimulator.averageTimeTitle",
|
||||||
|
"toolboxes.modules.rngSimulator.occurrenceCountLabel",
|
||||||
|
"toolboxes.modules.rngSimulator.hourTimeLabel",
|
||||||
|
"toolboxes.modules.rngSimulator.dayTimeLabel",
|
||||||
|
"toolboxes.modules.rngSimulator.monthTimeLabel",
|
||||||
|
"toolboxes.modules.rngSimulator.chanceTargetsTitle",
|
||||||
|
"toolboxes.modules.rngSimulator.drawsUnit",
|
||||||
|
"toolboxes.modules.rngSimulator.hoursUnit",
|
||||||
|
"toolboxes.modules.rngSimulator.daysUnit",
|
||||||
|
"toolboxes.modules.rngSimulator.monthsUnit",
|
||||||
|
"toolboxes.modules.rngSimulator.emptySummary",
|
||||||
|
"toolboxes.modules.rngSimulator.simulationTitle",
|
||||||
|
"toolboxes.modules.rngSimulator.simulateButton",
|
||||||
|
"toolboxes.modules.rngSimulator.clearHistoryButton",
|
||||||
|
"toolboxes.modules.rngSimulator.emptyHistory",
|
||||||
|
"toolboxes.modules.rngSimulator.successesLabel",
|
||||||
|
"toolboxes.modules.rngSimulator.failsLabel",
|
||||||
|
"toolboxes.modules.rngSimulator.observedRateLabel",
|
||||||
|
"toolboxes.modules.rngSimulator.multiplierShortLabel",
|
||||||
|
"toolboxes.modules.rngSimulator.longestFailStreakLabel",
|
||||||
|
"toolboxes.modules.rngSimulator.importPlaceholder",
|
||||||
|
"toolboxes.modules.rngSimulator.importOpenButton",
|
||||||
|
"toolboxes.modules.rngSimulator.exportOpenButton",
|
||||||
|
"toolboxes.modules.rngSimulator.importModalTitle",
|
||||||
|
"toolboxes.modules.rngSimulator.exportModalTitle",
|
||||||
|
"toolboxes.modules.rngSimulator.importButton",
|
||||||
|
"toolboxes.modules.rngSimulator.exportButton",
|
||||||
|
"toolboxes.modules.rngSimulator.importSuccess",
|
||||||
|
"toolboxes.modules.rngSimulator.exportSuccess",
|
||||||
|
"toolboxes.modules.rngSimulator.importError",
|
||||||
|
"toolboxes.modules.rngSimulator.exportInfo",
|
||||||
"toolboxes.modules.combos.deviceLabel",
|
"toolboxes.modules.combos.deviceLabel",
|
||||||
"toolboxes.modules.combos.keyboardLayoutLabel",
|
"toolboxes.modules.combos.keyboardLayoutLabel",
|
||||||
"toolboxes.modules.combos.categoryPlaceholder",
|
"toolboxes.modules.combos.categoryPlaceholder",
|
||||||
|
|
@ -711,7 +765,7 @@ export function validateSiteContent(site) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const libraryToolDocs = valueAt(site, "library.toolDocs");
|
const libraryToolDocs = valueAt(site, "library.toolDocs");
|
||||||
["memos", "notepad", "checklist", "counters", "randomizer", "images", "imageAnnotation", "links", "calculator", "table", "goalTree", "timer", "calendar", "taskPlanner", "combos", "equipmentPlanner"].forEach((toolType) => {
|
["memos", "notepad", "checklist", "counters", "randomizer", "rngSimulator", "images", "imageAnnotation", "links", "calculator", "table", "goalTree", "timer", "calendar", "taskPlanner", "combos", "equipmentPlanner"].forEach((toolType) => {
|
||||||
const docs = libraryToolDocs?.[toolType];
|
const docs = libraryToolDocs?.[toolType];
|
||||||
assert.equal(typeof docs, "object", `library.toolDocs.${toolType} must be an object`);
|
assert.equal(typeof docs, "object", `library.toolDocs.${toolType} must be an object`);
|
||||||
assert.ok(Array.isArray(docs.controls), `library.toolDocs.${toolType}.controls must be an array`);
|
assert.ok(Array.isArray(docs.controls), `library.toolDocs.${toolType}.controls must be an array`);
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,9 @@ import { getMonthOccurrenceSegments, getWeekGridLayout, getWeekMinutePosition, l
|
||||||
import { appendChecklistItems, moveChecklistItem, moveChecklistItemToCategory, renameChecklistSection } from "../website/src/features/toolboxes/modules/checklistUtils.js";
|
import { appendChecklistItems, moveChecklistItem, moveChecklistItemToCategory, renameChecklistSection } from "../website/src/features/toolboxes/modules/checklistUtils.js";
|
||||||
import { formatUpdatedAt, isHtmlEmpty, queryBlockFormat, queryCommandState } from "../website/src/features/toolboxes/modules/notepadUtils.js";
|
import { formatUpdatedAt, isHtmlEmpty, queryBlockFormat, queryCommandState } from "../website/src/features/toolboxes/modules/notepadUtils.js";
|
||||||
import { createBalancedTeams, drawWeightedEntries, filterExclusionsForPlayers, getDiceShape, rollDice } from "../website/src/features/toolboxes/modules/randomizerUtils.js";
|
import { createBalancedTeams, drawWeightedEntries, filterExclusionsForPlayers, getDiceShape, rollDice } from "../website/src/features/toolboxes/modules/randomizerUtils.js";
|
||||||
|
import { calculateAtLeastOneProbability, calculateDrawsForChance, calculateRngSummary, getEffectiveRate, normalizeRateString, simulateRngDraws } from "../website/src/features/toolboxes/modules/rngSimulatorUtils.js";
|
||||||
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js";
|
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js";
|
||||||
import { compactModuleDataForStorage, createToolboxExportPayload, moduleStorageKey, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeGoalTreeData, normalizeImageAnnotationData, normalizeLinksData, normalizeMemosData, normalizeModuleOrder, normalizeNotepadData, normalizeRandomizerData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeToolbox, normalizeUrl, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
|
import { compactModuleDataForStorage, createToolboxExportPayload, moduleStorageKey, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeGoalTreeData, normalizeImageAnnotationData, normalizeLinksData, normalizeMemosData, normalizeModuleOrder, normalizeNotepadData, normalizeRandomizerData, normalizeRngSimulatorData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeToolbox, normalizeUrl, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
|
||||||
import { applyGroupedReorderOperation, completeGroupOrder, getBoundaryItemId, getGroupedEntries, moveGroupOrder, moveGroupOrderToEnd, moveGroupOrderToStart, moveItem, moveItemGroup } from "../website/src/hooks/useGroupedReorder.js";
|
import { applyGroupedReorderOperation, completeGroupOrder, getBoundaryItemId, getGroupedEntries, moveGroupOrder, moveGroupOrderToEnd, moveGroupOrderToStart, moveItem, moveItemGroup } from "../website/src/hooks/useGroupedReorder.js";
|
||||||
|
|
||||||
function createTextImportContext() {
|
function createTextImportContext() {
|
||||||
|
|
@ -34,6 +35,7 @@ function createTextImportContext() {
|
||||||
normalizeEquipmentPlannerData,
|
normalizeEquipmentPlannerData,
|
||||||
normalizeImageAnnotationData,
|
normalizeImageAnnotationData,
|
||||||
normalizeRandomizerData,
|
normalizeRandomizerData,
|
||||||
|
normalizeRngSimulatorData,
|
||||||
normalizeUrl
|
normalizeUrl
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -284,6 +286,94 @@ test("randomizer text exchange round trips full configuration", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rng simulator preserves rate precision and compacts storage", () => {
|
||||||
|
assert.equal(normalizeRateString("2.500"), "2.500");
|
||||||
|
assert.equal(normalizeRateString("0.001"), "0.001");
|
||||||
|
assert.equal(normalizeRateString("0.000004%"), "0.000004");
|
||||||
|
assert.equal(normalizeRateString("100.500"), "100");
|
||||||
|
|
||||||
|
const normalized = normalizeRngSimulatorData({
|
||||||
|
activeRateId: "rate2",
|
||||||
|
rates: [
|
||||||
|
{ id: "rate1", label: "Drop rare", rate: "2.500" },
|
||||||
|
{ id: "rate2", label: "Ultra rare", rate: "0.000004" },
|
||||||
|
{ id: "bad", label: "", rate: "4" }
|
||||||
|
],
|
||||||
|
settings: { multiplier: 50, simulationDraws: 2000, perHour: 30, perDay: 120, perMonth: 3600 },
|
||||||
|
history: [
|
||||||
|
{ id: "h1", at: "2026-08-30T12:00:00.000Z", label: "Drop rare", rate: "2.500", multiplier: 50, draws: 100, successes: 4, fails: 400, longestFailStreak: 40 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(normalized.activeRateId, "rate2");
|
||||||
|
assert.deepEqual(normalized.rates.map((rate) => [rate.label, rate.rate]), [["Drop rare", "2.500"], ["Ultra rare", "0.000004"]]);
|
||||||
|
assert.equal(normalized.history, undefined);
|
||||||
|
assert.equal(compactModuleDataForStorage("rngSimulator", normalizeRngSimulatorData({})), null);
|
||||||
|
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).settings.multiplier, 50);
|
||||||
|
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).settings.perMonth, 3600);
|
||||||
|
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).history, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rng simulator calculates probability milestones and deterministic simulations", () => {
|
||||||
|
assert.equal(calculateAtLeastOneProbability("100", 50), 1);
|
||||||
|
assert.equal(calculateDrawsForChance("50", 0.5), 1);
|
||||||
|
assert.equal(calculateDrawsForChance("1", 0.5), 69);
|
||||||
|
assert.equal(getEffectiveRate("10", 50), 15);
|
||||||
|
assert.equal(getEffectiveRate("10", -50), 5);
|
||||||
|
assert.equal(getEffectiveRate("75", 100), 100);
|
||||||
|
|
||||||
|
const hourlySummary = calculateRngSummary({ rate: "25", simulationDraws: 8, perHour: 2, perDay: 0, perMonth: 0 });
|
||||||
|
assert.equal(hourlySummary.timeByFrequency.hour, 2);
|
||||||
|
assert.equal(hourlySummary.timeByFrequency.day, 2 / 24);
|
||||||
|
assert.equal(hourlySummary.timeByFrequency.month, 2 / 24 / 30);
|
||||||
|
const multipliedSummary = calculateRngSummary({ rate: "10", multiplier: 100, simulationDraws: 1, perHour: 0, perDay: 0, perMonth: 0 });
|
||||||
|
assert.equal(multipliedSummary.effectiveRate, 20);
|
||||||
|
assert.equal(multipliedSummary.averageDraws, 5);
|
||||||
|
|
||||||
|
const result = simulateRngDraws("25", 8, (() => {
|
||||||
|
const values = [0.1, 0.9, 0.2, 0.8, 0.7, 0.1, 0.6, 0.5];
|
||||||
|
let index = 0;
|
||||||
|
return () => values[index++];
|
||||||
|
})());
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
draws: 8,
|
||||||
|
successes: 3,
|
||||||
|
fails: 5,
|
||||||
|
observedRate: 37.5,
|
||||||
|
longestFailStreak: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
const multipliedResult = simulateRngDraws("10", 4, (() => {
|
||||||
|
const values = [0.15, 0.25, 0.05, 0.9];
|
||||||
|
let index = 0;
|
||||||
|
return () => values[index++];
|
||||||
|
})(), 100);
|
||||||
|
assert.equal(multipliedResult.successes, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rng simulator text exchange round trips rates and settings", () => {
|
||||||
|
const context = createTextImportContext();
|
||||||
|
const rngSimulator = normalizeRngSimulatorData({
|
||||||
|
activeRateId: "rate2",
|
||||||
|
rates: [
|
||||||
|
{ id: "rate1", label: "Drop rare", rate: "2.500" },
|
||||||
|
{ id: "rate2", label: "Ultra rare", rate: "0.000004" }
|
||||||
|
],
|
||||||
|
settings: { multiplier: 50, simulationDraws: 5000, perHour: 40, perDay: 120, perMonth: 3600 }
|
||||||
|
});
|
||||||
|
const exported = exportModuleText("rngSimulator", rngSimulator);
|
||||||
|
assert.doesNotMatch(exported, /\[history\]/);
|
||||||
|
const imported = importModuleText("rngSimulator", exported, context);
|
||||||
|
assert.equal(imported.ok, true);
|
||||||
|
assert.deepEqual(imported.data.rates.map((rate) => [rate.label, rate.rate]), [["Drop rare", "2.500"], ["Ultra rare", "0.000004"]]);
|
||||||
|
assert.equal(imported.data.rates.find((rate) => rate.id === imported.data.activeRateId)?.label, "Ultra rare");
|
||||||
|
assert.equal(imported.data.settings.multiplier, 50);
|
||||||
|
assert.equal(imported.data.settings.simulationDraws, 5000);
|
||||||
|
assert.equal(imported.data.settings.perHour, 40);
|
||||||
|
assert.equal(imported.data.history, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
test("notepad helpers keep empty html and invalid command states stable", () => {
|
test("notepad helpers keep empty html and invalid command states stable", () => {
|
||||||
assert.equal(isHtmlEmpty("<p><br></p>", ""), true);
|
assert.equal(isHtmlEmpty("<p><br></p>", ""), true);
|
||||||
assert.equal(isHtmlEmpty("<p> </p>", ""), true);
|
assert.equal(isHtmlEmpty("<p> </p>", ""), true);
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,10 @@
|
||||||
"id": "mg",
|
"id": "mg",
|
||||||
"type": "randomizer"
|
"type": "randomizer"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "mh",
|
||||||
|
"type": "rngSimulator"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "m9",
|
"id": "m9",
|
||||||
"type": "timer"
|
"type": "timer"
|
||||||
|
|
@ -238,6 +242,7 @@
|
||||||
"m8",
|
"m8",
|
||||||
"mf",
|
"mf",
|
||||||
"mg",
|
"mg",
|
||||||
|
"mh",
|
||||||
"m9",
|
"m9",
|
||||||
"md",
|
"md",
|
||||||
"me",
|
"me",
|
||||||
|
|
@ -261,6 +266,7 @@
|
||||||
"m8",
|
"m8",
|
||||||
"mf",
|
"mf",
|
||||||
"mg",
|
"mg",
|
||||||
|
"mh",
|
||||||
"m9",
|
"m9",
|
||||||
"md",
|
"md",
|
||||||
"me",
|
"me",
|
||||||
|
|
@ -4759,6 +4765,28 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"mh": {
|
||||||
|
"activeRateId": "fnn88zy",
|
||||||
|
"rates": [
|
||||||
|
{
|
||||||
|
"id": "fnn88zy",
|
||||||
|
"label": "Elten Piece",
|
||||||
|
"rate": "0.000004"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pen-eklecta",
|
||||||
|
"label": "Pen Eklecta",
|
||||||
|
"rate": "20.02"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"multiplier": 320,
|
||||||
|
"simulationDraws": 100000,
|
||||||
|
"perHour": 60,
|
||||||
|
"perDay": 0,
|
||||||
|
"perMonth": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
"m9": {
|
"m9": {
|
||||||
"activeTab": "countdown",
|
"activeTab": "countdown",
|
||||||
"stopwatch": {
|
"stopwatch": {
|
||||||
|
|
|
||||||
|
|
@ -459,7 +459,7 @@
|
||||||
{
|
{
|
||||||
"group": "Historique",
|
"group": "Historique",
|
||||||
"icon": "rubber",
|
"icon": "rubber",
|
||||||
"title": "Effacer l'historique",
|
"title": "Supprimer les simulations",
|
||||||
"preview": {
|
"preview": {
|
||||||
"kind": "button",
|
"kind": "button",
|
||||||
"label": "Effacer"
|
"label": "Effacer"
|
||||||
|
|
@ -491,6 +491,61 @@
|
||||||
"example": "@activeTab: teams\n[dice]\nfaces: 6\nrolls: 5\n\n[wheel]\ndraws: 3\n\n- Oui | weight=2\n- Non | weight=2\n- Peut etre | weight=1\n\n[teams]\nteamCount: 4\nplayerSelectionMode: all\nplayerSelectionCount: 6\n\n- Dracaufeu | score=0\n- Tortank | score=0\n- Florizarre | score=0\n- Dracolosse | score=0\n- Ectoplasma | score=0\n- Alakazam | score=0\n- Mackogneur | score=0\n- Cizayox | score=0\n- Tyranocif | score=0\n- Gardevoir | score=0\n- Drattak | score=0\n- Métalosse | score=0\n- Carchacrok | score=0\n- Lucario | score=0\n- Trioxhydre | score=0\n- Amphinobi | score=0\n- Archéduc | score=0\n- Félinferno | score=0\n- Corvaillus | score=0\n- Lanssorien | score=0\n- Raichu | score=0\n- Artikodin | score=0\n- Électhor | score=0\n- Sulfura | score=0\n! Dracolosse / Drattak / Carchacrok / Lanssorien | mode=group\n! Artikodin / Électhor / Sulfura | mode=group"
|
"example": "@activeTab: teams\n[dice]\nfaces: 6\nrolls: 5\n\n[wheel]\ndraws: 3\n\n- Oui | weight=2\n- Non | weight=2\n- Peut etre | weight=1\n\n[teams]\nteamCount: 4\nplayerSelectionMode: all\nplayerSelectionCount: 6\n\n- Dracaufeu | score=0\n- Tortank | score=0\n- Florizarre | score=0\n- Dracolosse | score=0\n- Ectoplasma | score=0\n- Alakazam | score=0\n- Mackogneur | score=0\n- Cizayox | score=0\n- Tyranocif | score=0\n- Gardevoir | score=0\n- Drattak | score=0\n- Métalosse | score=0\n- Carchacrok | score=0\n- Lucario | score=0\n- Trioxhydre | score=0\n- Amphinobi | score=0\n- Archéduc | score=0\n- Félinferno | score=0\n- Corvaillus | score=0\n- Lanssorien | score=0\n- Raichu | score=0\n- Artikodin | score=0\n- Électhor | score=0\n- Sulfura | score=0\n! Dracolosse / Drattak / Carchacrok / Lanssorien | mode=group\n! Artikodin / Électhor / Sulfura | mode=group"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"rngSimulator": {
|
||||||
|
"controls": [
|
||||||
|
{
|
||||||
|
"group": "Taux",
|
||||||
|
"icon": "clover",
|
||||||
|
"title": "Enregistrer un taux",
|
||||||
|
"preview": {
|
||||||
|
"kind": "textButton",
|
||||||
|
"label": "%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "Simulation",
|
||||||
|
"icon": "play-circle",
|
||||||
|
"title": "Simuler",
|
||||||
|
"preview": {
|
||||||
|
"kind": "iconButton",
|
||||||
|
"icon": "play-circle"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "Historique",
|
||||||
|
"icon": "rubber",
|
||||||
|
"title": "Effacer l'historique",
|
||||||
|
"preview": {
|
||||||
|
"kind": "iconButton",
|
||||||
|
"icon": "rubber"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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.",
|
||||||
|
"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."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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.",
|
||||||
|
"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."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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\n- Pen Eklecta | rate=20.02\n\n[settings]\nmultiplier: 320\nsimulationDraws: 100000\nperHour: 60\nperDay: 0\nperMonth: 0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"images": {
|
"images": {
|
||||||
"controls": [
|
"controls": [
|
||||||
{
|
{
|
||||||
|
|
@ -2038,6 +2093,62 @@
|
||||||
"importError": "Format de randomizer invalide.",
|
"importError": "Format de randomizer invalide.",
|
||||||
"exportInfo": "Le format texte conserve les paramètres du dé, de la roue et des équipes."
|
"exportInfo": "Le format texte conserve les paramètres du dé, de la roue et des équipes."
|
||||||
},
|
},
|
||||||
|
"rngSimulator": {
|
||||||
|
"ratesTitle": "Taux enregistrés",
|
||||||
|
"addRateTitle": "Ajouter un taux",
|
||||||
|
"editRateTitle": "Modifier un taux",
|
||||||
|
"emptyRates": "Aucun taux enregistré.",
|
||||||
|
"labelLabel": "Libellé",
|
||||||
|
"labelPlaceholder": "Drop rare",
|
||||||
|
"rateLabel": "Taux (%)",
|
||||||
|
"rateLabelRequired": "Libellé requis.",
|
||||||
|
"rateInvalid": "Taux invalide. Utilisez une valeur supérieure à 0 et inférieure ou égale à 100.",
|
||||||
|
"addRateButton": "Ajouter",
|
||||||
|
"saveRateButton": "Enregistrer",
|
||||||
|
"cancelButton": "Annuler",
|
||||||
|
"deleteTitle": "Supprimer",
|
||||||
|
"noActiveRateTitle": "Aucun taux actif",
|
||||||
|
"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",
|
||||||
|
"averageTimeTitle": "Temps moyen avant réussite",
|
||||||
|
"occurrenceCountLabel": "Nombre d'occurrences",
|
||||||
|
"hourTimeLabel": "En heures",
|
||||||
|
"dayTimeLabel": "En jours",
|
||||||
|
"monthTimeLabel": "En mois",
|
||||||
|
"chanceTargetsTitle": "Tirages pour atteindre une chance cumulée",
|
||||||
|
"drawsUnit": "tirages",
|
||||||
|
"hoursUnit": "h",
|
||||||
|
"daysUnit": "j",
|
||||||
|
"monthsUnit": "mois",
|
||||||
|
"emptySummary": "Ajoutez un taux pour afficher les calculs.",
|
||||||
|
"simulationTitle": "Résultats simulés",
|
||||||
|
"simulateButton": "Simuler",
|
||||||
|
"clearHistoryButton": "Supprimer les simulations",
|
||||||
|
"emptyHistory": "Aucune simulation.",
|
||||||
|
"successesLabel": "Réussites",
|
||||||
|
"failsLabel": "Fails",
|
||||||
|
"observedRateLabel": "Taux de réussite",
|
||||||
|
"multiplierShortLabel": "Bonus",
|
||||||
|
"longestFailStreakLabel": "Pire série",
|
||||||
|
"importPlaceholder": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true\n- Pen Eklecta | rate=20.02\n\n[settings]\nmultiplier: 320\nsimulationDraws: 100000\nperHour: 60\nperDay: 0\nperMonth: 0",
|
||||||
|
"importOpenButton": "Importer du texte",
|
||||||
|
"exportOpenButton": "Exporter en format texte",
|
||||||
|
"importModalTitle": "Importer un simulateur de chance",
|
||||||
|
"exportModalTitle": "Exporter un simulateur de chance",
|
||||||
|
"importButton": "Importer le texte",
|
||||||
|
"exportButton": "Copier le texte",
|
||||||
|
"importSuccess": "Import du simulateur de chance terminé.",
|
||||||
|
"exportSuccess": "Export du simulateur de chance copié.",
|
||||||
|
"importError": "Format de simulateur de chance invalide.",
|
||||||
|
"exportInfo": "Le format texte conserve les taux enregistrés et les paramètres."
|
||||||
|
},
|
||||||
"combos": {
|
"combos": {
|
||||||
"importPlaceholder": "@device: playstation\n# Neutral\nHadoken | device=playstation | direction:down > direction:right > button:cross",
|
"importPlaceholder": "@device: playstation\n# Neutral\nHadoken | device=playstation | direction:down > direction:right > button:cross",
|
||||||
"importOpenButton": "Importer du texte",
|
"importOpenButton": "Importer du texte",
|
||||||
|
|
|
||||||
18
website/public/static/icons/clover.svg
Normal file
18
website/public/static/icons/clover.svg
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#000000"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M16.2 3.8a2.7 2.7 0 00-3.81 0l-.4.38-.4-.4a2.7 2.7 0 00-3.82 0C6.73 4.85 6.67 6.64 8 8l4 4 4-4c1.33-1.36 1.27-3.15.2-4.2z" />
|
||||||
|
<path d="M8 8c-1.36-1.33-3.15-1.27-4.2-.2a2.7 2.7 0 000 3.81l.38.4-.4.4a2.7 2.7 0 000 3.82C4.85 17.27 6.64 17.33 8 16" />
|
||||||
|
<path d="M16 16c1.36 1.33 3.15 1.27 4.2.2a2.7 2.7 0 000-3.81l-.38-.4.4-.4a2.7 2.7 0 000-3.82C19.15 6.73 17.36 6.67 16 8" />
|
||||||
|
<path d="M7.8 20.2a2.7 2.7 0 003.81 0l.4-.38.4.4a2.7 2.7 0 003.82 0c1.06-1.06 1.12-2.85-.21-4.21l-4-4-4 4c-1.33 1.36-1.27 3.15-.2 4.2z" />
|
||||||
|
<path d="M7 17l-5 5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 892 B |
|
|
@ -27,6 +27,7 @@ import {
|
||||||
normalizeModuleOrder,
|
normalizeModuleOrder,
|
||||||
normalizeNotepadData,
|
normalizeNotepadData,
|
||||||
normalizeRandomizerData,
|
normalizeRandomizerData,
|
||||||
|
normalizeRngSimulatorData,
|
||||||
normalizeTableData,
|
normalizeTableData,
|
||||||
normalizeTaskPlannerData,
|
normalizeTaskPlannerData,
|
||||||
normalizeTimerData,
|
normalizeTimerData,
|
||||||
|
|
@ -202,6 +203,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
|
||||||
normalizeImageAnnotationData,
|
normalizeImageAnnotationData,
|
||||||
normalizeNotepadData,
|
normalizeNotepadData,
|
||||||
normalizeRandomizerData,
|
normalizeRandomizerData,
|
||||||
|
normalizeRngSimulatorData,
|
||||||
normalizeTableData,
|
normalizeTableData,
|
||||||
normalizeTimerData,
|
normalizeTimerData,
|
||||||
normalizeTaskPlannerData,
|
normalizeTaskPlannerData,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Rôle : rend les sections et items de checklist avec édition inline et quantités.
|
// 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 { Icon } from "../../../components/Icon.jsx";
|
||||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -119,6 +119,7 @@ export function ChecklistSection({ section, context, data, reorder, onRenameSect
|
||||||
onDeleteItem={onDeleteItem}
|
onDeleteItem={onDeleteItem}
|
||||||
editing={editingItemId === item.id}
|
editing={editingItemId === item.id}
|
||||||
onToggleEditing={() => setEditingItemId(editingItemId === item.id ? "" : item.id)}
|
onToggleEditing={() => setEditingItemId(editingItemId === item.id ? "" : item.id)}
|
||||||
|
onCloseEditing={() => setEditingItemId("")}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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 done = context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget;
|
||||||
const textContent = context.moduleText?.checklist || {};
|
const textContent = context.moduleText?.checklist || {};
|
||||||
const className = [
|
const className = [
|
||||||
|
|
@ -149,6 +151,15 @@ function ChecklistItem({ item, section, context, reorder, items, save, onUpdateI
|
||||||
onCommit: (title) => onMoveItemToCategory(item.id, title)
|
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) {
|
function updateItem(updater) {
|
||||||
save(items.map((entry) => entry.id === item.id ? updater(entry) : entry));
|
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 (
|
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" : ""}`}>
|
<div className={`checklist-item-main ${editing ? "is-editing" : ""}`}>
|
||||||
<button
|
<button
|
||||||
className="checklist-drag-handle nds-icon-button"
|
className="checklist-drag-handle nds-icon-button"
|
||||||
|
|
@ -225,7 +236,7 @@ function ChecklistItem({ item, section, context, reorder, items, save, onUpdateI
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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" />
|
<Icon name="edit" />
|
||||||
</button>
|
</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"}>
|
<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"}>
|
||||||
|
|
|
||||||
407
website/src/features/toolboxes/modules/RngSimulatorModule.jsx
Normal file
407
website/src/features/toolboxes/modules/RngSimulatorModule.jsx
Normal file
|
|
@ -0,0 +1,407 @@
|
||||||
|
// Rôle : fournit le simulateur de chance pour calculer et simuler des taux enregistrés.
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
|
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
||||||
|
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||||
|
import {
|
||||||
|
RNG_SIMULATOR_DEFAULT_RATE,
|
||||||
|
RNG_SIMULATOR_HISTORY_LIMIT,
|
||||||
|
RNG_SIMULATOR_LIMITS,
|
||||||
|
addRngSimulationHistory,
|
||||||
|
calculateRngSummary,
|
||||||
|
clampDecimal,
|
||||||
|
clampInteger,
|
||||||
|
isValidRateString,
|
||||||
|
normalizeRateString,
|
||||||
|
simulateRngDraws
|
||||||
|
} from "./rngSimulatorUtils.js";
|
||||||
|
|
||||||
|
const CHANCE_TARGETS = [50, 90, 95, 99];
|
||||||
|
|
||||||
|
function formatNumber(value, maximumFractionDigits = 2) {
|
||||||
|
if (!Number.isFinite(value)) return "—";
|
||||||
|
return value.toLocaleString("fr-FR", { maximumFractionDigits });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPercent(value, maximumFractionDigits = 6) {
|
||||||
|
if (!Number.isFinite(value)) return "—";
|
||||||
|
return `${value.toLocaleString("fr-FR", { maximumFractionDigits })} %`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDrawTarget(value, unit) {
|
||||||
|
if (!Number.isFinite(value)) return `— ${unit}`;
|
||||||
|
if (value >= 1000000) {
|
||||||
|
return `${value.toLocaleString("fr-FR", { notation: "compact", maximumFractionDigits: 2 })} ${unit}`;
|
||||||
|
}
|
||||||
|
return `${formatNumber(value, 0)} ${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSignedPercent(value, maximumFractionDigits = 2) {
|
||||||
|
if (!Number.isFinite(value)) return "0 %";
|
||||||
|
const prefix = value > 0 ? "+" : "";
|
||||||
|
return `${prefix}${formatNumber(value, maximumFractionDigits)} %`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value, unit) {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return "—";
|
||||||
|
return `${formatNumber(value, 2)} ${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionStorage() {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
try {
|
||||||
|
return window.sessionStorage;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSimulationHistory(storageKey) {
|
||||||
|
const storage = getSessionStorage();
|
||||||
|
if (!storage) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(storage.getItem(storageKey) || "[]");
|
||||||
|
return Array.isArray(parsed) ? parsed.map(normalizeSessionHistoryEntry).filter(Boolean).slice(0, RNG_SIMULATOR_HISTORY_LIMIT) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSessionHistoryEntry(entry, index) {
|
||||||
|
const draws = clampInteger(entry?.draws, RNG_SIMULATOR_LIMITS.simulationDrawsMin, RNG_SIMULATOR_LIMITS.simulationDrawsMax, 0);
|
||||||
|
if (!draws) return null;
|
||||||
|
const successes = clampInteger(entry?.successes, 0, draws, 0);
|
||||||
|
return {
|
||||||
|
id: entry?.id || `rng-session-${index}`,
|
||||||
|
label: String(entry?.label || "").trim() || "Simulation",
|
||||||
|
rate: normalizeRateString(entry?.rate),
|
||||||
|
multiplier: clampDecimal(entry?.multiplier, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0),
|
||||||
|
draws,
|
||||||
|
successes,
|
||||||
|
fails: draws - successes,
|
||||||
|
observedRate: draws > 0 ? (successes / draws) * 100 : 0,
|
||||||
|
longestFailStreak: clampInteger(entry?.longestFailStreak, 0, draws, 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSimulationHistory(storageKey, history) {
|
||||||
|
const storage = getSessionStorage();
|
||||||
|
if (!storage) return;
|
||||||
|
try {
|
||||||
|
storage.setItem(storageKey, JSON.stringify(history.slice(0, RNG_SIMULATOR_HISTORY_LIMIT)));
|
||||||
|
} catch {
|
||||||
|
// L'historique de session est opportuniste; l'outil reste utilisable sans stockage.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RngSimulatorModule({ toolboxId, moduleId, context, textExchangeOpen }) {
|
||||||
|
const data = context.normalizeRngSimulatorData(context.getModuleData(toolboxId, moduleId, {}));
|
||||||
|
const textContent = context.moduleText?.rngSimulator || {};
|
||||||
|
const historyStorageKey = `rng-simulator:${toolboxId}:${moduleId}:history`;
|
||||||
|
const [history, setHistory] = useState(() => readSimulationHistory(historyStorageKey));
|
||||||
|
const [editingRateId, setEditingRateId] = useState("");
|
||||||
|
const [multiplierDraft, setMultiplierDraft] = useState(null);
|
||||||
|
const [occurrenceDrafts, setOccurrenceDrafts] = useState({ perHour: null, perDay: null, perMonth: null });
|
||||||
|
const rateDraft = useDraftForm({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
|
||||||
|
const activeRate = data.rates.find((rate) => rate.id === data.activeRateId) || data.rates[0] || null;
|
||||||
|
const summary = useMemo(() => activeRate ? calculateRngSummary({ rate: activeRate.rate, ...data.settings }) : null, [activeRate, data.settings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHistory(readSimulationHistory(historyStorageKey));
|
||||||
|
}, [historyStorageKey]);
|
||||||
|
|
||||||
|
function save(nextData) {
|
||||||
|
context.setModuleData(toolboxId, moduleId, nextData, "rngSimulator");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSettings(field, value) {
|
||||||
|
const settings = { ...data.settings };
|
||||||
|
if (field === "simulationDraws") {
|
||||||
|
settings[field] = clampInteger(value, RNG_SIMULATOR_LIMITS.simulationDrawsMin, RNG_SIMULATOR_LIMITS.simulationDrawsMax, data.settings.simulationDraws);
|
||||||
|
} else if (field === "multiplier") {
|
||||||
|
settings[field] = clampDecimal(value, RNG_SIMULATOR_LIMITS.multiplierMin, RNG_SIMULATOR_LIMITS.multiplierMax, 0);
|
||||||
|
} else {
|
||||||
|
settings[field] = clampDecimal(value, RNG_SIMULATOR_LIMITS.occurrenceRateMin, RNG_SIMULATOR_LIMITS.occurrenceRateMax, data.settings[field]);
|
||||||
|
}
|
||||||
|
save({ ...data, settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMultiplier(value) {
|
||||||
|
setMultiplierDraft(value);
|
||||||
|
if (String(value).trim() === "") {
|
||||||
|
updateSettings("multiplier", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateSettings("multiplier", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function blurMultiplier() {
|
||||||
|
if (multiplierDraft === null) return;
|
||||||
|
const raw = multiplierDraft.trim();
|
||||||
|
const parsed = Number(raw.replace(",", "."));
|
||||||
|
if (!raw || !Number.isFinite(parsed)) {
|
||||||
|
setMultiplierDraft(null);
|
||||||
|
updateSettings("multiplier", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMultiplierDraft(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMultiplier() {
|
||||||
|
setMultiplierDraft(null);
|
||||||
|
updateSettings("multiplier", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOccurrence(field, value) {
|
||||||
|
setOccurrenceDrafts((drafts) => ({ ...drafts, [field]: value }));
|
||||||
|
if (String(value).trim() === "") {
|
||||||
|
updateSettings(field, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateSettings(field, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function blurOccurrence(field) {
|
||||||
|
const value = occurrenceDrafts[field];
|
||||||
|
if (value === null) return;
|
||||||
|
const raw = value.trim();
|
||||||
|
const parsed = Number(raw.replace(",", "."));
|
||||||
|
if (!raw || !Number.isFinite(parsed)) updateSettings(field, 0);
|
||||||
|
setOccurrenceDrafts((drafts) => ({ ...drafts, [field]: null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitRate(event) {
|
||||||
|
rateDraft.handleSubmit(event, (draft, { reset }) => {
|
||||||
|
const label = draft.label.trim();
|
||||||
|
if (!label) return textContent.rateLabelRequired || "Libellé requis.";
|
||||||
|
if (!isValidRateString(draft.rate)) return textContent.rateInvalid || "Taux invalide.";
|
||||||
|
const rate = normalizeRateString(draft.rate);
|
||||||
|
if (editingRateId) {
|
||||||
|
save({ ...data, rates: data.rates.map((item) => item.id === editingRateId ? { ...item, label, rate } : item) });
|
||||||
|
setEditingRateId("");
|
||||||
|
} else {
|
||||||
|
const id = context.uid("rng");
|
||||||
|
save({ ...data, activeRateId: data.activeRateId || id, rates: [...data.rates, { id, label, rate }] });
|
||||||
|
}
|
||||||
|
reset({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function editRate(rate) {
|
||||||
|
save({ ...data, activeRateId: rate.id });
|
||||||
|
setEditingRateId(rate.id);
|
||||||
|
rateDraft.reset({ label: rate.label, rate: rate.rate });
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
setEditingRateId("");
|
||||||
|
rateDraft.reset({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRate(rateId) {
|
||||||
|
const rates = data.rates.filter((rate) => rate.id !== rateId);
|
||||||
|
save({ ...data, rates, activeRateId: data.activeRateId === rateId ? rates[0]?.id || "" : data.activeRateId });
|
||||||
|
if (editingRateId === rateId) cancelEdit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSimulation() {
|
||||||
|
if (!activeRate) return;
|
||||||
|
const result = simulateRngDraws(activeRate.rate, data.settings.simulationDraws, Math.random, data.settings.multiplier);
|
||||||
|
const entry = {
|
||||||
|
id: context.uid("rng"),
|
||||||
|
label: activeRate.label,
|
||||||
|
rate: activeRate.rate,
|
||||||
|
multiplier: data.settings.multiplier,
|
||||||
|
...result
|
||||||
|
};
|
||||||
|
const nextHistory = addRngSimulationHistory(history, entry);
|
||||||
|
setHistory(nextHistory);
|
||||||
|
writeSimulationHistory(historyStorageKey, nextHistory);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHistory() {
|
||||||
|
setHistory([]);
|
||||||
|
writeSimulationHistory(historyStorageKey, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{textExchangeOpen && (
|
||||||
|
<div className="module-text-exchange-panel">
|
||||||
|
<TextExchangeActions type="rngSimulator" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "rngSimulator")} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="rng-simulator-tool">
|
||||||
|
<section className="rng-simulator-grid">
|
||||||
|
<div className="rng-simulator-rates-stack">
|
||||||
|
<div className="rng-simulator-panel rng-simulator-rate-add-panel">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h3>{editingRateId ? textContent.editRateTitle || "Modifier un taux" : textContent.addRateTitle || "Ajouter un taux"}</h3>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<form className="rng-simulator-rate-form" onSubmit={submitRate}>
|
||||||
|
<label className="rng-simulator-field">
|
||||||
|
<span>{textContent.labelLabel || "Libellé"}</span>
|
||||||
|
<input {...rateDraft.getFieldProps("label", { className: "nds-input", "data-size": "sm", type: "text", placeholder: textContent.labelPlaceholder || "Drop rare" })} />
|
||||||
|
</label>
|
||||||
|
<label className="rng-simulator-field">
|
||||||
|
<span>{textContent.rateLabel || "Taux (%)"}</span>
|
||||||
|
<input {...rateDraft.getFieldProps("rate", { className: "nds-input", "data-size": "sm", type: "text", inputMode: "decimal", placeholder: RNG_SIMULATOR_DEFAULT_RATE })} />
|
||||||
|
</label>
|
||||||
|
{rateDraft.error && <p className="rng-simulator-error">{rateDraft.error}</p>}
|
||||||
|
<div className="rng-simulator-form-actions">
|
||||||
|
<button className="nds-button" data-size="sm" data-variant="primary" type="submit">
|
||||||
|
<Icon name={editingRateId ? "save" : "add"} />
|
||||||
|
<span>{editingRateId ? textContent.saveRateButton || "Enregistrer" : textContent.addRateButton || "Ajouter"}</span>
|
||||||
|
</button>
|
||||||
|
{editingRateId && (
|
||||||
|
<button className="nds-button" data-size="sm" type="button" onClick={cancelEdit}>
|
||||||
|
{textContent.cancelButton || "Annuler"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div className="rng-simulator-panel rng-simulator-rates-panel">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h3>{textContent.ratesTitle || "Taux enregistrés"}</h3>
|
||||||
|
</div>
|
||||||
|
<span className="rng-simulator-count">{data.rates.length}</span>
|
||||||
|
</header>
|
||||||
|
<div className="rng-simulator-rates nds-scrollbox">
|
||||||
|
{data.rates.length ? data.rates.map((rate) => (
|
||||||
|
<article className={`rng-simulator-rate-row ${rate.id === activeRate?.id ? "is-active" : ""}`} key={rate.id}>
|
||||||
|
<button type="button" onClick={() => editRate(rate)}>
|
||||||
|
<strong>{rate.label}</strong>
|
||||||
|
</button>
|
||||||
|
<div className="rng-simulator-rate-actions">
|
||||||
|
<button className="nds-icon-button" data-size="xs" data-variant="danger" type="button" onClick={() => deleteRate(rate.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${rate.label}`} title={textContent.deleteTitle || "Supprimer"}>
|
||||||
|
<Icon name="trash" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)) : <p className="rng-simulator-empty">{textContent.emptyRates || "Aucun taux enregistré."}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rng-simulator-panel rng-simulator-summary-panel">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h3>{activeRate?.label || textContent.noActiveRateTitle || "Aucun taux actif"}</h3>
|
||||||
|
</div>
|
||||||
|
{activeRate && <span className="rng-simulator-active-rate">{activeRate.rate} %</span>}
|
||||||
|
</header>
|
||||||
|
{activeRate && summary ? (
|
||||||
|
<>
|
||||||
|
<div className="rng-simulator-section">
|
||||||
|
<span className="rng-simulator-section-title">{textContent.probabilitySectionTitle || "Probabilités avec multiplicateur"}</span>
|
||||||
|
<div className="rng-simulator-field rng-simulator-multiplier-field">
|
||||||
|
<span>{textContent.multiplierLabel || "Multiplicateur"}</span>
|
||||||
|
<div className="rng-simulator-percent-input">
|
||||||
|
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={multiplierDraft ?? String(data.settings.multiplier ?? 0)} onChange={(event) => updateMultiplier(event.target.value)} onBlur={blurMultiplier} aria-label={textContent.multiplierLabel || "Multiplicateur"} />
|
||||||
|
<span>%</span>
|
||||||
|
<button className="nds-icon-button" data-size="xs" type="button" onClick={clearMultiplier} aria-label={textContent.clearMultiplierButton || "Réinitialiser le multiplicateur"} title={textContent.clearMultiplierButton || "Réinitialiser le multiplicateur"}>
|
||||||
|
<Icon name="rubber" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rng-simulator-stat-grid">
|
||||||
|
<RngStat label={textContent.averageDrawsLabel || "Moyenne avant réussite" } value={`${formatNumber(summary.averageDraws, 2)} ${textContent.drawsUnit || "tirages"}`} />
|
||||||
|
<RngStat label={textContent.simulationChanceLabel || "Chance sur les tirages saisis"} value={formatPercent(summary.chanceForSimulationDraws * 100, 6)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rng-simulator-section">
|
||||||
|
<span className="rng-simulator-section-title">{textContent.averageTimeTitle || "Temps moyen avant réussite"}</span>
|
||||||
|
<span className="rng-simulator-subtitle">{textContent.occurrenceCountLabel || "Nombre d'occurrences"}</span>
|
||||||
|
<div className="rng-simulator-settings rng-simulator-three-columns">
|
||||||
|
<label className="rng-simulator-field">
|
||||||
|
<span>{textContent.perHourLabel || "Par heure"}</span>
|
||||||
|
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDrafts.perHour ?? String(data.settings.perHour ?? 0)} onChange={(event) => updateOccurrence("perHour", event.target.value)} onBlur={() => blurOccurrence("perHour")} />
|
||||||
|
</label>
|
||||||
|
<label className="rng-simulator-field">
|
||||||
|
<span>{textContent.perDayLabel || "Par jour"}</span>
|
||||||
|
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDrafts.perDay ?? String(data.settings.perDay ?? 0)} onChange={(event) => updateOccurrence("perDay", event.target.value)} onBlur={() => blurOccurrence("perDay")} />
|
||||||
|
</label>
|
||||||
|
<label className="rng-simulator-field">
|
||||||
|
<span>{textContent.perMonthLabel || "Par mois"}</span>
|
||||||
|
<input className="nds-input" data-size="sm" type="text" inputMode="decimal" value={occurrenceDrafts.perMonth ?? String(data.settings.perMonth ?? 0)} onChange={(event) => updateOccurrence("perMonth", event.target.value)} onBlur={() => blurOccurrence("perMonth")} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="rng-simulator-time-grid">
|
||||||
|
<RngStat label={textContent.hourTimeLabel || "En heures"} value={formatTime(summary.timeByFrequency.hour, textContent.hoursUnit || "h")} />
|
||||||
|
<RngStat label={textContent.dayTimeLabel || "En jours"} value={formatTime(summary.timeByFrequency.day, textContent.daysUnit || "j")} />
|
||||||
|
<RngStat label={textContent.monthTimeLabel || "En mois"} value={formatTime(summary.timeByFrequency.month, textContent.monthsUnit || "mois")} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rng-simulator-section">
|
||||||
|
<span className="rng-simulator-section-title">{textContent.chanceTargetsTitle || "Tirages pour atteindre une chance cumulée"}</span>
|
||||||
|
<div className="rng-simulator-targets">
|
||||||
|
{CHANCE_TARGETS.map((target) => (
|
||||||
|
<RngStat key={target} label={`${target} %`} value={formatDrawTarget(summary.drawsForChance[target], textContent.drawsUnit || "tirages")} title={`${formatNumber(summary.drawsForChance[target], 0)} ${textContent.drawsUnit || "tirages"}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : <p className="rng-simulator-empty">{textContent.emptySummary || "Ajoutez un taux pour afficher les calculs."}</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rng-simulator-panel rng-simulator-simulation-panel">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h3>{textContent.simulationTitle || "Résultats simulés"}</h3>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="rng-simulator-simulation-bar">
|
||||||
|
<div className="rng-simulator-simulation-actions">
|
||||||
|
<label className="rng-simulator-field rng-simulator-inline-field rng-simulator-simulation-draws">
|
||||||
|
<span>{textContent.simulationDrawsLabel || "Tirages simulés"}</span>
|
||||||
|
<input className="nds-input" data-size="sm" type="number" min="1" max={RNG_SIMULATOR_LIMITS.simulationDrawsMax} value={data.settings.simulationDraws} onChange={(event) => updateSettings("simulationDraws", event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<button className="nds-button" data-size="sm" data-variant="primary" type="button" onClick={runSimulation} disabled={!activeRate}>
|
||||||
|
<Icon name="play-circle" />
|
||||||
|
<span>{textContent.simulateButton || "Simuler"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button className="nds-button" data-size="sm" data-variant="danger" type="button" onClick={clearHistory} disabled={!history.length}>
|
||||||
|
<Icon name="rubber" />
|
||||||
|
<span>{textContent.clearHistoryButton || "Supprimer les simulations"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="rng-simulator-history nds-scrollbox">
|
||||||
|
{history.length ? history.map((entry, index) => (
|
||||||
|
<article className={`rng-simulator-history-row ${index === 0 ? "is-latest" : ""}`} key={entry.id}>
|
||||||
|
<div>
|
||||||
|
<strong>{entry.label}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="rng-simulator-history-rate">
|
||||||
|
<span>{entry.rate} %</span>
|
||||||
|
<small>{textContent.multiplierShortLabel || "Bonus"} {formatSignedPercent(entry.multiplier ?? 0)}</small>
|
||||||
|
</div>
|
||||||
|
<div className="rng-simulator-history-metrics">
|
||||||
|
<span><small>{textContent.successesLabel || "Réussites"}</small><b>{entry.successes}/{entry.draws}</b></span>
|
||||||
|
<span><small>{textContent.failsLabel || "Fails"}</small><b>{entry.fails}</b></span>
|
||||||
|
<span><small>{textContent.observedRateLabel || "Taux de réussite"}</small><b>{formatPercent((entry.successes / entry.draws) * 100, 6)}</b></span>
|
||||||
|
<span><small>{textContent.longestFailStreakLabel || "Pire série"}</small><b>{entry.longestFailStreak}</b></span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)) : <p className="rng-simulator-empty">{textContent.emptyHistory || "Aucune simulation."}</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RngStat({ label, value, title }) {
|
||||||
|
return (
|
||||||
|
<article className="rng-simulator-stat" title={title}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{value}</strong>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
website/src/features/toolboxes/modules/rngSimulatorUtils.js
Normal file
138
website/src/features/toolboxes/modules/rngSimulatorUtils.js
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ import { exportEquipmentPlannerText, importEquipmentPlannerText } from "./textIm
|
||||||
import { exportImageAnnotationText, exportImagesText, importImageAnnotationText, importImagesText } from "./textImport/media.js";
|
import { exportImageAnnotationText, exportImagesText, importImageAnnotationText, importImagesText } from "./textImport/media.js";
|
||||||
import { exportGoalTreeText, exportTaskPlannerText, importGoalTreeText, importTaskPlannerText } from "./textImport/planning.js";
|
import { exportGoalTreeText, exportTaskPlannerText, importGoalTreeText, importTaskPlannerText } from "./textImport/planning.js";
|
||||||
import { exportRandomizerText, importRandomizerText } from "./textImport/randomizer.js";
|
import { exportRandomizerText, importRandomizerText } from "./textImport/randomizer.js";
|
||||||
|
import { exportRngSimulatorText, importRngSimulatorText } from "./textImport/rngSimulator.js";
|
||||||
import { fail, parseColonImportLines } from "./textImport/shared.js";
|
import { fail, parseColonImportLines } from "./textImport/shared.js";
|
||||||
import { exportCalendarText, exportTimerText, importCalendarText, importTimerText } from "./textImport/time.js";
|
import { exportCalendarText, exportTimerText, importCalendarText, importTimerText } from "./textImport/time.js";
|
||||||
|
|
||||||
|
|
@ -56,6 +57,11 @@ const TEXT_EXCHANGE_HANDLERS = {
|
||||||
exportText: exportRandomizerText,
|
exportText: exportRandomizerText,
|
||||||
importText: importRandomizerText
|
importText: importRandomizerText
|
||||||
},
|
},
|
||||||
|
rngSimulator: {
|
||||||
|
hasContent: (data) => Boolean(data?.rates?.length),
|
||||||
|
exportText: exportRngSimulatorText,
|
||||||
|
importText: importRngSimulatorText
|
||||||
|
},
|
||||||
combos: {
|
combos: {
|
||||||
hasContent: (data) => Boolean(data?.combos?.length),
|
hasContent: (data) => Boolean(data?.combos?.length),
|
||||||
exportText: exportCombosText,
|
exportText: exportCombosText,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import { LinksModule } from "./LinksModule.jsx";
|
||||||
import { MemosModule } from "./MemosModule.jsx";
|
import { MemosModule } from "./MemosModule.jsx";
|
||||||
import { NotepadModule } from "./NotepadModule.jsx";
|
import { NotepadModule } from "./NotepadModule.jsx";
|
||||||
import { RandomizerModule } from "./RandomizerModule.jsx";
|
import { RandomizerModule } from "./RandomizerModule.jsx";
|
||||||
|
import { RngSimulatorModule } from "./RngSimulatorModule.jsx";
|
||||||
import { TableModule } from "./TableModule.jsx";
|
import { TableModule } from "./TableModule.jsx";
|
||||||
import { TaskPlannerModule } from "./TaskPlannerModule.jsx";
|
import { TaskPlannerModule } from "./TaskPlannerModule.jsx";
|
||||||
import { TimerModule } from "./TimerModule.jsx";
|
import { TimerModule } from "./TimerModule.jsx";
|
||||||
|
|
@ -28,6 +29,7 @@ export const MODULE_COMPONENTS = {
|
||||||
links: { ...TOOLBOX_MODULE_DEFINITIONS.links, Component: LinksModule },
|
links: { ...TOOLBOX_MODULE_DEFINITIONS.links, Component: LinksModule },
|
||||||
counters: { ...TOOLBOX_MODULE_DEFINITIONS.counters, Component: CountersModule },
|
counters: { ...TOOLBOX_MODULE_DEFINITIONS.counters, Component: CountersModule },
|
||||||
randomizer: { ...TOOLBOX_MODULE_DEFINITIONS.randomizer, Component: RandomizerModule },
|
randomizer: { ...TOOLBOX_MODULE_DEFINITIONS.randomizer, Component: RandomizerModule },
|
||||||
|
rngSimulator: { ...TOOLBOX_MODULE_DEFINITIONS.rngSimulator, Component: RngSimulatorModule },
|
||||||
combos: { ...TOOLBOX_MODULE_DEFINITIONS.combos, Component: CombosModule },
|
combos: { ...TOOLBOX_MODULE_DEFINITIONS.combos, Component: CombosModule },
|
||||||
calculator: { ...TOOLBOX_MODULE_DEFINITIONS.calculator, Component: CalculatorModule },
|
calculator: { ...TOOLBOX_MODULE_DEFINITIONS.calculator, Component: CalculatorModule },
|
||||||
calendar: { ...TOOLBOX_MODULE_DEFINITIONS.calendar, Component: CalendarModule },
|
calendar: { ...TOOLBOX_MODULE_DEFINITIONS.calendar, Component: CalendarModule },
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { compactLinksDataForStorage } from "./links.js";
|
||||||
import { compactMemosDataForStorage } from "./memos.js";
|
import { compactMemosDataForStorage } from "./memos.js";
|
||||||
import { compactNotepadDataForStorage } from "./notepad.js";
|
import { compactNotepadDataForStorage } from "./notepad.js";
|
||||||
import { compactRandomizerDataForStorage } from "./randomizer.js";
|
import { compactRandomizerDataForStorage } from "./randomizer.js";
|
||||||
|
import { compactRngSimulatorDataForStorage } from "./rngSimulator.js";
|
||||||
import { compactTableDataForStorage } from "./table.js";
|
import { compactTableDataForStorage } from "./table.js";
|
||||||
import { compactTaskPlannerDataForStorage } from "./taskPlanner.js";
|
import { compactTaskPlannerDataForStorage } from "./taskPlanner.js";
|
||||||
import { compactTimerDataForStorage } from "./timer.js";
|
import { compactTimerDataForStorage } from "./timer.js";
|
||||||
|
|
@ -24,6 +25,7 @@ export const MODULE_DATA_COMPACTORS = {
|
||||||
links: compactLinksDataForStorage,
|
links: compactLinksDataForStorage,
|
||||||
counters: compactCountersDataForStorage,
|
counters: compactCountersDataForStorage,
|
||||||
randomizer: compactRandomizerDataForStorage,
|
randomizer: compactRandomizerDataForStorage,
|
||||||
|
rngSimulator: compactRngSimulatorDataForStorage,
|
||||||
combos: compactCombosDataForStorage,
|
combos: compactCombosDataForStorage,
|
||||||
calculator: compactCalculatorDataForStorage,
|
calculator: compactCalculatorDataForStorage,
|
||||||
calendar: compactCalendarDataForStorage,
|
calendar: compactCalendarDataForStorage,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -25,7 +25,8 @@ export const ID_PREFIXES = {
|
||||||
socketItem: "j",
|
socketItem: "j",
|
||||||
socketLink: "u",
|
socketLink: "u",
|
||||||
socketBonus: "b",
|
socketBonus: "b",
|
||||||
randomizer: "x"
|
randomizer: "x",
|
||||||
|
rng: "f"
|
||||||
};
|
};
|
||||||
|
|
||||||
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export { hostnameFromUrl, normalizeLinksData, normalizeUrl } from "./modules/lin
|
||||||
export { normalizeMemosData } from "./modules/memos.js";
|
export { normalizeMemosData } from "./modules/memos.js";
|
||||||
export { normalizeNotepadData, sanitizeNotepadHtml } from "./modules/notepad.js";
|
export { normalizeNotepadData, sanitizeNotepadHtml } from "./modules/notepad.js";
|
||||||
export { normalizeRandomizerData } from "./modules/randomizer.js";
|
export { normalizeRandomizerData } from "./modules/randomizer.js";
|
||||||
|
export { normalizeRngSimulatorData } from "./modules/rngSimulator.js";
|
||||||
export { uid } from "./modules/shared.js";
|
export { uid } from "./modules/shared.js";
|
||||||
export { normalizeTableData } from "./modules/table.js";
|
export { normalizeTableData } from "./modules/table.js";
|
||||||
export { normalizeTaskPlannerData } from "./modules/taskPlanner.js";
|
export { normalizeTaskPlannerData } from "./modules/taskPlanner.js";
|
||||||
|
|
@ -238,6 +239,16 @@ function remapModuleDataForExport(type, data, nextId) {
|
||||||
return remapped;
|
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") {
|
if (type === "memos") {
|
||||||
const remapped = {};
|
const remapped = {};
|
||||||
if (compact.tickerEnabled === false) remapped.tickerEnabled = false;
|
if (compact.tickerEnabled === false) remapped.tickerEnabled = false;
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,16 @@ export const TOOLBOX_MODULE_DEFINITIONS = {
|
||||||
scrollable: true,
|
scrollable: true,
|
||||||
libraryCategory: "calculationData"
|
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: {
|
combos: {
|
||||||
label: "Combos",
|
label: "Combos",
|
||||||
defaultTitle: "Combos",
|
defaultTitle: "Combos",
|
||||||
|
|
@ -156,7 +166,7 @@ export const TOOLBOX_MODULES = Object.fromEntries(Object.entries(TOOLBOX_MODULE_
|
||||||
export const LIBRARY_CATEGORY_DEFINITIONS = [
|
export const LIBRARY_CATEGORY_DEFINITIONS = [
|
||||||
{ key: "notesTracking", types: ["memos", "notepad", "checklist", "counters"] },
|
{ key: "notesTracking", types: ["memos", "notepad", "checklist", "counters"] },
|
||||||
{ key: "references", types: ["images", "imageAnnotation", "links"] },
|
{ 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: "timeRoutines", types: ["timer", "calendar", "taskPlanner"] },
|
||||||
{ key: "buildsCommands", types: ["combos", "equipmentPlanner"] }
|
{ key: "buildsCommands", types: ["combos", "equipmentPlanner"] }
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
normalizeMemosData,
|
normalizeMemosData,
|
||||||
normalizeNotepadData,
|
normalizeNotepadData,
|
||||||
normalizeRandomizerData,
|
normalizeRandomizerData,
|
||||||
|
normalizeRngSimulatorData,
|
||||||
normalizeTableData,
|
normalizeTableData,
|
||||||
normalizeTaskPlannerData,
|
normalizeTaskPlannerData,
|
||||||
normalizeTimerData,
|
normalizeTimerData,
|
||||||
|
|
@ -43,7 +44,8 @@ const MODULE_COMPONENTS = {
|
||||||
taskPlanner: lazy(() => import("../features/toolboxes/modules/TaskPlannerModule.jsx").then((module) => ({ default: module.TaskPlannerModule }))),
|
taskPlanner: lazy(() => import("../features/toolboxes/modules/TaskPlannerModule.jsx").then((module) => ({ default: module.TaskPlannerModule }))),
|
||||||
equipmentPlanner: lazy(() => import("../features/toolboxes/modules/EquipmentPlannerModule.jsx").then((module) => ({ default: module.EquipmentPlannerModule }))),
|
equipmentPlanner: lazy(() => import("../features/toolboxes/modules/EquipmentPlannerModule.jsx").then((module) => ({ default: module.EquipmentPlannerModule }))),
|
||||||
imageAnnotation: lazy(() => import("../features/toolboxes/modules/ImageAnnotationModule.jsx").then((module) => ({ default: module.ImageAnnotationModule }))),
|
imageAnnotation: lazy(() => import("../features/toolboxes/modules/ImageAnnotationModule.jsx").then((module) => ({ default: module.ImageAnnotationModule }))),
|
||||||
randomizer: lazy(() => import("../features/toolboxes/modules/RandomizerModule.jsx").then((module) => ({ default: module.RandomizerModule })))
|
randomizer: lazy(() => import("../features/toolboxes/modules/RandomizerModule.jsx").then((module) => ({ default: module.RandomizerModule }))),
|
||||||
|
rngSimulator: lazy(() => import("../features/toolboxes/modules/RngSimulatorModule.jsx").then((module) => ({ default: module.RngSimulatorModule })))
|
||||||
};
|
};
|
||||||
|
|
||||||
function cloneData(value) {
|
function cloneData(value) {
|
||||||
|
|
@ -166,6 +168,7 @@ export function LibraryPage({ siteContent, actions }) {
|
||||||
normalizeImageAnnotationData,
|
normalizeImageAnnotationData,
|
||||||
normalizeNotepadData,
|
normalizeNotepadData,
|
||||||
normalizeRandomizerData,
|
normalizeRandomizerData,
|
||||||
|
normalizeRngSimulatorData,
|
||||||
normalizeTableData,
|
normalizeTableData,
|
||||||
normalizeTimerData,
|
normalizeTimerData,
|
||||||
normalizeTaskPlannerData,
|
normalizeTaskPlannerData,
|
||||||
|
|
|
||||||
|
|
@ -1066,7 +1066,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.nds-scrollbox {
|
.nds-scrollbox {
|
||||||
max-height: 150px;
|
max-height: 400px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px solid rgba(165, 180, 252, 0.12);
|
border: 1px solid rgba(165, 180, 252, 0.12);
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,11 @@
|
||||||
-webkit-mask-image: url("/static/icons/checklist.svg");
|
-webkit-mask-image: url("/static/icons/checklist.svg");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-icon-clover {
|
||||||
|
mask-image: url("/static/icons/clover.svg");
|
||||||
|
-webkit-mask-image: url("/static/icons/clover.svg");
|
||||||
|
}
|
||||||
|
|
||||||
.ui-icon-diagram {
|
.ui-icon-diagram {
|
||||||
mask-image: url("/static/icons/diagram.svg");
|
mask-image: url("/static/icons/diagram.svg");
|
||||||
-webkit-mask-image: url("/static/icons/diagram.svg");
|
-webkit-mask-image: url("/static/icons/diagram.svg");
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,17 @@
|
||||||
grid-template-columns: 1fr 34px 34px;
|
grid-template-columns: 1fr 34px 34px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 3px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(5, 7, 17, 0.44);
|
||||||
|
transition: border-color 160ms ease, background 160ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-item:hover {
|
||||||
|
border-color: rgba(196, 181, 253, 0.22);
|
||||||
|
background: rgba(10, 13, 30, 0.54);
|
||||||
}
|
}
|
||||||
|
|
||||||
.checklist-item-main {
|
.checklist-item-main {
|
||||||
|
|
@ -153,15 +164,15 @@
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 0 12px;
|
padding: 0 9px;
|
||||||
border: 1px solid var(--color-border);
|
border: 0;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-sm);
|
||||||
background: rgba(5, 7, 17, 0.44);
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checklist-item:hover .checklist-item-main,
|
||||||
.checklist-item-main:hover {
|
.checklist-item-main:hover {
|
||||||
border-color: rgba(196, 181, 253, 0.22);
|
background: transparent;
|
||||||
background: rgba(10, 13, 30, 0.54);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.checklist-item-main.is-editing {
|
.checklist-item-main.is-editing {
|
||||||
|
|
@ -182,7 +193,7 @@
|
||||||
border-color: rgba(246, 196, 83, 0.72);
|
border-color: rgba(246, 196, 83, 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
.checklist-item.is-dragging .checklist-item-main {
|
.checklist-item.is-dragging {
|
||||||
opacity: 0.58;
|
opacity: 0.58;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -223,6 +234,14 @@
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checklist .checklist-item-summary:hover:not(:disabled, .active, .is-active, [aria-pressed="true"]) {
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
background-color: transparent;
|
||||||
|
background-image: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.checklist-item-summary span {
|
.checklist-item-summary span {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
@use "combos";
|
@use "combos";
|
||||||
@use "counters";
|
@use "counters";
|
||||||
@use "randomizer";
|
@use "randomizer";
|
||||||
|
@use "rng-simulator";
|
||||||
@use "calculator";
|
@use "calculator";
|
||||||
@use "goal-tree";
|
@use "goal-tree";
|
||||||
@use "timer";
|
@use "timer";
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,11 @@
|
||||||
-webkit-mask-image: url("/static/icons/dice.svg");
|
-webkit-mask-image: url("/static/icons/dice.svg");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-icon-clover {
|
||||||
|
mask-image: url("/static/icons/clover.svg");
|
||||||
|
-webkit-mask-image: url("/static/icons/clover.svg");
|
||||||
|
}
|
||||||
|
|
||||||
.module-icon-wheel {
|
.module-icon-wheel {
|
||||||
mask-image: url("/static/icons/wheel.svg");
|
mask-image: url("/static/icons/wheel.svg");
|
||||||
-webkit-mask-image: url("/static/icons/wheel.svg");
|
-webkit-mask-image: url("/static/icons/wheel.svg");
|
||||||
|
|
|
||||||
414
website/src/styles/toolboxes/_rng-simulator.scss
Normal file
414
website/src/styles/toolboxes/_rng-simulator.scss
Normal file
|
|
@ -0,0 +1,414 @@
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(16, 20, 38, 0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-panel > header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
border-bottom: 1px solid rgba(150, 165, 205, 0.12);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-panel > header > div:first-child {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-panel h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-count,
|
||||||
|
.rng-simulator-active-rate {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid rgba(246, 196, 83, 0.28);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-accent-gold);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 900;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-rates {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 220px;
|
||||||
|
min-height: 72px;
|
||||||
|
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;
|
||||||
|
gap: 0;
|
||||||
|
align-items: stretch;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(11, 14, 27, 0.56);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-rate-row:hover {
|
||||||
|
border-color: rgba(196, 181, 253, 0.24);
|
||||||
|
background: rgba(10, 13, 30, 0.68);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-rate-row.is-active {
|
||||||
|
border-color: rgba(246, 196, 83, 0.48);
|
||||||
|
background: rgba(246, 196, 83, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-rate-row.is-active:hover {
|
||||||
|
border-color: rgba(246, 196, 83, 0.62);
|
||||||
|
background: rgba(246, 196, 83, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-rate-row > button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 7px 8px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-rate-row > button:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-rate-row strong,
|
||||||
|
.rng-simulator-history-row strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-rate-row span,
|
||||||
|
.rng-simulator-history-row span,
|
||||||
|
.rng-simulator-history-row small,
|
||||||
|
.rng-simulator-empty {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-rate-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-rate-form,
|
||||||
|
.rng-simulator-settings {
|
||||||
|
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);
|
||||||
|
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-section-title {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-subtitle {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-field > .nds-input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-percent-input {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-percent-input > span {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
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;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-stat-grid,
|
||||||
|
.rng-simulator-targets,
|
||||||
|
.rng-simulator-time-grid {
|
||||||
|
display: grid;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
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;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-inline-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-inline-field > span {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-inline-field > .nds-input {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-history {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-history-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 8px 12px;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(11, 14, 27, 0.52);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-history-row.is-latest {
|
||||||
|
border-color: rgba(139, 92, 246, 0.38);
|
||||||
|
background: rgba(139, 92, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-history-row > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-history-rate {
|
||||||
|
justify-items: end;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-history-metrics > span {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid rgba(150, 165, 205, 0.12);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
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-stat-grid,
|
||||||
|
.rng-simulator-targets,
|
||||||
|
.rng-simulator-time-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-history-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-history-rate {
|
||||||
|
justify-items: start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-history-metrics {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-simulation-bar {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rng-simulator-simulation-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue