Compare commits

...
27 Commits
Author SHA1 Message Date
TrèsAbhi 8c4afa73f8 Merge pull request #32 from tresabhi/1.12.8 2023-09-11 13:15:00 -05:00
TrèsAbhi 6db3ccdb40 feat: removed embed buttons 2023-09-11 13:14:05 -05:00
TrèsAbhi 90e012319a Merge pull request #30 from tresabhi/1.12.7 2023-09-08 21:35:46 -05:00
TresAbhi 18de9cbaab feat: upgraded dependencies 2023-09-09 02:35:14 +00:00
TresAbhi 42a76cbd38 feat: changelogs 2023-09-09 02:19:20 +00:00
TresAbhi eb95634801 fix: escaped markdown for clan event title 2023-09-09 02:11:32 +00:00
TresAbhi de8bfff47f feat: clearer statement about timezones 2023-09-09 02:10:54 +00:00
TresAbhi 07c18911df feat: clearer statement about public data 2023-09-09 02:06:09 +00:00
TresAbhi cc769cc330 fix: react is now defined 2023-09-09 02:04:36 +00:00
TrèsAbhi 9794f58936 Update cd-host.yml 2023-09-08 11:50:31 -05:00
TrèsAbhi a44fcff3b1 Update cd-host.yml 2023-09-08 11:48:41 -05:00
TrèsAbhi 7e4a3e88b9 Update cd-host.yml 2023-09-08 11:47:39 -05:00
TrèsAbhi 8fd45e2205 Merge pull request #29 from tresabhi/1.12.5 2023-09-08 11:45:15 -05:00
TresAbhi 7d235057e8 feat: changelogs 2023-09-08 16:41:06 +00:00
TresAbhi 63a8a67771 fix: better approximation message and players with 0 wins are omitted 2023-09-08 16:38:08 +00:00
TresAbhi bac39ca176 fix: only use players who have played within the last day 2023-09-08 16:30:44 +00:00
TrèsAbhi 626b1e8601 Merge branch '1.13.0' into main 2023-09-07 15:23:10 -05:00
TresAbhi 04086f31b9 fix: spelling 2023-09-07 03:25:07 +00:00
TresAbhi 7037aa9c3b feat: 1.12.5 2023-09-07 03:24:07 +00:00
TresAbhi f5bab8a698 faet: clan-event 2023-09-07 01:24:12 +00:00
TresAbhi ee44b1b1d8 feat: next js 2023-09-07 01:13:03 +00:00
TresAbhi d0f91df95b feat: split bot and server builds 2023-09-04 16:49:38 +00:00
TrèsAbhi dc1c49da6a Merge pull request #27 from tresabhi/main 2023-09-03 13:24:34 -05:00
TrèsAbhi 331040bbdf Merge pull request #26 from tresabhi/1.12.3 2023-09-03 13:21:00 -05:00
TresAbhi 70e1d4b30d feat: changelogs 2023-09-03 18:20:19 +00:00
TresAbhi 03dbd797fb feat: no ongoing season proof 2023-09-03 18:15:10 +00:00
TresAbhi eb4cb1fbfe feat: removed inDevelopment 2023-08-24 00:08:08 +00:00
47 changed files with 1128 additions and 836 deletions

No files matched your search

