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]); const { vsHistoryDetail: detail } = await getBattleDetail(state, battleList[0]);
console.log( console.log(
`Your latest battle is played at ${ `Your latest anarchy battle is played at ${
new Date(detail.playedTime).toLocaleString() new Date(detail.playedTime).toLocaleString()
}. Please enter your rank after this battle(format: RANK,POINT. S+0,300):`, }. 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 { Mutex } from "../deps.ts";
import { State } from "./state.ts"; import { RankState, State } from "./state.ts";
import { import {
getBankaraBattleHistories, getBankaraBattleHistories,
getBattleDetail, getBattleDetail,
@ -17,6 +17,7 @@ import {
} from "./types.ts"; } from "./types.ts";
import { Cache, MemoryCache } from "./cache.ts"; import { Cache, MemoryCache } from "./cache.ts";
import { gameId } from "./utils.ts"; import { gameId } from "./utils.ts";
import { RankTracker } from "./RankTracker.ts";
/** /**
* Fetch game and cache it. It also fetches bankara match challenge info. * Fetch game and cache it. It also fetches bankara match challenge info.
@ -24,6 +25,8 @@ import { gameId } from "./utils.ts";
export class GameFetcher { export class GameFetcher {
state: State; state: State;
cache: Cache; cache: Cache;
rankTracker: RankTracker;
lock: Record<string, Mutex | undefined> = {}; lock: Record<string, Mutex | undefined> = {};
bankaraLock = new Mutex(); bankaraLock = new Mutex();
bankaraHistory?: HistoryGroups<BattleListNode>["nodes"]; bankaraHistory?: HistoryGroups<BattleListNode>["nodes"];
@ -35,6 +38,7 @@ export class GameFetcher {
) { ) {
this.state = state; this.state = state;
this.cache = cache; this.cache = cache;
this.rankTracker = new RankTracker(state.rankState);
} }
private getLock(id: string): Mutex { private getLock(id: string): Mutex {
let cur = this.lock[id]; let cur = this.lock[id];
@ -47,6 +51,21 @@ export class GameFetcher {
return cur; 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() { getBankaraHistory() {
return this.bankaraLock.use(async () => { return this.bankaraLock.use(async () => {
if (this.bankaraHistory) { if (this.bankaraHistory) {
@ -120,6 +139,7 @@ export class GameFetcher {
challengeProgress: null, challengeProgress: null,
bankaraMatchChallenge: null, bankaraMatchChallenge: null,
listNode: null, listNode: null,
rankState: null,
}; };
} }
@ -149,6 +169,7 @@ export class GameFetcher {
bankaraMatchChallenge, bankaraMatchChallenge,
listNode, listNode,
challengeProgress, challengeProgress,
rankState: await this.rankTracker.getRankStateById(id) ?? null,
}; };
} }
cacheDetail<T>( cacheDetail<T>(

View File

@ -1,14 +1,6 @@
import { RankState } from "./state.ts"; import { RankState } from "./state.ts";
import { GameFetcher } from "./GameFetcher.ts"; import { BattleListNode, HistoryGroups, RankParam } from "./types.ts";
import { gameId, parseHistoryDetailId } from "./utils.ts";
type RankParam = {
rank: string;
pointRange: [number, number];
entrance: number;
openWin: number;
openLose: number;
rankUp?: boolean;
};
const splusParams = () => { const splusParams = () => {
const out: RankParam[] = []; const out: RankParam[] = [];
@ -18,12 +10,10 @@ const splusParams = () => {
const item: RankParam = { const item: RankParam = {
rank: `S+${i}`, rank: `S+${i}`,
pointRange: [300 + level * 350, 300 + (level + 1) * 350], pointRange: [300 + level * 350, 300 + (level + 1) * 350],
entrance: 160, charge: 160,
openWin: 8,
openLose: 5,
}; };
if (level === 9) { if (level === 9) {
item.rankUp = true; item.promotion = true;
} }
out.push(item); out.push(item);
} }
@ -31,9 +21,7 @@ const splusParams = () => {
out.push({ out.push({
rank: "S+50", rank: "S+50",
pointRange: [0, 9999], pointRange: [0, 9999],
entrance: 160, charge: 160,
openWin: 8,
openLose: 5,
}); });
return out; return out;
@ -42,72 +30,227 @@ const splusParams = () => {
export const RANK_PARAMS: RankParam[] = [{ export const RANK_PARAMS: RankParam[] = [{
rank: "C-", rank: "C-",
pointRange: [0, 200], pointRange: [0, 200],
entrance: 0, charge: 0,
openWin: 8,
openLose: 1,
}, { }, {
rank: "C", rank: "C",
pointRange: [200, 400], pointRange: [200, 400],
entrance: 20, charge: 20,
openWin: 8,
openLose: 1,
}, { }, {
rank: "C+", rank: "C+",
pointRange: [400, 600], pointRange: [400, 600],
entrance: 40, charge: 40,
openWin: 8, promotion: true,
openLose: 1,
rankUp: true,
}, { }, {
rank: "B-", rank: "B-",
pointRange: [100, 350], pointRange: [100, 350],
entrance: 55, charge: 55,
openWin: 8,
openLose: 2,
}, { }, {
rank: "B", rank: "B",
pointRange: [350, 600], pointRange: [350, 600],
entrance: 70, charge: 70,
openWin: 8,
openLose: 2,
}, { }, {
rank: "B+", rank: "B+",
pointRange: [600, 850], pointRange: [600, 850],
entrance: 85, charge: 85,
openWin: 8, promotion: true,
openLose: 2,
rankUp: true,
}, { }, {
rank: "A-", rank: "A-",
pointRange: [200, 500], pointRange: [200, 500],
entrance: 100, charge: 100,
openWin: 8,
openLose: 3,
}, { }, {
rank: "A", rank: "A",
pointRange: [500, 800], pointRange: [500, 800],
entrance: 110, charge: 110,
openWin: 8,
openLose: 3,
}, { }, {
rank: "A+", rank: "A+",
pointRange: [800, 1100], pointRange: [800, 1100],
entrance: 120, charge: 120,
openWin: 8, promotion: true,
openLose: 3,
rankUp: true,
}, { }, {
rank: "S", rank: "S",
pointRange: [300, 1000], pointRange: [300, 1000],
entrance: 150, charge: 150,
openWin: 8, promotion: true,
openLose: 4,
rankUp: true,
}, ...splusParams()]; }, ...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. * if state is empty, it will not track rank.
*/ */
export class RankTracker { 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, state: this.state,
}); });
const finalRankState = await fetcher.updateRank();
await Promise.all( await Promise.all(
exporters.map((e) => exporters.map((e) =>
showError( 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(); endBar();
} }

View File

@ -5,9 +5,9 @@ export type LoginState = {
}; };
export type RankState = { export type RankState = {
// generated by gameId(battle.id) // 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 // 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 // C-, B, A+, S, S+0, S+12
rank: string; rank: string;
rankPoint: number; rankPoint: number;

View File

@ -1,3 +1,5 @@
import { RankState } from "./state.ts";
export enum Queries { export enum Queries {
HomeQuery = "dba47124d5ec3090c97ba17db5d2f4b3", HomeQuery = "dba47124d5ec3090c97ba17db5d2f4b3",
LatestBattleHistoriesQuery = "7d8b560e31617e981cf7c8aa1ca13a00", LatestBattleHistoriesQuery = "7d8b560e31617e981cf7c8aa1ca13a00",
@ -43,6 +45,15 @@ export type BattleListNode = {
id: string; id: string;
udemae: string; udemae: string;
judgement: "LOSE" | "WIN" | "DEEMED_LOSE" | "EXEMPTED_LOSE" | "DRAW"; judgement: "LOSE" | "WIN" | "DEEMED_LOSE" | "EXEMPTED_LOSE" | "DRAW";
nextHistoryDetail: null | {
id: string;
};
previousHistoryDetail: null | {
id: string;
};
bankaraMatch: null | {
earnedUdemaePoint: number;
};
}; };
export type CoopListNode = { export type CoopListNode = {
id: string; id: string;
@ -103,6 +114,7 @@ export type VsInfo = {
listNode: null | BattleListNode; listNode: null | BattleListNode;
bankaraMatchChallenge: null | BankaraMatchChallenge; bankaraMatchChallenge: null | BankaraMatchChallenge;
challengeProgress: null | ChallengeProgress; challengeProgress: null | ChallengeProgress;
rankState: null | RankState;
detail: VsHistoryDetail; detail: VsHistoryDetail;
}; };
// Salmon run // Salmon run
@ -164,6 +176,12 @@ export type GameExporter<
exportGame: (game: T) => Promise<{ url?: string }>; exportGame: (game: T) => Promise<{ url?: string }>;
}; };
export type BankaraBattleHistories = {
bankaraBattleHistories: {
historyGroups: HistoryGroups<BattleListNode>;
};
};
export type RespMap = { export type RespMap = {
[Queries.HomeQuery]: { [Queries.HomeQuery]: {
currentPlayer: { currentPlayer: {
@ -193,11 +211,7 @@ export type RespMap = {
historyGroups: HistoryGroups<BattleListNode>; historyGroups: HistoryGroups<BattleListNode>;
}; };
}; };
[Queries.BankaraBattleHistoriesQuery]: { [Queries.BankaraBattleHistoriesQuery]: BankaraBattleHistories;
bankaraBattleHistories: {
historyGroups: HistoryGroups<BattleListNode>;
};
};
[Queries.PrivateBattleHistoriesQuery]: { [Queries.PrivateBattleHistoriesQuery]: {
privateBattleHistories: { privateBattleHistories: {
historyGroups: HistoryGroups<BattleListNode>; historyGroups: HistoryGroups<BattleListNode>;
@ -330,3 +344,10 @@ export type StatInkPostResponse = {
id: string; id: string;
url: string; url: string;
}; };
export type RankParam = {
rank: string;
pointRange: [number, number];
charge: number;
promotion?: boolean;
};