Compare commits

...

26 Commits

Author SHA1 Message Date
0fc68e9bd8 update horse 2026-02-10 07:59:11 -05:00
50b98be6d3 update horse 2026-02-05 16:29:35 -05:00
30d4eeea40 update horse 2026-02-01 15:33:58 -05:00
f0c5e19870 update data 2026-01-30 10:23:16 -05:00
c0abf72041 clarify unique skill autocomplete by character 2026-01-24 00:23:33 -05:00
780002b35f include unique owners in autocomplete options 2026-01-24 00:00:01 -05:00
1dae50a918 mention owners of uniques 2026-01-23 23:57:11 -05:00
f4f563c530 don't deduplicate autocomplete keys 2026-01-23 17:32:50 -05:00
ff20bbef2c construct autocomplete interface objects once 2026-01-23 16:49:54 -05:00
c5c733d14c use fzf for autocomplete 2026-01-23 15:23:36 -05:00
2517d0cb37 update horse data 2026-01-22 23:08:38 -05:00
f3698f0fc6 better autocomplete 2026-01-22 22:24:24 -05:00
950662e0b9 implement basic autocomplete package 2026-01-22 22:06:29 -05:00
67ad8488a5 update for tamamo cross 2026-01-22 09:33:59 -05:00
9cc0e21dcb update horse data 2026-01-18 15:27:08 -05:00
a8ad64f5a4 include link to gametora skill conditions viewer 2026-01-17 19:29:15 -05:00
667144d0ab actually start http server 2026-01-17 17:55:57 -05:00
5909c36582 cleaner logging 2026-01-17 16:44:49 -05:00
dd5c8aa44f update readme 2026-01-17 14:11:52 -05:00
20511a02a9 update go version 2026-01-17 14:07:24 -05:00
128d16a32f add http api 2026-01-17 13:59:15 -05:00
d22d032152 don't show cooldown on passives 2026-01-17 00:39:24 -05:00
2e5f923831 decoration for debuffs 2026-01-17 00:12:51 -05:00
2aebd0144b simplify constructing skill container 2026-01-16 23:56:28 -05:00
e712263d13 disable button for currently shown skill 2026-01-16 23:41:13 -05:00
f5e26e5036 add related skill buttons 2026-01-16 23:01:05 -05:00
8 changed files with 359 additions and 74 deletions

View File

@@ -1,3 +1,10 @@
# horsebot # horsebot
Discord bot serving horse game data. Discord bot serving horse game data.
Production instance is named Zenno Rob Roy, because she has read all about Umamusume and is always happy to share her knowledge and give recommendations.
## Running
The bot always uses the Gateway API.
If the `-http` argument is provided, it will also use the HTTP API, and `-key` must also be provided.

View File

@@ -0,0 +1,56 @@
package autocomplete
import (
"bytes"
"cmp"
"slices"
"sync"
"github.com/junegunn/fzf/src/algo"
"github.com/junegunn/fzf/src/util"
)
// Set is an autocomplete set.
type Set[V any] struct {
keys []util.Chars
vals []V
}
// Add associates a value with a key in the autocomplete set.
// The behavior is undefined if the key already has a value.
func (s *Set[V]) Add(key string, val V) {
k := util.ToChars([]byte(key))
i, _ := slices.BinarySearchFunc(s.keys, k, func(a, b util.Chars) int {
return bytes.Compare(a.Bytes(), b.Bytes())
})
s.keys = slices.Insert(s.keys, i, k)
s.vals = slices.Insert(s.vals, i, val)
}
// Find appends to r all values in the set with keys that key matches.
func (s *Set[V]) Find(r []V, key string) []V {
initFzf()
var (
p = []rune(key)
got []V
t []algo.Result
slab util.Slab
)
for i := range s.keys {
res, _ := algo.FuzzyMatchV2(false, true, true, &s.keys[i], p, false, &slab)
if res.Score <= 0 {
continue
}
j, _ := slices.BinarySearchFunc(t, res, func(a, b algo.Result) int { return -cmp.Compare(a.Score, b.Score) })
// Insert after all other matches with the same score for stability.
for j < len(t) && t[j].Score == res.Score {
j++
}
t = slices.Insert(t, j, res)
got = slices.Insert(got, j, s.vals[i])
}
return append(r, got...)
}
var initFzf = sync.OnceFunc(func() { algo.Init("default") })

