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_waves
main
imspace 2022-11-25 19:07:39 +08:00 committed by GitHub
parent bd57f2cf6c
commit ff538c1f2a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 706 additions and 96 deletions

View File

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

20
scripts/delete-coop.ts Normal file
View File

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

62
scripts/find-coop-map.ts Normal file
View File

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

View File

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

View File

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

View File

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

16
src/dict/stat.ink.ts Normal file
View File

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

View File

@ -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 {
url: json.url,
};
return json;
}
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 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,
};
} else {
const body = await this.mapCoop(game);
const { url } = await this.api.postCoop(body);
return {
url,
};
}
}
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] {

View File

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

View File

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

View File

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

23
src/utils.test.ts Normal file
View File

@ -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",
);
});

View File

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