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 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",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
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 = {
|
||||
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[];
|
||||
|
|
|
|||
Loading…
Reference in New Issue