View File

@@ -0,0 +1,70 @@
package autocomplete_test
import (
"slices"
"testing"
"git.sunturtle.xyz/zephyr/horsebot/autocomplete"
)
func these(s ...string) []string { return s }
func TestAutocomplete(t *testing.T) {
cases := []struct {
name string
add []string
search string
want []string
}{
{
name: "empty",
add: nil,
search: "",
want: nil,
},
{
name: "exact",
add: these("bocchi"),
search: "bocchi",
want: these("bocchi"),
},
{
name: "extra",
add: these("bocchi", "ryo", "nijika", "kita"),
search: "bocchi",
want: these("bocchi"),
},
{
name: "short",
add: these("bocchi", "ryo", "nijika", "kita"),
search: "o",
want: these("bocchi", "ryo"),
},
{
name: "unrelated",
add: these("bocchi", "ryo", "nijika", "kita"),
search: "x",
want: nil,
},
{
name: "map",
add: these("Corazón ☆ Ardiente"),
search: "corazo",
want: these("Corazón ☆ Ardiente"),
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var set autocomplete.Set[string]
for _, s := range c.add {
set.Add(s, s)
}
got := set.Find(nil, c.search)
slices.Sort(c.want)
slices.Sort(got)
if !slices.Equal(c.want, got) {
t.Errorf("wrong results: want %q, got %q", c.want, got)
}
})
}
}

7
go.mod
View File

