mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
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:
1 parent
070f303b35
commit
4816f71fe3
3 files changed
+6
-88
No files matched your search
@@ -88,9 +88,9 @@ reply is immediate.
|
|||||||
`/gacha` uses the same service and token: the bot swaps the URL's last path
|
`/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
|
segment, so `.../api/gif` becomes `.../api/gacha`. It renders a 7-second
|
||||||
`640x360` silent MP4 wish animation, posts `Wishing...` while it renders, and
|
`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
|
falls back to a text reply such as `★★★★★ Pizza` on the same failures. Every
|
||||||
tagged `*4` or `*5` are rarer, following Genshin base rates (5★ 0.6%, 4★ 5.1%,
|
option is equally likely, as with `/random`; a `*4` or `*5` tag only sets the
|
||||||
3★ 94.3%) over the tiers present; untagged options are 3★.
|
rarity shown in the animation and reply. Untagged options are 3★.
|
||||||
|
|
||||||
## 1. MongoDB Atlas (M0)
|
## 1. MongoDB Atlas (M0)
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,6 @@ const (
|
|||||||
gachaDefaultRarity = gachaMinRarity
|
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.
|
// gachaRarityTag matches a trailing "*3", "*4", or "*5" rarity tag.
|
||||||
var gachaRarityTag = regexp.MustCompile(`\s*\*\s*([345])$`)
|
var gachaRarityTag = regexp.MustCompile(`\s*\*\s*([345])$`)
|
||||||
|
|
||||||
@@ -58,35 +53,6 @@ func parseGachaOptions(arg string) []gachaOption {
|
|||||||
return out
|
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.
|
// gachaResultText renders an option as its stars followed by its label.
|
||||||
func gachaResultText(option gachaOption) string {
|
func gachaResultText(option gachaOption) string {
|
||||||
return strings.Repeat("★", option.Rarity) + " " + option.Label
|
return strings.Repeat("★", option.Rarity) + " " + option.Label
|
||||||
@@ -106,7 +72,9 @@ func gachaCommand() modules.Command {
|
|||||||
if len(options) == 0 {
|
if len(options) == 0 {
|
||||||
return chathelper.Reply(ctx, b, update.Message, gachaUsage)
|
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))
|
results := make([]string, len(options))
|
||||||
for i, option := range options {
|
for i, option := range options {
|
||||||
results[i] = gachaResultText(option)
|
results[i] = gachaResultText(option)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package misc
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"math/rand/v2"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"reflect"
|
"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) {
|
func TestGacha_EmptyArgsRepliesUsage(t *testing.T) {
|
||||||
for _, text := range []string{"/gacha", "/gacha , ,", "/gacha *5"} {
|
for _, text := range []string{"/gacha", "/gacha , ,", "/gacha *5"} {
|
||||||
t.Run(text, func(t *testing.T) {
|
t.Run(text, func(t *testing.T) {
|
||||||
|
|||||||
Reference in new issue
Block a user