From 4816f71fe32b47e9c48fab1c380e785109a82f97 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 1 Oct 2026 13:09:08 +0700 Subject: [PATCH] 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. --- docs/deploy-coolify-selfhosted.md | 6 +-- internal/modules/misc/gacha_command.go | 38 ++-------------- internal/modules/misc/gacha_command_test.go | 50 --------------------- 3 files changed, 6 insertions(+), 88 deletions(-) diff --git a/docs/deploy-coolify-selfhosted.md b/docs/deploy-coolify-selfhosted.md index d77c615..bedb0a8 100644 --- a/docs/deploy-coolify-selfhosted.md +++ b/docs/deploy-coolify-selfhosted.md @@ -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) diff --git a/internal/modules/misc/gacha_command.go b/internal/modules/misc/gacha_command.go index a5f7ee3..dc3a159 100644 --- a/internal/modules/misc/gacha_command.go +++ b/internal/modules/misc/gacha_command.go @@ -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) diff --git a/internal/modules/misc/gacha_command_test.go b/internal/modules/misc/gacha_command_test.go index 846d2ab..c4df169 100644 --- a/internal/modules/misc/gacha_command_test.go +++ b/internal/modules/misc/gacha_command_test.go @@ -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) {