feat: add coop export (v0.1.8)
parent
cd4aa53c7d
commit
f30da22d0c
6
s3si.ts
6
s3si.ts
|
|
@ -4,7 +4,7 @@ import { flags } from "./deps.ts";
|
||||||
|
|
||||||
const parseArgs = (args: string[]) => {
|
const parseArgs = (args: string[]) => {
|
||||||
const parsed = flags.parse(args, {
|
const parsed = flags.parse(args, {
|
||||||
string: ["profilePath", "exporter"],
|
string: ["profilePath", "exporter", "skipMode"],
|
||||||
boolean: ["help", "noProgress", "monitor"],
|
boolean: ["help", "noProgress", "monitor"],
|
||||||
alias: {
|
alias: {
|
||||||
"help": "h",
|
"help": "h",
|
||||||
|
|
@ -12,6 +12,7 @@ const parseArgs = (args: string[]) => {
|
||||||
"exporter": ["e"],
|
"exporter": ["e"],
|
||||||
"noProgress": ["n", "no-progress"],
|
"noProgress": ["n", "no-progress"],
|
||||||
"monitor": ["m"],
|
"monitor": ["m"],
|
||||||
|
"skipMode": ["s", "skip-mode"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return parsed;
|
return parsed;
|
||||||
|
|
@ -28,6 +29,9 @@ Options:
|
||||||
Multiple exporters can be separated by commas
|
Multiple exporters can be separated by commas
|
||||||
(e.g. "stat.ink,file")
|
(e.g. "stat.ink,file")
|
||||||
--no-progress, -n Disable progress bar
|
--no-progress, -n Disable progress bar
|
||||||
|
--monitor, -m Monitor mode
|
||||||
|
--skip-mode <mode>, -s Skip mode (default: null)
|
||||||
|
("vs", "coop")
|
||||||
--help Show this help message and exit`,
|
--help Show this help message and exit`,
|
||||||
);
|
);
|
||||||
Deno.exit(0);
|
Deno.exit(0);
|
||||||
|
|
|
||||||
251
src/app.ts
251
src/app.ts
|
|
@ -10,21 +10,27 @@ import {
|
||||||
getBankaraBattleHistories,
|
getBankaraBattleHistories,
|
||||||
getBattleDetail,
|
getBattleDetail,
|
||||||
getBattleList,
|
getBattleList,
|
||||||
|
getCoopDetail,
|
||||||
|
getCoopHistories,
|
||||||
isTokenExpired,
|
isTokenExpired,
|
||||||
} from "./splatnet3.ts";
|
} from "./splatnet3.ts";
|
||||||
import {
|
import {
|
||||||
BattleExporter,
|
BattleListNode,
|
||||||
|
BattleListType,
|
||||||
ChallengeProgress,
|
ChallengeProgress,
|
||||||
|
CoopInfo,
|
||||||
|
CoopListNode,
|
||||||
|
Game,
|
||||||
|
GameExporter,
|
||||||
HistoryGroups,
|
HistoryGroups,
|
||||||
VsBattle,
|
VsInfo,
|
||||||
VsHistoryDetail,
|
|
||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
import { Cache, FileCache, MemoryCache } from "./cache.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 {
|
||||||
battleId,
|
|
||||||
delay,
|
delay,
|
||||||
|
gameId,
|
||||||
readline,
|
readline,
|
||||||
RecoverableError,
|
RecoverableError,
|
||||||
retryRecoverableError,
|
retryRecoverableError,
|
||||||
|
|
@ -36,6 +42,7 @@ export type Opts = {
|
||||||
exporter: string;
|
exporter: string;
|
||||||
noProgress: boolean;
|
noProgress: boolean;
|
||||||
monitor: boolean;
|
monitor: boolean;
|
||||||
|
skipMode?: string;
|
||||||
cache?: Cache;
|
cache?: Cache;
|
||||||
stateBackend?: StateBackend;
|
stateBackend?: StateBackend;
|
||||||
};
|
};
|
||||||
|
|
@ -48,14 +55,16 @@ export const DEFAULT_OPTS: Opts = {
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch battle and cache it.
|
* Fetch game and cache it.
|
||||||
*/
|
*/
|
||||||
class BattleFetcher {
|
class GameFetcher {
|
||||||
state: State;
|
state: State;
|
||||||
cache: Cache;
|
cache: Cache;
|
||||||
lock: Record<string, Mutex | undefined> = {};
|
lock: Record<string, Mutex | undefined> = {};
|
||||||
bankaraLock = new Mutex();
|
bankaraLock = new Mutex();
|
||||||
bankaraHistory?: HistoryGroups["nodes"];
|
bankaraHistory?: HistoryGroups<BattleListNode>["nodes"];
|
||||||
|
coopLock = new Mutex();
|
||||||
|
coopHistory?: HistoryGroups<CoopListNode>["nodes"];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
{ cache = new MemoryCache(), state }: { state: State; cache?: Cache },
|
{ cache = new MemoryCache(), state }: { state: State; cache?: Cache },
|
||||||
|
|
@ -64,7 +73,7 @@ class BattleFetcher {
|
||||||
this.cache = cache;
|
this.cache = cache;
|
||||||
}
|
}
|
||||||
private async getLock(id: string): Promise<Mutex> {
|
private async getLock(id: string): Promise<Mutex> {
|
||||||
const bid = await battleId(id);
|
const bid = await gameId(id);
|
||||||
|
|
||||||
let cur = this.lock[bid];
|
let cur = this.lock[bid];
|
||||||
if (!cur) {
|
if (!cur) {
|
||||||
|
|
@ -74,6 +83,7 @@ class BattleFetcher {
|
||||||
|
|
||||||
return cur;
|
return cur;
|
||||||
}
|
}
|
||||||
|
|
||||||
getBankaraHistory() {
|
getBankaraHistory() {
|
||||||
return this.bankaraLock.use(async () => {
|
return this.bankaraLock.use(async () => {
|
||||||
if (this.bankaraHistory) {
|
if (this.bankaraHistory) {
|
||||||
|
|
@ -90,8 +100,44 @@ class BattleFetcher {
|
||||||
return this.bankaraHistory;
|
return this.bankaraHistory;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async getBattleMetaById(id: string): Promise<Omit<VsBattle, "detail">> {
|
getCoopHistory() {
|
||||||
const bid = await battleId(id);
|
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);
|
||||||
const bankaraHistory = await this.getBankaraHistory();
|
const bankaraHistory = await this.getBankaraHistory();
|
||||||
const group = bankaraHistory.find((i) =>
|
const group = bankaraHistory.find((i) =>
|
||||||
i.historyDetails.nodes.some((i) => i._bid === bid)
|
i.historyDetails.nodes.some((i) => i._bid === bid)
|
||||||
|
|
@ -99,7 +145,7 @@ class BattleFetcher {
|
||||||
|
|
||||||
if (!group) {
|
if (!group) {
|
||||||
return {
|
return {
|
||||||
type: "VsBattle",
|
type: "VsInfo",
|
||||||
challengeProgress: null,
|
challengeProgress: null,
|
||||||
bankaraMatchChallenge: null,
|
bankaraMatchChallenge: null,
|
||||||
listNode: null,
|
listNode: null,
|
||||||
|
|
@ -127,39 +173,68 @@ class BattleFetcher {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "VsBattle",
|
type: "VsInfo",
|
||||||
bankaraMatchChallenge,
|
bankaraMatchChallenge,
|
||||||
listNode,
|
listNode,
|
||||||
challengeProgress,
|
challengeProgress,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
async getBattleDetail(id: string): Promise<VsHistoryDetail> {
|
async cacheDetail<T>(
|
||||||
|
id: string,
|
||||||
|
getter: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
const lock = await this.getLock(id);
|
const lock = await this.getLock(id);
|
||||||
|
|
||||||
return lock.use(async () => {
|
return lock.use(async () => {
|
||||||
const cached = await this.cache.read<VsHistoryDetail>(id);
|
const cached = await this.cache.read<T>(id);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const detail = (await getBattleDetail(this.state, id))
|
const detail = await getter();
|
||||||
.vsHistoryDetail;
|
|
||||||
|
|
||||||
await this.cache.write(id, detail);
|
await this.cache.write(id, detail);
|
||||||
|
|
||||||
return detail;
|
return detail;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async fetchBattle(id: string): Promise<VsBattle> {
|
fetch(type: Game["type"], id: string): Promise<Game> {
|
||||||
const detail = await this.getBattleDetail(id);
|
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 metadata = await this.getBattleMetaById(id);
|
||||||
|
|
||||||
const battle: VsBattle = {
|
const game: VsInfo = {
|
||||||
...metadata,
|
...metadata,
|
||||||
detail,
|
detail,
|
||||||
};
|
};
|
||||||
|
|
||||||
return battle;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,9 +278,18 @@ export class App {
|
||||||
await this.writeState(DEFAULT_STATE);
|
await this.writeState(DEFAULT_STATE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async getExporters(): Promise<BattleExporter<VsBattle>[]> {
|
getSkipMode(): ("vs" | "coop")[] {
|
||||||
|
const mode = this.opts.skipMode;
|
||||||
|
if (mode === "vs") {
|
||||||
|
return ["vs"];
|
||||||
|
} else if (mode === "coop") {
|
||||||
|
return ["coop"];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
async getExporters(): Promise<GameExporter[]> {
|
||||||
const exporters = this.opts.exporter.split(",");
|
const exporters = this.opts.exporter.split(",");
|
||||||
const out: BattleExporter<VsBattle>[] = [];
|
const out: GameExporter[] = [];
|
||||||
|
|
||||||
if (exporters.includes("stat.ink")) {
|
if (exporters.includes("stat.ink")) {
|
||||||
if (!this.state.statInkApiKey) {
|
if (!this.state.statInkApiKey) {
|
||||||
|
|
@ -237,24 +321,14 @@ export class App {
|
||||||
exportOnce() {
|
exportOnce() {
|
||||||
return retryRecoverableError(() => this._exportOnce(), this.recoveryToken);
|
return retryRecoverableError(() => this._exportOnce(), this.recoveryToken);
|
||||||
}
|
}
|
||||||
async _exportOnce() {
|
exporterProgress(title: string) {
|
||||||
const bar = !this.opts.noProgress
|
const bar = !this.opts.noProgress
|
||||||
? new MultiProgressBar({
|
? new MultiProgressBar({
|
||||||
title: "Export battles",
|
title,
|
||||||
display: "[:bar] :text :percent :time eta: :eta :completed/:total",
|
display: "[:bar] :text :percent :time eta: :eta :completed/:total",
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
try {
|
|
||||||
const exporters = await this.getExporters();
|
|
||||||
|
|
||||||
const fetcher = new BattleFetcher({
|
|
||||||
cache: this.opts.cache ?? new FileCache(this.state.cacheDir),
|
|
||||||
state: this.state,
|
|
||||||
});
|
|
||||||
console.log("Fetching battle list...");
|
|
||||||
const battleList = await getBattleList(this.state);
|
|
||||||
|
|
||||||
const allProgress: Record<string, Progress> = {};
|
const allProgress: Record<string, Progress> = {};
|
||||||
const redraw = (name: string, progress: Progress) => {
|
const redraw = (name: string, progress: Progress) => {
|
||||||
allProgress[name] = progress;
|
allProgress[name] = progress;
|
||||||
|
|
@ -266,17 +340,39 @@ export class App {
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
const endBar = () => {
|
||||||
|
bar?.end();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { redraw, endBar };
|
||||||
|
}
|
||||||
|
async _exportOnce() {
|
||||||
|
const exporters = await this.getExporters();
|
||||||
|
const skipMode = this.getSkipMode();
|
||||||
const stats: Record<string, number> = Object.fromEntries(
|
const stats: Record<string, number> = Object.fromEntries(
|
||||||
exporters.map((e) => [e.name, 0]),
|
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({
|
||||||
|
cache: this.opts.cache ?? new FileCache(this.state.cacheDir),
|
||||||
|
state: this.state,
|
||||||
|
});
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
exporters.map((e) =>
|
exporters.map((e) =>
|
||||||
showError(
|
showError(
|
||||||
this.exportBattleList({
|
this.exportGameList({
|
||||||
|
type: "VsInfo",
|
||||||
fetcher,
|
fetcher,
|
||||||
exporter: e,
|
exporter: e,
|
||||||
battleList,
|
gameList,
|
||||||
onStep: (progress) => redraw(e.name, progress),
|
onStep: (progress) => redraw(e.name, progress),
|
||||||
})
|
})
|
||||||
.then((count) => {
|
.then((count) => {
|
||||||
|
|
@ -289,7 +385,47 @@ export class App {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
bar?.end();
|
endBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Exported ${
|
`Exported ${
|
||||||
|
|
@ -298,9 +434,6 @@ export class App {
|
||||||
.join(", ")
|
.join(", ")
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
bar?.end();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
async monitor() {
|
async monitor() {
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|
@ -376,26 +509,26 @@ export class App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Export battle list.
|
* Export game list.
|
||||||
*
|
*
|
||||||
* @param fetcher BattleFetcher
|
* @param fetcher BattleFetcher
|
||||||
* @param exporter BattleExporter
|
* @param exporter BattleExporter
|
||||||
* @param battleList ID list of battles, sorted by date, newest first
|
* @param gameList ID list of games, sorted by date, newest first
|
||||||
* @param onStep Callback function called when a battle is exported
|
* @param onStep Callback function called when a game is exported
|
||||||
*/
|
*/
|
||||||
async exportBattleList(
|
async exportGameList({
|
||||||
{
|
type,
|
||||||
fetcher,
|
fetcher,
|
||||||
exporter,
|
exporter,
|
||||||
battleList,
|
gameList,
|
||||||
onStep,
|
onStep,
|
||||||
}: {
|
}: {
|
||||||
fetcher: BattleFetcher;
|
type: Game["type"];
|
||||||
exporter: BattleExporter<VsBattle>;
|
exporter: GameExporter;
|
||||||
battleList: string[];
|
fetcher: GameFetcher;
|
||||||
onStep?: (progress: Progress) => void;
|
gameList: string[];
|
||||||
},
|
onStep: (progress: Progress) => void;
|
||||||
): Promise<number> {
|
}) {
|
||||||
let exported = 0;
|
let exported = 0;
|
||||||
|
|
||||||
onStep?.({
|
onStep?.({
|
||||||
|
|
@ -403,11 +536,17 @@ export class App {
|
||||||
total: 1,
|
total: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const workQueue = [...await exporter.notExported(battleList)].reverse();
|
const workQueue = [
|
||||||
|
...await exporter.notExported({
|
||||||
|
type,
|
||||||
|
list: gameList,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
.reverse();
|
||||||
|
|
||||||
const step = async (battle: string) => {
|
const step = async (id: string) => {
|
||||||
const detail = await fetcher.fetchBattle(battle);
|
const detail = await fetcher.fetch(type, id);
|
||||||
await exporter.exportBattle(detail);
|
await exporter.exportGame(detail);
|
||||||
exported += 1;
|
exported += 1;
|
||||||
onStep?.({
|
onStep?.({
|
||||||
current: exported,
|
current: exported,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { StatInkPostBody, VsHistoryDetail } from "./types.ts";
|
import type { StatInkPostBody, VsHistoryDetail } from "./types.ts";
|
||||||
|
|
||||||
export const AGENT_NAME = "s3si.ts";
|
export const AGENT_NAME = "s3si.ts";
|
||||||
export const S3SI_VERSION = "0.1.7";
|
export const S3SI_VERSION = "0.1.8";
|
||||||
export const NSOAPP_VERSION = "2.3.1";
|
export const NSOAPP_VERSION = "2.3.1";
|
||||||
export const WEB_VIEW_VERSION = "1.0.0-216d0219";
|
export const WEB_VIEW_VERSION = "1.0.0-216d0219";
|
||||||
export const S3SI_LINK = "https://github.com/spacemeowx2/s3si.ts"
|
export const S3SI_LINK = "https://github.com/spacemeowx2/s3si.ts"
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { BattleExporter, VsBattle } from "../types.ts";
|
import { CoopInfo, GameExporter, VsInfo } from "../types.ts";
|
||||||
import { path } from "../../deps.ts";
|
import { path } from "../../deps.ts";
|
||||||
import { NSOAPP_VERSION, S3SI_VERSION } from "../constant.ts";
|
import { NSOAPP_VERSION, S3SI_VERSION } from "../constant.ts";
|
||||||
import { parseVsHistoryDetailId } from "../utils.ts";
|
import { parseHistoryDetailId } from "../utils.ts";
|
||||||
|
|
||||||
export type FileExporterType = {
|
export type FileExporterType = {
|
||||||
type: "VS" | "COOP";
|
type: "VS" | "COOP";
|
||||||
nsoVersion: string;
|
nsoVersion: string;
|
||||||
s3siVersion: string;
|
s3siVersion: string;
|
||||||
exportTime: string;
|
exportTime: string;
|
||||||
data: VsBattle;
|
data: VsInfo | CoopInfo;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -26,27 +26,27 @@ function replacer(key: string, value: unknown): unknown {
|
||||||
* This is useful for debugging. It will write each battle detail to a file.
|
* This is useful for debugging. It will write each battle detail to a file.
|
||||||
* Timestamp is used as filename. Example: 20210101T000000Z.json
|
* Timestamp is used as filename. Example: 20210101T000000Z.json
|
||||||
*/
|
*/
|
||||||
export class FileExporter implements BattleExporter<VsBattle> {
|
export class FileExporter implements GameExporter {
|
||||||
name = "file";
|
name = "file";
|
||||||
constructor(private exportPath: string) {
|
constructor(private exportPath: string) {
|
||||||
}
|
}
|
||||||
getFilenameById(id: string) {
|
getFilenameById(id: string) {
|
||||||
const { uid, timestamp } = parseVsHistoryDetailId(id);
|
const { uid, timestamp } = parseHistoryDetailId(id);
|
||||||
|
|
||||||
return `${uid}_${timestamp}Z.json`;
|
return `${uid}_${timestamp}Z.json`;
|
||||||
}
|
}
|
||||||
async exportBattle(battle: VsBattle) {
|
async exportGame(info: VsInfo | CoopInfo) {
|
||||||
await Deno.mkdir(this.exportPath, { recursive: true });
|
await Deno.mkdir(this.exportPath, { recursive: true });
|
||||||
|
|
||||||
const filename = this.getFilenameById(battle.detail.id);
|
const filename = this.getFilenameById(info.detail.id);
|
||||||
const filepath = path.join(this.exportPath, filename);
|
const filepath = path.join(this.exportPath, filename);
|
||||||
|
|
||||||
const body: FileExporterType = {
|
const body: FileExporterType = {
|
||||||
type: "VS",
|
type: info.type === "VsInfo" ? "VS" : "COOP",
|
||||||
nsoVersion: NSOAPP_VERSION,
|
nsoVersion: NSOAPP_VERSION,
|
||||||
s3siVersion: S3SI_VERSION,
|
s3siVersion: S3SI_VERSION,
|
||||||
exportTime: new Date().toISOString(),
|
exportTime: new Date().toISOString(),
|
||||||
data: battle,
|
data: info,
|
||||||
};
|
};
|
||||||
|
|
||||||
await Deno.writeTextFile(
|
await Deno.writeTextFile(
|
||||||
|
|
@ -54,7 +54,7 @@ export class FileExporter implements BattleExporter<VsBattle> {
|
||||||
JSON.stringify(body, replacer),
|
JSON.stringify(body, replacer),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
async notExported(list: string[]): Promise<string[]> {
|
async notExported({ list }: { list: string[] }): Promise<string[]> {
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
|
|
||||||
for (const id of list) {
|
for (const id of list) {
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,18 @@ import {
|
||||||
USERAGENT,
|
USERAGENT,
|
||||||
} from "../constant.ts";
|
} from "../constant.ts";
|
||||||
import {
|
import {
|
||||||
BattleExporter,
|
CoopInfo,
|
||||||
|
GameExporter,
|
||||||
StatInkPlayer,
|
StatInkPlayer,
|
||||||
StatInkPostBody,
|
StatInkPostBody,
|
||||||
StatInkStage,
|
StatInkStage,
|
||||||
VsBattle,
|
|
||||||
VsHistoryDetail,
|
VsHistoryDetail,
|
||||||
|
VsInfo,
|
||||||
VsPlayer,
|
VsPlayer,
|
||||||
} from "../types.ts";
|
} from "../types.ts";
|
||||||
import { base64, msgpack } from "../../deps.ts";
|
import { base64, msgpack } from "../../deps.ts";
|
||||||
import { APIError } from "../APIError.ts";
|
import { APIError } from "../APIError.ts";
|
||||||
import { battleId, cache } from "../utils.ts";
|
import { cache, gameId } from "../utils.ts";
|
||||||
|
|
||||||
const S3S_NAMESPACE = "b3a2dbf5-2c09-4792-b78c-00b548b70aeb";
|
const S3S_NAMESPACE = "b3a2dbf5-2c09-4792-b78c-00b548b70aeb";
|
||||||
|
|
||||||
|
|
@ -40,7 +41,7 @@ const getStage = cache(_getStage);
|
||||||
*
|
*
|
||||||
* This is the default exporter. It will upload each battle detail to stat.ink.
|
* This is the default exporter. It will upload each battle detail to stat.ink.
|
||||||
*/
|
*/
|
||||||
export class StatInkExporter implements BattleExporter<VsBattle> {
|
export class StatInkExporter implements GameExporter {
|
||||||
name = "stat.ink";
|
name = "stat.ink";
|
||||||
|
|
||||||
constructor(private statInkApiKey: string, private uploadMode: string) {
|
constructor(private statInkApiKey: string, private uploadMode: string) {
|
||||||
|
|
@ -54,8 +55,12 @@ export class StatInkExporter implements BattleExporter<VsBattle> {
|
||||||
"Authorization": `Bearer ${this.statInkApiKey}`,
|
"Authorization": `Bearer ${this.statInkApiKey}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
async exportBattle(battle: VsBattle) {
|
async exportGame(game: VsInfo | CoopInfo) {
|
||||||
const body = await this.mapBattle(battle);
|
if (game.type === "CoopInfo") {
|
||||||
|
// TODO: support coop
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = await this.mapBattle(game);
|
||||||
|
|
||||||
const resp = await fetch("https://stat.ink/api/v3/battle", {
|
const resp = await fetch("https://stat.ink/api/v3/battle", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -86,7 +91,7 @@ export class StatInkExporter implements BattleExporter<VsBattle> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async notExported(list: string[]): Promise<string[]> {
|
async notExported({ list }: { list: string[] }): Promise<string[]> {
|
||||||
const uuid = await (await fetch("https://stat.ink/api/v3/s3s/uuid-list", {
|
const uuid = await (await fetch("https://stat.ink/api/v3/s3s/uuid-list", {
|
||||||
headers: this.requestHeaders(),
|
headers: this.requestHeaders(),
|
||||||
})).json();
|
})).json();
|
||||||
|
|
@ -94,8 +99,8 @@ export class StatInkExporter implements BattleExporter<VsBattle> {
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
|
|
||||||
for (const id of list) {
|
for (const id of list) {
|
||||||
const s3sId = await battleId(id, S3S_NAMESPACE);
|
const s3sId = await gameId(id, S3S_NAMESPACE);
|
||||||
const s3siId = await battleId(id);
|
const s3siId = await gameId(id);
|
||||||
|
|
||||||
if (!uuid.includes(s3sId) && !uuid.includes(s3siId)) {
|
if (!uuid.includes(s3sId) && !uuid.includes(s3siId)) {
|
||||||
out.push(id);
|
out.push(id);
|
||||||
|
|
@ -166,7 +171,7 @@ export class StatInkExporter implements BattleExporter<VsBattle> {
|
||||||
}
|
}
|
||||||
async mapBattle(
|
async mapBattle(
|
||||||
{ challengeProgress, bankaraMatchChallenge, listNode, detail: vsDetail }:
|
{ challengeProgress, bankaraMatchChallenge, listNode, detail: vsDetail }:
|
||||||
VsBattle,
|
VsInfo,
|
||||||
): Promise<StatInkPostBody> {
|
): Promise<StatInkPostBody> {
|
||||||
const {
|
const {
|
||||||
knockout,
|
knockout,
|
||||||
|
|
@ -185,7 +190,7 @@ export class StatInkExporter implements BattleExporter<VsBattle> {
|
||||||
const startedAt = Math.floor(new Date(playedTime).getTime() / 1000);
|
const startedAt = Math.floor(new Date(playedTime).getTime() / 1000);
|
||||||
|
|
||||||
const result: StatInkPostBody = {
|
const result: StatInkPostBody = {
|
||||||
uuid: await battleId(vsDetail.id),
|
uuid: await gameId(vsDetail.id),
|
||||||
lobby: this.mapLobby(vsDetail),
|
lobby: this.mapLobby(vsDetail),
|
||||||
rule: SPLATNET3_STATINK_MAP.RULE[vsDetail.vsRule.rule],
|
rule: SPLATNET3_STATINK_MAP.RULE[vsDetail.vsRule.rule],
|
||||||
stage: await this.mapStage(vsDetail),
|
stage: await this.mapStage(vsDetail),
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
RespMap,
|
RespMap,
|
||||||
VarsMap,
|
VarsMap,
|
||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
import { battleId } from "./utils.ts";
|
import { gameId } from "./utils.ts";
|
||||||
|
|
||||||
async function request<Q extends Queries>(
|
async function request<Q extends Queries>(
|
||||||
state: State,
|
state: State,
|
||||||
|
|
@ -90,7 +90,9 @@ export async function checkToken(state: State) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getIdsFromGroups({ historyGroups }: { historyGroups: HistoryGroups }) {
|
function getIdsFromGroups<T extends { id: string }>(
|
||||||
|
{ historyGroups }: { historyGroups: HistoryGroups<T> },
|
||||||
|
) {
|
||||||
return historyGroups.nodes.flatMap((i) => i.historyDetails.nodes).map((i) =>
|
return historyGroups.nodes.flatMap((i) => i.historyDetails.nodes).map((i) =>
|
||||||
i.id
|
i.id
|
||||||
);
|
);
|
||||||
|
|
@ -112,6 +114,9 @@ const BATTLE_LIST_TYPE_MAP: Record<
|
||||||
[BattleListType.Private]: (state: State) =>
|
[BattleListType.Private]: (state: State) =>
|
||||||
request(state, Queries.PrivateBattleHistoriesQuery)
|
request(state, Queries.PrivateBattleHistoriesQuery)
|
||||||
.then((r) => getIdsFromGroups(r.privateBattleHistories)),
|
.then((r) => getIdsFromGroups(r.privateBattleHistories)),
|
||||||
|
[BattleListType.Coop]: (state: State) =>
|
||||||
|
request(state, Queries.CoopHistoryQuery)
|
||||||
|
.then((r) => getIdsFromGroups(r.coopResult)),
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getBattleList(
|
export async function getBattleList(
|
||||||
|
|
@ -134,12 +139,31 @@ export function getBattleDetail(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCoopDetail(
|
||||||
|
state: State,
|
||||||
|
id: string,
|
||||||
|
) {
|
||||||
|
return request(
|
||||||
|
state,
|
||||||
|
Queries.CoopHistoryDetailQuery,
|
||||||
|
{
|
||||||
|
coopHistoryDetailId: id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getBankaraBattleHistories(state: State) {
|
export async function getBankaraBattleHistories(state: State) {
|
||||||
const resp = await request(state, Queries.BankaraBattleHistoriesQuery);
|
const resp = await request(state, Queries.BankaraBattleHistoriesQuery);
|
||||||
for (const i of resp.bankaraBattleHistories.historyGroups.nodes) {
|
for (const i of resp.bankaraBattleHistories.historyGroups.nodes) {
|
||||||
for (const j of i.historyDetails.nodes) {
|
for (const j of i.historyDetails.nodes) {
|
||||||
j._bid = await battleId(j.id);
|
j._bid = await gameId(j.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCoopHistories(state: State) {
|
||||||
|
const resp = await request(state, Queries.CoopHistoryQuery);
|
||||||
|
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
|
||||||
55
src/types.ts
55
src/types.ts
|
|
@ -46,11 +46,15 @@ export type BattleListNode = {
|
||||||
udemae: string;
|
udemae: string;
|
||||||
judgement: "LOSE" | "WIN" | "DEEMED_LOSE" | "EXEMPTED_LOSE";
|
judgement: "LOSE" | "WIN" | "DEEMED_LOSE" | "EXEMPTED_LOSE";
|
||||||
};
|
};
|
||||||
export type HistoryGroups = {
|
export type CoopListNode = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
export type HistoryGroups<T> = {
|
||||||
nodes: {
|
nodes: {
|
||||||
bankaraMatchChallenge: null | BankaraMatchChallenge;
|
bankaraMatchChallenge: null | BankaraMatchChallenge;
|
||||||
|
|
||||||
historyDetails: {
|
historyDetails: {
|
||||||
nodes: BattleListNode[];
|
nodes: T[];
|
||||||
};
|
};
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
@ -96,13 +100,20 @@ export type ChallengeProgress = {
|
||||||
loseCount: number;
|
loseCount: number;
|
||||||
};
|
};
|
||||||
// With challenge info
|
// With challenge info
|
||||||
export type VsBattle = {
|
export type VsInfo = {
|
||||||
type: "VsBattle";
|
type: "VsInfo";
|
||||||
listNode: null | BattleListNode;
|
listNode: null | BattleListNode;
|
||||||
bankaraMatchChallenge: null | BankaraMatchChallenge;
|
bankaraMatchChallenge: null | BankaraMatchChallenge;
|
||||||
challengeProgress: null | ChallengeProgress;
|
challengeProgress: null | ChallengeProgress;
|
||||||
detail: VsHistoryDetail;
|
detail: VsHistoryDetail;
|
||||||
};
|
};
|
||||||
|
// Salmon run
|
||||||
|
export type CoopInfo = {
|
||||||
|
type: "CoopInfo";
|
||||||
|
listNode: null | CoopListNode;
|
||||||
|
detail: CoopHistoryDetail;
|
||||||
|
};
|
||||||
|
export type Game = VsInfo | CoopInfo;
|
||||||
export type VsHistoryDetail = {
|
export type VsHistoryDetail = {
|
||||||
id: string;
|
id: string;
|
||||||
vsRule: {
|
vsRule: {
|
||||||
|
|
@ -138,16 +149,21 @@ export type VsHistoryDetail = {
|
||||||
awards: { name: string; rank: string }[];
|
awards: { name: string; rank: string }[];
|
||||||
duration: number;
|
duration: number;
|
||||||
};
|
};
|
||||||
|
export type CoopHistoryDetail = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type BattleExporter<
|
export type GameExporter<
|
||||||
D extends {
|
T extends {
|
||||||
// type is seful when you implement more than one BattleExporter on the same class
|
// type is seful when you implement more than one GameExporter on the same class
|
||||||
type: string;
|
type: string;
|
||||||
},
|
} = Game,
|
||||||
> = {
|
> = {
|
||||||
name: string;
|
name: string;
|
||||||
notExported: (list: string[]) => Promise<string[]>;
|
notExported: (
|
||||||
exportBattle: (detail: D) => Promise<void>;
|
{ type, list }: { type: T["type"]; list: string[] },
|
||||||
|
) => Promise<string[]>;
|
||||||
|
exportGame: (game: T) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RespMap = {
|
export type RespMap = {
|
||||||
|
|
@ -171,29 +187,35 @@ export type RespMap = {
|
||||||
};
|
};
|
||||||
[Queries.LatestBattleHistoriesQuery]: {
|
[Queries.LatestBattleHistoriesQuery]: {
|
||||||
latestBattleHistories: {
|
latestBattleHistories: {
|
||||||
historyGroups: HistoryGroups;
|
historyGroups: HistoryGroups<BattleListNode>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
[Queries.RegularBattleHistoriesQuery]: {
|
[Queries.RegularBattleHistoriesQuery]: {
|
||||||
regularBattleHistories: {
|
regularBattleHistories: {
|
||||||
historyGroups: HistoryGroups;
|
historyGroups: HistoryGroups<BattleListNode>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
[Queries.BankaraBattleHistoriesQuery]: {
|
[Queries.BankaraBattleHistoriesQuery]: {
|
||||||
bankaraBattleHistories: {
|
bankaraBattleHistories: {
|
||||||
historyGroups: HistoryGroups;
|
historyGroups: HistoryGroups<BattleListNode>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
[Queries.PrivateBattleHistoriesQuery]: {
|
[Queries.PrivateBattleHistoriesQuery]: {
|
||||||
privateBattleHistories: {
|
privateBattleHistories: {
|
||||||
historyGroups: HistoryGroups;
|
historyGroups: HistoryGroups<BattleListNode>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
[Queries.VsHistoryDetailQuery]: {
|
[Queries.VsHistoryDetailQuery]: {
|
||||||
vsHistoryDetail: VsHistoryDetail;
|
vsHistoryDetail: VsHistoryDetail;
|
||||||
};
|
};
|
||||||
[Queries.CoopHistoryQuery]: Record<never, never>;
|
[Queries.CoopHistoryQuery]: {
|
||||||
[Queries.CoopHistoryDetailQuery]: Record<never, never>;
|
coopResult: {
|
||||||
|
historyGroups: HistoryGroups<CoopListNode>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
[Queries.CoopHistoryDetailQuery]: {
|
||||||
|
coopHistoryDetail: CoopHistoryDetail;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
export type GraphQLResponse<T> = {
|
export type GraphQLResponse<T> = {
|
||||||
data: T;
|
data: T;
|
||||||
|
|
@ -208,6 +230,7 @@ export enum BattleListType {
|
||||||
Regular,
|
Regular,
|
||||||
Bankara,
|
Bankara,
|
||||||
Private,
|
Private,
|
||||||
|
Coop,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StatInkPlayer = {
|
export type StatInkPlayer = {
|
||||||
|
|
|
||||||
34
src/utils.ts
34
src/utils.ts
|
|
@ -83,11 +83,11 @@ export async function showError<T>(p: Promise<T>): Promise<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param id id of VeVsHistoryDetail
|
* @param id id of VsHistoryDetail or CoopHistoryDetail
|
||||||
* @param namespace uuid namespace
|
* @param namespace uuid namespace
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export function battleId(
|
export function gameId(
|
||||||
id: string,
|
id: string,
|
||||||
namespace = S3SI_NAMESPACE,
|
namespace = S3SI_NAMESPACE,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
|
@ -96,22 +96,38 @@ export function battleId(
|
||||||
return uuid.v5.generate(namespace, tsUuid);
|
return uuid.v5.generate(namespace, tsUuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseVsHistoryDetailId(id: string) {
|
/**
|
||||||
|
* @param id VsHistoryDetail id or CoopHistoryDetail id
|
||||||
|
*/
|
||||||
|
//CoopHistoryDetail-u-quoeuj7rhknjq3jkanmm:20221022T065633_25287bf9-d9a8-42b0-b070-e938da103547
|
||||||
|
export function parseHistoryDetailId(id: string) {
|
||||||
const plainText = new TextDecoder().decode(base64.decode(id));
|
const plainText = new TextDecoder().decode(base64.decode(id));
|
||||||
|
|
||||||
const re = /VsHistoryDetail-([a-z0-9-]+):(\w+):(\d{8}T\d{6})_([0-9a-f-]{36})/;
|
const vsRE =
|
||||||
if (!re.test(plainText)) {
|
/VsHistoryDetail-([a-z0-9-]+):(\w+):(\d{8}T\d{6})_([0-9a-f-]{36})/;
|
||||||
throw new Error(`Invalid battle ID: ${plainText}`);
|
const coopRE = /CoopHistoryDetail-([a-z0-9-]+):(\d{8}T\d{6})_([0-9a-f-]{36})/;
|
||||||
}
|
if (vsRE.test(plainText)) {
|
||||||
|
const [, uid, listType, timestamp, uuid] = plainText.match(vsRE)!;
|
||||||
const [, uid, listType, timestamp, uuid] = plainText.match(re)!;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
type: "VsHistoryDetail",
|
||||||
uid,
|
uid,
|
||||||
listType,
|
listType,
|
||||||
timestamp,
|
timestamp,
|
||||||
uuid,
|
uuid,
|
||||||
};
|
};
|
||||||
|
} else if (coopRE.test(plainText)) {
|
||||||
|
const [, uid, timestamp, uuid] = plainText.match(coopRE)!;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "CoopHistoryDetail",
|
||||||
|
uid,
|
||||||
|
timestamp,
|
||||||
|
uuid,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error(`Invalid ID: ${plainText}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const delay = (ms: number) =>
|
export const delay = (ms: number) =>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue