zenno/spurt: calculator for last spurt speed

This commit is contained in:
2026-04-04 01:45:14 -04:00
parent 467098a9e4
commit 256e11d320
5 changed files with 216 additions and 1 deletions

103
zenno/src/lib/race.ts Normal file
View File

@@ -0,0 +1,103 @@
// Umamusume race mechanics adapted from KuromiAK's doc:
// https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?usp=sharing
export enum RunningStyle {
FrontRunner,
PaceChaser,
LateSurger,
EndCloser,
GreatEscape,
}
export enum AptitudeLevel {
G,
F,
E,
D,
C,
B,
A,
S,
}
export enum Phase {
EarlyRace,
MidRace,
LateRace,
}
function baseSpeed(raceLen: number): number {
return 20 - (raceLen - 2000) / 1000;
}
const strategyPhaseCoeff = [
[1.0, 0.98, 0.962],
[0.978, 0.991, 0.975],
[0.938, 0.998, 0.994],
[0.931, 1.0, 1.0],
[1.063, 0.962, 0.95],
] as const;
const distanceProficiencyMod = [0.1, 0.2, 0.4, 0.6, 0.8, 0.9, 1.0, 1.05] as const;
/**
* Calculate an Uma's last spurt target speed.
* @param rawSpeed Uma's speed stat. No accounting for mood lol.
* @param gutsStat Uma's guts stat.
* @param style Running style
* @param distance Distance aptitude
* @param raceLen Length of the race
* @returns Target speed in the last spurt in m/s
*/
export function spurtSpeed(
rawSpeed: number,
gutsStat: number,
style: RunningStyle,
distance: AptitudeLevel,
raceLen: number,
): number {
const speedStat = rawSpeed <= 1200 ? rawSpeed : 1200 + (rawSpeed - 1200) * 0.5;
const spc = strategyPhaseCoeff[style][Phase.LateRace];
const dpm = distanceProficiencyMod[distance];
const base = baseSpeed(raceLen);
// Expand and rearrange terms from the doccy to make solving for the inverse easier.
// (lateBaseTarget + 0.01*base) * 1.05 + sqrt(500*speedStat) * dpm * 0.002
// = (base * spc + sqrt(500*speedStat) * dpm * 0.002 + 0.01 * base) * 1.05 + sqrt(500*speedStat) * dpm * 0.002
// = 1.05*base*spc + 1.05*sqrt(500*speedStat)*dpm*0.002 + 0.0105*base + sqrt(500*speedStat)*dpm*0.002
// = base * (1.05*spc + 0.0105) + 0.0041 * sqrt(500*speedStat) * dpm
// plus the guts component.
// TODO(zephyr): numerical precision?
return base * (1.05 * spc + 0.0105) + 0.0041 * Math.sqrt(500 * speedStat) * dpm + Math.pow(450 * gutsStat, 0.597) * 0.0001;
}
/**
* Calculate the speed stat which produces a given target speed in the last spurt.
* Inverse of spurtSpeed.
* @param spurtSpeed Target speed in the last spurt in m/s
* @param gutsStat Uma's guts stat. No accounting for mood or stat thresholds.
* @param style Running style
* @param distance Distance aptitude
* @param raceLen Length of the race in meters
* @returns Speed stat which produces the target speed
*/
export function inverseSpurtSpeed(
spurtSpeed: number,
gutsStat: number,
style: RunningStyle,
distance: AptitudeLevel,
raceLen: number,
): number {
// spurtSpeed = base * (1.05*spc + 0.0105) + 0.0041*sqrt(500*speedStat)*dpm + pow(450*gutsStat, 0.597)*0.0001
// spurtSpeed - base * (1.05*spc + 0.0105) - pow(450*gutsStat, 0.597)*0.0001 = 0.0041*sqrt(500*speedStat)*dpm
// (spurtSpeed - base * (1.05*spc + 0.0105) - pow(450*gutsStat, 0.597)*0.0001)²/(0.0041²*dpm²*500) = speedStat
// (spurtSpeed - base * (1.05*spc + 0.0105) - pow(450*gutsStat, 0.597)*0.0001)²/(0.008405*dpm²) = speedStat
const spc = strategyPhaseCoeff[style][Phase.LateRace];
const dpm = distanceProficiencyMod[distance];
const base = baseSpeed(raceLen);
const nr = spurtSpeed - base * (1.05 * spc + 0.0105) - Math.pow(450 * gutsStat, 0.597) * 0.0001;
const r = (nr * nr) / (0.008405 * dpm * dpm);
if (r > 1200) {
return Math.round(2 * r - 1200);
}
return Math.round(r);
}

View File

@@ -21,6 +21,7 @@
<a href={resolve('/inherit')} class="mx-8 my-1 inline-block">Inheritance Chance</a> <a href={resolve('/inherit')} class="mx-8 my-1 inline-block">Inheritance Chance</a>
<a href={resolve('/spark')} class="mx-8 my-1 inline-block">Spark Chance</a> <a href={resolve('/spark')} class="mx-8 my-1 inline-block">Spark Chance</a>
<a href={resolve('/vet')} class="mx-8 my-1 inline-block">My Veterans</a> <a href={resolve('/vet')} class="mx-8 my-1 inline-block">My Veterans</a>
<a href={resolve('/spurt')} class="mx-8 my-1 inline-block">Spurt Speed</a>
<a href={resolve('/convo')} class="mx-8 my-1 inline-block">Lobby Conversations</a> <a href={resolve('/convo')} class="mx-8 my-1 inline-block">Lobby Conversations</a>
</span> </span>
</nav> </nav>

View File

