From 0791fdd3d4755295d8df63ea3c75d4de64323cee Mon Sep 17 00:00:00 2001 From: Branden J Brown Date: Thu, 3 Sep 2026 06:48:55 -0400 Subject: [PATCH] zenno: stamina calculator first pass --- zenno/src/lib/all.ts | 5 + zenno/src/lib/race.ts | 11 ++ zenno/src/lib/racelib/recovery.spec.ts | 29 +++++ zenno/src/lib/racelib/recovery.ts | 80 +++++++++++++ zenno/src/routes/race/stam/+page.svelte | 113 ++++++++++++++----- zenno/src/routes/race/stam/HPChart.svelte | 52 +++++++++ zenno/src/routes/race/stam/InfoPanels.svelte | 41 +++++++ 7 files changed, 304 insertions(+), 27 deletions(-) create mode 100644 zenno/src/lib/racelib/recovery.spec.ts create mode 100644 zenno/src/lib/racelib/recovery.ts create mode 100644 zenno/src/routes/race/stam/HPChart.svelte create mode 100644 zenno/src/routes/race/stam/InfoPanels.svelte diff --git a/zenno/src/lib/all.ts b/zenno/src/lib/all.ts index 0e2c415..cdbf2ec 100644 --- a/zenno/src/lib/all.ts +++ b/zenno/src/lib/all.ts @@ -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: [ { diff --git a/zenno/src/lib/race.ts b/zenno/src/lib/race.ts index 0c67707..d2d2bb1 100644 --- a/zenno/src/lib/race.ts +++ b/zenno/src/lib/race.ts @@ -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, diff --git a/zenno/src/lib/racelib/recovery.spec.ts b/zenno/src/lib/racelib/recovery.spec.ts new file mode 100644 index 0000000..b1b08be --- /dev/null +++ b/zenno/src/lib/racelib/recovery.spec.ts @@ -0,0 +1,29 @@ +import { expect, test, describe } from 'vitest'; +import * as math from 'mathjs'; +import * as recovery from './recovery'; + +describe.for([ + [], + [{ 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([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); + }); + }); +}); diff --git a/zenno/src/lib/racelib/recovery.ts b/zenno/src/lib/racelib/recovery.ts new file mode 100644 index 0000000..53f95b6 --- /dev/null +++ b/zenno/src/lib/racelib/recovery.ts @@ -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 { + const debuff = debuffs.reduce((a, { r, n }) => a + r * n, 0); + if (skills.length == 0) { + return new Map([[debuff, 1]]); + } + const d = new Map(); + 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): Map { + // 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(); + 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; +} diff --git a/zenno/src/routes/race/stam/+page.svelte b/zenno/src/routes/race/stam/+page.svelte index edf0263..2559f48 100644 --- a/zenno/src/routes/race/stam/+page.svelte +++ b/zenno/src/routes/race/stam/+page.svelte @@ -1,6 +1,9 @@

Stamina Calculator

-
+
{#each race.StatList as stat (stat)} {@const statName = race.Stat[stat]} -
+
{/each} -
+
-
+
-
+
-
+
-
+
@@ -203,7 +235,7 @@ {/each}
-
+
@@ -214,13 +246,41 @@
{#each race.StatList as stat (stat)} {@const statName = race.Stat[stat]} -
+
{/each}
-
+
+
Recoveries
+ {#each recovCounts as s (s.r)} +
+ + +
+ {/each} +
Self-Damage
+
Debuffs (No Wit Check)
+ {#each selfDebuffCounts as s (s.r)} +
+ + +
+ {/each} + {#each debuffCounts as s, i (s.r)} +
+ + +
+ {/each} +
+ + +
+
+
+
-
-
- Max HP - {Math.floor(maxHP)} -
-
- Spurt Threshold - {Math.ceil(needHP)} ({needHPPer.toFixed(1)}%) -
-
- Late Race Start - {#if spurtStartHP != null} - {Math.ceil(spurtStartHP[1])} – {Math.ceil(spurtStartHP[0])} - {/if} -
-
+ 1} + mode={statistics.mode} + modePer={statistics.modePer} + mean={statistics.mean} +/> +{#if skills.length != 0 || risky} +
+ +
+{/if} diff --git a/zenno/src/routes/race/stam/HPChart.svelte b/zenno/src/routes/race/stam/HPChart.svelte new file mode 100644 index 0000000..34b668c --- /dev/null +++ b/zenno/src/routes/race/stam/HPChart.svelte @@ -0,0 +1,52 @@ + + +
+
+ the chart seems to have didn't +
+
+ + diff --git a/zenno/src/routes/race/stam/InfoPanels.svelte b/zenno/src/routes/race/stam/InfoPanels.svelte new file mode 100644 index 0000000..3116fb1 --- /dev/null +++ b/zenno/src/routes/race/stam/InfoPanels.svelte @@ -0,0 +1,41 @@ + + +
+ {Math.floor(maxHP)} + {Math.ceil(needHP)} ({needHPPer.toFixed(1)}%) + +
+
+ +{white} HP + +{gold} HP + {goldDebuff} HP +
+{#if showStats} +
+ {Math.round(mode)} (P={modePer.toFixed(1)}%) + {Math.round(mean)} + {Math.ceil(effStam)} +
+{/if}