dumb-splashcat-thing
Rosalina 2023-06-12 14:16:13 -04:00
parent d5a31fdf85
commit 2941efd869
No known key found for this signature in database
5 changed files with 434 additions and 1 deletions

View File

@ -87,7 +87,7 @@
}, },
"npm": { "npm": {
"specifiers": { "specifiers": {
"mongodb": "mongodb@5.1.0", "mongodb": "mongodb@5.5.0",
"splatnet3-types": "splatnet3-types@0.2.20230227204004" "splatnet3-types": "splatnet3-types@0.2.20230227204004"
}, },
"packages": { "packages": {
@ -110,6 +110,10 @@
"integrity": "sha512-y09gBGusgHtinMon/GVbv1J6FrXhnr/+6hqLlSmEFzkz6PodqF6TxjyvfvY3AfO+oG1mgUtbC86xSbOlwvM62Q==", "integrity": "sha512-y09gBGusgHtinMon/GVbv1J6FrXhnr/+6hqLlSmEFzkz6PodqF6TxjyvfvY3AfO+oG1mgUtbC86xSbOlwvM62Q==",
"dependencies": {} "dependencies": {}
}, },
"bson@5.3.0": {
"integrity": "sha512-ukmCZMneMlaC5ebPHXIkP8YJzNl5DC41N5MAIvKDqLggdao342t4McltoJBQfQya/nHBWAcSsYRqlXPoQkTJag==",
"dependencies": {}
},
"ip@2.0.0": { "ip@2.0.0": {
"integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==",
"dependencies": {} "dependencies": {}
@ -134,6 +138,15 @@
"socks": "socks@2.7.1" "socks": "socks@2.7.1"
} }
}, },
"mongodb@5.5.0": {
"integrity": "sha512-XgrkUgAAdfnZKQfk5AsYL8j7O99WHd4YXPxYxnh8dZxD+ekYWFRA3JktUsBnfg+455Smf75/+asoU/YLwNGoQQ==",
"dependencies": {
"bson": "bson@5.3.0",
"mongodb-connection-string-url": "mongodb-connection-string-url@2.6.0",
"saslprep": "saslprep@1.0.3",
"socks": "socks@2.7.1"
}
},
"punycode@2.3.0": { "punycode@2.3.0": {
"integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==",
"dependencies": {} "dependencies": {}

View File

@ -10,6 +10,7 @@ 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 { MongoDBExporter } from "./exporters/mongodb.ts"; import { MongoDBExporter } from "./exporters/mongodb.ts";
import { SplashcatExporter } from "./exporters/splashcat.ts";
export type Opts = { export type Opts = {
profilePath: string; profilePath: string;
@ -137,6 +138,14 @@ export class App {
); );
} }
if (exporters.includes("splashcat")) {
out.push(new SplashcatExporter({
env: this.env,
uploadMode: this.opts.monitor ? "Monitoring" : "Manual",
splashcatApiKey: this.profile.state.splashcatApiKey!,
}));
}
return out; return out;
} }
exporterProgress(title: string) { exporterProgress(title: string) {

View File

@ -0,0 +1,127 @@
/**
* A battle to be uploaded to Splashcat. Any SplatNet 3 strings should use en-US locale.
* Splashcat will translate strings into the user's langauge.
*/
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[];
duration: number;
judgement: SplashcatBattleJudgement;
knockout?: Knockout;
playedTime: Date;
splatfest?: Splatfest;
/**
* base64 decoded and split by `:` to get the last section
*/
splatnetId: string;
teams: Team[];
vsMode: VsMode;
vsRule: VsRule;
vsStageId: number;
xBattle?: XBattle;
[property: string]: any;
}
export interface Anarchy {
mode?: AnarchyMode;
pointChange?: number;
[property: string]: any;
}
export type AnarchyMode = "SERIES" | "OPEN";
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;
[property: string]: any;
}
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;
[property: string]: any;
}
export interface Color {
a: number;
b: number;
g: number;
r: number;
[property: string]: any;
}
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;
kills?: number;
name: string;
nameId: string;
noroshiTry?: number;
nplnId: string;
paint: number;
shoesGear: Gear;
specials?: number;
species: Species;
splashtagBackgroundId: number;
title: string;
weaponId: number;
[property: string]: any;
}
/**
* A piece of gear. Use en-US locale for name and all abilities.
*/
export interface Gear {
name?: string;
primaryAbility?: string;
secondaryAbilities?: string[];
[property: string]: any;
}
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;
[property: string]: any;
}

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

