1 Commits
Author SHA1 Message Date
zephyr 9f758fcbe5 cmd/ismdb: start work 2026-10-21 22:33:43 -04:00
13 changed files with 21 additions and 1390 deletions
+8 -275
View File
@@ -11,11 +11,10 @@ import (
"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
const appID int32 = 0x4acedb
//go:embed schema-00.sql
var schema0 string
@@ -51,8 +50,6 @@ func opendb(uri, journalMode string) *sqlitemigration.Pool {
}
func insert(ctx context.Context, db *sqlitemigration.Pool, log *slog.Logger, career *ism.WorkIdleSingleMode, trainerID int64) (err error) {
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
@@ -60,52 +57,12 @@ func insert(ctx context.Context, db *sqlitemigration.Pool, log *slog.Logger, car
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")
func insertCareer(conn *sqlite.Conn, career *ism.WorkIdleSingleMode, trainerID int64) error {
stmt := conn.Prep(insertCareerSQL)
stmt.SetInt64("$trainer_id", trainerID)
stmt.SetInt64("$trained_chara_id", int64(career.End.Chara.SingleModeCharaID))
@@ -166,18 +123,17 @@ func insertCareer(conn *sqlite.Conn, log *slog.Logger, career *ism.WorkIdleSingl
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))
stmt.SetInt64("$succession_trained_chara_id_1", int64(career.OldChara.SuccessionTrainedCharaID1))
stmt.SetInt64("$succession_trained_chara_id_2", int64(career.OldChara.SuccessionTrainedCharaID2))
_, err := stmt.Step()
err = errors.Join(err, stmt.Reset())
if err != nil {
return fmt.Errorf("inserting career: %w", err)
return fmt.Errorf("inserting race course: %w", err)
}
return nil
}
func insertConditions(conn *sqlite.Conn, log *slog.Logger, cs []ism.CharaEffectLog, trainerID int64, trainedChara int32) error {
log.Debug("insert conditions")
func insertConditions(conn *sqlite.Conn, cs []ism.CharaEffectLog, trainerID int64, trainedChara int32) error {
const sql = `INSERT INTO condition VALUES (?, ?, ?, ?)`
stmt := conn.Prep(sql)
stmt.BindInt64(1, trainerID)
@@ -197,8 +153,7 @@ func insertConditions(conn *sqlite.Conn, log *slog.Logger, cs []ism.CharaEffectL
//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)))
func insertGain(conn *sqlite.Conn, gain *ism.IdleSingleModeGainInfo, trainerID int64, trainedChara int32, source int32) error {
stmt := conn.Prep(insertGainSQL)
stmt.SetInt64("$trainer_id", trainerID)
stmt.SetInt64("$trained_chara_id", int64(trainedChara))
@@ -227,229 +182,7 @@ func insertGain(conn *sqlite.Conn, log *slog.Logger, gain *ism.IdleSingleModeGai
_, 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 fmt.Errorf("inserting gain info: %w", err)
}
return nil
}
-73
View File
@@ -1,73 +0,0 @@
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))
}
+4 -3
View File
@@ -181,13 +181,14 @@ CREATE TABLE IF NOT EXISTS race_reward (
CREATE INDEX IF NOT EXISTS race_reward_by_career_turn ON race_reward (trainer_id, trained_chara_id, turn);
CREATE INDEX IF NOT EXISTS race_reward_by_item ON race_reward (item_id);
CREATE TABLE IF NOT EXISTS gained_skill (
CREATE TABLE IF NOT EXISTS gain_skill (
trainer_id INTEGER NOT NULL DEFAULT 0,
trained_chara_id INTEGER NOT NULL,
source 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
PRIMARY KEY (trainer_id, trained_chara_id, source, skill_id),
FOREIGN KEY (trainer_id, trained_chara_id, source) REFERENCES gain ON DELETE CASCADE
) STRICT;
CREATE TABLE IF NOT EXISTS career_skill (
+1 -5
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 int64 `json:"owner_viewer_id"`
OwnerID int32 `json:"owner_viewer_id"`
}
type GroupOuting struct {
@@ -318,7 +318,6 @@ 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"`
@@ -336,8 +335,6 @@ type ProgressInfo struct {
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 {
@@ -345,5 +342,4 @@ type WorkIdleSingleMode struct {
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
@@ -1,84 +0,0 @@
<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>
+1 -6
View File
@@ -54,11 +54,6 @@ 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: [
{
@@ -114,7 +109,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
@@ -1,145 +0,0 @@
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
@@ -1,82 +0,0 @@
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
@@ -1,407 +0,0 @@
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 @Moxayoru and @Werseter on the Hakuraku Discord, a precise formula for fans gained from career races has
been found.
Thanks to work by Moxa 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 = [
@@ -1,250 +0,0 @@
<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>
@@ -1,53 +0,0 @@
<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} />