zenno: stamina calculator first pass
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline was successful

This commit is contained in:
2026-09-03 06:51:47 -04:00
parent e5f18d3c78
commit 0791fdd3d4
7 changed files with 304 additions and 27 deletions
+5
View File
@@ -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: [
{
+11
View File
@@ -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,
+29
View File
@@ -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);
});
});
});
+80
View File
@@ -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;
}