diff --git a/zenno/src/lib/StatChart.svelte b/zenno/src/lib/StatChart.svelte index f9eda32..a9e277e 100644 --- a/zenno/src/lib/StatChart.svelte +++ b/zenno/src/lib/StatChart.svelte @@ -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; + /** 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; } - 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' })), diff --git a/zenno/src/lib/chart.ts b/zenno/src/lib/chart.ts index 285a38c..e97530b 100644 --- a/zenno/src/lib/chart.ts +++ b/zenno/src/lib/chart.ts @@ -2,3 +2,8 @@ export interface ComputedSeries { y: (x: number) => number; label: string; } + +export interface HorizontalRule { + y: number; + label: string; +} diff --git a/zenno/src/lib/race.ts b/zenno/src/lib/race.ts index 3a22ec7..f6d2659 100644 --- a/zenno/src/lib/race.ts +++ b/zenno/src/lib/race.ts @@ -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 strategy–phase 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)); +} diff --git a/zenno/src/routes/+layout.svelte b/zenno/src/routes/+layout.svelte index cb1a4d4..6d8a969 100644 --- a/zenno/src/routes/+layout.svelte +++ b/zenno/src/routes/+layout.svelte @@ -19,6 +19,7 @@ Zenno Rob Roy Spurt Speed + Mechanical Speed Lobby Conversations diff --git a/zenno/src/routes/+page.svelte b/zenno/src/routes/+page.svelte index cb1aa94..3c8ea4f 100644 --- a/zenno/src/routes/+page.svelte +++ b/zenno/src/routes/+page.svelte @@ -10,6 +10,10 @@ Spurt Speed — Calculate a horse's target speed in the last spurt and compare to other distance aptitudes and running styles. +
  • + Front Runner Mechanical Speed Comparator — Compare spot struggle and lane combo to the effects + of individual skills. +
  • Lobby Conversations — Check participants in lobby conversations and get recommendations on unlocking them quickly. diff --git a/zenno/src/routes/mspeed/+page.svelte b/zenno/src/routes/mspeed/+page.svelte new file mode 100644 index 0000000..03110a9 --- /dev/null +++ b/zenno/src/routes/mspeed/+page.svelte @@ -0,0 +1,173 @@ + + +

    Front Runner Mechanical Speed Comparator

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +Mechanics +
    + Spot Struggle + Idealized Lane Combo +
    +Unique Skills +
    + {#each uniques as [name, boost, dur, phase] (name)} + {name} + {/each} +
    +Inherited Uniques & Other Skills +
    + {#each skills as [name, boost, dur, phase] (name)} + {name} + {/each} +
    +
    + + +
    +
    +
      +
    • All lengths gained include acceleration at the beginning of each speed boost and deceleration after its end.
    • +
    • Each effect is assumed to be isolated and executed on level ground.
    • +
    • + 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. +
    • +
    • + 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. +
        +
      • + 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. +
      • +
      • + 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. +
      • +
      • + 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. +
      • +
      • + For full simulated analysis of lane combo, see 危険回避シミュ. +
      • +
      +
    • +
    +
    diff --git a/zenno/src/routes/mspeed/SpeedDur.svelte b/zenno/src/routes/mspeed/SpeedDur.svelte new file mode 100644 index 0000000..e59412d --- /dev/null +++ b/zenno/src/routes/mspeed/SpeedDur.svelte @@ -0,0 +1,33 @@ + + +
    +
    {@render children()}
    + {@html text} L +
    + {fmtp(speed)} m/s + {dur.toFixed(3)} s +
    +