Merge pull request #10 from spacemeowx2/feature-track-rank

Feature track rank
main
imspace 2022-10-31 14:39:40 +08:00 committed by GitHub
commit bd85393ce8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 1018 additions and 223 deletions

View File

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

View File

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

1
dev_deps.ts Normal file
View File

@ -0,0 +1 @@
export { assertEquals } from "https://deno.land/std@0.160.0/testing/asserts.ts";

112
initRank.ts Normal file
View File

@ -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 <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.");

236
src/GameFetcher.ts Normal file
View File

@ -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<string, Mutex | undefined> = {};
bankaraLock = new Mutex();
bankaraHistory?: HistoryGroups<BattleListNode>["nodes"];
coopLock = new Mutex();
coopHistory?: HistoryGroups<CoopListNode>["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<RankState | undefined> {
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<Omit<CoopInfo, "detail">> {
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<Omit<VsInfo, "detail">> {
const gid = await gameId(id);
const bankaraHistory = await this.getBankaraHistory();
const gameIdMap = new Map<BattleListNode, string>();
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<T>(
id: string,
getter: () => Promise<T>,
): Promise<T> {
const lock = this.getLock(id);
return lock.use(async () => {
const cached = await this.cache.read<T>(id);
if (cached) {
return cached;
}
const detail = await getter();
await this.cache.write(id, detail);
return detail;
});
}
fetch(type: Game["type"], id: string): Promise<Game> {
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<VsInfo> {
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<CoopInfo> {
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;
}
}

287
src/RankTracker.test.ts Normal file
View File

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

269
src/RankTracker.ts Normal file
View File

@ -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<string, Delta> = 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<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,
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;
}
}

View File

@ -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<string, Mutex | undefined> = {};
bankaraLock = new Mutex();
bankaraHistory?: HistoryGroups<BattleListNode>["nodes"];
coopLock = new Mutex();
coopHistory?: HistoryGroups<CoopListNode>["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<Omit<CoopInfo, "detail">> {
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<Omit<VsInfo, "detail">> {
const gid = await gameId(id);
const bankaraHistory = await this.getBankaraHistory();
const gameIdMap = new Map<BattleListNode, string>();
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<T>(
id: string,
getter: () => Promise<T>,
): Promise<T> {
const lock = this.getLock(id);
return lock.use(async () => {
const cached = await this.cache.read<T>(id);
if (cached) {
return cached;
}
const detail = await getter();
await this.cache.write(id, detail);
return detail;
});
}
fetch(type: Game["type"], id: string): Promise<Game> {
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<VsInfo> {
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<CoopInfo> {
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();
}

View File

@ -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<StatInkPostBody> {
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;
}
}

View File

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

View File

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

View File

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