From 9f383ae76c7f7b5a46022f6cbb7b3817be662033 Mon Sep 17 00:00:00 2001 From: spacemeowx2 Date: Fri, 28 Oct 2022 20:45:56 +0800 Subject: [PATCH] feat: add RankTracker --- initRank.ts | 2 +- src/GameFetcher.ts | 23 ++++- src/RankTracker.ts | 247 +++++++++++++++++++++++++++++++++++---------- src/app.ts | 9 ++ src/state.ts | 4 +- src/types.ts | 31 +++++- 6 files changed, 255 insertions(+), 61 deletions(-) diff --git a/initRank.ts b/initRank.ts index daf315a..57d13ff 100644 --- a/initRank.ts +++ b/initRank.ts @@ -80,7 +80,7 @@ if (battleList.length === 0) { const { vsHistoryDetail: detail } = await getBattleDetail(state, battleList[0]); console.log( - `Your latest battle is played at ${ + `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):`, ); diff --git a/src/GameFetcher.ts b/src/GameFetcher.ts index 00ee129..693b80c 100644 --- a/src/GameFetcher.ts +++ b/src/GameFetcher.ts @@ -1,5 +1,5 @@ import { Mutex } from "../deps.ts"; -import { State } from "./state.ts"; +import { RankState, State } from "./state.ts"; import { getBankaraBattleHistories, getBattleDetail, @@ -17,6 +17,7 @@ import { } 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. @@ -24,6 +25,8 @@ import { gameId } from "./utils.ts"; export class GameFetcher { state: State; cache: Cache; + rankTracker: RankTracker; + lock: Record = {}; bankaraLock = new Mutex(); bankaraHistory?: HistoryGroups["nodes"]; @@ -35,6 +38,7 @@ export class GameFetcher { ) { this.state = state; this.cache = cache; + this.rankTracker = new RankTracker(state.rankState); } private getLock(id: string): Mutex { let cur = this.lock[id]; @@ -47,6 +51,21 @@ export class GameFetcher { 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): Promise { + return this.rankTracker.getRankStateById(id); + } + getBankaraHistory() { return this.bankaraLock.use(async () => { if (this.bankaraHistory) { @@ -120,6 +139,7 @@ export class GameFetcher { challengeProgress: null, bankaraMatchChallenge: null, listNode: null, + rankState: null, }; } @@ -149,6 +169,7 @@ export class GameFetcher { bankaraMatchChallenge, listNode, challengeProgress, + rankState: await this.rankTracker.getRankStateById(id) ?? null, }; } cacheDetail( diff --git a/src/RankTracker.ts b/src/RankTracker.ts index e2b14c0..f537d8c 100644 --- a/src/RankTracker.ts +++ b/src/RankTracker.ts @@ -1,14 +1,6 @@ import { RankState } from "./state.ts"; -import { GameFetcher } from "./GameFetcher.ts"; - -type RankParam = { - rank: string; - pointRange: [number, number]; - entrance: number; - openWin: number; - openLose: number; - rankUp?: boolean; -}; +import { BattleListNode, HistoryGroups, RankParam } from "./types.ts"; +import { gameId, parseHistoryDetailId } from "./utils.ts"; const splusParams = () => { const out: RankParam[] = []; @@ -18,12 +10,10 @@ const splusParams = () => { const item: RankParam = { rank: `S+${i}`, pointRange: [300 + level * 350, 300 + (level + 1) * 350], - entrance: 160, - openWin: 8, - openLose: 5, + charge: 160, }; if (level === 9) { - item.rankUp = true; + item.promotion = true; } out.push(item); } @@ -31,9 +21,7 @@ const splusParams = () => { out.push({ rank: "S+50", pointRange: [0, 9999], - entrance: 160, - openWin: 8, - openLose: 5, + charge: 160, }); return out; @@ -42,72 +30,227 @@ const splusParams = () => { export const RANK_PARAMS: RankParam[] = [{ rank: "C-", pointRange: [0, 200], - entrance: 0, - openWin: 8, - openLose: 1, + charge: 0, }, { rank: "C", pointRange: [200, 400], - entrance: 20, - openWin: 8, - openLose: 1, + charge: 20, }, { rank: "C+", pointRange: [400, 600], - entrance: 40, - openWin: 8, - openLose: 1, - rankUp: true, + charge: 40, + promotion: true, }, { rank: "B-", pointRange: [100, 350], - entrance: 55, - openWin: 8, - openLose: 2, + charge: 55, }, { rank: "B", pointRange: [350, 600], - entrance: 70, - openWin: 8, - openLose: 2, + charge: 70, }, { rank: "B+", pointRange: [600, 850], - entrance: 85, - openWin: 8, - openLose: 2, - rankUp: true, + charge: 85, + promotion: true, }, { rank: "A-", pointRange: [200, 500], - entrance: 100, - openWin: 8, - openLose: 3, + charge: 100, }, { rank: "A", pointRange: [500, 800], - entrance: 110, - openWin: 8, - openLose: 3, + charge: 110, }, { rank: "A+", pointRange: [800, 1100], - entrance: 120, - openWin: 8, - openLose: 3, - rankUp: true, + charge: 120, + promotion: true, }, { rank: "S", pointRange: [300, 1000], - entrance: 150, - openWin: 8, - openLose: 4, - rankUp: true, + charge: 150, + promotion: true, }, ...splusParams()]; +type Delta = { + beforeGameId: string; + gameId: string; + rankPoint: number; + isRankUp: boolean; + isChallengeFirst: boolean; +}; + +function addRank(state: RankState, delta: Delta): RankState { + const { rank, rankPoint } = state; + const { gameId, isRankUp, 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 (isRankUp) { + const nextRankParam = RANK_PARAMS[rankIndex + 1]; + + return { + gameId, + rank: nextRankParam.rank, + rankPoint: nextRankParam.pointRange[0], + }; + } + + return { + gameId, + 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 { - constructor(private state?: RankState) {} + // key: privous game id + deltaMap: Map = new Map(); + + constructor(private state: RankState | undefined) {} + + async getRankStateById(id: string): Promise { + if (!this.state) { + return undefined; + } + const gid = await gameId(id); + + let cur = this.state; + while (cur.gameId !== gid) { + const delta = this.deltaMap.get(cur.gameId); + if (!delta) { + throw new Error("Delta not found"); + } + cur = addRank(cur, delta); + } + + return 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, + isRankUp: false, + isChallengeFirst: false, + }; + beforeGameId = i.gameId; + // challenge + if (i.bankaraMatchChallenge) { + if (i.index === 0 && i.bankaraMatchChallenge.state !== "INPROGRESS") { + // last battle in challenge + delta = { + ...delta, + rankPoint: i.bankaraMatchChallenge.earnedUdemaePoint ?? 0, + isRankUp: i.bankaraMatchChallenge.isUdemaeUp ?? false, + isChallengeFirst: i.index === 0, + }; + } else if (i.index === i.groupLength - 1) { + // first battle in challenge + delta = { + ...delta, + isChallengeFirst: true, + }; + } + } else { + delta = { + ...delta, + rankPoint: i.detail.bankaraMatch?.earnedUdemaePoint, + }; + } + + 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 1d0d228..e14fdd1 100644 --- a/src/app.ts +++ b/src/app.ts @@ -169,6 +169,8 @@ export class App { state: this.state, }); + const finalRankState = await fetcher.updateRank(); + await Promise.all( exporters.map((e) => showError( @@ -189,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/state.ts b/src/state.ts index b03794a..332f023 100644 --- a/src/state.ts +++ b/src/state.ts @@ -5,9 +5,9 @@ export type LoginState = { }; export type RankState = { // generated by gameId(battle.id) - // If the gameId does not exist, the tracker will assume that the user has + // 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; + gameId: string; // C-, B, A+, S, S+0, S+12 rank: string; rankPoint: number; diff --git a/src/types.ts b/src/types.ts index 3cfe87b..0702591 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,15 @@ export type BattleListNode = { id: string; udemae: string; judgement: "LOSE" | "WIN" | "DEEMED_LOSE" | "EXEMPTED_LOSE" | "DRAW"; + nextHistoryDetail: null | { + id: string; + }; + previousHistoryDetail: null | { + id: string; + }; + bankaraMatch: null | { + earnedUdemaePoint: number; + }; }; export type CoopListNode = { id: string; @@ -103,6 +114,7 @@ export type VsInfo = { listNode: null | BattleListNode; bankaraMatchChallenge: null | BankaraMatchChallenge; challengeProgress: null | ChallengeProgress; + rankState: null | RankState; detail: VsHistoryDetail; }; // Salmon run @@ -164,6 +176,12 @@ export type GameExporter< exportGame: (game: T) => Promise<{ url?: string }>; }; +export type BankaraBattleHistories = { + bankaraBattleHistories: { + historyGroups: HistoryGroups; + }; +}; + export type RespMap = { [Queries.HomeQuery]: { currentPlayer: { @@ -193,11 +211,7 @@ export type RespMap = { historyGroups: HistoryGroups; }; }; - [Queries.BankaraBattleHistoriesQuery]: { - bankaraBattleHistories: { - historyGroups: HistoryGroups; - }; - }; + [Queries.BankaraBattleHistoriesQuery]: BankaraBattleHistories; [Queries.PrivateBattleHistoriesQuery]: { privateBattleHistories: { historyGroups: HistoryGroups; @@ -330,3 +344,10 @@ export type StatInkPostResponse = { id: string; url: string; }; + +export type RankParam = { + rank: string; + pointRange: [number, number]; + charge: number; + promotion?: boolean; +};