Compare commits

..

39 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
zephyr 1d879cbf9f zenno/race/gate: add velocity/time chart 2026-07-07 20:47:20 -04:00
zephyr 240a3d9a73 zenno/doc/spark: decribe & clarify 2026-07-07 16:23:15 -04:00
zephyr 3d73460934 zenno: link up doc/spark 2026-07-07 15:55:31 -04:00
zephyr 0c518a9ce2 zenno/doc/spark: spark explorer 2026-07-07 15:51:28 -04:00
zephyr 5cbb6d7b1d zenno/doc/frbm: updates for 7-1 patch 2026-07-07 11:16:09 -04:00
zephyr b0bd16fd04 zenno/index: move discord bot to its own section 2026-07-06 09:45:07 -04:00
zephyr 55175c9c40 zenno: format & lint 2026-07-06 09:41:11 -04:00
zephyr 06e5a06680 zenno/race/gate: caveats 2026-07-06 09:39:36 -04:00
zephyr dbb0dcd2dc zenno: link up race/gate 2026-07-06 09:23:37 -04:00
zephyr 02f723b83a zenno/race/gate: new calculator 2026-07-06 09:01:11 -04:00
zephyr 1f58dec491 zenno/doc/race: conserve power 2026-07-01 23:39:31 -04:00
zephyr a6d264c79d zenno: pull race mechanics lib update from stam-calc 2026-07-01 22:40:11 -04:00
zephyr d62e729052 zenno: update stat charts for new limits 2026-07-01 22:12:10 -04:00
zephyr 9ce665865e zenno/doc/race: fix 4 skill activation chance chart 2026-06-26 13:13:30 -04:00
43 changed files with 3247 additions and 1145 deletions
+1
View File
@@ -2,6 +2,7 @@ when:
- event: push
branch: main
- event: pull_request
- event: manual
steps:
- name: test
+1 -2
View File
@@ -1,9 +1,8 @@
when:
- event: push
branch: main
path: 'zenno/**'
- event: pull_request
path: 'zenno/**'
- event: manual
steps:
- name: test
+12 -2
View File
@@ -1,10 +1,20 @@
# horsebot
Discord bot serving horse game data.
Website and Discord bot serving horse game data.
Production instance is named Zenno Rob Roy, because she has read all about Umamusume and is always happy to share her knowledge and give recommendations.
## Running
The `-mdb` flag is mandatory for both website and Discord bot modes.
It provides data for both the horsebot API and the discord bot.
## Running the Website
If the `-public` flag is provided, its value gives the directory to serve at `/`.
The `-img` flag gives a separate directory into which images are synced from GameTora; obtain permission from Gertas prior to using it.
The images are served on `/img/`.
## Running the Discord Bot
Provide the `-token` flag pointing to a file containing the token.
The bot always uses the Gateway API.
If the `-http` argument is provided, it will also use the HTTP API, and `-key` must also be provided.
+16
View File
@@ -36,6 +36,7 @@ func main() {
addr string
mdbf string
public string
img string
// discord
tokenFile string
apiRoute string
@@ -47,6 +48,7 @@ func main() {
flag.StringVar(&addr, "http", ":13669", "`address` to bind HTTP server")
flag.StringVar(&mdbf, "mdb", "", "`path` to master.mdb")
flag.StringVar(&public, "public", "", "`dir`ectory containing the website to serve")
flag.StringVar(&img, "img", "", "`dir`ectory to save and serve images")
flag.StringVar(&tokenFile, "token", "", "`file` containing the Discord bot token")
flag.StringVar(&apiRoute, "route", "/interactions/callback", "`path` to serve Discord HTTP API calls")
flag.StringVar(&pubkey, "key", "", "Discord public key")
@@ -86,6 +88,17 @@ func main() {
os.Exit(1)
}
if img != "" {
// Ensure the img directory is in fact a directory
// before we start trying to fill it out.
err := os.MkdirAll(img, 0777)
if err != nil {
slog.Error("img", slog.Any("err", err))
os.Exit(1)
}
go saveImgs(ctx, db, img)
}
var client *bot.Client
if tokenFile != "" {
skills, err := mdb.Skills(ctx, db)
@@ -128,6 +141,9 @@ func main() {
if public != "" {
mux.Handle("GET /", httpmiddle.Compress(5)(http.FileServerFS(os.DirFS(public))))
}
if img != "" {
mux.Handle("GET /img/", http.StripPrefix("/img", http.FileServerFS(os.DirFS(img))))
}
mux.Handle("GET /api/global/affinity", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.AffinitySummary)))
mux.Handle("GET /api/global/affinity-detail", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.AffinityDetail)))
mux.Handle("GET /api/global/character", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.Characters)))
+173
View File
@@ -0,0 +1,173 @@
package main
import (
"context"
"errors"
"fmt"
"image"
"image/png"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"github.com/gen2brain/avif"
"golang.org/x/image/draw"
"golang.org/x/sync/errgroup"
"zombiezen.com/go/sqlite"
"zombiezen.com/go/sqlite/sqlitex"
)
func saveImgs(ctx context.Context, db *sqlitex.Pool, out string) {
skill, err := skillIconIDs(ctx, db)
if err != nil {
slog.Error("skills", slog.Any("err", err))
}
chara, err := charaIDs(ctx, db)
if err != nil {
slog.Error("characters", slog.Any("err", err))
}
rank, err := rankIDs(ctx, db)
if err != nil {
slog.Error("ranks", slog.Any("err", err))
}
g, ctx := errgroup.WithContext(ctx)
tick := time.NewTicker(250 * time.Millisecond)
idouts(ctx, g, tick, skill, filepath.Join(out, "skill"), "https://media.gametora.com/umamusume/skills/icon/%d.png", image.Pt(64, 64), image.Pt(32, 32), image.Pt(16, 16))
idouts(ctx, g, tick, chara, filepath.Join(out, "chara"), "https://gametora.com/images/umamusume/characters/icons/chr_icon_%d.png", image.Pt(256, 256), image.Pt(64, 64), image.Pt(32, 32), image.Pt(16, 16))
idouts(ctx, g, tick, rank, filepath.Join(out, "rank"), "https://media.gametora.com/umamusume/ui/rank/%02d.png", image.Pt(128, 128), image.Pt(64, 64), image.Pt(32, 32), image.Pt(16, 16))
idouts(ctx, g, tick, rank, filepath.Join(out, "rank", "simple"), "https://media.gametora.com/umamusume/ui/rank/simple/%02d.png", image.Pt(50, 50), image.Pt(32, 32), image.Pt(16, 16))
if err := g.Wait(); err != nil {
slog.Error("output", slog.Any("err", err))
}
}
// load scans all results of sql and appends them to r.
func load[T any](ctx context.Context, db *sqlitex.Pool, r []T, sql string, row func(*sqlite.Stmt) T) ([]T, error) {
conn, err := db.Take(ctx)
defer db.Put(conn)
if err != nil {
return nil, err
}
stmt, err := conn.Prepare(sql)
if err != nil {
return nil, err
}
for {
ok, err := stmt.Step()
if err != nil {
return r, err
}
if !ok {
break
}
r = append(r, row(stmt))
}
return r, err
}
func skillIconIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) {
return load(ctx, db, nil, "SELECT DISTINCT icon_id FROM skill_data", func(s *sqlite.Stmt) int32 {
return s.ColumnInt32(0)
})
}
func charaIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) {
return load(ctx, db, nil, "SELECT id FROM chara_data", func(s *sqlite.Stmt) int32 {
return s.ColumnInt32(0)
})
}
func rankIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) {
return load(ctx, db, nil, "SELECT id-1 FROM single_mode_rank", func(s *sqlite.Stmt) int32 {
return s.ColumnInt32(0)
})
}
var pngenc = &png.Encoder{CompressionLevel: png.BestCompression}
func idouts(ctx context.Context, g *errgroup.Group, tick *time.Ticker, ids []int32, p, urlf string, sizes ...image.Point) {
for _, id := range ids {
g.Go(func() error {
n := strconv.Itoa(int(id))
p := filepath.Join(p, n)
_, err := os.Stat(p)
if !errors.Is(err, os.ErrNotExist) {
slog.DebugContext(ctx, "exists", slog.String("path", p))
return nil
}
if err := os.MkdirAll(p, 0777); err != nil {
return err
}
url := fmt.Sprintf(urlf, id)
select {
case <-ctx.Done():
return ctx.Err()
case <-tick.C: // continue on
}
img, err := dlimg(ctx, url)
if err != nil {
return err
}
slog.InfoContext(ctx, "resize", slog.String("url", url))
rs := resize(img, sizes...)
for _, r := range rs {
g.Go(func() error {
m := r.Bounds().Max
o := filepath.Join(p, fmt.Sprintf("%dx%d.png", m.X, m.Y))
slog.InfoContext(ctx, "write", slog.String("path", o))
f, err := os.Create(o)
if err != nil {
return err
}
defer f.Close()
return pngenc.Encode(f, r)
})
g.Go(func() error {
m := r.Bounds().Max
o := filepath.Join(p, fmt.Sprintf("%dx%d.avif", m.X, m.Y))
slog.InfoContext(ctx, "write", slog.String("path", o))
f, err := os.Create(o)
if err != nil {
return err
}
defer f.Close()
return avif.Encode(f, r)
})
}
return nil
})
}
}
func dlimg(ctx context.Context, url string) (image.Image, error) {
slog.InfoContext(ctx, "download", slog.String("url", url))
ctx, stop := context.WithTimeout(ctx, 5*time.Second)
defer stop()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
slog.InfoContext(ctx, "decode", slog.String("url", url))
img, _, err := image.Decode(resp.Body)
return img, err
}
func resize(img image.Image, sizes ...image.Point) []image.Image {
rs := make([]image.Image, 0, len(sizes))
for _, sz := range sizes {
d := image.NewNRGBA(image.Rectangle{Min: image.Point{}, Max: sz})
draw.CatmullRom.Scale(d, d.Bounds(), img, img.Bounds(), draw.Src, nil)
rs = append(rs, d)
}
return rs
}
+34
View File
@@ -15,6 +15,9 @@ This file is my notes from exploring the database.
- 119 is scenario full titles (e.g. The Beginning: URA Finale), 120 is scenario descriptions, 237 is scenario names (e.g. URA Finale)
- 130 is epithet names, 131 is epithet descriptions
in the decomp, text data categories are named in `public enum MasterString.Category`.
some categories seem to be missing, though.
# succession factor (sparks)
tables are succession_factor and succession_factor_effect
@@ -317,6 +320,37 @@ so, it doesn't seem like there's a particular flag that identifies maiden races,
- card_talent_hint_upgrade has costs to raise hint levels, but it's actually universal, only six rows
- single_mode_route_race is career goals (not only races)
- available_skill_set has starting skills including those unlocked by potential level given under need_rank (0 for pl1, 2 for pl2)
- single_mode_special_chara is scenario links
# gl
single_mode_live_song_list has the song list for lives ha ha.
this is defined from the perspective of bonuses they grant when you have them, not the shop per se.
- text_data category for song names is 210, look up by live_id; for post-concert effect text, category 208 indexed by song list id
- command_id and live_id are always equal
- level is 1 for all songs except the two instances of girls' legend u
- master_bonus_content_text_id corresponds to text_data categories 207 for pickup effect text and 209 for song name
- live_bonus_type is 1 for specialty prio, 2 for chain frequency, 3 for fb
- girls' legend u is defined twice as ids 22 and 24
costs are in single_mode_live_square.
- square_type is 1 for stat up, 2 for skill up, 3 for energy up, 4 for live
- perf_type_n is token type: 1 for dance, 2 for passion, 3 for vocals, 4 for visuals, 5 for composure
- perf_value_n is number of tokens
immediate bonuses are in single_mode_live_master_bonus.
- master_bonus_type is 1 for stat up, 2 for skill hint, 3 for energy up, 4 for song
- master_bonus_type_value is 1 for type 1, live id for type 4, 0 otherwise
- master_bonus_gain_type_n is 1 for stat, 2 for skill hint, 3 for energy, 5 for permanent training bonus
+ 1 for stat: then master_bonus_gain_value_n_1 is 1-5 for stat id or 6 for sp, n_2 is 1 for fixed or 2 for random (not actually in the game), n_3 is lower bound on gain, n_4 is 0 for fixed or upper bound for random
+ 2 for skill hint: then gain_value_n_1 is 3(*), n_2 is skill tag, n_3 is hint level
+ 3 for energy: then gain_value_n_1 is amount
+ 5 for permanent training bonus: then gain_value_n_1 is stat id (6 for sp), n_2 is amount
(*) MasterSingleModeLiveMasterBonus.SingleModeLiveMasterBonus.GainSkillHintTypeEnum defines values of Random = 1 and SkillId = 2, but they are unused in the db.
there is some ostensibly unused data.
- master bonus ids 12001-12206 grant random amounts of stats
- id 20000 corresponds to "Group Lesson" "Skill hint appropriate for aptitude"
# lobby conversations!!!
+15 -10
View File
@@ -4,32 +4,37 @@ go 1.25.5
require (
github.com/disgoorg/disgo v0.19.6
github.com/go-chi/chi/v5 v5.3.0
github.com/gen2brain/avif v0.6.0
github.com/go-chi/chi/v5 v5.3.1
github.com/google/go-cmp v0.7.0
github.com/junegunn/fzf v0.73.1
github.com/junegunn/fzf v0.74.1
golang.org/x/image v0.44.0
golang.org/x/sync v0.22.0
zombiezen.com/go/sqlite v1.4.2
)
require (
github.com/disgoorg/godave v0.1.0 // indirect
github.com/disgoorg/godave v0.3.0 // indirect
github.com/disgoorg/json/v2 v2.0.0 // indirect
github.com/disgoorg/omit v1.0.0 // indirect
github.com/disgoorg/snowflake/v2 v2.0.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/klauspost/compress v1.19.1 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/tools v0.46.0 // indirect
modernc.org/libc v1.73.4 // indirect
github.com/tetratelabs/wazero v1.12.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/tools v0.48.0 // indirect
modernc.org/libc v1.74.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.52.0 // indirect
modernc.org/sqlite v1.54.0 // indirect
)
+40 -32
View File
@@ -2,8 +2,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disgoorg/disgo v0.19.6 h1:1xJQt6KZgiqvBOnjuziG1gE4Iqd0aJn8DNfE6eE+0qw=
github.com/disgoorg/disgo v0.19.6/go.mod h1:NnV63iw4lJdF1fnV0gX27XR43ZgRGqnL122svRMTgTE=
github.com/disgoorg/godave v0.1.0 h1:3g0Zqzz+zNaxQTVLfCnl5eZKGqZk6cM/JLLbMKCtZQQ=
github.com/disgoorg/godave v0.1.0/go.mod h1:OreAC3hpabr39bMVA+jwOVDq1EUPXH5A0XUBiZaDI1Y=
github.com/disgoorg/godave v0.3.0 h1:37F3ZuiMd8/EXeoCtTeE8iP2eT2nWsfEa4/ITwoKfe4=
github.com/disgoorg/godave v0.3.0/go.mod h1:OreAC3hpabr39bMVA+jwOVDq1EUPXH5A0XUBiZaDI1Y=
github.com/disgoorg/json/v2 v2.0.0 h1:U16yy/ARK7/aEpzjjqK1b/KaqqGHozUdeVw/DViEzQI=
github.com/disgoorg/json/v2 v2.0.0/go.mod h1:jZTBC0nIE1WeetSEI3/Dka8g+qglb4FPVmp5I5HpEfI=
github.com/disgoorg/omit v1.0.0 h1:y0LkVUOyUHT8ZlnhIAeOZEA22UYykeysK8bLJ0SfT78=
@@ -12,8 +12,12 @@ github.com/disgoorg/snowflake/v2 v2.0.3 h1:3B+PpFjr7j4ad7oeJu4RlQ+nYOTadsKapJIzg
github.com/disgoorg/snowflake/v2 v2.0.3/go.mod h1:W6r7NUA7DwfZLwr00km6G4UnZ0zcoLBRufhkFWgAc4c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/gen2brain/avif v0.6.0 h1:/8WSgcU+IEF0jhKYsUZ/mzlziFuTeJFpIKBj2siTQps=
github.com/gen2brain/avif v0.6.0/go.mod h1:QgrYqdVE9y40PCfArK9VakcMIpYeDYpZmCSLkW6C1n8=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -22,14 +26,14 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/junegunn/fzf v0.73.1 h1:Ni9jaVMxsehcx9IwJ4ZAL/sr2c4dOEg1DHIAwPUVlyo=
github.com/junegunn/fzf v0.73.1/go.mod h1:hKDkO8orBWT5VLIZOL8oyhxn7mRLXdGyu+gz1Ss58pU=
github.com/junegunn/fzf v0.74.1 h1:rCGVdCSoOkfcfTxtxCO/vG2yceDjRlKSlv7IfCGkc/Y=
github.com/junegunn/fzf v0.74.1/go.mod h1:hKDkO8orBWT5VLIZOL8oyhxn7mRLXdGyu+gz1Ss58pU=
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMDfKzjT+DVfIS4iqknSEKtZpEcXtu6vuaasHs=
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -42,34 +46,38 @@ github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad h1:qIQkSlF5vAUHxE
github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad/go.mod h1:/pA7k3zsXKdjjAiUhB5CjuKib9KJGCaLvZwtxGC8U0s=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/libc v1.74.3 h1:a4J+Z8aVaxPyjyxRAdJzw246PqpcFGvVPnfT/AuM5Ws=
modernc.org/libc v1.74.3/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -78,8 +86,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+1 -1
View File
@@ -14,4 +14,4 @@ SELECT
FROM single_mode_scenario sc
JOIN scenario_name n ON sc.id = n.id
JOIN scenario_title t ON sc.id = t.id
ORDER BY sc.id
ORDER BY sc.sort_id
+621 -566
View File
File diff suppressed because it is too large Load Diff
+16 -13
View File
@@ -20,32 +20,35 @@
"@eslint/js": "^10.0.1",
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.60.1",
"@sveltejs/kit": "^2.70.1",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.3.0",
"@tailwindcss/vite": "^4.3.3",
"@types/d3": "^7.4.3",
"@types/node": "^22.19.19",
"@types/node": "^22.20.1",
"@vitest/browser-playwright": "^4.1.0",
"d3": "^7.9.0",
"eslint": "^10.4.0",
"eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.17.1",
"globals": "^17.6.0",
"playwright": "^1.60.0",
"prettier": "^3.8.3",
"eslint-plugin-svelte": "^3.22.0",
"globals": "^17.7.0",
"playwright": "^1.61.1",
"prettier": "^3.9.6",
"prettier-plugin-svelte": "^3.5.2",
"prettier-plugin-tailwindcss": "^0.7.4",
"svelte": "^5.55.7",
"svelte-check": "^4.4.8",
"svelte": "^5.56.7",
"svelte-check": "^4.7.3",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"typescript-eslint": "^8.59.3",
"vite": "^7.3.3",
"typescript-eslint": "^8.65.0",
"vite": "^7.3.6",
"vitest": "^4.1.0",
"vitest-browser-svelte": "^2.1.1"
"vitest-browser-svelte": "^2.2.1"
},
"dependencies": {
"@observablehq/plot": "^0.6.17",
"mathjs": "^15.2.0"
},
"allowScripts": {
"esbuild@0.28.1": true
}
}
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import type { SvelteHTMLElements } from 'svelte/elements';
const sizes = [256, 64, 32, 16] as const;
interface Props {
chara_id: number | null | undefined;
size: (typeof sizes)[number];
}
let { chara_id, size, ...rest }: Props & SvelteHTMLElements['picture'] = $props();
const icon = $derived(chara_id ?? 0);
const avifs = $derived(
sizes
.filter((sz) => sz >= size)
.map((sz) => `/img/chara/${icon}/${sz}x${sz}.avif ${sz / size}x`)
.join(', '),
);
const pngs = $derived(
sizes
.filter((sz) => sz >= size)
.map((sz) => `/img/chara/${icon}/${sz}x${sz}.png ${sz / size}x`)
.join(', '),
);
const src = $derived(icon !== 0 ? `/img/chara/${icon}/${sizes[0]}x${sizes[0]}.png` : `/img/skill/10011`);
</script>
{#if icon !== 0}
<picture {...rest}>
<source srcset={avifs} type="image/avif" />
<source srcset={pngs} type="image/png" />
<img {src} alt={`Icon for character ID ${chara_id}`} class="inline" width={size} height={size} loading="lazy" />
</picture>
{:else}
<div class={`w-[${size}px] h-[${size}px]`}></div>
{/if}
+12 -136
View File
@@ -1,153 +1,29 @@
<script lang="ts">
import type { Character } from '$lib/data/character';
import type { Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import { stringsearch } from './stringsearch';
import Pick from './Pick.svelte';
import CharaIcon from './CharaIcon.svelte';
interface Props {
id: string;
characters: Character[] | null;
value: Character | undefined;
class?: ClassValue | null;
option?: Snippet<[Character]>;
required?: boolean;
}
let { id, characters, value = $bindable(), class: className, option, required = false }: Props = $props();
let popover: HTMLElement | undefined = $state();
let optionsContainer: HTMLElement | undefined = $state();
const expanded = $derived(!popover?.hidden);
let search = $state('');
let { id, characters, value = $bindable(), class: className, required = false }: Props = $props();
const charas = $derived(characters ?? []);
const searchedCharas = $derived(stringsearch(search, charas, ({ name }) => name) || charas);
$effect(() => {
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();
}
const key = (v: Character) => ({ id: v.chara_id, name: v.name });
</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?.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>
<Pick {id} items={charas} {key} bind:value class={className} {required}>
{#snippet option(c: Character)}
<span>
<CharaIcon chara_id={c.chara_id} size={32} />
{c.name}
</span>
{/snippet}
</Pick>
+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 Skill,
} from './data/skill';
import SkillIcon from './SkillIcon.svelte';
interface Props {
skill: Skill | null | undefined;
@@ -24,6 +25,7 @@
group_rate: 1,
wit_check: false,
activations: [],
tags: [],
icon_id: 0,
};
@@ -62,9 +64,13 @@
<span class="group relative inline-block hyphens-none">
<span
class="skill-tip invisible absolute bottom-4 -left-1/2 flex w-80 flex-col rounded-lg border px-2 text-center opacity-0 shadow-lg transition-all group-hover:visible group-hover:bottom-6 group-hover:opacity-100"
class="skill-tip invisible absolute bottom-4 -left-1/2 flex min-w-80 flex-col rounded-lg border px-2 pt-2 text-center opacity-0 shadow-lg transition-all group-hover:visible group-hover:bottom-6 group-hover:opacity-100"
role="tooltip"
>
<span class="block text-xl">
<SkillIcon icon_id={skill?.icon_id} size={32} class="inline" />
{s.name}
</span>
<span class="block border-b py-1 hyphens-auto">
{s.description}
</span>
@@ -91,5 +97,6 @@
</span>
{/each}
</span>
<SkillIcon icon_id={skill?.icon_id} size={16} class="inline max-h-lh align-text-bottom" />
<span class={[spanClass, 'cursor-pointer']}>{s.name}</span>
</span>
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import type { SvelteHTMLElements } from 'svelte/elements';
const sizes = [64, 32, 16] as const;
interface Props {
icon_id: number | null | undefined;
size: (typeof sizes)[number];
}
let { icon_id, size, ...rest }: Props & SvelteHTMLElements['picture'] = $props();
const icon = $derived(icon_id ?? 0);
const avifs = $derived(
sizes
.filter((sz) => sz >= size)
.map((sz) => `/img/skill/${icon}/${sz}x${sz}.avif ${sz / size}x`)
.join(', '),
);
const pngs = $derived(
sizes
.filter((sz) => sz >= size)
.map((sz) => `/img/skill/${icon}/${sz}x${sz}.png ${sz / size}x`)
.join(', '),
);
const src = $derived(icon !== 0 ? `/img/skill/${icon}/${sizes[0]}x${sizes[0]}.png` : `/img/skill/10011`);
</script>
{#if icon !== 0}
<picture {...rest}>
<source srcset={avifs} type="image/avif" />
<source srcset={pngs} type="image/png" />
<img {src} alt={`Skill icon ID ${icon_id}`} class="inline" width={size} height={size} loading="lazy" />
</picture>
{:else}
<div class={`w-[${size}px] h-[${size}px]`}></div>
{/if}
+11 -11
View File
@@ -55,39 +55,39 @@
let width = $state(0);
let height = $state(0);
let expand = $state(false);
const xLabel = $derived(Stat[stat]);
const xLabel = $derived(`Raw ${Stat[stat]}`);
const xLines = $derived([xRule].flat(1));
const xMin = $derived(xRange?.[0] ?? 200);
const xMax = $derived.by(() => {
if (xRange?.[1] != null) {
return xRange[1];
}
if (expand) {
return 2000;
}
return 100 * Math.ceil(Math.max(1200, ...xLines) / 100);
return 100 * Math.ceil(Math.max(2000, ...xLines) / 100);
});
const xVal = $derived(d3.range(xMin, xMax + 1, 5)); // +1 because d3.range does not include the max
const thrX = 1200;
function adj(x: number): number {
return Math.min(x, 1200) + 0.5 * (Math.max(x, 1200) - 1200);
}
const series = $derived([y].flat(1).filter((s) => s != null));
const areas = $derived([yArea].flat(1).filter((s) => s != null));
const vals = $derived(series.flatMap(({ y, label }) => xVal.map((x) => ({ x, y: y(x), label }))));
const areaVals = $derived(areas.flatMap(({ y1, y2, label }) => xVal.map((x) => ({ x, y1: y1(x), y2: y2(x), label }))));
const vals = $derived(series.flatMap(({ y, label }) => xVal.map((x) => ({ x, y: y(adj(x), x), label }))));
const areaVals = $derived(
areas.flatMap(({ y1, y2, label }) => xVal.map((x) => ({ x, y1: y1(adj(x), x), y2: y2(adj(x), x), label }))),
);
const yRange: [number, number] = $derived.by(() => {
if (range != null) {
if (range.length === 2) {
return range;
}
return [range[0], expand ? range[2] : range[1]];
return [range[0], range[2]];
}
const l = d3.min(vals, ({ y }) => y) ?? 0;
const r = d3.max(vals, ({ y }) => y) ?? 1;
return [l, r];
});
const yLines = $derived(xLines.flatMap((x) => series.map(({ y, label }) => ({ y: y(x), label }))));
const yLines = $derived(xLines.flatMap((x) => series.map(({ y, label }) => ({ y: y(adj(x), x), label }))));
const makeChart: Attachment = (el) => {
$effect(() => {
+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,
Sasi: RunningStyle.LateSurger,
Oikomi: RunningStyle.EndCloser,
Oonige: RunningStyle.GreatEscape,
Oonige: RunningStyle.Runaway,
} as const;
export interface ImportUma {
+3 -3
View File
@@ -1,11 +1,11 @@
export interface ComputedSeries {
y: (x: number) => number;
y: (base: number, raw: number) => number;
label: string;
}
export interface ComputedAreas {
y1: (x: number) => number;
y2: (x: number) => number;
y1: (base: number, raw: number) => number;
y2: (base: number, raw: number) => number;
label: string;
}
+62
View File
@@ -48,6 +48,10 @@ export interface Skill {
* SP cost to purchase the skill, if applicable.
*/
sp_cost?: number;
/**
* Numeric tags applied to the skill, for effects which select skill types.
*/
tags: number[];
/**
* Skill icon ID.
*/
@@ -133,10 +137,13 @@ export enum AbilityType {
Frenzy = 13,
AddGateDelay = 14,
CurrentSpeed = 21,
CurrentSpeedWithDecel = 22,
TargetSpeed = 27,
LaneSpeed = 28,
RushedChance = 29,
Accel = 31,
LaneChange = 35,
ActivateRare = 37,
}
export const ABILITY_TYPE_FORMAT: Readonly<Record<AbilityType, (v: number) => string>> = {
@@ -152,10 +159,13 @@ export const ABILITY_TYPE_FORMAT: Readonly<Record<AbilityType, (v: number) => st
[AbilityType.Frenzy]: (v) => tenThousandths(v) + 's Frenzy',
[AbilityType.AddGateDelay]: (v) => plusBonus(v) + 's Gate Delay',
[AbilityType.CurrentSpeed]: (v) => plusBonus(v) + ' m/s Current Speed',
[AbilityType.CurrentSpeedWithDecel]: (v) => plusBonus(v) + ' m/s Current Speed',
[AbilityType.TargetSpeed]: (v) => plusBonus(v) + ' m/s Target Speed',
[AbilityType.LaneSpeed]: (v) => plusBonus(v) + ' CW/s Lane Change Speed',
[AbilityType.RushedChance]: (v) => plusBonus(v) + '% Rushed Chance',
[AbilityType.Accel]: (v) => plusBonus(v) + ' m/s² Acceleration',
[AbilityType.LaneChange]: (v) => 'Target Lane = ' + tenThousandths(v) + ' CW',
[AbilityType.ActivateRare]: (v) => 'Activate Rare Skills ×' + tenThousandths(v),
} as const;
export enum AbilityValueUsage {
@@ -329,3 +339,55 @@ export async function skillGroups(): Promise<SkillGroup[]> {
const resp = await fetch('/api/global/skill-group');
return resp.json();
}
export const TAGS = [
{ name: 'Front Runner', tag: 101 },
{ name: 'Pace Chaser', tag: 102 },
{ name: 'Late Surger', tag: 103 },
{ name: 'End Closer', tag: 104 },
{ name: 'Sprint', tag: 201 },
{ name: 'Mile', tag: 202 },
{ name: 'Medium', tag: 203 },
{ name: 'Long', tag: 204 },
{ name: 'Early Race', tag: 301 },
{ name: 'Mid Race', tag: 302 },
{ name: 'Late Race', tag: 303 },
{ name: 'Speed Stat or Velocity', tag: 401 },
{ name: 'Stamina Stat or HP', tag: 402 },
{ name: 'Power Stat or Acceleration', tag: 403 },
{ name: 'Guts Stat', tag: 404 },
{ name: 'Wit Stat or Vision', tag: 405 },
{ name: 'Debuff', tag: 406 },
{ name: 'Gate Delay', tag: 407 },
{ name: 'Turf', tag: 501 },
{ name: 'Dirt', tag: 502 },
{ name: 'Ground Condition Passive', tag: 601 },
{ name: 'Weather Passive', tag: 602 },
{ name: 'Season Passive', tag: 603 },
{ name: 'Racecourse Passive', tag: 604 },
{ name: 'Time of Day Passive', tag: 605 },
{ name: 'Exchange Race Passive', tag: 606 },
{ name: 'Distance Passive', tag: 607 },
{ name: 'Corner-Based Passive', tag: 608 },
{ name: 'Gate Position Passive', tag: 609 },
{ name: 'Ground Type Passive', tag: 610 },
{ name: 'Popularity Passive', tag: 611 },
{ name: 'Running Style Passive', tag: 612 },
{ name: "Passive Depending on Other Horses' Styles or Skills", tag: 613 },
{ name: 'Base Stat Threshold Passive', tag: 614 },
{ name: 'Mood Passive', tag: 615 },
{ name: 'URA', tag: 801 },
{ name: 'Unity Cup', tag: 802 },
{ name: 'Trackblazer', tag: 804 },
// { name: 'Our Grand Concert', tag: ? },
// { name: 'Grand Masters', tag: ? },
// { name: "Project L'Arc", tag: ? },
// { name: 'U.A.F.', tag: ? },
// { name: 'Great Food Festival', tag: ? },
// { name: 'Run, Mecha Umamusume!', tag: ? },
// { name: 'The Twinkle Legends', tag: ? },
// { name: 'Design Your Island', tag: ? },
// { name: 'Yukoma Hot Springs', tag: ? },
// { name: 'Beyond Dreams', tag: ? },
// { name: 'Tracen-Ken', tag: ? },
] as const;
+122 -6
View File
@@ -28,25 +28,141 @@ export interface Spark {
* Spark type.
* Roughly the spark color, with extra subdivisions for white sparks.
*/
type: 1 | 2 | 5 | 4 | 6 | 7 | 10 | 8 | 11 | 9 | 3;
type: Type;
/**
* Possible effects applied by the spark during inspiration.
* A random element is selected from this list according to unknown
* distributions, then all effects in that selection are applied.
*/
effects: SparkEffect[][];
effects: Effect[][];
}
/**
* Spark types.
* Roughly the spark color, with extra subdivisions for white sparks.
*/
export enum Type {
Stat = 1,
Aptitude = 2,
Unique = 3,
Skill = 4,
Race = 5,
Scenario = 6,
CarnivalBonus = 7,
Distance = 8,
Hidden = 9,
Surface = 10,
Style = 11,
}
/** Mapping of spark types to their names. */
export const TYPE_NAMES: Readonly<Record<Type, string>> = {
[Type.Stat]: 'Stat (Blue)',
[Type.Aptitude]: 'Aptitude (Pink)',
[Type.Unique]: 'Unique (Green)',
[Type.Skill]: 'Skill',
[Type.Race]: 'Race',
[Type.Scenario]: 'Scenario',
[Type.CarnivalBonus]: 'Carnival Bonus (Scarlet)',
[Type.Distance]: 'Distance (White)',
[Type.Hidden]: 'Hidden (White)',
[Type.Surface]: 'Surface (White)',
[Type.Style]: 'Style (White)',
};
/**
* Spark effect types.
*/
export enum Target {
Speed = 1,
Stamina = 2,
Power = 3,
Guts = 4,
Wit = 5,
SkillPoints = 6,
RandomStat = 7,
Turf = 11,
Dirt = 12,
FrontRunner = 21,
PaceChaser = 22,
LateSurger = 23,
EndCloser = 24,
Sprint = 31,
Mile = 32,
Medium = 33,
Long = 34,
Skill = 41,
CarnivalBonus = 51,
SpeedCap = 61,
StaminaCap = 62,
PowerCap = 63,
GutsCap = 64,
WitCap = 65,
}
/**
* Mapping of spark target types to their names.
* Note that Skill does not include the skill name.
*/
export const TARGET_NAMES: Readonly<Record<Target, string>> = {
[Target.Speed]: 'Speed',
[Target.Stamina]: 'Stamina',
[Target.Power]: 'Power',
[Target.Guts]: 'Guts',
[Target.Wit]: 'Wit',
[Target.SkillPoints]: 'SP',
[Target.RandomStat]: 'Random Stat',
[Target.Turf]: 'Turf aptitude',
[Target.Dirt]: 'Dirt aptitude',
[Target.FrontRunner]: 'Front Runner aptitude',
[Target.PaceChaser]: 'Pace Chaser aptitude',
[Target.LateSurger]: 'Late Surger aptitude',
[Target.EndCloser]: 'End Closer aptitude',
[Target.Sprint]: 'Sprint aptitude',
[Target.Mile]: 'Mile aptitude',
[Target.Medium]: 'Medium aptitude',
[Target.Long]: 'Long aptitude',
[Target.Skill]: 'hint level',
[Target.CarnivalBonus]: 'Carnival Bonus level',
[Target.SpeedCap]: 'Speed cap',
[Target.StaminaCap]: 'Stamina cap',
[Target.PowerCap]: 'Power cap',
[Target.GutsCap]: 'Guts cap',
[Target.WitCap]: 'Wit cap',
};
/**
* Effects that a spark can apply.
*/
export interface SparkEffect {
target: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 21 | 22 | 23 | 24 | 31 | 32 | 33 | 34 | 41 | 51 | 61 | 62 | 63 | 64 | 65;
value1?: number;
value2: number;
export interface Effect {
target: Target;
value1: number;
value2?: number;
}
export async function sparks(): Promise<Spark[]> {
const resp = await fetch('/api/global/spark');
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()];
}
+390 -28
View File
@@ -31,9 +31,236 @@ export enum Mood {
Great,
}
/**
* Moods as a descending list for easy iteration.
*/
export const MOODS = [Mood.Great, Mood.Good, Mood.Normal, Mood.Bad, Mood.Awful] as const;
const MOOD_COEF = {
[Mood.Awful]: 0.96,
[Mood.Bad]: 0.98,
[Mood.Normal]: 1,
[Mood.Good]: 1.02,
[Mood.Great]: 1.04,
} as const;
function statClamp(x: number): number {
return Math.round(math.max(1, math.min(2000, x)));
}
/**
* Calculate a horse's base stat value from her mood and raw stat value.
* @param mood Horse's mood
* @param rawStat Raw value of the stat, i.e. the displayed stat
* @returns Base stat value
*/
export function baseStat(mood: Mood, rawStat: number): number {
const r = (math.min(rawStat, 1200) + math.max(rawStat - 1200, 0) * 0.5) * MOOD_COEF[mood];
return statClamp(r);
}
/**
* Race distance types.
*/
export enum Distance {
Sprint,
Mile,
Medium,
Long,
}
/**
* Get the race distance type for a race length.
* @param raceLen Length of the race in meters
* @returns Distance type corresponding to the race length
*/
export function distance(raceLen: number): Distance {
if (raceLen < 1500) {
return Distance.Sprint;
}
if (raceLen < 1900) {
return Distance.Mile;
}
if (raceLen < 2500) {
return Distance.Medium;
}
return Distance.Long;
}
/**
* Get the distances at which each phase of a race ends.
* @param raceLen Length of the race in meters
* @returns Ends of each phase in meters
*/
export function phases(raceLen: number) {
const sixth = raceLen / 6;
return {
[Phase.EarlyRace]: sixth,
[Phase.MidRace]: 4 * sixth,
[Phase.LateRace]: raceLen,
};
}
/**
* Race surfaces.
*/
export enum Surface {
Turf,
Dirt,
}
/**
* Race ground condition types.
*/
export enum GroundConditions {
Firm,
Good,
Soft,
Heavy,
}
/**
* Ground condition types as a list for easy iterating.
*/
export const GROUND_CONDITONS = [
GroundConditions.Firm,
GroundConditions.Good,
GroundConditions.Soft,
GroundConditions.Heavy,
] as const;
const speedStatGroundMod = {
[Surface.Turf]: {
[GroundConditions.Firm]: 0,
[GroundConditions.Good]: 0,
[GroundConditions.Soft]: 0,
[GroundConditions.Heavy]: -50,
},
[Surface.Dirt]: {
[GroundConditions.Firm]: 0,
[GroundConditions.Good]: 0,
[GroundConditions.Soft]: 0,
[GroundConditions.Heavy]: -50,
},
} as const;
const powerStatGroundMod = {
[Surface.Turf]: {
[GroundConditions.Firm]: 0,
[GroundConditions.Good]: -50,
[GroundConditions.Soft]: -50,
[GroundConditions.Heavy]: -50,
},
[Surface.Dirt]: {
[GroundConditions.Firm]: -100,
[GroundConditions.Good]: -50,
[GroundConditions.Soft]: -100,
[GroundConditions.Heavy]: -100,
},
} as const;
const strategyProficiencyMod = [0.1, 0.2, 0.4, 0.6, 0.75, 0.85, 1.0, 1.1] as const;
/**
* Compute adjusted speed for a race.
* @param speed
* @param baseSpeed Base speed stat as computed from raw speed with baseStat
* @param career Whether the horse is currently training in career
* @param surface Race surface type
* @param conditions Race ground conditions
* @param thresholdMod Achieved stat threshold modifier for the race track
*/
export function adjustedStat(
speed: Stat.Speed,
baseSpeed: number,
career: boolean,
surface: Surface,
conditions: GroundConditions,
thresholdMod: number,
): number;
/**
* Compute adjusted stamina for a race.
* @param stam
* @param baseStam Base stamina stat as computed from raw stamina with baseStat
* @param career Whether the horse is currently training in career
*/
export function adjustedStat(stam: Stat.Stamina, baseStam: number, career: boolean): number;
/**
* Compute adjusted power for a race.
* @param power
* @param basePower Base power stat as computed from raw power with baseStat
* @param career Whether the horse is currently training in career
* @param surface Race surface type
* @param conditions Race ground conditions
*/
export function adjustedStat(
power: Stat.Power,
basePower: number,
career: boolean,
surface: Surface,
conditions: GroundConditions,
): number;
/**
* Compute adjusted guts for a race.
* @param guts
* @param baseGuts Base guts stat as computed from raw guts with baseStat
* @param career Whether the horse is currently training in career
*/
export function adjustedStat(guts: Stat.Guts, baseGuts: number, career: boolean): number;
/**
* Compute adjusted wit for a race.
* @param wit
* @param baseWit Base wit stat as computed from raw wit with baseStat
* @param career Whether the horse is currently training in career
* @param styleApt Aptitude for the horse's current running style
*/
export function adjustedStat(wit: Stat.Wit, baseWit: number, career: boolean, styleApt: AptitudeLevel): number;
export function adjustedStat(
stat: Stat,
baseStat: number,
career: boolean,
surfaceOrStyle?: Surface | AptitudeLevel,
conditions?: GroundConditions,
thresholdMod?: number,
): number {
const careerMod = career ? 400 : 0;
switch (stat) {
case Stat.Speed:
return statClamp(baseStat * thresholdMod! + speedStatGroundMod[surfaceOrStyle as Surface][conditions!] + careerMod);
case Stat.Stamina:
return statClamp(baseStat + careerMod);
case Stat.Power:
return statClamp(baseStat + powerStatGroundMod[surfaceOrStyle as Surface][conditions!] + careerMod);
case Stat.Guts:
return statClamp(baseStat + careerMod);
case Stat.Wit:
return statClamp(baseStat * strategyProficiencyMod[surfaceOrStyle as AptitudeLevel] + careerMod);
}
}
export function thresholdMod(stat1: number, stat2?: number): number {
if (stat2 != null) {
return 0.5 * (thresholdMod(stat1) + thresholdMod(stat2));
}
if (stat1 > 900) {
return 1.2;
}
if (stat1 > 600) {
return 1.15;
}
if (stat1 > 300) {
return 1.1;
}
return 1.05;
}
export function finalStat(adjStat: number, skillPassive: number): number {
return statClamp(adjStat + skillPassive);
}
/**
* Running styles for strategyphase coefficients.
* Great Escape is distinguished as a separate style even though it is
* Runaway is distinguished as a separate style even though it is
* mechanically identical to Front Runner.
*/
export enum RunningStyle {
@@ -41,7 +268,7 @@ export enum RunningStyle {
PaceChaser,
LateSurger,
EndCloser,
GreatEscape,
Runaway,
}
/**
@@ -52,7 +279,7 @@ export const RUNNING_STYLES = [
['Pace Chaser', RunningStyle.PaceChaser],
['Late Surger', RunningStyle.LateSurger],
['End Closer', RunningStyle.EndCloser],
['Great Escape', RunningStyle.GreatEscape],
['Runaway', RunningStyle.Runaway],
] as const;
/**
@@ -93,7 +320,103 @@ export enum Phase {
LateRace,
}
function baseSpeed(raceLen: number): number {
const hpStrategyCoeff = {
[RunningStyle.FrontRunner]: 0.95,
[RunningStyle.PaceChaser]: 0.89,
[RunningStyle.LateSurger]: 1.0,
[RunningStyle.EndCloser]: 0.995,
[RunningStyle.Runaway]: 0.86,
} as const;
/**
* Calculate a horse's max (starting) HP for a race.
* @param raceLen Length of the race in meters
* @param style Horse's running style
* @param stamStat Horse's final stamina stat
* @returns Max HP
*/
export function maxHP(raceLen: number, style: RunningStyle, stamStat: number): number {
return 0.8 * hpStrategyCoeff[style] * stamStat + raceLen;
}
const groundHPRateMod = {
[Surface.Turf]: {
[GroundConditions.Firm]: 1,
[GroundConditions.Good]: 1,
[GroundConditions.Soft]: 1.02,
[GroundConditions.Heavy]: 1.02,
},
[Surface.Dirt]: {
[GroundConditions.Firm]: 1,
[GroundConditions.Good]: 1,
[GroundConditions.Soft]: 1.01,
[GroundConditions.Heavy]: 1.02,
},
} as const;
/**
* Calculate HP consumption in HP/s.
* @param raceLen Length of the race in meters
* @param surface Surface type of the race
* @param conditions Race ground conditions
* @param gutsStat Final guts stat, ignored outside of late race
* @param phase Current race phase
* @param currentSpeed Current speed in m/s, not including the effects of current speed skills
* @param dam Whether downhill accel mode is active (default false)
* @param pdm Whether pace-down mode is active (default false)
* @param rushed Whether the horse is currently rushed (default false)
*/
export function hpPerSecond(
raceLen: number,
surface: Surface,
conditions: GroundConditions,
gutsStat: number,
phase: Phase,
currentSpeed: number,
dam?: boolean,
pdm?: boolean,
rushed?: boolean,
): number {
const rushedMod = rushed ? 1.6 : 1;
const pdmMod = pdm ? 0.6 : 1;
const damMod = dam ? 0.4 : 1;
const groundMod = groundHPRateMod[surface][conditions];
const gutsMod = phase === Phase.LateRace ? 1 + 200 / (math.sqrt(600 * gutsStat) as number) : 1;
const sp = currentSpeed - baseSpeed(raceLen) + 12;
return (20 / 144) * sp * sp * rushedMod * pdmMod * damMod * groundMod * gutsMod;
}
/**
* Calculate the HP required for a full spurt.
* @param raceLen Length of the race in meters
* @param spurtSpeed Target speed that a full spurt would produce
* @param hpRate HP usage per second during late race
* @returns HP threshold required for a full spurt
*/
export function fullSpurtHP(raceLen: number, spurtSpeed: number, hpRate: number): number {
// Per Aya:
/*
on hakuraku I do 62 here over 60 since we need the distance from last
spurt start to 60m before the end, not late race start to 60m to the
end, so since umas take an average of 1.5 frames to check their spurt
after late race and run at about 20 m/s mid race on most courses I
shifted it over by 1.5/15*20=2
forgot which CM it was but I checked what would be the best fit
empirically one time and got like 62.1
*/
// https://discord.com/channels/1493222996662419576/1493222998688137301/1517348759229436036
const spurtLen = raceLen / 3 - 62;
const spurtTime = spurtLen / spurtSpeed;
return spurtTime * hpRate;
}
/**
* Calculate the base speed for a race.
* Note that this is not the base target speed for a horse.
* @param raceLen Race length in meters
* @returns Base speed for the race
*/
export function baseSpeed(raceLen: number): number {
return 20 - (raceLen - 2000) / 1000;
}
@@ -260,25 +583,6 @@ export function inverseSpurtSpeed(
return Math.round(r);
}
const hpStrategyCoeff = {
[RunningStyle.FrontRunner]: 0.95,
[RunningStyle.PaceChaser]: 0.89,
[RunningStyle.LateSurger]: 1.0,
[RunningStyle.EndCloser]: 0.995,
[RunningStyle.GreatEscape]: 0.86,
} as const;
/**
* Calculate a horse's max HP (starting HP) for a race.
* @param raceLen Length of the race in meters
* @param style Horse's running style
* @param stamStat Final stamina stat
* @returns Max HP
*/
export function maxHP(raceLen: number, style: RunningStyle, stamStat: number): number {
return 0.8 * hpStrategyCoeff[style] * stamStat + raceLen;
}
/** Meters per horse length (馬身). */
export const HORSE_LENGTH = 2.5;
/** Meters per course width (a constant unit of measure). */
@@ -291,7 +595,7 @@ const accelStrategyPhaseCoeff = {
[RunningStyle.PaceChaser]: [0.985, 1.0, 0.996],
[RunningStyle.LateSurger]: [0.975, 1.0, 1.0],
[RunningStyle.EndCloser]: [0.945, 1.0, 0.997],
[RunningStyle.GreatEscape]: [1.17, 0.94, 0.956],
[RunningStyle.Runaway]: [1.17, 0.94, 0.956],
} as const;
const surfaceProficiencyMod = [0.1, 0.3, 0.5, 0.7, 0.8, 0.9, 1.0, 1.05] as const;
@@ -382,6 +686,56 @@ export function laneChangeSpeed(powerStat: number, maxLaneOrOrder?: number, curr
*/
export const LANE_CHANGE_ACCEL = 0.03;
const conserveStratDistCoef = {
[RunningStyle.FrontRunner]: {
[Distance.Sprint]: 1,
[Distance.Mile]: 1,
[Distance.Medium]: 1,
[Distance.Long]: 1,
},
[RunningStyle.PaceChaser]: {
[Distance.Sprint]: 0.7,
[Distance.Mile]: 0.8,
[Distance.Medium]: 0.9,
[Distance.Long]: 0.9,
},
[RunningStyle.LateSurger]: {
[Distance.Sprint]: 0.75,
[Distance.Mile]: 0.7,
[Distance.Medium]: 0.875,
[Distance.Long]: 1,
},
[RunningStyle.EndCloser]: {
[Distance.Sprint]: 0.7,
[Distance.Mile]: 0.75,
[Distance.Medium]: 0.86,
[Distance.Long]: 0.9,
},
[RunningStyle.Runaway]: {
[Distance.Sprint]: 1,
[Distance.Mile]: 1,
[Distance.Medium]: 1,
[Distance.Long]: 1,
},
} as const;
export function conservePowerAccel(
distanceType: Distance,
style: RunningStyle,
rawPower: number,
powerSkillBonus?: number,
spotStruggled?: boolean,
rushed?: boolean,
): number {
const power = rawPower + (powerSkillBonus ?? 0);
if (power <= 1200) {
return 0;
}
const sdc = conserveStratDistCoef[style][distanceType];
const activity = rushed ? 0.8 : spotStruggled ? 0.98 : 1;
return Math.sqrt((power - 1200) * 130) * 0.001 * sdc * activity;
}
/**
* Calculate a horse's minimum speed for a race.
* @param raceLen Length of the race in meters
@@ -410,8 +764,6 @@ export function spotStruggleSpeed(gutsStat: number): number {
return Math.pow(500 * gutsStat, 0.6) * 0.0001;
}
const strategyProficiencyMod = [0.1, 0.2, 0.4, 0.6, 0.75, 0.85, 1.0, 1.1] as const;
/**
* Calculate the max duration of spot struggle.
* Note that spot struggle ends early if the frontmost horse in it reaches a 5m lead,
@@ -447,13 +799,22 @@ export function duelAccelMod(gutsStat: number): number {
* Calculate the speed modifier for running uphill.
* Contrary to the race mechanics document, this is expressed as a negative number.
* @param powerStat Final power stat
* @param slopePer Slope percentage, generally one of 0.5, 1.0, 1.5, or 2.0
* @param slopePer Slope percentage, one of 1.0, 1.5, or 2.0
* @returns Speed modifier for running uphill, a negative value
*/
export function uphillMod(powerStat: number, slopePer: number): number {
return (slopePer * -200) / powerStat;
}
/**
* Calculate the speed modifier while running in downhill accel mode.
* @param slopePer Slope percentage, one of -1.0, -1.5, or -2.0
* @returns Speed modifier for downhill accel mode
*/
export function downhillAccelMod(slopePer: number): number {
return 0.3 - slopePer / 10;
}
/**
* Calculate the forward speed boost given when moving lanewise while a skill
* that grants a lane change speed boost is active.
@@ -519,7 +880,8 @@ export function speedGain(speedBonus: number, dur: number, accel: number | null,
* @returns Probability per candidate to accept
*/
export function reducedSpurtChance(witStat: number): number {
return 0.15 + 0.0005 * witStat;
// 1900 wit style S exceeds 100% chance if we don't explicitly cap.
return Math.min(0.15 + 0.0005 * witStat, 1);
}
/**
+97
View File
@@ -0,0 +1,97 @@
/**
* Instantaneous characteristics of a horse in motion.
*/
export interface Instant {
/** Timestamp of the instant. */
t: number;
/** Forward position along the course. */
x: number;
/** Actual speed. */
v: number;
}
/**
* Forward travel characteristics under the effects of acceleration.
*/
export interface Travel {
start: Instant;
end: Instant;
/** Actual acceleration value applied between start and end. */
a: number;
}
/**
* Advance a horse according to its forward motion characteristics.
* @param start Motion at the beginning of the segment
* @param targetSpeed Target speed to accelerate to
* @param totalAccel Total (positive) acceleration including skill bonuses
* @param maxDur Max duration of the acceleration, or infinite if not given
* @param maxDist Max distance of the acceleration, or infinite if not given
* @param decel Negative acceleration value for cases where target speed is below start speed
* @returns Travel segment after reaching the first of the given limits
*/
export function travelSegment(
start: Instant,
targetSpeed: number,
totalAccel: number,
maxDur?: number,
maxDist?: number,
decel?: number,
): Travel {
const a = start.v < targetSpeed ? totalAccel : (decel ?? -1);
const dvt = (targetSpeed - start.v) / a;
const dxt = maxDist != null ? (-start.v + Math.sqrt(start.v * start.v + 4 * a * maxDist)) / (2 * a) : dvt;
const time = maxDur != null ? Math.min(dvt, dxt, maxDur) : Math.min(dvt, dxt);
// To address numerical imprecision, if accel time comes from velocity or distance,
// set the reached values directly.
const v = time === dvt ? targetSpeed : start.v + time * a;
const dist = maxDist != null && time === dxt ? maxDist : (start.v + 0.5 * a * time) * time;
const end = { t: start.t + time, x: start.x + dist, v };
return { start, end, a };
}
/**
* Acceleration bonus from skills.
*/
export interface Skill {
/**
* Acceleration modifier in m/s².
*/
accel: number;
/**
* Remaining actual duration in seconds.
*/
dur: number;
}
/**
* Accelerate up to a new target speed while under the effects of acceleration skills.
* @param startSpeed Speed at the start of the acceleration in m/s
* @param targetSpeed Speed at which the acceleration ends in m/s
* @param baseAccel Base acceleration value to which skills add, in m/s²
* @param skills Skills that are active for the acceleration period
* @returns Acceleration segments and remainders of skills upon reaching the target speed
*/
export function up(start: Instant, targetSpeed: number, baseAccel: number, skills: Skill[]) {
const segments: Travel[] = [];
const sk = skills.map((s) => ({ ...s })).filter((s) => s.dur > 0);
while (start.v < targetSpeed) {
const active = sk.filter((s) => s.dur > 0);
if (active.length === 0) {
segments.push(travelSegment(start, targetSpeed, baseAccel));
break;
}
const boost = active.reduce((a, s) => a + s.accel, 0);
const time = Math.min(...active.map((s) => s.dur));
const seg = travelSegment(start, targetSpeed, baseAccel + boost, time);
segments.push(seg);
start = seg.end;
for (const s of active) {
s.dur -= seg.end.t - seg.start.t;
if (s.dur < 1e-5) {
s.dur = 0;
}
}
}
return { segments, skills: sk };
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Fundamental stats of umas.
*/
export enum Stat {
speed,
stamina,
power,
guts,
wit,
}
/**
* Stats as a list for easy iteration.
*/
export const LIST = [Stat.speed, Stat.stamina, Stat.power, Stat.guts, Stat.wit] as const;
declare const __stage: unique symbol;
export type Stage = 'raw' | 'base' | 'adjusted' | 'final' | 'skills';
export type StatValue<T extends Stage> = number & { [__stage]: T };
export interface Stats<T extends Stage> {
speed: StatValue<T>;
stamina: StatValue<T>;
power: StatValue<T>;
guts: StatValue<T>;
wit: StatValue<T>;
}
+21 -3
View File
@@ -1,13 +1,31 @@
<script lang="ts">
import './layout.css';
import favicon from '$lib/assets/favicon.png';
import * as all from '$lib/all';
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>
<svelte:head>
{#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} />
</svelte:head>
@@ -23,9 +41,9 @@
<a href={resolve('/doc')} class="mx-8 my-1 inline-block">Documents</a>
</span>
</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()}
</div>
</main>
<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"
>
+8 -41
View File
@@ -1,31 +1,18 @@
<script lang="ts">
import { resolve } from '$app/paths';
import * as all from '$lib/all';
import IndexSection from '$lib/IndexSection.svelte';
</script>
<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>
<h2 class="mt-8 mb-4 text-4xl">Race Mechanics Tools</h2>
<IndexSection title="Race Mechanics Tools" pages={all.PAGES.race} />
<IndexSection title="Character Tools" pages={all.PAGES.chara} />
<IndexSection title="Documents" pages={all.PAGES.doc} />
<!-- Not a real index section in, terms of types. -->
<h2 class="my-4 text-4xl">&amp;c.</h2>
<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>
</ul>
<h2 class="mt-8 mb-4 text-4xl">Character Tools</h2>
<ul>
<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>
<li>
<a href="https://discord.com/oauth2/authorize?client_id=1461931240264568994" target="_blank" rel="noopener noreferrer"
>Discord Bot</a
@@ -33,25 +20,5 @@
</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>
</ul>
<h2 class="my-4 text-4xl">About</h2>
<p>My repository of tools to fill some gaps I've felt in Umamusume optimization, as well as original research and editorials.</p>
+8 -14
View File
@@ -1,17 +1,11 @@
<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>
<h1 class="m-8 text-center text-7xl">Zenno Rob Roy &mdash; Character Tools</h1>
<p>Tools related to understanding Umamusume characters.</p>
<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>
<h1 class="m-8 text-center text-7xl">Zenno Rob Roy &mdash; {cur.name}</h1>
<p>{cur.description}</p>
<IndexSection pages={all.PAGES.chara} />
+3 -4
View File
@@ -8,13 +8,12 @@
const convoMap = $derived(byChara(conversations));
const popular = $derived(groupPopulars(conversations));
let characters: Character[] = $state([]);
const selCharas = $derived(characters.filter((c) => convoMap.has(c.chara_id)));
const names = $derived(charaNames(characters ?? []));
const minSuggest = 8;
onMount(async () => {
const [convos, charas] = await Promise.all([conversation(), character()]);
conversations = convos;
characters = charas.filter((c) => convoMap.has(c.chara_id));
[conversations, characters] = await Promise.all([conversation(), character()]);
});
let chara: Character | undefined = $state();
@@ -43,7 +42,7 @@
<div class="mx-auto mt-8 flex flex-col rounded-md text-center shadow-md ring md:max-w-xl md:flex-row">
<div class="m-4 flex-1 md:mt-3">
<label for="chara" class="hidden md:inline">Character</label>
<CharaPick id="chara" {characters} class="w-full" bind:value={chara} required />
<CharaPick id="chara" characters={selCharas} class="w-full" bind:value={chara} required />
</div>
<div class="m-4 flex-1 md:mt-3">
<label for="convo" class="hidden md:inline">Conversation</label>
+8 -22
View File
@@ -1,25 +1,11 @@
<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>
<h1 class="m-8 text-center text-7xl">Zenno Rob Roy &mdash; Documents</h1>
<p>Articles about Umamusume, including editorials and research.</p>
<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>
</ul>
<h1 class="m-8 text-center text-7xl">Zenno Rob Roy &mdash; {cur.name}</h1>
<p>{cur.description}</p>
<IndexSection pages={all.PAGES.doc} />
+9 -5
View File
@@ -7,12 +7,13 @@
h?: 1 | 2 | 3 | 4 | 5 | 6;
id: string;
children: Snippet;
ref?: string;
top?: string;
toc?: boolean;
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 href = $derived(`#${id}`);
@@ -39,9 +40,12 @@
<svelte:element this={tag} {id} class={['relative w-full', className]}>
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a {href}><span class="hidden md:inline">{sign}</span> {@render children()}</a>
{#if h >= 2}
<a href={top} class="absolute right-0 bottom-0 text-end align-bottom text-sm whitespace-nowrap transition-all hover:bottom-px"
>Top ▲</a
>
<div class="absolute right-0 bottom-0 inline-block text-end align-bottom text-sm whitespace-nowrap">
{#if ref != null}
<a href={ref} target="_blank" rel="noopener noreferrer">Ref &UpperRightArrow;</a>
{/if}
{#if h >= 2}
<a href={top} class="ml-4">Top ▲</a>
{/if}
</div>
</svelte:element>
+242 -82
View File
@@ -3,6 +3,8 @@
import type { ComputedAreas, ComputedSeries, HorizontalRule } from '$lib/chart';
import {
AptitudeLevel,
conservePowerAccel,
distance,
downhillAccelEnterChance,
frontModeEnterChance,
HORSE_LENGTH,
@@ -89,8 +91,11 @@
loneWolf: allSkills.get(201641),
slipstream: allSkills.get(201651),
pto: allSkills.get(201661),
headon: allSkills.get(201902),
risky: allSkills.get(202032),
runaway: allSkills.get(202051),
fr: allSkills.get(202462),
burningPOW: allSkills.get(210031),
burningWIT: allSkills.get(210051),
ignitedWIT: allSkills.get(210052),
radiant: allSkills.get(210061),
@@ -174,7 +179,7 @@
{ label: 'Style S', y: (x) => downhillAccelEnterChance(x * 1.1) * 100 },
{ label: 'Style A', y: (x) => downhillAccelEnterChance(x) * 100 },
];
const secIsFront = $derived(secSpeedStyle === RunningStyle.FrontRunner || secSpeedStyle === RunningStyle.GreatEscape);
const secIsFront = $derived(secSpeedStyle === RunningStyle.FrontRunner || secSpeedStyle === RunningStyle.Runaway);
const secSpeedSeries: Array<ComputedAreas | null> = $derived([
{
label: 'Early Race',
@@ -209,6 +214,12 @@
y2: (x) => modemean(sectionSpeed(raceLen, x, x, secSpeedStyle, Phase.MidRace)[1], 1.04, paceUpEnterChance(x)),
},
]);
const fcSeries: ComputedSeries[] = $derived([
{ label: 'Front Runner', y: (x) => conservePowerAccel(distance(raceLen), RunningStyle.FrontRunner, x) },
{ label: 'Pace Chaser', y: (x) => conservePowerAccel(distance(raceLen), RunningStyle.PaceChaser, x) },
{ label: 'Late Surger', y: (x) => conservePowerAccel(distance(raceLen), RunningStyle.LateSurger, x) },
{ label: 'End Closer', y: (x) => conservePowerAccel(distance(raceLen), RunningStyle.EndCloser, x) },
]);
</script>
<Article>
@@ -233,6 +244,28 @@
</p>
{/snippet}
<Sec h={2} id="7-1">1 Jul Balance Update</Sec>
<p>A quick summary of how the 1 Jul balance update affects front runners:</p>
<ul class="mb-4 list-disc pl-4">
<li>
Pace chasers and late surgers gained access to much more consistent accel skills. Still not as good as Angling, but good
enough that front runners can be considered substantially nerfed.
</li>
<li>
<a href="#full-charge">Conserving Power/Fully Charged</a> is in, along with stat uncaps, which means that about 1270 power
(base + skills) is now worth a perfectly timed 0.1 accel skill on front runners. The exact mechanics of conserving power
aren't yet known, but front runners show a <i>negative correlation</i> between guts stat and probability of activating full charge.
As good as spot struggle may be in sprint, a perfect accel is always going to be better, so we are now actively minimizing guts.
</li>
<li>
A solo front no longer loses access to speed-up mode. However, having watched a few dozen replays, it seems that while a
solo front is typically about 2 to 4 meters ahead of the best pace at position keep end, the best front in multi-front is
typically about 6 to 10 meters ahead. Solo front still overwhelmingly benefits other styles. The main impact of the position
keep changes in practice seems to be that pace promotion can happen <i>even if there are front runners in the lobby</i>,
which means you can't skimp on gate skills when running solo front as a pace promo blocker.
</li>
</ul>
<Sec h={2} id="me">About Me</Sec>
<p>
About three weeks after Global launched, my friend told me to get a job, so I sent him a screenshot of me clicking the install
@@ -257,18 +290,16 @@
>. 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
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.
</p>
<p>
Also worth mentioning is that I've been unemployed for over two years, with a computer science degree that is increasingly
difficult for an anti-AI person to apply. If this article helps you, I'd be tremendously grateful if you could <a
href="https://ko-fi.com/zephyrtronium"
target="_blank"
rel="noopener noreferrer">drop me some spare change</a
>.
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>
<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>
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
@@ -295,10 +326,7 @@
<Sec h={3} id="runaway">Runaway</Sec>
<p>
The skill <Skill skill={skills.runaway} /> converts front runners into the <i>Great Escape</i> running style. However, no
player has ever uttered the words "Great Escape" when talking about Umamusume, presumably because Runaway is a much cooler
name. ("Great Escape" is a direct translation of Japanese 大逃げ <i>oonige</i>, whereas "Front Runner" is a more liberal
localization of 逃げ <i>nige</i> that technically just means "escape.")
The skill <Skill skill={skills.runaway} /> converts front runners into the Runaway running style.
</p>
<p>
Runaways are still front runners for all purposes. The difference is just different numbers for things like base speed and
@@ -306,7 +334,12 @@
runners also apply to runaways.
</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>
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
@@ -319,8 +352,8 @@
</p>
<p>
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
will likely lose only a couple races within the difference throughout a CM event.
aren't what make front runners win most of the time. Capped speed with distance S is ideal, but being a hundred speed short
with distance A will likely lose only a couple races within the difference throughout a CM event.
</p>
<Sec h={3} id="win-cons">Win Conditions</Sec>
@@ -364,16 +397,20 @@
mid race.
</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>
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.
</p>
<p>
The running modes for front runners are speed-up (+4% target speed for first among that front type) and overtake (+5% for
not-first). Entering these modes requires meeting certain conditions relating to positioning, which collectively can be read
as "solo fronts are heavily penalized." (With the 1.5 anni balance patch, we expect that solo fronts will be given more
forgiving running mode conditions.) They also require passing a wit check, with the same chance for both modes.
not-first). Entering these modes requires meeting certain conditions relating to positioning. They also require passing a wit
check, with the same chance for both modes.
</p>
<div class="mx-auto h-60 w-full max-w-3xl md:h-96">
<StatChart
@@ -386,24 +423,34 @@
/>
</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>
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.
</p>
<p>
Watch a MANT late surger with 1000+ power and wit in a daily legend race. As long as they don't get blocked, they should <a
Watch a UC late surger with 1200+ power and wit in a daily legend race. As long as they don't get blocked, they should <a
href="#section-speed">slide forward</a
> throughout the early race. Then, around when they reach the pace chaser pack, they'll suddenly start moonwalking back to the rest
of the late surgers, often near the back of the group. That's PDM.
</p>
<p>
On styles with PDM, early race and sometimes mid race speed skills are effectively converted from distance gain into HP
conservation. The thing that really makes front runners good is that they don't have to worry about that &ndash; they aren't
conservation. The thing that really makes front runners strong is that they don't have to worry about that &ndash; they aren't
subject to PDM at all. Their mid race speed skills always gain distance.
</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>
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
@@ -411,17 +458,18 @@
</p>
<p>
Spot struggle provides a target speed bonus that scales with the guts stat. If it isn't cut short, which will approximately
never happen, its duration also scales with the guts stat. Unlike skills, its duration <i>does not</i> scale with race distance.
never happen outside of sprint, its duration also scales with the guts stat. Unlike skills, its duration <i>does not</i> scale with
race distance.
</p>
<div class="mb-4 grid h-60 w-full grid-cols-2 md:h-96">
<StatChart stat={Stat.Guts} y={ssBoostSeries} yLabel="Speed Bonus (m/s)" range={[0, 0.3]} />
<StatChart stat={Stat.Guts} y={ssDurSeries} yLabel="Duration (s)" range={[0, 12]} />
<StatChart stat={Stat.Guts} y={ssBoostSeries} yLabel="Speed Bonus (m/s)" range={[0, 0.4]} />
<StatChart stat={Stat.Guts} y={ssDurSeries} yLabel="Duration (s)" range={[0, 14]} />
</div>
<p>
Spot struggle also greatly increases HP consumption. For normal front runners, the rate is slightly less than Rushed. For
runaways, it's more than double Rushed. (This is the reason people say you can't get enough stamina for runaways on Global.)
Actually getting Rushed during spot struggle dramatically increases HP consumption, much more than just adding them together;
red-light green-light pretty much guarantees that horse won't spurt.
red-light green-light pretty much guarantees that horse won't full spurt.
</p>
<p>
In medium+ races, the extra HP consumption is a serious consideration; front runners need more stamina and recoveries than
@@ -429,6 +477,52 @@
multiple gold speed skills in total distance gained. See the <a href={resolve('/race/mspeed')}>mechanical speed calculator</a> for
precise analysis.
</p>
<p>
However, while the details aren't yet known, it appears that spot struggle reduces <a href="#full-charge">conserved power</a>,
which means that front runners who spot struggle are less likely to full charge, and the probability falls as the spot
struggle duration rises.
</p>
<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>
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
with running style and distance, but it is greatest for front runners at all distances.
</p>
<div class="mb-24 h-60 w-full md:mb-20 md:h-96">
<StatChart
class="mx-auto mb-12 h-full w-full max-w-3xl md:mb-10"
stat={Stat.Power}
y={fcSeries}
yLabel="Accel Bonus (m/s²)"
range={[0, 0.25]}
plotOptions={{ color: { legend: true } }}
/>
<div class="mx-auto flex w-full md:max-w-2xl">
<label class="my-auto hidden flex-none pr-2 text-right text-sm md:inline" for="secSpeedRaceLen">Race Length</label>
<input
class="my-auto max-w-40 flex-1 md:flex-2"
type="range"
id="secSpeedRaceLen"
min="1000"
max="3600"
step="100"
bind:value={raceLen}
/>
<span class="my-auto flex-none pl-2 text-sm">{raceLen}m</span>
</div>
</div>
<p>
The exact mechanics of conserving power are unclear at this time. What has been confirmed is that rushed and <a
href="#spot-struggle">spot struggle</a
> both reduce the chance for it to fire. Since spot struggle duration scales with guts, it appears that taking high guts to benefit
from the strength of spot struggle has become likely to deprive you of an even stronger mechanic.
</p>
<Sec h={3} id="lane-combo">Lane Combo</Sec>
<p>
@@ -443,7 +537,7 @@
y={laneComboSeries}
yLabel="Speed Boost"
yRule={lcYRule}
range={[0.2, 0.5]}
range={[0.2, 0.6]}
/>
</div>
<p>
@@ -488,7 +582,12 @@
<a href={resolve('/doc/lanecalc')}>my guide</a> for English instructions on how to use it.
</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>
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
@@ -513,7 +612,12 @@
power on the Arima Kinen mid-race hills.
</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>
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
@@ -530,7 +634,7 @@
stat={Stat.Wit}
y={downhillSeries}
yLabel="Entry Chance (% each second)"
range={[0, 60]}
range={[0, 75]}
/>
</div>
<p>
@@ -539,7 +643,12 @@
Conversely, the HP savings on long downhills can be enough to drop a recovery skill or two on some tracks.
</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>
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.)
@@ -550,7 +659,7 @@
stat={Stat.Wit}
yArea={secSpeedSeries}
yLabel="Section Speed (m/s)"
range={[17.5, 22.5]}
range={[17, 23]}
plotOptions={{ color: { legend: true } }}
/>
<div class="mx-auto flex w-full md:max-w-2xl">
@@ -583,7 +692,12 @@
wit front A horse will pass in {secSpeedPassTime} seconds on average at mid race speeds.
</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>
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
@@ -674,7 +788,7 @@
<Sec h={3} id="spurt-skills">Spurt Skills</Sec>
<p>
Because they are post-Angling, skills that activate in the final spurt are typically less interesting to front runners.
Notable exceptions are <Skill skill={skills.barcaroleInherit} /> and, on Tokyo turf, <Skill
Notable exceptions are <Skill skill={skills.fr} />, <Skill skill={skills.barcaroleInherit} /> and, on Tokyo turf, <Skill
skill={skills.inesInherit}
/><!-- skill name ends with ! -->
These are good inherits for those who don't have easy access to <Skill skill={skills.cacaoInherit} mention /> and <Skill
@@ -708,8 +822,8 @@
</p>
<p>
Eyes especially has the potential to be threatening, since it's roughly unconditional. However, it hits others <i>in vision</i
>, which is essentially always a range of 20m. Debuffers are typically built with very little stamina and power, so Eyes is
almost never going to reach front runners, especially the front who got Angling.
>, which is essentially always a range of 20m, or 8 lengths. Debuffers are typically built with very little stamina and power,
so Eyes is almost never going to reach front runners, especially the front who got Angling.
</p>
<p>
The other category of skills to think about is other horses' uniques.
@@ -753,6 +867,10 @@
Obviously, CM is run in teams of three. While spamming the highest stats you can manage is a valid strategy that will get
wins, building a team holistically will lead to higher win rates.
</p>
<p>
N.B. This section has only partially been updated for the 1 Jul balance patch, which means that some of the advice especially
relating to runaways and spot struggle is out of date. A full update here probably won't come until after CM17 Virgo Cup.
</p>
<Sec h={3} id="pace-promo">Zero Front</Sec>
<p>
@@ -790,21 +908,20 @@
<Sec h={3} id="solo-front">Solo Front</Sec>
<p>
Because solo fronts essentially don't have access to their <a href="#front-modes">running modes</a>, they are unlikely to
build enough of a lead over paces to win, even with Angling. (This should change to some degree with the release of Grand
Live, as solo fronts will get an extra long speed-up zone.)
build enough of a lead over paces to win, even with Angling. (This changed a bit with the 1 Jul balance patch, as solo fronts
now get an extra-long speed-up range, but it doesn't appear that they're maintaining much of a gap over paces in practice.)
</p>
<p>
In particularly front-dominated metas, this is manageable, since you'll have opponent fronts to run against. When any other
style is viable, which is probably always now that Nishino Flower exists, you'll have matches where your team's front runner
is solo. The best choice for how to design a solo front depends on the rest of your team composition, because ultimately the
choice you are faced with is whether to use a runaway blocker.
style is viable, which is always now that <Skill skill={skills.headon} mention /> is buffed, you'll have matches where your team's
front runner is solo. The best choice for how to design a solo front depends on the rest of your team composition, because ultimately
the choice you are faced with is whether to use a runaway blocker.
</p>
<p>
In the MANT era, building a runaway that can defeat normal front runners is difficult. For one thing, a runaway existing means
that all fronts have access to overtake mode for position keep, which is better than speed-up mode. Moreover, beating their
mid race skill list requires some lucky hints in Unity Cup. The ideal runaway blocker has capped power, capped wit, and
surface S, along with full gate and <a href="#lane-combo">lane combos</a> where applicable, to build as much of an early race lead
as possible.
The ideal runaway blocker has capped power, capped wit, and surface S, along with full gate and <a href="#lane-combo"
>lane combos</a
> where applicable, to build as much of an early race lead as possible. Her speed stat doesn't particularly matter, because her
job is to block, not to win.
</p>
<p>
Because runaways get so far ahead during the first half of the race, they essentially disable <a href="#pdm">pace-down mode</a
@@ -826,12 +943,12 @@
<p>
There is still a difference between dual ace double front and double front with an ace and a backup. The ideal stat lines and
skill lists will be the same between the two styles, but character choice will be different. In particular, dual ace wants
both to have strong speed uniques &ndash; VBourbon, SMaru, Ines Fujin on Tokyo turf.
both to have strong speed uniques &ndash; VBourbon or SMaru.
</p>
<p>
With an ace and a backup, the ace is the one with the speed unique, and the other is a horse who gambles on winning mid race
and turns that into a first place finish. Seiun Sky is the strongest such option; as an own unique rather than inherited,
Angling gives so much perfectly timed accel that speed inherits give her more lengths than other accel skills.
Angling gives so much perfectly timed acceleration that speed inherits give her more lengths than other accel skills.
</p>
<p>
Since your two front runners are both meant to be winners, making your third horse a support type is likely to give the best
@@ -853,12 +970,6 @@
long distance; front runners are common generally here.) So, double front for long is ultimately about the same as double front
for other distances, just with different goals when choosing the legacy.
</p>
<p>
Given that NN is good, it's also worth considering the gold version, <Skill skill={skills.nsm} /><!-- ends with ! -->
NSM is a better skill for the long double front build, but for now, the only way to get it while running MANT is from Yukino Bijin
Wit. That card's numbers are not great. It's still possible to get a good stat line using her, but it will take more careers to
get there than to just get NN from Fine Motion.
</p>
<p>
If you're feeling adventurous, runaway is another consideration for double (and triple) front on long distances. Your ace
(hopefully) still gets VC, but the runaway paces up the race. As mentioned in the <a href="#solo-front">solo front</a>
@@ -888,10 +999,10 @@
</p>
<p>
The fact that triple front tries to occupy positions 3-4 lets you do some very interesting team comps where you run pace
chasers as front runners. E.g., I ran Taiki Shuttle as front in dirt CM and Curren Chan as front in sprint CM. This is not
terribly consistent because they're readily equipped to get into first just as easily as third, but then they just get Angling
instead and follow the normal front runner game plan. If you see people talking about a pace chaser being especially strong
for a given CM, consider running them as a front runner in a triple front build instead.
chasers as front runners. E.g., I ran Taiki Shuttle as front in CM10 on dirt and Curren Chan as front in the CM9 sprint. This
is not terribly consistent because they're readily equipped to get into first just as easily as third, but then they just get
Angling instead and follow the normal front runner game plan. If you see people talking about a given pace chaser being
especially strong for a CM, consider running them as a front runner in a triple front build instead.
</p>
<p>
A more mechanical type of support is to build for <a href="#spot-struggle">spot struggle</a> and have her pull your less gutsy fronts
@@ -905,13 +1016,13 @@
showing. In addition to the normal front win cons, it's potentially strong to take unconditional gambles like <Skill
skill={skills.cornerConn}
mention
/> and <Skill skill={skills.radiant} mention />.
/>, <Skill skill={skills.burningPOW} mention />, and <Skill skill={skills.radiant} mention />.
</p>
<p>
SS supports do best on medium and long tracks, because you want to be building <i>everyone</i> for spot struggle on miles and
sprints. However, spot struggle is the only mechanic in the game that scales superlinearly with stats, so even the difference
between 1200 and 1000 guts can matter enough to justify an extra guts focus on the SS support in miles. See the
<a href={resolve('/race/mspeed')}>mechanical speed calculator</a> for details.
Now that <a href="#full-charge">conserving power</a> is a game mechanic, SS supports have the problem that spot struggle risks
taking away full charge, which means losing a strong accel. This can be alleviated to some degree by managing
<a href="#lane-combo">Dodging Danger</a> to keep your ace out of the spot struggle &ndash; either the ace has lane combo and the
other two don't, or the ace doesn't have it and the other two do.
</p>
<div class="hidden" id="chariot"></div>
@@ -934,15 +1045,17 @@
/>, so the normal fronts (typically UG1 or better) pass them during early race.
</p>
<p>
If this build ever had an era, it was during Unity Cup, when NSM was essentially free and there was no VC. I don't think it's
viable now.
If this build ever had an era, it was during the first pass of Unity Cup, when NSM was essentially free and there was no VC.
We have reworked UC for one CM before Our Grand Concert releases, but it's a sprint CM that overwhelmingly favors pace
chasers. I don't think it's viable now.
</p>
<Sec h={2} id="career">Career</Sec>
<p>Most front runners enjoy easy careers thanks to strong kits and little chance to be blocked.</p>
<p>
Currently, this chapter is about MANT (a.k.a. Trackblazer). Unity Cup is still useful &ndash; obviously for runaways, but also
for spinning up an <Skill skill={skills.ignitedWIT} mention /> legacy.
Currently, this chapter is about MANT (a.k.a. Trackblazer). Current data for CM16 following the URA and UC reworks suggests
that MANT horses win approximately just as much as UC horses relative to their respective populations, despite the lower stat
caps. So, I am going to leave this section largely untouched until the next scenario releases.
</p>
<Sec h={3} id="support-cards">Support Cards</Sec>
@@ -1170,7 +1283,8 @@
<Sec h={3} id="taiki-shuttle">Taiki Shuttle &amp; Curren Chan</Sec>
<p>
When building <a href="#triple-front">triple fronts</a>, you will naturally have a horse in third. This presents an
opportunity to use a horse who is normally a pace chaser as a type of gambler.
opportunity to use a horse who is normally a pace chaser, with a strong unique that requires position 3 or behind, as a type
of gambler.
</p>
<p>
Taiki Shuttle and original Curren Chan are notable for having C and B front, respectively, which is quite reasonable to fix.
@@ -1196,14 +1310,15 @@
<Sec h={3} id="sparks">Important Sparks</Sec>
<p>
Front runners are uniquely capable of making good use of all stat (blue) sparks, since guts is unusually important for them.
Power sparks are typically still best, and stamina sparks can help to forgo a stamina card or two for 2400m+ builds. Fronts
have a disproportionate representation of horses with high speed talent, which makes speed sparks a bit less valuable.
Front runners are uniquely capable of making good use of wit sparks, since wit governs early- and mid-race speed, which is
unusually important for them. Power sparks are typically still best, and stamina sparks can help to forgo a stamina card or
two for 2400m+ builds. Fronts have a disproportionate representation of horses with high speed talent, which makes speed
sparks a bit less valuable.
</p>
<p>
Pink sparks are more interesting, because &ndash; at least until other styles catch up on accels &ndash; they're less reliant
on distance S to win. Building a high affinity legacy tree requires keeping some mile and long sparks on hand. Front sparks
are better than other style sparks, especially if you do <a href="#triple-front">triple front</a> CM builds using
Pink sparks are more interesting, because &ndash; at least until other styles finish catching up on accels &ndash; they're
less reliant on distance S to win. Building a high affinity legacy tree requires keeping some mile and long sparks on hand.
Front sparks are better than other style sparks, especially if you do <a href="#triple-front">triple front</a> CM builds using
<a href="#taiki-shuttle">fixed pace chasers</a>.
</p>
<p>Front runners are more reliant than other styles on getting certain skills from inspiration:</p>
@@ -1221,7 +1336,7 @@
<li>
<Skill skill={skills.ignitedWIT} /> is the stronger lane combo piece. The only sources of it are inheritance and Unity Cup; which
is to say, the source of it is inheritance. When searching databases for borrows, looking for Ignited WIT is a shortcut for listing
good front runner legacies, since it's only valuable to fronts and somewhat hard to get even when you focus on it.
good front runner legacies, since it's only valuable to fronts.
</li>
<li>
Almost all green skills are valuable to inherit at one point or another as GW triggers.
@@ -1236,12 +1351,17 @@
</li>
<li>
All generic speed skills that aren't strictly late race are good inherits.
<Skill skill={skills.risky} /> is the best one by ratio of strength to difficulty to acquire.
<Skill skill={skills.risky} /> is the best one by ratio of strength to difficulty to acquire; if you aren't forced to borrow Smart
Falcon, you can get the gold version of this from Nakayama Festa Wit.
<Skill skill={skills.thh} />, <Skill skill={skills.pto} />, <Skill skill={skills.ramp} />, and <Skill
skill={skills.cornerAdept}
/> are also strong, but available as hints or chain skills from some usable-to-good cards.
</li>
<li>Obviously, getting front-runner-specific skills from inheritance makes them cheaper and more consistent to get.</li>
<li>
Getting front-runner-specific skills from inheritance has become especially important now that reworked UC doesn't give
skills according to aptitudes. This is a significant reason for MANT to remain the parenting scenario, arguably even more so
than the higher affinity potential.
</li>
</ul>
<Sec h={3} id="parents">Parents</Sec>
@@ -1270,7 +1390,8 @@
</li>
<li>
Mayano Top Gun doesn't have an actual front runner variant (until Halloween Mayano in 2028 or so), but she acts like a front
runner for affinity. In particular, Maya and Kitasan is the highest base compatibility pair currently in the game.
runner for affinity. In particular, Maya and Kitasan is among the highest base compatibility pairs currently in the game.
(They were #1 prior to the 1 Jul balance patch, but now that spot goes to Tamamo Cross and Inari One.)
</li>
<li>
TM Opera O is technically a pace chaser parent, but she still has good compatibility with most front runners, and her unique
@@ -1286,6 +1407,37 @@
<Sec h={2} id="cm">My CM Teams</Sec>
<Sec h={3} id="cm16">CM16 &ndash; Leo Cup (Sprinters Stakes)</Sec>
<p>
The <a href="#7-1">1 Jul balance patch</a> redefined everything for everyone, and front runners are suddenly actually quite bad.
I guess the stakes are lower for my triple front mandate.
</p>
<p>
An additional interesting detail is that this track has no straights before late race. That means straight velos are much less
important.
</p>
<p>
I had some amazing luck with support card pulls and got both Maruzensky and Nakayama Festa to MLB in 400. Are these runs what
it feels like to not be F2P?
</p>
<ol class="mb-4 list-decimal pl-4">
<li>Suzuka gets to be a runaway again!</li>
<li>Chocobon's Sprint C is easy to fix!</li>
<li>
Taiki Shuttle has a very-nearly-perfect 3-4 unique, which will be great with her as a front runner. Given her lack of
relevant built-in skills, I'm building her in MANT.
</li>
</ol>
<p>
This CM is interesting as the first opportunity to use multiple runaways and not have them be vaporized by spot struggle.
However, after getting a high roll with an S Sprint Palmer, I found that using two runaways was actually pretty bad. Even with
1300 power, <Skill skill={skills.gw} mention />, and <Skill skill={skills.ttl} mention />, runaways spend over two thirds of
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>
<Sec h={3} id="cm15">CM15 &ndash; Cancer Cup (Takarazuka Kinen)</Sec>
<p>
Kitasan's unique ends about a sixth of a second before late race starts, which means massive carryover potential (although
@@ -1309,6 +1461,14 @@
ace, and that's what Chocobon does best.
</li>
</ol>
<p>Win rates are a bit messy because I experimented with a few different comps and Throne reworks. After 80:</p>
<ul class="mb-4 list-disc pl-4">
<li>VBourbon: 28-15-17-20</li>
<li>Kitasan: 12-14-11-38 (of 75)</li>
<li>Suzuka: 8-5-7-60 = 8-4-6-37 + 0-1-1-23 (replaced after 25; I ended up not using a runaway either time)</li>
<li>Seiun Sky: 0-0-2-3 (of 5)</li>
</ul>
<p>Lost finals to two COCs, one getting Pump and the other NSM, while Chocobon blew velos during accel.</p>
<Sec h={3} id="cm14">CM14 &ndash; Gemini Cup (Yasuda Kinen, NHK Mile Cup, Victoria Mile)</Sec>
<p>
+157 -50
View File
@@ -14,18 +14,9 @@
let surfaceApt = $state(race.AptitudeLevel.A);
let distanceApt = $state(race.AptitudeLevel.A);
let raceLen = $state(2000);
const raceLenType = $derived.by(() => {
if (raceLen < 1500) {
return 'Sprint';
}
if (raceLen < 1900) {
return 'Mile';
}
if (raceLen < 2500) {
return 'Medium';
}
return 'Long';
});
const styleIsFront = $derived(style === race.RunningStyle.FrontRunner || style === race.RunningStyle.Runaway);
const distanceType = $derived(race.distance(raceLen));
const raceLenType = $derived(race.Distance[distanceType]);
const spurtSpeed: Array<ComputedSeries | null> = $derived([
{ label: `Guts 1200`, y: (x) => race.spurtSpeed(x, 1200, style, distanceApt, raceLen) },
@@ -48,7 +39,7 @@
{ label: 'Pace Chaser', y: (x) => race.maxHP(raceLen, race.RunningStyle.PaceChaser, x) },
{ label: 'Late Surger', y: (x) => race.maxHP(raceLen, race.RunningStyle.LateSurger, x) },
{ label: 'End Closer', y: (x) => race.maxHP(raceLen, race.RunningStyle.EndCloser, x) },
{ label: 'Runaway', y: (x) => race.maxHP(raceLen, race.RunningStyle.GreatEscape, x) },
{ label: 'Runaway', y: (x) => race.maxHP(raceLen, race.RunningStyle.Runaway, x) },
]);
const moveLane: ComputedSeries[] = [{ label: 'Move Lane Modifier', y: (x) => race.moveLaneModifier(x) }];
const uphill: ComputedSeries[] = [
@@ -66,11 +57,21 @@
},
]);
const laneChange: ComputedSeries[] = [{ label: 'Lane Change Target Speed', y: (x) => race.laneChangeSpeed(x) }];
const conservePower: Array<ComputedSeries | null> = $derived([
{ label: 'Conserved Power', y: (_raw, x) => race.conservePowerAccel(distanceType, style, x, 0, false, false) },
{ label: 'Rushed', y: (_raw, x) => race.conservePowerAccel(distanceType, style, x, 0, false, true) },
styleIsFront
? { label: 'Spot Struggled', y: (_raw, x) => race.conservePowerAccel(distanceType, style, x, 0, true, false) }
: null,
]);
const gutsSpurt: Array<ComputedSeries | null> = $derived([
{ label: `Speed 1200`, y: (x) => race.spurtSpeed(1200, x, style, distanceApt, raceLen) },
{ label: `Speed 600`, y: (x) => race.spurtSpeed(600, x, style, distanceApt, raceLen) },
{ label: `Speed 1200`, y: (x) => race.spurtSpeed(1200, x, style, distanceApt, raceLen) },
// NOTE(zeph): The label is 1600 despite the input being 1400
// for raw vs final difference.
{ label: `Speed 1600`, y: (x) => race.spurtSpeed(1400, x, style, distanceApt, raceLen) },
distanceApt !== race.AptitudeLevel.A
? { label: `${raceLenType} A, Speed 1200`, y: (x) => race.spurtSpeed(1200, x, style, race.AptitudeLevel.A, raceLen) }
? { label: `${raceLenType} A, Speed 1600`, y: (x) => race.spurtSpeed(1400, x, style, race.AptitudeLevel.A, raceLen) }
: null,
]);
const minSpeed: ComputedSeries[] = $derived([{ label: 'Minimum Speed', y: (x) => race.minSpeed(raceLen, x) }]);
@@ -82,7 +83,6 @@
];
const duelSpeed: ComputedSeries[] = [{ label: 'Dueling Speed Boost', y: (x) => race.duelSpeedMod(x) }];
const duelAccel: ComputedSeries[] = [{ label: 'Dueling Acceleration Boost', y: (x) => race.duelAccelMod(x) }];
const styleIsFront = $derived(style === race.RunningStyle.FrontRunner || style === race.RunningStyle.GreatEscape);
const secSpeed: Array<ComputedAreas | null> = $derived([
{
label: 'Early Race',
@@ -132,8 +132,7 @@
{ label: '1 of 2 or 2 of 2', y: (x) => (race.skillWitCheck(x, 2, 1) + race.skillWitCheck(x, 2, 2)) * 100 },
{ label: '2 of 2', y: (x) => race.skillWitCheck(x, 2, 2) * 100 },
{ label: '3 of 3', y: (x) => race.skillWitCheck(x, 3, 3) * 100 },
// TODO(zeph): why is this wrong?
{ label: '3 of 3 or 3 of 4', y: (x) => (race.skillWitCheck(x, 4, 3) + race.skillWitCheck(x, 3, 3)) * 100 },
{ label: '3 of 4 or 4 of 4', y: (x) => (race.skillWitCheck(x, 4, 3) + race.skillWitCheck(x, 4, 4)) * 100 },
];
const poskeep: ComputedSeries[] = [
{ label: 'Front Runner S', y: (x) => 100 * race.frontModeEnterChance(x * 1.1) },
@@ -240,39 +239,77 @@
<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>
{@render statChart(race.Stat.Speed, spurtSpeed, 'Spurt Speed (m/s)', [20, 26], { 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>
{@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={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>
{@render statChart(race.Stat.Stamina, hp, 'HP', [1000, 5000], { len: true })}
<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>
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
>.
</p>
{@render statChart(race.Stat.Power, moveLane, 'Target Speed Modifier (m/s)', [0.2, 0.5])}
{@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>
{@render statChart(race.Stat.Power, uphill, 'Target Speed Modifier (m/s)', [-2, 0])}
<Sec h={3} id="acceleration">Acceleration</Sec>
<p>Acceleration.</p>
{@render statChart(race.Stat.Power, accel, 'Acceleration (m/s²)', [0.1, 0.5], { style: true })}
<Sec
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 })}
<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>
<div class="mb-4 h-60 w-full md:h-96">
<StatChart
@@ -280,41 +317,87 @@
stat={race.Stat.Power}
y={laneChange}
yLabel="Lane Change Target Speed (CW/s)"
range={[0.01, 0.03]}
range={[0.01, 0.04]}
/>
</div>
<Sec
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>
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.
</p>
{@render statChart(race.Stat.Power, conservePower, 'Acceleration (m/s²)', [0, 0.35], { len: true, style: true })}
<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>
{@render statChart(race.Stat.Guts, gutsSpurt, 'Spurt Speed (m/s)', [20, 26], { 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>
{@render statChart(race.Stat.Guts, minSpeed, 'Target Speed (m/s)', [15.8, 18.4], { 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>
{@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>
<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.3]} />
<StatChart stat={race.Stat.Guts} y={ssDur} yLabel="Duration (s)" range={[0, 12]} />
<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]} />
</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>
<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.65]} />
<StatChart stat={race.Stat.Guts} y={duelAccel} yLabel="Acceleration Bonus (m/s²)" range={[0, 0.14]} />
<StatChart stat={race.Stat.Guts} y={duelSpeed} yLabel="Speed Bonus (m/s)" range={[0, 0.8]} />
<StatChart stat={race.Stat.Guts} y={duelAccel} yLabel="Acceleration Bonus (m/s²)" range={[0, 0.16]} />
</div>
<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>
<div class="mb-24 h-60 w-full md:mb-20 md:h-96">
<StatChart
@@ -322,7 +405,7 @@
stat={race.Stat.Wit}
yArea={secSpeed}
yLabel="Section Speed (m/s)"
range={[17.5, 22.5]}
range={[17, 23]}
plotOptions={{ color: { legend: true } }}
/>
<div class="mx-auto flex w-full md:max-w-2xl">
@@ -346,30 +429,54 @@
</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>
{@render statChart(race.Stat.Wit, downhill, 'Entry Chance (% each second)', [0, 60])}
{@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>
{@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>
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.
</p>
<p>TODO(zeph): Why is the 3 of 4 series getting values over 100%? Is it not just binom(3, 4, p) + binom(4, 4, p)?</p>
{@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>
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.
</p>
{@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>
{@render statChart(race.Stat.Wit, rushed, 'Rushed Chance (%)', [0, 30])}
</Article>
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import * as skill from '$lib/data/skill';
import * as spark from '$lib/data/spark';
import Pick from '$lib/Pick.svelte';
import { onMount } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
let sparks: spark.Spark[] = $state([]);
let skills: SvelteMap<number, skill.Skill> = new SvelteMap();
onMount(async () => {
const [sp, sk] = await Promise.all([spark.sparks(), skill.skills()]);
sparks = sp;
for (const s of sk) {
skills.set(s.skill_id, s);
}
});
let group: spark.SparkGroup | undefined = $state();
const groups = $derived(spark.byGroup(sparks));
function describe(eff: spark.Effect) {
if (eff.target === spark.Target.Skill) {
const sk = skills.get(eff.value1);
return `${sk?.name} ${spark.TARGET_NAMES[eff.target]} +${eff.value2}`;
}
return `${spark.TARGET_NAMES[eff.target]} +${eff.value1}`;
}
const curEffectGroups = $derived(group?.effects ?? []);
const curDescriptions = $derived(curEffectGroups.map((g) => g.map((e) => describe(e))));
function sparkClass(t: spark.Type | null | undefined): string[] {
if (t == null) {
return [];
}
switch (t) {
case 1:
return ['stat'];
case 2:
return ['aptitude'];
case 3:
return ['unique'];
case 5:
case 4:
case 6:
case 10:
case 8:
case 11:
case 9:
return ['white-spark'];
default:
return [];
}
}
const curClass = $derived(sparkClass(group?.type));
const key = (v: spark.SparkGroup) => ({ id: v.spark_group, name: v.name });
</script>
<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">
<Pick id="spark" items={groups} class="w-full" {key} bind:value={group} required>
{#snippet option(g)}
<span class={['spark rounded px-2 py-1', sparkClass(g.type)]}>{g.name}</span>
{/snippet}
</Pick>
</form>
<svelte:boundary>
<div
class={[
'spark mx-auto mt-8 max-w-md flex-1 flex-col rounded-md border p-2 text-center shadow-sm transition-shadow hover:shadow-md',
curClass,
]}
>
<span class="block text-xl">{group?.name}</span>
{#if group != null}
<span class="block">
{spark.TYPE_NAMES[group.type]} Spark
</span>
{/if}
<span class="block">{group?.description}</span>
</div>
<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>
<tbody>
{#each curDescriptions as s (s)}
<tr>
<td class="flex gap-2 border border-mist-400 p-1 text-center dark:border-mist-950">
{#each s as n (n)}
<span class="my-auto flex-1">{n}</span>
{/each}
</td>
</tr>
{/each}
</tbody>
</table>
{#snippet pending()}
<div>Loading data...</div>
{/snippet}
</svelte:boundary>
<div class="mx-auto mt-12 w-full max-w-4xl border-t pt-4 md:mt-8">
<p>
When a spark activates during inspiration, it has an additional random roll for one of the effects listed in the table here.
All star levels of each spark have the same possible effects. Higher star levels are more likely to give better rewards.
</p>
</div>
<style>
.white-spark {
--spark-color: light-dark(var(--color-mist-200), var(--color-mist-800));
}
.stat {
--spark-color: light-dark(var(--color-sky-200), var(--color-sky-800));
}
.aptitude {
--spark-color: light-dark(var(--color-rose-300), var(--color-rose-800));
}
.unique {
--spark-color: light-dark(var(--color-lime-400), var(--color-lime-800));
}
.spark {
background-color: var(--spark-color);
}
</style>
@@ -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 -14
View File
@@ -1,17 +1,11 @@
<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>
<h1 class="m-8 text-center text-7xl">Zenno Rob Roy &mdash; Character Tools</h1>
<p>Tools related to understanding Umamusume characters.</p>
<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>
</ul>
<h1 class="m-8 text-center text-7xl">Zenno Rob Roy &mdash; {cur.name}</h1>
<p>{cur.description}</p>
<IndexSection pages={all.PAGES.race} />
+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>
+352
View File
@@ -0,0 +1,352 @@
<script lang="ts">
import * as Plot from '@observablehq/plot';
import * as race from '$lib/race';
import * as accel from '$lib/racelib/accel';
import type { Attachment } from 'svelte/attachments';
import CalcInfo from '../CalcInfo.svelte';
let rawPower = $state(1200);
let rawGuts = $state(1200);
let rawWit = $state(1200);
let powerBonus = $state(0);
let gutsBonus = $state(0);
let witBonus = $state(0);
let style = $state(race.RunningStyle.FrontRunner);
let surfApt = $state(race.AptitudeLevel.A);
let styleApt = $state(race.AptitudeLevel.A);
let mood = $state(race.Mood.Normal);
let isCareer = $state(false);
let raceLen = $state(2000);
let surface = $state(race.Surface.Turf);
let conditions = $state(race.GroundConditions.Firm);
let slope = $state(0);
let rawSkills: accel.Skill[] = $state([]);
let skillToAdd: accel.Skill = $state({ accel: 0.2, dur: 1.2 });
const quick = [
{
name: 'GW+TTL',
skills: [
{ accel: 0.2, dur: 3 },
{ accel: 0.4, dur: 1.2 },
],
},
{
name: 'GW+EL',
skills: [
{ accel: 0.2, dur: 3 },
{ accel: 0.2, dur: 1.2 },
],
},
{ name: 'Clear', skills: [] },
];
function addSkill() {
rawSkills.push(skillToAdd);
skillToAdd = { accel: 0.2, dur: 1.2 };
}
function removeSkill(i: number) {
rawSkills.splice(i, 1);
}
function setSkillList(l: accel.Skill[]) {
rawSkills = l;
}
const phases = $derived(race.phases(raceLen));
const basePower = $derived(race.baseStat(mood, rawPower));
const adjPower = $derived(race.adjustedStat(race.Stat.Power, basePower, isCareer, surface, conditions));
const power = $derived(race.finalStat(adjPower, powerBonus));
const baseGuts = $derived(race.baseStat(mood, rawGuts));
const adjGuts = $derived(race.adjustedStat(race.Stat.Guts, baseGuts, isCareer));
const guts = $derived(race.finalStat(adjGuts, gutsBonus));
const baseWit = $derived(race.baseStat(mood, rawWit));
const adjWit = $derived(race.adjustedStat(race.Stat.Wit, baseWit, isCareer, styleApt));
const wit = $derived(race.finalStat(adjWit, witBonus));
const dashAccel = $derived(
race.acceleration(power, style, surfApt, race.Phase.EarlyRace, race.AptitudeLevel.A, slope > 0, true),
);
const postAccel = $derived(
race.acceleration(power, style, surfApt, race.Phase.EarlyRace, race.AptitudeLevel.A, slope > 0, false),
);
const minSpeed = $derived(race.minSpeed(raceLen, guts));
const baseSpeed = $derived(race.baseSpeed(raceLen));
const dashTarget = $derived(baseSpeed * 0.85);
const hillMod: [number, number] = $derived.by(() => {
if (slope > 0) {
const m = race.uphillMod(power, slope);
return [m, m];
}
if (slope < 0) {
return [0, race.downhillAccelMod(slope)];
}
return [0, 0];
});
const earlyTarget = $derived(race.sectionSpeed(raceLen, baseWit, wit, style, race.Phase.EarlyRace));
const speeds: [number, number] = $derived([earlyTarget[0] + hillMod[0], earlyTarget[1] + hillMod[1]]);
const startInstant: accel.Instant = { t: 0, x: 0, v: 3 };
function doSeggsToIt(targetSpeed: number, skills: accel.Skill[]) {
const startDash = accel.up(startInstant, dashTarget, dashAccel, skills);
const postStart = { ...last(startDash.segments).end, v: minSpeed };
const post = accel.up(postStart, targetSpeed, postAccel, startDash.skills);
const r = [...startDash.segments, ...post.segments];
return r;
}
function last<T>(l: T[]): T {
return l[l.length - 1];
}
const skills = $derived(rawSkills.map((s) => ({ ...s, dur: race.skillDuration(s.dur, raceLen) })));
const seggs = $derived([doSeggsToIt(speeds[0], skills), doSeggsToIt(speeds[1], skills)]);
const unskilledSeggs = $derived([doSeggsToIt(speeds[0], []), doSeggsToIt(speeds[1], [])]);
const distance = $derived([last(seggs[0]).end.x, last(seggs[1]).end.x]);
const time = $derived([last(seggs[0]).end.t, last(seggs[1]).end.t]);
const midTime = $derived([
time[0] + (phases[race.Phase.EarlyRace] - distance[0]) / speeds[1],
time[1] + (phases[race.Phase.EarlyRace] - distance[1]) / speeds[0],
]);
function gainLengths(fast: accel.Instant, slow: accel.Instant): number {
const m = fast.v * (slow.t - fast.t) + fast.x - slow.x;
return m / race.HORSE_LENGTH;
}
const gain = $derived([
gainLengths(last(seggs[0]).end, last(unskilledSeggs[0]).end),
gainLengths(last(seggs[1]).end, last(unskilledSeggs[1]).end),
]);
let width = $state(1);
let height = $state(1);
const plotLines = $derived([
{ t: 0, v: 3, x: 0, label: 'Slowest Speed' },
...seggs[0].map((t) => ({ t: t.end.t, v: t.end.v, x: t.end.x, label: 'Slowest Speed' })),
{ t: midTime[1], v: speeds[0], label: 'Slowest Speed' },
{ t: 0, v: 3, x: 0, label: 'Fastest Speed' },
...seggs[1].map((t) => ({ t: t.end.t, v: t.end.v, x: t.end.x, label: 'Fastest Speed' })),
{ t: midTime[1], v: speeds[1], label: 'Fastest Speed' },
{ t: 0, v: 3, x: 0, label: 'Without Skills' },
...unskilledSeggs[1].map((t) => ({ t: t.end.t, v: t.end.v, x: t.end.x, label: 'Without Skills' })),
{ t: midTime[1], v: speeds[1], label: 'Without Skills' },
]);
const makeChart: Attachment = (el) => {
$effect(() => {
el?.firstChild?.remove();
el?.append(
Plot.plot({
width,
height,
clip: true,
x: {
domain: [0, midTime[1]],
label: 'Time (s)',
line: true,
},
y: {
domain: [0, 23],
label: 'Speed (m/s)',
line: true,
},
color: { legend: true },
marks: [Plot.frame(), Plot.line(plotLines, { x: 't', y: 'v', stroke: 'label', strokeWidth: 3 })],
}),
);
});
};
</script>
<h1 class="text-4xl">Gate Calculator</h1>
<form class="mx-auto my-4 grid max-w-4xl grid-cols-1 gap-4 rounded-md p-4 shadow-sm md:grid-cols-5">
<div class="grid rounded-md text-center shadow-md ring md:col-span-5 md:grid-cols-5">
<div class="m-4">
<label for="powerStat">Power</label>
<input type="number" id="powerStat" bind:value={rawPower} class="w-full" />
</div>
<div class="m-4">
<label for="gutsStat">Guts</label>
<input type="number" id="gutsStat" bind:value={rawGuts} class="w-full" />
</div>
<div class="m-4">
<label for="witStat">Wit</label>
<input type="number" id="witStat" bind:value={rawWit} class="w-full" />
</div>
<div class="m-4">
<label for="style">Style</label>
<select id="style" required bind:value={style} class="w-full">
{#each race.RUNNING_STYLES as [name, style] (style)}
<option value={style}>{name}</option>
{/each}
</select>
</div>
<div class="m-4">
<label for="mood">Mood</label>
<select id="mood" required bind:value={mood} class="w-full">
{#each race.MOODS as mood (mood)}
<option value={mood}>{race.Mood[mood]}</option>
{/each}
</select>
</div>
<div class="m-4">
<label for="powerBonus">Power Greens</label>
<input type="number" id="powerBonus" bind:value={powerBonus} class="w-full" />
</div>
<div class="m-4">
<label for="gutsBonus">Guts Greens</label>
<input type="number" id="gutsBonus" bind:value={gutsBonus} class="w-full" />
</div>
<div class="m-4">
<label for="witBonus">Wit Greens</label>
<input type="number" id="witBonus" bind:value={witBonus} class="w-full" />
</div>
<div class="m-4">
<label for="surfaceApt">{race.Surface[surface]}</label>
<select id="surfaceApt" required bind:value={surfApt} class="w-full">
{#each race.APTITUDE_LEVELS as apt (apt)}
<option value={apt}>{race.AptitudeLevel[apt]}</option>
{/each}
</select>
</div>
<div class="m-4">
<label for="styleApt">Style</label>
<select id="styleApt" required bind:value={styleApt} class="w-full">
{#each race.APTITUDE_LEVELS as apt (apt)}
<option value={apt}>{race.AptitudeLevel[apt]}</option>
{/each}
</select>
</div>
</div>
<div class="mb-auto grid max-w-4xl grid-cols-6 rounded-md text-center shadow-md ring md:col-span-3">
<input
class="col-span-4 m-4 w-full justify-self-center-safe"
type="range"
id="raceLen"
min="1000"
max="3600"
step="100"
bind:value={raceLen}
/>
<span class="col-span-2 my-auto mb-4 pl-2 text-sm">{raceLen}m</span>
<select id="surface" bind:value={surface} class="col-span-2 m-4">
<option value={race.Surface.Turf}>Turf</option>
<option value={race.Surface.Dirt}>Dirt</option>
</select>
<select id="conditions" bind:value={conditions} class="col-span-2 m-4">
{#each race.GROUND_CONDITONS as cond (cond)}
<option value={cond}>{race.GroundConditions[cond]}</option>
{/each}
</select>
<select id="slope" bind:value={slope} class="col-span-2 m-4">
{#each [2, 1.5, 1, 0, -1, -1.5, -2] as per (per)}
<option value={per}>{per > 0 ? `Uphill +${per}` : per < 0 ? `Downhill ${per}` : 'Flat'}</option>
{/each}
</select>
</div>
<div class="mb-auto flex w-full max-w-4xl flex-col rounded-md text-center shadow-md ring md:col-span-2">
<div class="mx-4 mt-2 flex flex-row gap-2">
<span class="w-24 flex-1">Duration</span>
<span class="w-24 flex-1">Accel</span>
<span class="w-8 flex-none"><!-- filler to align other columns --></span>
</div>
{#each rawSkills as skill, i (i)}
<div class="mx-4 mt-2 flex flex-row gap-2">
<span class="my-auto w-24 flex-1">{skill.dur}s</span>
<span class="my-auto w-24 flex-1">{skill.accel}</span>
<button
type="button"
class="my-auto h-8 w-8 flex-none rounded-full bg-mist-300 text-2xl hover:cursor-pointer dark:bg-mist-900"
onclick={() => removeSkill(i)}>&times;</button
>
</div>
{/each}
<div class="mx-4 mt-2 flex flex-row gap-2">
<input type="number" class="my-auto w-24 flex-1" min="0.9" step="0.3" bind:value={skillToAdd.dur} />
<input type="number" class="my-auto w-24 flex-1" min="0" step="0.05" bind:value={skillToAdd.accel} />
<button
type="button"
class="my-auto h-8 w-8 flex-none rounded-full bg-mist-300 text-2xl hover:cursor-pointer dark:bg-mist-900"
onclick={addSkill}>+</button
>
</div>
<div class="mt-1 flex flex-row">
<span class="my-auto h-0 flex-1 border"></span>
<span class="mx-2 flex-none">Quick Setup</span>
<span class="my-auto h-0 flex-1 border"></span>
</div>
<div class="m-2 flex flex-row place-content-center-safe gap-2">
{#each quick as q (q.name)}
<button
type="button"
class="rounded-md bg-mist-300 p-2 hover:cursor-pointer dark:bg-mist-900"
onclick={() => setSkillList(q.skills)}>{q.name}</button
>
{/each}
</div>
</div>
</form>
{#snippet info(name: string, unit: string, val: number, val2?: number)}
<CalcInfo title={name} class="max-w-72 flex-1">
{#if val2 == null}
<span class="block text-xl">{val.toFixed(3)} {unit}</span>
{:else}
<span class="block text-xl">{val.toFixed(3)} &ndash; {val2.toFixed(3)} {unit}</span>
{/if}
</CalcInfo>
{/snippet}
<div class="mx-auto flex w-full flex-col place-items-center md:flex-row md:justify-center">
{@render info('Base Acceleration', 'm/s²', postAccel)}
{@render info('Target Speed', 'm/s', speeds[0], speeds[1])}
</div>
<div class="mx-auto flex w-full flex-col place-items-center md:flex-row md:justify-center">
{@render info('Accel Time', 's', time[0], time[1])}
{@render info('Accel Distance', 'm', distance[0], distance[1])}
{@render info('Time to Mid-Race', 's', midTime[0], midTime[1])}
</div>
<div class="mx-auto flex w-full flex-col place-items-center md:flex-row md:justify-center">
{@render info('Skill Gain', 'L', gain[0], gain[1])}
</div>
<div class="mx-auto mb-12 h-72 max-w-4xl" bind:clientWidth={width} bind:clientHeight={height}>
<div role="img" {@attach makeChart}>the chart seems to have didn't</div>
</div>
{#snippet seggsTable(name: string, s: accel.Travel[])}
<table class="table-fixed border">
<caption class="text-xl">{name}</caption>
<thead>
<tr class="py-2">
<th class="px-2" scope="col">Time</th>
<th class="px-2" scope="col">Accel</th>
<th class="px-2" scope="col">Distance</th>
<th class="px-2" scope="col">Speed</th>
</tr>
</thead>
<tbody>
{#each s as { start, end, a } (start.t)}
<tr class="even:bg-mist-300 dark:even:bg-mist-900">
<td class="px-4 text-center">{end.t.toFixed(3)}</td>
<td class="px-4 text-center">{a.toFixed(3)}</td>
<td class="px-4 text-center">{end.x.toFixed(3)}</td>
<td class="px-4 text-center">{end.v.toFixed(3)}</td>
</tr>
{/each}
</tbody>
</table>
{/snippet}
<div class="mx-auto mt-8 grid max-w-4xl grid-cols-1 gap-8 md:grid-cols-2">
{@render seggsTable(slope < 0 ? 'Fastest Speed (Downhill)' : 'Fastest Speed', seggs[1])}
{@render seggsTable('Slowest Speed', seggs[0])}
</div>
<div class="mx-auto mt-12 w-full max-w-4xl border-t pt-4 md:mt-8">
<p>
This calculator integrates acceleration analytically for efficiency, but the game uses a finite step approximation. The
difference has some implications:
</p>
<ul class="ml-4 list-disc">
<li>Late starts are dramatically more punishing in the game, as acceleration is not applied for the first frame.</li>
<li>
Start dash always completes a single frame at the dash target speed. This calculator finds the exact moment start dash
target speed would be reached and continues integrating from there, leading to a slightly higher speed.
</li>
</ul>
</div>
+3 -11
View File
@@ -30,7 +30,7 @@
const careerMod = $derived(isCareer ? 400 : 0);
const powerStat = $derived(rawPower + careerMod);
const gutsStat = $derived(rawGuts + careerMod);
const style = $derived(isRunaway ? RunningStyle.GreatEscape : RunningStyle.FrontRunner);
const style = $derived(isRunaway ? RunningStyle.Runaway : RunningStyle.FrontRunner);
const phases = [Phase.EarlyRace, Phase.MidRace, Phase.LateRace] as const;
const accel = $derived(phases.map((p) => acceleration(powerStat, style, surfApt, p)));
@@ -164,21 +164,13 @@
{/each}
</div>
<div class="mx-auto grid h-96 grid-cols-1 py-4 md:grid-cols-2">
<StatChart
class="flex-1"
stat={Stat.Guts}
y={ssY}
yLabel="Lengths Gained"
range={[0, 1.5, 2.5]}
xRule={gutsStat}
yRule={ssYRule}
/>
<StatChart class="flex-1" stat={Stat.Guts} y={ssY} yLabel="Lengths Gained" range={[0, 2]} xRule={gutsStat} yRule={ssYRule} />
<StatChart
class="flex-1"
stat={Stat.Power}
y={lcY}
yLabel="Lengths Gained"
range={[0, 1.5, 2.5]}
range={[0, 1.1]}
xRule={powerStat}
yRule={lcYRule}
/>
+6 -6
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { HORSE_LENGTH, speedGain } from '$lib/race';
import type { Snippet } from 'svelte';
import CalcInfo from '../CalcInfo.svelte';
interface Props {
children: Snippet;
@@ -21,13 +22,12 @@
const text = $derived(gain.map(fmtp).join(' '));
</script>
<div
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"
>
<div class="block">{@render children()}</div>
<span class="block text-xl">{text} L</span>
<CalcInfo title={children} class="max-w-80 flex-1">
{text} L
{#snippet additional()}
<div class="flex flex-row">
<span class="flex-1 text-xs">{fmtp(speed)} m/s</span>
<span class="flex-1 text-xs">{dur.toFixed(3)} s</span>
</div>
</div>
{/snippet}
</CalcInfo>
+64 -61
View File
@@ -1,61 +1,74 @@
<script lang="ts">
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 CalcInfo from '../CalcInfo.svelte';
const aptsList = Object.entries(AptitudeLevel).filter(([, val]) => typeof val === 'number');
const stylesList = [
['Front Runner', RunningStyle.FrontRunner],
['Pace Chaser', RunningStyle.PaceChaser],
['Late Surger', RunningStyle.LateSurger],
['End Closer', RunningStyle.EndCloser],
['Great Escape', RunningStyle.GreatEscape],
] as const;
const skillSpeeds = [0.45, 0.35, 0.25, 0.15] as const;
let rawSpeed: number = $state(1200);
let rawGuts: number = $state(1200);
let style: RunningStyle = $state(RunningStyle.FrontRunner);
let opponentStyle: RunningStyle = $state(RunningStyle.PaceChaser);
let distanceApt: AptitudeLevel = $state(AptitudeLevel.S);
let style: race.RunningStyle = $state(race.RunningStyle.FrontRunner);
let opponentStyle: race.RunningStyle = $state(race.RunningStyle.PaceChaser);
let distanceApt: race.AptitudeLevel = $state(race.AptitudeLevel.S);
let raceLen: number = $state(2000);
let isCareer: boolean = $state(false);
const careerMod = $derived(isCareer ? 400 : 0);
const speedStat = $derived(rawSpeed + careerMod);
const gutsStat = $derived(rawGuts + careerMod);
const speed = $derived(spurtSpeed(speedStat, gutsStat, style, distanceApt, raceLen));
const speed = $derived(race.spurtSpeed(speedStat, gutsStat, style, distanceApt, raceLen));
const duelBonus = $derived(race.duelSpeedMod(gutsStat));
const sProf = $derived([
inverseSpurtSpeed(speed, gutsStat, RunningStyle.FrontRunner, AptitudeLevel.S, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.PaceChaser, AptitudeLevel.S, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.LateSurger, AptitudeLevel.S, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.EndCloser, AptitudeLevel.S, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.GreatEscape, AptitudeLevel.S, raceLen) - careerMod,
race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.FrontRunner, race.AptitudeLevel.S, raceLen) - careerMod,
race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.PaceChaser, race.AptitudeLevel.S, raceLen) - careerMod,
race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.LateSurger, race.AptitudeLevel.S, raceLen) - careerMod,
race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.EndCloser, race.AptitudeLevel.S, raceLen) - careerMod,
race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.Runaway, race.AptitudeLevel.S, raceLen) - careerMod,
]);
const aProf = $derived([
inverseSpurtSpeed(speed, gutsStat, RunningStyle.FrontRunner, AptitudeLevel.A, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.PaceChaser, AptitudeLevel.A, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.LateSurger, AptitudeLevel.A, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.EndCloser, AptitudeLevel.A, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.GreatEscape, AptitudeLevel.A, raceLen) - careerMod,
race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.FrontRunner, race.AptitudeLevel.A, raceLen) - careerMod,
race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.PaceChaser, race.AptitudeLevel.A, raceLen) - careerMod,
race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.LateSurger, race.AptitudeLevel.A, raceLen) - careerMod,
race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.EndCloser, race.AptitudeLevel.A, raceLen) - careerMod,
race.inverseSpurtSpeed(speed, gutsStat, race.RunningStyle.Runaway, race.AptitudeLevel.A, raceLen) - careerMod,
]);
const skillProf = $derived(
skillSpeeds.map((v) => [v, inverseSpurtSpeed(speed + v, gutsStat, opponentStyle, AptitudeLevel.S, raceLen) - careerMod]),
skillSpeeds.map((v) => [
v,
race.inverseSpurtSpeed(speed + v, gutsStat, opponentStyle, race.AptitudeLevel.S, raceLen) - careerMod,
]),
);
const y: Array<ComputedSeries | null> = $derived([
{ label: 'Aptitude S', y: (x) => spurtSpeed(x, gutsStat, style, AptitudeLevel.S, raceLen) },
{ label: 'Aptitude A', y: (x) => spurtSpeed(x, gutsStat, style, AptitudeLevel.A, raceLen) },
distanceApt < AptitudeLevel.A
? { label: `Aptitude ${AptitudeLevel[distanceApt]}`, y: (x) => spurtSpeed(x, gutsStat, style, distanceApt, raceLen) }
const speedChart: Array<ComputedSeries | null> = $derived([
{ label: 'Aptitude S', y: (x) => race.spurtSpeed(x, gutsStat, style, race.AptitudeLevel.S, raceLen) },
{
label: `Aptitude ${race.AptitudeLevel[distanceApt]} + Dueling`,
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,
]);
const range: [number, number] = $derived([
spurtSpeed(200, gutsStat, RunningStyle.GreatEscape, AptitudeLevel.C, raceLen),
spurtSpeed(2000, gutsStat, RunningStyle.EndCloser, AptitudeLevel.S, raceLen),
]);
const range: [number, number] = [20, 27.5];
</script>
<h1 class="text-4xl">Spurt Speed Calculator</h1>
@@ -71,16 +84,16 @@
<div class="m-4">
<label for="style">Style</label>
<select id="style" required bind:value={style} class="w-full">
{#each stylesList as [name, style] (style)}
<option value={style}>{style === RunningStyle.GreatEscape ? 'Great Escape (Runaway)' : name}</option>
{#each race.RUNNING_STYLES as [name, style] (style)}
<option value={style}>{name}</option>
{/each}
</select>
</div>
<div class="m-4">
<label for="distanceApt">Distance Aptitude</label>
<select id="distanceApt" required bind:value={distanceApt} class="w-full">
{#each aptsList.toReversed() as [name, val] (val)}
<option value={val}>{name}</option>
{#each race.APTITUDE_LEVELS as apt (apt)}
<option value={apt}>{race.AptitudeLevel[apt]}</option>
{/each}
</select>
</div>
@@ -93,24 +106,18 @@
<input type="checkbox" id="isCareer" role="switch" bind:checked={isCareer} class="min-h-6 min-w-6 align-middle" />
</div>
</div>
<span class="mt-8 block w-full text-center text-lg">
Target spurt speed: {speed.toFixed(3)} m/s
</span>
{#each [[AptitudeLevel.A, aProf] as const, [AptitudeLevel.S, sProf] as const] as [level, inv] (level)}
<div class="mx-auto mt-8 flex max-w-3xl flex-col md:flex-row">
<CalcInfo title="Target Spurt Speed" class="flex-1">{speed.toFixed(3)} m/s</CalcInfo>
<CalcInfo title="Dueling Bonus" class="flex-1">+{duelBonus.toFixed(3)} m/s</CalcInfo>
</div>
{#each [[race.AptitudeLevel.A, aProf] as const, [race.AptitudeLevel.S, sProf] as const] as [level, inv] (level)}
<div class="mx-auto max-w-3xl">
<span class="mt-8 block w-full">
With {AptitudeLevel[level]} proficiency, the equivalent speed is
With {race.AptitudeLevel[level]} proficiency, the equivalent speed is
</span>
<div class="flex flex-col md:flex-row">
{#each stylesList as [styleName, s] (s)}
<div
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 race.RUNNING_STYLES as [styleName, s] (s)}
<CalcInfo title={styleName} class={['flex-1', { 'border-2': s === style }]}>{inv[s]}</CalcInfo>
{/each}
</div>
</div>
@@ -119,23 +126,19 @@
<span class="mt-8 block w-full">
While a speed skill is active, the equivalent speed for a distance S
<select id="opponentStyle" required bind:value={opponentStyle}>
{#each stylesList as [name, style] (style)}
<option value={style}>{style === RunningStyle.GreatEscape ? 'Great Escape (Runaway)' : name}</option>
{#each race.RUNNING_STYLES as [name, style] (style)}
<option value={style}>{style === race.RunningStyle.Runaway ? 'Great Escape (Runaway)' : name}</option>
{/each}
</select>
is
</span>
<div class="flex flex-col md:flex-row">
{#each skillProf as [v, inv] (v)}
<div class="m-2 flex-1 rounded-md border shadow-sm transition-shadow hover:shadow-md">
<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>
<CalcInfo title={`+${v.toFixed(2)}`} class="flex-1">{inv}</CalcInfo>
{/each}
</div>
</div>
<div class="mx-auto h-60 max-w-3xl place-content-center py-4 md:h-96">
<StatChart stat={Stat.Speed} {y} yLabel="Spurt Speed (m/s)" xRule={speedStat} {range} />
<div class="mx-auto grid h-96 grid-cols-1 py-4 md:grid-cols-2">
<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>
+17 -14
View File
@@ -1,6 +1,5 @@
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import { sveltekit } from '@sveltejs/kit/vite';
export default defineConfig({
@@ -9,19 +8,20 @@ export default defineConfig({
test: {
expect: { requireAssertions: true },
projects: [
{
extends: './vite.config.ts',
test: {
name: 'client',
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium', headless: true }],
},
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
exclude: ['src/lib/server/**'],
},
},
// Playwright tests are disabled because I don't feel like setting it up in NixOS.
// {
// extends: './vite.config.ts',
// test: {
// name: 'client',
// browser: {
// enabled: true,
// provider: playwright(),
// instances: [{ browser: 'chromium', headless: true }],
// },
// include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
// exclude: ['src/lib/server/**'],
// },
// },
{
extends: './vite.config.ts',
@@ -39,6 +39,9 @@ export default defineConfig({
'/api': {
target: 'http://localhost:13669',
},
'/img': {
target: 'http://localhost:13669',
},
},
},
});