Compare commits
5
Commits
7085fea1b7
...
0791fdd3d4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0791fdd3d4 | ||
|
|
e5f18d3c78 | ||
|
|
0d3cf24714 | ||
|
|
a8ed0dbbe0 | ||
|
|
84f4f8e358 |
@@ -49,6 +49,11 @@ export const PAGES = {
|
||||
name: 'Carryover Calculator',
|
||||
description: 'Calculate the importance of acceleration and speed carryover at the start of late race.',
|
||||
},
|
||||
{
|
||||
route: resolve('/race/stam'),
|
||||
name: 'Stamina Calculator',
|
||||
description: 'Compute stamina requirements and recovery probability distributions.',
|
||||
},
|
||||
] satisfies Page[],
|
||||
career: [
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
||||
import * as math from 'mathjs';
|
||||
import { Phase, type Stat, type Surface } from './race';
|
||||
|
||||
export interface Race {
|
||||
location: RaceLocation;
|
||||
length: number;
|
||||
surface: Surface;
|
||||
direction: Direction;
|
||||
spans: Span[];
|
||||
slopes: Slope[];
|
||||
thresholds: [] | [Stat] | [Stat, Stat];
|
||||
}
|
||||
|
||||
export enum RaceLocation {
|
||||
Sapporo = 'Sapporo',
|
||||
Hakodate = 'Hakodate',
|
||||
Niigata = 'Niigata',
|
||||
Fukushima = 'Fukushima',
|
||||
Nakayama = 'Nakayama',
|
||||
Tokyo = 'Tokyo',
|
||||
Chukyo = 'Chukyo',
|
||||
Kyoto = 'Kyoto',
|
||||
Hanshin = 'Hanshin',
|
||||
Kokura = 'Kokura',
|
||||
Ooi = 'Ooi',
|
||||
Kawasaki = 'Kawasaki',
|
||||
Funabashi = 'Funabashi',
|
||||
Morioka = 'Morioka',
|
||||
Longchamp = 'Longchamp',
|
||||
SantaAnitaPark = 'Santa Anita Park',
|
||||
DelMar = 'Del Mar',
|
||||
}
|
||||
|
||||
export enum Direction {
|
||||
Right,
|
||||
Left,
|
||||
Stretch,
|
||||
}
|
||||
|
||||
export enum SpanType {
|
||||
FrontStraight,
|
||||
AcrossStraight,
|
||||
FalseStraight,
|
||||
Corner1,
|
||||
Corner2,
|
||||
Corner3,
|
||||
Corner4,
|
||||
}
|
||||
|
||||
export interface Span {
|
||||
type: SpanType;
|
||||
span: [number, number];
|
||||
}
|
||||
|
||||
export interface Slope {
|
||||
per: -2 | -1.5 | -1 | 1 | 1.5 | 2;
|
||||
span: [number, number];
|
||||
}
|
||||
|
||||
function sortSlopes(slopes: Slope[]): Slope[] {
|
||||
return slopes.toSorted((a, b) => a.span[0] - b.span[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the distances into a race where each phase ends.
|
||||
* @param raceLen Length of the race in meters
|
||||
* @returns Quadruple of phase end points, including separate late and last spurt phases
|
||||
*/
|
||||
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];
|
||||
}
|
||||
|
||||
export interface SectionInfo {
|
||||
section: number;
|
||||
phase: Phase;
|
||||
endRate: number;
|
||||
posKeep: boolean;
|
||||
rush: boolean;
|
||||
spotStruggleEnter: boolean;
|
||||
}
|
||||
|
||||
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 },
|
||||
];
|
||||
|
||||
export interface SectionSlopeSegment extends SectionInfo {
|
||||
startDist: number;
|
||||
endDist: number;
|
||||
slope: -2 | -1.5 | -1 | 0 | 1 | 1.5 | 2;
|
||||
}
|
||||
|
||||
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 s = ss.filter((sl) => sl.span[0] < endDist && sl.span[1] > startDist);
|
||||
if (s.length === 0) {
|
||||
return [{ ...sec, startDist, 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 },
|
||||
);
|
||||
}
|
||||
return r.filter((sl) => sl.startDist < sl.endDist);
|
||||
});
|
||||
}
|
||||
@@ -354,6 +354,17 @@ export function maxHP(raceLen: number, style: RunningStyle, stamStat: number): n
|
||||
return 0.8 * hpStrategyCoeff[style] * stamStat + raceLen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the stamina stat that would produce a given effective HP
|
||||
* @param raceLen Length of the race in meters
|
||||
* @param style Horse's running style
|
||||
* @param hp Effective HP, i.e. max HP plus recoveries minus debuffs
|
||||
* @returns Stamina
|
||||
*/
|
||||
export function effectiveStam(raceLen: number, style: RunningStyle, hp: number): number {
|
||||
return (hp - raceLen) / (0.8 * hpStrategyCoeff[style]);
|
||||
}
|
||||
|
||||
const groundHPRateMod = {
|
||||
[Surface.Turf]: {
|
||||
[GroundConditions.Firm]: 1,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, test, describe } from 'vitest';
|
||||
import * as math from 'mathjs';
|
||||
import * as recovery from './recovery';
|
||||
|
||||
describe.for<recovery.SkillCount[]>([
|
||||
[],
|
||||
[{ r: 100, n: 1 }],
|
||||
[
|
||||
{ r: 100, n: 2 },
|
||||
{ r: 200, n: 2 },
|
||||
],
|
||||
[
|
||||
{ r: 100, n: 3 },
|
||||
{ r: 200, n: 3 },
|
||||
{ r: -400, n: 3 },
|
||||
],
|
||||
])('%j', (skills) => {
|
||||
describe.for<number>([300, 1000, 2000])('base wit %i', (baseWit) => {
|
||||
const base = recovery.distribution(baseWit, skills, []);
|
||||
test('is a pmf', () => {
|
||||
const p = math.sum([...base.values()]);
|
||||
expect(p).toBeCloseTo(1, 8);
|
||||
});
|
||||
test('withRisky is a pmf', () => {
|
||||
const p = math.sum([...recovery.withRisky(baseWit, base).values()]);
|
||||
expect(p).toBeCloseTo(1, 8);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import * as race from '$lib/race';
|
||||
|
||||
/**
|
||||
* Count of skills activated at a given recovery rate.
|
||||
*/
|
||||
export interface SkillCount {
|
||||
/** Recovery rate in ten thousandths. */
|
||||
r: number;
|
||||
/** Count of skills available. */
|
||||
n: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the distribution of HP rates after skill activations.
|
||||
* @param baseWit Uma's base wit stat
|
||||
* @param skills Skills activated by the uma, both recovery and self-damaging
|
||||
* @param debuffs Skills always applied to the uma
|
||||
* @returns Map of HP rate to P(HP rate)
|
||||
*/
|
||||
export function distribution(baseWit: number, skills: SkillCount[], debuffs: SkillCount[]): Map<number, number> {
|
||||
const debuff = debuffs.reduce((a, { r, n }) => a + r * n, 0);
|
||||
if (skills.length == 0) {
|
||||
return new Map([[debuff, 1]]);
|
||||
}
|
||||
const d = new Map<number, number>();
|
||||
const nn = skills.map(() => 0);
|
||||
while (true) {
|
||||
// Calculate the current combination of skills.
|
||||
const { r, p } = skills.reduce(
|
||||
(a, s, i) => ({
|
||||
r: a.r + s.r * nn[i],
|
||||
p: a.p * race.skillWitCheck(baseWit, s.n, nn[i]),
|
||||
}),
|
||||
{ r: debuff, p: 1 },
|
||||
);
|
||||
// All events are mutually exclusive since they're combinations of skills,
|
||||
// so we can combine by simple addition.
|
||||
d.set(r, p + (d.get(r) ?? 0));
|
||||
// Update counts for the next iteration.
|
||||
let i = 0;
|
||||
while (i < nn.length) {
|
||||
nn[i]++;
|
||||
if (nn[i] <= skills[i].n) {
|
||||
break;
|
||||
}
|
||||
nn[i] = 0;
|
||||
i++;
|
||||
}
|
||||
if (i >= nn.length) {
|
||||
// All skills finished.
|
||||
break;
|
||||
}
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the distribution of HP rates accounting for Risky Business
|
||||
* @param baseWit Uma's base wit stat
|
||||
* @param dist Map of HP rate to P(HP rate) as calculated by `distribution`
|
||||
* @returns Map of HP rate to P(HP rate) accounting for Risky Business/Nothing Ventured
|
||||
*/
|
||||
export function withRisky(baseWit: number, dist: Map<number, number>): Map<number, number> {
|
||||
// P(-0) = P(no activation OR activation AND -0) = (1-skillChance) + skillChance * 0.6,
|
||||
// P(-200) = P(activation AND -200) = skillChance * 0.3,
|
||||
// P(-400) = P(activation AND -400) = skillChance * 0.1.
|
||||
// This is a single independent skill, so we combine with the existing pmf via
|
||||
// P(r AND risky result) = P(r) * P(risky result).
|
||||
const s = race.skillWitCheck(baseWit);
|
||||
const p0 = 1 - s + s * 0.6;
|
||||
const p200 = s * 0.3;
|
||||
const p400 = s * 0.1;
|
||||
const n = new Map<number, number>();
|
||||
for (const [r, p] of dist.entries()) {
|
||||
n.set(r, p * p0 + (n.get(r) ?? 0));
|
||||
n.set(r - 200, p * p200 + (n.get(r - 200) ?? 0));
|
||||
n.set(r - 400, p * p400 + (n.get(r - 400) ?? 0));
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
<script lang="ts">
|
||||
import * as course from '$lib/course';
|
||||
import * as race from '$lib/race';
|
||||
import * as recovery from '$lib/racelib/recovery';
|
||||
import HPChart from './HPChart.svelte';
|
||||
import InfoPanels from './InfoPanels.svelte';
|
||||
|
||||
let rawStats = $state({
|
||||
[race.Stat.Speed]: 1200,
|
||||
[race.Stat.Stamina]: 1200,
|
||||
[race.Stat.Power]: 1200,
|
||||
[race.Stat.Guts]: 1200,
|
||||
[race.Stat.Wit]: 1200,
|
||||
});
|
||||
let style = $state(race.RunningStyle.FrontRunner);
|
||||
let surfaceApt = $state(race.AptitudeLevel.A);
|
||||
let distanceApt = $state(race.AptitudeLevel.S);
|
||||
let styleApt = $state(race.AptitudeLevel.A);
|
||||
let mood = $state(race.Mood.Normal);
|
||||
let isCareer = $state(false);
|
||||
let skillStatBonus = $state({
|
||||
[race.Stat.Speed]: 0,
|
||||
[race.Stat.Stamina]: 0,
|
||||
[race.Stat.Power]: 0,
|
||||
[race.Stat.Guts]: 0,
|
||||
[race.Stat.Wit]: 0,
|
||||
});
|
||||
|
||||
const recovRates = [35, 50, 150, 350, 550, 750, 950];
|
||||
const debuffRates = [-25, -50, -100, -200, -300];
|
||||
const selfDebuffRates = [-200, -400];
|
||||
let recovCounts = $state(recovRates.map((v) => ({ r: v, n: 0 })));
|
||||
let debuffCounts = $state(debuffRates.map((v) => ({ r: v, n: 0 })));
|
||||
let selfDebuffCounts = $state(selfDebuffRates.map((v) => ({ r: v, n: 0 })));
|
||||
let risky = $state(false);
|
||||
const skills = $derived([...recovCounts, ...selfDebuffCounts].filter(({ n }) => n > 0));
|
||||
|
||||
function rate(r: number): string {
|
||||
return `${r / 100}%`;
|
||||
}
|
||||
|
||||
let raceLen = $state(2000);
|
||||
let slopesRaw: course.Slope[] = $state([]);
|
||||
let surface = $state(race.Surface.Turf);
|
||||
let conditions = $state(race.GroundConditions.Firm);
|
||||
let thresh1: race.Stat | undefined = $state();
|
||||
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 spotStruggleEnabled = $state(false);
|
||||
|
||||
const baseStats = $derived({
|
||||
[race.Stat.Speed]: race.baseStat(mood, rawStats[race.Stat.Speed]),
|
||||
[race.Stat.Stamina]: race.baseStat(mood, rawStats[race.Stat.Stamina]),
|
||||
[race.Stat.Power]: race.baseStat(mood, rawStats[race.Stat.Power]),
|
||||
[race.Stat.Guts]: race.baseStat(mood, rawStats[race.Stat.Guts]),
|
||||
[race.Stat.Wit]: race.baseStat(mood, rawStats[race.Stat.Wit]),
|
||||
});
|
||||
|
||||
const thresholdMod = $derived.by(() => {
|
||||
if (thresh1 == null) {
|
||||
if (thresh2 == null) {
|
||||
return 1;
|
||||
}
|
||||
return race.thresholdMod(baseStats[thresh2]);
|
||||
}
|
||||
if (thresh2 == null) {
|
||||
return race.thresholdMod(baseStats[thresh1]);
|
||||
}
|
||||
return race.thresholdMod(baseStats[thresh1], baseStats[thresh2]);
|
||||
});
|
||||
|
||||
const adjStats = $derived({
|
||||
[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),
|
||||
[race.Stat.Wit]: race.adjustedStat(race.Stat.Wit, baseStats[race.Stat.Wit], isCareer, styleApt),
|
||||
});
|
||||
|
||||
const stats = $derived({
|
||||
[race.Stat.Speed]: race.finalStat(adjStats[race.Stat.Speed], skillStatBonus[race.Stat.Speed]),
|
||||
[race.Stat.Stamina]: race.finalStat(adjStats[race.Stat.Stamina], skillStatBonus[race.Stat.Stamina]),
|
||||
[race.Stat.Power]: race.finalStat(adjStats[race.Stat.Power], skillStatBonus[race.Stat.Power]),
|
||||
[race.Stat.Guts]: race.finalStat(adjStats[race.Stat.Guts], skillStatBonus[race.Stat.Guts]),
|
||||
[race.Stat.Wit]: race.finalStat(adjStats[race.Stat.Wit], skillStatBonus[race.Stat.Wit]),
|
||||
});
|
||||
|
||||
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.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),
|
||||
race.spurtSpeed(stats[race.Stat.Speed], stats[race.Stat.Guts], style, distanceApt, raceLen),
|
||||
] as [number, number],
|
||||
});
|
||||
|
||||
// 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 baseDistribution = $derived(recovery.distribution(baseStats[race.Stat.Wit], skills, debuffCounts));
|
||||
const distribution = $derived(risky ? recovery.withRisky(baseStats[race.Stat.Wit], baseDistribution) : baseDistribution);
|
||||
const statistics = $derived.by(() => {
|
||||
let mp = 0,
|
||||
mr = 0,
|
||||
mean = 0;
|
||||
for (const [r, p] of distribution) {
|
||||
mean += (r * p * maxHP) / 10000;
|
||||
if (p > mp) {
|
||||
mp = p;
|
||||
mr = r;
|
||||
}
|
||||
}
|
||||
return { mode: (mr * maxHP) / 10000, modePer: mp * 100, mean };
|
||||
});
|
||||
const effStam = $derived(race.effectiveStam(raceLen, style, maxHP + statistics.mode));
|
||||
|
||||
const sections = $derived.by(() => {
|
||||
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]];
|
||||
const hp = [
|
||||
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] };
|
||||
});
|
||||
let remain = [maxHP, maxHP];
|
||||
for (const v of r) {
|
||||
remain = [remain[0] - v.hp[0], remain[1] - v.hp[1]];
|
||||
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 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] });
|
||||
function addSlope() {
|
||||
// TODO(zeph): validate no overlap with current slopes
|
||||
slopesRaw.push(slopeAddParams);
|
||||
slopeAddParams = { per: 1, span: [0, 0] };
|
||||
}
|
||||
function removeSlope(i: number) {
|
||||
if (i < 0 || i >= slopesRaw.length) {
|
||||
return;
|
||||
}
|
||||
slopesRaw.splice(i, 1);
|
||||
}
|
||||
|
||||
// const phaseNames = {
|
||||
// [race.Phase.EarlyRace]: 'Early',
|
||||
// [race.Phase.MidRace]: 'Mid',
|
||||
// [race.Phase.LateRace]: 'Late',
|
||||
// } as const;
|
||||
</script>
|
||||
|
||||
<h1 class="text-4xl">Stamina Calculator</h1>
|
||||
<div class="mx-auto mt-8 grid max-w-5xl grid-cols-1 gap-2 rounded-md p-2 text-center shadow-md ring md:grid-cols-6">
|
||||
{#each race.StatList as stat (stat)}
|
||||
{@const statName = race.Stat[stat]}
|
||||
<div class="">
|
||||
<label for={statName}>{statName}</label>
|
||||
<input type="number" class="w-full" id={statName} required bind:value={rawStats[stat]} />
|
||||
</div>
|
||||
{/each}
|
||||
<div class="">
|
||||
<label for="mood">Mood</label>
|
||||
<select class="w-full" id="mood" required bind:value={mood}>
|
||||
{#each race.MOODS as mood (mood)}
|
||||
<option value={mood}>{race.Mood[mood]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="">
|
||||
<label for="style">Style</label>
|
||||
<select class="w-full" id="style" required bind:value={style}>
|
||||
{#each race.RUNNING_STYLES as [name, style] (style)}
|
||||
<option value={style}>{name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="">
|
||||
<label for="distanceApt">{race.Distance[distType]} Aptitude</label>
|
||||
<select class="w-full" id="distanceApt" required bind:value={distanceApt}>
|
||||
{#each race.APTITUDE_LEVELS as apt (apt)}
|
||||
<option value={apt}>{race.AptitudeLevel[apt]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="">
|
||||
<label for="surfaceApt">{race.Surface[surface]} Aptitude</label>
|
||||
<select class="w-full" id="surfaceApt" required bind:value={surfaceApt}>
|
||||
{#each race.APTITUDE_LEVELS as apt (apt)}
|
||||
<option value={apt}>{race.AptitudeLevel[apt]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="">
|
||||
<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>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class=" self-center">
|
||||
<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="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]}
|
||||
<div class="">
|
||||
<label for={`${statName}Bonus`}>{statName}</label>
|
||||
<input type="number" class="w-full" id={`${statName}Bonus`} required bind:value={skillStatBonus[stat]} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mx-auto my-2 grid max-w-4xl grid-cols-2 rounded-md text-center shadow-md ring md:grid-cols-7">
|
||||
<div class="col-span-full mt-1 text-lg">Recoveries</div>
|
||||
{#each recovCounts as s (s.r)}
|
||||
<div class="grid grid-cols-3 p-2 md:grid-cols-1 md:flex-col">
|
||||
<label for={`recov${s.r}`}>{rate(s.r)}</label>
|
||||
<input id={`recov${s.r}`} type="number" min="0" bind:value={s.n} class="col-span-2" />
|
||||
</div>
|
||||
{/each}
|
||||
<div class="col-span-full border-t pt-1 text-lg md:col-span-2">Self-Damage</div>
|
||||
<div class="col-span-full border-t border-l pt-1 text-lg md:col-span-5">Debuffs (No Wit Check)</div>
|
||||
{#each selfDebuffCounts as s (s.r)}
|
||||
<div class="grid grid-cols-3 p-2 md:grid-cols-1 md:flex-col">
|
||||
<label for={`selfdamage${s.r}`}>{rate(s.r)}</label>
|
||||
<input id={`selfdamage${s.r}`} type="number" min="0" bind:value={s.n} />
|
||||
</div>
|
||||
{/each}
|
||||
{#each debuffCounts as s, i (s.r)}
|
||||
<div class={{ 'grid grid-cols-3 p-2 md:grid-cols-1 md:flex-col': true, 'border-l': i === 0 }}>
|
||||
<label for={`debuff${s.r}`}>{rate(s.r)}</label>
|
||||
<input id={`debuff${s.r}`} type="number" min="0" bind:value={s.n} />
|
||||
</div>
|
||||
{/each}
|
||||
<div class="col-span-2 flex flex-row py-2">
|
||||
<label for="risky" class="mr-1 ml-auto">Risky Business</label>
|
||||
<input id="risky" type="checkbox" bind:checked={risky} class="mr-auto ml-1 h-6 w-6" />
|
||||
</div>
|
||||
<div class="border-l"><!-- only here for the border --></div>
|
||||
</div>
|
||||
<div class="mx-auto my-2 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}>
|
||||
<option value={race.Surface.Turf}>Turf</option>
|
||||
<option value={race.Surface.Dirt}>Dirt</option>
|
||||
</select>
|
||||
<select id="conditions" class="m-4" bind:value={conditions}>
|
||||
{#each race.GROUND_CONDITONS as cond (cond)}
|
||||
<option value={cond}>{race.GroundConditions[cond]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div class="m-4">
|
||||
<label for="thresh1">Threshold 1</label>
|
||||
<select id="thresh1" class="w-full" bind:value={thresh1}>
|
||||
<option value={undefined}></option>
|
||||
{#each race.StatList as stat (stat)}
|
||||
<option value={stat}>{race.Stat[stat]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="m-4">
|
||||
<label for="thresh2">Threshold 2</label>
|
||||
<select id="thresh2" class="w-full" bind:value={thresh2}>
|
||||
<option value={undefined}></option>
|
||||
{#each race.StatList as stat (stat)}
|
||||
<option value={stat}>{race.Stat[stat]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!--
|
||||
<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>
|
||||
{/each}
|
||||
</select>
|
||||
<div class="place-self-center">
|
||||
<label for="spotStruggle">Spot Struggle</label>
|
||||
<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 hidden 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>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Slope</th>
|
||||
<th scope="col">Start</th>
|
||||
<th scope="col">End</th>
|
||||
<th scope="col"><!-- empty --></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each slopesRaw as slope, i (slope.span[0])}
|
||||
<tr>
|
||||
<td>{slope.per}</td>
|
||||
<td>{slope.span[0]}</td>
|
||||
<td>{slope.span[1]}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="w-24 bg-mist-300 p-2 hover:cursor-pointer dark:bg-mist-900"
|
||||
onclick={() => removeSlope(i)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
<tr>
|
||||
<td>
|
||||
<select id="slopeAddPer" bind:value={slopeAddParams.per}>
|
||||
<option value={2}>Uphill +2</option>
|
||||
<option value={1.5}>Uphill +1.5</option>
|
||||
<option value={1}>Uphill +1</option>
|
||||
<option value={-1}>Downhill -1</option>
|
||||
<option value={-1.5}>Downhill -1.5</option>
|
||||
<option value={-2}>Downhill -2</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" id="slopeAddStart" bind:value={slopeAddParams.span[0]} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" id="slopeAddEnd" bind:value={slopeAddParams.span[1]} />
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="w-24 bg-mist-300 p-2 hover:cursor-pointer dark:bg-mist-900" onclick={addSlope}>
|
||||
Add
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<InfoPanels
|
||||
{maxHP}
|
||||
{needHP}
|
||||
{spurtStartHP}
|
||||
{effStam}
|
||||
showStats={distribution.size > 1}
|
||||
mode={statistics.mode}
|
||||
modePer={statistics.modePer}
|
||||
mean={statistics.mean}
|
||||
/>
|
||||
<!--
|
||||
<table class="mt-8 w-full border text-center">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="colgroup" colspan="4" class="border-x">Section Data</th>
|
||||
<th scope="colgroup" colspan={spotStruggle ? 2 : 1} class="border-x">Section State</th>
|
||||
<th scope="colgroup" colspan="4" class="border-x">Fastest</th>
|
||||
<th scope="colgroup" colspan="4" class="border-x">Slowest</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="col">Section</th>
|
||||
<th scope="col">Phase</th>
|
||||
<th scope="col" colspan="2">Span</th>
|
||||
<th scope="col">Slope</th>
|
||||
<th scope="col" class:hidden={!spotStruggle}>Spot Struggle</th>
|
||||
<th scope="col">Speed</th>
|
||||
<th scope="col">Time</th>
|
||||
<th scope="col">HP</th>
|
||||
<th scope="col">Remaining</th>
|
||||
<th scope="col">Speed</th>
|
||||
<th scope="col">Time</th>
|
||||
<th scope="col">HP</th>
|
||||
<th scope="col">Remaining</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sections as s (s.startDist)}
|
||||
<tr class="even:bg-mist-300 dark:even:bg-mist-900" class:font-bold={s.phase === race.Phase.LateRace}>
|
||||
<td>{s.section}</td>
|
||||
<td>{phaseNames[s.phase]}</td>
|
||||
<td>{s.startDist.toFixed(1)} m</td>
|
||||
<td>{s.endDist.toFixed(1)} m</td>
|
||||
<td>{s.slope}</td>
|
||||
<td class:hidden={!spotStruggle}></td>
|
||||
<td>{s.speed[1].toFixed(3)} m/s</td>
|
||||
<td>{s.time[1].toFixed(3)} s</td>
|
||||
<td>{s.hp[1].toFixed(1)}</td>
|
||||
<td>{s.remain[0].toFixed(1)}</td>
|
||||
<td>{s.speed[0].toFixed(3)} m/s</td>
|
||||
<td>{s.time[0].toFixed(3)} s</td>
|
||||
<td>{s.hp[0].toFixed(1)}</td>
|
||||
<td>{s.remain[1].toFixed(1)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
-->
|
||||
{#if skills.length != 0 || risky}
|
||||
<div class="mx-auto h-96 max-w-5xl">
|
||||
<HPChart {distribution} {maxHP} />
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import * as Plot from '@observablehq/plot';
|
||||
import type { Attachment } from 'svelte/attachments';
|
||||
|
||||
interface Props {
|
||||
distribution: Map<number, number>;
|
||||
maxHP: number;
|
||||
}
|
||||
|
||||
let { distribution, maxHP }: Props = $props();
|
||||
const vals = $derived([...distribution.entries()].map(([r, p]) => ({ r: (r * maxHP) / 1e4, p: p * 100 })));
|
||||
|
||||
let width = $state(1);
|
||||
let height = $state(1);
|
||||
const makeChart: Attachment = (el) => {
|
||||
$effect(() => {
|
||||
el?.firstChild?.remove();
|
||||
el?.append(
|
||||
Plot.plot({
|
||||
width,
|
||||
height,
|
||||
x: {
|
||||
label: 'HP Mod',
|
||||
line: true,
|
||||
},
|
||||
y: {
|
||||
label: 'Probability (%)',
|
||||
domain: [0, 100],
|
||||
line: true,
|
||||
},
|
||||
marks: [
|
||||
Plot.ruleX([0], { strokeOpacity: 0.5 }),
|
||||
Plot.link(vals, { x: 'r', y1: 0, y2: 'p', markerEnd: 'circle-stroke', strokeWidth: 3 }),
|
||||
Plot.tip(vals, Plot.pointerX({ x: 'r', y: 'p', className: 'plot-tip' })),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<div bind:clientWidth={width} bind:clientHeight={height} class="h-full w-full">
|
||||
<div role="img" {@attach makeChart}>
|
||||
<span>the chart seems to have didn't</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.plot-tip) {
|
||||
--plot-background: light-dark(var(--color-mist-200), var(--color-mist-800));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import CalcInfo from '../CalcInfo.svelte';
|
||||
|
||||
interface Props {
|
||||
maxHP: number;
|
||||
needHP: number;
|
||||
effStam: number;
|
||||
spurtStartHP?: number[];
|
||||
showStats: boolean;
|
||||
mode: number;
|
||||
modePer: number;
|
||||
mean: number;
|
||||
}
|
||||
|
||||
let { maxHP, needHP, effStam, spurtStartHP = [0, 0], showStats, mode, modePer, mean }: Props = $props();
|
||||
|
||||
const needHPPer = $derived((100 * needHP) / maxHP);
|
||||
const white = $derived(Math.trunc(maxHP * 0.015));
|
||||
const gold = $derived(Math.trunc(maxHP * 0.055));
|
||||
const goldDebuff = $derived(Math.trunc(maxHP * -0.03));
|
||||
</script>
|
||||
|
||||
<div class="mx-auto flex w-full flex-col place-items-center md:flex-row md:justify-center">
|
||||
<CalcInfo title="Max HP" class="max-w-72 flex-1">{Math.floor(maxHP)}</CalcInfo>
|
||||
<CalcInfo title="Spurt Threshold" class="max-w-72 flex-1">{Math.ceil(needHP)} ({needHPPer.toFixed(1)}%)</CalcInfo>
|
||||
<CalcInfo title="Spurt Start HP" class="hidden max-w-72 flex-1">
|
||||
{Math.ceil(spurtStartHP[1])} – {Math.ceil(spurtStartHP[0])}
|
||||
</CalcInfo>
|
||||
</div>
|
||||
<div class="mx-auto flex w-full flex-col place-items-center md:flex-row md:justify-center">
|
||||
<CalcInfo title="White Recovery" class="max-w-72 flex-1">+{white} HP</CalcInfo>
|
||||
<CalcInfo title="Gold Recovery" class="max-w-72 flex-1">+{gold} HP</CalcInfo>
|
||||
<CalcInfo title="Gold Debuff" class="max-w-72 flex-1">{goldDebuff} HP</CalcInfo>
|
||||
</div>
|
||||
{#if showStats}
|
||||
<div class="mx-auto flex w-full flex-col place-items-center md:flex-row md:justify-center">
|
||||
<CalcInfo title="Mode HP from Skills" class="max-w-72 flex-1">{Math.round(mode)} (P={modePer.toFixed(1)}%)</CalcInfo>
|
||||
<CalcInfo title="Mean HP from Skills" class="max-w-72 flex-1">{Math.round(mean)}</CalcInfo>
|
||||
<CalcInfo title="Effective Stamina" class="max-w-72 flex-1">{Math.ceil(effStam)}</CalcInfo>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user