zenno/race/gate: new calculator
This commit is contained in:
+31
-2
@@ -93,6 +93,20 @@ export function distance(raceLen: number): Distance {
|
||||
return Distance.Long;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the distances at which each phase of a race ends.
|
||||
* @param raceLen Length of the race in meters
|
||||
* @returns Ends of each phase in meters
|
||||
*/
|
||||
export function phases(raceLen: number) {
|
||||
const sixth = raceLen / 6;
|
||||
return {
|
||||
[Phase.EarlyRace]: sixth,
|
||||
[Phase.MidRace]: 4 * sixth,
|
||||
[Phase.LateRace]: raceLen,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Race surfaces.
|
||||
*/
|
||||
@@ -372,7 +386,13 @@ export function fullSpurtHP(raceLen: number, spurtSpeed: number, hpRate: number)
|
||||
return spurtTime * hpRate;
|
||||
}
|
||||
|
||||
function baseSpeed(raceLen: number): number {
|
||||
/**
|
||||
* Calculate the base speed for a race.
|
||||
* Note that this is not the base target speed for a horse.
|
||||
* @param raceLen Race length in meters
|
||||
* @returns Base speed for the race
|
||||
*/
|
||||
export function baseSpeed(raceLen: number): number {
|
||||
return 20 - (raceLen - 2000) / 1000;
|
||||
}
|
||||
|
||||
@@ -748,13 +768,22 @@ export function duelAccelMod(gutsStat: number): number {
|
||||
* Calculate the speed modifier for running uphill.
|
||||
* Contrary to the race mechanics document, this is expressed as a negative number.
|
||||
* @param powerStat Final power stat
|
||||
* @param slopePer Slope percentage, generally one of 0.5, 1.0, 1.5, or 2.0
|
||||
* @param slopePer Slope percentage, one of 1.0, 1.5, or 2.0
|
||||
* @returns Speed modifier for running uphill, a negative value
|
||||
*/
|
||||
export function uphillMod(powerStat: number, slopePer: number): number {
|
||||
return (slopePer * -200) / powerStat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the speed modifier while running in downhill accel mode.
|
||||
* @param slopePer Slope percentage, one of -1.0, -1.5, or -2.0
|
||||
* @returns Speed modifier for downhill accel mode
|
||||
*/
|
||||
export function downhillAccelMod(slopePer: number): number {
|
||||
return 0.3 - slopePer/10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the forward speed boost given when moving lanewise while a skill
|
||||
* that grants a lane change speed boost is active.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Instantaneous characteristics of a horse in motion.
|
||||
*/
|
||||
export interface Instant {
|
||||
/** Timestamp of the instant. */
|
||||
t: number;
|
||||
/** Forward position along the course. */
|
||||
x: number;
|
||||
/** Actual speed. */
|
||||
v: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward travel characteristics under the effects of acceleration.
|
||||
*/
|
||||
export interface Travel {
|
||||
start: Instant;
|
||||
end: Instant;
|
||||
/** Actual acceleration value applied between start and end. */
|
||||
a: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance a horse according to its forward motion characteristics.
|
||||
* @param start Motion at the beginning of the segment
|
||||
* @param targetSpeed Target speed to accelerate to
|
||||
* @param totalAccel Total (positive) acceleration including skill bonuses
|
||||
* @param maxDur Max duration of the acceleration, or infinite if not given
|
||||
* @param maxDist Max distance of the acceleration, or infinite if not given
|
||||
* @param decel Negative acceleration value for cases where target speed is below start speed
|
||||
* @returns Travel segment after reaching the first of the given limits
|
||||
*/
|
||||
export function travelSegment(start: Instant, targetSpeed: number, totalAccel: number, maxDur?: number, maxDist?: number, decel?: number): Travel {
|
||||
const a = start.v < targetSpeed ? totalAccel : decel ?? -1;
|
||||
const dvt = (targetSpeed - start.v) / a;
|
||||
const dxt = maxDist != null ? (-start.v + Math.sqrt(start.v*start.v + 4*a*maxDist)) / (2*a) : dvt;
|
||||
const time = maxDur != null ? Math.min(dvt, dxt, maxDur) : Math.min(dvt, dxt);
|
||||
// To address numerical imprecision, if accel time comes from velocity or distance,
|
||||
// set the reached values directly.
|
||||
const v = time === dvt ? targetSpeed : start.v + time * a;
|
||||
const dist = maxDist != null && time === dxt ? maxDist : (start.v + 0.5 * a * time) * time;
|
||||
const end = { t: start.t + time, x: start.x + dist, v };
|
||||
return {start, end, a};
|
||||
}
|
||||
|
||||
/**
|
||||
* Acceleration bonus from skills.
|
||||
*/
|
||||
export interface Skill {
|
||||
/**
|
||||
* Acceleration modifier in m/s².
|
||||
*/
|
||||
accel: number;
|
||||
/**
|
||||
* Remaining actual duration in seconds.
|
||||
*/
|
||||
dur: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accelerate up to a new target speed while under the effects of acceleration skills.
|
||||
* @param startSpeed Speed at the start of the acceleration in m/s
|
||||
* @param targetSpeed Speed at which the acceleration ends in m/s
|
||||
* @param baseAccel Base acceleration value to which skills add, in m/s²
|
||||
* @param skills Skills that are active for the acceleration period
|
||||
* @returns Acceleration segments and remainders of skills upon reaching the target speed
|
||||
*/
|
||||
export function up(start: Instant, targetSpeed: number, baseAccel: number, skills: Skill[]) {
|
||||
const segments: Travel[] = [];
|
||||
const sk = skills.map((s) => ({...s})).filter((s) => s.dur > 0);
|
||||
while (start.v < targetSpeed) {
|
||||
const active = sk.filter((s) => s.dur > 0);
|
||||
if (active.length === 0) {
|
||||
segments.push(travelSegment(start, targetSpeed, baseAccel));
|
||||
break;
|
||||
}
|
||||
const boost = active.reduce((a, s) => a + s.accel, 0);
|
||||
const time = Math.min(...active.map((s) => s.dur));
|
||||
const seg = travelSegment(start, targetSpeed, baseAccel + boost, time);
|
||||
segments.push(seg);
|
||||
start = seg.end
|
||||
for (const s of active) {
|
||||
s.dur -= seg.end.t - seg.start.t;
|
||||
if (s.dur < 1e-5) {
|
||||
s.dur = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
return {segments, skills: sk}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Fundamental stats of umas.
|
||||
*/
|
||||
export enum Stat {
|
||||
speed,
|
||||
stamina,
|
||||
power,
|
||||
guts,
|
||||
wit,
|
||||
}
|
||||
|
||||
/**
|
||||
* Stats as a list for easy iteration.
|
||||
*/
|
||||
export const LIST = [Stat.speed, Stat.stamina, Stat.power, Stat.guts, Stat.wit] as const;
|
||||
|
||||
declare const __stage: unique symbol;
|
||||
|
||||
export type Stage = 'raw' | 'base' | 'adjusted' | 'final' | 'skills';
|
||||
|
||||
export type StatValue<T extends Stage> = number & {[__stage]: T};
|
||||
|
||||
export interface Stats<T extends Stage> {
|
||||
speed: StatValue<T>;
|
||||
stamina: StatValue<T>;
|
||||
power: StatValue<T>;
|
||||
guts: StatValue<T>;
|
||||
wit: StatValue<T>;
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
<script lang="ts">
|
||||
import * as race from '$lib/race';
|
||||
import * as accel from '$lib/racelib/accel';
|
||||
|
||||
let rawPower = $state(1200);
|
||||
let rawGuts = $state(1200);
|
||||
let rawWit = $state(1200);
|
||||
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 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);
|
||||
|
||||
let rawSkills: accel.Skill[] = $state([]);
|
||||
let skillToAdd: accel.Skill = $state({accel: 0.2, dur: 1.2});
|
||||
const quick = [
|
||||
{name: "GW+TTL", skills: [{accel: 0.2, dur: 3}, {accel: 0.4, dur: 1.2}]},
|
||||
{name: "GW+EL", skills: [{accel: 0.2, dur: 3}, {accel: 0.2, dur: 1.2}]},
|
||||
{name: "Clear", skills: []},
|
||||
];
|
||||
function addSkill() {
|
||||
rawSkills.push(skillToAdd);
|
||||
skillToAdd = {accel: 0.2, dur: 1.2};
|
||||
}
|
||||
function removeSkill(i: number) {
|
||||
rawSkills.splice(i, 1);
|
||||
}
|
||||
function setSkillList(l: accel.Skill[]) {
|
||||
rawSkills = l;
|
||||
}
|
||||
|
||||
const phases = $derived(race.phases(raceLen));
|
||||
|
||||
const basePower = $derived(race.baseStat(mood, rawPower));
|
||||
const adjPower = $derived(race.adjustedStat(race.Stat.Power, basePower, isCareer, surface, conditions));
|
||||
const power = $derived(race.finalStat(adjPower, powerBonus));
|
||||
const baseGuts = $derived(race.baseStat(mood, rawGuts));
|
||||
const adjGuts = $derived(race.adjustedStat(race.Stat.Guts, baseGuts, isCareer));
|
||||
const guts = $derived(race.finalStat(adjGuts, gutsBonus));
|
||||
const baseWit = $derived(race.baseStat(mood, rawWit));
|
||||
const adjWit = $derived(race.adjustedStat(race.Stat.Wit, baseWit, isCareer, styleApt));
|
||||
const wit = $derived(race.finalStat(adjWit, witBonus));
|
||||
|
||||
const dashAccel = $derived(race.acceleration(power, style, surfApt, race.Phase.EarlyRace, race.AptitudeLevel.A, slope > 0, true));
|
||||
const postAccel = $derived(race.acceleration(power, style, surfApt, race.Phase.EarlyRace, race.AptitudeLevel.A, slope > 0, false));
|
||||
const minSpeed = $derived(race.minSpeed(raceLen, guts));
|
||||
const baseSpeed = $derived(race.baseSpeed(raceLen));
|
||||
const dashTarget = $derived(baseSpeed * 0.85);
|
||||
const hillMod: [number, number] = $derived.by(() => {
|
||||
if (slope > 0) {
|
||||
const m = race.uphillMod(power, slope);
|
||||
return [m, m];
|
||||
}
|
||||
if (slope < 0) {
|
||||
return [0, race.downhillAccelMod(slope)];
|
||||
}
|
||||
return [0, 0];
|
||||
})
|
||||
const earlyTarget = $derived(race.sectionSpeed(raceLen, baseWit, wit, style, race.Phase.EarlyRace));
|
||||
const speeds: [number, number] = $derived([earlyTarget[0] + hillMod[0], earlyTarget[1] + hillMod[1]]);
|
||||
const avgEarlyTarget = $derived((earlyTarget[0] + earlyTarget[1]) * 0.5);
|
||||
|
||||
const startInstant: accel.Instant = {t: 0, x: 0, v: 3};
|
||||
function doSeggsToIt(targetSpeed: number, skills: accel.Skill[]) {
|
||||
const startDash = accel.up(startInstant, dashTarget, dashAccel, skills);
|
||||
const postStart = {...last(startDash.segments).end, v: minSpeed};
|
||||
const post = accel.up(postStart, targetSpeed, postAccel, startDash.skills);
|
||||
const r = [...startDash.segments, ...post.segments];
|
||||
return r;
|
||||
}
|
||||
function last<T>(l: T[]): T {
|
||||
return l[l.length - 1];
|
||||
}
|
||||
const skills = $derived(rawSkills.map((s) => ({...s, dur: race.skillDuration(s.dur, raceLen)})));
|
||||
const seggs = $derived([doSeggsToIt(speeds[0], skills), doSeggsToIt(speeds[1], skills)]);
|
||||
const unskilledSeggs = $derived([doSeggsToIt(speeds[0], []), doSeggsToIt(speeds[1], [])]);
|
||||
|
||||
const distance = $derived([
|
||||
last(seggs[0]).end.x,
|
||||
last(seggs[1]).end.x,
|
||||
]);
|
||||
const time = $derived([
|
||||
last(seggs[0]).end.t,
|
||||
last(seggs[1]).end.t,
|
||||
]);
|
||||
const midTime = $derived([
|
||||
time[0] + (phases[race.Phase.EarlyRace] - distance[0])/speeds[1],
|
||||
time[1] + (phases[race.Phase.EarlyRace] - distance[1])/speeds[0],
|
||||
]);
|
||||
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 gain = $derived([
|
||||
gainLengths(last(seggs[0]).end, last(unskilledSeggs[0]).end),
|
||||
gainLengths(last(seggs[1]).end, last(unskilledSeggs[1]).end),
|
||||
]);
|
||||
</script>
|
||||
|
||||
<h1 class="text-4xl">Gate Calculator</h1>
|
||||
<form class="mx-auto my-4 p-4 grid gap-8 grid-cols-1 max-w-4xl rounded-md shadow-sm md:grid-cols-5">
|
||||
<div class="grid rounded-md text-center shadow-md ring md:col-span-5 md:grid-cols-5">
|
||||
<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="m-4">
|
||||
<label for="style">Style</label>
|
||||
<select id="style" required bind:value={style} class="w-full">
|
||||
{#each race.RUNNING_STYLES as [name, style]}
|
||||
<option value={style}>{name}</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 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="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>
|
||||
<div class="mb-auto grid grid-cols-6 max-w-4xl rounded-md text-center shadow-md ring md:col-span-3">
|
||||
<input class="m-4 w-full col-span-4 justify-self-center-safe" type="range" id="raceLen" min="1000" max="3600" step="100" bind:value={raceLen} />
|
||||
<span class="mb-4 my-auto pl-2 col-span-2 text-sm">{raceLen}m</span>
|
||||
<select id="surface" bind:value={surface} class="m-4 col-span-2">
|
||||
<option value={race.Surface.Turf}>Turf</option>
|
||||
<option value={race.Surface.Dirt}>Dirt</option>
|
||||
</select>
|
||||
<select id="conditions" bind:value={conditions} class="m-4 col-span-2">
|
||||
{#each race.GROUND_CONDITONS as cond (cond)}
|
||||
<option value={cond}>{race.GroundConditions[cond]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select id="slope" bind:value={slope} class="m-4 col-span-2">
|
||||
{#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>
|
||||
<div class="mb-auto w-full flex flex-col max-w-4xl rounded-md text-center shadow-md ring md:col-span-2">
|
||||
<div class="mx-4 mt-2 flex flex-row gap-2">
|
||||
<span class="w-24 flex-1">Duration</span>
|
||||
<span class="w-24 flex-1">Accel</span>
|
||||
<span class="flex-none w-8"><!-- filler to align other columns --></span>
|
||||
</div>
|
||||
{#each rawSkills as skill, i (i)}
|
||||
<div class="mx-4 mt-2 flex flex-row gap-2">
|
||||
<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 flex-none w-8 h-8 rounded-full text-2xl hover:cursor-pointer bg-mist-300 dark:bg-mist-900" onclick={() => removeSkill(i)}>×</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.9" step="0.3" bind:value={skillToAdd.dur} />
|
||||
<input type="number" class="my-auto w-24 flex-1" min="0" step="0.05" bind:value={skillToAdd.accel} />
|
||||
<button type="button" class="my-auto flex-none w-8 h-8 rounded-full text-2xl hover:cursor-pointer bg-mist-300 dark:bg-mist-900" onclick={addSkill}>+</button>
|
||||
</div>
|
||||
<div class="flex flex-row mt-1">
|
||||
<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="flex flex-row m-2 gap-2 place-content-center-safe">
|
||||
{#each quick as q (q.name)}
|
||||
<button type="button" class="p-2 rounded-md hover:cursor-pointer bg-mist-300 dark:bg-mist-900" onclick={() => setSkillList(q.skills)}>{q.name}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#snippet info(name: string, unit: string, val: number, val2?: number)}
|
||||
<div class="m-2 max-w-72 flex-1 flex-col rounded-md border p-2 text-center shadow-sm transition-shadow hover:shadow-md">
|
||||
<span class="block">{name}</span>
|
||||
{#if val2 == null}
|
||||
<span class="block text-xl">{val.toFixed(3)} {unit}</span>
|
||||
{:else}
|
||||
<span class="block text-xl">{val.toFixed(3)} – {val2.toFixed(3)} {unit}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="mx-auto flex w-full flex-col place-items-center md:flex-row md:justify-center">
|
||||
{@render info('Base Acceleration', 'm/s²', postAccel)}
|
||||
{@render info('Target Speed', 'm/s', speeds[0], speeds[1])}
|
||||
</div>
|
||||
<div class="mx-auto flex w-full flex-col place-items-center md:flex-row md:justify-center">
|
||||
{@render info('Accel Time', 's', time[0], time[1])}
|
||||
{@render info('Accel Distance', 'm', distance[0], distance[1])}
|
||||
{@render info('Time to Mid-Race', 's', midTime[0], midTime[1])}
|
||||
</div>
|
||||
<div class="mx-auto flex w-full flex-col place-items-center md:flex-row md:justify-center">
|
||||
{@render info('Skill Gain', 'L', gain[0], gain[1])}
|
||||
</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 max-w-4xl grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{@render seggsTable(slope < 0 ? 'Fastest Speed (Downhill)' : 'Fastest Speed', seggs[1])}
|
||||
{@render seggsTable('Slowest Speed', seggs[0])}
|
||||
</div>
|
||||
Reference in New Issue
Block a user