diff --git a/commands/batter_savant.js b/commands/batter_savant.js new file mode 100644 index 0000000..724fed7 --- /dev/null +++ b/commands/batter_savant.js @@ -0,0 +1,20 @@ +const interactionHandlers = require('../modules/interaction-handlers.js'); +const { SlashCommandBuilder } = require('@discordjs/builders'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('batter_savant') + .setDescription('View the savant metrics for who is at the plate right now.'), + async execute (interaction) { + try { + await interactionHandlers.batterSavantHandler(interaction); + } catch (e) { + console.error(e); + if (interaction.deferred && !interaction.replied) { + await interaction.followUp('There was an error processing this command. If it persists, please reach out to the developer.'); + } else if (!interaction.replied) { + await interaction.reply('There was an error processing this command. If it persists, please reach out to the developer.'); + } + } + } +}; diff --git a/commands/pitcher_savant.js b/commands/pitcher_savant.js new file mode 100644 index 0000000..00f3684 --- /dev/null +++ b/commands/pitcher_savant.js @@ -0,0 +1,20 @@ +const interactionHandlers = require('../modules/interaction-handlers.js'); +const { SlashCommandBuilder } = require('@discordjs/builders'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('pitcher_savant') + .setDescription('View the savant metrics for who is on the mound right now.'), + async execute (interaction) { + try { + await interactionHandlers.pitcherSavantHandler(interaction); + } catch (e) { + console.error(e); + if (interaction.deferred && !interaction.replied) { + await interaction.followUp('There was an error processing this command. If it persists, please reach out to the developer.'); + } else if (!interaction.replied) { + await interaction.reply('There was an error processing this command. If it persists, please reach out to the developer.'); + } + } + } +}; diff --git a/modules/MLB-API-util.js b/modules/MLB-API-util.js index 7919442..2cd792a 100644 --- a/modules/MLB-API-util.js +++ b/modules/MLB-API-util.js @@ -64,6 +64,9 @@ const endpoints = { return 'https://baseballsavant.mlb.com/player-services/statcast-pitches-breakdown?playerId=' + personId + '&position=1&hand=&pitchBreakdown=pitches&timeFrame=yearly&season=' + new Date().getFullYear() + '&pitchType=&count=&updatePitches=true'; }, + savantPage: (personId, type) => { + return `https://baseballsavant.mlb.com/savant-player/${personId}?stats=statcast-r-${type}-mlb`; + }, xParks: (gamePk, playId) => { return 'https://baseballsavant.mlb.com/gamefeed/x-parks/' + gamePk + '/' + playId; }, @@ -222,6 +225,18 @@ module.exports = { return {}; } }, + savantPage: async (personId, type) => { + try { + return (await fetch(endpoints.savantPage(personId, type), + { + signal: AbortSignal.timeout(6000) + } + )).text(); + } catch (e) { + LOGGER.error(e); + return {}; + } + }, hitter: async (personId) => { return (await fetch(endpoints.hitter(personId))).json(); }, diff --git a/modules/command-util.js b/modules/command-util.js index df47100..06d0e36 100644 --- a/modules/command-util.js +++ b/modules/command-util.js @@ -6,6 +6,8 @@ const jsdom = require('jsdom'); const globals = require('../config/globals'); const puppeteer = require('puppeteer'); const LOGGER = require('./logger')(process.env.LOG_LEVEL?.trim() || globals.LOG_LEVEL.INFO); +const chroma = require('chroma-js'); +const ztable = require('ztable'); module.exports = { getLineupCardTable: async (game) => { @@ -249,6 +251,113 @@ module.exports = { return (await getScreenshotOfHTMLTables([table])); }, + getStatcastData: (savantText) => { + const statcast = /statcast: \[(?.+)],/.exec(savantText)?.groups.statcast; + const metricSummaries = /metricSummaryStats: {(?.+)},/.exec(savantText)?.groups.metricSummaries; + if (statcast) { + try { + const statcastJSON = JSON.parse('[' + statcast + ']'); + const metricSummaryJSON = JSON.parse('{' + metricSummaries + '}'); + const mostRecentStatcast = statcastJSON.findLast(set => set.year != null); + // object properties are not guaranteed to always be in the same order, so we need to find the most recent year of data + const mostRecentMetricYear = Object.keys(metricSummaryJSON) + .map(k => parseInt(k)) + .sort((a, b) => { + return a < b ? 1 : -1; + })[0]; + return { mostRecentStatcast, metricSummaryJSON, mostRecentMetricYear }; + } catch (e) { + console.error(e); + return {}; + } + } + return {}; + }, + + buildBatterSavantTable: async (statcast, metricSummaries) => { + const value = [ + { label: 'Batting Run Value', value: statcast.swing_take_run_value, metric: 'swing_take_run_value', percentile: statcast.percent_rank_swing_take_run_value }, + { label: 'Baserunning Run Value', value: statcast.runner_run_value, metric: 'runner_run_value', percentile: statcast.percent_rank_runner_run_value }, + { label: 'Fielding Run Value', value: statcast.fielding_run_value, metric: 'fielding_run_value', percentile: statcast.percent_rank_fielding_run_value } + ]; + const hitting = [ + { label: 'xwOBA', value: statcast.xwoba, metric: 'xwoba', percentile: statcast.percent_rank_xwoba }, + { label: 'xBA', value: statcast.xba, metric: 'xba', percentile: statcast.percent_rank_xba }, + { label: 'xSLG', value: statcast.xslg, metric: 'xslg', percentile: statcast.percent_rank_xslg }, + { label: 'Avg Exit Velocity', value: statcast.exit_velocity_avg, metric: 'exit_velocity_avg', percentile: statcast.percent_rank_exit_velocity_avg }, + { label: 'Barrel %', value: statcast.barrel_batted_rate, metric: 'barrel_batted_rate', percentile: statcast.percent_rank_barrel_batted_rate }, + { label: 'Hard-Hit %', value: statcast.hard_hit_percent, metric: 'hard_hit_percent', percentile: statcast.percent_rank_hard_hit_percent }, + { label: 'LA Sweet-Spot %', value: statcast.sweet_spot_percent, metric: 'sweet_spot_percent', percentile: statcast.percent_rank_sweet_spot_percent }, + { label: 'Bat Speed', value: statcast.avg_swing_speed, metric: 'avg_swing_speed', percentile: statcast.percent_rank_swing_speed }, + { label: 'Squared-Up %', value: statcast.squared_up_swing, metric: 'squared_up_swing', percentile: statcast.percent_rank_squared_up_swing }, + // Chase, Whiff, and K have the "shouldInvert" flag because, for them, high numbers = bad. + { label: 'Chase %', value: statcast.oz_swing_percent, metric: 'oz_swing_percent', percentile: statcast.percent_rank_chase_percent, shouldInvert: true }, + { label: 'Whiff %', value: statcast.whiff_percent, metric: 'whiff_percent', percentile: statcast.percent_rank_whiff_percent, shouldInvert: true }, + { label: 'K %', value: statcast.k_percent, metric: 'k_percent', percentile: statcast.percent_rank_k_percent, shouldInvert: true }, + { label: 'BB %', value: statcast.bb_percent, metric: 'bb_percent', percentile: statcast.percent_rank_bb_percent } + ]; + const fielding = [ + { label: 'OAA', value: statcast.outs_above_average, metric: 'outs_above_average', percentile: statcast.percent_rank_oaa }, + { label: 'Arm Value', value: statcast.fielding_run_value_arm, metric: 'fielding_run_value_arm', percentile: statcast.percent_rank_fielding_run_value_arm }, + { label: 'Arm Strength', value: statcast.arm_overall, metric: 'arm_overall', percentile: statcast.percent_rank_arm_overall } + ]; + const catching = [ + { label: 'Blocks Above Avg', value: statcast.blocks_above_average, metric: 'blocks_above_average', percentile: statcast.percent_rank_blocks_above_average }, + { label: 'CS Above Avg', value: statcast.cs_above_average, metric: 'cs_above_average', percentile: statcast.percent_rank_cs_above_average }, + { label: 'Framing', value: statcast.fielding_run_value_framing, metric: 'fielding_run_value_framing', percentile: statcast.percent_rank_fielding_run_value_framing }, + { label: 'Pop Time', value: statcast.pop_2b, metric: 'pop_2b', percentile: statcast.percent_rank_pop_2b } + ]; + const running = [ + { label: 'Sprint Speed', value: statcast.sprint_speed, metric: 'sprint_speed', percentile: statcast.percent_rank_sprint_speed } + ]; + const html = ` +
` + + '

Value

' + + buildSavantSection(value, metricSummaries) + + '

Hitting

' + + buildSavantSection(hitting, metricSummaries) + + '

Fielding

' + + buildSavantSection(fielding, metricSummaries) + + (statcast.blocks_above_average !== null ? '

Catching

' + buildSavantSection(catching, metricSummaries) : '') + + '

Running

' + + buildSavantSection(running, metricSummaries) + + '
'; + + return (await getScreenshotOfSavantTable(html)); + }, + + buildPitcherSavantTable: async (statcast, metricSummaries) => { + const value = [ + { label: 'Pitching Run Value', value: statcast.swing_take_run_value, metric: 'swing_take_run_value', percentile: statcast.percent_rank_swing_take_run_value }, + { label: 'Fastball Run Value', value: Math.round(statcast.pitch_run_value_fastball), metric: 'pitch_run_value_fastball', percentile: statcast.percent_rank_pitch_run_value_fastball }, + { label: 'Breaking Run Value', value: Math.round(statcast.pitch_run_value_breaking), metric: 'pitch_run_value_breaking', percentile: statcast.percent_rank_pitch_run_value_breaking }, + { label: 'Offspeed Run Value', value: Math.round(statcast.pitch_run_value_offspeed), metric: 'pitch_run_value_offspeed', percentile: statcast.percent_rank_pitch_run_value_offspeed } + ]; + const pitching = [ + { label: 'xERA', value: statcast.xera, metric: 'xera', percentile: statcast.percent_rank_xera, shouldInvert: true }, + { label: 'xBA', value: statcast.xba, metric: 'xba', percentile: statcast.percent_rank_xba, shouldInvert: true }, + { label: 'Fastball Velo', value: statcast.fastball_velo, metric: 'fastball_velo', percentile: statcast.percent_rank_fastball_velo }, + { label: 'Avg Exit Velocity', value: statcast.exit_velocity_avg, metric: 'exit_velocity_avg', percentile: statcast.percent_rank_exit_velocity_avg, shouldInvert: true }, + { label: 'Chase %', value: statcast.oz_swing_percent, metric: 'oz_swing_percent', percentile: statcast.percent_rank_chase_percent }, + { label: 'Whiff %', value: statcast.whiff_percent, metric: 'whiff_percent', percentile: statcast.percent_rank_whiff_percent }, + { label: 'K %', value: statcast.k_percent, metric: 'k_percent', percentile: statcast.percent_rank_k_percent }, + { label: 'BB %', value: statcast.bb_percent, metric: 'bb_percent', percentile: statcast.percent_rank_bb_percent, shouldInvert: true }, + { label: 'Barrel %', value: statcast.barrel_batted_rate, metric: 'barrel_batted_rate', percentile: statcast.percent_rank_barrel_batted_rate, shouldInvert: true }, + { label: 'Hard-Hit %', value: statcast.hard_hit_percent, metric: 'hard_hit_percent', percentile: statcast.percent_rank_hard_hit_percent, shouldInvert: true }, + { label: 'GB %', value: statcast.groundballs_percent, metric: 'groundballs_percent', percentile: statcast.percent_rank_groundballs_percent }, + { label: 'Extension', value: statcast.fastball_extension, metric: 'fastball_extension', percentile: statcast.percent_rank_fastball_extension } + ]; + const html = ` +
` + + '

Value

' + + buildSavantSection(value, metricSummaries) + + '

Pitching

' + + buildSavantSection(pitching, metricSummaries) + + '
'; + + return (await getScreenshotOfSavantTable(html)); + }, + screenInteraction: async (interaction) => { if (globalCache.values.nearestGames instanceof Error) { await interaction.followUp({ @@ -461,6 +570,120 @@ async function getScreenshotOfHTMLTables (tables) { return buffer; } +async function getScreenshotOfSavantTable (savantHTML) { + const browser = await puppeteer.launch({ + headless: true, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox' + ] + }); + const page = await browser.newPage(); + await page.setContent( + ` + ` + + savantHTML + ); + const element = await page.waitForSelector('#savant-table'); + const buffer = await element.screenshot({ + type: 'png', + omitBackground: false + }); + await browser.close(); + return buffer; +} + +function buildSavantSection (statCollection, metricSummaries) { + const scale = chroma.scale(['#325aa1', '#a8c1c3', '#c91f26']); + return statCollection.reduce((acc, value) => acc + (value.value !== null + ? ` +
+
${value.label}
+
+
${value.value}
+
${value.percentile || ' '} +
+
+
+ ` + : ''), ''); +} + async function getScreenshotOfLineScore (tables, inning, half, awayScore, homeScore, awayAbbreviation, homeAbbreviation) { const browser = await puppeteer.launch({ headless: true, @@ -512,3 +735,8 @@ async function getScreenshotOfLineScore (tables, inning, half, awayScore, homeSc await browser.close(); return buffer; } + +function caculateRoundedPercentileFromNormalDistribution (metric, value, mean, standardDeviation, shouldInvert) { + if (typeof value === 'string') { value = parseFloat(value); } + return shouldInvert ? (1.00 - ztable((value - mean) / standardDeviation)) : ztable((value - mean) / standardDeviation); +} diff --git a/modules/interaction-handlers.js b/modules/interaction-handlers.js index 66ab7b5..926d23e 100644 --- a/modules/interaction-handlers.js +++ b/modules/interaction-handlers.js @@ -464,6 +464,103 @@ module.exports = { }); }, + batterSavantHandler: async (interaction) => { + await interaction.deferReply(); + const currentLiveFeed = globalCache.values.game.currentLiveFeed; + if (currentLiveFeed === null || currentLiveFeed.gameData.status.abstractGameState !== 'Live') { + await interaction.followUp('No game is live right now!'); + return; + } + const batter = currentLiveFeed.liveData.plays.currentPlay.matchup.batter; + const text = await mlbAPIUtil.savantPage(batter.id, 'hitting'); + const statcastData = commandUtil.getStatcastData(text); + if (statcastData.mostRecentStatcast && statcastData.mostRecentMetricYear && statcastData.metricSummaryJSON) { + const batterInfo = await commandUtil.hydrateHitter(batter.id); + const attachment = new AttachmentBuilder(Buffer.from(batterInfo.spot), { name: 'spot.png' }); + const savantAttachment = new AttachmentBuilder((await commandUtil.buildBatterSavantTable( + statcastData.mostRecentStatcast, + statcastData.metricSummaryJSON[statcastData.mostRecentMetricYear.toString()])), { name: 'savant.png' }); + const abbreviations = commandUtil.getAbbreviations(currentLiveFeed); + const halfInning = currentLiveFeed.liveData.plays.currentPlay.about.halfInning; + const abbreviation = halfInning === 'top' + ? abbreviations.away + : abbreviations.home; + const inning = currentLiveFeed.liveData.plays.currentPlay.about.inning; + const myEmbed = new EmbedBuilder() + .setTitle(halfInning.toUpperCase() + ' ' + inning + ', ' + + abbreviations.away + ' vs. ' + abbreviations.home + ': Current Batter') + .setDescription( + '## ' + currentLiveFeed.liveData.plays.currentPlay.matchup.batSide.code + + 'HB ' + batter.fullName + ' (' + abbreviation + ')') + .setThumbnail('attachment://spot.png') + .setImage('attachment://savant.png') + .setColor((halfInning === 'top' + ? globalCache.values.game.awayTeamColor + : globalCache.values.game.homeTeamColor) + ); + await interaction.followUp({ + ephemeral: false, + files: [attachment, savantAttachment], + embeds: [myEmbed], + components: [], + content: '' + }); + } else { + await interaction.followUp({ + content: 'There was a problem fetching the savant metrics for this player.' + }); + } + }, + + pitcherSavantHandler: async (interaction) => { + await interaction.deferReply(); + const currentLiveFeed = globalCache.values.game.currentLiveFeed; + if (currentLiveFeed === null || currentLiveFeed.gameData.status.abstractGameState !== 'Live') { + await interaction.followUp('No game is live right now!'); + return; + } + const pitcher = currentLiveFeed.liveData.plays.currentPlay.matchup.pitcher; + const text = await mlbAPIUtil.savantPage(pitcher.id, 'pitching'); + const statcastData = commandUtil.getStatcastData(text); + if (statcastData.mostRecentStatcast && statcastData.mostRecentMetricYear && statcastData.metricSummaryJSON) { + const pitcherInfo = await commandUtil.hydrateProbable(pitcher.id); + const attachment = new AttachmentBuilder(Buffer.from(pitcherInfo.spot), { name: 'spot.png' }); + const savantAttachment = new AttachmentBuilder((await commandUtil.buildPitcherSavantTable( + statcastData.mostRecentStatcast, + statcastData.metricSummaryJSON[statcastData.mostRecentMetricYear.toString()])), { name: 'savant.png' }); + const abbreviations = commandUtil.getAbbreviations(currentLiveFeed); + const halfInning = currentLiveFeed.liveData.plays.currentPlay.about.halfInning; + const abbreviation = halfInning === 'top' + ? abbreviations.home + : abbreviations.away; + const inning = currentLiveFeed.liveData.plays.currentPlay.about.inning; + const myEmbed = new EmbedBuilder() + .setTitle(halfInning.toUpperCase() + ' ' + inning + ', ' + + abbreviations.away + ' vs. ' + abbreviations.home + ': Current Pitcher') + .setDescription( + '## ' + (pitcherInfo.handedness + ? pitcherInfo.handedness + 'HP **' + : '**') + (pitcher.fullName || 'TBD') + '** (' + abbreviation + ')') + .setThumbnail('attachment://spot.png') + .setImage('attachment://savant.png') + .setColor((halfInning === 'top' + ? globalCache.values.game.homeTeamColor + : globalCache.values.game.awayTeamColor) + ); + await interaction.followUp({ + ephemeral: false, + files: [attachment, savantAttachment], + embeds: [myEmbed], + components: [], + content: '' + }); + } else { + await interaction.followUp({ + content: 'There was a problem fetching the savant metrics for this player.' + }); + } + }, + scoringPlaysHandler: async (interaction) => { console.info(`SCORING PLAYS command invoked by guild: ${interaction.guildId}`); if (!globalCache.values.game.isDoubleHeader) { diff --git a/package-lock.json b/package-lock.json index 9a15c25..88c77e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "ascii-table": "^0.0.9", + "chroma-js": "^2.6.0", "color-contrast-checker": "^2.1.0", "discord.js": "^14.15.2", "jasmine": "^5.1.0", @@ -19,7 +20,8 @@ "puppeteer": "^22.10.0", "reconnecting-websocket": "^4.4.0", "sharp": "^0.32.6", - "ws": "^8.17.0" + "ws": "^8.17.0", + "ztable": "^1.0.7" }, "devDependencies": { "@babel/core": "^7.16.7", @@ -3344,6 +3346,11 @@ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" }, + "node_modules/chroma-js": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.6.0.tgz", + "integrity": "sha512-BLHvCB9s8Z1EV4ethr6xnkl/P2YRFOGqfgvuMG/MyCbZPrTA+NeiByY6XvgF0zP4/2deU2CXnWyMa3zu1LqQ3A==" + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -8204,6 +8211,11 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/ztable": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ztable/-/ztable-1.0.7.tgz", + "integrity": "sha512-jock8g1wqxu5tDMsXRyIm8nrUcBrmRQIH3o1yhxcndB4jazeqRve9ROvX3bDgH8wcoI/H1cwZua8grA661483A==" } } } diff --git a/package.json b/package.json index c689f30..a482efe 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "license": "ISC", "dependencies": { "ascii-table": "^0.0.9", + "chroma-js": "^2.6.0", "color-contrast-checker": "^2.1.0", "discord.js": "^14.15.2", "jasmine": "^5.1.0", @@ -21,7 +22,8 @@ "puppeteer": "^22.10.0", "reconnecting-websocket": "^4.4.0", "sharp": "^0.32.6", - "ws": "^8.17.0" + "ws": "^8.17.0", + "ztable": "^1.0.7" }, "devDependencies": { "@babel/core": "^7.16.7",