Compare commits

...

17 Commits

Author SHA1 Message Date
0ad064725f zenno: link to discord bot 2026-03-31 23:21:29 -04:00
886dccb6b8 zenno: title and favicon 2026-03-31 19:33:24 -04:00
57e8a06383 zenno: generate pages as directory/index.html 2026-03-31 19:23:10 -04:00
ab14f58079 cmd/horsebot: fix http server in http interactions mode 2026-03-31 19:03:51 -04:00
4106215180 zenno, cmd/horsebot: host website in horsebot 2026-03-31 18:43:17 -04:00
cdea376f94 zenno: make footer links open new tabs 2026-03-31 17:27:45 -04:00
d157dfc9b6 zenno: don't use sakura 2026-03-31 16:54:51 -04:00
773625b842 zenno: allow styling CharaPick 2026-03-31 12:32:04 -04:00
22ca5c98f3 zenno: format 2026-03-31 12:08:36 -04:00
08deedea8f zenno: nicer lobby conversation page 2026-03-31 01:19:13 -04:00
86b769d7ed zenno: lobby conversation page 2026-03-30 23:22:43 -04:00
e139eae06d zenno: start conversations tool 2026-03-30 21:53:03 -04:00
34e8c1f812 zenno: implement character picker 2026-03-30 12:13:20 -04:00
cc3128d65a zenno: better mobile nav 2026-03-30 10:13:17 -04:00
d04544030a zenno: create sveltekit website 2026-03-29 23:14:55 -04:00
e13c435afa global: generate with 2026-03-26 db 2026-03-26 04:05:39 -04:00
4429bbecd1 global: generate with 2026-03-19 db 2026-03-19 11:29:46 -04:00
40 changed files with 22062 additions and 29 deletions

View File

