Splashcat exporter (#88)

* add Splashcat exporter

* update readme and cli help

* use splashcat exporter when set using cli flags

* run deno fmt

* use s3si.ts fetcher and send a user agent
splashcat-exporter-v2
Rosalina 2024-01-17 12:42:52 -05:00 committed by GitHub
parent 02a01188c6
commit 16c83c34e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 581 additions and 5 deletions

View File

@ -3,7 +3,7 @@
[![Build status](https://github.com/spacemeowx2/s3si.ts/workflows/Build/badge.svg)](https://github.com/spacemeowx2/s3si.ts/actions/workflows/ci.yaml) [![Build status](https://github.com/spacemeowx2/s3si.ts/workflows/Build/badge.svg)](https://github.com/spacemeowx2/s3si.ts/actions/workflows/ci.yaml)
[![Constant check status](https://github.com/spacemeowx2/s3si.ts/workflows/Constant%20Check/badge.svg)](https://github.com/spacemeowx2/s3si.ts/actions/workflows/constant-check.yaml) [![Constant check status](https://github.com/spacemeowx2/s3si.ts/workflows/Constant%20Check/badge.svg)](https://github.com/spacemeowx2/s3si.ts/actions/workflows/constant-check.yaml)
Export your battles from SplatNet to stat.ink. Export your battles from SplatNet to stat.ink and Splashcat.
If you have used s3s, please see [here](#migrate-from-s3s). If you have used s3s, please see [here](#migrate-from-s3s).
@ -19,7 +19,7 @@ Options:
--profile-path <path>, -p Path to config file (default: ./profile.json) --profile-path <path>, -p Path to config file (default: ./profile.json)
--exporter <exporter>, -e Exporter list to use (default: stat.ink) --exporter <exporter>, -e Exporter list to use (default: stat.ink)
Multiple exporters can be separated by commas Multiple exporters can be separated by commas
(e.g. "stat.ink,file") (e.g. "stat.ink,file,splashcat")
--list-method When set to "latest", the latest 50 matches will be obtained. --list-method When set to "latest", the latest 50 matches will be obtained.
When set to "all", matches of all modes will be obtained with a maximum of 250 matches (5 modes x 50 matches). When set to "all", matches of all modes will be obtained with a maximum of 250 matches (5 modes x 50 matches).
When set to "auto", the latest 50 matches will be obtained. If 50 matches have not been uploaded yet, matches will be obtained from the list of all modes. When set to "auto", the latest 50 matches will be obtained. If 50 matches have not been uploaded yet, matches will be obtained from the list of all modes.
@ -39,6 +39,12 @@ Options:
- If you want to use a different profile, use `-p` to specify the path to the - If you want to use a different profile, use `-p` to specify the path to the
profile file. profile file.
### Splashcat Notes
Due to limitations with SplatNet 3 data, Splashcat requires battles uploaded to
use `en-US` (set with `userLang`). Splashcat will localize most parts of battle
results into the user's language when displayed.
### Track your rank ### Track your rank
- Run - Run
@ -72,7 +78,8 @@ Options:
// userLang will effect the language of the exported games to stat.ink // userLang will effect the language of the exported games to stat.ink
"userLang": "zh-CN", "userLang": "zh-CN",
"userCountry": "JP", "userCountry": "JP",
"statInkApiKey": "..." "statInkApiKey": "...",
"splashcatApiKey": "..."
} }
``` ```

View File

@ -36,7 +36,7 @@ Options:
--profile-path <path>, -p Path to config file (default: ./profile.json) --profile-path <path>, -p Path to config file (default: ./profile.json)
--exporter <exporter>, -e Exporter list to use (default: stat.ink) --exporter <exporter>, -e Exporter list to use (default: stat.ink)
Multiple exporters can be separated by commas Multiple exporters can be separated by commas
(e.g. "stat.ink,file") (e.g. "stat.ink,file,splashcat")
--list-method When set to "latest", the latest 50 matches will be obtained. --list-method When set to "latest", the latest 50 matches will be obtained.
When set to "all", matches of all modes will be obtained with a maximum of 250 matches (5 modes x 50 matches). When set to "all", matches of all modes will be obtained with a maximum of 250 matches (5 modes x 50 matches).
When set to "auto", the latest 50 matches will be obtained. If 50 matches have not been uploaded yet, matches will be obtained from the list of all modes. When set to "auto", the latest 50 matches will be obtained. If 50 matches have not been uploaded yet, matches will be obtained from the list of all modes.

View File

@ -9,7 +9,9 @@ import { FileExporter } from "./exporters/file.ts";
import { delay, showError } from "./utils.ts"; import { delay, showError } from "./utils.ts";
import { GameFetcher } from "./GameFetcher.ts"; import { GameFetcher } from "./GameFetcher.ts";
import { DEFAULT_ENV, Env } from "./env.ts"; import { DEFAULT_ENV, Env } from "./env.ts";
import { SplashcatExporter } from "./exporters/splashcat.ts";
import { SPLATOON3_TITLE_ID } from "./constant.ts"; import { SPLATOON3_TITLE_ID } from "./constant.ts";
import { USERAGENT } from "./constant.ts";
export type Opts = { export type Opts = {
profilePath: string; profilePath: string;
@ -223,6 +225,29 @@ export class App {
out.push(new FileExporter(state.fileExportPath)); out.push(new FileExporter(state.fileExportPath));
} }
if (exporters.includes("splashcat")) {
if (!state.splashcatApiKey) {
const key = (await this.env.prompts.prompt(
"Splashcat API key is not set. Please enter below.",
)).trim();
if (!key) {
this.env.logger.error("API key is required.");
Deno.exit(1);
}
await this.profile.writeState({
...state,
splashcatApiKey: key,
});
}
out.push(
new SplashcatExporter({
splashcatApiKey: this.profile.state.splashcatApiKey!,
uploadMode: this.opts.monitor ? "Monitoring" : "Manual",
env: this.env,
}),
);
}
return out; return out;
} }
exporterProgress(title: string) { exporterProgress(title: string) {
@ -400,11 +425,17 @@ export class App {
} }
async monitorWithNxapi() { async monitorWithNxapi() {
this.env.logger.debug("Monitoring with nxapi presence"); this.env.logger.debug("Monitoring with nxapi presence");
const fetcher = this.env.newFetcher();
await this.exportOnce(); await this.exportOnce();
while (true) { while (true) {
await this.countDown(this.profile.state.monitorInterval); await this.countDown(this.profile.state.monitorInterval);
const nxapiResponse = await fetch(this.opts.nxapiPresenceUrl!); const nxapiResponse = await fetcher.get({
url: this.opts.nxapiPresenceUrl!,
headers: {
"User-Agent": USERAGENT,
},
});
const nxapiData = await nxapiResponse.json(); const nxapiData = await nxapiResponse.json();
const isSplatoon3Active = nxapiData.title?.id === SPLATOON3_TITLE_ID; const isSplatoon3Active = nxapiData.title?.id === SPLATOON3_TITLE_ID;
if (isSplatoon3Active || this.splatoon3PreviouslyActive) { if (isSplatoon3Active || this.splatoon3PreviouslyActive) {

View File

@ -0,0 +1,178 @@
export interface SplashcatUpload {
battle: SplashcatBattle;
data_type: "splashcat";
uploader_agent: {
name: string; // max of 32 characters
version: string; // max of 50 characters
extra: string; // max of 100 characters. displayed as a string at the bottom of battle details. useful for debug info such as manual/monitoring modes
};
}
/**
* A battle to be uploaded to Splashcat. Any SplatNet 3 strings should use en-US locale.
* Splashcat will translate strings into the user's language.
*/
export interface SplashcatBattle {
anarchy?: Anarchy;
/**
* The en-US string for the award. Splashcat will translate this into the user's language
* and manage the award's rank.
*/
awards: string[];
challenge?: Challenge;
duration: number;
judgement: SplashcatBattleJudgement;
knockout?: Knockout;
playedTime: string;
splatfest?: Splatfest;
/**
* base64 decoded and split by `:` to get the last section
*/
splatnetId: string;
teams: Team[];
vsMode: VsMode;
vsRule: VsRule;
vsStageId: number;
xBattle?: XBattle;
}
export interface Anarchy {
mode?: AnarchyMode;
pointChange?: number;
points?: number;
power?: number;
rank?: Rank;
sPlusNumber?: number;
}
export type AnarchyMode = "SERIES" | "OPEN";
export type Rank =
| "C-"
| "C"
| "C+"
| "B-"
| "B"
| "B+"
| "A-"
| "A"
| "A+"
| "S"
| "S+";
export interface Challenge {
/**
* base64 decoded and split by `-` to get the last section
*/
id?: string;
power?: number;
}
export type SplashcatBattleJudgement =
| "WIN"
| "LOSE"
| "DRAW"
| "EXEMPTED_LOSE"
| "DEEMED_LOSE";
export type Knockout = "NEITHER" | "WIN" | "LOSE";
export interface Splatfest {
cloutMultiplier?: CloutMultiplier;
mode?: SplatfestMode;
power?: number;
}
export type CloutMultiplier = "NONE" | "DECUPLE" | "DRAGON" | "DOUBLE_DRAGON";
export type SplatfestMode = "OPEN" | "PRO";
export interface Team {
color: Color;
festStreakWinCount?: number;
festTeamName?: string;
festUniformBonusRate?: number;
festUniformName?: string;
isMyTeam: boolean;
judgement?: TeamJudgement;
noroshi?: number;
order: number;
paintRatio?: number;
players?: Player[];
score?: number;
tricolorRole?: TricolorRole;
}
export interface Color {
a: number;
b: number;
g: number;
r: number;
}
export type TeamJudgement = "WIN" | "LOSE" | "DRAW";
export interface Player {
assists?: number;
/**
* Array of badge IDs. Use JSON `null` for empty slots.
*/
badges: Array<number | null>;
clothingGear: Gear;
deaths?: number;
disconnected: boolean;
headGear: Gear;
isMe: boolean;
/**
* Should report the same way that SplatNet 3 does (kills + assists)
*/
kills?: number;
name: string;
nameId?: string;
noroshiTry?: number;
nplnId: string;
paint: number;
shoesGear: Gear;
specials?: number;
species: Species;
splashtagBackgroundId: number;
title: string;
weaponId: number;
}
/**
* A piece of gear. Use en-US locale for name and all abilities.
*/
export interface Gear {
name?: string;
primaryAbility?: string;
secondaryAbilities?: string[];
}
export type Species = "INKLING" | "OCTOLING";
export type TricolorRole = "ATTACK1" | "ATTACK2" | "DEFENSE";
export type VsMode =
| "BANKARA"
| "X_MATCH"
| "REGULAR"
| "FEST"
| "PRIVATE"
| "CHALLENGE";
export type VsRule =
| "AREA"
| "TURF_WAR"
| "TRI_COLOR"
| "LOFT"
| "CLAM"
| "GOAL";
export interface XBattle {
xPower?: number;
xRank?: number;
}
export interface SplashcatRecentBattleIds {
battle_ids: string[];
}

352
src/exporters/splashcat.ts Normal file
View File

@ -0,0 +1,352 @@
import { AGENT_NAME, S3SI_VERSION, USERAGENT } from "../constant.ts";
import {
Color,
ExportResult,
Game,
GameExporter,
Nameplate,
PlayerGear,
VsInfo,
VsPlayer,
VsTeam,
} from "../types.ts";
import { base64, msgpack, Mutex } from "../../deps.ts";
import { APIError } from "../APIError.ts";
import { Env } from "../env.ts";
import {
Gear,
Player,
Rank,
SplashcatBattle,
SplashcatRecentBattleIds,
Team,
TeamJudgement,
} from "./splashcat-types.ts";
import { SplashcatUpload } from "./splashcat-types.ts";
async function checkResponse(resp: Response) {
// 200~299
if (Math.floor(resp.status / 100) !== 2) {
const json = await resp.json().catch(() => undefined);
throw new APIError({
response: resp,
json,
message: "Failed to fetch data from stat.ink",
});
}
}
class SplashcatAPI {
splashcat = "https://splashcat.ink";
FETCH_LOCK = new Mutex();
cache: Record<string, unknown> = {};
constructor(private splashcatApiKey: string, private env: Env) {}
requestHeaders() {
return {
"User-Agent": USERAGENT,
"Authorization": `Bearer ${this.splashcatApiKey}`,
"Fly-Prefer-Region": "iad",
};
}
async uuidList(): Promise<string[]> {
const fetch = this.env.newFetcher();
const response = await fetch.get({
url: `${this.splashcat}/battles/api/recent/`,
headers: this.requestHeaders(),
});
await checkResponse(response);
const recentBattlesData: SplashcatRecentBattleIds = await response.json();
const recentBattleIds = recentBattlesData.battle_ids;
if (!Array.isArray(recentBattleIds)) {
throw new APIError({
response,
json: recentBattlesData,
});
}
return recentBattleIds;
}
async postBattle(body: SplashcatUpload) {
const fetch = this.env.newFetcher();
const resp = await fetch.post({
url: `${this.splashcat}/battles/api/upload/`,
headers: {
...this.requestHeaders(),
"Content-Type": "application/x-msgpack",
},
body: msgpack.encode(body),
});
const json = await resp.json().catch(() => ({}));
if (resp.status !== 200) {
throw new APIError({
response: resp,
message: "Failed to export battle",
json,
});
}
return json;
}
async _getCached<T>(url: string): Promise<T> {
const release = await this.FETCH_LOCK.acquire();
try {
if (this.cache[url]) {
return this.cache[url] as T;
}
const fetch = this.env.newFetcher();
const resp = await fetch.get({
url,
headers: this.requestHeaders(),
});
await checkResponse(resp);
const json = await resp.json();
this.cache[url] = json;
return json;
} finally {
release();
}
}
}
export type NameDict = {
gearPower: Record<string, number | undefined>;
};
/**
* Exporter to Splashcat.
*/
export class SplashcatExporter implements GameExporter {
name = "Splashcat";
private api: SplashcatAPI;
private uploadMode: string;
constructor(
{ splashcatApiKey, uploadMode, env }: {
splashcatApiKey: string;
uploadMode: string;
env: Env;
},
) {
this.api = new SplashcatAPI(splashcatApiKey, env);
this.uploadMode = uploadMode;
}
async exportGame(game: Game): Promise<ExportResult> {
if (game.type === "VsInfo") {
const battle = await this.mapBattle(game);
const body: SplashcatUpload = {
battle,
data_type: "splashcat",
uploader_agent: {
name: AGENT_NAME,
version: S3SI_VERSION,
extra: `Upload Mode: ${this.uploadMode}`,
},
};
const resp = await this.api.postBattle(body);
return {
status: "success",
url: resp.battle_id
? `https://splashcat.ink/battles/${resp.battle_id}/`
: undefined,
};
} else {
return {
status: "skip",
reason: "Splashcat does not support Salmon Run",
};
}
}
static getGameId(id: string) {
const plainText = new TextDecoder().decode(base64.decode(id));
return plainText.split(":").at(-1);
}
async notExported(
{ type, list }: { list: string[]; type: Game["type"] },
): Promise<string[]> {
if (type !== "VsInfo") return [];
const uuid = await this.api.uuidList();
const out: string[] = [];
for (const id of list) {
const gameId = SplashcatExporter.getGameId(id)!;
if (
!uuid.includes(gameId)
) {
out.push(id);
}
}
return out;
}
mapPlayer = (
player: VsPlayer,
_index: number,
): Player => {
const result: Player = {
badges: (player.nameplate as Nameplate).badges.map((i) =>
i
? Number(new TextDecoder().decode(base64.decode(i.id)).split("-")[1])
: null
),
splashtagBackgroundId: Number(
new TextDecoder().decode(
base64.decode((player.nameplate as Nameplate).background.id),
).split("-")[1],
),
clothingGear: this.mapGear(player.clothingGear),
headGear: this.mapGear(player.headGear),
shoesGear: this.mapGear(player.shoesGear),
disconnected: player.result ? false : true,
isMe: player.isMyself,
name: player.name,
nameId: player.nameId ?? "",
nplnId: new TextDecoder().decode(base64.decode(player.id)).split(":").at(
-1,
)!,
paint: player.paint,
species: player.species,
weaponId: Number(
new TextDecoder().decode(base64.decode(player.weapon.id)).split("-")[1],
),
assists: player.result?.assist,
deaths: player.result?.death,
kills: player.result?.kill,
specials: player.result?.special,
noroshiTry: player.result?.noroshiTry ?? undefined,
title: player.byname,
};
return result;
};
mapBattle(
{
detail: vsDetail,
rankState,
}: VsInfo,
): SplashcatBattle {
const {
myTeam,
otherTeams,
} = vsDetail;
const self = myTeam.players.find((i) => i.isMyself);
if (!self) {
throw new Error("Self not found");
}
if (otherTeams.length === 0) {
throw new Error(`Other teams is empty`);
}
let anarchyMode: "OPEN" | "SERIES" | undefined;
if (vsDetail.bankaraMatch?.mode) {
anarchyMode = vsDetail.bankaraMatch.mode === "OPEN" ? "OPEN" : "SERIES";
}
const rank = rankState?.rank.substring(0, 2) ?? undefined;
const sPlusNumber = rankState?.rank.substring(2) ?? undefined;
const result: SplashcatBattle = {
splatnetId: SplashcatExporter.getGameId(vsDetail.id)!,
duration: vsDetail.duration,
judgement: vsDetail.judgement,
playedTime: new Date(vsDetail.playedTime).toISOString()!,
vsMode: vsDetail.vsMode.mode === "LEAGUE"
? "CHALLENGE"
: vsDetail.vsMode.mode,
vsRule: vsDetail.vsRule.rule,
vsStageId: Number(
new TextDecoder().decode(base64.decode(vsDetail.vsStage.id)).split(
"-",
)[1],
),
anarchy: vsDetail.vsMode.mode === "BANKARA"
? {
mode: anarchyMode,
pointChange: vsDetail.bankaraMatch?.earnedUdemaePoint ?? undefined,
power: vsDetail.bankaraMatch?.bankaraPower?.power ?? undefined,
points: rankState?.rankPoint ?? undefined,
rank: rank as Rank,
sPlusNumber: sPlusNumber ? Number(sPlusNumber) : undefined,
}
: undefined,
knockout: vsDetail.knockout ?? undefined,
splatfest: vsDetail.vsMode.mode === "FEST"
? {
cloutMultiplier: vsDetail.festMatch?.dragonMatchType === "NORMAL"
? "NONE"
: (vsDetail.festMatch?.dragonMatchType ?? undefined),
power: vsDetail.festMatch?.myFestPower ?? undefined,
}
: undefined,
xBattle: vsDetail.vsMode.mode === "X_MATCH"
? {
xPower: vsDetail.xMatch?.lastXPower ?? undefined,
}
: undefined,
challenge: vsDetail.vsMode.mode === "LEAGUE"
? {
id: new TextDecoder().decode(
base64.decode(vsDetail.leagueMatch?.leagueMatchEvent?.id!),
).split("-")[1],
power: vsDetail.leagueMatch?.myLeaguePower ?? undefined,
}
: undefined,
teams: [],
awards: vsDetail.awards.map((i) => i.name),
};
const teams: VsTeam[] = [vsDetail.myTeam, ...vsDetail.otherTeams];
for (const team of teams) {
const players = team.players.map(this.mapPlayer);
const teamResult: Team = {
players,
color: team.color,
isMyTeam: team.players.find((i) => i.isMyself) !== undefined,
judgement: team.judgement as TeamJudgement,
order: team.order,
festStreakWinCount: team.festStreakWinCount,
festTeamName: team.festTeamName ?? undefined,
festUniformBonusRate: team.festUniformBonusRate,
festUniformName: team.festUniformName,
noroshi: team.result?.noroshi ?? undefined,
paintRatio: team.result?.paintRatio ?? undefined,
score: team.result?.score ?? undefined,
tricolorRole: team.tricolorRole ?? undefined,
};
result.teams.push(teamResult);
}
return result;
}
mapColor(color: Color): string | undefined {
const float2hex = (i: number) =>
Math.round(i * 255).toString(16).padStart(2, "0");
// rgba
const numbers = [color.r, color.g, color.b, color.a];
return numbers.map(float2hex).join("");
}
mapGear(gear: PlayerGear): Gear {
return {
name: gear.name,
primaryAbility: gear.primaryGearPower.name,
secondaryAbilities: gear.additionalGearPowers.map((i) => i.name),
};
}
}

View File

@ -30,6 +30,7 @@ export type State = {
statInkApiKey?: string; statInkApiKey?: string;
fileExportPath: string; fileExportPath: string;
monitorInterval: number; monitorInterval: number;
splashcatApiKey?: string;
}; };
export const DEFAULT_STATE: State = { export const DEFAULT_STATE: State = {

View File

@ -129,6 +129,7 @@ export type PlayerWeapon = {
}; };
}; };
export type VsPlayer = { export type VsPlayer = {
nameplate: Nameplate;
id: string; id: string;
nameId: string | null; nameId: string | null;
name: string; name: string;
@ -158,6 +159,11 @@ export type Color = {
r: number; r: number;
}; };
export type VsTeam = { export type VsTeam = {
festUniformName?: string;
festStreakWinCount?: number;
festUniformBonusRate?: number;
order: number;
judgement: string;
players: VsPlayer[]; players: VsPlayer[];
color: Color; color: Color;
tricolorRole: null | "DEFENSE" | "ATTACK1" | "ATTACK2"; tricolorRole: null | "DEFENSE" | "ATTACK1" | "ATTACK2";
@ -165,6 +171,7 @@ export type VsTeam = {
result: null | { result: null | {
paintRatio: null | number; paintRatio: null | number;
score: null | number; score: null | number;
noroshi: null | number;
}; };
}; };
export type VsRule = export type VsRule =