s3si.ts/src/app.ts

570 lines
13 KiB
TypeScript
Raw Normal View History

2022-10-21 07:09:30 -04:00
import { getBulletToken, getGToken, loginManually } from "./iksm.ts";
import { MultiProgressBar, Mutex } from "../deps.ts";
import {
2022-10-24 08:46:21 -04:00
DEFAULT_STATE,
FileStateBackend,
State,
StateBackend,
} from "./state.ts";
import {
2022-10-21 07:09:30 -04:00
getBankaraBattleHistories,
getBattleDetail,
getBattleList,
2022-10-24 14:45:36 -04:00
getCoopDetail,
getCoopHistories,
2022-10-24 08:46:21 -04:00
isTokenExpired,
2022-10-21 07:09:30 -04:00
} from "./splatnet3.ts";
import {
2022-10-24 14:45:36 -04:00
BattleListNode,
BattleListType,
ChallengeProgress,
2022-10-24 14:45:36 -04:00
CoopInfo,
CoopListNode,
Game,
GameExporter,
2022-10-21 07:09:30 -04:00
HistoryGroups,
2022-10-24 14:45:36 -04:00
VsInfo,
2022-10-21 07:09:30 -04:00
} from "./types.ts";
import { Cache, FileCache, MemoryCache } from "./cache.ts";
import { StatInkExporter } from "./exporters/stat.ink.ts";
import { FileExporter } from "./exporters/file.ts";
2022-10-24 08:46:21 -04:00
import {
delay,
2022-10-24 14:45:36 -04:00
gameId,
2022-10-24 08:46:21 -04:00
readline,
RecoverableError,
retryRecoverableError,
showError,
} from "./utils.ts";
2022-10-21 07:09:30 -04:00
export type Opts = {
profilePath: string;
exporter: string;
noProgress: boolean;
2022-10-22 09:00:45 -04:00
monitor: boolean;
2022-10-24 14:45:36 -04:00
skipMode?: string;
2022-10-23 04:00:16 -04:00
cache?: Cache;
2022-10-23 04:25:10 -04:00
stateBackend?: StateBackend;
2022-10-21 07:09:30 -04:00
};
export const DEFAULT_OPTS: Opts = {
profilePath: "./profile.json",
exporter: "stat.ink",
noProgress: false,
2022-10-22 09:00:45 -04:00
monitor: false,
2022-10-21 07:09:30 -04:00
};
/**
2022-10-24 14:45:36 -04:00
* Fetch game and cache it.
2022-10-21 07:09:30 -04:00
*/
2022-10-24 14:45:36 -04:00
class GameFetcher {
2022-10-21 07:09:30 -04:00
state: State;
cache: Cache;
lock: Record<string, Mutex | undefined> = {};
bankaraLock = new Mutex();
2022-10-24 14:45:36 -04:00
bankaraHistory?: HistoryGroups<BattleListNode>["nodes"];
coopLock = new Mutex();
coopHistory?: HistoryGroups<CoopListNode>["nodes"];
2022-10-21 07:09:30 -04:00
constructor(
{ cache = new MemoryCache(), state }: { state: State; cache?: Cache },
) {
this.state = state;
this.cache = cache;
}
private async getLock(id: string): Promise<Mutex> {
2022-10-24 14:45:36 -04:00
const bid = await gameId(id);
2022-10-21 07:09:30 -04:00
let cur = this.lock[bid];
if (!cur) {
cur = new Mutex();
this.lock[bid] = cur;
}
return cur;
}
2022-10-24 14:45:36 -04:00
2022-10-21 07:09:30 -04:00
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;
});
}
2022-10-24 14:45:36 -04:00
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 bid = await gameId(id);
2022-10-21 07:09:30 -04:00
const bankaraHistory = await this.getBankaraHistory();
const group = bankaraHistory.find((i) =>
i.historyDetails.nodes.some((i) => i._bid === bid)
);
if (!group) {
return {
2022-10-24 14:45:36 -04:00
type: "VsInfo",
challengeProgress: null,
2022-10-21 07:09:30 -04:00
bankaraMatchChallenge: null,
listNode: null,
};
}
const { bankaraMatchChallenge } = group;
const listNode = group.historyDetails.nodes.find((i) => i._bid === bid) ??
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,
};
}
2022-10-21 07:09:30 -04:00
return {
2022-10-24 14:45:36 -04:00
type: "VsInfo",
2022-10-21 07:09:30 -04:00
bankaraMatchChallenge,
listNode,
challengeProgress,
2022-10-21 07:09:30 -04:00
};
}
2022-10-24 14:45:36 -04:00
async cacheDetail<T>(
id: string,
getter: () => Promise<T>,
): Promise<T> {
2022-10-21 07:09:30 -04:00
const lock = await this.getLock(id);
return lock.use(async () => {
2022-10-24 14:45:36 -04:00
const cached = await this.cache.read<T>(id);
2022-10-21 07:09:30 -04:00
if (cached) {
return cached;
}
2022-10-24 14:45:36 -04:00
const detail = await getter();
2022-10-21 07:09:30 -04:00
await this.cache.write(id, detail);
return detail;
});
}
2022-10-24 14:45:36 -04:00
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),
);
2022-10-21 07:09:30 -04:00
const metadata = await this.getBattleMetaById(id);
2022-10-24 14:45:36 -04:00
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 = {
2022-10-21 07:09:30 -04:00
...metadata,
detail,
};
2022-10-24 14:45:36 -04:00
return game;
2022-10-21 07:09:30 -04:00
}
}
type Progress = {
current: number;
total: number;
};
export class App {
state: State = DEFAULT_STATE;
2022-10-23 04:25:10 -04:00
stateBackend: StateBackend;
2022-10-24 08:46:21 -04:00
recoveryToken: RecoverableError = {
name: "Refetch Token",
is: isTokenExpired,
recovery: async () => {
console.log("Token expired, refetch tokens.");
await this.fetchToken();
},
};
2022-10-21 07:09:30 -04:00
constructor(public opts: Opts) {
2022-10-24 08:46:21 -04:00
this.stateBackend = opts.stateBackend ??
new FileStateBackend(opts.profilePath);
2022-10-21 07:09:30 -04:00
}
async writeState(newState: State) {
this.state = newState;
2022-10-23 04:25:10 -04:00
await this.stateBackend.write(newState);
2022-10-21 07:09:30 -04:00
}
async readState() {
try {
2022-10-23 04:25:10 -04:00
const json = await this.stateBackend.read();
2022-10-21 07:09:30 -04:00
this.state = {
...DEFAULT_STATE,
...json,
};
} catch (e) {
console.warn(
`Failed to read config file, create new config file. (${e})`,
);
await this.writeState(DEFAULT_STATE);
}
}
2022-10-24 14:45:36 -04:00
getSkipMode(): ("vs" | "coop")[] {
const mode = this.opts.skipMode;
if (mode === "vs") {
return ["vs"];
} else if (mode === "coop") {
return ["coop"];
}
return [];
}
async getExporters(): Promise<GameExporter[]> {
2022-10-21 07:09:30 -04:00
const exporters = this.opts.exporter.split(",");
2022-10-24 14:45:36 -04:00
const out: GameExporter[] = [];
2022-10-21 07:09:30 -04:00
if (exporters.includes("stat.ink")) {
if (!this.state.statInkApiKey) {
console.log("stat.ink API key is not set. Please enter below.");
const key = (await readline()).trim();
if (!key) {
console.error("API key is required.");
Deno.exit(1);
}
await this.writeState({
...this.state,
statInkApiKey: key,
});
}
2022-10-22 18:52:08 -04:00
out.push(
new StatInkExporter(
this.state.statInkApiKey!,
this.opts.monitor ? "Monitoring" : "Manual",
),
);
2022-10-21 07:09:30 -04:00
}
if (exporters.includes("file")) {
out.push(new FileExporter(this.state.fileExportPath));
}
return out;
}
2022-10-24 08:46:21 -04:00
exportOnce() {
return retryRecoverableError(() => this._exportOnce(), this.recoveryToken);
}
2022-10-24 14:45:36 -04:00
exporterProgress(title: string) {
2022-10-21 07:09:30 -04:00
const bar = !this.opts.noProgress
? new MultiProgressBar({
2022-10-24 14:45:36 -04:00
title,
2022-10-21 07:09:30 -04:00
display: "[:bar] :text :percent :time eta: :eta :completed/:total",
})
: undefined;
2022-10-24 14:45:36 -04:00
const allProgress: Record<string, Progress> = {};
const redraw = (name: string, progress: Progress) => {
allProgress[name] = progress;
bar?.render(
Object.entries(allProgress).map(([name, progress]) => ({
completed: progress.current,
total: progress.total,
text: name,
})),
);
};
const endBar = () => {
bar?.end();
};
2022-10-21 07:09:30 -04:00
2022-10-24 14:45:36 -04:00
return { redraw, endBar };
}
async _exportOnce() {
const exporters = await this.getExporters();
const skipMode = this.getSkipMode();
const stats: Record<string, number> = Object.fromEntries(
exporters.map((e) => [e.name, 0]),
);
if (skipMode.includes("vs")) {
console.log("Skip exporting VS games.");
} else {
console.log("Fetching battle list...");
const gameList = await getBattleList(this.state);
const { redraw, endBar } = this.exporterProgress("Export games");
const fetcher = new GameFetcher({
2022-10-24 08:46:21 -04:00
cache: this.opts.cache ?? new FileCache(this.state.cacheDir),
state: this.state,
});
2022-10-21 07:09:30 -04:00
2022-10-24 08:46:21 -04:00
await Promise.all(
exporters.map((e) =>
showError(
2022-10-24 14:45:36 -04:00
this.exportGameList({
type: "VsInfo",
2022-10-24 08:46:21 -04:00
fetcher,
exporter: e,
2022-10-24 14:45:36 -04:00
gameList,
2022-10-24 08:46:21 -04:00
onStep: (progress) => redraw(e.name, progress),
})
.then((count) => {
stats[e.name] = count;
}),
)
.catch((err) => {
console.error(`\nFailed to export to ${e.name}:`, err);
})
),
);
2022-10-21 07:09:30 -04:00
2022-10-24 14:45:36 -04:00
endBar();
}
2022-10-22 18:52:08 -04:00
2022-10-24 14:45:36 -04:00
if (skipMode.includes("coop")) {
console.log("Skip exporting Coop games.");
} else {
console.log("Fetching coop battle list...");
const coopBattleList = await getBattleList(
this.state,
BattleListType.Coop,
2022-10-24 08:46:21 -04:00
);
2022-10-24 14:45:36 -04:00
const { redraw, endBar } = this.exporterProgress("Export games");
const fetcher = new GameFetcher({
cache: this.opts.cache ?? new FileCache(this.state.cacheDir),
state: this.state,
});
await Promise.all(
// TODO: remove this filter when stat.ink support coop export
exporters.filter((e) => e.name !== "stat.ink").map((e) =>
showError(
this.exportGameList({
type: "CoopInfo",
fetcher,
exporter: e,
gameList: coopBattleList,
onStep: (progress) => redraw(e.name, progress),
})
.then((count) => {
stats[e.name] = count;
}),
)
.catch((err) => {
console.error(`\nFailed to export to ${e.name}:`, err);
})
),
);
endBar();
2022-10-24 08:46:21 -04:00
}
2022-10-24 14:45:36 -04:00
console.log(
`Exported ${
Object.entries(stats)
.map(([name, count]) => `${name}: ${count}`)
.join(", ")
}`,
);
2022-10-22 18:52:08 -04:00
}
async monitor() {
while (true) {
await this.exportOnce();
await this.countDown(this.state.monitorInterval);
}
}
async countDown(sec: number) {
const bar = !this.opts.noProgress
? new MultiProgressBar({
title: "Killing time...",
display: "[:bar] :completed/:total",
})
: undefined;
for (const i of Array(sec).keys()) {
bar?.render([{
completed: i,
total: sec,
}]);
await delay(1000);
}
bar?.end();
}
2022-10-24 08:46:21 -04:00
async fetchToken() {
const sessionToken = this.state.loginState?.sessionToken;
if (!sessionToken) {
throw new Error("Session token is not set.");
}
const { webServiceToken, userCountry, userLang } = await getGToken({
fApi: this.state.fGen,
sessionToken,
});
const bulletToken = await getBulletToken({
webServiceToken,
userLang,
userCountry,
appUserAgent: this.state.appUserAgent,
});
await this.writeState({
...this.state,
loginState: {
...this.state.loginState,
gToken: webServiceToken,
bulletToken,
},
userLang: this.state.userLang ?? userLang,
userCountry: this.state.userCountry ?? userCountry,
});
}
2022-10-22 18:52:08 -04:00
async run() {
await this.readState();
if (!this.state.loginState?.sessionToken) {
const sessionToken = await loginManually();
await this.writeState({
...this.state,
loginState: {
...this.state.loginState,
sessionToken,
},
});
}
if (this.opts.monitor) {
await this.monitor();
} else {
await this.exportOnce();
}
2022-10-21 07:09:30 -04:00
}
/**
2022-10-24 14:45:36 -04:00
* Export game list.
2022-10-21 07:09:30 -04:00
*
* @param fetcher BattleFetcher
* @param exporter BattleExporter
2022-10-24 14:45:36 -04:00
* @param gameList ID list of games, sorted by date, newest first
* @param onStep Callback function called when a game is exported
2022-10-21 07:09:30 -04:00
*/
2022-10-24 14:45:36 -04:00
async exportGameList({
type,
fetcher,
exporter,
gameList,
onStep,
}: {
type: Game["type"];
exporter: GameExporter;
fetcher: GameFetcher;
gameList: string[];
onStep: (progress: Progress) => void;
}) {
2022-10-21 07:09:30 -04:00
let exported = 0;
onStep?.({
current: 0,
total: 1,
});
2022-10-24 14:45:36 -04:00
const workQueue = [
...await exporter.notExported({
type,
list: gameList,
}),
]
.reverse();
const step = async (id: string) => {
const detail = await fetcher.fetch(type, id);
await exporter.exportGame(detail);
2022-10-21 07:09:30 -04:00
exported += 1;
onStep?.({
current: exported,
total: workQueue.length,
});
};
if (workQueue.length > 0) {
onStep?.({
current: exported,
total: workQueue.length,
});
for (const battle of workQueue) {
await step(battle);
}
}
return exported;
}
}