@@ -3,11 +3,14 @@ package main
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"log/slog" "log/slog"
"net"
"net/http"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
@@ -26,20 +29,23 @@ import (
func main() { func main() {
var ( var (
dataDir string // public site
addr string
dataDir string
public string
// discord
tokenFile string tokenFile string
// http api options apiRoute string
addr string pubkey string
route string // logging
pubkey string
// logging options
level slog.Level level slog.Level
textfmt string textfmt string
) )
flag.StringVar(&addr, "http", ":80", "`address` to bind HTTP server")
flag.StringVar(&dataDir, "data", "", "`dir`ectory containing exported json data") flag.StringVar(&dataDir, "data", "", "`dir`ectory containing exported json data")
flag.StringVar(&public, "public", "", "`dir`ectory containing the website to serve")
flag.StringVar(&tokenFile, "token", "", "`file` containing the Discord bot token") flag.StringVar(&tokenFile, "token", "", "`file` containing the Discord bot token")
flag.StringVar(&addr, "http", "", "`address` to bind HTTP API server") flag.StringVar(&apiRoute, "route", "/interactions/callback", "`path` to serve Discord HTTP API calls")
flag.StringVar(&route, "route", "/interactions/callback", "`path` to serve HTTP API calls")
flag.StringVar(&pubkey, "key", "", "Discord public key") flag.StringVar(&pubkey, "key", "", "Discord public key")
flag.TextVar(&level, "log", slog.LevelInfo, "slog logging `level`") flag.TextVar(&level, "log", slog.LevelInfo, "slog logging `level`")
flag.StringVar(&textfmt, "log-format", "text", "slog logging `format`, text or json") flag.StringVar(&textfmt, "log-format", "text", "slog logging `format`, text or json")
@@ -57,6 +63,16 @@ func main() {
} }
slog.SetDefault(slog.New(lh)) slog.SetDefault(slog.New(lh))
stat, err := os.Stat(public)
if err != nil {
slog.Error("public", slog.Any("err", err))
os.Exit(1)
}
if !stat.IsDir() {
slog.Error("public", slog.String("err", "not a directory"))
os.Exit(1)
}
skills, err := loadSkills(filepath.Join(dataDir, "skill.json")) skills, err := loadSkills(filepath.Join(dataDir, "skill.json"))
slog.Info("loaded skills", slog.Int("count", len(skills))) slog.Info("loaded skills", slog.Int("count", len(skills)))
groups, err2 := loadSkillGroups(filepath.Join(dataDir, "skill-group.json")) groups, err2 := loadSkillGroups(filepath.Join(dataDir, "skill-group.json"))
@@ -87,38 +103,50 @@ func main() {
r.SelectMenuComponent("/swap", skillSrv.menu) r.SelectMenuComponent("/swap", skillSrv.menu)
r.ButtonComponent("/swap/{id}", skillSrv.button) r.ButtonComponent("/swap/{id}", skillSrv.button)
}) })
opts := []bot.ConfigOpt{bot.WithDefaultGateway(), bot.WithEventListeners(r)}
if addr != "" {
if pubkey == "" {
slog.Error("Discord public key must be provided when using HTTP API")
os.Exit(1)
}
opts = append(opts, bot.WithHTTPServerConfigOpts(pubkey,
httpserver.WithAddress(addr),
httpserver.WithURL(route),
))
}
slog.Info("connect", slog.String("disgo", disgo.Version)) slog.Info("connect", slog.String("disgo", disgo.Version))
client, err := disgo.New(string(token), opts...) client, err := disgo.New(string(token), bot.WithDefaultGateway(), bot.WithEventListeners(r))
if err != nil { if err != nil {
slog.Error("building bot", slog.Any("err", err)) slog.Error("building bot", slog.Any("err", err))
os.Exit(1) os.Exit(1)
} }
mux := http.NewServeMux()
mux.Handle("GET /", http.FileServerFS(os.DirFS(public)))
if pubkey != "" {
pk, err := hex.DecodeString(pubkey)
if err != nil {
slog.Error("pubkey", slog.Any("err", err))
os.Exit(1)
}
mux.Handle("POST "+apiRoute, httpserver.HandleInteraction(httpserver.DefaultVerifier{}, pk, slog.Default(), client.EventManager.HandleHTTPEvent))
slog.Info("Discord HTTP API enabled", slog.String("pubkey", pubkey))
}
l, err := net.Listen("tcp", addr)
if err != nil {
slog.Error("listen", slog.String("addr", addr), slog.Any("err", err))
os.Exit(1)
}
srv := http.Server{
Addr: addr,
Handler: mux,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
BaseContext: func(l net.Listener) context.Context { return ctx },
}
go func() {
slog.Info("HTTP", slog.Any("addr", l.Addr()))
err := srv.Serve(l)
if err == http.ErrServerClosed {
return
}
slog.Error("HTTP server closed", slog.Any("err", err))
}()
if err := handler.SyncCommands(client, commands, nil, rest.WithCtx(ctx)); err != nil { if err := handler.SyncCommands(client, commands, nil, rest.WithCtx(ctx)); err != nil {
slog.Error("syncing commands", slog.Any("err", err)) slog.Error("syncing commands", slog.Any("err", err))
os.Exit(1) os.Exit(1)
} }
if addr != "" {
slog.Info("start HTTP server", slog.String("address", addr), slog.String("route", route))
if err := client.OpenHTTPServer(); err != nil {
slog.Error("starting HTTP server", slog.Any("err", err))
stop()
}
}
slog.Info("start gateway") slog.Info("start gateway")
if err := client.OpenGateway(ctx); err != nil { if err := client.OpenGateway(ctx); err != nil {
slog.Error("starting gateway", slog.Any("err", err)) slog.Error("starting gateway", slog.Any("err", err))
@@ -131,6 +159,10 @@ func main() {
ctx, stop = context.WithTimeout(context.Background(), 5*time.Second) ctx, stop = context.WithTimeout(context.Background(), 5*time.Second)
defer stop() defer stop()
client.Close(ctx) client.Close(ctx)
if err := srv.Shutdown(ctx); err != nil {
slog.Error("HTTP API shutdown", slog.Any("err", err))
os.Exit(1)
}
} }
var commands = []discord.ApplicationCommandCreate{ var commands = []discord.ApplicationCommandCreate{

File diff suppressed because it is too large Load Diff

View File

@@ -203,6 +203,10 @@
"chara_id": 1062, "chara_id": 1062,
"name": "Matikanetannhauser" "name": "Matikanetannhauser"
}, },
{
"chara_id": 1067,
"name": "Satono Diamond"
},
{ {
"chara_id": 1068, "chara_id": 1068,
"name": "Kitasan Black" "name": "Kitasan Black"
@@ -214,5 +218,9 @@
{ {
"chara_id": 1071, "chara_id": 1071,
"name": "Mejiro Ardan" "name": "Mejiro Ardan"
},
{
"chara_id": 1074,
"name": "Mejiro Bright"
} }
] ]

View File

@@ -1361,6 +1361,16 @@
"chara_2": 1015, "chara_2": 1015,
"condition_type": 2 "condition_type": 2
}, },
{
"chara_id": 1033,
"number": 6,
"location": 530,
"location_name": "left side school map",
"chara_1": 1067,
"chara_2": 1068,
"chara_3": 1033,
"condition_type": 4
},
{ {
"chara_id": 1035, "chara_id": 1035,
"number": 1, "number": 1,
@@ -2135,6 +2145,48 @@
"chara_3": 1048, "chara_3": 1048,
"condition_type": 1 "condition_type": 1
}, },
{
"chara_id": 1067,
"number": 1,
"location": 310,
"location_name": "center back seat",
"chara_1": 1067,
"condition_type": 0
},
{
"chara_id": 1067,
"number": 2,
"location": 410,
"location_name": "center posters",
"chara_1": 1067,
"condition_type": 1
},
{
"chara_id": 1067,
"number": 3,
"location": 510,
"location_name": "left side school map",
"chara_1": 1067,
"condition_type": 1
},
{
"chara_id": 1067,
"number": 4,
"location": 220,
"location_name": "left side table",
"chara_1": 1067,
"chara_2": 1068,
"condition_type": 2
},
{
"chara_id": 1067,
"number": 5,
"location": 420,
"location_name": "center posters",
"chara_1": 1067,
"chara_2": 1013,
"condition_type": 2
},
{ {
"chara_id": 1068, "chara_id": 1068,
"number": 1, "number": 1,
@@ -2270,5 +2322,57 @@
"chara_1": 1069, "chara_1": 1069,
"chara_2": 1071, "chara_2": 1071,
"condition_type": 3 "condition_type": 3
},
{
"chara_id": 1074,
"number": 1,
"location": 310,
"location_name": "center back seat",
"chara_1": 1074,
"condition_type": 0
},
{
"chara_id": 1074,
"number": 2,
"location": 110,
"location_name": "right side front",
"chara_1": 1074,
"condition_type": 1
},
{
"chara_id": 1074,
"number": 3,
"location": 210,
"location_name": "left side table",
"chara_1": 1074,
"condition_type": 1
},
{
"chara_id": 1074,
"number": 4,
"location": 220,
"location_name": "left side table",
"chara_1": 1074,
"chara_2": 1027,
"condition_type": 2
},
{
"chara_id": 1074,
"number": 5,
"location": 420,
"location_name": "center posters",
"chara_1": 1001,
"chara_2": 1074,
"condition_type": 3
},
{
"chara_id": 1074,
"number": 6,
"location": 530,
"location_name": "left side school map",
"chara_1": 1074,
"chara_2": 1056,
"chara_3": 1059,
"condition_type": 2
} }
] ]

View File

