From b38b0d04d8308aebf5a7fdf2fa9c9ae28e5ee29c Mon Sep 17 00:00:00 2001 From: David Houweling Date: Tue, 22 Oct 2024 22:44:50 +1100 Subject: [PATCH] Add match stats to the series output (#9) Co-authored-by: Matthew Lee --- src/{utils => base}/preconditions.mts | 0 .../stats/embeds/attrition-match-embed.mts | 8 + .../stats/embeds/base-match-embed.mts | 106 ++++++++ src/commands/stats/embeds/ctf-match-embed.mts | 15 ++ .../stats/embeds/elimination-match-embed.mts | 17 ++ .../stats/embeds/escalation-match-embed.mts | 8 + .../stats/embeds/extraction-match-embed.mts | 14 ++ .../stats/embeds/fiesta-match-embed.mts | 8 + .../stats/embeds/firefight-match-embed.mts | 23 ++ .../stats/embeds/grifball-match-embed.mts | 8 + .../stats/embeds/infection-match-embed.mts | 19 ++ .../stats/embeds/koth-match-embed.mts | 16 ++ .../stats/embeds/land-grab-match-embed.mts | 8 + .../stats/embeds/minigame-match-embed.mts | 8 + .../stats/embeds/oddball-match-embed.mts | 11 + .../stats/embeds/slayer-match-embed.mts | 8 + .../stats/embeds/stockpile-match-embed.mts | 15 ++ .../stats/embeds/strongholds-match-embed.mts | 16 ++ .../embeds/total-control-match-embed.mts | 8 + .../stats/embeds/unknown-match-embed.mts | 8 + src/commands/stats/stats.mts | 226 +++++++++++++++--- src/config.mts | 2 +- src/services/discord/discord.mts | 2 +- src/services/halo/halo.mts | 56 +++-- src/services/halo/xsts-token-provider.mts | 2 +- 25 files changed, 565 insertions(+), 47 deletions(-) rename src/{utils => base}/preconditions.mts (100%) create mode 100644 src/commands/stats/embeds/attrition-match-embed.mts create mode 100644 src/commands/stats/embeds/base-match-embed.mts create mode 100644 src/commands/stats/embeds/ctf-match-embed.mts create mode 100644 src/commands/stats/embeds/elimination-match-embed.mts create mode 100644 src/commands/stats/embeds/escalation-match-embed.mts create mode 100644 src/commands/stats/embeds/extraction-match-embed.mts create mode 100644 src/commands/stats/embeds/fiesta-match-embed.mts create mode 100644 src/commands/stats/embeds/firefight-match-embed.mts create mode 100644 src/commands/stats/embeds/grifball-match-embed.mts create mode 100644 src/commands/stats/embeds/infection-match-embed.mts create mode 100644 src/commands/stats/embeds/koth-match-embed.mts create mode 100644 src/commands/stats/embeds/land-grab-match-embed.mts create mode 100644 src/commands/stats/embeds/minigame-match-embed.mts create mode 100644 src/commands/stats/embeds/oddball-match-embed.mts create mode 100644 src/commands/stats/embeds/slayer-match-embed.mts create mode 100644 src/commands/stats/embeds/stockpile-match-embed.mts create mode 100644 src/commands/stats/embeds/strongholds-match-embed.mts create mode 100644 src/commands/stats/embeds/total-control-match-embed.mts create mode 100644 src/commands/stats/embeds/unknown-match-embed.mts diff --git a/src/utils/preconditions.mts b/src/base/preconditions.mts similarity index 100% rename from src/utils/preconditions.mts rename to src/base/preconditions.mts diff --git a/src/commands/stats/embeds/attrition-match-embed.mts b/src/commands/stats/embeds/attrition-match-embed.mts new file mode 100644 index 0000000..ac5cb10 --- /dev/null +++ b/src/commands/stats/embeds/attrition-match-embed.mts @@ -0,0 +1,8 @@ +import { GameVariantCategory } from "halo-infinite-api"; +import { BaseMatchEmbed } from "./base-match-embed.mjs"; + +export class AttritionMatchEmbed extends BaseMatchEmbed { + override getPlayerObjectiveStats(): Map { + return new Map([]); + } +} diff --git a/src/commands/stats/embeds/base-match-embed.mts b/src/commands/stats/embeds/base-match-embed.mts new file mode 100644 index 0000000..aa847c8 --- /dev/null +++ b/src/commands/stats/embeds/base-match-embed.mts @@ -0,0 +1,106 @@ +import { GameVariantCategory, MatchStats } from "halo-infinite-api"; +import { HaloService } from "../../../services/halo/halo.mjs"; +import { EmbedBuilder } from "discord.js"; +import { Preconditions } from "../../../base/preconditions.mjs"; + +export type PlayerStats = + MatchStats["Players"][0]["PlayerTeamStats"][0]["Stats"]; + +export abstract class BaseMatchEmbed { + constructor(protected readonly haloService: HaloService) {} + + protected abstract getPlayerObjectiveStats(stats: PlayerStats): Map; + + protected getPlayerSlayerStats(stats: PlayerStats): Map { + const { CoreStats } = stats; + return new Map([ + ["Kills", CoreStats.Kills.toString()], + ["Deaths", CoreStats.Deaths.toString()], + ["Assists", CoreStats.Assists.toString()], + ["KDA", CoreStats.KDA.toString()], + ["Headshot kills", CoreStats.HeadshotKills.toString()], + ["Shots H:F", `${CoreStats.ShotsHit.toString()}:${CoreStats.ShotsFired.toString()}`], + ["Accuracy", `${CoreStats.Accuracy.toString()}%`], + ["Damage D:T", `${CoreStats.DamageDealt.toString()}:${CoreStats.DamageTaken.toString()}`], + ["Av life duration", this.haloService.getReadableDuration(CoreStats.AverageLifeDuration)], + ["Av damage/life", (CoreStats.DamageDealt / CoreStats.Deaths).toFixed(2)], + ]); + } + + async getEmbed(match: MatchStats, players: Map) { + const gameTypeAndMap = await this.haloService.getGameTypeAndMap(match); + + const embed = new EmbedBuilder() + .setTitle(gameTypeAndMap) + .setURL(`https://halodatahive.com/Infinite/Match/${match.MatchId}`); + + for (const team of match.Teams) { + embed.addFields({ + name: this.haloService.getTeamName(team.TeamId), + value: `Team Score: ${team.Stats.CoreStats.Score.toString()}`, + inline: false, + }); + + const teamPlayers = match.Players.filter((player) => + player.PlayerTeamStats.find((teamStats) => teamStats.TeamId === team.TeamId), + ).sort((a, b) => { + if (a.Rank - b.Rank !== 0) { + return a.Rank - b.Rank; + } + + const aStats = Preconditions.checkExists( + a.PlayerTeamStats.find((teamStats) => teamStats.TeamId === team.TeamId), + ); + const bStats = Preconditions.checkExists( + b.PlayerTeamStats.find((teamStats) => teamStats.TeamId === team.TeamId), + ); + return aStats.Stats.CoreStats.Score - bStats.Stats.CoreStats.Score; + }); + + let playerFields = []; + for (const teamPlayer of teamPlayers) { + const playerXuid = this.haloService.getPlayerXuid(teamPlayer); + const playerGamertag = Preconditions.checkExists(players.get(playerXuid)); + const playerStats = Preconditions.checkExists( + teamPlayer.PlayerTeamStats.find((teamStats) => teamStats.TeamId === team.TeamId), + "Unable to match player to team", + ) as MatchStats["Players"][0]["PlayerTeamStats"][0]; + + const { + Stats: { CoreStats: coreStats }, + } = playerStats; + + const outputStats = [ + `Rank: ${teamPlayer.Rank.toString()}`, + `Score: ${coreStats.Score.toString()}`, + ...this.playerStatsToFields(this.getPlayerSlayerStats(playerStats.Stats)), + ...this.playerStatsToFields(this.getPlayerObjectiveStats(playerStats.Stats)), + ]; + playerFields.push({ + name: playerGamertag, + value: `\`\`\`${outputStats.join("\n")}\`\`\``, + inline: true, + }); + + // If two players are added, or if it's the last player, push to embed and reset + if (playerFields.length === 2 || teamPlayer === teamPlayers[teamPlayers.length - 1]) { + embed.addFields(playerFields); + playerFields = []; + + // Adds a new row + embed.addFields({ + name: "\n", + value: "\n", + inline: false, + }); + } + } + } + + return embed; + } + + private playerStatsToFields(playerStats: Map): string[] { + return Array.from(playerStats.entries()).map(([key, value]) => `${key}: ${value}`); + } +} diff --git a/src/commands/stats/embeds/ctf-match-embed.mts b/src/commands/stats/embeds/ctf-match-embed.mts new file mode 100644 index 0000000..73cfddc --- /dev/null +++ b/src/commands/stats/embeds/ctf-match-embed.mts @@ -0,0 +1,15 @@ +import { GameVariantCategory } from "halo-infinite-api"; +import { BaseMatchEmbed, PlayerStats } from "./base-match-embed.mjs"; + +export class CtfMatchEmbed extends BaseMatchEmbed { + override getPlayerObjectiveStats(stats: PlayerStats): Map { + return new Map([ + ["Captures", stats.CaptureTheFlagStats.FlagCaptures.toString()], + ["Captures assists", stats.CaptureTheFlagStats.FlagCaptureAssists.toString()], + ["Carrier time", this.haloService.getReadableDuration(stats.CaptureTheFlagStats.TimeAsFlagCarrier)], + ["Grabs", stats.CaptureTheFlagStats.FlagGrabs.toString()], + ["Returns", stats.CaptureTheFlagStats.FlagReturns.toString()], + ["Carriers killed", stats.CaptureTheFlagStats.FlagCarriersKilled.toString()], + ]); + } +} diff --git a/src/commands/stats/embeds/elimination-match-embed.mts b/src/commands/stats/embeds/elimination-match-embed.mts new file mode 100644 index 0000000..a1bec7b --- /dev/null +++ b/src/commands/stats/embeds/elimination-match-embed.mts @@ -0,0 +1,17 @@ +import { GameVariantCategory } from "halo-infinite-api"; +import { BaseMatchEmbed, PlayerStats } from "./base-match-embed.mjs"; + +export class EliminationMatchEmbed extends BaseMatchEmbed { + override getPlayerObjectiveStats( + stats: PlayerStats, + ): Map { + return new Map([ + ["Eliminations", stats.EliminationStats.Eliminations.toString()], + ["Elimination assists", stats.EliminationStats.EliminationAssists.toString()], + ["Allies revived", stats.EliminationStats.AlliesRevived.toString()], + ["Rounds Survived", stats.EliminationStats.RoundsSurvived.toString()], + ["Times revived by ally", stats.EliminationStats.TimesRevivedByAlly.toString()], + ["Enemy revives denied", stats.EliminationStats.EnemyRevivesDenied.toString()], + ]); + } +} diff --git a/src/commands/stats/embeds/escalation-match-embed.mts b/src/commands/stats/embeds/escalation-match-embed.mts new file mode 100644 index 0000000..a90fb70 --- /dev/null +++ b/src/commands/stats/embeds/escalation-match-embed.mts @@ -0,0 +1,8 @@ +import { GameVariantCategory } from "halo-infinite-api"; +import { BaseMatchEmbed } from "./base-match-embed.mjs"; + +export class EscalationMatchEmbed extends BaseMatchEmbed { + override getPlayerObjectiveStats(): Map { + return new Map(); + } +} diff --git a/src/commands/stats/embeds/extraction-match-embed.mts b/src/commands/stats/embeds/extraction-match-embed.mts new file mode 100644 index 0000000..814afa9 --- /dev/null +++ b/src/commands/stats/embeds/extraction-match-embed.mts @@ -0,0 +1,14 @@ +import { GameVariantCategory } from "halo-infinite-api"; +import { BaseMatchEmbed, PlayerStats } from "./base-match-embed.mjs"; + +export class ExtractionMatchEmbed extends BaseMatchEmbed { + override getPlayerObjectiveStats(stats: PlayerStats): Map { + return new Map([ + ["Successful extractions", stats.ExtractionStats.SuccessfulExtractions.toString()], + ["Extraction initiations completed", stats.ExtractionStats.ExtractionInitiationsCompleted.toString()], + ["Extraction initiations denied", stats.ExtractionStats.ExtractionInitiationsDenied.toString()], + ["Extraction conversions completed", stats.ExtractionStats.ExtractionConversionsCompleted.toString()], + ["Extraction conversions denied", stats.ExtractionStats.ExtractionConversionsDenied.toString()], + ]); + } +} diff --git a/src/commands/stats/embeds/fiesta-match-embed.mts b/src/commands/stats/embeds/fiesta-match-embed.mts new file mode 100644 index 0000000..56bd106 --- /dev/null +++ b/src/commands/stats/embeds/fiesta-match-embed.mts @@ -0,0 +1,8 @@ +import { GameVariantCategory } from "halo-infinite-api"; +import { BaseMatchEmbed } from "./base-match-embed.mjs"; + +export class FiestaMatchEmbed extends BaseMatchEmbed { + override getPlayerObjectiveStats(): Map { + return new Map([]); + } +} diff --git a/src/commands/stats/embeds/firefight-match-embed.mts b/src/commands/stats/embeds/firefight-match-embed.mts new file mode 100644 index 0000000..fc2feac --- /dev/null +++ b/src/commands/stats/embeds/firefight-match-embed.mts @@ -0,0 +1,23 @@ +import { GameVariantCategory } from "halo-infinite-api"; +import { BaseMatchEmbed, PlayerStats } from "./base-match-embed.mjs"; + +export class FirefightMatchEmbed extends BaseMatchEmbed { + override getPlayerObjectiveStats(stats: PlayerStats): Map { + return new Map([ + ["Eliminations", stats.EliminationStats.Eliminations.toString()], + ["Elimination assists", stats.EliminationStats.EliminationAssists.toString()], + ["Allies revived", stats.EliminationStats.AlliesRevived.toString()], + ["Rounds Survived", stats.EliminationStats.RoundsSurvived.toString()], + ["Times revived by ally", stats.EliminationStats.TimesRevivedByAlly.toString()], + ["Enemy revives denied", stats.EliminationStats.EnemyRevivesDenied.toString()], + ["Boss kills", stats.PveStats.BossKills.toString()], + ["Hunter kills", stats.PveStats.HunterKills.toString()], + ["Elite kills", stats.PveStats.EliteKills.toString()], + ["Jackal kills", stats.PveStats.JackalKills.toString()], + ["Grunt kills", stats.PveStats.GruntKills.toString()], + ["Brute kills", stats.PveStats.BruteKills.toString()], + ["Sentinel kills", stats.PveStats.SentinelKills.toString()], + ["Skimmer kills", stats.PveStats.SkimmerKills.toString()], + ]); + } +} diff --git a/src/commands/stats/embeds/grifball-match-embed.mts b/src/commands/stats/embeds/grifball-match-embed.mts new file mode 100644 index 0000000..0cf04eb --- /dev/null +++ b/src/commands/stats/embeds/grifball-match-embed.mts @@ -0,0 +1,8 @@ +import { GameVariantCategory } from "halo-infinite-api"; +import { BaseMatchEmbed } from "./base-match-embed.mjs"; + +export class GrifballMatchEmbed extends BaseMatchEmbed { + override getPlayerObjectiveStats(): Map { + return new Map([]); + } +} diff --git a/src/commands/stats/embeds/infection-match-embed.mts b/src/commands/stats/embeds/infection-match-embed.mts new file mode 100644 index 0000000..031950c --- /dev/null +++ b/src/commands/stats/embeds/infection-match-embed.mts @@ -0,0 +1,19 @@ +import { GameVariantCategory } from "halo-infinite-api"; +import { BaseMatchEmbed, PlayerStats } from "./base-match-embed.mjs"; + +export class InfectionMatchEmbed extends BaseMatchEmbed { + override getPlayerObjectiveStats(stats: PlayerStats): Map { + return new Map([ + ["Alphas killed", stats.InfectionSTats.AlphasKilled.toString()], + ["Infected killed", stats.InfectionSTats.InfectedKilled.toString()], + ["Kills as last spartan standing", stats.InfectionSTats.KillsAsLastSpartanStanding.toString()], + ["Rounds survived as spartan", stats.InfectionSTats.RoundsSurvivedAsSpartan.toString()], + [ + "Time as last spartan standing", + this.haloService.getReadableDuration(stats.InfectionSTats.TimeAsLastSpartanStanding), + ], + ["Spartans infected", stats.InfectionSTats.SpartansInfected.toString()], + ["Spartans infected as alpha", stats.InfectionSTats.SpartansInfectedAsAlpha.toString()], + ]); + } +} diff --git a/src/commands/stats/embeds/koth-match-embed.mts b/src/commands/stats/embeds/koth-match-embed.mts new file mode 100644 index 0000000..30b2c67 --- /dev/null +++ b/src/commands/stats/embeds/koth-match-embed.mts @@ -0,0 +1,16 @@ +import { GameVariantCategory } from "halo-infinite-api"; +import { BaseMatchEmbed, PlayerStats } from "./base-match-embed.mjs"; + +export class KOTHMatchEmbed extends BaseMatchEmbed { + override getPlayerObjectiveStats( + stats: PlayerStats, + ): Map { + return new Map([ + ["Captures", stats.ZonesStats.StrongholdCaptures.toString()], + ["Occupation time", this.haloService.getReadableDuration(stats.ZonesStats.StrongholdOccupationTime)], + ["Secures", stats.ZonesStats.StrongholdSecures.toString()], + ["Offensive kills", stats.ZonesStats.StrongholdOffensiveKills.toString()], + ["Defensive kills", stats.ZonesStats.StrongholdDefensiveKills.toString()], + ]); + } +} diff --git a/src/commands/stats/embeds/land-grab-match-embed.mts b/src/commands/stats/embeds/land-grab-match-embed.mts new file mode 100644 index 0000000..8c8c5d1 --- /dev/null +++ b/src/commands/stats/embeds/land-grab-match-embed.mts @@ -0,0 +1,8 @@ +import { GameVariantCategory } from "halo-infinite-api"; +import { BaseMatchEmbed } from "./base-match-embed.mjs"; + +export class LandGrabMatchEmbed extends BaseMatchEmbed { + override getPlayerObjectiveStats(): Map { + return new Map([]); + } +} diff --git a/src/commands/stats/embeds/minigame-match-embed.mts b/src/commands/stats/embeds/minigame-match-embed.mts new file mode 100644 index 0000000..2bb32b3 --- /dev/null +++ b/src/commands/stats/embeds/minigame-match-embed.mts @@ -0,0 +1,8 @@ +import { GameVariantCategory } from "halo-infinite-api"; +import { BaseMatchEmbed } from "./base-match-embed.mjs"; + +export class MinigameMatchEmbed extends BaseMatchEmbed { + override getPlayerObjectiveStats(): Map { + return new Map([]); + } +} diff --git a/src/commands/stats/embeds/oddball-match-embed.mts b/src/commands/stats/embeds/oddball-match-embed.mts new file mode 100644 index 0000000..4372366 --- /dev/null +++ b/src/commands/stats/embeds/oddball-match-embed.mts @@ -0,0 +1,11 @@ +import { GameVariantCategory } from "halo-infinite-api"; +import { BaseMatchEmbed, PlayerStats } from "./base-match-embed.mjs"; + +export class OddballMatchEmbed extends BaseMatchEmbed { + override getPlayerObjectiveStats(stats: PlayerStats): Map { + return new Map([ + ["Total time as carrier", this.haloService.getReadableDuration(stats.OddballStats.TimeAsSkullCarrier)], + ["Longest time as carrier", this.haloService.getReadableDuration(stats.OddballStats.LongestTimeAsSkullCarrier)], + ]); + } +} diff --git a/src/commands/stats/embeds/slayer-match-embed.mts b/src/commands/stats/embeds/slayer-match-embed.mts new file mode 100644 index 0000000..79ba634 --- /dev/null +++ b/src/commands/stats/embeds/slayer-match-embed.mts @@ -0,0 +1,8 @@ +import { GameVariantCategory } from "halo-infinite-api"; +import { BaseMatchEmbed } from "./base-match-embed.mjs"; + +export class SlayerMatchEmbed extends BaseMatchEmbed { + override getPlayerObjectiveStats(): Map { + return new Map([]); + } +} diff --git a/src/commands/stats/embeds/stockpile-match-embed.mts b/src/commands/stats/embeds/stockpile-match-embed.mts new file mode 100644 index 0000000..f3636f2 --- /dev/null +++ b/src/commands/stats/embeds/stockpile-match-embed.mts @@ -0,0 +1,15 @@ +import { GameVariantCategory } from "halo-infinite-api"; +import { BaseMatchEmbed, PlayerStats } from "./base-match-embed.mjs"; + +export class StockpileMatchEmbed extends BaseMatchEmbed { + override getPlayerObjectiveStats(stats: PlayerStats): Map { + return new Map([ + ["Power seeds deposited", stats.StockpileStats.PowerSeedsDeposited.toString()], + ["Power seeds stolen", stats.StockpileStats.PowerSeedsStolen.toString()], + ["Kills as power seed carrier", stats.StockpileStats.KillsAsPowerSeedCarrier.toString()], + ["Power seed carriers killed", stats.StockpileStats.PowerSeedCarriersKilled.toString()], + ["Time as power seed carrier", this.haloService.getReadableDuration(stats.StockpileStats.TimeAsPowerSeedCarrier)], + ["Time as power seed driver", this.haloService.getReadableDuration(stats.StockpileStats.TimeAsPowerSeedDriver)], + ]); + } +} diff --git a/src/commands/stats/embeds/strongholds-match-embed.mts b/src/commands/stats/embeds/strongholds-match-embed.mts new file mode 100644 index 0000000..f523a2d --- /dev/null +++ b/src/commands/stats/embeds/strongholds-match-embed.mts @@ -0,0 +1,16 @@ +import { GameVariantCategory } from "halo-infinite-api"; +import { BaseMatchEmbed, PlayerStats } from "./base-match-embed.mjs"; + +export class StrongholdsMatchEmbed extends BaseMatchEmbed { + override getPlayerObjectiveStats( + stats: PlayerStats, + ): Map { + return new Map([ + ["Captures", stats.ZonesStats.StrongholdCaptures.toString()], + ["Occupation time", this.haloService.getReadableDuration(stats.ZonesStats.StrongholdOccupationTime)], + ["Secures", stats.ZonesStats.StrongholdSecures.toString()], + ["Offensive kills", stats.ZonesStats.StrongholdOffensiveKills.toString()], + ["Defensive kills", stats.ZonesStats.StrongholdDefensiveKills.toString()], + ]); + } +} diff --git a/src/commands/stats/embeds/total-control-match-embed.mts b/src/commands/stats/embeds/total-control-match-embed.mts new file mode 100644 index 0000000..d41a60c --- /dev/null +++ b/src/commands/stats/embeds/total-control-match-embed.mts @@ -0,0 +1,8 @@ +import { GameVariantCategory } from "halo-infinite-api"; +import { BaseMatchEmbed } from "./base-match-embed.mjs"; + +export class TotalControlMatchEmbed extends BaseMatchEmbed { + override getPlayerObjectiveStats(): Map { + return new Map([]); + } +} diff --git a/src/commands/stats/embeds/unknown-match-embed.mts b/src/commands/stats/embeds/unknown-match-embed.mts new file mode 100644 index 0000000..6f00645 --- /dev/null +++ b/src/commands/stats/embeds/unknown-match-embed.mts @@ -0,0 +1,8 @@ +import { GameVariantCategory } from "halo-infinite-api"; +import { BaseMatchEmbed } from "./base-match-embed.mjs"; + +export class UnknownMatchEmbed extends BaseMatchEmbed { + override getPlayerObjectiveStats(): Map { + return new Map([]); + } +} diff --git a/src/commands/stats/stats.mts b/src/commands/stats/stats.mts index f0b522d..8cdfb55 100644 --- a/src/commands/stats/stats.mts +++ b/src/commands/stats/stats.mts @@ -1,31 +1,102 @@ import { SlashCommandBuilder, ChatInputCommandInteraction, EmbedBuilder } from "discord.js"; import { BaseCommand } from "../base/base.mjs"; -import { Preconditions } from "../../utils/preconditions.mjs"; -import { MatchStats } from "halo-infinite-api"; +import { Preconditions } from "../../base/preconditions.mjs"; +import { GameVariantCategory, MatchStats } from "halo-infinite-api"; import { QueueData } from "../../services/discord/discord.mjs"; +import { inspect } from "util"; +import { BaseMatchEmbed } from "./embeds/base-match-embed.mjs"; +import { AttritionMatchEmbed } from "./embeds/attrition-match-embed.mjs"; +import { CtfMatchEmbed } from "./embeds/ctf-match-embed.mjs"; +import { EliminationMatchEmbed } from "./embeds/elimination-match-embed.mjs"; +import { EscalationMatchEmbed } from "./embeds/escalation-match-embed.mjs"; +import { ExtractionMatchEmbed } from "./embeds/extraction-match-embed.mjs"; +import { FiestaMatchEmbed } from "./embeds/fiesta-match-embed.mjs"; +import { FirefightMatchEmbed } from "./embeds/firefight-match-embed.mjs"; +import { GrifballMatchEmbed } from "./embeds/grifball-match-embed.mjs"; +import { InfectionMatchEmbed } from "./embeds/infection-match-embed.mjs"; +import { KOTHMatchEmbed } from "./embeds/koth-match-embed.mjs"; +import { LandGrabMatchEmbed } from "./embeds/land-grab-match-embed.mjs"; +import { MinigameMatchEmbed } from "./embeds/minigame-match-embed.mjs"; +import { OddballMatchEmbed } from "./embeds/oddball-match-embed.mjs"; +import { SlayerMatchEmbed } from "./embeds/slayer-match-embed.mjs"; +import { StockpileMatchEmbed } from "./embeds/stockpile-match-embed.mjs"; +import { StrongholdsMatchEmbed } from "./embeds/strongholds-match-embed.mjs"; +import { TotalControlMatchEmbed } from "./embeds/total-control-match-embed.mjs"; +import { UnknownMatchEmbed } from "./embeds/unknown-match-embed.mjs"; + +const MATCH_ID_EXAMPLE = "d9d77058-f140-4838-8f41-1a3406b28566"; export class StatsCommand extends BaseCommand { data = new SlashCommandBuilder() .setName("stats") - .setDescription("Pulls stats") - .addChannelOption((option) => - option.setName("channel").setDescription("The channel to echo into").setRequired(true), - ) - .addIntegerOption((option) => - option.setName("queue").setDescription("The Queue number for the series").setRequired(true), + .setDescription("Pulls stats from Halo waypoint") + .addSubcommand((subcommand) => + subcommand + .setName("neatqueue") + .setDescription("Pulls stats for a NeatQueue series result") + .addChannelOption((option) => + option + .setName("channel") + .setDescription("The channel which has the NeatQueue result message") + .setRequired(true), + ) + .addIntegerOption((option) => + option.setName("queue").setDescription("The Queue number for the series").setRequired(true), + ) + .addBooleanOption((option) => + option + .setName("private") + .setDescription("Only provide the response to you instead of the channel") + .setRequired(false), + ), ) - .addBooleanOption((option) => - option.setName("debug").setDescription("Debug mode, will only set ephemeral to true").setRequired(false), + .addSubcommand((subcommand) => + subcommand + .setName("match") + .setDescription("Pulls stats for a specific match") + .addStringOption((option) => + option + .setName("id") + .setDescription(`The match ID (example: ${MATCH_ID_EXAMPLE})`) + .setRequired(true) + .setMinLength(MATCH_ID_EXAMPLE.length) + .setMaxLength(MATCH_ID_EXAMPLE.length), + ) + .addBooleanOption((option) => + option + .setName("private") + .setDescription("Only provide the response to you instead of the channel") + .setRequired(false), + ), ); async execute(interaction: ChatInputCommandInteraction): Promise { + console.log(inspect(interaction, { depth: 10, colors: true, compact: false })); + + console.log( + `StatsCommand execute from ${interaction.user.globalName ?? interaction.user.username}: ${interaction.options.getSubcommand()}`, + ); + + switch (interaction.options.getSubcommand()) { + case "neatqueue": + await this.handleNeatQueueSubCommand(interaction); + break; + case "match": + await this.handleMatchSubCommand(interaction); + break; + default: + await interaction.reply("Unknown subcommand"); + break; + } + } + + private async handleNeatQueueSubCommand(interaction: ChatInputCommandInteraction) { const channel = interaction.options.get("channel", true); const queue = interaction.options.get("queue", true); - const ephemeral = interaction.options.getBoolean("debug") ?? false; + const ephemeral = interaction.options.getBoolean("private") ?? false; let deferred = false; try { - console.log(`StatsCommand execute from ${interaction.user.globalName ?? interaction.user.username}`); const channelValue = Preconditions.checkExists(channel.channel); const queueValue = queue.value as number; @@ -45,13 +116,25 @@ export class StatsCommand extends BaseCommand { return; } - console.log(`Queue data: ${JSON.stringify(queueData)}`); - const series = await this.services.haloService.getSeriesFromDiscordQueue(queueData); + const seriesEmbed = await this.createSeriesEmbed(queueData, queueValue, series); - await interaction.editReply({ - embeds: [await this.createEmbed(queueData, queueValue, series)], + const message = await interaction.editReply({ + embeds: [seriesEmbed], + }); + + const thread = await message.startThread({ + name: `In depth match stats for queue #${queueValue.toString()}`, + autoArchiveDuration: 60, }); + + for (const match of series) { + const players = await this.services.haloService.getPlayerXuidsToGametags(match); + const matchEmbed = this.getMatchEmbed(match); + const embed = await matchEmbed.getEmbed(match, players); + + await thread.send({ embeds: [embed] }); + } } catch (error) { const reply = { content: `Failed to fetch (Channel: <#${channel.channel?.id ?? "unknown"}>, queue: ${queue.value?.toString() ?? "unknown"}): ${error instanceof Error ? error.message : "unknown"}`, @@ -66,14 +149,65 @@ export class StatsCommand extends BaseCommand { } } - private async createEmbed(queueData: QueueData, queue: number, series: MatchStats[]) { + private async handleMatchSubCommand(interaction: ChatInputCommandInteraction) { + const matchId = interaction.options.get("id", true); + const ephemeral = interaction.options.getBoolean("private") ?? false; + let deferred = false; + + try { + await interaction.deferReply({ ephemeral }); + deferred = true; + const matches = await this.services.haloService.getMatchDetails([matchId.value as string]); + + if (!matches.length) { + await interaction.editReply({ content: "Match not found" }); + return; + } + + const match = Preconditions.checkExists(matches[0]); + const players = await this.services.haloService.getPlayerXuidsToGametags(match); + + const matchEmbed = this.getMatchEmbed(match); + const embed = await matchEmbed.getEmbed(match, players); + + await interaction.editReply({ + embeds: [embed], + }); + } catch (error) { + const reply = { + content: `Error: ${error instanceof Error ? error.message : "unknown"}`, + }; + if (deferred) { + await interaction.editReply(reply); + } else { + await interaction.reply({ ...reply, ephemeral: true }); + } + + console.error(error); + } + } + + private addEmbedFields(embed: EmbedBuilder, titles: string[], data: string[][]) { + for (let column = 0; column < titles.length; column++) { + embed.addFields({ + name: Preconditions.checkExists(titles[column]), + value: data + .slice(1) + .map((row) => row[column]) + .join("\n"), + inline: true, + }); + } + } + + private async createSeriesEmbed(queueData: QueueData, queue: number, series: MatchStats[]) { const { haloService } = this.services; const titles = ["Game", "Duration", "Score"]; const tableData = [titles]; for (const seriesMatch of series) { const gameTypeAndMap = await haloService.getGameTypeAndMap(seriesMatch); - const gameDuration = haloService.getGameDuration(seriesMatch); - const gameScore = haloService.getGameScore(seriesMatch); + const gameDuration = haloService.getReadableDuration(seriesMatch.MatchInfo.Duration); + const gameScore = haloService.getMatchScore(seriesMatch); tableData.push([gameTypeAndMap, gameDuration, gameScore]); } @@ -86,17 +220,51 @@ export class StatsCommand extends BaseCommand { .setURL(`https://discord.com/channels/${guildId}/${channelId}/${messageId}`) .setColor("DarkBlue"); - for (let column = 0; column < titles.length; column++) { - embed.addFields({ - name: Preconditions.checkExists(titles[column]), - value: tableData - .slice(1) - .map((row) => row[column]) - .join("\n"), - inline: true, - }); - } + this.addEmbedFields(embed, titles, tableData); return embed; } + + private getMatchEmbed(match: MatchStats): BaseMatchEmbed { + const { haloService } = this.services; + + switch (match.MatchInfo.GameVariantCategory) { + case GameVariantCategory.MultiplayerAttrition: + return new AttritionMatchEmbed(haloService); + case GameVariantCategory.MultiplayerCtf: + return new CtfMatchEmbed(haloService); + case GameVariantCategory.MultiplayerElimination: + return new EliminationMatchEmbed(haloService); + case GameVariantCategory.MultiplayerEscalation: + return new EscalationMatchEmbed(haloService); + case GameVariantCategory.MultiplayerExtraction: + return new ExtractionMatchEmbed(haloService); + case GameVariantCategory.MultiplayerFiesta: + return new FiestaMatchEmbed(haloService); + case GameVariantCategory.MultiplayerFirefight: + return new FirefightMatchEmbed(haloService); + case GameVariantCategory.MultiplayerGrifball: + return new GrifballMatchEmbed(haloService); + case GameVariantCategory.MultiplayerInfection: + return new InfectionMatchEmbed(haloService); + case GameVariantCategory.MultiplayerKingOfTheHill: + return new KOTHMatchEmbed(haloService); + case GameVariantCategory.MultiplayerLandGrab: + return new LandGrabMatchEmbed(haloService); + case GameVariantCategory.MultiplayerMinigame: + return new MinigameMatchEmbed(haloService); + case GameVariantCategory.MultiplayerOddball: + return new OddballMatchEmbed(haloService); + case GameVariantCategory.MultiplayerSlayer: + return new SlayerMatchEmbed(haloService); + case GameVariantCategory.MultiplayerStockpile: + return new StockpileMatchEmbed(haloService); + case GameVariantCategory.MultiplayerStrongholds: + return new StrongholdsMatchEmbed(haloService); + case GameVariantCategory.MultiplayerTotalControl: + return new TotalControlMatchEmbed(haloService); + default: + return new UnknownMatchEmbed(haloService); + } + } } diff --git a/src/config.mts b/src/config.mts index 492a05a..c8b732b 100644 --- a/src/config.mts +++ b/src/config.mts @@ -1,4 +1,4 @@ -import { Preconditions } from "./utils/preconditions.mjs"; +import { Preconditions } from "./base/preconditions.mjs"; export const config = { DISCORD_APP_ID: Preconditions.checkExists(process.env["DISCORD_APP_ID"]), diff --git a/src/services/discord/discord.mts b/src/services/discord/discord.mts index a526b12..c50989c 100644 --- a/src/services/discord/discord.mts +++ b/src/services/discord/discord.mts @@ -11,7 +11,7 @@ import { } from "discord.js"; import { BaseCommand } from "../../commands/base/base.mjs"; import { config } from "../../config.mjs"; -import { Preconditions } from "../../utils/preconditions.mjs"; +import { Preconditions } from "../../base/preconditions.mjs"; const NEAT_QUEUE_BOT_USER_ID = "857633321064595466"; diff --git a/src/services/halo/halo.mts b/src/services/halo/halo.mts index 26d2594..11a0040 100644 --- a/src/services/halo/halo.mts +++ b/src/services/halo/halo.mts @@ -13,7 +13,7 @@ import { XstsTokenProvider } from "./xsts-token-provider.mjs"; import { User } from "discord.js"; import { differenceInHours, isBefore } from "date-fns"; import { QueueData } from "../discord/discord.mjs"; -import { Preconditions } from "../../utils/preconditions.mjs"; +import { Preconditions } from "../../base/preconditions.mjs"; interface HaloServiceOpts { xboxService: XboxService; @@ -30,7 +30,11 @@ export class HaloService { const users = queueData.teams.flatMap((team) => team.players); const xboxUsers = await this.getXboxUsers(users); const matchesForUsers = await this.getMatchesForUsers(xboxUsers, queueData.timestamp); - const matchDetails = await this.getMatchDetails(matchesForUsers); + const matchDetails = await this.getMatchDetails(matchesForUsers, (match) => { + const parsedDuration = tinyduration.parse(match.MatchInfo.Duration); + // we want at least 2 minutes of game play, otherwise assume that the match was chalked + return (parsedDuration.days ?? 0) > 0 || (parsedDuration.hours ?? 0) > 0 || (parsedDuration.minutes ?? 0) >= 2; + }); const finalMatch = Preconditions.checkExists(matchDetails[matchDetails.length - 1]); const finalMatchPlayers = finalMatch.Players.map((player) => player.PlayerId); const seriesMatches = matchDetails.filter((match) => @@ -97,24 +101,21 @@ export class HaloService { return scopedMatches.map(([matchID]) => matchID); } - private async getMatchDetails(matchIDs: string[]) { + async getMatchDetails(matchIDs: string[], filter?: (match: MatchStats, index: number) => boolean) { const matchStats = await Promise.all(matchIDs.map((matchID) => this.client.getMatchStats(matchID))); + const filteredMatches = filter ? matchStats.filter((match, index) => filter(match, index)) : matchStats; - return matchStats - .filter((match) => { - const parsedDuration = tinyduration.parse(match.MatchInfo.Duration); - // we want at least 2 minutes of game play, otherwise assume that the match was chalked - return (parsedDuration.days ?? 0) > 0 || (parsedDuration.hours ?? 0) > 0 || (parsedDuration.minutes ?? 0) >= 2; - }) - .sort((a, b) => new Date(a.MatchInfo.StartTime).getTime() - new Date(b.MatchInfo.StartTime).getTime()); + return filteredMatches.sort( + (a, b) => new Date(a.MatchInfo.StartTime).getTime() - new Date(b.MatchInfo.StartTime).getTime(), + ); } async getGameTypeAndMap(match: MatchStats) { const mapName = await this.getMapName(match); - return `${this.getGameVariant(match)}: ${mapName}`; + return `${this.getMatchVariant(match)}: ${mapName}`; } - getGameScore(match: MatchStats) { + getMatchScore(match: MatchStats) { const scoreCompare = match.Teams.map((team) => team.Stats.CoreStats.Score); if (match.MatchInfo.GameVariantCategory === GameVariantCategory.MultiplayerOddball) { @@ -126,8 +127,33 @@ export class HaloService { return scoreCompare.join(":"); } - getGameDuration(match: MatchStats) { - const parsedDuration = tinyduration.parse(match.MatchInfo.Duration); + getTeamName(teamId: number) { + switch (teamId) { + case 0: + return "Eagle"; + case 1: + return "Cobra"; + case 2: + return "Green"; + case 3: + return "Orange"; + default: + return "Unknown"; + } + } + + getPlayerXuid(player: Pick) { + return player.PlayerId.replace(/^xuid\((\d+)\)$/, "$1"); + } + + async getPlayerXuidsToGametags(match: MatchStats): Promise> { + const playerNames = await this.client.getUsers(match.Players.map((player) => this.getPlayerXuid(player))); + + return new Map(playerNames.map((player) => [player.xuid, player.gamertag])); + } + + getReadableDuration(duration: string) { + const parsedDuration = tinyduration.parse(duration); const output: string[] = []; if (parsedDuration.days) { output.push(`${parsedDuration.days.toString()}d`); @@ -153,7 +179,7 @@ export class HaloService { return mapData.PublicName; } - private getGameVariant(match: MatchStats) { + private getMatchVariant(match: MatchStats) { switch (match.MatchInfo.GameVariantCategory) { case GameVariantCategory.MultiplayerAttrition: return "Attrition"; diff --git a/src/services/halo/xsts-token-provider.mts b/src/services/halo/xsts-token-provider.mts index c3c4b65..d9100b6 100644 --- a/src/services/halo/xsts-token-provider.mts +++ b/src/services/halo/xsts-token-provider.mts @@ -1,6 +1,6 @@ import { SpartanTokenProvider, StaticXstsTicketTokenSpartanTokenProvider } from "halo-infinite-api"; import { XboxService } from "../xbox/xbox.mjs"; -import { Preconditions } from "../../utils/preconditions.mjs"; +import { Preconditions } from "../../base/preconditions.mjs"; export class XstsTokenProvider implements SpartanTokenProvider { private readonly xboxService: XboxService;