diff --git a/package-lock.json b/package-lock.json index 70c6514..62c5ede 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "dotenv-expand": "^11.0.6", "env-paths": "^3.0.0", "express": "^4.19.2", + "murmurhash": "^2.0.1", "node-notifier": "^10.0.1", "node-persist": "^3.1.3", "read": "^3.0.1", @@ -7522,6 +7523,12 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/murmurhash": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/murmurhash/-/murmurhash-2.0.1.tgz", + "integrity": "sha512-5vQEh3y+DG/lMPM0mCGPDnyV8chYg/g7rl6v3Gd8WMF9S429ox3Xk8qrk174kWhG767KQMqqxLD1WnGd77hiew==", + "license": "MIT" + }, "node_modules/mute-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", diff --git a/package.json b/package.json index 916a64d..e3cdbba 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "dotenv-expand": "^11.0.6", "env-paths": "^3.0.0", "express": "^4.19.2", + "murmurhash": "^2.0.1", "node-notifier": "^10.0.1", "node-persist": "^3.1.3", "read": "^3.0.1", diff --git a/src/cli/util/commands.ts b/src/cli/util/commands.ts index 08d378e..bd400b7 100644 --- a/src/cli/util/commands.ts +++ b/src/cli/util/commands.ts @@ -10,6 +10,7 @@ export * as presenceEmbedServer from './presence-embed-server.js'; export * as logArchive from './log-archive.js'; export * as decryptLogArchive from './decrypt-log-archive.js'; export * as status from './status.js'; +export * as splatoon3Seed from './splatoon3-seed.js'; export * as updateS2sToken from './update-s2s-token.js'; export * as updateS3sToken from './update-s3s-token.js'; export * as updateS3siToken from './update-s3si-token.js'; diff --git a/src/cli/util/splatoon3-seed.ts b/src/cli/util/splatoon3-seed.ts new file mode 100644 index 0000000..f856c95 --- /dev/null +++ b/src/cli/util/splatoon3-seed.ts @@ -0,0 +1,155 @@ +import createDebug from 'debug'; +import murmurhash from 'murmurhash'; +import { ResultTypes } from 'splatnet3-types/splatnet3'; +import type { Arguments as ParentArguments } from './index.js'; +import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js'; +import { initStorage } from '../../util/storage.js'; +import { getBulletToken } from '../../common/auth/splatnet3.js'; +import SplatNet3Api from '../../api/splatnet3.js'; + +const debug = createDebug('cli:util:splatoon3-seed'); + +export const command = 'splatoon3-seed [id]'; +export const desc = 'Fetches data for https://leanny.github.io/splat3seedchecker/'; + +export function builder(yargs: Argv) { + return yargs.positional('id', { + describe: 'Replay code', + type: 'string', + }).option('znc-proxy-url', { + describe: 'URL of Nintendo Switch Online app API proxy server to use', + type: 'string', + default: process.env.ZNC_PROXY_URL, + }).option('auto-update-session', { + describe: 'Automatically obtain and refresh the SplatNet 3 access token', + type: 'boolean', + default: true, + }).option('user', { + describe: 'Nintendo Account ID', + type: 'string', + }).option('token', { + describe: 'Nintendo Account session token', + type: 'string', + }).option('include-gear', { + describe: 'Fetch all gear from SplatNet 3', + type: 'boolean', + }).option('json', { + describe: 'Output raw JSON', + type: 'boolean', + }).option('json-pretty-print', { + describe: 'Output pretty-printed JSON', + type: 'boolean', + }); +} + +type Arguments = YargsArguments>; + +export async function handler(argv: ArgumentsCamelCase) { + const storage = await initStorage(argv.dataPath); + + const usernsid = argv.user ?? await storage.getItem('SelectedUser'); + const token: string = argv.token || await storage.getItem('NintendoAccountToken.' + usernsid); + const {splatnet, data} = await getBulletToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession); + + splatnet.getCurrentFest(); + splatnet.getConfigureAnalytics(); + + const npln_user_id = await getNplnUserId(splatnet, argv.id); + debug('NPLN user ID', npln_user_id); + + const hash = murmurhash.v3(npln_user_id); + debug('hash', hash); + + const key = Buffer.from(npln_user_id); + for (let i = 0; i < key.length; i++) { + key[i] ^= hash & 0xff; + } + + const key_str = key.toString('base64'); + debug('key', key_str); + + if (argv.json || argv.jsonPrettyPrint) { + let equipment; + if (argv.includeGear ?? true) { + debug('Fetching equipment'); + equipment = await splatnet.getEquipment(); + } + + console.log(JSON.stringify({ + key: key_str, + h: hash, + timestamp: Math.floor(Date.now() / 1000), + gear: equipment, + }, null, argv.jsonPrettyPrint ? 4 : 0)); + return; + } + + console.log('Key %s', key.toString('hex')); + console.log('Hash %d', hash); +} + +const REPLAY_CODE_REGEX = /^[A-Z0-9]{16}$/; + +function getNplnUserId(splatnet: SplatNet3Api, id?: string) { + if (id) { + if (id.replace(/-/g, '').match(REPLAY_CODE_REGEX)) { + return getNplnUserIdFromReplayCode(splatnet, id.replace(/-/g, '')); + } + + throw new Error('Invalid argument "' + id + '"'); + } + + return getNplnUserIdSelf(splatnet); +} + +async function getNplnUserIdSelf(splatnet: SplatNet3Api) { + debug('Fetching outfits'); + + const outfits = await splatnet.getMyOutfits(); + + if (outfits.data.myOutfits.edges.length) { + const outfit = await splatnet.getMyOutfitDetail(outfits.data.myOutfits.edges[0].node.id); + } + + const outfit_id = outfits.data.myOutfits.edges[0]?.node.id; + + if (outfit_id) { + const id_str = Buffer.from(outfit_id, 'base64').toString(); + const match = id_str.match(/^MyOutfit-(u-[0-9a-z]{20}):(\d+)$/); + if (match) return match[1]; + } + + debug('Failed to get NPLN user ID from outfits, fetching battle histories'); + + const [player, battles, battles_regular, battles_anarchy, battles_private] = await Promise.all([ + splatnet.getBattleHistoryCurrentPlayer(), + splatnet.getLatestBattleHistories(), + splatnet.getRegularBattleHistories(), + splatnet.getBankaraBattleHistories(), + splatnet.getPrivateBattleHistories(), + ]); + + const latest_id = battles.data.latestBattleHistories.historyGroupsOnlyFirst.nodes[0]?.historyDetails.nodes[0].id; + + if (latest_id) { + const id_str = Buffer.from(latest_id, 'base64').toString(); + const match = id_str.match(/^VsHistoryDetail-(u-[0-9a-z]{20}):([A-Z]+):((\d{8,}T\d{6})_([0-9a-f-]{36}))$/); + if (match) return match[1]; + } + + throw new Error('Failed to get NPLN user ID, try creating an outfit or play an online battle'); +} + +async function getNplnUserIdFromReplayCode(splatnet: SplatNet3Api, code: string) { + debug('Fetching replay'); + + const replays = await splatnet.getReplays(); + const replay = await splatnet.getReplaySearchResult(code); + + const id_str = Buffer.from(replay.data.replay.id, 'base64').toString(); + debug('replay', id_str); + + const match = id_str.match(/^Replay-(u-[0-9a-z]{20}):([0-9A-Z]{16})$/); + if (!match) throw new Error('Unable to find NPLN user ID from replay ID'); + return match[1]; +}