@@ -307,6 +307,11 @@
"skill1": 100621, "skill1": 100621,
"skill2": 900621 "skill2": 900621
}, },
{
"skill_group": 10067,
"skill1": 100671,
"skill2": 900671
},
{ {
"skill_group": 10068, "skill_group": 10068,
"skill1": 100681, "skill1": 100681,
@@ -322,6 +327,11 @@
"skill1": 100711, "skill1": 100711,
"skill2": 900711 "skill2": 900711
}, },
{
"skill_group": 10074,
"skill1": 100741,
"skill2": 900741
},
{ {
"skill_group": 11001, "skill_group": 11001,
"skill1": 110011, "skill1": 110011,
@@ -421,6 +431,7 @@
"skill_group": 20001, "skill_group": 20001,
"skill1": 200012, "skill1": 200012,
"skill2": 200011, "skill2": 200011,
"skill3": 200014,
"skill_bad": 200013 "skill_bad": 200013
}, },
{ {
@@ -1303,6 +1314,15 @@
"skill_group": 20205, "skill_group": 20205,
"skill2": 202051 "skill2": 202051
}, },
{
"skill_group": 20206,
"skill1": 202061
},
{
"skill_group": 20207,
"skill1": 202072,
"skill2": 202071
},
{ {
"skill_group": 21001, "skill_group": 21001,
"skill1": 210012, "skill1": 210012,
@@ -1373,6 +1393,14 @@
"skill_group": 30010, "skill_group": 30010,
"skill1": 300101 "skill1": 300101
}, },
{
"skill_group": 30011,
"skill1": 300111
},
{
"skill_group": 30012,
"skill1": 300121
},
{ {
"skill_group": 90001, "skill_group": 90001,
"skill1": 100011, "skill1": 100011,
@@ -1613,6 +1641,11 @@
"skill1": 100621, "skill1": 100621,
"skill2": 900621 "skill2": 900621
}, },
{
"skill_group": 90067,
"skill1": 100671,
"skill2": 900671
},
{ {
"skill_group": 90068, "skill_group": 90068,
"skill1": 100681, "skill1": 100681,
@@ -1628,6 +1661,11 @@
"skill1": 100711, "skill1": 100711,
"skill2": 900711 "skill2": 900711
}, },
{
"skill_group": 90074,
"skill1": 100741,
"skill2": 900741
},
{ {
"skill_group": 91001, "skill_group": 91001,
"skill1": 110011, "skill1": 110011,

View File

@@ -1969,6 +1969,48 @@
"unique_owner": "[Clippety-Tippety-Clop] Matikanetannhauser", "unique_owner": "[Clippety-Tippety-Clop] Matikanetannhauser",
"icon_id": 20023 "icon_id": 20023
}, },
{
"skill_id": 100671,
"name": "Eternal Encompassing Shine",
"description": "If well-positioned at the start of the final straight, her strong willpower to win increases velocity. If positioned near the front, greatly increase velocity instead.",
"group": 10067,
"rarity": 5,
"group_rate": 1,
"grade_value": 340,
"wit_check": false,
"activations": [
{
"condition": "is_last_straight_onetime==1&order>=2&order<=5&distance_diff_top<=5",
"duration": 50000,
"dur_scale": 1,
"cooldown": 5000000,
"abilities": [
{
"type": 27,
"value_usage": 1,
"value": 4500,
"target": 1
}
]
},
{
"condition": "is_last_straight_onetime==1&order>=2&order<=5&distance_diff_top>5",
"duration": 50000,
"dur_scale": 1,
"cooldown": 5000000,
"abilities": [
{
"type": 27,
"value_usage": 1,
"value": 3500,
"target": 1
}
]
}
],
"unique_owner": "[Natural Brilliance] Satono Diamond",
"icon_id": 20013
},
{ {
"skill_id": 100681, "skill_id": 100681,
"name": "Victory Cheer!", "name": "Victory Cheer!",
@@ -2079,6 +2121,34 @@
"unique_owner": "[Crystalline] Mejiro Ardan", "unique_owner": "[Crystalline] Mejiro Ardan",
"icon_id": 20013 "icon_id": 20013
}, },
{
"skill_id": 100741,
"name": "Lovely Spring Breeze",
"description": "From the midpack in the second half of the race, slightly increase velocity steadily for a duration based on remaining endurance.",
"group": 10074,
"rarity": 5,
"group_rate": 1,
"grade_value": 340,
"wit_check": false,
"activations": [
{
"condition": "distance_rate>=50&order_rate>=40&order_rate<=80",
"duration": 50000,
"dur_scale": 3,
"cooldown": 5000000,
"abilities": [
{
"type": 27,
"value_usage": 1,
"value": 1500,
"target": 1
}
]
}
],
"unique_owner": "[Brunissage Line] Mejiro Bright",
"icon_id": 20013
},
{ {
"skill_id": 110011, "skill_id": 110011,
"name": "Dazzl'n ♪ Diver", "name": "Dazzl'n ♪ Diver",
@@ -2755,6 +2825,39 @@
"sp_cost": 50, "sp_cost": 50,
"icon_id": 10014 "icon_id": 10014
}, },
{
"skill_id": 200014,
"name": "Right-Handed Demon",
"description": "Increase proficiency in right-handed tracks, increasing Speed and Power.",
"group": 20001,
"rarity": 2,
"group_rate": 3,
"grade_value": 461,
"wit_check": false,
"activations": [
{
"condition": "rotation==1",
"duration": -1,
"dur_scale": 1,
"abilities": [
{
"type": 1,
"value_usage": 1,
"value": 600000,
"target": 1
},
{
"type": 3,
"value_usage": 1,
"value": 600000,
"target": 1
}
]
}
],
"sp_cost": 130,
"icon_id": 10012
},
{ {
"skill_id": 200021, "skill_id": 200021,
"name": "Left-Handed ◎", "name": "Left-Handed ◎",
@@ -12646,6 +12749,102 @@
"sp_cost": 200, "sp_cost": 200,
"icon_id": 40012 "icon_id": 40012
}, },
{
"skill_id": 202061,
"name": "Best in Japan",
"description": "Everyone's expectations strongly inspire the skill user, increasing velocity on the final corner. (Long)",
"group": 20206,
"rarity": 2,
"group_rate": 1,
"grade_value": 508,
"wit_check": true,
"activations": [
{
"condition": "distance_type==4&is_finalcorner_random==1",
"duration": 30000,
"dur_scale": 1,
"cooldown": 5000000,
"abilities": [
{
"type": 27,
"value_usage": 1,
"value": 3500,
"target": 1
}
]
}
],
"sp_cost": 360,
"icon_id": 2010010
},
{
"skill_id": 202071,
"name": "Of Calm Mind",
"description": "If positioned in the midpack around when the mid-race starts, slightly decrease velocity and greatly recover endurance. (Long)",
"group": 20207,
"rarity": 2,
"group_rate": 2,
"grade_value": 508,
"wit_check": true,
"activations": [
{
"condition": "distance_type==4&phase_firsthalf_random==1&order_rate>=40&order_rate<=80",
"duration": 12000,
"dur_scale": 1,
"cooldown": 5000000,
"abilities": [
{
"type": 9,
"value_usage": 1,
"value": 750,
"target": 1
},
{
"type": 21,
"value_usage": 1,
"value": -1500,
"target": 1
}
]
}
],
"sp_cost": 170,
"icon_id": 20022
},
{
"skill_id": 202072,
"name": "Free-Spirited",
"description": "If positioned in the midpack around when the mid-race starts, slightly decrease velocity and moderately recover endurance. (Long)",
"group": 20207,
"rarity": 1,
"group_rate": 1,
"grade_value": 217,
"wit_check": true,
"activations": [
{
"condition": "distance_type==4&phase_firsthalf_random==1&order_rate>=40&order_rate<=80",
"duration": 12000,
"dur_scale": 1,
"cooldown": 5000000,
"abilities": [
{
"type": 9,
"value_usage": 1,
"value": 350,
"target": 1
},
{
"type": 21,
"value_usage": 1,
"value": -1500,
"target": 1
}
]
}
],
"sp_cost": 170,
"icon_id": 20021
},
{ {
"skill_id": 210011, "skill_id": 210011,
"name": "Burning Spirit SPD", "name": "Burning Spirit SPD",
@@ -13284,6 +13483,56 @@
], ],
"icon_id": 20022 "icon_id": 20022
}, },
{
"skill_id": 300111,
"name": "Chin Up, Derby Umamusume!",
"description": "Feel closer to being Japan's top racer, moderately increasing performance.",
"group": 30011,
"rarity": 1,
"group_rate": 1,
"wit_check": false,
"activations": [
{
"condition": "always==1",
"duration": -1,
"dur_scale": 1,
"abilities": [
{
"type": 1,
"value_usage": 1,
"value": 400000,
"target": 1
}
]
}
],
"icon_id": 10011
},
{
"skill_id": 300121,
"name": "For the Team",
"description": "The whole team feels super determined to win this special race, greatly increasing performance.",
"group": 30012,
"rarity": 2,
"group_rate": 1,
"wit_check": false,
"activations": [
{
"condition": "always==1",
"duration": -1,
"dur_scale": 1,
"abilities": [
{
"type": 2,
"value_usage": 1,
"value": 800000,
"target": 1
}
]
}
],
"icon_id": 10022
},
{ {
"skill_id": 900011, "skill_id": 900011,
"name": "Shooting Star", "name": "Shooting Star",
@@ -14791,6 +15040,49 @@
"sp_cost": 200, "sp_cost": 200,
"icon_id": 20021 "icon_id": 20021
}, },
{
"skill_id": 900671,
"name": "Eternal Encompassing Shine",
"description": "If well-positioned at the start of the final straight, slightly increase velocity. If positioned near the front, moderately increase velocity instead.",
"group": 10067,
"rarity": 1,
"group_rate": 2,
"grade_value": 180,
"wit_check": true,
"activations": [
{
"condition": "is_last_straight_onetime==1&order>=2&order<=5&distance_diff_top<=5",
"duration": 30000,
"dur_scale": 1,
"cooldown": 5000000,
"abilities": [
{
"type": 27,
"value_usage": 1,
"value": 2500,
"target": 1
}
]
},
{
"condition": "is_last_straight_onetime==1&order>=2&order<=5&distance_diff_top>5",
"duration": 30000,
"dur_scale": 1,
"cooldown": 5000000,
"abilities": [
{
"type": 27,
"value_usage": 1,
"value": 1500,
"target": 1
}
]
}
],
"unique_owner": "[Natural Brilliance] Satono Diamond",
"sp_cost": 200,
"icon_id": 20011
},
{ {
"skill_id": 900681, "skill_id": 900681,
"name": "Victory Cheer!", "name": "Victory Cheer!",
@@ -14904,6 +15196,35 @@
"sp_cost": 200, "sp_cost": 200,
"icon_id": 20011 "icon_id": 20011
}, },
{
"skill_id": 900741,
"name": "Lovely Spring Breeze",
"description": "From the midpack in the second half of the race, minimally increase velocity steadily for a duration based on remaining endurance.",
"group": 10074,
"rarity": 1,
"group_rate": 2,
"grade_value": 180,
"wit_check": true,
"activations": [
{
"condition": "distance_rate>=50&order_rate>=40&order_rate<=80",
"duration": 30000,
"dur_scale": 3,
"cooldown": 5000000,
"abilities": [
{
"type": 27,
"value_usage": 1,
"value": 350,
"target": 1
}
]
}
],
"unique_owner": "[Brunissage Line] Mejiro Bright",
"sp_cost": 200,
"icon_id": 20011
},
{ {
"skill_id": 910011, "skill_id": 910011,
"name": "Dazzl'n ♪ Diver", "name": "Dazzl'n ♪ Diver",

View File

@@ -28322,6 +28322,141 @@
] ]
] ]
}, },
{
"spark_id": 2020701,
"name": "Free-Spirited",
"description": "A Spark that gives a skill hint for \"Free-Spirited\".",
"spark_group": 20207,
"rarity": 1,
"type": 4,
"effects": [
[
{
"target": 41,
"value1": 202072,
"value2": 1
}
],
[
{
"target": 41,
"value1": 202072,
"value2": 2
}
],
[
{
"target": 41,
"value1": 202072,
"value2": 3
}
],
[
{
"target": 41,
"value1": 202072,
"value2": 4
}
],
[
{
"target": 41,
"value1": 202072,
"value2": 5
}
]
]
},
{
"spark_id": 2020702,
"name": "Free-Spirited",
"description": "A Spark that gives a skill hint for \"Free-Spirited\".",
"spark_group": 20207,
"rarity": 2,
"type": 4,
"effects": [
[
{
"target": 41,
"value1": 202072,
"value2": 1
}
],
[
{
"target": 41,
"value1": 202072,
"value2": 2
}
],
[
{
"target": 41,
"value1": 202072,
"value2": 3
}
],
[
{
"target": 41,
"value1": 202072,
"value2": 4
}
],
[
{
"target": 41,
"value1": 202072,
"value2": 5
}
]
]
},
{
"spark_id": 2020703,
"name": "Free-Spirited",
"description": "A Spark that gives a skill hint for \"Free-Spirited\".",
"spark_group": 20207,
"rarity": 3,
"type": 4,
"effects": [
[
{
"target": 41,
"value1": 202072,
"value2": 1
}
],
[
{
"target": 41,
"value1": 202072,
"value2": 2
}
],
[
{
"target": 41,
"value1": 202072,
"value2": 3
}
],
[
{
"target": 41,
"value1": 202072,
"value2": 4
}
],
[
{
"target": 41,
"value1": 202072,
"value2": 5
}
]
]
},
{ {
"spark_id": 2100101, "spark_id": 2100101,
"name": "Ignited Spirit SPD", "name": "Ignited Spirit SPD",
@@ -35723,6 +35858,99 @@
] ]
] ]
}, },
{
"spark_id": 10670101,
"name": "Eternal Encompassing Shine",
"description": "A Spark that gives a skill hint for \"Eternal Encompassing Shine\".",
"spark_group": 106701,
"rarity": 1,
"type": 3,
"effects": [
[
{
"target": 41,
"value1": 900671,
"value2": 1
}
],
[
{
"target": 41,
"value1": 900671,
"value2": 2
}
],
[
{
"target": 41,
"value1": 900671,
"value2": 3
}
]
]
},
{
"spark_id": 10670102,
"name": "Eternal Encompassing Shine",
"description": "A Spark that gives a skill hint for \"Eternal Encompassing Shine\".",
"spark_group": 106701,
"rarity": 2,
"type": 3,
"effects": [
[
{
"target": 41,
"value1": 900671,
"value2": 1
}
],
[
{
"target": 41,
"value1": 900671,
"value2": 2
}
],
[
{
"target": 41,
"value1": 900671,
"value2": 3
}
]
]
},
{
"spark_id": 10670103,
"name": "Eternal Encompassing Shine",
"description": "A Spark that gives a skill hint for \"Eternal Encompassing Shine\".",
"spark_group": 106701,
"rarity": 3,
"type": 3,
"effects": [
[
{
"target": 41,
"value1": 900671,
"value2": 1
}
],
[
{
"target": 41,
"value1": 900671,
"value2": 2
}
],
[
{
"target": 41,
"value1": 900671,
"value2": 3
}
]
]
},
{ {
"spark_id": 10680101, "spark_id": 10680101,
"name": "Victory Cheer!", "name": "Victory Cheer!",
@@ -36001,5 +36229,98 @@
} }
] ]
] ]
},
{
"spark_id": 10740101,
"name": "Lovely Spring Breeze",
"description": "A Spark that gives a skill hint for \"Lovely Spring Breeze\".",
"spark_group": 107401,
"rarity": 1,
"type": 3,
"effects": [
[
{
"target": 41,
"value1": 900741,
"value2": 1
}
],
[
{
"target": 41,
"value1": 900741,
"value2": 2
}
],
[
{
"target": 41,
"value1": 900741,
"value2": 3
}
]
]
},
{
"spark_id": 10740102,
"name": "Lovely Spring Breeze",
"description": "A Spark that gives a skill hint for \"Lovely Spring Breeze\".",
"spark_group": 107401,
"rarity": 2,
"type": 3,
"effects": [
[
{
"target": 41,
"value1": 900741,
"value2": 1
}
],
[
{
"target": 41,
"value1": 900741,
"value2": 2
}
],
[
{
"target": 41,
"value1": 900741,
"value2": 3
}
]
]
},
{
"spark_id": 10740103,
"name": "Lovely Spring Breeze",
"description": "A Spark that gives a skill hint for \"Lovely Spring Breeze\".",
"spark_group": 107401,
"rarity": 3,
"type": 3,
"effects": [
[
{
"target": 41,
"value1": 900741,
"value2": 1
}
],
[
{
"target": 41,
"value1": 900741,
"value2": 2
}
],
[
{
"target": 41,
"value1": 900741,
"value2": 3
}
]
]
} }
] ]

