From bfdaa42f404df97f032aee210954e2ef94bad47c Mon Sep 17 00:00:00 2001 From: spacemeowx2 Date: Fri, 21 Oct 2022 08:06:25 +0800 Subject: [PATCH] feat: add basic stat ink export --- constant.ts | 36 ++++++++- exporter/stat.ink.ts | 186 +++++++++++++++++++++++++++++++++++++++++-- types.ts | 127 +++++++++++++++++++++-------- 3 files changed, 308 insertions(+), 41 deletions(-) diff --git a/constant.ts b/constant.ts index 8beeec1..fc60871 100644 --- a/constant.ts +++ b/constant.ts @@ -1,6 +1,9 @@ +import type { StatInkPostBody, VsHistoryDetail } from "./types.ts"; + +export const AGENT_NAME = "s3si.ts"; export const S3SI_VERSION = "0.1.0"; export const NSOAPP_VERSION = "2.3.1"; -export const USERAGENT = `s3si.ts/${S3SI_VERSION}`; +export const USERAGENT = `${AGENT_NAME}/${S3SI_VERSION}`; export const DEFAULT_APP_USER_AGENT = "Mozilla/5.0 (Linux; Android 11; Pixel 5) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + @@ -9,3 +12,34 @@ export const SPLATNET3_URL = "https://api.lp1.av5ja.srv.nintendo.net"; export const SPLATNET3_ENDPOINT = "https://api.lp1.av5ja.srv.nintendo.net/api/graphql"; export const S3SI_NAMESPACE = "63941e1c-e32e-4b56-9a1d-f6fbe19ef6e1"; + +export const SPLATNET3_STATINK_MAP: { + RULE: Record; + RESULT: Record; + DRAGON: Record< + NonNullable["dragonMatchType"], + StatInkPostBody["fest_dragon"] + >; +} = { + RULE: { + TURF_WAR: "nawabari", + AREA: "area", + LOFT: "yagura", + GOAL: "hoko", + CLAM: "asari", + // TODO: support tri-color + TRI_COLOR: "nawabari", + }, + RESULT: { + WIN: "win", + LOSE: "lose", + DEEMED_LOSE: "lose", + EXEMPTED_LOSE: "exempted_lose", + }, + DRAGON: { + NORMAL: undefined, + DECUPLE: "10x", + DRAGON: "100x", + DOUBLE_DRAGON: "333x", + }, +}; diff --git a/exporter/stat.ink.ts b/exporter/stat.ink.ts index e5a75a3..198cddd 100644 --- a/exporter/stat.ink.ts +++ b/exporter/stat.ink.ts @@ -1,7 +1,21 @@ -import { S3SI_NAMESPACE, USERAGENT } from "../constant.ts"; -import { BattleExporter, VsHistoryDetail } from "../types.ts"; +import { + AGENT_NAME, + S3SI_NAMESPACE, + S3SI_VERSION, + SPLATNET3_STATINK_MAP, + USERAGENT, +} from "../constant.ts"; +import { + BattleExporter, + StatInkPlayer, + StatInkPostBody, + StatInkStage, + VsHistoryDetail, + VsPlayer, +} from "../types.ts"; import { base64, msgpack, uuid } from "../deps.ts"; import { APIError } from "../APIError.ts"; +import { cache } from "../utils.ts"; const S3S_NAMESPACE = "b3a2dbf5-2c09-4792-b78c-00b548b70aeb"; @@ -21,6 +35,22 @@ function battleId(id: string): Promise { return uuid.v5.generate(S3SI_NAMESPACE, new TextEncoder().encode(id)); } +/** + * Decode ID and get number after '-' + */ +function b64Number(id: string): number { + const text = new TextDecoder().decode(base64.decode(id)); + const [_, num] = text.split("-"); + return parseInt(num); +} + +async function _getStage(): Promise { + const resp = await fetch("https://stat.ink/api/v3/stage"); + const json = await resp.json(); + return json; +} +const getStage = cache(_getStage); + /** * Exporter to stat.ink. * @@ -40,9 +70,7 @@ export class StatInkExporter implements BattleExporter { }; } async exportBattle(detail: VsHistoryDetail) { - const body = { - test: "yes", - }; + const body = this.mapBattle(detail); const resp = await fetch("https://stat.ink/api/v3/battle", { method: "POST", @@ -92,4 +120,152 @@ export class StatInkExporter implements BattleExporter { return out; } + mapLobby(vsDetail: VsHistoryDetail): StatInkPostBody["lobby"] { + const { mode: vsMode } = vsDetail.vsMode; + if (vsMode === "REGULAR") { + return "regular"; + } else if (vsMode === "BANKARA") { + const { mode } = vsDetail.bankaraMatch ?? { mode: "UNKNOWN" }; + const map = { + OPEN: "bankara_open", + CHALLENGE: "bankara_challenge", + UNKNOWN: "", + } as const; + const result = map[mode]; + if (result) { + return result; + } + } else if (vsMode === "PRIVATE") { + return "private"; + } else if (vsMode === "FEST") { + const modeId = b64Number(vsDetail.id); + if (modeId === 6) { + return "splatfest_open"; + } else if (modeId === 7) { + return "splatfest_challenge"; + } + } + + throw new TypeError(`Unknown vsMode ${vsMode}`); + } + async mapStage({ vsStage }: VsHistoryDetail): Promise { + const id = b64Number(vsStage.id).toString(); + const stage = await getStage(); + + const result = stage.find((s) => s.aliases.includes(id)); + + if (!result) { + throw new Error("Unknown stage: " + vsStage.name); + } + + return result.key; + } + mapPlayer(player: VsPlayer, index: number): StatInkPlayer { + const result: StatInkPlayer = { + me: player.isMyself ? "yes" : "no", + rank_in_team: index + 1, + name: player.name, + number: player.nameId ?? undefined, + splashtag_title: player.byname, + weapon: b64Number(player.weapon.id).toString(), + inked: player.paint, + disconnected: player.result ? "no" : "yes", + }; + if (player.result) { + result.kill_or_assist = player.result.kill; + result.assist = player.result.assist; + result.kill = result.kill_or_assist - result.assist; + result.death = player.result.death; + result.special = player.result.special; + } + return result; + } + async mapBattle(vsDetail: VsHistoryDetail): Promise { + const { + knockout, + vsMode: { mode }, + myTeam, + otherTeams, + bankaraMatch, + festMatch, + playedTime, + } = vsDetail; + + const self = vsDetail.myTeam.players.find((i) => i.isMyself); + if (!self) { + throw new Error("Self not found"); + } + const startedAt = Math.floor(new Date(playedTime).getTime() / 1000); + + const result: StatInkPostBody = { + test: "yes", + uuid: await battleId(vsDetail.id), + lobby: this.mapLobby(vsDetail), + rule: SPLATNET3_STATINK_MAP.RULE[vsDetail.vsRule.rule], + stage: await this.mapStage(vsDetail), + result: SPLATNET3_STATINK_MAP.RESULT[vsDetail.judgement], + + weapon: b64Number(self.weapon.id).toString(), + inked: self.paint, + rank_in_team: vsDetail.myTeam.players.indexOf(self) + 1, + + 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, + ), + + agent: AGENT_NAME, + agent_version: S3SI_VERSION, + agent_variables: {}, + automated: "yes", + start_at: startedAt, + end_at: startedAt + vsDetail.duration, + }; + + if (self.result) { + if (!bankaraMatch) { + throw new TypeError("bankaraMatch is null"); + } + result.kill_or_assist = self.result.kill; + result.assist = self.result.assist; + result.kill = result.kill_or_assist - result.assist; + } + + if (mode === "FEST") { + if (!festMatch) { + throw new TypeError("festMatch is null"); + } + result.fest_dragon = + SPLATNET3_STATINK_MAP.DRAGON[festMatch.dragonMatchType]; + result.clout_change = festMatch.contribution; + result.fest_power = festMatch.myFestPower ?? undefined; + } + if (mode === "FEST" || mode === "REGULAR") { + result.our_team_percent = (myTeam.result.paintRatio ?? 0) * 100; + result.their_team_percent = (otherTeams?.[0].result.paintRatio ?? 0) * + 100; + result.our_team_inked = myTeam.players.reduce( + (acc, i) => acc + i.paint, + 0, + ); + result.their_team_inked = otherTeams?.[0].players.reduce( + (acc, i) => acc + i.paint, + 0, + ); + } + if (mode === "BANKARA") { + if (!bankaraMatch) { + throw new TypeError("bankaraMatch is null"); + } + result.our_team_count = myTeam.result.score ?? undefined; + result.their_team_count = otherTeams?.[0].result.score ?? undefined; + + result.knockout = (!knockout || knockout === "NEITHER") ? "no" : "yes"; + result.rank_exp_change = bankaraMatch.earnedUdemaePoint; + } + + return result; + } } diff --git a/types.ts b/types.ts index 8692ccb..0cb0272 100644 --- a/types.ts +++ b/types.ts @@ -37,6 +37,34 @@ export type HistoryGroups = { }; }[]; }; +export type VsPlayer = { + id: string; + nameId: string | null; + name: string; + isMyself: boolean; + byname: string; + weapon: { + id: string; + subWeapon: { + id: string; + }; + }; + species: "INKLING" | "OCTOLING"; + result: { + kill: number; + death: number; + assist: number; + special: number; + } | null; + paint: number; +}; +export type VsTeam = { + players: VsPlayer[]; + result: { + paintRatio: null | number; + score: null | number; + }; +}; export type VsHistoryDetail = { id: string; vsRule: { @@ -54,6 +82,23 @@ export type VsHistoryDetail = { image: Image; }; playedTime: string; // 2021-01-01T00:00:00Z + + bankaraMatch: { + earnedUdemaePoint: number; + mode: "OPEN" | "CHALLENGE"; + } | null; + festMatch: { + dragonMatchType: "NORMAL" | "DECUPLE" | "DRAGON" | "DOUBLE_DRAGON"; + contribution: number; + myFestPower: number | null; + } | null; + + myTeam: VsTeam; + otherTeams: VsTeam[]; + judgement: "LOSE" | "WIN" | "DEEMED_LOSE" | "EXEMPTED_LOSE"; + knockout: null | undefined | "NEITHER" | "WIN" | "LOSE"; + awards: { name: string; rank: string }[]; + duration: number; }; export type BattleExporter = { @@ -126,18 +171,30 @@ export type StatInkPlayer = { me: "yes" | "no"; rank_in_team: number; name: string; - number: string; + number: string | undefined; splashtag_title: string; weapon: string; inked: number; - kill: number; - assist: number; - kill_or_assist: number; - death: number; - special: number; + kill?: number; + assist?: number; + kill_or_assist?: number; + death?: number; + special?: number; disconnected: "yes" | "no"; }; +export type StatInkStage = { + key: string; + aliases: string[]; + name: Record; + short_name: Record; + area: number; + release_at: { + time: number; + iso8601: string; + }; +}[]; + export type StatInkPostBody = { test: "yes" | "no"; uuid: string; @@ -152,34 +209,34 @@ export type StatInkPostBody = { stage: string; weapon: string; result: "win" | "lose" | "draw" | "exempted_lose"; - knockout: "yes" | "no" | null; // for TW, set null or not sending - rank_in_team: 1 | 2 | 3 | 4; // position in scoreboard - kill: number; - assist: number; - kill_or_assist: number; // equals to kill + assist if you know them - death: number; - special: number; // use count + knockout?: "yes" | "no"; // for TW, set null or not sending + rank_in_team: number; // position in scoreboard + kill?: number; + assist?: number; + kill_or_assist?: number; // equals to kill + assist if you know them + death?: number; + special?: number; // use count inked: number; // not including bonus medals: string[]; // 0-3 elements - our_team_inked: number; // TW, not including bonus - their_team_inked: number; // TW, not including bonus - our_team_percent: number; // TW - their_team_percent: number; // TW - our_team_count: number; // Anarchy - their_team_count: number; // Anarchy - level_before: number; - level_after: number; - rank_before: string; // one of c- ... s+, lowercase only /^[abcs][+-]?$/ except s- - rank_before_s_plus: number; - rank_before_exp: number; - rank_after: string; - rank_after_s_plus: number; - rank_after_exp: number; - rank_exp_change: number; // Set rank_after_exp - rank_before_exp. It can be negative. Set only this value if you don't know their exact values. - rank_up_battle: "yes" | "no"; // Set "yes" if now "Rank-up Battle" mode. - challenge_win: number; // Win count for Anarchy (Series) If rank_up_battle is truthy("yes"), the value range is limited to [0, 3]. - challenge_lose: number; - fest_power: number; // Splatfest Power (Pro) + our_team_inked?: number; // TW, not including bonus + their_team_inked?: number; // TW, not including bonus + our_team_percent?: number; // TW + their_team_percent?: number; // TW + our_team_count?: number; // Anarchy + their_team_count?: number; // Anarchy + level_before?: number; + level_after?: number; + rank_before?: string; // one of c- ... s+, lowercase only /^[abcs][+-]?$/ except s- + rank_before_s_plus?: number; + rank_before_exp?: number; + rank_after?: string; + rank_after_s_plus?: number; + rank_after_exp?: number; + rank_exp_change?: number; // Set rank_after_exp - rank_before_exp. It can be negative. Set only this value if you don't know their exact values. + rank_up_battle?: "yes" | "no"; // Set "yes" if now "Rank-up Battle" mode. + challenge_win?: number; // Win count for Anarchy (Series) If rank_up_battle is truthy("yes"), the value range is limited to [0, 3]. + challenge_lose?: number; + fest_power?: number; // Splatfest Power (Pro) fest_dragon?: | "10x" | "decuple" @@ -187,9 +244,9 @@ export type StatInkPostBody = { | "dragon" | "333x" | "double_dragon"; - clout_before: number; // Splatfest Clout, before the battle - clout_after: number; // Splatfest Clout, after the battle - clout_change: number; // Splatfest Clout, equals to clout_after - clout_before if you know them + clout_before?: number; // Splatfest Clout, before the battle + clout_after?: number; // Splatfest Clout, after the battle + clout_change?: number; // Splatfest Clout, equals to clout_after - clout_before if you know them cash_before?: number; cash_after?: number; our_team_players: StatInkPlayer[];