diff --git a/README.md b/README.md index 7781810..ba0a76b 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ $ curl https://static-api.firestoneapp.com/bgs-quests?questCardId=BG24_Quest_313 Used this project as template: https://github.com/alukach/aws-sam-typescript-boilerplate -# Random musings +# Random notes for Quests what do we want to get? diff --git a/package.json b/package.json index e054965..fbdc770 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@firestone-hs/bgs-global-stats", - "version": "1.0.29", + "version": "1.0.33", "description": "", "scripts": { "lint": "eslint --color --fix --ext .ts .", diff --git a/src/bgs-global-stats.ts b/src/bgs-global-stats.ts index f7e6527..558ed2b 100644 --- a/src/bgs-global-stats.ts +++ b/src/bgs-global-stats.ts @@ -51,3 +51,5 @@ export interface MmrPercentile { readonly mmr: number; readonly percentile: 100 | 50 | 25 | 10 | 1; } + +export * from './stats-v2/bgs-hero-stat'; diff --git a/src/build-battlegrounds-hero-stats-new.ts b/src/build-battlegrounds-hero-stats-new.ts index b5ea331..ca5eb89 100644 --- a/src/build-battlegrounds-hero-stats-new.ts +++ b/src/build-battlegrounds-hero-stats-new.ts @@ -11,6 +11,7 @@ import { buildMmrPercentiles, buildPlacementDistribution, filterRowsForTimePerio import { InternalBgsRow } from './internal-model'; import { handleQuests } from './quests'; import { handleQuestsV2 } from './quests-v2/quests-v2'; +import { handleStatsV2 } from './stats-v2/stats-v2'; import { formatDate, normalizeHeroCardId } from './utils/util-functions'; export const s3 = new S3(); @@ -49,6 +50,12 @@ export const handleNewStats = async (event, context: Context) => { for (const timePeriod of allTimePeriods) { await handleQuestsV2(timePeriod, rows, lastPatch); } + } else if (event.statsV2) { + const rows: readonly InternalBgsRow[] = await readRowsFromS3(); + logger.log('read rows', rows?.length); + for (const timePeriod of allTimePeriods) { + await handleStatsV2(timePeriod, rows, lastPatch, allCards); + } } else if (event.permutation) { const rows: readonly InternalBgsRow[] = await readRowsFromS3(); logger.log('read rows', rows?.length); @@ -63,6 +70,7 @@ export const handleNewStats = async (event, context: Context) => { await dispatchNewLambdas(rows, context); await dispatchQuestsLambda(rows, context); await dispatchQuestsV2Lambda(rows, context); + await dispatchStatsV2Lambda(rows, context); } cleanup(); @@ -113,6 +121,28 @@ const dispatchQuestsV2Lambda = async (rows: readonly InternalBgsRow[], context: logger.log('\tinvocation result', result); }; +const dispatchStatsV2Lambda = async (rows: readonly InternalBgsRow[], context: Context) => { + const newEvent = { + statsV2: true, + }; + const params = { + FunctionName: context.functionName, + InvocationType: 'Event', + LogType: 'Tail', + Payload: JSON.stringify(newEvent), + }; + logger.log('\tinvoking lambda', params); + const result = await lambda + .invoke({ + FunctionName: context.functionName, + InvocationType: 'Event', + LogType: 'Tail', + Payload: JSON.stringify(newEvent), + }) + .promise(); + logger.log('\tinvocation result', result); +}; + const dispatchNewLambdas = async (rows: readonly InternalBgsRow[], context: Context) => { const allTribes = extractAllTribes(rows); logger.log('all tribes', allTribes); @@ -349,7 +379,7 @@ const buildWarbandStats = ( return result; }; -const buildCombatWinrate = ( +export const buildCombatWinrate = ( rows: readonly InternalBgsRow[], ): readonly { turn: number; dataPoints: number; totalWinrate: number }[] => { const ref = rows[0]; diff --git a/src/common.ts b/src/common.ts index 385e5db..361b6f3 100644 --- a/src/common.ts +++ b/src/common.ts @@ -72,6 +72,18 @@ export const buildPlacementDistribution = ( return placementDistribution; }; +export const buildPlacementDistributionWithPercentages = ( + rows: readonly InternalBgsRow[], +): readonly { rank: number; percentage: number }[] => { + const placementDistribution = buildPlacementDistribution(rows); + const totalMatches = placementDistribution.map(p => p.totalMatches).reduce((a, b) => a + b, 0); + const result: readonly { rank: number; percentage: number }[] = placementDistribution.map(p => ({ + rank: p.rank, + percentage: (100 * p.totalMatches) / totalMatches, + })); + return result; +}; + export interface PatchInfo { readonly number: number; readonly version: string; diff --git a/src/stats-v2/bgs-hero-stat.ts b/src/stats-v2/bgs-hero-stat.ts new file mode 100644 index 0000000..e4bbc83 --- /dev/null +++ b/src/stats-v2/bgs-hero-stat.ts @@ -0,0 +1,32 @@ +import { Race } from '@firestone-hs/reference-data'; +import { MmrPercentile } from '../bgs-global-stats'; +import { WithMmrAndTimePeriod } from '../quests-v2/charged-stat'; + +export interface BgsHeroStatsV2 { + readonly lastUpdateDate: Date; + readonly mmrPercentiles: readonly MmrPercentile[]; + readonly dataPoints: number; + readonly heroStats: readonly WithMmrAndTimePeriod[]; +} + +export interface BgsGlobalHeroStat { + readonly heroCardId: string; + readonly dataPoints: number; + readonly averagePosition: number; + readonly placementDistribution: readonly { rank: number; percentage: number }[]; + readonly combatWinrate: readonly { turn: number; winrate: number }[]; + // // Same + // readonly warbandStats: readonly { turn: number; dataPoints: number; totalStats: number }[]; + readonly tribeStats: readonly BgsHeroTribeStat[]; +} + +export interface BgsHeroTribeStat { + readonly tribe: Race; + readonly dataPoints: number; + readonly averagePosition: number; + readonly impactAveragePosition: number; + readonly placementDistribution: readonly { rank: number; percentage: number }[]; + readonly impactPlacementDistribution: readonly { rank: number; impact: number }[]; + readonly combatWinrate: readonly { turn: number; winrate: number }[]; + readonly impactCombatWinrate: readonly { turn: number; impact: number }[]; +} diff --git a/src/stats-v2/stats-buikder.ts b/src/stats-v2/stats-buikder.ts new file mode 100644 index 0000000..8deb866 --- /dev/null +++ b/src/stats-v2/stats-buikder.ts @@ -0,0 +1,104 @@ +import { groupByFunction } from '@firestone-hs/aws-lambda-utils'; +import { AllCardsService, CardIds, Race } from '@firestone-hs/reference-data'; +import { buildCombatWinrate } from '../build-battlegrounds-hero-stats-new'; +import { buildPlacementDistributionWithPercentages } from '../common'; +import { InternalBgsRow } from '../internal-model'; +import { normalizeHeroCardId } from '../utils/util-functions'; +import { BgsGlobalHeroStat, BgsHeroTribeStat } from './bgs-hero-stat'; + +export const buildStats = ( + rows: readonly InternalBgsRow[], + allCards: AllCardsService, +): readonly BgsGlobalHeroStat[] => { + const groupedByHero: { + [questCardId: string]: readonly InternalBgsRow[]; + } = groupByFunction((row: InternalBgsRow) => normalizeHeroCardId(row.heroCardId, allCards))(rows); + return Object.values(groupedByHero).flatMap(data => buildStatsForSingleHero(data)); +}; + +// All rows here belong to a single hero +const buildStatsForSingleHero = (rows: readonly InternalBgsRow[]): BgsGlobalHeroStat => { + const ref = rows[0]; + const averagePosition = average(rows.map(r => r.rank)); + const placementDistribution = buildPlacementDistributionWithPercentages(rows); + const rawCombatWinrates = buildCombatWinrate(rows); + const combatWinrate: readonly { turn: number; winrate: number }[] = rawCombatWinrates.map(info => ({ + turn: info.turn, + winrate: info.totalWinrate / info.dataPoints, + })); + const result: BgsGlobalHeroStat = { + heroCardId: ref.heroCardId, + dataPoints: rows.length, + averagePosition: averagePosition, + placementDistribution: placementDistribution, + combatWinrate: combatWinrate, + tribeStats: buildTribeStats(rows, averagePosition, placementDistribution, combatWinrate), + }; + if (ref.heroCardId === CardIds.PatchesThePirateBattlegrounds) { + console.debug('Patches sanity', result); + console.debug( + 'Patches sanity', + result.tribeStats.map(t => t.impactAveragePosition * t.dataPoints).reduce((a, b) => a + b, 0), + ); + console.debug( + 'Patches sanity', + rows.filter(r => !r.tribes.includes('' + Race.PIRATE)).length, + rows.filter(r => !r.tribes.includes('' + Race.PIRATE)), + ); + } + return result; +}; + +const buildTribeStats = ( + rows: readonly InternalBgsRow[], + refAveragePosition: number, + refPlacementDistribution: readonly { rank: number; percentage: number }[], + refCombatWinrate: readonly { turn: number; winrate: number }[], +): readonly BgsHeroTribeStat[] => { + const uniqueTribes: readonly Race[] = [...new Set(rows.flatMap(r => r.tribes.split(',')).map(r => parseInt(r)))]; + return uniqueTribes.map(tribe => { + const rowsForTribe = rows.filter(r => r.tribes.split(',').includes('' + tribe)); + const rowsWithoutTribe = rows.filter(r => !r.tribes.split(',').includes('' + tribe)); + const averagePosition = average(rowsForTribe.map(r => r.rank)); + const placementDistribution = buildPlacementDistributionWithPercentages(rowsForTribe); + const rawCombatWinrates = buildCombatWinrate(rowsForTribe); + const combatWinrate = rawCombatWinrates.map(info => ({ + turn: info.turn, + winrate: info.totalWinrate / info.dataPoints, + })); + return { + tribe: tribe, + dataPoints: rowsForTribe.length, + dataPointsOnMissingTribe: rowsWithoutTribe.length, + averagePosition: averagePosition, + impactAveragePosition: averagePosition - refAveragePosition, + placementDistribution: placementDistribution, + impactPlacementDistribution: refPlacementDistribution.map(p => { + const newPlacementInfo = placementDistribution.find(p2 => p2.rank === p.rank); + // Cna happen when there isn't a lot of data points, typically for high MMR + if (!newPlacementInfo) { + console.log('missing placement info', placementDistribution, p); + } + return { + rank: p.rank, + impact: (newPlacementInfo?.percentage ?? 0) - p.percentage, + }; + }), + combatWinrate: combatWinrate, + impactCombatWinrate: refCombatWinrate.map(c => { + const newCombatWinrate = combatWinrate.find(c2 => c2.turn === c.turn); + if (!newCombatWinrate) { + console.debug('missing winrate info', combatWinrate); + } + return { + turn: c.turn, + impact: (newCombatWinrate?.winrate ?? 0) - c.winrate, + }; + }), + }; + }); +}; + +const average = (data: readonly number[]): number => { + return data.reduce((a, b) => a + b, 0) / data.length; +}; diff --git a/src/stats-v2/stats-v2.ts b/src/stats-v2/stats-v2.ts new file mode 100644 index 0000000..0c87c78 --- /dev/null +++ b/src/stats-v2/stats-v2.ts @@ -0,0 +1,41 @@ +import { logger } from '@firestone-hs/aws-lambda-utils'; +import { AllCardsService } from '@firestone-hs/reference-data'; +import { gzipSync } from 'zlib'; +import { s3 } from '../build-battlegrounds-hero-stats-new'; +import { PatchInfo } from '../common'; +import { InternalBgsRow } from '../internal-model'; +import { buildSplitStats } from '../quests-v2/data-filter'; +import { BgsHeroStatsV2 } from './bgs-hero-stat'; +import { buildStats } from './stats-buikder'; + +export const STATS_BUCKET = `static.zerotoheroes.com`; + +export const handleStatsV2 = async ( + timePeriod: 'all-time' | 'past-three' | 'past-seven' | 'last-patch', + rows: readonly InternalBgsRow[], + lastPatch: PatchInfo, + allCards: AllCardsService, +) => { + const rowsWithStats = rows.filter(row => !!row.rank).filter(r => !!r.tribes?.length); + console.log('total relevant rows', rowsWithStats?.length); + + const statResult = await buildSplitStats(rowsWithStats, timePeriod, lastPatch, (data: InternalBgsRow[]) => + buildStats(data, allCards), + ); + const stats = statResult.stats; + const statsV2: BgsHeroStatsV2 = { + lastUpdateDate: new Date(), + mmrPercentiles: statResult.mmrPercentiles, + heroStats: stats, + dataPoints: stats.map(s => s.dataPoints).reduce((a, b) => a + b, 0), + }; + logger.log('\tbuilt stats', statsV2.dataPoints, statsV2.heroStats?.length); + const timeSuffix = timePeriod; + await s3.writeFile( + gzipSync(JSON.stringify(statsV2)), + STATS_BUCKET, + `api/bgs/stats-v2/bgs-${timeSuffix}.gz.json`, + 'application/json', + 'gzip', + ); +};