View File

@@ -1607,6 +1607,30 @@
"skill_pl4": 200512, "skill_pl4": 200512,
"skill_pl5": 201211 "skill_pl5": 201211
}, },
{
"chara_card_id": 106701,
"chara_id": 1067,
"name": "[Natural Brilliance] Satono Diamond",
"variant": "[Natural Brilliance]",
"sprint": 0,
"mile": 5,
"medium": 7,
"long": 7,
"front": 1,
"pace": 6,
"late": 7,
"end": 4,
"turf": 7,
"dirt": 1,
"unique": 100671,
"skill1": 200012,
"skill2": 201692,
"skill3": 201102,
"skill_pl2": 202012,
"skill_pl3": 201691,
"skill_pl4": 200152,
"skill_pl5": 200014
},
{ {
"chara_card_id": 106801, "chara_card_id": 106801,
"chara_id": 1068, "chara_id": 1068,
@@ -1678,5 +1702,29 @@
"skill_pl3": 200571, "skill_pl3": 200571,
"skill_pl4": 201142, "skill_pl4": 201142,
"skill_pl5": 201701 "skill_pl5": 201701
},
{
"chara_card_id": 107401,
"chara_id": 1074,
"name": "[Brunissage Line] Mejiro Bright",
"variant": "[Brunissage Line]",
"sprint": 0,
"mile": 5,
"medium": 7,
"long": 7,
"front": 1,
"pace": 4,
"late": 7,
"end": 7,
"turf": 7,
"dirt": 1,
"unique": 100741,
"skill1": 200472,
"skill2": 201462,
"skill3": 202072,
"skill_pl2": 201212,
"skill_pl3": 200471,
"skill_pl4": 201222,
"skill_pl5": 202071
} }
] ]

