Gacha Box
Simulates drawing items from a gacha box with configurable drop rates and a pity system.
Level:Beginner
- How does the pity mechanic work?
- If the count of draws since the top rarity meets or exceeds pity_after, the next draw automatically yields the top rarity.
- How are rarities chosen on a normal draw?
- random.choices selects a rarity using the probability weights configured for each tier.
- What metrics does the recorder process track?
- It probes count_ for each rarity as well as total spent after every draw.
Gacha box draw simulation
In this luck-driven gacha experience, every pull costs a set amount of in-game currency and delivers an item whose rarity follows a predefined probability table—common drops appear often, while the coveted top-tier reward glitters at the end of long odds. Behind the scenes, a gentle “pity” mechanic watches your streak: if you’ve whiffed on the highest rarity for a configurable number of consecutive draws, the very next pull is guaranteed to break the drought, resetting the counter and injecting a burst of excitement. Each spin updates running tallies of total spending and how many items of each tier you’ve collected, so players can track both their fortunes and their finances over the course of a session.
import random
from tys import probe, progress
def simulate(cfg: dict):
import simpy
env = simpy.Environment()
Configuration
num_draws = cfg["num_draws"] # total pulls to make
cost_per_draw = cfg["cost_per_draw"] # currency spent each pull
rarities = cfg["rarities"] # mapping of rarity -> probability
pity_after = cfg.get("pity_after", 0) # guarantee top rarity after this many
seed = cfg.get("seed", 12345) # RNG seed for reproducibility
rng = random.Random(seed)
names = list(rarities.keys())
weights = [rarities[n] for n in names]
State
counts = {n: 0 for n in names} # cumulative draws per rarity
spent = 0 # total currency spent
draws_since_top = 0 # draws since last top rarity pull
top_rarity = names[-1] # most coveted rarity in the pool
done = env.event()
Record cumulative counts and spending every step so graphs update live.
def recorder():
while True:
for r in names:
probe(f"count_{r}", env.now, counts[r])
probe("spent", env.now, spent)
yield env.timeout(1)
env.process(recorder())
Perform each draw and update state. This is where the "pity" logic lives.
def dynamics():
nonlocal spent, draws_since_top
for i in range(num_draws):
If pity applies, award the top rarity automatically
if pity_after and draws_since_top >= pity_after:
rarity = top_rarity
draws_since_top = 0
else:
rarity = rng.choices(names, weights=weights)[0]
if rarity == top_rarity:
draws_since_top = 0
else:
draws_since_top += 1
counts[rarity] += 1
spent += cost_per_draw
progress(int(100 * (i + 1) / num_draws))
yield env.timeout(1)
done.succeed({"spent": spent} | {f"count_{k}": v for k, v in counts.items()})
env.process(dynamics())
env.run(until=done)
return done.value
def requirements():
return {
"builtin": ["micropip", "pyyaml"],
"external": ["simpy==4.1.1"],
}
num_draws: 50
cost_per_draw: 10
rarities:
common: 0.8
rare: 0.19
ultra: 0.01
pity_after: 30
seed: 42
count_common
Samples | 51 @ 0.00–50.00 |
---|---|
Values | min 0.00, mean 20.43, median 21.00, max 40.00, σ 11.06 |
count_rare
Samples | 51 @ 0.00–50.00 |
---|---|
Values | min 0.00, mean 4.18, median 4.00, max 9.00, σ 3.37 |
count_ultra
Samples | 51 @ 0.00–50.00 |
---|---|
Values | min 0.00, mean 0.39, median 0.00, max 1.00, σ 0.49 |
spent
Samples | 51 @ 0.00–50.00 |
---|---|
Values | min 0.00, mean 250.00, median 250.00, max 500.00, σ 147.20 |
Metric | Value |
---|---|
spent | 500.00 |
count_common | 40.00 |
count_rare | 9.00 |
count_ultra | 1.00 |