feat(misc): give every /gacha option an equal chance

The rarity tag now only styles the wish animation and the result text, so
/gacha picks like /random.
This commit is contained in:
tiennm99 committed 2026-10-01 13:09:08 +07:00
1 parent 070f303b35
commit 4816f71fe3
3 files changed
+6 -88

No files matched your search

+3 -3
View File
@@ -88,9 +88,9 @@ reply is immediate.
`/gacha` uses the same service and token: the bot swaps the URL's last path
segment, so `.../api/gif` becomes `.../api/gacha`. It renders a 7-second
`640x360` silent MP4 wish animation, posts `Wishing...` while it renders, and
falls back to a text reply such as `★★★★★ Pizza` on the same failures. Options
tagged `*4` or `*5` are rarer, following Genshin base rates (5★ 0.6%, 4★ 5.1%,
3★ 94.3%) over the tiers present; untagged options are 3★.
falls back to a text reply such as `★★★★★ Pizza` on the same failures. Every
option is equally likely, as with `/random`; a `*4` or `*5` tag only sets the
rarity shown in the animation and reply. Untagged options are 3★.
## 1. MongoDB Atlas (M0)
+3 -35
View File
@@ -26,11 +26,6 @@ const (
gachaDefaultRarity = gachaMinRarity
)
// gachaTierRates are Genshin Impact's base single-wish odds per mille. A
// pick first rolls a tier among the tiers present, then an option uniformly
// within it, so untagged input is a plain uniform pick.
var gachaTierRates = map[int]int{5: 6, 4: 51, 3: 943}
// gachaRarityTag matches a trailing "*3", "*4", or "*5" rarity tag.
var gachaRarityTag = regexp.MustCompile(`\s*\*\s*([345])$`)
@@ -58,35 +53,6 @@ func parseGachaOptions(arg string) []gachaOption {
return out
}
// pickGachaOption returns the index of the wished option. rng may be nil to
// use the global source.
func pickGachaOption(options []gachaOption, rng *rand.Rand) int {
intN := rand.IntN
if rng != nil {
intN = rng.IntN
}
byRarity := map[int][]int{}
total := 0
for i, option := range options {
if len(byRarity[option.Rarity]) == 0 {
total += gachaTierRates[option.Rarity]
}
byRarity[option.Rarity] = append(byRarity[option.Rarity], i)
}
roll := intN(total)
for rarity := gachaMaxRarity; rarity >= gachaMinRarity; rarity-- {
tier := byRarity[rarity]
if len(tier) == 0 {
continue
}
if roll < gachaTierRates[rarity] {
return tier[intN(len(tier))]
}
roll -= gachaTierRates[rarity]
}
return 0 // unreachable: roll < total, the sum of present tier rates.
}
// gachaResultText renders an option as its stars followed by its label.
func gachaResultText(option gachaOption) string {
return strings.Repeat("★", option.Rarity) + " " + option.Label
@@ -106,7 +72,9 @@ func gachaCommand() modules.Command {
if len(options) == 0 {
return chathelper.Reply(ctx, b, update.Message, gachaUsage)
}
winner := pickGachaOption(options, nil)
// Every option is equally likely; the rarity tag only styles the
// wish animation and the result text.
winner := rand.IntN(len(options))
results := make([]string, len(options))
for i, option := range options {
results[i] = gachaResultText(option)
@@ -3,7 +3,6 @@ package misc
import (
"context"
"encoding/json"
"math/rand/v2"
"net/http"
"net/http/httptest"
"reflect"
@@ -29,55 +28,6 @@ func TestParseGachaOptions(t *testing.T) {
}
}
// One option per tier must land near the Genshin base rates.
func TestPickGachaOption_FollowsTierRates(t *testing.T) {
options := []gachaOption{{"five", 5}, {"four", 4}, {"three", 3}}
rng := rand.New(rand.NewPCG(1, 2))
const rolls = 200_000
counts := make([]int, len(options))
for range rolls {
counts[pickGachaOption(options, rng)]++
}
for i, wantPerMille := range []float64{6, 51, 943} {
got := float64(counts[i]) / rolls * 1000
if got < wantPerMille*0.85 || got > wantPerMille*1.15 {
t.Errorf("%s rate = %.2f‰, want about %.0f‰", options[i].Label, got, wantPerMille)
}
}
}
func TestPickGachaOption_UniformWithinTier(t *testing.T) {
options := []gachaOption{{"a", 3}, {"b", 3}, {"c", 3}, {"d", 3}}
rng := rand.New(rand.NewPCG(3, 4))
counts := make([]int, len(options))
for range 40_000 {
counts[pickGachaOption(options, rng)]++
}
for i, n := range counts {
if n < 9_000 || n > 11_000 {
t.Errorf("option %d picked %d times, want about 10000", i, n)
}
}
}
// Missing tiers do not dilute the odds: with only 4★ and 5★ options, the
// 4★ share is 51/(51+6).
func TestPickGachaOption_RenormalisesPresentTiers(t *testing.T) {
options := []gachaOption{{"five", 5}, {"four", 4}}
rng := rand.New(rand.NewPCG(5, 6))
five := 0
const rolls = 100_000
for range rolls {
if pickGachaOption(options, rng) == 0 {
five++
}
}
got := float64(five) / rolls
if want := 6.0 / 57; got < want*0.9 || got > want*1.1 {
t.Fatalf("5★ share = %.4f, want about %.4f", got, want)
}
}
func TestGacha_EmptyArgsRepliesUsage(t *testing.T) {
for _, text := range []string{"/gacha", "/gacha , ,", "/gacha *5"} {
t.Run(text, func(t *testing.T) {