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 { NSOAPP_VERSION, S3SI_VERSION } from "../constant.ts";
const FILENAME_FORMAT = "yyyyMMddHHmmss";
@ -8,7 +8,7 @@ type FileExporterType = {
nsoVersion: string;
s3siVersion: 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.
* 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";
constructor(private exportPath: string) {
}
async exportBattle(detail: VsHistoryDetail) {
async exportBattle(battle: VsBattle) {
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 filepath = path.join(this.exportPath, filename);
@ -33,7 +33,7 @@ export class FileExporter implements BattleExporter<VsHistoryDetail> {
nsoVersion: NSOAPP_VERSION,
s3siVersion: S3SI_VERSION,
exportTime: new Date().toISOString(),
data: detail,
data: battle,
};
await Deno.writeTextFile(filepath, JSON.stringify(body));

View File

@ -1,6 +1,5 @@
import {
AGENT_NAME,
S3SI_NAMESPACE,
S3SI_VERSION,
SPLATNET3_STATINK_MAP,
USERAGENT,
@ -10,31 +9,16 @@ import {
StatInkPlayer,
StatInkPostBody,
StatInkStage,
VsBattle,
VsHistoryDetail,
VsPlayer,
} from "../types.ts";
import { base64, msgpack, uuid } from "../deps.ts";
import { base64, msgpack } from "../deps.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";
/**
* 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 '-'
*/
@ -56,7 +40,7 @@ const getStage = cache(_getStage);
*
* 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";
constructor(private statInkApiKey: string) {
if (statInkApiKey.length !== 43) {
@ -69,8 +53,8 @@ export class StatInkExporter implements BattleExporter<VsHistoryDetail> {
"Authorization": `Bearer ${this.statInkApiKey}`,
};
}
async exportBattle(detail: VsHistoryDetail) {
const body = await this.mapBattle(detail);
async exportBattle(battle: VsBattle) {
const body = await this.mapBattle(battle);
const resp = await fetch("https://stat.ink/api/v3/battle", {
method: "POST",
@ -100,8 +84,6 @@ export class StatInkExporter implements BattleExporter<VsHistoryDetail> {
json,
});
}
throw new Error("abort");
}
async notExported(list: string[]): Promise<string[]> {
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[] = [];
for (const id of list) {
const s3sId = await s3sUuid(id);
const s3sId = await battleId(id, S3S_NAMESPACE);
const s3siId = await battleId(id);
if (!uuid.includes(s3sId) && !uuid.includes(s3siId)) {
@ -181,10 +163,14 @@ export class StatInkExporter implements BattleExporter<VsHistoryDetail> {
}
return result;
}
async mapBattle(vsDetail: VsHistoryDetail): Promise<StatInkPostBody> {
async mapBattle(
{ lastInChallenge, bankaraMatchChallenge, listNode, detail: vsDetail }:
VsBattle,
): Promise<StatInkPostBody> {
const {
knockout,
vsMode: { mode },
vsRule: { rule },
myTeam,
otherTeams,
bankaraMatch,
@ -244,7 +230,7 @@ export class StatInkExporter implements BattleExporter<VsHistoryDetail> {
result.clout_change = festMatch.contribution;
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.their_team_percent = (otherTeams?.[0].result.paintRatio ?? 0) *
100;
@ -257,17 +243,40 @@ export class StatInkExporter implements BattleExporter<VsHistoryDetail> {
0,
);
}
if (mode === "BANKARA") {
if (!bankaraMatch) {
throw new TypeError("bankaraMatch is null");
}
if (bankaraMatch) {
result.our_team_count = myTeam.result.score ?? undefined;
result.their_team_count = otherTeams?.[0].result.score ?? undefined;
result.knockout = (!knockout || knockout === "NEITHER") ? "no" : "yes";
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;
}
}
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 { flags, MultiProgressBar, Mutex } from "./deps.ts";
import { DEFAULT_STATE, State } from "./state.ts";
import { checkToken, getBattleDetail, getBattleList } from "./splatnet3.ts";
import { BattleExporter, VsHistoryDetail } from "./types.ts";
import {
checkToken,
getBankaraBattleHistories,
getBattleDetail,
getBattleList,
} from "./splatnet3.ts";
import {
BattleExporter,
HistoryGroups,
VsBattle,
VsHistoryDetail,
} from "./types.ts";
import { Cache, FileCache, MemoryCache } from "./cache.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";
type Opts = {
@ -29,6 +39,8 @@ class BattleFetcher {
state: State;
cache: Cache;
lock: Record<string, Mutex | undefined> = {};
bankaraLock = new Mutex();
bankaraHistory?: HistoryGroups["nodes"];
constructor(
{ cache = new MemoryCache(), state }: { state: State; cache?: Cache },
@ -36,16 +48,62 @@ class BattleFetcher {
this.state = state;
this.cache = cache;
}
getLock(id: string): Mutex {
let cur = this.lock[id];
private async getLock(id: string): Promise<Mutex> {
const bid = await battleId(id);
let cur = this.lock[bid];
if (!cur) {
cur = new Mutex();
this.lock[id] = cur;
this.lock[bid] = cur;
}
return cur;
}
fetchBattle(id: string): Promise<VsHistoryDetail> {
const lock = this.getLock(id);
getBankaraHistory() {
return this.bankaraLock.use(async () => {
if (this.bankaraHistory) {
return this.bankaraHistory;
}
const { bankaraBattleHistories: { historyGroups } } =
await getBankaraBattleHistories(
this.state,
);
this.bankaraHistory = historyGroups.nodes;
return this.bankaraHistory;
});
}
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 () => {
const cached = await this.cache.read<VsHistoryDetail>(id);
@ -61,6 +119,17 @@ class BattleFetcher {
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 = {
@ -111,9 +180,9 @@ Options:
await this.writeState(DEFAULT_STATE);
}
}
async getExporters(): Promise<BattleExporter<VsHistoryDetail>[]> {
async getExporters(): Promise<BattleExporter<VsBattle>[]> {
const exporters = this.opts.exporter.split(",");
const out: BattleExporter<VsHistoryDetail>[] = [];
const out: BattleExporter<VsBattle>[] = [];
if (exporters.includes("stat.ink")) {
if (!this.state.statInkApiKey) {
@ -248,7 +317,7 @@ Options:
onStep,
}: {
fetcher: BattleFetcher;
exporter: BattleExporter<VsHistoryDetail>;
exporter: BattleExporter<VsBattle>;
battleList: string[];
onStep?: (progress: Progress) => void;
},

View File

@ -10,6 +10,7 @@ import {
RespMap,
VarsMap,
} from "./types.ts";
import { battleId } from "./utils.ts";
async function request<Q extends Queries>(
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;
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 = {
nodes: {
bankaraMatchChallenge: null | BankaraMatchChallenge;
historyDetails: {
nodes: {
id: string;
}[];
nodes: BattleListNode[];
};
}[];
};
@ -65,12 +81,27 @@ export type VsTeam = {
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 = {
id: string;
vsRule: {
name: string;
id: string;
rule: "TURF_WAR" | "AREA" | "LOFT" | "GOAL" | "CLAM" | "TRI_COLOR";
rule: VsRule;
};
vsMode: {
id: string;

View File

@ -1,5 +1,6 @@
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);
@ -80,3 +81,12 @@ export async function showError(p: Promise<void>) {
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);
}