@@ -1,10 +1,11 @@
module git.sunturtle.xyz/zephyr/horsebot module git.sunturtle.xyz/zephyr/horsebot
go 1.24.1 go 1.25.5
require ( require (
git.sunturtle.xyz/zephyr/horse v0.0.0-20260116044610-b0c555f547e4 git.sunturtle.xyz/zephyr/horse v0.0.0-20260210125822-b55e1bc200a1
github.com/disgoorg/disgo v0.19.0-rc.15 github.com/disgoorg/disgo v0.19.0-rc.15
github.com/junegunn/fzf v0.67.0
) )
require ( require (
@@ -13,6 +14,8 @@ require (
github.com/disgoorg/snowflake/v2 v2.0.3 // indirect github.com/disgoorg/snowflake/v2 v2.0.3 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/compress v1.18.2 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad // indirect github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad // indirect
golang.org/x/crypto v0.46.0 // indirect golang.org/x/crypto v0.46.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect

11
go.sum
View File

@@ -1,5 +1,5 @@
git.sunturtle.xyz/zephyr/horse v0.0.0-20260116044610-b0c555f547e4 h1:YJEGZG/EnxE5Tr6EZMGxikXDF6QKKHrH9Bp1dLoWXkk= git.sunturtle.xyz/zephyr/horse v0.0.0-20260210125822-b55e1bc200a1 h1:nIk5Mis384wx/ndMa/3zSr1omkWK8rg4I/Z46BCAIe8=
git.sunturtle.xyz/zephyr/horse v0.0.0-20260116044610-b0c555f547e4/go.mod h1:qGXO/93EfCOI1oGSLqrRkPDF/EAdsgLNZJjRKx+i4Lk= git.sunturtle.xyz/zephyr/horse v0.0.0-20260210125822-b55e1bc200a1/go.mod h1:qGXO/93EfCOI1oGSLqrRkPDF/EAdsgLNZJjRKx+i4Lk=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disgoorg/disgo v0.19.0-rc.15 h1:x0NsV2gcbdjwuztsg2wYXw76p1Cpc8f6ByDrkPcfQtU= github.com/disgoorg/disgo v0.19.0-rc.15 h1:x0NsV2gcbdjwuztsg2wYXw76p1Cpc8f6ByDrkPcfQtU=
@@ -12,16 +12,23 @@ github.com/disgoorg/snowflake/v2 v2.0.3 h1:3B+PpFjr7j4ad7oeJu4RlQ+nYOTadsKapJIzg
github.com/disgoorg/snowflake/v2 v2.0.3/go.mod h1:W6r7NUA7DwfZLwr00km6G4UnZ0zcoLBRufhkFWgAc4c= github.com/disgoorg/snowflake/v2 v2.0.3/go.mod h1:W6r7NUA7DwfZLwr00km6G4UnZ0zcoLBRufhkFWgAc4c=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/junegunn/fzf v0.67.0 h1:naiOdIkV5/ZCfHgKQIV/f5YDWowl95G6yyOQqW8FeSo=
github.com/junegunn/fzf v0.67.0/go.mod h1:xlXX2/rmsccKQUnr9QOXPDi5DyV9cM0UjKy/huScBeE=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad h1:qIQkSlF5vAUHxEmTbaqt1hkJ/t6skqEGYiMag343ucI= github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad h1:qIQkSlF5vAUHxEmTbaqt1hkJ/t6skqEGYiMag343ucI=
github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad/go.mod h1:/pA7k3zsXKdjjAiUhB5CjuKib9KJGCaLvZwtxGC8U0s= github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad/go.mod h1:/pA7k3zsXKdjjAiUhB5CjuKib9KJGCaLvZwtxGC8U0s=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

55
log.go Normal file
View File

@@ -0,0 +1,55 @@
package main
import (
"log/slog"
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/handler"
)
func logMiddleware(next handler.Handler) handler.Handler {
return func(e *handler.InteractionEvent) error {
var msg string
attrs := make([]slog.Attr, 0, 8)
attrs = append(attrs,
slog.Uint64("interaction", uint64(e.Interaction.ID())),
slog.Uint64("user", uint64(e.Interaction.User().ID)),
)
if guild := e.Interaction.GuildID(); guild != nil {
attrs = append(attrs, slog.String("guild", guild.String()))
}
switch i := e.Interaction.(type) {
case discord.ApplicationCommandInteraction:
msg = "command"
attrs = append(attrs,
slog.String("name", i.Data.CommandName()),
slog.Int("type", int(i.Data.Type())),
)
switch data := i.Data.(type) {
case discord.SlashCommandInteractionData:
attrs = append(attrs, slog.String("path", data.CommandPath()))
}
case discord.AutocompleteInteraction:
msg = "autocomplete"
attrs = append(attrs,
slog.String("name", i.Data.CommandName),
slog.String("path", i.Data.CommandPath()),
slog.String("focus", i.Data.Focused().Name),
)
case discord.ComponentInteraction:
msg = "component"
attrs = append(attrs,
slog.Int("type", int(i.Data.Type())),
slog.String("custom", i.Data.CustomID()),
)
default:
slog.WarnContext(e.Ctx, "unknown interaction", slog.Any("event", e))
return nil
}
slog.LogAttrs(e.Ctx, slog.LevelInfo, msg, attrs...)
return next(e)
}
}

99
main.go
View File

@@ -4,11 +4,11 @@ import (
"bytes" "bytes"
"context" "context"
"flag" "flag"
"fmt"
"log/slog" "log/slog"
"os" "os"
"os/signal" "os/signal"
"strconv" "strconv"
"strings"
"time" "time"
"git.sunturtle.xyz/zephyr/horse/horse" "git.sunturtle.xyz/zephyr/horse/horse"
@@ -18,20 +18,41 @@ import (
"github.com/disgoorg/disgo/discord" "github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/handler" "github.com/disgoorg/disgo/handler"
"github.com/disgoorg/disgo/handler/middleware" "github.com/disgoorg/disgo/handler/middleware"
"github.com/disgoorg/disgo/httpserver"
"github.com/disgoorg/disgo/rest" "github.com/disgoorg/disgo/rest"
) )
func main() { func main() {
var ( var (
appID string
pubkey string
tokenFile string tokenFile string
// http api options
addr string
route string
pubkey string
// logging options
level slog.Level
textfmt string
) )
flag.StringVar(&appID, "id", "", "Discord application ID")
flag.StringVar(&pubkey, "key", "", "Discord public key")
flag.StringVar(&tokenFile, "token", "", "`file` containing the Discord bot token") flag.StringVar(&tokenFile, "token", "", "`file` containing the Discord bot token")
flag.StringVar(&addr, "http", "", "`address` to bind HTTP API server")
flag.StringVar(&route, "route", "/interactions/callback", "`path` to serve HTTP API calls")
flag.StringVar(&pubkey, "key", "", "Discord public key")
flag.TextVar(&level, "log", slog.LevelInfo, "slog logging `level`")
flag.StringVar(&textfmt, "log-format", "text", "slog logging `format`, text or json")
flag.Parse() flag.Parse()
var lh slog.Handler
switch textfmt {
case "text":
lh = slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})
case "json":
lh = slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: level})
default:
fmt.Fprintf(os.Stderr, "invalid log format %q, must be text or json", textfmt)
os.Exit(1)
}
slog.SetDefault(slog.New(lh))
token, err := os.ReadFile(tokenFile) token, err := os.ReadFile(tokenFile)
if err != nil { if err != nil {
slog.Error("reading token", slog.Any("err", err)) slog.Error("reading token", slog.Any("err", err))
@@ -44,15 +65,27 @@ func main() {
r := handler.New() r := handler.New()
r.DefaultContext(func() context.Context { return ctx }) r.DefaultContext(func() context.Context { return ctx })
r.Use(middleware.Go) r.Use(middleware.Go)
r.Use(middleware.Logger) r.Use(logMiddleware)
r.Route("/skill", func(r handler.Router) { r.Route("/skill", func(r handler.Router) {
r.SlashCommand("/", skillHandler) r.SlashCommand("/", skillHandler)
r.Autocomplete("/", skillAutocomplete) r.Autocomplete("/", skillAutocomplete)
// TODO(zeph): button handler r.ButtonComponent("/{id}", skillButton)
}) })
opts := []bot.ConfigOpt{bot.WithDefaultGateway(), bot.WithEventListeners(r)}
if addr != "" {
if pubkey == "" {
slog.Error("Discord public key must be provided when using HTTP API")
os.Exit(1)
}
opts = append(opts, bot.WithHTTPServerConfigOpts(pubkey,
httpserver.WithAddress(addr),
httpserver.WithURL(route),
))
}
slog.Info("connect", slog.String("disgo", disgo.Version)) slog.Info("connect", slog.String("disgo", disgo.Version))
client, err := disgo.New(string(token), bot.WithDefaultGateway(), bot.WithEventListeners(r)) client, err := disgo.New(string(token), opts...)
if err != nil { if err != nil {
slog.Error("building bot", slog.Any("err", err)) slog.Error("building bot", slog.Any("err", err))
os.Exit(1) os.Exit(1)
@@ -63,11 +96,19 @@ func main() {
os.Exit(1) os.Exit(1)
} }
if err := client.OpenGateway(ctx); err != nil { if addr != "" {
slog.Error("opening gateway", slog.Any("err", err)) slog.Info("start HTTP server", slog.String("address", addr), slog.String("route", route))
if err := client.OpenHTTPServer(); err != nil {
slog.Error("starting HTTP server", slog.Any("err", err))
stop() stop()
} }
slog.Info("running") }
slog.Info("start gateway")
if err := client.OpenGateway(ctx); err != nil {
slog.Error("starting gateway", slog.Any("err", err))
stop()
}
slog.Info("ready")
<-ctx.Done() <-ctx.Done()
stop() stop()
@@ -94,15 +135,14 @@ var commands = []discord.ApplicationCommandCreate{
func skillHandler(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error { func skillHandler(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error {
q := data.String("query") q := data.String("query")
id, err := strconv.ParseInt(q, 10, 32) id, err := strconv.ParseInt(q, 10, 32)
var s horse.Skill
if err == nil { if err == nil {
// note inverted condition; this is when we have an id // note inverted condition; this is when we have an id
s = global.AllSkills[horse.SkillID(id)] id = int64(global.AllSkills[horse.SkillID(id)].ID)
} }
if s.ID == 0 { if id == 0 {
// Either we weren't given a number or the number doesn't match any skill ID. // Either we weren't given a number or the number doesn't match any skill ID.
id, ok := global.SkillNameToID[q] v := global.SkillNameToID[q]
if !ok { if v == 0 {
// No such skill. // No such skill.
m := discord.MessageCreate{ m := discord.MessageCreate{
Content: "No such skill.", Content: "No such skill.",
@@ -110,26 +150,33 @@ func skillHandler(data discord.SlashCommandInteractionData, e *handler.CommandEv
} }
return e.CreateMessage(m) return e.CreateMessage(m)
} }
s = global.AllSkills[id] id = int64(v)
} }
// TODO(zeph): search conditions and effects, give a list // TODO(zeph): search conditions and effects, give a list
m := discord.MessageCreate{ m := discord.MessageCreate{
Components: []discord.LayoutComponent{RenderSkill(s)}, Components: []discord.LayoutComponent{RenderSkill(horse.SkillID(id), global.AllSkills, global.SkillGroups)},
Flags: discord.MessageFlagIsComponentsV2, Flags: discord.MessageFlagIsComponentsV2,
} }
return e.CreateMessage(m) return e.CreateMessage(m)
} }
func skillAutocomplete(e *handler.AutocompleteEvent) error { func skillAutocomplete(e *handler.AutocompleteEvent) error {
q := strings.ToLower(e.Data.String("query")) q := e.Data.String("query")
r := make([]discord.AutocompleteChoice, 0, 25) opts := skillGlobalAuto().Find(nil, q)
for k, _ := range global.SkillNameToID { return e.AutocompleteResult(opts[:min(len(opts), 25)])
if strings.HasPrefix(strings.ToLower(k), q) {
r = append(r, discord.AutocompleteChoiceString{Name: k, Value: k})
if len(r) == cap(r) {
break
} }
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)
} }
return e.AutocompleteResult(r) m := discord.MessageUpdate{
Components: &[]discord.LayoutComponent{RenderSkill(horse.SkillID(id), global.AllSkills, global.SkillGroups)},
}
return e.UpdateMessage(m)
} }

124
skill.go
View File

@@ -3,59 +3,65 @@ package main
import ( import (
"fmt" "fmt"
"strings" "strings"
"sync"
"git.sunturtle.xyz/zephyr/horse/horse" "git.sunturtle.xyz/zephyr/horse/horse"
"git.sunturtle.xyz/zephyr/horse/horse/global"
"git.sunturtle.xyz/zephyr/horsebot/autocomplete"
"github.com/disgoorg/disgo/discord" "github.com/disgoorg/disgo/discord"
) )
func RenderSkill(s horse.Skill) discord.ContainerComponent { func RenderSkill(id horse.SkillID, all map[horse.SkillID]horse.Skill, groups map[int32][4]horse.SkillID) discord.ContainerComponent {
skilltype := discord.TextDisplayComponent{ s, ok := all[id]
ID: 4, if !ok {
Content: "Skill Issue", return discord.NewContainer(discord.NewTextDisplayf("invalid skill ID %v made it to RenderSkill", id))
} }
r := discord.ContainerComponent{
ID: 1, // specify ids so we guarantee we don't get collisions with related skill buttons thumburl := fmt.Sprintf("https://gametora.com/images/umamusume/skill_icons/utx_ico_skill_%d.png", s.IconID)
Components: []discord.ContainerSubComponent{ top := "## " + s.Name
discord.SectionComponent{ if s.UniqueOwner != "" {
ID: 2, top += "\n-# " + s.UniqueOwner
Components: []discord.SectionSubComponent{
discord.TextDisplayComponent{
ID: 3,
Content: "## " + s.Name + "\n" + s.Description,
},
&skilltype, // doing something evil :)
},
Accessory: discord.NewThumbnail(fmt.Sprintf("https://gametora.com/images/umamusume/skill_icons/utx_ico_skill_%d.png", s.IconID)),
},
},
} }
r := discord.NewContainer(
discord.NewSection(
discord.NewTextDisplay(top),
discord.NewTextDisplay(s.Description),
).WithAccessory(discord.NewThumbnail(thumburl)),
)
var skilltype string
switch { switch {
case s.Rarity == 3, s.Rarity == 4, s.Rarity == 5: case s.Rarity == 3, s.Rarity == 4, s.Rarity == 5:
// unique of various star levels // unique of various star levels
r.AccentColor = 0xaca4d4 r.AccentColor = 0xaca4d4
skilltype.Content = "Unique Skill" skilltype = "Unique Skill"
case s.UniqueOwner != "":
r.AccentColor = 0xcccccc
skilltype = "Inherited Unique"
case s.Rarity == 2: case s.Rarity == 2:
// rare (gold) // rare (gold)
r.AccentColor = 0xd7c25b r.AccentColor = 0xd7c25b
skilltype.Content = "Rare Skill" skilltype = "Rare Skill"
case s.GroupRate == -1: case s.GroupRate == -1:
// negative (purple) skill // negative (purple) skill
r.AccentColor = 0x9151d4 r.AccentColor = 0x9151d4
skilltype.Content = "Negative Skill" skilltype = "Negative Skill"
case !s.WitCheck: case !s.WitCheck:
// should be passive (green) // should be passive (green)
r.AccentColor = 0x66ae1c r.AccentColor = 0x66ae1c
skilltype.Content = "Passive Skill" skilltype = "Passive Skill"
// TODO(zeph): debuff (red) case isDebuff(s):
// debuff (red)
r.AccentColor = 0xe34747
skilltype = "Debuff Skill"
case s.Rarity == 1: case s.Rarity == 1:
// common (white) // common (white)
r.AccentColor = 0xcccccc r.AccentColor = 0xcccccc
skilltype.Content = "Common Skill" skilltype = "Common Skill"
} }
r.Components = append(r.Components, discord.SeparatorComponent{ID: 5, Spacing: discord.SeparatorSpacingSizeSmall}) r.Components = append(r.Components, discord.NewSmallSeparator())
text := make([]string, 0, 3) text := make([]string, 0, 3)
abils := make([]string, 0, 3) abils := make([]string, 0, 3)
for i, act := range s.Activations { for _, act := range s.Activations {
text, abils = text[:0], abils[:0] text, abils = text[:0], abils[:0]
if act.Precondition != "" { if act.Precondition != "" {
text = append(text, "Precondition: "+formatCondition(act.Precondition)) text = append(text, "Precondition: "+formatCondition(act.Precondition))
@@ -70,27 +76,38 @@ func RenderSkill(s horse.Skill) discord.ContainerComponent {
case act.Duration >= 500e4: case act.Duration >= 500e4:
t = "Permanent " t = "Permanent "
default: default:
t = act.Duration.String() + "s " t = "For " + act.Duration.String() + "s, "
} }
for _, a := range act.Abilities { for _, a := range act.Abilities {
abils = append(abils, a.String()) abils = append(abils, a.String())
} }
t += strings.Join(abils, ", ") t += strings.Join(abils, ", ")
if act.Cooldown < 500e4 { if act.Cooldown > 0 && act.Cooldown < 500e4 {
t += " on " + act.Cooldown.String() + "s cooldown" t += " on " + act.Cooldown.String() + "s cooldown"
} }
text = append(text, t) text = append(text, t)
r.Components = append(r.Components, discord.TextDisplayComponent{ r.Components = append(r.Components, discord.NewTextDisplay(strings.Join(text, "\n")))
ID: 10 + i, }
Content: 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)
r.Components = append(r.Components, discord.NewSmallSeparator(), l)
rel := make([]horse.Skill, 0, 4)
for _, id := range groups[s.Group] {
if id != 0 {
rel = append(rel, all[id])
}
}
if len(rel) > 1 {
buttons := make([]discord.InteractiveComponent, 0, 4)
for _, rs := range rel {
b := discord.NewSecondaryButton(rs.Name, fmt.Sprintf("/skill/%d", rs.ID))
if rs.ID == id {
b = b.AsDisabled()
}
buttons = append(buttons, b)
}
r.Components = append(r.Components, discord.NewActionRow(buttons...))
} }
r.Components = append(r.Components, discord.SeparatorComponent{ID: 50, Spacing: discord.SeparatorSpacingSizeSmall})
r.Components = append(r.Components, discord.TextDisplayComponent{
ID: 51,
Content: fmt.Sprintf("SP cost %d. Grade value %d.", s.SPCost, s.GradeValue),
})
// TODO(zeph): related skills, use a row of buttons with skill numbers for the ids and edit the message when clicked?
return r return r
} }
@@ -102,6 +119,29 @@ func formatCondition(s string) string {
return "`" + s + "`" return "`" + s + "`"
} }
// TODO(zeph): autocomplete func isDebuff(s horse.Skill) bool {
// if we want to backgroundify construction of an autocomplete map, for _, act := range s.Activations {
// use sync.OnceValue and launch a goroutine in main to get the value and discard it for _, a := range act.Abilities {
if a.Value < 0 {
return true
}
}
}
return false
}
var skillGlobalAuto = sync.OnceValue(func() *autocomplete.Set[discord.AutocompleteChoice] {
var set autocomplete.Set[discord.AutocompleteChoice]
for _, id := range global.OrderedSkills {
s := global.AllSkills[id]
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
})