Compare commits

...

62 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
zephyr 7d395b2cc7 ci: set up
ci/woodpecker/push/horsebot Pipeline was successful
2026-06-17 10:50:04 -04:00
zephyr 2b69211a6c horse: update deps 2026-06-17 09:58:02 -04:00
zephyr 67377c4690 zenno: format 2026-06-16 14:20:03 -04:00
zephyr 1d65a0ec14 zenno/doc/race: add missing chart controls
Fixes #21.
2026-06-16 13:40:16 -04:00
zephyr ad58edbfe1 zenno: make character selects searchable
Fixes #20.
2026-06-16 13:37:59 -04:00
zephyr f8e7bfbec6 zenno/doc/race: skill activation chance is not integer percent 2026-06-14 20:00:27 -04:00
zephyr 2e618b613f zenno/lib/race: constrain poskeep chances to hundredths 2026-06-14 19:05:52 -04:00
zephyr cc2b5fcfcb zenno: make stat chart tooltips attach vertically 2026-06-14 18:59:33 -04:00
zephyr 8ef3936be5 zenno/doc/race: fix mobile 2026-06-14 18:57:43 -04:00
zephyr b06e4b537b zenno: link up doc/race
Fixes #16.
2026-06-14 17:55:21 -04:00
zephyr 7b4cd41d3b zenno/doc/frbm: remove stat summaries
For #16.
2026-06-14 17:45:29 -04:00
zephyr 2c9434729a zenno/doc/race: interactive graphs for race mechanics
For #16.
2026-06-14 17:36:00 -04:00
zephyr 7f02925ff7 horse: define rushed chance mod and current speed up ability types
Fixes #17.
2026-06-14 12:04:07 -04:00
zephyr b217691c8a zenno/doc/lanecalc: lint fixes 2026-06-14 10:45:39 -04:00
zephyr 4bce196c09 zenno/doc/frbm: effects on other styles in cm comps
Fixes #18.
2026-06-14 10:34:34 -04:00
zephyr aa17d8ccf3 zenno: beg
Fixes #15.
2026-06-13 22:59:49 -04:00
zephyr 26a5ca4496 zenno/doc/lanecalc: typo 2026-06-13 22:12:59 -04:00
zephyr 51f942ee18 zenno: mention doc/lanecalc as appropriate
Fixes #14.
2026-06-13 22:09:09 -04:00
zephyr 2c773391f0 zenno: link up doc/lanecalc 2026-06-13 22:04:49 -04:00
zephyr c66d969c82 zenno/doc/lanecalc: english manual for lanecalc
For #14.
2026-06-13 22:02:16 -04:00
zephyr bc9d722ee0 zenno/doc/frbm: cm14 results and cm15 plans 2026-06-13 13:20:54 -04:00
zephyr 6482f11620 zenno/doc/frbm: rename chariot -> rabbit
jern on the Hakuraku discord informs me that chariot is a different
style of build.

