zenno: rebase stamina calculator & format

This commit is contained in:
2026-08-21 20:00:59 -04:00
parent a8ed0dbbe0
commit 0d3cf24714
3 changed files with 2161 additions and 614 deletions
+1674 -161
View File
File diff suppressed because it is too large Load Diff
+33 -33
View File
@@ -1,5 +1,5 @@
import * as math from 'mathjs';
import { Phase, type Stat, type Surface } from "./race";
import { Phase, type Stat, type Surface } from './race';
export interface Race {
location: RaceLocation;
@@ -69,7 +69,7 @@ function sortSlopes(slopes: Slope[]): Slope[] {
export function phases(raceLen: number): [number, number, number, number] {
const sixth = raceLen / 6;
const half = raceLen / 2;
return [sixth, sixth + half, 2*sixth + half, raceLen];
return [sixth, sixth + half, 2 * sixth + half, raceLen];
}
export interface SectionInfo {
@@ -82,30 +82,30 @@ export interface SectionInfo {
}
export const SECTIONS: ReadonlyArray<SectionInfo> = [
{section: 1, phase: Phase.EarlyRace, endRate: 1/24, posKeep: true, rush: false, spotStruggleEnter: false},
{section: 2, phase: Phase.EarlyRace, endRate: 2/24, posKeep: true, rush: true, spotStruggleEnter: true},
{section: 3, phase: Phase.EarlyRace, endRate: 3/24, posKeep: true, rush: true, spotStruggleEnter: true},
{section: 4, phase: Phase.EarlyRace, endRate: 4/24, posKeep: true, rush: true, spotStruggleEnter: true},
{section: 5, phase: Phase.MidRace, endRate: 5/24, posKeep: true, rush: true, spotStruggleEnter: true},
{section: 6, phase: Phase.MidRace, endRate: 6/24, posKeep: true, rush: true, spotStruggleEnter: false},
{section: 7, phase: Phase.MidRace, endRate: 7/24, posKeep: true, rush: true, spotStruggleEnter: false},
{section: 8, phase: Phase.MidRace, endRate: 8/24, posKeep: true, rush: true, spotStruggleEnter: false},
{section: 9, phase: Phase.MidRace, endRate: 9/24, posKeep: true, rush: true, spotStruggleEnter: false},
{section: 10, phase: Phase.MidRace, endRate: 10/24, posKeep: true, rush: false, spotStruggleEnter: false},
{section: 11, phase: Phase.MidRace, endRate: 11/24, posKeep: false, rush: false, spotStruggleEnter: false},
{section: 12, phase: Phase.MidRace, endRate: 12/24, posKeep: false, rush: false, spotStruggleEnter: false},
{section: 13, phase: Phase.MidRace, endRate: 13/24, posKeep: false, rush: false, spotStruggleEnter: false},
{section: 14, phase: Phase.MidRace, endRate: 14/24, posKeep: false, rush: false, spotStruggleEnter: false},
{section: 15, phase: Phase.MidRace, endRate: 15/24, posKeep: false, rush: false, spotStruggleEnter: false},
{section: 16, phase: Phase.MidRace, endRate: 16/24, posKeep: false, rush: false, spotStruggleEnter: false},
{section: 17, phase: Phase.LateRace, endRate: 17/24, posKeep: false, rush: false, spotStruggleEnter: false},
{section: 18, phase: Phase.LateRace, endRate: 18/24, posKeep: false, rush: false, spotStruggleEnter: false},
{section: 19, phase: Phase.LateRace, endRate: 19/24, posKeep: false, rush: false, spotStruggleEnter: false},
{section: 20, phase: Phase.LateRace, endRate: 20/24, posKeep: false, rush: false, spotStruggleEnter: false},
{section: 21, phase: Phase.LateRace, endRate: 21/24, posKeep: false, rush: false, spotStruggleEnter: false},
{section: 22, phase: Phase.LateRace, endRate: 22/24, posKeep: false, rush: false, spotStruggleEnter: false},
{section: 23, phase: Phase.LateRace, endRate: 23/24, posKeep: false, rush: false, spotStruggleEnter: false},
{section: 24, phase: Phase.LateRace, endRate: 24/24, posKeep: false, rush: false, spotStruggleEnter: false},
{ section: 1, phase: Phase.EarlyRace, endRate: 1 / 24, posKeep: true, rush: false, spotStruggleEnter: false },
{ section: 2, phase: Phase.EarlyRace, endRate: 2 / 24, posKeep: true, rush: true, spotStruggleEnter: true },
{ section: 3, phase: Phase.EarlyRace, endRate: 3 / 24, posKeep: true, rush: true, spotStruggleEnter: true },
{ section: 4, phase: Phase.EarlyRace, endRate: 4 / 24, posKeep: true, rush: true, spotStruggleEnter: true },
{ section: 5, phase: Phase.MidRace, endRate: 5 / 24, posKeep: true, rush: true, spotStruggleEnter: true },
{ section: 6, phase: Phase.MidRace, endRate: 6 / 24, posKeep: true, rush: true, spotStruggleEnter: false },
{ section: 7, phase: Phase.MidRace, endRate: 7 / 24, posKeep: true, rush: true, spotStruggleEnter: false },
{ section: 8, phase: Phase.MidRace, endRate: 8 / 24, posKeep: true, rush: true, spotStruggleEnter: false },
{ section: 9, phase: Phase.MidRace, endRate: 9 / 24, posKeep: true, rush: true, spotStruggleEnter: false },
{ section: 10, phase: Phase.MidRace, endRate: 10 / 24, posKeep: true, rush: false, spotStruggleEnter: false },
{ section: 11, phase: Phase.MidRace, endRate: 11 / 24, posKeep: false, rush: false, spotStruggleEnter: false },
{ section: 12, phase: Phase.MidRace, endRate: 12 / 24, posKeep: false, rush: false, spotStruggleEnter: false },
{ section: 13, phase: Phase.MidRace, endRate: 13 / 24, posKeep: false, rush: false, spotStruggleEnter: false },
{ section: 14, phase: Phase.MidRace, endRate: 14 / 24, posKeep: false, rush: false, spotStruggleEnter: false },
{ section: 15, phase: Phase.MidRace, endRate: 15 / 24, posKeep: false, rush: false, spotStruggleEnter: false },
{ section: 16, phase: Phase.MidRace, endRate: 16 / 24, posKeep: false, rush: false, spotStruggleEnter: false },
{ section: 17, phase: Phase.LateRace, endRate: 17 / 24, posKeep: false, rush: false, spotStruggleEnter: false },
{ section: 18, phase: Phase.LateRace, endRate: 18 / 24, posKeep: false, rush: false, spotStruggleEnter: false },
{ section: 19, phase: Phase.LateRace, endRate: 19 / 24, posKeep: false, rush: false, spotStruggleEnter: false },
{ section: 20, phase: Phase.LateRace, endRate: 20 / 24, posKeep: false, rush: false, spotStruggleEnter: false },
{ section: 21, phase: Phase.LateRace, endRate: 21 / 24, posKeep: false, rush: false, spotStruggleEnter: false },
{ section: 22, phase: Phase.LateRace, endRate: 22 / 24, posKeep: false, rush: false, spotStruggleEnter: false },
{ section: 23, phase: Phase.LateRace, endRate: 23 / 24, posKeep: false, rush: false, spotStruggleEnter: false },
{ section: 24, phase: Phase.LateRace, endRate: 24 / 24, posKeep: false, rush: false, spotStruggleEnter: false },
];
export interface SectionSlopeSegment extends SectionInfo {
@@ -117,20 +117,20 @@ export interface SectionSlopeSegment extends SectionInfo {
export function splitSectionSlopes(raceLen: number, slopes: Slope[]): SectionSlopeSegment[] {
const ss = sortSlopes(slopes);
return SECTIONS.flatMap<SectionSlopeSegment>((sec) => {
const startDist = (sec.section - 1) * raceLen / 24;
const endDist = sec.section * raceLen / 24;
const startDist = ((sec.section - 1) * raceLen) / 24;
const endDist = (sec.section * raceLen) / 24;
const s = ss.filter((sl) => sl.span[0] < endDist && sl.span[1] > startDist);
if (s.length === 0) {
return [{...sec, startDist, endDist, slope: 0}];
return [{ ...sec, startDist, endDist, slope: 0 }];
}
const r: Array<SectionSlopeSegment> = [{...sec, startDist, endDist: math.min(s[0].span[0], endDist), slope: 0}];
const r: Array<SectionSlopeSegment> = [{ ...sec, startDist, endDist: math.min(s[0].span[0], endDist), slope: 0 }];
for (const [i, sl] of s.entries()) {
const slopeEnd = math.min(endDist, sl.span[1]);
r.push(
{...sec, startDist: math.max(startDist, sl.span[0]), endDist: slopeEnd, slope: sl.per},
{...sec, startDist: slopeEnd, endDist: math.min(s[i+1]?.span[0] ?? endDist, endDist), slope: 0},
{ ...sec, startDist: math.max(startDist, sl.span[0]), endDist: slopeEnd, slope: sl.per },
{ ...sec, startDist: slopeEnd, endDist: math.min(s[i + 1]?.span[0] ?? endDist, endDist), slope: 0 },
);
}
return r.filter((sl) => sl.startDist < sl.endDist);
})
});
}
+62 -28
View File
@@ -31,7 +31,7 @@
let thresh2: race.Stat | undefined = $state();
const rushedOpts = ['No Rushed', 'Rushed Enabled', 'Rushed Forced'] as const;
let rushedType: typeof rushedOpts[number] = $state('No Rushed');
let rushedType: (typeof rushedOpts)[number] = $state('No Rushed');
let spotStruggleEnabled = $state(false);
const baseStats = $derived({
@@ -56,7 +56,14 @@
});
const adjStats = $derived({
[race.Stat.Speed]: race.adjustedStat(race.Stat.Speed, baseStats[race.Stat.Speed], isCareer, surface, conditions, thresholdMod),
[race.Stat.Speed]: race.adjustedStat(
race.Stat.Speed,
baseStats[race.Stat.Speed],
isCareer,
surface,
conditions,
thresholdMod,
),
[race.Stat.Stamina]: race.adjustedStat(race.Stat.Stamina, baseStats[race.Stat.Stamina], isCareer),
[race.Stat.Power]: race.adjustedStat(race.Stat.Power, baseStats[race.Stat.Power], isCareer, surface, conditions),
[race.Stat.Guts]: race.adjustedStat(race.Stat.Guts, baseStats[race.Stat.Guts], isCareer),
@@ -73,7 +80,13 @@
const distType = $derived(race.distance(raceLen));
const phaseSpeed = $derived({
[race.Phase.EarlyRace]: race.sectionSpeed(raceLen, baseStats[race.Stat.Wit], stats[race.Stat.Wit], style, race.Phase.EarlyRace),
[race.Phase.EarlyRace]: race.sectionSpeed(
raceLen,
baseStats[race.Stat.Wit],
stats[race.Stat.Wit],
style,
race.Phase.EarlyRace,
),
[race.Phase.MidRace]: race.sectionSpeed(raceLen, baseStats[race.Stat.Wit], stats[race.Stat.Wit], style, race.Phase.MidRace),
[race.Phase.LateRace]: [
race.spurtSpeed(stats[race.Stat.Speed], stats[race.Stat.Guts], style, distanceApt, raceLen),
@@ -81,14 +94,13 @@
] as [number, number],
});
const isFront = $derived(style === race.RunningStyle.FrontRunner || style === race.RunningStyle.GreatEscape);
const isFront = $derived(style === race.RunningStyle.FrontRunner || style === race.RunningStyle.Runaway);
const spotStruggle = $derived(spotStruggleEnabled && isFront);
const maxHP = $derived(race.maxHP(raceLen, style, stats[race.Stat.Stamina]));
const sections = $derived.by(() => {
const r = course.splitSectionSlopes(raceLen, slopesRaw)
.map((s) => {
const r = course.splitSectionSlopes(raceLen, slopesRaw).map((s) => {
const uphillMod = s.slope > 0 ? race.uphillMod(stats[race.Stat.Power], s.slope) : 0;
const speed = phaseSpeed[s.phase].map((v) => v + uphillMod);
const time = [(s.endDist - s.startDist) / speed[1], (s.endDist - s.startDist) / speed[0]];
@@ -96,7 +108,7 @@
time[0] * race.hpPerSecond(raceLen, surface, conditions, stats[race.Stat.Guts], s.phase, speed[0]),
time[1] * race.hpPerSecond(raceLen, surface, conditions, stats[race.Stat.Guts], s.phase, speed[1]),
];
return {...s, speed, time, hp, remain: [0, 0]};
return { ...s, speed, time, hp, remain: [0, 0] };
});
let remain = [maxHP, maxHP];
for (const v of r) {
@@ -104,18 +116,26 @@
v.remain = [...remain];
}
return r;
}
);
});
const spurtHPRate = $derived(race.hpPerSecond(raceLen, surface, conditions, stats[race.Stat.Guts], race.Phase.LateRace, phaseSpeed[race.Phase.LateRace][0]));
const spurtHPRate = $derived(
race.hpPerSecond(
raceLen,
surface,
conditions,
stats[race.Stat.Guts],
race.Phase.LateRace,
phaseSpeed[race.Phase.LateRace][0],
),
);
const needHP = $derived(race.fullSpurtHP(raceLen, phaseSpeed[race.Phase.LateRace][0], spurtHPRate));
const spurtStartHP = $derived(sections.findLast((s) => s.phase !== race.Phase.LateRace)?.remain);
let slopeAddParams: course.Slope = $state({per: 1, span: [0, 0]})
let slopeAddParams: course.Slope = $state({ per: 1, span: [0, 0] });
function addSlope() {
// TODO(zeph): validate no overlap with current slopes
slopesRaw.push(slopeAddParams);
slopeAddParams = {per: 1, span: [0, 0]};
slopeAddParams = { per: 1, span: [0, 0] };
}
function removeSlope(i: number) {
if (i < 0 || i >= slopesRaw.length) {
@@ -125,9 +145,9 @@
}
const phaseNames = {
[race.Phase.EarlyRace]: "Early",
[race.Phase.MidRace]: "Mid",
[race.Phase.LateRace]: "Late",
[race.Phase.EarlyRace]: 'Early',
[race.Phase.MidRace]: 'Mid',
[race.Phase.LateRace]: 'Late',
} as const;
</script>
@@ -173,7 +193,9 @@
</select>
</div>
<div class="m-4">
<label for="styleApt">{race.RUNNING_STYLES[style != race.RunningStyle.GreatEscape ? style : race.RunningStyle.FrontRunner][0]}</label>
<label for="styleApt"
>{race.RUNNING_STYLES[style != race.RunningStyle.Runaway ? style : race.RunningStyle.FrontRunner][0]}</label
>
<select class="w-full" id="styleApt" required bind:value={styleApt}>
{#each race.APTITUDE_LEVELS as apt (apt)}
<option value={apt}>{race.AptitudeLevel[apt]}</option>
@@ -184,10 +206,10 @@
<label for="isCareer" class="mr-1 align-middle">In Career</label>
<input type="checkbox" id="isCareer" role="switch" bind:checked={isCareer} class="min-h-6 min-w-6 align-middle" />
</div>
<div class="flex col-start-1 col-span-full">
<div class="grow h-px border-t place-self-center"></div>
<span class="flex-none mx-4">Skill Passives</span>
<div class="grow h-px border-t place-self-center"></div>
<div class="col-span-full col-start-1 flex">
<div class="h-px grow place-self-center border-t"></div>
<span class="mx-4 flex-none">Skill Passives</span>
<div class="h-px grow place-self-center border-t"></div>
</div>
{#each race.StatList as stat (stat)}
{@const statName = race.Stat[stat]}
@@ -197,9 +219,17 @@
</div>
{/each}
</div>
<div class="mx-auto mt-8 grid gap-4 max-w-5xl grid-cols-1 rounded-md text-center shadow-md ring md:grid-cols-6">
<div class="m-4 md:col-span-2 flex">
<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} />
<div class="mx-auto mt-8 grid max-w-5xl grid-cols-1 gap-4 rounded-md text-center shadow-md ring md:grid-cols-6">
<div class="m-4 flex md:col-span-2">
<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="my-auto pl-2 text-sm">{raceLen}m</span>
</div>
<select id="surface" class="m-4" bind:value={surface}>
@@ -230,7 +260,7 @@
</select>
</div>
</div>
<div class="mx-auto mt-8 p-4 grid grid-cols-2 gap-4 max-w-lg rounded-md text-center shadow-md ring">
<div class="mx-auto mt-8 grid max-w-lg grid-cols-2 gap-4 rounded-md p-4 text-center shadow-md ring">
<select id="rushed" class="w-full" bind:value={rushedType}>
{#each rushedOpts as opt (opt)}
<option>{opt}</option>
@@ -241,7 +271,7 @@
<input type="checkbox" class="min-h-6 min-w-6 align-middle" id="rushed" role="switch" bind:checked={spotStruggleEnabled} />
</div>
</div>
<div class="mx-auto mt-8 p-4 max-w-4xl rounded-md text-center shadow-md ring">
<div class="mx-auto mt-8 max-w-4xl rounded-md p-4 text-center shadow-md ring">
<!-- TODO(zeph): this doesn't work for mobile -->
<table class="w-full table-fixed border-spacing-4">
<caption>Slopes</caption>
@@ -260,7 +290,11 @@
<td>{slope.span[0]}</td>
<td>{slope.span[1]}</td>
<td>
<button type="button" class="p-2 w-24 bg-mist-300 hover:cursor-pointer dark:bg-mist-900" onclick={() => removeSlope(i)}>
<button
type="button"
class="w-24 bg-mist-300 p-2 hover:cursor-pointer dark:bg-mist-900"
onclick={() => removeSlope(i)}
>
Remove
</button>
</td>
@@ -284,7 +318,7 @@
<input type="number" id="slopeAddEnd" bind:value={slopeAddParams.span[1]} />
</td>
<td>
<button type="button" class="p-2 w-24 bg-mist-300 hover:cursor-pointer dark:bg-mist-900" onclick={addSlope}>
<button type="button" class="w-24 bg-mist-300 p-2 hover:cursor-pointer dark:bg-mist-900" onclick={addSlope}>
Add
</button>
</td>
@@ -293,7 +327,7 @@
</table>
</div>
<div class="mt-8 flex mx-auto max-w-3xl">
<div class="mx-auto mt-8 flex max-w-3xl">
<div class="m-2 flex-1 rounded-md border text-center shadow-sm transition-shadow hover:shadow-md">
<span class="block text-lg">Max HP</span>
<span class="block text-2xl">{Math.floor(maxHP)}</span>