Compare commits

...

4 Commits

Author SHA1 Message Date
zephyr dc78d51def zenno: link up affinity tool 2026-06-08 16:45:12 -04:00
zephyr 5f0ad1b0e0 zenno/affinity: new tool 2026-06-08 16:42:04 -04:00
zephyr 6db0ca5230 mdb, cmd/horsebot: affinity details 2026-06-07 15:58:29 -04:00
zephyr 049617c22e zenno/doc/frbm: cm14 round 1 win rates 2026-06-07 15:04:29 -04:00
11 changed files with 643 additions and 11 deletions
+11 -3
View File
@@ -11,13 +11,21 @@ func (c Character) String() string {
return c.Name
}
// AffinityRelation holds precomputed affinity values for a pair or trio.
type AffinityRelation struct {
IDA int `json:"chara_a"`
IDB int `json:"chara_b"`
IDC int `json:"chara_c,omitzero"`
IDA CharacterID `json:"chara_a"`
IDB CharacterID `json:"chara_b"`
IDC CharacterID `json:"chara_c,omitzero"`
Affinity int `json:"affinity"`
}
// AffinityDetail holds a single affinity entry.
type AffinityDetail struct {
CharaID CharacterID `json:"chara_id"`
Relation uint16 `json:"relation"`
Affinity uint16 `json:"affinity"`
}
// Conversation describes a lobby conversation.
type Conversation struct {
// CharacterID is the ID of the character who has the conversation as
+1
View File
@@ -129,6 +129,7 @@ func main() {
mux.Handle("GET /", httpmiddle.Compress(5)(http.FileServerFS(os.DirFS(public))))
}
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)))
mux.Handle("GET /api/global/conversation", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.Conversations)))
mux.Handle("GET /api/global/race", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.Races)))
+16 -3
View File
@@ -15,6 +15,8 @@ var (
characterSQL string
//go:embed sql/affinity.sql
affinitySQL string
//go:embed sql/affinity-detail.sql
affinityDetailSQL string
//go:embed sql/uma.sql
umaSQL string
//go:embed sql/conversation.sql
@@ -35,14 +37,25 @@ func Characters(ctx context.Context, db *sqlitex.Pool) ([]horse.Character, error
func AffinitySummary(ctx context.Context, db *sqlitex.Pool) ([]horse.AffinityRelation, error) {
return load(ctx, db, nil, affinitySQL, func(s *sqlite.Stmt) horse.AffinityRelation {
return horse.AffinityRelation{
IDA: s.ColumnInt(0),
IDB: s.ColumnInt(1),
IDC: s.ColumnInt(2),
IDA: horse.CharacterID(s.ColumnInt(0)),
IDB: horse.CharacterID(s.ColumnInt(1)),
IDC: horse.CharacterID(s.ColumnInt(2)),
Affinity: s.ColumnInt(3),
}
})
}
// AffinityDetail gets all individual affinity group entries.
func AffinityDetail(ctx context.Context, db *sqlitex.Pool) ([]horse.AffinityDetail, error) {
return load(ctx, db, nil, affinityDetailSQL, func(s *sqlite.Stmt) horse.AffinityDetail {
return horse.AffinityDetail{
CharaID: horse.CharacterID(s.ColumnInt(0)),
Relation: uint16(s.ColumnInt(1)),
Affinity: uint16(s.ColumnInt(2)),
}
})
}
// Umas retrieves all trainable Uma variants.
func Umas(ctx context.Context, db *sqlitex.Pool) ([]horse.Uma, error) {
return load(ctx, db, make([]horse.Uma, 0, 128), umaSQL, func(s *sqlite.Stmt) horse.Uma {
+101
View File
@@ -44,6 +44,107 @@ func TestAffinitySummary(t *testing.T) {
}
}
func TestAffinityDetail(t *testing.T) {
db := testdb(t.Context(), "file:TestAffinityDetail?mode=memory&cache=shared", characterSQL)
got, err := mdb.AffinityDetail(t.Context(), db)
if err != nil {
t.Error(err)
}
want := []horse.AffinityDetail{
{CharaID: 1001, Relation: 103, Affinity: 2},
{CharaID: 1001, Relation: 202, Affinity: 2},
{CharaID: 1001, Relation: 301, Affinity: 2},
{CharaID: 1001, Relation: 401, Affinity: 2},
{CharaID: 1001, Relation: 502, Affinity: 2},
{CharaID: 1001, Relation: 510, Affinity: 2},
{CharaID: 1001, Relation: 2001, Affinity: 1},
{CharaID: 1001, Relation: 2102, Affinity: 1},
{CharaID: 1001, Relation: 2301, Affinity: 1},
{CharaID: 1001, Relation: 2406, Affinity: 1},
{CharaID: 1001, Relation: 2506, Affinity: 1},
{CharaID: 1001, Relation: 2510, Affinity: 1},
{CharaID: 1001, Relation: 2516, Affinity: 1},
{CharaID: 1001, Relation: 2519, Affinity: 1},
{CharaID: 1001, Relation: 2601, Affinity: 1},
{CharaID: 1001, Relation: 2603, Affinity: 1},
{CharaID: 1001, Relation: 2811, Affinity: 1},
{CharaID: 1001, Relation: 2903, Affinity: 7},
{CharaID: 1001, Relation: 3003, Affinity: 7},
{CharaID: 1001, Relation: 3004, Affinity: 7},
{CharaID: 1001, Relation: 3101, Affinity: 7},
{CharaID: 1001, Relation: 3205, Affinity: 1},
{CharaID: 1001, Relation: 7003, Affinity: 1},
{CharaID: 1001, Relation: 7004, Affinity: 1},
{CharaID: 1001, Relation: 7005, Affinity: 1},
{CharaID: 1001, Relation: 7006, Affinity: 1},
{CharaID: 1001, Relation: 7007, Affinity: 1},
{CharaID: 1001, Relation: 8001, Affinity: 1},
{CharaID: 1001, Relation: 8002, Affinity: 1},
{CharaID: 1001, Relation: 8003, Affinity: 1},
{CharaID: 1001, Relation: 8004, Affinity: 1},
{CharaID: 1001, Relation: 8005, Affinity: 1},
{CharaID: 1001, Relation: 8006, Affinity: 1},
{CharaID: 1001, Relation: 8007, Affinity: 1},
{CharaID: 1001, Relation: 8008, Affinity: 1},
{CharaID: 1001, Relation: 8009, Affinity: 1},
{CharaID: 1001, Relation: 8010, Affinity: 1},
{CharaID: 1001, Relation: 8011, Affinity: 1},
{CharaID: 1001, Relation: 8012, Affinity: 1},
{CharaID: 1001, Relation: 8013, Affinity: 1},
{CharaID: 1001, Relation: 8014, Affinity: 1},
{CharaID: 1001, Relation: 8015, Affinity: 1},
{CharaID: 1001, Relation: 8016, Affinity: 1},
{CharaID: 1001, Relation: 8443, Affinity: 1},
{CharaID: 1001, Relation: 8479, Affinity: 1},
{CharaID: 1001, Relation: 8511, Affinity: 1},
{CharaID: 1001, Relation: 8536, Affinity: 1},
{CharaID: 1001, Relation: 8640, Affinity: 1},
{CharaID: 1002, Relation: 104, Affinity: 2},
{CharaID: 1002, Relation: 202, Affinity: 2},
{CharaID: 1002, Relation: 301, Affinity: 2},
{CharaID: 1002, Relation: 402, Affinity: 2},
{CharaID: 1002, Relation: 2001, Affinity: 1},
{CharaID: 1002, Relation: 2406, Affinity: 1},
{CharaID: 1002, Relation: 2512, Affinity: 1},
{CharaID: 1002, Relation: 2601, Affinity: 1},
{CharaID: 1002, Relation: 2603, Affinity: 1},
{CharaID: 1002, Relation: 2810, Affinity: 1},
{CharaID: 1002, Relation: 2901, Affinity: 7},
{CharaID: 1002, Relation: 3003, Affinity: 7},
{CharaID: 1002, Relation: 3101, Affinity: 7},
{CharaID: 1002, Relation: 3205, Affinity: 1},
{CharaID: 1002, Relation: 7000, Affinity: 1},
{CharaID: 1002, Relation: 7001, Affinity: 1},
{CharaID: 1002, Relation: 7002, Affinity: 1},
{CharaID: 1002, Relation: 7013, Affinity: 1},
{CharaID: 1002, Relation: 7015, Affinity: 1},
{CharaID: 1002, Relation: 8017, Affinity: 1},
{CharaID: 1002, Relation: 8018, Affinity: 1},
{CharaID: 1002, Relation: 8019, Affinity: 1},
{CharaID: 1002, Relation: 8020, Affinity: 1},
{CharaID: 1002, Relation: 8021, Affinity: 1},
{CharaID: 1002, Relation: 8022, Affinity: 1},
{CharaID: 1002, Relation: 8023, Affinity: 1},
{CharaID: 1002, Relation: 8024, Affinity: 1},
{CharaID: 1002, Relation: 8025, Affinity: 1},
{CharaID: 1002, Relation: 8026, Affinity: 1},
{CharaID: 1002, Relation: 8027, Affinity: 1},
{CharaID: 1002, Relation: 8028, Affinity: 1},
{CharaID: 1002, Relation: 8029, Affinity: 1},
{CharaID: 1002, Relation: 8030, Affinity: 1},
{CharaID: 1002, Relation: 8031, Affinity: 1},
{CharaID: 1002, Relation: 8032, Affinity: 1},
{CharaID: 1002, Relation: 8444, Affinity: 1},
{CharaID: 1002, Relation: 8480, Affinity: 1},
{CharaID: 1002, Relation: 8512, Affinity: 1},
{CharaID: 1002, Relation: 8537, Affinity: 1},
{CharaID: 1002, Relation: 8641, Affinity: 1},
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("wrong affinity details (+got/-want):\n%s", diff)
}
}
func TestUmas(t *testing.T) {
db := testdb(t.Context(), "file:TestUmas?mode=memory&cache=shared", characterSQL)
got, err := mdb.Umas(t.Context(), db)
+7
View File
@@ -0,0 +1,7 @@
SELECT
m.chara_id,
m.relation_type,
r.relation_point
FROM succession_relation_member m
JOIN succession_relation r ON m.relation_type = r.relation_type
ORDER BY m.chara_id, m.relation_type
+1 -1
View File
@@ -16,7 +16,7 @@
<select {id} class={className} bind:value {required}>
{#if !required}
<option value="0" class={optionClass}></option>
<option value={0} class={optionClass}></option>
{/if}
{#each characters ?? [] as c (c.chara_id)}
<option value={c.chara_id} class={optionClass}>{c.name}</option>
+349
View File
@@ -32,3 +32,352 @@ export function lookup(aff: Affinity[], chara_a: number, chara_b: number, chara_
const r = aff.find((v) => v.chara_a === a && v.chara_b === b && (v.chara_c ?? Infinity) === c);
return r?.affinity ?? 0;
}
/**
* Individual affinity entry.
*/
export interface AffinityDetail {
/** Character to which the entry applies. */
chara_id: number;
/** Succession relation that the character has. */
relation: number;
/** Value of the succession relation. */
affinity: number;
}
export async function affinityDetail(): Promise<AffinityDetail[]> {
const resp = await fetch('/api/global/affinity-detail');
return resp.json();
}
export interface AffinityRelationDescription {
name: string;
note?: string;
}
export const AFFINITY_RELATION_DESCRIPTIONS = new Map<number, AffinityRelationDescription>([
[2001, { name: 'Shared Parent', note: 'Sunday Silence' }],
[2002, { name: 'Shared Parent', note: 'Tony Bin' }],
[2003, { name: 'Shared Parent', note: 'Sakura Yutaka O' }],
[2004, { name: 'Shared Parent', note: 'Mejiro Ryan' }],
[2005, { name: 'Shared Parent' }],
[2006, { name: 'Shared Parent' }],
[2007, { name: 'Shared Parent', note: 'Symboli Rudolf' }],
[2008, { name: 'Shared Parent', note: 'Tosho Boy' }],
[2009, { name: 'Shared Parent', note: "Brian's Time" }],
[2010, { name: 'Shared Parent', note: 'Gold Allure' }],
[2011, { name: 'Shared Parent', note: 'Soccer Boy' }],
[2101, { name: 'Shared Grandparent', note: 'Sunday Silence (grandsire)' }],
[2102, { name: 'Shared Grandparent', note: 'Maruzensky (damsire)' }],
[2103, { name: 'Shared Grandparent', note: 'Roberto (grandsire)' }],
[2104, { name: 'Shared Grandparent', note: 'Blushing Groom (damsire)' }],
[2105, { name: 'Shared Grandparent', note: 'Tesco Boy (damsire)' }],
[2106, { name: 'Shared Grandparent', note: 'Northern Taste (damsire)' }],
[2107, { name: 'Shared Grandparent', note: 'Royal Ski (damsire)' }],
[2108, { name: 'Shared Grandparent', note: 'Never Beat (damsire)' }],
[2109, { name: 'Shared Grandparent', note: 'Affirmed (damsire)' }],
[2110, { name: 'Shared Grandparent', note: 'Sancy (damsire)' }],
[2111, { name: 'Shared Grandparent', note: 'Last Tycoon (grandsire)' }],
[2201, { name: 'Parent/Child' }],
[2202, { name: 'Parent/Child' }],
[2203, { name: 'Grandparent/Grandchild' }],
[2204, { name: 'Parent/Child' }],
[2205, { name: 'Grandparent/Grandchild' }],
[2206, { name: 'Parent/Child' }],
[2208, { name: 'Parent/Child' }],
[2209, { name: 'Parent/Child' }],
[2210, { name: 'Parent/Child' }],
[2211, { name: 'Grandparent/Grandchild' }],
[2212, { name: 'Parent/Child' }],
[2213, { name: 'Parent/Child' }],
[2214, { name: 'Grandparent/Grandchild' }],
[2215, { name: 'Grandparent/Grandchild' }],
[2216, { name: 'Grandparent/Grandchild' }],
[2217, { name: 'Grandparent/Grandchild' }],
[2218, { name: 'Parent/Child' }],
[2301, { name: 'Grandparent/Grandchild' }],
[2302, { name: 'Grandparent/Grandchild' }],
[2303, { name: 'Grandparent/Grandchild' }],
[2401, { name: 'Racing Generation', note: 'Pre-1980' }],
[2402, { name: 'Racing Generation', note: '1983-86' }],
[2403, { name: 'Racing Generation', note: '1987-90' }],
[2404, { name: 'Racing Generation', note: '1990-93' }],
[2405, { name: 'Racing Generation', note: '1995-97' }],
[2406, { name: 'Racing Generation', note: '1997-99' }],
[2407, { name: 'Racing Generation', note: '1998-2004' }],
[2408, { name: 'Racing Generation', note: '2001-04' }],
[2409, { name: 'Racing Generation', note: '2006-10' }],
[2410, { name: 'Racing Generation' }],
[2411, { name: 'Racing Generation' }],
[2412, { name: 'Racing Generation' }],
[2413, { name: 'Racing Generation' }],
[2501, { name: 'G1 Winner', note: 'February Stakes' }],
[2502, { name: 'G1 Winner', note: 'Takamatsunomiya Kinen' }],
[2503, { name: 'G1 Winner', note: 'Osaka Hai' }],
[2504, { name: 'G1 Winner', note: 'Oka Sho' }],
[2505, { name: 'G1 Winner', note: 'Satsuki Sho' }],
[2506, { name: 'G1 Winner', note: 'Tenno Sho (Spring)' }],
[2507, { name: 'G1 Winner', note: 'NHK Mile Cup' }],
[2508, { name: 'G1 Winner', note: 'Victoria Mile' }],
[2509, { name: 'G1 Winner', note: 'Japanese Oaks' }],
[2510, { name: 'G1 Winner', note: 'Tokyo Yushun (Japanese Derby)' }],
[2511, { name: 'G1 Winner', note: 'Yasuda Kinen' }],
[2512, { name: 'G1 Winner', note: 'Takarazuka Kinen' }],
[2513, { name: 'G1 Winner', note: 'Sprinters Stakes' }],
[2514, { name: 'G1 Winner', note: 'Shuka Sho' }],
[2515, { name: 'G1 Winner', note: 'Kikuka Sho' }],
[2516, { name: 'G1 Winner', note: 'Tenno Sho (Autumn)' }],
[2517, { name: 'G1 Winner', note: 'Queen Elizabeth II Cup' }],
[2518, { name: 'G1 Winner', note: 'Mile Championship' }],
[2519, { name: 'G1 Winner', note: 'Japan Cup' }],
[2520, { name: 'G1 Winner', note: 'Champions Cup' }],
[2521, { name: 'G1 Winner', note: 'Hanshin Juvenile Fillies' }],
[2522, { name: 'G1 Winner', note: 'Asahi Hai Futurity Stakes' }],
[2523, { name: 'G1 Winner', note: 'Arima Kinen' }],
[2524, { name: 'G1 Winner', note: 'Hopeful Stakes' }],
[2525, { name: 'G1 Winner', note: 'Teio Sho' }],
[2526, { name: 'G1 Winner', note: 'Japan Dirt Derby' }],
[2527, { name: 'G1 Winner', note: 'JBC Ladies Classic' }],
[2528, { name: 'G1 Winner', note: 'JBC Sprint' }],
[2529, { name: 'G1 Winner', note: 'JBC Classic' }],
[2530, { name: 'G1 Winner', note: 'Tokyo Daishoten' }],
[2531, { name: 'G1 Winner', note: 'Kawasaki Kinen' }],
[2532, { name: 'G1 Winner', note: 'Mile Championship Nambu Hai' }],
[2533, { name: 'G1 Winner', note: 'Kashiwa Kinen' }],
[2534, { name: 'G1 Winner', note: 'Zen-Nippon Junior Yushun' }],
[2601, { name: 'Stallion' }],
[2602, { name: 'Mare' }],
[2702, { name: '7 G1 + Triple Crown winner' }],
[2801, { name: 'Classic Year', note: '1986' }],
[2802, { name: 'Classic Year', note: '1987' }],
[2803, { name: 'Classic Year', note: '1990' }],
[2804, { name: 'Classic Year', note: '1991' }],
[2805, { name: 'Classic Year', note: '1992' }],
[2806, { name: 'Classic Year', note: '1993' }],
[2807, { name: 'Classic Year', note: '1994' }],
[2808, { name: 'Classic Year', note: '1995' }],
[2809, { name: 'Classic Year', note: '1996' }],
[2810, { name: 'Classic Year', note: '1997' }],
[2811, { name: 'Classic Year', note: '1998' }],
[2812, { name: 'Classic Year', note: '1999' }],
[2813, { name: 'Classic Year', note: '2000' }],
[2814, { name: 'Classic Year', note: '2001' }],
[2815, { name: 'Classic Year', note: '2007' }],
[2816, { name: 'Classic Year', note: '2009' }],
[2817, { name: 'Classic Year', note: '2010' }],
[2818, { name: 'Classic Year' }],
[2819, { name: 'Classic Year' }],
[2820, { name: 'Classic Year' }],
[2821, { name: 'Classic Year' }],
[2822, { name: 'Classic Year', note: '2002' }],
[2823, { name: 'Classic Year' }],
[2824, { name: 'Classic Year' }],
[2825, { name: 'Classic Year' }],
[2826, { name: 'Classic Year' }],
[2827, { name: 'Classic Year' }],
[2828, { name: 'Classic Year' }],
[2829, { name: 'Classic Year' }],
[2830, { name: 'Classic Year' }],
[2831, { name: 'Classic Year' }],
[2832, { name: 'Classic Year' }],
[2833, { name: 'Classic Year' }],
[3201, { name: 'Birth Month', note: 'January' }],
[3202, { name: 'Birth Month', note: 'February' }],
[3203, { name: 'Birth Month', note: 'March' }],
[3204, { name: 'Birth Month', note: 'April' }],
[3205, { name: 'Birth Month', note: 'May' }],
[3206, { name: 'Birth Month', note: 'June' }],
[3301, { name: 'No G1 Wins' }],
[3501, { name: 'Classic Triple Crown Runner' }],
[3502, { name: 'Triple Tiara Runner' }],
[3503, { name: 'Spring Triple Crown Runner' }],
[3504, { name: 'Autumn Triple Crown Runner' }],
[101, { name: 'Tracen Class' }],
[102, { name: 'Tracen Class' }],
[103, { name: 'Tracen Class' }],
[104, { name: 'Tracen Class' }],
[105, { name: 'Tracen Class' }],
[106, { name: 'Tracen Class' }],
[107, { name: 'Tracen Class' }],
[201, { name: 'Residence', note: 'Miho Dorm' }],
[202, { name: 'Residence', note: 'Ritto Dorm' }],
[203, { name: 'Residence', note: 'Lives Alone' }],
[204, { name: 'Residence' }],
[205, { name: 'Residence', note: 'Not In Dorm' }],
[301, { name: 'Roommate' }],
[302, { name: 'Roommate' }],
[303, { name: 'Roommate' }],
[304, { name: 'Roommate' }],
[305, { name: 'Roommate' }],
[306, { name: 'Roommate' }],
[307, { name: 'Roommate' }],
[308, { name: 'Roommate' }],
[309, { name: 'Roommate' }],
[310, { name: 'Roommate' }],
[311, { name: 'Roommate' }],
[312, { name: 'Roommate' }],
[313, { name: 'Roommate' }],
[314, { name: 'Roommate' }],
[315, { name: 'Roommate' }],
[316, { name: 'Roommate' }],
[317, { name: 'Roommate' }],
[318, { name: 'Roommate' }],
[319, { name: 'Roommate' }],
[320, { name: 'Roommate' }],
[321, { name: 'Roommate' }],
[322, { name: 'Roommate' }],
[323, { name: 'Roommate' }],
[324, { name: 'Roommate' }],
[325, { name: 'Roommate' }],
[326, { name: 'Roommate' }],
[327, { name: 'Roommate' }],
[328, { name: 'Roommate' }],
[329, { name: 'Roommate' }],
[330, { name: 'Roommate' }],
[331, { name: 'Roommate' }],
[332, { name: 'Roommate' }],
[333, { name: 'Roommate' }],
[334, { name: 'Roommate' }],
[335, { name: 'Roommate' }],
[336, { name: 'Roommate' }],
[337, { name: 'Roommate' }],
[338, { name: 'Roommate' }],
[339, { name: 'Roommate' }],
[340, { name: 'Roommate' }],
[341, { name: 'Roommate' }],
[342, { name: 'Roommate' }],
[343, { name: 'Roommate' }],
[344, { name: 'Roommate' }],
[345, { name: 'Roommate' }],
[346, { name: 'Roommate' }],
[347, { name: 'Roommate' }],
[348, { name: 'Roommate' }],
[349, { name: 'Roommate' }],
[350, { name: 'Roommate' }],
[351, { name: 'Roommate' }],
[352, { name: 'Roommate' }],
[353, { name: 'Roommate' }],
[354, { name: 'Roommate' }],
[355, { name: 'Roommate' }],
[356, { name: 'Roommate' }],
[357, { name: 'Roommate' }],
[358, { name: 'Roommate' }],
[359, { name: 'Roommate' }],
[360, { name: 'Roommate' }],
[361, { name: 'Roommate' }],
[362, { name: 'Roommate' }],
[363, { name: 'Roommate' }],
[364, { name: 'Roommate' }],
[365, { name: 'Roommate' }],
[366, { name: 'Roommate' }],
[367, { name: 'Roommate' }],
[368, { name: 'Roommate' }],
[369, { name: 'Roommate' }],
[370, { name: 'Roommate' }],
[371, { name: 'Roommate' }],
[372, { name: 'Roommate' }],
[373, { name: 'Roommate' }],
[374, { name: 'Roommate' }],
[375, { name: 'Roommate' }],
[376, { name: 'Roommate' }],
[377, { name: 'Roommate' }],
[378, { name: 'Roommate' }],
[379, { name: 'Roommate' }],
[380, { name: 'Roommate' }],
[381, { name: 'Roommate' }],
[382, { name: 'Roommate' }],
[383, { name: 'Roommate' }],
[384, { name: 'Roommate' }],
[385, { name: 'Roommate' }],
[386, { name: 'Roommate' }],
[387, { name: 'Roommate' }],
[388, { name: 'Roommate' }],
[401, { name: 'Clique', note: 'Golden Generation' }],
[402, { name: 'Clique' }],
[403, { name: 'Clique' }],
[404, { name: 'Clique' }],
[405, { name: 'Clique' }],
[406, { name: 'Clique', note: 'Old People' }],
[407, { name: 'Clique' }],
[408, { name: 'Clique' }],
[409, { name: 'Clique' }],
[410, { name: 'Clique' }],
[411, { name: 'Clique' }],
[412, { name: 'Clique' }],
[413, { name: 'Clique' }],
[414, { name: 'Clique' }],
[415, { name: 'Clique' }],
[416, { name: 'Clique' }],
[417, { name: 'Clique' }],
[418, { name: 'Clique' }],
[419, { name: 'Clique' }],
[420, { name: 'Clique' }],
[421, { name: 'Clique' }],
[422, { name: 'Clique' }],
[423, { name: 'Clique' }],
[424, { name: 'Clique' }],
[501, { name: 'Story Group', note: 'Student Council' }],
[502, { name: 'Story Group', note: 'Golden Generation' }],
[503, { name: 'Story Group', note: 'Mejiro Family' }],
[504, { name: 'Story Group', note: 'Rich People' }],
[505, { name: 'Story Group', note: 'Dorm Leaders' }],
[506, { name: 'Story Group', note: 'Heisei Big Three' }],
[507, { name: 'Story Group', note: 'BNW' }],
[511, { name: 'Story Group', note: '"Sakura"' }],
[512, { name: 'Story Group' }],
[513, { name: 'Story Group', note: 'Team Canopus' }],
[514, { name: 'Story Group', note: 'Runaway Bakas' }],
[515, { name: 'Story Group', note: 'Kita and Dia' }],
[516, { name: 'Story Group', note: 'McQueen and Dia' }],
[517, { name: 'Story Group', note: 'Teio and Kitasan' }],
[518, { name: 'Story Group', note: 'Notable Front Runners' }],
[519, { name: 'Story Group' }],
[520, { name: 'Story Group', note: 'Notable Dirt Runners' }],
[521, { name: 'Story Group' }],
[522, { name: 'Story Group', note: 'Satono Family' }],
[523, { name: 'Story Group', note: 'Team Sirius' }],
[524, { name: 'Story Group', note: 'Team Rigil' }],
[525, { name: 'Story Group', note: 'Team Spica' }],
[526, { name: 'Story Group', note: 'Notable Runaways' }],
[3505, { name: 'Ran on Dirt' }],
[3506, { name: 'Overseas' }],
[3508, { name: 'Ran a Sprint' }],
[2901, { name: 'Strategy', note: 'Front Runner' }],
[2902, { name: 'Strategy', note: 'Pace Chaser' }],
[2903, { name: 'Strategy', note: 'Late Surger' }],
[2904, { name: 'Strategy', note: 'End Closer' }],
[3001, { name: 'Distance', note: 'Sprint' }],
[3002, { name: 'Distance', note: 'Mile' }],
[3003, { name: 'Distance', note: 'Medium' }],
[3004, { name: 'Distance', note: 'Long' }],
[3101, { name: 'Surface', note: 'Turf' }],
[3102, { name: 'Surface', note: 'Dirt' }],
[508, { name: 'Haru Urara', note: 'Haru Urara' }],
[509, { name: 'Haru Urara', note: 'Haru Urara' }],
[510, { name: 'Haru Urara', note: 'Haru Urara' }],
[527, { name: 'Haru Urara', note: 'Haru Urara' }],
[528, { name: 'Haru Urara', note: 'Haru Urara' }],
[529, { name: 'Haru Urara', note: 'Haru Urara' }],
[530, { name: 'Haru Urara', note: 'Haru Urara' }],
[531, { name: 'Haru Urara', note: 'Haru Urara' }],
[532, { name: 'Haru Urara', note: 'Haru Urara' }],
[533, { name: 'Haru Urara', note: 'Haru Urara' }],
[534, { name: 'Haru Urara', note: 'Haru Urara' }],
[535, { name: 'Haru Urara', note: 'Haru Urara' }],
[536, { name: 'Haru Urara', note: 'Haru Urara' }],
[537, { name: 'Haru Urara', note: 'Haru Urara' }],
[538, { name: 'Haru Urara', note: 'Haru Urara' }],
[539, { name: 'Haru Urara', note: 'Haru Urara' }],
[540, { name: 'Haru Urara', note: 'Haru Urara' }],
[541, { name: 'Haru Urara', note: 'Haru Urara' }],
[542, { name: 'Haru Urara', note: 'Haru Urara' }],
[543, { name: 'Haru Urara', note: 'Haru Urara' }],
[544, { name: 'Haru Urara', note: 'Haru Urara' }],
[545, { name: 'Haru Urara', note: 'Haru Urara' }],
[546, { name: 'Haru Urara', note: 'Haru Urara' }],
[547, { name: 'Haru Urara', note: 'Haru Urara' }],
[548, { name: 'Haru Urara', note: 'Haru Urara' }],
[549, { name: 'Haru Urara', note: 'Haru Urara' }],
[550, { name: 'Haru Urara', note: 'Haru Urara' }],
[551, { name: 'Haru Urara', note: 'Haru Urara' }],
]);
+2
View File
@@ -2,6 +2,7 @@
import './layout.css';
import favicon from '$lib/assets/favicon.png';
import { resolve } from '$app/paths';
import { affinity } from '$lib/data/affinity';
let { children } = $props();
</script>
@@ -20,6 +21,7 @@
<a href={resolve('/')} class="mx-8 my-1 block font-semibold md:hidden">Zenno Rob Roy</a>
<a href={resolve('/spurt')} class="mx-8 my-1 inline-block">Spurt Speed</a>
<a href={resolve('/mspeed')} class="mx-8 my-1 inline-block">Mechanical Speed</a>
<a href={resolve('/affinity')} class="mx-8 my-1 inline-block">Affinity Details</a>
<a href={resolve('/convo')} class="mx-8 my-1 inline-block">Lobby Conversations</a>
<a href={resolve('/doc')} class="mx-8 my-1 inline-block">Documents</a>
</span>
+6 -3
View File
@@ -8,7 +8,7 @@
<h2 class="mt-8 mb-4 text-4xl">Tools</h2>
<ul class="mb-4 list-disc pl-4">
<li>
<a href={resolve('/spurt')}>Spurt Speed</a> Calculate a horse's target speed in the last spurt and compare to other distance aptitudes
<a href={resolve('/spurt')}>Spurt Speed</a> &mdash; Calculate a horse's target speed in the last spurt and compare to other distance aptitudes
and running styles.
</li>
<li>
@@ -16,13 +16,16 @@
of individual skills.
</li>
<li>
<a href={resolve('/convo')}>Lobby Conversations</a> — Check participants in lobby conversations and get recommendations on unlocking
<a href={resolve('/affinity')}>Affinity Details</a> &mdash; Details of why characters have the base compatibility numbers they have.
</li>
<li>
<a href={resolve('/convo')}>Lobby Conversations</a> &mdash; 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.
> &mdash; Skill search by name or unique owner within Discord. Install to a server or user.
</li>
</ul>
+144
View File
@@ -0,0 +1,144 @@
<script lang="ts">
import CharaPick from '$lib/CharaPick.svelte';
import { AFFINITY_RELATION_DESCRIPTIONS, affinityDetail, type AffinityDetail } from '$lib/data/affinity';
import { character, charaNames, type Character } from '$lib/data/character';
import { onMount } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
let characters: Character[] = $state([]);
let affs: AffinityDetail[] = $state([]);
onMount(async () => {
const [charas, affinities] = await Promise.all([character(), affinityDetail()]);
affs = affinities;
characters = charas.filter((c) => affs.some((d) => d.chara_id === c.chara_id));
});
const names = $derived(charaNames(characters));
let charA: number = $state(1001);
let charB: number = $state(0);
let charC: number = $state(0);
const nameA = $derived(names.get(charA)?.en ?? `Character ${charA}`);
const nameB = $derived(names.get(charB)?.en ?? `Character ${charB}`);
const nameC = $derived(names.get(charC)?.en ?? `Character ${charC}`);
function groupsFor(affs: AffinityDetail[], chara: number): AffinityDetail[] {
return chara !== 0 ? affs.filter((d) => d.chara_id === chara) : [];
}
const groupsA = $derived(groupsFor(affs, charA));
const groupsB = $derived(groupsFor(affs, charB));
const groupsC = $derived(groupsFor(affs, charC));
const allGroups = $derived.by(() => {
const seen = new SvelteSet<number>();
const r = [];
for (const g of [groupsA, groupsB, groupsC]) {
for (const d of g) {
if (seen.has(d.relation)) {
continue;
}
seen.add(d.relation);
r.push(d);
}
}
return r;
});
const selCount = $derived(1 + (charB === 0 ? 0 : 1) + (charC === 0 ? 0 : 1));
const duplicate = $derived(charA === charB || charA === charC || (charB !== 0 && charB === charC));
const infos = $derived(
allGroups
.map((d) => ({
...d,
description: AFFINITY_RELATION_DESCRIPTIONS.get(d.relation),
a: groupsA.some(({ relation }) => relation === d.relation),
b: groupsB.some(({ relation }) => relation === d.relation),
c: groupsC.some(({ relation }) => relation === d.relation),
}))
.map((d) => ({
...d,
counted: (d.a ? 1 : 0) + (d.b ? 1 : 0) + (d.c ? 1 : 0),
}))
.toSorted((a, b) => (a.counted !== b.counted ? b.counted - a.counted : a.relation - b.relation)),
);
const total = $derived(infos.filter((d) => d.counted === selCount).reduce((t, d) => t + d.affinity, 0));
</script>
<h1 class="text-4xl">Affinity Details</h1>
<div class="mx-auto mt-8 flex flex-col rounded-md text-center shadow-md ring md:max-w-2xl md:flex-row">
<div class="m-4 flex-1 md:mt-3">
<label for="charA" class="hidden md:inline">Character 1</label>
<CharaPick id="charA" {characters} class="w-full" bind:value={charA} required />
</div>
<div class="m-4 flex-1 md:mt-3">
<label for="charB" class="hidden md:inline">Character 2</label>
<CharaPick id="charB" {characters} class="w-full" bind:value={charB} />
</div>
<div class="m-4 flex-1 md:mt-3">
<label for="charC" class="hidden md:inline">Character 3</label>
<CharaPick id="charC" {characters} class="w-full" bind:value={charC} />
</div>
</div>
<svelte:boundary>
{#if duplicate}
<span class="mt-8 block w-full text-center text-lg">Duplicate characters always have zero affinity.</span>
{:else if selCount > 1}
<span class="mt-8 block w-full text-center text-lg">Total {selCount === 2 ? 'pair' : 'trio'} affinity: {total}</span>
{/if}
<table class="mx-auto mt-8 table-fixed">
<thead>
<tr class="py-2">
<th class="w-32 px-2" scope="col">Group ID</th>
<th class="w-96 px-2" scope="col">Description</th>
{#if selCount > 1}
<th class="w-56 px-2" scope="col">{nameA}</th>
{#if charB !== 0}
<th class="w-56 px-2" scope="col">{nameB}</th>
{/if}
{#if charC !== 0}
<th class="w-56 px-2" scope="col">{nameC}</th>
{/if}
{/if}
<th class="w-32 px-2" scope="col">Affinity</th>
</tr>
</thead>
<tbody>
{#each infos as { relation, affinity, description, a, b, c, counted } (relation)}
<tr class="even:bg-mist-300 dark:even:bg-mist-900">
<td class="px-4 text-center">{relation}</td>
{#if description != null}
<td class="px-4">{[description.name, description.note].filter((s) => s != null).join(' ')}</td>
{:else}
<td class="px-4"></td>
{/if}
{#if selCount > 1}
<td class="px-4 text-center">{a ? '✓' : ''}</td>
{#if charB !== 0}
<td class="px-4 text-center">{b ? '✓' : ''}</td>
{/if}
{#if charC !== 0}
<td class="px-4 text-center">{c ? '✓' : ''}</td>
{/if}
{/if}
<td class="px-4 text-center">{!duplicate && selCount > 1 && counted === selCount ? '+' : ''}{affinity}</td>
</tr>
{/each}
</tbody>
</table>
{#snippet pending()}
<div>Loading data...</div>
{/snippet}
</svelte:boundary>
<div class="mx-auto mt-12 w-full max-w-4xl border-t pt-4 md:mt-8">
<p class="text-center">
Descriptions are inferred from the horses in the respective groups. They are not included in the game data.
</p>
<p class="text-center">
Some characters are not in affinity groups that it seems like they should be in, e.g. parent/child pairs and G1 winners for
various races. Other characters are in affinity groups that it seems like they should not be in, e.g. Mihono Bourbon in the
sprint group. Inclusion in a group is automatically generated from the game data and hence is known to correspond to game
calculations, even where they don't make sense.
</p>
<p class="text-center">
Thanks to princess_fox, hell259, Werseter, thegenosys, Lofi (Class: Voyager), and Ordalca on the GameTora Discord server, and
Damhain on the CirnoTV server, for figuring out most of the groups.
</p>
</div>
+4
View File
@@ -1342,6 +1342,10 @@
how good this no-overtake thing really is. <span class="inline-block">1200/662/1094/902/932 S/A/S</span> with lane combo.
</li>
</ol>
<p>
Win rates after 40: Falco 47.5%, Bourbon 27.5%, Suzuka 5%. While this is my best round 1 by a substantial margin, I'm pretty
sure I'm going to get torn up in round 2; my stats aren't good enough to beat correctly built end closers.
</p>
<Sec h={3} id="cm13">CM13 &ndash; Taurus Cup (Tokyo Derby)</Sec>
<p>