Files
horse/zenno/src/routes/doc/race/+page.svelte
T
2026-06-16 13:40:16 -04:00

376 lines
16 KiB
Svelte

<script lang="ts">
import type { ComputedAreas, ComputedSeries } from '$lib/chart';
import Article from '../Article.svelte';
import Sec from '../Sec.svelte';
import * as race from '$lib/race';
import StatChart from '$lib/StatChart.svelte';
import { resolve } from '$app/paths';
function modemean(x: number, bonus: number, p: number): number {
return x * (1 - p) + x * bonus * p;
}
let style = $state(race.RunningStyle.FrontRunner);
let surfaceApt = $state(race.AptitudeLevel.A);
let distanceApt = $state(race.AptitudeLevel.A);
let raceLen = $state(2000);
const raceLenType = $derived.by(() => {
if (raceLen < 1500) {
return 'Sprint';
}
if (raceLen < 1900) {
return 'Mile';
}
if (raceLen < 2500) {
return 'Medium';
}
return 'Long';
});
const spurtSpeed: Array<ComputedSeries | null> = $derived([
{ label: `Guts 1200`, y: (x) => race.spurtSpeed(x, 1200, style, distanceApt, raceLen) },
{ label: `Guts 600`, y: (x) => race.spurtSpeed(x, 600, style, distanceApt, raceLen) },
distanceApt !== race.AptitudeLevel.A
? { label: `${raceLenType} A, Guts 1200`, y: (x) => race.spurtSpeed(x, 1200, style, race.AptitudeLevel.A, raceLen) }
: null,
]);
const nonSpurtSpeed: Array<ComputedSeries | null> = $derived([
{ label: `Base Target Speed`, y: (x) => race.baseTargetSpeed(raceLen, style, race.Phase.LateRace, x, distanceApt) },
distanceApt !== race.AptitudeLevel.A
? {
label: `${raceLenType} A`,
y: (x) => race.baseTargetSpeed(raceLen, style, race.Phase.LateRace, x, race.AptitudeLevel.A),
}
: null,
]);
const hp: ComputedSeries[] = $derived([
{ label: 'Front Runner', y: (x) => race.maxHP(raceLen, race.RunningStyle.FrontRunner, x) },
{ label: 'Pace Chaser', y: (x) => race.maxHP(raceLen, race.RunningStyle.PaceChaser, x) },
{ label: 'Late Surger', y: (x) => race.maxHP(raceLen, race.RunningStyle.LateSurger, x) },
{ label: 'End Closer', y: (x) => race.maxHP(raceLen, race.RunningStyle.EndCloser, x) },
{ label: 'Runaway', y: (x) => race.maxHP(raceLen, race.RunningStyle.GreatEscape, x) },
]);
const moveLane: ComputedSeries[] = [{ label: 'Move Lane Modifier', y: (x) => race.moveLaneModifier(x) }];
const uphill: ComputedSeries[] = [
{ label: '+2 Hill', y: (x) => race.uphillMod(x, 2.0) },
{ label: '+1.5 Hill', y: (x) => race.uphillMod(x, 1.5) },
{ label: '+1 Hill', y: (x) => race.uphillMod(x, 1.0) },
];
const accel: Array<ComputedSeries | null> = $derived([
{ label: 'Early Race', y: (x) => race.acceleration(x, style, surfaceApt, race.Phase.EarlyRace) },
{ label: 'Mid Race', y: (x) => race.acceleration(x, style, surfaceApt, race.Phase.MidRace) },
{ label: 'Late Race', y: (x) => race.acceleration(x, style, surfaceApt, race.Phase.LateRace) },
{
label: 'Early Race Uphill',
y: (x) => race.acceleration(x, style, surfaceApt, race.Phase.EarlyRace, race.AptitudeLevel.A, true),
},
]);
const laneChange: ComputedSeries[] = [{ label: 'Lane Change Target Speed', y: (x) => race.laneChangeSpeed(x) }];
const gutsSpurt: Array<ComputedSeries | null> = $derived([
{ label: `Speed 1200`, y: (x) => race.spurtSpeed(1200, x, style, distanceApt, raceLen) },
{ label: `Speed 600`, y: (x) => race.spurtSpeed(600, x, style, distanceApt, raceLen) },
distanceApt !== race.AptitudeLevel.A
? { label: `${raceLenType} A, Speed 1200`, y: (x) => race.spurtSpeed(1200, x, style, race.AptitudeLevel.A, raceLen) }
: null,
]);
const minSpeed: ComputedSeries[] = $derived([{ label: 'Minimum Speed', y: (x) => race.minSpeed(raceLen, x) }]);
const gutsHPRate: ComputedSeries[] = [{ label: 'HP Consumption Rate Multiplier', y: (x) => race.spurtHPRateMod(x) }];
const ssBoost: ComputedSeries[] = [{ label: 'Target Speed Boost', y: (x) => race.spotStruggleSpeed(x) }];
const ssDur: ComputedSeries[] = [
{ label: 'Front Runner S', y: (x) => race.spotStruggleDuration(x, race.AptitudeLevel.S) },
{ label: 'Front Runner A', y: (x) => race.spotStruggleDuration(x, race.AptitudeLevel.A) },
];
const duelSpeed: ComputedSeries[] = [{ label: 'Dueling Speed Boost', y: (x) => race.duelSpeedMod(x) }];
const duelAccel: ComputedSeries[] = [{ label: 'Dueling Acceleration Boost', y: (x) => race.duelAccelMod(x) }];
const styleIsFront = $derived(style === race.RunningStyle.FrontRunner || style === race.RunningStyle.GreatEscape);
const secSpeed: Array<ComputedAreas | null> = $derived([
{
label: 'Early Race',
y1: (x) => race.sectionSpeed(raceLen, x, x, style, race.Phase.EarlyRace)[0],
y2: (x) => race.sectionSpeed(raceLen, x, x, style, race.Phase.EarlyRace)[1],
},
{
label: 'Mid Race',
y1: (x) => race.sectionSpeed(raceLen, x, x, style, race.Phase.MidRace)[0],
y2: (x) => race.sectionSpeed(raceLen, x, x, style, race.Phase.MidRace)[1],
},
styleIsFront
? {
label: 'Early Race + Mean Speed-Up Mode',
y1: (x) =>
modemean(race.sectionSpeed(raceLen, x, x, style, race.Phase.EarlyRace)[0], 1.04, race.frontModeEnterChance(x)),
y2: (x) =>
modemean(race.sectionSpeed(raceLen, x, x, style, race.Phase.EarlyRace)[1], 1.04, race.frontModeEnterChance(x)),
}
: {
label: 'Early Race + Mean Pace-Up Mode',
y1: (x) => modemean(race.sectionSpeed(raceLen, x, x, style, race.Phase.EarlyRace)[0], 1.04, race.paceUpEnterChance(x)),
y2: (x) => modemean(race.sectionSpeed(raceLen, x, x, style, race.Phase.EarlyRace)[1], 1.04, race.paceUpEnterChance(x)),
},
styleIsFront
? {
label: 'Mid Race + Mean Speed-Up Mode',
y1: (x) => modemean(race.sectionSpeed(raceLen, x, x, style, race.Phase.MidRace)[0], 1.04, race.frontModeEnterChance(x)),
y2: (x) => modemean(race.sectionSpeed(raceLen, x, x, style, race.Phase.MidRace)[1], 1.04, race.frontModeEnterChance(x)),
}
: {
label: 'Mid Race + Mean Pace-Up Mode',
y1: (x) => modemean(race.sectionSpeed(raceLen, x, x, style, race.Phase.MidRace)[0], 1.04, race.paceUpEnterChance(x)),
y2: (x) => modemean(race.sectionSpeed(raceLen, x, x, style, race.Phase.MidRace)[1], 1.04, race.paceUpEnterChance(x)),
},
]);
const downhill: ComputedSeries[] = [
{ label: 'Style S', y: (x) => race.downhillAccelEnterChance(x * 1.1) * 100 },
{ label: 'Style A', y: (x) => race.downhillAccelEnterChance(x) * 100 },
];
const reducedSpurt: ComputedSeries[] = [
{ label: 'Style S', y: (x) => race.reducedSpurtChance(x * 1.1) * 100 },
{ label: 'Style A', y: (x) => race.reducedSpurtChance(x) * 100 },
];
const skillChance: ComputedSeries[] = [
{ label: 'Per Skill', y: (x) => race.skillWitCheck(x) * 100 },
{ label: '1 of 2 or 2 of 2', y: (x) => (race.skillWitCheck(x, 2, 1) + race.skillWitCheck(x, 2, 2)) * 100 },
{ label: '2 of 2', y: (x) => race.skillWitCheck(x, 2, 2) * 100 },
{ label: '3 of 3', y: (x) => race.skillWitCheck(x, 3, 3) * 100 },
// TODO(zeph): why is this wrong?
{ label: '3 of 3 or 3 of 4', y: (x) => (race.skillWitCheck(x, 4, 3) + race.skillWitCheck(x, 3, 3)) * 100 },
];
const poskeep: ComputedSeries[] = [
{ label: 'Front Runner S', y: (x) => 100 * race.frontModeEnterChance(x * 1.1) },
{ label: 'Front Runner A', y: (x) => 100 * race.frontModeEnterChance(x) },
{ label: 'Style S', y: (x) => 100 * race.paceUpEnterChance(x * 1.1) },
{ label: 'Style A', y: (x) => 100 * race.paceUpEnterChance(x) },
];
const rushed: ComputedSeries[] = [
{ label: 'Style S', y: (x) => race.rushedChance(x * 1.1) * 100 },
{ label: 'Style A', y: (x) => race.rushedChance(x) * 100 },
];
</script>
{#snippet statChart(
stat: race.Stat,
y: Array<ComputedSeries | null>,
yLabel: string,
range?: [number, number],
pick?: { len?: boolean; style?: boolean; dist?: boolean; surf?: boolean },
)}
{#if !pick?.len && !pick?.style && !pick?.dist && !pick?.surf}
<div class="mb-12 h-60 w-full md:mb-20 md:h-96">
<StatChart
class="mx-auto mb-12 h-full w-full max-w-3xl md:mb-10"
{stat}
{y}
{yLabel}
{range}
plotOptions={{ color: { legend: true } }}
/>
</div>
{:else}
<div class="mb-36 h-60 w-full md:mb-28 md:h-96">
<StatChart
class="mx-auto mb-12 h-full w-full max-w-3xl md:mb-10"
{stat}
{y}
{yLabel}
{range}
plotOptions={{ color: { legend: true } }}
/>
<div class="mx-auto grid w-full grid-cols-3 gap-y-6 md:max-w-2xl md:grid-cols-7">
{#if pick?.len}
<input
class="col-span-2 my-auto w-full justify-self-center-safe"
type="range"
id="raceLen"
min="1000"
max="3600"
step="100"
bind:value={raceLen}
/>
<span class="col-span-1 my-auto pl-2 text-sm">{raceLen}m</span>
{/if}
{#if pick?.style}
<select class="col-span-1 my-auto text-sm md:col-span-2" id="stylePick" bind:value={style}>
{#each race.RUNNING_STYLES as [name, style] (style)}
<option value={style}>{name}</option>
{/each}
</select>
{/if}
{#if pick?.dist}
<label class="col-span-1 my-auto pr-2 text-right text-sm md:inline" for="distPick">{raceLenType}</label>
<select class="col-span-1 my-auto text-sm" id="distPick" bind:value={distanceApt}>
{#each race.APTITUDE_LEVELS as l (l)}
<option value={l}>{race.AptitudeLevel[l]}</option>
{/each}
</select>
{/if}
{#if pick?.surf}
<label class="col-span-1 my-auto pr-2 text-right text-sm md:inline" for="surfPick">Surface</label>
<select class="col-span-1 my-auto text-sm" id="surfPick" bind:value={surfaceApt}>
{#each race.APTITUDE_LEVELS as l (l)}
<option value={l}>{l}</option>
{/each}
</select>
{/if}
</div>
</div>
{/if}
{/snippet}
<Article>
{#snippet head()}
<Sec h={1} id="top" class="text-center">Race Mechanics Charts</Sec>
<p>
This article is an interactive version of <a
href="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit"
target="_blank"
rel="noopener noreferrer">KuromiAK's race mechanics documentation</a
>
to give a better sense of how mechanics scale with stats and aptitudes.
</p>
<p>
Mechanics are organized in this article according to the stats that determine them. Those that vary with multiple stats,
e.g. spurt speed, appear in all relevant sections with differing horizontal axes. Mechanics that vary with race distance,
distance aptitudes, and surface aptitudes have controls to change each by their charts.
</p>
<p>
Only numeric mechanics that vary with stats and aptitudes are shown. Descriptions of concepts and conditions are left to the
original source.
</p>
{/snippet}
<Sec h={2} id="speed">Speed</Sec>
<Sec h={3} id="spurt-speed">Spurt Speed</Sec>
<p>Target speed during the Uma's last spurt. See also <a href="#spurt-speed-guts">the effect of Guts</a>.</p>
{@render statChart(race.Stat.Speed, spurtSpeed, 'Spurt Speed (m/s)', [20, 26], { len: true, style: true, dist: true })}
<Sec h={3} id="late-race-speed">Late Race Speed</Sec>
<p>Base target speed during late race when the Uma is not spurting (but not dead yet).</p>
{@render statChart(race.Stat.Speed, nonSpurtSpeed, 'Base Target Speed (m/s)', [18, 23], { len: true, style: true, dist: true })}
<Sec h={2} id="stamina">Stamina</Sec>
<Sec h={3} id="hp">HP</Sec>
<p>Max HP, i.e. starting HP.</p>
{@render statChart(race.Stat.Stamina, hp, 'HP', [1000, 5000], {len: true})}
<Sec h={2} id="power">Power</Sec>
<Sec h={3} id="move-lane-mod">Move Lane Modifier</Sec>
<p>
Target speed bonus when changing lanes while under the effect of a lane change speed skill. See <a
href={resolve('/doc/frbm#lane-combo')}>lane combo</a
>.
</p>
{@render statChart(race.Stat.Power, moveLane, 'Target Speed Modifier (m/s)', [0.2, 0.5])}
<Sec h={3} id="uphill">Uphill Target Speed Loss</Sec>
<p>Target speed modifier while running uphill.</p>
{@render statChart(race.Stat.Power, uphill, 'Target Speed Modifier (m/s)', [-2, 0])}
<Sec h={3} id="acceleration">Acceleration</Sec>
<p>Acceleration.</p>
{@render statChart(race.Stat.Power, accel, 'Acceleration (m/s²)', [0.1, 0.5], {style: true})}
<Sec h={3} id="lane-change-target-speed">Lane Change Target Speed</Sec>
<p>Horizontal (rather than forward) target speed of changing lanes.</p>
<div class="mb-4 h-60 w-full md:h-96">
<StatChart
class="mx-auto mb-12 h-full w-full max-w-3xl md:mb-10"
stat={race.Stat.Power}
y={laneChange}
yLabel="Lane Change Target Speed (CW/s)"
range={[0.01, 0.03]}
/>
</div>
<Sec h={2} id="guts">Guts</Sec>
<Sec h={3} id="spurt-speed-guts">Spurt Speed</Sec>
<p>Target speed during the Uma's last spurt. See also <a href="#spurt-speed">the effect of Speed</a>.</p>
{@render statChart(race.Stat.Guts, gutsSpurt, 'Spurt Speed (m/s)', [20, 26], { len: true, style: true, dist: true })}
<Sec h={3} id="min-speed">Minimum Speed</Sec>
<p>Forced minimum speed, as well as target speed when out of HP.</p>
{@render statChart(race.Stat.Guts, minSpeed, 'Target Speed (m/s)', [15.8, 18.4], { len: true })}
<Sec h={3} id="hp-rate-mod">Late Race HP Consumption Rate Modifier</Sec>
<p>Multiplier on HP consumption rate during late race and last spurt phase.</p>
{@render statChart(race.Stat.Guts, gutsHPRate, 'HP Consumption Multiplier', [1.2, 1.6])}
<Sec h={3} id="spot-struggle">Spot Struggle</Sec>
<p>Speed boost and duration of spot struggle.</p>
<div class="mb-4 grid h-96 w-full md:grid-cols-2">
<StatChart stat={race.Stat.Guts} y={ssBoost} yLabel="Speed Bonus (m/s)" range={[0, 0.3]} />
<StatChart stat={race.Stat.Guts} y={ssDur} yLabel="Duration (s)" range={[0, 12]} />
</div>
<Sec h={3} id="dueling">Dueling</Sec>
<p>Speed boost and acceleration boost of dueling.</p>
<div class="mb-4 grid h-96 w-full md:grid-cols-2">
<StatChart stat={race.Stat.Guts} y={duelSpeed} yLabel="Speed Bonus (m/s)" range={[0, 0.65]} />
<StatChart stat={race.Stat.Guts} y={duelAccel} yLabel="Acceleration Bonus (m/s²)" range={[0, 0.14]} />
</div>
<Sec h={2} id="wit">Wit</Sec>
<Sec h={3} id="section-speed">Section Speed</Sec>
<p>Random variance in target speed per race section.</p>
<div class="mb-24 h-60 w-full md:mb-20 md:h-96">
<StatChart
class="mx-auto mb-12 h-full w-full max-w-3xl md:mb-10"
stat={race.Stat.Wit}
yArea={secSpeed}
yLabel="Section Speed (m/s)"
range={[17.5, 22.5]}
plotOptions={{ color: { legend: true } }}
/>
<div class="mx-auto flex w-full md:max-w-2xl">
<label class="my-auto hidden flex-1 pr-2 text-right text-sm md:inline" for="secSpeedRaceLen">Race Length</label>
<input
class="my-auto max-w-40 flex-1 md:flex-2"
type="range"
id="secSpeedRaceLen"
min="1000"
max="3600"
step="100"
bind:value={raceLen}
/>
<span class="my-auto flex-1 pl-2 text-sm">{raceLen}m</span>
<label class="my-auto hidden flex-1 pr-2 text-right text-sm md:inline" for="secSpeedStyle">Style</label>
<select class="my-auto flex-1 text-sm" id="secSpeedStyle" bind:value={style}>
{#each race.RUNNING_STYLES as [name, style] (style)}
<option value={style}>{name}</option>
{/each}
</select>
</div>
</div>
<Sec h={3} id="downhill">Downhill Accel Mode Chance</Sec>
<p>Chance each second to enter downhill accel mode when eligible.</p>
{@render statChart(race.Stat.Wit, downhill, 'Entry Chance (% each second)', [0, 60])}
<Sec h={3} id="spurt-accept">Reduced Spurt Accept Chance</Sec>
<p>Chance to accept each checked spurt delay and speed when not full spurting.</p>
{@render statChart(race.Stat.Wit, reducedSpurt, 'Accept Chance (% each candidate)', [0, 100])}
<Sec h={3} id="skill">Skill Activation Chance</Sec>
<p>
Chance that each skill that has a wit check will be eligible to activate. This uses base wit, so style aptitude and passive
wit skills do not affect the chance, although mood does.
</p>
<p>TODO(zeph): Why is the 3 of 4 series getting values over 100%? Is it not just binom(3, 4, p) + binom(4, 4, p)?</p>
{@render statChart(race.Stat.Wit, skillChance, 'Skill Activation Chance (%)', [0, 100])}
<Sec h={3} id="poskeep">Position Keep Mode Chance</Sec>
<p>
Chance for front runners to enter speed-up or overtake mode, or for non-front runners to enter pace-up mode (but no effect on
pace-down mode), each 2 seconds when eligible.
</p>
{@render statChart(race.Stat.Wit, poskeep, 'Mode Entry Chance (%)', [0, 50])}
<Sec h={3} id="rushed">Rushed Chance</Sec>
<p>Chance for runners to become rushed at some point during the race. Rushed chance is bracketed to integer percentages.</p>
{@render statChart(race.Stat.Wit, rushed, 'Rushed Chance (%)', [0, 30])}
</Article>