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 agentsplashcat-exporter-v2
parent
02a01188c6
commit
16c83c34e3
13
README.md
13
README.md
|
|
@ -3,7 +3,7 @@
|
||||||
[](https://github.com/spacemeowx2/s3si.ts/actions/workflows/ci.yaml)
|
[](https://github.com/spacemeowx2/s3si.ts/actions/workflows/ci.yaml)
|
||||||
[](https://github.com/spacemeowx2/s3si.ts/actions/workflows/constant-check.yaml)
|
[](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": "..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
2
s3si.ts
2
s3si.ts
|
|
@ -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.
|
||||||
|
|
|
||||||
33
src/app.ts
33
src/app.ts
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue