diff --git a/src/api/coral.ts b/src/api/coral.ts index e85e22e..c9bb8fe 100644 --- a/src/api/coral.ts +++ b/src/api/coral.ts @@ -319,7 +319,7 @@ export default class CoralApi { if ('errorMessage' in data) { throw new ErrorResponse('[znc] ' + data.errorMessage, response, data); } - if (data.status !== 0) { + if (data.status !== CoralStatus.OK) { throw new ErrorResponse('[znc] Unknown error', response, data); } diff --git a/src/api/splatnet3-types.ts b/src/api/splatnet3-types.ts index 37a7da8..65467f4 100644 --- a/src/api/splatnet3-types.ts +++ b/src/api/splatnet3-types.ts @@ -213,6 +213,39 @@ interface GearPower { }; } +export enum FestState { + SCHEDULED = 'SCHEDULED', + FIRST_HALF = 'FIRST_HALF', + SECOND_HALF = 'SECOND_HALF', + CLOSED = 'CLOSED', +} +export enum FestVoteState { + VOTED = 'VOTED', + PRE_VOTED = 'PRE_VOTED', +} +export enum FestTeamRole { + ATTACK = 'ATTACK', + DEFENSE = 'DEFENSE', +} + +/** a2c742c840718f37488e0394cd6e1e08 VotesUpdateFestVoteMutation */ +export interface VotesUpdateFestVoteResult { + updateFestVote: { + fest: { + id: string; + teams: DetailVotingStatusTeam<{ + totalCount: number; + nodes: FestVotePlayer[]; + }>[]; + isVotable: boolean; + undecidedVotes: { + totalCount: number; + nodes: FestVotePlayer[]; + }; + } + userErrors: null; + } +} /** f8ae00773cc412a50dd41a6d9a159ddd ConfigureAnalyticsQuery */ export interface ConfigureAnalyticsResult { @@ -419,6 +452,7 @@ export interface BattleHistoryCurrentPlayerResult { /** 7a0e05c28c7d3f7e5a06def87ab8cd2d FriendListQuery */ export interface FriendListResult { + /** Only includes friends that have played Splatoon 3 */ friends: { nodes: Friend[]; }; @@ -431,7 +465,9 @@ export type FriendListRefetchResult = FriendListResult; interface Friend { id: string; onlineState: FriendOnlineState; + /** Switch user name */ nickname: string; + /** Splatoon 3 name, if the user has one set and is currently playing Splatoon 3 */ playerName: string | null; userIcon: { url: string; @@ -450,6 +486,10 @@ interface Friend { export enum FriendOnlineState { OFFLINE = 'OFFLINE', + /** + * The user is online and selected in *any* game, not just Splatoon 3. + * Coral may be used to check which game the user is playing. + */ ONLINE = 'ONLINE', VS_MODE_MATCHING = 'VS_MODE_MATCHING', COOP_MODE_MATCHING = 'COOP_MODE_MATCHING', @@ -535,6 +575,129 @@ interface WeaponHistoryCategoryWeapon extends WeaponHistoryWeapon { }; } +/** 2d661988c055d843b3be290f04fb0db9 DetailFestRecordDetailQuery */ +export interface DetailFestRecordDetailResult { + fest: { + __typename: 'Fest'; + id: string; + title: string; + lang: string; + startTime: string; + endTime: string; + state: FestState; + image: { + url: string; + }; + teams: DetailFestTeam[]; + playerResult: unknown | null; + myTeam: unknown | null; + isVotable: boolean; + undecidedVotes: { + totalCount: number; + }; + }; + currentPlayer: { + name: string; + userIcon: { + url: string; + }; + }; +} + +interface DetailFestTeam { + result: unknown | null; + id: string; + teamName: string; + color: Colour; + image: { + url: string; + }; + myVoteState: FestVoteState | null; + preVotes: Votes; + votes: Votes; + role: FestTeamRole | null; +} + +/** 0eb7bac3d8aabcad0e9d663ee5b90846 DetailFestRefethQuery */ +export type DetailFestRefetchResult = DetailFestRecordDetailResult; + +/** 92f51ed1ab462bbf1ab64cad49d36f79 DetailFestVotingStatusRefethQuery */ +export type DetailFestVotingStatusRefetchResult = DetailVotingStatusResult; + +/** 53ee6b6e2acc3859bf42454266d671fc DetailVotingStatusQuery */ +export interface DetailVotingStatusResult { + fest: { + __typename: 'Fest'; + id: string; + lang: string; + teams: DetailVotingStatusTeam[]; + undecidedVotes: { + nodes: FestVotePlayer[]; + }; + }; +} + +interface DetailVotingStatusTeam { + id: string; + teamName: string; + image: { + url: string; + }; + color: Colour; + /** undefined = not voted, null = not voted for this team */ + myVoteState?: FestVoteState | null; + preVotes: Votes; + votes: Votes; +} + +interface FestVotePlayer { + playerName: string; + userIcon: { + url: string; + }; +} + +/** 44c76790b68ca0f3da87f2a3452de986 FestRecordQuery */ +export interface FestRecordResult { + festRecords: { + nodes: FestRecord[]; + }; + currentPlayer: { + name: string; + userIcon: { + url: string; + }; + }; +} + +interface FestRecord { + id: string; + state: FestState; + startTime: string; + endTime: string; + title: string; + lang: string; + image: { + url: string; + }; + playerResult: unknown | null; + teams: FestRecordTeam[]; + myTeam: unknown | null; +} + +interface FestRecordTeam { + id: string; + teamName: string; + result: unknown | null; +} + +/** 73b9837d0e4dd29bfa2f1a7d7ee0814a FestRecordRefetchQuery */ +export type FestRecordRefetchResult = FestRecordResult; + /** 61228d553e7463c203e05e7810dd79a7 SettingQuery */ export interface SettingResult { currentPlayer: { @@ -723,12 +886,12 @@ export interface HomeResult { }; }; banners: HomeBanner[]; - /** Only includes online friends */ + /** Only includes online friends that have played Splatoon 3, even if they are currently playing a different game */ friends: { nodes: HomeFriend[]; totalCount: number; }; - footerMessages: unknown[]; + footerMessages: HomeFooterMessage[]; } interface HomeBanner { @@ -751,6 +914,22 @@ interface HomeFriend { }; } +type HomeFooterMessage = FooterBigRunMessage | FooterFestMessage | FooterSeasonMessage; + +interface FooterBigRunMessage { + __typename: 'FooterBigRunMessage'; + bigRunState: 'SCHEDULED' | unknown; +} +interface FooterFestMessage { + __typename: 'FooterFestMessage'; + festState: FestState; + festTitle: string; +} +interface FooterSeasonMessage { + __typename: 'FooterSeasonMessage'; + seasonName: string; +} + /** 994cf141e55213e6923426caf37a1934 VsHistoryDetailPagerRefetchQuery */ export interface VsHistoryDetailPagerRefetchQueryResult { vsHistoryDetail: { diff --git a/src/api/splatnet3.ts b/src/api/splatnet3.ts index cbda764..344179a 100644 --- a/src/api/splatnet3.ts +++ b/src/api/splatnet3.ts @@ -5,7 +5,7 @@ import { NintendoAccountUser } from './na.js'; import { defineResponse, ErrorResponse } from './util.js'; import CoralApi from './coral.js'; import { timeoutSignal } from '../util/misc.js'; -import { BankaraBattleHistoriesResult, BattleHistoryCurrentPlayerResult, BulletToken, CurrentFestResult, FriendListResult, GraphQLRequest, GraphQLResponse, HistoryRecordResult, HomeResult, LatestBattleHistoriesResult, PrivateBattleHistoriesResult, RegularBattleHistoriesResult, RequestId, SettingResult, StageScheduleResult, VsHistoryDetailResult, CoopHistoryResult, CoopHistoryDetailResult } from './splatnet3-types.js'; +import { BankaraBattleHistoriesResult, BattleHistoryCurrentPlayerResult, BulletToken, CurrentFestResult, FriendListResult, GraphQLRequest, GraphQLResponse, HistoryRecordResult, HomeResult, LatestBattleHistoriesResult, PrivateBattleHistoriesResult, RegularBattleHistoriesResult, RequestId, SettingResult, StageScheduleResult, VsHistoryDetailResult, CoopHistoryResult, CoopHistoryDetailResult, FestRecordResult, FestRecordRefetchResult, DetailFestRecordDetailResult, DetailVotingStatusResult, DetailFestVotingStatusRefetchResult, VotesUpdateFestVoteResult } from './splatnet3-types.js'; const debug = createDebug('nxapi:api:splatnet3'); @@ -94,6 +94,44 @@ export default class SplatNet3Api { return this.persistedQuery(RequestId.SettingQuery, {}); } + async getFestRecords() { + return this.persistedQuery(RequestId.FestRecordQuery, {}); + } + + async getFestRecordsRefetch() { + return this.persistedQuery(RequestId.FestRecordRefetchQuery, {}); + } + + async getFestDetail(id: string) { + return this.persistedQuery(RequestId.DetailFestRecordDetailQuery, { + festId: id, + }); + } + + async getFestDetailRefetch(id: string) { + return this.persistedQuery(RequestId.DetailFestRefethQuery, { + festId: id, + }); + } + + async getFestVotingStatus(id: string) { + return this.persistedQuery(RequestId.DetailVotingStatusQuery, { + festId: id, + }); + } + + async getFestVotingStatusRefetch(id: string) { + return this.persistedQuery(RequestId.DetailFestVotingStatusRefethQuery, { + festId: id, + }); + } + + async updateFestPoll(id: string) { + return this.persistedQuery(RequestId.VotesUpdateFestVoteMutation, { + teamId: id, + }); + } + async getFriends() { return this.persistedQuery(RequestId.FriendListQuery, {}); } diff --git a/src/cli/splatnet3/festival.ts b/src/cli/splatnet3/festival.ts new file mode 100644 index 0000000..5813f80 --- /dev/null +++ b/src/cli/splatnet3/festival.ts @@ -0,0 +1,92 @@ +import createDebug from 'debug'; +import Table from '../util/table.js'; +import type { Arguments as ParentArguments } from '../splatnet3.js'; +import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js'; +import { initStorage } from '../../util/storage.js'; +import { getBulletToken } from '../../common/auth/splatnet3.js'; + +const debug = createDebug('cli:splatnet3:festival'); + +export const command = 'festival '; +export const desc = 'Show details about a specific Splatfest in your region'; + +export function builder(yargs: Argv) { + return yargs.positional('id', { + describe: 'Splatfest ID', + type: 'string', + demandOption: true, + }).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, data} = await getBulletToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession); + + const fest_records = await splatnet.getFestRecords(); + + const req_id = argv.id; + const encoded_req_id = Buffer.from(req_id).toString('base64'); + const encoded_part_req_id = Buffer.from('Fest-' + req_id).toString('base64'); + const fest = fest_records.data.festRecords.nodes.find(f => f.id === req_id || + f.id === encoded_req_id || f.id === encoded_part_req_id); + + if (!fest) { + throw new Error('Invalid Splatfest ID'); + } + + const detail = await splatnet.getFestDetail(fest.id); + const votes = await splatnet.getFestVotingStatus(fest.id); + + if (argv.jsonPrettyPrint) { + console.log(JSON.stringify({fest: detail.data.fest, votes: votes.data.fest}, null, 4)); + return; + } + if (argv.json) { + console.log(JSON.stringify({fest: detail.data.fest, votes: votes.data.fest})); + return; + } + + console.log('Details', detail.data.fest); + + const table = new Table({ + head: [ + 'Name', + 'State', + 'Team', + ], + }); + + for (const team of votes.data.fest.teams) { + for (const vote of team.votes.nodes) { + table.push([vote.playerName, 'Voted', team.teamName]); + } + for (const vote of team.preVotes.nodes) { + table.push([vote.playerName, 'Planning to vote', team.teamName]); + } + } + + for (const vote of votes.data.fest.undecidedVotes.nodes) { + table.push([vote.playerName, 'Undecided', '-']); + } + + console.log('Friends votes'); + console.log(table.toString()); +} diff --git a/src/cli/splatnet3/festivals.ts b/src/cli/splatnet3/festivals.ts new file mode 100644 index 0000000..95c750d --- /dev/null +++ b/src/cli/splatnet3/festivals.ts @@ -0,0 +1,77 @@ +import createDebug from 'debug'; +import Table from '../util/table.js'; +import type { Arguments as ParentArguments } from '../splatnet3.js'; +import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js'; +import { initStorage } from '../../util/storage.js'; +import { getBulletToken } from '../../common/auth/splatnet3.js'; + +const debug = createDebug('cli:splatnet3:festivals'); + +export const command = 'festivals'; +export const desc = 'List all Splatfests in your region'; + +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 getBulletToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession); + + const fest_records = await splatnet.getFestRecords(); + + if (argv.jsonPrettyPrint) { + console.log(JSON.stringify({festRecords: fest_records.data.festRecords.nodes}, null, 4)); + return; + } + if (argv.json) { + console.log(JSON.stringify({festRecords: fest_records.data.festRecords.nodes})); + return; + } + + const table = new Table({ + head: [ + 'ID', + 'State', + 'Start', + // 'L', + 'Title', + 'A', + 'B', + 'C', + ], + }); + + for (const fest of fest_records.data.festRecords.nodes) { + const id_str = Buffer.from(fest.id, 'base64').toString() || fest.id; + + table.push([ + id_str, + fest.state, + fest.startTime, + // fest.lang, + fest.title, + ...fest.teams.map(t => t.teamName), + ]); + } + + console.log(table.toString()); +} diff --git a/src/cli/splatnet3/index.ts b/src/cli/splatnet3/index.ts index d15bb08..c88add9 100644 --- a/src/cli/splatnet3/index.ts +++ b/src/cli/splatnet3/index.ts @@ -2,5 +2,7 @@ export * as user from './user.js'; export * as token from './token.js'; export * as friends from './friends.js'; export * as schedule from './schedule.js'; +export * as festivals from './festivals.js'; +export * as festival from './festival.js'; export * as battles from './battles.js'; export * as dumpResults from './dump-results.js'; diff --git a/src/cli/splatnet3/user.ts b/src/cli/splatnet3/user.ts index bb5ee34..7e261a0 100644 --- a/src/cli/splatnet3/user.ts +++ b/src/cli/splatnet3/user.ts @@ -34,5 +34,6 @@ export async function handler(argv: ArgumentsCamelCase) { console.log('Player %s#%s (title %s, first played %s)', history.data.currentPlayer.name, history.data.currentPlayer.nameId, - history.data.playHistory.gameStartTime); + history.data.currentPlayer.byname, + new Date(history.data.playHistory.gameStartTime).toLocaleString()); }