zenno/doc/fans: new data coverage and fix off-by-ones
ci/woodpecker/push/horsebot Pipeline was successful
ci/woodpecker/push/zenno Pipeline failed

This commit is contained in:
2026-10-18 19:31:05 -04:00
parent 75558f35ba
commit 16a5433594
2 changed files with 70 additions and 40 deletions
+51 -36
View File
@@ -1,17 +1,18 @@
<script lang="ts"> <script lang="ts">
import { asset, resolve } from '$app/paths'; import { resolve } from '$app/paths';
import CalcInfo from '../../race/CalcInfo.svelte'; import CalcInfo from '../../race/CalcInfo.svelte';
import Article from '../Article.svelte'; import Article from '../Article.svelte';
import Sec from '../Sec.svelte'; import Sec from '../Sec.svelte';
import * as fans from './fans'; import * as fans from './fans';
let baseFans = $state(700); let winFans = $state(700);
let fanBonus = $state(0); let fanBonus = $state(0);
let finishOrder = $state(1); let finishOrder = $state(1);
let margin = $state(0); let margin = $state(0);
let skills = $state(0); let skills = $state(0);
let glowsticks = $state(false); let glowsticks = $state(false);
const baseFans = $derived(fans.baseFans(winFans, finishOrder));
const marginFans = $derived(fans.marginFans(baseFans, finishOrder, margin)); const marginFans = $derived(fans.marginFans(baseFans, finishOrder, margin));
const skillFans = $derived(fans.skillFans(baseFans, skills)); const skillFans = $derived(fans.skillFans(baseFans, skills));
const glowstickBonus = $derived(glowsticks ? 50 : 0); const glowstickBonus = $derived(glowsticks ? 50 : 0);
@@ -76,18 +77,21 @@
<li>In Trackblazer/MANT, use of glowsticks.</li> <li>In Trackblazer/MANT, use of glowsticks.</li>
</ul> </ul>
<p> <p>
The identified formula perfectly fits hundreds of recorded career race gains, including losses. Some questions remain, but The identified formula perfectly fits hundreds of recorded career races, including losses. These results have important
the results so far have important implications both for fan runs and for low fan bonus decks for which debut can fall short implications both for fan runs and for low fan bonus decks for which debut can fall short of fan requirements for early
of fan requirements for early career goals. career goals.
</p> </p>
{/snippet} {/snippet}
<Sec h={2} id="calc-fans">Calculate Fans</Sec> <Sec h={2} id="calc-fans">Calculate Fans</Sec>
<p>This tool will calculate your exact fan gain from a race.</p> <p>
This tool will calculate your exact fan gain from a race. Use the fan value displayed in the race selection menu for the Race
Fans field.
</p>
<form class="mx-auto mb-4 grid grid-cols-1 gap-2 rounded-md p-4 text-center shadow-md ring md:max-w-2xl md:grid-cols-3"> <form class="mx-auto mb-4 grid grid-cols-1 gap-2 rounded-md p-4 text-center shadow-md ring md:max-w-2xl md:grid-cols-3">
<div> <div>
<label for="calc-base-fans">Base Fans</label> <label for="calc-base-fans">Race Fans</label>
<input id="calc-base-fans" class="max-w-32" type="number" min="0" max="30000" step="100" bind:value={baseFans} /> <input id="calc-base-fans" class="max-w-32" type="number" min="0" max="30000" step="100" bind:value={winFans} />
</div> </div>
<div> <div>
<label for="calc-fan-bonus">Fan Bonus</label> <label for="calc-fan-bonus">Fan Bonus</label>
@@ -95,7 +99,11 @@
</div> </div>
<div> <div>
<label for="calc-finish-order">Finish Order</label> <label for="calc-finish-order">Finish Order</label>
<input id="calc-finish-order" class="max-w-32" type="number" min="1" max="18" step="1" bind:value={finishOrder} /> <select id="calc-finish-order" class="max-w-32" bind:value={finishOrder}>
{#each orderPct as [v] (v)}
<option value={v}>{v}</option>
{/each}
</select>
</div> </div>
<div> <div>
<label for="calc-margin">{marginType} (L)</label> <label for="calc-margin">{marginType} (L)</label>
@@ -110,17 +118,18 @@
<label for="calc-glowsticks">Glowsticks</label> <label for="calc-glowsticks">Glowsticks</label>
</div> </div>
</form> </form>
<div class="grid grid-cols-1 md:grid-cols-12"> <div class="mx-auto grid max-w-2xl grid-cols-1 md:grid-cols-3">
<CalcInfo title="Fans from Margin" class="md:col-span-4 md:col-start-3">{marginFans}</CalcInfo> <CalcInfo title="Base Fans">{baseFans}</CalcInfo>
<CalcInfo title="Fans from Skills" class="md:col-span-4">{skillFans}</CalcInfo> <CalcInfo title="Fans from Margin">{marginFans}</CalcInfo>
<CalcInfo title="Fans Gained" class="md:col-span-4 md:col-start-5">{gainedFans}</CalcInfo> <CalcInfo title="Fans from Skills">{skillFans}</CalcInfo>
<CalcInfo title="Fans Gained" class="md:col-start-2">{gainedFans}</CalcInfo>
</div> </div>
<Sec h={2} id="base-fans">Base Fans</Sec> <Sec h={2} id="base-fans">Base Fans</Sec>
<p> <p>
Base fans is a parameter per race and finish placement, defined in <a href={resolve('/doc/mdb')}>master data</a>. The number Base fans is a parameter per race and finish placement, defined in <a href={resolve('/doc/mdb')}>master data</a>. The number
shown in the career race selection menu is the base fans for first place. While it is defined separately for each race and shown in the career race selection menu is the base fans for first place. While it is defined separately for each race and
finish order, every race currently follows the same curve for all placements. finish order, every race currently follows the same curve for all placements, up to rounding.
</p> </p>
<table class="mx-auto mb-4 border text-center"> <table class="mx-auto mb-4 border text-center">
<thead> <thead>
@@ -140,14 +149,10 @@
</table> </table>
<Sec h={2} id="margin">Fans from Margin</Sec> <Sec h={2} id="margin">Fans from Margin</Sec>
<p> <p>When the trainee places in the top five, she receives a bonus percentage of base fans according to the finish margin.</p>
When the trainee places in the top three (top five?), she receives a bonus percentage of base fans according to the finish
margin.
</p>
<p> <p>
The bonus depends on whether the trainee won the race. If so, it is calculated by her margin versus second place. Otherwise, The bonus depends on whether the trainee won the race. If so, it is calculated by her margin versus second place. Otherwise,
her total margin behind the winner is used. If she did not place in the top three (top five?), then she gains no fans from her total margin behind the winner is used. If she did not place in the top five, then she gains no fans from margin.
margin.
</p> </p>
<table class="mx-auto mb-4 border text-center"> <table class="mx-auto mb-4 border text-center">
<thead> <thead>
@@ -190,34 +195,44 @@
<Sec h={2} id="rounding">Calculation Order and Rounding</Sec> <Sec h={2} id="rounding">Calculation Order and Rounding</Sec>
<p>The calculation is perfomed as if in the following order.</p> <p>The calculation is perfomed as if in the following order.</p>
<ol class="mb-4 list-decimal pl-4"> <ol class="mb-4 list-decimal pl-4">
<li>Calculate fans from win margin and round down.</li> <li>Look up base fans for the race and placement. This value is always an integer.</li>
<li>Calculate fans from skill activation count and round down.</li> <li>Calculate fans from win margin as a proportion of base fans and round down.</li>
<li>Calculate fans from skill activation count as a proportion of base fans and round down.</li>
<li>Calculate the sum of base fans, win margin fans, and skill activation fans.</li> <li>Calculate the sum of base fans, win margin fans, and skill activation fans.</li>
<li>Apply fan bonus to the sum and round to the nearest integer with halfway cases away from zero.</li> <li>Apply fan bonus to the sum in single-precision floating point, without otherwise rounding.</li>
<li>
Round to five significant figures with halfway cases rounded up, then round down. Examples are useful here.
<ul class="list-[revert] pl-8">
<li>
700 base plus 14 from margin with 88% fan bonus = <span class="inline-block">714&times;1.88</span> = 1342.32, rounded to five
significant figures becomes 1342.3, rounded down to 1342.
</li>
<li>
10500 base plus 210 from margin with 105% fan bonus =
<span class="inline-block">10710&times;2.05</span>
= 21955.5, rounded to five significant figures becomes 21956, rounded down to 21956.
</li>
<li>
100 base fans plus 2 fans from skills with 88% fan bonus = <span class="inline-block">102&times;1.88</span> = 191.76, rounded
to five significant figures becomes 191.76, rounded down to 191.
</li>
</ul>
</li>
<li>(Trackblazer/MANT) If glowsticks were used for the race, apply their multiplier and round down.</li> <li>(Trackblazer/MANT) If glowsticks were used for the race, apply their multiplier and round down.</li>
</ol> </ol>
<Sec h={2} id="data">Data and Limitations</Sec> <Sec h={2} id="data">Data</Sec>
<p> <p>
The data for this research comes from 193 races and fan results dumped using <a The data for this research comes from 325 races and fan results dumped using <a
href="https://werseter.github.io/umadump/" href="https://werseter.github.io/umadump/"
target="_blank" target="_blank"
rel="noopener noreferrer">Umadump</a rel="noopener noreferrer">Umadump</a
>. The races come from sixteen careers run between 19 Aug and 11 Oct 2026. These careers include five different values of race >. The races come from careers run between 19 Aug and 18 Oct 2026. These careers include fan bonus values of 50, 55, 65, 70,
bonus ranging from 55 to 105. The identified formula predicts the actual fan gain perfectly for all of the races. You can 80, 88, and 105. The identified formula predicts the actual fan gain perfectly for all of the races. You can
<a <a
href="https://docs.google.com/spreadsheets/d/1_jtOv6qk9dP4jJTagJqG53Pvj48IVd6gl2zKpyT6Kvs/edit?gid=463816108#gid=463816108" href="https://docs.google.com/spreadsheets/d/1_jtOv6qk9dP4jJTagJqG53Pvj48IVd6gl2zKpyT6Kvs/edit?gid=463816108#gid=463816108"
target="_blank" target="_blank"
rel="noopener noreferrer">view the data</a rel="noopener noreferrer">view the data</a
>. >.
</p> </p>
<p>
There are some gaps in the data. No collected race had a finish placement of 4th or 5th, nor lower than 6th, so the exact
details of the win margin fan bonus remain open. Similarly, no race featured a trainee who activated more than ten skills, so
it is unknown whether there are further tiers of skill activation count fan bonus.
</p>
<p>
Once these edge cases are covered, this article will be updated. If you'd like to contribute, please contact me
(@zephyrtronium) via Discord on the Hakuraku, uma.moe, GameTora, or EoG servers.
</p>
</Article> </Article>
+19 -4
View File
@@ -1,3 +1,15 @@
const placementPms = [1000, 400, 250, 150, 100, 90, 80, 70, 60, 50, 40, 30, 25, 20, 15, 10, 10, 10] as const;
/**
* Calculate base fans for a given placement in a race.
* @param winBase Base fans for a win, as displayed in the race menu
* @param finishOrder Placement of the trainee, with winner being 1
* @returns Base fans for the race and placement together
*/
export function baseFans(winBase: number, finishOrder: number): number {
return Math.round(winBase * placementPms[finishOrder - 1] / 1000);
}
const margins = [ const margins = [
[0.75, 0.0, 0.09], [0.75, 0.0, 0.09],
[2.5, 0.02, 0.06], [2.5, 0.02, 0.06],
@@ -9,15 +21,15 @@ const margins = [
/** /**
* Calculate fans gained from a career race due to the margin of the finish. * Calculate fans gained from a career race due to the margin of the finish.
* @param baseFans Base fans from the race and finish order * @param baseFans Base fans from the race and finish order
* @param finishOrder Placement of the trainee * @param finishOrder Placement of the trainee, with winner being 1
* @param margin Margin in lengths between the trainee and second if a win, or the winner and trainee if a loss * @param margin Margin in lengths between the trainee and second if a win, or the winner and trainee if a loss
* @returns Fans gained due to finish margin * @returns Fans gained due to finish margin
*/ */
export function marginFans(baseFans: number, finishOrder: number, margin: number): number { export function marginFans(baseFans: number, finishOrder: number, margin: number): number {
if (finishOrder > 3) { if (finishOrder > 5) {
return 0; return 0;
} }
const [, wb, lb] = margins.find(([m]) => margin < m)!; const [, wb, lb] = margins.find(([m]) => margin <= m)!;
const b = finishOrder === 1 ? wb : lb; const b = finishOrder === 1 ? wb : lb;
return Math.floor(baseFans * b); return Math.floor(baseFans * b);
} }
@@ -52,7 +64,10 @@ export function skillFans(baseFans: number, skillsActivated: number): number {
*/ */
export function gained(baseFans: number, marginFans: number, skillFans: number, fanBonus: number, itemBonus?: number): number { export function gained(baseFans: number, marginFans: number, skillFans: number, fanBonus: number, itemBonus?: number): number {
const f = baseFans + marginFans + skillFans; const f = baseFans + marginFans + skillFans;
const d = Math.round(f * (1 + fanBonus / 100)); const s = Math.fround(f * Math.fround(1 + fanBonus / 100));
const p = Math.exp((4 - Math.floor(Math.log10(s))) * Math.LN10);
// Add half a significant figure to account for precision.
const d = Math.floor(Math.round(s * p + 5e-6) / p + 5e-6);
const b = itemBonus ?? 0; const b = itemBonus ?? 0;
return Math.floor(d * (1 + b / 100)); return Math.floor(d * (1 + b / 100));
} }