Compare commits
3 Commits
67ad8488a5
...
2517d0cb37
| Author | SHA1 | Date | |
|---|---|---|---|
| 2517d0cb37 | |||
| f3698f0fc6 | |||
| 950662e0b9 |
@@ -7,4 +7,4 @@ Production instance is named Zenno Rob Roy, because she has read all about Umamu
|
||||
## Running
|
||||
|
||||
The bot always uses the Gateway API.
|
||||
If the `-http` argument is provided, it will also use the HTTP API, and `-token` must also be provided.
|
||||
If the `-http` argument is provided, it will also use the HTTP API, and `-key` must also be provided.
|
||||
|
||||
122
autocomplete/autocomplete.go
Normal file
122
autocomplete/autocomplete.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package autocomplete
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Set is an autocomplete set.
|
||||
type Set struct {
|
||||
mask []uint64
|
||||
keys [][]string
|
||||
vals [][]string
|
||||
}
|
||||
|
||||
var (
|
||||
test [2]uint64
|
||||
idxs [256]byte
|
||||
)
|
||||
|
||||
const careset = " 0123456789abcdefghijklmnopqrstuvwxyz.!'"
|
||||
|
||||
// Certify the size of careset at compile time.
|
||||
// It must be no larger than 64.
|
||||
var _ [0]struct{} = [len(careset) - 40]struct{}{}
|
||||
|
||||
func init() {
|
||||
// Construct the test and idxs maps.
|
||||
for i, c := range []byte(careset) {
|
||||
if c < 64 {
|
||||
test[0] |= 1 << c
|
||||
} else {
|
||||
test[1] |= 1 << (c - 64)
|
||||
}
|
||||
idxs[c] = byte(i)
|
||||
}
|
||||
}
|
||||
|
||||
func normalize(s string) string {
|
||||
s = strings.Map(func(c rune) rune {
|
||||
c = unicode.ToLower(c)
|
||||
// TODO(zeph): map latin letters with diacritics to their not-diacritic
|
||||
// characters; e.g. 2* pasta's unique Corazón ☆ Ardiente should have
|
||||
// ó mapped to o. for now, we just special-case it.
|
||||
if c == 'ó' {
|
||||
c = 'o'
|
||||
}
|
||||
return c
|
||||
}, s)
|
||||
return s
|
||||
}
|
||||
|
||||
func filter(s string) (r uint64) {
|
||||
for _, c := range s {
|
||||
if c > 0x7f {
|
||||
// Skip non-ASCII characters.
|
||||
continue
|
||||
}
|
||||
if test[byte(c)/64]>>(byte(c)%64)&1 == 0 {
|
||||
// Not in the care set.
|
||||
continue
|
||||
}
|
||||
r |= 1 << idxs[byte(c)]
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// 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) Add(key, val string) {
|
||||
key = normalize(key)
|
||||
m := filter(key)
|
||||
i, ok := slices.BinarySearch(s.mask, m)
|
||||
if !ok {
|
||||
s.mask = slices.Insert(s.mask, i, m)
|
||||
s.keys = slices.Insert(s.keys, i, nil)
|
||||
s.vals = slices.Insert(s.vals, i, nil)
|
||||
}
|
||||
j, _ := slices.BinarySearch(s.keys[i], key)
|
||||
s.keys[i] = slices.Insert(s.keys[i], j, key)
|
||||
s.vals[i] = slices.Insert(s.vals[i], j, val)
|
||||
}
|
||||
|
||||
// Find appends to r all values in the set with keys that key matches.
|
||||
func (s *Set) Find(r []string, key string) []string {
|
||||
key = normalize(key)
|
||||
m := filter(key)
|
||||
for i, v := range s.mask {
|
||||
if m&v != m {
|
||||
continue
|
||||
}
|
||||
for j, k := range s.keys[i] {
|
||||
if inorder(key, k) {
|
||||
r = append(r, s.vals[i][j])
|
||||
}
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// inorder checks whether each character in a appears in the same relative order in b.
|
||||
func inorder(a, b string) bool {
|
||||
for _, c := range a {
|
||||
k := strings.IndexRune(b, c)
|
||||
if k < 0 {
|
||||
return false
|
||||
}
|
||||
_, l := utf8.DecodeRuneInString(b[k:])
|
||||
b = b[k+l:]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Metrics gets the number of buckets in the autocomplete set and the length
|
||||
// of the longest bucket.
|
||||
func (s *Set) Metrics() (buckets, longest int) {
|
||||
for _, b := range s.keys {
|
||||
longest = max(longest, len(b))
|
||||
}
|
||||
return len(s.keys), longest
|
||||
}
|
||||
64
autocomplete/autocomplete_test.go
Normal file
64
autocomplete/autocomplete_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
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: "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
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
2
go.mod
2
go.mod
@@ -3,7 +3,7 @@ module git.sunturtle.xyz/zephyr/horsebot
|
||||
go 1.25.6
|
||||
|
||||
require (
|
||||
git.sunturtle.xyz/zephyr/horse v0.0.0-20260122143238-1ae654c266b7
|
||||
git.sunturtle.xyz/zephyr/horse v0.0.0-20260123040553-4bfb06b6826f
|
||||
github.com/disgoorg/disgo v0.19.0-rc.15
|
||||
)
|
||||
|
||||
|
||||
6
go.sum
6
go.sum
@@ -1,7 +1,5 @@
|
||||
git.sunturtle.xyz/zephyr/horse v0.0.0-20260118202043-d147d71519e4 h1:3m5C5/9TX4BcW8z8gQAC0+zbsb+jLtJ+VlJk3UibY2s=
|
||||
git.sunturtle.xyz/zephyr/horse v0.0.0-20260118202043-d147d71519e4/go.mod h1:qGXO/93EfCOI1oGSLqrRkPDF/EAdsgLNZJjRKx+i4Lk=
|
||||
git.sunturtle.xyz/zephyr/horse v0.0.0-20260122143238-1ae654c266b7 h1:fneQyGAkeefCemRtuvTO32MIwkcYm3ajeIS6hfXrAno=
|
||||
git.sunturtle.xyz/zephyr/horse v0.0.0-20260122143238-1ae654c266b7/go.mod h1:qGXO/93EfCOI1oGSLqrRkPDF/EAdsgLNZJjRKx+i4Lk=
|
||||
git.sunturtle.xyz/zephyr/horse v0.0.0-20260123040553-4bfb06b6826f h1:S287bkj7whgs5NY0u3ih4Ggy+2LTrnfHY3K/smfv9dU=
|
||||
git.sunturtle.xyz/zephyr/horse v0.0.0-20260123040553-4bfb06b6826f/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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/disgoorg/disgo v0.19.0-rc.15 h1:x0NsV2gcbdjwuztsg2wYXw76p1Cpc8f6ByDrkPcfQtU=
|
||||
|
||||
22
main.go
22
main.go
@@ -9,7 +9,6 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sunturtle.xyz/zephyr/horse/horse"
|
||||
@@ -110,6 +109,13 @@ func main() {
|
||||
stop()
|
||||
}
|
||||
slog.Info("ready")
|
||||
go func() {
|
||||
// Preload autocomplete in a separate goroutine.
|
||||
slog.Info("begin computing skill autocomplete")
|
||||
set := skillGlobalAuto()
|
||||
buckets, longest := set.Metrics()
|
||||
slog.Info("done computing skill autocomplete", slog.Int("buckets", buckets), slog.Int("longest", longest))
|
||||
}()
|
||||
<-ctx.Done()
|
||||
stop()
|
||||
|
||||
@@ -162,15 +168,11 @@ func skillHandler(data discord.SlashCommandInteractionData, e *handler.CommandEv
|
||||
}
|
||||
|
||||
func skillAutocomplete(e *handler.AutocompleteEvent) error {
|
||||
q := strings.ToLower(e.Data.String("query"))
|
||||
r := make([]discord.AutocompleteChoice, 0, 25)
|
||||
for k, _ := range global.SkillNameToID {
|
||||
if strings.HasPrefix(strings.ToLower(k), q) {
|
||||
r = append(r, discord.AutocompleteChoiceString{Name: k, Value: k})
|
||||
if len(r) == cap(r) {
|
||||
break
|
||||
}
|
||||
}
|
||||
q := e.Data.String("query")
|
||||
opts := skillGlobalAuto().Find(nil, q)
|
||||
r := make([]discord.AutocompleteChoice, min(len(opts), 25))
|
||||
for i, k := range opts[:min(len(opts), len(r))] {
|
||||
r[i] = discord.AutocompleteChoiceString{Name: k, Value: k}
|
||||
}
|
||||
return e.AutocompleteResult(r)
|
||||
}
|
||||
|
||||
14
skill.go
14
skill.go
@@ -3,8 +3,11 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.sunturtle.xyz/zephyr/horse/horse"
|
||||
"git.sunturtle.xyz/zephyr/horse/horse/global"
|
||||
"git.sunturtle.xyz/zephyr/horsebot/autocomplete"
|
||||
"github.com/disgoorg/disgo/discord"
|
||||
)
|
||||
|
||||
@@ -115,6 +118,11 @@ func isDebuff(s horse.Skill) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// TODO(zeph): autocomplete
|
||||
// if we want to backgroundify construction of an autocomplete map,
|
||||
// use sync.OnceValue and launch a goroutine in main to get the value and discard it
|
||||
var skillGlobalAuto = sync.OnceValue(func() *autocomplete.Set {
|
||||
var set autocomplete.Set
|
||||
for _, id := range global.OrderedSkills {
|
||||
s := global.AllSkills[id]
|
||||
set.Add(s.Name, s.Name)
|
||||
}
|
||||
return &set
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user