@ -0,0 +1,277 @@
import {
USERAGENT,
} from "../constant.ts";
import {
Color,
ExportResult,
Game,
GameExporter,
Nameplate,
PlayerGear,
StatInkPostBody,
VsHistoryDetail,
VsInfo,
VsPlayer,
VsTeam,
} from "../types.ts";
import { base64, msgpack, Mutex } from "../../deps.ts";
import { APIError } from "../APIError.ts";
import {
b64Number,
gameId,
parseHistoryDetailId,
} from "../utils.ts";
import { Env } from "../env.ts";
import { Gear, Player, SplashcatBattle, Team, TeamJudgement } from "./splashcat-types.ts";
class SplashcatAPI {
splashcatApiBase = "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}`,
};
}
async uuidList(): Promise<string[]> {
const fetch = this.env.newFetcher();
const response = await fetch.get({
url: `${this.splashcatApiBase}/battles/api/recent/`,
headers: this.requestHeaders(),
});
const uuidResult: Record<string, unknown> = await response.json();
return uuidResult.battle_ids as string[];
}
async postBattle(body: unknown) {
const fetch = this.env.newFetcher();
const resp = await fetch.post({
url: `${this.splashcatApiBase}/battles/api/upload/`,
headers: {
...this.requestHeaders(),
"Content-Type": "application/x-msgpack",
},
body: msgpack.encode(body),
});
const json: unknown = {}//await resp.json().catch(() => ({}));
console.log(json)
// read the body again as text
const text = await resp.text();
console.log(text);
if (resp.status !== 200 && resp.status !== 201) {
throw new APIError({
response: resp,
message: "Failed to export battle",
json,
});
}
if (json.error) {
throw new APIError({
response: resp,
message: "Failed to export battle",
json,
});
}
return json;
}
}
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 body = await this.mapBattle(game);
const resp = await this.api.postBattle(body);
console.log(resp);
return {
status: "success",
url: undefined,
};
} else {
return {
status: "skip",
reason: "Splashcat API does not support Salmon Run",
}
}
}
static getGameId(id: string) { // very similar to the file exporter
const { uid, timestamp } = parseHistoryDetailId(id);
return `${uid}_${timestamp}Z`;
}
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 !== undefined,
isMe: player.isMyself,
name: player.name,
nameId: player.nameId ?? "",
nplnId: player.id.substring(0,50), // NOT CORRECT, FIX LATER
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;
};
async mapBattle(
{
groupInfo,
challengeProgress,
bankaraMatchChallenge,
listNode,
detail: vsDetail,
rankBeforeState,
rankState,
}: VsInfo,
): Promise<Record<string, unknown>> {
const {
knockout,
vsRule: { rule },
myTeam,
otherTeams,
bankaraMatch,
festMatch,
playedTime,
} = vsDetail;
const self = vsDetail.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 result: SplashcatBattle = {
splatnetId: await 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,
} : 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" ? {
power: vsDetail.xMatch?.lastXPower ?? 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 as number,
festStreakWinCount: team.festStreakWinCount as unknown as number ?? undefined,
festTeamName: team.festTeamName ?? undefined,
festUniformBonusRate: team.festUniformBonusRate as unknown as number ?? undefined,
festUniformName: team.festUniformName as unknown as string ?? undefined,
noroshi: team.result?.noroshi ?? undefined,
paintRatio: team.result?.paintRatio ?? undefined,
score: team.result?.score ?? undefined,
tricolorRole: team.tricolorRole ?? undefined,
}
result.teams.push(teamResult);
}
return {
battle: result,
data_type: "splashcat"
}
}
mapColor(color: Color): string | undefined {
const float2hex = (i: number) =>
Math.round(i * 255).toString(16).padStart(2, "0");
// rgba
const nums = [color.r, color.g, color.b, color.a];
return nums.map(float2hex).join("");
}
mapGear(gear: PlayerGear): Gear {
return {
name: gear.name,
primaryAbility: gear.primaryGearPower.name,
secondaryAbilities: gear.additionalGearPowers.map((i) => i.name),
}
}
}

View File

@ -130,6 +130,7 @@ export type PlayerWeapon = {
}; };
}; };
export type VsPlayer = { export type VsPlayer = {
[x: string]: 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: undefined;
festUniformBonusRate: unknown;
festStreakWinCount: undefined;
order: unknown;
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 =