+4 -4
View File
@@ -22,11 +22,11 @@ jobs:
- name: Download release assets
run: |
DOWNLOAD_URL=$(curl -s https://api.github.com/repos/tresabhi/blitzkrieg/releases/latest \
| grep "browser_download_url.*dist.zip" \
| grep "browser_download_url.*bot.zip" \
| cut -d : -f 2,3 \
| tr -d \")
curl -L -o dist.zip $DOWNLOAD_URL
unzip dist.zip
curl -L -o bot.zip $DOWNLOAD_URL
unzip bot.zip
- name: Run bot
run: NODE_ENV=production GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} WARGAMING_APPLICATION_ID=${{ secrets.WARGAMING_APPLICATION_ID }} node --enable-source-maps --trace-warnings bot.cjs
run: NODE_ENV=production GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} WARGAMING_APPLICATION_ID=${{ secrets.WARGAMING_APPLICATION_ID }} node --enable-source-maps --trace-warnings bot/index.cjs
+3 -1
View File
@@ -9,6 +9,8 @@ dist.*
yarn-error.log
commands.txt
/test.*
test.*
/docs/.vitepress/dist
/docs/.vitepress/cache
servers.csv
servers.csv
.next/
+13
View File
@@ -0,0 +1,13 @@
# Blitzkrieg 1.12.3
3-day downtime fixed!
## Changes
- Split `/ratings` into `/ratings current` and `/ratings archive`
- `current` now properly handles when there is no ongoing ratings season
- `archive` displays older archived seasons
## Fixes
- The bot is now no ratings seasons tolerant
+7
View File
@@ -0,0 +1,7 @@
# Blitzkrieg 1.12.4
Added very badly-coded temporary `/clan-event` command (without linting)
## Changes
- Added `/clan-event` command
+12
View File
@@ -0,0 +1,12 @@
# Blitzkrieg 1.12.5
Some patch up work
## Changes
- `/clan-event` now is more legible and comes with an approximation warning
## Fixes
- `/clan-event` now uses a more optimized way of fetching large data
- This should fix the issue where too many people using the command broke it
+12
View File
@@ -0,0 +1,12 @@
# Blitzkrieg 1.12.6
Stops making the bot lie!
## Changes
- The approximation warning for `/clan-event` has been made more concise
- Players with `0` wins are now omitted
## Fixes
- Players who haven't player within the current day are now omitted
+13
View File
@@ -0,0 +1,13 @@
# Blitzkrieg 1.12.7
A few fixes and clarifications
## Changes
- `/clan-event`
- Made statement about the limited publicly available data
- Added a statement about reset times
## Fixes
- Commands with images now once again work
+7
View File
@@ -0,0 +1,7 @@
# Blitzkrieg 1.12.8
Blitzkrieg is a completely free service and I earn no revenue from it. To keep it sustainable, I have to rely on free services. Unfortunately for embeds, seems like I have exceeded my limits. Before this service starts charging me, I have shut down embeds. They'll hopefully be back in 1.13 alongside the website! Only the future will tell...
## Changes
- Removed all embed buttons
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
/* config options here */
distDir: 'dist/website',
};
export default nextConfig;
+20 -14
View File
@@ -1,24 +1,28 @@
{
"name": "blitzkrieg",
"version": "1.12.1",
"version": "1.12.7",
"description": "🎉 All-in-one Discord bot for everything World of Tanks Blitz",
"main": "src/server.ts",
"type": "module",
"scripts": {
"build": "node scripts/build && yarn build:website",
"build:bot": "node scripts/build --build=bot",
"build:server": "node scripts/build --build=server",
"build:website": "next build",
"build:docs": "vitepress build docs",
"build:ratingsleaderboard": "vite-node --script scripts/buildRatingsLeaderboard",
"build": "node scripts/build --build=all",
"dev:bot": "yarn build:bot --dev && yarn start:bot",
"dev:server": "yarn build:server --dev && yarn start:server",
"dev:bot": "yarn build:bot && yarn start:bot",
"dev:server": "yarn build:server && yarn start:server",
"dev:docs": "vitepress dev docs",
"start:bot": "node --enable-source-maps --trace-warnings dist/bot.cjs",
"start:server": "node --enable-source-maps --trace-warnings dist/server.cjs",
"dev:website": "next dev",
"start:bot": "node --enable-source-maps --trace-warnings dist/bot/index.cjs",
"start:server": "node --enable-source-maps --trace-warnings dist/server/index.cjs",
"start:website": "next start",
"host:docs": "vitepress preview docs",
"publish:ratingsleaderboard": "vite-node --script scripts/buildRatingsLeaderboard --publish",
"zip": "zip -r dist.zip dist",
"lint": "npx tsc",
"lint:website": "next lint",
"format": "prettier --write \"**/*.@(json|js|jsx|css|md|ts|tsx|html|xml|yml|gltf)\"",
"knip": "knip",
"update": "yarn upgrade --latest"
@@ -35,23 +39,25 @@
"fuzzysort": "^2.0.4",
"lodash": "^4.17.21",
"markdown-escape": "^2.0.0",
"next": "^13.4.19",
"react": "^18.2.0",
"satori": "^0.10.3",
"vite-node": "^0.34.2",
"react-dom": "^18.2.0",
"satori": "^0.10.4",
"vite-node": "^0.34.4",
"vitepress-plugin-auto-sidebar": "^1.1.0"
},
"devDependencies": {
"@total-typescript/ts-reset": "^0.5.1",
"@types/escape-html": "^1.0.2",
"@types/express": "^4.17.17",
"@types/lodash": "^4.14.197",
"@types/lodash": "^4.14.198",
"@types/markdown-escape": "^1.1.0",
"@types/node": "^20.5.3",
"@types/node": "^20.6.0",
"@types/react": "^18.2.21",
"compare-versions": "^6.1.0",
"knip": "^2.19.9",
"prettier": "^3.0.2",
"typescript": "^5.0.4",
"vitepress": "^1.0.0-rc.4"
"knip": "^2.22.0",
"prettier": "^3.0.3",
"typescript": "^5.2.2",
"vitepress": "^1.0.0-rc.10"
}
}
+24 -54
View File
@@ -1,63 +1,33 @@
import { build } from 'esbuild';
import { copyFile, mkdirSync, rmSync } from 'fs';
import { argv } from 'process';
const isProd = !argv.includes('--dev');
const buildAll = argv.includes('--build=all');
const buildBot = argv.includes('--build=bot') || buildAll;
const buildServer = argv.includes('--build=server') || buildAll;
const targetFile = argv
.find((arg) => arg.startsWith('--build='))
?.split('=')[1];
const files = targetFile ? [targetFile] : ['bot', 'server'];
if (isProd) {
// only remove in production to ensure no time is wasted in dev
console.log('Removing dist...');
rmSync('dist', { recursive: true, force: true });
console.log('Dist removed');
files.forEach(async (file) => {
console.log(`Building ${file}...`);
console.log('Creating dist...');
mkdirSync('dist');
console.log('Dist created');
await build({
entryPoints: [`src/${file}.ts`],
outfile: `dist/${file}/index.cjs`,
console.log('Copying package.json...');
copyFile('package.dist.json', 'dist/package.json', () =>
console.log('package.json copied'),
);
}
platform: 'node',
loader: {
'.node': 'copy',
'.ttf': 'file',
},
format: 'cjs',
const commonOptions = {
platform: 'node',
loader: {
'.node': 'copy',
'.ttf': 'file',
},
format: 'cjs',
bundle: true,
sourcemap: true,
minifyIdentifiers: false, // cause goofy errors
minifySyntax: true,
minifyWhitespace: true,
bundle: true,
sourcemap: true,
minifyIdentifiers: false, // causes errors
minifySyntax: isProd,
minifyWhitespace: isProd,
logLevel: isProd ? 'info' : 'silent',
};
if (buildBot) {
console.log('Building bot...');
build({
...commonOptions,
entryPoints: ['src/bot.ts'],
outfile: 'dist/bot.cjs',
}).then(() => console.log('Bot built'));
}
if (buildServer) {
console.log('Building server...');
build({
...commonOptions,
entryPoints: ['src/server.ts'],
outfile: 'dist/server.cjs',
}).then(() => {
console.log('Server built');
logLevel: 'info',
});
}
console.log(`Built ${file}`);
});
+10 -3
View File
@@ -31,7 +31,11 @@ const PLAYERS = await fetch(
`https://${server}.wotblitz.com/en/api/rating-leaderboards/season/`,
)
.then((response) => response.json() as Promise<RatingsInfo>)
.then((data) => data.count);
.then((data) => (data.detail === undefined ? data.count : undefined));
if (PLAYERS === undefined) {
throw new Error('No current ratings season running');
}
const players: Record<number, RatingsPlayer> = {};
const leaderboard: BlitzkriegRatingsLeaderboard = [];
@@ -60,7 +64,7 @@ async function branchFromPlayer(id: number) {
console.log(
`Registered ${registered} / ${PLAYERS} (${(
(registered / PLAYERS) *
(registered / PLAYERS!) *
100
).toFixed(2)}%)`,
);
@@ -109,7 +113,10 @@ if (publish) {
const info = await fetch(
`https://${server}.wotblitz.com/en/api/rating-leaderboards/season/`,
).then((response) => response.json() as Promise<RatingsInfo>);
).then(
(response) =>
response.json() as Promise<RatingsInfo & { detail: undefined }>,
);
const normalizedServer = server === 'na' ? 'com' : server;
const infoPath = `${normalizedServer}/ratings/${info.current_season}/info.json`;
+31
View File
@@ -0,0 +1,31 @@
import { ReactNode } from 'react';
import Navbar from '../components/Navbar';
interface RootLayoutProps {
children: ReactNode;
}
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html>
<body
style={{
margin: 0,
}}
>
<div
style={{
width: '100vw',
height: '100vh',
display: 'flex',
flexDirection: 'column',
}}
>
<Navbar />
{children}
</div>
</body>
</html>
);
}
+3
View File
@@ -0,0 +1,3 @@
export default function Page() {
return <h1>Hello, Next.js!</h1>;
}
+97
View File
@@ -0,0 +1,97 @@
import { SlashCommandBuilder, escapeMarkdown } from 'discord.js';
import markdownEscape from 'markdown-escape';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import { PlayerHistoriesRaw } from '../core/blitzstars/getPlayerHistories';
import getTimeDaysAgo from '../core/blitzstars/getTimeDaysAgo';
import addClanChoices from '../core/discord/addClanChoices';
import autocompleteClan from '../core/discord/autocompleteClan';
import embedWarning from '../core/discord/embedWarning';
import resolveClanFromCommand from '../core/discord/resolveClanFromCommand';
import { secrets } from '../core/node/secrets';
import { CommandRegistry } from '../events/interactionCreate';
import { AccountAchievements } from '../types/accountAchievements';
import { AccountInfo } from '../types/accountInfo';
import { ClanInfo } from '../types/clanInfo';
export const clanEventCommand: CommandRegistry = {
inProduction: true,
inPublic: true,
command: new SlashCommandBuilder()
.setName('clan-event')
.setDescription('Lists all inactive players')
.addStringOption(addClanChoices),
async handler(interaction) {
const { region, id } = await resolveClanFromCommand(interaction);
const clan = (
await getWargamingResponse<ClanInfo>(
`https://api.wotblitz.${region}/wotb/clans/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&clan_id=${id}`,
)
)[id];
const memberIds = clan.members_ids.join(',');
const [players, achievements, previousJointVictories] = await Promise.all([
getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${region}/wotb/account/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${memberIds}`,
),
getWargamingResponse<AccountAchievements>(
`https://api.wotblitz.${region}/wotb/account/achievements/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${memberIds}`,
),
Promise.all(
clan.members_ids.map((id) =>
fetch(`https://www.blitzstars.com/api/playerstats/${id}`)
.then((response) => response.json() as Promise<PlayerHistoriesRaw>)
.then((data) => ({
id,
jointVictory: data[0]?.achievements.jointVictoryCount as
| number
| undefined,
})),
),
).then((data) =>
data.reduce<Record<number, number | undefined>>(
(accumulator, value) => ({
...accumulator,
[value.id]: value.jointVictory,
}),
{},
),
),
]);
const jointVictories = clan.members_ids.map((id) => ({
id,
joinVictory:
players[id].last_battle_time > getTimeDaysAgo(region, 1)
? previousJointVictories[id] === undefined
? 0
: achievements[id].max_series.jointVictory -
previousJointVictories[id]!
: 0,
}));
return [
`# ${escapeMarkdown(clan.name)} [${escapeMarkdown(
clan.tag,
)}]'s platoon wins today: ${(
jointVictories.reduce(
(accumulator, { joinVictory }) => accumulator + joinVictory,
0,
) / 2
).toFixed(0)}\n${jointVictories
.filter(({ joinVictory }) => joinVictory)
.sort((a, b) => b.joinVictory - a.joinVictory)
.map(
({ id, joinVictory }) =>
`- ${markdownEscape(players[id].nickname)}: ${joinVictory}`,
)
.join('\n')}`,
embedWarning(
'This is an approximation!',
"Wargaming provides very little information about platoons publicly. Caveats:\n- Players in two different clans platooning artificially inflates the total count\n- Game-mode battles aren't counted since Wargaming doesn't publicly share that data\n- This command is also subject to Blitzkrieg's reset times (use `/help timezones` for more info)",
),
];
},
autocomplete: autocompleteClan,
};
-1
View File
@@ -12,7 +12,6 @@ const executionStart = new Date().getTime();
export const debugCommand: CommandRegistry = {
inProduction: true,
inDevelopment: true,
inPublic: true,
command: new SlashCommandBuilder()
-1
View File
@@ -26,7 +26,6 @@ export const SKILLED_CLANS: Record<SkilledClan, string> = {
export const eligibleCommand: CommandRegistry = {
inProduction: true,
inDevelopment: true,
inPublic: false,
command: new SlashCommandBuilder()
-1
View File
@@ -29,7 +29,6 @@ export const evolutionCommand = new Promise<CommandRegistryRaw>(
resolve({
inProduction: true,
inDevelopment: true,
inPublic: true,
command,
-1
View File
@@ -29,7 +29,6 @@ export const fullStatsCommand = new Promise<CommandRegistryRaw>(
resolve({
inProduction: true,
inDevelopment: true,
inPublic: true,
command,
-1
View File
@@ -30,7 +30,6 @@ function addDocsSubcommands(option: SlashCommandSubcommandsOnlyBuilder) {
export const helpCommand: CommandRegistry = {
inProduction: true,
inDevelopment: true,
inPublic: true,
command: addDocsSubcommands(
+4 -6
View File
@@ -3,10 +3,10 @@ import GenericStats from '../components/GenericStats';
import NoData, { NoDataType } from '../components/NoData';
import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper';
import getBlitzClan from '../core/blitz/getBlitzClan';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import addClanChoices from '../core/discord/addClanChoices';
import autocompleteClan from '../core/discord/autocompleteClan';
import resolveClanFromCommand from '../core/discord/resolveClanFromCommand';
import { secrets } from '../core/node/secrets';
import { CommandRegistry } from '../events/interactionCreate';
import { AccountInfo } from '../types/accountInfo';
@@ -16,7 +16,6 @@ const DEFAULT_THRESHOLD = 7;
export const inactiveCommand: CommandRegistry = {
inProduction: true,
inDevelopment: true,
inPublic: true,
command: new SlashCommandBuilder()
@@ -33,19 +32,18 @@ export const inactiveCommand: CommandRegistry = {
),
async handler(interaction) {
const clanName = interaction.options.getString('clan')!;
const { server, id } = await getBlitzClan(clanName);
const { region, id } = await resolveClanFromCommand(interaction);
const threshold =
interaction.options.getNumber('threshold')! ?? DEFAULT_THRESHOLD;
const time = new Date().getTime() / 1000;
const clanData = (
await getWargamingResponse<ClanInfo>(
`https://api.wotblitz.${server}/wotb/clans/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&clan_id=${id}`,
`https://api.wotblitz.${region}/wotb/clans/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&clan_id=${id}`,
)
)[id];
const memberIds = clanData.members_ids;
const accounts = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${server}/wotb/account/info/?application_id=${
`https://api.wotblitz.${region}/wotb/account/info/?application_id=${
secrets.WARGAMING_APPLICATION_ID
}&account_id=${memberIds.join(',')}`,
);
-1
View File
@@ -46,7 +46,6 @@ const COMP_TANKS = [
export const ownedTanksCommand: CommandRegistry = {
inProduction: true,
inDevelopment: true,
inPublic: true,
command: new SlashCommandBuilder()
-1
View File
@@ -3,7 +3,6 @@ import { CommandRegistry } from '../events/interactionCreate';
export const pingCommand: CommandRegistry = {
inProduction: true,
inDevelopment: true,
inPublic: true,
handlesInteraction: true,
-1
View File
@@ -16,7 +16,6 @@ type SortBy = 'name' | 'count';
export const playerAchievementsCommand: CommandRegistry = {
inProduction: true,
inDevelopment: true,
inPublic: true,
command: new SlashCommandBuilder()
-1
View File
@@ -12,7 +12,6 @@ import { AccountInfo } from '../types/accountInfo';
export const playerInfoCommand: CommandRegistry = {
inProduction: true,
inDevelopment: true,
inPublic: true,
command: new SlashCommandBuilder()
+358 -113
View File
@@ -1,21 +1,28 @@
import {
APIApplicationCommandOptionChoice,
SlashCommandBuilder,
SlashCommandSubcommandBuilder,
SlashCommandSubcommandGroupBuilder,
} from 'discord.js';
import { BlitzkriegRatingsLeaderboard } from '../../scripts/buildRatingsLeaderboard';
import * as Leaderboard from '../components/Leaderboard';
import TitleBar from '../components/TitleBar';
import Wrapper from '../components/Wrapper';
import { REGION_NAMES_SHORT, Region } from '../constants/regions';
import getArchivedRatingsInfo from '../core/blitz/getArchivedRatingsInfo';
import getRatingsInfo from '../core/blitz/getRatingsInfo';
import getWargamingResponse from '../core/blitz/getWargamingResponse';
import regionToRegionSubdomain from '../core/blitz/regionToRegionSubdomain';
import getMidnightLeaderboard from '../core/database/getMidnightLeaderboard';
import getMidnightLeaderboard, {
DATABASE_REPO,
} from '../core/database/getMidnightLeaderboard';
import addRegionChoices from '../core/discord/addRegionChoices';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { octokit } from '../core/express/octokit';
import { secrets } from '../core/node/secrets';
import throwError from '../core/node/throwError';
import { CommandRegistryRaw } from '../events/interactionCreate';
import { AccountInfo } from '../types/accountInfo';
import { PlayerClanData } from '../types/playerClanData';
@@ -86,97 +93,160 @@ export interface RatingsLeague {
percentile: number;
}
export interface RatingsInfo {
title: string;
icon: string | null;
start_at: string;
finish_at: string;
current_season: number;
updated_at: string;
count: number;
export type RatingsInfo =
| {
detail: undefined;
rewards: RatingsReward[];
leagues: RatingsLeague[];
}
title: string;
icon: string | null;
start_at: string;
finish_at: string;
current_season: number;
updated_at: string;
count: number;
rewards: RatingsReward[];
leagues: RatingsLeague[];
}
| { detail: { error: string } };
export interface RatingsNeighbors {
neighbors: RatingsPlayer[];
}
console.log('Caching ratings info...');
const ratingsInfo = getRatingsInfo('com').then((info) => {
console.log('Cached ratings info');
return info;
});
const LEAGUES = [
{ name: 'Diamond', minScore: 5000 },
{ name: 'Platinum', minScore: 4000 },
{ name: 'Gold', minScore: 3000 },
{ name: 'Silver', minScore: 2000 },
{ name: 'Bronze', minScore: 0 },
];
function addSubcommands(
option: SlashCommandSubcommandGroupBuilder,
addOptions = (option: SlashCommandSubcommandBuilder) => option,
) {
return option
.addSubcommand((option) =>
addOptions(
option
.setName('neighbours')
.setDescription('Your position and neighbours'),
)
.addStringOption(addUsernameChoices)
.addIntegerOption((option) =>
option
.setName('limit')
.setDescription('How many neighbours to display (default: 10)')
.setRequired(false)
.setMinValue(5)
.setMaxValue(30),
),
)
.addSubcommand((option) =>
addOptions(option.setName('league').setDescription('Top league players'))
.addStringOption((option) =>
option
.setName('league')
.setDescription('The league to display')
.addChoices(
...LEAGUES.map(
({ name }, value) =>
({
name,
value: `${value}`,
}) satisfies APIApplicationCommandOptionChoice<string>,
),
)
.setRequired(true),
)
.addStringOption(addRegionChoices)
.addIntegerOption((option) =>
option
.setName('limit')
.setDescription('How many rows to display (default: 10)')
.setRequired(false)
.setMinValue(5)
.setMaxValue(30),
),
);
}
export const ratingsCommand = new Promise<CommandRegistryRaw>(
async (resolve) => {
const awaitedRatingsInfo = await ratingsInfo;
const seasonChoices = await octokit.repos
.getContent({
...DATABASE_REPO,
path: 'com/ratings',
})
.then(({ data }) => {
if (!Array.isArray(data)) {
throw throwError('Archived ratings data is malformed');
}
return data.map(
(folder) =>
({
name: `Season ${folder.name}`,
value: folder.name,
}) satisfies APIApplicationCommandOptionChoice<string>,
);
});
const command = new SlashCommandBuilder()
.setName('ratings')
.setDescription('Ratings battles statistics')
.addSubcommand((option) =>
option
.setName('league')
.setDescription('Top league players')
.addStringOption((option) =>
option
.setName('league')
.setDescription('The league to display')
.addChoices(
...awaitedRatingsInfo.leagues.map(
(league) =>
({
name: league.title.split(' ')[0].toLowerCase(),
value: `${league.index}`,
}) satisfies APIApplicationCommandOptionChoice<string>,
),
)
.setRequired(true),
)
.addStringOption(addRegionChoices)
.addIntegerOption((option) =>
option
.setName('limit')
.setDescription('How many rows to display (default: 10)')
.setRequired(false)
.setMinValue(5)
.setMaxValue(30),
),
.addSubcommandGroup((option) =>
addSubcommands(
option.setName('archive').setDescription('Any previous season'),
(option) =>
option.addStringOption((option) =>
option
.setName('season')
.setDescription('The season number')
.setChoices(...seasonChoices)
.setRequired(true),
),
),
)
.addSubcommand((option) =>
option
.setName('neighbours')
.setDescription('Your position and neighbours')
.addStringOption(addUsernameChoices)
.addIntegerOption((option) =>
option
.setName('limit')
.setDescription('How many neighbours to display (default: 10)')
.setRequired(false)
.setMinValue(5)
.setMaxValue(30),
),
.addSubcommandGroup((option) =>
addSubcommands(
option
.setName('current')
.setDescription('The current running Ratings season'),
),
);
resolve({
inProduction: true,
inDevelopment: true,
inPublic: true,
command,
async handler(interaction) {
const subcommand = interaction.options.getSubcommand(true);
interface SimplifiedPlayer {
id: number;
score: number;
position: number;
clan: string | undefined;
nickname: string;
}
const subcommandGroup = interaction.options.getSubcommandGroup(true) as
| 'archive'
| 'current';
const subcommand = interaction.options.getSubcommand(true) as
| 'league'
| 'neighbours';
const limit = interaction.options.getInteger('limit') ?? 10;
const seasonOption = interaction.options.getString('season');
const season = seasonOption ? parseInt(seasonOption) : undefined;
let titleName: string;
let titleImage: string | undefined;
let titleNameDiscriminator: string | undefined;
let titleDescription: string;
let playersBefore: number;
let playersAfter: number;
let players: RatingsPlayer[];
let players: SimplifiedPlayer[] | undefined;
let midnightLeaderboard: BlitzkriegRatingsLeaderboard | undefined;
let playerId: number | undefined;
let regionRatingsInfo: RatingsInfo;
@@ -186,31 +256,120 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
interaction.options.getString('league')!,
);
const region = interaction.options.getString('region') as Region;
regionRatingsInfo = await getRatingsInfo(region);
regionRatingsInfo = await (subcommandGroup === 'current'
? getRatingsInfo(region)
: getArchivedRatingsInfo(season!, region));
const regionSubdomain = regionToRegionSubdomain(region);
const result = await fetch(
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/league/${leagueIndex}/top/`,
)
.then((response) => response.json() as Promise<LeagueTop>)
.then(({ result }) => result.slice(0, limit));
const leagueInfo = regionRatingsInfo.leagues[leagueIndex];
midnightLeaderboard = await getMidnightLeaderboard(
region,
regionRatingsInfo.current_season,
);
const result =
subcommandGroup === 'current'
? regionRatingsInfo.detail === undefined
? await fetch(
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/league/${leagueIndex}/top/`,
)
.then((response) => response.json() as Promise<LeagueTop>)
.then(async ({ result }) =>
result.slice(0, limit).map(
(player) =>
({
id: player.spa_id,
score: player.score,
position: player.number,
clan: player.clan_tag,
nickname: player.nickname,
}) satisfies SimplifiedPlayer,
),
)
: undefined
: await octokit.repos
.getContent({
...DATABASE_REPO,
path: `${region}/ratings/${season}/latest.json`,
})
.then(async ({ data }) => {
if (Array.isArray(data) || data.type !== 'file') {
throw throwError('Archived ratings data is malformed');
}
const players = (await fetch(data.download_url!).then(
(response) => response.json(),
)) as BlitzkriegRatingsLeaderboard;
const firstPlayerIndex =
leagueIndex === 0
? 0
: players.findIndex(
(player) =>
player.score < LEAGUES[leagueIndex - 1].minScore,
);
const lastPlayerIndex = firstPlayerIndex + limit - 1;
const trimmed = players.slice(
firstPlayerIndex,
lastPlayerIndex + 1,
);
const ids = trimmed.map((player) => player.id).join(',');
const clanData = await getWargamingResponse<PlayerClanData>(
`https://api.wotblitz.${region}/wotb/clans/accountinfo/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${ids}&extra=clan`,
);
return trimmed.map(
(player, index) =>
({
id: player.id,
score: player.score,
position: firstPlayerIndex + index + 1,
clan: clanData[player.id]!.clan?.tag,
nickname: clanData[player.id]!.account_name,
}) satisfies SimplifiedPlayer,
);
});
const leagueInfo =
regionRatingsInfo.detail === undefined
? regionRatingsInfo.leagues[leagueIndex]
: undefined;
midnightLeaderboard =
subcommandGroup === 'current'
? regionRatingsInfo.detail === undefined
? await getMidnightLeaderboard(
region,
regionRatingsInfo.current_season,
)
: undefined
: await getMidnightLeaderboard(region, season!);
players = result;
playersBefore = result[0].number - 1;
playersBefore = result ? result[0].position - 1 : 0;
playersAfter =
regionRatingsInfo.count - result[result.length - 1].number;
titleName = `${leagueInfo.title} - ${REGION_NAMES_SHORT[region]}`;
titleImage = leagueInfo.big_icon.startsWith('http')
? leagueInfo.big_icon
: `https:${leagueInfo.big_icon}`;
regionRatingsInfo.detail === undefined && result !== undefined
? regionRatingsInfo.count - result[result.length - 1].position
: 0;
titleName = `${
leagueInfo ? leagueInfo.title : 'No Ongoing Season'
} - ${REGION_NAMES_SHORT[region]}`;
titleImage = leagueInfo
? leagueInfo.big_icon.startsWith('http')
? leagueInfo.big_icon
: `https:${leagueInfo.big_icon}`
: undefined;
titleDescription = `Top ${limit} players`;
} else {
const { region, id } = await resolvePlayerFromCommand(interaction);
regionRatingsInfo = await getRatingsInfo(region);
regionRatingsInfo =
subcommandGroup === 'current'
? await getRatingsInfo(region)
: await octokit.repos
.getContent({
...DATABASE_REPO,
path: `${region}/ratings/${season}/info.json`,
})
.then(async ({ data }) => {
if (Array.isArray(data) || data.type !== 'file') {
throw throwError('Archived ratings info is malformed');
}
const response = fetch(data.download_url!);
return (await response.then((response) =>
response.json(),
)) as RatingsInfo;
});
const accountInfo = await getWargamingResponse<AccountInfo>(
`https://api.wotblitz.${region}/wotb/account/info/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${id}`,
);
@@ -220,57 +379,139 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
)
)[id]?.clan;
const regionSubdomain = regionToRegionSubdomain(region);
const { neighbors } = await fetch(
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/user/${id}/?neighbors=${Math.round(
limit / 2,
)}`,
).then((response) => response.json() as Promise<RatingsNeighbors>);
midnightLeaderboard = await getMidnightLeaderboard(
region,
regionRatingsInfo.current_season,
);
const neighbors =
subcommandGroup === 'current'
? regionRatingsInfo.detail === undefined
? await fetch(
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/user/${id}/?neighbors=${Math.round(
limit / 2,
)}`,
)
.then(
(response) =>
response.json() as Promise<RatingsNeighbors>,
)
.then((players) =>
players.neighbors.map(
(player) =>
({
id: player.spa_id,
position: player.number,
score: player.score,
clan: player.clan_tag,
nickname: player.nickname,
}) satisfies SimplifiedPlayer,
),
)
: undefined
: await octokit.repos
.getContent({
...DATABASE_REPO,
path: `${region}/ratings/${season}/latest.json`,
})
.then(async ({ data }) => {
if (Array.isArray(data) || data.type !== 'file') {
throw throwError(
'Archived ratings latest data is malformed',
);
}
if (clan) titleNameDiscriminator = `[${clan.tag}]`;
const response = fetch(data.download_url!);
const players = (await response.then((response) =>
response.json(),
)) as BlitzkriegRatingsLeaderboard;
const playerIndex = players.findIndex(
(player) => player.id === id,
);
if (playerIndex === -1) {
throw throwError(
`${accountInfo[id].nickname} didn't player ratings in season ${season}`,
'This player did not participate in this season or did not get past calibration.',
);
}
const halfRange = Math.round(limit / 2);
const trimmed = players.slice(
playerIndex - halfRange,
playerIndex + halfRange + 1,
);
const ids = trimmed.map((player) => player.id).join(',');
const clanData = await getWargamingResponse<PlayerClanData>(
`https://api.wotblitz.${region}/wotb/clans/accountinfo/?application_id=${secrets.WARGAMING_APPLICATION_ID}&account_id=${ids}&extra=clan`,
);
return trimmed.map((player, index) => ({
id: player.id,
score: player.score,
position: playerIndex + index + 1,
clan: clanData[player.id]!.clan?.tag,
nickname: clanData[player.id]!.account_name,
}));
});
midnightLeaderboard =
subcommandGroup === 'current'
? regionRatingsInfo.detail === undefined
? await getMidnightLeaderboard(
region,
regionRatingsInfo.current_season,
)
: undefined
: await getMidnightLeaderboard(region, season!);
if (clan && neighbors) titleNameDiscriminator = `[${clan.tag}]`;
players = neighbors;
titleImage = clan
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png`
titleImage = neighbors
? clan
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png`
: undefined
: undefined;
titleName = accountInfo[id].nickname;
titleName = neighbors
? accountInfo[id].nickname
: `No Ongoing Season - ${REGION_NAMES_SHORT[region]}`;
titleDescription = 'Ratings neighbours';
playersBefore = neighbors[0].number - 1;
playersBefore = neighbors ? neighbors[0].position - 1 : 0;
playersAfter =
regionRatingsInfo.count - neighbors[neighbors.length - 1].number;
regionRatingsInfo.detail === undefined
? neighbors
? regionRatingsInfo.count -
neighbors[neighbors.length - 1].position
: 0
: 0;
playerId = id;
}
const items = players.map((player) => {
const reward = regionRatingsInfo.rewards.find(
(reward) =>
player.number >= reward.from_position &&
player.number <= reward.to_position,
);
const items = players?.map((player) => {
const reward =
regionRatingsInfo.detail === undefined
? regionRatingsInfo.rewards.find(
(reward) =>
player.position >= reward.from_position &&
player.position <= reward.to_position,
)
: undefined;
const midnightIndex = midnightLeaderboard?.findIndex(
(item) => item.id === player.spa_id,
(item) => item.id === player.id,
);
const midnightScore =
midnightIndex === undefined
? player.score
: midnightLeaderboard![midnightIndex].score;
const midnightPosition =
midnightIndex === undefined ? player.number : midnightIndex + 1;
midnightIndex === undefined ? player.position : midnightIndex + 1;
return (
<Leaderboard.Item
nickname={player.nickname}
score={player.score}
position={player.number}
clan={player.clan_tag}
position={player.position}
clan={player.clan}
reward={reward}
deltaScore={player.score - midnightScore}
deltaPosition={midnightPosition - player.number}
key={player.spa_id}
highlight={player.spa_id === playerId}
deltaPosition={midnightPosition - player.position}
key={player.id}
highlight={player.id === playerId}
/>
);
});
@@ -284,11 +525,15 @@ export const ratingsCommand = new Promise<CommandRegistryRaw>(
description={`${titleDescription} • ${new Date().toDateString()}`}
/>
<Leaderboard.Root>
{playersBefore > 0 && <Leaderboard.Gap number={playersBefore} />}
{items}
{playersAfter > 0 && <Leaderboard.Gap number={playersAfter} />}
</Leaderboard.Root>
{items && (
<Leaderboard.Root>
{playersBefore > 0 && (
<Leaderboard.Gap number={playersBefore} />
)}
{items}
{playersAfter > 0 && <Leaderboard.Gap number={playersAfter} />}
</Leaderboard.Root>
)}
</Wrapper>
);
},
-1
View File
@@ -11,7 +11,6 @@ import { ClanList } from '../types/clanList';
export const searchClansCommand: CommandRegistry = {
inProduction: true,
inDevelopment: true,
inPublic: true,
command: new SlashCommandBuilder()
-1
View File
@@ -12,7 +12,6 @@ import { AccountList } from '../types/accountList';
export const searchPlayersCommand: CommandRegistry = {
inProduction: true,
inDevelopment: true,
inPublic: true,
command: new SlashCommandBuilder()
-1
View File
@@ -8,7 +8,6 @@ import { CommandRegistry } from '../events/interactionCreate';
export const searchTanksCommand: CommandRegistry = {
inProduction: true,
inDevelopment: true,
inPublic: true,
command: new SlashCommandBuilder()
+1 -2
View File
@@ -27,7 +27,6 @@ export const statsCommand = new Promise<CommandRegistryRaw>(async (resolve) => {
resolve({
inProduction: true,
inDevelopment: true,
inPublic: true,
command,
@@ -81,7 +80,7 @@ export const statsCommand = new Promise<CommandRegistryRaw>(async (resolve) => {
: null,
),
primaryButton(path, 'Refresh'),
linkButton(`${CYCLIC_API}/${path}`, 'Embed'),
// linkButton(`${CYCLIC_API}/${path}`, 'Embed'),
linkButton(
`https://www.blitzstars.com/player/${player.region}/${nickname}`,
'BlitzStars',
+1 -2
View File
@@ -15,7 +15,6 @@ import { AccountInfo } from '../types/accountInfo';
export const todayCommand: CommandRegistry = {
inProduction: true,
inDevelopment: true,
inPublic: true,
command: new SlashCommandBuilder()
@@ -67,7 +66,7 @@ export const todayCommand: CommandRegistry = {
return [
await today(player, cutoff, maximized, showTotal, false),
primaryButton(path, 'Refresh'),
linkButton(`${CYCLIC_API}/${path}`, 'Embed'),
// linkButton(`${CYCLIC_API}/${path}`, 'Embed'),
linkButton(
`https://www.blitzstars.com/player/${player.region}/${nickname}`,
'BlitzStars',
-1
View File
@@ -14,7 +14,6 @@ import { PlayerClanData } from '../types/playerClanData';
export const verifyCommand: CommandRegistry = {
inProduction: true,
inDevelopment: true,
inPublic: true,
command: new SlashCommandBuilder()
+5
View File
@@ -0,0 +1,5 @@
export default function Navbar() {
return (
<div style={{ display: 'flex', height: 100, backgroundColor: 'red' }}></div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { RatingsInfo } from '../../commands/ratings';
import { Region } from '../../constants/regions';
import { DATABASE_REPO } from '../database/getMidnightLeaderboard';
import { octokit } from '../express/octokit';
import throwError from '../node/throwError';
export default async function getArchivedRatingsInfo(
season: number,
region: Region,
) {
const { data } = await octokit.repos.getContent({
...DATABASE_REPO,
path: `${region}/ratings/${season}/info.json`,
});
if (Array.isArray(data) || data.type !== 'file')
throw throwError('Archived ratings info is malformed');
const content = Buffer.from(data.content, 'base64').toString();
const jsonContent = JSON.parse(content) as RatingsInfo & {
detail: undefined;
};
return jsonContent;
}
-22
View File
@@ -1,22 +0,0 @@
import { Region } from '../../constants/regions';
import { serverAndIdPattern } from '../discord/resolvePlayerFromCommand';
import throwError from '../node/throwError';
import listClans from './listClans';
export default async function getBlitzClan(clan: string) {
if (serverAndIdPattern.test(clan)) {
const [server, accountId] = clan.split('/');
return { server: server as Region, id: Number(accountId) };
} else {
const accounts = await listClans(clan);
if (accounts[0]) {
return { server: accounts[0].server, id: accounts[0].clan_id };
} else {
throw throwError(
'Could not find clan',
`I couldn't find clan \`${clan}\`. Try selecting a username from the search result.`,
);
}
}
}
+1
View File
@@ -8,5 +8,6 @@ export default async function getRatingsInfo(region: Region) {
region,
)}.wotblitz.com/en/api/rating-leaderboards/season/`,
);
return (await response.json()) as Promise<RatingsInfo>;
}
+5 -5
View File
@@ -3,8 +3,8 @@ import { Clan, ClanList } from '../../types/clanList';
import { secrets } from '../node/secrets';
import getWargamingResponse from './getWargamingResponse';
export type ClanListWithServer = (Clan & {
server: 'com' | 'eu' | 'asia';
export type ClanListWithRegion = (Clan & {
region: 'com' | 'eu' | 'asia';
})[];
export default async function listClans(search: string, limit = 9) {
@@ -21,7 +21,7 @@ export default async function listClans(search: string, limit = 9) {
value &&
value.map((account) => ({
...account,
server: 'com' as Region,
region: 'com' as Region,
})),
),
getWargamingResponse<ClanList>(
@@ -31,7 +31,7 @@ export default async function listClans(search: string, limit = 9) {
value &&
value.map((account) => ({
...account,
server: 'eu' as Region,
region: 'eu' as Region,
})),
),
getWargamingResponse<ClanList>(
@@ -41,7 +41,7 @@ export default async function listClans(search: string, limit = 9) {
value &&
value.map((account) => ({
...account,
server: 'asia' as Region,
region: 'asia' as Region,
})),
),
])
+5 -4
View File
@@ -15,12 +15,13 @@ export default async function getMidnightLeaderboard(
});
if (!Array.isArray(data) && data.type === 'file') {
const content = Buffer.from(data.content, 'base64').toString();
const jsonContent = JSON.parse(content) as BlitzkriegRatingsLeaderboard;
const response = await fetch(data.download_url!);
return jsonContent;
return (await response.json()) as BlitzkriegRatingsLeaderboard;
}
} catch (error) {
console.warn('No midnight leaderboard found');
console.warn(
`No midnight leaderboard found for season ${season}, region ${region}`,
);
}
}
+2 -2
View File
@@ -19,8 +19,8 @@ export default async function autocompleteClan(
? players.map(
(player) =>
({
name: `${player.name} (${REGION_NAMES[player.server]})`,
value: `${player.server}/${player.clan_id}`,
name: `${player.name} (${REGION_NAMES[player.region]})`,
value: `${player.region}/${player.clan_id}`,
}) satisfies ApplicationCommandOptionChoiceData<string>,
)
: [],
@@ -0,0 +1,27 @@
import { ChatInputCommandInteraction } from 'discord.js';
import { Region } from '../../constants/regions';
import listClans from '../blitz/listClans';
import throwError from '../node/throwError';
import { serverAndIdPattern } from './resolvePlayerFromCommand';
export default async function resolveClanFromCommand(
interaction: ChatInputCommandInteraction,
) {
const clan = interaction.options.getString('clan', true);
if (serverAndIdPattern.test(clan)) {
const [server, accountId] = clan.split('/');
return { region: server as Region, id: Number(accountId) };
} else {
const accounts = await listClans(clan);
if (accounts[0]) {
return { region: accounts[0].region, id: accounts[0].clan_id };
} else {
throw throwError(
'Could not find clan',
`I couldn't find clan \`${clan}\`. Try selecting a username from the search result.`,
);
}
}
}
+7 -8
View File
@@ -13,6 +13,7 @@ import {
SlashCommandSubcommandsOnlyBuilder,
} from 'discord.js';
import discord from '../../../discord.json' assert { type: 'json' };
import { clanEventCommand } from '../../commands/clanEvent';
import { debugCommand } from '../../commands/debug';
import { eligibleCommand } from '../../commands/eligible';
import { evolutionCommand } from '../../commands/evolution';
@@ -50,7 +51,6 @@ export type InteractionReturnable =
| Promise<InteractionIterableReturnable>;
export interface Registry {
inDevelopment: boolean;
inProduction: boolean;
}
@@ -99,6 +99,7 @@ export const COMMANDS_RAW: CommandRegistry[] = [
evolutionCommand,
statsCommand,
ratingsCommand,
clanEventCommand,
];
export const commands = Promise.all(COMMANDS_RAW).then((rawCommands) =>
@@ -122,14 +123,12 @@ export const publicCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] =
commands.then((awaitedCommands) => {
Object.entries(awaitedCommands).forEach(([, registry]) => {
if (isDev() ? registry.inDevelopment : registry.inProduction) {
const json = registry.command.toJSON();
const json = registry.command.toJSON();
if (registry.inPublic) {
publicCommands.push(json);
} else {
guildCommands.push(json);
}
if (registry.inPublic) {
publicCommands.push(json);
} else {
guildCommands.push(json);
}
});
-1
View File
@@ -6,7 +6,6 @@ import { RouteRegistry } from '../server';
export const statsRoute: RouteRegistry = {
route: '/stats/:type/:period',
inProduction: true,
inDevelopment: true,
async handler(req) {
const type = req.params.type as StatType;
-1
View File
@@ -5,7 +5,6 @@ import { RouteRegistry } from '../server';
export const todayRoute: RouteRegistry = {
route: '/today',
inProduction: true,
inDevelopment: true,
async handler(req) {
const player = resolvePlayerFromRequest(req);
-3
View File
@@ -1,7 +1,6 @@
import express, { Request, Response } from 'express';
import normalizeRouteReturnable from './core/express/normalizeRouteReturnable';
import withAutoRefresh from './core/express/withAutoRefresh';
import isDev from './core/node/isDev';
import { Registry } from './events/interactionCreate';
import { statsRoute } from './routes/stats';
import { todayRoute } from './routes/today';
@@ -22,8 +21,6 @@ export interface RouteRegistry<HandlesInteraction extends boolean = false>
const app = express();
([statsRoute, todayRoute] as RouteRegistry[]).forEach((registry) => {
if (!(isDev() ? registry.inDevelopment : registry.inProduction)) return;
app.get(registry.route, async (req, res) => {
console.log(req.originalUrl);
+12 -2
View File
@@ -9,7 +9,17 @@
"skipLibCheck": true,
"noEmit": true,
"resolveJsonModule": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"incremental": true,
"isolatedModules": true,
"plugins": [
{
"name": "next"
}
]
},
"include": ["src/**/*.ts", "src/components/Tanks/components/Row.tsx"]
"include": ["src/**/*", ".next/types/**/*.ts", "dist/website/types/**/*.ts"],
"exclude": ["node_modules"]
}
+407 -576
View File
File diff suppressed because it is too large. Load diff