@@ -18,6 +18,10 @@
<a href={resolve('/vet')}>My Veterans</a><i>Not yet implemented</i> — Set up and track your veterans for Zenno Rob Roy's inspiration <a href={resolve('/vet')}>My Veterans</a><i>Not yet implemented</i> — Set up and track your veterans for Zenno Rob Roy's inspiration
and spark calculators. and spark calculators.
</li> </li>
<li>
<a href={resolve('/spurt')}>Spurt Speed</a> — Calculate a horse's target speed in the last spurt and compare to other distance aptitudes
and running styles.
</li>
<li> <li>
<a href={resolve('/convo')}>Lobby Conversations</a> — Check participants in lobby conversations and get recommendations on unlocking <a href={resolve('/convo')}>Lobby Conversations</a> — Check participants in lobby conversations and get recommendations on unlocking
them quickly. them quickly.

View File

@@ -38,5 +38,15 @@ a:hover {
select { select {
background-color: light-dark(var(--color-mist-300), var(--color-mist-900)); background-color: light-dark(var(--color-mist-300), var(--color-mist-900));
padding: 0.5rem 0.75rem; padding: 0 0.75rem;
min-height: 1.5lh;
}
input {
background-color: light-dark(var(--color-mist-300), var(--color-mist-900));
padding: 0 0.75rem;
}
input[type='number'] {
min-height: 1.5lh;
} }

View File

@@ -0,0 +1,97 @@
<script lang="ts">
import { AptitudeLevel, inverseSpurtSpeed, RunningStyle, spurtSpeed } from '$lib/race';
const aptsList = Object.entries(AptitudeLevel).filter(([_name, val]) => typeof val === 'number');
const stylesList = [
['Front Runner', RunningStyle.FrontRunner],
['Pace Chaser', RunningStyle.PaceChaser],
['Late Surger', RunningStyle.LateSurger],
['End Closer', RunningStyle.EndCloser],
['Great Escape', RunningStyle.GreatEscape],
] as const;
let rawSpeed: number = $state(1200);
let rawGuts: number = $state(1200);
let style: RunningStyle = $state(RunningStyle.FrontRunner);
let distanceApt: AptitudeLevel = $state(AptitudeLevel.S);
let raceLen: number = $state(2000);
let isCareer: boolean = $state(false);
const careerMod = $derived(isCareer ? 400 : 0);
const speedStat = $derived(rawSpeed + careerMod);
const gutsStat = $derived(rawGuts + careerMod);
const speed = $derived(spurtSpeed(speedStat, gutsStat, style, distanceApt, raceLen));
const sProf = $derived([
inverseSpurtSpeed(speed, gutsStat, RunningStyle.FrontRunner, AptitudeLevel.S, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.PaceChaser, AptitudeLevel.S, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.LateSurger, AptitudeLevel.S, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.EndCloser, AptitudeLevel.S, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.GreatEscape, AptitudeLevel.S, raceLen) - careerMod,
]);
const aProf = $derived([
inverseSpurtSpeed(speed, gutsStat, RunningStyle.FrontRunner, AptitudeLevel.A, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.PaceChaser, AptitudeLevel.A, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.LateSurger, AptitudeLevel.A, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.EndCloser, AptitudeLevel.A, raceLen) - careerMod,
inverseSpurtSpeed(speed, gutsStat, RunningStyle.GreatEscape, AptitudeLevel.A, raceLen) - careerMod,
]);
</script>
<h1 class="text-4xl">Spurt Speed Calculator</h1>
<div class="mx-auto mt-8 grid max-w-4xl grid-cols-1 rounded-md text-center shadow-md ring md:grid-cols-4">
<div class="m-4">
<label for="speedStat">Speed Stat</label>
<input type="number" id="speedStat" required bind:value={rawSpeed} class="w-full" />
</div>
<div class="m-4">
<label for="gutsStat">Guts Stat</label>
<input type="number" id="gutsStat" required bind:value={rawGuts} class="w-full" />
</div>
<div class="m-4">
<label for="style">Style</label>
<select id="style" required bind:value={style} class="w-full">
{#each stylesList as [name, style]}
<option value={style}>{style === RunningStyle.GreatEscape ? 'Great Escape (Runaway)' : name}</option>
{/each}
</select>
</div>
<div class="m-4">
<label for="distanceApt">Distance Aptitude</label>
<select id="distanceApt" required bind:value={distanceApt} class="w-full">
{#each aptsList.toReversed() as [name, val] (val)}
<option value={val}>{name}</option>
{/each}
</select>
</div>
<div class="m-4 md:col-start-2">
<label for="raceLen">Race Distance</label>
<input type="number" id="raceLen" required bind:value={raceLen} class="w-full" />
</div>
<div class="m-4 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>
<span class="mt-8 block w-full text-center text-lg">
Target spurt speed: {speed.toFixed(3)} m/s
</span>
{#each [[AptitudeLevel.A, aProf] as const, [AptitudeLevel.S, sProf] as const] as [level, inv] (level)}
<div class="mx-auto max-w-3xl">
<span class="mt-8 block w-full">
With {AptitudeLevel[level]} proficiency, the equivalent speed is
</span>
<div class="flex flex-col md:flex-row">
{#each stylesList as [styleName, s] (s)}
<div
class={['m-2 flex-1 rounded-md border shadow-sm transition-shadow hover:shadow-md', s === style ? 'border-2' : null]}
>
<div class="h-full w-full flex-col text-center">
<span class="block text-lg">{styleName}</span>
<span class="block text-2xl">{inv[s]}</span>
</div>
</div>
{/each}
</div>
</div>
{/each}