zenno: categorize tools

This commit is contained in:
2026-06-10 14:03:42 -04:00
parent dc78d51def
commit 75024c7c11
18 changed files with 119 additions and 42 deletions
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import { resolve } from '$app/paths';
</script>
<h1 class="m-8 text-center text-7xl">Zenno Rob Roy &mdash; Character Tools</h1>
<p>Tools related to understanding Umamusume characters.</p>
<ul class="mb-4 list-disc pl-4">
<li>
<a href={resolve('/chara/affinity')}>Affinity Details</a> &mdash; Details of why characters have the base compatibility numbers
they have.
</li>
<li>
<a href={resolve('/chara/convo')}>Lobby Conversations</a> &mdash; Check participants in lobby conversations and get recommendations
on unlocking them quickly.
</li>
</ul>
@@ -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>
+90
View File
@@ -0,0 +1,90 @@
<script lang="ts">
import { character, charaNames, type Character } from '$lib/data/character';
import { byChara, locations, groupPopulars, conversation, type Conversation } from '$lib/data/convo';
import CharaPick from '$lib/CharaPick.svelte';
import { onMount } from 'svelte';
let conversations: Conversation[] = $state([]);
const convoMap = $derived(byChara(conversations));
const popular = $derived(groupPopulars(conversations));
let characters: Character[] = $state([]);
const names = $derived(charaNames(characters ?? []));
const minSuggest = 8;
onMount(async () => {
const [convos, charas] = await Promise.all([conversation(), character()]);
conversations = convos;
characters = charas.filter((c) => convoMap.has(c.chara_id));
});
let charaID = $state(1001);
let convo = $state(1);
const options = $derived(convoMap.get(charaID) ?? []);
const cur = $derived(options.find((c) => c.number === convo));
const cur1Name = $derived(cur?.chara_1 && names.get(cur.chara_1)?.en);
const cur2Name = $derived(cur?.chara_2 && names.get(cur.chara_2)?.en);
const cur3Name = $derived(cur?.chara_3 && names.get(cur.chara_3)?.en);
const alone = $derived([cur?.chara_1, cur?.chara_2, cur?.chara_3].filter((x) => x != null).length == 1 ? ' alone' : '');
const suggested = $derived.by(async () => {
if (cur == null) {
return [];
}
const u = popular[locations[cur.location].group].filter(
(s) => names.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, chara_name: names.get(chara_id), count }));
});
const curLoc = $derived(cur != null ? locations[cur.location] : null);
</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">
<label for="chara" class="hidden md:inline">Character</label>
<CharaPick id="chara" {characters} class="w-full" 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 (opt.number)}
<option value={opt.number}>Slice of Life {opt.number}</option>
{/each}
</select>
</div>
</div>
{#await suggested}
<div class="mt-4 w-full text-center text-3xl">Loading...</div>
{:then suggested}
{#if cur}
<div class="shadow-sm transition-shadow hover:shadow-md">
<div class="mt-8 flex text-center text-lg">
<span class="flex-1">{cur1Name}{alone}</span>
{#if cur2Name}
<span class="flex-1">{cur2Name}</span>
{/if}
{#if cur3Name}
<span class="flex-1">{cur3Name}</span>
{/if}
</div>
<div class="flex w-full text-center text-lg">
<span class="flex-1">at {curLoc?.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 (s.chara_id)}
<span>{s.chara_name?.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}
{/await}