diff options
| author | schneefux <schneefux+commit@schneefux.xyz> | 2018-06-16 20:47:59 +0200 |
|---|---|---|
| committer | schneefux <schneefux+commit@schneefux.xyz> | 2018-12-13 17:09:20 +0100 |
| commit | a8135ad40cbeb6c0c464d57010032dc033993439 (patch) | |
| tree | e9bf2c11e277b7b60bb58ab37818c0eb1c998574 /src | |
| parent | 37fda8b8f9d7a6cf812478cdcfb46a32b8e9d259 (diff) | |
| download | brokentalents-a8135ad40cbeb6c0c464d57010032dc033993439.tar.gz brokentalents-a8135ad40cbeb6c0c464d57010032dc033993439.zip | |
Calculate and show level scaling
Diffstat (limited to 'src')
| -rw-r--r-- | src/HeroTalentTable.vue | 29 | ||||
| -rw-r--r-- | src/Highlights.vue | 44 | ||||
| -rw-r--r-- | src/ReportService.js | 111 | ||||
| -rw-r--r-- | src/ReportTable.vue | 27 | ||||
| -rw-r--r-- | src/WinPickScatter.vue | 4 | ||||
| -rw-r--r-- | src/maps/maps.js | 18 |
6 files changed, 158 insertions, 75 deletions
diff --git a/src/HeroTalentTable.vue b/src/HeroTalentTable.vue index 0e4509b..5c3545b 100644 --- a/src/HeroTalentTable.vue +++ b/src/HeroTalentTable.vue @@ -8,18 +8,27 @@ :mobile-cards="false"> <template slot-scope="props"> <b-table-column field="Talent" label="Talent"> - <div style="display: flex; align-items: center; justify-content: space-between;"> - <span>{{ getTalentName(props.row.Talent) }}</span> - <talent-image :entry="props.row" :size="48"></talent-image> - </div> + <talent-image :entry="props.row" :size="48"></talent-image> </b-table-column> - <b-table-column field="Winner" label="Win Rate" sortable numeric> - <span class="tag is-warning mdi mdi-gauge-empty mdi-18px" - title="Very low pick rate" - v-if="100 * playersPerMatch * props.row.Count / totalPicks < 0.5"></span> - {{ Math.round(100 * props.row.Winner) }}% + <b-table-column field="Winner" label="Level 1 Win Rate" sortable numeric> + <template v-if="isNaN(props.row.TalentWinrateBase)"> + {{ (100 * props.row.Winner).toFixed(2) }}% + </template> + <template v-else> + {{ (100 * props.row.TalentWinrateBase).toFixed(2) }}% + </template> </b-table-column> + + <b-table-column field="TalentWinrateScaling" label="Win Rate Advantage" sortable numeric> + <template v-if="isNaN(props.row.TalentWinrateScaling) || props.row.SampleTooSmall"> + <span class="mdi mdi-gauge-empty mdi-18px" title="Not enough data"></span> + </template> + <template v-else> + {{ (props.row.TalentWinrateScaling > 0? '+' : '') + (100 * props.row.TalentWinrateScaling / getLevelBuckets()).toFixed(2) }}% <small>for {{ getLevelsPerBucket(props.row) + (getLevelsPerBucket(props.row) > 1? ' Levels' : ' Level') }}</small> + </template> + </b-table-column> + </template> </b-table> </div> @@ -38,6 +47,8 @@ export default Vue.component('hero-talent-table', { data: function() { return { getTalentName: maps.getTalentName, + getLevelBuckets: ReportService.getLevelBuckets, + getLevelsPerBucket: (entry) => maps.getMaxLevel(entry) / this.getLevelBuckets(), }; }, computed: { diff --git a/src/Highlights.vue b/src/Highlights.vue index b074b70..0aa8a5f 100644 --- a/src/Highlights.vue +++ b/src/Highlights.vue @@ -5,7 +5,7 @@ title="Overpowered" type="Win Rate" label="%" - :value="Math.round(100 * stats.topWin.Winner)" + :value="Math.round(100 * stats.topWin.TotalWinner)" :entry="stats.topWin" /> </div> @@ -18,29 +18,31 @@ :entry="stats.topPick" /> </div> - <div class="column" v-if="!!stats.topUnpopularWin"> + <div class="column" v-if="!!stats.topLowLevel"> <talent-box class="is-dark notification" - title="Hidden Gem" - type="Win Rate" + title="Beginner" + type="Level 1 Win Rate" label="%" - :value="Math.round(100 * stats.topUnpopularWin.Winner)" - :entry="stats.topUnpopularWin" /> + :value="Math.round(100 * stats.topLowLevel.Winner)" + :entry="stats.topLowLevel" /> </div> - <div class="column" v-if="!!stats.highestLevelAvg"> + <div class="column" v-if="!!stats.topScaling"> <talent-box class="is-dark notification" - title="Highest Level" - type="Average Level" - :value="stats.highestLevelAvg.Level.toFixed(2)" - :entry="stats.highestLevelAvg" /> + title="Spender" + type="Win Rate per Level" + label="%" + :value="'+' + Math.round(100 * stats.topScaling.TalentWinrateLevelScaling)" + :entry="stats.topScaling" /> </div> - <div class="column" v-if="!!stats.lowestLevelAvg"> + <div class="column" v-if="!!stats.topUnpopularWin"> <talent-box class="is-dark notification" - title="Lowest Level" - type="Average Level" - :value="stats.lowestLevelAvg.Level.toFixed(2)" - :entry="stats.lowestLevelAvg" /> + title="Hidden Gem" + type="Win Rate" + label="%" + :value="Math.round(100 * stats.topUnpopularWin.TotalWinner)" + :entry="stats.topUnpopularWin" /> </div> <div class="column" v-if="!!stats.topRareWin"> @@ -48,7 +50,7 @@ title="Best Rare" type="Win Rate" label="%" - :value="Math.round(100 * stats.topRareWin.Winner)" + :value="Math.round(100 * stats.topRareWin.TotalWinner)" :entry="stats.topRareWin" /> </div> @@ -57,7 +59,7 @@ title="Best Epic" type="Win Rate" label="%" - :value="Math.round(100 * stats.topEpicWin.Winner)" + :value="Math.round(100 * stats.topEpicWin.TotalWinner)" :entry="stats.topEpicWin" /> </div> @@ -66,7 +68,7 @@ title="Best Legendary" type="Win Rate" label="%" - :value="Math.round(100 * stats.topLegendaryWin.Winner)" + :value="Math.round(100 * stats.topLegendaryWin.TotalWinner)" :entry="stats.topLegendaryWin" /> </div> @@ -89,11 +91,11 @@ export default Vue.component('highlights', { topUnpopularWin: ReportService.getTopUnpopularWin(this.selectedMode), topPick: ReportService.getTopPick(this.selectedMode), totalPicks: ReportService.getTotalPicks(this.selectedMode), - highestLevelAvg: ReportService.getHighestLevelAvg(this.selectedMode), topRareWin: ReportService.getTopRareWins(this.selectedMode), topEpicWin: ReportService.getTopEpicWins(this.selectedMode), topLegendaryWin: ReportService.getTopLegendaryWins(this.selectedMode), - lowestLevelAvg: ReportService.getLowestLevelAvg(this.selectedMode), + topLowLevel: ReportService.getTopLowLevel(this.selectedMode), + topScaling: ReportService.getTopScaling(this.selectedMode), }; }, playersPerMatch: function() { diff --git a/src/ReportService.js b/src/ReportService.js index e0bd299..8bd9514 100644 --- a/src/ReportService.js +++ b/src/ReportService.js @@ -1,9 +1,10 @@ import * as maps from './maps/maps'; -import * as metadata from '../data/78f0edf0/metadata.json'; +import * as metadata from '../data/2d73896c/metadata.json'; const POPULAR_THRESHOLD = 1.0; // percent +const PICKS_THRESHOLD = 200; // picks -const report = require('../data/78f0edf0/report.json') +const report = require('../data/2d73896c/report.json') .filter((entry) => entry.Actor != undefined); // bad data from API downtime const reports = new Map(); @@ -14,45 +15,95 @@ const topWins = new Map(); const topRareWins = new Map(); const topEpicWins = new Map(); const topLegendaryWins = new Map(); -const topLeveledUp = new Map(); -const topLeveledDown = new Map(); +const topLowLevel = new Map(); +const topScaling = new Map(); const topUnpopularWins = new Map(); let totalMatches = 0; let actors = []; const modes = metadata.config.api.modes; for(let mode of modes) { - const relevancy = (entry) => (entry.Count / totalPicks.get(mode)) * entry.Winner; + const relevancy = (entry) => (entry.TotalPicks / totalPicks.get(mode)) * entry.TotalWinner; const playersPerMatch = maps.playersPerMatch(mode); - reports.set(mode, report.filter((entry) => entry.Mode == mode)); - totalPicks.set(mode, report.map((entry) => entry.Count).reduce((agg, cur) => agg + cur, 0)); + const reportFilterMode = report.filter((entry) => entry.Mode == mode); + + const baseLevelForEntry = (entry) => reportFilterMode + .filter((entry2) => entry2.Actor == entry.Actor && + entry2.Talent == entry.Talent) + .map((entry2) => entry2.LevelBucket) + .reduce((agg, cur) => cur < agg? cur : agg); + + const reportFilterModeBaseLevel = reportFilterMode.filter((entry) => entry.LevelBucket == baseLevelForEntry(entry)); + + // using the "base level" (bucket 0) report, add information about diff to 'NoTalent' and scaling + const reportFilterModeRegressed = reportFilterModeBaseLevel.map((entry) => { + const sum = (agg, cur) => agg + cur; + const range = (n) => [...Array(n).keys()]; + + // perform a weighted linear regression. x: level bucket (scaled 0 - 1) y: win rate weights: picks + const actorReport = reportFilterMode + .filter((entry2) => + entry2.Actor == entry.Actor && + entry2.Talent == entry.Talent && + entry2.LevelBucket >= 0); + const buckets = actorReport.map((entry) => entry.LevelBucket).sort(); + const findActorWithBucket = (bucket) => actorReport.find((entry) => entry.LevelBucket == bucket); + const weights = buckets.map((bucket) => findActorWithBucket(bucket).Count); + const sum_weights = weights.reduce(sum, 0); + const xs = [].concat(...buckets.map((bucket, index) => range(weights[index]).map((_) => bucket / metadata.config.self.levelBuckets))); + const ys = [].concat(...buckets.map((bucket, index) => range(weights[index]).map((_) => findActorWithBucket(bucket).Winner))); + const n = xs.length; + const sum_x = xs.reduce(sum, 0); + const sum_y = ys.reduce(sum, 0); + const sum_xy = xs.map((x, index) => x * ys[index]).reduce(sum, 0); + const sum_xx = xs.map((x) => x * x).reduce(sum, 0); + const sum_yy = ys.map((y) => y * y).reduce(sum, 0); + + const slope = (n * sum_xy - sum_x * sum_y) / (n * sum_xx - sum_x * sum_x); + const intercept = (sum_y - slope * sum_x) / n; + + return Object.assign({}, entry, { + TalentWinrateBase: intercept, + TalentWinrateScaling: slope, // to 1 = max level + TalentWinrateLevelScaling: slope / maps.getMaxLevel(entry), + TotalPicks: sum_weights || entry.Count, // NoTalent has no weights + TotalWinner: sum_y / sum_weights || entry.Winner, + SampleTooSmall: sum_weights < PICKS_THRESHOLD, + }); + }); + reports.set(mode, reportFilterModeRegressed); + + totalPicks.set(mode, reports.get(mode) + .map((entry) => entry.TotalPicks) + .reduce((agg, cur) => agg + cur, 0)); totalMatches += totalPicks.get(mode) / maps.playersPerMatch(mode); top10Relevancy.set(mode, reports.get(mode) .sort((entry1, entry2) => relevancy(entry2) - relevancy(entry1)) .slice(0, 10)); - topPicks.set(mode, reports.get(mode).sort((entry1, entry2) => entry2.Count - entry1.Count)[0]); + topPicks.set(mode, reports.get(mode).sort((entry1, entry2) => entry2.TotalPicks - entry1.TotalPicks)[0]); topWins.set(mode, reports.get(mode) - .filter((entry) => 100 * playersPerMatch * entry.Count / totalPicks.get(mode) > POPULAR_THRESHOLD) - .sort((entry1, entry2) => entry2.Winner - entry1.Winner)[0]); + .filter((entry) => 100 * playersPerMatch * entry.TotalPicks / totalPicks.get(mode) > POPULAR_THRESHOLD) + .sort((entry1, entry2) => entry2.TotalWinner - entry1.TotalWinner)[0]); topUnpopularWins.set(mode, reports.get(mode) - .filter((entry) => 100 * playersPerMatch * entry.Count / totalPicks.get(mode) <= POPULAR_THRESHOLD) - .sort((entry1, entry2) => entry2.Winner - entry1.Winner)[0]); + .filter((entry) => 100 * playersPerMatch * entry.TotalPicks / totalPicks.get(mode) <= POPULAR_THRESHOLD && + entry.TotalPicks > PICKS_THRESHOLD) + .sort((entry1, entry2) => entry2.TotalWinner - entry1.TotalWinner)[0]); topRareWins.set(mode, reports.get(mode) - .filter((entry) => maps.getTalentRarity(entry.Talent) == 'Rare' && 100 * playersPerMatch * entry.Count / totalPicks.get(mode)) - .sort((entry1, entry2) => entry2.Winner - entry1.Winner)[0]); + .filter((entry) => maps.getTalentRarity(entry.Talent) == 'Rare' && 100 * playersPerMatch * entry.TotalPicks / totalPicks.get(mode)) + .sort((entry1, entry2) => entry2.TotalWinner - entry1.TotalWinner)[0]); topEpicWins.set(mode, reports.get(mode) - .filter((entry) => maps.getTalentRarity(entry.Talent) == 'Epic' && 100 * playersPerMatch * entry.Count / totalPicks.get(mode)) - .sort((entry1, entry2) => entry2.Winner - entry1.Winner)[0]); + .filter((entry) => maps.getTalentRarity(entry.Talent) == 'Epic' && 100 * playersPerMatch * entry.TotalPicks / totalPicks.get(mode)) + .sort((entry1, entry2) => entry2.TotalWinner - entry1.TotalWinner)[0]); topLegendaryWins.set(mode, reports.get(mode) - .filter((entry) => maps.getTalentRarity(entry.Talent) == 'Legendary' && 100 * playersPerMatch * entry.Count / totalPicks.get(mode)) - .sort((entry1, entry2) => entry2.Winner - entry1.Winner)[0]); - topLeveledUp.set(mode, reports.get(mode) - .filter((entry) => maps.getTalentRarity(entry.Talent) != 'None') - .sort((entry1, entry2) => maps.getScaledLevel(entry2) - maps.getScaledLevel(entry1))[0]); - topLeveledDown.set(mode, reports.get(mode) - .filter((entry) => maps.getTalentRarity(entry.Talent) != 'None') - .sort((entry1, entry2) => maps.getScaledLevel(entry1) - maps.getScaledLevel(entry2))[0]); + .filter((entry) => maps.getTalentRarity(entry.Talent) == 'Legendary' && 100 * playersPerMatch * entry.TotalPicks / totalPicks.get(mode)) + .sort((entry1, entry2) => entry2.TotalWinner - entry1.TotalWinner)[0]); + topLowLevel.set(mode, reports.get(mode) + .filter((entry) => !entry.SampleTooSmall) + .sort((entry1, entry2) => entry2.TalentWinrateBase - entry1.TalentWinrateBase)[0]); + topScaling.set(mode, reports.get(mode) + .filter((entry) => !entry.SampleTooSmall) + .sort((entry1, entry2) => entry2.TalentWinrateLevelScaling - entry1.TalentWinrateLevelScaling)[0]); if (actors.length == 0) { actors = [...new Set(reports.get(mode).map((entry) => entry.Actor))]; @@ -76,12 +127,12 @@ export default { return top10Relevancy.get(mode); }, - getHighestLevelAvg(mode) { - return topLeveledUp.get(mode); + getTopLowLevel(mode) { + return topLowLevel.get(mode); }, - getLowestLevelAvg(mode) { - return topLeveledDown.get(mode); + getTopScaling(mode) { + return topScaling.get(mode); }, getTopWin(mode) { @@ -123,4 +174,8 @@ export default { getLastUpdate() { return metadata.lastUpdate; }, + + getLevelBuckets() { + return metadata.config.self.levelBuckets; + }, }; diff --git a/src/ReportTable.vue b/src/ReportTable.vue index 75a1369..4cbc280 100644 --- a/src/ReportTable.vue +++ b/src/ReportTable.vue @@ -1,7 +1,7 @@ <template> <section> <b-table :data="report" - :default-sort="['Count', 'desc']" + :default-sort="['TotalPicks', 'desc']" :default-sort-directon="'desc'" :paginated="true"> <template slot-scope="props"> @@ -39,21 +39,26 @@ </div> </b-table-column> - <b-table-column field="Level" label="Average Level" :visible="hasTalents" sortable numeric> - <template v-if="!!props.row.Level"> - {{ props.row.Level.toFixed(2) }} + <b-table-column field="Winner" label="Level 1 Win Rate" sortable numeric> + <template v-if="isNaN(props.row.TalentWinrateBase) || props.row.SampleTooSmall"> + {{ (100 * props.row.Winner).toFixed(2) }}% </template> - <template v-if="!props.row.Level"> - 0 + <template v-else> + {{ (100 * props.row.TalentWinrateBase).toFixed(2) }}% </template> </b-table-column> - <b-table-column field="Winner" label="Win Rate" sortable numeric> - {{ (100 * props.row.Winner).toFixed(2) }}% + <b-table-column field="TalentWinrateScaling" label="Win Rate Advantage" sortable numeric> + <template v-if="isNaN(props.row.TalentWinrateScaling) || props.row.SampleTooSmall"> + <span class="mdi mdi-gauge-empty mdi-18px" title="Not enough data"></span> + </template> + <template v-else> + {{ (props.row.TalentWinrateScaling > 0? '+' : '') + (100 * props.row.TalentWinrateScaling / getLevelBuckets()).toFixed(2) }}% <small>for {{ getLevelsPerBucket(props.row) + (getLevelsPerBucket(props.row) > 1? ' Levels' : ' Level') }}</small> + </template> </b-table-column> - <b-table-column field="Count" label="Pick Rate" sortable numeric> - {{ (100 * playersPerMatch * props.row.Count / totalPicks).toFixed(2) }}% + <b-table-column field="TotalPicks" label="Pick Rate" sortable numeric> + {{ (100 * playersPerMatch * props.row.TotalPicks / totalPicks).toFixed(2) }}% </b-table-column> </template> </b-table> @@ -74,6 +79,8 @@ export default Vue.component('report-table', { getTalentName: maps.getTalentName, getTalentRarityIndex: maps.getTalentRarityIndex, getHero: maps.getHero, + getLevelBuckets: ReportService.getLevelBuckets, + getLevelsPerBucket: (entry) => maps.getMaxLevel(entry) / this.getLevelBuckets(), }; }, computed: { diff --git a/src/WinPickScatter.vue b/src/WinPickScatter.vue index f2d495c..7c2f906 100644 --- a/src/WinPickScatter.vue +++ b/src/WinPickScatter.vue @@ -33,8 +33,8 @@ export default Vue.component('win-pick-scatter', { const dataWithRarity = (rarity) => data.filter((entry) => maps.getTalentRarity(entry.Talent) == rarity); const traces = maps.RARITIES.map((rarity) => ({ - x: dataWithRarity(rarity).map((entry) => entry.Winner), - y: dataWithRarity(rarity).map((entry) => maps.playersPerMatch(this.selectedMode) * entry.Count / total), + x: dataWithRarity(rarity).map((entry) => entry.TotalWinner || entry.Winner), + y: dataWithRarity(rarity).map((entry) => maps.playersPerMatch(this.selectedMode) * (entry.TotalCount || entry.Count) / total), text: dataWithRarity(rarity).map((entry) => maps.getHero(entry.Actor)), name: rarity, mode: 'markers', diff --git a/src/maps/maps.js b/src/maps/maps.js index 08ce779..ce91c24 100644 --- a/src/maps/maps.js +++ b/src/maps/maps.js @@ -28,7 +28,7 @@ function getTalentName(talent) { } function getTalentRarity(talent) { - if (talent == 'NoTalent') { + if (!talent || talent == 'NoTalent') { return 'None'; } @@ -45,14 +45,21 @@ function getTalentRarity(talent) { } } -function getScaledLevel(entry) { +function getMaxLevel(entry) { switch (getTalentRarity(entry.Talent)) { case 'unknown': case 'None': return -1; - case 'Rare': return entry.Level / 20; - case 'Epic': return entry.Level / 10; - case 'Legendary': return entry.Level / 5; + case 'Rare': return 20; + case 'Epic': return 10; + case 'Legendary': return 5; + } +}; + +function getScaledLevel(entry) { + if (['unknown', 'None'].includes(getTalentRarity(entry.Talent))) { + return 0; } + return entry.Level / getMaxLevel(entry); }; function getHero(actor) { @@ -75,6 +82,7 @@ module.exports = { RARITIES, getTalentName, getTalentRarity, + getMaxLevel, getScaledLevel, getHero, getMode, |
