commit
bd85393ce8
30
CHANGELOG
30
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
|
0.1.5
|
||||||
|
|
||||||
fix: rank_up sent on last battle of challenge
|
fix: rank_up sent on last battle of challenge
|
||||||
|
|
|
||||||
12
README.md
12
README.md
|
|
@ -30,6 +30,18 @@ Options:
|
||||||
- If you want to use a different profile, use `-p` to specify the path to the
|
- If you want to use a different profile, use `-p` to specify the path to the
|
||||||
profile file.
|
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
|
### profile.json
|
||||||
|
|
||||||
```js
|
```js
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { assertEquals } from "https://deno.land/std@0.160.0/testing/asserts.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 <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.");
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
228
src/app.ts
228
src/app.ts
|
|
@ -1,41 +1,24 @@
|
||||||
import { getBulletToken, getGToken, loginManually } from "./iksm.ts";
|
import { getBulletToken, getGToken, loginManually } from "./iksm.ts";
|
||||||
import { MultiProgressBar, Mutex } from "../deps.ts";
|
import { MultiProgressBar } from "../deps.ts";
|
||||||
import {
|
import {
|
||||||
DEFAULT_STATE,
|
DEFAULT_STATE,
|
||||||
FileStateBackend,
|
FileStateBackend,
|
||||||
State,
|
State,
|
||||||
StateBackend,
|
StateBackend,
|
||||||
} from "./state.ts";
|
} from "./state.ts";
|
||||||
import {
|
import { getBattleList, isTokenExpired } from "./splatnet3.ts";
|
||||||
getBankaraBattleHistories,
|
import { BattleListType, Game, GameExporter } from "./types.ts";
|
||||||
getBattleDetail,
|
import { Cache, FileCache } from "./cache.ts";
|
||||||
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 { StatInkExporter } from "./exporters/stat.ink.ts";
|
import { StatInkExporter } from "./exporters/stat.ink.ts";
|
||||||
import { FileExporter } from "./exporters/file.ts";
|
import { FileExporter } from "./exporters/file.ts";
|
||||||
import {
|
import {
|
||||||
delay,
|
delay,
|
||||||
gameId,
|
|
||||||
readline,
|
readline,
|
||||||
RecoverableError,
|
RecoverableError,
|
||||||
retryRecoverableError,
|
retryRecoverableError,
|
||||||
showError,
|
showError,
|
||||||
} from "./utils.ts";
|
} from "./utils.ts";
|
||||||
|
import { GameFetcher } from "./GameFetcher.ts";
|
||||||
|
|
||||||
export type Opts = {
|
export type Opts = {
|
||||||
profilePath: string;
|
profilePath: string;
|
||||||
|
|
@ -54,198 +37,6 @@ export const DEFAULT_OPTS: Opts = {
|
||||||
monitor: false,
|
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 = {
|
type Progress = {
|
||||||
currentUrl?: string;
|
currentUrl?: string;
|
||||||
current: number;
|
current: number;
|
||||||
|
|
@ -378,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(
|
||||||
|
|
@ -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();
|
endBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -173,8 +173,14 @@ export class StatInkExporter implements GameExporter {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
async mapBattle(
|
async mapBattle(
|
||||||
{ challengeProgress, bankaraMatchChallenge, listNode, detail: vsDetail }:
|
{
|
||||||
VsInfo,
|
challengeProgress,
|
||||||
|
bankaraMatchChallenge,
|
||||||
|
listNode,
|
||||||
|
detail: vsDetail,
|
||||||
|
rankBeforeState,
|
||||||
|
rankState,
|
||||||
|
}: VsInfo,
|
||||||
): Promise<StatInkPostBody> {
|
): Promise<StatInkPostBody> {
|
||||||
const {
|
const {
|
||||||
knockout,
|
knockout,
|
||||||
|
|
@ -276,6 +282,19 @@ export class StatInkExporter implements GameExporter {
|
||||||
result.challenge_lose = challengeProgress.loseCount;
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
src/state.ts
11
src/state.ts
|
|
@ -3,6 +3,15 @@ export type LoginState = {
|
||||||
gToken?: string;
|
gToken?: string;
|
||||||
bulletToken?: 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 = {
|
export type State = {
|
||||||
loginState?: LoginState;
|
loginState?: LoginState;
|
||||||
fGen: string;
|
fGen: string;
|
||||||
|
|
@ -10,6 +19,8 @@ export type State = {
|
||||||
userLang?: string;
|
userLang?: string;
|
||||||
userCountry?: string;
|
userCountry?: string;
|
||||||
|
|
||||||
|
rankState?: RankState;
|
||||||
|
|
||||||
cacheDir: string;
|
cacheDir: string;
|
||||||
|
|
||||||
// Exporter config
|
// Exporter config
|
||||||
|
|
|
||||||
26
src/types.ts
26
src/types.ts
|
|
@ -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,9 @@ 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";
|
||||||
|
bankaraMatch: null | {
|
||||||
|
earnedUdemaePoint: null | number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
export type CoopListNode = {
|
export type CoopListNode = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -103,6 +108,8 @@ export type VsInfo = {
|
||||||
listNode: null | BattleListNode;
|
listNode: null | BattleListNode;
|
||||||
bankaraMatchChallenge: null | BankaraMatchChallenge;
|
bankaraMatchChallenge: null | BankaraMatchChallenge;
|
||||||
challengeProgress: null | ChallengeProgress;
|
challengeProgress: null | ChallengeProgress;
|
||||||
|
rankState: null | RankState;
|
||||||
|
rankBeforeState: null | RankState;
|
||||||
detail: VsHistoryDetail;
|
detail: VsHistoryDetail;
|
||||||
};
|
};
|
||||||
// Salmon run
|
// Salmon run
|
||||||
|
|
@ -164,6 +171,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 +206,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 +339,10 @@ export type StatInkPostResponse = {
|
||||||
id: string;
|
id: string;
|
||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RankParam = {
|
||||||
|
rank: string;
|
||||||
|
pointRange: [number, number];
|
||||||
|
charge: number;
|
||||||
|
promotion?: boolean;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
for await (const line of stdinLines) {
|
||||||
if (line !== "") {
|
if (!skipEmpty || line !== "") {
|
||||||
return line;
|
return line;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue