|
| 1 | +/* |
| 2 | + * Copyright (C) 2024 BlueLib Contributors |
| 3 | + * |
| 4 | + * This Source Code Form is subject to the terms of the MIT License. |
| 5 | + * If a copy of the MIT License was not distributed with this file, |
| 6 | + * You can obtain one at https://opensource.org/licenses/MIT. |
| 7 | + */ |
| 8 | +package software.bluelib.api.random; |
| 9 | + |
| 10 | +import java.util.*; |
| 11 | +import org.jetbrains.annotations.ApiStatus; |
| 12 | +import org.jetbrains.annotations.NotNull; |
| 13 | + |
| 14 | +/** |
| 15 | + * <b>WARNING:</b> <i>Still a massive Work in Progress.</i> <br> |
| 16 | + * A generic randomizer with a "hard pity" system layered on top of the standard pity randomization. |
| 17 | + * <p> |
| 18 | + * This class extends {@link PityRandom}, retaining all standard pity random behavior: |
| 19 | + * <ul> |
| 20 | + * <li>Each call to {@link #nextValue()} uses the pity system to weight less-picked values higher.</li> |
| 21 | + * <li>The hard pity threshold guarantees that after a specified number of attempts, the least-picked value will be selected.</li> |
| 22 | + * <li>The pity system may select the least-picked value <b>before</b> the hard pity threshold is reached.</li> |
| 23 | + * <li>When the hard pity is triggered, or (optionally) when the least-picked value is selected by the pity system, all selection counts can be reset (configurable).</li> |
| 24 | + * </ul> |
| 25 | + * <p> |
| 26 | + * <b>Supported types:</b> |
| 27 | + * <ul> |
| 28 | + * <li>Any type (T) provided as a collection of values</li> |
| 29 | + * <li>Convenience factory methods are available for common types such as Integer, Boolean, Float, and Double</li> |
| 30 | + * </ul> |
| 31 | + * <p> |
| 32 | + * <b>How it works:</b> |
| 33 | + * <ul> |
| 34 | + * <li>Each call to {@link #nextValue()} increments an attempt counter.</li> |
| 35 | + * <li>If the number of attempts reaches the hard pity threshold, the least-picked value is forcibly selected, and all selection counts are reset.</li> |
| 36 | + * <li>If the least-picked value is selected by the pity system before the threshold, selection counts may also be reset (if configured).</li> |
| 37 | + * <li>After a hard pity trigger or reset, the attempt counter is reset.</li> |
| 38 | + * </ul> |
| 39 | + * <p> |
| 40 | + * <b>Use cases:</b> |
| 41 | + * <ul> |
| 42 | + * <li>Ensures fairness by guaranteeing a rare outcome after repeated failures, while still allowing for early success via the pity system.</li> |
| 43 | + * <li>Useful for systems like loot boxes or gacha, where both randomness and guaranteed outcomes are desired.</li> |
| 44 | + * </ul> |
| 45 | + * <p> |
| 46 | + * <b>Parameters:</b> |
| 47 | + * <ul> |
| 48 | + * <li><code>hardPity</code>: The number of attempts after which the hard pity is triggered.</li> |
| 49 | + * <li><code>resetOnAnyPity</code> (if implemented): Whether to reset selection counts when the least-picked value is selected by the pity system before the hard pity threshold.</li> |
| 50 | + * </ul> |
| 51 | + */ |
| 52 | +@SuppressWarnings("unused") |
| 53 | +@ApiStatus.Experimental |
| 54 | +public class HardPityRandom<T> extends PityRandom<T> { |
| 55 | + |
| 56 | + @NotNull |
| 57 | + private final Integer hardPity; |
| 58 | + @NotNull |
| 59 | + private Integer attempts = 0; |
| 60 | + |
| 61 | + public HardPityRandom(@NotNull Collection<T> pValues, @NotNull Integer pHardPity) { |
| 62 | + super(pValues); |
| 63 | + this.hardPity = pHardPity; |
| 64 | + } |
| 65 | + |
| 66 | + @NotNull |
| 67 | + public static HardPityRandom<Double> ofRange(@NotNull Double pMin, @NotNull Double pMax, @NotNull Double pStep) { |
| 68 | + return ofRange(pMin, pMax, pStep, pMax.intValue()); |
| 69 | + } |
| 70 | + |
| 71 | + @NotNull |
| 72 | + public static HardPityRandom<Double> ofRange(@NotNull Double pMax, @NotNull Double pStep, @NotNull Integer pHardPity) { |
| 73 | + return ofRange(0.0, pMax, pStep, pHardPity); |
| 74 | + } |
| 75 | + |
| 76 | + @NotNull |
| 77 | + public static HardPityRandom<Double> ofRange(@NotNull Double pMax, @NotNull Double pStep) { |
| 78 | + return ofRange(0.0, pMax, pStep, pMax.intValue()); |
| 79 | + } |
| 80 | + |
| 81 | + @NotNull |
| 82 | + public static HardPityRandom<Double> ofRange(@NotNull Double pMin, @NotNull Double pMax, @NotNull Double pStep, @NotNull Integer pHardPity) { |
| 83 | + return new HardPityRandom<>(buildRange(pMin, pMax, pStep), pHardPity); |
| 84 | + } |
| 85 | + |
| 86 | + @NotNull |
| 87 | + private static List<Double> buildRange(@NotNull Double pStart, @NotNull Double pEnd, @NotNull Double pStep) { |
| 88 | + List<Double> range = new ArrayList<>(); |
| 89 | + for (double d = pStart; d <= pEnd + 1e-9; d += pStep) { |
| 90 | + range.add(Math.round(d * 1_000_000.0) / 1_000_000.0); |
| 91 | + } |
| 92 | + return range; |
| 93 | + } |
| 94 | + |
| 95 | + @Override |
| 96 | + public @NotNull T nextValue() { |
| 97 | + attempts++; |
| 98 | + |
| 99 | + if (hardPity > 0 && attempts >= hardPity) { |
| 100 | + attempts = 0; |
| 101 | + |
| 102 | + T leastPicked = selectionCounts.entrySet().stream() |
| 103 | + .min(Comparator.comparingInt(Map.Entry::getValue)) |
| 104 | + .map(Map.Entry::getKey) |
| 105 | + .orElse(values.getFirst()); |
| 106 | + |
| 107 | + selectionCounts.put(leastPicked, selectionCounts.get(leastPicked) + 1); |
| 108 | + resetCounts(); |
| 109 | + return leastPicked; |
| 110 | + } |
| 111 | + |
| 112 | + T value = super.nextValue(); |
| 113 | + |
| 114 | + int minCount = selectionCounts.values().stream().min(Integer::compareTo).orElse(0); |
| 115 | + |
| 116 | + if (selectionCounts.get(value) == minCount + 1) { |
| 117 | + long stillMin = selectionCounts.values().stream().filter(c -> c == minCount).count(); |
| 118 | + if (stillMin == 0) { |
| 119 | + resetCounts(); |
| 120 | + attempts = 0; |
| 121 | + } |
| 122 | + } |
| 123 | + |
| 124 | + return value; |
| 125 | + } |
| 126 | +} |
0 commit comments