From ff538c1f2ae51386dd876c201dac118e8323a9f1 Mon Sep 17 00:00:00 2001 From: imspace Date: Fri, 25 Nov 2022 19:07:39 +0800 Subject: [PATCH] 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 --- CHANGELOG.md | 6 + scripts/delete-coop.ts | 20 +++ scripts/find-coop-map.ts | 62 +++++++ src/GameFetcher.ts | 9 +- src/app.ts | 7 +- src/constant.ts | 54 +++++- src/dict/stat.ink.ts | 16 ++ src/exporters/stat.ink.ts | 367 ++++++++++++++++++++++++++++++-------- src/iksm.ts | 4 +- src/splatnet3.ts | 11 +- src/types.ts | 186 ++++++++++++++++++- src/utils.test.ts | 23 +++ src/utils.ts | 37 +++- 13 files changed, 706 insertions(+), 96 deletions(-) create mode 100644 scripts/delete-coop.ts create mode 100644 scripts/find-coop-map.ts create mode 100644 src/dict/stat.ink.ts create mode 100644 src/utils.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cc67215..044d3e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/scripts/delete-coop.ts b/scripts/delete-coop.ts new file mode 100644 index 0000000..f8ca4b6 --- /dev/null +++ b/scripts/delete-coop.ts @@ -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 "); + 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); +} diff --git a/scripts/find-coop-map.ts b/scripts/find-coop-map.ts new file mode 100644 index 0000000..bc84fa2 --- /dev/null +++ b/scripts/find-coop-map.ts @@ -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(); +const uniforms = new Map(); +const specials = new Map(); +const bosses = new Map(); + +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])); diff --git a/src/GameFetcher.ts b/src/GameFetcher.ts index 363a914..0684d37 100644 --- a/src/GameFetcher.ts +++ b/src/GameFetcher.ts @@ -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["nodes"]; coopLock = new Mutex(); - coopHistory?: HistoryGroups["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> { diff --git a/src/app.ts b/src/app.ts index d5d9f73..6c11a84 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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({ diff --git a/src/constant.ts b/src/constant.ts index 4400bda..fb9acc0 100644 --- a/src/constant.ts +++ b/src/constant.ts @@ -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["dragonMatchType"], StatInkPostBody["fest_dragon"] >; + COOP_EVENT_MAP: Record; + COOP_UNIFORM_MAP: Record< + number, + | "orange" + | "green" + | "yellow" + | "pink" + | "blue" + | "black" + | "white" + | undefined + >; + COOP_SPECIAL_MAP: Record; + 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", + }, }; diff --git a/src/dict/stat.ink.ts b/src/dict/stat.ink.ts new file mode 100644 index 0000000..93d0781 --- /dev/null +++ b/src/dict/stat.ink.ts @@ -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(); +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); + } +} diff --git a/src/exporters/stat.ink.ts b/src/exporters/stat.ink.ts index dced098..ac9e7ad 100644 --- a/src/exporters/stat.ink.ts +++ b/src/exporters/stat.ink.ts @@ -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 = {}; -const FETCH_LOCK = new Mutex(); -async function _getAbility(): Promise { - 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 { - 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; -}; - -/** - * 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 { + 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 { - 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(url: string): Promise { + 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("https://stat.ink/api/v3/weapon?full=1"); + getAbility = () => + this._getCached("https://stat.ink/api/v3/ability?full=1"); + getStage = () => + this._getCached("https://stat.ink/api/v3/stage"); +} + +export type NameDict = { + gearPower: Record; +}; + +/** + * 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 { + 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 { 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 { - 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 { + 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 { + 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) as Record; + + 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 { + 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] { diff --git a/src/iksm.ts b/src/iksm.ts index 27d2793..fc9d474 100644 --- a/src/iksm.ts +++ b/src/iksm.ts @@ -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, }), }); diff --git a/src/splatnet3.ts b/src/splatnet3.ts index 78ac226..9f1df2f 100644 --- a/src/splatnet3.ts +++ b/src/splatnet3.ts @@ -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( - { historyGroups }: { historyGroups: HistoryGroups }, + { historyGroups }: { + historyGroups: { + nodes: { + historyDetails: { + nodes: T[]; + }; + }[]; + }; + }, ) { return historyGroups.nodes.flatMap((i) => i.historyDetails.nodes).map((i) => i.id diff --git a/src/types.ts b/src/types.ts index 0c0339b..7838a94 100644 --- a/src/types.ts +++ b/src/types.ts @@ -66,6 +66,26 @@ export type HistoryGroups = { }; }[]; }; +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; 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; + historyGroups: CoopHistoryGroups; }; }; [Queries.CoopHistoryDetailQuery]: { @@ -281,6 +385,11 @@ export type StatInkAbility = { primary_only: boolean; }[]; +export type StatInkWeapon = { + key: string; + name: Record; +}[]; + 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; +}; + +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; + note?: string; + private_note?: string; + link_url?: string; + agent: string; + agent_version: string; + agent_variables: Record; + automated: "yes"; + start_at: number; + end_at?: number; +}; + export type StatInkPostBody = { test?: "yes" | "no"; uuid: string; diff --git a/src/utils.test.ts b/src/utils.test.ts new file mode 100644 index 0000000..6602390 --- /dev/null +++ b/src/utils.test.ts @@ -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", + ); +}); diff --git a/src/utils.ts b/src/utils.ts index b984722..bbb5e52 100644 --- a/src/utils.ts +++ b/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(env: Env, p: Promise): Promise { /** * @param id id of VsHistoryDetail or CoopHistoryDetail - * @param namespace uuid namespace * @returns */ export function gameId( id: string, - namespace = S3S_NAMESPACE, ): Promise { + 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((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); +}