11 Commits
Author SHA1 Message Date
zephyr ddaf100069 meta: update go version in ci container
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline was successful
2026-11-03 09:09:36 -05:00
zephyr f013aa4765 umadump/ism: remove old field 2026-11-03 09:09:36 -05:00
zephyr 2f676f0a62 umadump/ism: resparking info is populated now 2026-11-03 09:09:36 -05:00
zephyr 04cb6388c3 zenno/race/skillzone: indicate inherits
ci/woodpecker/push/horsebot Pipeline failed
ci/woodpecker/push/zenno Pipeline was successful
2026-10-31 21:12:17 -04:00
zephyr de2b871862 zenno: link up race/skillzone
ci/woodpecker/push/horsebot Pipeline failed
ci/woodpecker/push/zenno Pipeline was successful
2026-10-31 13:46:50 -04:00
zephyr c95bffbcb8 zenno/doc/race: disclaimer and bug reporting info 2026-10-31 13:45:13 -04:00
zephyr 04be0b75a9 zenno: lint fixes
ci/woodpecker/push/horsebot Pipeline failed
ci/woodpecker/push/zenno Pipeline was successful
2026-10-31 13:23:15 -04:00
zephyr 5a2b9b7bf4 zenno/race/skillzone: new tool for viewing skill activation locations
Fixes #48.
2026-10-31 13:21:37 -04:00
zephyr 2d935b87ec zenno/doc/fans: update attribution 2026-10-31 13:21:37 -04:00
zephyr f623388291 umadump/ism: add missing fields
ci/woodpecker/push/horsebot Pipeline failed
ci/woodpecker/push/zenno Pipeline failed
2026-10-24 23:00:43 -04:00
zephyr 74185d92d4 cmd/ismdb: tool to load auto mode umadumps into sqlite 2026-10-24 23:00:30 -04:00
14 changed files with 1392 additions and 24 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ when:
steps: steps:
- name: test - name: test
image: golang:1.26-alpine image: golang:1.27-alpine
commands: commands:
- go vet ./... - go vet ./...
- go test ./... - go test ./...
+275 -8
View File
@@ -11,10 +11,11 @@ import (
"zombiezen.com/go/sqlite/sqlitemigration" "zombiezen.com/go/sqlite/sqlitemigration"
"zombiezen.com/go/sqlite/sqlitex" "zombiezen.com/go/sqlite/sqlitex"
"git.sunturtle.xyz/zephyr/horse"
"git.sunturtle.xyz/zephyr/horse/umadump/ism" "git.sunturtle.xyz/zephyr/horse/umadump/ism"
) )
const appID int32 = 0x4acedb const appID int32 = 0x157db
//go:embed schema-00.sql //go:embed schema-00.sql
var schema0 string var schema0 string
@@ -50,6 +51,8 @@ func opendb(uri, journalMode string) *sqlitemigration.Pool {
} }
func insert(ctx context.Context, db *sqlitemigration.Pool, log *slog.Logger, career *ism.WorkIdleSingleMode, trainerID int64) (err error) { func insert(ctx context.Context, db *sqlitemigration.Pool, log *slog.Logger, career *ism.WorkIdleSingleMode, trainerID int64) (err error) {
log = log.With(slog.Int64("trainer", trainerID), slog.Int("id", int(career.End.Chara.SingleModeCharaID)))
conn, err := db.Take(ctx) conn, err := db.Take(ctx)
if err != nil { if err != nil {
return err return err
@@ -57,12 +60,52 @@ func insert(ctx context.Context, db *sqlitemigration.Pool, log *slog.Logger, car
defer db.Put(conn) defer db.Put(conn)
defer sqlitex.Transaction(conn)(&err) defer sqlitex.Transaction(conn)(&err)
// Do the first insert separately so we can check whether the career is
// already in, and so that the join doesn't report the same error repeatedly
// if there's something wrong with the db.
if err := insertCareer(conn, log, career, trainerID); err != nil {
code := sqlite.ErrCode(err)
if code == sqlite.ResultConstraintUnique {
log.InfoContext(ctx, "already loaded")
return nil
}
return err
}
trainedChara := career.End.Chara.SingleModeCharaID
err = errors.Join(
insertConditions(conn, log, career.ProgressLog.CharaEffectLog, trainerID, trainedChara),
insertGain(conn, log, &career.ProgressLog.EventGain, trainerID, trainedChara, 0),
insertGainSkillHint(conn, log, career.ProgressLog.EventGain.SkillTips, trainerID, trainedChara, 0),
insertGain(conn, log, &career.ProgressLog.SuccessionGain, trainerID, trainedChara, 1),
insertGainSkillHint(conn, log, career.ProgressLog.SuccessionGain.SkillTips, trainerID, trainedChara, 1),
)
for _, s := range career.ProgressLog.SupportCardGains {
err = errors.Join(
err,
insertGain(conn, log, &s.GainInfo, trainerID, trainedChara, int32(s.SupportCardID)),
insertGainSkillHint(conn, log, s.GainInfo.SkillTips, trainerID, trainedChara, int32(s.SupportCardID)),
)
}
err = errors.Join(
err,
insertInspirations(conn, log, career.ProgressLog.SuccessionFactorGains, trainerID, trainedChara),
insertRaces(conn, log, career.ProgressLog.RaceHistory, trainerID, trainedChara),
insertGainedSkills(conn, log, career.ProgressLog.GainSkillIDs, trainerID, trainedChara),
insertCareerSkills(conn, log, career.End.Chara.Skills, trainerID, trainedChara),
insertCareerSkillHints(conn, log, career.End.Chara.SkillTips, trainerID, trainedChara),
insertSupportCards(conn, log, career.End.Chara.SupportCards, trainerID, trainedChara),
insertStartSkills(conn, log, career.ProgressInfo.Chara.Skills, trainerID, trainedChara),
insertStartSkillHints(conn, log, career.ProgressInfo.Chara.SkillTips, trainerID, trainedChara),
)
return err
} }
//go:embed insert-career.sql //go:embed insert-career.sql
var insertCareerSQL string var insertCareerSQL string
func insertCareer(conn *sqlite.Conn, career *ism.WorkIdleSingleMode, trainerID int64) error { func insertCareer(conn *sqlite.Conn, log *slog.Logger, career *ism.WorkIdleSingleMode, trainerID int64) error {
log.Debug("insert career")
stmt := conn.Prep(insertCareerSQL) stmt := conn.Prep(insertCareerSQL)
stmt.SetInt64("$trainer_id", trainerID) stmt.SetInt64("$trainer_id", trainerID)
stmt.SetInt64("$trained_chara_id", int64(career.End.Chara.SingleModeCharaID)) stmt.SetInt64("$trained_chara_id", int64(career.End.Chara.SingleModeCharaID))
@@ -123,17 +166,18 @@ func insertCareer(conn *sqlite.Conn, career *ism.WorkIdleSingleMode, trainerID i
stmt.SetInt64("$reserve_race_program_id", int64(career.End.Chara.ReserveRaceProgramID)) stmt.SetInt64("$reserve_race_program_id", int64(career.End.Chara.ReserveRaceProgramID))
stmt.SetInt64("$race_running_style", int64(career.End.Chara.RaceRunningStyle)) stmt.SetInt64("$race_running_style", int64(career.End.Chara.RaceRunningStyle))
stmt.SetInt64("$is_short_race", int64(career.End.Chara.IsShortRace)) stmt.SetInt64("$is_short_race", int64(career.End.Chara.IsShortRace))
stmt.SetInt64("$succession_trained_chara_id_1", int64(career.OldChara.SuccessionTrainedCharaID1)) stmt.SetInt64("$succession_trained_chara_id_1", int64(career.End.Chara.SuccessionTrainedCharaID1))
stmt.SetInt64("$succession_trained_chara_id_2", int64(career.OldChara.SuccessionTrainedCharaID2)) stmt.SetInt64("$succession_trained_chara_id_2", int64(career.End.Chara.SuccessionTrainedCharaID2))
_, err := stmt.Step() _, err := stmt.Step()
err = errors.Join(err, stmt.Reset()) err = errors.Join(err, stmt.Reset())
if err != nil { if err != nil {
return fmt.Errorf("inserting race course: %w", err) return fmt.Errorf("inserting career: %w", err)
} }
return nil return nil
} }
func insertConditions(conn *sqlite.Conn, cs []ism.CharaEffectLog, trainerID int64, trainedChara int32) error { func insertConditions(conn *sqlite.Conn, log *slog.Logger, cs []ism.CharaEffectLog, trainerID int64, trainedChara int32) error {
log.Debug("insert conditions")
const sql = `INSERT INTO condition VALUES (?, ?, ?, ?)` const sql = `INSERT INTO condition VALUES (?, ?, ?, ?)`
stmt := conn.Prep(sql) stmt := conn.Prep(sql)
stmt.BindInt64(1, trainerID) stmt.BindInt64(1, trainerID)
@@ -153,7 +197,8 @@ func insertConditions(conn *sqlite.Conn, cs []ism.CharaEffectLog, trainerID int6
//go:embed insert-gain.sql //go:embed insert-gain.sql
var insertGainSQL string var insertGainSQL string
func insertGain(conn *sqlite.Conn, gain *ism.IdleSingleModeGainInfo, trainerID int64, trainedChara int32, source int32) error { func insertGain(conn *sqlite.Conn, log *slog.Logger, gain *ism.IdleSingleModeGainInfo, trainerID int64, trainedChara int32, source int32) error {
log.Debug("insert gain", slog.Int("source", int(source)))
stmt := conn.Prep(insertGainSQL) stmt := conn.Prep(insertGainSQL)
stmt.SetInt64("$trainer_id", trainerID) stmt.SetInt64("$trainer_id", trainerID)
stmt.SetInt64("$trained_chara_id", int64(trainedChara)) stmt.SetInt64("$trained_chara_id", int64(trainedChara))
@@ -182,7 +227,229 @@ func insertGain(conn *sqlite.Conn, gain *ism.IdleSingleModeGainInfo, trainerID i
_, err := stmt.Step() _, err := stmt.Step()
err = errors.Join(err, stmt.Reset()) err = errors.Join(err, stmt.Reset())
if err != nil { if err != nil {
return fmt.Errorf("inserting gain info: %w", err) return fmt.Errorf("inserting gain info for source %d: %w", source, err)
}
return nil
}
func insertGainSkillHint(conn *sqlite.Conn, log *slog.Logger, tips []ism.SkillTip, trainerID int64, trainedChara int32, source int32) error {
log.Debug("insert gain skill hints", slog.Int("source", int(source)))
stmt := conn.Prep(`INSERT INTO gain_skill_hint VALUES (?, ?, ?, ?, ?, ?)`)
stmt.BindInt64(1, trainerID)
stmt.BindInt64(2, int64(trainedChara))
stmt.BindInt64(3, int64(source))
for _, t := range tips {
stmt.BindInt64(4, int64(t.GroupID))
stmt.BindInt64(5, int64(t.Rarity))
stmt.BindInt64(6, int64(t.Level))
_, err := stmt.Step()
err = errors.Join(err, stmt.Reset())
if err != nil {
return fmt.Errorf("inserting skill hint for group %d, rarity %d from source %d: %w", t.GroupID, t.Rarity, source, err)
}
}
return nil
}
func insertInspirations(conn *sqlite.Conn, log *slog.Logger, inspos []ism.IdleSingleModeSuccessionFactorGain, trainerID int64, trainedChara int32) error {
log.Debug("insert inspirations")
stmt := conn.Prep(`INSERT INTO inspiration VALUES (?, ?, ?, ?, ?)`)
stmt.BindInt64(1, trainerID)
stmt.BindInt64(2, int64(trainedChara))
for _, yr := range inspos {
stmt.BindInt64(3, int64(yr.Year))
for _, f := range yr.GainFactorInfo {
stmt.BindInt64(4, int64(f.FactorID))
stmt.BindInt64(5, int64(f.Level))
_, err := stmt.Step()
err = errors.Join(err, stmt.Reset())
if err != nil {
return fmt.Errorf("inserting spark: %w", err)
}
}
}
return nil
}
func insertRaces(conn *sqlite.Conn, log *slog.Logger, races []ism.IdleSingleModeRaceHistory, trainerID int64, trainedChara int32) error {
log.Debug("insert races")
raceStmt := conn.Prep(`INSERT INTO race_history VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
rewardStmt := conn.Prep(`INSERT INTO race_reward VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
raceStmt.BindInt64(1, trainerID)
raceStmt.BindInt64(2, int64(trainedChara))
rewardStmt.BindInt64(1, trainerID)
rewardStmt.BindInt64(2, int64(trainedChara))
for _, r := range races {
raceStmt.BindInt64(3, int64(r.RaceHistory.Turn))
raceStmt.BindInt64(4, int64(r.RaceHistory.ProgramID))
raceStmt.BindInt64(5, int64(r.RaceHistory.Weather))
raceStmt.BindInt64(6, int64(r.RaceHistory.GroundCondition))
raceStmt.BindInt64(7, int64(r.RaceHistory.RunningStyle))
raceStmt.BindInt64(8, int64(r.RaceHistory.ResultRank))
raceStmt.BindInt64(9, int64(r.RaceHistory.FrameOrder))
raceStmt.BindInt64(10, int64(r.RaceRewardInfo.GainedFans))
raceStmt.BindInt64(11, int64(r.LoseTipsID))
_, err := raceStmt.Step()
err = errors.Join(err, raceStmt.Reset())
if err != nil {
return fmt.Errorf("inserting race history: %w", err)
}
rewardStmt.BindInt64(3, int64(r.RaceHistory.Turn))
rewardStmt.BindBool(7, false)
rewardStmt.BindBool(8, false)
rewardStmt.BindBool(9, false)
for _, reward := range r.RaceRewardInfo.RaceReward {
rewardStmt.BindInt64(4, int64(reward.ItemType))
rewardStmt.BindInt64(5, int64(reward.ItemID))
rewardStmt.BindInt64(6, int64(reward.ItemNum))
_, err := rewardStmt.Step()
err = errors.Join(err, rewardStmt.Reset())
if err != nil {
return fmt.Errorf("inserting race reward: %w", err)
}
}
rewardStmt.BindBool(7, true)
for _, reward := range r.RaceRewardInfo.RaceRewardBonus {
rewardStmt.BindInt64(4, int64(reward.ItemType))
rewardStmt.BindInt64(5, int64(reward.ItemID))
rewardStmt.BindInt64(6, int64(reward.ItemNum))
_, err := rewardStmt.Step()
err = errors.Join(err, rewardStmt.Reset())
if err != nil {
return fmt.Errorf("inserting race reward: %w", err)
}
}
rewardStmt.BindBool(7, false)
rewardStmt.BindBool(8, true)
for _, reward := range r.RaceRewardInfo.RaceRewardPlusBonus {
rewardStmt.BindInt64(4, int64(reward.ItemType))
rewardStmt.BindInt64(5, int64(reward.ItemID))
rewardStmt.BindInt64(6, int64(reward.ItemNum))
_, err := rewardStmt.Step()
err = errors.Join(err, rewardStmt.Reset())
if err != nil {
return fmt.Errorf("inserting race reward: %w", err)
}
}
rewardStmt.BindBool(8, false)
rewardStmt.BindBool(9, true)
for _, reward := range r.RaceRewardInfo.RaceRewardBonusWin {
rewardStmt.BindInt64(4, int64(reward.ItemType))
rewardStmt.BindInt64(5, int64(reward.ItemID))
rewardStmt.BindInt64(6, int64(reward.ItemNum))
_, err := rewardStmt.Step()
err = errors.Join(err, rewardStmt.Reset())
if err != nil {
return fmt.Errorf("inserting race reward: %w", err)
}
}
}
return nil
}
func insertGainedSkills(conn *sqlite.Conn, log *slog.Logger, skills []horse.SkillID, trainerID int64, trainedChara int32) error {
log.Debug("insert gained skills")
stmt := conn.Prep(`INSERT INTO gained_skill VALUES (?, ?, ?)`)
stmt.BindInt64(1, trainerID)
stmt.BindInt64(2, int64(trainedChara))
for _, sk := range skills {
stmt.BindInt64(3, int64(sk))
_, err := stmt.Step()
err = errors.Join(err, stmt.Reset())
if err != nil {
return fmt.Errorf("inserting gain skill: %w", err)
}
}
return nil
}
func insertCareerSkills(conn *sqlite.Conn, log *slog.Logger, skills []ism.Skill, trainerID int64, trainedChara int32) error {
log.Debug("insert career skills")
stmt := conn.Prep(`INSERT INTO career_skill VALUES (?, ?, ?, ?)`)
stmt.BindInt64(1, trainerID)
stmt.BindInt64(2, int64(trainedChara))
for _, sk := range skills {
stmt.BindInt64(3, int64(sk.ID))
stmt.BindInt64(4, int64(sk.Level))
_, err := stmt.Step()
err = errors.Join(err, stmt.Reset())
if err != nil {
return fmt.Errorf("inserting skill: %w", err)
}
}
return nil
}
func insertCareerSkillHints(conn *sqlite.Conn, log *slog.Logger, hints []ism.SkillTip, trainerID int64, trainedChara int32) error {
log.Debug("insert career skill hints")
stmt := conn.Prep(`INSERT INTO career_skill_hint VALUES (?, ?, ?, ?, ?)`)
stmt.BindInt64(1, trainerID)
stmt.BindInt64(2, int64(trainedChara))
for _, h := range hints {
stmt.BindInt64(3, int64(h.GroupID))
stmt.BindInt64(4, int64(h.Rarity))
stmt.BindInt64(5, int64(h.Level))
_, err := stmt.Step()
err = errors.Join(err, stmt.Reset())
if err != nil {
return fmt.Errorf("inserting skill hint: %w", err)
}
}
return nil
}
func insertSupportCards(conn *sqlite.Conn, log *slog.Logger, supps []ism.SingleModeSupportCard, trainerID int64, trainedChara int32) error {
log.Debug("insert support cards")
stmt := conn.Prep(`INSERT INTO career_support_card VALUES (?, ?, ?, ?, ?, ?, ?)`)
stmt.BindInt64(1, trainerID)
stmt.BindInt64(2, int64(trainedChara))
for _, s := range supps {
stmt.BindInt64(3, int64(s.Position))
stmt.BindInt64(4, int64(s.ID))
stmt.BindInt64(5, int64(s.LimitBreak))
stmt.BindInt64(6, int64(s.Exp))
stmt.BindInt64(7, int64(s.OwnerID))
_, err := stmt.Step()
err = errors.Join(err, stmt.Reset())
if err != nil {
return fmt.Errorf("inserting support card: %w", err)
}
}
return nil
}
func insertStartSkills(conn *sqlite.Conn, log *slog.Logger, skills []ism.Skill, trainerID int64, trainedChara int32) error {
log.Debug("insert starting skills")
stmt := conn.Prep(`INSERT INTO start_skill VALUES (?, ?, ?, ?)`)
stmt.BindInt64(1, trainerID)
stmt.BindInt64(2, int64(trainedChara))
for _, sk := range skills {
stmt.BindInt64(3, int64(sk.ID))
stmt.BindInt64(4, int64(sk.Level))
_, err := stmt.Step()
err = errors.Join(err, stmt.Reset())
if err != nil {
return fmt.Errorf("inserting skill: %w", err)
}
}
return nil
}
func insertStartSkillHints(conn *sqlite.Conn, log *slog.Logger, hints []ism.SkillTip, trainerID int64, trainedChara int32) error {
log.Debug("insert starting skill hints")
stmt := conn.Prep(`INSERT INTO start_skill_hint VALUES (?, ?, ?, ?, ?)`)
stmt.BindInt64(1, trainerID)
stmt.BindInt64(2, int64(trainedChara))
for _, h := range hints {
stmt.BindInt64(3, int64(h.GroupID))
stmt.BindInt64(4, int64(h.Rarity))
stmt.BindInt64(5, int64(h.Level))
_, err := stmt.Step()
err = errors.Join(err, stmt.Reset())
if err != nil {
return fmt.Errorf("inserting skill hint: %w", err)
}
} }
return nil return nil
} }
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"context"
"encoding/json"
"flag"
"log/slog"
"os"
"os/signal"
"path/filepath"
"git.sunturtle.xyz/zephyr/horse/umadump/ism"
)
func main() {
var (
uri, journalMode string
fs []string
trainer int64
logLevel slog.Level
)
flag.StringVar(&uri, "db", "idle_single_mode.sqlite3", "output database `URI` or filename")
flag.StringVar(&journalMode, "journal", "memory", "journal `mode` to apply to database connections")
flag.Int64Var(&trainer, "t", 0, "trainer `ID` to associate with the data")
flag.TextVar(&logLevel, "log", slog.LevelInfo, "log `level`")
flag.Func("d", "`dir`ectory containing idle single mode dumps to load (repeatable)", func(s string) error {
es, err := os.ReadDir(s)
for _, e := range es {
if e.IsDir() {
continue
}
fs = append(fs, filepath.Join(s, e.Name()))
}
return err
})
flag.Parse()
fs = append(fs, flag.Args()...)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
go func() {
<-ctx.Done()
stop()
}()
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel}))
db := opendb(uri, journalMode)
ok := 0
for _, n := range fs {
log := log.With(slog.String("file", n))
log.Info("begin")
f, err := os.Open(n)
if err != nil {
log.Error("open", slog.Any("err", err))
continue
}
var career ism.WorkIdleSingleMode
d := json.NewDecoder(f)
d.DisallowUnknownFields()
log.Debug("decode")
if err := d.Decode(&career); err != nil {
log.Error("load", slog.Any("err", err))
continue
}
log.Debug("insert")
err = insert(ctx, db, log, &career, trainer)
if err != nil {
log.Error("insert", slog.Any("err", err))
continue
}
ok++
}
slog.Info("done", slog.Int("careers", ok))
}
+3 -4
View File
@@ -181,14 +181,13 @@ CREATE TABLE IF NOT EXISTS race_reward (
CREATE INDEX IF NOT EXISTS race_reward_by_career_turn ON race_reward (trainer_id, trained_chara_id, turn); CREATE INDEX IF NOT EXISTS race_reward_by_career_turn ON race_reward (trainer_id, trained_chara_id, turn);
CREATE INDEX IF NOT EXISTS race_reward_by_item ON race_reward (item_id); CREATE INDEX IF NOT EXISTS race_reward_by_item ON race_reward (item_id);
CREATE TABLE IF NOT EXISTS gain_skill ( CREATE TABLE IF NOT EXISTS gained_skill (
trainer_id INTEGER NOT NULL DEFAULT 0, trainer_id INTEGER NOT NULL DEFAULT 0,
trained_chara_id INTEGER NOT NULL, trained_chara_id INTEGER NOT NULL,
source INTEGER NOT NULL,
skill_id INTEGER NOT NULL, skill_id INTEGER NOT NULL,
PRIMARY KEY (trainer_id, trained_chara_id, source, skill_id), PRIMARY KEY (trainer_id, trained_chara_id, skill_id),
FOREIGN KEY (trainer_id, trained_chara_id, source) REFERENCES gain ON DELETE CASCADE FOREIGN KEY (trainer_id, trained_chara_id) REFERENCES career ON DELETE CASCADE
) STRICT; ) STRICT;
CREATE TABLE IF NOT EXISTS career_skill ( CREATE TABLE IF NOT EXISTS career_skill (
+6 -3
View File
@@ -21,7 +21,7 @@ type SingleModeSupportCard struct {
ID horse.SupportCardID `json:"support_card_id"` ID horse.SupportCardID `json:"support_card_id"`
LimitBreak int32 `json:"limit_break_count"` LimitBreak int32 `json:"limit_break_count"`
Exp int32 `json:"exp"` Exp int32 `json:"exp"`
OwnerID int32 `json:"owner_viewer_id"` OwnerID int64 `json:"owner_viewer_id"`
} }
type GroupOuting struct { type GroupOuting struct {
@@ -318,11 +318,12 @@ type End struct {
TrophyRewardInfo *umadump.Never `json:"trophy_reward_info"` TrophyRewardInfo *umadump.Never `json:"trophy_reward_info"`
PrevCharaGrade *umadump.Never `json:"prev_chara_grade"` PrevCharaGrade *umadump.Never `json:"prev_chara_grade"`
RaceAddRewardInfo []umadump.Never `json:"race_add_reward_info"` RaceAddRewardInfo []umadump.Never `json:"race_add_reward_info"`
ReservedRaceInfo *umadump.Never `json:"reserved_race_info"`
Missions []umadump.Never `json:"mission_list"` Missions []umadump.Never `json:"mission_list"`
StoryEventMissions []umadump.Never `json:"story_event_mission_list"` StoryEventMissions []umadump.Never `json:"story_event_mission_list"`
StoryEventCharaBonuses []umadump.Never `json:"story_event_chara_bonus_list"` StoryEventCharaBonuses []umadump.Never `json:"story_event_chara_bonus_list"`
StartDressInfo []umadump.Never `json:"start_dress_info"` StartDressInfo []umadump.Never `json:"start_dress_info"`
ResumeFactorSelect *umadump.Never `json:"resume_factor_select"` ResumeFactorSelect any `json:"resume_factor_select"` // don't care yet
RaceRandomPrograms []umadump.Never `json:"race_random_program_array"` RaceRandomPrograms []umadump.Never `json:"race_random_program_array"`
RaceRewardLimitMore []umadump.Never `json:"race_reward_limit_more_list"` RaceRewardLimitMore []umadump.Never `json:"race_reward_limit_more_list"`
SkillFilterSettings []umadump.Never `json:"skill_filter_setting_array"` SkillFilterSettings []umadump.Never `json:"skill_filter_setting_array"`
@@ -335,11 +336,13 @@ type ProgressInfo struct {
StartTime string `json:"start_time"` StartTime string `json:"start_time"`
EndTime string `json:"end_time"` EndTime string `json:"end_time"`
DressID int32 `json:"dress_id"` DressID int32 `json:"dress_id"`
StoryEventMissions []umadump.Never `json:"story_event_mission_list"`
StoryEventCharaBonuses []umadump.Never `json:"story_event_chara_bonus_list"`
} }
type WorkIdleSingleMode struct { type WorkIdleSingleMode struct {
ProgressInfo ProgressInfo `json:"progress_info"` ProgressInfo ProgressInfo `json:"progress_info"`
ProgressLog IdleSingleModeProgressLog `json:"progress_log_info"` ProgressLog IdleSingleModeProgressLog `json:"progress_log_info"`
End End `json:"end_info"` End End `json:"end_info"`
OldChara *SingleModeChara `json:"chara_info"` Extra map[string]any `json:"extra_data"`
} }
+84
View File
@@ -0,0 +1,84 @@
<script lang="ts">
import * as Plot from '@observablehq/plot';
import type { Attachment } from 'svelte/attachments';
import type { ClassValue } from 'svelte/elements';
import * as race from '$lib/race';
import * as racecourse from '$lib/data/racecourse';
export interface DrawSpan {
fy: string;
x1: number;
x2: number;
color: string;
label?: string;
}
interface Props {
course: racecourse.Course;
title?: string;
class?: ClassValue | null;
plotOptions?: Omit<Plot.PlotOptions, 'marks' | 'x' | 'y'>;
draw: DrawSpan[];
}
let { course, title, class: className, plotOptions, draw }: Props = $props();
let width = $state(0);
let height = $state(0);
const phases = $derived(race.phases(course.distance));
const phaseX = $derived([
0,
phases[race.Phase.EarlyRace],
phases[race.Phase.MidRace],
phases[race.Phase.LateRace],
phases[race.Phase.LastSpurt],
]);
const sectionX = $derived([0, ...racecourse.SECTIONS.map((s) => course.distance * s.endRate)]);
const drawTitles = $derived(draw.map((d) => d.fy));
const makeChart: Attachment = (el) => {
$effect(() => {
el?.firstChild?.remove();
el?.append(
Plot.plot({
width: width,
height,
clip: true,
title,
marginLeft: 50,
x: {
domain: [0, course.distance],
ticks: phaseX,
},
fy: {
label: null,
padding: 0,
domain: drawTitles,
},
...plotOptions,
marks: [
Plot.barX(draw, {
x1: 'x1',
x2: 'x2',
fy: 'fy',
fill: 'color',
fillOpacity: 0.8,
title: 'label',
}),
Plot.text(draw, { x: 'x1', fy: 'fy', text: 'label', dx: 2, textAnchor: 'start', fontSize: 16, fill: 'black' }),
Plot.ruleX(sectionX, { opacity: 0.25 }),
Plot.ruleX([phases[0], phases[1]], { opacity: 0.5 }),
],
}),
);
});
};
</script>
<div bind:clientWidth={width} bind:clientHeight={height} class={['flex h-full w-full flex-col md:flex-row', className]}>
<div role="img" {@attach makeChart}>
<span>the chart seems to have didn't</span>
</div>
</div>
+6 -1
View File
@@ -54,6 +54,11 @@ export const PAGES = {
name: 'Stamina Calculator', name: 'Stamina Calculator',
description: 'Compute stamina requirements and recovery probability distributions.', description: 'Compute stamina requirements and recovery probability distributions.',
}, },
{
route: resolve('/race/skillzone'),
name: 'Skill Activation Zones',
description: 'View locations where skills can activate on a given race course.',
},
] satisfies Page[], ] satisfies Page[],
career: [ career: [
{ {
@@ -109,7 +114,7 @@ export const PAGES = {
{ {
route: resolve('/doc/fans'), route: resolve('/doc/fans'),
name: 'Career Fan Gain Is Solved', name: 'Career Fan Gain Is Solved',
description: 'The exact formula for fan gain from career races – nothing "random" about it.' description: 'The exact formula for fan gain from career races – nothing "random" about it.',
}, },
] satisfies Page[], ] satisfies Page[],
} as const; } as const;
+145
View File
@@ -0,0 +1,145 @@
import * as z from 'zod';
import * as race from '$lib/race';
export enum InOut {
None = 1,
Inner = 2,
Outer = 3,
OuterToInner = 4,
}
export enum Turn {
Right = 1,
Left = 2,
RightStretch = 3,
LeftStretch = 4,
}
const Span = z.strictObject({
start: z.number(),
end: z.number(),
straight: z.literal([1, 2]).optional(),
corner: z.literal([1, 2, 3, 4]).optional(),
});
export type Span = z.infer<typeof Span>;
const Slope = z.strictObject({
per: z.int32(),
start: z.number(),
end: z.number(),
});
export type Slope = z.infer<typeof Slope>;
export const Course = z.strictObject({
race_course_id: z.int32(),
race_track_id: z.int32(),
race_track_name: z.string(),
distance: z.int32(),
ground: z.enum(race.Surface),
inout: z.enum(InOut),
turn: z.enum(Turn),
lane_max: z.int32(),
threshold1: z.enum(race.Stat).optional(),
threshold2: z.enum(race.Stat).optional(),
lane_start: z.array(z.int32()).length(18),
spans: z.array(Span),
slopes: z.array(Slope),
move_lane_point: z.number(),
});
export type Course = z.infer<typeof Course>;
export async function racecourses(): Promise<Course[]> {
const resp = await fetch('/api/global/racecourse');
return Course.array().parseAsync(await resp.json());
}
export interface SectionInfo {
section: number;
phase: race.Phase;
endRate: number;
posKeep?: boolean;
rush?: boolean;
spotStruggleEnter?: boolean;
}
export const SECTIONS: ReadonlyArray<SectionInfo> = [
{ section: 1, phase: race.Phase.EarlyRace, endRate: 1 / 24, posKeep: true },
{ section: 2, phase: race.Phase.EarlyRace, endRate: 2 / 24, posKeep: true, rush: true, spotStruggleEnter: true },
{ section: 3, phase: race.Phase.EarlyRace, endRate: 3 / 24, posKeep: true, rush: true, spotStruggleEnter: true },
{ section: 4, phase: race.Phase.EarlyRace, endRate: 4 / 24, posKeep: true, rush: true, spotStruggleEnter: true },
{ section: 5, phase: race.Phase.MidRace, endRate: 5 / 24, posKeep: true, rush: true, spotStruggleEnter: true },
{ section: 6, phase: race.Phase.MidRace, endRate: 6 / 24, posKeep: true, rush: true },
{ section: 7, phase: race.Phase.MidRace, endRate: 7 / 24, posKeep: true, rush: true },
{ section: 8, phase: race.Phase.MidRace, endRate: 8 / 24, posKeep: true, rush: true },
{ section: 9, phase: race.Phase.MidRace, endRate: 9 / 24, posKeep: true, rush: true },
{ section: 10, phase: race.Phase.MidRace, endRate: 10 / 24, posKeep: true },
{ section: 11, phase: race.Phase.MidRace, endRate: 11 / 24 },
{ section: 12, phase: race.Phase.MidRace, endRate: 12 / 24 },
{ section: 13, phase: race.Phase.MidRace, endRate: 13 / 24 },
{ section: 14, phase: race.Phase.MidRace, endRate: 14 / 24 },
{ section: 15, phase: race.Phase.MidRace, endRate: 15 / 24 },
{ section: 16, phase: race.Phase.MidRace, endRate: 16 / 24 },
{ section: 17, phase: race.Phase.LateRace, endRate: 17 / 24 },
{ section: 18, phase: race.Phase.LateRace, endRate: 18 / 24 },
{ section: 19, phase: race.Phase.LateRace, endRate: 19 / 24 },
{ section: 20, phase: race.Phase.LateRace, endRate: 20 / 24 },
{ section: 21, phase: race.Phase.LastSpurt, endRate: 21 / 24 },
{ section: 22, phase: race.Phase.LastSpurt, endRate: 22 / 24 },
{ section: 23, phase: race.Phase.LastSpurt, endRate: 23 / 24 },
{ section: 24, phase: race.Phase.LastSpurt, endRate: 24 / 24 },
];
const dirtOnly = [10101, 10103, 10104, 10105];
export function referName(course: Course): string {
const parts = [course.race_track_name, course.distance.toString()];
if (course.ground === race.Surface.Dirt && !dirtOnly.includes(course.race_track_id)) {
parts.push('Dirt');
}
switch (course.inout) {
case InOut.None:
break;
case InOut.Inner:
parts.push('(Inner)');
break;
case InOut.Outer:
parts.push('(Outer)');
break;
case InOut.OuterToInner:
parts.push('(Outer→Inner)');
break;
}
return parts.join(' ');
}
export function fillSlopes(slopes: Slope[], distance: number): Slope[] {
const r: Slope[] = [{ start: 0, end: slopes[0]?.start ?? distance, per: 0 }];
for (const [i, s] of slopes.entries()) {
r.push(s);
r.push({ start: s.end, end: slopes[i + 1]?.start ?? distance, per: 0 });
}
return r.filter((s) => s.start < s.end);
}
export enum DistanceType {
Sprint = 1,
Mile = 2,
Medium = 3,
Long = 4,
}
export function distanceType(course: Course): DistanceType {
if (course.distance < 1500) {
return DistanceType.Sprint;
}
if (course.distance < 1900) {
return DistanceType.Mile;
}
if (course.distance < 2500) {
return DistanceType.Medium;
}
return DistanceType.Long;
}
+82
View File
@@ -0,0 +1,82 @@
import { expect, test } from 'vitest';
import * as condition from './condition';
test.for([
{
c: '',
want: [],
},
{
c: 'all_corner_random==1',
want: [[{ type: 'all_corner_random', op: '==', arg: 1 }]],
},
{
c: 'corner!=0',
want: [[{ type: 'corner', op: '!=', arg: 0 }]],
},
{
c: 'order_rate<80',
want: [[{ type: 'order_rate', op: '<', arg: 80 }]],
},
{
c: 'distance_diff_top<=5',
want: [[{ type: 'distance_diff_top', op: '<=', arg: 5 }]],
},
{
c: 'motivation>=4',
want: [[{ type: 'motivation', op: '>=', arg: 4 }]],
},
{
c: 'compete_fight_count>0',
want: [[{ type: 'compete_fight_count', op: '>', arg: 0 }]],
},
{
c: 'phase==1&change_order_onetime<0',
want: [
[
{ type: 'phase', op: '==', arg: 1 },
{ type: 'change_order_onetime', op: '<', arg: 0 },
],
],
},
{
c: 'temptation_count==0&remain_distance<=201&remain_distance>=199&order>=4&order_rate<=70',
want: [
[
{ type: 'temptation_count', op: '==', arg: 0 },
{ type: 'remain_distance', op: '<=', arg: 201 },
{ type: 'remain_distance', op: '>=', arg: 199 },
{ type: 'order', op: '>=', arg: 4 },
{ type: 'order_rate', op: '<=', arg: 70 },
],
],
},
{
c: 'track_id==10001@track_id==10002@track_id==10004@track_id==10010@track_id==10103@track_id==10104',
want: [
[{ type: 'track_id', op: '==', arg: 10001 }],
[{ type: 'track_id', op: '==', arg: 10002 }],
[{ type: 'track_id', op: '==', arg: 10004 }],
[{ type: 'track_id', op: '==', arg: 10010 }],
[{ type: 'track_id', op: '==', arg: 10103 }],
[{ type: 'track_id', op: '==', arg: 10104 }],
],
},
{
c: 'distance_type==1&phase>=2&order_rate>=50&is_overtake==1@distance_type==2&phase>=2&order_rate>=50&is_overtake==1',
want: [
[
{ type: 'distance_type', op: '==', arg: 1 },
{ type: 'phase', op: '>=', arg: 2 },
{ type: 'order_rate', op: '>=', arg: 50 },
{ type: 'is_overtake', op: '==', arg: 1 },
],
[
{ type: 'distance_type', op: '==', arg: 2 },
{ type: 'phase', op: '>=', arg: 2 },
{ type: 'order_rate', op: '>=', arg: 50 },
{ type: 'is_overtake', op: '==', arg: 1 },
],
],
},
])('$c', ({ c, want }) => expect(condition.parse(c)).toEqual(want));
+407
View File
@@ -0,0 +1,407 @@
import * as race from '$lib/race';
import * as racecourse from '$lib/data/racecourse';
export enum Op {
Eq = '==',
Ne = '!=',
Le = '<=',
Lt = '<',
Ge = '>=',
Gt = '>',
}
export interface Condition {
type: string;
op: Op;
arg: number;
}
const re = /(?<type>[a-z0-9_]+)(?<op>[=!<>]=?)(?<arg>-?\d+)/;
function parseCond(conj: string): Condition {
// Using ! assertions because we want to explode if the parse fails.
const r = re.exec(conj)!.groups!;
return { type: r.type, op: r.op as Op, arg: parseInt(r.arg) };
}
export function parse(cond: string): Condition[][] {
if (cond === '') {
return [];
}
return cond.split('@').map((disj) => disj.split('&').map(parseCond));
}
export enum SpanType {
Never = 'N/A',
Random = 'Random',
Skill = 'Triggered',
Whenever = 'Immediate',
}
export interface Span {
start: number;
end: number;
type: SpanType;
}
function coursespans(spans: racecourse.Span[] | racecourse.Slope[], type: SpanType): Span[] {
return or(spans.map((s) => ({ start: s.start, end: s.end, type })));
}
function phase(course: racecourse.Course, op: Op, phase: race.Phase, type: SpanType): Span {
const p = race.phases(course.distance);
switch (op) {
case Op.Eq:
if (phase === race.Phase.EarlyRace) {
return { start: 0, end: p[race.Phase.EarlyRace], type };
}
return { start: p[(phase - 1) as race.Phase], end: p[phase], type };
case Op.Le:
return { start: 0, end: p[phase], type };
case Op.Ge:
if (phase === race.Phase.EarlyRace) {
return { start: 0, end: course.distance, type };
}
return { start: p[(phase - 1) as race.Phase], end: course.distance, type };
default:
// Other operators aren't used.
throw new Error('weird operator on phase condition');
}
}
function endafter(course: racecourse.Course, type: SpanType): Span {
return phase(course, Op.Ge, race.Phase.LateRace, type);
}
function is_finalcorner(course: racecourse.Course, toEnd: boolean, type: SpanType): Span {
const c = course.spans.findLast((s) => s.corner != null);
if (c == null) {
return { start: 0, end: 0, type: SpanType.Never };
}
return { start: c.start, end: toEnd ? course.distance : c.end, type };
}
function laststraight(course: racecourse.Course, type: SpanType): Span {
const s = course.spans[course.spans.length - 1];
return { start: s.start, end: s.end, type };
}
function distrate(course: racecourse.Course, cond: Condition, type: SpanType): Span {
switch (cond.op) {
case Op.Le:
case Op.Lt:
return { start: 0, end: Math.round((course.distance * cond.arg) / 100), type };
case Op.Ge:
case Op.Gt:
return { start: Math.round((course.distance * cond.arg) / 100), end: course.distance, type };
}
return { start: 0, end: 0, type: SpanType.Never };
}
function remaining(course: racecourse.Course, cond: Condition, type: SpanType): Span {
switch (cond.op) {
case Op.Eq:
return { start: course.distance - cond.arg - 1, end: course.distance - cond.arg + 1, type };
case Op.Ne:
throw new Error('weird operator on remaining distance');
case Op.Le:
case Op.Lt:
return { start: course.distance - cond.arg, end: course.distance, type };
case Op.Ge:
case Op.Gt:
return { start: 0, end: course.distance - cond.arg, type };
}
}
function later(s: Span, p: number): Span {
return { ...s, start: (s.start + s.end) * p };
}
function first(s: Span, p: number): Span {
return { ...s, end: (s.start + s.end) * p };
}
function accumulatetime(course: racecourse.Course, t: number, type: SpanType): Span {
return { start: race.baseSpeed(course.distance) * t, end: course.distance, type };
}
function intersection(a: Span, b: Span): Span {
const start = Math.max(a.start, b.start);
const end = Math.min(a.end, b.end);
if (start >= end) {
return { start: 0, end: 0, type: SpanType.Never };
}
switch (a.type) {
case SpanType.Never:
return { start: 0, end: 0, type: SpanType.Never };
case SpanType.Random:
case SpanType.Skill:
if (b.type === SpanType.Never) {
return { start: 0, end: 0, type: SpanType.Never };
}
// We use a.type on the assumption that we will never intersect
// a random span with a skill-triggered span.
return { start, end, type: a.type };
case SpanType.Whenever:
return { start, end, type: b.type };
}
}
function opcompare(cond: Condition, x: number) {
switch (cond.op) {
case Op.Eq:
return x === cond.arg;
case Op.Ne:
return x !== cond.arg;
case Op.Le:
return x <= cond.arg;
case Op.Lt:
return x < cond.arg;
case Op.Ge:
return x >= cond.arg;
case Op.Gt:
return x > cond.arg;
}
}
function must(x: boolean): SpanType {
return x ? SpanType.Whenever : SpanType.Never;
}
const perCond: Readonly<Partial<Record<Condition['type'], (course: racecourse.Course, cond: Condition) => Span[]>>> = {
accumulatetime: (course, cond) => [accumulatetime(course, cond.arg, SpanType.Whenever)],
activate_count_all_team: (course) => [{ start: 0, end: course.distance, type: SpanType.Skill }],
activate_count_end_after: (course) => [endafter(course, SpanType.Skill)],
activate_count_heal: (course) => [{ start: 0, end: course.distance, type: SpanType.Skill }],
activate_count_later_half: (course) => [{ start: course.distance / 2, end: course.distance, type: SpanType.Skill }],
activate_count_middle: (course) => [phase(course, Op.Eq, race.Phase.MidRace, SpanType.Skill)],
activate_count_start: (course) => [phase(course, Op.Eq, race.Phase.EarlyRace, SpanType.Skill)],
all_corner_random: (course) =>
coursespans(
course.spans.filter((s) => s.corner != null),
SpanType.Random,
),
change_order_up_end_after: (course) => [endafter(course, SpanType.Whenever)],
change_order_up_finalcorner_after: (course) => [is_finalcorner(course, true, SpanType.Whenever)],
change_order_up_middle: (course) => [phase(course, Op.Eq, race.Phase.MidRace, SpanType.Whenever)],
compete_fight_count: (course) => [laststraight(course, SpanType.Whenever)],
corner: (course, cond) =>
coursespans(
course.spans.filter((s) => opcompare(cond, s.corner ?? 0)),
SpanType.Whenever,
),
corner_count: (course, cond) => [
{
start: 0,
end: course.distance,
type: opcompare(
cond,
course.spans.reduce((n, s) => n + (s.corner != null ? 1 : 0), 0),
)
? SpanType.Never
: SpanType.Whenever,
},
],
corner_random: (course, cond) => {
const c = course.spans.findLast((s) => s.corner === cond.arg);
if (c == null) {
return [{ start: 0, end: 0, type: SpanType.Never }];
}
return [{ start: c.start, end: c.end, type: SpanType.Random }];
},
course_distance: (course, cond) => [{ start: 0, end: course.distance, type: must(opcompare(cond, course.distance)) }],
distance_rate: (course, cond) => [distrate(course, cond, SpanType.Whenever)],
distance_rate_after_random: (course, cond) => [distrate(course, cond, SpanType.Random)],
distance_type: (course, cond) => [
{ start: 0, end: course.distance, type: must(opcompare(cond, racecourse.distanceType(course))) },
],
down_slope_random: (course) =>
coursespans(
course.slopes.filter((s) => s.per < 0),
SpanType.Random,
),
furlong: (course, cond) => {
switch (cond.op) {
case Op.Eq:
return [{ start: cond.arg * 201.168, end: (cond.arg + 1) * 201.168, type: SpanType.Whenever }];
case Op.Ne:
return [
{ start: 0, end: cond.arg * 201.168, type: SpanType.Whenever },
{ start: (cond.arg + 1) * 201.168, end: course.distance, type: SpanType.Whenever },
];
case Op.Le:
case Op.Lt:
return [{ start: 0, end: (cond.arg + 1) * 201.168, type: SpanType.Whenever }];
case Op.Ge:
case Op.Gt:
return [{ start: cond.arg * 201.168, end: course.distance, type: SpanType.Whenever }];
}
},
ground_type: (course, cond) => [{ start: 0, end: course.distance, type: must(opcompare(cond, course.ground)) }],
// hp_per would be an interesting challenge...
is_finalcorner: (course, cond) => {
// Either is_finalcorner==1 or is_finalcorner==0.
if (cond.arg === 1) {
return [is_finalcorner(course, true, SpanType.Whenever)];
}
const c = course.spans.findLast((s) => s.corner != null);
if (c == null) {
return [{ start: 0, end: course.distance, type: SpanType.Whenever }];
}
return [{ start: 0, end: c.start, type: SpanType.Whenever }];
},
// TODO(zeph): is_abroad
is_activate_any_skill: (course) => [{ start: 0, end: course.distance, type: SpanType.Skill }],
is_activate_heal_skill: (course) => [{ start: 0, end: course.distance, type: SpanType.Skill }],
is_basis_distance: (course, cond) => [
{ start: 0, end: course.distance, type: must(opcompare(cond, course.distance % 400 === 0 ? 1 : 0)) },
],
is_dirtgrade: (course, cond) => {
// NAR tracks: Ooi, Kawasaki, Funabashi, Morioka
const is = [10101, 10103, 10104, 10105].includes(course.race_track_id);
return [{ start: 0, end: course.distance, type: must(opcompare(cond, is ? 1 : 0)) }];
},
is_finalcorner_laterhalf: (course) => [later(is_finalcorner(course, false, SpanType.Whenever), 0.5)],
is_finalcorner_random: (course) => [is_finalcorner(course, false, SpanType.Random)],
is_last_straight: (course) => [laststraight(course, SpanType.Whenever)],
is_last_straight_onetime: (course) => [first(laststraight(course, SpanType.Whenever), 0.02)],
is_lastspurt: (course) => [endafter(course, SpanType.Whenever)],
// could implement is_temptation!=0, but that condition isn't used
is_tight_track: (course, cond) => {
// Sapporo, Hakodate, Fukushima, Kokura, Kawasaki, Funabashi
// TODO(zeph): GameTora says "afaik" for this list...
const is = [10001, 10002, 10004, 10010, 10103, 10104];
return [{ start: 0, end: course.distance, type: must(opcompare(cond, is ? 1 : 0)) }];
},
last_straight_random: (course) => [laststraight(course, SpanType.Random)],
lastspurt: (course) => [endafter(course, SpanType.Whenever)],
order_rate_in20_continue: (course) => [accumulatetime(course, 5, SpanType.Whenever)],
order_rate_in40_continue: (course) => [accumulatetime(course, 5, SpanType.Whenever)],
order_rate_in50_continue: (course) => [accumulatetime(course, 5, SpanType.Whenever)],
order_rate_in80_continue: (course) => [accumulatetime(course, 5, SpanType.Whenever)],
order_rate_out20_continue: (course) => [accumulatetime(course, 5, SpanType.Whenever)],
order_rate_out40_continue: (course) => [accumulatetime(course, 5, SpanType.Whenever)],
order_rate_out50_continue: (course) => [accumulatetime(course, 5, SpanType.Whenever)],
order_rate_out70_continue: (course) => [accumulatetime(course, 5, SpanType.Whenever)],
phase: (course, cond) => [phase(course, cond.op, cond.arg, SpanType.Whenever)],
phase_corner_random: (course, cond) => {
const p = phase(course, Op.Eq, cond.arg, SpanType.Random);
return coursespans(
course.spans.filter((s) => s.corner != null),
SpanType.Random,
).map((s) => intersection(s, p));
},
phase_first_half_straight_random: (course, cond) => {
const p = first(phase(course, Op.Eq, cond.arg, SpanType.Random), 0.5);
return coursespans(
course.spans.filter((s) => s.straight != null),
SpanType.Random,
).map((s) => intersection(s, p));
},
phase_firsthalf: (course, cond) => [first(phase(course, Op.Eq, cond.arg, SpanType.Whenever), 0.5)],
phase_firsthalf_random: (course, cond) => [first(phase(course, Op.Eq, cond.arg, SpanType.Random), 0.5)],
phase_firstquarter: (course, cond) => [first(phase(course, Op.Eq, cond.arg, SpanType.Whenever), 0.25)],
phase_firstquarter_random: (course, cond) => [first(phase(course, Op.Eq, cond.arg, SpanType.Random), 0.25)],
phase_laterhalf: (course, cond) => [later(phase(course, Op.Eq, cond.arg, SpanType.Whenever), 0.5)],
phase_laterhalf_random: (course, cond) => [later(phase(course, Op.Eq, cond.arg, SpanType.Random), 0.5)],
phase_latter_half_straight_random: (course, cond) => {
const p = later(phase(course, Op.Eq, cond.arg, SpanType.Random), 0.5);
return coursespans(
course.spans.filter((s) => s.straight != null),
SpanType.Random,
).map((s) => intersection(s, p));
},
phase_random: (course, cond) => [phase(course, cond.op, cond.arg, SpanType.Random)],
phase_straight_random: (course, cond) => {
const p = phase(course, Op.Eq, cond.arg, SpanType.Random);
return coursespans(
course.spans.filter((s) => s.straight != null),
SpanType.Random,
).map((s) => intersection(s, p));
},
remain_distance: (course, cond) => [remaining(course, cond, SpanType.Whenever)],
remain_distance_viewer_id: (course, cond) => [remaining(course, cond, SpanType.Whenever)],
rotation: (course, cond) => [{ start: 0, end: course.distance, type: must(opcompare(cond, course.turn)) }],
run_at_full_speed_random: (course) => [endafter(course, SpanType.Random)],
slope: (course, cond) => {
switch (cond.arg) {
case 0:
return coursespans(
racecourse.fillSlopes(course.slopes, course.distance).filter((s) => s.per === 0),
SpanType.Whenever,
);
case 1:
return coursespans(
course.slopes.filter((s) => s.per > 0),
SpanType.Whenever,
);
case 2:
return coursespans(
course.slopes.filter((s) => s.per < 0),
SpanType.Whenever,
);
default:
throw new Error('weird argument to slope condition');
}
},
straight_front_type: (course, cond) =>
coursespans(
course.spans.filter((s) => s.straight === cond.arg),
SpanType.Whenever,
),
straight_random: (course) =>
coursespans(
course.spans.filter((s) => s.straight != null),
SpanType.Random,
),
track_id: (course, cond) => [{ start: 0, end: course.distance, type: must(opcompare(cond, course.race_track_id)) }],
up_slope_random: (course) =>
coursespans(
course.slopes.filter((s) => s.per > 0),
SpanType.Random,
),
up_slope_random_later_half: (course) => {
const p = { start: course.distance / 2, end: course.distance, type: SpanType.Random };
return coursespans(
course.slopes.filter((s) => s.per > 0),
SpanType.Random,
).map((s) => intersection(s, p));
},
} as const;
export function spans(course: racecourse.Course, cond: Condition): Span[] {
const f = perCond[cond.type];
return f?.(course, cond) ?? [{ start: 0, end: course.distance, type: SpanType.Whenever }];
}
export function and(as: Span[], bs: Span[]): Span[] {
const a = as.toSorted((l, r) => l.start - r.start);
const b = bs.toSorted((l, r) => l.start - r.start);
// TODO(zeph): this is quadratic in the number of spans; we could do it linear
return a.flatMap((u) => b.map((v) => intersection(u, v)).filter((v) => v.type !== SpanType.Never));
}
export function or(spans: Span[]): Span[] {
const a = spans.filter((s) => s.type !== SpanType.Never).sort((l, r) => l.start - r.start);
if (spans.length === 0) {
return [];
}
const r: Span[] = [];
let cur = a.shift()!;
while (a.length > 0) {
const next = a.shift()!;
// If they're disjoint or different types, they can't be merged.
// Note that we only need to check next start versus current end
// because we sort by start position first.
if (next.start > cur.end || next.type !== cur.type) {
r.push(cur);
cur = next;
continue;
}
// The current start is always the minimum of the two because we sorted,
// and the merged type is the same as the current type.
cur.end = Math.max(cur.end, next.end);
}
r.push(cur);
return r;
}
+2 -2
View File
@@ -62,8 +62,8 @@
{#snippet head()} {#snippet head()}
<Sec h={1} id="top" class="text-center pointer-coarse:mb-8">Career Fan Gain Is Solved</Sec> <Sec h={1} id="top" class="text-center pointer-coarse:mb-8">Career Fan Gain Is Solved</Sec>
<p> <p>
Thanks to work by Moxa and Werseter on the Hakuraku Discord, a precise formula for fans gained from career races has been Thanks to work by @Moxayoru and @Werseter on the Hakuraku Discord, a precise formula for fans gained from career races has
found. been found.
</p> </p>
<p> <p>
There is no random component to fan gain, except insofar as the race itself is random. It is now known to be entirely There is no random component to fan gain, except insofar as the race itself is random. It is now known to be entirely
+1 -1
View File
@@ -7,7 +7,7 @@ const placementPms = [1000, 400, 250, 150, 100, 90, 80, 70, 60, 50, 40, 30, 25,
* @returns Base fans for the race and placement together * @returns Base fans for the race and placement together
*/ */
export function baseFans(winBase: number, finishOrder: number): number { export function baseFans(winBase: number, finishOrder: number): number {
return Math.round(winBase * placementPms[finishOrder - 1] / 1000); return Math.round((winBase * placementPms[finishOrder - 1]) / 1000);
} }
const margins = [ const margins = [
@@ -0,0 +1,250 @@
<script lang="ts">
import CourseChart from '$lib/CourseChart.svelte';
import * as racecourse from '$lib/data/racecourse';
import * as skill from '$lib/data/skill';
import * as condition from '$lib/racelib/condition';
import Pick from '$lib/Pick.svelte';
import SkillIcon from '$lib/SkillIcon.svelte';
import { onMount } from 'svelte';
import TopChart from './TopChart.svelte';
let courses: racecourse.Course[] = $state([]);
let skills: skill.Skill[] = $state([]);
onMount(async () => {
const [c, sk] = await Promise.all([racecourse.racecourses(), skill.skills()]);
courses = c;
skills = sk;
});
let course: racecourse.Course | undefined = $state();
let selSkills: skill.Skill[] = $state([]);
const spanColors: Readonly<Record<condition.SpanType, string>> = {
[condition.SpanType.Never]: 'red',
[condition.SpanType.Random]: 'yellow',
[condition.SpanType.Skill]: 'cyan',
[condition.SpanType.Whenever]: 'lime',
} as const;
const drawSkills = $derived(
selSkills.map((skill) => {
if (course == null) {
return { skill, draw: [] };
}
const cs =
skill.activations.length === 1
? [
{ fy: 'Pre', parse: condition.parse(skill.activations[0].precondition ?? '') },
{ fy: 'Trigger', parse: condition.parse(skill.activations[0].condition) },
]
: [
{ fy: 'Pre1', parse: condition.parse(skill.activations[0].precondition ?? '') },
{ fy: 'Trigger1', parse: condition.parse(skill.activations[0].condition) },
{ fy: 'Pre2', parse: condition.parse(skill.activations[1].precondition ?? '') },
{ fy: 'Trigger2', parse: condition.parse(skill.activations[1].condition) },
];
return {
skill,
draw: cs
.filter((c) => c.parse.length > 0)
.flatMap(({ fy, parse }) => {
const p = parse
.map((conj) => conj.map((c) => condition.spans(course!, c)).reduce(condition.and))
.flatMap((spans) =>
spans
.filter((sp) => sp != null)
.map((sp) => ({ fy, x1: sp.start, x2: sp.end, color: spanColors[sp.type], label: sp.type })),
);
if (p.length === 0) {
return [{ fy, x1: 0, x2: course?.distance ?? 0, label: 'N/A', color: spanColors[condition.SpanType.Never] }];
}
return p;
}),
};
}),
);
function courseKey(c: racecourse.Course) {
return { id: c.race_course_id, name: racecourse.referName(c) };
}
function skillKey(sk: skill.Skill) {
const name = sk.unique_owner != null && sk.rarity === 1 ? `${sk.name} (Inherited)` : sk.name;
return { id: sk.skill_id, name };
}
let skillToAdd: skill.Skill | undefined = $state();
function addSkill() {
if (skillToAdd == null) {
return;
}
selSkills.push(skillToAdd);
}
function delSkill(i: number) {
if (i < 0 || i >= selSkills.length) {
return;
}
selSkills.splice(i, 1);
}
function swapSkill(i: number, dir: number) {
const j = i + dir;
if (j < 0 || j >= selSkills.length) {
return;
}
const [a, b] = [selSkills[i], selSkills[j]];
[selSkills[i], selSkills[j]] = [b, a];
}
</script>
{#snippet showcond(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}
<h1 class="text-4xl">Skill Activation Zones</h1>
<form class="mx-auto mt-8 rounded-md p-4 text-center shadow-md ring md:max-w-2xl">
<Pick id="course" items={courses} key={courseKey} class="w-full" bind:value={course} required />
</form>
<svelte:boundary>
<div class="mx-auto mt-8 flex max-w-6xl flex-col text-center">
<div class="h-40">
{#if course != null}
<TopChart {course} />
{/if}
</div>
{#each drawSkills as { skill, draw }, i (i)}
<div class="h-28 border-t">
{#if course != null}
<CourseChart {course} {draw} />
{/if}
</div>
<div class="flex flex-row items-center gap-2">
<SkillIcon icon_id={skill.icon_id} size={32} />
<Pick id={`skill${i}`} class="w-64" items={skills} key={skillKey} bind:value={selSkills[i]} required>
{#snippet option(sk)}
<span>
<SkillIcon icon_id={sk.icon_id} size={16} />
{skillKey(sk).name}
</span>
{/snippet}
</Pick>
<div class="flex grow flex-col items-center">
{#each skill.activations as act, i (i)}
{#if skill.activations.length > 1}
<span class="block text-xs italic">Activation {i + 1}</span>
{/if}
{#if act.precondition}
<span class="block text-sm italic">Precondition</span>
{@render showcond(act.precondition)}
<span class="block text-sm italic">Condition</span>
{/if}
{@render showcond(act.condition)}
{/each}
</div>
<div class="flex shrink flex-row items-stretch gap-1 px-2 pb-4">
<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 pointer-coarse:hidden"
onclick={() => swapSkill(i, -1)}
>
&uparrow;
</button>
<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={() => delSkill(i)}
>
&times;
</button>
<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 pointer-coarse:hidden"
onclick={() => swapSkill(i, 1)}
>
&downarrow;
</button>
</div>
</div>
{/each}
<div class="flex items-center-safe justify-center-safe gap-2 border-t pt-8">
<Pick id="addSkill" class="w-64" items={skills} key={skillKey} bind:value={skillToAdd} required>
{#snippet option(sk)}
<span>
<SkillIcon icon_id={sk.icon_id} size={16} />
{skillKey(sk).name}
</span>
{/snippet}
</Pick>
<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>
<div class="mx-auto mt-12 w-full max-w-4xl border-t pt-4 md:mt-8">
<p>This calculator separates conditions into four categories:</p>
<ul class="mb-4 list-disc pl-4">
<li>
Immediate conditions that are satisfied continuously during their range. The skill activates as soon as all other
conditions are satisfied. These conditions are mapped in <div
class="inline-block h-4 max-h-4 min-h-4 w-4 max-w-4 min-w-4 border bg-[lime] align-middle"
>
&nbsp;
</div>
green with an "Immediate" label.
</li>
<li>
Random conditions that are satisfied at a single point within their range, or several points in the case of e.g. <span
class="font-mono">all_corner_random==1</span
>. All other conditions must be satisfied at the time the horse reaches the random points for the skill to activate. Other
conditions may exclude portions of the random range, which can cause them to become impossible to trigger; this is
particularly common with <span class="font-mono">phase_random==0&nbsp;&amp;&nbsp;accumulatetime&gt;=5</span> skills. These
conditions are mapped in
<div class="inline-block h-4 max-h-4 min-h-4 w-4 max-w-4 min-w-4 border bg-[yellow] align-middle">&nbsp;</div>
yellow with a "Random" label.
</li>
<li>
Conditions satisfied by the Uma's other skills triggering. These are mapped in <div
class="inline-block h-4 max-h-4 min-h-4 w-4 max-w-4 min-w-4 border bg-[cyan] align-middle"
>
&nbsp;
</div>
cyan with a "Triggered" label.
</li>
<li>
Unsatisfiable conditions. These are mapped in <div
class="inline-block h-4 max-h-4 min-h-4 w-4 max-w-4 min-w-4 border bg-[red] align-middle"
>
&nbsp;
</div>
red with an "N/A" label.
</li>
</ul>
<p>
<span class="font-mono">accumulatetime</span> is implemented by assuming the horse runs at the
<a
href="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.q3a514loz268"
target="_blank"
rel="noopener noreferrer"
>
race base speed
</a>
from the gate. In practice, ignoring the start dash this way will tend to overestimate the starting position of the condition
especially for backline running styles, and it may underestimate it for runaways with full gate accels.
</p>
<p>
Condition zones were implemented by hand, and some combinations may have been missed. If you suspect a bug, please
@zephyrtronium on the GameTora, Hakuraku, or uma.moe Discord servers.
</p>
</div>
{#snippet pending()}
<div>Loading...</div>
{/snippet}
</svelte:boundary>
@@ -0,0 +1,53 @@
<script lang="ts">
import * as race from '$lib/race';
import * as racecourse from '$lib/data/racecourse';
import CourseChart from '$lib/CourseChart.svelte';
interface Props {
course: racecourse.Course;
}
let { course }: Props = $props();
function slopeprops(s: racecourse.Slope) {
if (s.per > 0) {
return { fy: 'Slope', x1: s.start, x2: s.end, label: `+${s.per / 10000}`, color: 'white' };
}
if (s.per < 0) {
return { fy: 'Slope', x1: s.start, x2: s.end, label: `${s.per / 10000}`, color: 'cyan' };
}
return { fy: 'Slope', x1: s.start, x2: s.end, color: 'none' };
}
function spanprops(s: racecourse.Span) {
if (s.corner != null) {
if (s.corner === 4) {
return { fy: 'Span', x1: s.start, x2: s.end, label: 'C4', color: 'red' };
}
return { fy: 'Span', x1: s.start, x2: s.end, label: `C${s.corner}`, color: 'cyan' };
}
if (s.straight != null) {
return { fy: 'Span', x1: s.start, x2: s.end, label: `S${s.straight}`, color: 'white' };
}
return { fy: 'Span', x1: s.start, x2: s.end, label: '~', color: 'pink' };
}
const poskeep = $derived((course.distance * 10) / 24);
const spotStruggle = $derived((course.distance * 9) / 24);
const rushStart = $derived(course.distance / 24);
const rushEnd = $derived((course.distance * 10) / 24);
const slopes = $derived(racecourse.fillSlopes(course.slopes, course.distance));
const phases = $derived(race.phases(course.distance));
const draw = $derived([
...slopes.map(slopeprops),
...course.spans.map(spanprops),
{ fy: 'Phase', x1: 0, x2: phases[race.Phase.EarlyRace], label: 'Early', color: 'cyan' },
{ fy: 'Phase', x1: phases[race.Phase.EarlyRace], x2: phases[race.Phase.MidRace], label: 'Mid', color: 'yellow' },
{ fy: 'Phase', x1: phases[race.Phase.MidRace], x2: phases[race.Phase.LateRace], label: 'Late', color: 'magenta' },
{ fy: 'Phase', x1: phases[race.Phase.LateRace], x2: phases[race.Phase.LastSpurt], label: 'Spurt', color: 'magenta' },
{ fy: 'PosKeep', x1: 0, x2: poskeep, color: 'purple' },
{ fy: 'SpotS', x1: 150, x2: spotStruggle, color: 'green' },
{ fy: 'Rush', x1: rushStart, x2: rushEnd, color: 'red' },
]);
</script>
<CourseChart {course} {draw} />