package misc import ( "bytes" "context" "errors" "math/rand/v2" "regexp" "strings" "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" "github.com/tiennm99/miti99bot/internal/log" "github.com/tiennm99/miti99bot/internal/modules" "github.com/tiennm99/miti99bot/internal/modules/util/chathelper" ) const ( gachaUsage = "Usage: /gacha \nTag rarity with *4 or *5, e.g. /gacha Pizza*5, Pho*4, Rice" gachaPlaceholder = "Wishing..." gachaFilename = "gacha.mp4" gachaMinRarity = 3 gachaMaxRarity = 5 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])$`) type gachaOption struct { Label string Rarity int } // parseGachaOptions splits comma-separated options and strips each one's // rarity tag. Untagged options are 3★; options left blank after the tag is // removed are dropped. func parseGachaOptions(arg string) []gachaOption { parts := splitWheelOptions(arg) out := make([]gachaOption, 0, len(parts)) for _, part := range parts { option := gachaOption{Label: part, Rarity: gachaDefaultRarity} if m := gachaRarityTag.FindStringSubmatchIndex(part); m != nil { option.Label = strings.TrimSpace(part[:m[0]]) option.Rarity = int(part[m[2]] - '0') } if option.Label != "" { out = append(out, option) } } 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 } func gachaCommand() modules.Command { return modules.Command{ Name: "gacha", Visibility: modules.VisibilityPublic, Description: "Wish for one comma-separated option, Genshin style; tag *4 or *5 for rarity", Parameters: "", Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error { if update.Message == nil { return nil } options := parseGachaOptions(chathelper.ArgAfterCommand(update.Message.Text)) if len(options) == 0 { return chathelper.Reply(ctx, b, update.Message, gachaUsage) } winner := pickGachaOption(options, nil) results := make([]string, len(options)) for i, option := range options { results[i] = gachaResultText(option) } placeholder := sendRenderPlaceholder(ctx, b, update.Message, gachaPlaceholder) animation, err := renderGachaAnimation(ctx, options[winner].Label, options[winner].Rarity) if err != nil { if !errors.Is(err, errWheelAPINotConfigured) { log.Warn("gacha remote render failed", "err", err) } return replaceWheelPlaceholder(ctx, b, update.Message, placeholder, results[winner]) } _, err = b.SendAnimation(ctx, &bot.SendAnimationParams{ ChatID: update.Message.Chat.ID, MessageThreadID: update.Message.MessageThreadID, Animation: &models.InputFileUpload{ Filename: gachaFilename, Data: bytes.NewReader(animation.Data), }, Duration: animation.Duration, Width: animation.Width, Height: animation.Height, Caption: wheelResultCaption(results, winner), ParseMode: models.ParseModeHTML, }) if err != nil { log.Warn("gacha send animation failed", "chat", update.Message.Chat.ID, "err", err) return replaceWheelPlaceholder(ctx, b, update.Message, placeholder, results[winner]) } if err := chathelper.DeleteMessage(ctx, b, update.Message, placeholder); err != nil { log.Warn("gacha placeholder delete failed", "chat", update.Message.Chat.ID, "err", err) } return nil }, } }