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,7 +67,10 @@
const vals = $derived(series.flatMap(({ y, label }) => xVal.map((x) => ({ x, y: y(x), label }))));
const yRange: [number, number] = $derived.by(() => {
if (range != null) {
return range;
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;
@@ -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));
}