feat: add coop export (v0.1.8)

main
spacemeowx2 2022-10-25 02:45:36 +08:00
parent cd4aa53c7d
commit f30da22d0c
8 changed files with 345 additions and 134 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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