diff --git a/CHANGELOG b/CHANGELOG index f4561be..5b01b20 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,33 @@ +0.1.12 + +feat: add rank tracker + +0.1.11 + +feat: use s3s' namespace. (see https://github.com/frozenpandaman/s3s/issues/65 for detail) +refactor: remove `_bid` in exported file + +0.1.10 + +fix: missing draw judgement + +0.1.9 + +update WEB_VIEW_VERSION + +0.1.8 + +feat: add coop export + +0.1.7 + +feat: refetch token when 401 close #5 +feat: add github link to UA + +0.1.6 + +fix: wrong base64 encode/decode. (#4) + 0.1.5 fix: rank_up sent on last battle of challenge diff --git a/README.md b/README.md index 641a3d3..a59a50e 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,18 @@ Options: - If you want to use a different profile, use `-p` to specify the path to the profile file. +### Track your rank + +- Run + `deno run -Ar https://raw.githubusercontent.com/spacemeowx2/s3si.ts/main/initRank.ts` + to initialize your rank data. (You can also use `-p` to specify the path to + the profile file.) + +- Then enter your current rank and rank point. For example: `S+0,300`. And the + rank will be saved in the `profile.json`. + +- After that, run `s3si.ts`, the rank point will be reported to `stat.ink`. + ### profile.json ```js diff --git a/dev_deps.ts b/dev_deps.ts new file mode 100644 index 0000000..d68d3be --- /dev/null +++ b/dev_deps.ts @@ -0,0 +1 @@ +export { assertEquals } from "https://deno.land/std@0.160.0/testing/asserts.ts"; diff --git a/initRank.ts b/initRank.ts new file mode 100644 index 0000000..57d13ff --- /dev/null +++ b/initRank.ts @@ -0,0 +1,112 @@ +/** + * If rankState in profile.json is not defined, it will be initialized. + */ +import { flags } from "./deps.ts"; +import { getBulletToken, getGToken } from "./src/iksm.ts"; +import { checkToken, getBattleDetail, getBattleList } from "./src/splatnet3.ts"; +import { gameId, readline } from "./src/utils.ts"; +import { FileStateBackend } from "./src/state.ts"; +import { BattleListType } from "./src/types.ts"; +import { RANK_PARAMS } from "./src/RankTracker.ts"; + +const parseArgs = (args: string[]) => { + const parsed = flags.parse(args, { + string: ["profilePath"], + alias: { + "help": "h", + "profilePath": ["p", "profile-path"], + }, + }); + return parsed; +}; + +const opts = parseArgs(Deno.args); +if (opts.help) { + console.log( + `Usage: deno run -A ${Deno.mainModule} [options] + + Options: + --profile-path , -p Path to config file (default: ./profile.json) + --help Show this help message and exit`, + ); + Deno.exit(0); +} + +const stateBackend = new FileStateBackend(opts.profilePath ?? "./profile.json"); +let state = await stateBackend.read(); + +if (state.rankState) { + console.log("rankState is already initialized."); + Deno.exit(0); +} + +if (!await checkToken(state)) { + const sessionToken = state.loginState?.sessionToken; + + if (!sessionToken) { + throw new Error("Session token is not set."); + } + + const { webServiceToken, userCountry, userLang } = await getGToken({ + fApi: state.fGen, + sessionToken, + }); + + const bulletToken = await getBulletToken({ + webServiceToken, + userLang, + userCountry, + appUserAgent: state.appUserAgent, + }); + + state = { + ...state, + loginState: { + ...state.loginState, + gToken: webServiceToken, + bulletToken, + }, + userLang: state.userLang ?? userLang, + userCountry: state.userCountry ?? userCountry, + }; + await stateBackend.write(state); +} + +const battleList = await getBattleList(state, BattleListType.Bankara); +if (battleList.length === 0) { + console.log("No anarchy battle found. Did you play anarchy?"); + Deno.exit(0); +} +const { vsHistoryDetail: detail } = await getBattleDetail(state, battleList[0]); + +console.log( + `Your latest anarchy battle is played at ${ + new Date(detail.playedTime).toLocaleString() + }. Please enter your rank after this battle(format: RANK,POINT. S+0,300):`, +); + +while (true) { + const userInput = await readline(); + const [rank, point] = userInput.split(","); + const pointNumber = parseInt(point); + + if (!RANK_PARAMS.find((i) => i.rank === rank)) { + console.log("Invalid rank. Please enter again:"); + } else if (isNaN(pointNumber)) { + console.log("Invalid point. Please enter again:"); + } else { + state = { + ...state, + rankState: { + gameId: await gameId(detail.id), + rank, + rankPoint: pointNumber, + }, + }; + + break; + } +} + +await stateBackend.write(state); +console.log("rankState is initialized."); diff --git a/src/GameFetcher.ts b/src/GameFetcher.ts new file mode 100644 index 0000000..e20ab83 --- /dev/null +++ b/src/GameFetcher.ts @@ -0,0 +1,236 @@ +import { Mutex } from "../deps.ts"; +import { RankState, State } from "./state.ts"; +import { + getBankaraBattleHistories, + getBattleDetail, + getCoopDetail, + getCoopHistories, +} from "./splatnet3.ts"; +import { + BattleListNode, + ChallengeProgress, + CoopInfo, + CoopListNode, + Game, + HistoryGroups, + VsInfo, +} from "./types.ts"; +import { Cache, MemoryCache } from "./cache.ts"; +import { gameId } from "./utils.ts"; +import { RankTracker } from "./RankTracker.ts"; + +/** + * Fetch game and cache it. It also fetches bankara match challenge info. + */ +export class GameFetcher { + state: State; + cache: Cache; + rankTracker: RankTracker; + + lock: Record = {}; + bankaraLock = new Mutex(); + bankaraHistory?: HistoryGroups["nodes"]; + coopLock = new Mutex(); + coopHistory?: HistoryGroups["nodes"]; + + constructor( + { cache = new MemoryCache(), state }: { state: State; cache?: Cache }, + ) { + this.state = state; + this.cache = cache; + this.rankTracker = new RankTracker(state.rankState); + } + private getLock(id: string): Mutex { + let cur = this.lock[id]; + + if (!cur) { + cur = new Mutex(); + this.lock[id] = cur; + } + + return cur; + } + + setRankState(state: RankState | undefined) { + this.rankTracker.setState(state); + } + + async updateRank(): Promise { + const finalState = await this.rankTracker.updateState( + await this.getBankaraHistory(), + ); + return finalState; + } + + getRankStateById(id: string) { + return this.rankTracker.getRankStateById(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; + }); + } + getCoopHistory() { + return this.coopLock.use(async () => { + if (this.coopHistory) { + return this.coopHistory; + } + + const { coopResult: { historyGroups } } = await getCoopHistories( + this.state, + ); + + this.coopHistory = historyGroups.nodes; + + return this.coopHistory; + }); + } + async getCoopMetaById(id: string): Promise> { + const coopHistory = await this.getCoopHistory(); + const group = coopHistory.find((i) => + i.historyDetails.nodes.some((i) => i.id === id) + ); + + if (!group) { + return { + type: "CoopInfo", + listNode: null, + }; + } + + const listNode = group.historyDetails.nodes.find((i) => i.id === id) ?? + null; + + return { + type: "CoopInfo", + listNode, + }; + } + async getBattleMetaById(id: string): Promise> { + const gid = await gameId(id); + const bankaraHistory = await this.getBankaraHistory(); + const gameIdMap = new Map(); + + for (const i of bankaraHistory) { + for (const j of i.historyDetails.nodes) { + gameIdMap.set(j, await gameId(j.id)); + } + } + + const group = bankaraHistory.find((i) => + i.historyDetails.nodes.some((i) => gameIdMap.get(i) === gid) + ); + + if (!group) { + return { + type: "VsInfo", + challengeProgress: null, + bankaraMatchChallenge: null, + listNode: null, + rankState: null, + rankBeforeState: null, + }; + } + + const { bankaraMatchChallenge } = group; + const listNode = + group.historyDetails.nodes.find((i) => gameIdMap.get(i) === gid) ?? + null; + const index = group.historyDetails.nodes.indexOf(listNode!); + + let challengeProgress: null | ChallengeProgress = null; + if (bankaraMatchChallenge) { + const pastBattles = group.historyDetails.nodes.slice(0, index); + const { winCount, loseCount } = bankaraMatchChallenge; + challengeProgress = { + index, + winCount: winCount - + pastBattles.filter((i) => i.judgement == "WIN").length, + loseCount: loseCount - + pastBattles.filter((i) => + ["LOSE", "DEEMED_LOSE"].includes(i.judgement) + ).length, + }; + } + + const { before, after } = await this.rankTracker.getRankStateById(id) ?? {}; + + return { + type: "VsInfo", + bankaraMatchChallenge, + listNode, + challengeProgress, + rankState: after ?? null, + rankBeforeState: before ?? null, + }; + } + cacheDetail( + id: string, + getter: () => Promise, + ): Promise { + const lock = this.getLock(id); + + return lock.use(async () => { + const cached = await this.cache.read(id); + if (cached) { + return cached; + } + + const detail = await getter(); + + await this.cache.write(id, detail); + + return detail; + }); + } + fetch(type: Game["type"], id: string): Promise { + switch (type) { + case "VsInfo": + return this.fetchBattle(id); + case "CoopInfo": + return this.fetchCoop(id); + default: + throw new Error(`Unknown game type: ${type}`); + } + } + async fetchBattle(id: string): Promise { + const detail = await this.cacheDetail( + id, + () => getBattleDetail(this.state, id).then((r) => r.vsHistoryDetail), + ); + const metadata = await this.getBattleMetaById(id); + + const game: VsInfo = { + ...metadata, + detail, + }; + + return game; + } + async fetchCoop(id: string): Promise { + const detail = await this.cacheDetail( + id, + () => getCoopDetail(this.state, id).then((r) => r.coopHistoryDetail), + ); + const metadata = await this.getCoopMetaById(id); + + const game: CoopInfo = { + ...metadata, + detail, + }; + + return game; + } +} diff --git a/src/RankTracker.test.ts b/src/RankTracker.test.ts new file mode 100644 index 0000000..36b32e7 --- /dev/null +++ b/src/RankTracker.test.ts @@ -0,0 +1,287 @@ +import { RankTracker } from "./RankTracker.ts"; +import { assertEquals } from "../dev_deps.ts"; +import { BattleListNode } from "./types.ts"; +import { base64 } from "../deps.ts"; +import { gameId } from "./utils.ts"; + +const INIT_STATE = { + gameId: await gameId(genId(0)), + rank: "B-", + rankPoint: 100, +}; + +class TestRankTracker extends RankTracker { + testGet() { + const { state, deltaMap } = this; + return { + state, + deltaMap, + }; + } +} + +function genId(id: number): string { + return base64.encode( + `VsHistoryDetail-asdf:asdf:20220101T${ + id.toString().padStart(6, "0") + }_------------------------------------`, + ); +} + +function genOpenWins( + { startId, count, udemae }: { + startId: number; + count: number; + udemae: string; + }, +) { + const result: BattleListNode[] = []; + let id = startId; + + for (let i = 0; i < count; i++) { + result.push({ + id: genId(id), + udemae, + judgement: "WIN", + bankaraMatch: { + earnedUdemaePoint: 8, + }, + }); + id += 1; + } + + return result; +} + +Deno.test("RankTracker tracks promotion, ignoring INPROGRESS", async () => { + const INIT_STATE = { + gameId: await gameId(genId(0)), + rank: "B+", + rankPoint: 850, + }; + + const tracker = new TestRankTracker(INIT_STATE); + assertEquals(tracker.testGet(), { + state: INIT_STATE, + deltaMap: new Map(), + }); + + const finalState = await tracker.updateState([{ + bankaraMatchChallenge: { + winCount: 2, + loseCount: 0, + maxWinCount: 3, + maxLoseCount: 3, + state: "INPROGRESS", + isPromo: true, + isUdemaeUp: true, + udemaeAfter: "A-", + earnedUdemaePoint: null, + }, + historyDetails: { + nodes: [{ + id: await genId(1), + udemae: "B+", + judgement: "WIN", + bankaraMatch: { + earnedUdemaePoint: null, + }, + }, { + id: await genId(0), + udemae: "B+", + judgement: "WIN", + bankaraMatch: { + earnedUdemaePoint: null, + }, + }], + }, + }]); + + assertEquals(finalState, { + gameId: await gameId(genId(1)), + rank: "B+", + rankPoint: 850, + }); +}); + +Deno.test("RankTracker tracks promotion", async () => { + const INIT_STATE = { + gameId: await gameId(genId(0)), + rank: "B+", + rankPoint: 850, + }; + + const tracker = new TestRankTracker(INIT_STATE); + assertEquals(tracker.testGet(), { + state: INIT_STATE, + deltaMap: new Map(), + }); + + const finalState = await tracker.updateState([{ + bankaraMatchChallenge: { + winCount: 3, + loseCount: 0, + maxWinCount: 3, + maxLoseCount: 3, + state: "SUCCEEDED", + isPromo: true, + isUdemaeUp: true, + udemaeAfter: "A-", + earnedUdemaePoint: null, + }, + historyDetails: { + nodes: [{ + id: await genId(2), + udemae: "B+", + judgement: "WIN", + bankaraMatch: { + earnedUdemaePoint: null, + }, + }, { + id: await genId(1), + udemae: "B+", + judgement: "WIN", + bankaraMatch: { + earnedUdemaePoint: null, + }, + }, { + id: await genId(0), + udemae: "B+", + judgement: "WIN", + bankaraMatch: { + earnedUdemaePoint: null, + }, + }], + }, + }]); + + assertEquals(finalState, { + gameId: await gameId(genId(2)), + rank: "A-", + rankPoint: 200, + }); +}); + +Deno.test("RankTracker tracks challenge charge", async () => { + const tracker = new TestRankTracker(INIT_STATE); + assertEquals(tracker.testGet(), { + state: INIT_STATE, + deltaMap: new Map(), + }); + + const finalState = await tracker.updateState([{ + bankaraMatchChallenge: { + winCount: 1, + loseCount: 0, + maxWinCount: 5, + maxLoseCount: 3, + isPromo: false, + isUdemaeUp: false, + udemaeAfter: null, + earnedUdemaePoint: null, + state: "INPROGRESS", + }, + historyDetails: { + nodes: [ + { + id: genId(2), + udemae: "B-", + judgement: "WIN", + bankaraMatch: { + earnedUdemaePoint: null, + }, + }, + ], + }, + }, { + bankaraMatchChallenge: null, + historyDetails: { + nodes: genOpenWins({ + startId: 0, + count: 1, + udemae: "B-", + }), + }, + }]); + + assertEquals(tracker.testGet().state, INIT_STATE); + + assertEquals(finalState, { + gameId: await gameId(genId(2)), + rank: "B-", + rankPoint: 45, + }); +}); + +Deno.test("RankTracker", async () => { + const tracker = new TestRankTracker(INIT_STATE); + assertEquals(tracker.testGet(), { + state: INIT_STATE, + deltaMap: new Map(), + }); + + const finalState = await tracker.updateState([{ + bankaraMatchChallenge: null, + historyDetails: { + nodes: [...genOpenWins({ + startId: 0, + count: 19, + udemae: "B-", + })].reverse(), + }, + }]); + + assertEquals(tracker.testGet().state, INIT_STATE); + + assertEquals(finalState, { + gameId: await gameId(genId(18)), + rank: "B-", + rankPoint: 244, + }); + + assertEquals((await tracker.getRankStateById(genId(1)))?.after, { + gameId: await gameId(genId(1)), + rank: "B-", + rankPoint: 108, + }); + + assertEquals((await tracker.getRankStateById(genId(17)))?.after, { + gameId: await gameId(genId(17)), + rank: "B-", + rankPoint: 236, + }); + + tracker.setState(finalState); + + assertEquals(tracker.testGet().state, finalState); + + // history goes too far + const finalState2 = await tracker.updateState([{ + bankaraMatchChallenge: null, + historyDetails: { + nodes: [...genOpenWins({ + startId: 30, + count: 1, + udemae: "B-", + })].reverse(), + }, + }]); + assertEquals(finalState2, undefined); + + await tracker.updateState([{ + bankaraMatchChallenge: null, + historyDetails: { + nodes: [...genOpenWins({ + startId: 0, + count: 30, + udemae: "B-", + })].reverse(), + }, + }]); + + assertEquals((await tracker.getRankStateById(genId(29)))?.after, { + gameId: await gameId(genId(29)), + rank: "B-", + rankPoint: 332, + }); +}); diff --git a/src/RankTracker.ts b/src/RankTracker.ts new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/src/RankTracker.ts @@ -0,0 +1,269 @@ +import { RankState } from "./state.ts"; +import { BattleListNode, HistoryGroups, RankParam } from "./types.ts"; +import { gameId, parseHistoryDetailId } from "./utils.ts"; + +const splusParams = () => { + const out: RankParam[] = []; + + for (let i = 0; i < 50; i++) { + const level = i % 10; + const item: RankParam = { + rank: `S+${i}`, + pointRange: [300 + level * 350, 300 + (level + 1) * 350], + charge: 160, + }; + if (level === 9) { + item.promotion = true; + } + out.push(item); + } + + out.push({ + rank: "S+50", + pointRange: [0, 9999], + charge: 160, + }); + + return out; +}; + +export const RANK_PARAMS: RankParam[] = [{ + rank: "C-", + pointRange: [0, 200], + charge: 0, +}, { + rank: "C", + pointRange: [200, 400], + charge: 20, +}, { + rank: "C+", + pointRange: [400, 600], + charge: 40, + promotion: true, +}, { + rank: "B-", + pointRange: [100, 350], + charge: 55, +}, { + rank: "B", + pointRange: [350, 600], + charge: 70, +}, { + rank: "B+", + pointRange: [600, 850], + charge: 85, + promotion: true, +}, { + rank: "A-", + pointRange: [200, 500], + charge: 100, +}, { + rank: "A", + pointRange: [500, 800], + charge: 110, +}, { + rank: "A+", + pointRange: [800, 1100], + charge: 120, + promotion: true, +}, { + rank: "S", + pointRange: [300, 1000], + charge: 150, + promotion: true, +}, ...splusParams()]; + +type Delta = { + beforeGameId: string; + gameId: string; + rankAfter?: string; + rankPoint: number; + isPromotion: boolean; + isChallengeFirst: boolean; +}; + +// TODO: auto rank up using rank params and delta. +function addRank(state: RankState, delta: Delta): RankState { + const { rank, rankPoint } = state; + const { gameId, rankAfter, isPromotion, isChallengeFirst } = delta; + + const rankIndex = RANK_PARAMS.findIndex((r) => r.rank === rank); + + if (rankIndex === -1) { + throw new Error(`Rank not found: ${rank}`); + } + + const rankParam = RANK_PARAMS[rankIndex]; + + if (isChallengeFirst) { + return { + gameId, + rank, + rankPoint: rankPoint - rankParam.charge, + }; + } + + // S+50 is the highest rank + if (rankIndex === RANK_PARAMS.length - 1) { + return { + gameId, + rank, + rankPoint: Math.min(rankPoint + delta.rankPoint, rankParam.pointRange[1]), + }; + } + + if (isPromotion) { + const nextRankParam = RANK_PARAMS[rankIndex + 1]; + + return { + gameId, + rank: nextRankParam.rank, + rankPoint: nextRankParam.pointRange[0], + }; + } + + return { + gameId, + rank: rankAfter ?? rank, + rankPoint: rankPoint + delta.rankPoint, + }; +} + +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); +}; + +/** + * if state is empty, it will not track rank. + */ +export class RankTracker { + // key: privous game id + protected deltaMap: Map = new Map(); + + constructor(protected state: RankState | undefined) {} + + async getRankStateById( + id: string, + ): Promise<{ before: RankState; after: RankState } | undefined> { + if (!this.state) { + return; + } + const gid = await gameId(id); + + let cur = this.state; + let before = cur; + while (cur.gameId !== gid) { + const delta = this.deltaMap.get(cur.gameId); + if (!delta) { + return; + } + before = cur; + cur = addRank(cur, delta); + } + + return { + before, + after: cur, + }; + } + + setState(state: RankState | undefined) { + this.state = state; + } + + async updateState( + hisotry: HistoryGroups["nodes"], + ) { + if (!this.state) { + return; + } + + const flatten = await Promise.all( + hisotry + .flatMap( + ({ historyDetails, bankaraMatchChallenge }) => { + return historyDetails.nodes.map((j, index) => ({ + time: battleTime(j.id), + gameId: gameId(j.id), + bankaraMatchChallenge, + index, + groupLength: historyDetails.nodes.length, + detail: j, + })); + }, + ) + .sort((a, b) => a.time.getTime() - b.time.getTime()) + .map((i) => i.gameId.then((gameId) => ({ ...i, gameId }))), + ); + + const index = flatten.findIndex((i) => i.gameId === this.state!.gameId); + + if (index === -1) { + return; + } + + const unProcessed = flatten.slice(index); + const deltaList: Delta[] = []; + let beforeGameId = this.state.gameId; + + for (const i of unProcessed.slice(1)) { + if (!i.detail.bankaraMatch) { + throw new TypeError("bankaraMatch must be defined"); + } + + let delta: Delta = { + beforeGameId, + gameId: i.gameId, + rankPoint: 0, + isPromotion: false, + isChallengeFirst: false, + }; + beforeGameId = i.gameId; + if (i.bankaraMatchChallenge) { + // challenge + if (i.index === 0 && i.bankaraMatchChallenge.state !== "INPROGRESS") { + // last battle in challenge + delta = { + ...delta, + rankAfter: i.bankaraMatchChallenge.udemaeAfter ?? undefined, + rankPoint: i.bankaraMatchChallenge.earnedUdemaePoint ?? 0, + isPromotion: i.bankaraMatchChallenge.isPromo ?? false, + isChallengeFirst: false, + }; + } else if (i.index === i.groupLength - 1) { + // first battle in challenge + delta = { + ...delta, + isChallengeFirst: true, + }; + } + } else { + // open + delta = { + ...delta, + // TODO: rankAfter should be undefined in open battle + rankAfter: i.detail.udemae, + rankPoint: i.detail.bankaraMatch?.earnedUdemaePoint ?? 0, + }; + } + + deltaList.push(delta); + } + + let curState = this.state; + + for (const delta of deltaList) { + this.deltaMap.set(delta.beforeGameId, delta); + curState = addRank(curState, delta); + } + + return curState; + } +} diff --git a/src/app.ts b/src/app.ts index 5bf40c2..e14fdd1 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,41 +1,24 @@ import { getBulletToken, getGToken, loginManually } from "./iksm.ts"; -import { MultiProgressBar, Mutex } from "../deps.ts"; +import { MultiProgressBar } from "../deps.ts"; import { DEFAULT_STATE, FileStateBackend, State, StateBackend, } from "./state.ts"; -import { - getBankaraBattleHistories, - getBattleDetail, - getBattleList, - getCoopDetail, - getCoopHistories, - isTokenExpired, -} from "./splatnet3.ts"; -import { - BattleListNode, - BattleListType, - ChallengeProgress, - CoopInfo, - CoopListNode, - Game, - GameExporter, - HistoryGroups, - VsInfo, -} from "./types.ts"; -import { Cache, FileCache, MemoryCache } from "./cache.ts"; +import { getBattleList, isTokenExpired } from "./splatnet3.ts"; +import { BattleListType, Game, GameExporter } from "./types.ts"; +import { Cache, FileCache } from "./cache.ts"; import { StatInkExporter } from "./exporters/stat.ink.ts"; import { FileExporter } from "./exporters/file.ts"; import { delay, - gameId, readline, RecoverableError, retryRecoverableError, showError, } from "./utils.ts"; +import { GameFetcher } from "./GameFetcher.ts"; export type Opts = { profilePath: string; @@ -54,198 +37,6 @@ export const DEFAULT_OPTS: Opts = { monitor: false, }; -/** - * Fetch game and cache it. - */ -class GameFetcher { - state: State; - cache: Cache; - lock: Record = {}; - bankaraLock = new Mutex(); - bankaraHistory?: HistoryGroups["nodes"]; - coopLock = new Mutex(); - coopHistory?: HistoryGroups["nodes"]; - - constructor( - { cache = new MemoryCache(), state }: { state: State; cache?: Cache }, - ) { - this.state = state; - this.cache = cache; - } - private getLock(id: string): Mutex { - let cur = this.lock[id]; - - if (!cur) { - cur = new Mutex(); - this.lock[id] = cur; - } - - return cur; - } - - 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; - }); - } - getCoopHistory() { - return this.coopLock.use(async () => { - if (this.coopHistory) { - return this.coopHistory; - } - - const { coopResult: { historyGroups } } = await getCoopHistories( - this.state, - ); - - this.coopHistory = historyGroups.nodes; - - return this.coopHistory; - }); - } - async getCoopMetaById(id: string): Promise> { - const coopHistory = await this.getCoopHistory(); - const group = coopHistory.find((i) => - i.historyDetails.nodes.some((i) => i.id === id) - ); - - if (!group) { - return { - type: "CoopInfo", - listNode: null, - }; - } - - const listNode = group.historyDetails.nodes.find((i) => i.id === id) ?? - null; - - return { - type: "CoopInfo", - listNode, - }; - } - async getBattleMetaById(id: string): Promise> { - const gid = await gameId(id); - const bankaraHistory = await this.getBankaraHistory(); - const gameIdMap = new Map(); - - for (const i of bankaraHistory) { - for (const j of i.historyDetails.nodes) { - gameIdMap.set(j, await gameId(j.id)); - } - } - - const group = bankaraHistory.find((i) => - i.historyDetails.nodes.some((i) => gameIdMap.get(i) === gid) - ); - - if (!group) { - return { - type: "VsInfo", - challengeProgress: null, - bankaraMatchChallenge: null, - listNode: null, - }; - } - - const { bankaraMatchChallenge } = group; - const listNode = - group.historyDetails.nodes.find((i) => gameIdMap.get(i) === gid) ?? - null; - const index = group.historyDetails.nodes.indexOf(listNode!); - - let challengeProgress: null | ChallengeProgress = null; - if (bankaraMatchChallenge) { - const pastBattles = group.historyDetails.nodes.slice(0, index); - const { winCount, loseCount } = bankaraMatchChallenge; - challengeProgress = { - index, - winCount: winCount - - pastBattles.filter((i) => i.judgement == "WIN").length, - loseCount: loseCount - - pastBattles.filter((i) => - ["LOSE", "DEEMED_LOSE"].includes(i.judgement) - ).length, - }; - } - - return { - type: "VsInfo", - bankaraMatchChallenge, - listNode, - challengeProgress, - }; - } - cacheDetail( - id: string, - getter: () => Promise, - ): Promise { - const lock = this.getLock(id); - - return lock.use(async () => { - const cached = await this.cache.read(id); - if (cached) { - return cached; - } - - const detail = await getter(); - - await this.cache.write(id, detail); - - return detail; - }); - } - fetch(type: Game["type"], id: string): Promise { - switch (type) { - case "VsInfo": - return this.fetchBattle(id); - case "CoopInfo": - return this.fetchCoop(id); - default: - throw new Error(`Unknown game type: ${type}`); - } - } - async fetchBattle(id: string): Promise { - const detail = await this.cacheDetail( - id, - () => getBattleDetail(this.state, id).then((r) => r.vsHistoryDetail), - ); - const metadata = await this.getBattleMetaById(id); - - const game: VsInfo = { - ...metadata, - detail, - }; - - return game; - } - async fetchCoop(id: string): Promise { - const detail = await this.cacheDetail( - id, - () => getCoopDetail(this.state, id).then((r) => r.coopHistoryDetail), - ); - const metadata = await this.getCoopMetaById(id); - - const game: CoopInfo = { - ...metadata, - detail, - }; - - return game; - } -} - type Progress = { currentUrl?: string; current: number; @@ -378,6 +169,8 @@ export class App { state: this.state, }); + const finalRankState = await fetcher.updateRank(); + await Promise.all( exporters.map((e) => showError( @@ -398,6 +191,13 @@ export class App { ), ); + // save rankState only if all exporters succeeded + fetcher.setRankState(finalRankState); + await this.writeState({ + ...this.state, + rankState: finalRankState, + }); + endBar(); } diff --git a/src/exporters/stat.ink.ts b/src/exporters/stat.ink.ts index 69e7509..d0d17b5 100644 --- a/src/exporters/stat.ink.ts +++ b/src/exporters/stat.ink.ts @@ -173,8 +173,14 @@ export class StatInkExporter implements GameExporter { return result; } async mapBattle( - { challengeProgress, bankaraMatchChallenge, listNode, detail: vsDetail }: - VsInfo, + { + challengeProgress, + bankaraMatchChallenge, + listNode, + detail: vsDetail, + rankBeforeState, + rankState, + }: VsInfo, ): Promise { const { knockout, @@ -276,6 +282,19 @@ export class StatInkExporter implements GameExporter { result.challenge_lose = challengeProgress.loseCount; } + if (rankBeforeState) { + result.rank_before_exp = rankBeforeState.rankPoint; + } + + if (rankState) { + result.rank_after_exp = rankState.rankPoint; + if (!result.rank_after) { + [result.rank_after, result.rank_after_s_plus] = parseUdemae( + rankState.rank, + ); + } + } + return result; } } diff --git a/src/state.ts b/src/state.ts index fd611de..332f023 100644 --- a/src/state.ts +++ b/src/state.ts @@ -3,6 +3,15 @@ export type LoginState = { gToken?: string; bulletToken?: string; }; +export type RankState = { + // generated by gameId(battle.id) + // TODO: If the gameId does not exist, the tracker will assume that the user has + // not played a bankara match. And it will start tracking from the first match + gameId: string; + // C-, B, A+, S, S+0, S+12 + rank: string; + rankPoint: number; +}; export type State = { loginState?: LoginState; fGen: string; @@ -10,6 +19,8 @@ export type State = { userLang?: string; userCountry?: string; + rankState?: RankState; + cacheDir: string; // Exporter config diff --git a/src/types.ts b/src/types.ts index 3cfe87b..236cddf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import { RankState } from "./state.ts"; + export enum Queries { HomeQuery = "dba47124d5ec3090c97ba17db5d2f4b3", LatestBattleHistoriesQuery = "7d8b560e31617e981cf7c8aa1ca13a00", @@ -43,6 +45,9 @@ export type BattleListNode = { id: string; udemae: string; judgement: "LOSE" | "WIN" | "DEEMED_LOSE" | "EXEMPTED_LOSE" | "DRAW"; + bankaraMatch: null | { + earnedUdemaePoint: null | number; + }; }; export type CoopListNode = { id: string; @@ -103,6 +108,8 @@ export type VsInfo = { listNode: null | BattleListNode; bankaraMatchChallenge: null | BankaraMatchChallenge; challengeProgress: null | ChallengeProgress; + rankState: null | RankState; + rankBeforeState: null | RankState; detail: VsHistoryDetail; }; // Salmon run @@ -164,6 +171,12 @@ export type GameExporter< exportGame: (game: T) => Promise<{ url?: string }>; }; +export type BankaraBattleHistories = { + bankaraBattleHistories: { + historyGroups: HistoryGroups; + }; +}; + export type RespMap = { [Queries.HomeQuery]: { currentPlayer: { @@ -193,11 +206,7 @@ export type RespMap = { historyGroups: HistoryGroups; }; }; - [Queries.BankaraBattleHistoriesQuery]: { - bankaraBattleHistories: { - historyGroups: HistoryGroups; - }; - }; + [Queries.BankaraBattleHistoriesQuery]: BankaraBattleHistories; [Queries.PrivateBattleHistoriesQuery]: { privateBattleHistories: { historyGroups: HistoryGroups; @@ -330,3 +339,10 @@ export type StatInkPostResponse = { id: string; url: string; }; + +export type RankParam = { + rank: string; + pointRange: [number, number]; + charge: number; + promotion?: boolean; +}; diff --git a/src/utils.ts b/src/utils.ts index cb4bf72..de5619d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -19,9 +19,11 @@ export function urlBase64Decode(data: string) { ); } -export async function readline() { +export async function readline( + { skipEmpty = true }: { skipEmpty?: boolean } = {}, +) { for await (const line of stdinLines) { - if (line !== "") { + if (!skipEmpty || line !== "") { return line; } }