Compare commits
7 Commits
34029fe001
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 48118efeeb | |||
| 79d4e76090 | |||
| ea39be0510 | |||
| 7bac55ca43 | |||
| 435da61405 | |||
| f3e7c7b43b | |||
| eded415df4 |
+12
-2
@@ -1,10 +1,20 @@
|
||||
# horsebot
|
||||
|
||||
Discord bot serving horse game data.
|
||||
Website and 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 `-mdb` flag is mandatory for both website and Discord bot modes.
|
||||
It provides data for both the horsebot API and the discord bot.
|
||||
|
||||
## Running the Website
|
||||
|
||||
If the `-public` flag is provided, its value gives the directory to serve at `/`.
|
||||
The `-img` flag gives a separate directory into which images are synced from GameTora; obtain permission from Gertas prior to using it.
|
||||
The images are served on `/img/`.
|
||||
|
||||
## Running the Discord Bot
|
||||
|
||||
Provide the `-token` flag pointing to a file containing the token.
|
||||
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.
|
||||
|
||||
@@ -36,6 +36,7 @@ func main() {
|
||||
addr string
|
||||
mdbf string
|
||||
public string
|
||||
img string
|
||||
// discord
|
||||
tokenFile string
|
||||
apiRoute string
|
||||
@@ -47,6 +48,7 @@ func main() {
|
||||
flag.StringVar(&addr, "http", ":13669", "`address` to bind HTTP server")
|
||||
flag.StringVar(&mdbf, "mdb", "", "`path` to master.mdb")
|
||||
flag.StringVar(&public, "public", "", "`dir`ectory containing the website to serve")
|
||||
flag.StringVar(&img, "img", "", "`dir`ectory to save and serve images")
|
||||
flag.StringVar(&tokenFile, "token", "", "`file` containing the Discord bot token")
|
||||
flag.StringVar(&apiRoute, "route", "/interactions/callback", "`path` to serve Discord HTTP API calls")
|
||||
flag.StringVar(&pubkey, "key", "", "Discord public key")
|
||||
@@ -86,6 +88,17 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if img != "" {
|
||||
// Ensure the img directory is in fact a directory
|
||||
// before we start trying to fill it out.
|
||||
err := os.MkdirAll(img, 0777)
|
||||
if err != nil {
|
||||
slog.Error("img", slog.Any("err", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
go saveImgs(ctx, db, img)
|
||||
}
|
||||
|
||||
var client *bot.Client
|
||||
if tokenFile != "" {
|
||||
skills, err := mdb.Skills(ctx, db)
|
||||
@@ -128,6 +141,9 @@ func main() {
|
||||
if public != "" {
|
||||
mux.Handle("GET /", httpmiddle.Compress(5)(http.FileServerFS(os.DirFS(public))))
|
||||
}
|
||||
if img != "" {
|
||||
mux.Handle("GET /img/", http.StripPrefix("/img", http.FileServerFS(os.DirFS(img))))
|
||||
}
|
||||
mux.Handle("GET /api/global/affinity", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.AffinitySummary)))
|
||||
mux.Handle("GET /api/global/affinity-detail", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.AffinityDetail)))
|
||||
mux.Handle("GET /api/global/character", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.Characters)))
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gen2brain/avif"
|
||||
"golang.org/x/image/draw"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"zombiezen.com/go/sqlite"
|
||||
"zombiezen.com/go/sqlite/sqlitex"
|
||||
)
|
||||
|
||||
func saveImgs(ctx context.Context, db *sqlitex.Pool, out string) {
|
||||
skill, err := skillIconIDs(ctx, db)
|
||||
if err != nil {
|
||||
slog.Error("skills", slog.Any("err", err))
|
||||
}
|
||||
chara, err := charaIDs(ctx, db)
|
||||
if err != nil {
|
||||
slog.Error("characters", slog.Any("err", err))
|
||||
}
|
||||
rank, err := rankIDs(ctx, db)
|
||||
if err != nil {
|
||||
slog.Error("ranks", slog.Any("err", err))
|
||||
}
|
||||
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
tick := time.NewTicker(250 * time.Millisecond)
|
||||
idouts(ctx, g, tick, skill, filepath.Join(out, "skill"), "https://media.gametora.com/umamusume/skills/icon/%d.png", image.Pt(64, 64), image.Pt(32, 32), image.Pt(16, 16))
|
||||
idouts(ctx, g, tick, chara, filepath.Join(out, "chara"), "https://gametora.com/images/umamusume/characters/icons/chr_icon_%d.png", image.Pt(256, 256), image.Pt(64, 64), image.Pt(32, 32), image.Pt(16, 16))
|
||||
idouts(ctx, g, tick, rank, filepath.Join(out, "rank"), "https://media.gametora.com/umamusume/ui/rank/%02d.png", image.Pt(128, 128), image.Pt(64, 64), image.Pt(32, 32), image.Pt(16, 16))
|
||||
idouts(ctx, g, tick, rank, filepath.Join(out, "rank", "simple"), "https://media.gametora.com/umamusume/ui/rank/simple/%02d.png", image.Pt(50, 50), image.Pt(32, 32), image.Pt(16, 16))
|
||||
if err := g.Wait(); err != nil {
|
||||
slog.Error("output", slog.Any("err", err))
|
||||
}
|
||||
}
|
||||
|
||||
// load scans all results of sql and appends them to r.
|
||||
func load[T any](ctx context.Context, db *sqlitex.Pool, r []T, sql string, row func(*sqlite.Stmt) T) ([]T, error) {
|
||||
conn, err := db.Take(ctx)
|
||||
defer db.Put(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stmt, err := conn.Prepare(sql)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for {
|
||||
ok, err := stmt.Step()
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
r = append(r, row(stmt))
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
func skillIconIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) {
|
||||
return load(ctx, db, nil, "SELECT DISTINCT icon_id FROM skill_data", func(s *sqlite.Stmt) int32 {
|
||||
return s.ColumnInt32(0)
|
||||
})
|
||||
}
|
||||
|
||||
func charaIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) {
|
||||
return load(ctx, db, nil, "SELECT id FROM chara_data", func(s *sqlite.Stmt) int32 {
|
||||
return s.ColumnInt32(0)
|
||||
})
|
||||
}
|
||||
|
||||
func rankIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) {
|
||||
return load(ctx, db, nil, "SELECT id-1 FROM single_mode_rank", func(s *sqlite.Stmt) int32 {
|
||||
return s.ColumnInt32(0)
|
||||
})
|
||||
}
|
||||
|
||||
var pngenc = &png.Encoder{CompressionLevel: png.BestCompression}
|
||||
|
||||
func idouts(ctx context.Context, g *errgroup.Group, tick *time.Ticker, ids []int32, p, urlf string, sizes ...image.Point) {
|
||||
for _, id := range ids {
|
||||
g.Go(func() error {
|
||||
n := strconv.Itoa(int(id))
|
||||
p := filepath.Join(p, n)
|
||||
_, err := os.Stat(p)
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
slog.DebugContext(ctx, "exists", slog.String("path", p))
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(p, 0777); err != nil {
|
||||
return err
|
||||
}
|
||||
url := fmt.Sprintf(urlf, id)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-tick.C: // continue on
|
||||
}
|
||||
img, err := dlimg(ctx, url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.InfoContext(ctx, "resize", slog.String("url", url))
|
||||
rs := resize(img, sizes...)
|
||||
for _, r := range rs {
|
||||
g.Go(func() error {
|
||||
m := r.Bounds().Max
|
||||
o := filepath.Join(p, fmt.Sprintf("%dx%d.png", m.X, m.Y))
|
||||
slog.InfoContext(ctx, "write", slog.String("path", o))
|
||||
f, err := os.Create(o)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return pngenc.Encode(f, r)
|
||||
})
|
||||
g.Go(func() error {
|
||||
m := r.Bounds().Max
|
||||
o := filepath.Join(p, fmt.Sprintf("%dx%d.avif", m.X, m.Y))
|
||||
slog.InfoContext(ctx, "write", slog.String("path", o))
|
||||
f, err := os.Create(o)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return avif.Encode(f, r)
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func dlimg(ctx context.Context, url string) (image.Image, error) {
|
||||
slog.InfoContext(ctx, "download", slog.String("url", url))
|
||||
ctx, stop := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer stop()
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
slog.InfoContext(ctx, "decode", slog.String("url", url))
|
||||
img, _, err := image.Decode(resp.Body)
|
||||
return img, err
|
||||
}
|
||||
|
||||
func resize(img image.Image, sizes ...image.Point) []image.Image {
|
||||
rs := make([]image.Image, 0, len(sizes))
|
||||
for _, sz := range sizes {
|
||||
d := image.NewNRGBA(image.Rectangle{Min: image.Point{}, Max: sz})
|
||||
draw.CatmullRom.Scale(d, d.Bounds(), img, img.Bounds(), draw.Src, nil)
|
||||
rs = append(rs, d)
|
||||
}
|
||||
return rs
|
||||
}
|
||||
@@ -15,6 +15,9 @@ This file is my notes from exploring the database.
|
||||
- 119 is scenario full titles (e.g. The Beginning: URA Finale), 120 is scenario descriptions, 237 is scenario names (e.g. URA Finale)
|
||||
- 130 is epithet names, 131 is epithet descriptions
|
||||
|
||||
in the decomp, text data categories are named in `public enum MasterString.Category`.
|
||||
some categories seem to be missing, though.
|
||||
|
||||
# succession factor (sparks)
|
||||
|
||||
tables are succession_factor and succession_factor_effect
|
||||
@@ -317,6 +320,37 @@ so, it doesn't seem like there's a particular flag that identifies maiden races,
|
||||
- card_talent_hint_upgrade has costs to raise hint levels, but it's actually universal, only six rows
|
||||
- single_mode_route_race is career goals (not only races)
|
||||
- available_skill_set has starting skills including those unlocked by potential level given under need_rank (0 for pl1, 2 for pl2)
|
||||
- single_mode_special_chara is scenario links
|
||||
|
||||
# gl
|
||||
|
||||
single_mode_live_song_list has the song list for lives ha ha.
|
||||
this is defined from the perspective of bonuses they grant when you have them, not the shop per se.
|
||||
- text_data category for song names is 210, look up by live_id; for post-concert effect text, category 208 indexed by song list id
|
||||
- command_id and live_id are always equal
|
||||
- level is 1 for all songs except the two instances of girls' legend u
|
||||
- master_bonus_content_text_id corresponds to text_data categories 207 for pickup effect text and 209 for song name
|
||||
- live_bonus_type is 1 for specialty prio, 2 for chain frequency, 3 for fb
|
||||
- girls' legend u is defined twice as ids 22 and 24
|
||||
|
||||
costs are in single_mode_live_square.
|
||||
- square_type is 1 for stat up, 2 for skill up, 3 for energy up, 4 for live
|
||||
- perf_type_n is token type: 1 for dance, 2 for passion, 3 for vocals, 4 for visuals, 5 for composure
|
||||
- perf_value_n is number of tokens
|
||||
|
||||
immediate bonuses are in single_mode_live_master_bonus.
|
||||
- master_bonus_type is 1 for stat up, 2 for skill hint, 3 for energy up, 4 for song
|
||||
- master_bonus_type_value is 1 for type 1, live id for type 4, 0 otherwise
|
||||
- master_bonus_gain_type_n is 1 for stat, 2 for skill hint, 3 for energy, 5 for permanent training bonus
|
||||
+ 1 for stat: then master_bonus_gain_value_n_1 is 1-5 for stat id or 6 for sp, n_2 is 1 for fixed or 2 for random (not actually in the game), n_3 is lower bound on gain, n_4 is 0 for fixed or upper bound for random
|
||||
+ 2 for skill hint: then gain_value_n_1 is 3(*), n_2 is skill tag, n_3 is hint level
|
||||
+ 3 for energy: then gain_value_n_1 is amount
|
||||
+ 5 for permanent training bonus: then gain_value_n_1 is stat id (6 for sp), n_2 is amount
|
||||
(*) MasterSingleModeLiveMasterBonus.SingleModeLiveMasterBonus.GainSkillHintTypeEnum defines values of Random = 1 and SkillId = 2, but they are unused in the db.
|
||||
|
||||
there is some ostensibly unused data.
|
||||
- master bonus ids 12001-12206 grant random amounts of stats
|
||||
- id 20000 corresponds to "Group Lesson" "Skill hint appropriate for aptitude"
|
||||
|
||||
# lobby conversations!!!
|
||||
|
||||
|
||||
@@ -4,32 +4,37 @@ go 1.25.5
|
||||
|
||||
require (
|
||||
github.com/disgoorg/disgo v0.19.6
|
||||
github.com/go-chi/chi/v5 v5.3.0
|
||||
github.com/gen2brain/avif v0.6.0
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/junegunn/fzf v0.73.1
|
||||
github.com/junegunn/fzf v0.74.1
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/sync v0.22.0
|
||||
zombiezen.com/go/sqlite v1.4.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/disgoorg/godave v0.1.0 // indirect
|
||||
github.com/disgoorg/godave v0.3.0 // indirect
|
||||
github.com/disgoorg/json/v2 v2.0.0 // indirect
|
||||
github.com/disgoorg/omit v1.0.0 // indirect
|
||||
github.com/disgoorg/snowflake/v2 v2.0.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/klauspost/compress v1.19.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/tools v0.46.0 // indirect
|
||||
modernc.org/libc v1.73.4 // indirect
|
||||
github.com/tetratelabs/wazero v1.12.0 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
modernc.org/libc v1.74.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.52.0 // indirect
|
||||
modernc.org/sqlite v1.54.0 // indirect
|
||||
)
|
||||
|
||||
@@ -2,8 +2,8 @@ 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.6 h1:1xJQt6KZgiqvBOnjuziG1gE4Iqd0aJn8DNfE6eE+0qw=
|
||||
github.com/disgoorg/disgo v0.19.6/go.mod h1:NnV63iw4lJdF1fnV0gX27XR43ZgRGqnL122svRMTgTE=
|
||||
github.com/disgoorg/godave v0.1.0 h1:3g0Zqzz+zNaxQTVLfCnl5eZKGqZk6cM/JLLbMKCtZQQ=
|
||||
github.com/disgoorg/godave v0.1.0/go.mod h1:OreAC3hpabr39bMVA+jwOVDq1EUPXH5A0XUBiZaDI1Y=
|
||||
github.com/disgoorg/godave v0.3.0 h1:37F3ZuiMd8/EXeoCtTeE8iP2eT2nWsfEa4/ITwoKfe4=
|
||||
github.com/disgoorg/godave v0.3.0/go.mod h1:OreAC3hpabr39bMVA+jwOVDq1EUPXH5A0XUBiZaDI1Y=
|
||||
github.com/disgoorg/json/v2 v2.0.0 h1:U16yy/ARK7/aEpzjjqK1b/KaqqGHozUdeVw/DViEzQI=
|
||||
github.com/disgoorg/json/v2 v2.0.0/go.mod h1:jZTBC0nIE1WeetSEI3/Dka8g+qglb4FPVmp5I5HpEfI=
|
||||
github.com/disgoorg/omit v1.0.0 h1:y0LkVUOyUHT8ZlnhIAeOZEA22UYykeysK8bLJ0SfT78=
|
||||
@@ -12,8 +12,12 @@ 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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
|
||||
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/gen2brain/avif v0.6.0 h1:/8WSgcU+IEF0jhKYsUZ/mzlziFuTeJFpIKBj2siTQps=
|
||||
github.com/gen2brain/avif v0.6.0/go.mod h1:QgrYqdVE9y40PCfArK9VakcMIpYeDYpZmCSLkW6C1n8=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -22,14 +26,14 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/junegunn/fzf v0.73.1 h1:Ni9jaVMxsehcx9IwJ4ZAL/sr2c4dOEg1DHIAwPUVlyo=
|
||||
github.com/junegunn/fzf v0.73.1/go.mod h1:hKDkO8orBWT5VLIZOL8oyhxn7mRLXdGyu+gz1Ss58pU=
|
||||
github.com/junegunn/fzf v0.74.1 h1:rCGVdCSoOkfcfTxtxCO/vG2yceDjRlKSlv7IfCGkc/Y=
|
||||
github.com/junegunn/fzf v0.74.1/go.mod h1:hKDkO8orBWT5VLIZOL8oyhxn7mRLXdGyu+gz1Ss58pU=
|
||||
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMDfKzjT+DVfIS4iqknSEKtZpEcXtu6vuaasHs=
|
||||
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -42,34 +46,38 @@ github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad h1:qIQkSlF5vAUHxE
|
||||
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/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
|
||||
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
|
||||
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
||||
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||
modernc.org/libc v1.74.3 h1:a4J+Z8aVaxPyjyxRAdJzw246PqpcFGvVPnfT/AuM5Ws=
|
||||
modernc.org/libc v1.74.3/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
@@ -78,8 +86,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
|
||||
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
|
||||
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -14,4 +14,4 @@ SELECT
|
||||
FROM single_mode_scenario sc
|
||||
JOIN scenario_name n ON sc.id = n.id
|
||||
JOIN scenario_title t ON sc.id = t.id
|
||||
ORDER BY sc.id
|
||||
ORDER BY sc.sort_id
|
||||
|
||||
Generated
+621
-566
File diff suppressed because it is too large
Load Diff
+16
-13
@@ -20,32 +20,35 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.60.1",
|
||||
"@sveltejs/kit": "^2.70.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/node": "^22.19.19",
|
||||
"@types/node": "^22.20.1",
|
||||
"@vitest/browser-playwright": "^4.1.0",
|
||||
"d3": "^7.9.0",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.17.1",
|
||||
"globals": "^17.6.0",
|
||||
"playwright": "^1.60.0",
|
||||
"prettier": "^3.8.3",
|
||||
"eslint-plugin-svelte": "^3.22.0",
|
||||
"globals": "^17.7.0",
|
||||
"playwright": "^1.61.1",
|
||||
"prettier": "^3.9.6",
|
||||
"prettier-plugin-svelte": "^3.5.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.4",
|
||||
"svelte": "^5.55.7",
|
||||
"svelte-check": "^4.4.8",
|
||||
"svelte": "^5.56.7",
|
||||
"svelte-check": "^4.7.3",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.59.3",
|
||||
"vite": "^7.3.3",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vite": "^7.3.6",
|
||||
"vitest": "^4.1.0",
|
||||
"vitest-browser-svelte": "^2.1.1"
|
||||
"vitest-browser-svelte": "^2.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@observablehq/plot": "^0.6.17",
|
||||
"mathjs": "^15.2.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.28.1": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import type { SvelteHTMLElements } from 'svelte/elements';
|
||||
|
||||
const sizes = [256, 64, 32, 16] as const;
|
||||
|
||||
interface Props {
|
||||
chara_id: number | null | undefined;
|
||||
size: (typeof sizes)[number];
|
||||
}
|
||||
|
||||
let { chara_id, size, ...rest }: Props & SvelteHTMLElements['picture'] = $props();
|
||||
|
||||
const icon = $derived(chara_id ?? 0);
|
||||
const avifs = $derived(
|
||||
sizes
|
||||
.filter((sz) => sz >= size)
|
||||
.map((sz) => `/img/chara/${icon}/${sz}x${sz}.avif ${sz / size}x`)
|
||||
.join(', '),
|
||||
);
|
||||
const pngs = $derived(
|
||||
sizes
|
||||
.filter((sz) => sz >= size)
|
||||
.map((sz) => `/img/chara/${icon}/${sz}x${sz}.png ${sz / size}x`)
|
||||
.join(', '),
|
||||
);
|
||||
const src = $derived(icon !== 0 ? `/img/chara/${icon}/${sizes[0]}x${sizes[0]}.png` : `/img/skill/10011`);
|
||||
</script>
|
||||
|
||||
{#if icon !== 0}
|
||||
<picture {...rest}>
|
||||
<source srcset={avifs} type="image/avif" />
|
||||
<source srcset={pngs} type="image/png" />
|
||||
<img {src} alt={`Icon for character ID ${chara_id}`} class="inline" width={size} height={size} loading="lazy" />
|
||||
</picture>
|
||||
{:else}
|
||||
<div class={`w-[${size}px] h-[${size}px]`}></div>
|
||||
{/if}
|
||||
@@ -1,23 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type { Character } from '$lib/data/character';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
import Pick from './Pick.svelte';
|
||||
import CharaIcon from './CharaIcon.svelte';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
characters: Character[] | null;
|
||||
value: Character | undefined;
|
||||
class?: ClassValue | null;
|
||||
option?: Snippet<[Character]>;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
let { id, characters, value = $bindable(), class: className, option, required = false }: Props = $props();
|
||||
let { id, characters, value = $bindable(), class: className, required = false }: Props = $props();
|
||||
|
||||
const charas = $derived(characters ?? []);
|
||||
|
||||
const key = (v: Character) => ({ id: v.chara_id, name: v.name });
|
||||
</script>
|
||||
|
||||
<Pick {id} items={charas} {key} bind:value {option} class={className} {required} />
|
||||
<Pick {id} items={charas} {key} bind:value class={className} {required}>
|
||||
{#snippet option(c: Character)}
|
||||
<span>
|
||||
<CharaIcon chara_id={c.chara_id} size={32} />
|
||||
{c.name}
|
||||
</span>
|
||||
{/snippet}
|
||||
</Pick>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
type Ability,
|
||||
type Skill,
|
||||
} from './data/skill';
|
||||
import SkillIcon from './SkillIcon.svelte';
|
||||
|
||||
interface Props {
|
||||
skill: Skill | null | undefined;
|
||||
@@ -63,9 +64,13 @@
|
||||
|
||||
<span class="group relative inline-block hyphens-none">
|
||||
<span
|
||||
class="skill-tip invisible absolute bottom-4 -left-1/2 flex w-80 flex-col rounded-lg border px-2 text-center opacity-0 shadow-lg transition-all group-hover:visible group-hover:bottom-6 group-hover:opacity-100"
|
||||
class="skill-tip invisible absolute bottom-4 -left-1/2 flex min-w-80 flex-col rounded-lg border px-2 pt-2 text-center opacity-0 shadow-lg transition-all group-hover:visible group-hover:bottom-6 group-hover:opacity-100"
|
||||
role="tooltip"
|
||||
>
|
||||
<span class="block text-xl">
|
||||
<SkillIcon icon_id={skill?.icon_id} size={32} class="inline" />
|
||||
{s.name}
|
||||
</span>
|
||||
<span class="block border-b py-1 hyphens-auto">
|
||||
{s.description}
|
||||
</span>
|
||||
@@ -92,5 +97,6 @@
|
||||
</span>
|
||||
{/each}
|
||||
</span>
|
||||
<SkillIcon icon_id={skill?.icon_id} size={16} class="inline max-h-lh align-text-bottom" />
|
||||
<span class={[spanClass, 'cursor-pointer']}>{s.name}</span>
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import type { SvelteHTMLElements } from 'svelte/elements';
|
||||
|
||||
const sizes = [64, 32, 16] as const;
|
||||
|
||||
interface Props {
|
||||
icon_id: number | null | undefined;
|
||||
size: (typeof sizes)[number];
|
||||
}
|
||||
|
||||
let { icon_id, size, ...rest }: Props & SvelteHTMLElements['picture'] = $props();
|
||||
|
||||
const icon = $derived(icon_id ?? 0);
|
||||
const avifs = $derived(
|
||||
sizes
|
||||
.filter((sz) => sz >= size)
|
||||
.map((sz) => `/img/skill/${icon}/${sz}x${sz}.avif ${sz / size}x`)
|
||||
.join(', '),
|
||||
);
|
||||
const pngs = $derived(
|
||||
sizes
|
||||
.filter((sz) => sz >= size)
|
||||
.map((sz) => `/img/skill/${icon}/${sz}x${sz}.png ${sz / size}x`)
|
||||
.join(', '),
|
||||
);
|
||||
const src = $derived(icon !== 0 ? `/img/skill/${icon}/${sizes[0]}x${sizes[0]}.png` : `/img/skill/10011`);
|
||||
</script>
|
||||
|
||||
{#if icon !== 0}
|
||||
<picture {...rest}>
|
||||
<source srcset={avifs} type="image/avif" />
|
||||
<source srcset={pngs} type="image/png" />
|
||||
<img {src} alt={`Skill icon ID ${icon_id}`} class="inline" width={size} height={size} loading="lazy" />
|
||||
</picture>
|
||||
{:else}
|
||||
<div class={`w-[${size}px] h-[${size}px]`}></div>
|
||||
{/if}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import * as skill from '$lib/data/skill';
|
||||
import SkillIcon from '$lib/SkillIcon.svelte';
|
||||
|
||||
let rare = $state(false);
|
||||
let unique = $state(false);
|
||||
@@ -94,7 +95,10 @@
|
||||
<div class="grid max-w-7xl grid-cols-1 gap-2 md:grid-cols-3">
|
||||
{#each skills as s (s.skill_id)}
|
||||
<div class="rounded-md border px-2 pt-1 pb-2 text-center shadow-sm transition-shadow hover:shadow-md">
|
||||
<span class="block text-xl">{s.name}</span>
|
||||
<span class="block text-xl">
|
||||
<SkillIcon icon_id={s?.icon_id} size={32} />
|
||||
{s.name}
|
||||
</span>
|
||||
<span class="block italic">{s.description}</span>
|
||||
{#each s.activations as act, i (i)}
|
||||
<span class="my-2 block rounded-md border">
|
||||
|
||||
@@ -39,6 +39,9 @@ export default defineConfig({
|
||||
'/api': {
|
||||
target: 'http://localhost:13669',
|
||||
},
|
||||
'/img': {
|
||||
target: 'http://localhost:13669',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user