zenno/doc/frbm: chart and calcs for section speed

This commit is contained in:
2026-05-24 17:55:57 -04:00
parent bd99cfaa6d
commit b1c6850ce1
4 changed files with 191 additions and 28 deletions

View File

@@ -2,7 +2,7 @@
import * as Plot from '@observablehq/plot';
import * as d3 from 'd3';
import { Stat } from './race';
import type { ComputedSeries, HorizontalRule } from './chart';
import type { ComputedAreas, ComputedSeries, HorizontalRule } from './chart';
import type { ClassValue } from 'svelte/elements';
import type { Attachment } from 'svelte/attachments';
@@ -10,7 +10,9 @@
/** The stat the chart shows. */
stat: Stat;
/** Series to show in the chart. */
y: ComputedSeries | Array<ComputedSeries | null>;
y?: ComputedSeries | Array<ComputedSeries | null>;
/** Areas to show in the chart. */
yArea?: ComputedAreas | Array<ComputedAreas | null>;
/** Label for the dependent variable. */
yLabel: string;
/**
@@ -37,7 +39,7 @@
plotOptions?: Omit<Plot.PlotOptions, 'marks' | 'x' | 'y'>;
}
let { stat, y, yLabel, range, xRange, xRule = [], yRule = [], class: className, plotOptions = {} }: Props = $props();
let { stat, y = [], yArea = [], yLabel, range, xRange, xRule = [], yRule = [], class: className, plotOptions = {} }: Props = $props();
let width = $state(0);
let height = $state(0);
@@ -60,7 +62,9 @@
const thrX = 1200;
const series = $derived([y].flat(1).filter((s) => s != null));
const areas = $derived([yArea].flat(1).filter((s) => s != null));
const vals = $derived(series.flatMap(({ y, label }) => xVal.map((x) => ({ x, y: y(x), label }))));
const areaVals = $derived(areas.flatMap(({ y1, y2, label }) => xVal.map((x) => ({ x, y1: y1(x), y2: y2(x), label }))));
const yRange: [number, number] = $derived.by(() => {
if (range != null) {
if (range.length === 2) {
@@ -103,8 +107,11 @@
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.areaY(areaVals, { x: 'x', y1: 'y1', y2: 'y2', fill: 'label', fillOpacity: 0.5 }),
Plot.line(areaVals, { x: 'x', y: 'y1', stroke: 'label' }),
Plot.line(areaVals, { x: 'x', y: 'y2', stroke: 'label' }),
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' })),
vals.length > 0 ? Plot.tip(vals, Plot.pointerY({ x: 'x', y: 'y', stroke: 'label', className: 'plot-tip' })) : null,
],
}),
);

View File

@@ -3,6 +3,12 @@ export interface ComputedSeries {
label: string;
}
export interface ComputedAreas {
y1: (x: number) => number;
y2: (x: number) => number;
label: string;
}
export interface HorizontalRule {
y: number;
label: string;

View File

@@ -1,6 +1,7 @@
// Umamusume race mechanics adapted from KuromiAK's doc:
// https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?usp=sharing
import * as math from "mathjs";
import { binomPMF } from "./prob";
/**
@@ -43,6 +44,17 @@ export enum RunningStyle {
GreatEscape,
}
/**
* Running styles with their proper names, for easy iterating.
*/
export const RUNNING_STYLES = [
['Front Runner', RunningStyle.FrontRunner],
['Pace Chaser', RunningStyle.PaceChaser],
['Late Surger', RunningStyle.LateSurger],
['End Closer', RunningStyle.EndCloser],
['Great Escape', RunningStyle.GreatEscape],
] as const;
/**
* Aptitude or proficiency levels.
*/
@@ -95,6 +107,40 @@ const speedStrategyPhaseCoeff = [
const distanceProficiencyMod = [0.1, 0.2, 0.4, 0.6, 0.8, 0.9, 1.0, 1.05] as const;
/**
* Calculate the range of section speed values for a horse in early or mid race.
* @param raceLen Length of the race in meters
* @param baseWit Base wit, after mood but before other modifiers
* @param witStat Final wit stat, including strategy proficiency and skills
* @param style Horse's running style
* @param phase Phase of the current section
*/
export function sectionSpeed(raceLen: number, baseWit: number, witStat: number, style: RunningStyle, phase: Exclude<Phase, Phase.LateRace>): [number, number];
/**
* Calculate the range of section speed values for a horse not spurting during late race.
* @param raceLen Length of the race in meters
* @param speedStat Final speed stat
* @param baseWit Base wit, after mood but before other modifiers
* @param witStat Final wit stat, including strategy proficiency and skills
* @param style Horse's running style
* @param distance Hores's distance proficiency aptitude
* @param phase Phase.LateRace
*/
export function sectionSpeed(raceLen: number, speedStat: number, baseWit: number, witStat: number, style: RunningStyle, distance: AptitudeLevel, phase: Phase.LateRace): [number, number];
export function sectionSpeed(raceLen: number, speedStatOrBaseWit: number, baseWitOrWitStat: number, witStatOrStyle: number | RunningStyle, styleOrPhase: RunningStyle | Phase, distance?: AptitudeLevel, lateRace?: Phase.LateRace): [number, number] {
const speedStat = lateRace !== undefined ? speedStatOrBaseWit : 0;
const baseWit = lateRace !== undefined ? baseWitOrWitStat : speedStatOrBaseWit;
const witStat = lateRace !== undefined ? witStatOrStyle : baseWitOrWitStat;
const style = lateRace !== undefined ? styleOrPhase as RunningStyle : witStatOrStyle as RunningStyle;
const phase = lateRace !== undefined ? lateRace : styleOrPhase as Phase;
const base = baseSpeed(raceLen);
const baseTarget = base * speedStrategyPhaseCoeff[style][phase];
const late = phase === Phase.LateRace ? (math.sqrt(500 * speedStat) as number) * distanceProficiencyMod[distance ?? AptitudeLevel.A] * 0.002 : 0;
const u = witStat / 550000 * math.log10(baseWit * 0.1);
const l = u - 0.0065;
return [baseTarget + late + base*l, baseTarget + late + base*u];
}
/**
* Calculate an Uma's last spurt target speed.
* @param speedStat Adjusted speed stat
@@ -326,3 +372,21 @@ export function speedGain(speedBonus: number, dur: number, accel: number | null,
export function downhillAccelEnterChance(witStat: number): number {
return witStat * 0.0004;
}
/**
* Calculate the chance for a front runner to enter speed-up or overtake mode when eligible.
* @param witStat Final wit stat, including style aptitude modifier
* @returns Probability each eligible tick to enter speed-up or overtake mode
*/
export function frontModeEnterChance(witStat: number): number {
return 0.2 * math.log10(witStat) - 0.2;
}
/**
* Calculate the chance for a non-front runner to enter pace-up mode when eligible.
* @param witStat Final wit stat, including style aptitude modifier
* @returns Probability each eligible tick to enter pace-up mode
*/
export function paceUpEnterChance(witStat: number): number {
return 0.15 * math.log10(witStat) - 0.15;
}

View File

@@ -1,15 +1,44 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { ComputedSeries, HorizontalRule } from '$lib/chart';
import { AptitudeLevel, downhillAccelEnterChance, moveLaneModifier, skillWitCheck, spotStruggleDuration, spotStruggleSpeed, Stat, uphillMod } from '$lib/race';
import type { ComputedAreas, ComputedSeries, HorizontalRule } from '$lib/chart';
import { AptitudeLevel, downhillAccelEnterChance, frontModeEnterChance, moveLaneModifier, paceUpEnterChance, Phase, RUNNING_STYLES, RunningStyle, sectionSpeed, skillWitCheck, spotStruggleDuration, spotStruggleSpeed, spurtSpeed, Stat, uphillMod } from '$lib/race';
import Skill from '$lib/Skill.svelte';
import StatChart from '$lib/StatChart.svelte';
import Sec from '../Sec.svelte';
const witCheckSeries: ComputedSeries[] = [
{ label: "1/1", y: (x) => 100*skillWitCheck(x, 1, 1) },
{ label: "1/2 or 2/2", y: (x) => 100*(skillWitCheck(x, 2, 1) + skillWitCheck(x, 2, 2)) },
{ label: "2/2", y: (x) => 100*skillWitCheck(x, 2, 2) },
let raceLen = $state(2000);
let secSpeedStyle = $state(RunningStyle.FrontRunner);
function mean2(x: [number, number]): number {
return (x[0] + x[1]) * 0.5;
}
function modemean(x: number, bonus: number, p: number): number {
return x * (1 - p) + x * bonus * p;
}
const secSpeedS = 1200;
const secSpeedA = 1000;
const secSpeedExample = $derived(sectionSpeed(raceLen, secSpeedS, secSpeedS*1.1, RunningStyle.FrontRunner, Phase.EarlyRace));
const secSpeedInfo = $derived(secSpeedExample.map((x) => x.toFixed(2)).join(' to '));
const secSpeedPassTime = $derived.by(() => {
const sSecSpeed = mean2(sectionSpeed(raceLen, secSpeedS, secSpeedS*1.1, RunningStyle.FrontRunner, Phase.MidRace));
const sMode = frontModeEnterChance(secSpeedS*1.1);
const sOvertakeSpeed = modemean(sSecSpeed, 1.05, sMode);
const aSecSpeed = mean2(sectionSpeed(raceLen, secSpeedA, secSpeedA, RunningStyle.FrontRunner, Phase.MidRace));
const aMode = frontModeEnterChance(secSpeedA);
const aSpeedupSpeed = modemean(aSecSpeed, 1.04, aMode);
const r = 2/(sOvertakeSpeed - aSpeedupSpeed);
return r.toFixed(1);
});
const frontModeCheckSeries: ComputedSeries[] = [
{ label: "Aptitude S", y: (x) => 100*frontModeEnterChance(x*1.1) },
{ label: "Aptitude A", y: (x) => 100*frontModeEnterChance(x) },
];
const skillCheckSeries: ComputedSeries[] = [
{ label: "1 of 1", y: (x) => 100*skillWitCheck(x, 1, 1) },
{ label: "1 of 2 or 2 of 2", y: (x) => 100*(skillWitCheck(x, 2, 1) + skillWitCheck(x, 2, 2)) },
{ label: "2 of 2", y: (x) => 100*skillWitCheck(x, 2, 2) },
];
const ssBoostSeries: ComputedSeries = { label: "Target Speed Boost", y: (x) => spotStruggleSpeed(x) };
const ssDurSeries: ComputedSeries[] = [
@@ -33,6 +62,37 @@
{ label: "Style S", y: (x) => downhillAccelEnterChance(x * 1.1) * 100 },
{ label: "Style A", y: (x) => downhillAccelEnterChance(x) * 100 },
];
const secIsFront = $derived(secSpeedStyle === RunningStyle.FrontRunner || secSpeedStyle === RunningStyle.GreatEscape);
const secSpeedSeries: Array<ComputedAreas | null> = $derived([
{
label: "Early Race",
y1: (x) => sectionSpeed(raceLen, x, x, secSpeedStyle, Phase.EarlyRace)[0],
y2: (x) => sectionSpeed(raceLen, x, x, secSpeedStyle, Phase.EarlyRace)[1],
},
{
label: "Mid Race",
y1: (x) => sectionSpeed(raceLen, x, x, secSpeedStyle, Phase.MidRace)[0],
y2: (x) => sectionSpeed(raceLen, x, x, secSpeedStyle, Phase.MidRace)[1],
},
secIsFront ? {
label: "Early Race + Mean Speed-Up Mode",
y1: (x) => modemean(sectionSpeed(raceLen, x, x, secSpeedStyle, Phase.EarlyRace)[0], 1.04, frontModeEnterChance(x)),
y2: (x) => modemean(sectionSpeed(raceLen, x, x, secSpeedStyle, Phase.EarlyRace)[1], 1.04, frontModeEnterChance(x)),
} : {
label: "Early Race + Mean Pace-Up Mode",
y1: (x) => modemean(sectionSpeed(raceLen, x, x, secSpeedStyle, Phase.EarlyRace)[0], 1.04, paceUpEnterChance(x)),
y2: (x) => modemean(sectionSpeed(raceLen, x, x, secSpeedStyle, Phase.EarlyRace)[1], 1.04, paceUpEnterChance(x)),
},
secIsFront ? {
label: "Mid Race + Mean Speed-Up Mode",
y1: (x) => modemean(sectionSpeed(raceLen, x, x, secSpeedStyle, Phase.MidRace)[0], 1.04, frontModeEnterChance(x)),
y2: (x) => modemean(sectionSpeed(raceLen, x, x, secSpeedStyle, Phase.MidRace)[1], 1.04, frontModeEnterChance(x)),
} : {
label: "Mid Race + Mean Pace-Up Mode",
y1: (x) => modemean(sectionSpeed(raceLen, x, x, secSpeedStyle, Phase.MidRace)[0], 1.04, paceUpEnterChance(x)),
y2: (x) => modemean(sectionSpeed(raceLen, x, x, secSpeedStyle, Phase.MidRace)[1], 1.04, paceUpEnterChance(x)),
},
]);
</script>
<article class="mx-auto max-w-4xl text-justify">
@@ -63,9 +123,11 @@
That said, most of the information here is ultimately my interpretations of <a
href="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?usp=sharing"
target="_blank"
rel="noopener noreferrer">KuromiAK's Race Mechanics doc</a
rel="noopener noreferrer">kuromiAK's Race Mechanics doc</a
>. Many of those interpretations are also informed by the exceptionally knowledgeable folks on the
<a href="https://discord.gg/SyAVkbBSkx" target="_blank" rel="noopener noreferrer">GameTora Discord server</a>.
I may present some of the information from the race mechanics doc in chart form, but I will generally leave out exact mechanic numbers and conditions;
the doc is already the place for that information.
</p>
<p>
I want to share the knowledge I've accrued about front runners, because teaching is my favorite thing. Definitely not just to
@@ -110,7 +172,7 @@
<Sec h="2" id="win-cons">Win Conditions</Sec>
<p>
On Global today, competitive horses usually have stat lines that are pretty similar to each other.
Races, therefore, are more often won by skills typically acceleration skills that activate at the start of late race.
Races, therefore, are more often won by skills &ndash; typically acceleration skills that activate at the start of late race.
Front runners have strong options.
</p>
<ul class="list-disc pl-4 mb-4">
@@ -154,6 +216,16 @@
Their mid race speed skills always gain distance.
</p>
<Sec h="2" id="front-modes">Speed-Up and Overtake Modes</Sec>
<p>
Instead of pace-down and pace-up, front runners have speed-up (+4% target speed for first among that front type) and overtake (+5% for not-first) modes.
Entering these modes requires meeting certain conditions relating to positioning, which collectively can be read as "solo fronts are heavily penalized."
They also require passing a wit check, with the same chance for both modes.
</p>
<div class="w-full h-60 md:h-96 max-w-3xl mx-auto">
<StatChart class="h-full w-full max-w-3xl mx-auto" stat={Stat.Wit} y={frontModeCheckSeries} yLabel="Entry Chance (% each 2 seconds)" range={[0, 50]} xRule={1200} />
</div>
<Sec h="2" id="skill-timing">Skill Timing</Sec>
<p>Thought experiment.</p>
<p>
@@ -200,12 +272,12 @@
For reference, the chart below shows proc chances of one of one, one of two, or two of two skills with wit checks.
</p>
<div class="w-full h-60 md:h-96 mb-4">
<StatChart class="h-full w-full max-w-3xl mx-auto" stat={Stat.Wit} y={witCheckSeries} yLabel="% Chance" range={[50, 100]} xRule={1000} />
<StatChart class="h-full w-full max-w-3xl mx-auto" stat={Stat.Wit} y={skillCheckSeries} yLabel="% Chance" range={[50, 100]} xRule={1000} />
</div>
<p>
TTL must be combined with GW if they want any chance of being first out of early race.
Since the main source of it is the Mihono Bourbon Wit SSR from the first Halloween event, VBourbon can suffice with its white version <Skill skill={200532} hint="early lead" mention /> and get to the front with her unique instead.
(Her other option is the Twin Turbo SSR that does generate a lot of stats but requires winning three 50/50s to get the gold skill.)
TTL or its white version <Skill skill={200532} hint="el" /> must be combined with GW if they want any chance of being first out of early race.
Since the main source of TTL is the Mihono Bourbon Wit SSR from the first Halloween event, VBourbon can suffice with EL.
(The other TTL option is the Twin Turbo SSR that does generate a lot of stats but requires winning three 50/50s to get the gold skill.)
</p>
<p>
Conc is less critical.
@@ -320,18 +392,32 @@
The modifier's range is determined by the wit stat.
(Curiously, the calculation uses both wit as modified by style proficiency and green skills as well as base wit.)
</p>
<div class="w-full h-60 md:h-96 mb-24 md:mb-20">
<StatChart class="w-full max-w-3xl h-full mx-auto mb-12 md:mb-10" stat={Stat.Wit} yArea={secSpeedSeries} yLabel="Section Speed (m/s)" range={[17.5, 22.5]} plotOptions={{color: {legend: true}}} />
<div class="flex w-full md:max-w-2xl mx-auto">
<label class="hidden md:inline flex-1 my-auto text-sm text-right pr-2" for="secSpeedRaceLen">Race Length</label>
<input class="flex-1 md:flex-2 max-w-40 my-auto" type="range" id="secSpeedRaceLen" min="1000" max="3600" step="100" bind:value={raceLen} />
<span class="flex-1 my-auto text-sm pl-2">{raceLen}m</span>
<label class="hidden md:inline flex-1 my-auto text-sm text-right pr-2" for="secSpeedStyle">Style</label>
<select class="flex-1 my-auto text-sm" id="secSpeedStyle" bind:value={secSpeedStyle}>
{#each RUNNING_STYLES as [name, style] (style)}
<option value={style}>{name}</option>
{/each}
</select>
</div>
</div>
<p>
Section speed is generally very small; at 1200 wit with style S, it has a range of about -0.15% to 0.5% of race base speed.
At 2000m, that translates to an actual speed range of 19.97 to 20.10 m/s.
Section speed is generally very small; at {secSpeedS} wit with style S, it has a range of about -0.15% to 0.5% of race base speed.
For a front runner at {raceLen}m, that translates to an actual speed range of {secSpeedInfo} m/s.
</p>
<p>
Unlike anything affected by the speed stat, though, it applies during the early and mid race, where front runners are trying to become frontest runners.
Wit difference alone can
It isn't negligible, though, since it applies during the portion of the race where front runners are trying to become frontest runners.
All else equal, including the effects of <a href="#front-modes">running modes</a>, such a horse blocked in front by a {secSpeedA} wit front A horse will pass in {secSpeedPassTime} seconds on average at mid race speeds.
</p>
<Sec h="2" id="phase-speed">Phase Speed</Sec>
<p>
Race base speed is multiplied by the strategyphase coefficient for each horse.
Race base speed is multiplied by the strategy&ndash;phase coefficient for each horse.
As the name suggests, SPC is different per running style and per race phase.
It's the thing that makes runaways take off in early race, and the thing that makes pace chaser promotion scary in late race (for those not using any of the correct running style).
</p>
@@ -358,9 +444,9 @@
<!-- gw, ttl, conc -->
<Sec h="3" id="lane-combo">Lane Combo</Sec>
<!-- dd, pp, ignited wit -->
<Sec h="3" id="front-speed-skills">Speed Skills Front Runner Exclusives</Sec>
<Sec h="3" id="front-speed-skills">Speed Skills &ndash; Front Runner Exclusives</Sec>
<!-- escape artist, front/distance corners/straights, leader's pride, speed eater (mile) -->
<Sec h="3" id="generic-speed-skills">Speed Skills Generic</Sec>
<Sec h="3" id="generic-speed-skills">Speed Skills &ndash; Generic</Sec>
<!-- thh, ramp up, pto, slipstream -->
<Sec h="3" id="spurt-skills">Spurt Skills</Sec>
<!-- barcarole, triumphant pulse, all i've got -->
@@ -503,7 +589,7 @@
with your intended style, but in career, winning is more important than front running.
</p>
<Sec h="2" id="cm">My CM Teams</Sec>
<Sec h="3" id="cm13">CM13 Taurus Cup (Tokyo Derby)</Sec>
<Sec h="3" id="cm13">CM13 &ndash; Taurus Cup (Tokyo Derby)</Sec>
<p>
Maruzensky's unique is live as an order&le;5 for approximately everyone.
Filling the ranks with front runners should be a strong means to delay it for later positions, especially COC.
@@ -556,7 +642,7 @@
If you prefer winning over running your favorites, this should be Maruzensky instead.
</li>
</ol>
<Sec h="3" id="cm12">CM12 Aries Cup (Satsuki Sho)</Sec>
<Sec h="3" id="cm12">CM12 &ndash; Aries Cup (Satsuki Sho)</Sec>
<p>
One of COC's best tracks, because U=ma2 is at worst only slightly less good than 777 as a trigger.
If there is any other front runner, triple front pushes pace COC out of range for U=ma2, making her at best as reliable as the usual.
@@ -593,7 +679,7 @@
Win rates after 80: VBourbon 30%, Sei 22.5%, Suzuka 12.5%. I believe this is my best round 2 performance ever.
I lose more to other fronts than to COC. "Most dominant racing horse for a year" continues to get trounced by the wacky triple front build.
</p>
<Sec h="3" id="cm11">CM11 Pisces Cup (Hanshin 3200 Heavy Rain)</Sec>
<Sec h="3" id="cm11">CM11 &ndash; Pisces Cup (Hanshin 3200 Heavy Rain)</Sec>
<p>
N.B. This CM was before I started writing this document, so henceforth, there is much less info.
</p>
@@ -625,7 +711,7 @@
<p>
Extremely unlucky finals gave me third place for the first time ever.
</p>
<Sec h="3" id="cm10">CM10 Aquarius Cup (February Stakes)</Sec>
<Sec h="3" id="cm10">CM10 &ndash; Aquarius Cup (February Stakes)</Sec>
<p>
Everyone is terrified of Taiki Shuttle, who has a 3-4 ult.
Triple fronts would like to have a word.
@@ -634,7 +720,7 @@
<ol class="list-decimal pl-4 mb-4">
<li>
Smart Falcon is the obvious choice, being the only actual dirt front runner to exist.
Her unique isn't terribly strong for this track, but her gold skills are Trending makes it extremely difficult for others to overtake her.
Her unique isn't terribly strong for this track, but her gold skills are &ndash; Trending makes it extremely difficult for others to overtake her.
1200/467/920/410/930 A/S/A.
</li>
<li>