diff --git a/CHANGELOG.md b/CHANGELOG.md index 98ca018..6ddd7b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## 0.4.4 + +feat: send Anarchy (Open) Power + +## 0.4.3 + +feat: add `list-method` option +([#73](https://github.com/spacemeowx2/s3si.ts/issues/73)) + +## 0.4.2 + +fix: `coral_user_id` is string + ## 0.4.1 feat: add support for Challenges diff --git a/README.md b/README.md index 689cf3a..89a324a 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,16 @@ Options: --exporter , -e Exporter list to use (default: stat.ink) Multiple exporters can be separated by commas (e.g. "stat.ink,file") + --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 "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. + "auto" is the default setting. --no-progress, -n Disable progress bar --monitor, -m Monitor mode --skip-mode , -s Skip mode (default: null) ("vs", "coop") --with-summary Include summary in the output - --help Show this help message and exit`, + --help Show this help message and exit ``` 3. If it's your first time running this, follow the instructions to login to diff --git a/gui/src-tauri/tauri.conf.json b/gui/src-tauri/tauri.conf.json index a4867df..794615c 100644 --- a/gui/src-tauri/tauri.conf.json +++ b/gui/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "package": { "productName": "s3si-ts", - "version": "0.4.1" + "version": "0.4.4" }, "tauri": { "allowlist": { diff --git a/s3si.ts b/s3si.ts index f7a6ed9..d30449f 100644 --- a/s3si.ts +++ b/s3si.ts @@ -4,7 +4,7 @@ import { flags } from "./deps.ts"; const parseArgs = (args: string[]) => { const parsed = flags.parse(args, { - string: ["profilePath", "exporter", "skipMode"], + string: ["profilePath", "exporter", "skipMode", "listMethod"], boolean: ["help", "noProgress", "monitor", "withSummary"], alias: { "help": "h", @@ -15,6 +15,7 @@ const parseArgs = (args: string[]) => { "skipMode": ["s", "skip-mode"], "withSummary": "with-summary", "withStages": "with-stages", + "listMethod": "list-method", }, }); return parsed; @@ -30,6 +31,10 @@ Options: --exporter , -e Exporter list to use (default: stat.ink) Multiple exporters can be separated by commas (e.g. "stat.ink,file,mongodb") + --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 "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. + "auto" is the default setting. --no-progress, -n Disable progress bar --monitor, -m Monitor mode --skip-mode , -s Skip mode (default: null) diff --git a/src/RankTracker.ts b/src/RankTracker.ts index ba95181..145d7a0 100644 --- a/src/RankTracker.ts +++ b/src/RankTracker.ts @@ -5,7 +5,7 @@ import { HistoryGroups, RankParam, } from "./types.ts"; -import { gameId, parseHistoryDetailId } from "./utils.ts"; +import { battleTime, gameId } from "./utils.ts"; import { getSeason } from "./VersionData.ts"; const splusParams = () => { @@ -193,17 +193,6 @@ function addRank( }; } -const battleTime = (id: string) => { - const { timestamp } = parseHistoryDetailId(id); - - const dateStr = timestamp.replace( - /(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/, - "$1-$2-$3T$4:$5:$6Z", - ); - - return new Date(dateStr); -}; - type FlattenItem = { id: string; gameId: string; diff --git a/src/app.ts b/src/app.ts index e8e069c..7925542 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,8 +1,8 @@ import { loginManually } from "./iksm.ts"; -import { MultiProgressBar } from "../deps.ts"; +import { MultiProgressBar, Mutex } from "../deps.ts"; import { FileStateBackend, Profile, StateBackend } from "./state.ts"; import { Splatnet3 } from "./splatnet3.ts"; -import { BattleListType, Game, GameExporter } from "./types.ts"; +import { BattleListType, Game, GameExporter, ListMethod } from "./types.ts"; import { Cache, FileCache } from "./cache.ts"; import { StatInkExporter } from "./exporters/stat.ink.ts"; import { FileExporter } from "./exporters/file.ts"; @@ -20,6 +20,7 @@ export type Opts = { withSummary: boolean; withStages: boolean; skipMode?: string; + listMethod?: string; cache?: Cache; stateBackend?: StateBackend; env: Env; @@ -32,6 +33,7 @@ export const DEFAULT_OPTS: Opts = { monitor: false, withSummary: false, withStages: true, + listMethod: "latest", env: DEFAULT_ENV, }; @@ -56,6 +58,103 @@ class StepProgress { } } +interface GameListFetcher { + /** + * Return not exported game list. + * [0] is the latest game. + * @param exporter GameExporter + */ + fetch(exporter: GameExporter): Promise; +} + +class BattleListFetcher implements GameListFetcher { + protected listMethod: ListMethod; + protected allBattleList?: string[]; + protected latestBattleList?: string[]; + protected allLock = new Mutex(); + protected latestLock = new Mutex(); + + constructor( + listMethod: string, + protected splatnet: Splatnet3, + ) { + if (listMethod === "all") { + this.listMethod = "all"; + } else if (listMethod === "latest") { + this.listMethod = "latest"; + } else { + this.listMethod = "auto"; + } + } + + protected getAllBattleList() { + return this.allLock.use(async () => { + if (!this.allBattleList) { + this.allBattleList = await this.splatnet.getAllBattleList(); + } + return this.allBattleList; + }); + } + + protected getLatestBattleList() { + return this.latestLock.use(async () => { + if (!this.latestBattleList) { + this.latestBattleList = await this.splatnet.getBattleList(); + } + return this.latestBattleList; + }); + } + + private async innerFetch(exporter: GameExporter) { + if (this.listMethod === "latest") { + return await exporter.notExported({ + type: "VsInfo", + list: await this.getLatestBattleList(), + }); + } + if (this.listMethod === "all") { + return await exporter.notExported({ + type: "VsInfo", + list: await this.getAllBattleList(), + }); + } + if (this.listMethod === "auto") { + const latestList = await exporter.notExported({ + type: "VsInfo", + list: await this.getLatestBattleList(), + }); + if (latestList.length === 50) { + return await exporter.notExported({ + type: "VsInfo", + list: await this.getAllBattleList(), + }); + } + return latestList; + } + + throw new TypeError(`Unknown listMethod: ${this.listMethod}`); + } + + async fetch(exporter: GameExporter) { + return [...await this.innerFetch(exporter)].reverse(); + } +} + +class CoopListFetcher implements GameListFetcher { + constructor( + protected splatnet: Splatnet3, + ) {} + + async fetch(exporter: GameExporter) { + return [ + ...await exporter.notExported({ + type: "CoopInfo", + list: await this.splatnet.getBattleList(BattleListType.Coop), + }), + ].reverse(); + } +} + function progress({ total, currentUrl, done }: StepProgress): Progress { return { total, @@ -76,6 +175,12 @@ export class App { env: opts.env, }); this.env = opts.env; + + if ( + opts.listMethod && !["all", "auto", "latest"].includes(opts.listMethod) + ) { + throw new TypeError(`Unknown listMethod: ${opts.listMethod}`); + } } getSkipMode(): ("vs" | "coop")[] { @@ -193,8 +298,10 @@ export class App { if (skipMode.includes("vs") || exporters.length === 0) { this.env.logger.log("Skip exporting VS games."); } else { - this.env.logger.log("Fetching battle list..."); - const gameList = await splatnet.getBattleList(); + const gameListFetcher = new BattleListFetcher( + this.opts.listMethod ?? "auto", + splatnet, + ); const { redraw, endBar } = this.exporterProgress("Export vs games"); const fetcher = new GameFetcher({ @@ -213,7 +320,7 @@ export class App { type: "VsInfo", fetcher, exporter: e, - gameList, + gameListFetcher, stepProgress: stats[e.name], onStep: () => { redraw(e.name, progress(stats[e.name])); @@ -247,10 +354,7 @@ export class App { if (skipMode.includes("coop") || exporters.length === 0) { this.env.logger.log("Skip exporting coop games."); } else { - this.env.logger.log("Fetching coop battle list..."); - const coopBattleList = await splatnet.getBattleList( - BattleListType.Coop, - ); + const gameListFetcher = new CoopListFetcher(splatnet); const { redraw, endBar } = this.exporterProgress("Export coop games"); const fetcher = new GameFetcher({ @@ -267,7 +371,7 @@ export class App { type: "CoopInfo", fetcher, exporter: e, - gameList: coopBattleList, + gameListFetcher, stepProgress: stats[e.name], onStep: () => { redraw(e.name, progress(stats[e.name])); @@ -401,30 +505,24 @@ export class App { * @param gameList ID list of games, sorted by date, newest first * @param onStep Callback function called when a game is exported */ - async exportGameList({ + private async exportGameList({ type, fetcher, exporter, - gameList, + gameListFetcher, stepProgress, onStep, }: { type: Game["type"]; exporter: GameExporter; fetcher: GameFetcher; - gameList: string[]; + gameListFetcher: GameListFetcher; stepProgress: StepProgress; onStep: () => void; }): Promise { onStep?.(); - const workQueue = [ - ...await exporter.notExported({ - type, - list: gameList, - }), - ] - .reverse(); + const workQueue = await gameListFetcher.fetch(exporter); const step = async (id: string) => { const detail = await fetcher.fetch(type, id); diff --git a/src/constant.ts b/src/constant.ts index b5b64b4..4c0822e 100644 --- a/src/constant.ts +++ b/src/constant.ts @@ -2,9 +2,9 @@ import type { StatInkPostBody, VsHistoryDetail } from "./types.ts"; export const AGENT_NAME = "splashcat / s3si.ts"; export const AGENT_VERSION = "1.1.1"; -export const S3SI_VERSION = "0.4.1"; +export const S3SI_VERSION = "0.4.4"; export const COMBINED_VERSION = `${AGENT_VERSION}/${S3SI_VERSION}`; -export const NSOAPP_VERSION = "2.5.1"; +export const NSOAPP_VERSION = "2.5.2"; export const WEB_VIEW_VERSION = "4.0.0-d5178440"; export enum Queries { HomeQuery = "7dcc64ea27a08e70919893a0d3f70871", @@ -12,6 +12,7 @@ export enum Queries { RegularBattleHistoriesQuery = "3baef04b095ad8975ea679d722bc17de", BankaraBattleHistoriesQuery = "0438ea6978ae8bd77c5d1250f4f84803", XBattleHistoriesQuery = "6796e3cd5dc3ebd51864dc709d899fc5", + EventBattleHistoriesQuery = "9744fcf676441873c7c8a51285b6aa4d", PrivateBattleHistoriesQuery = "8e5ae78b194264a6c230e262d069bd28", VsHistoryDetailQuery = "9ee0099fbe3d8db2a838a75cf42856dd", CoopHistoryQuery = "91b917becd2fa415890f5b47e15ffb15", diff --git a/src/exporters/stat.ink.ts b/src/exporters/stat.ink.ts index c68b2dd..9bfa254 100644 --- a/src/exporters/stat.ink.ts +++ b/src/exporters/stat.ink.ts @@ -588,6 +588,8 @@ export class StatInkExporter implements GameExporter { } } + result.bankara_power_after = vsDetail.bankaraMatch?.bankaraPower?.power; + if (rankBeforeState && rankState) { result.rank_before_exp = rankBeforeState.rankPoint; result.rank_after_exp = rankState.rankPoint; diff --git a/src/iksm.ts b/src/iksm.ts index 5c1c5fa..f53db9f 100644 --- a/src/iksm.ts +++ b/src/iksm.ts @@ -213,20 +213,20 @@ export async function getGToken( const idToken2: string = respJson?.result?.webApiServerCredential ?.accessToken; - const coralUserId: number = respJson?.result?.user?.id; + const coralUserId: string = respJson?.result?.user?.id?.toString(); if (!idToken2 || !coralUserId) { throw new APIError({ response: resp, json: respJson, message: - `No idToken2 or coralUserId found. Please try again later. ('${idToken2}', '${coralUserId}')`, + `No idToken2 or coralUserId found. Please try again later. (${idToken2.length}, ${coralUserId.length})`, }); } return [idToken2, coralUserId] as const; }; - const getGToken = async (idToken: string, coralUserId: number) => { + const getGToken = async (idToken: string, coralUserId: string) => { const { f, request_id: requestId, timestamp } = await callImink({ step: 2, idToken, @@ -414,7 +414,7 @@ async function callImink( step: number; idToken: string; userId: string; - coralUserId?: number; + coralUserId?: string; env: Env; }, ): Promise { diff --git a/src/splatnet3.ts b/src/splatnet3.ts index a86cffa..32b001f 100644 --- a/src/splatnet3.ts +++ b/src/splatnet3.ts @@ -15,7 +15,7 @@ import { } from "./types.ts"; import { DEFAULT_ENV, Env } from "./env.ts"; import { getBulletToken, getGToken } from "./iksm.ts"; -import { parseHistoryDetailId } from "./utils.ts"; +import { battleTime, parseHistoryDetailId } from "./utils.ts"; export class Splatnet3 { protected profile: Profile; @@ -137,6 +137,12 @@ export class Splatnet3 { [BattleListType.Bankara]: () => this.request(Queries.BankaraBattleHistoriesQuery) .then((r) => getIdsFromGroups(r.bankaraBattleHistories)), + [BattleListType.XBattle]: () => + this.request(Queries.XBattleHistoriesQuery) + .then((r) => getIdsFromGroups(r.xBattleHistories)), + [BattleListType.Event]: () => + this.request(Queries.EventBattleHistoriesQuery) + .then((r) => getIdsFromGroups(r.eventBattleHistories)), [BattleListType.Private]: () => this.request(Queries.PrivateBattleHistoriesQuery) .then((r) => getIdsFromGroups(r.privateBattleHistories)), @@ -168,6 +174,29 @@ export class Splatnet3 { return await this.BATTLE_LIST_TYPE_MAP[battleListType](); } + // Get all id from all battle list, sort by time, [0] is the latest + async getAllBattleList() { + const ALL_TYPE: BattleListType[] = [ + BattleListType.Regular, + BattleListType.Bankara, + BattleListType.XBattle, + BattleListType.Event, + BattleListType.Private, + ]; + const ids: string[] = []; + for (const type of ALL_TYPE) { + ids.push(...await this.getBattleList(type)); + } + + const timeMap = new Map( + ids.map((id) => [id, battleTime(id)] as const), + ); + + return ids.sort((a, b) => + timeMap.get(b)!.getTime() - timeMap.get(a)!.getTime() + ); + } + getBattleDetail( id: string, ) { diff --git a/src/types.ts b/src/types.ts index d5eedfa..a60b05d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,7 @@ export type VarsMap = { [Queries.RegularBattleHistoriesQuery]: []; [Queries.BankaraBattleHistoriesQuery]: []; [Queries.XBattleHistoriesQuery]: []; + [Queries.EventBattleHistoriesQuery]: []; [Queries.PrivateBattleHistoriesQuery]: []; [Queries.VsHistoryDetailQuery]: [{ vsResultId: string; @@ -244,6 +245,9 @@ export type VsHistoryDetail = { bankaraMatch: { earnedUdemaePoint: null | number; mode: "OPEN" | "CHALLENGE"; + bankaraPower?: null | { + power?: null | number; + }; } | null; festMatch: { dragonMatchType: "NORMAL" | "DECUPLE" | "DRAGON" | "DOUBLE_DRAGON"; @@ -427,6 +431,11 @@ export type RespMap = { }; [Queries.BankaraBattleHistoriesQuery]: BankaraBattleHistories; [Queries.XBattleHistoriesQuery]: XBattleHistories; + [Queries.EventBattleHistoriesQuery]: { + eventBattleHistories: { + historyGroups: HistoryGroups; + }; + }; [Queries.PrivateBattleHistoriesQuery]: { privateBattleHistories: { historyGroups: HistoryGroups; @@ -614,10 +623,14 @@ export enum BattleListType { Latest, Regular, Bankara, + Event, + XBattle, Private, Coop, } +export type ListMethod = "latest" | "all" | "auto"; + export type StatInkUuidList = { status: number; code: number; @@ -822,6 +835,8 @@ export type StatInkPostBody = { challenge_lose?: number; x_power_before?: number | null; x_power_after?: number | null; + bankara_power_before?: number | null; + bankara_power_after?: number | null; fest_power?: number; // Splatfest Power (Pro) fest_dragon?: | "10x" diff --git a/src/utils.ts b/src/utils.ts index 53dd34e..0bd356a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -188,3 +188,14 @@ export function urlSimplify(url: string): { pathname: string } | string { return url; } } + +export const battleTime = (id: string) => { + const { timestamp } = parseHistoryDetailId(id); + + const dateStr = timestamp.replace( + /(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/, + "$1-$2-$3T$4:$5:$6Z", + ); + + return new Date(dateStr); +};