From 63659a4934fad3a2ad47ed012a5ee547937c74fb Mon Sep 17 00:00:00 2001 From: Branden J Brown Date: Mon, 9 Mar 2026 10:05:07 -0400 Subject: [PATCH] horsebot: refactor skill server --- cmd/horsebot/main.go | 94 +++++------------------------ cmd/horsebot/skill.go | 134 +++++++++++++++++++++++++++++++----------- 2 files changed, 113 insertions(+), 115 deletions(-) diff --git a/cmd/horsebot/main.go b/cmd/horsebot/main.go index 7b33ea7..1a91070 100644 --- a/cmd/horsebot/main.go +++ b/cmd/horsebot/main.go @@ -11,7 +11,6 @@ import ( "os" "os/signal" "path/filepath" - "strconv" "time" "github.com/disgoorg/disgo" @@ -58,13 +57,16 @@ func main() { } slog.SetDefault(slog.New(lh)) - byID, byName, err := loadSkills(filepath.Join(dataDir, "skill.json")) + skills, err := loadSkills(filepath.Join(dataDir, "skill.json")) + slog.Info("loaded skills", slog.Int("count", len(skills))) groups, err2 := loadSkillGroups(filepath.Join(dataDir, "skill-group.json")) + slog.Info("loaded skill groups", slog.Int("count", len(groups))) if err = errors.Join(err, err2); err != nil { slog.Error("loading data", slog.Any("err", err)) os.Exit(1) } - skillsByID, skillsByName, skillGroupMap = byID, byName, groups + skillSrv := newSkillServer(skills, groups) + slog.Info("skill server ready") token, err := os.ReadFile(tokenFile) if err != nil { @@ -80,9 +82,9 @@ func main() { r.Use(middleware.Go) r.Use(logMiddleware) r.Route("/skill", func(r handler.Router) { - r.SlashCommand("/", skillHandler) - r.Autocomplete("/", skillAutocomplete) - r.ButtonComponent("/{id}", skillButton) + r.SlashCommand("/", skillSrv.slash) + r.Autocomplete("/", skillSrv.autocomplete) + r.ButtonComponent("/{id}", skillSrv.button) }) opts := []bot.ConfigOpt{bot.WithDefaultGateway(), bot.WithEventListeners(r)} @@ -145,33 +147,19 @@ var commands = []discord.ApplicationCommandCreate{ }, } -// TODO(zeph): these globals could go away, but there's a bit of ceremony to doing that -var ( - skillsByID map[horse.SkillID]horse.Skill - skillsByName map[string]horse.SkillID - skillGroupMap map[horse.SkillGroupID]horse.SkillGroup -) - -func loadSkills(file string) (map[horse.SkillID]horse.Skill, map[string]horse.SkillID, error) { +func loadSkills(file string) ([]horse.Skill, error) { b, err := os.ReadFile(file) if err != nil { - return nil, nil, err + return nil, err } var skills []horse.Skill if err := json.Unmarshal(b, &skills); err != nil { - return nil, nil, err + return nil, err } - byID := make(map[horse.SkillID]horse.Skill, len(skills)) - byName := make(map[string]horse.SkillID, len(skills)) - for _, s := range skills { - byID[s.ID] = s - byName[s.Name] = s.ID - } - slog.Info("loaded skills", slog.Int("count", len(skills))) - return byID, byName, nil + return skills, nil } -func loadSkillGroups(file string) (map[horse.SkillGroupID]horse.SkillGroup, error) { +func loadSkillGroups(file string) ([]horse.SkillGroup, error) { b, err := os.ReadFile(file) if err != nil { return nil, err @@ -180,59 +168,5 @@ func loadSkillGroups(file string) (map[horse.SkillGroupID]horse.SkillGroup, erro if err := json.Unmarshal(b, &groups); err != nil { return nil, err } - m := make(map[horse.SkillGroupID]horse.SkillGroup, len(groups)) - for _, s := range groups { - m[s.ID] = s - } - slog.Info("loaded skill groups", slog.Int("count", len(groups))) - return m, nil -} - -func skillHandler(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error { - q := data.String("query") - id, err := strconv.ParseInt(q, 10, 32) - if err == nil { - // note inverted condition; this is when we have an id - id = int64(skillsByID[horse.SkillID(id)].ID) - } - if id == 0 { - // Either we weren't given a number or the number doesn't match any skill ID. - v := skillsByName[q] - if v == 0 { - // No such skill. - m := discord.MessageCreate{ - Content: "No such skill.", - Flags: discord.MessageFlagEphemeral, - } - return e.CreateMessage(m) - } - id = int64(v) - } - // TODO(zeph): search conditions and effects, give a list - m := discord.MessageCreate{ - Components: []discord.LayoutComponent{RenderSkill(horse.SkillID(id), skillsByID, skillGroupMap)}, - Flags: discord.MessageFlagIsComponentsV2, - } - return e.CreateMessage(m) -} - -func skillAutocomplete(e *handler.AutocompleteEvent) error { - q := e.Data.String("query") - opts := skillGlobalAuto().Find(nil, q) - return e.AutocompleteResult(opts[:min(len(opts), 25)]) -} - -func skillButton(data discord.ButtonInteractionData, e *handler.ComponentEvent) error { - id, err := strconv.ParseInt(e.Vars["id"], 10, 32) - if err != nil { - m := discord.MessageCreate{ - Content: "That button produced an invalid skill ID. That's not supposed to happen.", - Flags: discord.MessageFlagEphemeral, - } - return e.CreateMessage(m) - } - m := discord.MessageUpdate{ - Components: &[]discord.LayoutComponent{RenderSkill(horse.SkillID(id), skillsByID, skillGroupMap)}, - } - return e.UpdateMessage(m) + return groups, nil } diff --git a/cmd/horsebot/skill.go b/cmd/horsebot/skill.go index 0339305..6835c4d 100644 --- a/cmd/horsebot/skill.go +++ b/cmd/horsebot/skill.go @@ -2,58 +2,138 @@ package main import ( "fmt" + "strconv" "strings" - "sync" "github.com/disgoorg/disgo/discord" + "github.com/disgoorg/disgo/handler" "git.sunturtle.xyz/zephyr/horse/cmd/horsebot/autocomplete" "git.sunturtle.xyz/zephyr/horse/horse" ) -func RenderSkill(id horse.SkillID, all map[horse.SkillID]horse.Skill, groups map[horse.SkillGroupID]horse.SkillGroup) discord.ContainerComponent { - s, ok := all[id] +type skillServer struct { + skills map[horse.SkillID]horse.Skill + byName map[string]horse.SkillID + groups map[horse.SkillGroupID]horse.SkillGroup + autocom autocomplete.Set[discord.AutocompleteChoice] +} + +func newSkillServer(skills []horse.Skill, groups []horse.SkillGroup) *skillServer { + s := skillServer{ + skills: make(map[horse.SkillID]horse.Skill, len(skills)), + byName: make(map[string]horse.SkillID, len(skills)), + groups: make(map[horse.SkillGroupID]horse.SkillGroup, len(groups)), + } + for _, skill := range skills { + s.skills[skill.ID] = skill + s.byName[skill.Name] = skill.ID + s.autocom.Add(skill.Name, discord.AutocompleteChoiceString{Name: skill.Name, Value: skill.Name}) + if skill.UniqueOwner != "" { + if skill.Rarity >= 3 { + s.autocom.Add(skill.UniqueOwner, discord.AutocompleteChoiceString{Name: "Unique: " + skill.UniqueOwner, Value: skill.Name}) + } else { + s.autocom.Add(skill.UniqueOwner, discord.AutocompleteChoiceString{Name: "Inherited unique: " + skill.UniqueOwner, Value: skill.Name}) + } + } + } + for _, g := range groups { + s.groups[g.ID] = g + } + return &s +} + +func (s *skillServer) slash(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + q := data.String("query") + id, err := strconv.ParseInt(q, 10, 32) + if err == nil { + // note inverted condition; this is when we have an id + id = int64(s.skills[horse.SkillID(id)].ID) + } + if id == 0 { + // Either we weren't given a number or the number doesn't match any skill ID. + v := s.byName[q] + if v == 0 { + // No such skill. + m := discord.MessageCreate{ + Content: "No such skill.", + Flags: discord.MessageFlagEphemeral, + } + return e.CreateMessage(m) + } + id = int64(v) + } + m := discord.MessageCreate{ + Components: []discord.LayoutComponent{s.render(horse.SkillID(id))}, + Flags: discord.MessageFlagIsComponentsV2, + } + return e.CreateMessage(m) +} + +func (s *skillServer) autocomplete(e *handler.AutocompleteEvent) error { + q := e.Data.String("query") + opts := s.autocom.Find(nil, q) + return e.AutocompleteResult(opts[:min(len(opts), 25)]) +} + +func (s *skillServer) button(data discord.ButtonInteractionData, e *handler.ComponentEvent) error { + id, err := strconv.ParseInt(e.Vars["id"], 10, 32) + if err != nil { + m := discord.MessageCreate{ + Content: "That button produced an invalid skill ID. That's not supposed to happen.", + Flags: discord.MessageFlagEphemeral, + } + return e.CreateMessage(m) + } + m := discord.MessageUpdate{ + Components: &[]discord.LayoutComponent{s.render(horse.SkillID(id))}, + } + return e.UpdateMessage(m) +} + +func (s *skillServer) render(id horse.SkillID) discord.ContainerComponent { + skill, ok := s.skills[id] if !ok { return discord.NewContainer(discord.NewTextDisplayf("invalid skill ID %v made it to RenderSkill", id)) } - thumburl := fmt.Sprintf("https://gametora.com/images/umamusume/skill_icons/utx_ico_skill_%d.png", s.IconID) - top := "## " + s.Name - if s.UniqueOwner != "" { - top += "\n-# " + s.UniqueOwner + thumburl := fmt.Sprintf("https://gametora.com/images/umamusume/skill_icons/utx_ico_skill_%d.png", skill.IconID) + top := "## " + skill.Name + if skill.UniqueOwner != "" { + top += "\n-# " + skill.UniqueOwner } r := discord.NewContainer( discord.NewSection( discord.NewTextDisplay(top), - discord.NewTextDisplay(s.Description), + discord.NewTextDisplay(skill.Description), ).WithAccessory(discord.NewThumbnail(thumburl)), ) var skilltype string switch { - case s.Rarity == 3, s.Rarity == 4, s.Rarity == 5: + case skill.Rarity == 3, skill.Rarity == 4, skill.Rarity == 5: // unique of various star levels r.AccentColor = 0xaca4d4 skilltype = "Unique Skill" - case s.UniqueOwner != "": + case skill.UniqueOwner != "": r.AccentColor = 0xcccccc skilltype = "Inherited Unique" - case s.Rarity == 2: + case skill.Rarity == 2: // rare (gold) r.AccentColor = 0xd7c25b skilltype = "Rare Skill" - case s.GroupRate == -1: + case skill.GroupRate == -1: // negative (purple) skill r.AccentColor = 0x9151d4 skilltype = "Negative Skill" - case !s.WitCheck: + case !skill.WitCheck: // should be passive (green) r.AccentColor = 0x66ae1c skilltype = "Passive Skill" - case isDebuff(s): + case isDebuff(skill): // debuff (red) r.AccentColor = 0xe34747 skilltype = "Debuff Skill" - case s.Rarity == 1: + case skill.Rarity == 1: // common (white) r.AccentColor = 0xcccccc skilltype = "Common Skill" @@ -61,7 +141,7 @@ func RenderSkill(id horse.SkillID, all map[horse.SkillID]horse.Skill, groups map r.Components = append(r.Components, discord.NewSmallSeparator()) text := make([]string, 0, 3) abils := make([]string, 0, 3) - for _, act := range s.Activations { + for _, act := range skill.Activations { text, abils = text[:0], abils[:0] if act.Precondition != "" { text = append(text, "Precondition: "+formatCondition(act.Precondition)) @@ -91,13 +171,13 @@ func RenderSkill(id horse.SkillID, all map[horse.SkillID]horse.Skill, groups map r.Components = append(r.Components, discord.NewTextDisplay(strings.Join(text, "\n"))) } - l := discord.NewTextDisplayf("%s ・ SP cost %d ・ Grade value %d ・ [Conditions on GameTora](https://gametora.com/umamusume/skill-condition-viewer?skill=%d)", skilltype, s.SPCost, s.GradeValue, s.ID) + l := discord.NewTextDisplayf("%s ・ SP cost %d ・ Grade value %d ・ [Conditions on GameTora](https://gametora.com/umamusume/skill-condition-viewer?skill=%d)", skilltype, skill.SPCost, skill.GradeValue, skill.ID) r.Components = append(r.Components, discord.NewSmallSeparator(), l) rel := make([]horse.Skill, 0, 4) - group := groups[s.Group] + group := s.groups[skill.Group] for _, id := range [...]horse.SkillID{group.Skill1, group.Skill2, group.Skill3, group.SkillBad} { if id != 0 { - rel = append(rel, all[id]) + rel = append(rel, s.skills[id]) } } if len(rel) > 1 { @@ -132,19 +212,3 @@ func isDebuff(s horse.Skill) bool { } return false } - -var skillGlobalAuto = sync.OnceValue(func() *autocomplete.Set[discord.AutocompleteChoice] { - var set autocomplete.Set[discord.AutocompleteChoice] - // NOTE(zeph): we're using global variables here - for _, s := range skillsByID { - set.Add(s.Name, discord.AutocompleteChoiceString{Name: s.Name, Value: s.Name}) - if s.UniqueOwner != "" { - if s.Rarity >= 3 { - set.Add(s.UniqueOwner, discord.AutocompleteChoiceString{Name: "Unique: " + s.UniqueOwner, Value: s.Name}) - } else { - set.Add(s.UniqueOwner, discord.AutocompleteChoiceString{Name: "Inherited unique: " + s.UniqueOwner, Value: s.Name}) - } - } - } - return &set -})