zenno: stamina calculator first pass
This commit is contained in:
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user