Skip to content

Commit

Permalink
Basketball shootout
Browse files Browse the repository at this point in the history
  • Loading branch information
dumbmatter committed Apr 7, 2024
1 parent ecbb817 commit 37bc586
Show file tree
Hide file tree
Showing 7 changed files with 172 additions and 22 deletions.
8 changes: 6 additions & 2 deletions TODO
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
33 changes: 32 additions & 1 deletion src/ui/util/processLiveGameEvents.basketball.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down
23 changes: 13 additions & 10 deletions src/ui/views/LiveGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ const DEFAULT_SPORT_STATE = bySport<any>({
hockey: undefined,
});

type PlayByPlayEntry = {
type PlayByPlayEntryInfo = {
key: number;
score: ReactNode | undefined;
scoreDiff: number;
Expand All @@ -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")) {
Expand Down Expand Up @@ -204,7 +204,7 @@ const PlayByPlay = ({
playByPlayDivRef,
}: {
boxScore: any;
entries: PlayByPlayEntry[];
entries: PlayByPlayEntryInfo[];
playByPlayDivRef: React.MutableRefObject<HTMLDivElement | null>;
}) => {
useEffect(() => {
Expand Down Expand Up @@ -276,7 +276,7 @@ export const LiveGame = (props: View<"liveGame">) => {
DEFAULT_SPORT_STATE ? { ...DEFAULT_SPORT_STATE } : undefined,
);

const playByPlayEntries = useRef<PlayByPlayEntry[]>([]);
const playByPlayEntries = useRef<PlayByPlayEntryInfo[]>([]);

// 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(
Expand All @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -337,17 +340,17 @@ export const LiveGame = (props: View<"liveGame">) => {
score =
scoreT === 0 ? (
<>
<b>{boxScore.current.teams[0].pts}</b>-
<b>{boxScore.current.teams[0][ptsKey]}</b>-
<span className="text-body-secondary">
{boxScore.current.teams[1].pts}
{boxScore.current.teams[1][ptsKey]}
</span>
</>
) : scoreT === 1 ? (
<>
<span className="text-body-secondary">
{boxScore.current.teams[0].pts}
{boxScore.current.teams[0][ptsKey]}
</span>
-<b>{boxScore.current.teams[1].pts}</b>
-<b>{boxScore.current.teams[1][ptsKey]}</b>
</>
) : undefined;

Expand Down
18 changes: 18 additions & 0 deletions src/worker/core/GameSim.basketball/PlayByPlayLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
103 changes: 97 additions & 6 deletions src/worker/core/GameSim.basketball/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -52,7 +52,9 @@ type Stat =
| "stl"
| "tov"
| "tp"
| "tpa";
| "tpa"
| "sAtt"
| "sPts";
type PlayerNumOnCourt = number;
type TeamNum = 0 | 1;
type CompositeRating =
Expand Down Expand Up @@ -409,6 +411,8 @@ class GameSim extends GameSimBase {

this.checkGameWinner();

this.doShootout();

this.playByPlay.logEvent({
type: "gameOver",
});
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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,
);
}
}
}
Expand Down
1 change: 0 additions & 1 deletion src/worker/core/GameSim.football/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand Down
8 changes: 6 additions & 2 deletions src/worker/views/liveGame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand All @@ -93,11 +97,11 @@ export const boxScoreToLiveSim = async ({
? {
...p.injuryAtStart,
playingThrough: true,
}
}
: {
type: "Healthy",
gamesRemaining: 0,
};
};
}

for (const stat of resetStatsPlayer) {
Expand Down

0 comments on commit 37bc586

Please sign in to comment.