diff --git a/src/api/splatnet2-xrank.ts b/src/api/splatnet2-xrank.ts new file mode 100644 index 0000000..2df8b1b --- /dev/null +++ b/src/api/splatnet2-xrank.ts @@ -0,0 +1,169 @@ +// +// X Rank seasons +// +// X Rank seasons are calculated by the web app, not retrieved from the API. +// + +export interface Season { + /** Season ID use by the API, e.g. "220501T00_220601T00" */ + id: string; + /** Season ID use by the web app, e.g. "2022_05" */ + key: string; + start: Date; + end: Date; + complete: boolean; +} + +export function* getAllSeasons(sort_ascending = false) { + let season = sort_ascending ? FIRST_SEASON : CURRENT_SEASON; + + while (season) { + yield season; + season = sort_ascending ? getNextSeason(season) : getPreviousSeason(season); + } +} + +export function getSeason(year: number, month: number): Season | null +export function getSeason(start: Date): Season | null +export function getSeason(id: string): Season | null +export function getSeason(year: number | Date | string, month?: number): Season | null { + if (year instanceof Date) { + month = year.getUTCMonth() + 1; + year = year.getUTCFullYear(); + } + if (typeof year === 'string') { + // 180401T00_180601T00 + const match = year.match(/^(\d{2,})(0\d,1[012])01T00_(\d{2,})(0\d,1[012])01T00$/); + if (!match) throw new Error('Invalid season ID'); + + year = 2000 + parseInt(match[1]); + month = parseInt(match[2]); + + const nextyear = month === 12 ? year + 1 : year; + const nextmonth = year === 2018 && month === 4 ? 6 : + month === 12 ? 1 : month + 1; + if (nextyear !== parseInt(match[3]) || nextmonth !== parseInt(match[4])) throw new Error('Invalid season ID'); + } + + const start = new Date(Date.UTC(year, month! - 1)); + + if (Date.now() < start.getTime()) return null; + if (start.getUTCFullYear() < 2018 || (start.getUTCFullYear() === 2018 && start.getUTCMonth() < 3)) return null; + + const end = new Date(Date.UTC( + start.getFullYear() + (start.getMonth() === 11 ? 1 : 0), + start.getMonth() === 11 ? 0 : start.getMonth() + 1, + )); + + const id = toSeasonId(start.getUTCFullYear(), start.getUTCMonth() + 1); + const key = start.getUTCFullYear() + '_' + ('' + (start.getUTCMonth() + 1)).padStart(2, '0'); + + if (start.getUTCFullYear() === 2018 && start.getUTCMonth() === 4) { + start.setUTCMonth(3); + } + + return { + id, + key, + start, + end, + complete: Date.now() > end.getTime(), + }; +} + +export function getFirstSeason() { + return getSeason(2018, 5); +} + +const FIRST_SEASON = getFirstSeason(); +const CURRENT_SEASON = getSeason(new Date()); + +export function getNextSeason(season: Season): Season | null +export function getNextSeason(year: number, month: number): Season | null +export function getNextSeason(season: Season | number, month?: number): Season | null { + const current_start = typeof season === 'number' ? new Date(Date.UTC(season, month!)) : season.start; + + if (current_start.getUTCFullYear() === 2018 && current_start.getUTCMonth() === 3) { + current_start.setUTCMonth(4); + } + + const start = new Date(Date.UTC( + current_start.getFullYear() + (current_start.getMonth() === 11 ? 1 : 0), + current_start.getMonth() === 11 ? 0 : current_start.getMonth() + 1, + )); + + if (Date.now() < start.getTime()) return null; + + const end = new Date(Date.UTC( + start.getFullYear() + (start.getMonth() === 11 ? 1 : 0), + start.getMonth() === 11 ? 0 : start.getMonth() + 1, + )); + + const id = toSeasonId(start.getUTCFullYear(), start.getUTCMonth() + 1); + const key = start.getUTCFullYear() + '_' + ('' + (start.getUTCMonth() + 1)).padStart(2, '0'); + + return { + id, + key, + start, + end, + complete: Date.now() > end.getTime(), + }; +} + +export function getPreviousSeason(season: Season): Season | null +export function getPreviousSeason(year: number, month: number): Season | null +export function getPreviousSeason(season: Season | number, month?: number): Season | null { + const current_start = typeof season === 'number' ? new Date(Date.UTC(season, month!)) : season.start; + + const start = new Date(Date.UTC( + current_start.getFullYear() - (current_start.getMonth() === 0 ? 1 : 0), + current_start.getMonth() === 0 ? 11 : current_start.getMonth() - 1, + )); + + if (start.getUTCFullYear() < 2018 || (start.getUTCFullYear() === 2018 && start.getUTCMonth() < 3)) return null; + + const end = new Date(Date.UTC( + start.getFullYear() + (start.getMonth() === 11 ? 1 : 0), + start.getMonth() === 11 ? 0 : start.getMonth() + 1, + )); + + const id = toSeasonId(start.getUTCFullYear(), start.getUTCMonth() + 1); + const key = start.getUTCFullYear() + '_' + ('' + (start.getUTCMonth() + 1)).padStart(2, '0'); + + if (start.getUTCFullYear() === 2018 && start.getUTCMonth() === 4) { + start.setUTCMonth(3); + } + + return { + id, + key, + start, + end, + complete: Date.now() > end.getTime(), + }; +} + +export function toSeasonId(year: number, month: number) { + const nextyear = month === 12 ? year + 1 : year; + const nextmonth = month === 12 ? 1 : month + 1; + + if (year < 2000) throw new Error('Invalid season ID'); + if (nextyear >= 2100) throw new Error('Invalid season ID'); + + if (year === 2018 && month === 5) month = 4; + + return ('' + (year - 2000)).padStart(2, '0') + + ('' + month).padStart(2, '0') + + '01T00_' + + ('' + (nextyear - 2000)).padStart(2, '0') + + ('' + nextmonth).padStart(2, '0') + + '01T00'; +} + +export enum Rule { + SPLAT_ZONES = 'splat_zones', + TOWER_CONTROL = 'tower_control', + RAINMAKER = 'rainmaker', + CLAM_BLITZ = 'clam_blitz', +} diff --git a/src/api/splatnet2.ts b/src/api/splatnet2.ts index eeb6a0b..9ff8ea7 100644 --- a/src/api/splatnet2.ts +++ b/src/api/splatnet2.ts @@ -7,6 +7,7 @@ import { ErrorResponse } from './util.js'; import ZncApi from './znc.js'; import { ActiveFestivals, CoopResult, CoopResults, CoopSchedules, HeroRecords, NicknameAndIcons, PastFestivals, Records, Result, Results, Schedules, ShareResponse, ShopMerchandises, Stages, Timeline, WebServiceError, XPowerRankingSummary } from './splatnet2-types.js'; import { timeoutSignal } from '../util/misc.js'; +import { toSeasonId, Rule as XPowerRankingRule } from './splatnet2-xrank.js'; const debug = createDebug('nxapi:api:splatnet2'); @@ -302,28 +303,6 @@ ${colour} } } -export function toSeasonId(year: number, month: number) { - const nextyear = month === 12 ? year + 1 : year; - const nextmonth = month === 12 ? 1 : month + 1; - - if (year < 2000) throw new Error('Invalid season ID'); - if (nextyear >= 2100) throw new Error('Invalid season ID'); - - return ('' + (year - 2000)).padStart(2, '0') + - ('' + month).padStart(2, '0') + - '01T00_' + - ('' + (nextyear - 2000)).padStart(2, '0') + - ('' + nextmonth).padStart(2, '0') + - '01T00'; -} - -export enum XPowerRankingRule { - SPLAT_ZONES = 'splat_zones', - TOWER_CONTROL = 'tower_control', - RAINMAKER = 'rainmaker', - CLAM_BLITZ = 'clam_blitz', -} - export function toLeagueId(date: Date, type: LeagueType) { const year = date.getUTCFullYear(); const month = date.getUTCMonth() + 1; diff --git a/src/cli/splatnet2/index.ts b/src/cli/splatnet2/index.ts index 185b0dd..e99312c 100644 --- a/src/cli/splatnet2/index.ts +++ b/src/cli/splatnet2/index.ts @@ -9,3 +9,4 @@ 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'; +export * as xRankSeasons from './x-rank-seasons.js'; diff --git a/src/cli/splatnet2/x-rank-seasons.ts b/src/cli/splatnet2/x-rank-seasons.ts new file mode 100644 index 0000000..db4a04d --- /dev/null +++ b/src/cli/splatnet2/x-rank-seasons.ts @@ -0,0 +1,62 @@ +import createDebug from 'debug'; +import Table from '../util/table.js'; +import type { Arguments as ParentArguments } from '../splatnet2.js'; +import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js'; +import { getAllSeasons } from '../../api/splatnet2-xrank.js'; + +const debug = createDebug('cli:splatnet2:x-rank-seasons'); + +export const command = 'x-rank-seasons'; +export const desc = 'Show X Rank seasons'; + +export function builder(yargs: Argv) { + return yargs.option('sort', { + describe: 'Sort', + type: 'string', + choices: ['asc', 'desc'], + default: 'asc', + }).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 sort_ascending = argv.sort !== 'desc'; + + if (argv.json || argv.jsonPrettyPrint) { + const result = { + seasons: [...getAllSeasons()], + }; + + console.log(JSON.stringify(result, null, argv.jsonPrettyPrint ? 4 : 0)); + return; + } + + const table = new Table({ + head: [ + 'ID', + 'Key', + 'Start', + 'End', + 'Status', + ], + }); + + for (const season of getAllSeasons(sort_ascending)) { + table.push([ + season.id, + season.key, + season.start.toLocaleString('en-GB'), + season.end.toLocaleString('en-GB'), + season.complete ? 'Complete' : 'Calculating', + ]); + } + + console.log(table.toString()); +} diff --git a/src/index.ts b/src/index.ts index 0b9ff0c..e229c06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,11 +8,20 @@ export * as f from './api/f.js'; export { default as SplatNet2Api, - XPowerRankingRule as SplatNet2XPowerRankingRule, LeagueType as SplatNet2LeagueType, LeagueRegion as SplatNet2LeagueRegion, ShareColour as SplatNet2ProfileColour, + toLeagueId as toSplatNet2LeagueId, } from './api/splatnet2.js'; +export { + Season as SplatNet2XRankSeason, + Rule as SplatNet2XPowerRankingRule, + getAllSeasons as getSplatNet2XRankSeasons, + getSeason as getSplatNet2XRankSeason, + getNextSeason as getSplatNet2NextXRankSeason, + getPreviousSeason as getSplatNet2PreviousXRankSeason, + toSeasonId as toSplatNet2XRankSeasonId, +} from './api/splatnet2-xrank.js'; export * as splatnet2 from './api/splatnet2-types.js'; export { default as NooklinkApi,