feat: add basic stat ink export

main
spacemeowx2 2022-10-21 08:06:25 +08:00
parent 3b79bc39dc
commit bfdaa42f40
3 changed files with 308 additions and 41 deletions

View File

@ -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<VsHistoryDetail["vsRule"]["rule"], StatInkPostBody["rule"]>;
RESULT: Record<VsHistoryDetail["judgement"], StatInkPostBody["result"]>;
DRAGON: Record<
NonNullable<VsHistoryDetail["festMatch"]>["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",
},
};

View File

@ -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<string> {
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<StatInkStage> {
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<VsHistoryDetail> {
};
}
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<VsHistoryDetail> {
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<string> {
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<StatInkPostBody> {
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;
}
}

127
types.ts
View File

@ -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<D> = {
@ -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<string, string>;
short_name: Record<string, string>;
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[];