feat: add RankTracker

main
spacemeowx2 2022-10-28 20:45:56 +08:00
parent 297e73e197
commit 9f383ae76c
6 changed files with 255 additions and 61 deletions

View File

@ -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):`,
);

View File

@ -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<string, Mutex | undefined> = {};
bankaraLock = new Mutex();
bankaraHistory?: HistoryGroups<BattleListNode>["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<RankState | undefined> {
const finalState = await this.rankTracker.updateState(
await this.getBankaraHistory(),
);
return finalState;
}
getRankStateById(id: string): Promise<RankState | undefined> {
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<T>(

View File

@ -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<string, Delta> = new Map();
constructor(private state: RankState | undefined) {}
async getRankStateById(id: string): Promise<RankState | undefined> {
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<BattleListNode>["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;
}
}

View File

@ -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();
}

View File

@ -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;

View File

@ -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<BattleListNode>;
};
};
export type RespMap = {
[Queries.HomeQuery]: {
currentPlayer: {
@ -193,11 +211,7 @@ export type RespMap = {
historyGroups: HistoryGroups<BattleListNode>;
};
};
[Queries.BankaraBattleHistoriesQuery]: {
bankaraBattleHistories: {
historyGroups: HistoryGroups<BattleListNode>;
};
};
[Queries.BankaraBattleHistoriesQuery]: BankaraBattleHistories;
[Queries.PrivateBattleHistoriesQuery]: {
privateBattleHistories: {
historyGroups: HistoryGroups<BattleListNode>;
@ -330,3 +344,10 @@ export type StatInkPostResponse = {
id: string;
url: string;
};
export type RankParam = {
rank: string;
pointRange: [number, number];
charge: number;
promotion?: boolean;
};