feat: add udemae info

main
spacemeowx2 2022-10-21 10:47:56 +08:00
parent 5737f9da37
commit 74a0ef99ec
6 changed files with 183 additions and 53 deletions

View File

@ -1,4 +1,4 @@
import { BattleExporter, VsHistoryDetail } from "../types.ts"; import { BattleExporter, VsBattle } from "../types.ts";
import { datetime, path } from "../deps.ts"; import { datetime, path } from "../deps.ts";
import { NSOAPP_VERSION, S3SI_VERSION } from "../constant.ts"; import { NSOAPP_VERSION, S3SI_VERSION } from "../constant.ts";
const FILENAME_FORMAT = "yyyyMMddHHmmss"; const FILENAME_FORMAT = "yyyyMMddHHmmss";
@ -8,7 +8,7 @@ type FileExporterType = {
nsoVersion: string; nsoVersion: string;
s3siVersion: string; s3siVersion: string;
exportTime: string; exportTime: string;
data: VsHistoryDetail; data: VsBattle;
}; };
/** /**
@ -17,14 +17,14 @@ type FileExporterType = {
* 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: 2021-01-01T00:00:00.000Z.json * Timestamp is used as filename. Example: 2021-01-01T00:00:00.000Z.json
*/ */
export class FileExporter implements BattleExporter<VsHistoryDetail> { export class FileExporter implements BattleExporter<VsBattle> {
name = "file"; name = "file";
constructor(private exportPath: string) { constructor(private exportPath: string) {
} }
async exportBattle(detail: VsHistoryDetail) { async exportBattle(battle: VsBattle) {
await Deno.mkdir(this.exportPath, { recursive: true }); await Deno.mkdir(this.exportPath, { recursive: true });
const playedTime = new Date(detail.playedTime); const playedTime = new Date(battle.detail.playedTime);
const filename = `${datetime.format(playedTime, FILENAME_FORMAT)}.json`; const filename = `${datetime.format(playedTime, FILENAME_FORMAT)}.json`;
const filepath = path.join(this.exportPath, filename); const filepath = path.join(this.exportPath, filename);
@ -33,7 +33,7 @@ export class FileExporter implements BattleExporter<VsHistoryDetail> {
nsoVersion: NSOAPP_VERSION, nsoVersion: NSOAPP_VERSION,
s3siVersion: S3SI_VERSION, s3siVersion: S3SI_VERSION,
exportTime: new Date().toISOString(), exportTime: new Date().toISOString(),
data: detail, data: battle,
}; };
await Deno.writeTextFile(filepath, JSON.stringify(body)); await Deno.writeTextFile(filepath, JSON.stringify(body));

View File

@ -1,6 +1,5 @@
import { import {
AGENT_NAME, AGENT_NAME,
S3SI_NAMESPACE,
S3SI_VERSION, S3SI_VERSION,
SPLATNET3_STATINK_MAP, SPLATNET3_STATINK_MAP,
USERAGENT, USERAGENT,
@ -10,31 +9,16 @@ import {
StatInkPlayer, StatInkPlayer,
StatInkPostBody, StatInkPostBody,
StatInkStage, StatInkStage,
VsBattle,
VsHistoryDetail, VsHistoryDetail,
VsPlayer, VsPlayer,
} from "../types.ts"; } from "../types.ts";
import { base64, msgpack, uuid } from "../deps.ts"; import { base64, msgpack } from "../deps.ts";
import { APIError } from "../APIError.ts"; import { APIError } from "../APIError.ts";
import { cache } from "../utils.ts"; import { battleId, cache } from "../utils.ts";
const S3S_NAMESPACE = "b3a2dbf5-2c09-4792-b78c-00b548b70aeb"; const S3S_NAMESPACE = "b3a2dbf5-2c09-4792-b78c-00b548b70aeb";
/**
* generate s3s uuid
*
* @param id ID from SplatNet3
* @returns id generated from s3s
*/
function s3sUuid(id: string): Promise<string> {
const fullId = base64.decode(id);
const tsUuid = fullId.slice(fullId.length - 52, fullId.length);
return uuid.v5.generate(S3S_NAMESPACE, tsUuid);
}
function battleId(id: string): Promise<string> {
return uuid.v5.generate(S3SI_NAMESPACE, new TextEncoder().encode(id));
}
/** /**
* Decode ID and get number after '-' * Decode ID and get number after '-'
*/ */
@ -56,7 +40,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<VsHistoryDetail> { export class StatInkExporter implements BattleExporter<VsBattle> {
name = "stat.ink"; name = "stat.ink";
constructor(private statInkApiKey: string) { constructor(private statInkApiKey: string) {
if (statInkApiKey.length !== 43) { if (statInkApiKey.length !== 43) {
@ -69,8 +53,8 @@ export class StatInkExporter implements BattleExporter<VsHistoryDetail> {
"Authorization": `Bearer ${this.statInkApiKey}`, "Authorization": `Bearer ${this.statInkApiKey}`,
}; };
} }
async exportBattle(detail: VsHistoryDetail) { async exportBattle(battle: VsBattle) {
const body = await this.mapBattle(detail); const body = await this.mapBattle(battle);
const resp = await fetch("https://stat.ink/api/v3/battle", { const resp = await fetch("https://stat.ink/api/v3/battle", {
method: "POST", method: "POST",
@ -100,8 +84,6 @@ export class StatInkExporter implements BattleExporter<VsHistoryDetail> {
json, json,
}); });
} }
throw new Error("abort");
} }
async notExported(list: string[]): Promise<string[]> { async notExported(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", {
@ -111,7 +93,7 @@ export class StatInkExporter implements BattleExporter<VsHistoryDetail> {
const out: string[] = []; const out: string[] = [];
for (const id of list) { for (const id of list) {
const s3sId = await s3sUuid(id); const s3sId = await battleId(id, S3S_NAMESPACE);
const s3siId = await battleId(id); const s3siId = await battleId(id);
if (!uuid.includes(s3sId) && !uuid.includes(s3siId)) { if (!uuid.includes(s3sId) && !uuid.includes(s3siId)) {
@ -181,10 +163,14 @@ export class StatInkExporter implements BattleExporter<VsHistoryDetail> {
} }
return result; return result;
} }
async mapBattle(vsDetail: VsHistoryDetail): Promise<StatInkPostBody> { async mapBattle(
{ lastInChallenge, bankaraMatchChallenge, listNode, detail: vsDetail }:
VsBattle,
): Promise<StatInkPostBody> {
const { const {
knockout, knockout,
vsMode: { mode }, vsMode: { mode },
vsRule: { rule },
myTeam, myTeam,
otherTeams, otherTeams,
bankaraMatch, bankaraMatch,
@ -244,7 +230,7 @@ export class StatInkExporter implements BattleExporter<VsHistoryDetail> {
result.clout_change = festMatch.contribution; result.clout_change = festMatch.contribution;
result.fest_power = festMatch.myFestPower ?? undefined; result.fest_power = festMatch.myFestPower ?? undefined;
} }
if (mode === "FEST" || mode === "REGULAR") { if (rule === "TURF_WAR") {
result.our_team_percent = (myTeam.result.paintRatio ?? 0) * 100; result.our_team_percent = (myTeam.result.paintRatio ?? 0) * 100;
result.their_team_percent = (otherTeams?.[0].result.paintRatio ?? 0) * result.their_team_percent = (otherTeams?.[0].result.paintRatio ?? 0) *
100; 100;
@ -257,17 +243,40 @@ export class StatInkExporter implements BattleExporter<VsHistoryDetail> {
0, 0,
); );
} }
if (mode === "BANKARA") { if (bankaraMatch) {
if (!bankaraMatch) {
throw new TypeError("bankaraMatch is null");
}
result.our_team_count = myTeam.result.score ?? undefined; result.our_team_count = myTeam.result.score ?? undefined;
result.their_team_count = otherTeams?.[0].result.score ?? undefined; result.their_team_count = otherTeams?.[0].result.score ?? undefined;
result.knockout = (!knockout || knockout === "NEITHER") ? "no" : "yes"; result.knockout = (!knockout || knockout === "NEITHER") ? "no" : "yes";
result.rank_exp_change = bankaraMatch.earnedUdemaePoint; result.rank_exp_change = bankaraMatch.earnedUdemaePoint;
} }
if (listNode) {
[result.rank_before, result.rank_before_s_plus] = parseUdemae(
listNode.udemae,
);
}
if (bankaraMatchChallenge) {
result.rank_up_battle = bankaraMatchChallenge.isPromo ? "yes" : "no";
if (bankaraMatchChallenge.udemaeAfter) {
[result.rank_after, result.rank_after_s_plus] = parseUdemae(
bankaraMatchChallenge.udemaeAfter,
);
}
if (lastInChallenge) {
result.challenge_win = bankaraMatchChallenge.winCount;
result.challenge_lose = bankaraMatchChallenge.loseCount;
result.rank_exp_change = bankaraMatchChallenge.earnedUdemaePoint;
}
}
return result; return result;
} }
} }
function parseUdemae(udemae: string): [string, number | undefined] {
const [rank, rankNum] = udemae.split(/([0-9]+)/);
return [
rank.toLowerCase(),
rankNum === undefined ? undefined : parseInt(rankNum),
];
}

91
s3si.ts
View File

@ -1,11 +1,21 @@
import { getBulletToken, getGToken, loginManually } from "./iksm.ts"; import { getBulletToken, getGToken, loginManually } from "./iksm.ts";
import { flags, MultiProgressBar, Mutex } from "./deps.ts"; import { flags, MultiProgressBar, Mutex } from "./deps.ts";
import { DEFAULT_STATE, State } from "./state.ts"; import { DEFAULT_STATE, State } from "./state.ts";
import { checkToken, getBattleDetail, getBattleList } from "./splatnet3.ts"; import {
import { BattleExporter, VsHistoryDetail } from "./types.ts"; checkToken,
getBankaraBattleHistories,
getBattleDetail,
getBattleList,
} from "./splatnet3.ts";
import {
BattleExporter,
HistoryGroups,
VsBattle,
VsHistoryDetail,
} from "./types.ts";
import { Cache, FileCache, MemoryCache } from "./cache.ts"; import { Cache, FileCache, MemoryCache } from "./cache.ts";
import { StatInkExporter } from "./exporter/stat.ink.ts"; import { StatInkExporter } from "./exporter/stat.ink.ts";
import { readline, showError } from "./utils.ts"; import { battleId, readline, showError } from "./utils.ts";
import { FileExporter } from "./exporter/file.ts"; import { FileExporter } from "./exporter/file.ts";
type Opts = { type Opts = {
@ -29,6 +39,8 @@ class BattleFetcher {
state: State; state: State;
cache: Cache; cache: Cache;
lock: Record<string, Mutex | undefined> = {}; lock: Record<string, Mutex | undefined> = {};
bankaraLock = new Mutex();
bankaraHistory?: HistoryGroups["nodes"];
constructor( constructor(
{ cache = new MemoryCache(), state }: { state: State; cache?: Cache }, { cache = new MemoryCache(), state }: { state: State; cache?: Cache },
@ -36,16 +48,62 @@ class BattleFetcher {
this.state = state; this.state = state;
this.cache = cache; this.cache = cache;
} }
getLock(id: string): Mutex { private async getLock(id: string): Promise<Mutex> {
let cur = this.lock[id]; const bid = await battleId(id);
let cur = this.lock[bid];
if (!cur) { if (!cur) {
cur = new Mutex(); cur = new Mutex();
this.lock[id] = cur; this.lock[bid] = cur;
} }
return cur; return cur;
} }
fetchBattle(id: string): Promise<VsHistoryDetail> { getBankaraHistory() {
const lock = this.getLock(id); 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;
});
}
async getBattleMetaById(id: string): Promise<Omit<VsBattle, "detail">> {
const bid = await battleId(id);
const bankaraHistory = await this.getBankaraHistory();
const group = bankaraHistory.find((i) =>
i.historyDetails.nodes.some((i) => i._bid === bid)
);
if (!group) {
return {
bankaraMatchChallenge: null,
listNode: null,
lastInChallenge: null,
};
}
const { bankaraMatchChallenge } = group;
const listNode = group.historyDetails.nodes.find((i) => i._bid === bid) ??
null;
const idx = group.historyDetails.nodes.indexOf(listNode!);
return {
bankaraMatchChallenge,
listNode,
lastInChallenge: (bankaraMatchChallenge?.state !== "INPROGRESS") &&
(idx === 0),
};
}
async getBattleDetail(id: string): Promise<VsHistoryDetail> {
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<VsHistoryDetail>(id);
@ -61,6 +119,17 @@ class BattleFetcher {
return detail; return detail;
}); });
} }
async fetchBattle(id: string): Promise<VsBattle> {
const detail = await this.getBattleDetail(id);
const metadata = await this.getBattleMetaById(id);
const battle: VsBattle = {
...metadata,
detail,
};
return battle;
}
} }
type Progress = { type Progress = {
@ -111,9 +180,9 @@ Options:
await this.writeState(DEFAULT_STATE); await this.writeState(DEFAULT_STATE);
} }
} }
async getExporters(): Promise<BattleExporter<VsHistoryDetail>[]> { async getExporters(): Promise<BattleExporter<VsBattle>[]> {
const exporters = this.opts.exporter.split(","); const exporters = this.opts.exporter.split(",");
const out: BattleExporter<VsHistoryDetail>[] = []; const out: BattleExporter<VsBattle>[] = [];
if (exporters.includes("stat.ink")) { if (exporters.includes("stat.ink")) {
if (!this.state.statInkApiKey) { if (!this.state.statInkApiKey) {
@ -248,7 +317,7 @@ Options:
onStep, onStep,
}: { }: {
fetcher: BattleFetcher; fetcher: BattleFetcher;
exporter: BattleExporter<VsHistoryDetail>; exporter: BattleExporter<VsBattle>;
battleList: string[]; battleList: string[];
onStep?: (progress: Progress) => void; onStep?: (progress: Progress) => void;
}, },

View File

@ -10,6 +10,7 @@ import {
RespMap, RespMap,
VarsMap, VarsMap,
} from "./types.ts"; } from "./types.ts";
import { battleId } from "./utils.ts";
async function request<Q extends Queries>( async function request<Q extends Queries>(
state: State, state: State,
@ -121,3 +122,13 @@ export function getBattleDetail(
}, },
); );
} }
export async function getBankaraBattleHistories(state: State) {
const resp = await request(state, Queries.BankaraBattleHistoriesQuery);
for (const i of resp.bankaraBattleHistories.historyGroups.nodes) {
for (const j of i.historyDetails.nodes) {
j._bid = await battleId(j.id);
}
}
return resp;
}

View File

@ -28,12 +28,28 @@ export type Image = {
width?: number; width?: number;
height?: number; height?: number;
}; };
export type BankaraMatchChallenge = {
winCount: number;
loseCount: number;
maxWinCount: number;
maxLoseCount: number;
state: "FAILED" | "SUCCEEDED" | "INPROGRESS";
isPromo: boolean;
isUdemaeUp: boolean;
udemaeAfter: string | null;
earnedUdemaePoint: number;
};
export type BattleListNode = {
// battle id added after fetch
_bid: string;
id: string;
udemae: string;
};
export type HistoryGroups = { export type HistoryGroups = {
nodes: { nodes: {
bankaraMatchChallenge: null | BankaraMatchChallenge;
historyDetails: { historyDetails: {
nodes: { nodes: BattleListNode[];
id: string;
}[];
}; };
}[]; }[];
}; };
@ -65,12 +81,27 @@ export type VsTeam = {
score: null | number; score: null | number;
}; };
}; };
export type VsRule =
| "TURF_WAR"
| "AREA"
| "LOFT"
| "GOAL"
| "CLAM"
| "TRI_COLOR";
// With challenge info
export type VsBattle = {
listNode: null | BattleListNode;
bankaraMatchChallenge: null | BankaraMatchChallenge;
lastInChallenge: null | boolean;
detail: VsHistoryDetail;
};
export type VsHistoryDetail = { export type VsHistoryDetail = {
id: string; id: string;
vsRule: { vsRule: {
name: string; name: string;
id: string; id: string;
rule: "TURF_WAR" | "AREA" | "LOFT" | "GOAL" | "CLAM" | "TRI_COLOR"; rule: VsRule;
}; };
vsMode: { vsMode: {
id: string; id: string;

View File

@ -1,5 +1,6 @@
import { APIError } from "./APIError.ts"; import { APIError } from "./APIError.ts";
import { base64, io } from "./deps.ts"; import { S3SI_NAMESPACE } from "./constant.ts";
import { base64, io, uuid } from "./deps.ts";
const stdinLines = io.readLines(Deno.stdin); const stdinLines = io.readLines(Deno.stdin);
@ -80,3 +81,12 @@ export async function showError(p: Promise<void>) {
throw e; throw e;
} }
} }
export function battleId(
id: string,
namespace = S3SI_NAMESPACE,
): Promise<string> {
const fullId = base64.decode(id);
const tsUuid = fullId.slice(fullId.length - 52, fullId.length);
return uuid.v5.generate(namespace, tsUuid);
}