Compare commits
5
Commits
ismdb
...
04be0b75a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04be0b75a9 | ||
|
|
5a2b9b7bf4 | ||
|
|
2d935b87ec | ||
|
|
f623388291 | ||
|
|
74185d92d4 |
+455
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
+5
-1
@@ -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,6 +318,7 @@ 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"`
|
||||||
@@ -335,6 +336,8 @@ 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 {
|
||||||
@@ -342,4 +345,5 @@ type WorkIdleSingleMode struct {
|
|||||||
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"`
|
OldChara *SingleModeChara `json:"chara_info"`
|
||||||
|
Extra map[string]any `json:"extra_data"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -109,7 +109,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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,245 @@
|
|||||||
|
<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) {
|
||||||
|
return { id: sk.skill_id, name: sk.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} />
|
||||||
|
{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)}
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</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)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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)}
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</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} />
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
|
||||||
|
</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 & accumulatetime>=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"> </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"
|
||||||
|
>
|
||||||
|
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
|
||||||
|
</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>
|
||||||
|
</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} />
|
||||||
Reference in New Issue
Block a user