diff --git a/src/cli/splatnet2.ts b/src/cli/splatnet2.ts index 2a3e68e..d23ae5d 100644 --- a/src/cli/splatnet2.ts +++ b/src/cli/splatnet2.ts @@ -17,7 +17,8 @@ export function builder(yargs: Argv) { return yargs.option('znc-proxy-url', { describe: 'URL of Nintendo Switch Online app API proxy server to use', type: 'string', - }).option('auto-update-iksm-session', { + }).option('auto-update-session', { + alias: ['auto-update-iksm-session'], describe: 'Automatically obtain and refresh the iksm_session cookie', type: 'boolean', default: true, diff --git a/src/cli/splatnet2/battles.ts b/src/cli/splatnet2/battles.ts new file mode 100644 index 0000000..5294b7d --- /dev/null +++ b/src/cli/splatnet2/battles.ts @@ -0,0 +1,92 @@ +import createDebug from 'debug'; +// @ts-expect-error +import Table from 'cli-table/lib/index.js'; +import type { Arguments as ParentArguments } from '../splatnet2.js'; +import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js'; +import { getIksmToken } from './util.js'; + +const debug = createDebug('cli:splatnet2:battles'); + +export const command = 'battles'; +export const desc = 'List the last 50 regular/ranked/private/festival battles'; + +export function builder(yargs: Argv) { + return yargs.option('user', { + describe: 'Nintendo Account ID', + type: 'string', + }).option('token', { + describe: 'Nintendo Account session token', + type: 'string', + }).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} = await getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession); + + const results = await splatnet.getResults(); + + if (argv.jsonPrettyPrint) { + console.log(JSON.stringify(results, null, 4)); + return; + } + if (argv.json) { + console.log(JSON.stringify(results)); + return; + } + + console.log('Summary', results.summary); + + const table = new Table({ + head: [ + '#', + 'Type', + 'Mode', + 'Rule', + 'Stage', + 'Result', + 'Inked', + 'K (A)', + 'D', + 'S', + 'Timestamp', + ], + }); + + results.results.sort((a, b) => a.start_time > b.start_time ? 1 : a.start_time < b.start_time ? -1 : 0); + + for (const result of results.results) { + table.push([ + result.battle_number, + result.type, + (result.game_mode.key === 'regular' ? '\u001b[32m' : + result.game_mode.key === 'ranked' ? '\u001b[33m' : + result.game_mode.key === 'league' ? '\u001b[31m' : + result.game_mode.key === 'private' ? '\u001b[35m' : '') + + result.game_mode.name + '\u001b[0m', + result.rule.key, + result.stage.name, + (result.my_team_result.key === 'victory' ? '\u001b[32m' : '\u001b[31m') + + result.my_team_result.name + '\u001b[0m', + result.player_result.game_paint_point + 'p', + result.player_result.kill_count + ' (' + result.player_result.assist_count + ')', + result.player_result.death_count, + result.player_result.special_count, + new Date(result.start_time * 1000).toISOString(), + ]); + } + + console.log(table.toString()); +} diff --git a/src/cli/splatnet2/challenges.ts b/src/cli/splatnet2/challenges.ts new file mode 100644 index 0000000..0b16a05 --- /dev/null +++ b/src/cli/splatnet2/challenges.ts @@ -0,0 +1,92 @@ +import createDebug from 'debug'; +// @ts-expect-error +import Table from 'cli-table/lib/index.js'; +import type { Arguments as ParentArguments } from '../splatnet2.js'; +import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js'; +import { getIksmToken } from './util.js'; + +const debug = createDebug('cli:splatnet2:challenges'); + +export const command = 'challenges'; +export const desc = 'List lifetime inkage challenges'; + +export function builder(yargs: Argv) { + return yargs.option('user', { + describe: 'Nintendo Account ID', + type: 'string', + }).option('token', { + describe: 'Nintendo Account session token', + type: 'string', + }).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} = await getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession); + + const records = await splatnet.getRecords(); + + if (argv.jsonPrettyPrint) { + console.log(JSON.stringify(records.challenges, null, 4)); + return; + } + if (argv.json) { + console.log(JSON.stringify(records.challenges)); + return; + } + + for (const [text, season, challenges, next, total_paint_point] of [ + [ + 'Lifetime inkage challenges season 1', 1, + records.challenges.archived_challenges, records.challenges.next_challenge, + records.challenges.total_paint_point, + ] as const, + [ + 'Lifetime inkage challenges season 2 (Octoling)', 2, + records.challenges.archived_challenges_octa, records.challenges.next_challenge_octa, + records.challenges.total_paint_point_octa, + ] as const, + ]) { + const table = new Table({ + head: [ + 'ID', + 'Name', + 'Turf covered', + 'Completion', + ], + }); + + for (const challenge of challenges) { + table.push([ + challenge.key, + challenge.name, + challenge.paint_points + 'p', + '100%', + ]); + } + + if (next) { + table.push([ + '???', + '???', + next.paint_points + 'p', + (Math.round((total_paint_point / next.paint_points) * 10000) / 100) + '%', + ]); + } + + console.log(text); + console.log(table.toString()); + } +} diff --git a/src/cli/splatnet2/dump-records.ts b/src/cli/splatnet2/dump-records.ts index c253812..b9162ae 100644 --- a/src/cli/splatnet2/dump-records.ts +++ b/src/cli/splatnet2/dump-records.ts @@ -67,7 +67,7 @@ export async function handler(argv: ArgumentsCamelCase) { const usernsid = argv.user ?? await storage.getItem('SelectedUser'); const token: string = argv.token || await storage.getItem('NintendoAccountToken.' + usernsid); - const {splatnet} = await getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateIksmSession); + const {splatnet} = await getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession); await mkdirp(argv.directory); diff --git a/src/cli/splatnet2/dump-results.ts b/src/cli/splatnet2/dump-results.ts index 72a4a22..e337fe3 100644 --- a/src/cli/splatnet2/dump-results.ts +++ b/src/cli/splatnet2/dump-results.ts @@ -56,7 +56,7 @@ export async function handler(argv: ArgumentsCamelCase) { const usernsid = argv.user ?? await storage.getItem('SelectedUser'); const token: string = argv.token || await storage.getItem('NintendoAccountToken.' + usernsid); - const {splatnet} = await getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateIksmSession); + const {splatnet} = await getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession); await mkdirp(argv.directory); @@ -123,13 +123,16 @@ export async function dumpResults( await fs.writeFile(image_file, image); } + const skipped = []; + const skipped_images = []; + for (const item of results.results) { const filename = 'splatnet2-result-' + results.unique_id + '-' + item.battle_number + '-' + item.type + '.json'; const file = path.join(directory, filename); try { await fs.stat(file); - debug('Skipping battle result %d, file already exists', item.battle_number); + skipped.push(item.battle_number); } catch (err) { debug('Fetching battle result %d', item.battle_number); const result = await splatnet.getResult(item.battle_number); @@ -159,26 +162,36 @@ export async function dumpResults( try { await fs.stat(file); await fs.stat(image_file); - debug('Skipping battle result image %d, file already exists', item.battle_number); + skipped_images.push(item.battle_number); } catch (err) { - debug('Fetching battle results summary image URL'); + debug('Fetching battle results image URL'); const share = await splatnet.shareResult(item.battle_number); - debug('Fetching battle results summary image'); + debug('Fetching battle results image'); const image_response = await fetch(share.url); const image = await image_response.buffer(); - debug('Writing battle results summary image data %s', filename); + debug('Writing battle results image data %s', filename); await fs.writeFile(file, JSON.stringify({ share, }, null, 4) + '\n', 'utf-8'); - debug('Writing battle results summary image %s', filename); + debug('Writing battle results image %s', filename); await fs.writeFile(image_file, image); } } } + if (skipped.length) { + if (skipped.length === 1) debug('Skipped battle result %d, file already exists', skipped[0]); + else debug('Skipped battle results %s, files already exist', skipped.join(', ')); + } + if (skipped_images.length) { + if (skipped_images.length === 1) debug('Skipped battle result image %d, file already exists', + skipped_images[0]); + else debug('Skipped battle result images %s, files already exist', skipped_images.join(', ')); + } + await fs.writeFile(latest_file, JSON.stringify({timestamp}, null, 4) + '\n', 'utf-8'); } @@ -207,13 +220,15 @@ export async function dumpCoopResults(splatnet: SplatNet2Api, directory: string, debug('Writing summary %s', summary_filename); await fs.writeFile(summary_file, JSON.stringify(results, null, 4) + '\n', 'utf-8'); + const skipped = []; + for (const item of results.results) { const filename = 'splatnet2-coop-result-' + user_id + '-' + item.job_id + '.json'; const file = path.join(directory, filename); try { await fs.stat(file); - debug('Skipping coop result %d, file already exists', item.job_id); + skipped.push(item.job_id); continue; } catch (err) {} @@ -234,5 +249,10 @@ export async function dumpCoopResults(splatnet: SplatNet2Api, directory: string, }, null, 4) + '\n', 'utf-8'); } + if (skipped.length) { + if (skipped.length === 1) debug('Skipped coop result %d, file already exists', skipped[0]); + else debug('Skipped coop results %s, files already exist', skipped.join(', ')); + } + await fs.writeFile(latest_file, JSON.stringify({timestamp}, null, 4) + '\n', 'utf-8'); } diff --git a/src/cli/splatnet2/hero.ts b/src/cli/splatnet2/hero.ts new file mode 100644 index 0000000..81d0a7b --- /dev/null +++ b/src/cli/splatnet2/hero.ts @@ -0,0 +1,97 @@ +import createDebug from 'debug'; +// @ts-expect-error +import Table from 'cli-table/lib/index.js'; +import type { Arguments as ParentArguments } from '../splatnet2.js'; +import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js'; +import { getIksmToken } from './util.js'; + +const debug = createDebug('cli:splatnet2:hero'); + +export const command = 'hero'; +export const desc = 'Show hero (Octo Canyon) records'; + +export function builder(yargs: Argv) { + return yargs.option('user', { + describe: 'Nintendo Account ID', + type: 'string', + }).option('token', { + describe: 'Nintendo Account session token', + type: 'string', + }).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} = await getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession); + + const hero = await splatnet.getHeroRecords(); + + if (argv.jsonPrettyPrint) { + console.log(JSON.stringify(hero, null, 4)); + return; + } + if (argv.json) { + console.log(JSON.stringify(hero)); + return; + } + + console.log('Summary', hero.summary); + + const table = new Table({ + head: [ + 'Mission', + 'Hero Shot', + 'Hero Roller', + 'Hero Charger', + 'Hero Dualies', + 'Hero Brella', + 'Hero Splatling', + 'Hero Blaster', + 'Hero Slosher', + 'Herobrush', + ], + }); + + for (const stage of hero.stage_infos) { + table.push([ + stage.stage.area + '-' + (stage.stage.is_boss ? 'B' : stage.stage.id), + stage.clear_weapons[0] ? 'Level ' + stage.clear_weapons[0].weapon_level + ', ' + + hrduration(stage.clear_weapons[0].clear_time) : '', + stage.clear_weapons[1] ? hrduration(stage.clear_weapons[1].clear_time) : '', + stage.clear_weapons[2] ? hrduration(stage.clear_weapons[2].clear_time) : '', + stage.clear_weapons[3] ? hrduration(stage.clear_weapons[3].clear_time) : '', + stage.clear_weapons[4] ? hrduration(stage.clear_weapons[4].clear_time) : '', + stage.clear_weapons[5] ? hrduration(stage.clear_weapons[5].clear_time) : '', + stage.clear_weapons[6] ? hrduration(stage.clear_weapons[6].clear_time) : '', + stage.clear_weapons[7] ? hrduration(stage.clear_weapons[7].clear_time) : '', + stage.clear_weapons[8] ? hrduration(stage.clear_weapons[8].clear_time) : '', + ]); + } + + console.log('Stages'); + console.log(table.toString()); +} + +function hrduration(duration: number) { + const minutes = Math.floor(duration / 60); + const seconds = duration - (minutes * 60); + + if (minutes >= 1) { + return minutes + 'm' + + (seconds ? ' ' + seconds + 's' : ''); + } else { + return seconds + 's'; + } +} diff --git a/src/cli/splatnet2/index.ts b/src/cli/splatnet2/index.ts index b6bb63a..b87bca9 100644 --- a/src/cli/splatnet2/index.ts +++ b/src/cli/splatnet2/index.ts @@ -1,4 +1,10 @@ export * as user from './user.js'; +export * as stages from './stages.js'; +export * as challenges from './challenges.js'; +export * as weapons from './weapons.js'; +export * as hero from './hero.js'; +export * as battles from './battles.js'; +export * as schedule from './schedule.js'; export * as dumpResults from './dump-results.js'; export * as dumpRecords from './dump-records.js'; export * as monitor from './monitor.js'; diff --git a/src/cli/splatnet2/monitor.ts b/src/cli/splatnet2/monitor.ts index 5bca2d4..f1ce242 100644 --- a/src/cli/splatnet2/monitor.ts +++ b/src/cli/splatnet2/monitor.ts @@ -91,6 +91,8 @@ export async function handler(argv: ArgumentsCamelCase) { i.cached_records = records; + i.auto_update_iksm_session = argv.autoUpdateSession; + console.log('Player %s (Splatoon 2 ID %s, NSA ID %s) level %d', records.records.player.nickname, records.records.unique_id, @@ -120,6 +122,8 @@ export class SplatNet2RecordsMonitor { /** Prevents redownloading user records on the first loop run */ cached_records: Records | null = null; + auto_update_iksm_session = true; + constructor( public storage: persist.LocalStorage, public token: string, diff --git a/src/cli/splatnet2/schedule.ts b/src/cli/splatnet2/schedule.ts new file mode 100644 index 0000000..299866e --- /dev/null +++ b/src/cli/splatnet2/schedule.ts @@ -0,0 +1,78 @@ +import createDebug from 'debug'; +// @ts-expect-error +import Table from 'cli-table/lib/index.js'; +import type { Arguments as ParentArguments } from '../splatnet2.js'; +import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js'; +import { getIksmToken } from './util.js'; + +const debug = createDebug('cli:splatnet2:schedule'); + +export const command = 'schedule'; +export const desc = 'Show stage schedules'; + +export function builder(yargs: Argv) { + return yargs.option('user', { + describe: 'Nintendo Account ID', + type: 'string', + }).option('token', { + describe: 'Nintendo Account session token', + type: 'string', + }).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} = await getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession); + + const schedules = await splatnet.getSchedules(); + + if (argv.jsonPrettyPrint) { + console.log(JSON.stringify(schedules, null, 4)); + return; + } + if (argv.json) { + console.log(JSON.stringify(schedules)); + return; + } + + for (const [text, schedule] of [ + ['Regular Battle', schedules.regular], + ['Ranked Battle', schedules.gachi], + ['League Battle', schedules.league], + ] as const) { + const table = new Table({ + head: [ + 'ID', + 'Start', + 'Rule', + 'Stage', + 'Stage', + ], + }); + + for (const item of schedule) { + table.push([ + item.id, + new Date(item.start_time * 1000).toISOString(), + item.rule.name, + item.stage_a.name, + item.stage_b.name, + ]); + } + + console.log(text); + console.log(table.toString()); + } +} diff --git a/src/cli/splatnet2/stages.ts b/src/cli/splatnet2/stages.ts new file mode 100644 index 0000000..51b06c2 --- /dev/null +++ b/src/cli/splatnet2/stages.ts @@ -0,0 +1,67 @@ +import createDebug from 'debug'; +// @ts-expect-error +import Table from 'cli-table/lib/index.js'; +import type { Arguments as ParentArguments } from '../splatnet2.js'; +import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js'; +import { getIksmToken } from './util.js'; + +const debug = createDebug('cli:splatnet2:stages'); + +export const command = 'stages'; +export const desc = 'List stages'; + +export function builder(yargs: Argv) { + return yargs.option('user', { + describe: 'Nintendo Account ID', + type: 'string', + }).option('token', { + describe: 'Nintendo Account session token', + type: 'string', + }).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} = await getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession); + + const stages = await splatnet.getStages(); + + if (argv.jsonPrettyPrint) { + console.log(JSON.stringify(stages, null, 4)); + return; + } + if (argv.json) { + console.log(JSON.stringify(stages)); + return; + } + + const table = new Table({ + head: [ + 'ID', + 'Name', + ], + }); + + stages.stages.sort((a, b) => parseInt(a.id) > parseInt(b.id) ? 1 : parseInt(a.id) < parseInt(b.id) ? -1 : 0); + + for (const stage of stages.stages) { + table.push([ + stage.id, + stage.name, + ]); + } + + console.log(table.toString()); +} diff --git a/src/cli/splatnet2/user.ts b/src/cli/splatnet2/user.ts index 4aa9aff..68f1c71 100644 --- a/src/cli/splatnet2/user.ts +++ b/src/cli/splatnet2/user.ts @@ -26,7 +26,7 @@ export async function handler(argv: ArgumentsCamelCase) { const usernsid = argv.user ?? await storage.getItem('SelectedUser'); const token: string = argv.token || await storage.getItem('NintendoAccountToken.' + usernsid); - const {splatnet} = await getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateIksmSession); + const {splatnet, data} = await getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession); const [records, stages, activefestivals, timeline] = await Promise.all([ splatnet.getRecords(), @@ -42,4 +42,6 @@ export async function handler(argv: ArgumentsCamelCase) { records.records.player.principal_id, records.records.player.player_rank, records.records.player.player_type); + + console.log(data.iksm_session); } diff --git a/src/cli/splatnet2/util.ts b/src/cli/splatnet2/util.ts index 2bcecf3..e625ad0 100644 --- a/src/cli/splatnet2/util.ts +++ b/src/cli/splatnet2/util.ts @@ -28,7 +28,7 @@ export async function getIksmToken(storage: persist.LocalStorage, token: string, const existingToken: SavedIksmSessionToken | undefined = await storage.getItem('IksmToken.' + token); - const td = 2 * 24 * 60 * 60 * 1000; // 2 days in ms + const td = 24 * 60 * 60 * 1000; // 1 day in ms const last_used_days_ago = existingToken?.last_used && (existingToken.last_used + td) <= Date.now(); const expired = existingToken && existingToken.expires_at <= Date.now(); diff --git a/src/cli/splatnet2/weapons.ts b/src/cli/splatnet2/weapons.ts new file mode 100644 index 0000000..ae4a815 --- /dev/null +++ b/src/cli/splatnet2/weapons.ts @@ -0,0 +1,82 @@ +import createDebug from 'debug'; +// @ts-expect-error +import Table from 'cli-table/lib/index.js'; +import type { Arguments as ParentArguments } from '../splatnet2.js'; +import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js'; +import { getIksmToken } from './util.js'; + +const debug = createDebug('cli:splatnet2:weapons'); + +export const command = 'weapons'; +export const desc = 'Show weapon stats'; + +export function builder(yargs: Argv) { + return yargs.option('user', { + describe: 'Nintendo Account ID', + type: 'string', + }).option('token', { + describe: 'Nintendo Account session token', + type: 'string', + }).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} = await getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession); + + const records = await splatnet.getRecords(); + + if (argv.jsonPrettyPrint) { + console.log(JSON.stringify(records.records.weapon_stats, null, 4)); + return; + } + if (argv.json) { + console.log(JSON.stringify(records.records.weapon_stats)); + return; + } + + const table = new Table({ + head: [ + 'ID', + 'Name', + 'Sub', + 'Special', + 'Wins', + 'Losses', + 'Meter', + 'H. meter', + 'Turf inked', + 'Last used', + ], + }); + + for (const weaponstats of Object.values(records.records.weapon_stats)) { + table.push([ + weaponstats.weapon.id, + weaponstats.weapon.name, + weaponstats.weapon.sub.name, + weaponstats.weapon.special.name, + weaponstats.win_count, + weaponstats.lose_count, + weaponstats.win_meter, + weaponstats.max_win_meter, + weaponstats.total_paint_point + 'p', + new Date(weaponstats.last_use_time * 1000).toISOString(), + ]); + } + + console.log('Weapon stats'); + console.log(table.toString()); +}