zenno/mspeed: calculator for front runner mechanical speed bonuses

This commit is contained in:
2026-05-22 19:08:23 -04:00
parent b720b325b3
commit 2a07f193ec
7 changed files with 433 additions and 7 deletions

View File

@@ -2,23 +2,42 @@
import * as Plot from '@observablehq/plot';
import * as d3 from 'd3';
import { Stat } from './race';
import type { ComputedSeries } from './chart';
import type { ComputedSeries, HorizontalRule } from './chart';
import type { ClassValue } from 'svelte/elements';
import type { Attachment } from 'svelte/attachments';
interface Props {
/** The stat the chart shows. */
stat: Stat;
/** Series to show in the chart. */
y: ComputedSeries | Array<ComputedSeries | null>;
/** Label for the dependent variable. */
yLabel: string;
range?: [number, number];
/**
* Range of the dependent variable to show.
* If not given, the limits are the minimum and maximum values plotted.
* If given as a triple, the second value is the default maximum and
* the third is the maximum when the chart is expanded to 2000.
*/
range?: [number, number] | [number, number, number];
/** Range of the stat to plot. */
xRange?: [number, number] | null;
/**
* Vertical rules to place on the graph.
* Each rule gets a corresponding horizontal rule at the intersection
* with each series.
*/
xRule?: number | number[];
/**
* Horizontal rules to place on the graph.
*/
yRule?: HorizontalRule[];
class?: ClassValue | null;
plotOptions?: Omit<Plot.PlotOptions, 'marks' | 'x' | 'y'>;
}
let { stat, y, yLabel, range, xRange, xRule = [], class: className, plotOptions = {} }: Props = $props();
let { stat, y, yLabel, range, xRange, xRule = [], yRule = [], class: className, plotOptions = {} }: Props = $props();
let width = $state(0);
let height = $state(0);
@@ -48,8 +67,11 @@
const vals = $derived(series.flatMap(({ y, label }) => xVal.map((x) => ({ x, y: y(x), label }))));
const yRange: [number, number] = $derived.by(() => {
if (range != null) {
if (range.length === 2) {
return range;
}
return [range[0], expand ? range[2] : range[1]];
}
const l = d3.min(vals, ({ y }) => y) ?? 0;
const r = d3.max(vals, ({ y }) => y) ?? 1;
return [l, r];
@@ -81,6 +103,8 @@
Plot.ruleX([thrX], { strokeOpacity: 0.25 }),
Plot.ruleX(xLines, { strokeOpacity: 0.5 }),
Plot.ruleY(yLines, { y: 'y', stroke: 'label', strokeOpacity: 0.5 }),
Plot.ruleY(yRule, { y: 'y', strokeOpacity: 0.75 }),
Plot.tip(yRule, { x: xMax, y: 'y', title: 'label', anchor: 'top-right', className: 'plot-tip' }),
Plot.frame(),
Plot.line(vals, { x: 'x', y: 'y', stroke: 'label', strokeWidth: 3 }),
Plot.tip(vals, Plot.pointerY({ x: 'x', y: 'y', stroke: 'label', className: 'plot-tip' })),

View File

@@ -2,3 +2,8 @@ export interface ComputedSeries {
y: (x: number) => number;
label: string;
}
export interface HorizontalRule {
y: number;
label: string;
}

View File

@@ -3,6 +3,9 @@
import type { Uma } from './data/uma';
/**
* Fundamental stats of umas.
*/
export enum Stat {
Speed,
Stamina,
@@ -11,8 +14,14 @@ export enum Stat {
Wit,
}
/**
* Stats as a list for easy iteration.
*/
export const StatList = [Stat.Speed, Stat.Stamina, Stat.Power, Stat.Guts, Stat.Wit] as const;
/**
* Race runner, i.e. a trained horse.
*/
export interface Runner {
name: string;
@@ -41,6 +50,12 @@ export interface Runner {
unique_level: number;
}
/**
* Create a new runner with baseline stats.
* @param name Name to apply to the runner
* @param base_uma Character card (trainee or uma) to use for aptitudes; otherwise all aptitudes are A
* @returns Baseline runner
*/
export function new_runner(name?: string, base_uma?: Uma): Runner {
return {
name: name ?? '',
@@ -68,6 +83,9 @@ export function new_runner(name?: string, base_uma?: Uma): Runner {
};
}
/**
* Mood levels.
*/
export enum Mood {
Awful = -2,
Bad,
@@ -76,6 +94,11 @@ export enum Mood {
Great,
}
/**
* Running styles for strategyphase coefficients.
* Great Escape is distinguished as a separate style even though it is
* mechanically identical to Front Runner.
*/
export enum RunningStyle {
FrontRunner,
PaceChaser,
@@ -84,6 +107,9 @@ export enum RunningStyle {
GreatEscape,
}
/**
* Aptitude or proficiency levels.
*/
export enum AptitudeLevel {
G,
F,
@@ -95,6 +121,24 @@ export enum AptitudeLevel {
S,
}
/**
* Aptitude levels as a descending list for easy iterating.
*/
export const APTITUDE_LEVELS = [
AptitudeLevel.S,
AptitudeLevel.A,
AptitudeLevel.B,
AptitudeLevel.C,
AptitudeLevel.D,
AptitudeLevel.E,
AptitudeLevel.F,
AptitudeLevel.G,
] as const;
/**
* Race phases.
* While last spurt phase is also a phase, it is not distinguished here.
*/
export enum Phase {
EarlyRace,
MidRace,
@@ -179,6 +223,12 @@ namespace Alpha123Umalator {
}
}
/**
* Import a runner from an external source.
* @param obj Decoded object to import
* @param name Name or memo to apply to the runner
* @returns Imported runner, or null if import was not possible
*/
export function import_runner(obj: any, name?: string): Runner | null {
// TODO(zeph): check for keys that identify the uma source
if (typeof obj === 'object') {
@@ -199,7 +249,7 @@ function baseSpeed(raceLen: number): number {
return 20 - (raceLen - 2000) / 1000;
}
const strategyPhaseCoeff = [
const speedStrategyPhaseCoeff = [
[1.0, 0.98, 0.962],
[0.978, 0.991, 0.975],
[0.938, 0.998, 0.994],
@@ -226,7 +276,7 @@ export function spurtSpeed(
raceLen: number,
): number {
const speedStat = rawSpeed <= 1200 ? rawSpeed : 1200 + (rawSpeed - 1200) * 0.5;
const spc = strategyPhaseCoeff[style][Phase.LateRace];
const spc = speedStrategyPhaseCoeff[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.
@@ -260,7 +310,7 @@ export function inverseSpurtSpeed(
// 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 spc = speedStrategyPhaseCoeff[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;
@@ -270,3 +320,139 @@ export function inverseSpurtSpeed(
}
return Math.round(r);
}
/** Meters per horse length (馬身). */
export const HORSE_LENGTH = 2.5;
/** Meters per course width (a constant unit of measure). */
export const COURSE_WIDTH = 11.25;
/** Meters per lane width. */
export const LANE_WIDTH = COURSE_WIDTH / 18;
const accelStrategyPhaseCoeff = {
[RunningStyle.FrontRunner]: [1.0, 1.0, 0.996],
[RunningStyle.PaceChaser]: [0.985, 1.0, 0.996],
[RunningStyle.LateSurger]: [0.975, 1.0, 1.0],
[RunningStyle.EndCloser]: [0.945, 1.0, 0.997],
[RunningStyle.GreatEscape]: [1.17, 0.94, 0.956],
} as const;
const surfaceProficiencyMod = [0.1, 0.3, 0.5, 0.7, 0.8, 0.9, 1.0, 1.05] as const;
const accelDistanceProficiencyMod = [0.4, 0.5, 0.6, 1, 1, 1, 1, 1] as const;
/**
* Calculate a horse's instantaneous acceleration value.
* @param powerStat Final power stat
* @param style Running style
* @param phase Current race phase for this frame
* @param surfaceAptitude Surface aptitude
* @param distanceAptitude Distance aptitude; no effect if not given
* @param uphill Whether this frame has a positive SlopePer value
* @param startDash Whether this frame is in the start dash period, i.e. current speed has not yet reached 85% of the race's base speed
* @returns Acceleration in m/s²
*/
export function acceleration(
powerStat: number,
style: RunningStyle,
surfaceAptitude: AptitudeLevel,
phase: Phase,
distanceAptitude?: AptitudeLevel,
uphill?: boolean,
startDash?: boolean,
): number {
const baseAccel = uphill ? 0.0004 : 0.0006;
const startDashMod = startDash ? 24.0 : 0;
const spc = accelStrategyPhaseCoeff[style][phase];
const spm = surfaceProficiencyMod[surfaceAptitude];
const dpm = accelDistanceProficiencyMod[distanceAptitude ?? AptitudeLevel.A];
return baseAccel * Math.sqrt(500 * powerStat) * spc * spm * dpm + startDashMod;
}
const phaseDecel = {
[Phase.EarlyRace]: -1.2,
[Phase.MidRace]: -0.8,
[Phase.LateRace]: -1.0,
} as const;
/**
* Get the phase-based deceleration value.
* @param phase Race phase that the horse is running this frame
* @param pdm Whether the horse is currently running in pace-down mode
* @param dead Whether the horse has zero or less HP
* @returns Current deceleration value in m/s², a negative value
*/
export function deceleration(phase: Phase, pdm?: boolean, dead?: boolean): number {
if (dead) {
return -1.2;
}
if (pdm) {
// This isn't until 1.5anni.
// return -0.5;
return phaseDecel[phase];
}
return phaseDecel[phase];
}
/**
* Calculate the speed boost gained from spot struggle.
* @param gutsStat Final guts stat
* @returns Spot struggle speed boost in m/s
*/
export function spotStruggleSpeed(gutsStat: number): number {
return Math.pow(500 * gutsStat, 0.6) * 0.0001;
}
const strategyProficiencyMod = [0.1, 0.2, 0.4, 0.6, 0.75, 0.85, 1.0, 1.1] as const;
/**
* Calculate the max duration of spot struggle.
* Note that spot struggle ends early if the frontmost horse in it reaches a 5m lead,
* or at the start of section 9.
* @param gutsStat Final guts stat
* @param frontAptitude Front runner aptitude level
* @returns Spot struggle duration in s
*/
export function spotStruggleDuration(gutsStat: number, frontAptitude: AptitudeLevel): number {
// https://hakuraku.moe/notes/spot-struggle
return Math.sqrt(700 * gutsStat) * 0.012 * strategyProficiencyMod[frontAptitude];
}
/**
* Calculate the forward speed boost given when moving lanewise while a skill
* that grants a lane change speed boost is active.
* @param powerStat Final power stat
* @returns Move-lane speed modifier in m/s
*/
export function moveLaneModifier(powerStat: number): number {
return Math.sqrt(0.0002 * powerStat);
}
/**
* Calculate a skill's actual duration scaled to race length.
* @param baseDur Skill's listed duration in s
* @param raceLen Length of the race in m
* @returns Actual skill duration in s
*/
export function skillDuration(baseDur: number, raceLen: number): number {
return baseDur * raceLen * 0.001;
}
/**
* Calculate the distance gained from a target speed boost, including
* acceleration to the boosted target speed and deceleration back to baseline.
* @param speedBonus Difference between baseline and boosted speed in m/s
* @param accel Current acceleration value in m/s²
* @param decel Current phase-based deceleration value in m/s², a negative value.
* @param dur Duration of the boosted speed
* @returns Distance gained from the speed boost in m
*/
export function speedGain(speedBonus: number, accel: number, decel: number, dur: number): number {
// Actual effect of a target speed bonus looks like
// speed: __/-----\__
// bonus: ======
// I.e., the speed bonus duration includes acceleration to the new speed
// and does not include the acceleration back to baseline after it ends.
const accelTime = speedBonus / accel;
const decelTime = -speedBonus / decel;
// speedBonus*(dur-accelTime) + speedBonus*accelTime/2 + speedBonus*decelTime/2
return speedBonus * (dur + 0.5 * (decelTime - accelTime));
}

View File

@@ -19,6 +19,7 @@
<span class="flex-1 text-center">
<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('/convo')} class="mx-8 my-1 inline-block">Lobby Conversations</a>
</span>
</nav>

View File

@@ -10,6 +10,10 @@
<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>
<a href={resolve('/mspeed')}>Front Runner Mechanical Speed Comparator</a> &mdash; Compare spot struggle and lane combo to the effects
of individual skills.
</li>
<li>
<a href={resolve('/convo')}>Lobby Conversations</a> — Check participants in lobby conversations and get recommendations on unlocking
them quickly.

View File

@@ -0,0 +1,173 @@
<script lang="ts">
import type { ComputedSeries, HorizontalRule } from '$lib/chart';
import {
acceleration,
APTITUDE_LEVELS,
AptitudeLevel,
deceleration,
HORSE_LENGTH,
moveLaneModifier,
Phase,
RunningStyle,
skillDuration,
speedGain,
spotStruggleDuration,
spotStruggleSpeed,
Stat,
} from '$lib/race';
import StatChart from '$lib/StatChart.svelte';
import SpeedDur from './SpeedDur.svelte';
let rawPower = $state(1200);
let rawGuts = $state(1200);
let raceLen = $state(1600);
let surfApt = $state(AptitudeLevel.A);
let frontApt = $state(AptitudeLevel.A);
let isRunaway = $state(false);
let isCareer = $state(false);
const careerMod = $derived(isCareer ? 400 : 0);
const powerStat = $derived(rawPower + careerMod);
const gutsStat = $derived(rawGuts + careerMod);
const style = $derived(isRunaway ? RunningStyle.GreatEscape : RunningStyle.FrontRunner);
const phases = [Phase.EarlyRace, Phase.MidRace, Phase.LateRace] as const;
const accel = $derived(phases.map((p) => acceleration(powerStat, style, surfApt, p)));
const decel = phases.map((p) => deceleration(p));
const ssBoost = $derived(spotStruggleSpeed(gutsStat));
const ssDur = $derived(spotStruggleDuration(gutsStat, frontApt));
const lcBoost = $derived(moveLaneModifier(powerStat));
const lcDur = $derived(Math.min(skillDuration(3, raceLen), 6));
const uniques = [
['Operation Cacao', 0.35, 5, Phase.MidRace],
["All Charged! It's Go Time! (Tokyo turf)", 0.45, 5, Phase.LateRace],
] as const;
const skills = [
['Fast-Paced', 0.15, 3, Phase.MidRace],
['Professor of Curvature (mid race)', 0.35, 2.4, Phase.MidRace],
["All Charged! It's Go Time! (inherited)", 0.25, 3, Phase.LateRace],
] as const;
const ssY: Array<ComputedSeries | null> = $derived([
{
label: 'Aptitude S',
y: (x) => speedGain(spotStruggleSpeed(x), accel[0], decel[0], spotStruggleDuration(x, AptitudeLevel.S)) / HORSE_LENGTH,
},
{
label: 'Aptitude A',
y: (x) => speedGain(spotStruggleSpeed(x), accel[0], decel[0], spotStruggleDuration(x, AptitudeLevel.A)) / HORSE_LENGTH,
},
frontApt < AptitudeLevel.A
? {
label: `Aptitude ${AptitudeLevel[frontApt]}`,
y: (x) => speedGain(spotStruggleSpeed(x), accel[0], decel[0], spotStruggleDuration(x, frontApt)) / HORSE_LENGTH,
}
: null,
]);
const lcY: Array<ComputedSeries | null> = $derived([
{ label: 'Ideal Lane Combo', y: (x) => speedGain(moveLaneModifier(x), accel[0], decel[0], lcDur) / HORSE_LENGTH },
]);
const yRule: HorizontalRule[] = $derived([
{ y: speedGain(0.35, accel[1], decel[1], skillDuration(2.4, raceLen)) / HORSE_LENGTH, label: 'Professor of Curvature' },
]);
</script>
<h1 class="text-4xl">Front Runner Mechanical Speed Comparator</h1>
<div class="mx-auto mt-8 grid max-w-4xl grid-cols-1 rounded-md text-center shadow-md ring md:grid-cols-8">
<div class="m-4 md:col-span-2">
<label for="powerStat">Power Stat</label>
<input type="number" id="powerStat" bind:value={rawPower} class="w-full" />
</div>
<div class="m-4 md:col-span-2">
<label for="gutsStat">Guts Stat</label>
<input type="number" id="gutsStat" bind:value={rawGuts} class="w-full" />
</div>
<div class="m-4 md:col-span-2">
<label for="surfaceApt">Surface Aptitude</label>
<select id="surfaceApt" required bind:value={surfApt} class="w-full">
{#each APTITUDE_LEVELS as apt (apt)}
<option value={apt}>{AptitudeLevel[apt]}</option>
{/each}
</select>
</div>
<div class="m-4 md:col-span-2">
<label for="frontApt">Front Runner Aptitude</label>
<select id="frontApt" required bind:value={frontApt} class="w-full">
{#each APTITUDE_LEVELS as apt (apt)}
<option value={apt}>{AptitudeLevel[apt]}</option>
{/each}
</select>
</div>
<div class="m-4 md:col-span-2 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 md:col-span-2">
<label for="isRunaway" class="mr-1 align-middle">Runaway</label>
<input type="checkbox" id="isRunaway" role="switch" bind:checked={isRunaway} class="min-h-6 min-w-6 align-middle" />
</div>
<div class="m-4 self-center md:col-span-2">
<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">Mechanics</span>
<div class="mx-auto flex w-full flex-col md:flex-row md:justify-center">
<SpeedDur speed={ssBoost} dur={ssDur} accel={accel[0]} decel={[decel[0], decel[1]]}>Spot Struggle</SpeedDur>
<SpeedDur speed={lcBoost} dur={lcDur} accel={accel[0]} decel={decel[0]}>Idealized Lane Combo</SpeedDur>
</div>
<span class="mt-8 block w-full text-center text-lg">Unique Skills</span>
<div class="mx-auto flex flex-col md:flex-row md:justify-center">
{#each uniques as [name, boost, dur, phase] (name)}
<SpeedDur speed={boost} dur={skillDuration(dur, raceLen)} accel={accel[phase]} decel={decel[phase]}>{name}</SpeedDur>
{/each}
</div>
<span class="mt-8 block w-full text-center text-lg">Inherited Uniques &amp; Other Skills</span>
<div class="mx-auto flex flex-col md:flex-row md:justify-center">
{#each skills as [name, boost, dur, phase] (name)}
<SpeedDur speed={boost} dur={skillDuration(dur, raceLen)} accel={accel[phase]} decel={decel[phase]}>{name}</SpeedDur>
{/each}
</div>
<div class="mx-auto flex h-60 flex-col place-content-center py-4 md:h-96 md:flex-row">
<StatChart class="flex-1" stat={Stat.Guts} y={ssY} yLabel="Lengths Gained" range={[0, 1.5, 2.5]} xRule={gutsStat} {yRule} />
<StatChart class="flex-1" stat={Stat.Power} y={lcY} yLabel="Lengths Gained" range={[0, 1.5, 2.5]} xRule={powerStat} {yRule} />
</div>
<div class="mx-auto mt-8 w-full max-w-4xl">
<ul class="list-disc">
<li>All lengths gained include acceleration at the beginning of each speed boost and deceleration after its end.</li>
<li>Each effect is assumed to be isolated and executed on level ground.</li>
<li>
Spot struggle has two numbers to distinguish ending in early race versus ending in mid race, which gives different
deceleration values. Since spot struggle duration does not scale with race length, it is more likely to end in mid race on
shorter races.
</li>
<li>
Lane combo is idealized in the sense of assuming second lane change speed skill executes immediately after Dodging Danger
completes and the horse is never blocked.
<ul class="ml-4 list-[revert]">
<li>
The move lane modifier is capped to 6 seconds, which is the approximate observed time to move from the Dodging Danger
fixed lane back to the rail under the effect of Prudent Positioning.
</li>
<li>
On medium+ tracks, with a proper gate acceleration build, Dodging Danger should realize some lane movement speed
modifier, so the actual benefit will be more than the idealized number.
</li>
<li>
Ignited Spirit WIT has a longer duration and lower lane change speed boost than Prudent Positioning, so it is likely to
give more benefit than the idealized number.
</li>
<li>
For full simulated analysis of lane combo, see <a
href="https://lanecalc.hf-uma.net/"
target="_blank"
rel="noopener noreferrer">危険回避シミュ</a
>.
</li>
</ul>
</li>
</ul>
</div>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import { HORSE_LENGTH, speedGain } from '$lib/race';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
speed: number;
dur: number;
accel: number;
decel: number | number[];
}
function fmtp(x: number): string {
return x >= 0 ? '+' + x.toFixed(3) : x.toFixed(3);
}
const { children, speed, dur, accel, decel }: Props = $props();
const decels = $derived([decel].flat(1));
const gain = $derived(decels.map((d) => speedGain(speed, accel, d, dur) / HORSE_LENGTH));
const text = $derived(gain.map(fmtp).join(' &ndash; '));
</script>
<div
class="m-2 flex h-full w-full max-w-80 flex-1 flex-col rounded-md border p-2 text-center shadow-sm transition-shadow hover:shadow-md"
>
<div class="block">{@render children()}</div>
<span class="block text-xl">{@html text} L</span>
<div class="flex flex-row">
<span class="flex-1 text-xs">{fmtp(speed)} m/s</span>
<span class="flex-1 text-xs">{dur.toFixed(3)} s</span>
</div>
</div>