diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000..e27b814 --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,23 @@ +# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created +# For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages + +name: Node.js Package + +on: + release: + types: [published] + +jobs: + publish-npm: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 16 + registry-url: https://registry.npmjs.org/ + - run: npm ci + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{secrets.npm_token}} diff --git a/README.md b/README.md index 8fcdc0e..62d8423 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,10 @@ The package is currently limited to the endpoints I've needed to use in other pr - GET https://profile.svc.halowaypoint.com/users/{gamerTag} - GET https://skill.svc.halowaypoint.com/hi/playlist/{playlistId}/csrs?players={playerIds} -- GET https://halostats.svc.halowaypoint.com/hi/playlist/{playlistId}/csrs?players={playerIds} - GET https://gamecms-hacs.svc.halowaypoint.com/hi/multiplayer/file/playlists/assets/{playlistId}.json +- GET https://halostats.svc.halowaypoint.com/hi/playlist/{playlistId}/csrs?players={playerIds} +- GET https://halostats.svc.halowaypoint.com/hi/players/xuid({playerId})/matches +- GET https://skill.svc.halowaypoint.com/hi/matches/{matchId}/skill ### Getting Started diff --git a/package.json b/package.json index c3f4a71..46110fa 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "halo-infinite-api", - "version": "1.0.2", + "type": "module", + "version": "1.0.3", "description": "An NPM package for accessing the official Halo Infinite API.", "main": "dist/index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", "prepack": "tsc --project tsconfig.app.json" }, "repository": { diff --git a/src/authentication/halo-authentication-client.ts b/src/authentication/halo-authentication-client.ts index ae6c4a1..ad17e5d 100644 --- a/src/authentication/halo-authentication-client.ts +++ b/src/authentication/halo-authentication-client.ts @@ -1,7 +1,8 @@ -import axios, { AxiosInstance } from "axios"; +import axios from "axios"; +import { DateTime } from "luxon"; import type { SpartanToken } from "../models/spartan-token"; import type { SpartanTokenRequest } from "../models/spartan-token-request"; -import { DateTime } from "luxon"; +import { coalesceDateTime } from "../util/date-time"; export interface Token { token: string; @@ -13,7 +14,10 @@ export class HaloAuthenticationClient { constructor( private readonly fetchXstsToken: () => Promise, - private readonly loadToken: () => Promise, + private readonly loadToken: () => Promise<{ + token?: string; + expiresAt?: unknown; + } | null>, private readonly saveToken: (token: Token) => Promise ) {} @@ -56,11 +60,15 @@ export class HaloAuthenticationClient { }); try { - const currentToken = await this.loadToken(); + const loadedToken = await this.loadToken(); + const currentToken = { + token: loadedToken?.token ?? "", + expiresAt: coalesceDateTime(loadedToken?.expiresAt), + }; - if (currentToken && currentToken.expiresAt > DateTime.now()) { + if (currentToken.expiresAt && currentToken.expiresAt > DateTime.now()) { // Current token is valid, return it and alert other callers if applicable - promiseResolver(currentToken); + promiseResolver(currentToken as Token); return currentToken.token; } else { const xstsToken = await this.fetchXstsToken(); diff --git a/src/authentication/xbox-authentication-client.ts b/src/authentication/xbox-authentication-client.ts index 70f4f1d..901a84a 100644 --- a/src/authentication/xbox-authentication-client.ts +++ b/src/authentication/xbox-authentication-client.ts @@ -2,8 +2,15 @@ import axios, { AxiosInstance } from "axios"; import getPkce from "oauth-pkce"; import { DateTime } from "luxon"; import { XboxTicket } from "../models/xbox-ticket"; +import { coalesceDateTime } from "../util/date-time"; const SCOPES = ["Xboxlive.signin", "Xboxlive.offline_access"]; +// polyfill crypto for oauth-pkce +if (!globalThis.window) globalThis.window = {} as any; +if (!globalThis.window.crypto) { + globalThis.window.crypto = (await import("node:crypto")) + .webcrypto as typeof globalThis.window.crypto; +} export enum RelyingParty { Xbox = "http://xboxlive.com", @@ -25,7 +32,11 @@ export class XboxAuthenticationClient { private readonly clientId: string, private readonly redirectUri: string, private readonly getAuthCode: (authorizeUrl: string) => Promise, - private readonly loadToken: () => Promise, + private readonly loadToken: () => Promise<{ + token?: string; + expiresAt?: unknown; + refreshToken?: string; + } | null>, private readonly saveToken: ( token: XboxAuthenticationToken ) => Promise @@ -92,11 +103,16 @@ export class XboxAuthenticationClient { ); try { - const currentToken = await this.loadToken(); + const loadedToken = await this.loadToken(); + const currentToken = { + ...loadedToken, + token: loadedToken?.token ?? "", + expiresAt: coalesceDateTime(loadedToken?.expiresAt), + }; - if (currentToken && currentToken.expiresAt > DateTime.now()) { + if (currentToken.expiresAt && currentToken.expiresAt > DateTime.now()) { // Current token is valid, return it and alert other callers if applicable - promiseResolver(currentToken); + promiseResolver(currentToken as XboxAuthenticationToken); return currentToken.token; } else { const newToken = await this.fetchOauth2Token(); diff --git a/src/core/halo-infinite-client.ts b/src/core/halo-infinite-client.ts index ddc1fac..a22666c 100644 --- a/src/core/halo-infinite-client.ts +++ b/src/core/halo-infinite-client.ts @@ -12,6 +12,10 @@ import { } from "../authentication/xbox-authentication-client"; import { HaloAuthenticationClient } from "../authentication/halo-authentication-client"; import { Playlist } from "../models/halo-infinite/playlist"; +import { MatchType } from "../models/halo-infinite/match-type"; +import { MatchStats } from "src/models/halo-infinite/match-stats"; +import { PlayerMatchHistory } from "src/models/halo-infinite/player-match-history"; +import { MatchSkill } from "src/models/halo-infinite/match-skill"; interface ResultContainer { Id: string; @@ -23,6 +27,13 @@ interface ResultsContainer { Value: ResultContainer[]; } +interface PaginationContainer { + Start: number; + Count: number; + ResultCount: number; + Results: TValue[]; +} + interface TokenPersister { load: (tokenName: string) => Promise; save: (tokenName: string, token: unknown) => Promise; @@ -111,7 +122,7 @@ export class HaloInfiniteClient { return response.data; } - private async executeArrayRequest( + private async executeResultsRequest( ...args: Parameters ) { const result = await this.executeRequest>(...args); @@ -119,12 +130,31 @@ export class HaloInfiniteClient { return result.Value; } + private async executePaginationRequest( + count: number, + start: number, + queryParameters: Record, + ...args: Parameters + ) { + const [url, ...rest] = args; + const result = await this.executeRequest>( + `${url}?${new URLSearchParams({ + ...queryParameters, + count: count.toString(), + start: start.toString(), + })}`, + ...rest + ); + + return result.Results; + } + /** Gets playlist Competitive Skill Rank (CSR) for a player or a set of players. * @param playlistId - Unique ID for the playlist. * @param playerIds - Array of player xuids. */ public getPlaylistCsr = (playlistId: string, playerIds: string[]) => - this.executeArrayRequest( + this.executeResultsRequest( `https://${HaloCoreEndpoints.SkillOrigin}.${ HaloCoreEndpoints.ServiceDomain }/hi/playlist/${playlistId}/csrs?players=xuid(${playerIds.join( @@ -159,4 +189,32 @@ export class HaloInfiniteClient { `https://${HaloCoreEndpoints.GameCmsOrigin}.${HaloCoreEndpoints.ServiceDomain}/hi/multiplayer/file/playlists/assets/${playlistId}.json`, "get" ); + + public getPlayerMatches = ( + playerXuid: string, + type: MatchType = MatchType.All, + count: number = 25, + start: number = 0 + ) => + this.executePaginationRequest( + count, + start, + { type: type.toString() }, + `https://${HaloCoreEndpoints.StatsOrigin}.${HaloCoreEndpoints.ServiceDomain}/hi/players/xuid(${playerXuid})/matches`, + "get" + ); + + public getMatchStats = (matchId: string) => + this.executeRequest( + `https://${HaloCoreEndpoints.StatsOrigin}.${HaloCoreEndpoints.ServiceDomain}/hi/matches/${matchId}/stats`, + "get" + ); + + public getMatchSkill = (matchId: string, playerIds: string[]) => + this.executeResultsRequest( + `https://${HaloCoreEndpoints.SkillOrigin}.${ + HaloCoreEndpoints.ServiceDomain + }/hi/matches/${matchId}/skill?players=xuid(${playerIds.join("),xuid(")})`, + "get" + ); } diff --git a/src/index.ts b/src/index.ts index dadd2e7..6aafae4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,3 +3,8 @@ export { Playlist } from "./models/halo-infinite/playlist"; export { PlaylistCsrContainer } from "./models/halo-infinite/playlist-csr-container"; export { UserInfo } from "./models/halo-infinite/user-info"; export { ServiceRecord } from "./models/halo-infinite/service-record"; +export { MatchType } from "./models/halo-infinite/match-type"; +export { GameVariantCategory } from "./models/halo-infinite/game-variant-category"; +export { MatchStats } from "./models/halo-infinite/match-stats"; +export { PlayerMatchHistory } from "./models/halo-infinite/player-match-history"; +export { Stats } from "./models/halo-infinite/stats"; diff --git a/src/models/halo-infinite/game-variant-category.ts b/src/models/halo-infinite/game-variant-category.ts new file mode 100644 index 0000000..14bc218 --- /dev/null +++ b/src/models/halo-infinite/game-variant-category.ts @@ -0,0 +1,11 @@ +export enum GameVariantCategory { + MultiplayerSlayer = 6, + MultiplayerAttrition = 7, + MultiplayerFiesta = 9, + MultiplayerStrongholds = 11, + MultiplayerKingOfTheHill = 12, + MultiplayerCtf = 15, + MultiplayerOddball = 18, + MultiplayerGrifball = 25, + MultiplayerLandGrab = 39, +} diff --git a/src/models/halo-infinite/match-info.ts b/src/models/halo-infinite/match-info.ts new file mode 100644 index 0000000..80901c6 --- /dev/null +++ b/src/models/halo-infinite/match-info.ts @@ -0,0 +1,38 @@ +import { GameVariantCategory } from "./game-variant-category"; +import { PlaylistExperience } from "./playlist-experience"; +export interface MatchInfo< + TCategory extends GameVariantCategory = GameVariantCategory +> { + StartTime: string; + EndTime: string; + Duration: string; + LifecycleMode: number; + GameVariantCategory: TCategory; + LevelId: string; + MapVariant: { + AssetKind: number; + AssetId: string; + VersionId: string; + }; + UgcGameVariant: { + AssetKind: number; + AssetId: string; + VersionId: string; + }; + ClearanceId: string; + Playlist: { + AssetKind: number; + AssetId: string; + VersionId: string; + }; + PlaylistExperience: PlaylistExperience; + PlaylistMapModePair: { + AssetKind: number; + AssetId: string; + VersionId: string; + }; + SeasonId: string; + PlayableDuration: string; + TeamsEnabled: boolean; + TeamScoringEnabled: boolean; +} diff --git a/src/models/halo-infinite/match-outcome.ts b/src/models/halo-infinite/match-outcome.ts new file mode 100644 index 0000000..a1cfdc6 --- /dev/null +++ b/src/models/halo-infinite/match-outcome.ts @@ -0,0 +1,4 @@ +export enum MatchOutcome { + Win = 2, + Loss = 3, +} diff --git a/src/models/halo-infinite/match-skill.ts b/src/models/halo-infinite/match-skill.ts new file mode 100644 index 0000000..16025f0 --- /dev/null +++ b/src/models/halo-infinite/match-skill.ts @@ -0,0 +1,48 @@ +interface CsrObject { + Value: number; + MeasurementMatchesRemaining: number; + Tier: string; + TierStart: number; + NextTier: string; + NextTierStart: number; + NextSubTier: number; + InitialMeasurementMatches: number; +} + +interface StatPerformance { + Count: number; + Expected: number; + StdDev: number; +} + +interface Counterfactual { + Kills: number; + Deaths: number; +} + +export interface MatchSkill { + TeamId: number; + TeamMmr: number; + TeamMmrs: { + [key: number]: number; + }; + RankRecap: { + PreMatchCsr: CsrObject; + PostMatchCsr: CsrObject; + }; + StatPerformances: { + Kills: StatPerformance; + Deaths: StatPerformance; + }; + Counterfactuals: { + SelfCounterfactual: Counterfactual; + TierCounterfactuals: { + Bronze: Counterfactual; + Silver: Counterfactual; + Gold: Counterfactual; + Platinum: Counterfactual; + Diamond: Counterfactual; + Onyx: Counterfactual; + }; + }; +} diff --git a/src/models/halo-infinite/match-stats.ts b/src/models/halo-infinite/match-stats.ts new file mode 100644 index 0000000..6a09f17 --- /dev/null +++ b/src/models/halo-infinite/match-stats.ts @@ -0,0 +1,35 @@ +import { GameVariantCategory } from "./game-variant-category"; +import { MatchInfo } from "./match-info"; +import { Stats } from "./stats"; + +export interface MatchStats< + TCategory extends GameVariantCategory = GameVariantCategory +> { + MatchId: string; + MatchInfo: MatchInfo; + Teams: { + TeamId: number; + Outcome: number; + Rank: number; + Stats: Stats; + }[]; + Players: { + PlayerId: string; + LastTeamId: number; + Rank: number; + ParticipationInfo: { + FirstJoinedTime: string; + LastLeaveTime: string | null; + PresentAtBeginning: boolean; + JoinedInProgress: boolean; + LeftInProgress: boolean; + PresentAtCompletion: boolean; + TimePlayed: string; + ConfirmedParticipation: boolean | null; + }; + PlayerTeamStats: { + TeamId: number; + Stats: Stats; + }[]; + }[]; +} diff --git a/src/models/halo-infinite/match-type.ts b/src/models/halo-infinite/match-type.ts new file mode 100644 index 0000000..9868540 --- /dev/null +++ b/src/models/halo-infinite/match-type.ts @@ -0,0 +1,6 @@ +export enum MatchType { + All = 0, + Matchmaking = 1, + Custom = 2, + Local = 3, +} diff --git a/src/models/halo-infinite/player-match-history.ts b/src/models/halo-infinite/player-match-history.ts new file mode 100644 index 0000000..c60e085 --- /dev/null +++ b/src/models/halo-infinite/player-match-history.ts @@ -0,0 +1,11 @@ +import { MatchInfo } from "./match-info"; +import { MatchOutcome } from "./match-outcome"; + +export interface PlayerMatchHistory { + MatchId: string; + LastTeamId: number; + Outcome: MatchOutcome; + Rank: number; + PresentAtEndOfMatch: boolean; + MatchInfo: MatchInfo; +} diff --git a/src/models/halo-infinite/playlist-experience.ts b/src/models/halo-infinite/playlist-experience.ts new file mode 100644 index 0000000..86a9beb --- /dev/null +++ b/src/models/halo-infinite/playlist-experience.ts @@ -0,0 +1,4 @@ +export enum PlaylistExperience { + Arena = 2, + Featured = 5, +} diff --git a/src/models/halo-infinite/stats.ts b/src/models/halo-infinite/stats.ts new file mode 100644 index 0000000..14e6949 --- /dev/null +++ b/src/models/halo-infinite/stats.ts @@ -0,0 +1,85 @@ +import { GameVariantCategory } from "./game-variant-category"; + +interface OddballStats { + KillsAsSkullCarrier: number; + LongestTimeAsSkullCarrier: string; + SkullCarriersKilled: number; + SkullGrabs: number; + TimeAsSkullCarrier: string; + SkullScoringTicks: number; +} +interface ZonesStats { + StrongholdCaptures: number; + StrongholdDefensiveKills: number; + StrongholdOffensiveKills: number; + StrongholdSecures: number; + StrongholdOccupationTime: string; + StrongholdScoringTicks: number; +} +interface CaptureTheFlagStats { + FlagCaptureAssists: number; + FlagCaptures: number; + FlagCarriersKilled: number; + FlagGrabs: number; + FlagReturnersKilled: number; + FlagReturns: number; + FlagSecures: number; + FlagSteals: number; + KillsAsFlagCarrier: number; + KillsAsFlagReturner: number; + TimeAsFlagCarrier: string; +} +type StatsMap = { + [GameVariantCategory.MultiplayerOddball]: { OddballStats: OddballStats }; + [GameVariantCategory.MultiplayerStrongholds]: { ZonesStats: ZonesStats }; + [GameVariantCategory.MultiplayerCtf]: { + CaptureTheFlagStats: CaptureTheFlagStats; + }; + [GameVariantCategory.MultiplayerKingOfTheHill]: { ZonesStats: ZonesStats }; +}; + +export type Stats = { + CoreStats: { + Score: number; + PersonalScore: number; + RoundsWon: number; + RoundsLost: number; + RoundsTied: number; + Kills: number; + Deaths: number; + Assists: number; + KDA: number; + Suicides: number; + Betrayals: number; + AverageLifeDuration: string; + GrenadeKills: number; + HeadshotKills: number; + MeleeKills: number; + PowerWeaponKills: number; + ShotsFired: number; + ShotsHit: number; + Accuracy: number; + DamageDealt: number; + DamageTaken: number; + CalloutAssists: number; + VehicleDestroys: number; + DriverAssists: number; + Hijacks: number; + EmpAssists: number; + MaxKillingSpree: number; + Medals: { + NameId: number; + Count: number; + TotalPersonalScoreAwarded: number; + }[]; + PersonalScores: { + NameId: number; + Count: number; + TotalPersonalScoreAwarded: number; + }[]; + DeprecatedDamageDealt: number; + DeprecatedDamageTaken: number; + Spawns: number; + ObjectivesCompleted: number; + }; +} & (TCategory extends keyof StatsMap ? StatsMap[TCategory] : {}); diff --git a/src/util/date-time.ts b/src/util/date-time.ts new file mode 100644 index 0000000..ffe288a --- /dev/null +++ b/src/util/date-time.ts @@ -0,0 +1,12 @@ +import { DateTime } from "luxon"; + +export function coalesceDateTime(maybeDateTime: unknown) { + if (DateTime.isDateTime(maybeDateTime)) { + return maybeDateTime; + } else if (maybeDateTime instanceof Date) { + return DateTime.fromJSDate(maybeDateTime); + } else if (typeof maybeDateTime === "string") { + return DateTime.fromISO(maybeDateTime); + } + return undefined; +} diff --git a/tsconfig.app.json b/tsconfig.app.json index 474c8a6..b4d6003 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "rootDir": "src", + "rootDir": "./src", "moduleResolution": "node", "outDir": "dist", "declarationDir": "dist", diff --git a/tsconfig.json b/tsconfig.json index 3432cb9..3cf6922 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,9 +25,6 @@ { "path": "./tsconfig.app.json" }, - { - "path": "./tsconfig.build.json" - }, { "path": "./tsconfig.spec.json" }