doc/fans: new article for career fan gain formula
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
<script lang="ts">
|
||||
import { asset, resolve } from '$app/paths';
|
||||
import CalcInfo from '../../race/CalcInfo.svelte';
|
||||
import Article from '../Article.svelte';
|
||||
import Sec from '../Sec.svelte';
|
||||
import * as fans from './fans';
|
||||
|
||||
let baseFans = $state(700);
|
||||
let fanBonus = $state(0);
|
||||
let finishOrder = $state(1);
|
||||
let margin = $state(0);
|
||||
let skills = $state(0);
|
||||
let glowsticks = $state(false);
|
||||
|
||||
const marginFans = $derived(fans.marginFans(baseFans, finishOrder, margin / 1.18));
|
||||
const skillFans = $derived(fans.skillFans(baseFans, skills));
|
||||
const glowstickBonus = $derived(glowsticks ? 50 : 0);
|
||||
const gainedFans = $derived(fans.gained(baseFans, marginFans, skillFans, fanBonus, glowstickBonus));
|
||||
|
||||
const marginType = $derived(finishOrder === 1 ? 'Win Margin' : 'Margin vs. Winner');
|
||||
|
||||
const orderPct = [
|
||||
[1, 100],
|
||||
[2, 40],
|
||||
[3, 25],
|
||||
[4, 15],
|
||||
[5, 10],
|
||||
[6, 9],
|
||||
[7, 8],
|
||||
[8, 7],
|
||||
[9, 6],
|
||||
[10, 5],
|
||||
[11, 4],
|
||||
[12, 3],
|
||||
[13, 2],
|
||||
[14, 2],
|
||||
[15, 1],
|
||||
[16, 1],
|
||||
[17, 1],
|
||||
[18, 1],
|
||||
] as const;
|
||||
|
||||
const marginBonus = [
|
||||
['≤3/4 L', 0, 9],
|
||||
['3/4 L – 2.5 L', 2, 6],
|
||||
['2.5 L – 4 L', 4, 4],
|
||||
['4 L – 6 L', 6, 2],
|
||||
['>6L', 9, 0],
|
||||
] as const;
|
||||
|
||||
const skillBonus = [
|
||||
['0', 0],
|
||||
['1-3', 2],
|
||||
['4-6', 3],
|
||||
['7-9', 5],
|
||||
['10+', 8],
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<Article>
|
||||
{#snippet head()}
|
||||
<Sec h={1} id="top" class="text-center pointer-coarse:mb-8">Career Fan Gain Is Solved</Sec>
|
||||
<p>
|
||||
Thanks to work by Moxa and Werseter on the Hakuraku Discord, a precise formula for fans gained from career races has been
|
||||
found.
|
||||
</p>
|
||||
<p>
|
||||
There is no random component to fan gain, except insofar as the race itself is random. It is now known to be entirely
|
||||
determined by five factors:
|
||||
</p>
|
||||
<ul class="mb-4 list-disc pl-4">
|
||||
<li>Base fans, according to the race and finish placement.</li>
|
||||
<li>Fan bonus from support cards.</li>
|
||||
<li>Margin of the win, or margin behind the winner if the player's trainee did not win.</li>
|
||||
<li>Number of skills the trainee activated during the race.</li>
|
||||
<li>In Trackblazer/MANT, use of glowsticks.</li>
|
||||
</ul>
|
||||
<p>
|
||||
The identified formula perfectly fits hundreds of recorded career race gains, including losses. Some questions remain, but
|
||||
the results so far have important implications both for fan runs and for low fan bonus decks for which debut can fall short
|
||||
of fan requirements for early career goals.
|
||||
</p>
|
||||
{/snippet}
|
||||
|
||||
<Sec h={2} id="calc-fans">Calculate Fans</Sec>
|
||||
<p>
|
||||
This tool will calculate your exact fan gain from a race. Due to some <a href="#margin">internal details</a>, the margin
|
||||
displayed in game may differ by up to a length from the margin used to calculate fans. (This calculator takes displayed
|
||||
margin, so its report can differ from actual gains in edge cases.)
|
||||
</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">
|
||||
<div>
|
||||
<label for="calc-base-fans">Base Fans</label>
|
||||
<input id="calc-base-fans" class="max-w-32" type="number" min="0" max="30000" step="100" bind:value={baseFans} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="calc-fan-bonus">Fan Bonus</label>
|
||||
<input id="calc-fan-bonus" class="max-w-32" type="number" min="0" max="150" step="5" bind:value={fanBonus} />
|
||||
</div>
|
||||
<div>
|
||||
<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} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="calc-margin">{marginType} (L)</label>
|
||||
<input id="calc-margin" class="max-w-32" type="number" min="0" step="0.25" bind:value={margin} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="calc-skills">Activated Skills</label>
|
||||
<input id="calc-skills" class="max-w-32" type="number" min="0" step="1" bind:value={skills} />
|
||||
</div>
|
||||
<div class="my-auto">
|
||||
<input id="calc-glowsticks" class="min-h-6 min-w-6 align-middle" type="checkbox" bind:checked={glowsticks} />
|
||||
<label for="calc-glowsticks">Glowsticks</label>
|
||||
</div>
|
||||
</form>
|
||||
<div class="grid grid-cols-1 md:grid-cols-12">
|
||||
<CalcInfo title="Fans from Margin" class="md:col-span-4 md:col-start-3">{marginFans}</CalcInfo>
|
||||
<CalcInfo title="Fans from Skills" class="md:col-span-4">{skillFans}</CalcInfo>
|
||||
<CalcInfo title="Fans Gained" class="md:col-span-4 md:col-start-5">{gainedFans}</CalcInfo>
|
||||
</div>
|
||||
|
||||
<Sec h={2} id="base-fans">Base Fans</Sec>
|
||||
<p>
|
||||
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
|
||||
finish order, every race currently follows the same curve for all placements.
|
||||
</p>
|
||||
<table class="mx-auto mb-4 border text-center">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="px-2">Placement</th>
|
||||
<th scope="col" class="px-2">Fan Ratio</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each orderPct as [o, p] (o)}
|
||||
<tr class="even:bg-mist-300 dark:even:bg-mist-900">
|
||||
<td>{o}</td>
|
||||
<td>{p}%</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Sec h={2} id="margin">Fans from Margin</Sec>
|
||||
<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>
|
||||
For the purpose of this calculation, "margin" is defined as the distance covered moving at 15 m/s since the time the first
|
||||
horse crossed the goal line. I.e., we observe the exact margin used to calculate fans from race replay data. The <i
|
||||
>displayed</i
|
||||
>
|
||||
margin in the game is
|
||||
<a
|
||||
href="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.p0458fiye6y9"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">scaled by a factor of 1.18</a
|
||||
>
|
||||
to derive the margin label (e.g. ½L instead of 0.7L). Because of this scaling and rounding, using the margin displayed in game
|
||||
to calculate fans may underestimate the gain, particularly for displayed margins of 2½L, 5L, or 6L.
|
||||
</p>
|
||||
<p>
|
||||
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
|
||||
margin.
|
||||
</p>
|
||||
<table class="mx-auto mb-4 border text-center">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="px-2">Actual<br />Margin Within</th>
|
||||
<th scope="col" class="px-2">Bonus for Winner</th>
|
||||
<th scope="col" class="px-2">Bonus for Podium</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each marginBonus as [m, w, l] (m)}
|
||||
<tr class="even:bg-mist-300 dark:even:bg-mist-900">
|
||||
<td>{m}</td>
|
||||
<td>{w}%</td>
|
||||
<td>{l}%</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Sec h={2} id="skills">Fans from Skill Activations</Sec>
|
||||
<p>The trainee receives a bonus percentage of base fans according to the number of skills she activated during the race.</p>
|
||||
<table class="mx-auto mb-4 border text-center">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="px-2">Activated Skills</th>
|
||||
<th scope="col" class="px-2">Bonus</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each skillBonus as [n, b] (n)}
|
||||
<tr class="even:bg-mist-300 dark:even:bg-mist-900">
|
||||
<td>{n}</td>
|
||||
<td>{b}%</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Sec h={2} id="rounding">Calculation Order and Rounding</Sec>
|
||||
<p>The calculation is perfomed as if in the following order.</p>
|
||||
<ol class="mb-4 list-decimal pl-4">
|
||||
<li>Calculate fans from win margin and round down.</li>
|
||||
<li>Calculate fans from skill activation count and round down.</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>(Trackblazer/MANT) If glowsticks were used for the race, apply their multiplier and round down.</li>
|
||||
</ol>
|
||||
|
||||
<Sec h={2} id="data">Data and Limitations</Sec>
|
||||
<p>
|
||||
The data for this research comes from 193 races and fan results dumped using <a
|
||||
href="https://werseter.github.io/umadump/"
|
||||
target="_blank"
|
||||
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
|
||||
bonus ranging from 55 to 105. The identified formula predicts the actual fan gain perfectly for all of the races. You can
|
||||
<a href={asset('/career_fan_gain_summary_193.csv')} download>download a summary</a>.
|
||||
</p>
|
||||
<p>
|
||||
There are some gaps in the data. Moxa's original data that was used to establish that the margin bonus uses actual rather than
|
||||
displayed margin was lost, so that conclusion is based on past discussion rather than available 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>
|
||||
@@ -0,0 +1,58 @@
|
||||
const margins = [
|
||||
[0.75, 0.0, 0.09],
|
||||
[2.5, 0.02, 0.06],
|
||||
[4, 0.04, 0.04],
|
||||
[6, 0.06, 0.02],
|
||||
[Infinity, 0.09, 0.0],
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 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 finishOrder Placement of the trainee
|
||||
* @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
|
||||
*/
|
||||
export function marginFans(baseFans: number, finishOrder: number, margin: number): number {
|
||||
if (finishOrder > 3) {
|
||||
return 0;
|
||||
}
|
||||
const [, wb, lb] = margins.find(([m]) => margin < m)!;
|
||||
const b = finishOrder === 1 ? wb : lb;
|
||||
return Math.floor(baseFans * b);
|
||||
}
|
||||
|
||||
const skills = [
|
||||
[0, 0.0],
|
||||
[3, 0.02],
|
||||
[6, 0.03],
|
||||
[9, 0.05],
|
||||
[Infinity, 0.08],
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Calculate the fans gained from a career race due to skills activated.
|
||||
* @param baseFans Base fans from the race and finish order
|
||||
* @param skillsActivated Number of skills the trainee activated during the race
|
||||
* @returns Fans gained due to skill activations
|
||||
*/
|
||||
export function skillFans(baseFans: number, skillsActivated: number): number {
|
||||
const [, b] = skills.find(([n]) => skillsActivated <= n)!;
|
||||
return Math.floor(baseFans * b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate fan gain from a career race.
|
||||
* @param baseFans Base fans from the race and finish order
|
||||
* @param marginFans Fans gained due to finish margin
|
||||
* @param skillFans Fans gained due to skill activations
|
||||
* @param fanBonus Total fan bonus from support cards
|
||||
* @param itemBonus Fan bonus from items (MANT glowsticks)
|
||||
* @returns Total fans gained
|
||||
*/
|
||||
export function gained(baseFans: number, marginFans: number, skillFans: number, fanBonus: number, itemBonus?: number): number {
|
||||
const f = baseFans + marginFans + skillFans;
|
||||
const d = Math.round(f * (1 + fanBonus / 100));
|
||||
const b = itemBonus ?? 0;
|
||||
return Math.floor(d * (1 + b / 100));
|
||||
}
|
||||
Reference in New Issue
Block a user