Compare commits

..

11 Commits

Author SHA1 Message Date
zephyr 48118efeeb doc: document grand live mechanics
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline was successful
2026-07-28 00:19:14 -04:00
zephyr 79d4e76090 mdb: sort scenarios by sort id
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline was successful
Fixes #33.
2026-07-25 11:57:24 -04:00
zephyr ea39be0510 zenno: include character icons in character picks
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline was successful
2026-07-24 23:09:24 -04:00
zephyr 7bac55ca43 zenno: show icons on skills 2026-07-24 22:56:31 -04:00
zephyr 435da61405 cmd: move toradl into horsebot
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline was successful
Fixes #31.
2026-07-24 17:18:44 -04:00
zephyr f3e7c7b43b all: update deps
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline was successful
2026-07-23 22:55:17 -04:00
zephyr eded415df4 cmd/toradl: program for downloading icons from gametora 2026-07-23 22:48:12 -04:00
zephyr 34029fe001 zenno/doc/tagged-skills: more skill info
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline was successful
2026-07-21 17:01:23 -04:00
zephyr ffb700545f zenno/doc/tagged-skills: new page
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline was successful
2026-07-19 22:29:15 -04:00
zephyr 70ad09a13e zenno/chara/convo: list characters in convos even when they don't have their own
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline was successful
Fixes #24.
2026-07-19 15:46:41 -04:00
zephyr 74c22c7313 zenno: rename Great Escape -> Runaway everywhere
Fixes #26.
2026-07-19 15:38:01 -04:00
24 changed files with 1315 additions and 699 deletions
+12 -2
View File
@@ -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.
+16
View File
@@ -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)))
+173
View File
@@ -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
}
+34
View File
@@ -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!!!
+15 -10
View File
@@ -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
)
+40 -32
View File
@@ -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=
+1 -1
View File
@@ -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
+621 -566
View File
File diff suppressed because it is too large Load Diff
+16 -13
View File
@@ -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
}
}
+37
View File
@@ -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}
+10 -4
View File
@@ -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>
+8 -1
View File
@@ -9,6 +9,7 @@
type Ability,
type Skill,
} from './data/skill';
import SkillIcon from './SkillIcon.svelte';
interface Props {
skill: Skill | null | undefined;
@@ -24,6 +25,7 @@
group_rate: 1,
wit_check: false,
activations: [],
tags: [],
icon_id: 0,
};
@@ -62,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>
@@ -91,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>
+37
View File
@@ -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}
+5
View File
@@ -79,6 +79,11 @@ export const PAGES = {
name: 'Spark Explorer',
description: 'View all possible inheritance effects of any spark.',
},
{
route: resolve('/doc/tagged-skills'),
name: 'Tagged Skills Explorer',
description: 'View skills matching the tags internal to the game.',
},
] satisfies Page[],
} as const;
+1 -1
View File
@@ -18,7 +18,7 @@ const styleMap = {
Sentou: RunningStyle.PaceChaser,
Sasi: RunningStyle.LateSurger,
Oikomi: RunningStyle.EndCloser,
Oonige: RunningStyle.GreatEscape,
Oonige: RunningStyle.Runaway,
} as const;
export interface ImportUma {
+62
View File
@@ -48,6 +48,10 @@ export interface Skill {
* SP cost to purchase the skill, if applicable.
*/
sp_cost?: number;
/**
* Numeric tags applied to the skill, for effects which select skill types.
*/
tags: number[];
/**
* Skill icon ID.
*/
@@ -133,10 +137,13 @@ export enum AbilityType {
Frenzy = 13,
AddGateDelay = 14,
CurrentSpeed = 21,
CurrentSpeedWithDecel = 22,
TargetSpeed = 27,
LaneSpeed = 28,
RushedChance = 29,
Accel = 31,
LaneChange = 35,
ActivateRare = 37,
}
export const ABILITY_TYPE_FORMAT: Readonly<Record<AbilityType, (v: number) => string>> = {
@@ -152,10 +159,13 @@ export const ABILITY_TYPE_FORMAT: Readonly<Record<AbilityType, (v: number) => st
[AbilityType.Frenzy]: (v) => tenThousandths(v) + 's Frenzy',
[AbilityType.AddGateDelay]: (v) => plusBonus(v) + 's Gate Delay',
[AbilityType.CurrentSpeed]: (v) => plusBonus(v) + ' m/s Current Speed',
[AbilityType.CurrentSpeedWithDecel]: (v) => plusBonus(v) + ' m/s Current Speed',
[AbilityType.TargetSpeed]: (v) => plusBonus(v) + ' m/s Target Speed',
[AbilityType.LaneSpeed]: (v) => plusBonus(v) + ' CW/s Lane Change Speed',
[AbilityType.RushedChance]: (v) => plusBonus(v) + '% Rushed Chance',
[AbilityType.Accel]: (v) => plusBonus(v) + ' m/s² Acceleration',
[AbilityType.LaneChange]: (v) => 'Target Lane = ' + tenThousandths(v) + ' CW',
[AbilityType.ActivateRare]: (v) => 'Activate Rare Skills ×' + tenThousandths(v),
} as const;
export enum AbilityValueUsage {
@@ -329,3 +339,55 @@ export async function skillGroups(): Promise<SkillGroup[]> {
const resp = await fetch('/api/global/skill-group');
return resp.json();
}
export const TAGS = [
{ name: 'Front Runner', tag: 101 },
{ name: 'Pace Chaser', tag: 102 },
{ name: 'Late Surger', tag: 103 },
{ name: 'End Closer', tag: 104 },
{ name: 'Sprint', tag: 201 },
{ name: 'Mile', tag: 202 },
{ name: 'Medium', tag: 203 },
{ name: 'Long', tag: 204 },
{ name: 'Early Race', tag: 301 },
{ name: 'Mid Race', tag: 302 },
{ name: 'Late Race', tag: 303 },
{ name: 'Speed Stat or Velocity', tag: 401 },
{ name: 'Stamina Stat or HP', tag: 402 },
{ name: 'Power Stat or Acceleration', tag: 403 },
{ name: 'Guts Stat', tag: 404 },
{ name: 'Wit Stat or Vision', tag: 405 },
{ name: 'Debuff', tag: 406 },
{ name: 'Gate Delay', tag: 407 },
{ name: 'Turf', tag: 501 },
{ name: 'Dirt', tag: 502 },
{ name: 'Ground Condition Passive', tag: 601 },
{ name: 'Weather Passive', tag: 602 },
{ name: 'Season Passive', tag: 603 },
{ name: 'Racecourse Passive', tag: 604 },
{ name: 'Time of Day Passive', tag: 605 },
{ name: 'Exchange Race Passive', tag: 606 },
{ name: 'Distance Passive', tag: 607 },
{ name: 'Corner-Based Passive', tag: 608 },
{ name: 'Gate Position Passive', tag: 609 },
{ name: 'Ground Type Passive', tag: 610 },
{ name: 'Popularity Passive', tag: 611 },
{ name: 'Running Style Passive', tag: 612 },
{ name: "Passive Depending on Other Horses' Styles or Skills", tag: 613 },
{ name: 'Base Stat Threshold Passive', tag: 614 },
{ name: 'Mood Passive', tag: 615 },
{ name: 'URA', tag: 801 },
{ name: 'Unity Cup', tag: 802 },
{ name: 'Trackblazer', tag: 804 },
// { name: 'Our Grand Concert', tag: ? },
// { name: 'Grand Masters', tag: ? },
// { name: "Project L'Arc", tag: ? },
// { name: 'U.A.F.', tag: ? },
// { name: 'Great Food Festival', tag: ? },
// { name: 'Run, Mecha Umamusume!', tag: ? },
// { name: 'The Twinkle Legends', tag: ? },
// { name: 'Design Your Island', tag: ? },
// { name: 'Yukoma Hot Springs', tag: ? },
// { name: 'Beyond Dreams', tag: ? },
// { name: 'Tracen-Ken', tag: ? },
] as const;
+6 -6
View File
@@ -260,7 +260,7 @@ export function finalStat(adjStat: number, skillPassive: number): number {
/**
* Running styles for strategyphase coefficients.
* Great Escape is distinguished as a separate style even though it is
* Runaway is distinguished as a separate style even though it is
* mechanically identical to Front Runner.
*/
export enum RunningStyle {
@@ -268,7 +268,7 @@ export enum RunningStyle {
PaceChaser,
LateSurger,
EndCloser,
GreatEscape,
Runaway,
}
/**
@@ -279,7 +279,7 @@ export const RUNNING_STYLES = [
['Pace Chaser', RunningStyle.PaceChaser],
['Late Surger', RunningStyle.LateSurger],
['End Closer', RunningStyle.EndCloser],
['Great Escape', RunningStyle.GreatEscape],
['Runaway', RunningStyle.Runaway],
] as const;
/**
@@ -325,7 +325,7 @@ const hpStrategyCoeff = {
[RunningStyle.PaceChaser]: 0.89,
[RunningStyle.LateSurger]: 1.0,
[RunningStyle.EndCloser]: 0.995,
[RunningStyle.GreatEscape]: 0.86,
[RunningStyle.Runaway]: 0.86,
} as const;
/**
@@ -595,7 +595,7 @@ const accelStrategyPhaseCoeff = {
[RunningStyle.PaceChaser]: [0.985, 1.0, 0.996],
[RunningStyle.LateSurger]: [0.975, 1.0, 1.0],
[RunningStyle.EndCloser]: [0.945, 1.0, 0.997],
[RunningStyle.GreatEscape]: [1.17, 0.94, 0.956],
[RunningStyle.Runaway]: [1.17, 0.94, 0.956],
} as const;
const surfaceProficiencyMod = [0.1, 0.3, 0.5, 0.7, 0.8, 0.9, 1.0, 1.05] as const;
@@ -711,7 +711,7 @@ const conserveStratDistCoef = {
[Distance.Medium]: 0.86,
[Distance.Long]: 0.9,
},
[RunningStyle.GreatEscape]: {
[RunningStyle.Runaway]: {
[Distance.Sprint]: 1,
[Distance.Mile]: 1,
[Distance.Medium]: 1,
+3 -4
View File
@@ -8,13 +8,12 @@
const convoMap = $derived(byChara(conversations));
const popular = $derived(groupPopulars(conversations));
let characters: Character[] = $state([]);
const selCharas = $derived(characters.filter((c) => convoMap.has(c.chara_id)));
const names = $derived(charaNames(characters ?? []));
const minSuggest = 8;
onMount(async () => {
const [convos, charas] = await Promise.all([conversation(), character()]);
conversations = convos;
characters = charas.filter((c) => convoMap.has(c.chara_id));
[conversations, characters] = await Promise.all([conversation(), character()]);
});
let chara: Character | undefined = $state();
@@ -43,7 +42,7 @@
<div class="mx-auto mt-8 flex flex-col rounded-md text-center shadow-md ring md:max-w-xl md:flex-row">
<div class="m-4 flex-1 md:mt-3">
<label for="chara" class="hidden md:inline">Character</label>
<CharaPick id="chara" {characters} class="w-full" bind:value={chara} required />
<CharaPick id="chara" characters={selCharas} class="w-full" bind:value={chara} required />
</div>
<div class="m-4 flex-1 md:mt-3">
<label for="convo" class="hidden md:inline">Conversation</label>
+2 -5
View File
@@ -179,7 +179,7 @@
{ label: 'Style S', y: (x) => downhillAccelEnterChance(x * 1.1) * 100 },
{ label: 'Style A', y: (x) => downhillAccelEnterChance(x) * 100 },
];
const secIsFront = $derived(secSpeedStyle === RunningStyle.FrontRunner || secSpeedStyle === RunningStyle.GreatEscape);
const secIsFront = $derived(secSpeedStyle === RunningStyle.FrontRunner || secSpeedStyle === RunningStyle.Runaway);
const secSpeedSeries: Array<ComputedAreas | null> = $derived([
{
label: 'Early Race',
@@ -326,10 +326,7 @@
<Sec h={3} id="runaway">Runaway</Sec>
<p>
The skill <Skill skill={skills.runaway} /> converts front runners into the <i>Great Escape</i> running style. However, no
player has ever uttered the words "Great Escape" when talking about Umamusume, presumably because Runaway is a much cooler
name. ("Great Escape" is a direct translation of Japanese 大逃げ <i>oonige</i>, whereas "Front Runner" is a more liberal
localization of 逃げ <i>nige</i> that technically just means "escape.")
The skill <Skill skill={skills.runaway} /> converts front runners into the Runaway running style.
</p>
<p>
Runaways are still front runners for all purposes. The difference is just different numbers for things like base speed and
+6 -3
View File
@@ -14,7 +14,7 @@
let surfaceApt = $state(race.AptitudeLevel.A);
let distanceApt = $state(race.AptitudeLevel.A);
let raceLen = $state(2000);
const styleIsFront = $derived(style === race.RunningStyle.FrontRunner || style === race.RunningStyle.GreatEscape);
const styleIsFront = $derived(style === race.RunningStyle.FrontRunner || style === race.RunningStyle.Runaway);
const distanceType = $derived(race.distance(raceLen));
const raceLenType = $derived(race.Distance[distanceType]);
@@ -39,7 +39,7 @@
{ label: 'Pace Chaser', y: (x) => race.maxHP(raceLen, race.RunningStyle.PaceChaser, x) },
{ label: 'Late Surger', y: (x) => race.maxHP(raceLen, race.RunningStyle.LateSurger, x) },
{ label: 'End Closer', y: (x) => race.maxHP(raceLen, race.RunningStyle.EndCloser, x) },
{ label: 'Runaway', y: (x) => race.maxHP(raceLen, race.RunningStyle.GreatEscape, x) },
{ label: 'Runaway', y: (x) => race.maxHP(raceLen, race.RunningStyle.Runaway, x) },
]);
const moveLane: ComputedSeries[] = [{ label: 'Move Lane Modifier', y: (x) => race.moveLaneModifier(x) }];
const uphill: ComputedSeries[] = [
@@ -298,7 +298,10 @@
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.t9fv1oq5lsu9"
>Acceleration</Sec
>
<p>Acceleration.</p>
<p>
Acceleration, i.e. the rate of change of forward velocity when target speed is greater than current speed. Stats do not affect
deceleration (acceleration when target speed is less than current speed).
</p>
{@render statChart(race.Stat.Power, accel, 'Acceleration (m/s²)', [0.1, 0.55], { style: true })}
<Sec
@@ -0,0 +1,158 @@
<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);
let allSkillsList: skill.Skill[] = $state([]);
onMount(async () => {
allSkillsList = await skill.skills();
});
const tagTypes = $state([
{
name: 'Running Style',
includes: skill.TAGS.filter((t) => 101 <= t.tag && t.tag <= 199).map((t) => ({ ...t, on: false })),
},
{
name: 'Distance',
includes: skill.TAGS.filter((t) => 201 <= t.tag && t.tag <= 299).map((t) => ({ ...t, on: false })),
},
{
name: 'Surface',
includes: skill.TAGS.filter((t) => 501 <= t.tag && t.tag <= 599).map((t) => ({ ...t, on: false })),
},
{
name: 'Timing',
includes: skill.TAGS.filter((t) => 301 <= t.tag && t.tag <= 399).map((t) => ({ ...t, on: false })),
},
{
name: 'Effects',
includes: skill.TAGS.filter((t) => 401 <= t.tag && t.tag <= 499).map((t) => ({ ...t, on: false })),
},
{
name: 'Passive Types',
includes: skill.TAGS.filter((t) => 601 <= t.tag && t.tag <= 699).map((t) => ({ ...t, on: false })),
},
{
name: 'Scenario Skills',
includes: skill.TAGS.filter((t) => 801 <= t.tag && t.tag <= 899).map((t) => ({ ...t, on: false })),
},
]);
const tags = $derived(tagTypes.flatMap((v) => v.includes.filter((t) => t.on).map((t) => t.tag)));
const skills = $derived(
allSkillsList.filter(
(s) =>
(rare || s.group_rate === 1) &&
(unique || (s.rarity < 3 && s.unique_owner == null)) &&
tags.some((t) => s.tags.includes(t)),
),
);
</script>
<h1 class="text-4xl">Tagged Skills Explorer</h1>
<form class="mx-auto my-8 rounded-md p-4 text-center shadow-md ring md:max-w-4xl">
<div class="flex w-full gap-4">
<div class="my-auto inline-block flex-1">
<input id="rare" type="checkbox" role="switch" class="min-h-6 min-w-6 align-middle" bind:checked={rare} />
<label for="rare">Include All Levels and Rarities</label>
</div>
<div class="my-auto inline-block flex-1">
<input id="unique" type="checkbox" role="switch" class="min-h-6 min-w-6 align-middle" bind:checked={unique} />
<label for="unique">Include Uniques</label>
</div>
</div>
{#each tagTypes as { name, includes } (name)}
<div class="mt-1 flex flex-row">
<span class="my-auto h-0 flex-1 border"></span>
<span class="m-2 flex-none text-sm italic">{name}</span>
<span class="my-auto h-0 flex-1 border"></span>
</div>
<div class="flex w-full flex-wrap place-content-center-safe gap-6 md:grid-cols-4">
{#each includes as t (t.tag)}
<div class="inline-block">
<input id={t.name} type="checkbox" class="min-h-6 min-w-6 align-middle" bind:checked={t.on} />
<label for={t.name} class="align-middle">{t.name}</label>
</div>
{/each}
</div>
{/each}
</form>
{#snippet condition(cond: string)}
{@const disj = cond.replaceAll('&', ' & ').split('@')}
{#each disj as conj, i (conj)}
<span class="block font-mono text-sm">{conj}</span>
{#if i !== disj.length - 1}
<span class="block text-xs italic">or</span>
{/if}
{/each}
{/snippet}
<span class="mb-4 block text-center text-2xl">{skills.length} skills selected</span>
<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">
<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">
{#if act.precondition}
<span class="block border-b py-1">
<span class="text-xs italic">Precondition</span>
{@render condition(act.precondition)}
</span>
{/if}
<span class="block border-b py-1">
{@render condition(act.condition)}
</span>
<span class="block py-1">
{#each act.abilities as a, i (i)}
<span class="block text-sm">
{console.log(a.type)}
{skill.ABILITY_TYPE_FORMAT[a.type](a.value)}
{skill.ABILITY_SCALE_NAME[a.value_usage]}
{#if a.target && a.target !== skill.Target.Self}
{skill.TARGET_FORMAT[a.target](a.target_value)}
{/if}
</span>
{/each}
{#if act.duration != null && act.duration > 0}
<span class="block text-sm">
for {skill.tenThousandths(act.duration)}s
{skill.DURATION_SCALE_NAME[act.dur_scale]}
</span>
{/if}
</span>
</span>
{/each}
<div class="flex w-full text-xs">
<span class="flex-1">ID {s.skill_id}</span>
{#if s.unique_owner}
<span class="flex-1">{s.unique_owner}</span>
{/if}
{#if s.sp_cost}
<span class="flex-1">{s.sp_cost} SP</span>
{/if}
<span class="flex-1">Grade Value {s.grade_value}</span>
</div>
</div>
{/each}
</div>
<div class="mx-auto mt-12 w-full max-w-4xl border-t pt-4 md:mt-8">
<ul class="ml-4 list-disc">
<li>
To see skills that can be received from MANT/Trackblazer rivals, select the running style used and the distance and surface
of the race. Some skills require meeting multiple conditions to be eligible hints, e.g. both Pace and Long; they will
instead be shown in the tool when either condition is selected.
</li>
<li>Skills for each tag are auto-generated from the game's local database.</li>
<li>Tag names are inferred from the skills that have them.</li>
</ul>
</div>
+1 -1
View File
@@ -30,7 +30,7 @@
const careerMod = $derived(isCareer ? 400 : 0);
const powerStat = $derived(rawPower + careerMod);
const gutsStat = $derived(rawGuts + careerMod);
const style = $derived(isRunaway ? RunningStyle.GreatEscape : RunningStyle.FrontRunner);
const style = $derived(isRunaway ? RunningStyle.Runaway : RunningStyle.FrontRunner);
const phases = [Phase.EarlyRace, Phase.MidRace, Phase.LateRace] as const;
const accel = $derived(phases.map((p) => acceleration(powerStat, style, surfApt, p)));
+48 -50
View File
@@ -1,76 +1,74 @@
<script lang="ts">
import type { ComputedSeries } from '$lib/chart';
import { AptitudeLevel, duelSpeedMod, inverseSpurtSpeed, RunningStyle, spurtSpeed, Stat } from '$lib/race';
import * as race from '$lib/race';
import StatChart from '$lib/StatChart.svelte';
import CalcInfo from '../CalcInfo.svelte';
const aptsList = Object.entries(AptitudeLevel).filter(([, val]) => typeof val === 'number');
const stylesList = [
['Front Runner', RunningStyle.FrontRunner],
['Pace Chaser', RunningStyle.PaceChaser],
['Late Surger', RunningStyle.LateSurger],
['End Closer', RunningStyle.EndCloser],
['Great Escape', RunningStyle.GreatEscape],
] as const;
const skillSpeeds = [0.45, 0.35, 0.25, 0.15] as const;
let rawSpeed: number = $state(1200);
let rawGuts: number = $state(1200);
let style: RunningStyle = $state(RunningStyle.FrontRunner);
let opponentStyle: RunningStyle = $state(RunningStyle.PaceChaser);
let distanceApt: AptitudeLevel = $state(AptitudeLevel.S);
let style: race.RunningStyle = $state(race.RunningStyle.FrontRunner);
let opponentStyle: race.RunningStyle = $state(race.RunningStyle.PaceChaser);
let distanceApt: race.AptitudeLevel = $state(race.AptitudeLevel.S);
let raceLen: number = $state(2000);
let isCareer: boolean = $state(false);
const careerMod = $derived(isCareer ? 400 : 0);
const speedStat = $derived(rawSpeed + careerMod);
const gutsStat = $derived(rawGuts + careerMod);
const speed = $derived(spurtSpeed(speedStat, gutsStat, style, distanceApt, raceLen));
const duelBonus = $derived(duelSpeedMod(gutsStat));
const speed = $derived(race.spurtSpeed(speedStat, gutsStat, style, distanceApt, raceLen));
const duelBonus = $derived(race.duelSpeedMod(gutsStat));
const sProf = $derived([
inverseSpurtSpeed(speed, gutsStat, RunningStyle.FrontRunner, AptitudeLevel.S, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.PaceChaser, AptitudeLevel.S, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.LateSurger, AptitudeLevel.S, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.EndCloser, AptitudeLevel.S, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.GreatEscape, AptitudeLevel.S, raceLen) - careerMod,
race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.FrontRunner, race.AptitudeLevel.S, raceLen) - careerMod,
race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.PaceChaser, race.AptitudeLevel.S, raceLen) - careerMod,
race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.LateSurger, race.AptitudeLevel.S, raceLen) - careerMod,
race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.EndCloser, race.AptitudeLevel.S, raceLen) - careerMod,
race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.Runaway, race.AptitudeLevel.S, raceLen) - careerMod,
]);
const aProf = $derived([
inverseSpurtSpeed(speed, gutsStat, RunningStyle.FrontRunner, AptitudeLevel.A, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.PaceChaser, AptitudeLevel.A, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.LateSurger, AptitudeLevel.A, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.EndCloser, AptitudeLevel.A, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.GreatEscape, AptitudeLevel.A, raceLen) - careerMod,
race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.FrontRunner, race.AptitudeLevel.A, raceLen) - careerMod,
race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.PaceChaser, race.AptitudeLevel.A, raceLen) - careerMod,
race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.LateSurger, race.AptitudeLevel.A, raceLen) - careerMod,
race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.EndCloser, race.AptitudeLevel.A, raceLen) - careerMod,
race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.Runaway, race.AptitudeLevel.A, raceLen) - careerMod,
]);
const skillProf = $derived(
skillSpeeds.map((v) => [v, inverseSpurtSpeed(speed + v, gutsStat, opponentStyle, AptitudeLevel.S, raceLen) - careerMod]),
skillSpeeds.map((v) => [
v,
race.inverseSpurtSpeed(speed + v, gutsStat, opponentStyle, race.AptitudeLevel.S, raceLen) - careerMod,
]),
);
const speedChart: Array<ComputedSeries | null> = $derived([
{ label: 'Aptitude S', y: (x) => spurtSpeed(x, gutsStat, style, AptitudeLevel.S, raceLen) },
{ label: 'Aptitude S', y: (x) => race.spurtSpeed(x, gutsStat, style, race.AptitudeLevel.S, raceLen) },
{
label: `Aptitude ${AptitudeLevel[distanceApt]} + Dueling`,
y: (x) => spurtSpeed(x, gutsStat, style, distanceApt, raceLen) + duelBonus,
label: `Aptitude ${race.AptitudeLevel[distanceApt]} + Dueling`,
y: (x) => race.spurtSpeed(x, gutsStat, style, distanceApt, raceLen) + duelBonus,
},
distanceApt !== AptitudeLevel.S
? { label: `Aptitude ${AptitudeLevel[distanceApt]}`, y: (x) => spurtSpeed(x, gutsStat, style, distanceApt, raceLen) }
distanceApt !== race.AptitudeLevel.S
? {
label: `Aptitude ${race.AptitudeLevel[distanceApt]}`,
y: (x) => race.spurtSpeed(x, gutsStat, style, distanceApt, raceLen),
}
: null,
]);
const gutsChart: Array<ComputedSeries | null> = $derived([
{ label: 'Aptitude S', y: (x) => spurtSpeed(speedStat, x, style, AptitudeLevel.S, raceLen) },
{ label: 'Aptitude S', y: (x) => race.spurtSpeed(speedStat, x, style, race.AptitudeLevel.S, raceLen) },
{
label: `Aptitude ${AptitudeLevel[distanceApt]} + Dueling`,
y: (x) => spurtSpeed(speedStat, x, style, distanceApt, raceLen) + duelSpeedMod(x),
label: `Aptitude ${race.AptitudeLevel[distanceApt]} + Dueling`,
y: (x) => race.spurtSpeed(speedStat, x, style, distanceApt, raceLen) + race.duelSpeedMod(x),
},
distanceApt !== AptitudeLevel.S
? { label: `Aptitude ${AptitudeLevel[distanceApt]}`, y: (x) => spurtSpeed(speedStat, x, style, distanceApt, raceLen) }
distanceApt !== race.AptitudeLevel.S
? {
label: `Aptitude ${race.AptitudeLevel[distanceApt]}`,
y: (x) => race.spurtSpeed(speedStat, x, style, distanceApt, raceLen),
}
: null,
]);
const range: [number, number] = $derived([
spurtSpeed(200, gutsStat, RunningStyle.GreatEscape, AptitudeLevel.C, raceLen),
spurtSpeed(2000, gutsStat, RunningStyle.EndCloser, AptitudeLevel.S, raceLen) + duelBonus,
]);
const range: [number, number] = [20, 27.5];
</script>
<h1 class="text-4xl">Spurt Speed Calculator</h1>
@@ -86,16 +84,16 @@
<div class="m-4">
<label for="style">Style</label>
<select id="style" required bind:value={style} class="w-full">
{#each stylesList as [name, style] (style)}
<option value={style}>{style === RunningStyle.GreatEscape ? 'Great Escape (Runaway)' : name}</option>
{#each race.RUNNING_STYLES as [name, style] (style)}
<option value={style}>{name}</option>
{/each}
</select>
</div>
<div class="m-4">
<label for="distanceApt">Distance Aptitude</label>
<select id="distanceApt" required bind:value={distanceApt} class="w-full">
{#each aptsList.toReversed() as [name, val] (val)}
<option value={val}>{name}</option>
{#each race.APTITUDE_LEVELS as apt (apt)}
<option value={apt}>{race.AptitudeLevel[apt]}</option>
{/each}
</select>
</div>
@@ -112,13 +110,13 @@
<CalcInfo title="Target Spurt Speed" class="flex-1">{speed.toFixed(3)} m/s</CalcInfo>
<CalcInfo title="Dueling Bonus" class="flex-1">+{duelBonus.toFixed(3)} m/s</CalcInfo>
</div>
{#each [[AptitudeLevel.A, aProf] as const, [AptitudeLevel.S, sProf] as const] as [level, inv] (level)}
{#each [[race.AptitudeLevel.A, aProf] as const, [race.AptitudeLevel.S, sProf] as const] as [level, inv] (level)}
<div class="mx-auto max-w-3xl">
<span class="mt-8 block w-full">
With {AptitudeLevel[level]} proficiency, the equivalent speed is
With {race.AptitudeLevel[level]} proficiency, the equivalent speed is
</span>
<div class="flex flex-col md:flex-row">
{#each stylesList as [styleName, s] (s)}
{#each race.RUNNING_STYLES as [styleName, s] (s)}
<CalcInfo title={styleName} class={['flex-1', { 'border-2': s === style }]}>{inv[s]}</CalcInfo>
{/each}
</div>
@@ -128,8 +126,8 @@
<span class="mt-8 block w-full">
While a speed skill is active, the equivalent speed for a distance S
<select id="opponentStyle" required bind:value={opponentStyle}>
{#each stylesList as [name, style] (style)}
<option value={style}>{style === RunningStyle.GreatEscape ? 'Great Escape (Runaway)' : name}</option>
{#each race.RUNNING_STYLES as [name, style] (style)}
<option value={style}>{style === race.RunningStyle.Runaway ? 'Great Escape (Runaway)' : name}</option>
{/each}
</select>
is
@@ -141,6 +139,6 @@
</div>
</div>
<div class="mx-auto grid h-96 grid-cols-1 py-4 md:grid-cols-2">
<StatChart stat={Stat.Speed} y={speedChart} yLabel="Spurt Speed (m/s)" xRule={speedStat} {range} />
<StatChart stat={Stat.Guts} y={gutsChart} yLabel="Spurt Speed (m/s)" xRule={gutsStat} {range} />
<StatChart stat={race.Stat.Speed} y={speedChart} yLabel="Spurt Speed (m/s)" xRule={speedStat} {range} />
<StatChart stat={race.Stat.Guts} y={gutsChart} yLabel="Spurt Speed (m/s)" xRule={gutsStat} {range} />
</div>
+3
View File
@@ -39,6 +39,9 @@ export default defineConfig({
'/api': {
target: 'http://localhost:13669',
},
'/img': {
target: 'http://localhost:13669',
},
},
},
});