Fixes #19.
2026-06-13 12:35:07 -04:00
zephyr 75024c7c11 zenno: categorize tools 2026-06-10 14:03:42 -04:00
64 changed files with 4386 additions and 1157 deletions
+12
View File
@@ -0,0 +1,12 @@
when:
- event: push
branch: main
- event: pull_request
- event: manual
steps:
- name: test
image: golang:1.26-alpine
commands:
- go vet ./...
- go test ./...
+15
View File
@@ -0,0 +1,15 @@
when:
- event: push
branch: main
- event: pull_request
- event: manual
steps:
- name: test
image: node:24-alpine
commands:
- cd zenno
- npm ci
- npm run check
- npm run lint
- npm run test
+10 -6
View File
@@ -20,8 +20,10 @@ func _() {
_ = x[AbilityFrenzy-13] _ = x[AbilityFrenzy-13]
_ = x[AbilityAddGateDelay-14] _ = x[AbilityAddGateDelay-14]
_ = x[AbilityCurrentSpeed-21] _ = x[AbilityCurrentSpeed-21]
_ = x[AbilityCurrentSpeedUp-22]
_ = x[AbilityTargetSpeed-27] _ = x[AbilityTargetSpeed-27]
_ = x[AbilityLaneSpeed-28] _ = x[AbilityLaneSpeed-28]
_ = x[AbilityRushedChance-29]
_ = x[AbilityAccel-31] _ = x[AbilityAccel-31]
_ = x[AbilityLaneChange-35] _ = x[AbilityLaneChange-35]
} }
@@ -30,8 +32,8 @@ const (
_AbilityType_name_0 = "SpeedStaminaPowerGutsWitEnable Great Escape" _AbilityType_name_0 = "SpeedStaminaPowerGutsWitEnable Great Escape"
_AbilityType_name_1 = "VisionHPGate delay multiplier" _AbilityType_name_1 = "VisionHPGate delay multiplier"
_AbilityType_name_2 = "FrenzyAdded gate delay" _AbilityType_name_2 = "FrenzyAdded gate delay"
_AbilityType_name_3 = "Current speed" _AbilityType_name_3 = "Current speedCurrent speed"
_AbilityType_name_4 = "Target speedLane change speed" _AbilityType_name_4 = "Target speedLane change speedRushed chance"
_AbilityType_name_5 = "Acceleration" _AbilityType_name_5 = "Acceleration"
_AbilityType_name_6 = "Forced lane change" _AbilityType_name_6 = "Forced lane change"
) )
@@ -40,7 +42,8 @@ var (
_AbilityType_index_0 = [...]uint8{0, 5, 12, 17, 21, 24, 43} _AbilityType_index_0 = [...]uint8{0, 5, 12, 17, 21, 24, 43}
_AbilityType_index_1 = [...]uint8{0, 6, 8, 29} _AbilityType_index_1 = [...]uint8{0, 6, 8, 29}
_AbilityType_index_2 = [...]uint8{0, 6, 22} _AbilityType_index_2 = [...]uint8{0, 6, 22}
_AbilityType_index_4 = [...]uint8{0, 12, 29} _AbilityType_index_3 = [...]uint8{0, 13, 26}
_AbilityType_index_4 = [...]uint8{0, 12, 29, 42}
) )
func (i AbilityType) String() string { func (i AbilityType) String() string {
@@ -54,9 +57,10 @@ func (i AbilityType) String() string {
case 13 <= i && i <= 14: case 13 <= i && i <= 14:
i -= 13 i -= 13
return _AbilityType_name_2[_AbilityType_index_2[i]:_AbilityType_index_2[i+1]] return _AbilityType_name_2[_AbilityType_index_2[i]:_AbilityType_index_2[i+1]]
case i == 21: case 21 <= i && i <= 22:
return _AbilityType_name_3 i -= 21
case 27 <= i && i <= 28: return _AbilityType_name_3[_AbilityType_index_3[i]:_AbilityType_index_3[i+1]]
case 27 <= i && i <= 29:
i -= 27 i -= 27
return _AbilityType_name_4[_AbilityType_index_4[i]:_AbilityType_index_4[i+1]] return _AbilityType_name_4[_AbilityType_index_4[i]:_AbilityType_index_4[i+1]]
case i == 31: case i == 31:
+12 -2
View File
@@ -1,10 +1,20 @@
# horsebot # horsebot
Discord bot serving horse game data. Website and Discord bot serving horse game data.
Production instance is named Zenno Rob Roy, because she has read all about Umamusume and is always happy to share her knowledge and give recommendations. Production instance is named Zenno Rob Roy, because she has read all about Umamusume and is always happy to share her knowledge and give recommendations.
## Running The `-mdb` flag is mandatory for both website and Discord bot modes.
It provides data for both the horsebot API and the discord bot.
## Running the Website
If the `-public` flag is provided, its value gives the directory to serve at `/`.
The `-img` flag gives a separate directory into which images are synced from GameTora; obtain permission from Gertas prior to using it.
The images are served on `/img/`.
## Running the Discord Bot
Provide the `-token` flag pointing to a file containing the token.
The bot always uses the Gateway API. The bot always uses the Gateway API.
If the `-http` argument is provided, it will also use the HTTP API, and `-key` must also be provided. If the `-http` argument is provided, it will also use the HTTP API, and `-key` must also be provided.
+16
View File
@@ -36,6 +36,7 @@ func main() {
addr string addr string
mdbf string mdbf string
public string public string
img string
// discord // discord
tokenFile string tokenFile string
apiRoute string apiRoute string
@@ -47,6 +48,7 @@ func main() {
flag.StringVar(&addr, "http", ":13669", "`address` to bind HTTP server") flag.StringVar(&addr, "http", ":13669", "`address` to bind HTTP server")
flag.StringVar(&mdbf, "mdb", "", "`path` to master.mdb") flag.StringVar(&mdbf, "mdb", "", "`path` to master.mdb")
flag.StringVar(&public, "public", "", "`dir`ectory containing the website to serve") flag.StringVar(&public, "public", "", "`dir`ectory containing the website to serve")
flag.StringVar(&img, "img", "", "`dir`ectory to save and serve images")
flag.StringVar(&tokenFile, "token", "", "`file` containing the Discord bot token") flag.StringVar(&tokenFile, "token", "", "`file` containing the Discord bot token")
flag.StringVar(&apiRoute, "route", "/interactions/callback", "`path` to serve Discord HTTP API calls") flag.StringVar(&apiRoute, "route", "/interactions/callback", "`path` to serve Discord HTTP API calls")
flag.StringVar(&pubkey, "key", "", "Discord public key") flag.StringVar(&pubkey, "key", "", "Discord public key")
@@ -86,6 +88,17 @@ func main() {
os.Exit(1) os.Exit(1)
} }
if img != "" {
// Ensure the img directory is in fact a directory
// before we start trying to fill it out.
err := os.MkdirAll(img, 0777)
if err != nil {
slog.Error("img", slog.Any("err", err))
os.Exit(1)
}
go saveImgs(ctx, db, img)
}
var client *bot.Client var client *bot.Client
if tokenFile != "" { if tokenFile != "" {
skills, err := mdb.Skills(ctx, db) skills, err := mdb.Skills(ctx, db)
@@ -128,6 +141,9 @@ func main() {
if public != "" { if public != "" {
mux.Handle("GET /", httpmiddle.Compress(5)(http.FileServerFS(os.DirFS(public)))) mux.Handle("GET /", httpmiddle.Compress(5)(http.FileServerFS(os.DirFS(public))))
} }
if img != "" {
mux.Handle("GET /img/", http.StripPrefix("/img", http.FileServerFS(os.DirFS(img))))
}
mux.Handle("GET /api/global/affinity", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.AffinitySummary))) mux.Handle("GET /api/global/affinity", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.AffinitySummary)))
mux.Handle("GET /api/global/affinity-detail", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.AffinityDetail))) mux.Handle("GET /api/global/affinity-detail", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.AffinityDetail)))
mux.Handle("GET /api/global/character", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.Characters))) mux.Handle("GET /api/global/character", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.Characters)))
+173
View File
@@ -0,0 +1,173 @@
package main
import (
"context"
"errors"
"fmt"
"image"
"image/png"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"github.com/gen2brain/avif"
"golang.org/x/image/draw"
"golang.org/x/sync/errgroup"
"zombiezen.com/go/sqlite"
"zombiezen.com/go/sqlite/sqlitex"
)
func saveImgs(ctx context.Context, db *sqlitex.Pool, out string) {
skill, err := skillIconIDs(ctx, db)
if err != nil {
slog.Error("skills", slog.Any("err", err))
}
chara, err := charaIDs(ctx, db)
if err != nil {
slog.Error("characters", slog.Any("err", err))
}
rank, err := rankIDs(ctx, db)
if err != nil {
slog.Error("ranks", slog.Any("err", err))
}
g, ctx := errgroup.WithContext(ctx)
tick := time.NewTicker(250 * time.Millisecond)
idouts(ctx, g, tick, skill, filepath.Join(out, "skill"), "https://media.gametora.com/umamusume/skills/icon/%d.png", image.Pt(64, 64), image.Pt(32, 32), image.Pt(16, 16))
idouts(ctx, g, tick, chara, filepath.Join(out, "chara"), "https://gametora.com/images/umamusume/characters/icons/chr_icon_%d.png", image.Pt(256, 256), image.Pt(64, 64), image.Pt(32, 32), image.Pt(16, 16))
idouts(ctx, g, tick, rank, filepath.Join(out, "rank"), "https://media.gametora.com/umamusume/ui/rank/%02d.png", image.Pt(128, 128), image.Pt(64, 64), image.Pt(32, 32), image.Pt(16, 16))
idouts(ctx, g, tick, rank, filepath.Join(out, "rank", "simple"), "https://media.gametora.com/umamusume/ui/rank/simple/%02d.png", image.Pt(50, 50), image.Pt(32, 32), image.Pt(16, 16))
if err := g.Wait(); err != nil {
slog.Error("output", slog.Any("err", err))
}
}
// load scans all results of sql and appends them to r.
func load[T any](ctx context.Context, db *sqlitex.Pool, r []T, sql string, row func(*sqlite.Stmt) T) ([]T, error) {
conn, err := db.Take(ctx)
defer db.Put(conn)
if err != nil {
return nil, err
}
stmt, err := conn.Prepare(sql)
if err != nil {
return nil, err
}
for {
ok, err := stmt.Step()
if err != nil {
return r, err
}
if !ok {
break
}
r = append(r, row(stmt))
}
return r, err
}
func skillIconIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) {
return load(ctx, db, nil, "SELECT DISTINCT icon_id FROM skill_data", func(s *sqlite.Stmt) int32 {
return s.ColumnInt32(0)
})
}
func charaIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) {
return load(ctx, db, nil, "SELECT id FROM chara_data", func(s *sqlite.Stmt) int32 {
return s.ColumnInt32(0)
})
}
func rankIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) {
return load(ctx, db, nil, "SELECT id-1 FROM single_mode_rank", func(s *sqlite.Stmt) int32 {
return s.ColumnInt32(0)
})
}
var pngenc = &png.Encoder{CompressionLevel: png.BestCompression}
func idouts(ctx context.Context, g *errgroup.Group, tick *time.Ticker, ids []int32, p, urlf string, sizes ...image.Point) {
for _, id := range ids {
g.Go(func() error {
n := strconv.Itoa(int(id))
p := filepath.Join(p, n)
_, err := os.Stat(p)
if !errors.Is(err, os.ErrNotExist) {
slog.DebugContext(ctx, "exists", slog.String("path", p))
return nil
}
if err := os.MkdirAll(p, 0777); err != nil {
return err
}
url := fmt.Sprintf(urlf, id)
select {
case <-ctx.Done():
return ctx.Err()
case <-tick.C: // continue on
}
img, err := dlimg(ctx, url)
if err != nil {
return err
}
slog.InfoContext(ctx, "resize", slog.String("url", url))
rs := resize(img, sizes...)
for _, r := range rs {
g.Go(func() error {
m := r.Bounds().Max
o := filepath.Join(p, fmt.Sprintf("%dx%d.png", m.X, m.Y))
slog.InfoContext(ctx, "write", slog.String("path", o))
f, err := os.Create(o)
if err != nil {
return err
}
defer f.Close()
return pngenc.Encode(f, r)
})
g.Go(func() error {
m := r.Bounds().Max
o := filepath.Join(p, fmt.Sprintf("%dx%d.avif", m.X, m.Y))
slog.InfoContext(ctx, "write", slog.String("path", o))
f, err := os.Create(o)
if err != nil {
return err
}
defer f.Close()
return avif.Encode(f, r)
})
}
return nil
})
}
}
func dlimg(ctx context.Context, url string) (image.Image, error) {
slog.InfoContext(ctx, "download", slog.String("url", url))
ctx, stop := context.WithTimeout(ctx, 5*time.Second)
defer stop()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
slog.InfoContext(ctx, "decode", slog.String("url", url))
img, _, err := image.Decode(resp.Body)
return img, err
}
func resize(img image.Image, sizes ...image.Point) []image.Image {
rs := make([]image.Image, 0, len(sizes))
for _, sz := range sizes {
d := image.NewNRGBA(image.Rectangle{Min: image.Point{}, Max: sz})
draw.CatmullRom.Scale(d, d.Bounds(), img, img.Bounds(), draw.Src, nil)
rs = append(rs, d)
}
return rs
}
+34
View File
@@ -15,6 +15,9 @@ This file is my notes from exploring the database.
- 119 is scenario full titles (e.g. The Beginning: URA Finale), 120 is scenario descriptions, 237 is scenario names (e.g. URA Finale) - 119 is scenario full titles (e.g. The Beginning: URA Finale), 120 is scenario descriptions, 237 is scenario names (e.g. URA Finale)
- 130 is epithet names, 131 is epithet descriptions - 130 is epithet names, 131 is epithet descriptions
in the decomp, text data categories are named in `public enum MasterString.Category`.
some categories seem to be missing, though.
# succession factor (sparks) # succession factor (sparks)
tables are succession_factor and succession_factor_effect tables are succession_factor and succession_factor_effect
@@ -317,6 +320,37 @@ so, it doesn't seem like there's a particular flag that identifies maiden races,
- card_talent_hint_upgrade has costs to raise hint levels, but it's actually universal, only six rows - card_talent_hint_upgrade has costs to raise hint levels, but it's actually universal, only six rows
- single_mode_route_race is career goals (not only races) - single_mode_route_race is career goals (not only races)
- available_skill_set has starting skills including those unlocked by potential level given under need_rank (0 for pl1, 2 for pl2) - available_skill_set has starting skills including those unlocked by potential level given under need_rank (0 for pl1, 2 for pl2)
- single_mode_special_chara is scenario links
# gl
single_mode_live_song_list has the song list for lives ha ha.
this is defined from the perspective of bonuses they grant when you have them, not the shop per se.
- text_data category for song names is 210, look up by live_id; for post-concert effect text, category 208 indexed by song list id
- command_id and live_id are always equal
- level is 1 for all songs except the two instances of girls' legend u
- master_bonus_content_text_id corresponds to text_data categories 207 for pickup effect text and 209 for song name
- live_bonus_type is 1 for specialty prio, 2 for chain frequency, 3 for fb
- girls' legend u is defined twice as ids 22 and 24
costs are in single_mode_live_square.
- square_type is 1 for stat up, 2 for skill up, 3 for energy up, 4 for live
- perf_type_n is token type: 1 for dance, 2 for passion, 3 for vocals, 4 for visuals, 5 for composure
- perf_value_n is number of tokens
immediate bonuses are in single_mode_live_master_bonus.
- master_bonus_type is 1 for stat up, 2 for skill hint, 3 for energy up, 4 for song
- master_bonus_type_value is 1 for type 1, live id for type 4, 0 otherwise
- master_bonus_gain_type_n is 1 for stat, 2 for skill hint, 3 for energy, 5 for permanent training bonus
+ 1 for stat: then master_bonus_gain_value_n_1 is 1-5 for stat id or 6 for sp, n_2 is 1 for fixed or 2 for random (not actually in the game), n_3 is lower bound on gain, n_4 is 0 for fixed or upper bound for random
+ 2 for skill hint: then gain_value_n_1 is 3(*), n_2 is skill tag, n_3 is hint level
+ 3 for energy: then gain_value_n_1 is amount
+ 5 for permanent training bonus: then gain_value_n_1 is stat id (6 for sp), n_2 is amount
(*) MasterSingleModeLiveMasterBonus.SingleModeLiveMasterBonus.GainSkillHintTypeEnum defines values of Random = 1 and SkillId = 2, but they are unused in the db.
there is some ostensibly unused data.
- master bonus ids 12001-12206 grant random amounts of stats
- id 20000 corresponds to "Group Lesson" "Skill hint appropriate for aptitude"
# lobby conversations!!! # lobby conversations!!!
+19 -13
View File
@@ -3,32 +3,38 @@ module git.sunturtle.xyz/zephyr/horse
go 1.25.5 go 1.25.5
require ( require (
github.com/disgoorg/disgo v0.19.0-rc.15 github.com/disgoorg/disgo v0.19.6
github.com/go-chi/chi/v5 v5.2.5 github.com/gen2brain/avif v0.6.0
github.com/google/go-cmp v0.6.0 github.com/go-chi/chi/v5 v5.3.1
github.com/junegunn/fzf v0.67.0 github.com/google/go-cmp v0.7.0
github.com/junegunn/fzf v0.74.1
golang.org/x/image v0.44.0
golang.org/x/sync v0.22.0
zombiezen.com/go/sqlite v1.4.2 zombiezen.com/go/sqlite v1.4.2
) )
require ( require (
github.com/disgoorg/godave v0.3.0 // indirect
github.com/disgoorg/json/v2 v2.0.0 // indirect github.com/disgoorg/json/v2 v2.0.0 // indirect
github.com/disgoorg/omit v1.0.0 // indirect github.com/disgoorg/omit v1.0.0 // indirect
github.com/disgoorg/snowflake/v2 v2.0.3 // indirect github.com/disgoorg/snowflake/v2 v2.0.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.2 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/klauspost/compress v1.18.2 // indirect github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/klauspost/compress v1.19.1 // indirect
github.com/ncruces/go-strftime v0.1.9 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad // indirect github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad // indirect
golang.org/x/crypto v0.46.0 // indirect github.com/tetratelabs/wazero v1.12.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/crypto v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/tools v0.48.0 // indirect
modernc.org/libc v1.65.7 // indirect modernc.org/libc v1.74.3 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.37.1 // indirect modernc.org/sqlite v1.54.0 // indirect
) )
+56 -41
View File
@@ -1,7 +1,9 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disgoorg/disgo v0.19.0-rc.15 h1:x0NsV2gcbdjwuztsg2wYXw76p1Cpc8f6ByDrkPcfQtU= github.com/disgoorg/disgo v0.19.6 h1:1xJQt6KZgiqvBOnjuziG1gE4Iqd0aJn8DNfE6eE+0qw=
github.com/disgoorg/disgo v0.19.0-rc.15/go.mod h1:14mgXzenkJqifkDmsEgU0zI1di6jNXodwX6L8geW33A= github.com/disgoorg/disgo v0.19.6/go.mod h1:NnV63iw4lJdF1fnV0gX27XR43ZgRGqnL122svRMTgTE=
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 h1:U16yy/ARK7/aEpzjjqK1b/KaqqGHozUdeVw/DViEzQI=
github.com/disgoorg/json/v2 v2.0.0/go.mod h1:jZTBC0nIE1WeetSEI3/Dka8g+qglb4FPVmp5I5HpEfI= github.com/disgoorg/json/v2 v2.0.0/go.mod h1:jZTBC0nIE1WeetSEI3/Dka8g+qglb4FPVmp5I5HpEfI=
github.com/disgoorg/omit v1.0.0 h1:y0LkVUOyUHT8ZlnhIAeOZEA22UYykeysK8bLJ0SfT78= github.com/disgoorg/omit v1.0.0 h1:y0LkVUOyUHT8ZlnhIAeOZEA22UYykeysK8bLJ0SfT78=
@@ -10,22 +12,30 @@ github.com/disgoorg/snowflake/v2 v2.0.3 h1:3B+PpFjr7j4ad7oeJu4RlQ+nYOTadsKapJIzg
github.com/disgoorg/snowflake/v2 v2.0.3/go.mod h1:W6r7NUA7DwfZLwr00km6G4UnZ0zcoLBRufhkFWgAc4c= github.com/disgoorg/snowflake/v2 v2.0.3/go.mod h1:W6r7NUA7DwfZLwr00km6G4UnZ0zcoLBRufhkFWgAc4c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/gen2brain/avif v0.6.0 h1:/8WSgcU+IEF0jhKYsUZ/mzlziFuTeJFpIKBj2siTQps=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/junegunn/fzf v0.67.0 h1:naiOdIkV5/ZCfHgKQIV/f5YDWowl95G6yyOQqW8FeSo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/junegunn/fzf v0.67.0/go.mod h1:xlXX2/rmsccKQUnr9QOXPDi5DyV9cM0UjKy/huScBeE= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/junegunn/fzf v0.74.1 h1:rCGVdCSoOkfcfTxtxCO/vG2yceDjRlKSlv7IfCGkc/Y=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/junegunn/fzf v0.74.1/go.mod h1:hKDkO8orBWT5VLIZOL8oyhxn7mRLXdGyu+gz1Ss58pU=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMDfKzjT+DVfIS4iqknSEKtZpEcXtu6vuaasHs=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -36,43 +46,48 @@ github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad h1:qIQkSlF5vAUHxE
github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad/go.mod h1:/pA7k3zsXKdjjAiUhB5CjuKib9KJGCaLvZwtxGC8U0s= github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad/go.mod h1:/pA7k3zsXKdjjAiUhB5CjuKib9KJGCaLvZwtxGC8U0s=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= 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.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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+1 -1
View File
@@ -14,4 +14,4 @@ SELECT
FROM single_mode_scenario sc FROM single_mode_scenario sc
JOIN scenario_name n ON sc.id = n.id JOIN scenario_name n ON sc.id = n.id
JOIN scenario_title t ON sc.id = t.id JOIN scenario_title t ON sc.id = t.id
ORDER BY sc.id ORDER BY sc.sort_id
+6 -1
View File
@@ -78,6 +78,9 @@ func (a Ability) String() string {
case AbilityHP: case AbilityHP:
r = append(r, (a.Value * 100).String()...) r = append(r, (a.Value * 100).String()...)
r = append(r, '%') r = append(r, '%')
case AbilityRushedChance:
r = append(r, a.Value.String()...)
r = append(r, '%')
case AbilityGateDelay: case AbilityGateDelay:
// This skill type in particular should be × instead of +. // This skill type in particular should be × instead of +.
r = append(r[:len(r)-1], "×"...) r = append(r[:len(r)-1], "×"...)
@@ -85,7 +88,7 @@ func (a Ability) String() string {
case AbilityFrenzy, AbilityAddGateDelay: case AbilityFrenzy, AbilityAddGateDelay:
r = append(r, a.Value.String()...) r = append(r, a.Value.String()...)
r = append(r, 's') r = append(r, 's')
case AbilityCurrentSpeed, AbilityTargetSpeed, AbilityLaneSpeed: case AbilityCurrentSpeed, AbilityCurrentSpeedUp, AbilityTargetSpeed, AbilityLaneSpeed:
r = append(r, a.Value.String()...) r = append(r, a.Value.String()...)
r = append(r, " m/s"...) r = append(r, " m/s"...)
case AbilityAccel: case AbilityAccel:
@@ -160,8 +163,10 @@ const (
AbilityFrenzy AbilityType = 13 // Frenzy AbilityFrenzy AbilityType = 13 // Frenzy
AbilityAddGateDelay AbilityType = 14 // Added gate delay AbilityAddGateDelay AbilityType = 14 // Added gate delay
AbilityCurrentSpeed AbilityType = 21 // Current speed AbilityCurrentSpeed AbilityType = 21 // Current speed
AbilityCurrentSpeedUp AbilityType = 22 // Current speed
AbilityTargetSpeed AbilityType = 27 // Target speed AbilityTargetSpeed AbilityType = 27 // Target speed
AbilityLaneSpeed AbilityType = 28 // Lane change speed AbilityLaneSpeed AbilityType = 28 // Lane change speed
AbilityRushedChance AbilityType = 29 // Rushed chance
AbilityAccel AbilityType = 31 // Acceleration AbilityAccel AbilityType = 31 // Acceleration
AbilityLaneChange AbilityType = 35 // Forced lane change AbilityLaneChange AbilityType = 35 // Forced lane change
) )
+621 -566
View File
File diff suppressed because it is too large Load Diff
+16 -13
View File
@@ -20,32 +20,35 @@
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.60.1", "@sveltejs/kit": "^2.70.1",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.3",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/node": "^22.19.19", "@types/node": "^22.20.1",
"@vitest/browser-playwright": "^4.1.0", "@vitest/browser-playwright": "^4.1.0",
"d3": "^7.9.0", "d3": "^7.9.0",
"eslint": "^10.4.0", "eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.17.1", "eslint-plugin-svelte": "^3.22.0",
"globals": "^17.6.0", "globals": "^17.7.0",
"playwright": "^1.60.0", "playwright": "^1.61.1",
"prettier": "^3.8.3", "prettier": "^3.9.6",
"prettier-plugin-svelte": "^3.5.2", "prettier-plugin-svelte": "^3.5.2",
"prettier-plugin-tailwindcss": "^0.7.4", "prettier-plugin-tailwindcss": "^0.7.4",
"svelte": "^5.55.7", "svelte": "^5.56.7",
"svelte-check": "^4.4.8", "svelte-check": "^4.7.3",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.59.3", "typescript-eslint": "^8.65.0",
"vite": "^7.3.3", "vite": "^7.3.6",
"vitest": "^4.1.0", "vitest": "^4.1.0",
"vitest-browser-svelte": "^2.1.1" "vitest-browser-svelte": "^2.2.1"
}, },
"dependencies": { "dependencies": {
"@observablehq/plot": "^0.6.17", "@observablehq/plot": "^0.6.17",
"mathjs": "^15.2.0" "mathjs": "^15.2.0"
},
"allowScripts": {
"esbuild@0.28.1": true
} }
} }
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import type { SvelteHTMLElements } from 'svelte/elements';
const sizes = [256, 64, 32, 16] as const;
interface Props {
chara_id: number | null | undefined;
size: (typeof sizes)[number];
}
let { chara_id, size, ...rest }: Props & SvelteHTMLElements['picture'] = $props();
const icon = $derived(chara_id ?? 0);
const avifs = $derived(
sizes
.filter((sz) => sz >= size)
.map((sz) => `/img/chara/${icon}/${sz}x${sz}.avif ${sz / size}x`)
.join(', '),
);
const pngs = $derived(
sizes
.filter((sz) => sz >= size)
.map((sz) => `/img/chara/${icon}/${sz}x${sz}.png ${sz / size}x`)
.join(', '),
);
const src = $derived(icon !== 0 ? `/img/chara/${icon}/${sizes[0]}x${sizes[0]}.png` : `/img/skill/10011`);
</script>
{#if icon !== 0}
<picture {...rest}>
<source srcset={avifs} type="image/avif" />
<source srcset={pngs} type="image/png" />
<img {src} alt={`Icon for character ID ${chara_id}`} class="inline" width={size} height={size} loading="lazy" />
</picture>
{:else}
<div class={`w-[${size}px] h-[${size}px]`}></div>
{/if}
+17 -12
View File
@@ -1,24 +1,29 @@
<script lang="ts"> <script lang="ts">
import { type Character } from '$lib/data/character'; import type { Character } from '$lib/data/character';
import type { ClassValue } from 'svelte/elements'; import type { ClassValue } from 'svelte/elements';
import Pick from './Pick.svelte';
import CharaIcon from './CharaIcon.svelte';
interface Props { interface Props {
id: string; id: string;
characters: Character[] | null; characters: Character[] | null;
value: number; value: Character | undefined;
class?: ClassValue | null; class?: ClassValue | null;
optionClass?: ClassValue | null;
required?: boolean; required?: boolean;
} }
let { id, characters, value = $bindable(), class: className, optionClass, required = false }: Props = $props(); let { id, characters, value = $bindable(), class: className, required = false }: Props = $props();
const charas = $derived(characters ?? []);
const key = (v: Character) => ({ id: v.chara_id, name: v.name });
</script> </script>
<select {id} class={className} bind:value {required}> <Pick {id} items={charas} {key} bind:value class={className} {required}>
{#if !required} {#snippet option(c: Character)}
<option value={0} class={optionClass}></option> <span>
{/if} <CharaIcon chara_id={c.chara_id} size={32} />
{#each characters ?? [] as c (c.chara_id)} {c.name}
<option value={c.chara_id} class={optionClass}>{c.name}</option> </span>
{/each} {/snippet}
</select> </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 Ability,
type Skill, type Skill,
} from './data/skill'; } from './data/skill';
import SkillIcon from './SkillIcon.svelte';
interface Props { interface Props {
skill: Skill | null | undefined; skill: Skill | null | undefined;
@@ -24,6 +25,7 @@
group_rate: 1, group_rate: 1,
wit_check: false, wit_check: false,
activations: [], activations: [],
tags: [],
icon_id: 0, icon_id: 0,
}; };
@@ -62,9 +64,13 @@
<span class="group relative inline-block hyphens-none"> <span class="group relative inline-block hyphens-none">
<span <span
class="skill-tip invisible absolute bottom-4 -left-1/2 flex w-80 flex-col rounded-lg border px-2 text-center opacity-0 shadow-lg transition-all group-hover:visible group-hover:bottom-6 group-hover:opacity-100" class="skill-tip invisible absolute bottom-4 -left-1/2 flex min-w-80 flex-col rounded-lg border px-2 pt-2 text-center opacity-0 shadow-lg transition-all group-hover:visible group-hover:bottom-6 group-hover:opacity-100"
role="tooltip" role="tooltip"
> >
<span class="block text-xl">
<SkillIcon icon_id={skill?.icon_id} size={32} class="inline" />
{s.name}
</span>
<span class="block border-b py-1 hyphens-auto"> <span class="block border-b py-1 hyphens-auto">
{s.description} {s.description}
</span> </span>
@@ -91,5 +97,6 @@
</span> </span>
{/each} {/each}
</span> </span>
<SkillIcon icon_id={skill?.icon_id} size={16} class="inline max-h-lh align-text-bottom" />
<span class={[spanClass, 'cursor-pointer']}>{s.name}</span> <span class={[spanClass, 'cursor-pointer']}>{s.name}</span>
</span> </span>
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import type { SvelteHTMLElements } from 'svelte/elements';
const sizes = [64, 32, 16] as const;
interface Props {
icon_id: number | null | undefined;
size: (typeof sizes)[number];
}
let { icon_id, size, ...rest }: Props & SvelteHTMLElements['picture'] = $props();
const icon = $derived(icon_id ?? 0);
const avifs = $derived(
sizes
.filter((sz) => sz >= size)
.map((sz) => `/img/skill/${icon}/${sz}x${sz}.avif ${sz / size}x`)
.join(', '),
);
const pngs = $derived(
sizes
.filter((sz) => sz >= size)
.map((sz) => `/img/skill/${icon}/${sz}x${sz}.png ${sz / size}x`)
.join(', '),
);
const src = $derived(icon !== 0 ? `/img/skill/${icon}/${sizes[0]}x${sizes[0]}.png` : `/img/skill/10011`);
</script>
{#if icon !== 0}
<picture {...rest}>
<source srcset={avifs} type="image/avif" />
<source srcset={pngs} type="image/png" />
<img {src} alt={`Skill icon ID ${icon_id}`} class="inline" width={size} height={size} loading="lazy" />
</picture>
{:else}
<div class={`w-[${size}px] h-[${size}px]`}></div>
{/if}
+12 -12
View File
@@ -55,39 +55,39 @@
let width = $state(0); let width = $state(0);
let height = $state(0); let height = $state(0);
let expand = $state(false); const xLabel = $derived(`Raw ${Stat[stat]}`);
const xLabel = $derived(Stat[stat]);
const xLines = $derived([xRule].flat(1)); const xLines = $derived([xRule].flat(1));
const xMin = $derived(xRange?.[0] ?? 200); const xMin = $derived(xRange?.[0] ?? 200);
const xMax = $derived.by(() => { const xMax = $derived.by(() => {
if (xRange?.[1] != null) { if (xRange?.[1] != null) {
return xRange[1]; return xRange[1];
} }
if (expand) { return 100 * Math.ceil(Math.max(2000, ...xLines) / 100);
return 2000;
}
return 100 * Math.ceil(Math.max(1200, ...xLines) / 100);
}); });
const xVal = $derived(d3.range(xMin, xMax + 1, 5)); // +1 because d3.range does not include the max const xVal = $derived(d3.range(xMin, xMax + 1, 5)); // +1 because d3.range does not include the max
const thrX = 1200; 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 series = $derived([y].flat(1).filter((s) => s != null));
const areas = $derived([yArea].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 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(x), y2: y2(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(() => { const yRange: [number, number] = $derived.by(() => {
if (range != null) { if (range != null) {
if (range.length === 2) { if (range.length === 2) {
return range; return range;
} }
return [range[0], expand ? range[2] : range[1]]; return [range[0], range[2]];
} }
const l = d3.min(vals, ({ y }) => y) ?? 0; const l = d3.min(vals, ({ y }) => y) ?? 0;
const r = d3.max(vals, ({ y }) => y) ?? 1; const r = d3.max(vals, ({ y }) => y) ?? 1;
return [l, r]; 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) => { const makeChart: Attachment = (el) => {
$effect(() => { $effect(() => {
@@ -122,7 +122,7 @@
Plot.line(areaVals, { x: 'x', y: 'y1', stroke: 'label' }), Plot.line(areaVals, { x: 'x', y: 'y1', stroke: 'label' }),
Plot.line(areaVals, { x: 'x', y: 'y2', stroke: 'label' }), Plot.line(areaVals, { x: 'x', y: 'y2', stroke: 'label' }),
Plot.line(vals, { x: 'x', y: 'y', stroke: 'label', strokeWidth: 3 }), Plot.line(vals, { x: 'x', y: 'y', stroke: 'label', strokeWidth: 3 }),
vals.length > 0 ? Plot.tip(vals, Plot.pointerY({ x: 'x', y: 'y', stroke: 'label', className: 'plot-tip' })) : null, vals.length > 0 ? Plot.tip(vals, Plot.pointerX({ x: 'x', y: 'y', stroke: 'label', className: 'plot-tip' })) : null,
], ],
}), }),
); );
+103
View File
@@ -0,0 +1,103 @@
import { resolve } from '$app/paths';
import type { ResolvedPathname } from '$app/types';
export interface Page {
route: ResolvedPathname;
name: string;
description: string;
}
/**
* All tool and doc pages in the site.
*/
export const PAGES = {
index: [
{
route: resolve('/race'),
name: 'Race Mechanics Tools',
description: 'Tools for calculating Umamusume race characteristics.',
},
{
route: resolve('/chara'),
name: 'Character Tools',
description: 'Tools for understanding Umamusume characters.',
},
{
route: resolve('/doc'),
name: 'Documents',
description: 'Articles about Umamusume, including research and editorials.',
},
] satisfies Page[],
race: [
{
route: resolve('/race/spurt'),
name: 'Spurt Speed',
description: 'Calculate target speed in the last spurt and compare to other distance aptitudes and running styles.',
},
{
route: resolve('/race/mspeed'),
name: 'Front Runner Mechanical Speed Comparator',
description: 'Compare spot struggle and lane combo to the effects of individual skills.',
},
{
route: resolve('/race/gate'),
name: 'Gate Calculator',
description: 'Calculate the importance of acceleration effects at the starting gate.',
},
] satisfies Page[],
chara: [
{
route: resolve('/chara/affinity'),
name: 'Affinity Details',
description: 'Details of why characters have the base compatibility numbers they have.',
},
{
route: resolve('/chara/convo'),
name: 'Lobby Conversations',
description: 'Check participants in lobby conversations and get recommendations on unlocking them quickly.',
},
] satisfies Page[],
doc: [
{
route: resolve('/doc/frbm'),
name: 'Front Runner Black Magic',
description: 'Detailed analysis of how front runners interact with race mechanics.',
},
{
route: resolve('/doc/lanecalc'),
name: "Gaikokujin's Guide to Lanecalc",
description: 'English language manual for 危険回避シミュ, or Lanecalc, for precise lane combo simulation.',
},
{
route: resolve('/doc/race'),
name: 'Race Mechanics Charts',
description:
"Interactive adaptation of KuromiAK's race mechanics documentation for easier visualization of how mechanics scale.",
},
{
route: resolve('/doc/spark'),
name: 'Spark Explorer',
description: 'View all possible inheritance effects of any spark.',
},
{
route: resolve('/doc/tagged-skills'),
name: 'Tagged Skills Explorer',
description: 'View skills matching the tags internal to the game.',
},
] satisfies Page[],
} as const;
/**
* Get the page object for a route.
* @param route Route ID for the page
* @returns Matching page, or an empty object if none match
*/
export function page(route: string | null): Partial<Page> {
for (const p of Object.values(PAGES)) {
const r = p.find((v) => v.route === route);
if (r != null) {
return r;
}
}
return {};
}
+1 -1
View File
@@ -18,7 +18,7 @@ const styleMap = {
Sentou: RunningStyle.PaceChaser, Sentou: RunningStyle.PaceChaser,
Sasi: RunningStyle.LateSurger, Sasi: RunningStyle.LateSurger,
Oikomi: RunningStyle.EndCloser, Oikomi: RunningStyle.EndCloser,
Oonige: RunningStyle.GreatEscape, Oonige: RunningStyle.Runaway,
} as const; } as const;
export interface ImportUma { export interface ImportUma {
+3 -3
View File
@@ -1,11 +1,11 @@
export interface ComputedSeries { export interface ComputedSeries {
y: (x: number) => number; y: (base: number, raw: number) => number;
label: string; label: string;
} }
export interface ComputedAreas { export interface ComputedAreas {
y1: (x: number) => number; y1: (base: number, raw: number) => number;
y2: (x: number) => number; y2: (base: number, raw: number) => number;
label: string; label: string;
} }
+62
View File
@@ -48,6 +48,10 @@ export interface Skill {
* SP cost to purchase the skill, if applicable. * SP cost to purchase the skill, if applicable.
*/ */
sp_cost?: number; sp_cost?: number;
/**
* Numeric tags applied to the skill, for effects which select skill types.
*/
tags: number[];
/** /**
* Skill icon ID. * Skill icon ID.
*/ */
@@ -133,10 +137,13 @@ export enum AbilityType {
Frenzy = 13, Frenzy = 13,
AddGateDelay = 14, AddGateDelay = 14,
CurrentSpeed = 21, CurrentSpeed = 21,
CurrentSpeedWithDecel = 22,
TargetSpeed = 27, TargetSpeed = 27,
LaneSpeed = 28, LaneSpeed = 28,
RushedChance = 29,
Accel = 31, Accel = 31,
LaneChange = 35, LaneChange = 35,
ActivateRare = 37,
} }
export const ABILITY_TYPE_FORMAT: Readonly<Record<AbilityType, (v: number) => string>> = { export const ABILITY_TYPE_FORMAT: Readonly<Record<AbilityType, (v: number) => string>> = {
@@ -152,10 +159,13 @@ export const ABILITY_TYPE_FORMAT: Readonly<Record<AbilityType, (v: number) => st
[AbilityType.Frenzy]: (v) => tenThousandths(v) + 's Frenzy', [AbilityType.Frenzy]: (v) => tenThousandths(v) + 's Frenzy',
[AbilityType.AddGateDelay]: (v) => plusBonus(v) + 's Gate Delay', [AbilityType.AddGateDelay]: (v) => plusBonus(v) + 's Gate Delay',
[AbilityType.CurrentSpeed]: (v) => plusBonus(v) + ' m/s Current Speed', [AbilityType.CurrentSpeed]: (v) => plusBonus(v) + ' m/s Current Speed',
[AbilityType.CurrentSpeedWithDecel]: (v) => plusBonus(v) + ' m/s Current Speed',
[AbilityType.TargetSpeed]: (v) => plusBonus(v) + ' m/s Target Speed', [AbilityType.TargetSpeed]: (v) => plusBonus(v) + ' m/s Target Speed',
[AbilityType.LaneSpeed]: (v) => plusBonus(v) + ' CW/s Lane Change Speed', [AbilityType.LaneSpeed]: (v) => plusBonus(v) + ' CW/s Lane Change Speed',
[AbilityType.RushedChance]: (v) => plusBonus(v) + '% Rushed Chance',
[AbilityType.Accel]: (v) => plusBonus(v) + ' m/s² Acceleration', [AbilityType.Accel]: (v) => plusBonus(v) + ' m/s² Acceleration',
[AbilityType.LaneChange]: (v) => 'Target Lane = ' + tenThousandths(v) + ' CW', [AbilityType.LaneChange]: (v) => 'Target Lane = ' + tenThousandths(v) + ' CW',
[AbilityType.ActivateRare]: (v) => 'Activate Rare Skills ×' + tenThousandths(v),
} as const; } as const;
export enum AbilityValueUsage { export enum AbilityValueUsage {
@@ -329,3 +339,55 @@ export async function skillGroups(): Promise<SkillGroup[]> {
const resp = await fetch('/api/global/skill-group'); const resp = await fetch('/api/global/skill-group');
return resp.json(); return resp.json();
} }
export const TAGS = [
{ name: 'Front Runner', tag: 101 },
{ name: 'Pace Chaser', tag: 102 },
{ name: 'Late Surger', tag: 103 },
{ name: 'End Closer', tag: 104 },
{ name: 'Sprint', tag: 201 },
{ name: 'Mile', tag: 202 },
{ name: 'Medium', tag: 203 },
{ name: 'Long', tag: 204 },
{ name: 'Early Race', tag: 301 },
{ name: 'Mid Race', tag: 302 },
{ name: 'Late Race', tag: 303 },
{ name: 'Speed Stat or Velocity', tag: 401 },
{ name: 'Stamina Stat or HP', tag: 402 },
{ name: 'Power Stat or Acceleration', tag: 403 },
{ name: 'Guts Stat', tag: 404 },
{ name: 'Wit Stat or Vision', tag: 405 },
{ name: 'Debuff', tag: 406 },
{ name: 'Gate Delay', tag: 407 },
{ name: 'Turf', tag: 501 },
{ name: 'Dirt', tag: 502 },
{ name: 'Ground Condition Passive', tag: 601 },
{ name: 'Weather Passive', tag: 602 },
{ name: 'Season Passive', tag: 603 },
{ name: 'Racecourse Passive', tag: 604 },
{ name: 'Time of Day Passive', tag: 605 },
{ name: 'Exchange Race Passive', tag: 606 },
{ name: 'Distance Passive', tag: 607 },
{ name: 'Corner-Based Passive', tag: 608 },
{ name: 'Gate Position Passive', tag: 609 },
{ name: 'Ground Type Passive', tag: 610 },
{ name: 'Popularity Passive', tag: 611 },
{ name: 'Running Style Passive', tag: 612 },
{ name: "Passive Depending on Other Horses' Styles or Skills", tag: 613 },
{ name: 'Base Stat Threshold Passive', tag: 614 },
{ name: 'Mood Passive', tag: 615 },
{ name: 'URA', tag: 801 },
{ name: 'Unity Cup', tag: 802 },
{ name: 'Trackblazer', tag: 804 },
// { name: 'Our Grand Concert', tag: ? },
// { name: 'Grand Masters', tag: ? },
// { name: "Project L'Arc", tag: ? },
// { name: 'U.A.F.', tag: ? },
// { name: 'Great Food Festival', tag: ? },
// { name: 'Run, Mecha Umamusume!', tag: ? },
// { name: 'The Twinkle Legends', tag: ? },
// { name: 'Design Your Island', tag: ? },
// { name: 'Yukoma Hot Springs', tag: ? },
// { name: 'Beyond Dreams', tag: ? },
// { name: 'Tracen-Ken', tag: ? },
] as const;
+122 -6
View File
@@ -28,25 +28,141 @@ export interface Spark {
* Spark type. * Spark type.
* Roughly the spark color, with extra subdivisions for white sparks. * 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. * Possible effects applied by the spark during inspiration.
* A random element is selected from this list according to unknown * A random element is selected from this list according to unknown
* distributions, then all effects in that selection are applied. * 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. * Effects that a spark can apply.
*/ */
export interface SparkEffect { export interface Effect {
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; target: Target;
value1?: number; value1: number;
value2: number; value2?: number;
} }
export async function sparks(): Promise<Spark[]> { export async function sparks(): Promise<Spark[]> {
const resp = await fetch('/api/global/spark'); const resp = await fetch('/api/global/spark');
return resp.json(); return resp.json();
} }
export interface SparkGroup {
spark_group: number;
name: string;
description: string;
type: Type;
effects: Effect[][];
spark_ids: number[];
}
export function byGroup(sparks: Spark[]): SparkGroup[] {
const r = new Map<number, SparkGroup>();
for (const { spark_id, name, description, spark_group, type, effects } of sparks) {
let g = r.get(spark_group);
if (g == null) {
g = { spark_group, name, description, type, effects, spark_ids: [] };
r.set(spark_group, g);
}
g.spark_ids.push(spark_id);
}
return [...r.values()];
}
+5 -6
View File
@@ -1,6 +1,3 @@
import { affinity, lookup } from './data/affinity';
import { uma, type Uma } from './data/uma';
/** /**
* A legacy, or parents and grandparents. * A legacy, or parents and grandparents.
* Defined as an applicative over generic types to reduce the API surface. * Defined as an applicative over generic types to reduce the API surface.
@@ -41,9 +38,9 @@ export interface Veteran {
saddles: number[]; saddles: number[];
} }
function findUma(umas: Uma[], u: number): Uma | null { // function findUma(umas: Uma[], u: number): Uma | null {
return umas.find((v) => v.chara_card_id === u) ?? null; // return umas.find((v) => v.chara_card_id === u) ?? null;
} // }
/** /**
* Compute individual affinities for a legacy using the global region ruleset. * Compute individual affinities for a legacy using the global region ruleset.
@@ -51,6 +48,7 @@ function findUma(umas: Uma[], u: number): Uma | null {
* @param legacy Legacy veterans * @param legacy Legacy veterans
* @returns Individual affinities * @returns Individual affinities
*/ */
/*
export function globalAffinity(trainee: number, legacy: Legacy<Veteran>): Legacy<number> { export function globalAffinity(trainee: number, legacy: Legacy<Veteran>): Legacy<number> {
const t = findUma(uma.global, trainee)?.chara_id ?? 0; const t = findUma(uma.global, trainee)?.chara_id ?? 0;
const charas = mapLegacy(legacy, (u) => findUma(uma.global, u.uma)?.chara_id ?? 0); const charas = mapLegacy(legacy, (u) => findUma(uma.global, u.uma)?.chara_id ?? 0);
@@ -70,3 +68,4 @@ export function globalAffinity(trainee: number, legacy: Legacy<Veteran>): Legacy
s22, s22,
}; };
} }
*/
+515 -10
View File
@@ -31,9 +31,236 @@ export enum Mood {
Great, 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. * Running styles for strategyphase coefficients.
* Great Escape is distinguished as a separate style even though it is * Runaway is distinguished as a separate style even though it is
* mechanically identical to Front Runner. * mechanically identical to Front Runner.
*/ */
export enum RunningStyle { export enum RunningStyle {
@@ -41,7 +268,7 @@ export enum RunningStyle {
PaceChaser, PaceChaser,
LateSurger, LateSurger,
EndCloser, EndCloser,
GreatEscape, Runaway,
} }
/** /**
@@ -52,7 +279,7 @@ export const RUNNING_STYLES = [
['Pace Chaser', RunningStyle.PaceChaser], ['Pace Chaser', RunningStyle.PaceChaser],
['Late Surger', RunningStyle.LateSurger], ['Late Surger', RunningStyle.LateSurger],
['End Closer', RunningStyle.EndCloser], ['End Closer', RunningStyle.EndCloser],
['Great Escape', RunningStyle.GreatEscape], ['Runaway', RunningStyle.Runaway],
] as const; ] as const;
/** /**
@@ -93,7 +320,103 @@ export enum Phase {
LateRace, 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; return 20 - (raceLen - 2000) / 1000;
} }
@@ -107,6 +430,42 @@ const speedStrategyPhaseCoeff = [
const distanceProficiencyMod = [0.1, 0.2, 0.4, 0.6, 0.8, 0.9, 1.0, 1.05] as const; const distanceProficiencyMod = [0.1, 0.2, 0.4, 0.6, 0.8, 0.9, 1.0, 1.05] as const;
/**
* Get a horse's base target speed outside of late race.
* @param raceLen Length of the race in meters
* @param style Horse's running style
* @param phase Phase to calculate for
*/
export function baseTargetSpeed(raceLen: number, style: RunningStyle, phase: Exclude<Phase, Phase.LateRace>): number;
/**
* Get a horse's base target speed when not spurting during late race.
* @param raceLen Length of the race in meters
* @param style Horse's running style
* @param phase Phase to calculate for
* @param speedStat Final speed stat
* @param distanceApt Horse's aptitude for the distance
*/
export function baseTargetSpeed(
raceLen: number,
style: RunningStyle,
phase: Phase.LateRace,
speedStat: number,
distanceApt: AptitudeLevel,
): number;
export function baseTargetSpeed(
raceLen: number,
style: RunningStyle,
phase: Phase,
speedStat?: number,
distanceApt?: number,
): number {
const base = baseSpeed(raceLen);
const baseTarget = base * speedStrategyPhaseCoeff[style][phase];
const late =
phase === Phase.LateRace ? (math.sqrt(500 * speedStat!) as number) * distanceProficiencyMod[distanceApt!] * 0.002 : 0;
return baseTarget + late;
}
/** /**
* Calculate the range of section speed values for a horse in early or mid race. * Calculate the range of section speed values for a horse in early or mid race.
* @param raceLen Length of the race in meters * @param raceLen Length of the race in meters
@@ -236,7 +595,7 @@ const accelStrategyPhaseCoeff = {
[RunningStyle.PaceChaser]: [0.985, 1.0, 0.996], [RunningStyle.PaceChaser]: [0.985, 1.0, 0.996],
[RunningStyle.LateSurger]: [0.975, 1.0, 1.0], [RunningStyle.LateSurger]: [0.975, 1.0, 1.0],
[RunningStyle.EndCloser]: [0.945, 1.0, 0.997], [RunningStyle.EndCloser]: [0.945, 1.0, 0.997],
[RunningStyle.GreatEscape]: [1.17, 0.94, 0.956], [RunningStyle.Runaway]: [1.17, 0.94, 0.956],
} as const; } as const;
const surfaceProficiencyMod = [0.1, 0.3, 0.5, 0.7, 0.8, 0.9, 1.0, 1.05] as const; const surfaceProficiencyMod = [0.1, 0.3, 0.5, 0.7, 0.8, 0.9, 1.0, 1.05] as const;
@@ -295,6 +654,107 @@ export function deceleration(phase: Phase, pdm?: boolean, dead?: boolean): numbe
return phaseDecel[phase]; return phaseDecel[phase];
} }
/**
* Calculate a horse's lane change target speed before the first move lane point.
* @param powerStat Final power stat
* @param maxLane Max lane value on the race track in CW
* @param currentLane Current lane on the race track in CW
* @returns Lane change speed in CW/s
*/
export function laneChangeSpeed(powerStat: number, maxLane: number, currentLane: number): number;
/**
* Calculate a horse's lane change target speed during late race and final spurt phase.
* @param powerStat Final power stat
* @param order Current order on the field
* @returns Lane change speed in CW/s
*/
export function laneChangeSpeed(powerStat: number, order: number): number;
/**
* Calculate a horse's lane change target speed between the first move lane point and late race.
* @param powerStat Final power stat
* @returns Lane change speed in CW/s
*/
export function laneChangeSpeed(powerStat: number): number;
export function laneChangeSpeed(powerStat: number, maxLaneOrOrder?: number, currentLane?: number): number {
const laneMod = currentLane != null ? 1 + (currentLane / maxLaneOrOrder!) * 0.05 : 1;
const orderMod = currentLane == null ? 1 + (maxLaneOrOrder ?? 0) * 0.05 : 1;
return 0.02 * (0.3 + 0.001 * powerStat) * laneMod * orderMod;
}
/**
* Acceleration of lane change (horizontal) current speed.
*/
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
* @param gutsStat Final guts stat
* @returns Minimum speed in m/s
*/
export function minSpeed(raceLen: number, gutsStat: number): number {
return 0.85 * baseSpeed(raceLen) + Math.sqrt(200 * gutsStat) * 0.001;
}
/**
* Calculate a horse's HP consumption multiplier for late race and last spurt phase.
* @param gutsStat Final guts stat
* @returns HP consumption multiplier
*/
export function spurtHPRateMod(gutsStat: number): number {
return 1 + 200 / Math.sqrt(600 * gutsStat);
}
/** /**
* Calculate the speed boost gained from spot struggle. * Calculate the speed boost gained from spot struggle.
* @param gutsStat Final guts stat * @param gutsStat Final guts stat
@@ -304,8 +764,6 @@ export function spotStruggleSpeed(gutsStat: number): number {
return Math.pow(500 * gutsStat, 0.6) * 0.0001; 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. * Calculate the max duration of spot struggle.
* Note that spot struggle ends early if the frontmost horse in it reaches a 5m lead, * Note that spot struggle ends early if the frontmost horse in it reaches a 5m lead,
@@ -319,17 +777,44 @@ export function spotStruggleDuration(gutsStat: number, frontAptitude: AptitudeLe
return Math.sqrt(700 * gutsStat) * 0.012 * strategyProficiencyMod[frontAptitude]; return Math.sqrt(700 * gutsStat) * 0.012 * strategyProficiencyMod[frontAptitude];
} }
/**
* Calculate the target speed bonus of dueling.
* @param gutsStat Final guts stat
* @returns Modifier to target speed while dueling in m/s
*/
export function duelSpeedMod(gutsStat: number): number {
return Math.pow(200 * gutsStat, 0.708) * 0.0001;
}
/**
* Calculate the acceleration bonus of dueling.
* @param gutsStat Final guts stat
* @returns Modifier to acceleration while dueling in m/s²
*/
export function duelAccelMod(gutsStat: number): number {
return Math.pow(160 * gutsStat, 0.59) * 0.0001;
}
/** /**
* Calculate the speed modifier for running uphill. * Calculate the speed modifier for running uphill.
* Contrary to the race mechanics document, this is expressed as a negative number. * Contrary to the race mechanics document, this is expressed as a negative number.
* @param powerStat Final power stat * @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 * @returns Speed modifier for running uphill, a negative value
*/ */
export function uphillMod(powerStat: number, slopePer: number): number { export function uphillMod(powerStat: number, slopePer: number): number {
return (slopePer * -200) / powerStat; 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 * Calculate the forward speed boost given when moving lanewise while a skill
* that grants a lane change speed boost is active. * that grants a lane change speed boost is active.
@@ -389,6 +874,16 @@ export function speedGain(speedBonus: number, dur: number, accel: number | null,
return speedBonus * (dur + 0.5 * (decelTime - accelTime)); return speedBonus * (dur + 0.5 * (decelTime - accelTime));
} }
/**
* Calculate the probability of accepting a reduced spurt candidate.
* @param witStat Final wit stat
* @returns Probability per candidate to accept
*/
export function reducedSpurtChance(witStat: number): number {
// 1900 wit style S exceeds 100% chance if we don't explicitly cap.
return Math.min(0.15 + 0.0005 * witStat, 1);
}
/** /**
* Calculate the chance to enter downhill accel mode each second while running downhill. * Calculate the chance to enter downhill accel mode each second while running downhill.
* @param witStat Final wit stat, including style aptitude modifier * @param witStat Final wit stat, including style aptitude modifier
@@ -404,7 +899,7 @@ export function downhillAccelEnterChance(witStat: number): number {
* @returns Probability each eligible tick to enter speed-up or overtake mode * @returns Probability each eligible tick to enter speed-up or overtake mode
*/ */
export function frontModeEnterChance(witStat: number): number { export function frontModeEnterChance(witStat: number): number {
return 0.2 * math.log10(witStat) - 0.2; return 0.2 * math.log10(witStat * 0.1);
} }
/** /**
@@ -413,5 +908,15 @@ export function frontModeEnterChance(witStat: number): number {
* @returns Probability each eligible tick to enter pace-up mode * @returns Probability each eligible tick to enter pace-up mode
*/ */
export function paceUpEnterChance(witStat: number): number { export function paceUpEnterChance(witStat: number): number {
return 0.15 * math.log10(witStat) - 0.15; return 0.15 * math.log10(witStat * 0.1);
}
/**
* Calculate the chance for a horse to become rushed during a given race.
* @param witStat Final wit stat, including style aptitude modifier
* @returns Probability to become rushed during the race
*/
export function rushedChance(witStat: number): number {
const r = 6.5 / Math.log10(0.1 * witStat + 1);
return Math.ceil(r * r) / 100;
} }
+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>;
}
+56
View File
@@ -0,0 +1,56 @@
const WORD_BOUNDARY = " ,!?/-+();#○☆♡'=♪∀゚∴";
function score(s: string, tt: string): number {
let k: number | undefined;
let r = 0;
let run = 0;
for (const c of s) {
const j = tt.indexOf(c, k);
// If the character isn't in the string, there's a major penalty.
if (j < 0) {
// The penalty scales with run length, on the assumption that we're
// typing something else.
// Really this should scale with the longest current run among all
// search terms, but that's infeasible to implement.
r -= 6 + run * run;
run = 0;
continue;
}
run++;
// Characters at word boundaries get extra score.
if (j == 0 || WORD_BOUNDARY.includes(tt[j - 1])) {
r += 2;
}
// As do characters that *are* word boundaries.
if (WORD_BOUNDARY.includes(c)) {
r += 2;
}
// And runs of matches scale with run length.
if (j === k) {
r += (run + 1) * (run + 1);
} else {
run = 0;
}
k = j + 1;
}
return r;
}
/**
* Fuzzy string search.
* @param sub Substring to search for
* @param terms Iterable of values to search among
* @param map Mapping from term to string to search
* @returns Matching terms in decreasing match quality order
*/
export function stringsearch<T>(sub: string, terms: Iterable<T>, map: (t: T) => string): T[] {
const s = sub.toLocaleLowerCase();
const scored: [T, number][] = [];
for (const t of terms) {
const sc = score(s, map(t).toLocaleLowerCase());
if (sc >= 0) {
scored.push([t, sc]);
}
}
return scored.sort(([, a], [, b]) => b - a).map(([t]) => t);
}
@@ -0,0 +1,14 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
onMount(() => {
goto(resolve('/chara/affinity'), { replaceState: true });
});
</script>
<p class="mt-8 block w-full text-center text-4xl">
<a href={resolve('/chara/affinity')}>This page has moved.</a>
</p>
<p class="mt-8 block w-full text-center">You should be redirected momentarily.</p>
@@ -0,0 +1,14 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
onMount(() => {
goto(resolve('/chara/convo'), { replaceState: true });
});
</script>
<p class="mt-8 block w-full text-center text-4xl">
<a href={resolve('/chara/convo')}>This page has moved.</a>
</p>
<p class="mt-8 block w-full text-center">You should be redirected momentarily.</p>
@@ -0,0 +1,14 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
onMount(() => {
goto(resolve('/race/mspeed'), { replaceState: true });
});
</script>
<p class="mt-8 block w-full text-center text-4xl">
<a href={resolve('/race/mspeed')}>This page has moved.</a>
</p>
<p class="mt-8 block w-full text-center">You should be redirected momentarily.</p>
@@ -0,0 +1,14 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
onMount(() => {
goto(resolve('/race/spurt'), { replaceState: true });
});
</script>
<p class="mt-8 block w-full text-center text-4xl">
<a href={resolve('/race/spurt')}>This page has moved.</a>
</p>
<p class="mt-8 block w-full text-center">You should be redirected momentarily.</p>
+26 -9
View File
@@ -1,14 +1,31 @@
<script lang="ts"> <script lang="ts">
import './layout.css'; import './layout.css';
import favicon from '$lib/assets/favicon.png'; import favicon from '$lib/assets/favicon.png';
import * as all from '$lib/all';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { affinity } from '$lib/data/affinity'; import type { LayoutProps } from './$types';
import { page } from '$app/state';
let { children } = $props(); let { children }: LayoutProps = $props();
const cur = $derived(all.page(page.route.id));
const title = $derived(cur.name);
const desc = $derived(
cur.description ??
"She's read all about Umamusume, and she's always happy to share her knowledge and give recommendations! Tools and editorials for Umamusume optimization.",
);
</script> </script>
<svelte:head> <svelte:head>
{#if title != null}
<title>Zenno Rob Roy &ndash; {title}</title>
{:else}
<title>Zenno Rob Roy</title> <title>Zenno Rob Roy</title>
{/if}
<meta name="description" content={desc} />
<meta name="color-scheme" content="light dark" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<meta name="robots" content="noimageindex" />
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
</svelte:head> </svelte:head>
@@ -19,16 +36,14 @@
</span> </span>
<span class="flex-1 text-center"> <span class="flex-1 text-center">
<a href={resolve('/')} class="mx-8 my-1 block font-semibold md:hidden">Zenno Rob Roy</a> <a href={resolve('/')} class="mx-8 my-1 block font-semibold md:hidden">Zenno Rob Roy</a>
<a href={resolve('/spurt')} class="mx-8 my-1 inline-block">Spurt Speed</a> <a href={resolve('/race')} class="mx-8 my-1 inline-block">Race Tools</a>
<a href={resolve('/mspeed')} class="mx-8 my-1 inline-block">Mechanical Speed</a> <a href={resolve('/chara')} class="mx-8 my-1 inline-block">Character Tools</a>
<a href={resolve('/affinity')} class="mx-8 my-1 inline-block">Affinity Details</a>
<a href={resolve('/convo')} class="mx-8 my-1 inline-block">Lobby Conversations</a>
<a href={resolve('/doc')} class="mx-8 my-1 inline-block">Documents</a> <a href={resolve('/doc')} class="mx-8 my-1 inline-block">Documents</a>
</span> </span>
</nav> </nav>
<div class="mx-4 grow lg:m-auto lg:max-w-7xl lg:min-w-7xl"> <main class="mx-4 grow lg:m-auto lg:max-w-7xl lg:min-w-7xl">
{@render children()} {@render children()}
</div> </main>
<footer <footer
class="inset-x-0 bottom-0 mt-12 border-t bg-mist-300 p-4 text-center text-sm md:mt-20 dark:border-none dark:bg-mist-900" class="inset-x-0 bottom-0 mt-12 border-t bg-mist-300 p-4 text-center text-sm md:mt-20 dark:border-none dark:bg-mist-900"
> >
@@ -38,6 +53,8 @@
All game data is auto-generated from the All game data is auto-generated from the
<a href="https://git.sunturtle.xyz/zephyr/horse/src/branch/main/doc/README.md" target="_blank" rel="noopener noreferrer" <a href="https://git.sunturtle.xyz/zephyr/horse/src/branch/main/doc/README.md" target="_blank" rel="noopener noreferrer"
>game's local database</a >game's local database</a
>. >.<br />
If you find this site helpful, please consider
<a href="https://ko-fi.com/zephyrtronium" target="_blank" rel="noopener noreferrer">supporting my Ko-fi</a>.
</footer> </footer>
</div> </div>
+8 -24
View File
@@ -1,27 +1,18 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import * as all from '$lib/all';
import IndexSection from '$lib/IndexSection.svelte';
</script> </script>
<h1 class="m-8 text-center text-7xl">Zenno Rob Roy</h1> <h1 class="m-8 text-center text-7xl">Zenno Rob Roy</h1>
<p>She's read all about Umamusume, and she's always happy to share her knowledge and give recommendations!</p> <p>She's read all about Umamusume, and she's always happy to share her knowledge and give recommendations!</p>
<h2 class="mt-8 mb-4 text-4xl">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"> <ul class="mb-4 list-disc pl-4">
<li>
<a href={resolve('/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('/mspeed')}>Front Runner Mechanical Speed Comparator</a> &mdash; Compare spot struggle and lane combo to the effects
of individual skills.
</li>
<li>
<a href={resolve('/affinity')}>Affinity Details</a> &mdash; Details of why characters have the base compatibility numbers they have.
</li>
<li>
<a href={resolve('/convo')}>Lobby Conversations</a> &mdash; Check participants in lobby conversations and get recommendations on unlocking
them quickly.
</li>
<li> <li>
<a href="https://discord.com/oauth2/authorize?client_id=1461931240264568994" target="_blank" rel="noopener noreferrer" <a href="https://discord.com/oauth2/authorize?client_id=1461931240264568994" target="_blank" rel="noopener noreferrer"
>Discord Bot</a >Discord Bot</a
@@ -29,12 +20,5 @@
</li> </li>
</ul> </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>
</ul>
<h2 class="my-4 text-4xl">About</h2> <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> <p>My repository of tools to fill some gaps I've felt in Umamusume optimization, as well as original research and editorials.</p>
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
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; {cur.name}</h1>
<p>{cur.description}</p>
<IndexSection pages={all.PAGES.chara} />
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import CharaPick from '$lib/CharaPick.svelte'; import CharaPick from '$lib/CharaPick.svelte';
import { AFFINITY_RELATION_DESCRIPTIONS, affinityDetail, type AffinityDetail } from '$lib/data/affinity'; import { AFFINITY_RELATION_DESCRIPTIONS, affinityDetail, type AffinityDetail } from '$lib/data/affinity';
import { character, charaNames, type Character } from '$lib/data/character'; import { character, type Character } from '$lib/data/character';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
@@ -12,21 +12,21 @@
affs = affinities; affs = affinities;
characters = charas.filter((c) => affs.some((d) => d.chara_id === c.chara_id)); characters = charas.filter((c) => affs.some((d) => d.chara_id === c.chara_id));
}); });
const names = $derived(charaNames(characters));
let charA: number = $state(1001); let charA: Character | undefined = $state();
let charB: number = $state(0); let charB: Character | undefined = $state();
let charC: number = $state(0); let charC: Character | undefined = $state();
const nameA = $derived(names.get(charA)?.en ?? `Character ${charA}`);
const nameB = $derived(names.get(charB)?.en ?? `Character ${charB}`); const charaListA = $derived(characters.filter((c) => c.chara_id != charB?.chara_id && c.chara_id != charC?.chara_id));
const nameC = $derived(names.get(charC)?.en ?? `Character ${charC}`); const charaListB = $derived(characters.filter((c) => c.chara_id != charA?.chara_id && c.chara_id != charC?.chara_id));
const charaListC = $derived(characters.filter((c) => c.chara_id != charA?.chara_id && c.chara_id != charB?.chara_id));
function groupsFor(affs: AffinityDetail[], chara: number): AffinityDetail[] { function groupsFor(affs: AffinityDetail[], chara: number): AffinityDetail[] {
return chara !== 0 ? affs.filter((d) => d.chara_id === chara) : []; return chara !== 0 ? affs.filter((d) => d.chara_id === chara) : [];
} }
const groupsA = $derived(groupsFor(affs, charA)); const groupsA = $derived(groupsFor(affs, charA?.chara_id ?? 0));
const groupsB = $derived(groupsFor(affs, charB)); const groupsB = $derived(groupsFor(affs, charB?.chara_id ?? 0));
const groupsC = $derived(groupsFor(affs, charC)); const groupsC = $derived(groupsFor(affs, charC?.chara_id ?? 0));
const allGroups = $derived.by(() => { const allGroups = $derived.by(() => {
const seen = new SvelteSet<number>(); const seen = new SvelteSet<number>();
const r = []; const r = [];
@@ -41,8 +41,7 @@
} }
return r; return r;
}); });
const selCount = $derived(1 + (charB === 0 ? 0 : 1) + (charC === 0 ? 0 : 1)); const selCount = $derived(1 + (charB == null ? 0 : 1) + (charC == null ? 0 : 1));
const duplicate = $derived(charA === charB || charA === charC || (charB !== 0 && charB === charC));
const infos = $derived( const infos = $derived(
allGroups allGroups
.map((d) => ({ .map((d) => ({
@@ -65,21 +64,19 @@
<div class="mx-auto mt-8 flex flex-col rounded-md text-center shadow-md ring md:max-w-2xl md:flex-row"> <div class="mx-auto mt-8 flex flex-col rounded-md text-center shadow-md ring md:max-w-2xl md:flex-row">
<div class="m-4 flex-1 md:mt-3"> <div class="m-4 flex-1 md:mt-3">
<label for="charA" class="hidden md:inline">Character 1</label> <label for="charA" class="hidden md:inline">Character 1</label>
<CharaPick id="charA" {characters} class="w-full" bind:value={charA} required /> <CharaPick id="charA" characters={charaListA} class="w-full" bind:value={charA} required />
</div> </div>
<div class="m-4 flex-1 md:mt-3"> <div class="m-4 flex-1 md:mt-3">
<label for="charB" class="hidden md:inline">Character 2</label> <label for="charB" class="hidden md:inline">Character 2</label>
<CharaPick id="charB" {characters} class="w-full" bind:value={charB} /> <CharaPick id="charB" characters={charaListB} class="w-full" bind:value={charB} />
</div> </div>
<div class="m-4 flex-1 md:mt-3"> <div class="m-4 flex-1 md:mt-3">
<label for="charC" class="hidden md:inline">Character 3</label> <label for="charC" class="hidden md:inline">Character 3</label>
<CharaPick id="charC" {characters} class="w-full" bind:value={charC} /> <CharaPick id="charC" characters={charaListC} class="w-full" bind:value={charC} />
</div> </div>
</div> </div>
<svelte:boundary> <svelte:boundary>
{#if duplicate} {#if selCount > 1}
<span class="mt-8 block w-full text-center text-lg">Duplicate characters always have zero affinity.</span>
{:else if selCount > 1}
<span class="mt-8 block w-full text-center text-lg">Total {selCount === 2 ? 'pair' : 'trio'} affinity: {total}</span> <span class="mt-8 block w-full text-center text-lg">Total {selCount === 2 ? 'pair' : 'trio'} affinity: {total}</span>
{/if} {/if}
<table class="mx-auto mt-8 table-fixed"> <table class="mx-auto mt-8 table-fixed">
@@ -88,12 +85,12 @@
<th class="w-32 px-2" scope="col">Group ID</th> <th class="w-32 px-2" scope="col">Group ID</th>
<th class="w-96 px-2" scope="col">Description</th> <th class="w-96 px-2" scope="col">Description</th>
{#if selCount > 1} {#if selCount > 1}
<th class="w-56 px-2" scope="col">{nameA}</th> <th class="w-56 px-2" scope="col">{charA!.name}</th>
{#if charB !== 0} {#if charB != null}
<th class="w-56 px-2" scope="col">{nameB}</th> <th class="w-56 px-2" scope="col">{charB.name}</th>
{/if} {/if}
{#if charC !== 0} {#if charC != null}
<th class="w-56 px-2" scope="col">{nameC}</th> <th class="w-56 px-2" scope="col">{charC.name}</th>
{/if} {/if}
{/if} {/if}
<th class="w-32 px-2" scope="col">Affinity</th> <th class="w-32 px-2" scope="col">Affinity</th>
@@ -110,14 +107,14 @@
{/if} {/if}
{#if selCount > 1} {#if selCount > 1}
<td class="px-4 text-center">{a ? '✓' : ''}</td> <td class="px-4 text-center">{a ? '✓' : ''}</td>
{#if charB !== 0} {#if charB != null}
<td class="px-4 text-center">{b ? '✓' : ''}</td> <td class="px-4 text-center">{b ? '✓' : ''}</td>
{/if} {/if}
{#if charC !== 0} {#if charC != null}
<td class="px-4 text-center">{c ? '✓' : ''}</td> <td class="px-4 text-center">{c ? '✓' : ''}</td>
{/if} {/if}
{/if} {/if}
<td class="px-4 text-center">{!duplicate && selCount > 1 && counted === selCount ? '+' : ''}{affinity}</td> <td class="px-4 text-center">{selCount > 1 && counted === selCount ? '+' : ''}{affinity}</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
@@ -8,19 +8,18 @@
const convoMap = $derived(byChara(conversations)); const convoMap = $derived(byChara(conversations));
const popular = $derived(groupPopulars(conversations)); const popular = $derived(groupPopulars(conversations));
let characters: Character[] = $state([]); let characters: Character[] = $state([]);
const selCharas = $derived(characters.filter((c) => convoMap.has(c.chara_id)));
const names = $derived(charaNames(characters ?? [])); const names = $derived(charaNames(characters ?? []));
const minSuggest = 8; const minSuggest = 8;
onMount(async () => { onMount(async () => {
const [convos, charas] = await Promise.all([conversation(), character()]); [conversations, characters] = await Promise.all([conversation(), character()]);
conversations = convos;
characters = charas.filter((c) => convoMap.has(c.chara_id));
}); });
let charaID = $state(1001); let chara: Character | undefined = $state();
let convo = $state(1); let convo = $state(1);
const options = $derived(convoMap.get(charaID) ?? []); const options = $derived(convoMap.get(chara?.chara_id ?? 0) ?? []);
const cur = $derived(options.find((c) => c.number === convo)); const cur = $derived(options.find((c) => c.number === convo));
const cur1Name = $derived(cur?.chara_1 && names.get(cur.chara_1)?.en); const cur1Name = $derived(cur?.chara_1 && names.get(cur.chara_1)?.en);
const cur2Name = $derived(cur?.chara_2 && names.get(cur.chara_2)?.en); const cur2Name = $derived(cur?.chara_2 && names.get(cur.chara_2)?.en);
@@ -43,7 +42,7 @@
<div class="mx-auto mt-8 flex flex-col rounded-md text-center shadow-md ring md:max-w-xl md:flex-row"> <div class="mx-auto mt-8 flex flex-col rounded-md text-center shadow-md ring md:max-w-xl md:flex-row">
<div class="m-4 flex-1 md:mt-3"> <div class="m-4 flex-1 md:mt-3">
<label for="chara" class="hidden md:inline">Character</label> <label for="chara" class="hidden md:inline">Character</label>
<CharaPick id="chara" {characters} class="w-full" bind:value={charaID} required /> <CharaPick id="chara" characters={selCharas} class="w-full" bind:value={chara} required />
</div> </div>
<div class="m-4 flex-1 md:mt-3"> <div class="m-4 flex-1 md:mt-3">
<label for="convo" class="hidden md:inline">Conversation</label> <label for="convo" class="hidden md:inline">Conversation</label>
+8 -9
View File
@@ -1,12 +1,11 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { page } from '$app/state';
import * as all from '$lib/all';
import IndexSection from '$lib/IndexSection.svelte';
const cur = $derived(all.page(page.route.id));
</script> </script>
<h1 class="m-8 text-center text-7xl">Zenno Rob Roy &mdash; Documents</h1> <h1 class="m-8 text-center text-7xl">Zenno Rob Roy &mdash; {cur.name}</h1>
<p>Articles about Umamusume, including editorials and research.</p> <p>{cur.description}</p>
<IndexSection pages={all.PAGES.doc} />
<ul class="mb-4 list-disc pl-4">
<li>
<a href={resolve('/doc/frbm')}>Front Runner Black Magic</a> &mdash; Detailed analysis of how front runners interact with race mechanics.
</li>
</ul>
+9 -5
View File
@@ -7,12 +7,13 @@
h?: 1 | 2 | 3 | 4 | 5 | 6; h?: 1 | 2 | 3 | 4 | 5 | 6;
id: string; id: string;
children: Snippet; children: Snippet;
ref?: string;
top?: string; top?: string;
toc?: boolean; toc?: boolean;
class?: ClassValue | null; class?: ClassValue | null;
} }
let { h = 1, id, children, top = '#top', toc = true, class: className }: Props = $props(); let { h = 1, id, children, ref, top = '#top', toc = true, class: className }: Props = $props();
const tag = $derived(`h${h}`); const tag = $derived(`h${h}`);
const href = $derived(`#${id}`); const href = $derived(`#${id}`);
@@ -39,9 +40,12 @@
<svelte:element this={tag} {id} class={['relative w-full', className]}> <svelte:element this={tag} {id} class={['relative w-full', className]}>
<!-- eslint-disable svelte/no-navigation-without-resolve --> <!-- eslint-disable svelte/no-navigation-without-resolve -->
<a {href}><span class="hidden md:inline">{sign}</span> {@render children()}</a> <a {href}><span class="hidden md:inline">{sign}</span> {@render children()}</a>
{#if h >= 2} <div class="absolute right-0 bottom-0 inline-block text-end align-bottom text-sm whitespace-nowrap">
<a href={top} class="absolute right-0 bottom-0 text-end align-bottom text-sm whitespace-nowrap transition-all hover:bottom-px" {#if ref != null}
>Top ▲</a <a href={ref} target="_blank" rel="noopener noreferrer">Ref &UpperRightArrow;</a>
>
{/if} {/if}
{#if h >= 2}
<a href={top} class="ml-4">Top ▲</a>
{/if}
</div>
</svelte:element> </svelte:element>
+349 -191
View File
@@ -3,6 +3,8 @@
import type { ComputedAreas, ComputedSeries, HorizontalRule } from '$lib/chart'; import type { ComputedAreas, ComputedSeries, HorizontalRule } from '$lib/chart';
import { import {
AptitudeLevel, AptitudeLevel,
conservePowerAccel,
distance,
downhillAccelEnterChance, downhillAccelEnterChance,
frontModeEnterChance, frontModeEnterChance,
HORSE_LENGTH, HORSE_LENGTH,
@@ -79,16 +81,21 @@
topRunner: allSkills.get(201271), topRunner: allSkills.get(201271),
leadersPride: allSkills.get(201272), leadersPride: allSkills.get(201272),
moxie: allSkills.get(201282), moxie: allSkills.get(201282),
shrewd: allSkills.get(201332),
eyes: allSkills.get(201441), eyes: allSkills.get(201441),
savvy: allSkills.get(201522), savvy: allSkills.get(201522),
stan: allSkills.get(201591),
lucky7: allSkills.get(201562), lucky7: allSkills.get(201562),
gw: allSkills.get(201601), gw: allSkills.get(201601),
thh: allSkills.get(201611), thh: allSkills.get(201611),
loneWolf: allSkills.get(201641), loneWolf: allSkills.get(201641),
slipstream: allSkills.get(201651), slipstream: allSkills.get(201651),
pto: allSkills.get(201661), pto: allSkills.get(201661),
headon: allSkills.get(201902),
risky: allSkills.get(202032), risky: allSkills.get(202032),
runaway: allSkills.get(202051), runaway: allSkills.get(202051),
fr: allSkills.get(202462),
burningPOW: allSkills.get(210031),
burningWIT: allSkills.get(210051), burningWIT: allSkills.get(210051),
ignitedWIT: allSkills.get(210052), ignitedWIT: allSkills.get(210052),
radiant: allSkills.get(210061), radiant: allSkills.get(210061),
@@ -172,7 +179,7 @@
{ label: 'Style S', y: (x) => downhillAccelEnterChance(x * 1.1) * 100 }, { label: 'Style S', y: (x) => downhillAccelEnterChance(x * 1.1) * 100 },
{ label: 'Style A', y: (x) => downhillAccelEnterChance(x) * 100 }, { label: 'Style A', y: (x) => downhillAccelEnterChance(x) * 100 },
]; ];
const secIsFront = $derived(secSpeedStyle === RunningStyle.FrontRunner || secSpeedStyle === RunningStyle.GreatEscape); const secIsFront = $derived(secSpeedStyle === RunningStyle.FrontRunner || secSpeedStyle === RunningStyle.Runaway);
const secSpeedSeries: Array<ComputedAreas | null> = $derived([ const secSpeedSeries: Array<ComputedAreas | null> = $derived([
{ {
label: 'Early Race', label: 'Early Race',
@@ -207,6 +214,12 @@
y2: (x) => modemean(sectionSpeed(raceLen, x, x, secSpeedStyle, Phase.MidRace)[1], 1.04, paceUpEnterChance(x)), 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> </script>
<Article> <Article>
@@ -231,6 +244,28 @@
</p> </p>
{/snippet} {/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> <Sec h={2} id="me">About Me</Sec>
<p> <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 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
@@ -255,10 +290,16 @@
>. Many of those interpretations are also informed by the exceptionally knowledgeable folks on the >. Many of those interpretations are also informed by the exceptionally knowledgeable folks on the
<a href="https://discord.gg/SyAVkbBSkx" target="_blank" rel="noopener noreferrer">GameTora Discord server</a>. I may present <a href="https://discord.gg/SyAVkbBSkx" target="_blank" rel="noopener noreferrer">GameTora Discord server</a>. I may present
some of the information from the race mechanics doc in chart form, but I will generally leave out exact mechanic numbers and some of the information from the race mechanics doc in chart form, but I will generally leave out exact mechanic numbers and
conditions; the doc is already the place for that information. conditions; the doc is already the place for that information. Some sections have "Ref &UpperRightArrow;" links that will take
you to the corresponding section of the mechanics doc.
</p> </p>
<Sec h={2} id="mechanics">Race Mechanics</Sec> <Sec
h={2}
id="mechanics"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.3q7x98goz5cu"
>Race Mechanics</Sec
>
<p> <p>
Very quick gloss of race fundamentals. Races are divided into four phases: early race, mid race, late race, and last spurt Very quick gloss of race fundamentals. Races are divided into four phases: early race, mid race, late race, and last spurt
phase. They are also divided into twenty-four equal length sections. Early race is sections 1 to 4, mid race is sections 5 to phase. They are also divided into twenty-four equal length sections. Early race is sections 1 to 4, mid race is sections 5 to
@@ -285,10 +326,7 @@
<Sec h={3} id="runaway">Runaway</Sec> <Sec h={3} id="runaway">Runaway</Sec>
<p> <p>
The skill <Skill skill={skills.runaway} /> converts front runners into the <i>Great Escape</i> running style. However, no The skill <Skill skill={skills.runaway} /> converts front runners into the Runaway running style.
player has ever uttered the words "Great Escape" when talking about Umamusume, presumably because Runaway is a much cooler
name. ("Great Escape" is a direct translation of Japanese 大逃げ <i>oonige</i>, whereas "Front Runner" is a more liberal
localization of 逃げ <i>nige</i> that technically just means "escape.")
</p> </p>
<p> <p>
Runaways are still front runners for all purposes. The difference is just different numbers for things like base speed and Runaways are still front runners for all purposes. The difference is just different numbers for things like base speed and
@@ -296,7 +334,12 @@
runners also apply to runaways. runners also apply to runaways.
</p> </p>
<Sec h={3} id="phase-speed">Phase Speed</Sec> <Sec
h={3}
id="phase-speed"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.40iug3hm8429"
>Phase Speed</Sec
>
<p> <p>
Race base speed is multiplied by the speed strategy&ndash;phase coefficient for each horse. As the name suggests, SSPC is Race base speed is multiplied by the speed strategy&ndash;phase coefficient for each horse. As the name suggests, SSPC is
different per running style and per race phase. It's the thing that makes runaways take off in early race, and the thing that different per running style and per race phase. It's the thing that makes runaways take off in early race, and the thing that
@@ -309,8 +352,8 @@
</p> </p>
<p> <p>
The late race speed difference means that, in a competitive setting, the speed stat (and, correspondingly, distance aptitude) The late race speed difference means that, in a competitive setting, the speed stat (and, correspondingly, distance aptitude)
aren't what make front runners win most of the time. Capped speed with distance S is ideal, but 1100 speed with distance A aren't what make front runners win most of the time. Capped speed with distance S is ideal, but being a hundred speed short
will likely lose only a couple races within the difference throughout a CM event. with distance A will likely lose only a couple races within the difference throughout a CM event.
</p> </p>
<Sec h={3} id="win-cons">Win Conditions</Sec> <Sec h={3} id="win-cons">Win Conditions</Sec>
@@ -354,16 +397,20 @@
mid race. mid race.
</p> </p>
<Sec h={3} id="front-modes">Speed-Up and Overtake Modes</Sec> <Sec
h={3}
id="front-modes"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.tn8irhl5bjhv"
>Speed-Up and Overtake Modes</Sec
>
<p> <p>
During the first 41.67% of the race, <i>position keep</i> is busy arranging each running style into their respective packs. During the first 41.67% of the race, <i>position keep</i> is busy arranging each running style into their respective packs.
During position keep, all horses have access to <i>running modes</i> that influence how they run. During position keep, all horses have access to <i>running modes</i> that influence how they run.
</p> </p>
<p> <p>
The running modes for front runners are speed-up (+4% target speed for first among that front type) and overtake (+5% for 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 not-first). Entering these modes requires meeting certain conditions relating to positioning. They also require passing a wit
as "solo fronts are heavily penalized." (With the 1.5 anni balance patch, we expect that solo fronts will be given more check, with the same chance for both modes.
forgiving running mode conditions.) They also require passing a wit check, with the same chance for both modes.
</p> </p>
<div class="mx-auto h-60 w-full max-w-3xl md:h-96"> <div class="mx-auto h-60 w-full max-w-3xl md:h-96">
<StatChart <StatChart
@@ -376,24 +423,34 @@
/> />
</div> </div>
<Sec h={3} id="pdm">Pace Down Mode</Sec> <Sec
h={3}
id="pdm"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.5x2q33mxvumk"
>Pace Down Mode</Sec
>
<p> <p>
The running modes for all other running styles are pace-up, which is similar to speed-up, and paced-down, which activates The running modes for all other running styles are pace-up, which is similar to speed-up, and paced-down, which activates
whenever a horse gets what their style defines as too close to first place. whenever a horse gets what their style defines as too close to first place.
</p> </p>
<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 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 > 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. of the late surgers, often near the back of the group. That's PDM.
</p> </p>
<p> <p>
On styles with PDM, early race and sometimes mid race speed skills are effectively converted from distance gain into HP 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. subject to PDM at all. Their mid race speed skills always gain distance.
</p> </p>
<Sec h={3} id="spot-struggle">Spot Struggle</Sec> <Sec
h={3}
id="spot-struggle"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.5l523bk8k3vz"
>Spot Struggle</Sec
>
<p> <p>
For each of runaways and non-runaways, there is at most one spot struggle per race. Runaways will not spot struggle with For each of runaways and non-runaways, there is at most one spot struggle per race. Runaways will not spot struggle with
non-runaways, nor vice-versa. When a spot struggle triggers, all front runnners of that type within range participate; I've non-runaways, nor vice-versa. When a spot struggle triggers, all front runnners of that type within range participate; I've
@@ -401,23 +458,70 @@
</p> </p>
<p> <p>
Spot struggle provides a target speed bonus that scales with the guts stat. If it isn't cut short, which will approximately 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> </p>
<div class="mb-4 grid h-60 w-full grid-cols-2 md:h-96"> <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={ssBoostSeries} yLabel="Speed Bonus (m/s)" range={[0, 0.4]} />
<StatChart stat={Stat.Guts} y={ssDurSeries} yLabel="Duration (s)" range={[0, 12]} /> <StatChart stat={Stat.Guts} y={ssDurSeries} yLabel="Duration (s)" range={[0, 14]} />
</div> </div>
<p> <p>
Spot struggle also greatly increases HP consumption. For normal front runners, the rate is slightly less than Rushed. For 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.) 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; 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>
<p> <p>
In medium+ races, the extra HP consumption is a serious consideration; front runners need more stamina and recoveries than In medium+ races, the extra HP consumption is a serious consideration; front runners need more stamina and recoveries than
other styles. At 1600m and shorter, the fact that Spot Struggle doesn't scale with race distance means that it can be worth other styles. At 1600m and shorter, the fact that Spot Struggle doesn't scale with race distance means that it can be worth
multiple gold speed skills in total distance gained. See the <a href={resolve('/mspeed')}>mechanical speed calculator</a> for precise multiple gold speed skills in total distance gained. See the <a href={resolve('/race/mspeed')}>mechanical speed calculator</a> for
analysis. 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> </p>
<Sec h={3} id="lane-combo">Lane Combo</Sec> <Sec h={3} id="lane-combo">Lane Combo</Sec>
@@ -433,13 +537,14 @@
y={laneComboSeries} y={laneComboSeries}
yLabel="Speed Boost" yLabel="Speed Boost"
yRule={lcYRule} yRule={lcYRule}
range={[0.2, 0.5]} range={[0.2, 0.6]}
/> />
</div> </div>
<p> <p>
Front runners have access to the skill <Skill skill={skills.dd} />, which forces a horse who uses it to move outward to a Front runners have access to the skill <Skill skill={skills.dd} />, which forces a horse who uses it to move outward to a
specific distance from the rail. DD almost always ends shortly before the horse has finished accelerating to early race speed, specific distance from the rail. DD almost always activates while the horse is still accelerating to early race speed, so it
so it does not convert the move lane speed modifier into distance. does not convert much of the move lane speed modifier into distance (although still on the order of a gold speed skill worth
of gain on most medium races).
</p> </p>
<p> <p>
We get advantage from move lane speed modifier by following DD with <Skill skill={skills.pp} /> or <Skill We get advantage from move lane speed modifier by following DD with <Skill skill={skills.pp} /> or <Skill
@@ -471,14 +576,18 @@
consistency at the cost of lowering the maximum gain. consistency at the cost of lowering the maximum gain.
</p> </p>
<p> <p>
The <a href={resolve('/mspeed')}>mechanical speed calculator</a> has an approximation of lane combo's benefit. A more precise The <a href={resolve('/race/mspeed')}>mechanical speed calculator</a> has an approximation of lane combo's benefit. A more
lane combo simulator exists at precise lane combo simulator exists at
<a href="https://lanecalc.hf-uma.net/" target="_blank" rel="noopener noreferrer">危険回避シミュ</a>, but I am not sufficiently <a href="https://lanecalc.hf-uma.net/" target="_blank" rel="noopener noreferrer">危険回避シミュ</a>. See
confident in my Japanese to try to guide readers through it. <a href={resolve('/doc/lanecalc')}>my guide</a> for English instructions on how to use it.
<!-- TODO(zeph): i could totally annotate a picture though, or find someone else's explanation -->
</p> </p>
<Sec h={3} id="uphills">Uphills</Sec> <Sec
h={3}
id="uphills"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.wdj69udyo340"
>Uphills</Sec
>
<p> <p>
Running uphill carries a penalty to target speed. This penalty scales negatively with the power stat; that is, higher power Running uphill carries a penalty to target speed. This penalty scales negatively with the power stat; that is, higher power
means faster uphill running. It scales positively with slope angle. There is also a flat reduction in base acceleration for means faster uphill running. It scales positively with slope angle. There is also a flat reduction in base acceleration for
@@ -503,7 +612,12 @@
power on the Arima Kinen mid-race hills. power on the Arima Kinen mid-race hills.
</p> </p>
<Sec h={3} id="downhills">Downhills</Sec> <Sec
h={3}
id="downhills"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.wdj69udyo340"
>Downhills</Sec
>
<p> <p>
Running downhill allows horses to enter <i>downhill accel mode</i>. Contrary to its name, downhill accel mode does not affect Running downhill allows horses to enter <i>downhill accel mode</i>. Contrary to its name, downhill accel mode does not affect
acceleration at all; it gives horses a target speed boost that scales with the slope angle, plus lowered HP consumption via a acceleration at all; it gives horses a target speed boost that scales with the slope angle, plus lowered HP consumption via a
@@ -520,7 +634,7 @@
stat={Stat.Wit} stat={Stat.Wit}
y={downhillSeries} y={downhillSeries}
yLabel="Entry Chance (% each second)" yLabel="Entry Chance (% each second)"
range={[0, 60]} range={[0, 75]}
/> />
</div> </div>
<p> <p>
@@ -529,7 +643,12 @@
Conversely, the HP savings on long downhills can be enough to drop a recovery skill or two on some tracks. Conversely, the HP savings on long downhills can be enough to drop a recovery skill or two on some tracks.
</p> </p>
<Sec h={3} id="section-speed">Section Speed</Sec> <Sec
h={3}
id="section-speed"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.xdha40q2176g"
>Section Speed</Sec
>
<p> <p>
Each section, each horse gets a random modifier to target speed. The modifier's range is determined by the wit stat. Each section, each horse gets a random modifier to target speed. The modifier's range is determined by the wit stat.
(Curiously, the calculation uses both wit as modified by style proficiency and green skills as well as base wit.) (Curiously, the calculation uses both wit as modified by style proficiency and green skills as well as base wit.)
@@ -540,7 +659,7 @@
stat={Stat.Wit} stat={Stat.Wit}
yArea={secSpeedSeries} yArea={secSpeedSeries}
yLabel="Section Speed (m/s)" yLabel="Section Speed (m/s)"
range={[17.5, 22.5]} range={[17, 23]}
plotOptions={{ color: { legend: true } }} plotOptions={{ color: { legend: true } }}
/> />
<div class="mx-auto flex w-full md:max-w-2xl"> <div class="mx-auto flex w-full md:max-w-2xl">
@@ -573,7 +692,12 @@
wit front A horse will pass in {secSpeedPassTime} seconds on average at mid race speeds. wit front A horse will pass in {secSpeedPassTime} seconds on average at mid race speeds.
</p> </p>
<Sec h={3} id="no-zone">No-Overtake Zone</Sec> <Sec
h={3}
id="no-zone"
ref="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.7l02ti4pvzxp"
>No-Overtake Zone</Sec
>
<p> <p>
The no-overtake zone is the 200m portion of the race prior to the first corner. For unclear reasons, while in the no-overtake The no-overtake zone is the 200m portion of the race prior to the first corner. For unclear reasons, while in the no-overtake
zone, horses cannot enter overtake lane mode, which is what allows them to move away from the rail. (Overtake lane mode is zone, horses cannot enter overtake lane mode, which is what allows them to move away from the rail. (Overtake lane mode is
@@ -664,7 +788,7 @@
<Sec h={3} id="spurt-skills">Spurt Skills</Sec> <Sec h={3} id="spurt-skills">Spurt Skills</Sec>
<p> <p>
Because they are post-Angling, skills that activate in the final spurt are typically less interesting to front runners. 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={skills.inesInherit}
/><!-- skill name ends with ! --> /><!-- skill name ends with ! -->
These are good inherits for those who don't have easy access to <Skill skill={skills.cacaoInherit} mention /> and <Skill These are good inherits for those who don't have easy access to <Skill skill={skills.cacaoInherit} mention /> and <Skill
@@ -682,7 +806,7 @@
Angling, they are almost certainly a different running style. Angling, they are almost certainly a different running style.
</p> </p>
<p> <p>
The <a href={resolve('/spurt')}>spurt speed calculator</a> analyzes this situation. A 1200 speed front runner with a 0.25 speed The <a href={resolve('/race/spurt')}>spurt speed calculator</a> analyzes this situation. A 1200 speed front runner with a 0.25 speed
skill active has speed equivalent to an 1187 speed pace chaser. In other words, as long as that pace chaser is built the way Global skill active has speed equivalent to an 1187 speed pace chaser. In other words, as long as that pace chaser is built the way Global
players tend to build, Pulse does not allow the front runner to pass back. So, if you happen to inherit it off a grandparent, it players tend to build, Pulse does not allow the front runner to pass back. So, if you happen to inherit it off a grandparent, it
is not worth taking. is not worth taking.
@@ -698,8 +822,8 @@
</p> </p>
<p> <p>
Eyes especially has the potential to be threatening, since it's roughly unconditional. However, it hits others <i>in vision</i 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 >, which is essentially always a range of 20m, or 8 lengths. Debuffers are typically built with very little stamina and power,
almost never going to reach front runners, especially the front who got Angling. so Eyes is almost never going to reach front runners, especially the front who got Angling.
</p> </p>
<p> <p>
The other category of skills to think about is other horses' uniques. The other category of skills to think about is other horses' uniques.
@@ -743,37 +867,72 @@
Obviously, CM is run in teams of three. While spamming the highest stats you can manage is a valid strategy that will get 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. wins, building a team holistically will lead to higher win rates.
</p> </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>
While this is a front runner guide, it's still valuable to think about the effects that zero front races have on other styles;
which is to say, <i>pace promotion</i>.
</p>
<p>
Whichever pace chaser (or late surger if there are also no paces, or end closer if it's all ends) happens to get in front of
the pack by the first few dozen meters or so becomes the pace setter for the race. She runs as if she were a front runner with
no position keep modes, but she retains the pace chaser coefficients for target speed and acceleration. For this reason, pace
chasers tend to see their highest win rates in zero front races.
</p>
<p>
If you feel a given CM will have a low population of front runners, you can build specifically <i>for</i> pace promo. Burgh on
the GameTora Discord server
<a
href="https://discord.com/channels/879780247137050684/879780820116721665/1472986531529752637"
target="_blank"
rel="noopener noreferrer">ran extensive tests</a
>
and found that <Skill skill={skills.conc} mention /> and <Skill skill={skills.stan} mention /> were the only skills to produce a
statistically significant difference in pace promotion ability. More precisely, achieving pace promo is decided by start delay,
any target speed skill activating, and <a href="#section-speed">section speed</a> for the first section (i.e. wit stat), in
decreasing order of importance. (Notably, contrary to common advice, <Skill skill={skills.gw} mention /> and <Skill
skill={skills.shrewd}
mention
/> are <i>not</i> effective for pace promo.)
</p>
<p>
On the other hand, if you want to run an end closer or late surger as your ace, but front runners may be rare and <s
>Christmas Oguri Cap</s
> a pace chaser is meta for the race, preventing pace promotion could be a reason to run a solo front build instead.
</p>
<Sec h={3} id="solo-front">Solo Front</Sec> <Sec h={3} id="solo-front">Solo Front</Sec>
<p> <p>
Because solo fronts essentially don't have access to their <a href="#front-modes">running modes</a>, they are unlikely to 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. 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>
<p> <p>
In particularly front-dominated metas, this is manageable, since you'll have opponent fronts to run against. When any other 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 solo front is style is viable, which is always now that <Skill skill={skills.headon} mention /> is buffed, you'll have matches where your team's
truly solo. 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>
<p> <p>
For that reason, it is my belief that solo fronts should not be built as aces; they're better left to a support role. And, The ideal runaway blocker has capped power, capped wit, and surface S, along with full gate and <a href="#lane-combo"
generally, the best support front runners are runaways, to block other teams' front runners from Angling and to keep pace up >lane combos</a
to mitigate PDM on your other horses. > 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>
<p> <p>
I say that they shouldn't be built as aces, but right now, building a runaway that can block MANT front runners requires all Because runaways get so far ahead during the first half of the race, they essentially disable <a href="#pdm">pace-down mode</a
the early and mid race skills you can manage. > for everyone. That especially benefits pace chasers, who tend to lose access to pace-up mode with slower pacesetters, and it disfavors
<a href="#gate-skills">Gate skills</a> are especially important, because runaways have a tremendously higher early race target late surgers and end closers who normally don't keep up with their pace zones either way but now have to fight better paces.
speed. If there is a real difference between a correctly built runaway blocker and a runaway ace, it's that <Skill
skill={skills.cacaoInherit}
mention
/>
and <Skill skill={skills.kfcInherit} mention /> are arguably more appropriate inherits than Angling, but the argument isn't strong
if you're still able to hit a good stat line on your runaway.
</p> </p>
<p> <p>
Runaway blockers come with some caveats. Because they're so far ahead during the first half of the race, they essentially So, if you want to run a composition like one front and two end, it might serve you to choose against building a runaway
disable <a href="#pdm">pace-down mode</a> for everyone. That especially benefits pace chasers, who have more forgiving PDM limits blocker. This kind of composition probably should build the front runner for the purpose of winning against other front
in the first place, and it disfavors late surgers and end closers who are less equipped to move forward during position keep. runners, and simply accept that solo front matches will be up to the other two instead. That leaves little room for a
dedicated support like a debuffer.
</p> </p>
<Sec h={3} id="double-front">Double Front</Sec> <Sec h={3} id="double-front">Double Front</Sec>
@@ -784,12 +943,12 @@
<p> <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 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 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>
<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 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, 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>
<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 Since your two front runners are both meant to be winners, making your third horse a support type is likely to give the best
@@ -808,14 +967,8 @@
Building for second place to bunny with NN does not mean manipulating stats and skills to force one of the front runners into Building for second place to bunny with NN does not mean manipulating stats and skills to force one of the front runners into
second. Both fronts need to be strong enough to be adjacent at late race start, and you have to beat other people's front second. Both fronts need to be strong enough to be adjacent at late race start, and you have to beat other people's front
runners, too. (Especially since VC and <Skill skill={skills.encroaching} mention /> are currently the only consistent accels for runners, too. (Especially since VC and <Skill skill={skills.encroaching} mention /> are currently the only consistent accels for
long distance.) So, double front for long is ultimately about the same as double front for other distances, just with different long distance; front runners are common generally here.) So, double front for long is ultimately about the same as double front
goals when choosing the legacy. 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>
<p> <p>
If you're feeling adventurous, runaway is another consideration for double (and triple) front on long distances. Your ace If you're feeling adventurous, runaway is another consideration for double (and triple) front on long distances. Your ace
@@ -839,17 +992,17 @@
One type of support that all front runners do automatically is helping to kill position-based pace chaser skills. Such skills One type of support that all front runners do automatically is helping to kill position-based pace chaser skills. Such skills
have conditions that translate into needing to be in 3rd or 4th, or sometimes 2nd through 4th, to activate. When you're have conditions that translate into needing to be in 3rd or 4th, or sometimes 2nd through 4th, to activate. When you're
bringing three front runners, if anyone else brings a single other one, they're going to occupy 2-4 naturally. If there happen bringing three front runners, if anyone else brings a single other one, they're going to occupy 2-4 naturally. If there happen
to be three others, then even late/end win cons like <Skill skill={skills.beyondInherit} mention /> and <Skill to be three others between front and pace, then even late/end win cons like <Skill skill={skills.beyondInherit} mention /> and <Skill
skill={skills.pumpInherit} skill={skills.pumpInherit}
mention mention
/> are dead. /> are dead.
</p> </p>
<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 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 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
terribly consistent because they're readily equipped to get into first just as easily as third, but then they just get Angling is not terribly consistent because they're readily equipped to get into first just as easily as third, but then they just get
instead and follow the normal front runner game plan. If you see people talking about a pace chaser being especially strong Angling instead and follow the normal front runner game plan. If you see people talking about a given pace chaser being
for a given CM, consider running them as a front runner in a triple front build instead. especially strong for a CM, consider running them as a front runner in a triple front build instead.
</p> </p>
<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 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
@@ -863,34 +1016,46 @@
showing. In addition to the normal front win cons, it's potentially strong to take unconditional gambles like <Skill showing. In addition to the normal front win cons, it's potentially strong to take unconditional gambles like <Skill
skill={skills.cornerConn} skill={skills.cornerConn}
mention mention
/> and <Skill skill={skills.radiant} mention />. />, <Skill skill={skills.burningPOW} mention />, and <Skill skill={skills.radiant} mention />.
</p> </p>
<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 Now that <a href="#full-charge">conserving power</a> is a game mechanic, SS supports have the problem that spot struggle risks
sprints. However, spot struggle is the only mechanic in the game that scales superlinearly with stats, so even the difference taking away full charge, which means losing a strong accel. This can be alleviated to some degree by managing
between 1200 and 1000 guts can matter. See the <a href={resolve('/mspeed')}>mechanical speed calculator</a> for details. <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> </p>
<Sec h={3} id="chariot">Chariot</Sec> <div class="hidden" id="chariot"></div>
<Sec h={3} id="rabbit">Rabbit</Sec>
<p> <p>
A front runner build that I have seen but not attempted is the chariot: a runaway with a strong mid race but insufficient HP A front runner build that I have seen but not attempted is the rabbit: a runaway with a strong early race but insufficient HP
to spurt stays in front of one or two other front runners who have <Skill skill={skills.nsm} mention /><!-- ends with ! --> to spurt stays in front of one or two other front runners who have <Skill skill={skills.nsm} mention /><!-- ends with ! -->
</p> </p>
<p> <p>
I saw a team using a chariot in CM13 round 2 day 2; that team had an overall eleven wins at the time. Maybe their horses just I saw a team using a rabbit in CM13 round 2 day 2; that team had an overall eleven wins at the time. Maybe their horses just
weren't strong enough. I'm not sure. My opinion is that this build gives up the consistency of front runners to pursue a game weren't strong enough. I'm not sure. My opinion is that this build gives up the consistency of front runners to pursue a game
plan that is hardly stronger when it does work. plan that is hardly stronger when it does work.
</p> </p>
<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 About two weeks ahead of CM15, Hakuraku's replay data is showing teams using a rabbit build, or even double runaways, with
viable now. high win rates. I've watched a few dozen of those races and have found that the runaways are not functionally doing anything
specific to runaways: they're being built without <Skill skill={skills.gw} mention /> and <Skill
skill={skills.earlyLead}
mention
/>, 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 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> </p>
<Sec h={2} id="career">Career</Sec> <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>Most front runners enjoy easy careers thanks to strong kits and little chance to be blocked.</p>
<p> <p>
Currently, this chapter is about MANT (a.k.a. Trackblazer). Unity Cup is still useful &ndash; obviously for runaways, but also Currently, this chapter is about MANT (a.k.a. Trackblazer). Current data for CM16 following the URA and UC reworks suggests
for spinning up an <Skill skill={skills.ignitedWIT} mention /> legacy. 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> </p>
<Sec h={3} id="support-cards">Support Cards</Sec> <Sec h={3} id="support-cards">Support Cards</Sec>
@@ -996,8 +1161,8 @@
</p> </p>
<p> <p>
As a corollary, you also cannot win this race with B mile. A miraculous start can get to 350 speed for this race; a B mile As a corollary, you also cannot win this race with B mile. A miraculous start can get to 350 speed for this race; a B mile
runner with 350 speed is equivalent to an A mile runner with only 207 speed in career. See the <a href={resolve('/spurt')} runner with 350 speed is equivalent to an A mile runner with only 207 speed in career. See the <a
>spurt calculator</a href={resolve('/race/spurt')}>spurt calculator</a
>. (This race is why I made it.) >. (This race is why I made it.)
</p> </p>
@@ -1059,7 +1224,7 @@
<Sec h={2} id="umas">Front Runners</Sec> <Sec h={2} id="umas">Front Runners</Sec>
<ul class="mb-4 list-disc pl-4"> <ul class="mb-4 list-disc pl-4">
<li> <li>
Valentine's Mihono Bourbon (VBourbon) is the easiest font runner to train because she has <Skill Valentine's Mihono Bourbon (VBourbon or Chocobon) is the easiest font runner to train because she has <Skill
skill={skills.gw} skill={skills.gw}
mention mention
/> built in, whereas most others have to get it from either inheritance or cards that aren't terribly strong. She is also the /> built in, whereas most others have to get it from either inheritance or cards that aren't terribly strong. She is also the
@@ -1118,7 +1283,8 @@
<Sec h={3} id="taiki-shuttle">Taiki Shuttle &amp; Curren Chan</Sec> <Sec h={3} id="taiki-shuttle">Taiki Shuttle &amp; Curren Chan</Sec>
<p> <p>
When building <a href="#triple-front">triple fronts</a>, you will naturally have a horse in third. This presents an 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>
<p> <p>
Taiki Shuttle and original Curren Chan are notable for having C and B front, respectively, which is quite reasonable to fix. Taiki Shuttle and original Curren Chan are notable for having C and B front, respectively, which is quite reasonable to fix.
@@ -1144,14 +1310,15 @@
<Sec h={3} id="sparks">Important Sparks</Sec> <Sec h={3} id="sparks">Important Sparks</Sec>
<p> <p>
Front runners are uniquely capable of making good use of all stat (blue) sparks, since guts is unusually important for them. Front runners are uniquely capable of making good use of wit sparks, since wit governs early- and mid-race speed, which is
Power sparks are typically still best, and stamina sparks can help to forgo a stamina card or two for 2400m+ builds. Fronts unusually important for them. Power sparks are typically still best, and stamina sparks can help to forgo a stamina card or
have a disproportionate representation of horses with high speed talent, which makes speed sparks a bit less valuable. 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>
<p> <p>
Pink sparks are more interesting, because &ndash; at least until other styles catch up on accels &ndash; they're less reliant Pink sparks are more interesting, because &ndash; at least until other styles finish catching up on accels &ndash; they're
on distance S to win. Building a high affinity legacy tree requires keeping some mile and long sparks on hand. Front sparks less reliant on distance S to win. Building a high affinity legacy tree requires keeping some mile and long sparks on hand.
are better than other style sparks, especially if you do <a href="#triple-front">triple front</a> CM builds using 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>. <a href="#taiki-shuttle">fixed pace chasers</a>.
</p> </p>
<p>Front runners are more reliant than other styles on getting certain skills from inspiration:</p> <p>Front runners are more reliant than other styles on getting certain skills from inspiration:</p>
@@ -1169,7 +1336,7 @@
<li> <li>
<Skill skill={skills.ignitedWIT} /> is the stronger lane combo piece. The only sources of it are inheritance and Unity Cup; which <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 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>
<li> <li>
Almost all green skills are valuable to inherit at one point or another as GW triggers. Almost all green skills are valuable to inherit at one point or another as GW triggers.
@@ -1184,12 +1351,17 @@
</li> </li>
<li> <li>
All generic speed skills that aren't strictly late race are good inherits. 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 skill={skills.thh} />, <Skill skill={skills.pto} />, <Skill skill={skills.ramp} />, and <Skill
skill={skills.cornerAdept} skill={skills.cornerAdept}
/> are also strong, but available as hints or chain skills from some usable-to-good cards. /> are also strong, but available as hints or chain skills from some usable-to-good cards.
</li> </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> </ul>
<Sec h={3} id="parents">Parents</Sec> <Sec h={3} id="parents">Parents</Sec>
@@ -1218,7 +1390,8 @@
</li> </li>
<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 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>
<li> <li>
TM Opera O is technically a pace chaser parent, but she still has good compatibility with most front runners, and her unique TM Opera O is technically a pace chaser parent, but she still has good compatibility with most front runners, and her unique
@@ -1226,100 +1399,77 @@
</li> </li>
</ul> </ul>
<Sec h={2} id="stats">Stat Reference</Sec> {#each ['stats', 'stats-speed', 'stats-stamina', 'stats-power', 'stats-guts', 'stats-wit', 'stats-surface', 'stats-style'] as id (id)}
<p> <h2 class="hidden target:block target:py-32" {id}>
This chapter is an extremely brief reference of what front runner race mechanics each stat and aptitude applies to and how Stat Reference: This chapter moved to <a href={resolve('/doc/race')}>its own article</a>.
they scale. </h2>
</p> {/each}
<p>There are four types of each stat.</p>
<ol class="mb-4 list-decimal pl-4">
<li>
<i>Raw stat</i> is the number displayed in the horse's info. Once stat caps are raised, any portion of raw stats above 1200 is
halved.
</li>
<li><i>Base stat</i> is raw stat modified by mood.</li>
<li>
<i>Adjusted stat</i> is base stat modified by surface conditions (for speed and power), track stat thresholds (for speed), and
style proficiency (for wit).
</li>
<li><i>Final stat</i> or just <i>stat</i> is adjusted stat plus the effects of green skills.</li>
</ol>
<p>
For example, a 1200 wit style S horse in great mood with the corresponding savvy skill at level 1 has 1200 raw wit, 1248 base
wit, 1372 adjusted wit, and 1412 (final) wit.
</p>
<p>
<i>Linear scaling</i> means that a given increase to a stat produces the same change in the mechanical value regardless of the
stat value.
<i>Root scaling</i> means that the mechanical value scales with the square root, 0.6 power, or 0.7 power of the stat, which
means the increase becomes smaller as the stat grows.
<i>Log scaling</i> means it scales with the logarithm of the stat; this is the fastest diminishing scaling type.
<i>Log-linear scaling</i> means it scales with the stat value multiplied by its logarithm.
<i>Negative scaling</i> of any type means that the mechanic instead decreases as the stat increases.
<i>Inverse scaling</i> means it scales with the multiplicative inverse of the stat, i.e. <span class="font-mono">1/x</span>.
Inverse scaling is negative scaling by definition, which means that negative inverse scaling (e.g. skill activation chance)
flips back to positive.
</p>
<Sec h={3} id="stats-speed">Speed</Sec>
<ul class="mb-4 list-disc pl-4">
<li>Spurt speed, root scaling</li>
<li>Late race speed when not spurting, root scaling</li>
</ul>
<Sec h={3} id="stats-stamina">Stamina</Sec>
<ul class="mb-4 list-disc pl-4">
<li>HP, linear scaling</li>
</ul>
<Sec h={3} id="stats-power">Power</Sec>
<ul class="mb-4 list-disc pl-4">
<li>Move lane modifier (<a href="#lane-combo">lane combo</a>), root scaling</li>
<li><a href="#uphills">Uphill target speed loss</a>, negative inverse scaling</li>
<li>Acceleration, root scaling</li>
<li>Lane change target speed (i.e. the horizontal rather than forward speed of changing lanes), linear</li>
</ul>
<Sec h={3} id="stats-guts">Guts</Sec>
<ul class="mb-4 list-disc pl-4">
<li>Spurt speed, root scaling (roughly 5% of the effect of the speed stat)</li>
<li>Minimum speed, root scaling</li>
<li>HP consumption multiplier during last spurt, inverse root scaling</li>
<li>Target speed gain from spot struggle, root scaling</li>
<li>Duration of spot struggle, root scaling</li>
<li>Target speed gain from dueling, root scaling</li>
<li>Acceleration gain from dueling, root scaling</li>
</ul>
<Sec h={3} id="stats-wit">Wit</Sec>
<ul class="mb-4 list-disc pl-4">
<li><a href="#section-speed">Section speed</a>, log-linear scaling</li>
<li>Chance to enter <a href="#downhills">downhill accel mode</a>, linear scaling</li>
<li>Chance to accept a spurt speed and delay when not full spurting, linear scaling</li>
<li>Chance to activate skills, negative inverse scaling, base stat</li>
<li>Chance to enter <a href="#front-modes">speed-up and overtake modes</a> during position keep, log scaling</li>
<li>
For non-fronts, chance to enter pace-up mode, log scaling; but no effect on <a href="#pdm">pace-down mode</a>, which is
purely controlled by distance to the pacemaker
</li>
<li>Chance to become rushed, inverse log-squared scaling</li>
</ul>
<Sec h={3} id="stats-surface">Surface (Turf/Dirt) Aptitude</Sec>
<p>Surface aptitude is a multiplier on acceleration. (Not the power stat, for the purposes of other mechanics using power.)</p>
<Sec h={3} id="stats-distance">Distance Aptitude</Sec>
<p>Distance aptitude is a multiplier on spurt speed. At distance E and below, it is also a multiplier on acceleration.</p>
<Sec h={3} id="stats-style">Style Aptitude</Sec>
<p>
Style aptitude directly multiplies wit in the computation of the wit stat, which means it affects everything in <a
href="#stats-wit">the wit section</a
> except for mechanics that use base wit.
</p>
<Sec h={2} id="cm">My CM Teams</Sec> <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
being just a 0.25 skill is sad). I see on Hakuraku that runaways are unusually prevalent, and as much as I think it's a bad
build, I kind of want to try rabbiting this time.
</p>
<ol class="mb-4 list-decimal pl-4">
<li>
Suzuka. I'll try building her as a runaway with TTL, GW, Conc, lane combo, and no other skills except debuffs, and see
whether I can make two aces that can consistently pass in mid race. If that doesn't work out, I'll do a spot struggle
support instead.
</li>
<li>
Kitasan Black. She won't get to run often, so I'm going to run her while I have the opportunity. She's pretty difficult to
build a deck for, though. No Professor, and only one of Tunes and Escape Artist, since I have neither Pasta nor Helios SSR.
(The latter is proving non-functional anyway. Five runs with agemasen every time.)
</li>
<li>
Chocobon. Part of me wants to do Seiun Sky here, but if Kitasan has carryover potential on her unique, then so does Bourbon
if she whiffs on the first half corners. Since Kitasan won't have as good of a mid race, I really need a proper front runner
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> <Sec h={3} id="cm14">CM14 &ndash; Gemini Cup (Yasuda Kinen, NHK Mile Cup, Victoria Mile)</Sec>
<p> <p>
The ultimate lane combo map, and also the ultimate <a href="#no-zone">no-overtake zone</a> map, and also the ultimate dueling map. The ultimate lane combo map, and also the ultimate <a href="#no-zone">no-overtake zone</a> map, and also the ultimate dueling map.
@@ -1346,6 +1496,14 @@
Win rates after 40: Falco 47.5%, Bourbon 27.5%, Suzuka 5%. While this is my best round 1 by a substantial margin, I'm pretty Win rates after 40: Falco 47.5%, Bourbon 27.5%, Suzuka 5%. While this is my best round 1 by a substantial margin, I'm pretty
sure I'm going to get torn up in round 2; my stats aren't good enough to beat correctly built end closers. sure I'm going to get torn up in round 2; my stats aren't good enough to beat correctly built end closers.
</p> </p>
<p>
Win rates after 80: Falco 36.25%, Bourbon 22.5%, Suzuka 6.25%. Not as bad as I expected; most round 2 entries finished with
three wins. I'll take it.
</p>
<p>
Unfortunately lost to a bad mid race and a Taishin getting a perfect Radiant. At least it was only one this time. Second
place.
</p>
<Sec h={3} id="cm13">CM13 &ndash; Taurus Cup (Tokyo Derby)</Sec> <Sec h={3} id="cm13">CM13 &ndash; Taurus Cup (Tokyo Derby)</Sec>
<p> <p>
+279
View File
@@ -0,0 +1,279 @@
<script lang="ts">
import { asset, resolve } from '$app/paths';
import Skill from '$lib/Skill.svelte';
import Sec from '../Sec.svelte';
import * as skill from '$lib/data/skill';
import { onMount } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import courseSetupHoriz from './course-setup.horiz.png';
import courseSetupVert from './course-setup.vert.png';
import umaSetupHoriz from './uma-setup.horiz.png';
import umaSetupVert from './uma-setup.vert.png';
import resultsChart from './results-chart.png';
import resultsHoriz from './results.horiz.png';
import resultsVert from './results.vert.png';
import Article from '../Article.svelte';
let allSkillsList: skill.Skill[] = $state([]);
onMount(async () => {
allSkillsList = await skill.skills();
});
const allSkills = $derived(allSkillsList.reduce((m, s) => m.set(s.skill_id, s), new Map<number, skill.Skill>()));
const skills = $derived({
goldpp: allSkills.get(200451),
pp: allSkills.get(200452),
ttl: allSkills.get(200531),
earlyLead: allSkills.get(200532),
dd: allSkills.get(201262),
gw: allSkills.get(201601),
ignitedWIT: allSkills.get(210052),
});
const locations = [
['札幌', 'Sapporo'],
['函館', 'Hakodate'],
['新潟', 'Niigata'],
['福島', 'Fukushima'],
['中山', 'Nakayama'],
['東京', 'Tokyo'],
['中京', 'Chukyo'],
['京都', 'Kyoto'],
['阪神', 'Hanshin'],
['小倉', 'Kokura'],
['大井', 'Ooi'],
['川崎', 'Kawasaki'],
['船橋', 'Funabashi'],
['盛岡', 'Morioka'],
] as const;
const courseinfos = [
['芝', 'Turf'],
['ダート', 'Dirt'],
['内', 'Inner'],
['外', 'Outer'],
] as const;
const conditionses = [
['良', 'Firm'],
['稍重', 'Good'],
['重', 'Soft'],
['不良', 'Heavy'],
] as const;
const styles = [
['大逃げ', 'Runaway'],
['逃げ', 'Front Runner'],
] as const;
const moods = [
['絶好調', 'Great'],
['好調', 'Good'],
['普通', 'Normal'],
['不調', 'Bad'],
['絶不調', 'Awful'],
] as const;
const accels = [
['地固め', 'Groundwork'],
['盤石', 'Unshakable Stance'],
['先駆け', 'Early Lead'],
['先手', 'Taking the Lead'],
['進化先手', 'Evolved Taking the Lead'],
] as const;
const arinashi = [
['あり', 'Enabled'],
['なし', 'Disabled'],
] as const;
const hills = [
['上り坂 (Red)', 'Uphill'],
['下り坂 (Blue)', 'Downhill'],
] as const;
const times = [
['危険回避', 'Dodging Danger'],
['スキル', 'Lane Skill Activation'],
['中盤', 'Mid Race'],
] as const;
const straco = [
['直線 (Yellow)', 'Straight'],
['コーナー (Pink)', 'Corner'],
] as const;
const laneskills = [
['スキルなし', 'No Skills'],
['危険回避のみ', 'Dodging Danger'],
['+注目の踊り子', 'DD + Center Stage'],
['+ポジションセンス', 'DD + Prudent Positioning'],
['+アオハル点火・賢', 'DD + Ignited Spirit WIT'],
] as const;
const tooltip = [
['時間', 'Time (s)'],
['レーン', 'Lane (CW)'],
['走行距離', 'Distance (m)'],
['距離ロス', 'Distance Loss (m)'],
] as const;
const summaryText = [
['バ身', 'Lengths'],
['アド', 'Add'],
['ロス', 'Loss'],
] as const;
</script>
{#snippet transbody(heads: string | [string, string], cells: ReadonlyArray<readonly [string, string]>, cls?: ClassValue | null)}
<table class={['mx-1 mt-4 table-fixed border md:mb-auto', cls]}>
{#if typeof heads === 'string'}
<caption class="border border-b-0">{heads}</caption>
{:else}
<thead class="border-b">
<tr>
<th class="w-1/2 px-2" scope="col">{heads[0]}</th>
<th class="w-1/2 px-2" scope="col">{heads[1]}</th>
</tr>
</thead>
{/if}
<tbody>
{#each cells as [jp, en] (jp)}
<tr class="even:bg-mist-300 dark:even:bg-mist-900">
<td>{jp}</td>
<td>{en}</td>
</tr>
{/each}
</tbody>
</table>
{/snippet}
<Article>
{#snippet head()}
<Sec h={1} id="top" class="text-center pointer-coarse:mb-8">Gaikokujin's Guide to Lanecalc</Sec>
<p>
This is an English usage manual for <a href="https://lanecalc.hf-uma.net/" target="_blank" rel="noopener noreferrer"
>危険回避シミュ</a
>, or Lanecalc.
</p>
<p>
Lanecalc is a precise calculator for the effect of <a href={resolve('/doc/frbm#lane-combo')}>lane combo</a>, the use of <Skill
skill={skills.dd}
/> along with some combination of <Skill skill={skills.pp} />, <Skill skill={skills.goldpp} />, and <Skill
skill={skills.ignitedWIT}
/>
for a substantial speed boost in early race. Lanecalc helps with deciding whether lane combo (and DD alone) is applicable to a
given track.
</p>
<p>
The translations presented in this manual are contextualized to an English speaker using Lanecalc, rather than being direct
translations. E.g., スキル literally means "skill," but I am translating it as "lane skill activation time" where that is
what スキル signifies.
</p>
{/snippet}
<Sec h={2} id="course">Course Setup</Sec>
<p>
The top section of lane calc is the course setup section. Set the race location, course at the location, and ground condition
here.
</p>
<a href={asset(courseSetupHoriz)}>
<picture>
<source srcset={asset(courseSetupHoriz)} media="(orientation: landscape)" width="882" height="111" />
<img
class="mx-auto"
src={asset(courseSetupVert)}
width="412"
height="214"
alt="Lanecalc's course setup section with English labels"
/>
</picture>
</a>
<div class="mb-4 grid grid-cols-1 text-center text-xl md:grid-cols-3">
{@render transbody(['レース場', 'Location'], locations)}
{@render transbody(['コース', 'Race Course'], courseinfos)}
{@render transbody(['バ場状態', 'Conditions'], conditionses)}
</div>
<p>
The race course menu shows only courses available at the selected race location. Changing the location will reset the course.
</p>
<Sec h={2} id="uma">Uma Setup</Sec>
<p>The next section is the setup for the Uma.</p>
<a href={asset(umaSetupHoriz)}>
<picture>
<source srcset={asset(umaSetupHoriz)} media="(orientation: landscape)" width="878" height="218" />
<img
class="mx-auto"
src={asset(umaSetupVert)}
width="412"
height="456"
alt="Lanecalc's Uma setup section with English labels"
/>
</picture>
</a>
<div class="mb-4 grid grid-cols-1 text-center text-xl md:grid-cols-3">
{@render transbody(['脚質', 'Running Style'], styles)}
{@render transbody(['パワー', 'Power'], [])}
{@render transbody(['調子', 'Mood'], moods)}
{@render transbody(['ウマ番', 'Gate'], [])}
{@render transbody(['危険回避発動', 'Dodging Danger'], arinashi)}
{@render transbody(['横ブロック', 'Side Block'], arinashi)}
{@render transbody(['レーンスキル発動位置', 'Lane Skill Activation Time'], [['秒', 'seconds']])}
{@render transbody(['加速スキル', 'Acceleration Skills'], accels, 'md:col-span-2')}
</div>
<p>
Power should be entered as the raw power stat, i.e. the number shown on your Uma's stat screen. (There doesn't appear to be a
way to apply power passives.)
</p>
<p>
Unshakable Stance is the gold version of <Skill skill={skills.gw} mention /> and is not yet available in Global. "Evolved Taking
the Lead" means any of the evolutions of <Skill skill={skills.ttl} mention /> that turn it into a 0.5 accel skill. (Aston Machan
will have a TTL evolution that instead turns it into a 4s 0.4 accel. There doesn't appear to be a way to apply it in the calculator.)
</p>
<p>
Selecting another skill in the same group as a selected one will remove the existing skill. Selecting an already selected
skill will disable it entirely in the simulator.
</p>
<Sec h={2} id="chart">Results Chart</Sec>
<p>Once the course and Uma are configured, click the green 計算 button. After a moment, the results appear.</p>
<a href={asset(resultsChart)}>
<picture>
<img class="mx-auto" src={asset(resultsChart)} width="800" height="600" alt="Lanecalc results chart" />
</picture>
</a>
<p class="mt-4">
The chart shows the Uma's simulated lane (horizontal distance from the rail) and forward velocity under the effects of various
lane combo skills.
</p>
<p>
The example above is Hanshin 2200, firm, with a front runner with 1200 power in great condition from gate 6. She has <Skill
skill={skills.gw}
mention
/> and <Skill skill={skills.earlyLead} mention />, and the lane change speed skills are set to activate at 7 seconds.
</p>
<div class="-mt-4 mb-4 grid grid-cols-1 text-center text-xl md:grid-cols-6">
{@render transbody('Hill Indicators', hills, 'md:col-span-2')}
{@render transbody('Time Indicators', times, 'md:col-span-2')}
{@render transbody('Straights/Corners', straco, 'md:col-span-2')}
{@render transbody('Lane Speed Skills', laneskills, 'md:col-span-4 md:col-start-2')}
</div>
<p>
You can click the labels underneath the chart to hide the corresponding line. The top row is for lane position lines, and the
bottom row is for velocity lines.
</p>
<p>
You can also hover the mouse over the lines for details. The first portion of the details are the skills that produce the
exact corresponding point, with a color square for each. Underneath is the simulation state.
</p>
<div class="-mt-4 mb-4 grid grid-cols-1 text-center text-xl md:grid-cols-3">
{@render transbody('Hover Info', tooltip, 'md:col-start-2')}
</div>
<Sec h={2} id="summary">Results Summary</Sec>
<p>
Last is the summary, shown as circles showing the gain from each combination of skills:
<Skill skill={skills.dd} /> alone, DD plus <Skill skill={skills.goldpp} />, DD plus <Skill skill={skills.pp} />, and DD plus <Skill
skill={skills.ignitedWIT}
/>.
</p>
<a href={asset(resultsHoriz)}>
<picture>
<source srcset={asset(resultsHoriz)} media="(orientation: landscape)" width="700" height="176" />
<img class="mx-auto" src={asset(resultsVert)} width="380" height="703" alt="Lanecalc results summary" />
</picture>
</a>
<div class="mb-4 grid grid-cols-1 text-center text-xl md:grid-cols-3">
{@render transbody('Lane Speed Skills', laneskills.slice(1), 'md:col-span-2')}
{@render transbody('Labels', summaryText)}
</div>
</Article>
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+482
View File
@@ -0,0 +1,482 @@
<script lang="ts">
import type { ComputedAreas, ComputedSeries } from '$lib/chart';
import Article from '../Article.svelte';
import Sec from '../Sec.svelte';
import * as race from '$lib/race';
import StatChart from '$lib/StatChart.svelte';
import { resolve } from '$app/paths';
function modemean(x: number, bonus: number, p: number): number {
return x * (1 - p) + x * bonus * p;
}
let style = $state(race.RunningStyle.FrontRunner);
let surfaceApt = $state(race.AptitudeLevel.A);
let distanceApt = $state(race.AptitudeLevel.A);
let raceLen = $state(2000);
const styleIsFront = $derived(style === race.RunningStyle.FrontRunner || style === race.RunningStyle.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) },
{ label: `Guts 600`, y: (x) => race.spurtSpeed(x, 600, style, distanceApt, raceLen) },
distanceApt !== race.AptitudeLevel.A
? { label: `${raceLenType} A, Guts 1200`, y: (x) => race.spurtSpeed(x, 1200, style, race.AptitudeLevel.A, raceLen) }
: null,
]);
const nonSpurtSpeed: Array<ComputedSeries | null> = $derived([
{ label: `Base Target Speed`, y: (x) => race.baseTargetSpeed(raceLen, style, race.Phase.LateRace, x, distanceApt) },
distanceApt !== race.AptitudeLevel.A
? {
label: `${raceLenType} A`,
y: (x) => race.baseTargetSpeed(raceLen, style, race.Phase.LateRace, x, race.AptitudeLevel.A),
}
: null,
]);
const hp: ComputedSeries[] = $derived([
{ label: 'Front Runner', y: (x) => race.maxHP(raceLen, race.RunningStyle.FrontRunner, x) },
{ label: 'Pace Chaser', y: (x) => race.maxHP(raceLen, race.RunningStyle.PaceChaser, x) },
{ label: 'Late Surger', y: (x) => race.maxHP(raceLen, race.RunningStyle.LateSurger, x) },
{ label: 'End Closer', y: (x) => race.maxHP(raceLen, race.RunningStyle.EndCloser, 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[] = [
{ label: '+2 Hill', y: (x) => race.uphillMod(x, 2.0) },
{ label: '+1.5 Hill', y: (x) => race.uphillMod(x, 1.5) },
{ label: '+1 Hill', y: (x) => race.uphillMod(x, 1.0) },
];
const accel: Array<ComputedSeries | null> = $derived([
{ label: 'Early Race', y: (x) => race.acceleration(x, style, surfaceApt, race.Phase.EarlyRace) },
{ label: 'Mid Race', y: (x) => race.acceleration(x, style, surfaceApt, race.Phase.MidRace) },
{ label: 'Late Race', y: (x) => race.acceleration(x, style, surfaceApt, race.Phase.LateRace) },
{
label: 'Early Race Uphill',
y: (x) => race.acceleration(x, style, surfaceApt, race.Phase.EarlyRace, race.AptitudeLevel.A, true),
},
]);
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 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 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) }]);
const gutsHPRate: ComputedSeries[] = [{ label: 'HP Consumption Rate Multiplier', y: (x) => race.spurtHPRateMod(x) }];
const ssBoost: ComputedSeries[] = [{ label: 'Target Speed Boost', y: (x) => race.spotStruggleSpeed(x) }];
const ssDur: ComputedSeries[] = [
{ label: 'Front Runner S', y: (x) => race.spotStruggleDuration(x, race.AptitudeLevel.S) },
{ label: 'Front Runner A', y: (x) => race.spotStruggleDuration(x, race.AptitudeLevel.A) },
];
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 secSpeed: Array<ComputedAreas | null> = $derived([
{
label: 'Early Race',
y1: (x) => race.sectionSpeed(raceLen, x, x, style, race.Phase.EarlyRace)[0],
y2: (x) => race.sectionSpeed(raceLen, x, x, style, race.Phase.EarlyRace)[1],
},
{
label: 'Mid Race',
y1: (x) => race.sectionSpeed(raceLen, x, x, style, race.Phase.MidRace)[0],
y2: (x) => race.sectionSpeed(raceLen, x, x, style, race.Phase.MidRace)[1],
},
styleIsFront
? {
label: 'Early Race + Mean Speed-Up Mode',
y1: (x) =>
modemean(race.sectionSpeed(raceLen, x, x, style, race.Phase.EarlyRace)[0], 1.04, race.frontModeEnterChance(x)),
y2: (x) =>
modemean(race.sectionSpeed(raceLen, x, x, style, race.Phase.EarlyRace)[1], 1.04, race.frontModeEnterChance(x)),
}
: {
label: 'Early Race + Mean Pace-Up Mode',
y1: (x) => modemean(race.sectionSpeed(raceLen, x, x, style, race.Phase.EarlyRace)[0], 1.04, race.paceUpEnterChance(x)),
y2: (x) => modemean(race.sectionSpeed(raceLen, x, x, style, race.Phase.EarlyRace)[1], 1.04, race.paceUpEnterChance(x)),
},
styleIsFront
? {
label: 'Mid Race + Mean Speed-Up Mode',
y1: (x) => modemean(race.sectionSpeed(raceLen, x, x, style, race.Phase.MidRace)[0], 1.04, race.frontModeEnterChance(x)),
y2: (x) => modemean(race.sectionSpeed(raceLen, x, x, style, race.Phase.MidRace)[1], 1.04, race.frontModeEnterChance(x)),
}
: {
label: 'Mid Race + Mean Pace-Up Mode',
y1: (x) => modemean(race.sectionSpeed(raceLen, x, x, style, race.Phase.MidRace)[0], 1.04, race.paceUpEnterChance(x)),
y2: (x) => modemean(race.sectionSpeed(raceLen, x, x, style, race.Phase.MidRace)[1], 1.04, race.paceUpEnterChance(x)),
},
]);
const downhill: ComputedSeries[] = [
{ label: 'Style S', y: (x) => race.downhillAccelEnterChance(x * 1.1) * 100 },
{ label: 'Style A', y: (x) => race.downhillAccelEnterChance(x) * 100 },
];
const reducedSpurt: ComputedSeries[] = [
{ label: 'Style S', y: (x) => race.reducedSpurtChance(x * 1.1) * 100 },
{ label: 'Style A', y: (x) => race.reducedSpurtChance(x) * 100 },
];
const skillChance: ComputedSeries[] = [
{ label: 'Per Skill', y: (x) => race.skillWitCheck(x) * 100 },
{ 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 },
{ 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) },
{ label: 'Front Runner A', y: (x) => 100 * race.frontModeEnterChance(x) },
{ label: 'Style S', y: (x) => 100 * race.paceUpEnterChance(x * 1.1) },
{ label: 'Style A', y: (x) => 100 * race.paceUpEnterChance(x) },
];
const rushed: ComputedSeries[] = [
{ label: 'Style S', y: (x) => race.rushedChance(x * 1.1) * 100 },
{ label: 'Style A', y: (x) => race.rushedChance(x) * 100 },
];
</script>
{#snippet statChart(
stat: race.Stat,
y: Array<ComputedSeries | null>,
yLabel: string,
range?: [number, number],
pick?: { len?: boolean; style?: boolean; dist?: boolean; surf?: boolean },
)}
{#if !pick?.len && !pick?.style && !pick?.dist && !pick?.surf}
<div class="mb-12 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}
{y}
{yLabel}
{range}
plotOptions={{ color: { legend: true } }}
/>
</div>
{:else}
<div class="mb-36 h-60 w-full md:mb-28 md:h-96">
<StatChart
class="mx-auto mb-12 h-full w-full max-w-3xl md:mb-10"
{stat}
{y}
{yLabel}
{range}
plotOptions={{ color: { legend: true } }}
/>
<div class="mx-auto grid w-full grid-cols-3 gap-y-6 md:max-w-2xl md:grid-cols-7">
{#if pick?.len}
<input
class="col-span-2 my-auto w-full justify-self-center-safe"
type="range"
id="raceLen"
min="1000"
max="3600"
step="100"
bind:value={raceLen}
/>
<span class="col-span-1 my-auto pl-2 text-sm">{raceLen}m</span>
{/if}
{#if pick?.style}
<select class="col-span-1 my-auto text-sm md:col-span-2" id="stylePick" bind:value={style}>
{#each race.RUNNING_STYLES as [name, style] (style)}
<option value={style}>{name}</option>
{/each}
</select>
{/if}
{#if pick?.dist}
<label class="col-span-1 my-auto pr-2 text-right text-sm md:inline" for="distPick">{raceLenType}</label>
<select class="col-span-1 my-auto text-sm" id="distPick" bind:value={distanceApt}>
{#each race.APTITUDE_LEVELS as l (l)}
<option value={l}>{race.AptitudeLevel[l]}</option>
{/each}
</select>
{/if}
{#if pick?.surf}
<label class="col-span-1 my-auto pr-2 text-right text-sm md:inline" for="surfPick">Surface</label>
<select class="col-span-1 my-auto text-sm" id="surfPick" bind:value={surfaceApt}>
{#each race.APTITUDE_LEVELS as l (l)}
<option value={l}>{l}</option>
{/each}
</select>
{/if}
</div>
</div>
{/if}
{/snippet}
<Article>
{#snippet head()}
<Sec h={1} id="top" class="text-center">Race Mechanics Charts</Sec>
<p>
This article is an interactive version of <a
href="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit"
target="_blank"
rel="noopener noreferrer">KuromiAK's race mechanics documentation</a
>
to give a better sense of how mechanics scale with stats and aptitudes.
</p>
<p>
Mechanics are organized in this article according to the stats that determine them. Those that vary with multiple stats,
e.g. spurt speed, appear in all relevant sections with differing horizontal axes. Mechanics that vary with race distance,
distance aptitudes, and surface aptitudes have controls to change each by their charts.
</p>
<p>
Only numeric mechanics that vary with stats and aptitudes are shown. Descriptions of concepts and conditions are left to the
original source.
</p>
{/snippet}
<Sec h={2} id="speed">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.5], { len: true, style: true, dist: true })}
<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"
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"
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.6])}
<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"
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"
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
class="mx-auto mb-12 h-full w-full max-w-3xl md:mb-10"
stat={race.Stat.Power}
y={laneChange}
yLabel="Lane Change Target Speed (CW/s)"
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"
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.5], { len: true, style: true, dist: true })}
<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.5], { len: true })}
<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"
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.4]} />
<StatChart stat={race.Stat.Guts} y={ssDur} yLabel="Duration (s)" range={[0, 14]} />
</div>
<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.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"
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
class="mx-auto mb-12 h-full w-full max-w-3xl md:mb-10"
stat={race.Stat.Wit}
yArea={secSpeed}
yLabel="Section Speed (m/s)"
range={[17, 23]}
plotOptions={{ color: { legend: true } }}
/>
<div class="mx-auto flex w-full md:max-w-2xl">
<label class="my-auto hidden flex-1 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-1 pl-2 text-sm">{raceLen}m</span>
<label class="my-auto hidden flex-1 pr-2 text-right text-sm md:inline" for="secSpeedStyle">Style</label>
<select class="my-auto flex-1 text-sm" id="secSpeedStyle" bind:value={style}>
{#each race.RUNNING_STYLES as [name, style] (style)}
<option value={style}>{name}</option>
{/each}
</select>
</div>
</div>
<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, 75])}
<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"
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>
{@render statChart(race.Stat.Wit, skillChance, 'Skill Activation Chance (%)', [0, 100])}
<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"
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>
-3
View File
@@ -1,3 +0,0 @@
<h1>Inheritance Chance</h1>
<p>Given a legacy, calculate the probability distribution of activation counts for each spark.</p>
<p>TODO</p>
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
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; {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>
@@ -16,6 +16,7 @@
Stat, Stat,
} from '$lib/race'; } from '$lib/race';
import StatChart from '$lib/StatChart.svelte'; import StatChart from '$lib/StatChart.svelte';
import { resolve } from '$app/paths';
import SpeedDur from './SpeedDur.svelte'; import SpeedDur from './SpeedDur.svelte';
let rawPower = $state(1200); let rawPower = $state(1200);
@@ -29,7 +30,7 @@
const careerMod = $derived(isCareer ? 400 : 0); const careerMod = $derived(isCareer ? 400 : 0);
const powerStat = $derived(rawPower + careerMod); const powerStat = $derived(rawPower + careerMod);
const gutsStat = $derived(rawGuts + careerMod); const gutsStat = $derived(rawGuts + careerMod);
const style = $derived(isRunaway ? RunningStyle.GreatEscape : RunningStyle.FrontRunner); const style = $derived(isRunaway ? RunningStyle.Runaway : RunningStyle.FrontRunner);
const phases = [Phase.EarlyRace, Phase.MidRace, Phase.LateRace] as const; const phases = [Phase.EarlyRace, Phase.MidRace, Phase.LateRace] as const;
const accel = $derived(phases.map((p) => acceleration(powerStat, style, surfApt, p))); const accel = $derived(phases.map((p) => acceleration(powerStat, style, surfApt, p)));
@@ -163,21 +164,13 @@
{/each} {/each}
</div> </div>
<div class="mx-auto grid h-96 grid-cols-1 py-4 md:grid-cols-2"> <div class="mx-auto grid h-96 grid-cols-1 py-4 md:grid-cols-2">
<StatChart <StatChart class="flex-1" stat={Stat.Guts} y={ssY} yLabel="Lengths Gained" range={[0, 2]} xRule={gutsStat} yRule={ssYRule} />
class="flex-1"
stat={Stat.Guts}
y={ssY}
yLabel="Lengths Gained"
range={[0, 1.5, 2.5]}
xRule={gutsStat}
yRule={ssYRule}
/>
<StatChart <StatChart
class="flex-1" class="flex-1"
stat={Stat.Power} stat={Stat.Power}
y={lcY} y={lcY}
yLabel="Lengths Gained" yLabel="Lengths Gained"
range={[0, 1.5, 2.5]} range={[0, 1.1]}
xRule={powerStat} xRule={powerStat}
yRule={lcYRule} yRule={lcYRule}
/> />
@@ -212,7 +205,7 @@
href="https://lanecalc.hf-uma.net/" href="https://lanecalc.hf-uma.net/"
target="_blank" target="_blank"
rel="noopener noreferrer">危険回避シミュ</a rel="noopener noreferrer">危険回避シミュ</a
>. >, or <a href={resolve('/doc/lanecalc')}>Gaikokujin's Guide to Lanecalc</a> for English instructions on how to use it.
</li> </li>
</ul> </ul>
</li> </li>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { HORSE_LENGTH, speedGain } from '$lib/race'; import { HORSE_LENGTH, speedGain } from '$lib/race';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import CalcInfo from '../CalcInfo.svelte';
interface Props { interface Props {
children: Snippet; children: Snippet;
@@ -21,13 +22,12 @@
const text = $derived(gain.map(fmtp).join(' ')); const text = $derived(gain.map(fmtp).join(' '));
</script> </script>
<div <CalcInfo title={children} class="max-w-80 flex-1">
class="m-2 flex h-full w-full max-w-80 flex-1 flex-col rounded-md border p-2 text-center shadow-sm transition-shadow hover:shadow-md" {text} L
> {#snippet additional()}
<div class="block">{@render children()}</div>
<span class="block text-xl">{text} L</span>
<div class="flex flex-row"> <div class="flex flex-row">
<span class="flex-1 text-xs">{fmtp(speed)} m/s</span> <span class="flex-1 text-xs">{fmtp(speed)} m/s</span>
<span class="flex-1 text-xs">{dur.toFixed(3)} s</span> <span class="flex-1 text-xs">{dur.toFixed(3)} s</span>
</div> </div>
</div> {/snippet}
</CalcInfo>
+144
View File
@@ -0,0 +1,144 @@
<script lang="ts">
import type { ComputedSeries } from '$lib/chart';
import * as race from '$lib/race';
import StatChart from '$lib/StatChart.svelte';
import CalcInfo from '../CalcInfo.svelte';
const skillSpeeds = [0.45, 0.35, 0.25, 0.15] as const;
let rawSpeed: number = $state(1200);
let rawGuts: number = $state(1200);
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(race.spurtSpeed(speedStat, gutsStat, style, distanceApt, raceLen));
const duelBonus = $derived(race.duelSpeedMod(gutsStat));
const sProf = $derived([
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([
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,
race.inverseSpurtSpeed(speed + v, gutsStat, opponentStyle, race.AptitudeLevel.S, raceLen) - careerMod,
]),
);
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] = [20, 27.5];
</script>
<h1 class="text-4xl">Spurt Speed Calculator</h1>
<div class="mx-auto mt-8 grid max-w-4xl grid-cols-1 rounded-md text-center shadow-md ring md:grid-cols-4">
<div class="m-4">
<label for="speedStat">Speed Stat</label>
<input type="number" id="speedStat" required bind:value={rawSpeed} class="w-full" />
</div>
<div class="m-4">
<label for="gutsStat">Guts Stat</label>
<input type="number" id="gutsStat" required bind:value={rawGuts} 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="distanceApt">Distance Aptitude</label>
<select id="distanceApt" required bind:value={distanceApt} class="w-full">
{#each race.APTITUDE_LEVELS as apt (apt)}
<option value={apt}>{race.AptitudeLevel[apt]}</option>
{/each}
</select>
</div>
<div class="m-4 md:col-start-2">
<label for="raceLen">Race Distance</label>
<input type="number" id="raceLen" required min="1000" max="3600" step="100" bind:value={raceLen} class="w-full" />
</div>
<div class="m-4 self-center">
<label for="isCareer" class="mr-1 align-middle">In Career</label>
<input type="checkbox" id="isCareer" role="switch" bind:checked={isCareer} class="min-h-6 min-w-6 align-middle" />
</div>
</div>
<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 {race.AptitudeLevel[level]} proficiency, the equivalent speed is
</span>
<div class="flex flex-col md:flex-row">
{#each race.RUNNING_STYLES as [styleName, s] (s)}
<CalcInfo title={styleName} class={['flex-1', { 'border-2': s === style }]}>{inv[s]}</CalcInfo>
{/each}
</div>
</div>
{/each}
<div class="mx-auto max-w-3xl">
<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 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)}
<CalcInfo title={`+${v.toFixed(2)}`} class="flex-1">{inv}</CalcInfo>
{/each}
</div>
</div>
<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>
-6
View File
@@ -1,6 +0,0 @@
<h1>Spark Generation Chance</h1>
<p>
Given a legacy, calculate the chance of generating each spark if you fulfill the conditions to do so, and the distribution of
total spark counts.
</p>
<p>TODO</p>
-141
View File
@@ -1,141 +0,0 @@
<script lang="ts">
import type { ComputedSeries } from '$lib/chart';
import { AptitudeLevel, inverseSpurtSpeed, RunningStyle, spurtSpeed, Stat } from '$lib/race';
import StatChart from '$lib/StatChart.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 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 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,
]);
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,
]);
const skillProf = $derived(
skillSpeeds.map((v) => [v, inverseSpurtSpeed(speed + v, gutsStat, opponentStyle, 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) }
: null,
]);
const range: [number, number] = $derived([
spurtSpeed(200, gutsStat, RunningStyle.GreatEscape, AptitudeLevel.C, raceLen),
spurtSpeed(2000, gutsStat, RunningStyle.EndCloser, AptitudeLevel.S, raceLen),
]);
</script>
<h1 class="text-4xl">Spurt Speed Calculator</h1>
<div class="mx-auto mt-8 grid max-w-4xl grid-cols-1 rounded-md text-center shadow-md ring md:grid-cols-4">
<div class="m-4">
<label for="speedStat">Speed Stat</label>
<input type="number" id="speedStat" required bind:value={rawSpeed} class="w-full" />
</div>
<div class="m-4">
<label for="gutsStat">Guts Stat</label>
<input type="number" id="gutsStat" required bind:value={rawGuts} class="w-full" />
</div>
<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}
</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}
</select>
</div>
<div class="m-4 md:col-start-2">
<label for="raceLen">Race Distance</label>
<input type="number" id="raceLen" required min="1000" max="3600" step="100" bind:value={raceLen} class="w-full" />
</div>
<div class="m-4 self-center">
<label for="isCareer" class="mr-1 align-middle">In Career</label>
<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 max-w-3xl">
<span class="mt-8 block w-full">
With {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}
</div>
</div>
{/each}
<div class="mx-auto max-w-3xl">
<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}
</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>
{/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>
-4
View File
@@ -1,4 +0,0 @@
<h1>My Veterans</h1>
<p>Set up and track your veterans for Zenno Rob Roy's inspiration and spark calculators.</p>
<p>All data is saved on your own machine, not transmitted or shared unless you explicitly choose to do so.</p>
<p>TODO</p>
+17 -14
View File
@@ -1,6 +1,5 @@
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
export default defineConfig({ export default defineConfig({
@@ -9,19 +8,20 @@ export default defineConfig({
test: { test: {
expect: { requireAssertions: true }, expect: { requireAssertions: true },
projects: [ projects: [
{ // Playwright tests are disabled because I don't feel like setting it up in NixOS.
extends: './vite.config.ts', // {
test: { // extends: './vite.config.ts',
name: 'client', // test: {
browser: { // name: 'client',
enabled: true, // browser: {
provider: playwright(), // enabled: true,
instances: [{ browser: 'chromium', headless: true }], // provider: playwright(),
}, // instances: [{ browser: 'chromium', headless: true }],
include: ['src/**/*.svelte.{test,spec}.{js,ts}'], // },
exclude: ['src/lib/server/**'], // include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
}, // exclude: ['src/lib/server/**'],
}, // },
// },
{ {
extends: './vite.config.ts', extends: './vite.config.ts',
@@ -39,6 +39,9 @@ export default defineConfig({
'/api': { '/api': {
target: 'http://localhost:13669', target: 'http://localhost:13669',
}, },
'/img': {
target: 'http://localhost:13669',
},
}, },
}, },
}); });