Compare commits

...

5 Commits

Author SHA1 Message Date
zephyr 48118efeeb doc: document grand live mechanics
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline was successful
2026-07-28 00:19:14 -04:00
zephyr 79d4e76090 mdb: sort scenarios by sort id
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline was successful
Fixes #33.
2026-07-25 11:57:24 -04:00
zephyr ea39be0510 zenno: include character icons in character picks
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline was successful
2026-07-24 23:09:24 -04:00
zephyr 7bac55ca43 zenno: show icons on skills 2026-07-24 22:56:31 -04:00
zephyr 435da61405 cmd: move toradl into horsebot
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline was successful
Fixes #31.
2026-07-24 17:18:44 -04:00
13 changed files with 206 additions and 95 deletions
+12 -2
View File
@@ -1,10 +1,20 @@
# horsebot
Discord bot serving horse game data.
Website and Discord bot serving horse game data.
Production instance is named Zenno Rob Roy, because she has read all about Umamusume and is always happy to share her knowledge and give recommendations.
## Running
The `-mdb` flag is mandatory for both website and Discord bot modes.
It provides data for both the horsebot API and the discord bot.
## Running the Website
If the `-public` flag is provided, its value gives the directory to serve at `/`.
The `-img` flag gives a separate directory into which images are synced from GameTora; obtain permission from Gertas prior to using it.
The images are served on `/img/`.
## Running the Discord Bot
Provide the `-token` flag pointing to a file containing the token.
The bot always uses the Gateway API.
If the `-http` argument is provided, it will also use the HTTP API, and `-key` must also be provided.
+16
View File
@@ -36,6 +36,7 @@ func main() {
addr string
mdbf string
public string
img string
// discord
tokenFile string
apiRoute string
@@ -47,6 +48,7 @@ func main() {
flag.StringVar(&addr, "http", ":13669", "`address` to bind HTTP server")
flag.StringVar(&mdbf, "mdb", "", "`path` to master.mdb")
flag.StringVar(&public, "public", "", "`dir`ectory containing the website to serve")
flag.StringVar(&img, "img", "", "`dir`ectory to save and serve images")
flag.StringVar(&tokenFile, "token", "", "`file` containing the Discord bot token")
flag.StringVar(&apiRoute, "route", "/interactions/callback", "`path` to serve Discord HTTP API calls")
flag.StringVar(&pubkey, "key", "", "Discord public key")
@@ -86,6 +88,17 @@ func main() {
os.Exit(1)
}
if img != "" {
// Ensure the img directory is in fact a directory
// before we start trying to fill it out.
err := os.MkdirAll(img, 0777)
if err != nil {
slog.Error("img", slog.Any("err", err))
os.Exit(1)
}
go saveImgs(ctx, db, img)
}
var client *bot.Client
if tokenFile != "" {
skills, err := mdb.Skills(ctx, db)
@@ -128,6 +141,9 @@ func main() {
if public != "" {
mux.Handle("GET /", httpmiddle.Compress(5)(http.FileServerFS(os.DirFS(public))))
}
if img != "" {
mux.Handle("GET /img/", http.StripPrefix("/img", http.FileServerFS(os.DirFS(img))))
}
mux.Handle("GET /api/global/affinity", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.AffinitySummary)))
mux.Handle("GET /api/global/affinity-detail", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.AffinityDetail)))
mux.Handle("GET /api/global/character", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.Characters)))
+44 -27
View File
@@ -3,14 +3,12 @@ package main
import (
"context"
"errors"
"flag"
"fmt"
"image"
"image/png"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"time"
@@ -22,31 +20,7 @@ import (
"zombiezen.com/go/sqlite/sqlitex"
)
func main() {
var (
mdb string
out string
)
flag.StringVar(&mdb, "mdb", "", "`path` to master.mdb")
flag.StringVar(&out, "o", "", "output `dir`ectory")
flag.Parse()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
go func() {
<-ctx.Done()
stop()
}()
db, err := sqlitex.NewPool(mdb, sqlitex.PoolOptions{Flags: sqlite.OpenReadOnly})
if err != nil {
slog.Error("mdb", slog.Any("err", err))
os.Exit(1)
}
if err := os.MkdirAll(out, 0777); err != nil {
slog.Error("output", slog.Any("err", err))
os.Exit(1)
}
func saveImgs(ctx context.Context, db *sqlitex.Pool, out string) {
skill, err := skillIconIDs(ctx, db)
if err != nil {
slog.Error("skills", slog.Any("err", err))
@@ -71,6 +45,49 @@ func main() {
}
}
// load scans all results of sql and appends them to r.
func load[T any](ctx context.Context, db *sqlitex.Pool, r []T, sql string, row func(*sqlite.Stmt) T) ([]T, error) {
conn, err := db.Take(ctx)
defer db.Put(conn)
if err != nil {
return nil, err
}
stmt, err := conn.Prepare(sql)
if err != nil {
return nil, err
}
for {
ok, err := stmt.Step()
if err != nil {
return r, err
}
if !ok {
break
}
r = append(r, row(stmt))
}
return r, err
}
func skillIconIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) {
return load(ctx, db, nil, "SELECT DISTINCT icon_id FROM skill_data", func(s *sqlite.Stmt) int32 {
return s.ColumnInt32(0)
})
}
func charaIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) {
return load(ctx, db, nil, "SELECT id FROM chara_data", func(s *sqlite.Stmt) int32 {
return s.ColumnInt32(0)
})
}
func rankIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) {
return load(ctx, db, nil, "SELECT id-1 FROM single_mode_rank", func(s *sqlite.Stmt) int32 {
return s.ColumnInt32(0)
})
}
var pngenc = &png.Encoder{CompressionLevel: png.BestCompression}
func idouts(ctx context.Context, g *errgroup.Group, tick *time.Ticker, ids []int32, p, urlf string, sizes ...image.Point) {
-8
View File
@@ -1,8 +0,0 @@
# toradl
Download and process icons from GameTora.
Obtain permission from Gertas before using this program.
toradl pulls the list of all skill icon IDs, character IDs, and rating IDs from the MDB
Then, for each that has no previous output, it downloads the corresponding icon from GameTora.
Each type is resized to various sizes and saved as PNG and AVIF.
-51
View File
@@ -1,51 +0,0 @@
package main
import (
"context"
"zombiezen.com/go/sqlite"
"zombiezen.com/go/sqlite/sqlitex"
)
// load scans all results of sql and appends them to r.
func load[T any](ctx context.Context, db *sqlitex.Pool, r []T, sql string, row func(*sqlite.Stmt) T) ([]T, error) {
conn, err := db.Take(ctx)
defer db.Put(conn)
if err != nil {
return nil, err
}
stmt, err := conn.Prepare(sql)
if err != nil {
return nil, err
}
for {
ok, err := stmt.Step()
if err != nil {
return r, err
}
if !ok {
break
}
r = append(r, row(stmt))
}
return r, err
}
func skillIconIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) {
return load(ctx, db, nil, "SELECT DISTINCT icon_id FROM skill_data", func(s *sqlite.Stmt) int32 {
return s.ColumnInt32(0)
})
}
func charaIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) {
return load(ctx, db, nil, "SELECT id FROM chara_data", func(s *sqlite.Stmt) int32 {
return s.ColumnInt32(0)
})
}
func rankIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) {
return load(ctx, db, nil, "SELECT id-1 FROM single_mode_rank", func(s *sqlite.Stmt) int32 {
return s.ColumnInt32(0)
})
}
+34
View File
@@ -15,6 +15,9 @@ This file is my notes from exploring the database.
- 119 is scenario full titles (e.g. The Beginning: URA Finale), 120 is scenario descriptions, 237 is scenario names (e.g. URA Finale)
- 130 is epithet names, 131 is epithet descriptions
in the decomp, text data categories are named in `public enum MasterString.Category`.
some categories seem to be missing, though.
# succession factor (sparks)
tables are succession_factor and succession_factor_effect
@@ -317,6 +320,37 @@ so, it doesn't seem like there's a particular flag that identifies maiden races,
- card_talent_hint_upgrade has costs to raise hint levels, but it's actually universal, only six rows
- single_mode_route_race is career goals (not only races)
- available_skill_set has starting skills including those unlocked by potential level given under need_rank (0 for pl1, 2 for pl2)
- single_mode_special_chara is scenario links
# gl
single_mode_live_song_list has the song list for lives ha ha.
this is defined from the perspective of bonuses they grant when you have them, not the shop per se.
- text_data category for song names is 210, look up by live_id; for post-concert effect text, category 208 indexed by song list id
- command_id and live_id are always equal
- level is 1 for all songs except the two instances of girls' legend u
- master_bonus_content_text_id corresponds to text_data categories 207 for pickup effect text and 209 for song name
- live_bonus_type is 1 for specialty prio, 2 for chain frequency, 3 for fb
- girls' legend u is defined twice as ids 22 and 24
costs are in single_mode_live_square.
- square_type is 1 for stat up, 2 for skill up, 3 for energy up, 4 for live
- perf_type_n is token type: 1 for dance, 2 for passion, 3 for vocals, 4 for visuals, 5 for composure
- perf_value_n is number of tokens
immediate bonuses are in single_mode_live_master_bonus.
- master_bonus_type is 1 for stat up, 2 for skill hint, 3 for energy up, 4 for song
- master_bonus_type_value is 1 for type 1, live id for type 4, 0 otherwise
- master_bonus_gain_type_n is 1 for stat, 2 for skill hint, 3 for energy, 5 for permanent training bonus
+ 1 for stat: then master_bonus_gain_value_n_1 is 1-5 for stat id or 6 for sp, n_2 is 1 for fixed or 2 for random (not actually in the game), n_3 is lower bound on gain, n_4 is 0 for fixed or upper bound for random
+ 2 for skill hint: then gain_value_n_1 is 3(*), n_2 is skill tag, n_3 is hint level
+ 3 for energy: then gain_value_n_1 is amount
+ 5 for permanent training bonus: then gain_value_n_1 is stat id (6 for sp), n_2 is amount
(*) MasterSingleModeLiveMasterBonus.SingleModeLiveMasterBonus.GainSkillHintTypeEnum defines values of Random = 1 and SkillId = 2, but they are unused in the db.
there is some ostensibly unused data.
- master bonus ids 12001-12206 grant random amounts of stats
- id 20000 corresponds to "Group Lesson" "Skill hint appropriate for aptitude"
# lobby conversations!!!
+1 -1
View File
@@ -14,4 +14,4 @@ SELECT
FROM single_mode_scenario sc
JOIN scenario_name n ON sc.id = n.id
JOIN scenario_title t ON sc.id = t.id
ORDER BY sc.id
ORDER BY sc.sort_id
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import type { SvelteHTMLElements } from 'svelte/elements';
const sizes = [256, 64, 32, 16] as const;
interface Props {
chara_id: number | null | undefined;
size: (typeof sizes)[number];
}
let { chara_id, size, ...rest }: Props & SvelteHTMLElements['picture'] = $props();
const icon = $derived(chara_id ?? 0);
const avifs = $derived(
sizes
.filter((sz) => sz >= size)
.map((sz) => `/img/chara/${icon}/${sz}x${sz}.avif ${sz / size}x`)
.join(', '),
);
const pngs = $derived(
sizes
.filter((sz) => sz >= size)
.map((sz) => `/img/chara/${icon}/${sz}x${sz}.png ${sz / size}x`)
.join(', '),
);
const src = $derived(icon !== 0 ? `/img/chara/${icon}/${sizes[0]}x${sizes[0]}.png` : `/img/skill/10011`);
</script>
{#if icon !== 0}
<picture {...rest}>
<source srcset={avifs} type="image/avif" />
<source srcset={pngs} type="image/png" />
<img {src} alt={`Icon for character ID ${chara_id}`} class="inline" width={size} height={size} loading="lazy" />
</picture>
{:else}
<div class={`w-[${size}px] h-[${size}px]`}></div>
{/if}
+10 -4
View File
@@ -1,23 +1,29 @@
<script lang="ts">
import type { Character } from '$lib/data/character';
import type { Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import Pick from './Pick.svelte';
import CharaIcon from './CharaIcon.svelte';
interface Props {
id: string;
characters: Character[] | null;
value: Character | undefined;
class?: ClassValue | null;
option?: Snippet<[Character]>;
required?: boolean;
}
let { id, characters, value = $bindable(), class: className, option, required = false }: Props = $props();
let { id, characters, value = $bindable(), class: className, required = false }: Props = $props();
const charas = $derived(characters ?? []);
const key = (v: Character) => ({ id: v.chara_id, name: v.name });
</script>
<Pick {id} items={charas} {key} bind:value {option} class={className} {required} />
<Pick {id} items={charas} {key} bind:value class={className} {required}>
{#snippet option(c: Character)}
<span>
<CharaIcon chara_id={c.chara_id} size={32} />
{c.name}
</span>
{/snippet}
</Pick>
+7 -1
View File
@@ -9,6 +9,7 @@
type Ability,
type Skill,
} from './data/skill';
import SkillIcon from './SkillIcon.svelte';
interface Props {
skill: Skill | null | undefined;
@@ -63,9 +64,13 @@
<span class="group relative inline-block hyphens-none">
<span
class="skill-tip invisible absolute bottom-4 -left-1/2 flex w-80 flex-col rounded-lg border px-2 text-center opacity-0 shadow-lg transition-all group-hover:visible group-hover:bottom-6 group-hover:opacity-100"
class="skill-tip invisible absolute bottom-4 -left-1/2 flex min-w-80 flex-col rounded-lg border px-2 pt-2 text-center opacity-0 shadow-lg transition-all group-hover:visible group-hover:bottom-6 group-hover:opacity-100"
role="tooltip"
>
<span class="block text-xl">
<SkillIcon icon_id={skill?.icon_id} size={32} class="inline" />
{s.name}
</span>
<span class="block border-b py-1 hyphens-auto">
{s.description}
</span>
@@ -92,5 +97,6 @@
</span>
{/each}
</span>
<SkillIcon icon_id={skill?.icon_id} size={16} class="inline max-h-lh align-text-bottom" />
<span class={[spanClass, 'cursor-pointer']}>{s.name}</span>
</span>
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import type { SvelteHTMLElements } from 'svelte/elements';
const sizes = [64, 32, 16] as const;
interface Props {
icon_id: number | null | undefined;
size: (typeof sizes)[number];
}
let { icon_id, size, ...rest }: Props & SvelteHTMLElements['picture'] = $props();
const icon = $derived(icon_id ?? 0);
const avifs = $derived(
sizes
.filter((sz) => sz >= size)
.map((sz) => `/img/skill/${icon}/${sz}x${sz}.avif ${sz / size}x`)
.join(', '),
);
const pngs = $derived(
sizes
.filter((sz) => sz >= size)
.map((sz) => `/img/skill/${icon}/${sz}x${sz}.png ${sz / size}x`)
.join(', '),
);
const src = $derived(icon !== 0 ? `/img/skill/${icon}/${sizes[0]}x${sizes[0]}.png` : `/img/skill/10011`);
</script>
{#if icon !== 0}
<picture {...rest}>
<source srcset={avifs} type="image/avif" />
<source srcset={pngs} type="image/png" />
<img {src} alt={`Skill icon ID ${icon_id}`} class="inline" width={size} height={size} loading="lazy" />
</picture>
{:else}
<div class={`w-[${size}px] h-[${size}px]`}></div>
{/if}
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import * as skill from '$lib/data/skill';
import SkillIcon from '$lib/SkillIcon.svelte';
let rare = $state(false);
let unique = $state(false);
@@ -94,7 +95,10 @@
<div class="grid max-w-7xl grid-cols-1 gap-2 md:grid-cols-3">
{#each skills as s (s.skill_id)}
<div class="rounded-md border px-2 pt-1 pb-2 text-center shadow-sm transition-shadow hover:shadow-md">
<span class="block text-xl">{s.name}</span>
<span class="block text-xl">
<SkillIcon icon_id={s?.icon_id} size={32} />
{s.name}
</span>
<span class="block italic">{s.description}</span>
{#each s.activations as act, i (i)}
<span class="my-2 block rounded-md border">
+3
View File
@@ -39,6 +39,9 @@ export default defineConfig({
'/api': {
target: 'http://localhost:13669',
},
'/img': {
target: 'http://localhost:13669',
},
},
},
});