13 Commits
Author SHA1 Message Date
zephyr 56bd5b460d zenno/doc/race: better section speed charts
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline was successful
Fixes #49.
2026-11-05 14:45:59 -05:00
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
zephyr 7f1aa00be6 horse: update dependencies and go version
ci/woodpecker/push/horsebot Pipeline failed
ci/woodpecker/push/zenno Pipeline failed
2026-10-21 10:18:55 -04:00
20 changed files with 2116 additions and 118 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ when:
steps:
- name: test
image: golang:1.26-alpine
image: golang:1.27-alpine
commands:
- go vet ./...
- go test ./...
+455
View File
@@ -0,0 +1,455 @@
package main
import (
"context"
_ "embed"
"errors"
"fmt"
"log/slog"
"zombiezen.com/go/sqlite"
"zombiezen.com/go/sqlite/sqlitemigration"
"zombiezen.com/go/sqlite/sqlitex"
"git.sunturtle.xyz/zephyr/horse"
"git.sunturtle.xyz/zephyr/horse/umadump/ism"
)
const appID int32 = 0x157db
//go:embed schema-00.sql
var schema0 string
//go:embed schema-final.sql
var schemaFinal string
func opendb(uri, journalMode string) *sqlitemigration.Pool {
schema := sqlitemigration.Schema{
Migrations: []string{
schema0,
},
AppID: appID,
RepeatableMigration: schemaFinal,
}
j := "PRAGMA journal_mode=" + journalMode
opts := sqlitemigration.Options{
Flags: sqlite.OpenReadWrite | sqlite.OpenCreate | sqlite.OpenURI,
PrepareConn: func(conn *sqlite.Conn) error {
if journalMode == "" {
return nil
}
stmt, _, err := conn.PrepareTransient(j)
if err != nil {
return err
}
_, err = stmt.Step()
return errors.Join(err, stmt.Finalize())
},
}
db := sqlitemigration.NewPool(uri, schema, opts)
return db
}
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)
if err != nil {
return err
}
defer db.Put(conn)
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
var insertCareerSQL string
func insertCareer(conn *sqlite.Conn, log *slog.Logger, career *ism.WorkIdleSingleMode, trainerID int64) error {
log.Debug("insert career")
stmt := conn.Prep(insertCareerSQL)
stmt.SetInt64("$trainer_id", trainerID)
stmt.SetInt64("$trained_chara_id", int64(career.End.Chara.SingleModeCharaID))
stmt.SetInt64("$card_id", int64(career.End.Chara.CardID))
stmt.SetInt64("$scenario_id", int64(career.End.Chara.ScenarioID))
stmt.SetInt64("$route_id", int64(career.End.Chara.RouteID))
stmt.SetText("$start_time", career.ProgressInfo.StartTime)
stmt.SetText("$end_time", career.ProgressInfo.EndTime)
stmt.SetInt64("$chara_grade", int64(career.End.Chara.CharaGrade))
stmt.SetInt64("$star_level", int64(career.End.Chara.Rarity))
stmt.SetInt64("$potential_level", int64(career.End.Chara.TalentLevel))
stmt.SetInt64("$speed", int64(career.End.Chara.Speed))
stmt.SetInt64("$stamina", int64(career.End.Chara.Stamina))
stmt.SetInt64("$power", int64(career.End.Chara.Power))
stmt.SetInt64("$guts", int64(career.End.Chara.Guts))
stmt.SetInt64("$wit", int64(career.End.Chara.Wiz))
stmt.SetInt64("$energy", int64(career.End.Chara.Vital))
stmt.SetInt64("$max_speed", int64(career.End.Chara.MaxSpeed))
stmt.SetInt64("$max_stamina", int64(career.End.Chara.MaxStamina))
stmt.SetInt64("$max_power", int64(career.End.Chara.MaxPower))
stmt.SetInt64("$max_guts", int64(career.End.Chara.MaxGuts))
stmt.SetInt64("$max_wit", int64(career.End.Chara.MaxWiz))
stmt.SetInt64("$max_energy", int64(career.End.Chara.MaxVital))
stmt.SetInt64("$skill_point", int64(career.ProgressLog.TotalSkillPoint))
stmt.SetInt64("$mood", int64(career.End.Chara.Motivation))
stmt.SetInt64("$turf", int64(career.End.Chara.ProperGroundTurf))
stmt.SetInt64("$dirt", int64(career.End.Chara.ProperGroundDirt))
stmt.SetInt64("$sprint", int64(career.End.Chara.ProperDistanceShort))
stmt.SetInt64("$mile", int64(career.End.Chara.ProperDistanceMile))
stmt.SetInt64("$medium", int64(career.End.Chara.ProperDistanceMiddle))
stmt.SetInt64("$long", int64(career.End.Chara.ProperDistanceLong))
stmt.SetInt64("$front_runner", int64(career.End.Chara.ProperRunningStyleNige))
stmt.SetInt64("$pace_chaser", int64(career.End.Chara.ProperRunningStyleSenko))
stmt.SetInt64("$late_surger", int64(career.End.Chara.ProperRunningStyleSashi))
stmt.SetInt64("$end_closer", int64(career.End.Chara.ProperRunningStyleOikomi))
stmt.SetInt64("$start_speed", int64(career.ProgressInfo.Chara.Speed))
stmt.SetInt64("$start_stamina", int64(career.ProgressInfo.Chara.Stamina))
stmt.SetInt64("$start_power", int64(career.ProgressInfo.Chara.Power))
stmt.SetInt64("$start_guts", int64(career.ProgressInfo.Chara.Guts))
stmt.SetInt64("$start_wit", int64(career.ProgressInfo.Chara.Wiz))
stmt.SetInt64("$start_max_speed", int64(career.ProgressInfo.Chara.MaxSpeed))
stmt.SetInt64("$start_max_stamina", int64(career.ProgressInfo.Chara.MaxStamina))
stmt.SetInt64("$start_max_power", int64(career.ProgressInfo.Chara.MaxPower))
stmt.SetInt64("$start_max_guts", int64(career.ProgressInfo.Chara.MaxGuts))
stmt.SetInt64("$start_max_wit", int64(career.ProgressInfo.Chara.MaxWiz))
stmt.SetInt64("$start_turf", int64(career.ProgressInfo.Chara.ProperGroundTurf))
stmt.SetInt64("$start_dirt", int64(career.ProgressInfo.Chara.ProperGroundDirt))
stmt.SetInt64("$start_sprint", int64(career.ProgressInfo.Chara.ProperDistanceShort))
stmt.SetInt64("$start_mile", int64(career.ProgressInfo.Chara.ProperDistanceMile))
stmt.SetInt64("$start_medium", int64(career.ProgressInfo.Chara.ProperDistanceMiddle))
stmt.SetInt64("$start_long", int64(career.ProgressInfo.Chara.ProperDistanceLong))
stmt.SetInt64("$start_front_runner", int64(career.ProgressInfo.Chara.ProperRunningStyleNige))
stmt.SetInt64("$start_pace_chaser", int64(career.ProgressInfo.Chara.ProperRunningStyleSenko))
stmt.SetInt64("$start_late_surger", int64(career.ProgressInfo.Chara.ProperRunningStyleSashi))
stmt.SetInt64("$start_end_closer", int64(career.ProgressInfo.Chara.ProperRunningStyleOikomi))
stmt.SetInt64("$fans", int64(career.End.Chara.Fans))
stmt.SetInt64("$race_program_id", int64(career.End.Chara.RaceProgramID))
stmt.SetInt64("$reserve_race_program_id", int64(career.End.Chara.ReserveRaceProgramID))
stmt.SetInt64("$race_running_style", int64(career.End.Chara.RaceRunningStyle))
stmt.SetInt64("$is_short_race", int64(career.End.Chara.IsShortRace))
stmt.SetInt64("$succession_trained_chara_id_1", int64(career.End.Chara.SuccessionTrainedCharaID1))
stmt.SetInt64("$succession_trained_chara_id_2", int64(career.End.Chara.SuccessionTrainedCharaID2))
_, err := stmt.Step()
err = errors.Join(err, stmt.Reset())
if err != nil {
return fmt.Errorf("inserting career: %w", err)
}
return nil
}
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 (?, ?, ?, ?)`
stmt := conn.Prep(sql)
stmt.BindInt64(1, trainerID)
stmt.BindInt64(2, int64(trainedChara))
for _, c := range cs {
stmt.BindInt64(3, int64(c.CharaEffectID))
stmt.BindBool(4, c.IsActive)
_, err := stmt.Step()
err = errors.Join(err, stmt.Reset())
if err != nil {
return fmt.Errorf("inserting condition: %w", err)
}
}
return nil
}
//go:embed insert-gain.sql
var insertGainSQL string
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.SetInt64("$trainer_id", trainerID)
stmt.SetInt64("$trained_chara_id", int64(trainedChara))
stmt.SetInt64("$source", int64(source))
stmt.SetInt64("$gain_speed", int64(gain.Speed.Value))
stmt.SetInt64("$gain_stamina", int64(gain.Stamina.Value))
stmt.SetInt64("$gain_power", int64(gain.Power.Value))
stmt.SetInt64("$gain_guts", int64(gain.Guts.Value))
stmt.SetInt64("$gain_wit", int64(gain.Wiz.Value))
stmt.SetInt64("$gain_skill_point", int64(gain.SkillPoint))
stmt.SetInt64("$gain_max_speed", int64(gain.MaxSpeed))
stmt.SetInt64("$gain_max_stamina", int64(gain.MaxStamina))
stmt.SetInt64("$gain_max_power", int64(gain.MaxPower))
stmt.SetInt64("$gain_max_guts", int64(gain.MaxGuts))
stmt.SetInt64("$gain_max_wit", int64(gain.MaxWiz))
stmt.SetInt64("$gain_turf", int64(gain.ProperGroundTurf))
stmt.SetInt64("$gain_dirt", int64(gain.ProperGroundDirt))
stmt.SetInt64("$gain_sprint", int64(gain.ProperDistanceShort))
stmt.SetInt64("$gain_mile", int64(gain.ProperDistanceMile))
stmt.SetInt64("$gain_medium", int64(gain.ProperDistanceMiddle))
stmt.SetInt64("$gain_long", int64(gain.ProperDistanceLong))
stmt.SetInt64("$gain_front_runner", int64(gain.ProperRunningStyleNige))
stmt.SetInt64("$gain_pace_chaser", int64(gain.ProperRunningStyleSenko))
stmt.SetInt64("$gain_late_surger", int64(gain.ProperRunningStyleSashi))
stmt.SetInt64("$gain_end_closer", int64(gain.ProperRunningStyleOikomi))
_, err := stmt.Step()
err = errors.Join(err, stmt.Reset())
if err != nil {
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
}
+69
View File
@@ -0,0 +1,69 @@
INSERT INTO career VALUES (
$trainer_id,
$trained_chara_id,
$card_id,
$scenario_id,
$route_id,
$start_time,
$end_time,
$chara_grade,
$star_level,
$potential_level,
$speed,
$stamina,
$power,
$guts,
$wit,
$energy,
$max_speed,
$max_stamina,
$max_power,
$max_guts,
$max_wit,
$max_energy,
$skill_point,
$mood,
$turf,
$dirt,
$sprint,
$mile,
$medium,
$long,
$front_runner,
$pace_chaser,
$late_surger,
$end_closer,
$start_speed,
$start_stamina,
$start_power,
$start_guts,
$start_wit,
$start_max_speed,
$start_max_stamina,
$start_max_power,
$start_max_guts,
$start_max_wit,
$start_turf,
$start_dirt,
$start_sprint,
$start_mile,
$start_medium,
$start_long,
$start_front_runner,
$start_pace_chaser,
$start_late_surger,
$start_end_closer,
$fans,
$race_program_id,
$reserve_race_program_id,
$race_running_style,
$is_short_race,
$succession_trained_chara_id_1,
$succession_trained_chara_id_2
)
+31
View File
@@ -0,0 +1,31 @@
INSERT INTO gain VALUES (
$trainer_id,
$trained_chara_id,
$source,
$gain_speed,
$gain_stamina,
$gain_power,
$gain_guts,
$gain_wit,
$gain_skill_point,
$gain_max_speed,
$gain_max_stamina,
$gain_max_power,
$gain_max_guts,
$gain_max_wit,
$gain_turf,
$gain_dirt,
$gain_sprint,
$gain_mile,
$gain_medium,
$gain_long,
$gain_front_runner,
$gain_pace_chaser,
$gain_late_surger,
$gain_end_closer
)
+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))
}
+250
View File
@@ -0,0 +1,250 @@
CREATE TABLE IF NOT EXISTS career (
trainer_id INTEGER NOT NULL DEFAULT 0,
trained_chara_id INTEGER NOT NULL,
card_id INTEGER NOT NULL,
scenario_id INTEGER NOT NULL,
route_id INTEGER NOT NULL,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
chara_grade INTEGER NOT NULL,
star_level INTEGER NOT NULL,
potential_level INTEGER NOT NULL,
speed INTEGER NOT NULL,
stamina INTEGER NOT NULL,
power INTEGER NOT NULL,
guts INTEGER NOT NULL,
wit INTEGER NOT NULL,
energy INTEGER NOT NULL,
max_speed INTEGER NOT NULL,
max_stamina INTEGER NOT NULL,
max_power INTEGER NOT NULL,
max_guts INTEGER NOT NULL,
max_wit INTEGER NOT NULL,
max_energy INTEGER NOT NULL,
skill_point INTEGER NOT NULL,
mood INTEGER NOT NULL,
turf INTEGER NOT NULL,
dirt INTEGER NOT NULL,
sprint INTEGER NOT NULL,
mile INTEGER NOT NULL,
medium INTEGER NOT NULL,
long INTEGER NOT NULL,
front_runner INTEGER NOT NULL,
pace_chaser INTEGER NOT NULL,
late_surger INTEGER NOT NULL,
end_closer INTEGER NOT NULL,
-- These fields are 0 if the career finished while it was not actively
-- displayed on the client used to dump it.
start_speed INTEGER NOT NULL,
start_stamina INTEGER NOT NULL,
start_power INTEGER NOT NULL,
start_guts INTEGER NOT NULL,
start_wit INTEGER NOT NULL,
start_max_speed INTEGER NOT NULL,
start_max_stamina INTEGER NOT NULL,
start_max_power INTEGER NOT NULL,
start_max_guts INTEGER NOT NULL,
start_max_wit INTEGER NOT NULL,
start_turf INTEGER NOT NULL,
start_dirt INTEGER NOT NULL,
start_sprint INTEGER NOT NULL,
start_mile INTEGER NOT NULL,
start_medium INTEGER NOT NULL,
start_long INTEGER NOT NULL,
start_front_runner INTEGER NOT NULL,
start_pace_chaser INTEGER NOT NULL,
start_late_surger INTEGER NOT NULL,
start_end_closer INTEGER NOT NULL,
fans INTEGER NOT NULL,
race_program_id INTEGER NOT NULL,
reserve_race_program_id INTEGER NOT NULL,
race_running_style INTEGER NOT NULL,
is_short_race INTEGER NOT NULL,
succession_trained_chara_id_1 INTEGER NOT NULL,
succession_trained_chara_id_2 INTEGER NOT NULL,
PRIMARY KEY (trainer_id, trained_chara_id)
) STRICT;
CREATE INDEX IF NOT EXISTS career_by_scenario ON career (scenario_id);
CREATE INDEX IF NOT EXISTS career_by_start_time ON career (start_time);
CREATE TABLE IF NOT EXISTS condition (
trainer_id INTEGER NOT NULL DEFAULT 0,
trained_chara_id INTEGER NOT NULL,
condition INTEGER NOT NULL,
is_active INTEGER NOT NULL,
PRIMARY KEY (trainer_id, trained_chara_id, condition),
FOREIGN KEY (trainer_id, trained_chara_id) REFERENCES career ON DELETE CASCADE
) STRICT;
CREATE TABLE IF NOT EXISTS gain (
trainer_id INTEGER NOT NULL DEFAULT 0,
trained_chara_id INTEGER NOT NULL,
source INTEGER NOT NULL, -- 0 for events, 1 for inspiration, otherwise support card id
gain_speed INTEGER NOT NULL,
gain_stamina INTEGER NOT NULL,
gain_power INTEGER NOT NULL,
gain_guts INTEGER NOT NULL,
gain_wit INTEGER NOT NULL,
gain_skill_point INTEGER NOT NULL,
gain_max_speed INTEGER NOT NULL,
gain_max_stamina INTEGER NOT NULL,
gain_max_power INTEGER NOT NULL,
gain_max_guts INTEGER NOT NULL,
gain_max_wit INTEGER NOT NULL,
gain_turf INTEGER NOT NULL,
gain_dirt INTEGER NOT NULL,
gain_sprint INTEGER NOT NULL,
gain_mile INTEGER NOT NULL,
gain_medium INTEGER NOT NULL,
gain_long INTEGER NOT NULL,
gain_front_runner INTEGER NOT NULL,
gain_pace_chaser INTEGER NOT NULL,
gain_late_surger INTEGER NOT NULL,
gain_end_closer INTEGER NOT NULL,
PRIMARY KEY (trainer_id, trained_chara_id, source),
FOREIGN KEY (trainer_id, trained_chara_id) REFERENCES career ON DELETE CASCADE
) STRICT;
CREATE INDEX IF NOT EXISTS gain_by_source ON gain (source);
CREATE TABLE IF NOT EXISTS gain_skill_hint (
trainer_id INTEGER NOT NULL DEFAULT 0,
trained_chara_id INTEGER NOT NULL,
source INTEGER NOT NULL,
group_id INTEGER NOT NULL,
rarity INTEGER NOT NULL,
level INTEGER NOT NULL,
PRIMARY KEY (trainer_id, trained_chara_id, source, group_id, rarity),
FOREIGN KEY (trainer_id, trained_chara_id, source) REFERENCES gain ON DELETE CASCADE
) STRICT;
CREATE TABLE IF NOT EXISTS inspiration (
trainer_id INTEGER NOT NULL DEFAULT 0,
trained_chara_id INTEGER NOT NULL,
year INTEGER NOT NULL,
factor_id INTEGER NOT NULL,
level INTEGER NOT NULL, -- always 0 but just in case we get it in the future
FOREIGN KEY (trainer_id, trained_chara_id) REFERENCES career ON DELETE CASCADE
) STRICT;
CREATE INDEX IF NOT EXISTS inspiration_by_career ON inspiration (trainer_id, trained_chara_id);
CREATE INDEX IF NOT EXISTS inspiration_by_factor_id ON inspiration (factor_id);
CREATE TABLE IF NOT EXISTS race_history (
trainer_id INTEGER NOT NULL DEFAULT 0,
trained_chara_id INTEGER NOT NULL,
turn INTEGER NOT NULL,
program_id INTEGER NOT NULL,
weather INTEGER NOT NULL,
ground_condition INTEGER NOT NULL,
running_style INTEGER NOT NULL,
result_rank INTEGER NOT NULL,
frame_order INTEGER NOT NULL,
gained_fans INTEGER NOT NULL,
lose_tips_id INTEGER NOT NULL,
PRIMARY KEY (trainer_id, trained_chara_id, turn),
FOREIGN KEY (trainer_id, trained_chara_id) REFERENCES career ON DELETE CASCADE
) STRICT;
CREATE TABLE IF NOT EXISTS race_reward (
trainer_id INTEGER NOT NULL DEFAULT 0,
trained_chara_id INTEGER NOT NULL,
turn INTEGER NOT NULL,
item_type INTEGER NOT NULL,
item_id INTEGER NOT NULL,
item_num INTEGER NOT NULL,
is_bonus INTEGER NOT NULL,
is_plus_bonus INTEGER NOT NULL,
is_bonus_win INTEGER NOT NULL,
FOREIGN KEY (trainer_id, trained_chara_id, turn) REFERENCES race_history ON DELETE CASCADE
) STRICT;
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 TABLE IF NOT EXISTS gained_skill (
trainer_id INTEGER NOT NULL DEFAULT 0,
trained_chara_id INTEGER NOT NULL,
skill_id INTEGER NOT NULL,
PRIMARY KEY (trainer_id, trained_chara_id, skill_id),
FOREIGN KEY (trainer_id, trained_chara_id) REFERENCES career ON DELETE CASCADE
) STRICT;
CREATE TABLE IF NOT EXISTS career_skill (
trainer_id INTEGER NOT NULL DEFAULT 0,
trained_chara_id INTEGER NOT NULL,
skill_id INTEGER NOT NULL,
level INTEGER NOT NULL,
PRIMARY KEY (trainer_id, trained_chara_id, skill_id),
FOREIGN KEY (trainer_id, trained_chara_id) REFERENCES career ON DELETE CASCADE
) STRICT;
CREATE TABLE IF NOT EXISTS career_skill_hint (
trainer_id INTEGER NOT NULL DEFAULT 0,
trained_chara_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
rarity INTEGER NOT NULL,
level INTEGER NOT NULL,
PRIMARY KEY (trainer_id, trained_chara_id, group_id, rarity),
FOREIGN KEY (trainer_id, trained_chara_id) REFERENCES career ON DELETE CASCADE
) STRICT;
CREATE TABLE IF NOT EXISTS career_support_card (
trainer_id INTEGER NOT NULL DEFAULT 0,
trained_chara_id INTEGER NOT NULL,
position INTEGER NOT NULL,
support_card_id INTEGER NOT NULL,
limit_break_count INTEGER NOT NULL,
exp INTEGER NOT NULL,
owner_viewer_id INTEGER NOT NULL,
PRIMARY KEY (trainer_id, trained_chara_id, position),
FOREIGN KEY (trainer_id, trained_chara_id) REFERENCES career ON DELETE CASCADE,
UNIQUE (trainer_id, trained_chara_id, support_card_id)
) STRICT;
CREATE INDEX IF NOT EXISTS career_support_card_by_card ON career_support_card (support_card_id, exp);
CREATE TABLE IF NOT EXISTS start_skill (
trainer_id INTEGER NOT NULL DEFAULT 0,
trained_chara_id INTEGER NOT NULL,
skill_id INTEGER NOT NULL,
level INTEGER NOT NULL,
PRIMARY KEY (trainer_id, trained_chara_id, skill_id),
FOREIGN KEY (trainer_id, trained_chara_id) REFERENCES career ON DELETE CASCADE
) STRICT;
CREATE TABLE IF NOT EXISTS start_skill_hint (
trainer_id INTEGER NOT NULL DEFAULT 0,
trained_chara_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
rarity INTEGER NOT NULL,
level INTEGER NOT NULL,
PRIMARY KEY (trainer_id, trained_chara_id, group_id, rarity),
FOREIGN KEY (trainer_id, trained_chara_id) REFERENCES career ON DELETE CASCADE
) STRICT;
+88
View File
@@ -0,0 +1,88 @@
DROP VIEW IF EXISTS gain_event;
DROP VIEW IF EXISTS gain_inspiration;
DROP VIEW IF EXISTS gain_support_card;
CREATE VIEW gain_event AS
SELECT
trainer_id,
trained_chara_id,
gain_speed,
gain_stamina,
gain_power,
gain_guts,
gain_wit,
gain_skill_point,
gain_max_speed,
gain_max_stamina,
gain_max_power,
gain_max_guts,
gain_max_wit,
gain_turf,
gain_dirt,
gain_sprint,
gain_mile,
gain_medium,
gain_long,
gain_front_runner,
gain_pace_chaser,
gain_late_surger,
gain_end_closer
FROM gain
WHERE source = 0;
CREATE VIEW gain_inspiration AS
SELECT
trainer_id,
trained_chara_id,
gain_speed,
gain_stamina,
gain_power,
gain_guts,
gain_wit,
gain_skill_point,
gain_max_speed,
gain_max_stamina,
gain_max_power,
gain_max_guts,
gain_max_wit,
gain_turf,
gain_dirt,
gain_sprint,
gain_mile,
gain_medium,
gain_long,
gain_front_runner,
gain_pace_chaser,
gain_late_surger,
gain_end_closer
FROM gain
WHERE source = 1;
CREATE VIEW gain_support_card AS
SELECT
trainer_id,
trained_chara_id,
source AS support_card_id,
gain_speed,
gain_stamina,
gain_power,
gain_guts,
gain_wit,
gain_skill_point,
gain_max_speed,
gain_max_stamina,
gain_max_power,
gain_max_guts,
gain_max_wit,
gain_turf,
gain_dirt,
gain_sprint,
gain_mile,
gain_medium,
gain_long,
gain_front_runner,
gain_pace_chaser,
gain_late_surger,
gain_end_closer
FROM gain
WHERE source > 1;
+14 -15
View File
@@ -1,15 +1,15 @@
module git.sunturtle.xyz/zephyr/horse
go 1.25.5
go 1.27.3
require (
github.com/disgoorg/disgo v0.19.6
github.com/gen2brain/avif v0.6.0
github.com/go-chi/chi/v5 v5.3.1
github.com/go-chi/chi/v5 v5.5.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
github.com/junegunn/fzf v0.74.5
golang.org/x/image v0.47.0
golang.org/x/sync v0.24.0
zombiezen.com/go/sqlite v1.4.2
)
@@ -18,23 +18,22 @@ require (
github.com/disgoorg/json/v2 v2.0.0 // indirect
github.com/disgoorg/omit v1.0.0 // indirect
github.com/disgoorg/snowflake/v2 v2.0.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.2 // indirect
github.com/dustin/go-humanize v1.1.0 // indirect
github.com/ebitengine/purego v0.11.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 // indirect
github.com/klauspost/compress v1.19.1 // indirect
github.com/klauspost/compress v1.20.3 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad // indirect
github.com/tetratelabs/wazero v1.12.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/tools v0.48.0 // indirect
modernc.org/libc v1.74.3 // indirect
golang.org/x/crypto v0.58.0 // indirect
golang.org/x/sys v0.49.0 // indirect
modernc.org/libc v1.79.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.54.0 // indirect
modernc.org/memory v1.12.1 // indirect
modernc.org/sqlite v1.61.0 // indirect
)
+38 -38
View File
@@ -10,14 +10,14 @@ github.com/disgoorg/omit v1.0.0 h1:y0LkVUOyUHT8ZlnhIAeOZEA22UYykeysK8bLJ0SfT78=
github.com/disgoorg/omit v1.0.0/go.mod h1:RTmSARkf6PWT/UckwI0bV8XgWkWQoPppaT01rYKLcFQ=
github.com/disgoorg/snowflake/v2 v2.0.3 h1:3B+PpFjr7j4ad7oeJu4RlQ+nYOTadsKapJIzgvSI2Ro=
github.com/disgoorg/snowflake/v2 v2.0.3/go.mod h1:W6r7NUA7DwfZLwr00km6G4UnZ0zcoLBRufhkFWgAc4c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU=
github.com/ebitengine/purego v0.11.2/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
github.com/gen2brain/avif v0.6.0 h1:/8WSgcU+IEF0jhKYsUZ/mzlziFuTeJFpIKBj2siTQps=
github.com/gen2brain/avif v0.6.0/go.mod h1:QgrYqdVE9y40PCfArK9VakcMIpYeDYpZmCSLkW6C1n8=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/chi/v5 v5.5.0 h1:xdKQ1dJmVJsmgahrXSKhUx8NyVPzpwdlyrrQFpMPJgg=
github.com/go-chi/chi/v5 v5.5.0/go.mod h1:d1IG7GtKPSjPyBP365PH8sAGVHk3qfB/IhjFNRH1wWM=
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=
@@ -26,16 +26,16 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/junegunn/fzf v0.74.1 h1:rCGVdCSoOkfcfTxtxCO/vG2yceDjRlKSlv7IfCGkc/Y=
github.com/junegunn/fzf v0.74.1/go.mod h1:hKDkO8orBWT5VLIZOL8oyhxn7mRLXdGyu+gz1Ss58pU=
github.com/junegunn/fzf v0.74.5 h1:mpJlCdgGuddKerHrdjvV3cnezEBgyjZAVn8KCo/4OMc=
github.com/junegunn/fzf v0.74.5/go.mod h1:TBRY54j3mGjAm4JcpDyiUIuDq38QlHvAzrB3GLKxQXM=
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMDfKzjT+DVfIS4iqknSEKtZpEcXtu6vuaasHs=
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4=
github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
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/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -48,46 +48,46 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo=
golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE=
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
modernc.org/gc/v3 v3.2.0/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/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/sqlite v1.61.0 h1:sQi63oLQ9uquAUA4CzOisH35Q3pEszkPBuHM0tFL8CY=
modernc.org/sqlite v1.61.0/go.mod h1:f4gr5tbpx2umklaT/QVtbi/50hNdM+4ng+2zMo8KXJ0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+10 -7
View File
@@ -21,7 +21,7 @@ type SingleModeSupportCard struct {
ID horse.SupportCardID `json:"support_card_id"`
LimitBreak int32 `json:"limit_break_count"`
Exp int32 `json:"exp"`
OwnerID int32 `json:"owner_viewer_id"`
OwnerID int64 `json:"owner_viewer_id"`
}
type GroupOuting struct {
@@ -318,11 +318,12 @@ type End struct {
TrophyRewardInfo *umadump.Never `json:"trophy_reward_info"`
PrevCharaGrade *umadump.Never `json:"prev_chara_grade"`
RaceAddRewardInfo []umadump.Never `json:"race_add_reward_info"`
ReservedRaceInfo *umadump.Never `json:"reserved_race_info"`
Missions []umadump.Never `json:"mission_list"`
StoryEventMissions []umadump.Never `json:"story_event_mission_list"`
StoryEventCharaBonuses []umadump.Never `json:"story_event_chara_bonus_list"`
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"`
RaceRewardLimitMore []umadump.Never `json:"race_reward_limit_more_list"`
SkillFilterSettings []umadump.Never `json:"skill_filter_setting_array"`
@@ -331,15 +332,17 @@ type End struct {
}
type ProgressInfo struct {
Chara SingleModeChara `json:"chara_info"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
DressID int32 `json:"dress_id"`
Chara SingleModeChara `json:"chara_info"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
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 {
ProgressInfo ProgressInfo `json:"progress_info"`
ProgressLog IdleSingleModeProgressLog `json:"progress_log_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',
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[],
career: [
{
@@ -109,7 +114,7 @@ export const PAGES = {
{
route: resolve('/doc/fans'),
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[],
} 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()}
<Sec h={1} id="top" class="text-center pointer-coarse:mb-8">Career Fan Gain Is Solved</Sec>
<p>
Thanks to work by Moxa and Werseter on the Hakuraku Discord, a precise formula for fans gained from career races has been
found.
Thanks to work by @Moxayoru and @Werseter on the Hakuraku Discord, a precise formula for fans gained from career races has
been found.
</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
+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
*/
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 = [
+57 -53
View File
@@ -7,10 +7,6 @@
import { resolve } from '$app/paths';
import RaceLenSlider from '$lib/RaceLenSlider.svelte';
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);
@@ -84,39 +80,39 @@
];
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([
const secSpeedEarly: Array<ComputedAreas | null> = $derived([
{
label: 'Early Race',
label: styleIsFront ? 'Front S' : 'Style S',
y1: (x) => race.sectionSpeed(raceLen, x * 1.1, style, race.Phase.EarlyRace)[0],
y2: (x) => race.sectionSpeed(raceLen, x * 1.1, style, race.Phase.EarlyRace)[1],
},
{
label: styleIsFront ? 'Front A' : 'Style A',
y1: (x) => race.sectionSpeed(raceLen, x, style, race.Phase.EarlyRace)[0],
y2: (x) => race.sectionSpeed(raceLen, x, style, race.Phase.EarlyRace)[1],
},
{
label: 'Mid Race',
label: styleIsFront ? 'Front A Speed-Up Mode' : 'Style A Pace-Up Mode',
y1: (x) => race.sectionSpeed(raceLen, x, style, race.Phase.EarlyRace)[0] * 1.04,
y2: (x) => race.sectionSpeed(raceLen, x, style, race.Phase.EarlyRace)[1] * 1.04,
},
]);
const secSpeedMid: Array<ComputedAreas | null> = $derived([
{
label: styleIsFront ? 'Front S' : 'Style S',
y1: (x) => race.sectionSpeed(raceLen, x * 1.1, style, race.Phase.MidRace)[0],
y2: (x) => race.sectionSpeed(raceLen, x * 1.1, style, race.Phase.MidRace)[1],
},
{
label: styleIsFront ? 'Front A' : 'Style A',
y1: (x) => race.sectionSpeed(raceLen, x, style, race.Phase.MidRace)[0],
y2: (x) => race.sectionSpeed(raceLen, x, style, race.Phase.MidRace)[1],
},
styleIsFront
? {
label: 'Early Race + Mean Speed-Up Mode',
y1: (x) => modemean(race.sectionSpeed(raceLen, x, style, race.Phase.EarlyRace)[0], 1.04, race.frontModeEnterChance(x)),
y2: (x) => modemean(race.sectionSpeed(raceLen, 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, style, race.Phase.EarlyRace)[0], 1.04, race.paceUpEnterChance(x)),
y2: (x) => modemean(race.sectionSpeed(raceLen, 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, style, race.Phase.MidRace)[0], 1.04, race.frontModeEnterChance(x)),
y2: (x) => modemean(race.sectionSpeed(raceLen, 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, style, race.Phase.MidRace)[0], 1.04, race.paceUpEnterChance(x)),
y2: (x) => modemean(race.sectionSpeed(raceLen, x, style, race.Phase.MidRace)[1], 1.04, race.paceUpEnterChance(x)),
},
{
label: styleIsFront ? 'Front A Speed-Up Mode' : 'Style A Pace-Up Mode',
y1: (x) => race.sectionSpeed(raceLen, x, style, race.Phase.MidRace)[0] * 1.04,
y2: (x) => race.sectionSpeed(raceLen, x, style, race.Phase.MidRace)[1] * 1.04,
},
]);
const downhill: ComputedSeries[] = [
{ label: 'Style S', y: (x) => race.downhillAccelEnterChance(x * 1.1) * 100 },
@@ -396,34 +392,42 @@
>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">
<div class="mb-10 grid h-96 w-full md:grid-cols-2">
<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]}
yArea={secSpeedEarly}
yLabel="Early Race Section Speed (m/s)"
range={[17, 23.5]}
plotOptions={{ color: { legend: true } }}
class="h-96 max-h-96"
/>
<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>
<StatChart
stat={race.Stat.Wit}
yArea={secSpeedMid}
yLabel="Mid Race Section Speed (m/s)"
range={[17, 23.5]}
plotOptions={{ color: { legend: true } }}
class="h-96 max-h-96"
/>
</div>
<div class="mx-auto mb-4 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>
<Sec
@@ -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} />