From 37bc586e6db2b932ac99276f7014515a6e946bbe Mon Sep 17 00:00:00 2001 From: Jeremy Scheff Date: Sun, 7 Apr 2024 19:26:25 -0400 Subject: [PATCH] Basketball shootout --- TODO | 8 +- .../util/processLiveGameEvents.basketball.tsx | 33 +++++- src/ui/views/LiveGame.tsx | 23 ++-- .../GameSim.basketball/PlayByPlayLogger.ts | 18 +++ src/worker/core/GameSim.basketball/index.ts | 103 +++++++++++++++++- src/worker/core/GameSim.football/index.ts | 1 - src/worker/views/liveGame.ts | 8 +- 7 files changed, 172 insertions(+), 22 deletions(-) diff --git a/TODO b/TODO index ee6d0909e4..7ba7451223 100644 --- a/TODO +++ b/TODO @@ -5,13 +5,17 @@ shootouts - UI displaying game results needs to handle it somehow - https://www.nhl.com/gamecenter/det-vs-fla/2024/03/30/2023021165 shows it as an extra period, resulting in 1 extra point for the winner - https://en.wikipedia.org/wiki/2022_FIFA_World_Cup#Knockout_stage shows # of PKs in parens after the actual # of goals -- specify number of rounds + - top bar + - game log + - schedule + - box score + - playoffs (1 game in round) +- stop after impossible to win - no injuries during shootout - if still tied, keep going one at a time - make sure it's not all 0, all sports should have some min prob of success - don't track any stats - no clock -- need to support different num overtimes in playoffs - if not infinity, then shootout happens - in writeGameStats, add sPts to gameStats.won and lost - make sure game-tying and game-winning appear correctly in text of clutch shots when there is a shootout - do shootout after calling checkGameWinner() diff --git a/src/ui/util/processLiveGameEvents.basketball.tsx b/src/ui/util/processLiveGameEvents.basketball.tsx index 316badf315..fe0c7f962b 100644 --- a/src/ui/util/processLiveGameEvents.basketball.tsx +++ b/src/ui/util/processLiveGameEvents.basketball.tsx @@ -254,6 +254,26 @@ export const getText = ( } } else if (event.type === "outOfBounds") { texts = [`Out of bounds, last touched by the ${event.on}`]; + } else if (event.type === "shootoutStart") { + texts = [ + `The game will now be decided by a three-point shootout with ${event.rounds} rounds!`, + ]; + } else if (event.type === "shootoutTeam") { + texts = [`${getName(event.pid)} steps up to the line`]; + } else if (event.type === "shootoutShot") { + const he = getPronoun("He"); + texts = event.made + ? ["It's good!", "Swish!", "It rattles around but goes in!"] + : [ + "It rims out!", + `${he} bricks it!`, + `${he} misses everything, airball!`, + ]; + weights = event.made ? [1, 0.25, 0.25] : [1, 0.1, 0.01]; + } else if (event.type === "shootoutTie") { + texts = [ + "The shootout is tied! Players will alternate shots until there is a winner", + ]; } if (texts) { @@ -371,6 +391,12 @@ const processLiveGameEvents = ({ )}${quarter}`; quarters.push(boxScore.quarterShort); } + } else if (e.type === "shootoutStart") { + boxScore.shootout = true; + boxScore.teams[0].sPts = 0; + boxScore.teams[0].sAtt = 0; + boxScore.teams[1].sPts = 0; + boxScore.teams[1].sAtt = 0; } if (e.type === "stat") { @@ -417,6 +443,9 @@ const processLiveGameEvents = ({ } else if (e.s === "gs") { const p = playersByPid[e.pid!]; p.inGame = true; + } else if (e.s === "sPts" || e.s === "sAtt") { + // Shootout + boxScore.teams[actualT!][e.s] += e.amt; } } else if (e.type !== "init") { text = getText(e, boxScore); @@ -425,7 +454,9 @@ const processLiveGameEvents = ({ e.type === "gameOver" || e.type === "period" || e.type === "overtime" || - e.type === "elamActive"; + e.type === "elamActive" || + e.type === "shootoutStart" || + e.type === "shootoutTie"; let time; if (eAny.clock !== undefined) { diff --git a/src/ui/views/LiveGame.tsx b/src/ui/views/LiveGame.tsx index b24bc96d0b..1494564829 100644 --- a/src/ui/views/LiveGame.tsx +++ b/src/ui/views/LiveGame.tsx @@ -104,7 +104,7 @@ const DEFAULT_SPORT_STATE = bySport({ hockey: undefined, }); -type PlayByPlayEntry = { +type PlayByPlayEntryInfo = { key: number; score: ReactNode | undefined; scoreDiff: number; @@ -117,7 +117,7 @@ type PlayByPlayEntry = { }; const PlayByPlayEntry = memo( - ({ boxScore, entry }: { boxScore: any; entry: PlayByPlayEntry }) => { + ({ boxScore, entry }: { boxScore: any; entry: PlayByPlayEntryInfo }) => { let scoreBlock = null; if (entry.score) { if (isSport("basketball")) { @@ -204,7 +204,7 @@ const PlayByPlay = ({ playByPlayDivRef, }: { boxScore: any; - entries: PlayByPlayEntry[]; + entries: PlayByPlayEntryInfo[]; playByPlayDivRef: React.MutableRefObject; }) => { useEffect(() => { @@ -276,7 +276,7 @@ export const LiveGame = (props: View<"liveGame">) => { DEFAULT_SPORT_STATE ? { ...DEFAULT_SPORT_STATE } : undefined, ); - const playByPlayEntries = useRef([]); + const playByPlayEntries = useRef([]); // Make sure to call setPlayIndex after calling this! Can't be done inside because React is not always smart enough to batch renders const processToNextPause = useCallback( @@ -291,10 +291,13 @@ export const LiveGame = (props: View<"liveGame">) => { const startSeconds = getSeconds(boxScore.current.time); + const shootout = !!boxScore.current.shootout; + const ptsKey = shootout ? "sPts" : "pts"; + // Save here since it is mutated in processLiveGameEvents const prevOuts = sportState.current?.outs; const prevPts = - boxScore.current.teams[0].pts + boxScore.current.teams[1].pts; + boxScore.current.teams[0][ptsKey] + boxScore.current.teams[1][ptsKey]; const output = processLiveGameEvents({ boxScore: boxScore.current, @@ -305,7 +308,7 @@ export const LiveGame = (props: View<"liveGame">) => { }); const text = output.text; const currentPts = - boxScore.current.teams[0].pts + boxScore.current.teams[1].pts; + boxScore.current.teams[0][ptsKey] + boxScore.current.teams[1][ptsKey]; const scoreDiff = currentPts - prevPts; overtimes.current = output.overtimes; @@ -337,17 +340,17 @@ export const LiveGame = (props: View<"liveGame">) => { score = scoreT === 0 ? ( <> - {boxScore.current.teams[0].pts}- + {boxScore.current.teams[0][ptsKey]}- - {boxScore.current.teams[1].pts} + {boxScore.current.teams[1][ptsKey]} ) : scoreT === 1 ? ( <> - {boxScore.current.teams[0].pts} + {boxScore.current.teams[0][ptsKey]} - -{boxScore.current.teams[1].pts} + -{boxScore.current.teams[1][ptsKey]} ) : undefined; diff --git a/src/worker/core/GameSim.basketball/PlayByPlayLogger.ts b/src/worker/core/GameSim.basketball/PlayByPlayLogger.ts index feee7cbbd7..4d96c1d6a2 100644 --- a/src/worker/core/GameSim.basketball/PlayByPlayLogger.ts +++ b/src/worker/core/GameSim.basketball/PlayByPlayLogger.ts @@ -336,6 +336,24 @@ type PlayByPlayEventInputNoScore = t: TeamNum; on: "offense" | "defense"; clock: number; + } + | { + type: "shootoutStart"; + rounds: number; + } + | { + type: "shootoutTeam"; + t: TeamNum; + pid: number; + } + | { + type: "shootoutShot"; + t: TeamNum; + pid: number; + made: boolean; + } + | { + type: "shootoutTie"; }; type PlayByPlayEventInput = diff --git a/src/worker/core/GameSim.basketball/index.ts b/src/worker/core/GameSim.basketball/index.ts index db4b69a438..83db2aa4cc 100644 --- a/src/worker/core/GameSim.basketball/index.ts +++ b/src/worker/core/GameSim.basketball/index.ts @@ -4,7 +4,7 @@ import jumpBallWinnerStartsThisPeriodWithPossession from "./jumpBallWinnerStarts import getInjuryRate from "./getInjuryRate"; import type { GameAttributesLeague, PlayerInjury } from "../../../common/types"; import GameSimBase from "../GameSimBase"; -import { range } from "../../../common/utils"; +import { maxBy, range } from "../../../common/utils"; import PlayByPlayLogger from "./PlayByPlayLogger"; import getWinner from "../../../common/getWinner"; @@ -52,7 +52,9 @@ type Stat = | "stl" | "tov" | "tp" - | "tpa"; + | "tpa" + | "sAtt" + | "sPts"; type PlayerNumOnCourt = number; type TeamNum = 0 | 1; type CompositeRating = @@ -409,6 +411,8 @@ class GameSim extends GameSimBase { this.checkGameWinner(); + this.doShootout(); + this.playByPlay.logEvent({ type: "gameOver", }); @@ -450,8 +454,88 @@ class GameSim extends GameSimBase { return out; } + doShootoutShot(t: TeamNum, p: PlayerGameSim) { + // 20% to 80% + const probMake = p.compositeRating.shootingThreePointer * 0.6 + 0.2; + + const made = Math.random() < probMake; + + this.recordStat(t, undefined, "sAtt"); + if (made) { + this.recordStat(t, undefined, "sPts"); + } + + this.playByPlay.logEvent({ + type: "shootoutShot", + t: t, + pid: p.id, + made, + }); + } + + doShootout() { + if ( + this.shootoutRounds <= 0 || + this.team[0].stat.pts !== this.team[1].stat.pts + ) { + return; + } + + this.shootout = true; + this.team[0].stat.sPts = 0; + this.team[0].stat.sAtt = 0; + this.team[1].stat.sPts = 0; + this.team[1].stat.sAtt = 0; + + this.playByPlay.logEvent({ + type: "shootoutStart", + rounds: this.shootoutRounds, + }); + + const shooters = teamNums.map(t => { + // Find best shooter - slight bias towards high usage players + return maxBy( + this.team[t].player, + p => + p.compositeRating.shootingThreePointer + + 0.2 * p.compositeRating.usage - + (p.injured ? 1000 : 0), + )!; + }) as [PlayerGameSim, PlayerGameSim]; + + const reversedTeamNums = [1, 0] as const; + + for (const t of reversedTeamNums) { + const shooter = shooters[t]; + + this.playByPlay.logEvent({ + type: "shootoutTeam", + t: t, + pid: shooter.id, + }); + + for (let i = 0; i < this.shootoutRounds; i++) { + this.doShootoutShot(t, shooter); + } + } + console.log(this.team[0].stat.sPts, this.team[1].stat.sPts); + + if (this.team[0].stat.sPts === this.team[1].stat.sPts) { + this.playByPlay.logEvent({ + type: "shootoutTie", + }); + + while (this.team[0].stat.sPts === this.team[1].stat.sPts) { + for (const t of reversedTeamNums) { + const shooter = shooters[t]; + this.doShootoutShot(t, shooter); + } + } + } + } + jumpBall() { - const jumpers = ([0, 1] as const).map(t => { + const jumpers = teamNums.map(t => { const ratios = this.ratingArray("jumpBall", t); const maxRatio = Math.max(...ratios); let ind = ratios.findIndex(ratio => ratio === maxRatio); @@ -2791,8 +2875,10 @@ class GameSim extends GameSimBase { * @param {string} s Key for the property of this.team[t].player[p].stat to increment. * @param {number} amt Amount to increment (default is 1). */ - recordStat(t: TeamNum, p: number, s: Stat, amt: number = 1) { - this.team[t].player[p].stat[s] += amt; + recordStat(t: TeamNum, p: number | undefined, s: Stat, amt: number = 1) { + if (p !== undefined) { + this.team[t].player[p].stat[s] += amt; + } if (s !== "courtTime" && s !== "benchTime" && s !== "energy") { if (s !== "gs") { @@ -2820,7 +2906,12 @@ class GameSim extends GameSimBase { } if (this.playByPlay !== undefined) { - this.playByPlay.logStat(t, this.team[t].player[p].id, s, amt); + this.playByPlay.logStat( + t, + p === undefined ? undefined : this.team[t].player[p].id, + s, + amt, + ); } } } diff --git a/src/worker/core/GameSim.football/index.ts b/src/worker/core/GameSim.football/index.ts index 9323acd46a..4bbe137e3a 100644 --- a/src/worker/core/GameSim.football/index.ts +++ b/src/worker/core/GameSim.football/index.ts @@ -206,7 +206,6 @@ class GameSim extends GameSimBase { type: "gameOver", clock: this.clock, }); - // this.checkGameWinner(); // Delete stuff that isn't needed before returning for (let t = 0; t < 2; t++) { diff --git a/src/worker/views/liveGame.ts b/src/worker/views/liveGame.ts index 2d6ca4e702..25f4420413 100644 --- a/src/worker/views/liveGame.ts +++ b/src/worker/views/liveGame.ts @@ -84,6 +84,10 @@ export const boxScoreToLiveSim = async ({ } } + // Special reset of shootout stats to undefined, since that is used in the UI to identify if we're in a shootout yet + delete t.sPts; + delete t.sAtt; + for (let j = 0; j < t.players.length; j++) { const p = t.players[j]; @@ -93,11 +97,11 @@ export const boxScoreToLiveSim = async ({ ? { ...p.injuryAtStart, playingThrough: true, - } + } : { type: "Healthy", gamesRemaining: 0, - }; + }; } for (const stat of resetStatsPlayer) {