Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion src/worker/core/GameSim.football/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { assert, beforeAll, test } from "vitest";
import { assert, beforeAll, describe, test } from "vitest";
import GameSim from "./index.ts";
import { player, team } from "../index.ts";
import loadTeams from "../game/loadTeams.ts";
Expand All @@ -7,6 +7,7 @@ import testHelpers from "../../../test/helpers.ts";
import Play from "./Play.ts";
import { DEFAULT_LEVEL } from "../../../common/budgetLevels.ts";
import { range } from "../../../common/utils.ts";
import type { teamPlayType } from "./types.ts";

export const genTwoTeams = async () => {
testHelpers.resetG();
Expand Down Expand Up @@ -63,6 +64,50 @@ test("kick a field goal when down 2 at the end of the game and there is little t
assert.strictEqual(game.getPlayType(), "fieldGoalLate");
});

describe("doTimeout", () => {
test("doesn't allow timeouts if team is out of timeouts", async () => {
const game = await initGameSim();
game.timeouts = [0, 3];
game.awaitingKickoff = undefined;
game.doTimeout(0, true, "fieldGoal");
assert.strictEqual(game.timeouts[0], 0);
game.doTimeout(1, true, "fieldGoal");
assert.strictEqual(game.timeouts[1], 2);
});

test("doesn't allow timeouts if a team is currently kicking an extra point", async () => {
const game = await initGameSim();
game.timeouts = [3, 3];
game.awaitingAfterTouchdown = true;
game.doTimeout(0, true, "extraPoint");
game.doTimeout(1, true, "extraPoint");
assert.strictEqual(game.timeouts[0], 3);
assert.strictEqual(game.timeouts[1], 3);
});

test("doesn't allow timeouts if the play is a kickoff", async () => {
// Test both offense and defense
const game = await initGameSim();
game.timeouts = [3, 3];
game.awaitingKickoff = 0;
game.doTimeout(0, true, "kickoff");
assert.strictEqual(game.timeouts[0], 3);
game.doTimeout(1, true, "kickoff");
assert.strictEqual(game.timeouts[1], 3);
});

test("doesn't allow timeouts if team is waiting for kickoff", async () => {
const game = await initGameSim();
game.timeouts = [3, 3];
game.overtimes = 1;
game.awaitingKickoff = 0;
const playType: teamPlayType = "kickoff";
game.doTimeout(0, true, playType);
assert.strictEqual(game.timeouts[0], 3);
game.doTimeout(1, true, playType);
assert.strictEqual(game.timeouts[1], 3);
});
});
test("kick a field goal on 4th down to take the lead late in the game", async () => {
const game = await initGameSim();
game.probMadeFieldGoal = () => 0.75;
Expand Down
237 changes: 235 additions & 2 deletions src/worker/core/GameSim.football/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import { PHASE, STARTING_NUM_TIMEOUTS } from "../../../common/index.ts";
import type { TeamNum } from "../../../common/types.ts";

const teamNums: [TeamNum, TeamNum] = [0, 1];

const FIELD_GOAL_DISTANCE_YARDS_ADDED_FROM_SCRIMMAGE = 17;

const ESTIMATED_SECONDS_PER_KNEEL = 42;
Expand Down Expand Up @@ -65,6 +64,7 @@ const fatigue = (energy: number, injured: boolean): number => {

return energy;
};
export type TeamPlayType = ReturnType<GameSim["getPlayType"]>;

class GameSim extends GameSimBase {
team: [TeamGameSim, TeamGameSim];
Expand Down Expand Up @@ -100,7 +100,7 @@ class GameSim extends GameSimBase {
awaitingAfterTouchdown = false;

awaitingAfterSafety = false;

awaitingFirstPlayOfQuarter = false;
awaitingKickoff: TeamNum | undefined;
lastHalfAwaitingKickoff: TeamNum;

Expand Down Expand Up @@ -390,6 +390,7 @@ class GameSim extends GameSimBase {
this.playUntimedPossession
) {
this.simPlay();
this.awaitingFirstPlayOfQuarter = false;
}

// Who gets the ball after halftime?
Expand All @@ -408,6 +409,9 @@ class GameSim extends GameSimBase {
}

quarter += 1;
// Once quarter is locked, set clock running to false until next play ran
this.isClockRunning = false;
this.awaitingFirstPlayOfQuarter = true;

this.team[0].stat.ptsQtrs.push(0);
this.team[1].stat.ptsQtrs.push(0);
Expand Down Expand Up @@ -1042,6 +1046,7 @@ class GameSim extends GameSimBase {
dtClockRunning = helpers.bound(clockAtEndOfPlay - 2, 0, Infinity);
}

// TODO: Modify this, to call the decrement clock function
// Clock
dt += dtClockRunning;
this.clock -= dt;
Expand Down Expand Up @@ -1305,6 +1310,232 @@ class GameSim extends GameSimBase {
this.updateTeamCompositeRatings();
}

/**
* This function determines whether a team should call a timeout, and if so, the reasoning behind it.
*
* If no timeout is called, return undefined.
* If a timeout is called to stop the clock, return "toStopClock".
* If a timeout is called for another reason, return "other".
* Stopping the clock supercedes reasons for calling a timeout.
* @param t
* @param playType
* @returns
*/
shouldCallTimeout(
t: TeamNum,
teamHasPossession: boolean,
playType: ReturnType<GameSim["getPlayType"]>,
twoMinuteWarning: boolean,
quarter: number,
distance: number,
timeRemaining: number,
currentDown: number,
yardsToGo: number,
): undefined | "toStopClock" | "other" {
// Timeout cannot be called
if (playType === "kickoff" || playType === "onsideKick") {
return undefined;
}
if (playType === "extraPoint" || playType === "twoPointConversion") {
return undefined;
}
if (this.awaitingFirstPlayOfQuarter) {
return undefined;
}
if (this.timeouts[t] <= 0) {
return undefined;
}
let finalDecision = undefined;
const randomChanceTimeout = Math.random();
const RED_ZONE_DISTANCE = 20;
// TODO: FIND THIS OUT. Its ball position - scrimmage, not sure if this is fully correct
const distanceToGoalLine = 100 - this.scrimmage;
const diff = this.team[this.o].stat.pts - this.team[this.d].stat.pts;
const ONE_SCORE_GAME = Math.abs(diff) <= 8;
const inFinalPeriod = quarter >= this.numPeriods;
const inPeriodBeforeHalftime = quarter === Math.ceil(this.numPeriods / 2);
const clockRunning = this.isClockRunning;
// Redzone playcall timeout. Different percentages based on down, defense, offense, and score.
// If score is close, timeout is more likely, especially for offense.
const redZonePlayCallTimeout = () => {
if (distance > RED_ZONE_DISTANCE) {
return undefined;
}
const OFFENSE_TIMEOUT_PROBABILITY = 0.08;
const DEFENSE_TIMEOUT_PROBABILITY = 0.05;
// TODO: Make timeout probability 2x higher if game is close for offense, 1.5x for defense
if (currentDown >= 3) {
if (yardsToGo <= 5) {
if (teamHasPossession) {
return randomChanceTimeout < OFFENSE_TIMEOUT_PROBABILITY
? "other"
: undefined;
} else {
return randomChanceTimeout < DEFENSE_TIMEOUT_PROBABILITY
? "other"
: undefined;
}
} else if (yardsToGo <= 10) {
if (teamHasPossession) {
return randomChanceTimeout < OFFENSE_TIMEOUT_PROBABILITY * 0.5
? "other"
: undefined;
} else {
return randomChanceTimeout < DEFENSE_TIMEOUT_PROBABILITY * 0.5
? "other"
: undefined;
}
}
}
return undefined;
};

// A team on fourth down is more likely to call a timeout, especially the offense.
// Or, if they are trying to draw an offsides or something? Maybe dont have to calculate for that
const fourthDownDecisionTimeout = () => {
if (currentDown !== 4) {
return undefined;
}
const OFFENSE_TIMEOUT_PROBABILITY = 0.15;
const DEFENSE_TIMEOUT_PROBABILITY = 0.1;
if (teamHasPossession) {
if (yardsToGo <= 2) {
return randomChanceTimeout < OFFENSE_TIMEOUT_PROBABILITY
? "other"
: undefined;
} else {
return randomChanceTimeout < OFFENSE_TIMEOUT_PROBABILITY * 1.5
? "other"
: undefined;
}
} else {
if (yardsToGo <= 2) {
return randomChanceTimeout < DEFENSE_TIMEOUT_PROBABILITY * 1.5
? "other"
: undefined;
} else {
return randomChanceTimeout < DEFENSE_TIMEOUT_PROBABILITY * 0.5
? "other"
: undefined;
}
}
};

// Icing usually happens if a team has more than 1 timeout. Does not usually happen with only 1 timeout.
// Do this only if teams are within 24 points
const iceFieldGoalTimeout = () => {
const DEFENSE_TIMEOUT_PROBABILITY = 0.9;
if (
(playType === "fieldGoalLate" || playType === "fieldGoal") &&
!teamHasPossession &&
timeRemaining < 0.5
) {
if (this.timeouts[t] <= 1) {
return randomChanceTimeout < DEFENSE_TIMEOUT_PROBABILITY * 0.05
? "other"
: undefined;
} else {
return randomChanceTimeout < DEFENSE_TIMEOUT_PROBABILITY
? "other"
: undefined;
}
}
return undefined;
};

// Offenses usually call a timeout when the amount of time is < 10 second, and they have the option to stop the clock, to win off of a field goal.
// This scenario we need to check IF teamHasPossession, AND timeRemaining is low, AND the playType is a field goal attempt, let time trickle down to 4 seconds, and then call a timeout.
// Or actually, we shouldn't be looking if playType is field goal
// Do this in 2nd quarter and 4th quarter, no need to do it otherwise
const prepareForFieldGoalTimeout = () => {
if (!inPeriodBeforeHalftime && !inFinalPeriod) {
return undefined;
}
if (!teamHasPossession) {
return undefined;
}
const OFFENSE_TIMEOUT_PROBABILITY = 0.9;
if (
(playType === "fieldGoalLate" || playType === "fieldGoal") &&
teamHasPossession &&
timeRemaining < 0.5
) {
return randomChanceTimeout < OFFENSE_TIMEOUT_PROBABILITY
? "other"
: undefined;
}
return undefined;
};

const clockManagementTimeout = () => {
// If game is a blowout, don't stop clock
if (Math.abs(diff) > 24) {
return undefined;
}
// Defense losing stops clock
if (!teamHasPossession && diff > 0) {
if (timeRemaining < 2.5) {
return "toStopClock";
}
}

// Offense losing/tied saves time
if (teamHasPossession && diff <= 0) {
if (timeRemaining < 1.5) {
return "toStopClock";
}
}
};
// BEFORE TWO MINUTE WARNING
// Small random chance of timeout due to:
// - playcall confusion
// - substitution issues
// - avoiding delay of game
if (!inFinalPeriod && !inPeriodBeforeHalftime) {
const OFFENSE_TIMEOUT_PROBABILITY = 0.01;
// - Include ONE_SCORE_GAME variable here? Otherwise we cannot reach the other flows.
const DEFENSE_TIMEOUT_PROBABILITY = 0.003;

const prob = teamHasPossession
? OFFENSE_TIMEOUT_PROBABILITY
: DEFENSE_TIMEOUT_PROBABILITY;
// Include scenarios here
redZonePlayCallTimeout();
fourthDownDecisionTimeout();
return randomChanceTimeout < prob ? "other" : undefined;
} else {
// IF we are in a close game (within 24 points, which could plausibly be overcome), timeouts are more common
if (inFinalPeriod && twoMinuteWarning) {
if (Math.abs(diff) <= 24) {
if (clockRunning) {
clockManagementTimeout();
}
if (ONE_SCORE_GAME) {
// If a close game, we have to check these scenarios for if a team should call a timeout
iceFieldGoalTimeout();
redZonePlayCallTimeout();
prepareForFieldGoalTimeout();
fourthDownDecisionTimeout();
}
}
}
if (inPeriodBeforeHalftime) {
if (clockRunning) {
clockManagementTimeout();
prepareForFieldGoalTimeout();
}
if (ONE_SCORE_GAME) {
// If a close game, we have to check these scenarios for if a team should call a timeout
prepareForFieldGoalTimeout();
iceFieldGoalTimeout();
redZonePlayCallTimeout();
fourthDownDecisionTimeout();
}
}
}

return finalDecision;
}
doTimeout(t: TeamNum, toStopClock: boolean) {
if (this.timeouts[t] <= 0) {
return;
Expand Down Expand Up @@ -1997,6 +2228,7 @@ class GameSim extends GameSimBase {
qb,
defender: p,
ydsReturn: yds,
// TODO: THIS IS WHAT WE ARE LOOKING FOR, A POSSESSION CHANGE!
});

let fumble = false;
Expand Down Expand Up @@ -2752,6 +2984,7 @@ class GameSim extends GameSimBase {
power
: undefined;
return random.choice(players, weightFunc);
// If there is an injury, we can call an injury timeout if possible
}

// Pass undefined as p for some team-only stats
Expand Down
1 change: 1 addition & 0 deletions tools/lib/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export const startServer = async (exposeToNetwork: boolean) => {
return new Promise<void>((resolve) => {
server.listen(port, exposeToNetwork ? "0.0.0.0" : "localhost", () => {
console.log(`Local: http://localhost:${port}`);
console.log("Worker console: chrome://inspect/#workers");
if (exposeToNetwork) {
console.log(`Network: http://${getIpAddress()}:${port}`);
} else {
Expand Down