Files
horse/zenno/src/routes/race/stam/+page.svelte
T
zephyr 86583e68d1
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline failed
zenno: wrapper component for race length inputs
2026-09-22 10:59:29 -04:00

456 lines
16 KiB
Svelte

<script lang="ts">
import * as course from '$lib/course';
import * as race from '$lib/race';
import RaceLenSlider from '$lib/RaceLenSlider.svelte';
import * as recovery from '$lib/racelib/recovery';
import HPChart from './HPChart.svelte';
import InfoPanels from './InfoPanels.svelte';
let rawStats = $state({
[race.Stat.Speed]: 1200,
[race.Stat.Stamina]: 1200,
[race.Stat.Power]: 1200,
[race.Stat.Guts]: 1200,
[race.Stat.Wit]: 1200,
});
let style = $state(race.RunningStyle.FrontRunner);
let surfaceApt = $state(race.AptitudeLevel.A);
let distanceApt = $state(race.AptitudeLevel.S);
let styleApt = $state(race.AptitudeLevel.A);
let mood = $state(race.Mood.Normal);
let isCareer = $state(false);
let skillStatBonus = $state({
[race.Stat.Speed]: 0,
[race.Stat.Stamina]: 0,
[race.Stat.Power]: 0,
[race.Stat.Guts]: 0,
[race.Stat.Wit]: 0,
});
const recovRates = [35, 50, 150, 350, 550, 750, 950];
const debuffRates = [-25, -50, -100, -200, -300];
const selfDebuffRates = [-200, -400];
let recovCounts = $state(recovRates.map((v) => ({ r: v, n: 0 })));
let debuffCounts = $state(debuffRates.map((v) => ({ r: v, n: 0 })));
let selfDebuffCounts = $state(selfDebuffRates.map((v) => ({ r: v, n: 0 })));
let risky = $state(false);
const skills = $derived([...recovCounts, ...selfDebuffCounts].filter(({ n }) => n > 0));
function rate(r: number): string {
return `${r / 100}%`;
}
let raceLen = $state(2000);
let slopesRaw: course.Slope[] = $state([]);
let surface = $state(race.Surface.Turf);
let conditions = $state(race.GroundConditions.Firm);
let thresh1: race.Stat | undefined = $state();
let thresh2: race.Stat | undefined = $state();
// const rushedOpts = ['No Rushed', 'Rushed Enabled', 'Rushed Forced'] as const;
// let rushedType: (typeof rushedOpts)[number] = $state('No Rushed');
// let spotStruggleEnabled = $state(false);
const baseStats = $derived({
[race.Stat.Speed]: race.baseStat(mood, rawStats[race.Stat.Speed]),
[race.Stat.Stamina]: race.baseStat(mood, rawStats[race.Stat.Stamina]),
[race.Stat.Power]: race.baseStat(mood, rawStats[race.Stat.Power]),
[race.Stat.Guts]: race.baseStat(mood, rawStats[race.Stat.Guts]),
[race.Stat.Wit]: race.baseStat(mood, rawStats[race.Stat.Wit]),
});
const thresholdMod = $derived.by(() => {
if (thresh1 == null) {
if (thresh2 == null) {
return 1;
}
return race.thresholdMod(baseStats[thresh2]);
}
if (thresh2 == null) {
return race.thresholdMod(baseStats[thresh1]);
}
return race.thresholdMod(baseStats[thresh1], baseStats[thresh2]);
});
const adjStats = $derived({
[race.Stat.Speed]: race.adjustedStat(
race.Stat.Speed,
baseStats[race.Stat.Speed],
isCareer,
surface,
conditions,
thresholdMod,
),
[race.Stat.Stamina]: race.adjustedStat(race.Stat.Stamina, baseStats[race.Stat.Stamina], isCareer),
[race.Stat.Power]: race.adjustedStat(race.Stat.Power, baseStats[race.Stat.Power], isCareer, surface, conditions),
[race.Stat.Guts]: race.adjustedStat(race.Stat.Guts, baseStats[race.Stat.Guts], isCareer),
[race.Stat.Wit]: race.adjustedStat(race.Stat.Wit, baseStats[race.Stat.Wit], isCareer, styleApt),
});
const stats = $derived({
[race.Stat.Speed]: race.finalStat(adjStats[race.Stat.Speed], skillStatBonus[race.Stat.Speed]),
[race.Stat.Stamina]: race.finalStat(adjStats[race.Stat.Stamina], skillStatBonus[race.Stat.Stamina]),
[race.Stat.Power]: race.finalStat(adjStats[race.Stat.Power], skillStatBonus[race.Stat.Power]),
[race.Stat.Guts]: race.finalStat(adjStats[race.Stat.Guts], skillStatBonus[race.Stat.Guts]),
[race.Stat.Wit]: race.finalStat(adjStats[race.Stat.Wit], skillStatBonus[race.Stat.Wit]),
});
const distType = $derived(race.distance(raceLen));
const phaseSpeed = $derived({
[race.Phase.EarlyRace]: race.sectionSpeed(raceLen, stats[race.Stat.Wit], style, race.Phase.EarlyRace),
[race.Phase.MidRace]: race.sectionSpeed(raceLen, stats[race.Stat.Wit], style, race.Phase.MidRace),
[race.Phase.LateRace]: [
race.spurtSpeed(stats[race.Stat.Speed], stats[race.Stat.Guts], style, distanceApt, raceLen),
race.spurtSpeed(stats[race.Stat.Speed], stats[race.Stat.Guts], style, distanceApt, raceLen),
] as [number, number],
});
// const isFront = $derived(style === race.RunningStyle.FrontRunner || style === race.RunningStyle.Runaway);
// const spotStruggle = $derived(spotStruggleEnabled && isFront);
const maxHP = $derived(race.maxHP(raceLen, style, stats[race.Stat.Stamina]));
const baseDistribution = $derived(recovery.distribution(baseStats[race.Stat.Wit], skills, debuffCounts));
const distribution = $derived(risky ? recovery.withRisky(baseStats[race.Stat.Wit], baseDistribution) : baseDistribution);
const statistics = $derived.by(() => {
let mp = 0,
mr = 0,
mean = 0;
for (const [r, p] of distribution) {
mean += (r * p * maxHP) / 10000;
if (p > mp) {
mp = p;
mr = r;
}
}
return { mode: (mr * maxHP) / 10000, modePer: mp * 100, mean };
});
const debuffHP = $derived((maxHP * debuffCounts.reduce((a, s) => a + s.r * s.n, 0)) / 10000);
const effStam = $derived(race.effectiveStam(raceLen, style, maxHP + statistics.mode - debuffHP));
const sections = $derived.by(() => {
const r = course.splitSectionSlopes(raceLen, slopesRaw).map((s) => {
const uphillMod = s.slope > 0 ? race.uphillMod(stats[race.Stat.Power], s.slope) : 0;
const speed = phaseSpeed[s.phase].map((v) => v + uphillMod);
const time = [(s.endDist - s.startDist) / speed[1], (s.endDist - s.startDist) / speed[0]];
const hp = [
time[0] * race.hpPerSecond(raceLen, surface, conditions, stats[race.Stat.Guts], s.phase, speed[0]),
time[1] * race.hpPerSecond(raceLen, surface, conditions, stats[race.Stat.Guts], s.phase, speed[1]),
];
return { ...s, speed, time, hp, remain: [0, 0] };
});
let remain = [maxHP, maxHP];
for (const v of r) {
remain = [remain[0] - v.hp[0], remain[1] - v.hp[1]];
v.remain = [...remain];
}
return r;
});
const spurtHPRate = $derived(
race.hpPerSecond(
raceLen,
surface,
conditions,
stats[race.Stat.Guts],
race.Phase.LateRace,
phaseSpeed[race.Phase.LateRace][0],
),
);
const needHP = $derived(race.fullSpurtHP(raceLen, phaseSpeed[race.Phase.LateRace][0], spurtHPRate));
const spurtStartHP = $derived(sections.findLast((s) => s.phase !== race.Phase.LateRace)?.remain);
let slopeAddParams: course.Slope = $state({ per: 1, span: [0, 0] });
function addSlope() {
// TODO(zeph): validate no overlap with current slopes
slopesRaw.push(slopeAddParams);
slopeAddParams = { per: 1, span: [0, 0] };
}
function removeSlope(i: number) {
if (i < 0 || i >= slopesRaw.length) {
return;
}
slopesRaw.splice(i, 1);
}
// const phaseNames = {
// [race.Phase.EarlyRace]: 'Early',
// [race.Phase.MidRace]: 'Mid',
// [race.Phase.LateRace]: 'Late',
// } as const;
</script>
<h1 class="text-4xl">Stamina Calculator</h1>
<div class="mx-auto mt-8 grid max-w-5xl grid-cols-1 gap-2 rounded-md p-2 text-center shadow-md ring md:grid-cols-6">
{#each race.StatList as stat (stat)}
{@const statName = race.Stat[stat]}
<div class="">
<label for={statName}>{statName}</label>
<input type="number" class="w-full" id={statName} required bind:value={rawStats[stat]} />
</div>
{/each}
<div class="">
<label for="mood">Mood</label>
<select class="w-full" id="mood" required bind:value={mood}>
{#each race.MOODS as mood (mood)}
<option value={mood}>{race.Mood[mood]}</option>
{/each}
</select>
</div>
<div class="">
<label for="style">Style</label>
<select class="w-full" id="style" required bind:value={style}>
{#each race.RUNNING_STYLES as [name, style] (style)}
<option value={style}>{name}</option>
{/each}
</select>
</div>
<div class="">
<label for="distanceApt">{race.Distance[distType]} Aptitude</label>
<select class="w-full" id="distanceApt" required bind:value={distanceApt}>
{#each race.APTITUDE_LEVELS as apt (apt)}
<option value={apt}>{race.AptitudeLevel[apt]}</option>
{/each}
</select>
</div>
<div class="">
<label for="surfaceApt">{race.Surface[surface]} Aptitude</label>
<select class="w-full" id="surfaceApt" required bind:value={surfaceApt}>
{#each race.APTITUDE_LEVELS as apt (apt)}
<option value={apt}>{race.AptitudeLevel[apt]}</option>
{/each}
</select>
</div>
<div class="">
<label for="styleApt"
>{race.RUNNING_STYLES[style != race.RunningStyle.Runaway ? style : race.RunningStyle.FrontRunner][0]}</label
>
<select class="w-full" id="styleApt" required bind:value={styleApt}>
{#each race.APTITUDE_LEVELS as apt (apt)}
<option value={apt}>{race.AptitudeLevel[apt]}</option>
{/each}
</select>
</div>
<div class=" self-center">
<label for="isCareer" class="mr-1 align-middle">In Career</label>
<input type="checkbox" id="isCareer" role="switch" bind:checked={isCareer} class="min-h-6 min-w-6 align-middle" />
</div>
<div class="col-span-full col-start-1 flex">
<div class="h-px grow place-self-center border-t"></div>
<span class="mx-4 flex-none">Skill Passives</span>
<div class="h-px grow place-self-center border-t"></div>
</div>
{#each race.StatList as stat (stat)}
{@const statName = race.Stat[stat]}
<div class="">
<label for={`${statName}Bonus`}>{statName}</label>
<input type="number" class="w-full" id={`${statName}Bonus`} required bind:value={skillStatBonus[stat]} />
</div>
{/each}
</div>
<div class="mx-auto my-2 grid max-w-4xl grid-cols-2 rounded-md text-center shadow-md ring md:grid-cols-7">
<div class="col-span-full mt-1 text-lg">Recoveries</div>
{#each recovCounts as s (s.r)}
<div class="grid grid-cols-3 p-2 md:grid-cols-1 md:flex-col">
<label for={`recov${s.r}`}>{rate(s.r)}</label>
<input id={`recov${s.r}`} type="number" min="0" bind:value={s.n} class="col-span-2" />
</div>
{/each}
<div class="col-span-full border-t pt-1 text-lg md:col-span-2">Self-Damage</div>
<div class="col-span-full border-t border-l pt-1 text-lg md:col-span-5">Debuffs (No Wit Check)</div>
{#each selfDebuffCounts as s (s.r)}
<div class="grid grid-cols-3 p-2 md:grid-cols-1 md:flex-col">
<label for={`selfdamage${s.r}`}>{rate(s.r)}</label>
<input id={`selfdamage${s.r}`} type="number" min="0" bind:value={s.n} />
</div>
{/each}
{#each debuffCounts as s, i (s.r)}
<div class={{ 'grid grid-cols-3 p-2 md:grid-cols-1 md:flex-col': true, 'border-l': i === 0 }}>
<label for={`debuff${s.r}`}>{rate(s.r)}</label>
<input id={`debuff${s.r}`} type="number" min="0" bind:value={s.n} />
</div>
{/each}
<div class="col-span-2 flex flex-row py-2">
<label for="risky" class="mr-1 ml-auto">Risky Business</label>
<input id="risky" type="checkbox" bind:checked={risky} class="mr-auto ml-1 h-6 w-6" />
</div>
<div class="border-l"><!-- only here for the border --></div>
</div>
<div class="mx-auto my-2 grid max-w-5xl grid-cols-1 gap-4 rounded-md text-center shadow-md ring md:grid-cols-6">
<div class="m-4 flex md:col-span-2">
<RaceLenSlider bind:value={raceLen} />
</div>
<select id="surface" class="m-4" bind:value={surface}>
<option value={race.Surface.Turf}>Turf</option>
<option value={race.Surface.Dirt}>Dirt</option>
</select>
<select id="conditions" class="m-4" bind:value={conditions}>
{#each race.GROUND_CONDITONS as cond (cond)}
<option value={cond}>{race.GroundConditions[cond]}</option>
{/each}
</select>
<div class="m-4">
<label for="thresh1">Threshold 1</label>
<select id="thresh1" class="w-full" bind:value={thresh1}>
<option value={undefined}></option>
{#each race.StatList as stat (stat)}
<option value={stat}>{race.Stat[stat]}</option>
{/each}
</select>
</div>
<div class="m-4">
<label for="thresh2">Threshold 2</label>
<select id="thresh2" class="w-full" bind:value={thresh2}>
<option value={undefined}></option>
{#each race.StatList as stat (stat)}
<option value={stat}>{race.Stat[stat]}</option>
{/each}
</select>
</div>
</div>
<!--
<div class="mx-auto mt-8 grid max-w-lg grid-cols-2 gap-4 rounded-md p-4 text-center shadow-md ring">
<select id="rushed" class="w-full" bind:value={rushedType}>
{#each rushedOpts as opt (opt)}
<option>{opt}</option>
{/each}
</select>
<div class="place-self-center">
<label for="spotStruggle">Spot Struggle</label>
<input type="checkbox" class="min-h-6 min-w-6 align-middle" id="rushed" role="switch" bind:checked={spotStruggleEnabled} />
</div>
</div>
-->
<div class="mx-auto mt-8 hidden max-w-4xl rounded-md p-4 text-center shadow-md ring">
<!-- TODO(zeph): this doesn't work for mobile -->
<table class="w-full table-fixed border-spacing-4">
<caption>Slopes</caption>
<thead>
<tr>
<th scope="col">Slope</th>
<th scope="col">Start</th>
<th scope="col">End</th>
<th scope="col"><!-- empty --></th>
</tr>
</thead>
<tbody>
{#each slopesRaw as slope, i (slope.span[0])}
<tr>
<td>{slope.per}</td>
<td>{slope.span[0]}</td>
<td>{slope.span[1]}</td>
<td>
<button
type="button"
class="w-24 bg-mist-300 p-2 hover:cursor-pointer dark:bg-mist-900"
onclick={() => removeSlope(i)}
>
Remove
</button>
</td>
</tr>
{/each}
<tr>
<td>
<select id="slopeAddPer" bind:value={slopeAddParams.per}>
<option value={2}>Uphill +2</option>
<option value={1.5}>Uphill +1.5</option>
<option value={1}>Uphill +1</option>
<option value={-1}>Downhill -1</option>
<option value={-1.5}>Downhill -1.5</option>
<option value={-2}>Downhill -2</option>
</select>
</td>
<td>
<input type="number" id="slopeAddStart" bind:value={slopeAddParams.span[0]} />
</td>
<td>
<input type="number" id="slopeAddEnd" bind:value={slopeAddParams.span[1]} />
</td>
<td>
<button type="button" class="w-24 bg-mist-300 p-2 hover:cursor-pointer dark:bg-mist-900" onclick={addSlope}>
Add
</button>
</td>
</tr>
</tbody>
</table>
</div>
<InfoPanels
{maxHP}
{needHP}
{spurtStartHP}
{effStam}
showStats={distribution.size > 1}
mode={statistics.mode}
modePer={statistics.modePer}
mean={statistics.mean}
/>
<!--
<table class="mt-8 w-full border text-center">
<thead>
<tr>
<th scope="colgroup" colspan="4" class="border-x">Section Data</th>
<th scope="colgroup" colspan={spotStruggle ? 2 : 1} class="border-x">Section State</th>
<th scope="colgroup" colspan="4" class="border-x">Fastest</th>
<th scope="colgroup" colspan="4" class="border-x">Slowest</th>
</tr>
<tr>
<th scope="col">Section</th>
<th scope="col">Phase</th>
<th scope="col" colspan="2">Span</th>
<th scope="col">Slope</th>
<th scope="col" class:hidden={!spotStruggle}>Spot Struggle</th>
<th scope="col">Speed</th>
<th scope="col">Time</th>
<th scope="col">HP</th>
<th scope="col">Remaining</th>
<th scope="col">Speed</th>
<th scope="col">Time</th>
<th scope="col">HP</th>
<th scope="col">Remaining</th>
</tr>
</thead>
<tbody>
{#each sections as s (s.startDist)}
<tr class="even:bg-mist-300 dark:even:bg-mist-900" class:font-bold={s.phase === race.Phase.LateRace}>
<td>{s.section}</td>
<td>{phaseNames[s.phase]}</td>
<td>{s.startDist.toFixed(1)} m</td>
<td>{s.endDist.toFixed(1)} m</td>
<td>{s.slope}</td>
<td class:hidden={!spotStruggle}></td>
<td>{s.speed[1].toFixed(3)} m/s</td>
<td>{s.time[1].toFixed(3)} s</td>
<td>{s.hp[1].toFixed(1)}</td>
<td>{s.remain[0].toFixed(1)}</td>
<td>{s.speed[0].toFixed(3)} m/s</td>
<td>{s.time[0].toFixed(3)} s</td>
<td>{s.hp[0].toFixed(1)}</td>
<td>{s.remain[1].toFixed(1)}</td>
</tr>
{/each}
</tbody>
</table>
-->
{#if skills.length != 0 || risky}
<div class="mx-auto h-96 max-w-3xl">
<HPChart {distribution} {maxHP} />
</div>
{/if}
<div class="mx-auto mt-12 w-full max-w-4xl border-t pt-4 md:mt-8">
<p>
Mode HP from Skills gives the most frequent single result of recoveries and debuffs, along with the probability of that
result.
</p>
<p>
Effective Stamina gives the stamina stat which yields the same HP as the input configuration plus the mode HP from recovery
skills alone, ignoring debuffs. It can be considered the HP that removes wit checks from the equivalent HP pool.
</p>
<p>
As of now, this calculator does not handle recoveries that have no wit check, nor the HP effects of rushed, spot struggle, and
downhill running.
</p>
</div>