From 74a0ef99ece963d9f53355a790acfa2eb8a9d9e5 Mon Sep 17 00:00:00 2001 From: spacemeowx2 Date: Fri, 21 Oct 2022 10:47:56 +0800 Subject: [PATCH] feat: add udemae info --- exporter/file.ts | 12 +++--- exporter/stat.ink.ts | 71 +++++++++++++++++++--------------- s3si.ts | 91 ++++++++++++++++++++++++++++++++++++++------ splatnet3.ts | 11 ++++++ types.ts | 39 +++++++++++++++++-- utils.ts | 12 +++++- 6 files changed, 183 insertions(+), 53 deletions(-) diff --git a/exporter/file.ts b/exporter/file.ts index 1bbaf88..983feac 100644 --- a/exporter/file.ts +++ b/exporter/file.ts @@ -1,4 +1,4 @@ -import { BattleExporter, VsHistoryDetail } from "../types.ts"; +import { BattleExporter, VsBattle } from "../types.ts"; import { datetime, path } from "../deps.ts"; import { NSOAPP_VERSION, S3SI_VERSION } from "../constant.ts"; const FILENAME_FORMAT = "yyyyMMddHHmmss"; @@ -8,7 +8,7 @@ type FileExporterType = { nsoVersion: string; s3siVersion: string; exportTime: string; - data: VsHistoryDetail; + data: VsBattle; }; /** @@ -17,14 +17,14 @@ type FileExporterType = { * This is useful for debugging. It will write each battle detail to a file. * Timestamp is used as filename. Example: 2021-01-01T00:00:00.000Z.json */ -export class FileExporter implements BattleExporter { +export class FileExporter implements BattleExporter { name = "file"; constructor(private exportPath: string) { } - async exportBattle(detail: VsHistoryDetail) { + async exportBattle(battle: VsBattle) { await Deno.mkdir(this.exportPath, { recursive: true }); - const playedTime = new Date(detail.playedTime); + const playedTime = new Date(battle.detail.playedTime); const filename = `${datetime.format(playedTime, FILENAME_FORMAT)}.json`; const filepath = path.join(this.exportPath, filename); @@ -33,7 +33,7 @@ export class FileExporter implements BattleExporter { nsoVersion: NSOAPP_VERSION, s3siVersion: S3SI_VERSION, exportTime: new Date().toISOString(), - data: detail, + data: battle, }; await Deno.writeTextFile(filepath, JSON.stringify(body)); diff --git a/exporter/stat.ink.ts b/exporter/stat.ink.ts index dfc668e..5886a23 100644 --- a/exporter/stat.ink.ts +++ b/exporter/stat.ink.ts @@ -1,6 +1,5 @@ import { AGENT_NAME, - S3SI_NAMESPACE, S3SI_VERSION, SPLATNET3_STATINK_MAP, USERAGENT, @@ -10,31 +9,16 @@ import { StatInkPlayer, StatInkPostBody, StatInkStage, + VsBattle, VsHistoryDetail, VsPlayer, } from "../types.ts"; -import { base64, msgpack, uuid } from "../deps.ts"; +import { base64, msgpack } from "../deps.ts"; import { APIError } from "../APIError.ts"; -import { cache } from "../utils.ts"; +import { battleId, cache } from "../utils.ts"; const S3S_NAMESPACE = "b3a2dbf5-2c09-4792-b78c-00b548b70aeb"; -/** - * generate s3s uuid - * - * @param id ID from SplatNet3 - * @returns id generated from s3s - */ -function s3sUuid(id: string): Promise { - const fullId = base64.decode(id); - const tsUuid = fullId.slice(fullId.length - 52, fullId.length); - return uuid.v5.generate(S3S_NAMESPACE, tsUuid); -} - -function battleId(id: string): Promise { - return uuid.v5.generate(S3SI_NAMESPACE, new TextEncoder().encode(id)); -} - /** * Decode ID and get number after '-' */ @@ -56,7 +40,7 @@ const getStage = cache(_getStage); * * This is the default exporter. It will upload each battle detail to stat.ink. */ -export class StatInkExporter implements BattleExporter { +export class StatInkExporter implements BattleExporter { name = "stat.ink"; constructor(private statInkApiKey: string) { if (statInkApiKey.length !== 43) { @@ -69,8 +53,8 @@ export class StatInkExporter implements BattleExporter { "Authorization": `Bearer ${this.statInkApiKey}`, }; } - async exportBattle(detail: VsHistoryDetail) { - const body = await this.mapBattle(detail); + async exportBattle(battle: VsBattle) { + const body = await this.mapBattle(battle); const resp = await fetch("https://stat.ink/api/v3/battle", { method: "POST", @@ -100,8 +84,6 @@ export class StatInkExporter implements BattleExporter { json, }); } - - throw new Error("abort"); } async notExported(list: string[]): Promise { const uuid = await (await fetch("https://stat.ink/api/v3/s3s/uuid-list", { @@ -111,7 +93,7 @@ export class StatInkExporter implements BattleExporter { const out: string[] = []; for (const id of list) { - const s3sId = await s3sUuid(id); + const s3sId = await battleId(id, S3S_NAMESPACE); const s3siId = await battleId(id); if (!uuid.includes(s3sId) && !uuid.includes(s3siId)) { @@ -181,10 +163,14 @@ export class StatInkExporter implements BattleExporter { } return result; } - async mapBattle(vsDetail: VsHistoryDetail): Promise { + async mapBattle( + { lastInChallenge, bankaraMatchChallenge, listNode, detail: vsDetail }: + VsBattle, + ): Promise { const { knockout, vsMode: { mode }, + vsRule: { rule }, myTeam, otherTeams, bankaraMatch, @@ -244,7 +230,7 @@ export class StatInkExporter implements BattleExporter { result.clout_change = festMatch.contribution; result.fest_power = festMatch.myFestPower ?? undefined; } - if (mode === "FEST" || mode === "REGULAR") { + if (rule === "TURF_WAR") { result.our_team_percent = (myTeam.result.paintRatio ?? 0) * 100; result.their_team_percent = (otherTeams?.[0].result.paintRatio ?? 0) * 100; @@ -257,17 +243,40 @@ export class StatInkExporter implements BattleExporter { 0, ); } - if (mode === "BANKARA") { - if (!bankaraMatch) { - throw new TypeError("bankaraMatch is null"); - } + if (bankaraMatch) { result.our_team_count = myTeam.result.score ?? undefined; result.their_team_count = otherTeams?.[0].result.score ?? undefined; result.knockout = (!knockout || knockout === "NEITHER") ? "no" : "yes"; result.rank_exp_change = bankaraMatch.earnedUdemaePoint; } + if (listNode) { + [result.rank_before, result.rank_before_s_plus] = parseUdemae( + listNode.udemae, + ); + } + if (bankaraMatchChallenge) { + result.rank_up_battle = bankaraMatchChallenge.isPromo ? "yes" : "no"; + if (bankaraMatchChallenge.udemaeAfter) { + [result.rank_after, result.rank_after_s_plus] = parseUdemae( + bankaraMatchChallenge.udemaeAfter, + ); + } + if (lastInChallenge) { + result.challenge_win = bankaraMatchChallenge.winCount; + result.challenge_lose = bankaraMatchChallenge.loseCount; + result.rank_exp_change = bankaraMatchChallenge.earnedUdemaePoint; + } + } return result; } } + +function parseUdemae(udemae: string): [string, number | undefined] { + const [rank, rankNum] = udemae.split(/([0-9]+)/); + return [ + rank.toLowerCase(), + rankNum === undefined ? undefined : parseInt(rankNum), + ]; +} diff --git a/s3si.ts b/s3si.ts index 51e41df..9224280 100644 --- a/s3si.ts +++ b/s3si.ts @@ -1,11 +1,21 @@ import { getBulletToken, getGToken, loginManually } from "./iksm.ts"; import { flags, MultiProgressBar, Mutex } from "./deps.ts"; import { DEFAULT_STATE, State } from "./state.ts"; -import { checkToken, getBattleDetail, getBattleList } from "./splatnet3.ts"; -import { BattleExporter, VsHistoryDetail } from "./types.ts"; +import { + checkToken, + getBankaraBattleHistories, + getBattleDetail, + getBattleList, +} from "./splatnet3.ts"; +import { + BattleExporter, + HistoryGroups, + VsBattle, + VsHistoryDetail, +} from "./types.ts"; import { Cache, FileCache, MemoryCache } from "./cache.ts"; import { StatInkExporter } from "./exporter/stat.ink.ts"; -import { readline, showError } from "./utils.ts"; +import { battleId, readline, showError } from "./utils.ts"; import { FileExporter } from "./exporter/file.ts"; type Opts = { @@ -29,6 +39,8 @@ class BattleFetcher { state: State; cache: Cache; lock: Record = {}; + bankaraLock = new Mutex(); + bankaraHistory?: HistoryGroups["nodes"]; constructor( { cache = new MemoryCache(), state }: { state: State; cache?: Cache }, @@ -36,16 +48,62 @@ class BattleFetcher { this.state = state; this.cache = cache; } - getLock(id: string): Mutex { - let cur = this.lock[id]; + private async getLock(id: string): Promise { + const bid = await battleId(id); + + let cur = this.lock[bid]; if (!cur) { cur = new Mutex(); - this.lock[id] = cur; + this.lock[bid] = cur; } + return cur; } - fetchBattle(id: string): Promise { - const lock = this.getLock(id); + getBankaraHistory() { + return this.bankaraLock.use(async () => { + if (this.bankaraHistory) { + return this.bankaraHistory; + } + + const { bankaraBattleHistories: { historyGroups } } = + await getBankaraBattleHistories( + this.state, + ); + + this.bankaraHistory = historyGroups.nodes; + + return this.bankaraHistory; + }); + } + async getBattleMetaById(id: string): Promise> { + const bid = await battleId(id); + const bankaraHistory = await this.getBankaraHistory(); + const group = bankaraHistory.find((i) => + i.historyDetails.nodes.some((i) => i._bid === bid) + ); + + if (!group) { + return { + bankaraMatchChallenge: null, + listNode: null, + lastInChallenge: null, + }; + } + + const { bankaraMatchChallenge } = group; + const listNode = group.historyDetails.nodes.find((i) => i._bid === bid) ?? + null; + const idx = group.historyDetails.nodes.indexOf(listNode!); + + return { + bankaraMatchChallenge, + listNode, + lastInChallenge: (bankaraMatchChallenge?.state !== "INPROGRESS") && + (idx === 0), + }; + } + async getBattleDetail(id: string): Promise { + const lock = await this.getLock(id); return lock.use(async () => { const cached = await this.cache.read(id); @@ -61,6 +119,17 @@ class BattleFetcher { return detail; }); } + async fetchBattle(id: string): Promise { + const detail = await this.getBattleDetail(id); + const metadata = await this.getBattleMetaById(id); + + const battle: VsBattle = { + ...metadata, + detail, + }; + + return battle; + } } type Progress = { @@ -111,9 +180,9 @@ Options: await this.writeState(DEFAULT_STATE); } } - async getExporters(): Promise[]> { + async getExporters(): Promise[]> { const exporters = this.opts.exporter.split(","); - const out: BattleExporter[] = []; + const out: BattleExporter[] = []; if (exporters.includes("stat.ink")) { if (!this.state.statInkApiKey) { @@ -248,7 +317,7 @@ Options: onStep, }: { fetcher: BattleFetcher; - exporter: BattleExporter; + exporter: BattleExporter; battleList: string[]; onStep?: (progress: Progress) => void; }, diff --git a/splatnet3.ts b/splatnet3.ts index d9ee140..804729e 100644 --- a/splatnet3.ts +++ b/splatnet3.ts @@ -10,6 +10,7 @@ import { RespMap, VarsMap, } from "./types.ts"; +import { battleId } from "./utils.ts"; async function request( state: State, @@ -121,3 +122,13 @@ export function getBattleDetail( }, ); } + +export async function getBankaraBattleHistories(state: State) { + const resp = await request(state, Queries.BankaraBattleHistoriesQuery); + for (const i of resp.bankaraBattleHistories.historyGroups.nodes) { + for (const j of i.historyDetails.nodes) { + j._bid = await battleId(j.id); + } + } + return resp; +} diff --git a/types.ts b/types.ts index c82a573..2cf2374 100644 --- a/types.ts +++ b/types.ts @@ -28,12 +28,28 @@ export type Image = { width?: number; height?: number; }; +export type BankaraMatchChallenge = { + winCount: number; + loseCount: number; + maxWinCount: number; + maxLoseCount: number; + state: "FAILED" | "SUCCEEDED" | "INPROGRESS"; + isPromo: boolean; + isUdemaeUp: boolean; + udemaeAfter: string | null; + earnedUdemaePoint: number; +}; +export type BattleListNode = { + // battle id added after fetch + _bid: string; + id: string; + udemae: string; +}; export type HistoryGroups = { nodes: { + bankaraMatchChallenge: null | BankaraMatchChallenge; historyDetails: { - nodes: { - id: string; - }[]; + nodes: BattleListNode[]; }; }[]; }; @@ -65,12 +81,27 @@ export type VsTeam = { score: null | number; }; }; +export type VsRule = + | "TURF_WAR" + | "AREA" + | "LOFT" + | "GOAL" + | "CLAM" + | "TRI_COLOR"; + +// With challenge info +export type VsBattle = { + listNode: null | BattleListNode; + bankaraMatchChallenge: null | BankaraMatchChallenge; + lastInChallenge: null | boolean; + detail: VsHistoryDetail; +}; export type VsHistoryDetail = { id: string; vsRule: { name: string; id: string; - rule: "TURF_WAR" | "AREA" | "LOFT" | "GOAL" | "CLAM" | "TRI_COLOR"; + rule: VsRule; }; vsMode: { id: string; diff --git a/utils.ts b/utils.ts index debc1e7..7452eb8 100644 --- a/utils.ts +++ b/utils.ts @@ -1,5 +1,6 @@ import { APIError } from "./APIError.ts"; -import { base64, io } from "./deps.ts"; +import { S3SI_NAMESPACE } from "./constant.ts"; +import { base64, io, uuid } from "./deps.ts"; const stdinLines = io.readLines(Deno.stdin); @@ -80,3 +81,12 @@ export async function showError(p: Promise) { throw e; } } + +export function battleId( + id: string, + namespace = S3SI_NAMESPACE, +): Promise { + const fullId = base64.decode(id); + const tsUuid = fullId.slice(fullId.length - 52, fullId.length); + return uuid.v5.generate(namespace, tsUuid); +}