feat: add basic stat ink export
parent
3b79bc39dc
commit
bfdaa42f40
36
constant.ts
36
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 S3SI_VERSION = "0.1.0";
|
||||||
export const NSOAPP_VERSION = "2.3.1";
|
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 =
|
export const DEFAULT_APP_USER_AGENT =
|
||||||
"Mozilla/5.0 (Linux; Android 11; Pixel 5) " +
|
"Mozilla/5.0 (Linux; Android 11; Pixel 5) " +
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) " +
|
"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 =
|
export const SPLATNET3_ENDPOINT =
|
||||||
"https://api.lp1.av5ja.srv.nintendo.net/api/graphql";
|
"https://api.lp1.av5ja.srv.nintendo.net/api/graphql";
|
||||||
export const S3SI_NAMESPACE = "63941e1c-e32e-4b56-9a1d-f6fbe19ef6e1";
|
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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,21 @@
|
||||||
import { S3SI_NAMESPACE, USERAGENT } from "../constant.ts";
|
import {
|
||||||
import { BattleExporter, VsHistoryDetail } from "../types.ts";
|
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 { base64, msgpack, uuid } from "../deps.ts";
|
||||||
import { APIError } from "../APIError.ts";
|
import { APIError } from "../APIError.ts";
|
||||||
|
import { cache } from "../utils.ts";
|
||||||
|
|
||||||
const S3S_NAMESPACE = "b3a2dbf5-2c09-4792-b78c-00b548b70aeb";
|
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));
|
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.
|
* Exporter to stat.ink.
|
||||||
*
|
*
|
||||||
|
|
@ -40,9 +70,7 @@ export class StatInkExporter implements BattleExporter<VsHistoryDetail> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
async exportBattle(detail: VsHistoryDetail) {
|
async exportBattle(detail: VsHistoryDetail) {
|
||||||
const body = {
|
const body = this.mapBattle(detail);
|
||||||
test: "yes",
|
|
||||||
};
|
|
||||||
|
|
||||||
const resp = await fetch("https://stat.ink/api/v3/battle", {
|
const resp = await fetch("https://stat.ink/api/v3/battle", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -92,4 +120,152 @@ export class StatInkExporter implements BattleExporter<VsHistoryDetail> {
|
||||||
|
|
||||||
return out;
|
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
127
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 = {
|
export type VsHistoryDetail = {
|
||||||
id: string;
|
id: string;
|
||||||
vsRule: {
|
vsRule: {
|
||||||
|
|
@ -54,6 +82,23 @@ export type VsHistoryDetail = {
|
||||||
image: Image;
|
image: Image;
|
||||||
};
|
};
|
||||||
playedTime: string; // 2021-01-01T00:00:00Z
|
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> = {
|
export type BattleExporter<D> = {
|
||||||
|
|
@ -126,18 +171,30 @@ export type StatInkPlayer = {
|
||||||
me: "yes" | "no";
|
me: "yes" | "no";
|
||||||
rank_in_team: number;
|
rank_in_team: number;
|
||||||
name: string;
|
name: string;
|
||||||
number: string;
|
number: string | undefined;
|
||||||
splashtag_title: string;
|
splashtag_title: string;
|
||||||
weapon: string;
|
weapon: string;
|
||||||
inked: number;
|
inked: number;
|
||||||
kill: number;
|
kill?: number;
|
||||||
assist: number;
|
assist?: number;
|
||||||
kill_or_assist: number;
|
kill_or_assist?: number;
|
||||||
death: number;
|
death?: number;
|
||||||
special: number;
|
special?: number;
|
||||||
disconnected: "yes" | "no";
|
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 = {
|
export type StatInkPostBody = {
|
||||||
test: "yes" | "no";
|
test: "yes" | "no";
|
||||||
uuid: string;
|
uuid: string;
|
||||||
|
|
@ -152,34 +209,34 @@ export type StatInkPostBody = {
|
||||||
stage: string;
|
stage: string;
|
||||||
weapon: string;
|
weapon: string;
|
||||||
result: "win" | "lose" | "draw" | "exempted_lose";
|
result: "win" | "lose" | "draw" | "exempted_lose";
|
||||||
knockout: "yes" | "no" | null; // for TW, set null or not sending
|
knockout?: "yes" | "no"; // for TW, set null or not sending
|
||||||
rank_in_team: 1 | 2 | 3 | 4; // position in scoreboard
|
rank_in_team: number; // position in scoreboard
|
||||||
kill: number;
|
kill?: number;
|
||||||
assist: number;
|
assist?: number;
|
||||||
kill_or_assist: number; // equals to kill + assist if you know them
|
kill_or_assist?: number; // equals to kill + assist if you know them
|
||||||
death: number;
|
death?: number;
|
||||||
special: number; // use count
|
special?: number; // use count
|
||||||
inked: number; // not including bonus
|
inked: number; // not including bonus
|
||||||
medals: string[]; // 0-3 elements
|
medals: string[]; // 0-3 elements
|
||||||
our_team_inked: number; // TW, not including bonus
|
our_team_inked?: number; // TW, not including bonus
|
||||||
their_team_inked: number; // TW, not including bonus
|
their_team_inked?: number; // TW, not including bonus
|
||||||
our_team_percent: number; // TW
|
our_team_percent?: number; // TW
|
||||||
their_team_percent: number; // TW
|
their_team_percent?: number; // TW
|
||||||
our_team_count: number; // Anarchy
|
our_team_count?: number; // Anarchy
|
||||||
their_team_count: number; // Anarchy
|
their_team_count?: number; // Anarchy
|
||||||
level_before: number;
|
level_before?: number;
|
||||||
level_after: number;
|
level_after?: number;
|
||||||
rank_before: string; // one of c- ... s+, lowercase only /^[abcs][+-]?$/ except s-
|
rank_before?: string; // one of c- ... s+, lowercase only /^[abcs][+-]?$/ except s-
|
||||||
rank_before_s_plus: number;
|
rank_before_s_plus?: number;
|
||||||
rank_before_exp: number;
|
rank_before_exp?: number;
|
||||||
rank_after: string;
|
rank_after?: string;
|
||||||
rank_after_s_plus: number;
|
rank_after_s_plus?: number;
|
||||||
rank_after_exp: 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_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.
|
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_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;
|
challenge_lose?: number;
|
||||||
fest_power: number; // Splatfest Power (Pro)
|
fest_power?: number; // Splatfest Power (Pro)
|
||||||
fest_dragon?:
|
fest_dragon?:
|
||||||
| "10x"
|
| "10x"
|
||||||
| "decuple"
|
| "decuple"
|
||||||
|
|
@ -187,9 +244,9 @@ export type StatInkPostBody = {
|
||||||
| "dragon"
|
| "dragon"
|
||||||
| "333x"
|
| "333x"
|
||||||
| "double_dragon";
|
| "double_dragon";
|
||||||
clout_before: number; // Splatfest Clout, before the battle
|
clout_before?: number; // Splatfest Clout, before the battle
|
||||||
clout_after: number; // Splatfest Clout, after 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_change?: number; // Splatfest Clout, equals to clout_after - clout_before if you know them
|
||||||
cash_before?: number;
|
cash_before?: number;
|
||||||
cash_after?: number;
|
cash_after?: number;
|
||||||
our_team_players: StatInkPlayer[];
|
our_team_players: StatInkPlayer[];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue