diff --git a/package.json b/package.json index e4b61903f..9f2ed30ea 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "dev": "yarn build --dev && yarn start", "start": "node --enable-source-maps --trace-warnings dist/index.cjs", "zip": "zip -r dist.zip dist", - "build": "node scripts/build", + "build": "yarn lint && node scripts/build", "lint": "tsc" }, "license": "MIT", @@ -19,7 +19,7 @@ "discord.js": "^14.9.0", "escape-html": "^1.0.3", "fuzzysort": "^2.0.4", - "lodash.startcase": "^4.4.0", + "lodash": "^4.17.21", "markdown-escape": "^2.0.0", "react": "^18.2.0", "satori": "^0.9.1" @@ -27,7 +27,7 @@ "devDependencies": { "@total-typescript/ts-reset": "^0.4.2", "@types/escape-html": "^1.0.2", - "@types/lodash.startcase": "^4.4.7", + "@types/lodash": "^4.14.195", "@types/markdown-escape": "^1.1.0", "@types/node": "^20.2.1", "@types/react": "^18.2.7", diff --git a/src/commands/evolution.tsx b/src/commands/evolution.tsx new file mode 100644 index 000000000..49939ae6f --- /dev/null +++ b/src/commands/evolution.tsx @@ -0,0 +1,189 @@ +import { ButtonBuilder, ButtonStyle, SlashCommandBuilder } from 'discord.js'; +import * as Graph from '../components/Graph/index.js'; +import NoData, { NoDataType } from '../components/NoData.js'; +import PoweredByBlitzStars from '../components/PoweredByBlitzStars.js'; +import TitleBar from '../components/TitleBar.js'; +import Wrapper from '../components/Wrapper.js'; +import { BLITZ_SERVERS } from '../constants/servers.js'; +import tanksAutocomplete from '../core/autocomplete/tanks.js'; +import usernameAutocomplete from '../core/autocomplete/username.js'; +import getBlitzAccount from '../core/blitz/getBlitzAccount.js'; +import getWargamingResponse from '../core/blitz/getWargamingResponse.js'; +import resolveTankId from '../core/blitz/resolveTankId.js'; +import resolveTankName from '../core/blitz/resolveTankName.js'; +import { tankopedia } from '../core/blitz/tankopedia.js'; +import getTankHistories from '../core/blitzstars/getTankHistories.js'; +import cmdName from '../core/interaction/cmdName.js'; +import { supportBlitzStars } from '../core/interaction/supportBlitzStars.js'; +import addPeriodSubCommands, { + Period, + RELATIVE_PERIOD_NAMES, +} from '../core/options/addPeriodSubCommands.js'; +import addTankChoices from '../core/options/addTankChoices.js'; +import addUsernameOption from '../core/options/addUsernameOption.js'; +import getPeriodDataFromSubcommand from '../core/options/getPeriodDataFromSubcommand.js'; +import { WARGAMING_APPLICATION_ID } from '../core/process/args.js'; +import { CommandRegistry } from '../events/interactionCreate.js'; +import { AccountInfo } from '../types/accountInfo.js'; +import { PlayerClanData } from '../types/playerClanData.js'; + +export default { + inProduction: true, + inDevelopment: true, + inPublic: true, + + command: new SlashCommandBuilder() + .setName(cmdName('evolution')) + .setDescription('Evolution of statistics') + .addSubcommandGroup((option) => + addPeriodSubCommands(option, (option) => + option.addStringOption(addUsernameOption), + ) + .setName('player') + .setDescription("Player's statistics"), + ) + .addSubcommandGroup((option) => + addPeriodSubCommands(option, (option) => + option + .addStringOption(addTankChoices) + .addStringOption(addUsernameOption), + ) + .setName('tank') + .setDescription("Tank's statistics"), + ), + + async execute(interaction) { + const commandGroup = interaction.options.getSubcommandGroup()!; + const { id, server } = await getBlitzAccount(interaction); + let nameDiscriminator: string | undefined; + let image: string | undefined; + const tankIdRaw = interaction.options.getString('tank')!; + let tankId: number; + + if (commandGroup === 'player') { + const clan = ( + await getWargamingResponse( + `https://api.wotblitz.${server}/wotb/clans/accountinfo/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}&extra=clan`, + ) + )[id]?.clan; + + if (clan) nameDiscriminator = `[${clan.tag}]`; + image = clan + ? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png` + : undefined; + } else if (commandGroup === 'tank') { + tankId = resolveTankId(tankIdRaw); + nameDiscriminator = `(${resolveTankName(tankId)})`; + image = tankopedia[tankId].images.normal; + } + + const { evolutionName, start, end } = + getPeriodDataFromSubcommand(interaction); + + let careerWinrate: Graph.Plot; + let careerBattles: Graph.Plot; + + if (commandGroup === 'player') { + } else { + const tankId = resolveTankId(tankIdRaw); + const histories = await getTankHistories(server, id, { + tankId, + start, + end, + includeLatestHistories: true, + includePreviousHistories: true, + }); + + careerWinrate = histories.map( + (history) => + [ + history.last_battle_time, + (history.all.wins / history.all.battles) * 100, + ] as Graph.PlotItem, + ); + careerBattles = histories.map( + (history) => + [history.last_battle_time, history.all.battles] as Graph.PlotItem, + ); + } + + const winrateYs = careerWinrate.map(([, y]) => y); + const maxWinrate = Math.max(...winrateYs); + const minWinrate = Math.min(...winrateYs); + const battleYs = careerBattles.map(([, y]) => y); + const maxBattle = Math.max(...battleYs); + const minBattle = Math.min(...battleYs); + + const accountInfo = await getWargamingResponse( + `https://api.wotblitz.${server}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${id}`, + ); + + return [ + + + + {/* goofy ahh bug forces me to call them as functions */} + {careerWinrate.length > 0 && ( + + {Graph.Line({ + // total battles + plot: careerBattles!, + color: Graph.LineColor.Blue, + })} + {Graph.Line({ + // career winrate + plot: careerWinrate!, + minY: minWinrate, + maxY: maxWinrate, + })} + + )} + + {careerWinrate.length === 0 && ( + + )} + + + , + new ButtonBuilder() + .setStyle(ButtonStyle.Link) + .setLabel('View on BlitzStars') + .setURL( + commandGroup === 'tank' + ? `https://www.blitzstars.com/player/com/${ + accountInfo[id].nickname + }/tank/${tankId!}` + : `https://www.blitzstars.com/player/com/${accountInfo[id].nickname}`, + ), + supportBlitzStars, + ]; + }, + + autocomplete: (interaction) => { + usernameAutocomplete(interaction); + tanksAutocomplete(interaction); + }, +} satisfies CommandRegistry; diff --git a/src/commands/ownedtanks.tsx b/src/commands/ownedtanks.tsx index 7edceec12..8a23ecf05 100644 --- a/src/commands/ownedtanks.tsx +++ b/src/commands/ownedtanks.tsx @@ -9,7 +9,11 @@ import usernameAutocomplete from '../core/autocomplete/username.js'; import getBlitzAccount from '../core/blitz/getBlitzAccount.js'; import getTankStats from '../core/blitz/getTankStats.js'; import getWargamingResponse from '../core/blitz/getWargamingResponse.js'; -import { TIER_ROMAN_NUMERALS, tankopedia } from '../core/blitz/tankopedia.js'; +import { + TIER_ROMAN_NUMERALS, + Tier, + tankopedia, +} from '../core/blitz/tankopedia.js'; import cmdName from '../core/interaction/cmdName.js'; import addUsernameOption from '../core/options/addUsernameOption.js'; import { WARGAMING_APPLICATION_ID } from '../core/process/args.js'; @@ -86,7 +90,7 @@ export default { {/* TODO: integrate some of these into title bar */} - {stats!.battles === 0 && } - {stats!.battles > 0 && commandGroup === 'player' && ( + {stats.battles === 0 && } + {stats.battles > 0 && commandGroup === 'player' && ( )} - {stats!.battles > 0 && ( + {stats.battles > 0 && ( )} diff --git a/src/components/Graph/components/Line.tsx b/src/components/Graph/components/Line.tsx new file mode 100644 index 000000000..f4b527769 --- /dev/null +++ b/src/components/Graph/components/Line.tsx @@ -0,0 +1,75 @@ +import { blue, green, red } from '@radix-ui/colors'; +import { GRAPH_HEIGHT, GRAPH_WIDTH } from './Root.js'; + +export enum LineColor { + Red, + Green, + Blue, +} + +const LINE_COLORS = { + [LineColor.Red]: red.red9, + [LineColor.Green]: green.green9, + [LineColor.Blue]: blue.blue9, +}; + +export enum LineStyle { + Solid, + Dashes, + DashesAndDots, +} + +const LINE_STYLES = { + [LineStyle.Solid]: undefined, + [LineStyle.Dashes]: '8, 8', + [LineStyle.DashesAndDots]: '1, 4, 8, 4', +}; + +export type PlotItem = [number, number]; +export type Plot = PlotItem[]; + +export interface LineProps { + plot: Plot; + minY?: number; + maxY?: number; + color?: LineColor; + style?: LineStyle; + lineWidth?: number; +} + +export function Line({ + plot, + minY: providedMinY, + maxY: providedMaxY, + color = LineColor.Red, + style = LineStyle.Solid, + lineWidth = 2, +}: LineProps) { + const xs = plot.map(([x]) => x); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const ys = plot.map(([, y]) => y); + const minY = providedMinY ?? Math.min(...ys); + const maxY = providedMaxY ?? Math.max(...ys); + const rangeX = maxX - minX; + const rangeY = maxY - minY; + const points = plot + .map( + ([x, y]) => + `${(x - minX) * (GRAPH_WIDTH / rangeX)},${ + GRAPH_HEIGHT - (y - minY) * (GRAPH_HEIGHT / rangeY) + }`, + ) + .join(' '); + + return ( + + ); +} diff --git a/src/components/Graph/components/Root.tsx b/src/components/Graph/components/Root.tsx new file mode 100644 index 000000000..dc68b133e --- /dev/null +++ b/src/components/Graph/components/Root.tsx @@ -0,0 +1,96 @@ +import { times } from 'lodash'; +import { ReactNode } from 'react'; +import { theme } from '../../../stitches.config.js'; +import { VerticalMargin, VerticalMarginAlign } from './VerticalMargin.js'; + +export const GRAPH_WIDTH = 512; +export const GRAPH_HEIGHT = 320; + +export interface MarginProps { + min: number; + max: number; + suffix?: string; +} + +export interface RootProps { + children: ReactNode; + leftVerticalMargin?: MarginProps; + rightVerticalMargin?: MarginProps; + verticalSeparations?: number; + xMinLabel: string; + xMaxLabel: string; +} + +export function Root({ + children, + leftVerticalMargin, + rightVerticalMargin, + verticalSeparations = 5, + xMinLabel, + xMaxLabel, +}: RootProps) { + return ( +
+
+ {leftVerticalMargin && ( + + )} + + + {times(verticalSeparations - 2, (index) => { + const y = GRAPH_HEIGHT * ((index + 1) / (verticalSeparations - 1)); + + return ( + + ); + })} + + {children} + + + {rightVerticalMargin && ( + + )} +
+ +
+ + {xMinLabel} + + + {xMaxLabel} + +
+
+ ); +} diff --git a/src/components/Graph/components/VerticalMargin.tsx b/src/components/Graph/components/VerticalMargin.tsx new file mode 100644 index 000000000..6edd08837 --- /dev/null +++ b/src/components/Graph/components/VerticalMargin.tsx @@ -0,0 +1,51 @@ +import { times } from 'lodash'; +import { theme } from '../../../stitches.config.js'; +import { MarginProps } from './Root.js'; + +export enum VerticalMarginAlign { + Left, + Right, +} + +export interface VerticalMarginProps extends MarginProps { + verticalSeparations: number; + align?: VerticalMarginAlign; +} + +export function VerticalMargin({ + verticalSeparations, + max, + min, + suffix, + align = VerticalMarginAlign.Left, +}: VerticalMarginProps) { + return ( +
+ {times(verticalSeparations, (index) => ( + + {`${( + (max - min) * + ((verticalSeparations - (index + 1)) / + (verticalSeparations - 1)) + + min + ).toFixed(0)}${suffix ?? ''}`} + + ))} +
+ ); +} diff --git a/src/components/Graph/index.ts b/src/components/Graph/index.ts new file mode 100644 index 000000000..bdb4bf3f9 --- /dev/null +++ b/src/components/Graph/index.ts @@ -0,0 +1,3 @@ +export * from './components/Line.js'; +export * from './components/Root.js'; +export * from './components/VerticalMargin.js'; diff --git a/src/core/blitz/resolveTankId.ts b/src/core/blitz/resolveTankId.ts index 097c41ac8..1364c35c1 100644 --- a/src/core/blitz/resolveTankId.ts +++ b/src/core/blitz/resolveTankId.ts @@ -2,7 +2,7 @@ import { go } from 'fuzzysort'; import errorWithCause from '../process/errorWithCause.js'; import { TANKS, tankopedia } from './tankopedia.js'; -export default async function resolveTankId(tank: string) { +export default function resolveTankId(tank: string) { const number = Number(tank); if (Number.isNaN(number)) { diff --git a/src/core/blitzstars/getTankHistories.ts b/src/core/blitzstars/getTankHistories.ts new file mode 100644 index 000000000..751d7b55f --- /dev/null +++ b/src/core/blitzstars/getTankHistories.ts @@ -0,0 +1,110 @@ +import { BlitzServer } from '../../constants/servers.js'; +import { AllStats } from '../../types/accountInfo.js'; +import getTankStats from '../blitz/getTankStats.js'; +import { emptyAllStats } from './getTankStatsOverTime.js'; + +export interface TankHistory { + all: AllStats; + last_battle_time: number; + mark_of_mastery: number; + battle_life_time: number; + account_id: number; + tank_id: number; +} + +export const emptyTankHistory: TankHistory = { + all: emptyAllStats, + last_battle_time: -Infinity, + mark_of_mastery: 0, + battle_life_time: 0, + account_id: 0, + tank_id: 0, +}; + +export type TankHistories = TankHistory[]; + +export interface GetTankHistoriesOptions { + start: number; + end: number; + includeLatestHistories: boolean; + includePreviousHistories: boolean; + tankId?: number; +} + +export const getTankHistoriesDefaultOptions: GetTankHistoriesOptions = { + start: -Infinity, + end: Infinity, + includeLatestHistories: false, + includePreviousHistories: false, +}; + +export default async function getTankHistories( + server: BlitzServer, + id: number, + options?: Partial, +) { + const mergedOptions = { ...getTankHistoriesDefaultOptions, ...options }; + const tankHistoriesResponse = await fetch( + `https://www.blitzstars.com/api/tankhistories/for/${id}/`, + ); + const tankHistories = (await tankHistoriesResponse.json()) as TankHistories; + const compliantTanks: number[] = []; + const lastBattles: Record = {}; + const lastNonCompliantIndex: Record = {}; + const hasCompliedOnce: Record = {}; + const filtered = tankHistories.filter( + ({ last_battle_time, tank_id, all: { battles } }, index) => { + const complies = + last_battle_time >= mergedOptions.start && + last_battle_time <= mergedOptions.end && + (mergedOptions.tankId === undefined || + tank_id === mergedOptions.tankId); + + if (complies && !hasCompliedOnce[tank_id]) { + hasCompliedOnce[tank_id] = true; + compliantTanks.push(tank_id); + } + if (!hasCompliedOnce[tank_id]) lastNonCompliantIndex[tank_id] = index; + lastBattles[tank_id] = battles; + + return complies; + }, + ); + + const latestNodes: TankHistories = []; + + if (mergedOptions.includeLatestHistories) { + (await getTankStats(server, id)).forEach((tankHistory) => { + if ( + tankHistory.all.battles > (lastBattles[tankHistory.tank_id] ?? 0) && + (mergedOptions.tankId === undefined || + tankHistory.tank_id === mergedOptions.tankId) + ) { + if (!hasCompliedOnce[tankHistory.tank_id]) { + // was played today for the first time... I think??? + hasCompliedOnce[tankHistory.tank_id] = true; + compliantTanks.push(tankHistory.tank_id); + } + latestNodes.push(tankHistory); + } + }); + } + + const previousNodes: TankHistories = []; + + if (mergedOptions.includePreviousHistories) { + compliantTanks.forEach((tankId) => { + if (tankId in lastNonCompliantIndex) { + previousNodes.push( + tankHistories[lastNonCompliantIndex[tankId]] ?? { + ...emptyTankHistory, + account_id: id, + tank_id: tankId, + }, + ); + } + }); + } + + return [...previousNodes, ...filtered, ...latestNodes]; +} diff --git a/src/core/blitzstars/getTankStatsOverTime.ts b/src/core/blitzstars/getTankStatsOverTime.ts index 20793764c..526f1f451 100644 --- a/src/core/blitzstars/getTankStatsOverTime.ts +++ b/src/core/blitzstars/getTankStatsOverTime.ts @@ -1,15 +1,9 @@ import { BlitzServer } from '../../constants/servers.js'; import { AllStats } from '../../types/accountInfo.js'; -import getTankStats from '../blitz/getTankStats.js'; - -export interface TankHistoryNode { - all: AllStats; - last_battle_time: number; - mark_of_mastery: number; - battle_life_time: number; - account_id: number; - tank_id: number; -} +import getTankHistories, { + TankHistories, + TankHistory, +} from './getTankHistories.js'; export const emptyAllStats: AllStats = { battles: 0, @@ -31,7 +25,7 @@ export const emptyAllStats: AllStats = { wins: 0, }; -export const emptyTankHistoryNode: TankHistoryNode = { +export const emptyTankHistoryNode: TankHistory = { account_id: 0, battle_life_time: 0, last_battle_time: 0, @@ -40,23 +34,18 @@ export const emptyTankHistoryNode: TankHistoryNode = { all: emptyAllStats, }; -export type TankHistory = TankHistoryNode[]; - export default async function getTankStatsOverTime( server: BlitzServer, id: number, start: number, end: number, ) { - const latestTankStatsRaw = await getTankStats(server, id); - const tankHistoriesResponse = await fetch( - `https://www.blitzstars.com/api/tankhistories/for/${id}/`, - ); - const tankHistories = (await tankHistoriesResponse.json()) as TankHistory; - const history = [...tankHistories, ...latestTankStatsRaw]; + const history = await getTankHistories(server, id, { + includeLatestHistories: true, + }); // sort them even though most won't be used - const tankSortedHistory: Record = {}; + const tankSortedHistory: Record = {}; const tankIds: number[] = []; history.forEach((node) => { if (!tankSortedHistory[node.tank_id]) { diff --git a/src/core/options/addPeriodSubCommands.ts b/src/core/options/addPeriodSubCommands.ts index 1bd9efdfc..ee949a6b0 100644 --- a/src/core/options/addPeriodSubCommands.ts +++ b/src/core/options/addPeriodSubCommands.ts @@ -2,15 +2,16 @@ import { SlashCommandSubcommandBuilder, SlashCommandSubcommandGroupBuilder, } from 'discord.js'; +import { PERIOD_NAMES } from './getPeriodDataFromSubcommand.js'; export type Period = 'today' | '30' | '60' | '90' | 'career' | 'custom'; -export const PERIOD_NAMES: Record = { - today: "Today's statistics", - '30': "30 days' statistics", - '60': "60 days' statistics", - '90': "90 days' statistics", - career: 'Career statistics', +export const RELATIVE_PERIOD_NAMES: Record = { + today: 'A day ago', + '30': '30 days ago', + '60': '60 days ago', + '90': '90 days ago', + career: 'Start of career', custom: 'Custom period', }; diff --git a/src/core/options/getPeriodDataFromSubcommand.ts b/src/core/options/getPeriodDataFromSubcommand.ts new file mode 100644 index 000000000..65b9879c2 --- /dev/null +++ b/src/core/options/getPeriodDataFromSubcommand.ts @@ -0,0 +1,52 @@ +import { CacheType, ChatInputCommandInteraction } from 'discord.js'; +import getPeriodNow from '../blitzstars/getPeriodNow.js'; +import getPeriodStart from '../blitzstars/getPeriodStart.js'; +import getTimeDaysAgo from '../blitzstars/getTimeDaysAgo.js'; +import { Period } from './addPeriodSubCommands.js'; + +export const PERIOD_NAMES: Record = { + today: "Today's statistics", + '30': "30 days' statistics", + '60': "60 days' statistics", + '90': "90 days' statistics", + career: 'Career statistics', + custom: 'Custom period', +}; + +export const EVOLUTION_PERIOD_NAMES: Record = { + today: "Today's evolution", + '30': "30 days' evolution", + '60': "60 days' evolution", + '90': "90 days' evolution", + career: 'Career evolution', + custom: 'Custom period', +}; + +export default function getPeriodDataFromSubcommand( + interaction: ChatInputCommandInteraction, +) { + const period = interaction.options.getSubcommand(true) as Period; + let statsName: string; + let evolutionName: string; + let start: number; + let end: number; + + if (period === 'custom') { + const startRaw = interaction.options.getInteger('start')!; + const endRaw = interaction.options.getInteger('end')!; + const startMin = Math.min(startRaw, endRaw); + const endMax = Math.max(startRaw, endRaw); + + statsName = `${startMin} to ${endMax} days' statistics`; + evolutionName = `${startMin} to ${endMax} days' evolution`; + start = getTimeDaysAgo(startMin); + end = getTimeDaysAgo(endMax); + } else { + statsName = PERIOD_NAMES[period]; + evolutionName = EVOLUTION_PERIOD_NAMES[period]; + start = getPeriodStart(period); + end = getPeriodNow(); + } + + return { statsName, evolutionName, start, end }; +} diff --git a/src/core/ui/render.tsx b/src/core/ui/render.tsx index 3b6aa7029..19ba744b1 100644 --- a/src/core/ui/render.tsx +++ b/src/core/ui/render.tsx @@ -41,6 +41,7 @@ export default async function render(element: ReactNode) { { data: robotoBlack, name: FONT_NAME, weight: 900 }, ], }); + const png = new Resvg(svg).render().asPng(); return png; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 233470bcc..a8ce85cc9 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -17,6 +17,7 @@ import { import discord from '../../discord.json' assert { type: 'json' }; import debug from '../commands/debug.js'; import eligible from '../commands/eligible.js'; +import evolution from '../commands/evolution.js'; import help from '../commands/help.js'; import inactive from '../commands/inactive.js'; import ownedtanks from '../commands/ownedtanks.js'; @@ -46,7 +47,10 @@ export type CommandRegistry = { inProduction: boolean; inPublic: boolean; - command: SlashCommandBuilder | SlashCommandSubcommandsOnlyBuilder; + command: + | SlashCommandBuilder + | SlashCommandSubcommandsOnlyBuilder + | Omit; autocomplete?: (interaction: AutocompleteInteraction) => void; } & ( | { @@ -79,6 +83,7 @@ const commands = ( today, verify, ping, + evolution, ] as CommandRegistry[] ).reduce>((accumulator, registry) => { return { ...accumulator, [registry.command.name]: registry }; diff --git a/src/stitches.config.ts b/src/stitches.config.ts index 11ee8047b..c7f27bdff 100644 --- a/src/stitches.config.ts +++ b/src/stitches.config.ts @@ -1,6 +1,9 @@ import { slate, slateDark } from '@radix-ui/colors'; -import { createColors } from 'bepaint'; +import { createBorderStyles, createColors } from 'bepaint'; // this isn't really a stitches config, but the object is compliant -export const theme = { colors: createColors(slateDark) }; +export const theme = { + colors: createColors(slateDark), + borderStyles: createBorderStyles(slateDark), +}; export const themeLight = { colors: createColors(slate) }; diff --git a/yarn.lock b/yarn.lock index 21f5d549c..630e9a2cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -295,14 +295,7 @@ resolved "https://registry.yarnpkg.com/@types/escape-html/-/escape-html-1.0.2.tgz#072b7b13784fb3cee9c2450c22f36405983f5e3c" integrity sha512-gaBLT8pdcexFztLSPRtriHeXY/Kn4907uOCZ4Q3lncFBkheAWOuNt53ypsF8szgxbEJ513UeBzcf4utN0EzEwA== -"@types/lodash.startcase@^4.4.7": - version "4.4.7" - resolved "https://registry.yarnpkg.com/@types/lodash.startcase/-/lodash.startcase-4.4.7.tgz#69857f17be0aa5ce74da4fa386e32e22c012258a" - integrity sha512-6v8FVOcfxdomO1Vammc1Zsah7/4aif/Lx16oQQ0WZmKVGF/Yf5c5m68LqI/ELOhKaKUr8KfnDdKrytdrThoRQw== - dependencies: - "@types/lodash" "*" - -"@types/lodash@*": +"@types/lodash@^4.14.195": version "4.14.195" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.195.tgz#bafc975b252eb6cea78882ce8a7b6bf22a6de632" integrity sha512-Hwx9EUgdwf2GLarOjQp5ZH8ZmblzcbTBC2wtQWNKARBSxM9ezRIAUpeDTgoQRAFB0+8CNWXVA9+MaSOzOF3nPg== @@ -524,11 +517,6 @@ lodash.snakecase@^4.1.1: resolved "https://registry.yarnpkg.com/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz#39d714a35357147837aefd64b5dcbb16becd8f8d" integrity sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw== -lodash.startcase@^4.4.0: - version "4.4.0" - resolved "https://registry.yarnpkg.com/lodash.startcase/-/lodash.startcase-4.4.0.tgz#9436e34ed26093ed7ffae1936144350915d9add8" - integrity sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg== - lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"