zenno/career/ismdata: start visualization
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline failed

This commit is contained in:
2026-08-19 16:10:54 -04:00
parent bde080eadd
commit 2bf229b5da
4 changed files with 299 additions and 56 deletions
+63 -37
View File
@@ -13,6 +13,8 @@
import SupportIcon from '$lib/SupportIcon.svelte'; import SupportIcon from '$lib/SupportIcon.svelte';
import SkillIcon from '$lib/SkillIcon.svelte'; import SkillIcon from '$lib/SkillIcon.svelte';
import Spark from '$lib/Spark.svelte'; import Spark from '$lib/Spark.svelte';
import SupportCardGainChart from './SupportCardGainChart.svelte';
import { DEPENDENT, INDEPENDENT, type SupportCardDatum } from './SupportCardGainChart';
let characters = new SvelteMap<number, character.Character>(); let characters = new SvelteMap<number, character.Character>();
let skills = new SvelteMap<number, skill.Skill>(); let skills = new SvelteMap<number, skill.Skill>();
@@ -55,6 +57,7 @@
}); });
let files: FileList | undefined = $state(); let files: FileList | undefined = $state();
let careersOpen = $state(false);
const parses = $derived.by(async () => { const parses = $derived.by(async () => {
if (files == null) { if (files == null) {
return []; return [];
@@ -75,7 +78,11 @@
let exclude = new SvelteSet<string>(); let exclude = new SvelteSet<string>();
const errors = $derived((await parses).filter((f) => 'err' in f) as Array<{ f: File; err: unknown }>); const errors = $derived((await parses).filter((f) => 'err' in f) as Array<{ f: File; err: unknown }>);
const loaded = $derived((await parses).filter((f) => 'ok' in f) as Array<{ f: File; ok: ism.Career }>); const loaded = $derived((await parses).filter((f) => 'ok' in f) as Array<{ f: File; ok: ism.Career }>);
const careers = $derived(loaded.filter((f) => !exclude.has(f.f.name))); const careers = $derived(loaded.filter((f) => !exclude.has(f.f.name)).map((f) => f.ok));
let supportGainX: keyof SupportCardDatum = $state('Friendship Bonus');
let supportGainY: keyof SupportCardDatum = $state('Total Stats');
let supportGainS: keyof SupportCardDatum = $state('Source');
function groupskill(g: skill.SkillGroup | undefined, rarity: ism.SkillTip['rarity']) { function groupskill(g: skill.SkillGroup | undefined, rarity: ism.SkillTip['rarity']) {
if (g == null) { if (g == null) {
@@ -116,49 +123,68 @@
{#if loaded.length > 0} {#if loaded.length > 0}
<div class="mx-auto my-8 max-w-6xl rounded-md border p-4"> <div class="mx-auto my-8 max-w-6xl rounded-md border p-4">
<details class="w-full"> <details class="w-full" bind:open={careersOpen}>
<summary> <summary>
{(files?.length ?? 0) - errors.length} careers {(files?.length ?? 0) - errors.length} careers
{#if exclude.size !== 0} {#if exclude.size !== 0}
({loaded.length - careers.length} excluded) ({loaded.length - careers.length} excluded)
{/if} {/if}
</summary> </summary>
{#each loaded as career (career.ok.end_info.chara_info.single_mode_chara_id)} {#if careersOpen}
{@const uma = umas.get(career.ok.end_info.chara_info.card_id)} {#each loaded as career (career.ok.end_info.chara_info.single_mode_chara_id)}
{@const scen = scenarios.get(career.ok.end_info.chara_info.scenario_id)} {@const uma = umas.get(career.ok.end_info.chara_info.card_id)}
<CareerOverview career={career.ok} bind:enable={() => !exclude.has(career.f.name), (v) => setExclude(career.f.name, v)}> {@const scen = scenarios.get(career.ok.end_info.chara_info.scenario_id)}
{#snippet chara()} <CareerOverview career={career.ok} bind:enable={() => !exclude.has(career.f.name), (v) => setExclude(career.f.name, v)}>
{uma?.name} {#snippet chara()}
{/snippet} {uma?.name}
{#snippet scenario()} {/snippet}
{scen?.name} {#snippet scenario()}
{/snippet} {scen?.name}
{#snippet support(card: ism.SupportCard)} {/snippet}
{@const s = supports.get(card.support_card_id)} {#snippet support(card: ism.SupportCard)}
<div class="flex flex-col text-center"> {@const s = supports.get(card.support_card_id)}
<SupportIcon icon_id={card.support_card_id} size={64} alt={s?.name} /> <div class="flex flex-col text-center">
<span class="block text-sm">{'◆'.repeat(card.limit_break_count)}{'◇'.repeat(4 - card.limit_break_count)}</span> <SupportIcon icon_id={card.support_card_id} size={64} alt={s?.name} />
</div> <span class="block text-sm">{'◆'.repeat(card.limit_break_count)}{'◇'.repeat(4 - card.limit_break_count)}</span>
{/snippet} </div>
{#snippet skillHint(hint: ism.SkillTip)} {/snippet}
{@const g = skillGroups.get(hint.group_id)} {#snippet skillHint(hint: ism.SkillTip)}
{@const s = groupskill(g, hint.rarity)} {@const g = skillGroups.get(hint.group_id)}
<div class="block rounded-md border p-2"> {@const s = groupskill(g, hint.rarity)}
{#if s != null} <div class="block rounded-md border p-2">
<SkillIcon class="m-2" icon_id={s.icon_id} size={32} /> {#if s != null}
<span>{s.name}</span> <SkillIcon class="m-2" icon_id={s.icon_id} size={32} />
<span class="ml-auto">+{hint.level}</span> <span>{s.name}</span>
<span class="ml-auto">+{hint.level}</span>
{/if}
</div>
{/snippet}
{#snippet spark(id: number)}
{@const spark = sparks.get(id)}
{#if spark != null}
<Spark {spark} />
{/if} {/if}
</div> {/snippet}
{/snippet} </CareerOverview>
{#snippet spark(id: number)} {/each}
{@const spark = sparks.get(id)} {/if}
{#if spark != null}
<Spark {spark} />
{/if}
{/snippet}
</CareerOverview>
{/each}
</details> </details>
</div> </div>
<div class="flex">
<select class="flex-1" bind:value={supportGainY}>
{#each DEPENDENT as t (t.name)}
<option value={t.name}>{t.name}</option>
{/each}
</select>
<span class="mx-2 flex-none">vs</span>
<select class="flex-1" bind:value={supportGainX}>
{#each INDEPENDENT as t (t.name)}
<option value={t.name}>{t.name}</option>
{/each}
</select>
</div>
<div class="mx-auto mb-8 h-96 w-4xl">
<SupportCardGainChart {careers} {supports} {scenarios} x={supportGainX} y={supportGainY} symbol={supportGainS} />
</div>
{/if} {/if}
@@ -19,6 +19,8 @@
const totalSparks = $derived( const totalSparks = $derived(
career.progress_log_info.succession_factor_gain_array.reduce((a, y) => a + y.gain_factor_info_array.length, 0), career.progress_log_info.succession_factor_gain_array.reduce((a, y) => a + y.gain_factor_info_array.length, 0),
); );
let open = $state(false);
</script> </script>
{#snippet gainStat(name: ComponentProps<typeof SpecialtyIcon>['name'], stat: number, max: number)} {#snippet gainStat(name: ComponentProps<typeof SpecialtyIcon>['name'], stat: number, max: number)}
@@ -67,31 +69,33 @@
{@render support(card)} {@render support(card)}
{/each} {/each}
</div> </div>
<details class="mx-2 mb-1 grid w-full grid-cols-1 gap-x-2"> <details class="mx-2 mb-1 grid w-full grid-cols-1 gap-x-2" bind:open>
<summary>Gains per source</summary> <summary>Gains per source</summary>
{#each career.progress_log_info.support_card_gain_info_array as card (card.support_card_id)} {#if open}
{@const cardInfo = career.end_info.chara_info.support_card_array.find((c) => c.support_card_id === card.support_card_id)!} {#each career.progress_log_info.support_card_gain_info_array as card (card.support_card_id)}
{@const cardInfo = career.end_info.chara_info.support_card_array.find((c) => c.support_card_id === card.support_card_id)!}
<div class="flex w-full flex-row pr-4">
<div class="my-auto w-24 flex-none text-center">
{@render support(cardInfo)}
</div>
<div class="grow">
{@render gainInfo(card.gain_info)}
</div>
</div>
{/each}
<div class="flex w-full flex-row pr-4"> <div class="flex w-full flex-row pr-4">
<div class="my-auto w-24 flex-none text-center"> <div class="my-auto w-24 flex-none text-center">Events</div>
{@render support(cardInfo)}
</div>
<div class="grow"> <div class="grow">
{@render gainInfo(card.gain_info)} {@render gainInfo(career.progress_log_info.event_gain_info)}
</div> </div>
</div> </div>
{/each} <div class="flex w-full flex-row pr-4">
<div class="flex w-full flex-row pr-4"> <div class="my-auto w-24 flex-none text-center">Inspiration</div>
<div class="my-auto w-24 flex-none text-center">Events</div> <div class="grow">
<div class="grow"> {@render gainInfo(career.progress_log_info.succession_gain_info)}
{@render gainInfo(career.progress_log_info.event_gain_info)} </div>
</div> </div>
</div> {/if}
<div class="flex w-full flex-row pr-4">
<div class="my-auto w-24 flex-none text-center">Inspiration</div>
<div class="grow">
{@render gainInfo(career.progress_log_info.succession_gain_info)}
</div>
</div>
</details> </details>
<details class="mx-2 mb-1 w-full pr-4"> <details class="mx-2 mb-1 w-full pr-4">
<summary>Hints for {career.end_info.chara_info.skill_tips_array.length} skills</summary> <summary>Hints for {career.end_info.chara_info.skill_tips_array.length} skills</summary>
@@ -0,0 +1,66 @@
<script lang="ts">
import * as Plot from '@observablehq/plot';
import * as ism from '$lib/ism';
import * as support from '$lib/data/support';
import * as scenario from '$lib/data/scenario';
import type { SvelteMap } from 'svelte/reactivity';
import { supportVariables, type SupportCardDatum } from './SupportCardGainChart';
import type { Attachment } from 'svelte/attachments';
interface Props {
careers: ism.Career[];
supports: SvelteMap<number, support.SupportCard>;
scenarios: SvelteMap<number, scenario.Scenario>;
x: keyof SupportCardDatum;
y: keyof SupportCardDatum;
symbol?: keyof SupportCardDatum;
}
let { careers, supports, scenarios, x, y, symbol = 'Scenario' }: Props = $props();
let width = $state(0);
let height = $state(0);
const data = $derived(
careers.flatMap((c) =>
supportVariables(
c,
(id: number) => supports.get(id)!,
(id: number) => scenarios.get(id)!,
),
),
);
const makeChart: Attachment = (el) => {
$effect(() => {
el?.firstChild?.remove();
el?.append(
Plot.plot({
width,
height,
x: {},
y: {},
color: { scheme: 'Magma' },
marks: [
Plot.frame(),
Plot.rect(data, Plot.bin({ fill: 'count', filter: null }, { x, y, interval: 1 })),
Plot.dot(data, { x, y, symbol }),
data.length > 0 ? Plot.tip(data, Plot.pointer({ x, y, symbol, stroke: symbol, className: 'plot-tip' })) : null,
],
}),
);
});
};
</script>
<div bind:clientWidth={width} bind:clientHeight={height} class="h-full w-full">
<div role="img" {@attach makeChart}>
<span>the chart seems to have didn't</span>
</div>
</div>
<style>
:global(.plot-tip) {
--plot-background: light-dark(var(--color-mist-200), var(--color-mist-800));
}
</style>
@@ -0,0 +1,147 @@
import * as ism from '$lib/ism';
import * as support from '$lib/data/support';
import * as scenario from '$lib/data/scenario';
function gainTotalStats(gain: ism.GainInfo) {
return gain.speed.value + gain.stamina.value + gain.power.value + gain.guts.value + gain.wiz.value;
}
export const DEPENDENT = [
{
name: 'Total Stats',
map: (gain: ism.GainInfo, _effects: support.Effect[]) => gainTotalStats(gain),
},
{
name: 'Total Stats + SP',
map: (gain: ism.GainInfo, _effects: support.Effect[]) => gainTotalStats(gain) + gain.skill_point,
},
{
name: 'Speed',
map: (gain: ism.GainInfo, _effects: support.Effect[]) => gain.speed.value,
},
{
name: 'Stamina',
map: (gain: ism.GainInfo, _effects: support.Effect[]) => gain.stamina.value,
},
{
name: 'Power',
map: (gain: ism.GainInfo, _effects: support.Effect[]) => gain.power.value,
},
{
name: 'Guts',
map: (gain: ism.GainInfo, _effects: support.Effect[]) => gain.guts.value,
},
{
name: 'Wit',
map: (gain: ism.GainInfo, _effects: support.Effect[]) => gain.wiz.value,
},
{
name: 'SP',
map: (gain: ism.GainInfo, _effects: support.Effect[]) => gain.skill_point,
},
{
name: 'Skill Hints',
map: (gain: ism.GainInfo, effects: support.Effect[]) => {
// NOTE(zephyr): reduction starts with 1 because we want the value
// of each hint, not just the bonus from effects
const hintLevels = effects.reduce((l, e) => e.type === support.EffectType.HintLevels ? l + e.value : l, 1);
return gain.skill_tips_array.reduce((n, t) => n + Math.ceil(t.level / hintLevels), 0);
},
},
{
name: 'Skills',
map: (gain: ism.GainInfo, _effects: support.Effect[]) => gain.skill_tips_array.length,
},
] as const;
function label(c: ism.Career): string {
return `${c.end_info.chara_info.single_mode_chara_id} - ${c.end_info.chara_info.start_time}`;
}
export const INDEPENDENT = [
{
name: 'Friendship Bonus',
effect: support.EffectType.FriendshipBonus,
},
{
name: 'Mood Effect',
effect: support.EffectType.MoodEffect,
},
{
name: 'Speed Bonus',
effect: support.EffectType.SpeedBonus,
},
{
name: 'Stamina Bonus',
effect: support.EffectType.StaminaBonus,
},
{
name: 'Power Bonus',
effect: support.EffectType.PowerBonus,
},
{
name: 'Guts Bonus',
effect: support.EffectType.GutsBonus,
},
{
name: 'Wit Bonus',
effect: support.EffectType.WitBonus,
},
{
name: 'Skill Point Bonus',
effect: support.EffectType.SkillPointBonus,
},
{
name: 'Training Effectiveness',
effect: support.EffectType.TrainingEffectiveness,
},
{
name: 'Initial Friendship',
effect: support.EffectType.InitialFriendship,
},
{
name: 'Race Bonus',
effect: support.EffectType.RaceBonus,
},
{
name: 'Hint Frequency',
effect: support.EffectType.HintFrequency,
},
{
name: 'Specialty Priority',
effect: support.EffectType.SpecialtyPriority,
},
] as const;
export type SupportCardDatum = {
'Career': string;
'Scenario': string;
'Source': string;
'Unique Effect': string;
'Idle Mode Sub Rate': number;
'Races': number;
'Race Wins': number;
} & Record<typeof INDEPENDENT[number]['name'], number> & Record<typeof DEPENDENT[number]['name'], number>;
export function supportVariables(career: ism.Career, lookupCard: (id: number) => support.SupportCard, lookupScenario: (id: number) => scenario.Scenario): SupportCardDatum[] {
const deck = career.progress_log_info.support_card_gain_info_array.map((g) => lookupCard(g.support_card_id));
const scen = lookupScenario(career.end_info.chara_info.scenario_id);
return career.progress_log_info.support_card_gain_info_array.map((g, i) => {
const card = deck[i];
const level = support.level(card.rarity, career.end_info.chara_info.support_card_array[i].exp);
const { effects, unique } = support.effects(lookupCard(g.support_card_id), level);
const indep = Object.fromEntries(INDEPENDENT.map((t) => [t.name, effects.find((e) => e.type === t.effect)?.value ?? 0])) as Record<typeof INDEPENDENT[number]['name'], number>;
const dep = Object.fromEntries(DEPENDENT.map((t) => [t.name, t.map(g.gain_info, effects)])) as Record<typeof DEPENDENT[number]['name'], number>;
return {
'Career': label(career),
'Scenario': scen.name,
'Source': card.name,
'Unique Effect': unique?.description ?? 'None',
'Idle Mode Sub Rate': unique?.idle_mode_sub_rate ?? 0,
'Races': career.progress_log_info.race_history_array.length,
'Race Wins': career.progress_log_info.race_history_array.reduce((w, r) => r.race_history.result_rank === 1 ? w + 1 : w, 0),
...indep,
...dep,
};
});
}