From 425fa1ef73332892b6d3dc2c0d9db7a61574e7e9 Mon Sep 17 00:00:00 2001 From: rosalina Date: Sun, 14 Jan 2024 02:41:26 -0500 Subject: [PATCH] add Splashcat exporter --- src/exporters/splashcat-types.ts | 178 ++++++++++++++++ src/exporters/splashcat.ts | 352 +++++++++++++++++++++++++++++++ src/types.ts | 7 + 3 files changed, 537 insertions(+) create mode 100644 src/exporters/splashcat-types.ts create mode 100644 src/exporters/splashcat.ts diff --git a/src/exporters/splashcat-types.ts b/src/exporters/splashcat-types.ts new file mode 100644 index 0000000..b480fb5 --- /dev/null +++ b/src/exporters/splashcat-types.ts @@ -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; + 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[]; +} diff --git a/src/exporters/splashcat.ts b/src/exporters/splashcat.ts new file mode 100644 index 0000000..cacc2fe --- /dev/null +++ b/src/exporters/splashcat.ts @@ -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 = {}; + + constructor(private splashcatApiKey: string, private env: Env) {} + + requestHeaders() { + return { + "User-Agent": USERAGENT, + "Authorization": `Bearer ${this.splashcatApiKey}`, + "Fly-Prefer-Region": "iad", + }; + } + + async uuidList(): Promise { + 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(url: string): Promise { + 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; +}; + +/** + * 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 { + 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 { + 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), + }; + } +} diff --git a/src/types.ts b/src/types.ts index 8e1c95d..f42528f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -129,6 +129,7 @@ export type PlayerWeapon = { }; }; export type VsPlayer = { + nameplate: Nameplate; id: string; nameId: string | null; name: string; @@ -158,6 +159,11 @@ export type Color = { r: number; }; export type VsTeam = { + festUniformName?: string; + festStreakWinCount?: number; + festUniformBonusRate?: number; + order: number; + judgement: string; players: VsPlayer[]; color: Color; tricolorRole: null | "DEFENSE" | "ATTACK1" | "ATTACK2"; @@ -165,6 +171,7 @@ export type VsTeam = { result: null | { paintRatio: null | number; score: null | number; + noroshi: null | number; }; }; export type VsRule =