diff --git a/CHANGELOG b/CHANGELOG index 8b6213e..6584b34 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,7 @@ +## 0.1.17 + +feat: add gears to stat.ink + ## 0.1.16 fix: RankTracker broken when token expires diff --git a/src/app.ts b/src/app.ts index 0d48095..1c4cfe3 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,7 +6,7 @@ import { State, StateBackend, } from "./state.ts"; -import { getBattleList, isTokenExpired } from "./splatnet3.ts"; +import { getBattleList, getGearPower, isTokenExpired } from "./splatnet3.ts"; import { BattleListType, Game, GameExporter } from "./types.ts"; import { Cache, FileCache } from "./cache.ts"; import { StatInkExporter } from "./exporters/stat.ink.ts"; @@ -65,6 +65,7 @@ export class App { await this.fetchToken(); }, }; + gearMap: Record | null = null; constructor(public opts: Opts) { this.stateBackend = opts.stateBackend ?? @@ -88,6 +89,16 @@ export class App { await this.writeState(DEFAULT_STATE); } } + async getGearMap() { + if (this.gearMap) { + return this.gearMap; + } + const { gearPowers } = await getGearPower(this.state); + this.gearMap = Object.fromEntries( + gearPowers.nodes.map((i, id) => [i.name, id]), + ); + return this.gearMap; + } getSkipMode(): ("vs" | "coop")[] { const mode = this.opts.skipMode; if (mode === "vs") { @@ -115,10 +126,13 @@ export class App { }); } out.push( - new StatInkExporter( - this.state.statInkApiKey!, - this.opts.monitor ? "Monitoring" : "Manual", - ), + new StatInkExporter({ + statInkApiKey: this.state.statInkApiKey!, + uploadMode: this.opts.monitor ? "Monitoring" : "Manual", + nameDict: { + gearPower: await this.getGearMap(), + }, + }), ); } @@ -210,6 +224,8 @@ export class App { ), ); + endBar(); + printStats(stats); if (errors.length > 0) { throw errors[0]; @@ -221,8 +237,6 @@ export class App { ...this.state, rankState: finalRankState, }); - - endBar(); } stats = initStats(); @@ -268,12 +282,12 @@ export class App { ), ); + endBar(); + printStats(stats); if (errors.length > 0) { throw errors[0]; } - - endBar(); } } async monitor() { diff --git a/src/constant.ts b/src/constant.ts index 5bb9401..83ef628 100644 --- a/src/constant.ts +++ b/src/constant.ts @@ -1,7 +1,7 @@ import type { StatInkPostBody, VsHistoryDetail } from "./types.ts"; export const AGENT_NAME = "s3si.ts"; -export const S3SI_VERSION = "0.1.16"; +export const S3SI_VERSION = "0.1.17"; export const NSOAPP_VERSION = "2.3.1"; export const WEB_VIEW_VERSION = "1.0.0-5644e7a2"; export const S3SI_LINK = "https://github.com/spacemeowx2/s3si.ts"; diff --git a/src/exporters/stat.ink.ts b/src/exporters/stat.ink.ts index 86b3d00..5e57573 100644 --- a/src/exporters/stat.ink.ts +++ b/src/exporters/stat.ink.ts @@ -9,6 +9,10 @@ import { import { CoopInfo, GameExporter, + PlayerGear, + StatInkAbility, + StatInkGear, + StatInkGears, StatInkPlayer, StatInkPostBody, StatInkPostResponse, @@ -17,7 +21,7 @@ import { VsInfo, VsPlayer, } from "../types.ts"; -import { base64, msgpack } from "../../deps.ts"; +import { base64, msgpack, Mutex } from "../../deps.ts"; import { APIError } from "../APIError.ts"; import { cache, gameId } from "../utils.ts"; @@ -30,13 +34,29 @@ function b64Number(id: string): number { return parseInt(num); } +const FETCH_LOCK = new Mutex(); +async function _getAbility(): Promise { + const release = await FETCH_LOCK.acquire(); + try { + const resp = await fetch("https://stat.ink/api/v3/ability?full=1"); + const json = await resp.json(); + return json; + } finally { + release(); + } +} async function _getStage(): Promise { const resp = await fetch("https://stat.ink/api/v3/stage"); const json = await resp.json(); return json; } +const getAbility = cache(_getAbility); const getStage = cache(_getStage); +export type NameDict = { + gearPower: Record; +}; + /** * Exporter to stat.ink. * @@ -44,11 +64,23 @@ const getStage = cache(_getStage); */ export class StatInkExporter implements GameExporter { name = "stat.ink"; + private statInkApiKey: string; + private uploadMode: string; + private nameDict: NameDict; - constructor(private statInkApiKey: string, private uploadMode: string) { + constructor( + { statInkApiKey, uploadMode, nameDict }: { + statInkApiKey: string; + uploadMode: string; + nameDict: NameDict; + }, + ) { if (statInkApiKey.length !== 43) { throw new Error("Invalid stat.ink API key"); } + this.statInkApiKey = statInkApiKey; + this.uploadMode = uploadMode; + this.nameDict = nameDict; } requestHeaders() { return { @@ -157,7 +189,43 @@ export class StatInkExporter implements GameExporter { return result.key; } - mapPlayer(player: VsPlayer, index: number): StatInkPlayer { + async mapGears( + { headGear, clothingGear, shoesGear }: VsPlayer, + ): Promise { + const amap = await getAbility(); + const mapAbility = ({ name }: { name: string }): string | null => { + const abilityIdx = this.nameDict.gearPower[name]; + if (!abilityIdx) { + return null; + } + const result = amap[abilityIdx]; + if (!result) { + return null; + } + return result.key; + }; + const mapGear = ( + { primaryGearPower, additionalGearPowers }: PlayerGear, + ): StatInkGear => { + const primary = mapAbility(primaryGearPower); + if (!primary) { + throw new Error("Unknown ability: " + primaryGearPower.name); + } + return { + primary_ability: primary, + secondary_abilities: additionalGearPowers.map(mapAbility), + }; + }; + return { + headgear: mapGear(headGear), + clothing: mapGear(clothingGear), + shoes: mapGear(shoesGear), + }; + } + mapPlayer = async ( + player: VsPlayer, + index: number, + ): Promise => { const result: StatInkPlayer = { me: player.isMyself ? "yes" : "no", rank_in_team: index + 1, @@ -166,6 +234,7 @@ export class StatInkExporter implements GameExporter { splashtag_title: player.byname, weapon: b64Number(player.weapon.id).toString(), inked: player.paint, + gears: await this.mapGears(player), disconnected: player.result ? "no" : "yes", }; if (player.result) { @@ -176,7 +245,7 @@ export class StatInkExporter implements GameExporter { result.special = player.result.special; } return result; - } + }; async mapBattle( { challengeProgress, @@ -216,9 +285,11 @@ export class StatInkExporter implements GameExporter { medals: vsDetail.awards.map((i) => i.name), - our_team_players: myTeam.players.map(this.mapPlayer), - their_team_players: otherTeams.flatMap((i) => i.players).map( - this.mapPlayer, + our_team_players: await Promise.all(myTeam.players.map(this.mapPlayer)), + their_team_players: await Promise.all( + otherTeams.flatMap((i) => i.players).map( + this.mapPlayer, + ), ), agent: AGENT_NAME, diff --git a/src/splatnet3.ts b/src/splatnet3.ts index 9ba8a41..9e3e021 100644 --- a/src/splatnet3.ts +++ b/src/splatnet3.ts @@ -162,3 +162,12 @@ export async function getCoopHistories(state: State) { return resp; } + +export async function getGearPower(state: State) { + const resp = await request( + state, + Queries.myOutfitCommonDataFilteringConditionQuery, + ); + + return resp; +} diff --git a/src/types.ts b/src/types.ts index d1a72d4..b0dbf91 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,8 @@ export enum Queries { VsHistoryDetailQuery = "2b085984f729cd51938fc069ceef784a", CoopHistoryQuery = "817618ce39bcf5570f52a97d73301b30", CoopHistoryDetailQuery = "f3799a033f0a7ad4b1b396f9a3bafb1e", + myOutfitCommonDataFilteringConditionQuery = + "d02ab22c9dccc440076055c8baa0fa7a", } export type VarsMap = { [Queries.HomeQuery]: []; @@ -23,6 +25,7 @@ export type VarsMap = { [Queries.CoopHistoryDetailQuery]: [{ coopHistoryDetailId: string; }]; + [Queries.myOutfitCommonDataFilteringConditionQuery]: []; }; export type Image = { @@ -61,6 +64,19 @@ export type HistoryGroups = { }; }[]; }; +export type PlayerGear = { + name: string; + primaryGearPower: { + name: string; + }; + additionalGearPowers: { + name: string; + }[]; + brand: { + name: string; + id: string; + }; +}; export type VsPlayer = { id: string; nameId: string | null; @@ -81,6 +97,10 @@ export type VsPlayer = { special: number; } | null; paint: number; + + headGear: PlayerGear; + clothingGear: PlayerGear; + shoesGear: PlayerGear; }; export type VsTeam = { players: VsPlayer[]; @@ -223,6 +243,13 @@ export type RespMap = { [Queries.CoopHistoryDetailQuery]: { coopHistoryDetail: CoopHistoryDetail; }; + [Queries.myOutfitCommonDataFilteringConditionQuery]: { + gearPowers: { + nodes: { + name: string; + }[]; + }; + }; }; export type GraphQLResponse = { data: T; @@ -240,6 +267,23 @@ export enum BattleListType { Coop, } +export type StatInkAbility = { + key: string; + name: Record; + primary_only: boolean; +}[]; + +export type StatInkGear = { + primary_ability: string; + secondary_abilities: (string | null)[]; +}; + +export type StatInkGears = { + headgear: StatInkGear; + clothing: StatInkGear; + shoes: StatInkGear; +}; + export type StatInkPlayer = { me: "yes" | "no"; rank_in_team: number; @@ -253,6 +297,7 @@ export type StatInkPlayer = { kill_or_assist?: number; death?: number; special?: number; + gears?: StatInkGears; disconnected: "yes" | "no"; };