diff --git a/scripts/deno.lock b/scripts/deno.lock index ce8d4c5..f7b569d 100644 --- a/scripts/deno.lock +++ b/scripts/deno.lock @@ -87,7 +87,7 @@ }, "npm": { "specifiers": { - "mongodb": "mongodb@5.1.0", + "mongodb": "mongodb@5.5.0", "splatnet3-types": "splatnet3-types@0.2.20230227204004" }, "packages": { @@ -110,6 +110,10 @@ "integrity": "sha512-y09gBGusgHtinMon/GVbv1J6FrXhnr/+6hqLlSmEFzkz6PodqF6TxjyvfvY3AfO+oG1mgUtbC86xSbOlwvM62Q==", "dependencies": {} }, + "bson@5.3.0": { + "integrity": "sha512-ukmCZMneMlaC5ebPHXIkP8YJzNl5DC41N5MAIvKDqLggdao342t4McltoJBQfQya/nHBWAcSsYRqlXPoQkTJag==", + "dependencies": {} + }, "ip@2.0.0": { "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", "dependencies": {} @@ -134,6 +138,15 @@ "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": { "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", "dependencies": {} diff --git a/src/app.ts b/src/app.ts index aec2a03..e8e069c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,6 +10,7 @@ import { delay, showError } from "./utils.ts"; import { GameFetcher } from "./GameFetcher.ts"; import { DEFAULT_ENV, Env } from "./env.ts"; import { MongoDBExporter } from "./exporters/mongodb.ts"; +import { SplashcatExporter } from "./exporters/splashcat.ts"; export type Opts = { 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; } exporterProgress(title: string) { diff --git a/src/exporters/splashcat-types.ts b/src/exporters/splashcat-types.ts new file mode 100644 index 0000000..7831140 --- /dev/null +++ b/src/exporters/splashcat-types.ts @@ -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; + 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; +} diff --git a/src/exporters/splashcat.ts b/src/exporters/splashcat.ts new file mode 100644 index 0000000..7bee74b --- /dev/null +++ b/src/exporters/splashcat.ts @@ -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 = {}; + + constructor(private splashcatApiKey: string, private env: Env) {} + + requestHeaders() { + return { + "User-Agent": USERAGENT, + "Authorization": `Bearer ${this.splashcatApiKey}`, + }; + } + + async uuidList(): Promise { + const fetch = this.env.newFetcher(); + const response = await fetch.get({ + url: `${this.splashcatApiBase}/battles/api/recent/`, + headers: this.requestHeaders(), + }); + + const uuidResult: Record = 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 { + 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 { + 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> { + 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), + } + } +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 0769d78..d5eedfa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -130,6 +130,7 @@ export type PlayerWeapon = { }; }; export type VsPlayer = { +[x: string]: Nameplate; id: string; nameId: string | null; name: string; @@ -158,6 +159,11 @@ export type Color = { r: number; }; export type VsTeam = { +festUniformName: undefined; +festUniformBonusRate: unknown; +festStreakWinCount: undefined; +order: unknown; + 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 =