feat: add salmon run export to stat.ink (#18)
* feat: add coop types * feat: update coop types * feat: add coop game id * feat: add splatnet3 types * feat: did some mapping * feat: remove used code * feat: add coop player map * feat: update types * fix: lint error * refactor: use Env::newFetcher in exporter * feat: add some mappings * feat: add groupInfo to coop * feat: add private * feat: complete all mappings * fix: dirty fix upload error * fix: fApi request * feat: remove workarounds * chore: remove coop todo * feat: update constant and version * feat: remove coop test flag * fix: wrong clear_wavesmain
parent
bd57f2cf6c
commit
ff538c1f2a
|
|
@ -1,3 +1,9 @@
|
|||
## 0.1.23
|
||||
|
||||
feat: add salmon run export to stat.ink
|
||||
|
||||
feat: update `WEB_VIEW_VERSION` constant
|
||||
|
||||
## 0.1.22
|
||||
|
||||
feat: update `WEB_VIEW_VERSION` constant
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import { USERAGENT } from "../src/constant.ts";
|
||||
|
||||
const [key, ...uuids] = Deno.args;
|
||||
if (!key || uuids.length === 0) {
|
||||
console.log("Usage: delete-coop.ts <key> <uuid> <uuid...>");
|
||||
Deno.exit(1);
|
||||
}
|
||||
|
||||
for (const uuid of uuids) {
|
||||
console.log("Deleting", uuid);
|
||||
const resp = await fetch(`https://stat.ink/api/v3/salmon/${uuid}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${key}`,
|
||||
"User-Agent": USERAGENT,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(resp.status);
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { FileExporterType } from "../src/exporters/file.ts";
|
||||
import { b64Number } from "../src/utils.ts";
|
||||
|
||||
const dirs = Deno.args;
|
||||
|
||||
const files: string[] = [];
|
||||
|
||||
for (const dir of dirs) {
|
||||
for await (const entry of Deno.readDir(dir)) {
|
||||
if (entry.isFile) {
|
||||
files.push(`${dir}/${entry.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const events = new Map<number, string>();
|
||||
const uniforms = new Map<number, string>();
|
||||
const specials = new Map<number, string>();
|
||||
const bosses = new Map<number, string>();
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content: FileExporterType = JSON.parse(await Deno.readTextFile(file));
|
||||
const { data } = content;
|
||||
if (data.type === "CoopInfo") {
|
||||
const eventIds = data.detail.waveResults.map((i) => i.eventWave).filter(
|
||||
Boolean,
|
||||
).map((i) => i!);
|
||||
for (const { id, name } of eventIds) {
|
||||
events.set(b64Number(id), name);
|
||||
}
|
||||
|
||||
for (
|
||||
const { id, name } of [
|
||||
data.detail.myResult,
|
||||
...data.detail.memberResults,
|
||||
].map((i) => i.player.uniform)
|
||||
) {
|
||||
uniforms.set(b64Number(id), name);
|
||||
}
|
||||
|
||||
for (
|
||||
const { id, name } of data.detail.waveResults.flatMap((i) =>
|
||||
i.specialWeapons
|
||||
)
|
||||
) {
|
||||
specials.set(b64Number(id), name);
|
||||
}
|
||||
|
||||
for (const { id, name } of data.detail.enemyResults.map((i) => i.enemy)) {
|
||||
bosses.set(b64Number(id), name);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Failed to process file", file, e);
|
||||
}
|
||||
}
|
||||
|
||||
console.log([...events.entries()].sort((a, b) => a[0] - b[0]));
|
||||
console.log([...uniforms.entries()].sort((a, b) => a[0] - b[0]));
|
||||
console.log([...specials.entries()].sort((a, b) => a[0] - b[0]));
|
||||
console.log([...bosses.entries()].sort((a, b) => a[0] - b[0]));
|
||||
|
|
@ -4,8 +4,8 @@ import { Splatnet3 } from "./splatnet3.ts";
|
|||
import {
|
||||
BattleListNode,
|
||||
ChallengeProgress,
|
||||
CoopHistoryGroups,
|
||||
CoopInfo,
|
||||
CoopListNode,
|
||||
Game,
|
||||
HistoryGroups,
|
||||
VsInfo,
|
||||
|
|
@ -26,7 +26,7 @@ export class GameFetcher {
|
|||
bankaraLock = new Mutex();
|
||||
bankaraHistory?: HistoryGroups<BattleListNode>["nodes"];
|
||||
coopLock = new Mutex();
|
||||
coopHistory?: HistoryGroups<CoopListNode>["nodes"];
|
||||
coopHistory?: CoopHistoryGroups["nodes"];
|
||||
|
||||
constructor(
|
||||
{ cache = new MemoryCache(), splatnet, state }: {
|
||||
|
|
@ -103,15 +103,18 @@ export class GameFetcher {
|
|||
return {
|
||||
type: "CoopInfo",
|
||||
listNode: null,
|
||||
groupInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
const listNode = group.historyDetails.nodes.find((i) => i.id === id) ??
|
||||
const { historyDetails, ...groupInfo } = group;
|
||||
const listNode = historyDetails.nodes.find((i) => i.id === id) ??
|
||||
null;
|
||||
|
||||
return {
|
||||
type: "CoopInfo",
|
||||
listNode,
|
||||
groupInfo,
|
||||
};
|
||||
}
|
||||
async getBattleMetaById(id: string): Promise<Omit<VsInfo, "detail">> {
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ export class App {
|
|||
new StatInkExporter({
|
||||
statInkApiKey: this.profile.state.statInkApiKey!,
|
||||
uploadMode: this.opts.monitor ? "Monitoring" : "Manual",
|
||||
env: this.env,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -190,9 +191,7 @@ export class App {
|
|||
|
||||
stats = initStats();
|
||||
|
||||
// TODO: remove this filter when stat.ink support coop export
|
||||
const coopExporter = exporters.filter((e) => e.name !== "stat.ink");
|
||||
if (skipMode.includes("coop") || coopExporter.length === 0) {
|
||||
if (skipMode.includes("coop")) {
|
||||
this.env.logger.log("Skip exporting coop games.");
|
||||
} else {
|
||||
this.env.logger.log("Fetching coop battle list...");
|
||||
|
|
@ -208,7 +207,7 @@ export class App {
|
|||
});
|
||||
|
||||
await Promise.all(
|
||||
coopExporter.map((e) =>
|
||||
exporters.map((e) =>
|
||||
showError(
|
||||
this.env,
|
||||
this.exportGameList({
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import type { StatInkPostBody, VsHistoryDetail } from "./types.ts";
|
||||
|
||||
export const AGENT_NAME = "s3si.ts";
|
||||
export const S3SI_VERSION = "0.1.22";
|
||||
export const S3SI_VERSION = "0.1.23";
|
||||
export const NSOAPP_VERSION = "2.3.1";
|
||||
export const WEB_VIEW_VERSION = "1.0.0-d7b95a79";
|
||||
export const WEB_VIEW_VERSION = "1.0.0-433ec0e8";
|
||||
export const S3SI_LINK = "https://github.com/spacemeowx2/s3si.ts";
|
||||
|
||||
export const USERAGENT = `${AGENT_NAME}/${S3SI_VERSION} (${S3SI_LINK})`;
|
||||
|
|
@ -14,7 +14,8 @@ export const DEFAULT_APP_USER_AGENT =
|
|||
export const SPLATNET3_URL = "https://api.lp1.av5ja.srv.nintendo.net";
|
||||
export const SPLATNET3_ENDPOINT =
|
||||
"https://api.lp1.av5ja.srv.nintendo.net/api/graphql";
|
||||
export const S3S_NAMESPACE = "b3a2dbf5-2c09-4792-b78c-00b548b70aeb";
|
||||
export const BATTLE_NAMESPACE = "b3a2dbf5-2c09-4792-b78c-00b548b70aeb";
|
||||
export const COOP_NAMESPACE = "f1911910-605e-11ed-a622-7085c2057a9d";
|
||||
export const S3SI_NAMESPACE = "63941e1c-e32e-4b56-9a1d-f6fbe19ef6e1";
|
||||
|
||||
export const SPLATNET3_STATINK_MAP: {
|
||||
|
|
@ -24,6 +25,20 @@ export const SPLATNET3_STATINK_MAP: {
|
|||
NonNullable<VsHistoryDetail["festMatch"]>["dragonMatchType"],
|
||||
StatInkPostBody["fest_dragon"]
|
||||
>;
|
||||
COOP_EVENT_MAP: Record<number, string | undefined>;
|
||||
COOP_UNIFORM_MAP: Record<
|
||||
number,
|
||||
| "orange"
|
||||
| "green"
|
||||
| "yellow"
|
||||
| "pink"
|
||||
| "blue"
|
||||
| "black"
|
||||
| "white"
|
||||
| undefined
|
||||
>;
|
||||
COOP_SPECIAL_MAP: Record<number, string>;
|
||||
WATER_LEVEL_MAP: Record<0 | 1 | 2, "low" | "normal" | "high">;
|
||||
} = {
|
||||
RULE: {
|
||||
TURF_WAR: "nawabari",
|
||||
|
|
@ -47,4 +62,37 @@ export const SPLATNET3_STATINK_MAP: {
|
|||
DRAGON: "100x",
|
||||
DOUBLE_DRAGON: "333x",
|
||||
},
|
||||
COOP_EVENT_MAP: {
|
||||
1: "rush",
|
||||
2: "goldie_seeking",
|
||||
3: "griller",
|
||||
4: "mothership",
|
||||
5: "fog",
|
||||
6: "cohock_charge",
|
||||
7: "giant_tornado",
|
||||
8: "mudmouth_eruption",
|
||||
},
|
||||
COOP_UNIFORM_MAP: {
|
||||
1: "orange",
|
||||
2: "green",
|
||||
3: "yellow",
|
||||
4: "pink",
|
||||
5: "blue",
|
||||
6: "black",
|
||||
7: "white",
|
||||
},
|
||||
COOP_SPECIAL_MAP: {
|
||||
20006: "nicedama",
|
||||
20007: "hopsonar",
|
||||
20009: "megaphone51",
|
||||
20010: "jetpack",
|
||||
20012: "kanitank",
|
||||
20013: "sameride",
|
||||
20014: "tripletornado",
|
||||
},
|
||||
WATER_LEVEL_MAP: {
|
||||
0: "low",
|
||||
1: "normal",
|
||||
2: "high",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
const SOURCE = `
|
||||
kuma_blaster grizzco_blaster 熊先生印章爆破枪 Bär-Blaster Grizzco Blaster Grizzco Blaster Devastador Don Oso Lanzamotas Don Oso Blaster M. Ours SA Blasteur M. Ours Cie Blaster Ursus Beer & Co-blaster Бластер «Потапыч Inc.» クマサン印のブラスター 熊先生印章爆破槍 Mr. 베어표 블래스터
|
||||
kuma_stringer grizzco_stringer 熊先生印章猎鱼弓 Bär-Stringer Grizzco Stringer Grizzco Stringer Arcromatizador Don Oso Arcromatizador Don Oso Transperceur M. Ours SA Transperceur M. Ours Cie Calamarco Ursus Beer & Co-spanner Тетиватор «Потапыч Inc.» クマサン印のストリンガー 熊先生商會獵魚弓 Mr. 베어표 스트링거
|
||||
`;
|
||||
|
||||
export const KEY_DICT = new Map<string, string>();
|
||||
for (const line of SOURCE.split(/\n/)) {
|
||||
const [key, ...names] = line.split(/\t/);
|
||||
for (let name of names) {
|
||||
name = name.trim();
|
||||
if (KEY_DICT.has(name) && KEY_DICT.get(name) !== key) {
|
||||
console.log(`Conflict: ${name} => ${KEY_DICT.get(name)} and ${key}`);
|
||||
}
|
||||
KEY_DICT.set(name, key);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,102 +1,68 @@
|
|||
import {
|
||||
AGENT_NAME,
|
||||
S3S_NAMESPACE,
|
||||
S3SI_NAMESPACE,
|
||||
S3SI_VERSION,
|
||||
SPLATNET3_STATINK_MAP,
|
||||
USERAGENT,
|
||||
} from "../constant.ts";
|
||||
import {
|
||||
CoopHistoryDetail,
|
||||
CoopHistoryPlayerResult,
|
||||
CoopInfo,
|
||||
Game,
|
||||
GameExporter,
|
||||
PlayerGear,
|
||||
StatInkAbility,
|
||||
StatInkCoopPlayer,
|
||||
StatInkCoopPostBody,
|
||||
StatInkCoopWave,
|
||||
StatInkGear,
|
||||
StatInkGears,
|
||||
StatInkPlayer,
|
||||
StatInkPostBody,
|
||||
StatInkPostResponse,
|
||||
StatInkStage,
|
||||
StatInkWeapon,
|
||||
VsHistoryDetail,
|
||||
VsInfo,
|
||||
VsPlayer,
|
||||
} from "../types.ts";
|
||||
import { base64, msgpack, Mutex } from "../../deps.ts";
|
||||
import { msgpack, Mutex } from "../../deps.ts";
|
||||
import { APIError } from "../APIError.ts";
|
||||
import { cache, gameId } from "../utils.ts";
|
||||
import { b64Number, gameId, s3siGameId } from "../utils.ts";
|
||||
import { Env } from "../env.ts";
|
||||
import { KEY_DICT } from "../dict/stat.ink.ts";
|
||||
|
||||
/**
|
||||
* Decode ID and get number after '-'
|
||||
*/
|
||||
function b64Number(id: string): number {
|
||||
const text = new TextDecoder().decode(base64.decode(id));
|
||||
const [_, num] = text.split("-");
|
||||
return parseInt(num);
|
||||
}
|
||||
class StatInkAPI {
|
||||
FETCH_LOCK = new Mutex();
|
||||
cache: Record<string, unknown> = {};
|
||||
|
||||
const FETCH_LOCK = new Mutex();
|
||||
async function _getAbility(): Promise<StatInkAbility> {
|
||||
const release = await FETCH_LOCK.acquire();
|
||||
try {
|
||||
const resp = await fetch("https://stat.ink/api/v3/ability?full=1");
|
||||
const json = await resp.json();
|
||||
return json;
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
async function _getStage(): Promise<StatInkStage> {
|
||||
const resp = await fetch("https://stat.ink/api/v3/stage");
|
||||
const json = await resp.json();
|
||||
return json;
|
||||
}
|
||||
const getAbility = cache(_getAbility);
|
||||
const getStage = cache(_getStage);
|
||||
|
||||
export type NameDict = {
|
||||
gearPower: Record<string, number | undefined>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Exporter to stat.ink.
|
||||
*
|
||||
* This is the default exporter. It will upload each battle detail to stat.ink.
|
||||
*/
|
||||
export class StatInkExporter implements GameExporter {
|
||||
name = "stat.ink";
|
||||
private statInkApiKey: string;
|
||||
private uploadMode: string;
|
||||
|
||||
constructor(
|
||||
{ statInkApiKey, uploadMode }: {
|
||||
statInkApiKey: string;
|
||||
uploadMode: string;
|
||||
},
|
||||
) {
|
||||
constructor(private statInkApiKey: string, private env: Env) {
|
||||
if (statInkApiKey.length !== 43) {
|
||||
throw new Error("Invalid stat.ink API key");
|
||||
}
|
||||
this.statInkApiKey = statInkApiKey;
|
||||
this.uploadMode = uploadMode;
|
||||
}
|
||||
|
||||
requestHeaders() {
|
||||
return {
|
||||
"User-Agent": USERAGENT,
|
||||
"Authorization": `Bearer ${this.statInkApiKey}`,
|
||||
};
|
||||
}
|
||||
isTriColor({ vsMode }: VsHistoryDetail): boolean {
|
||||
return vsMode.mode === "FEST" && b64Number(vsMode.id) === 8;
|
||||
}
|
||||
async exportGame(game: VsInfo | CoopInfo) {
|
||||
if (game.type === "CoopInfo" || (this.isTriColor(game.detail))) {
|
||||
// TODO: support coop and tri-color fest
|
||||
return {};
|
||||
}
|
||||
const body = await this.mapBattle(game);
|
||||
|
||||
const resp = await fetch("https://stat.ink/api/v3/battle", {
|
||||
method: "POST",
|
||||
async uuidList(type: Game["type"]): Promise<string[]> {
|
||||
const fetch = this.env.newFetcher();
|
||||
return await (await fetch.get({
|
||||
url: type === "VsInfo"
|
||||
? "https://stat.ink/api/v3/s3s/uuid-list"
|
||||
: "https://stat.ink/api/v3/salmon/uuid-list",
|
||||
headers: this.requestHeaders(),
|
||||
})).json();
|
||||
}
|
||||
|
||||
async postBattle(body: StatInkPostBody) {
|
||||
const fetch = this.env.newFetcher();
|
||||
const resp = await fetch.post({
|
||||
url: "https://stat.ink/api/v3/battle",
|
||||
headers: {
|
||||
...this.requestHeaders(),
|
||||
"Content-Type": "application/x-msgpack",
|
||||
|
|
@ -122,20 +88,127 @@ export class StatInkExporter implements GameExporter {
|
|||
});
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
async postCoop(body: StatInkCoopPostBody) {
|
||||
const fetch = this.env.newFetcher();
|
||||
const resp = await fetch.post({
|
||||
url: "https://stat.ink/api/v3/salmon",
|
||||
headers: {
|
||||
...this.requestHeaders(),
|
||||
"Content-Type": "application/x-msgpack",
|
||||
},
|
||||
body: msgpack.encode(body),
|
||||
});
|
||||
|
||||
const json: StatInkPostResponse = await resp.json().catch(() => ({}));
|
||||
|
||||
if (resp.status !== 200 && resp.status !== 201) {
|
||||
throw new APIError({
|
||||
response: resp,
|
||||
message: "Failed to export battle",
|
||||
json,
|
||||
});
|
||||
}
|
||||
|
||||
if (json.error) {
|
||||
throw new APIError({
|
||||
response: resp,
|
||||
message: "Failed to export battle",
|
||||
json,
|
||||
});
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
async _getCached<T>(url: string): Promise<T> {
|
||||
const release = await this.FETCH_LOCK.acquire();
|
||||
try {
|
||||
if (this.cache[url]) {
|
||||
return this.cache[url] as T;
|
||||
}
|
||||
const fetch = this.env.newFetcher();
|
||||
const resp = await fetch.get({
|
||||
url,
|
||||
headers: this.requestHeaders(),
|
||||
});
|
||||
const json = await resp.json();
|
||||
this.cache[url] = json;
|
||||
return json;
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
getWeapon = () =>
|
||||
this._getCached<StatInkWeapon>("https://stat.ink/api/v3/weapon?full=1");
|
||||
getAbility = () =>
|
||||
this._getCached<StatInkAbility>("https://stat.ink/api/v3/ability?full=1");
|
||||
getStage = () =>
|
||||
this._getCached<StatInkStage>("https://stat.ink/api/v3/stage");
|
||||
}
|
||||
|
||||
export type NameDict = {
|
||||
gearPower: Record<string, number | undefined>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Exporter to stat.ink.
|
||||
*
|
||||
* This is the default exporter. It will upload each battle detail to stat.ink.
|
||||
*/
|
||||
export class StatInkExporter implements GameExporter {
|
||||
name = "stat.ink";
|
||||
private api: StatInkAPI;
|
||||
private uploadMode: string;
|
||||
|
||||
constructor(
|
||||
{ statInkApiKey, uploadMode, env }: {
|
||||
statInkApiKey: string;
|
||||
uploadMode: string;
|
||||
env: Env;
|
||||
},
|
||||
) {
|
||||
this.api = new StatInkAPI(statInkApiKey, env);
|
||||
this.uploadMode = uploadMode;
|
||||
}
|
||||
isTriColor({ vsMode }: VsHistoryDetail): boolean {
|
||||
return vsMode.mode === "FEST" && b64Number(vsMode.id) === 8;
|
||||
}
|
||||
async exportGame(game: VsInfo | CoopInfo) {
|
||||
if (game.type === "VsInfo" && this.isTriColor(game.detail)) {
|
||||
// TODO: support tri-color fest
|
||||
return {};
|
||||
}
|
||||
|
||||
if (game.type === "VsInfo") {
|
||||
const body = await this.mapBattle(game);
|
||||
const { url } = await this.api.postBattle(body);
|
||||
|
||||
return {
|
||||
url: json.url,
|
||||
url,
|
||||
};
|
||||
} else {
|
||||
const body = await this.mapCoop(game);
|
||||
const { url } = await this.api.postCoop(body);
|
||||
|
||||
return {
|
||||
url,
|
||||
};
|
||||
}
|
||||
async notExported({ list }: { list: string[] }): Promise<string[]> {
|
||||
const uuid = await (await fetch("https://stat.ink/api/v3/s3s/uuid-list", {
|
||||
headers: this.requestHeaders(),
|
||||
})).json();
|
||||
}
|
||||
async notExported(
|
||||
{ type, list }: { list: string[]; type: Game["type"] },
|
||||
): Promise<string[]> {
|
||||
const uuid = await this.api.uuidList(type);
|
||||
|
||||
const out: string[] = [];
|
||||
|
||||
for (const id of list) {
|
||||
const s3sId = await gameId(id, S3S_NAMESPACE);
|
||||
const s3siId = await gameId(id, S3SI_NAMESPACE);
|
||||
const s3sId = await gameId(id);
|
||||
const s3siId = await s3siGameId(id);
|
||||
|
||||
if (!uuid.includes(s3sId) && !uuid.includes(s3siId)) {
|
||||
out.push(id);
|
||||
|
|
@ -176,7 +249,7 @@ export class StatInkExporter implements GameExporter {
|
|||
}
|
||||
async mapStage({ vsStage }: VsHistoryDetail): Promise<string> {
|
||||
const id = b64Number(vsStage.id).toString();
|
||||
const stage = await getStage();
|
||||
const stage = await this.api.getStage();
|
||||
|
||||
const result = stage.find((s) => s.aliases.includes(id));
|
||||
|
||||
|
|
@ -189,7 +262,7 @@ export class StatInkExporter implements GameExporter {
|
|||
async mapGears(
|
||||
{ headGear, clothingGear, shoesGear }: VsPlayer,
|
||||
): Promise<StatInkGears> {
|
||||
const amap = (await getAbility()).map((i) => ({
|
||||
const amap = (await this.api.getAbility()).map((i) => ({
|
||||
...i,
|
||||
names: Object.values(i.name),
|
||||
}));
|
||||
|
|
@ -376,6 +449,150 @@ export class StatInkExporter implements GameExporter {
|
|||
|
||||
return result;
|
||||
}
|
||||
async mapCoopWeapon({ name }: { name: string }): Promise<string> {
|
||||
const weaponMap = await this.api.getWeapon();
|
||||
const weapon =
|
||||
weaponMap.find((i) => Object.values(i.name).includes(name))?.key ??
|
||||
KEY_DICT.get(name);
|
||||
|
||||
if (!weapon) {
|
||||
throw new Error(`Weapon not found: ${name}`);
|
||||
}
|
||||
|
||||
return weapon;
|
||||
}
|
||||
async mapCoopPlayer({
|
||||
player,
|
||||
weapons,
|
||||
specialWeapon,
|
||||
defeatEnemyCount,
|
||||
deliverCount,
|
||||
goldenAssistCount,
|
||||
goldenDeliverCount,
|
||||
rescueCount,
|
||||
rescuedCount,
|
||||
}: CoopHistoryPlayerResult): Promise<StatInkCoopPlayer> {
|
||||
return {
|
||||
me: player.isMyself ? "yes" : "no",
|
||||
name: player.name,
|
||||
number: player.nameId,
|
||||
splashtag_title: player.byname,
|
||||
uniform:
|
||||
SPLATNET3_STATINK_MAP.COOP_UNIFORM_MAP[b64Number(player.uniform.id)],
|
||||
special:
|
||||
SPLATNET3_STATINK_MAP.COOP_SPECIAL_MAP[b64Number(specialWeapon.id)],
|
||||
weapons: await Promise.all(weapons.map((w) => this.mapCoopWeapon(w))),
|
||||
golden_eggs: goldenDeliverCount,
|
||||
golden_assist: goldenAssistCount,
|
||||
power_eggs: deliverCount,
|
||||
rescue: rescueCount,
|
||||
rescued: rescuedCount,
|
||||
defeat_boss: defeatEnemyCount,
|
||||
disconnected: "no",
|
||||
};
|
||||
}
|
||||
mapKing(id?: string) {
|
||||
if (!id) {
|
||||
return undefined;
|
||||
}
|
||||
const nid = b64Number(id).toString();
|
||||
|
||||
return nid;
|
||||
}
|
||||
mapWave(wave: CoopHistoryDetail["waveResults"]["0"]): StatInkCoopWave {
|
||||
const event = wave.eventWave
|
||||
? SPLATNET3_STATINK_MAP.COOP_EVENT_MAP[b64Number(wave.eventWave.id)]
|
||||
: undefined;
|
||||
const special_uses = wave.specialWeapons.reduce((p, { id }) => {
|
||||
const key = SPLATNET3_STATINK_MAP.COOP_SPECIAL_MAP[b64Number(id)];
|
||||
|
||||
return {
|
||||
...p,
|
||||
[key]: (p[key] ?? 0) + 1,
|
||||
};
|
||||
}, {} as Record<string, number | undefined>) as Record<string, number>;
|
||||
|
||||
return {
|
||||
tide: SPLATNET3_STATINK_MAP.WATER_LEVEL_MAP[wave.waterLevel],
|
||||
event,
|
||||
golden_quota: wave.deliverNorm,
|
||||
golden_appearances: wave.goldenPopCount,
|
||||
golden_delivered: wave.teamDeliverCount,
|
||||
special_uses,
|
||||
};
|
||||
}
|
||||
async mapCoop(
|
||||
{
|
||||
groupInfo,
|
||||
detail,
|
||||
}: CoopInfo,
|
||||
): Promise<StatInkCoopPostBody> {
|
||||
const {
|
||||
dangerRate,
|
||||
resultWave,
|
||||
bossResult,
|
||||
myResult,
|
||||
memberResults,
|
||||
scale,
|
||||
playedTime,
|
||||
enemyResults,
|
||||
} = detail;
|
||||
|
||||
const startedAt = Math.floor(new Date(playedTime).getTime() / 1000);
|
||||
const golden_eggs = myResult.goldenDeliverCount +
|
||||
memberResults.reduce((acc, i) => acc + i.goldenDeliverCount, 0);
|
||||
const power_eggs = myResult.deliverCount +
|
||||
memberResults.reduce((p, i) => p + i.deliverCount, 0);
|
||||
const bosses = Object.fromEntries(
|
||||
enemyResults.map((
|
||||
i,
|
||||
) => [b64Number(i.enemy.id), {
|
||||
appearances: i.popCount,
|
||||
defeated: i.teamDefeatCount,
|
||||
defeated_by_me: i.defeatCount,
|
||||
}]),
|
||||
);
|
||||
|
||||
const result: StatInkCoopPostBody = {
|
||||
uuid: await gameId(detail.id),
|
||||
private: groupInfo?.mode === "PRIVATE_CUSTOM" ? "yes" : "no",
|
||||
big_run: "no",
|
||||
stage: b64Number(detail.coopStage.id).toString(),
|
||||
danger_rate: dangerRate * 100,
|
||||
clear_waves: detail.waveResults.filter((i) => i.waveNumber < 4).length -
|
||||
1 + (resultWave === 0 ? 1 : 0),
|
||||
fail_reason: null,
|
||||
king_salmonid: this.mapKing(detail.bossResult?.boss.id),
|
||||
clear_extra: bossResult?.hasDefeatBoss ? "yes" : "no",
|
||||
title_after: detail.afterGrade
|
||||
? b64Number(detail.afterGrade.id).toString()
|
||||
: undefined,
|
||||
title_exp_after: detail.afterGradePoint,
|
||||
golden_eggs,
|
||||
power_eggs,
|
||||
gold_scale: scale?.gold,
|
||||
silver_scale: scale?.silver,
|
||||
bronze_scale: scale?.bronze,
|
||||
job_point: detail.jobPoint,
|
||||
job_score: detail.jobScore,
|
||||
job_rate: detail.jobRate,
|
||||
job_bonus: detail.jobBonus,
|
||||
waves: detail.waveResults.map((w) => this.mapWave(w)),
|
||||
players: await Promise.all([
|
||||
this.mapCoopPlayer(myResult),
|
||||
...memberResults.map((p) => this.mapCoopPlayer(p)),
|
||||
]),
|
||||
bosses,
|
||||
agent: AGENT_NAME,
|
||||
agent_version: S3SI_VERSION,
|
||||
agent_variables: {
|
||||
"Upload Mode": this.uploadMode,
|
||||
},
|
||||
automated: "yes",
|
||||
start_at: startedAt,
|
||||
};
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function parseUdemae(udemae: string): [string, number | undefined] {
|
||||
|
|
|
|||
|
|
@ -363,11 +363,11 @@ async function callImink(
|
|||
url: fApi,
|
||||
headers: {
|
||||
"User-Agent": USERAGENT,
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
"token": idToken,
|
||||
"hashMethod": step,
|
||||
"hash_method": step,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { APIError } from "./APIError.ts";
|
|||
import {
|
||||
BattleListType,
|
||||
GraphQLResponse,
|
||||
HistoryGroups,
|
||||
Queries,
|
||||
RespMap,
|
||||
VarsMap,
|
||||
|
|
@ -227,7 +226,15 @@ export class Splatnet3 {
|
|||
}
|
||||
|
||||
function getIdsFromGroups<T extends { id: string }>(
|
||||
{ historyGroups }: { historyGroups: HistoryGroups<T> },
|
||||
{ historyGroups }: {
|
||||
historyGroups: {
|
||||
nodes: {
|
||||
historyDetails: {
|
||||
nodes: T[];
|
||||
};
|
||||
}[];
|
||||
};
|
||||
},
|
||||
) {
|
||||
return historyGroups.nodes.flatMap((i) => i.historyDetails.nodes).map((i) =>
|
||||
i.id
|
||||
|
|
|
|||
186
src/types.ts
186
src/types.ts
|
|
@ -66,6 +66,26 @@ export type HistoryGroups<T> = {
|
|||
};
|
||||
}[];
|
||||
};
|
||||
export type CoopHistoryGroup = {
|
||||
startTime: null | string;
|
||||
endTime: null | string;
|
||||
highestResult: null | {
|
||||
grade: {
|
||||
id: string;
|
||||
};
|
||||
gradePoint: number;
|
||||
jobScore: number;
|
||||
};
|
||||
mode: "PRIVATE_CUSTOM" | "REGULAR";
|
||||
rule: "REGULAR";
|
||||
|
||||
historyDetails: {
|
||||
nodes: CoopListNode[];
|
||||
};
|
||||
};
|
||||
export type CoopHistoryGroups = {
|
||||
nodes: CoopHistoryGroup[];
|
||||
};
|
||||
export type PlayerGear = {
|
||||
name: string;
|
||||
primaryGearPower: {
|
||||
|
|
@ -138,6 +158,7 @@ export type VsInfo = {
|
|||
export type CoopInfo = {
|
||||
type: "CoopInfo";
|
||||
listNode: null | CoopListNode;
|
||||
groupInfo: null | Omit<CoopHistoryGroup, "historyDetails">;
|
||||
detail: CoopHistoryDetail;
|
||||
};
|
||||
export type Game = VsInfo | CoopInfo;
|
||||
|
|
@ -176,8 +197,91 @@ export type VsHistoryDetail = {
|
|||
awards: { name: string; rank: string }[];
|
||||
duration: number;
|
||||
};
|
||||
|
||||
export type CoopHistoryPlayerResult = {
|
||||
player: {
|
||||
byname: string | null;
|
||||
name: string;
|
||||
nameId: string;
|
||||
uniform: {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
isMyself: boolean;
|
||||
};
|
||||
weapons: { name: string }[];
|
||||
specialWeapon: {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
defeatEnemyCount: number;
|
||||
deliverCount: number;
|
||||
goldenAssistCount: number;
|
||||
goldenDeliverCount: number;
|
||||
rescueCount: number;
|
||||
rescuedCount: number;
|
||||
};
|
||||
|
||||
export type CoopHistoryDetail = {
|
||||
id: string;
|
||||
afterGrade: null | {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
rule: "REGULAR";
|
||||
myResult: CoopHistoryPlayerResult;
|
||||
memberResults: CoopHistoryPlayerResult[];
|
||||
bossResult: null | {
|
||||
hasDefeatBoss: boolean;
|
||||
boss: {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
enemyResults: {
|
||||
defeatCount: number;
|
||||
teamDefeatCount: number;
|
||||
popCount: number;
|
||||
enemy: {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
}[];
|
||||
waveResults: {
|
||||
waveNumber: number;
|
||||
waterLevel: 0 | 1 | 2;
|
||||
eventWave: null | {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
deliverNorm: number;
|
||||
goldenPopCount: number;
|
||||
teamDeliverCount: number;
|
||||
specialWeapons: {
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
}[];
|
||||
resultWave: number;
|
||||
playedTime: string;
|
||||
coopStage: {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
dangerRate: number;
|
||||
scenarioCode: null;
|
||||
smellMeter: null | number;
|
||||
weapons: { name: string }[];
|
||||
afterGradePoint: null | number;
|
||||
scale: null | {
|
||||
gold: number;
|
||||
silver: number;
|
||||
bronze: number;
|
||||
};
|
||||
jobPoint: null | number;
|
||||
jobScore: null | number;
|
||||
jobRate: null | number;
|
||||
jobBonus: null | number;
|
||||
};
|
||||
|
||||
export type GameExporter<
|
||||
|
|
@ -239,7 +343,7 @@ export type RespMap = {
|
|||
};
|
||||
[Queries.CoopHistoryQuery]: {
|
||||
coopResult: {
|
||||
historyGroups: HistoryGroups<CoopListNode>;
|
||||
historyGroups: CoopHistoryGroups;
|
||||
};
|
||||
};
|
||||
[Queries.CoopHistoryDetailQuery]: {
|
||||
|
|
@ -281,6 +385,11 @@ export type StatInkAbility = {
|
|||
primary_only: boolean;
|
||||
}[];
|
||||
|
||||
export type StatInkWeapon = {
|
||||
key: string;
|
||||
name: Record<string, string>;
|
||||
}[];
|
||||
|
||||
export type StatInkGear = {
|
||||
primary_ability: string;
|
||||
secondary_abilities: (string | null)[];
|
||||
|
|
@ -321,6 +430,81 @@ export type StatInkStage = {
|
|||
};
|
||||
}[];
|
||||
|
||||
export type StatInkCoopWave = {
|
||||
tide: "low" | "normal" | "high";
|
||||
// https://stat.ink/api-info/salmon-event3
|
||||
event?: string;
|
||||
golden_quota: number;
|
||||
golden_delivered: number;
|
||||
golden_appearances: number;
|
||||
special_uses?: Record<string, number>;
|
||||
};
|
||||
|
||||
export type StatInkCoopPlayer = {
|
||||
me: "yes" | "no";
|
||||
name: string;
|
||||
number: string;
|
||||
splashtag_title: string | null;
|
||||
uniform?: "orange" | "green" | "yellow" | "pink" | "blue" | "black" | "white";
|
||||
special: string;
|
||||
weapons: string[];
|
||||
golden_eggs: number;
|
||||
golden_assist: number;
|
||||
power_eggs: number;
|
||||
rescue: number;
|
||||
rescued: number;
|
||||
defeat_boss: number;
|
||||
disconnected: "yes" | "no";
|
||||
};
|
||||
|
||||
export type StatInkCoopBoss = {
|
||||
appearances: number;
|
||||
defeated: number;
|
||||
defeated_by_me: number;
|
||||
};
|
||||
|
||||
export type StatInkCoopPostBody = {
|
||||
test?: "yes" | "no";
|
||||
uuid: string;
|
||||
private: "yes" | "no";
|
||||
big_run: "no";
|
||||
stage: string;
|
||||
// [0, 333]
|
||||
danger_rate: number;
|
||||
// [0, 3]
|
||||
clear_waves: number;
|
||||
fail_reason?: null | "wipe_out" | "time_limit";
|
||||
king_salmonid?: string;
|
||||
clear_extra: "yes" | "no";
|
||||
title_before?: string;
|
||||
// [0, 999]
|
||||
title_exp_before?: number;
|
||||
title_after?: string;
|
||||
// [0, 999]
|
||||
title_exp_after: null | number;
|
||||
golden_eggs: number;
|
||||
power_eggs: number;
|
||||
gold_scale?: null | number;
|
||||
silver_scale?: null | number;
|
||||
bronze_scale?: null | number;
|
||||
job_point: null | number;
|
||||
job_score: null | number;
|
||||
job_rate: null | number;
|
||||
job_bonus: null | number;
|
||||
waves: StatInkCoopWave[];
|
||||
players: StatInkCoopPlayer[];
|
||||
bosses: Record<string, StatInkCoopBoss>;
|
||||
note?: string;
|
||||
private_note?: string;
|
||||
link_url?: string;
|
||||
agent: string;
|
||||
agent_version: string;
|
||||
agent_variables: Record<string, string>;
|
||||
automated: "yes";
|
||||
start_at: number;
|
||||
end_at?: number;
|
||||
};
|
||||
|
||||
export type StatInkPostBody = {
|
||||
test?: "yes" | "no";
|
||||
uuid: string;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import { base64 } from "../deps.ts";
|
||||
import { assertEquals } from "../dev_deps.ts";
|
||||
import { gameId } from "./utils.ts";
|
||||
|
||||
Deno.test("gameId", async () => {
|
||||
assertEquals(
|
||||
await gameId(
|
||||
base64.encode(
|
||||
`VsHistoryDetail-asdf:asdf:20220101T012345_12345678-abcd-1234-5678-0123456789ab`,
|
||||
),
|
||||
),
|
||||
"042bcac9-6b25-5d2e-a5ea-800939a6dea1",
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
await gameId(
|
||||
base64.encode(
|
||||
`"CoopHistoryDetail-u-asdf:20220101T012345_12345678-abcd-1234-5678-0123456789ab`,
|
||||
),
|
||||
),
|
||||
"175af427-e83b-5bac-b02c-9539cc1fd684",
|
||||
);
|
||||
});
|
||||
37
src/utils.ts
37
src/utils.ts
|
|
@ -1,5 +1,9 @@
|
|||
import { APIError } from "./APIError.ts";
|
||||
import { S3S_NAMESPACE } from "./constant.ts";
|
||||
import {
|
||||
BATTLE_NAMESPACE,
|
||||
COOP_NAMESPACE,
|
||||
S3SI_NAMESPACE,
|
||||
} from "./constant.ts";
|
||||
import { base64, uuid } from "../deps.ts";
|
||||
import { Env } from "./env.ts";
|
||||
import { io } from "../deps.ts";
|
||||
|
|
@ -87,16 +91,28 @@ export async function showError<T>(env: Env, p: Promise<T>): Promise<T> {
|
|||
|
||||
/**
|
||||
* @param id id of VsHistoryDetail or CoopHistoryDetail
|
||||
* @param namespace uuid namespace
|
||||
* @returns
|
||||
*/
|
||||
export function gameId(
|
||||
id: string,
|
||||
namespace = S3S_NAMESPACE,
|
||||
): Promise<string> {
|
||||
const parsed = parseHistoryDetailId(id);
|
||||
if (parsed.type === "VsHistoryDetail") {
|
||||
const content = new TextEncoder().encode(
|
||||
`${parsed.timestamp}_${parsed.uuid}`,
|
||||
);
|
||||
return uuid.v5.generate(BATTLE_NAMESPACE, content);
|
||||
} else if (parsed.type === "CoopHistoryDetail") {
|
||||
return uuid.v5.generate(COOP_NAMESPACE, base64.decode(id));
|
||||
} else {
|
||||
throw new Error("Unknown type");
|
||||
}
|
||||
}
|
||||
|
||||
export function s3siGameId(id: string) {
|
||||
const fullId = base64.decode(id);
|
||||
const tsUuid = fullId.slice(fullId.length - 52, fullId.length);
|
||||
return uuid.v5.generate(namespace, tsUuid);
|
||||
return uuid.v5.generate(S3SI_NAMESPACE, tsUuid);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -117,7 +133,7 @@ export function parseHistoryDetailId(id: string) {
|
|||
listType,
|
||||
timestamp,
|
||||
uuid,
|
||||
};
|
||||
} as const;
|
||||
} else if (coopRE.test(plainText)) {
|
||||
const [, uid, timestamp, uuid] = plainText.match(coopRE)!;
|
||||
|
||||
|
|
@ -126,7 +142,7 @@ export function parseHistoryDetailId(id: string) {
|
|||
uid,
|
||||
timestamp,
|
||||
uuid,
|
||||
};
|
||||
} as const;
|
||||
} else {
|
||||
throw new Error(`Invalid ID: ${plainText}`);
|
||||
}
|
||||
|
|
@ -134,3 +150,12 @@ export function parseHistoryDetailId(id: string) {
|
|||
|
||||
export const delay = (ms: number) =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Decode ID and get number after '-'
|
||||
*/
|
||||
export function b64Number(id: string): number {
|
||||
const text = new TextDecoder().decode(base64.decode(id));
|
||||
const [_, num] = text.split("-");
|
||||
return parseInt(num);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue