Compare commits

..

25 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
zephyr 2c1b4b9261 zenno/doc/race: lint fix
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline was successful
2026-07-18 16:58:20 -04:00
zephyr e5128b6783 zenno/race/spurt: add dueling info 2026-07-18 16:58:12 -04:00
zephyr b22c8307fd zenno/race: common component for calculator outputs 2026-07-18 16:38:07 -04:00
zephyr cb54bbe414 zenno/doc/frbm: add doccy references to some sections
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline failed
2026-07-13 18:59:52 -04:00
zephyr 096cff28b6 zenno/doc/frbm: change of cm16 plans 2026-07-13 18:45:51 -04:00
zephyr 197df8f022 zenno/doc/race: add reference links to doccy 2026-07-13 18:41:18 -04:00
zephyr 009dd1d695 zenno: add page titles and descriptions
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline was successful
2026-07-12 14:37:43 -04:00
zephyr bdafc93315 zenno: lint fix
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline was successful
2026-07-10 20:01:20 -04:00
zephyr ef5b06d66e zenno: pull vite config from stam calc branch
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline failed
ci/woodpecker/manual/horsebot Pipeline was successful
ci/woodpecker/manual/zenno Pipeline failed
2026-07-10 17:34:02 -04:00
zephyr 65b81612bc ci: allow running manually
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline failed
2026-07-10 17:29:35 -04:00
zephyr 6269665e79 ci: always run zenno workflow 2026-07-10 16:20:02 -04:00
zephyr cae7d5b8f4 zenno: format 2026-07-10 15:37:29 -04:00
zephyr fca43e64b5 zenno/doc/spark: make spark select searchable 2026-07-09 15:40:07 -04:00
zephyr bc395a0788 zenno: refactor CharaPick to generic component 2026-07-09 14:54:03 -04:00
39 changed files with 1968 additions and 1058 deletions
+1
View File
@@ -2,6 +2,7 @@ when:
- event: push - event: push
branch: main branch: main
- event: pull_request - event: pull_request
- event: manual
steps: steps:
- name: test - name: test
+1 -2
View File
@@ -1,9 +1,8 @@
when: when:
- event: push - event: push
branch: main branch: main
path: 'zenno/**'
- event: pull_request - event: pull_request
path: 'zenno/**' - event: manual
steps: steps:
- name: test - name: test
+12 -2
View File
@@ -1,10 +1,20 @@
# horsebot # 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. 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. 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. 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 addr string
mdbf string mdbf string
public string public string
img string
// discord // discord
tokenFile string tokenFile string
apiRoute string apiRoute string
@@ -47,6 +48,7 @@ func main() {
flag.StringVar(&addr, "http", ":13669", "`address` to bind HTTP server") flag.StringVar(&addr, "http", ":13669", "`address` to bind HTTP server")
flag.StringVar(&mdbf, "mdb", "", "`path` to master.mdb") flag.StringVar(&mdbf, "mdb", "", "`path` to master.mdb")
flag.StringVar(&public, "public", "", "`dir`ectory containing the website to serve") 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(&tokenFile, "token", "", "`file` containing the Discord bot token")
flag.StringVar(&apiRoute, "route", "/interactions/callback", "`path` to serve Discord HTTP API calls") flag.StringVar(&apiRoute, "route", "/interactions/callback", "`path` to serve Discord HTTP API calls")
flag.StringVar(&pubkey, "key", "", "Discord public key") flag.StringVar(&pubkey, "key", "", "Discord public key")
@@ -86,6 +88,17 @@ func main() {
os.Exit(1) 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 var client *bot.Client
if tokenFile != "" { if tokenFile != "" {
skills, err := mdb.Skills(ctx, db) skills, err := mdb.Skills(ctx, db)
@@ -128,6 +141,9 @@ func main() {
if public != "" { if public != "" {
mux.Handle("GET /", httpmiddle.Compress(5)(http.FileServerFS(os.DirFS(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", 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/affinity-detail", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.AffinityDetail)))
mux.Handle("GET /api/global/character", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.Characters))) 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) - 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 - 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) # succession factor (sparks)
tables are succession_factor and succession_factor_effect 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 - 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) - 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) - 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!!! # lobby conversations!!!
+15 -10
View File
@@ -4,32 +4,37 @@ go 1.25.5
require ( require (
github.com/disgoorg/disgo v0.19.6 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/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 zombiezen.com/go/sqlite v1.4.2
) )
require ( 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/json/v2 v2.0.0 // indirect
github.com/disgoorg/omit v1.0.0 // indirect github.com/disgoorg/omit v1.0.0 // indirect
github.com/disgoorg/snowflake/v2 v2.0.3 // indirect github.com/disgoorg/snowflake/v2 v2.0.3 // indirect
github.com/dustin/go-humanize v1.0.1 // 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/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 // indirect github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 // indirect
github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/compress v1.19.1 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad // indirect github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad // indirect
golang.org/x/crypto v0.53.0 // indirect github.com/tetratelabs/wazero v1.12.0 // indirect
golang.org/x/sys v0.46.0 // indirect golang.org/x/crypto v0.54.0 // indirect
golang.org/x/tools v0.46.0 // indirect golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.73.4 // 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/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // 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/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 h1:1xJQt6KZgiqvBOnjuziG1gE4Iqd0aJn8DNfE6eE+0qw=
github.com/disgoorg/disgo v0.19.6/go.mod h1:NnV63iw4lJdF1fnV0gX27XR43ZgRGqnL122svRMTgTE= 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.3.0 h1:37F3ZuiMd8/EXeoCtTeE8iP2eT2nWsfEa4/ITwoKfe4=
github.com/disgoorg/godave v0.1.0/go.mod h1:OreAC3hpabr39bMVA+jwOVDq1EUPXH5A0XUBiZaDI1Y= 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 h1:U16yy/ARK7/aEpzjjqK1b/KaqqGHozUdeVw/DViEzQI=
github.com/disgoorg/json/v2 v2.0.0/go.mod h1:jZTBC0nIE1WeetSEI3/Dka8g+qglb4FPVmp5I5HpEfI= github.com/disgoorg/json/v2 v2.0.0/go.mod h1:jZTBC0nIE1WeetSEI3/Dka8g+qglb4FPVmp5I5HpEfI=
github.com/disgoorg/omit v1.0.0 h1:y0LkVUOyUHT8ZlnhIAeOZEA22UYykeysK8bLJ0SfT78= 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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 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/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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 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.74.1 h1:rCGVdCSoOkfcfTxtxCO/vG2yceDjRlKSlv7IfCGkc/Y=
github.com/junegunn/fzf v0.73.1/go.mod h1:hKDkO8orBWT5VLIZOL8oyhxn7mRLXdGyu+gz1Ss58pU= 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 h1:7dYDtfMDfKzjT+DVfIS4iqknSEKtZpEcXtu6vuaasHs=
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c= 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.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= 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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad/go.mod h1:/pA7k3zsXKdjjAiUhB5CjuKib9KJGCaLvZwtxGC8U0s=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= 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 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= 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 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 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.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= 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 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= modernc.org/libc v1.74.3 h1:a4J+Z8aVaxPyjyxRAdJzw246PqpcFGvVPnfT/AuM5Ws=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= 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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 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/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= 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 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+1 -1
View File
@@ -14,4 +14,4 @@ SELECT
FROM single_mode_scenario sc FROM single_mode_scenario sc
JOIN scenario_name n ON sc.id = n.id JOIN scenario_name n ON sc.id = n.id
JOIN scenario_title t ON sc.id = t.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", "@eslint/js": "^10.0.1",
"@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.60.1", "@sveltejs/kit": "^2.70.1",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.3",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/node": "^22.19.19", "@types/node": "^22.20.1",
"@vitest/browser-playwright": "^4.1.0", "@vitest/browser-playwright": "^4.1.0",
"d3": "^7.9.0", "d3": "^7.9.0",
"eslint": "^10.4.0", "eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.17.1", "eslint-plugin-svelte": "^3.22.0",
"globals": "^17.6.0", "globals": "^17.7.0",
"playwright": "^1.60.0", "playwright": "^1.61.1",
"prettier": "^3.8.3", "prettier": "^3.9.6",
"prettier-plugin-svelte": "^3.5.2", "prettier-plugin-svelte": "^3.5.2",
"prettier-plugin-tailwindcss": "^0.7.4", "prettier-plugin-tailwindcss": "^0.7.4",
"svelte": "^5.55.7", "svelte": "^5.56.7",
"svelte-check": "^4.4.8", "svelte-check": "^4.7.3",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.59.3", "typescript-eslint": "^8.65.0",
"vite": "^7.3.3", "vite": "^7.3.6",
"vitest": "^4.1.0", "vitest": "^4.1.0",
"vitest-browser-svelte": "^2.1.1" "vitest-browser-svelte": "^2.2.1"
}, },
"dependencies": { "dependencies": {
"@observablehq/plot": "^0.6.17", "@observablehq/plot": "^0.6.17",
"mathjs": "^15.2.0" "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}
+12 -136
View File
@@ -1,153 +1,29 @@
<script lang="ts"> <script lang="ts">
import type { Character } from '$lib/data/character'; import type { Character } from '$lib/data/character';
import type { Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements'; import type { ClassValue } from 'svelte/elements';
import { stringsearch } from './stringsearch'; import Pick from './Pick.svelte';
import CharaIcon from './CharaIcon.svelte';
interface Props { interface Props {
id: string; id: string;
characters: Character[] | null; characters: Character[] | null;
value: Character | undefined; value: Character | undefined;
class?: ClassValue | null; class?: ClassValue | null;
option?: Snippet<[Character]>;
required?: boolean; 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();
let popover: HTMLElement | undefined = $state();
let optionsContainer: HTMLElement | undefined = $state();
const expanded = $derived(!popover?.hidden);
let search = $state('');
const charas = $derived(characters ?? []); const charas = $derived(characters ?? []);
const searchedCharas = $derived(stringsearch(search, charas, ({ name }) => name) || charas);
$effect(() => { const key = (v: Character) => ({ id: v.chara_id, name: v.name });
if (required && value == null && charas.length > 0) {
value = charas[0];
}
});
function setByElem(o: HTMLElement) {
const j = parseInt(o.dataset.charaId ?? '');
value = charas.find((c) => c.chara_id === j) ?? value;
o.focus();
}
function onkeydown(evt: KeyboardEvent) {
switch (evt.key) {
case 'ArrowDown': {
const opts = [...optionsContainer!.children] as HTMLElement[];
const i = opts.findIndex((n) => parseInt(n.dataset.charaId ?? '') === value?.chara_id);
const o = i != null && i >= 0 ? opts[Math.min(i + 1, opts.length - 1)] : opts[0];
setByElem(o);
break;
}
case 'ArrowUp': {
const opts = [...optionsContainer!.children] as HTMLElement[];
const i = opts.findIndex((n) => parseInt(n.dataset.charaId ?? '') === value?.chara_id);
const o = i != null && i > 0 ? opts[i - 1] : opts[0];
setByElem(o);
break;
}
case 'Home': {
search = '';
setByElem(optionsContainer!.children[0] as HTMLElement);
break;
}
case 'End': {
search = '';
setByElem(optionsContainer!.children[optionsContainer!.childElementCount - 1] as HTMLElement);
break;
}
case ' ':
popover!.showPopover();
break;
case 'Escape':
// TODO(zeph): restore chara that was selected when the popup opened?
// for now just hide
popover!.hidePopover();
break;
case 'Enter':
popover!.togglePopover();
break;
default:
return;
}
evt.preventDefault();
}
</script> </script>
<div class={className} style={`anchor-name: --anchor-${id}`}> <Pick {id} items={charas} {key} bind:value class={className} {required}>
<span {#snippet option(c: Character)}
{id} <span>
class="block h-9 content-center bg-mist-300 hover:cursor-pointer dark:bg-mist-900" <CharaIcon chara_id={c.chara_id} size={32} />
role="combobox" {c.name}
aria-expanded={expanded} </span>
aria-controls="charaOptions" {/snippet}
aria-haspopup="listbox" </Pick>
onclick={() => popover?.togglePopover()}
{onkeydown}
tabindex="0">{value?.name ?? ''}</span
>
<div
class="skill-tip absolute top-2 flex-col px-2 shadow-lg open:flex"
style={`position-anchor: --anchor-${id}; position-area: bottom;`}
id="charaOptions"
role="listbox"
popover
bind:this={popover}
>
<input
class="my-2 min-h-8 rounded-md border pointer-coarse:min-h-12"
placeholder=" Search"
role="searchbox"
bind:value={search}
/>
<div class="max-h-72 overflow-y-scroll" bind:this={optionsContainer}>
{#if !required}
<div
class="h-8 w-full text-lg hover:cursor-pointer hover:bg-mist-300 hover:dark:bg-mist-900"
role="option"
aria-selected={value == undefined}
tabindex="0"
data-chara-id=""
onmousedown={() => {
value = undefined;
search = '';
popover!.hidePopover();
}}
onfocus={() => (value = undefined)}
{onkeydown}
>
<span class="text-sm italic">Reset</span>
</div>
{/if}
{#each searchedCharas as c (c.chara_id)}
<div
class="h-8 w-full text-lg hover:cursor-pointer hover:bg-mist-300 hover:dark:bg-mist-900"
role="option"
aria-selected={value?.chara_id === c.chara_id}
tabindex="0"
data-chara-id={c.chara_id}
onmousedown={() => {
value = c;
popover!.hidePopover();
}}
onfocus={() => (value = c)}
{onkeydown}
>
{#if option != null}
{@render option(c)}
{:else}
<span>{c.name}</span>
{/if}
</div>
{:else}
<div class="w-full h-8 text-lg italic">No matches.</div>
{/each}
</div>
</div>
</div>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import * as all from '$lib/all';
interface Props {
title?: string;
pages: all.Page[];
}
let { title, pages }: Props = $props();
</script>
{#if title != null}
<h2 class="mt-8 mb-4 text-4xl">{title}</h2>
{/if}
<ul class="mb-4 list-disc pl-4">
{#each pages as page (page.route)}
<li>
<a href={page.route}>{page.name}</a> &mdash; {page.description}
</li>
{/each}
</ul>
+155
View File
@@ -0,0 +1,155 @@
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import { stringsearch } from './stringsearch';
interface Props {
id: string;
items: T[];
key: (t: T) => { id: number; name: string };
value: T | undefined;
option?: Snippet<[T]>;
class?: ClassValue | null;
required?: boolean;
}
let { id, items, key, value = $bindable(), option, class: className, required = false }: Props = $props();
let popover: HTMLElement | undefined = $state();
let optionsContainer: HTMLElement | undefined = $state();
const expanded = $derived(!popover?.hidden);
let search = $state('');
const its = $derived(items ?? []);
const searched = $derived(stringsearch(search, its, (v) => key(v).name) || its);
$effect(() => {
if (required && value == null && its.length > 0) {
value = its[0];
}
});
function setByElem(o: HTMLElement) {
const j = parseInt(o.dataset.pickId ?? '');
value = its.find((c) => key(c).id === j) ?? value;
o.focus();
}
function onkeydown(evt: KeyboardEvent) {
switch (evt.key) {
case 'ArrowDown': {
const opts = [...optionsContainer!.children] as HTMLElement[];
const i = opts.findIndex((n) => value != null && parseInt(n.dataset.pickId ?? '') === key(value).id);
const o = i >= 0 ? opts[Math.min(i + 1, opts.length - 1)] : opts[0];
setByElem(o);
break;
}
case 'ArrowUp': {
const opts = [...optionsContainer!.children] as HTMLElement[];
const i = opts.findIndex((n) => value != null && parseInt(n.dataset.pickId ?? '') === key(value).id);
const o = i > 0 ? opts[i - 1] : opts[0];
setByElem(o);
break;
}
case 'Home': {
search = '';
setByElem(optionsContainer!.children[0] as HTMLElement);
break;
}
case 'End': {
search = '';
setByElem(optionsContainer!.children[optionsContainer!.childElementCount - 1] as HTMLElement);
break;
}
case ' ':
popover!.showPopover();
break;
case 'Escape':
// TODO(zeph): restore chara that was selected when the popup opened?
// for now just hide
popover!.hidePopover();
break;
case 'Enter':
popover!.togglePopover();
break;
default:
return;
}
evt.preventDefault();
}
</script>
<div class={className} style={`anchor-name: --anchor-${id}`}>
<span
{id}
class="block h-9 content-center bg-mist-300 hover:cursor-pointer dark:bg-mist-900"
role="combobox"
aria-expanded={expanded}
aria-controls="charaOptions"
aria-haspopup="listbox"
onclick={() => popover?.togglePopover()}
{onkeydown}
tabindex="0">{value != null ? key(value).name : ''}</span
>
<div
class="skill-tip absolute top-2 flex-col px-2 shadow-lg open:flex"
style={`position-anchor: --anchor-${id}; position-area: bottom;`}
id="charaOptions"
role="listbox"
popover
bind:this={popover}
>
<input
class="my-2 min-h-8 rounded-md border pointer-coarse:min-h-12"
placeholder=" Search"
role="searchbox"
bind:value={search}
/>
<div class="max-h-72 overflow-y-scroll" bind:this={optionsContainer}>
{#if !required}
<div
class="h-8 w-full text-lg hover:cursor-pointer hover:bg-mist-300 hover:dark:bg-mist-900"
role="option"
aria-selected={value == undefined}
tabindex="0"
data-pick-id=""
onmousedown={() => {
value = undefined;
search = '';
popover!.hidePopover();
}}
onfocus={() => (value = undefined)}
{onkeydown}
>
<span class="text-sm italic">Reset</span>
</div>
{/if}
{#each searched as c (key(c).id)}
{@const v = value != null ? key(value).id : 0}
{@const k = key(c)}
<div
class="h-8 w-full text-lg hover:cursor-pointer hover:bg-mist-300 hover:dark:bg-mist-900"
role="option"
aria-selected={v === k.id}
tabindex="0"
data-pick-id={k.id}
onmousedown={() => {
value = c;
popover!.hidePopover();
}}
onfocus={() => (value = c)}
{onkeydown}
>
{#if option != null}
{@render option(c)}
{:else}
<span>{k.name}</span>
{/if}
</div>
{:else}
<div class="w-full h-8 text-lg italic">No matches.</div>
{/each}
</div>
</div>
</div>
+8 -1
View File
@@ -9,6 +9,7 @@
type Ability, type Ability,
type Skill, type Skill,
} from './data/skill'; } from './data/skill';
import SkillIcon from './SkillIcon.svelte';
interface Props { interface Props {
skill: Skill | null | undefined; skill: Skill | null | undefined;
@@ -24,6 +25,7 @@
group_rate: 1, group_rate: 1,
wit_check: false, wit_check: false,
activations: [], activations: [],
tags: [],
icon_id: 0, icon_id: 0,
}; };
@@ -62,9 +64,13 @@
<span class="group relative inline-block hyphens-none"> <span class="group relative inline-block hyphens-none">
<span <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" 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"> <span class="block border-b py-1 hyphens-auto">
{s.description} {s.description}
</span> </span>
@@ -91,5 +97,6 @@
</span> </span>
{/each} {/each}
</span> </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 class={[spanClass, 'cursor-pointer']}>{s.name}</span>
</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}
+103
View File
@@ -0,0 +1,103 @@
import { resolve } from '$app/paths';
import type { ResolvedPathname } from '$app/types';
export interface Page {
route: ResolvedPathname;
name: string;
description: string;
}
/**
* All tool and doc pages in the site.
*/
export const PAGES = {
index: [
{
route: resolve('/race'),
name: 'Race Mechanics Tools',
description: 'Tools for calculating Umamusume race characteristics.',
},
{
route: resolve('/chara'),
name: 'Character Tools',
description: 'Tools for understanding Umamusume characters.',
},
{
route: resolve('/doc'),
name: 'Documents',
description: 'Articles about Umamusume, including research and editorials.',
},
] satisfies Page[],
race: [
{
route: resolve('/race/spurt'),
name: 'Spurt Speed',
description: 'Calculate target speed in the last spurt and compare to other distance aptitudes and running styles.',
},
{
route: resolve('/race/mspeed'),
name: 'Front Runner Mechanical Speed Comparator',
description: 'Compare spot struggle and lane combo to the effects of individual skills.',
},
{
route: resolve('/race/gate'),
name: 'Gate Calculator',
description: 'Calculate the importance of acceleration effects at the starting gate.',
},
] satisfies Page[],
chara: [
{
route: resolve('/chara/affinity'),
name: 'Affinity Details',
description: 'Details of why characters have the base compatibility numbers they have.',
},
{
route: resolve('/chara/convo'),
name: 'Lobby Conversations',
description: 'Check participants in lobby conversations and get recommendations on unlocking them quickly.',
},
] satisfies Page[],
doc: [
{
route: resolve('/doc/frbm'),
name: 'Front Runner Black Magic',
description: 'Detailed analysis of how front runners interact with race mechanics.',
},
{
route: resolve('/doc/lanecalc'),
name: "Gaikokujin's Guide to Lanecalc",
description: 'English language manual for 危険回避シミュ, or Lanecalc, for precise lane combo simulation.',
},
{
route: resolve('/doc/race'),
name: 'Race Mechanics Charts',
description:
"Interactive adaptation of KuromiAK's race mechanics documentation for easier visualization of how mechanics scale.",
},
{
route: resolve('/doc/spark'),
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;
/**
* Get the page object for a route.
* @param route Route ID for the page
* @returns Matching page, or an empty object if none match
*/
export function page(route: string | null): Partial<Page> {
for (const p of Object.values(PAGES)) {
const r = p.find((v) => v.route === route);
if (r != null) {
return r;
}
}
return {};
}
+1 -1
View File
@@ -18,7 +18,7 @@ const styleMap = {
Sentou: RunningStyle.PaceChaser, Sentou: RunningStyle.PaceChaser,
Sasi: RunningStyle.LateSurger, Sasi: RunningStyle.LateSurger,
Oikomi: RunningStyle.EndCloser, Oikomi: RunningStyle.EndCloser,
Oonige: RunningStyle.GreatEscape, Oonige: RunningStyle.Runaway,
} as const; } as const;
export interface ImportUma { export interface ImportUma {
+62
View File
@@ -48,6 +48,10 @@ export interface Skill {
* SP cost to purchase the skill, if applicable. * SP cost to purchase the skill, if applicable.
*/ */
sp_cost?: number; sp_cost?: number;
/**
* Numeric tags applied to the skill, for effects which select skill types.
*/
tags: number[];
/** /**
* Skill icon ID. * Skill icon ID.
*/ */
@@ -133,10 +137,13 @@ export enum AbilityType {
Frenzy = 13, Frenzy = 13,
AddGateDelay = 14, AddGateDelay = 14,
CurrentSpeed = 21, CurrentSpeed = 21,
CurrentSpeedWithDecel = 22,
TargetSpeed = 27, TargetSpeed = 27,
LaneSpeed = 28, LaneSpeed = 28,
RushedChance = 29,
Accel = 31, Accel = 31,
LaneChange = 35, LaneChange = 35,
ActivateRare = 37,
} }
export const ABILITY_TYPE_FORMAT: Readonly<Record<AbilityType, (v: number) => string>> = { 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.Frenzy]: (v) => tenThousandths(v) + 's Frenzy',
[AbilityType.AddGateDelay]: (v) => plusBonus(v) + 's Gate Delay', [AbilityType.AddGateDelay]: (v) => plusBonus(v) + 's Gate Delay',
[AbilityType.CurrentSpeed]: (v) => plusBonus(v) + ' m/s Current Speed', [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.TargetSpeed]: (v) => plusBonus(v) + ' m/s Target Speed',
[AbilityType.LaneSpeed]: (v) => plusBonus(v) + ' CW/s Lane Change 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.Accel]: (v) => plusBonus(v) + ' m/s² Acceleration',
[AbilityType.LaneChange]: (v) => 'Target Lane = ' + tenThousandths(v) + ' CW', [AbilityType.LaneChange]: (v) => 'Target Lane = ' + tenThousandths(v) + ' CW',
[AbilityType.ActivateRare]: (v) => 'Activate Rare Skills ×' + tenThousandths(v),
} as const; } as const;
export enum AbilityValueUsage { export enum AbilityValueUsage {
@@ -329,3 +339,55 @@ export async function skillGroups(): Promise<SkillGroup[]> {
const resp = await fetch('/api/global/skill-group'); const resp = await fetch('/api/global/skill-group');
return resp.json(); 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;
+22
View File
@@ -144,3 +144,25 @@ export async function sparks(): Promise<Spark[]> {
const resp = await fetch('/api/global/spark'); const resp = await fetch('/api/global/spark');
return resp.json(); return resp.json();
} }
export interface SparkGroup {
spark_group: number;
name: string;
description: string;
type: Type;
effects: Effect[][];
spark_ids: number[];
}
export function byGroup(sparks: Spark[]): SparkGroup[] {
const r = new Map<number, SparkGroup>();
for (const { spark_id, name, description, spark_group, type, effects } of sparks) {
let g = r.get(spark_group);
if (g == null) {
g = { spark_group, name, description, type, effects, spark_ids: [] };
r.set(spark_group, g);
}
g.spark_ids.push(spark_id);
}
return [...r.values()];
}
+6 -6
View File
@@ -260,7 +260,7 @@ export function finalStat(adjStat: number, skillPassive: number): number {
/** /**
* Running styles for strategyphase coefficients. * 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. * mechanically identical to Front Runner.
*/ */
export enum RunningStyle { export enum RunningStyle {
@@ -268,7 +268,7 @@ export enum RunningStyle {
PaceChaser, PaceChaser,
LateSurger, LateSurger,
EndCloser, EndCloser,
GreatEscape, Runaway,
} }
/** /**
@@ -279,7 +279,7 @@ export const RUNNING_STYLES = [
['Pace Chaser', RunningStyle.PaceChaser], ['Pace Chaser', RunningStyle.PaceChaser],
['Late Surger', RunningStyle.LateSurger], ['Late Surger', RunningStyle.LateSurger],
['End Closer', RunningStyle.EndCloser], ['End Closer', RunningStyle.EndCloser],
['Great Escape', RunningStyle.GreatEscape], ['Runaway', RunningStyle.Runaway],
] as const; ] as const;
/** /**
@@ -325,7 +325,7 @@ const hpStrategyCoeff = {
[RunningStyle.PaceChaser]: 0.89, [RunningStyle.PaceChaser]: 0.89,
[RunningStyle.LateSurger]: 1.0, [RunningStyle.LateSurger]: 1.0,
[RunningStyle.EndCloser]: 0.995, [RunningStyle.EndCloser]: 0.995,
[RunningStyle.GreatEscape]: 0.86, [RunningStyle.Runaway]: 0.86,
} as const; } as const;
/** /**
@@ -595,7 +595,7 @@ const accelStrategyPhaseCoeff = {
[RunningStyle.PaceChaser]: [0.985, 1.0, 0.996], [RunningStyle.PaceChaser]: [0.985, 1.0, 0.996],
[RunningStyle.LateSurger]: [0.975, 1.0, 1.0], [RunningStyle.LateSurger]: [0.975, 1.0, 1.0],
[RunningStyle.EndCloser]: [0.945, 1.0, 0.997], [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; } as const;
const surfaceProficiencyMod = [0.1, 0.3, 0.5, 0.7, 0.8, 0.9, 1.0, 1.05] 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.Medium]: 0.86,
[Distance.Long]: 0.9, [Distance.Long]: 0.9,
}, },
[RunningStyle.GreatEscape]: { [RunningStyle.Runaway]: {
[Distance.Sprint]: 1, [Distance.Sprint]: 1,
[Distance.Mile]: 1, [Distance.Mile]: 1,
[Distance.Medium]: 1, [Distance.Medium]: 1,
+22 -4
View File
@@ -1,13 +1,31 @@
<script lang="ts"> <script lang="ts">
import './layout.css'; import './layout.css';
import favicon from '$lib/assets/favicon.png'; import favicon from '$lib/assets/favicon.png';
import * as all from '$lib/all';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import type { LayoutProps } from './$types';
import { page } from '$app/state';
let { children } = $props(); let { children }: LayoutProps = $props();
const cur = $derived(all.page(page.route.id));
const title = $derived(cur.name);
const desc = $derived(
cur.description ??
"She's read all about Umamusume, and she's always happy to share her knowledge and give recommendations! Tools and editorials for Umamusume optimization.",
);
</script> </script>
<svelte:head> <svelte:head>
<title>Zenno Rob Roy</title> {#if title != null}
<title>Zenno Rob Roy &ndash; {title}</title>
{:else}
<title>Zenno Rob Roy</title>
{/if}
<meta name="description" content={desc} />
<meta name="color-scheme" content="light dark" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<meta name="robots" content="noimageindex" />
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
</svelte:head> </svelte:head>
@@ -23,9 +41,9 @@
<a href={resolve('/doc')} class="mx-8 my-1 inline-block">Documents</a> <a href={resolve('/doc')} class="mx-8 my-1 inline-block">Documents</a>
</span> </span>
</nav> </nav>
<div class="mx-4 grow lg:m-auto lg:max-w-7xl lg:min-w-7xl"> <main class="mx-4 grow lg:m-auto lg:max-w-7xl lg:min-w-7xl">
{@render children()} {@render children()}
</div> </main>
<footer <footer
class="inset-x-0 bottom-0 mt-12 border-t bg-mist-300 p-4 text-center text-sm md:mt-20 dark:border-none dark:bg-mist-900" class="inset-x-0 bottom-0 mt-12 border-t bg-mist-300 p-4 text-center text-sm md:mt-20 dark:border-none dark:bg-mist-900"
> >
+6 -49
View File
@@ -1,59 +1,16 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import * as all from '$lib/all';
import IndexSection from '$lib/IndexSection.svelte';
</script> </script>
<h1 class="m-8 text-center text-7xl">Zenno Rob Roy</h1> <h1 class="m-8 text-center text-7xl">Zenno Rob Roy</h1>
<p>She's read all about Umamusume, and she's always happy to share her knowledge and give recommendations!</p> <p>She's read all about Umamusume, and she's always happy to share her knowledge and give recommendations!</p>
<h2 class="mt-8 mb-4 text-4xl">Race Mechanics Tools</h2> <IndexSection title="Race Mechanics Tools" pages={all.PAGES.race} />
<ul class="mb-4 list-disc pl-4"> <IndexSection title="Character Tools" pages={all.PAGES.chara} />
<li> <IndexSection title="Documents" pages={all.PAGES.doc} />
<a href={resolve('/race/spurt')}>Spurt Speed</a> &mdash; Calculate a horse's target speed in the last spurt and compare to other
distance aptitudes and running styles.
</li>
<li>
<a href={resolve('/race/mspeed')}>Front Runner Mechanical Speed Comparator</a> &mdash; Compare spot struggle and lane combo to the
effects of individual skills.
</li>
<li>
<a href={resolve('/race/gate')}>Gate Calculator</a> &mdash; Calculate the importance of acceleration effects at the starting gate.
</li>
</ul>
<h2 class="mt-8 mb-4 text-4xl">Character Tools</h2>
<ul class="mb-4 list-disc pl-4">
<li>
<a href={resolve('/chara/affinity')}>Affinity Details</a> &mdash; Details of why characters have the base compatibility numbers
they have.
</li>
<li>
<a href={resolve('/chara/convo')}>Lobby Conversations</a> &mdash; Check participants in lobby conversations and get recommendations
on unlocking them quickly.
</li>
</ul>
<h2 class="my-4 text-4xl">Documents</h2>
<ul class="mb-4 list-disc pl-4">
<li>
<a href={resolve('/doc/frbm')}>Front Runner Black Magic</a> &mdash; Detailed analysis of how front runners interact with race mechanics.
</li>
<li>
<a href={resolve('/doc/lanecalc')}>Gaikokujin's Guide to Lanecalc</a> &mdash; English language manual for
<a href="https://lanecalc.hf-uma.net/" target="_blank" rel="noopener noreferrer">危険回避シミュ</a>, or Lanecalc, for precise
lane combo simulation.
</li>
<li>
<a href={resolve('/doc/race')}>Race Mechanics Charts</a> &mdash; Interactive adaptation of
<a
href="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit"
target="_blank"
rel="noopener noreferrer">KuromiAK's race mechanics documentation</a
> for easier visualization of how mechanics scale.
</li>
<li>
<a href={resolve('/doc/spark')}>Spark Explorer</a> &mdash; View all possible inheritance effects of any spark.
</li>
</ul>
<!-- Not a real index section in, terms of types. -->
<h2 class="my-4 text-4xl">&amp;c.</h2> <h2 class="my-4 text-4xl">&amp;c.</h2>
<ul class="mb-4 list-disc pl-4"> <ul class="mb-4 list-disc pl-4">
<li> <li>
+8 -14
View File
@@ -1,17 +1,11 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { page } from '$app/state';
import * as all from '$lib/all';
import IndexSection from '$lib/IndexSection.svelte';
const cur = $derived(all.page(page.route.id));
</script> </script>
<h1 class="m-8 text-center text-7xl">Zenno Rob Roy &mdash; Character Tools</h1> <h1 class="m-8 text-center text-7xl">Zenno Rob Roy &mdash; {cur.name}</h1>
<p>Tools related to understanding Umamusume characters.</p> <p>{cur.description}</p>
<IndexSection pages={all.PAGES.chara} />
<ul class="mb-4 list-disc pl-4">
<li>
<a href={resolve('/chara/affinity')}>Affinity Details</a> &mdash; Details of why characters have the base compatibility numbers
they have.
</li>
<li>
<a href={resolve('/chara/convo')}>Lobby Conversations</a> &mdash; Check participants in lobby conversations and get recommendations
on unlocking them quickly.
</li>
</ul>
+3 -4
View File
@@ -8,13 +8,12 @@
const convoMap = $derived(byChara(conversations)); const convoMap = $derived(byChara(conversations));
const popular = $derived(groupPopulars(conversations)); const popular = $derived(groupPopulars(conversations));
let characters: Character[] = $state([]); let characters: Character[] = $state([]);
const selCharas = $derived(characters.filter((c) => convoMap.has(c.chara_id)));
const names = $derived(charaNames(characters ?? [])); const names = $derived(charaNames(characters ?? []));
const minSuggest = 8; const minSuggest = 8;
onMount(async () => { onMount(async () => {
const [convos, charas] = await Promise.all([conversation(), character()]); [conversations, characters] = await Promise.all([conversation(), character()]);
conversations = convos;
characters = charas.filter((c) => convoMap.has(c.chara_id));
}); });
let chara: Character | undefined = $state(); 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="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"> <div class="m-4 flex-1 md:mt-3">
<label for="chara" class="hidden md:inline">Character</label> <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>
<div class="m-4 flex-1 md:mt-3"> <div class="m-4 flex-1 md:mt-3">
<label for="convo" class="hidden md:inline">Conversation</label> <label for="convo" class="hidden md:inline">Conversation</label>
+8 -25
View File
@@ -1,28 +1,11 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { page } from '$app/state';
import * as all from '$lib/all';
import IndexSection from '$lib/IndexSection.svelte';
const cur = $derived(all.page(page.route.id));
</script> </script>
<h1 class="m-8 text-center text-7xl">Zenno Rob Roy &mdash; Documents</h1> <h1 class="m-8 text-center text-7xl">Zenno Rob Roy &mdash; {cur.name}</h1>
<p>Articles about Umamusume, including editorials and research.</p> <p>{cur.description}</p>
<IndexSection pages={all.PAGES.doc} />
<ul class="mb-4 list-disc pl-4">
<li>
<a href={resolve('/doc/frbm')}>Front Runner Black Magic</a> &mdash; Detailed analysis of how front runners interact with race mechanics.
</li>
<li>
<a href={resolve('/doc/lanecalc')}>Gaikokujin's Guide to Lanecalc</a> &mdash; English language manual for
<a href="https://lanecalc.hf-uma.net/" target="_blank" rel="noopener noreferrer">危険回避シミュ</a>, or Lanecalc, for precise
lane combo simulation.
</li>
<li>
<a href={resolve('/doc/race')}>Race Mechanics Charts</a> &mdash; Interactive adaptation of
<a
href="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit"
target="_blank"
rel="noopener noreferrer">KuromiAK's race mechanics documentation</a
> for easier visualization of how mechanics scale.
</li>
<li>
<a href={resolve('/doc/spark')}>Spark Explorer</a> &mdash; View all possible inheritance effects of any spark.
</li>
</ul>
+10 -6
View File
@@ -7,12 +7,13 @@
h?: 1 | 2 | 3 | 4 | 5 | 6; h?: 1 | 2 | 3 | 4 | 5 | 6;
id: string; id: string;
children: Snippet; children: Snippet;
ref?: string;
top?: string; top?: string;
toc?: boolean; toc?: boolean;
class?: ClassValue | null; class?: ClassValue | null;
} }
let { h = 1, id, children, top = '#top', toc = true, class: className }: Props = $props(); let { h = 1, id, children, ref, top = '#top', toc = true, class: className }: Props = $props();
const tag = $derived(`h${h}`); const tag = $derived(`h${h}`);
const href = $derived(`#${id}`); const href = $derived(`#${id}`);
@@ -39,9 +40,12 @@
<svelte:element this={tag} {id} class={['relative w-full', className]}> <svelte:element this={tag} {id} class={['relative w-full', className]}>
<!-- eslint-disable svelte/no-navigation-without-resolve --> <!-- eslint-disable svelte/no-navigation-without-resolve -->
<a {href}><span class="hidden md:inline">{sign}</span> {@render children()}</a> <a {href}><span class="hidden md:inline">{sign}</span> {@render children()}</a>
{#if h >= 2} <div class="absolute right-0 bottom-0 inline-block text-end align-bottom text-sm whitespace-nowrap">
<a href={top} class="absolute right-0 bottom-0 text-end align-bottom text-sm whitespace-nowrap transition-all hover:bottom-px" {#if ref != null}
>Top ▲</a <a href={ref} target="_blank" rel="noopener noreferrer">Ref &UpperRightArrow;</a>
> {/if}
{/if} {#if h >= 2}
<a href={top} class="ml-4">Top ▲</a>
{/if}
</div>
</svelte:element> </svelte:element>
+76 -28
View File
@@ -179,7 +179,7 @@
{ label: 'Style S', y: (x) => downhillAccelEnterChance(x * 1.1) * 100 }, { label: 'Style S', y: (x) => downhillAccelEnterChance(x * 1.1) * 100 },
{ label: 'Style A', y: (x) => downhillAccelEnterChance(x) * 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([ const secSpeedSeries: Array<ComputedAreas | null> = $derived([
{ {
label: 'Early Race', label: 'Early Race',
@@ -290,10 +290,16 @@
>. Many of those interpretations are also informed by the exceptionally knowledgeable folks on the >. Many of those interpretations are also informed by the exceptionally knowledgeable folks on the
<a href="https://discord.gg/SyAVkbBSkx" target="_blank" rel="noopener noreferrer">GameTora Discord server</a>. I may present <a href="https://discord.gg/SyAVkbBSkx" target="_blank" rel="noopener noreferrer">GameTora Discord server</a>. I may present
some of the information from the race mechanics doc in chart form, but I will generally leave out exact mechanic numbers and some of the information from the race mechanics doc in chart form, but I will generally leave out exact mechanic numbers and
conditions; the doc is already the place for that information. conditions; the doc is already the place for that information. Some sections have "Ref &UpperRightArrow;" links that will take
you to the corresponding section of the mechanics doc.
</p> </p>
<Sec h={2} id="mechanics">Race Mechanics</Sec> <Sec
h={2}
id="mechanics"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.3q7x98goz5cu"
>Race Mechanics</Sec
>
<p> <p>
Very quick gloss of race fundamentals. Races are divided into four phases: early race, mid race, late race, and last spurt Very quick gloss of race fundamentals. Races are divided into four phases: early race, mid race, late race, and last spurt
phase. They are also divided into twenty-four equal length sections. Early race is sections 1 to 4, mid race is sections 5 to phase. They are also divided into twenty-four equal length sections. Early race is sections 1 to 4, mid race is sections 5 to
@@ -320,10 +326,7 @@
<Sec h={3} id="runaway">Runaway</Sec> <Sec h={3} id="runaway">Runaway</Sec>
<p> <p>
The skill <Skill skill={skills.runaway} /> converts front runners into the <i>Great Escape</i> running style. However, no The skill <Skill skill={skills.runaway} /> converts front runners into the Runaway running style.
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.")
</p> </p>
<p> <p>
Runaways are still front runners for all purposes. The difference is just different numbers for things like base speed and Runaways are still front runners for all purposes. The difference is just different numbers for things like base speed and
@@ -331,7 +334,12 @@
runners also apply to runaways. runners also apply to runaways.
</p> </p>
<Sec h={3} id="phase-speed">Phase Speed</Sec> <Sec
h={3}
id="phase-speed"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.40iug3hm8429"
>Phase Speed</Sec
>
<p> <p>
Race base speed is multiplied by the speed strategy&ndash;phase coefficient for each horse. As the name suggests, SSPC is Race base speed is multiplied by the speed strategy&ndash;phase coefficient for each horse. As the name suggests, SSPC is
different per running style and per race phase. It's the thing that makes runaways take off in early race, and the thing that different per running style and per race phase. It's the thing that makes runaways take off in early race, and the thing that
@@ -344,8 +352,8 @@
</p> </p>
<p> <p>
The late race speed difference means that, in a competitive setting, the speed stat (and, correspondingly, distance aptitude) The late race speed difference means that, in a competitive setting, the speed stat (and, correspondingly, distance aptitude)
aren't what make front runners win most of the time. Capped speed with distance S is ideal, but 1100 speed with distance A aren't what make front runners win most of the time. Capped speed with distance S is ideal, but being a hundred speed short
will likely lose only a couple races within the difference throughout a CM event. with distance A will likely lose only a couple races within the difference throughout a CM event.
</p> </p>
<Sec h={3} id="win-cons">Win Conditions</Sec> <Sec h={3} id="win-cons">Win Conditions</Sec>
@@ -389,7 +397,12 @@
mid race. mid race.
</p> </p>
<Sec h={3} id="front-modes">Speed-Up and Overtake Modes</Sec> <Sec
h={3}
id="front-modes"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.tn8irhl5bjhv"
>Speed-Up and Overtake Modes</Sec
>
<p> <p>
During the first 41.67% of the race, <i>position keep</i> is busy arranging each running style into their respective packs. During the first 41.67% of the race, <i>position keep</i> is busy arranging each running style into their respective packs.
During position keep, all horses have access to <i>running modes</i> that influence how they run. During position keep, all horses have access to <i>running modes</i> that influence how they run.
@@ -410,7 +423,12 @@
/> />
</div> </div>
<Sec h={3} id="pdm">Pace Down Mode</Sec> <Sec
h={3}
id="pdm"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.5x2q33mxvumk"
>Pace Down Mode</Sec
>
<p> <p>
The running modes for all other running styles are pace-up, which is similar to speed-up, and paced-down, which activates The running modes for all other running styles are pace-up, which is similar to speed-up, and paced-down, which activates
whenever a horse gets what their style defines as too close to first place. whenever a horse gets what their style defines as too close to first place.
@@ -427,7 +445,12 @@
subject to PDM at all. Their mid race speed skills always gain distance. subject to PDM at all. Their mid race speed skills always gain distance.
</p> </p>
<Sec h={3} id="spot-struggle">Spot Struggle</Sec> <Sec
h={3}
id="spot-struggle"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.5l523bk8k3vz"
>Spot Struggle</Sec
>
<p> <p>
For each of runaways and non-runaways, there is at most one spot struggle per race. Runaways will not spot struggle with For each of runaways and non-runaways, there is at most one spot struggle per race. Runaways will not spot struggle with
non-runaways, nor vice-versa. When a spot struggle triggers, all front runnners of that type within range participate; I've non-runaways, nor vice-versa. When a spot struggle triggers, all front runnners of that type within range participate; I've
@@ -460,7 +483,12 @@
struggle duration rises. struggle duration rises.
</p> </p>
<Sec h={3} id="full-charge">Conserve Power &amp; Fully Charged</Sec> <Sec
h={3}
id="full-charge"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.9edgwx6l733b"
>Conserve Power &amp; Fully Charged</Sec
>
<p> <p>
New in the 1 Jul balance patch is the conserve power mechanic. Horses with base power plus power passive skills greater than New in the 1 Jul balance patch is the conserve power mechanic. Horses with base power plus power passive skills greater than
1200 effectively gain access to a free perfectly-timed accel skill, Fully Charged. The effectiveness of full charge varies 1200 effectively gain access to a free perfectly-timed accel skill, Fully Charged. The effectiveness of full charge varies
@@ -554,7 +582,12 @@
<a href={resolve('/doc/lanecalc')}>my guide</a> for English instructions on how to use it. <a href={resolve('/doc/lanecalc')}>my guide</a> for English instructions on how to use it.
</p> </p>
<Sec h={3} id="uphills">Uphills</Sec> <Sec
h={3}
id="uphills"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.wdj69udyo340"
>Uphills</Sec
>
<p> <p>
Running uphill carries a penalty to target speed. This penalty scales negatively with the power stat; that is, higher power Running uphill carries a penalty to target speed. This penalty scales negatively with the power stat; that is, higher power
means faster uphill running. It scales positively with slope angle. There is also a flat reduction in base acceleration for means faster uphill running. It scales positively with slope angle. There is also a flat reduction in base acceleration for
@@ -579,7 +612,12 @@
power on the Arima Kinen mid-race hills. power on the Arima Kinen mid-race hills.
</p> </p>
<Sec h={3} id="downhills">Downhills</Sec> <Sec
h={3}
id="downhills"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.wdj69udyo340"
>Downhills</Sec
>
<p> <p>
Running downhill allows horses to enter <i>downhill accel mode</i>. Contrary to its name, downhill accel mode does not affect Running downhill allows horses to enter <i>downhill accel mode</i>. Contrary to its name, downhill accel mode does not affect
acceleration at all; it gives horses a target speed boost that scales with the slope angle, plus lowered HP consumption via a acceleration at all; it gives horses a target speed boost that scales with the slope angle, plus lowered HP consumption via a
@@ -605,7 +643,12 @@
Conversely, the HP savings on long downhills can be enough to drop a recovery skill or two on some tracks. Conversely, the HP savings on long downhills can be enough to drop a recovery skill or two on some tracks.
</p> </p>
<Sec h={3} id="section-speed">Section Speed</Sec> <Sec
h={3}
id="section-speed"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.xdha40q2176g"
>Section Speed</Sec
>
<p> <p>
Each section, each horse gets a random modifier to target speed. The modifier's range is determined by the wit stat. Each section, each horse gets a random modifier to target speed. The modifier's range is determined by the wit stat.
(Curiously, the calculation uses both wit as modified by style proficiency and green skills as well as base wit.) (Curiously, the calculation uses both wit as modified by style proficiency and green skills as well as base wit.)
@@ -649,7 +692,12 @@
wit front A horse will pass in {secSpeedPassTime} seconds on average at mid race speeds. wit front A horse will pass in {secSpeedPassTime} seconds on average at mid race speeds.
</p> </p>
<Sec h={3} id="no-zone">No-Overtake Zone</Sec> <Sec
h={3}
id="no-zone"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.7l02ti4pvzxp"
>No-Overtake Zone</Sec
>
<p> <p>
The no-overtake zone is the 200m portion of the race prior to the first corner. For unclear reasons, while in the no-overtake The no-overtake zone is the 200m portion of the race prior to the first corner. For unclear reasons, while in the no-overtake
zone, horses cannot enter overtake lane mode, which is what allows them to move away from the rail. (Overtake lane mode is zone, horses cannot enter overtake lane mode, which is what allows them to move away from the rail. (Overtake lane mode is
@@ -1374,20 +1422,20 @@
</p> </p>
<ol class="mb-4 list-decimal pl-4"> <ol class="mb-4 list-decimal pl-4">
<li>Suzuka gets to be a runaway again!</li> <li>Suzuka gets to be a runaway again!</li>
<li>Chocobon's Sprint C is easy to fix!</li>
<li> <li>
Mejiro Palmer gets to have G sprint fixed and be a second runaway! Hopefully she will both stay in front of Taiki and not Taiki Shuttle has a very-nearly-perfect 3-4 unique, which will be great with her as a front runner. Given her lack of
block Suzuka! relevant built-in skills, I'm building her in MANT.
</li>
<li>
Taiki Shuttle has a very-nearly-perfect 3-4 unique, which will be great with her as a front runner. Given the importance of
skills, I'm building her in MANT.
</li> </li>
</ol> </ol>
<p> <p>
This CM will be interesting as the first opportunity to use multiple runaways and not have them be vaporized by spot struggle. This CM is interesting as the first opportunity to use multiple runaways and not have them be vaporized by spot struggle.
I'll actually want to click on power to make sure I break 1200, and I'll want to minimize guts to conserve more power, which However, after getting a high roll with an S Sprint Palmer, I found that using two runaways was actually pretty bad. Even with
means I'll reasonably get the ca. 800 stamina required for them to survive. The only downside is that runaways, and front 1300 power, <Skill skill={skills.gw} mention />, and <Skill skill={skills.ttl} mention />, runaways spend over two thirds of
runners broadly, are not actually good here, because <Skill skill={skills.headon} mention /> is so wildly good in sprint now. early race accelerating, so they lose the majority of their strongest race phase. Normal front runners can hardly compete with
the newly buffed <Skill skill={skills.headon} mention /> in late race, which means runaways who never got the lead they're known
for have virtually no chance. Suzuka can still fight because <Skill skill={skills.unrestrained} mention /> is also strong, but Palmer
doesn't make the cut.
</p> </p>
<Sec h={3} id="cm15">CM15 &ndash; Cancer Cup (Takarazuka Kinen)</Sec> <Sec h={3} id="cm15">CM15 &ndash; Cancer Cup (Takarazuka Kinen)</Sec>
+124 -23
View File
@@ -14,7 +14,7 @@
let surfaceApt = $state(race.AptitudeLevel.A); let surfaceApt = $state(race.AptitudeLevel.A);
let distanceApt = $state(race.AptitudeLevel.A); let distanceApt = $state(race.AptitudeLevel.A);
let raceLen = $state(2000); 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 distanceType = $derived(race.distance(raceLen));
const raceLenType = $derived(race.Distance[distanceType]); const raceLenType = $derived(race.Distance[distanceType]);
@@ -39,7 +39,7 @@
{ label: 'Pace Chaser', y: (x) => race.maxHP(raceLen, race.RunningStyle.PaceChaser, x) }, { 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: 'Late Surger', y: (x) => race.maxHP(raceLen, race.RunningStyle.LateSurger, x) },
{ label: 'End Closer', y: (x) => race.maxHP(raceLen, race.RunningStyle.EndCloser, 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 moveLane: ComputedSeries[] = [{ label: 'Move Lane Modifier', y: (x) => race.moveLaneModifier(x) }];
const uphill: ComputedSeries[] = [ const uphill: ComputedSeries[] = [
@@ -239,23 +239,43 @@
<Sec h={2} id="speed">Speed</Sec> <Sec h={2} id="speed">Speed</Sec>
<Sec h={3} id="spurt-speed">Spurt Speed</Sec> <Sec
h={3}
id="spurt-speed"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.mpfjmgpzqalr"
>Spurt Speed</Sec
>
<p>Target speed during the Uma's last spurt. See also <a href="#spurt-speed-guts">the effect of Guts</a>.</p> <p>Target speed during the Uma's last spurt. See also <a href="#spurt-speed-guts">the effect of Guts</a>.</p>
{@render statChart(race.Stat.Speed, spurtSpeed, 'Spurt Speed (m/s)', [20, 26.5], { len: true, style: true, dist: true })} {@render statChart(race.Stat.Speed, spurtSpeed, 'Spurt Speed (m/s)', [20, 26.5], { len: true, style: true, dist: true })}
<Sec h={3} id="late-race-speed">Late Race Speed</Sec> <Sec
h={3}
id="late-race-speed"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.43unjgrhmm7e"
>Late Race Speed</Sec
>
<p>Base target speed during late race when the Uma is not spurting (but not dead yet).</p> <p>Base target speed during late race when the Uma is not spurting (but not dead yet).</p>
{@render statChart(race.Stat.Speed, nonSpurtSpeed, 'Base Target Speed (m/s)', [18, 23], { len: true, style: true, dist: true })} {@render statChart(race.Stat.Speed, nonSpurtSpeed, 'Base Target Speed (m/s)', [18, 23], { len: true, style: true, dist: true })}
<Sec h={2} id="stamina">Stamina</Sec> <Sec h={2} id="stamina">Stamina</Sec>
<Sec h={3} id="hp">HP</Sec> <Sec
h={3}
id="hp"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.fnd75v9k5dx3"
>HP</Sec
>
<p>Max HP, i.e. starting HP.</p> <p>Max HP, i.e. starting HP.</p>
{@render statChart(race.Stat.Stamina, hp, 'HP', [1000, 5000], { len: true })} {@render statChart(race.Stat.Stamina, hp, 'HP', [1000, 5000], { len: true })}
<Sec h={2} id="power">Power</Sec> <Sec h={2} id="power">Power</Sec>
<Sec h={3} id="move-lane-mod">Move Lane Modifier</Sec> <Sec
h={3}
id="move-lane-mod"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.s5glz57t0wzd"
>Move Lane Modifier</Sec
>
<p> <p>
Target speed bonus when changing lanes while under the effect of a lane change speed skill. See <a Target speed bonus when changing lanes while under the effect of a lane change speed skill. See <a
href={resolve('/doc/frbm#lane-combo')}>lane combo</a href={resolve('/doc/frbm#lane-combo')}>lane combo</a
@@ -263,15 +283,33 @@
</p> </p>
{@render statChart(race.Stat.Power, moveLane, 'Target Speed Modifier (m/s)', [0.2, 0.6])} {@render statChart(race.Stat.Power, moveLane, 'Target Speed Modifier (m/s)', [0.2, 0.6])}
<Sec h={3} id="uphill">Uphill Target Speed Loss</Sec> <Sec
h={3}
id="uphill"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.wdj69udyo340"
>Uphill Target Speed Loss</Sec
>
<p>Target speed modifier while running uphill.</p> <p>Target speed modifier while running uphill.</p>
{@render statChart(race.Stat.Power, uphill, 'Target Speed Modifier (m/s)', [-2, 0])} {@render statChart(race.Stat.Power, uphill, 'Target Speed Modifier (m/s)', [-2, 0])}
<Sec h={3} id="acceleration">Acceleration</Sec> <Sec
<p>Acceleration.</p> h={3}
id="acceleration"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.t9fv1oq5lsu9"
>Acceleration</Sec
>
<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 })} {@render statChart(race.Stat.Power, accel, 'Acceleration (m/s²)', [0.1, 0.55], { style: true })}
<Sec h={3} id="lane-change-target-speed">Lane Change Target Speed</Sec> <Sec
h={3}
id="lane-change-target-speed"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.8brkec5lv1fh"
>Lane Change Target Speed</Sec
>
<p>Horizontal (rather than forward) target speed of changing lanes.</p> <p>Horizontal (rather than forward) target speed of changing lanes.</p>
<div class="mb-4 h-60 w-full md:h-96"> <div class="mb-4 h-60 w-full md:h-96">
<StatChart <StatChart
@@ -283,8 +321,16 @@
/> />
</div> </div>
<Sec h={3} id="conserve-power">Conserve Power Acceleration</Sec> <Sec
<p>Bonus acceleration at spurt start from the Conserve Power mechanic.</p> h={3}
id="conserve-power"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.9edgwx6l733b"
>Fully Charged Acceleration</Sec
>
<p>
Bonus acceleration at spurt start from the Charge Up/Fully Charged mechanic. Scales with base power plus passive skills, so
modifiers for ground conditions and career mode do not apply.
</p>
<p> <p>
This mechanic does not appear to be well understood, in particular how long the acceleration bonus lasts and how the rushed This mechanic does not appear to be well understood, in particular how long the acceleration bonus lasts and how the rushed
and spot struggle multipliers apply. and spot struggle multipliers apply.
@@ -293,26 +339,51 @@
<Sec h={2} id="guts">Guts</Sec> <Sec h={2} id="guts">Guts</Sec>
<Sec h={3} id="spurt-speed-guts">Spurt Speed</Sec> <Sec
h={3}
id="spurt-speed-guts"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.mpfjmgpzqalr"
>Spurt Speed</Sec
>
<p>Target speed during the Uma's last spurt. See also <a href="#spurt-speed">the effect of Speed</a>.</p> <p>Target speed during the Uma's last spurt. See also <a href="#spurt-speed">the effect of Speed</a>.</p>
{@render statChart(race.Stat.Guts, gutsSpurt, 'Spurt Speed (m/s)', [20, 26.5], { len: true, style: true, dist: true })} {@render statChart(race.Stat.Guts, gutsSpurt, 'Spurt Speed (m/s)', [20, 26.5], { len: true, style: true, dist: true })}
<Sec h={3} id="min-speed">Minimum Speed</Sec> <Sec
h={3}
id="min-speed"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.nn1kp5g8fdhu"
>Minimum Speed</Sec
>
<p>Forced minimum speed, as well as target speed when out of HP.</p> <p>Forced minimum speed, as well as target speed when out of HP.</p>
{@render statChart(race.Stat.Guts, minSpeed, 'Target Speed (m/s)', [15.8, 18.5], { len: true })} {@render statChart(race.Stat.Guts, minSpeed, 'Target Speed (m/s)', [15.8, 18.5], { len: true })}
<Sec h={3} id="hp-rate-mod">Late Race HP Consumption Rate Modifier</Sec> <Sec
h={3}
id="hp-rate-mod"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.o0be6rj0myb1"
>Late Race HP Consumption Rate Modifier</Sec
>
<p>Multiplier on HP consumption rate during late race and last spurt phase.</p> <p>Multiplier on HP consumption rate during late race and last spurt phase.</p>
{@render statChart(race.Stat.Guts, gutsHPRate, 'HP Consumption Multiplier', [1.2, 1.6])} {@render statChart(race.Stat.Guts, gutsHPRate, 'HP Consumption Multiplier', [1.2, 1.6])}
<Sec h={3} id="spot-struggle">Spot Struggle</Sec> <Sec
h={3}
id="spot-struggle"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.5l523bk8k3vz"
>Spot Struggle</Sec
>
<p>Speed boost and duration of spot struggle.</p> <p>Speed boost and duration of spot struggle.</p>
<div class="mb-4 grid h-96 w-full md:grid-cols-2"> <div class="mb-4 grid h-96 w-full md:grid-cols-2">
<StatChart stat={race.Stat.Guts} y={ssBoost} yLabel="Speed Bonus (m/s)" range={[0, 0.4]} /> <StatChart stat={race.Stat.Guts} y={ssBoost} yLabel="Speed Bonus (m/s)" range={[0, 0.4]} />
<StatChart stat={race.Stat.Guts} y={ssDur} yLabel="Duration (s)" range={[0, 14]} /> <StatChart stat={race.Stat.Guts} y={ssDur} yLabel="Duration (s)" range={[0, 14]} />
</div> </div>
<Sec h={3} id="dueling">Dueling</Sec> <Sec
h={3}
id="dueling"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.98rgrb7ww1td"
>Dueling</Sec
>
<p>Speed boost and acceleration boost of dueling.</p> <p>Speed boost and acceleration boost of dueling.</p>
<div class="mb-4 grid h-96 w-full md:grid-cols-2"> <div class="mb-4 grid h-96 w-full md:grid-cols-2">
<StatChart stat={race.Stat.Guts} y={duelSpeed} yLabel="Speed Bonus (m/s)" range={[0, 0.8]} /> <StatChart stat={race.Stat.Guts} y={duelSpeed} yLabel="Speed Bonus (m/s)" range={[0, 0.8]} />
@@ -321,7 +392,12 @@
<Sec h={2} id="wit">Wit</Sec> <Sec h={2} id="wit">Wit</Sec>
<Sec h={3} id="section-speed">Section Speed</Sec> <Sec
h={3}
id="section-speed"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.xdha40q2176g"
>Section Speed</Sec
>
<p>Random variance in target speed per race section.</p> <p>Random variance in target speed per race section.</p>
<div class="mb-24 h-60 w-full md:mb-20 md:h-96"> <div class="mb-24 h-60 w-full md:mb-20 md:h-96">
<StatChart <StatChart
@@ -353,29 +429,54 @@
</div> </div>
</div> </div>
<Sec h={3} id="downhill">Downhill Accel Mode Chance</Sec> <Sec
h={3}
id="downhill"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.wdj69udyo340"
>Downhill Accel Mode Chance</Sec
>
<p>Chance each second to enter downhill accel mode when eligible.</p> <p>Chance each second to enter downhill accel mode when eligible.</p>
{@render statChart(race.Stat.Wit, downhill, 'Entry Chance (% each second)', [0, 75])} {@render statChart(race.Stat.Wit, downhill, 'Entry Chance (% each second)', [0, 75])}
<Sec h={3} id="spurt-accept">Reduced Spurt Accept Chance</Sec> <Sec
h={3}
id="spurt-accept"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.mpfjmgpzqalr"
>Reduced Spurt Accept Chance</Sec
>
<p>Chance to accept each checked spurt delay and speed when not full spurting.</p> <p>Chance to accept each checked spurt delay and speed when not full spurting.</p>
{@render statChart(race.Stat.Wit, reducedSpurt, 'Accept Chance (% each candidate)', [0, 100])} {@render statChart(race.Stat.Wit, reducedSpurt, 'Accept Chance (% each candidate)', [0, 100])}
<Sec h={3} id="skill">Skill Activation Chance</Sec> <Sec
h={3}
id="skill"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.23v0k7wyzcmb"
>Skill Activation Chance</Sec
>
<p> <p>
Chance that each skill that has a wit check will be eligible to activate. This uses base wit, so style aptitude and passive Chance that each skill that has a wit check will be eligible to activate. This uses base wit, so style aptitude and passive
wit skills do not affect the chance, although mood does. wit skills do not affect the chance, although mood does.
</p> </p>
{@render statChart(race.Stat.Wit, skillChance, 'Skill Activation Chance (%)', [0, 100])} {@render statChart(race.Stat.Wit, skillChance, 'Skill Activation Chance (%)', [0, 100])}
<Sec h={3} id="poskeep">Position Keep Mode Chance</Sec> <Sec
h={3}
id="poskeep"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.tulele26d1pm"
>Position Keep Mode Chance</Sec
>
<p> <p>
Chance for front runners to enter speed-up or overtake mode, or for non-front runners to enter pace-up mode (but no effect on Chance for front runners to enter speed-up or overtake mode, or for non-front runners to enter pace-up mode (but no effect on
pace-down mode), each 2 seconds when eligible. pace-down mode), each 2 seconds when eligible.
</p> </p>
{@render statChart(race.Stat.Wit, poskeep, 'Mode Entry Chance (%)', [0, 50])} {@render statChart(race.Stat.Wit, poskeep, 'Mode Entry Chance (%)', [0, 50])}
<Sec h={3} id="rushed">Rushed Chance</Sec> <Sec
h={3}
id="rushed"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.awbjufvej6ud"
>Rushed Chance</Sec
>
<p>Chance for runners to become rushed at some point during the race. Rushed chance is bracketed to integer percentages.</p> <p>Chance for runners to become rushed at some point during the race. Rushed chance is bracketed to integer percentages.</p>
{@render statChart(race.Stat.Wit, rushed, 'Rushed Chance (%)', [0, 30])} {@render statChart(race.Stat.Wit, rushed, 'Rushed Chance (%)', [0, 30])}
</Article> </Article>
+24 -29
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import * as skill from '$lib/data/skill'; import * as skill from '$lib/data/skill';
import * as spark from '$lib/data/spark'; import * as spark from '$lib/data/spark';
import Pick from '$lib/Pick.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { SvelteMap } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
@@ -14,17 +15,9 @@
} }
}); });
let group = $state(1); let group: spark.SparkGroup | undefined = $state();
const groups = $derived.by(() => { const groups = $derived(spark.byGroup(sparks));
const m = new SvelteMap<number, spark.Spark[]>();
for (const s of sparks) {
const l = m.get(s.spark_group) ?? [];
l.push(s);
m.set(s.spark_group, l);
}
return [...m.entries()];
});
function describe(eff: spark.Effect) { function describe(eff: spark.Effect) {
if (eff.target === spark.Target.Skill) { if (eff.target === spark.Target.Skill) {
const sk = skills.get(eff.value1); const sk = skills.get(eff.value1);
@@ -32,14 +25,13 @@
} }
return `${spark.TARGET_NAMES[eff.target]} +${eff.value1}`; return `${spark.TARGET_NAMES[eff.target]} +${eff.value1}`;
} }
const cur = $derived(sparks.find((s) => s.spark_group === group)); const curEffectGroups = $derived(group?.effects ?? []);
const curEffectGroups = $derived(cur?.effects ?? []);
const curDescriptions = $derived(curEffectGroups.map((g) => g.map((e) => describe(e)))); const curDescriptions = $derived(curEffectGroups.map((g) => g.map((e) => describe(e))));
const curClass = $derived.by(() => { function sparkClass(t: spark.Type | null | undefined): string[] {
if (cur == null) { if (t == null) {
return []; return [];
} }
switch (cur.type) { switch (t) {
case 1: case 1:
return ['stat']; return ['stat'];
case 2: case 2:
@@ -57,16 +49,19 @@
default: default:
return []; return [];
} }
}); }
const curClass = $derived(sparkClass(group?.type));
const key = (v: spark.SparkGroup) => ({ id: v.spark_group, name: v.name });
</script> </script>
<h1 class="text-4xl">Spark Explorer</h1> <h1 class="text-4xl">Spark Explorer</h1>
<form class="mx-auto mt-8 rounded-md p-4 text-center shadow-md ring md:max-w-2xl"> <form class="mx-auto mt-8 rounded-md p-4 text-center shadow-md ring md:max-w-2xl">
<select class="w-full" bind:value={group}> <Pick id="spark" items={groups} class="w-full" {key} bind:value={group} required>
{#each groups as [id, s] (id)} {#snippet option(g)}
<option value={id}>{s[0].name}</option> <span class={['spark rounded px-2 py-1', sparkClass(g.type)]}>{g.name}</span>
{/each} {/snippet}
</select> </Pick>
</form> </form>
<svelte:boundary> <svelte:boundary>
<div <div
@@ -75,22 +70,22 @@
curClass, curClass,
]} ]}
> >
<span class="block text-xl">{cur?.name}</span> <span class="block text-xl">{group?.name}</span>
{#if cur != null} {#if group != null}
<span class="block"> <span class="block">
{spark.TYPE_NAMES[cur.type]} Spark {spark.TYPE_NAMES[group.type]} Spark
</span> </span>
{/if} {/if}
<span class="block">{cur?.description}</span> <span class="block">{group?.description}</span>
</div> </div>
<table class="mx-auto mt-8 w-full max-w-xl table-fixed"> <table class="mx-auto mt-8 w-full max-w-xl table-fixed border-separate border-spacing-2">
<caption class="text-xl">Possible Effects</caption> <caption class="text-xl">Possible Effects</caption>
<tbody> <tbody>
{#each curDescriptions as s (s)} {#each curDescriptions as s (s)}
<tr class="text-center even:bg-mist-300 dark:even:bg-mist-900"> <tr>
<td> <td class="flex gap-2 border border-mist-400 p-1 text-center dark:border-mist-950">
{#each s as n (n)} {#each s as n (n)}
<span class="block">{n}</span> <span class="my-auto flex-1">{n}</span>
{/each} {/each}
</td> </td>
</tr> </tr>
@@ -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>
+8 -17
View File
@@ -1,20 +1,11 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { page } from '$app/state';
import * as all from '$lib/all';
import IndexSection from '$lib/IndexSection.svelte';
const cur = $derived(all.page(page.route.id));
</script> </script>
<h1 class="m-8 text-center text-7xl">Zenno Rob Roy &mdash; Character Tools</h1> <h1 class="m-8 text-center text-7xl">Zenno Rob Roy &mdash; {cur.name}</h1>
<p>Tools related to understanding Umamusume characters.</p> <p>{cur.description}</p>
<IndexSection pages={all.PAGES.race} />
<ul class="mb-4 list-disc pl-4">
<li>
<a href={resolve('/race/spurt')}>Spurt Speed</a> &mdash; Calculate a horse's target speed in the last spurt and compare to other
distance aptitudes and running styles.
</li>
<li>
<a href={resolve('/race/mspeed')}>Front Runner Mechanical Speed Comparator</a> &mdash; Compare spot struggle and lane combo to the
effects of individual skills.
</li>
<li>
<a href={resolve('/race/gate')}>Gate Calculator</a> &mdash; Calculate the importance of acceleration effects at the starting gate.
</li>
</ul>
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements';
interface Props {
title: Snippet | string;
children: Snippet;
additional?: Snippet;
class?: ClassValue | null;
}
let { title, children, additional, class: className }: Props = $props();
const cl = $derived([
'm-2 flex flex-col rounded-md border p-2 text-center shadow-sm transition-shadow hover:shadow-md',
className,
]);
</script>
<div class={cl}>
<span class="block">
{#if typeof title === 'string'}
{title}
{:else}
{@render title()}
{/if}
</span>
<span class="block text-xl">
{@render children()}
</span>
{@render additional?.()}
</div>
+3 -3
View File
@@ -3,6 +3,7 @@
import * as race from '$lib/race'; import * as race from '$lib/race';
import * as accel from '$lib/racelib/accel'; import * as accel from '$lib/racelib/accel';
import type { Attachment } from 'svelte/attachments'; import type { Attachment } from 'svelte/attachments';
import CalcInfo from '../CalcInfo.svelte';
let rawPower = $state(1200); let rawPower = $state(1200);
let rawGuts = $state(1200); let rawGuts = $state(1200);
@@ -284,14 +285,13 @@
</form> </form>
{#snippet info(name: string, unit: string, val: number, val2?: number)} {#snippet info(name: string, unit: string, val: number, val2?: number)}
<div class="m-2 max-w-72 flex-1 flex-col rounded-md border p-2 text-center shadow-sm transition-shadow hover:shadow-md"> <CalcInfo title={name} class="max-w-72 flex-1">
<span class="block">{name}</span>
{#if val2 == null} {#if val2 == null}
<span class="block text-xl">{val.toFixed(3)} {unit}</span> <span class="block text-xl">{val.toFixed(3)} {unit}</span>
{:else} {:else}
<span class="block text-xl">{val.toFixed(3)} &ndash; {val2.toFixed(3)} {unit}</span> <span class="block text-xl">{val.toFixed(3)} &ndash; {val2.toFixed(3)} {unit}</span>
{/if} {/if}
</div> </CalcInfo>
{/snippet} {/snippet}
<div class="mx-auto flex w-full flex-col place-items-center md:flex-row md:justify-center"> <div class="mx-auto flex w-full flex-col place-items-center md:flex-row md:justify-center">
+1 -1
View File
@@ -30,7 +30,7 @@
const careerMod = $derived(isCareer ? 400 : 0); const careerMod = $derived(isCareer ? 400 : 0);
const powerStat = $derived(rawPower + careerMod); const powerStat = $derived(rawPower + careerMod);
const gutsStat = $derived(rawGuts + 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 phases = [Phase.EarlyRace, Phase.MidRace, Phase.LateRace] as const;
const accel = $derived(phases.map((p) => acceleration(powerStat, style, surfApt, p))); const accel = $derived(phases.map((p) => acceleration(powerStat, style, surfApt, p)));
+10 -10
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { HORSE_LENGTH, speedGain } from '$lib/race'; import { HORSE_LENGTH, speedGain } from '$lib/race';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import CalcInfo from '../CalcInfo.svelte';
interface Props { interface Props {
children: Snippet; children: Snippet;
@@ -21,13 +22,12 @@
const text = $derived(gain.map(fmtp).join(' ')); const text = $derived(gain.map(fmtp).join(' '));
</script> </script>
<div <CalcInfo title={children} class="max-w-80 flex-1">
class="m-2 flex h-full w-full max-w-80 flex-1 flex-col rounded-md border p-2 text-center shadow-sm transition-shadow hover:shadow-md" {text} L
> {#snippet additional()}
<div class="block">{@render children()}</div> <div class="flex flex-row">
<span class="block text-xl">{text} L</span> <span class="flex-1 text-xs">{fmtp(speed)} m/s</span>
<div class="flex flex-row"> <span class="flex-1 text-xs">{dur.toFixed(3)} s</span>
<span class="flex-1 text-xs">{fmtp(speed)} m/s</span> </div>
<span class="flex-1 text-xs">{dur.toFixed(3)} s</span> {/snippet}
</div> </CalcInfo>
</div>
+64 -61
View File
@@ -1,61 +1,74 @@
<script lang="ts"> <script lang="ts">
import type { ComputedSeries } from '$lib/chart'; import type { ComputedSeries } from '$lib/chart';
import { AptitudeLevel, inverseSpurtSpeed, RunningStyle, spurtSpeed, Stat } from '$lib/race'; import * as race from '$lib/race';
import StatChart from '$lib/StatChart.svelte'; 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; const skillSpeeds = [0.45, 0.35, 0.25, 0.15] as const;
let rawSpeed: number = $state(1200); let rawSpeed: number = $state(1200);
let rawGuts: number = $state(1200); let rawGuts: number = $state(1200);
let style: RunningStyle = $state(RunningStyle.FrontRunner); let style: race.RunningStyle = $state(race.RunningStyle.FrontRunner);
let opponentStyle: RunningStyle = $state(RunningStyle.PaceChaser); let opponentStyle: race.RunningStyle = $state(race.RunningStyle.PaceChaser);
let distanceApt: AptitudeLevel = $state(AptitudeLevel.S); let distanceApt: race.AptitudeLevel = $state(race.AptitudeLevel.S);
let raceLen: number = $state(2000); let raceLen: number = $state(2000);
let isCareer: boolean = $state(false); let isCareer: boolean = $state(false);
const careerMod = $derived(isCareer ? 400 : 0); const careerMod = $derived(isCareer ? 400 : 0);
const speedStat = $derived(rawSpeed + careerMod); const speedStat = $derived(rawSpeed + careerMod);
const gutsStat = $derived(rawGuts + careerMod); const gutsStat = $derived(rawGuts + careerMod);
const speed = $derived(spurtSpeed(speedStat, gutsStat, style, distanceApt, raceLen)); const speed = $derived(race.spurtSpeed(speedStat, gutsStat, style, distanceApt, raceLen));
const duelBonus = $derived(race.duelSpeedMod(gutsStat));
const sProf = $derived([ const sProf = $derived([
inverseSpurtSpeed(speed, gutsStat, RunningStyle.FrontRunner, AptitudeLevel.S, raceLen) - careerMod, race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.FrontRunner, race.AptitudeLevel.S, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.PaceChaser, AptitudeLevel.S, raceLen) - careerMod, race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.PaceChaser, race.AptitudeLevel.S, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.LateSurger, AptitudeLevel.S, raceLen) - careerMod, race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.LateSurger, race.AptitudeLevel.S, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.EndCloser, AptitudeLevel.S, raceLen) - careerMod, race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.EndCloser, race.AptitudeLevel.S, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.GreatEscape, AptitudeLevel.S, raceLen) - careerMod, race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.Runaway, race.AptitudeLevel.S, raceLen) - careerMod,
]); ]);
const aProf = $derived([ const aProf = $derived([
inverseSpurtSpeed(speed, gutsStat, RunningStyle.FrontRunner, AptitudeLevel.A, raceLen) - careerMod, race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.FrontRunner, race.AptitudeLevel.A, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.PaceChaser, AptitudeLevel.A, raceLen) - careerMod, race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.PaceChaser, race.AptitudeLevel.A, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.LateSurger, AptitudeLevel.A, raceLen) - careerMod, race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.LateSurger, race.AptitudeLevel.A, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.EndCloser, AptitudeLevel.A, raceLen) - careerMod, race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.EndCloser, race.AptitudeLevel.A, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.GreatEscape, AptitudeLevel.A, raceLen) - careerMod, race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.Runaway, race.AptitudeLevel.A, raceLen) - careerMod,
]); ]);
const skillProf = $derived( 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 y: Array<ComputedSeries | null> = $derived([ 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 A', y: (x) => spurtSpeed(x, gutsStat, style, AptitudeLevel.A, raceLen) }, {
distanceApt < AptitudeLevel.A label: `Aptitude ${race.AptitudeLevel[distanceApt]} + Dueling`,
? { label: `Aptitude ${AptitudeLevel[distanceApt]}`, y: (x) => spurtSpeed(x, gutsStat, style, distanceApt, raceLen) } y: (x) => race.spurtSpeed(x, gutsStat, style, distanceApt, raceLen) + duelBonus,
},
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) => race.spurtSpeed(speedStat, x, style, race.AptitudeLevel.S, raceLen) },
{
label: `Aptitude ${race.AptitudeLevel[distanceApt]} + Dueling`,
y: (x) => race.spurtSpeed(speedStat, x, style, distanceApt, raceLen) + race.duelSpeedMod(x),
},
distanceApt !== race.AptitudeLevel.S
? {
label: `Aptitude ${race.AptitudeLevel[distanceApt]}`,
y: (x) => race.spurtSpeed(speedStat, x, style, distanceApt, raceLen),
}
: null, : null,
]); ]);
const range: [number, number] = $derived([ const range: [number, number] = [20, 27.5];
spurtSpeed(200, gutsStat, RunningStyle.GreatEscape, AptitudeLevel.C, raceLen),
spurtSpeed(2000, gutsStat, RunningStyle.EndCloser, AptitudeLevel.S, raceLen),
]);
</script> </script>
<h1 class="text-4xl">Spurt Speed Calculator</h1> <h1 class="text-4xl">Spurt Speed Calculator</h1>
@@ -71,16 +84,16 @@
<div class="m-4"> <div class="m-4">
<label for="style">Style</label> <label for="style">Style</label>
<select id="style" required bind:value={style} class="w-full"> <select id="style" required bind:value={style} class="w-full">
{#each stylesList as [name, style] (style)} {#each race.RUNNING_STYLES as [name, style] (style)}
<option value={style}>{style === RunningStyle.GreatEscape ? 'Great Escape (Runaway)' : name}</option> <option value={style}>{name}</option>
{/each} {/each}
</select> </select>
</div> </div>
<div class="m-4"> <div class="m-4">
<label for="distanceApt">Distance Aptitude</label> <label for="distanceApt">Distance Aptitude</label>
<select id="distanceApt" required bind:value={distanceApt} class="w-full"> <select id="distanceApt" required bind:value={distanceApt} class="w-full">
{#each aptsList.toReversed() as [name, val] (val)} {#each race.APTITUDE_LEVELS as apt (apt)}
<option value={val}>{name}</option> <option value={apt}>{race.AptitudeLevel[apt]}</option>
{/each} {/each}
</select> </select>
</div> </div>
@@ -93,24 +106,18 @@
<input type="checkbox" id="isCareer" role="switch" bind:checked={isCareer} class="min-h-6 min-w-6 align-middle" /> <input type="checkbox" id="isCareer" role="switch" bind:checked={isCareer} class="min-h-6 min-w-6 align-middle" />
</div> </div>
</div> </div>
<span class="mt-8 block w-full text-center text-lg"> <div class="mx-auto mt-8 flex max-w-3xl flex-col md:flex-row">
Target spurt speed: {speed.toFixed(3)} m/s <CalcInfo title="Target Spurt Speed" class="flex-1">{speed.toFixed(3)} m/s</CalcInfo>
</span> <CalcInfo title="Dueling Bonus" class="flex-1">+{duelBonus.toFixed(3)} m/s</CalcInfo>
{#each [[AptitudeLevel.A, aProf] as const, [AptitudeLevel.S, sProf] as const] as [level, inv] (level)} </div>
{#each [[race.AptitudeLevel.A, aProf] as const, [race.AptitudeLevel.S, sProf] as const] as [level, inv] (level)}
<div class="mx-auto max-w-3xl"> <div class="mx-auto max-w-3xl">
<span class="mt-8 block w-full"> <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> </span>
<div class="flex flex-col md:flex-row"> <div class="flex flex-col md:flex-row">
{#each stylesList as [styleName, s] (s)} {#each race.RUNNING_STYLES as [styleName, s] (s)}
<div <CalcInfo title={styleName} class={['flex-1', { 'border-2': s === style }]}>{inv[s]}</CalcInfo>
class={['m-2 flex-1 rounded-md border shadow-sm transition-shadow hover:shadow-md', s === style ? 'border-2' : null]}
>
<div class="h-full w-full flex-col text-center">
<span class="block text-lg">{styleName}</span>
<span class="block text-2xl">{inv[s]}</span>
</div>
</div>
{/each} {/each}
</div> </div>
</div> </div>
@@ -119,23 +126,19 @@
<span class="mt-8 block w-full"> <span class="mt-8 block w-full">
While a speed skill is active, the equivalent speed for a distance S While a speed skill is active, the equivalent speed for a distance S
<select id="opponentStyle" required bind:value={opponentStyle}> <select id="opponentStyle" required bind:value={opponentStyle}>
{#each stylesList as [name, style] (style)} {#each race.RUNNING_STYLES as [name, style] (style)}
<option value={style}>{style === RunningStyle.GreatEscape ? 'Great Escape (Runaway)' : name}</option> <option value={style}>{style === race.RunningStyle.Runaway ? 'Great Escape (Runaway)' : name}</option>
{/each} {/each}
</select> </select>
is is
</span> </span>
<div class="flex flex-col md:flex-row"> <div class="flex flex-col md:flex-row">
{#each skillProf as [v, inv] (v)} {#each skillProf as [v, inv] (v)}
<div class="m-2 flex-1 rounded-md border shadow-sm transition-shadow hover:shadow-md"> <CalcInfo title={`+${v.toFixed(2)}`} class="flex-1">{inv}</CalcInfo>
<div class="h-full w-full flex-col text-center">
<span class="block text-lg">+{v.toFixed(2)}</span>
<span class="block text-2xl">{inv}</span>
</div>
</div>
{/each} {/each}
</div> </div>
</div> </div>
<div class="mx-auto h-60 max-w-3xl place-content-center py-4 md:h-96"> <div class="mx-auto grid h-96 grid-cols-1 py-4 md:grid-cols-2">
<StatChart stat={Stat.Speed} {y} yLabel="Spurt Speed (m/s)" xRule={speedStat} {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> </div>
+17 -14
View File
@@ -1,6 +1,5 @@
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
export default defineConfig({ export default defineConfig({
@@ -9,19 +8,20 @@ export default defineConfig({
test: { test: {
expect: { requireAssertions: true }, expect: { requireAssertions: true },
projects: [ projects: [
{ // Playwright tests are disabled because I don't feel like setting it up in NixOS.
extends: './vite.config.ts', // {
test: { // extends: './vite.config.ts',
name: 'client', // test: {
browser: { // name: 'client',
enabled: true, // browser: {
provider: playwright(), // enabled: true,
instances: [{ browser: 'chromium', headless: true }], // provider: playwright(),
}, // instances: [{ browser: 'chromium', headless: true }],
include: ['src/**/*.svelte.{test,spec}.{js,ts}'], // },
exclude: ['src/lib/server/**'], // include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
}, // exclude: ['src/lib/server/**'],
}, // },
// },
{ {
extends: './vite.config.ts', extends: './vite.config.ts',
@@ -39,6 +39,9 @@ export default defineConfig({
'/api': { '/api': {
target: 'http://localhost:13669', target: 'http://localhost:13669',
}, },
'/img': {
target: 'http://localhost:13669',
},
}, },
}, },
}); });