Files
horse/zenno/src/routes/race/carryover/+page.svelte
T

387 lines
15 KiB
Svelte

<script lang="ts">
import * as Plot from '@observablehq/plot';
import * as race from '$lib/race';
import * as accel from '$lib/racelib/accel';
import type { Attachment } from 'svelte/attachments';
import CalcInfo from '../CalcInfo.svelte';
let rawSpeed = $state(1200);
let rawPower = $state(1200);
let rawGuts = $state(1200);
let rawWit = $state(1200);
let speedBonus = $state(0);
let powerBonus = $state(0);
let gutsBonus = $state(0);
let witBonus = $state(0);
let style = $state(race.RunningStyle.FrontRunner);
let surfApt = $state(race.AptitudeLevel.A);
let distApt = $state(race.AptitudeLevel.A);
let styleApt = $state(race.AptitudeLevel.A);
let mood = $state(race.Mood.Normal);
let isCareer = $state(false);
let raceLen = $state(2000);
let surface = $state(race.Surface.Turf);
let conditions = $state(race.GroundConditions.Firm);
let slope = $state(0); // TODO(zeph): where does it end?
let allowDAM = $state(false);
let threshMod = $state(1);
const phases = $derived(race.phases(raceLen));
const distanceType = $derived(race.distance(raceLen));
let skillCarry = $state(0);
let rawAccels: accel.Skill[] = $state([]);
let accelToAdd = $state({ x: 0, accel: 0.2, dur: 3 });
const quick = [
{
name: 'Angling/Beyond',
skills: [{ x: 0, accel: 0.4, dur: 5 }],
},
{
name: 'Angling/Beyond Inherit',
skills: [{ x: 0, accel: 0.2, dur: 3 }],
},
{
name: 'Clear',
skills: [],
},
];
function addAccel() {
rawAccels.push(accelToAdd);
accelToAdd = { x: 0, accel: 0.2, dur: 3 };
}
function removeAccel(i: number) {
rawAccels.splice(i, 1);
}
function setAccelList(l: accel.Skill[]) {
rawAccels = l;
}
const baseSpeed = $derived(race.baseStat(mood, rawSpeed));
const basePower = $derived(race.baseStat(mood, rawPower));
const baseGuts = $derived(race.baseStat(mood, rawGuts));
const baseWit = $derived(race.baseStat(mood, rawWit));
const adjSpeed = $derived(race.adjustedStat(race.Stat.Speed, baseSpeed, isCareer, surface, conditions, threshMod));
const adjPower = $derived(race.adjustedStat(race.Stat.Power, basePower, isCareer, surface, conditions));
const adjGuts = $derived(race.adjustedStat(race.Stat.Guts, baseGuts, isCareer));
const adjWit = $derived(race.adjustedStat(race.Stat.Wit, baseWit, isCareer, styleApt));
const speed = $derived(race.finalStat(adjSpeed, speedBonus));
const power = $derived(race.finalStat(adjPower, powerBonus));
const guts = $derived(race.finalStat(adjGuts, gutsBonus));
const wit = $derived(race.finalStat(adjWit, witBonus));
const damCarry = $derived(allowDAM && slope < 0 ? race.downhillAccelMod(slope) : 0);
const carry = $derived(skillCarry + damCarry);
const midSpeed = $derived.by(() => {
const s = race.sectionSpeed(raceLen, baseWit, wit, style, race.Phase.MidRace) satisfies [number, number];
const u = slope > 0 ? race.uphillMod(power, slope) : 0;
return [s[0] + u, s[1] + u] as const;
});
const spurtSpeed = $derived(race.spurtSpeed(speed, guts, style, distApt, raceLen));
const lateAccel = $derived(race.acceleration(power, style, surfApt, race.Phase.LateRace, distApt, slope > 0));
function doSeggs(startSpeed: number, accels: accel.Skill[]) {
const start: accel.Instant = { x: phases[race.Phase.MidRace], v: startSpeed, t: 0 };
return accel.up(start, spurtSpeed, lateAccel, accels).segments;
}
function last<T>(l: T[]): T {
return l[l.length - 1];
}
const accels = $derived(
rawAccels.map((s) => ({ ...s, x: (s.x ?? 0) + phases[race.Phase.MidRace], dur: race.skillDuration(s.dur, raceLen) })),
);
const seggs = $derived([doSeggs(midSpeed[0] + carry, accels), doSeggs(midSpeed[1] + carry, accels)] as const);
const uncarried = $derived([doSeggs(midSpeed[0], accels), doSeggs(midSpeed[1], accels)] as const);
const unskilled = $derived([doSeggs(midSpeed[0], []), doSeggs(midSpeed[1], [])] as const);
const distance = $derived([last(seggs[0]).end.x, last(seggs[1]).end.x] as const);
const time = $derived([last(seggs[0]).end.t, last(seggs[1]).end.t] as const);
const finishTime = $derived([
time[0] + (phases[race.Phase.LateRace] - distance[0]) / spurtSpeed,
time[1] + (phases[race.Phase.LateRace] - distance[1]) / spurtSpeed,
] as const);
function gainLengths(fast: accel.Instant, slow: accel.Instant): number {
const m = fast.v * (slow.t - fast.t) + fast.x - slow.x;
return m / race.HORSE_LENGTH;
}
const skillGain = $derived([
gainLengths(last(seggs[0]).end, last(unskilled[0]).end),
gainLengths(last(seggs[1]).end, last(unskilled[1]).end),
]);
const carryoverGain = $derived([
gainLengths(last(seggs[0]).end, last(uncarried[0]).end),
gainLengths(last(seggs[1]).end, last(uncarried[1]).end),
]);
let width = $state(1);
let height = $state(1);
const plotLines = $derived([
{ t: 0, x: phases[race.Phase.MidRace], v: midSpeed[1] + carry, label: 'Fastest Speed' },
...seggs[1].map((t) => ({ t: t.end.t, x: t.end.x, v: t.end.v, label: 'Fastest Speed' })),
{ t: finishTime[1], x: phases[race.Phase.LateRace], v: spurtSpeed, label: 'Fastest Speed' },
{ t: 0, x: phases[race.Phase.MidRace], v: midSpeed[1], label: 'No Carryover' },
...uncarried[1].map((t) => ({ t: t.end.t, x: t.end.x, v: t.end.v, label: 'No Carryover' })),
{ t: finishTime[1], x: phases[race.Phase.LateRace], v: spurtSpeed, label: 'No Carryover' },
{ t: 0, x: phases[race.Phase.MidRace], v: midSpeed[1], label: 'No Skills' },
...unskilled[1].map((t) => ({ t: t.end.t, x: t.end.x, v: t.end.v, label: 'No Skills' })),
{ t: finishTime[1], x: phases[race.Phase.LateRace], v: spurtSpeed, label: 'No Skills' },
]);
const makeChart: Attachment = (el) => {
$effect(() => {
el?.firstChild?.remove();
el?.append(
Plot.plot({
width,
height,
clip: true,
x: {
domain: [0, finishTime[1]],
label: 'Time (s)',
line: true,
},
y: {
domain: [17, 27],
label: 'Speed (m/s)',
line: true,
},
color: { legend: true },
marks: [Plot.frame(), Plot.line(plotLines, { x: 't', y: 'v', stroke: 'label', strokeWidth: 3 })],
}),
);
});
};
</script>
<h1 class="text-4xl">Carryover Calculator</h1>
<form class="mx-auto my-4 grid max-w-4xl grid-cols-1 gap-4 rounded-md p-4 shadow-sm md:grid-cols-12">
<div class="col-span-full grid rounded-md text-center shadow-md ring md:grid-cols-5">
<div class="m-4">
<label for="speedStat">Speed</label>
<input type="number" id="speedStat" bind:value={rawSpeed} class="w-full" />
</div>
<div class="m-4">
<label for="powerStat">Power</label>
<input type="number" id="powerStat" bind:value={rawPower} class="w-full" />
</div>
<div class="m-4">
<label for="gutsStat">Guts</label>
<input type="number" id="gutsStat" bind:value={rawGuts} class="w-full" />
</div>
<div class="m-4">
<label for="witStat">Wit</label>
<input type="number" id="witStat" bind:value={rawWit} class="w-full" />
</div>
<div class="col-start-1 m-4">
<label for="speedBonus">Speed Greens</label>
<input type="number" id="speedBonus" bind:value={speedBonus} class="w-full" />
</div>
<div class="m-4">
<label for="powerBonus">Power Greens</label>
<input type="number" id="powerBonus" bind:value={powerBonus} class="w-full" />
</div>
<div class="m-4">
<label for="gutsBonus">Guts Greens</label>
<input type="number" id="gutsBonus" bind:value={gutsBonus} class="w-full" />
</div>
<div class="m-4">
<label for="witBonus">Wit Greens</label>
<input type="number" id="witBonus" bind:value={witBonus} class="w-full" />
</div>
<div class="col-start-1 m-4">
<label for="style">Style</label>
<select id="style" required bind:value={style} class="w-full">
{#each race.RUNNING_STYLES as [name, style] (style)}
<option value={style}>{name}</option>
{/each}
</select>
</div>
<div class="m-4">
<label for="distanceApt">{race.Distance[distanceType]}</label>
<select id="distanceApt" required bind:value={distApt} class="w-full">
{#each race.APTITUDE_LEVELS as apt (apt)}
<option value={apt}>{race.AptitudeLevel[apt]}</option>
{/each}
</select>
</div>
<div class="m-4">
<label for="surfaceApt">{race.Surface[surface]}</label>
<select id="surfaceApt" required bind:value={surfApt} class="w-full">
{#each race.APTITUDE_LEVELS as apt (apt)}
<option value={apt}>{race.AptitudeLevel[apt]}</option>
{/each}
</select>
</div>
<div class="m-4">
<label for="styleApt">Style</label>
<select id="styleApt" required bind:value={styleApt} class="w-full">
{#each race.APTITUDE_LEVELS as apt (apt)}
<option value={apt}>{race.AptitudeLevel[apt]}</option>
{/each}
</select>
</div>
<div class="m-4">
<label for="mood">Mood</label>
<select id="mood" required bind:value={mood} class="w-full">
{#each race.MOODS as mood (mood)}
<option value={mood}>{race.Mood[mood]}</option>
{/each}
</select>
</div>
</div>
<div class="mb-auto grid max-w-4xl grid-cols-6 rounded-md text-center shadow-md ring md:col-span-6">
<input
class="col-span-4 m-4 w-full justify-self-center-safe"
type="range"
id="raceLen"
min="1000"
max="3600"
step="100"
bind:value={raceLen}
/>
<span class="col-span-2 my-auto mb-4 pl-2 text-sm">{raceLen}m</span>
<select id="surface" bind:value={surface} class="col-span-2 mx-2 mb-4">
<option value={race.Surface.Turf}>Turf</option>
<option value={race.Surface.Dirt}>Dirt</option>
</select>
<select id="conditions" bind:value={conditions} class="col-span-2 mx-2 mb-4">
{#each race.GROUND_CONDITONS as cond (cond)}
<option value={cond}>{race.GroundConditions[cond]}</option>
{/each}
</select>
<select id="slope" bind:value={slope} class="col-span-2 mx-2 mb-4">
{#each [2, 1.5, 1, 0, -1, -1.5, -2] as per (per)}
<option value={per}>{per > 0 ? `Uphill +${per}` : per < 0 ? `Downhill ${per}` : 'Flat'}</option>
{/each}
</select>
<div class="col-span-full mx-2 mb-4 flex gap-4">
<label for="threshMod" class="my-auto flex-1">Track Threshold Mod</label>
<input type="number" id="threshMod" bind:value={threshMod} min="1.0" max="1.2" step="0.025" class="my-auto flex-1" />
</div>
</div>
<div class="mb-auto flex w-full max-w-4xl flex-col rounded-md text-center shadow-md ring md:col-span-6">
<div class="mx-4 mt-2 flex flex-row gap-2">
<span class="w-24 flex-1">Delay (m)</span>
<span class="w-24 flex-1">Duration</span>
<span class="w-24 flex-1">Accel</span>
<span class="w-8 flex-none"><!-- filler to align other columns --></span>
</div>
{#each rawAccels as skill, i (i)}
<div class="mx-4 mt-2 flex flex-row gap-2">
<span class="my-auto w-24 flex-1">{skill.x}m</span>
<span class="my-auto w-24 flex-1">{skill.dur}s</span>
<span class="my-auto w-24 flex-1">{skill.accel}</span>
<button
type="button"
class="my-auto h-8 w-8 flex-none rounded-full bg-mist-300 text-2xl hover:cursor-pointer dark:bg-mist-900"
onclick={() => removeAccel(i)}>&times;</button
>
</div>
{/each}
<div class="mx-4 mt-2 flex flex-row gap-2">
<input type="number" class="my-auto w-24 flex-1" min="0" max={raceLen} step="1" bind:value={accelToAdd.x} />
<input type="number" class="my-auto w-24 flex-1" min="0.9" step="0.3" bind:value={accelToAdd.dur} />
<input type="number" class="my-auto w-24 flex-1" min="0" step="0.05" bind:value={accelToAdd.accel} />
<button
type="button"
class="my-auto h-8 w-8 flex-none rounded-full bg-mist-300 text-2xl hover:cursor-pointer dark:bg-mist-900"
onclick={addAccel}>+</button
>
</div>
<div class="mt-1 flex flex-row">
<span class="my-auto h-0 flex-1 border"></span>
<span class="mx-2 flex-none">Quick Setup</span>
<span class="my-auto h-0 flex-1 border"></span>
</div>
<div class="m-2 flex flex-row place-content-center-safe gap-2">
{#each quick as q (q.name)}
<button
type="button"
class="rounded-md bg-mist-300 p-2 hover:cursor-pointer dark:bg-mist-900"
onclick={() => setAccelList(q.skills)}>{q.name}</button
>
{/each}
</div>
</div>
<div class="flex w-full max-w-4xl rounded-md text-center shadow-md ring md:col-span-8 md:col-start-3">
<div class="m-4 flex-1">
<label for="carryover">Carryover Speed</label>
<input type="number" id="carryover" min="0" step="0.05" bind:value={skillCarry} class="w-full" />
</div>
<div class="m-4 my-auto flex-1">
<input type="checkbox" id="dam" bind:checked={allowDAM} disabled={slope >= 0} class="peer my-auto h-6 w-6 align-middle" />
<label for="dam" class="my-auto align-middle peer-disabled:opacity-75">Downhill Mode Carryover</label>
</div>
</div>
</form>
{#snippet info(name: string, unit: string, val: number, val2?: number)}
<CalcInfo title={name} class="max-w-72 flex-1">
{#if val2 == null}
<span class="block text-xl">{val.toFixed(3)} {unit}</span>
{:else}
<span class="block text-xl">{val.toFixed(3)} &ndash; {val2.toFixed(3)} {unit}</span>
{/if}
</CalcInfo>
{/snippet}
<div class="mx-auto flex w-full flex-col place-items-center md:flex-row md:justify-center">
{@render info('Mid-Race Speed', 'm/s', midSpeed[0], midSpeed[1])}
{@render info('Base Acceleration', 'm/s²', lateAccel)}
{@render info('Spurt Speed', 'm/s', spurtSpeed)}
</div>
<div class="mx-auto flex w-full flex-col place-items-center md:flex-row md:justify-center">
{@render info('Accel Distance', 'm', distance[1] - phases[race.Phase.MidRace], distance[0] - phases[race.Phase.MidRace])}
{@render info('Accel Time', 's', time[1], time[0])}
{@render info('Finish Time', 's', finishTime[1], finishTime[0])}
</div>
<div class="mx-auto flex w-full flex-col place-items-center md:flex-row md:justify-center">
{@render info('Carryover Gain', 'L', carryoverGain[1], carryoverGain[0])}
{@render info('Carryover + Accel Gain', 'L', skillGain[1], skillGain[0])}
</div>
<div class="mx-auto mb-12 h-72 max-w-4xl" bind:clientWidth={width} bind:clientHeight={height}>
<div role="img" {@attach makeChart}>the chart seems to have didn't</div>
</div>
{#snippet seggsTable(name: string, s: accel.Travel[])}
<table class="table-fixed border">
<caption class="text-xl">{name}</caption>
<thead>
<tr class="py-2">
<th class="px-2" scope="col">Time</th>
<th class="px-2" scope="col">Accel</th>
<th class="px-2" scope="col">Distance</th>
<th class="px-2" scope="col">Speed</th>
</tr>
</thead>
<tbody>
{#each s as { start, end, a } (start.t)}
<tr class="even:bg-mist-300 dark:even:bg-mist-900">
<td class="px-4 text-center">{end.t.toFixed(3)}</td>
<td class="px-4 text-center">{a.toFixed(3)}</td>
<td class="px-4 text-center">{end.x.toFixed(3)}</td>
<td class="px-4 text-center">{end.v.toFixed(3)}</td>
</tr>
{/each}
</tbody>
</table>
{/snippet}
<div class="mx-auto mt-8 grid max-w-4xl grid-cols-1 gap-8 md:grid-cols-2">
{@render seggsTable('Fastest Speed', seggs[1])}
{@render seggsTable('Slowest Speed', seggs[0])}
</div>
<div class="mx-auto mt-12 w-full max-w-4xl border-t pt-4 md:mt-8">
<ul class="ml-4 list-disc pb-4">
<li>
This calculator integrates acceleration analytically for efficiency, but the game uses a finite step approximation. Since
the last spurt is delayed by two frames, and skills effects don't apply until the frame after they activate, the result is
that this calculator slightly overestimates the benefit of acceleration.
</li>
<li>Acceleration skills that activate before late race cannot be accounted for in this calculator.</li>
</ul>
</div>