23
zenno/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
zenno/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

9
zenno/.prettierignore Normal file
View File

@@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

16
zenno/.prettierrc Normal file
View File

@@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 130,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/routes/layout.css"
}

42
zenno/README.md Normal file
View File

@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.13.0 create --template minimal --types ts --add prettier eslint vitest="usages:unit,component" tailwindcss="plugins:none" --install npm zenno
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

39
zenno/eslint.config.js Normal file
View File

@@ -0,0 +1,39 @@
import prettier from 'eslint-config-prettier';
import path from 'node:path';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
ts.configs.recommended,
svelte.configs.recommended,
prettier,
svelte.configs.prettier,
{
languageOptions: { globals: { ...globals.browser, ...globals.node } },
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off',
},
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig,
},
},
},
);

4347
zenno/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
zenno/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "zenno",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run"
},
"devDependencies": {
"@eslint/compat": "^2.0.3",
"@eslint/js": "^10.0.1",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^22",
"@vitest/browser-playwright": "^4.1.0",
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.15.2",
"globals": "^17.4.0",
"playwright": "^1.58.2",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.54.0",
"svelte-check": "^4.4.2",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^7.3.1",
"vitest": "^4.1.0",
"vitest-browser-svelte": "^2.0.2"
}
}

13
zenno/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

