From 64fd15c0a5a8ee34fbea6d25d0f2c023c21bf526 Mon Sep 17 00:00:00 2001 From: tresabhi Date: Wed, 28 Jun 2023 17:58:36 +0000 Subject: [PATCH] feat: new /stats --- src/commands/evolution.tsx | 2 +- src/commands/stats.tsx | 73 +++++++++ .../components/WN8Display/components/Glow.tsx | 39 +++++ .../components/WN8Display/index.tsx | 77 +++++++++ .../components/WN8SurroundingStat.tsx | 47 ++++++ src/components/AllStatsOverview/index.tsx | 97 ++++++++++++ src/core/node/hslToHex.ts | 22 +++ src/events/interactionCreate/index.ts | 2 + src/renderers/stats.tsx | 149 ++++++++++++++++++ src/routers/stats.tsx | 2 +- tsconfig.json | 3 +- 11 files changed, 510 insertions(+), 3 deletions(-) create mode 100644 src/commands/stats.tsx create mode 100644 src/components/AllStatsOverview/components/WN8Display/components/Glow.tsx create mode 100644 src/components/AllStatsOverview/components/WN8Display/index.tsx create mode 100644 src/components/AllStatsOverview/components/WN8SurroundingStat.tsx create mode 100644 src/components/AllStatsOverview/index.tsx create mode 100644 src/core/node/hslToHex.ts create mode 100644 src/renderers/stats.tsx diff --git a/src/commands/evolution.tsx b/src/commands/evolution.tsx index 3dc6d6ddb..5b4b8b1db 100644 --- a/src/commands/evolution.tsx +++ b/src/commands/evolution.tsx @@ -11,7 +11,7 @@ import { StatType } from '../renderers/statsfull.js'; export default { inProduction: true, - inDevelopment: true, + inDevelopment: false, inPublic: true, command: addStatTypeSubCommandGroups( diff --git a/src/commands/stats.tsx b/src/commands/stats.tsx new file mode 100644 index 000000000..e0f85a615 --- /dev/null +++ b/src/commands/stats.tsx @@ -0,0 +1,73 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { CYCLIC_API } from '../constants/cyclic.js'; +import resolveTankId from '../core/blitz/resolveTankId.js'; +import { Period } from '../core/discord/addPeriodSubCommands.js'; +import addStatTypeSubCommandGroups from '../core/discord/addStatTypeSubCommandGroups.js'; +import autocompleteTanks from '../core/discord/autocompleteTanks.js'; +import autocompleteUsername from '../core/discord/autocompleteUsername.js'; +import interactionToURL from '../core/discord/interactionToURL.js'; +import linkButton from '../core/discord/linkButton.js'; +import primaryButton from '../core/discord/primaryButton.js'; +import resolvePeriodFromButton from '../core/discord/resolvePeriodFromButton.js'; +import resolvePeriodFromCommand from '../core/discord/resolvePeriodFromCommand.js'; +import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton.js'; +import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand.js'; +import { CommandRegistry } from '../events/interactionCreate/index.js'; +import stats from '../renderers/stats.js'; +import statsfull, { StatType } from '../renderers/statsfull.js'; + +export default { + inProduction: true, + inDevelopment: true, + inPublic: true, + + command: addStatTypeSubCommandGroups( + new SlashCommandBuilder() + .setName('stats') + .setDescription('In-game statistics'), + ), + + async handler(interaction) { + const commandGroup = interaction.options.getSubcommandGroup( + true, + ) as StatType; + const subcommand = interaction.options.getSubcommand() as Period; + const player = await resolvePlayerFromCommand(interaction); + const period = resolvePeriodFromCommand(interaction); + const tankIdRaw = interaction.options.getString('tank')!; + const tankId = commandGroup === 'tank' ? resolveTankId(tankIdRaw) : 0; + const start = interaction.options.getInteger('start'); + const end = interaction.options.getInteger('end'); + const path = `stats/${commandGroup}/${subcommand}?server=${ + player.server + }&id=${player.id}&tankId=${tankId}&start=${start ?? 0}&end=${end ?? 0}`; + + interactionToURL(interaction); + + return [ + await stats(commandGroup, period, player, tankId), + primaryButton(path, 'Refresh'), + linkButton(path, 'Embed'), + ]; + }, + + autocomplete: (interaction) => { + autocompleteUsername(interaction); + autocompleteTanks(interaction); + }, + + async button(interaction) { + const url = new URL(`${CYCLIC_API}/${interaction.customId}`); + const path = url.pathname.split('/').filter(Boolean); + const commandGroup = path[1] as StatType; + const period = resolvePeriodFromButton(interaction); + const player = await resolvePlayerFromButton(interaction); + + return await statsfull( + commandGroup, + period, + player, + parseInt(url.searchParams.get('tankId')!), + ); + }, +} satisfies CommandRegistry; diff --git a/src/components/AllStatsOverview/components/WN8Display/components/Glow.tsx b/src/components/AllStatsOverview/components/WN8Display/components/Glow.tsx new file mode 100644 index 000000000..7c5d2f93e --- /dev/null +++ b/src/components/AllStatsOverview/components/WN8Display/components/Glow.tsx @@ -0,0 +1,39 @@ +import { Percentile } from '../../../../../constants/percentiles.js'; +import hslToHex from '../../../../../core/node/hslToHex.js'; +import { PERCENTILE_COLORS } from '../../../../PercentileIndicator.js'; + +export enum GlowSide { + Top, + Bottom, +} + +export interface GlowProps { + percentile: Percentile; + side: GlowSide; +} + +export function Glow({ percentile, side }: GlowProps) { + const color = PERCENTILE_COLORS[percentile]; + const color1 = hslToHex(color); + const color2 = `${color1}00`; + + const background = `linear-gradient(${ + side === GlowSide.Top ? 0 : 180 + }deg, ${color1} 0%, ${color2} 100%)`; + + return ( +
+
+
+
+
+
+
+ ); +} diff --git a/src/components/AllStatsOverview/components/WN8Display/index.tsx b/src/components/AllStatsOverview/components/WN8Display/index.tsx new file mode 100644 index 000000000..08f257fe2 --- /dev/null +++ b/src/components/AllStatsOverview/components/WN8Display/index.tsx @@ -0,0 +1,77 @@ +import { Percentile } from '../../../../constants/percentiles.js'; +import getWN8Percentile from '../../../../core/blitz/getWN8Percentile.js'; +import { theme } from '../../../../stitches.config.js'; +import { PERCENTILE_COLORS } from '../../../PercentileIndicator.js'; +import { Glow, GlowSide } from './components/Glow.js'; + +export interface WN8DisplayProps { + WN8?: number; +} + +export const PERCENTILE_NAMES = { + [Percentile.VeryBad]: 'Very Bad', + [Percentile.Bad]: 'Bad', + [Percentile.BelowAverage]: 'Below Average', + [Percentile.Average]: 'Average', + [Percentile.AboveAverage]: 'Above Average', + [Percentile.Good]: 'Good', + [Percentile.VeryGood]: 'Very Good', + [Percentile.Great]: 'Great', + [Percentile.Unicum]: 'Unicum', + [Percentile.SuperUnicum]: 'Super Unicum', +}; + +export function WN8Display({ WN8 }: WN8DisplayProps) { + const percentile = + WN8 === undefined ? Percentile.VeryBad : getWN8Percentile(WN8); + const color = PERCENTILE_COLORS[percentile]; + + return ( +
+ + +
+ + {WN8 === undefined ? '--' : WN8.toFixed(0)} + + + {PERCENTILE_NAMES[percentile]} + +
+ + +
+ ); +} diff --git a/src/components/AllStatsOverview/components/WN8SurroundingStat.tsx b/src/components/AllStatsOverview/components/WN8SurroundingStat.tsx new file mode 100644 index 000000000..4c48d00fa --- /dev/null +++ b/src/components/AllStatsOverview/components/WN8SurroundingStat.tsx @@ -0,0 +1,47 @@ +import { theme } from '../../../stitches.config.js'; + +export enum WN8SurroundingStatAlign { + Left, + Right, +} + +export interface WN8SurroundingStatProps { + label: string; + value: string | number; + align?: WN8SurroundingStatAlign; + padded?: boolean; +} + +export function WN8SurroundingStat({ + label, + value, + align = WN8SurroundingStatAlign.Left, + padded = false, +}: WN8SurroundingStatProps) { + return ( +
+ + {value} + + + {label} + +
+ ); +} diff --git a/src/components/AllStatsOverview/index.tsx b/src/components/AllStatsOverview/index.tsx new file mode 100644 index 000000000..0571520e7 --- /dev/null +++ b/src/components/AllStatsOverview/index.tsx @@ -0,0 +1,97 @@ +import { AllStats, SupplementaryStats } from '../../types/accountInfo.js'; +import { WN8Display } from './components/WN8Display/index.js'; +import { + WN8SurroundingStat, + WN8SurroundingStatAlign, +} from './components/WN8SurroundingStat.js'; + +export interface AllStatsOverviewProps { + stats: AllStats; + supplementaryStats: SupplementaryStats; +} + +export default function AllStatsOverview({ + stats, + supplementaryStats, +}: AllStatsOverviewProps) { + return ( +
+
+ + + + +
+ + + +
+ + + + +
+
+ ); +} diff --git a/src/core/node/hslToHex.ts b/src/core/node/hslToHex.ts new file mode 100644 index 000000000..304ed774d --- /dev/null +++ b/src/core/node/hslToHex.ts @@ -0,0 +1,22 @@ +const numberRegex = /\d+(\.\d+)?/g; + +/** + * Thanks to icl7126! + * https://stackoverflow.com/a/44134328/12294756 + */ +export default function hslToHex(hsl: string) { + let [h, s, l] = Array.from(hsl.matchAll(numberRegex)).map((m) => + parseFloat(m[0]), + ); + + l /= 100; + const a = (s * Math.min(l, 1 - l)) / 100; + const f = (n: number) => { + const k = (n + h / 30) % 12; + const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + return Math.round(255 * color) + .toString(16) + .padStart(2, '0'); // convert to Hex and prefix "0" if needed + }; + return `#${f(0)}${f(8)}${f(4)}`; +} diff --git a/src/events/interactionCreate/index.ts b/src/events/interactionCreate/index.ts index b4f48ad9e..92e145979 100644 --- a/src/events/interactionCreate/index.ts +++ b/src/events/interactionCreate/index.ts @@ -25,6 +25,7 @@ import playerinfo from '../../commands/playerinfo.js'; import searchclans from '../../commands/searchclans.js'; import searchplayers from '../../commands/searchplayers.js'; import searchtanks from '../../commands/searchtanks.js'; +import stats from '../../commands/stats.js'; import statsfull from '../../commands/statsfull.js'; import today from '../../commands/today.js'; import verify from '../../commands/verify.js'; @@ -86,6 +87,7 @@ export const commands: Record = ( verify, ping, evolution, + stats, ] as CommandRegistry[] ).reduce((accumulator, registry) => { if (isDev()) registry.command.setDefaultMemberPermissions(0); diff --git a/src/renderers/stats.tsx b/src/renderers/stats.tsx new file mode 100644 index 000000000..aec9de200 --- /dev/null +++ b/src/renderers/stats.tsx @@ -0,0 +1,149 @@ +import AllStatsOverview from '../components/AllStatsOverview/index.js'; +import NoData, { NoDataType } from '../components/NoData.js'; +import PoweredByBlitzStars from '../components/PoweredByBlitzStars.js'; +import { TierWeightsRecord } from '../components/TierWeights.js'; +import TitleBar from '../components/TitleBar.js'; +import Wrapper from '../components/Wrapper.js'; +import { BLITZ_SERVERS } from '../constants/servers.js'; +import calculateWN8 from '../core/blitz/calculateWN8.js'; +import getWargamingResponse from '../core/blitz/getWargamingResponse.js'; +import resolveTankName from '../core/blitz/resolveTankName.js'; +import sumStats from '../core/blitz/sumStats.js'; +import { Tier, tankopedia } from '../core/blitz/tankopedia.js'; +import getTankStatsDiffed from '../core/blitzstars/getTankStatsDiffed.js'; +import { tankAverages } from '../core/blitzstars/tankAverages.js'; +import { ResolvedPeriod } from '../core/discord/resolvePeriodFromCommand.js'; +import { ResolvedPlayer } from '../core/discord/resolvePlayerFromCommand.js'; +import { WARGAMING_APPLICATION_ID } from '../core/node/arguments.js'; +import { + AccountInfo, + AllStats, + SupplementaryStats, +} from '../types/accountInfo.js'; +import { PlayerClanData } from '../types/playerClanData.js'; + +export type StatType = 'player' | 'tank'; + +export default async function stats( + type: Type, + { start, end, statsName }: ResolvedPeriod, + { server, id }: ResolvedPlayer, + tankId: Type extends 'tank' ? number : never, +) { + let nameDiscriminator: string | undefined; + let image: string | undefined; + + if (type === 'player') { + const clan = ( + await getWargamingResponse( + `https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`, + ) + )[id]?.clan; + + if (clan) nameDiscriminator = `[${clan.tag}]`; + image = clan + ? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png` + : undefined; + } else if (type === 'tank') { + nameDiscriminator = `(${resolveTankName(tankId)})`; + image = tankopedia[tankId].images.normal; + } + + const tankStats = await getTankStatsDiffed(server, id, start, end); + let stats: AllStats; + let supplementaryStats: SupplementaryStats; + let tierWeights: TierWeightsRecord; + + if (type === 'player') { + const entries = Object.entries(tankStats); + stats = sumStats(entries.map(([, stats]) => stats)); + const battlesOfTanksWithAverages = entries.reduce( + (accumulator, [tankIdString, stats]) => { + const tankId = parseInt(tankIdString); + const tankAverage = tankAverages[tankId]; + + return tankAverage ? accumulator + stats.battles : accumulator; + }, + 0, + ); + const battlesOfTanksWithTankopediaEntry = entries.reduce( + (accumulator, [tankIdString, stats]) => { + const tankId = parseInt(tankIdString); + const tankopediaEntry = tankopedia[tankId]; + + return tankopediaEntry ? accumulator + stats.battles : accumulator; + }, + 0, + ); + + supplementaryStats = { + WN8: + entries.reduce((accumulator, [tankIdString, stats]) => { + const tankId = parseInt(tankIdString); + const tankAverage = tankAverages[tankId]; + + return tankAverage + ? accumulator + calculateWN8(tankAverage.all, stats) * stats.battles + : accumulator; + }, 0) / battlesOfTanksWithAverages, + tier: + entries.reduce((accumulator, [tankIdString, stats]) => { + const tankId = parseInt(tankIdString); + const tankopediaEntry = tankopedia[tankId]; + + return tankopediaEntry + ? accumulator + tankopediaEntry.tier * stats.battles + : accumulator; + }, 0) / battlesOfTanksWithTankopediaEntry, + }; + tierWeights = entries.reduce( + (accumulator, [tankIdString, stats]) => { + const tankId = parseInt(tankIdString); + const tier = tankopedia[tankId].tier as Tier; + + if (accumulator[tier]) { + accumulator[tier]! += stats.battles; + } else { + accumulator[tier] = stats.battles; + } + + return accumulator; + }, + {}, + ); + } else { + stats = tankStats[tankId]; + + supplementaryStats = { + WN8: calculateWN8(tankAverages[tankId].all, tankStats[tankId]), + tier: tankopedia[tankId].tier, + }; + } + + const accountInfo = await getWargamingResponse( + `https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`, + ); + + return ( + + + + {stats.battles === 0 && } + {stats.battles > 0 && ( + + )} + + + + ); +} diff --git a/src/routers/stats.tsx b/src/routers/stats.tsx index cdaea9824..861933cfe 100644 --- a/src/routers/stats.tsx +++ b/src/routers/stats.tsx @@ -6,7 +6,7 @@ import { RouterRegistry } from '../server.js'; export default { route: '/stats/:type/:period', inProduction: true, - inDevelopment: true, + inDevelopment: false, async handler(req) { const type = req.params.type as StatType; diff --git a/tsconfig.json b/tsconfig.json index 8d1a8a7e4..d47b9a960 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "target": "ESNext", "module": "ESNext", + // "moduleResolution": "Node", TODO: implement Node module resolution "moduleResolution": "nodenext", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, @@ -11,5 +12,5 @@ "resolveJsonModule": true, "jsx": "react-jsx" }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "src/components/AllStatsOverview/components/WN8SurroundingStat.tsx"] }