11
zenno/src/app.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import { character } from '$lib/data/character';
import type { ClassValue } from 'svelte/elements';
interface Props {
id: string;
value: number;
label?: string;
class?: ClassValue | null;
optionClass?: ClassValue | null;
labelClass?: ClassValue | null;
region?: keyof typeof character;
required?: boolean;
}
let {
id,
value = $bindable(),
label,
class: className,
optionClass,
labelClass,
region = 'global',
required = false,
}: Props = $props();
</script>
{#if label}
<label for={id} class={labelClass}>{label}</label>
{/if}
<select {id} class={className} bind:value {required}>
{#if !required}
<option value="0" class={optionClass}></option>
{/if}
{#each character[region] as c}
<option value={c.chara_id} class={optionClass}>{c.name}</option>
{/each}
</select>

BIN
zenno/src/lib/assets/favicon.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -0,0 +1,26 @@
import type { RegionalName } from '$lib/regional-name';
import globalJSON from '../../../../global/character.json';
/**
* Character definitions.
*/
export interface Character {
/**
* Character ID.
*/
chara_id: number;
/**
* Regional name of the character.
* E.g., Special Week for Global, or スペシャルウィーク for JP.
*/
name: string;
}
export const character = {
global: globalJSON as Character[],
};
export const charaNames = globalJSON.reduce(
(m, c) => m.set(c.chara_id, { en: c.name }),
new Map<Character['chara_id'], RegionalName>(),
);

View File

@@ -0,0 +1,85 @@
import type { RegionalName } from '$lib/regional-name';
import globalJSON from '../../../../global/conversation.json';
/**
* Lobby conversation data.
*/
export interface Conversation {
/**
* Character who owns the conversation as a gallery entry.
*/
chara_id: number;
/**
* Number of the conversation within the character's conversation gallery.
*/
number: number;
/**
* Location ID of the conversation.
*/
location: 110 | 120 | 130 | 210 | 220 | 310 | 410 | 420 | 430 | 510 | 520 | 530;
/**
* English name of the location, for convenience.
*/
location_name: string;
/**
* First character in the conversation.
* Not necessarily equal to chara_id.
*/
chara_1: number;
/**
* Second character, if present.
*/
chara_2?: number;
/**
* Third character, if present.
*/
chara_3?: number;
/**
* Some unknown number in the game's local database.
*/
condition_type: 0 | 1 | 2 | 3 | 4;
}
export const conversation = {
global: globalJSON as Conversation[],
};
export const byChara = {
global: globalJSON.reduce(
(m, c) => m.set(c.chara_id, (m.get(c.chara_id) ?? []).concat(c as Conversation)),
new Map<Conversation['chara_id'], Conversation[]>(),
),
};
export const locations: Record<Conversation['location'], { name: RegionalName; group: 1 | 2 | 3 | 4 | 5 }> = {
110: { name: { en: 'right side front' }, group: 1 },
120: { name: { en: 'right side front' }, group: 1 },
130: { name: { en: 'right side front' }, group: 1 },
210: { name: { en: 'left side table' }, group: 2 },
220: { name: { en: 'left side table' }, group: 2 },
310: { name: { en: 'center back seat' }, group: 3 },
410: { name: { en: 'center posters' }, group: 4 },
420: { name: { en: 'center posters' }, group: 4 },
430: { name: { en: 'center posters' }, group: 4 },
510: { name: { en: 'left side school map' }, group: 5 },
520: { name: { en: 'left side school map' }, group: 5 },
530: { name: { en: 'left side school map' }, group: 5 },
};
function locCharas(convos: Conversation[], locGroup: 1 | 2 | 3 | 4 | 5) {
const m = convos
.filter((c) => locations[c.location].group === locGroup)
.flatMap((c) => [c.chara_1, c.chara_2, c.chara_3].filter((x) => x != null))
.reduce((m, id) => m.set(id, 1 + (m.get(id) ?? 0)), new Map<number, number>());
return [...m].toSorted((a, b) => b[1] - a[1]); // descending
}
export const groupPopulars = {
global: {
1: locCharas(conversation.global, 1),
2: locCharas(conversation.global, 2),
3: locCharas(conversation.global, 3),
4: locCharas(conversation.global, 4),
5: locCharas(conversation.global, 5),
},
};

1
zenno/src/lib/index.ts Normal file
View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -0,0 +1,7 @@
/**
* Names accounting for regions.
* Currently English is the only supported language.
*/
export interface RegionalName {
en: string;
}

View File

@@ -0,0 +1,8 @@
<script>
import { greet } from './greet';
let { host = 'SvelteKit', guest = 'Vitest' } = $props();
</script>
<h1>{greet(host)}</h1>
<p>{greet(guest)}</p>

View File

@@ -0,0 +1,13 @@
import { page } from 'vitest/browser';
import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
import Welcome from './Welcome.svelte';
describe('Welcome.svelte', () => {
it('renders greetings for host and guest', async () => {
render(Welcome, { host: 'SvelteKit', guest: 'Vitest' });
await expect.element(page.getByRole('heading', { level: 1 })).toHaveTextContent('Hello, SvelteKit!');
await expect.element(page.getByText('Hello, Vitest!')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,8 @@
import { describe, it, expect } from 'vitest';
import { greet } from './greet';
describe('greet', () => {
it('returns a greeting', () => {
expect(greet('Svelte')).toBe('Hello, Svelte!');
});
});

View File

@@ -0,0 +1,3 @@
export function greet(name: string): string {
return 'Hello, ' + name + '!';
}

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import './layout.css';
import favicon from '$lib/assets/favicon.png';
let { children } = $props();
</script>
<svelte:head>
<title>Zenno Rob Roy</title>
<link rel="icon" href={favicon} />
</svelte:head>
<div class="flex h-screen flex-col">
<nav class="mb-4 flex min-w-full bg-mist-300 p-4 shadow-md dark:bg-mist-900">
<span class="hidden flex-1 md:inline">
<a href="/" class="text-4xl">Zenno Rob Roy</a>
</span>
<span class="flex-1 text-center">
<a href="/" class="mx-8 my-1 block font-semibold md:hidden">Zenno Rob Roy</a>
<a href="/inherit" class="mx-8 my-1 inline-block">Inheritance Chance</a>
<a href="/spark" class="mx-8 my-1 inline-block">Spark Chance</a>
<a href="/vet" class="mx-8 my-1 inline-block">My Veterans</a>
<a href="/convo" class="mx-8 my-1 inline-block">Lobby Conversations</a>
</span>
</nav>
<div class="mx-4 grow lg:m-auto lg:max-w-7xl lg:min-w-7xl">
{@render children()}
</div>
<footer class="inset-x-0 bottom-0 mt-8 border-t bg-mist-300 p-4 text-center text-sm md:mt-20 dark:border-none dark:bg-mist-900">
Umamusume: Pretty Derby tools by <a href="https://zephyrtronium.date/" target="_blank" rel="noopener noreferrer"
>zephyrtronium</a
>.<br />
All game data is auto-generated from the
<a href="https://git.sunturtle.xyz/zephyr/horse/src/branch/main/doc/README.md" target="_blank" rel="noopener noreferrer"
>game's local database</a
>.
</footer>
</div>

View File

@@ -0,0 +1,2 @@
export const prerender = true;
export const trailingSlash = 'always';

View File

@@ -0,0 +1,40 @@
<h1 class="m-8 text-center text-7xl">Zenno Rob Roy</h1>
<p>She's read all about Umamusume, and she's always happy to share her knowledge and give recommendations!</p>
<h2 class="mt-8 mb-4 text-4xl">Tools</h2>
<ul class="list-disc pl-4">
<li>
<a href="/inherit">Inheritance Chance</a><i>Not yet implemented</i> — Given a legacy, calculate the probability distribution
of activation counts for each spark.
</li>
<li>
<a href="/spark">Spark Chance</a><i>Not yet implemented</i> — Given a legacy, calculate the chance of generating each spark if
you fulfill the conditions to do so, and the distribution of total spark counts.
</li>
<li>
<a href="/vet">My Veterans</a><i>Not yet implemented</i> — Set up and track your veterans for Zenno Rob Roy's inspiration and
spark calculators.
</li>
<li>
<a href="/convo">Lobby Conversations</a> — Check participants in lobby conversations and get recommendations on unlocking them quickly.
</li>
<li>
<a href="https://discord.com/oauth2/authorize?client_id=1461931240264568994" target="_blank" rel="noopener noreferrer"
>Discord Bot</a
>
— Skill search by name or unique owner within Discord. Install to a server or user.
</li>
</ul>
<h2 class="mt-8 mb-4 text-4xl">About</h2>
<p>Tools to fill some gaps I've felt in Umamusume optimization.</p>
<p>This site is very under construction. To demonstrate just how under construction it is, here is lorem ipsum:</p>
<p>
Lorem ipsum (/ ˌ l ɔː. r ə m ˈ ɪ p. s ə m/ LOR-əm IP-səm) is a dummy or placeholder text commonly used in graphic design,
publishing, and web development. It is typically a corrupted version of De finibus bonorum et malorum, a 1st-century BC text by
the Roman statesman and philosopher Cicero, with words altered, added, and removed to make it nonsensical and improper Latin.
The first two words are the truncation of dolorem ipsum ("pain itself"). Lorem ipsum's purpose is to permit a page layout to be
designed, independently of the copy that will subsequently populate it, or to demonstrate various fonts of a typeface without
meaningful text that could be distracting. Versions of the Lorem ipsum text have been used in typesetting since the 1960s, when
advertisements for Letraset transfer sheets popularized it. Lorem ipsum was introduced to the digital world in the mid-1980s,
when Aldus employed it in graphic and word-processing templates for its desktop publishing program PageMaker. Other popular word
processors, including Pages and Microsoft Word, have since adopted Lorem ipsum, as have many LaTeX packages, web content
</p>

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import { charaNames } from '$lib/data/character';
import { byChara, locations, groupPopulars } from '$lib/data/convo';
import CharaPick from '$lib/CharaPick.svelte';
const minSuggest = 8;
let charaID = $state(1001);
let convo = $state(1);
let options = $derived(byChara.global.get(charaID) ?? []);
let cur = $derived(options.find((c) => c.number === convo));
let suggested = $derived.by(() => {
if (cur == null) {
return [];
}
const u = groupPopulars.global[locations[cur.location].group].filter(
(s) => charaNames.get(s[0]) != null && s[0] !== cur.chara_1 && s[0] !== cur.chara_2 && s[0] !== cur.chara_3,
);
const r = u.length <= minSuggest ? u : u.filter((s) => s[1] >= u[minSuggest][1]);
return r.map(([chara_id, count]) => ({ chara_id, count }));
});
</script>
<h1 class="text-4xl">Lobby Conversations</h1>
<div class="mx-auto mt-8 flex flex-col rounded-md text-center shadow-md ring md:max-w-xl md:flex-row">
<div class="m-4 flex-1 md:mt-3">
<CharaPick id="chara" class="w-full" label="Character" labelClass="hidden md:inline" bind:value={charaID} required />
</div>
<div class="m-4 flex-1 md:mt-3">
<label for="convo" class="hidden md:inline">Conversation</label>
<select id="convo" bind:value={convo} class="w-full">
{#each options as opt}
<option value={opt.number}>Slice of Life {opt.number}</option>
{/each}
</select>
</div>
</div>
{#if cur}
<div class="shadow-sm transition-shadow hover:shadow-md">
<div class="mt-8 flex text-center text-lg">
<span class="flex-1"
>{charaNames.get(cur.chara_1)?.en ?? 'someone not a trainee'}{(cur.chara_2 ?? cur.chara_3) == null ? ' alone' : ''}</span
>
{#if cur.chara_2}
<span class="flex-1">{charaNames.get(cur.chara_2)?.en ?? 'someone not a trainee'}</span>
{/if}
{#if cur.chara_3}
<span class="flex-1">{charaNames.get(cur.chara_3)?.en ?? 'someone not a trainee'}</span>
{/if}
</div>
<div class="flex w-full text-center text-lg">
<span class="flex-1">at {locations[cur.location].name.en}</span>
</div>
</div>
<div class="mt-4 block text-center">
<span>Other characters who appear here most often:</span>
</div>
<div class="mt-4 grid text-center shadow-sm transition-shadow ease-in hover:shadow-md hover:ease-out md:grid-cols-4">
{#each suggested as s}
<span>{charaNames.get(s.chara_id)?.en}: {s.count}&#xd7;</span>
{/each}
</div>
<div class="mt-4 block text-center">
<span>
Set these characters to fixed positions (main, upgrades, story, races) to maximize the chance of getting this conversation.
</span>
</div>
{/if}

View File

@@ -0,0 +1,3 @@
<h1>Inheritance Chance</h1>
<p>Given a legacy, calculate the probability distribution of activation counts for each spark.</p>
<p>TODO</p>

View File

@@ -0,0 +1,42 @@
@import 'tailwindcss';
:root {
color-scheme: light dark;
}
html,
body {
height: 100%;
padding: 0;
margin: 0;
}
html {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
}
body {
background-color: light-dark(var(--color-mist-200), var(--color-mist-800));
color: light-dark(var(--color-amber-950), var(--color-amber-50));
}
p {
margin-bottom: calc(var(--spacing) * 4);
}
a {
color: light-dark(var(--color-sky-900), var(--color-sky-100));
}
nav > span > a {
color: light-dark(var(--color-amber-950), var(--color-amber-50));
}
a:hover {
border-bottom-width: 1px;
}
select {
background-color: light-dark(var(--color-mist-300), var(--color-mist-900));
padding: 0.5rem 0.75rem;
}

View File

@@ -0,0 +1,6 @@
<h1>Spark Generation Chance</h1>
<p>
Given a legacy, calculate the chance of generating each spark if you fulfill the conditions to do so, and the distribution of
total spark counts.
</p>
<p>TODO</p>

View File

@@ -0,0 +1,4 @@
<h1>My Veterans</h1>
<p>Set up and track your veterans for Zenno Rob Roy's inspiration and spark calculators.</p>
<p>All data is saved on your own machine, not transmitted or shared unless you explicitly choose to do so.</p>
<p>TODO</p>

3
zenno/static/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

21
zenno/svelte.config.js Normal file
View File

@@ -0,0 +1,21 @@
import adapter from '@sveltejs/adapter-static';
import { relative, sep } from 'node:path';
/** @type {import('@sveltejs/kit').Config} */
const config = {
compilerOptions: {
// defaults to rune mode for the project, execept for `node_modules`. Can be removed in svelte 6.
runes: ({ filename }) => {
const relativePath = relative(import.meta.dirname, filename);
const pathSegments = relativePath.toLowerCase().split(sep);
const isExternalLibrary = pathSegments.includes('node_modules');
return isExternalLibrary ? undefined : true;
},
},
kit: {
adapter: adapter(),
},
};
export default config;

20
zenno/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

36
zenno/vite.config.ts Normal file
View File

@@ -0,0 +1,36 @@
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import { sveltekit } from '@sveltejs/kit/vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
test: {
expect: { requireAssertions: true },
projects: [
{
extends: './vite.config.ts',
test: {
name: 'client',
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium', headless: true }],
},
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
exclude: ['src/lib/server/**'],
},
},
{
extends: './vite.config.ts',
test: {
name: 'server',
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'],
},
},
],
},
});