Refactor game logic and improve state management, add some tests

- Added `handleHit` function to handle hit types (single, double, triple, home run).
- Enhanced `handleBall` and `handlePitch` to track `scoredRunners` and update scores dynamically.
- Updated history to include both `gameState` and `lineupState`.
- Refactored UI components (`GameStateDisplay`) to use unified score structure and display detailed base states.
- Styled active lineup tab with visual emphasis.
- Introduced utility functions `encodeInning` and `decodeInning` for inning state management.
This commit is contained in:
2024-12-06 17:57:55 -06:00
parent 9cc3793a17
commit 7203ac4c2d
3 changed files with 394 additions and 171 deletions

View File

@@ -12,25 +12,187 @@ import Tabs from 'react-bootstrap/Tabs';
const MAX_INNINGS = 1
// Handle strikes
const handleStrike = (prevState) => {
let newState = {...prevState}
let message
const newStrikes = prevState.count.strikes + 1;
var endsAtBat = false
if (newStrikes === 3) {
// Strikeout
const outResult = handleOut(newState)
endsAtBat = outResult.endsAtBat
newState = {...newState, ...outResult.newState};
message = ["Strikeout!", outResult.message].join(' ')
} else {
newState.count.strikes = newStrikes
message = `Strike ${newStrikes}.`
}
return {newState, message, endsAtBat}
};
// Handle fouls
const handleFoul = (prevState) => {
let newState = {...prevState}
let message = "Foul."
const prevStrikes = prevState.count.strikes;
if (prevStrikes === 2) {
// Nothing
} else {
newState.count.strikes = prevStrikes + 1;
}
return {newState, message}
};
// Handle balls
const handleBall = (prevState) => {
const newBalls = prevState.count.balls + 1;
let newState = {...prevState}
let message = `Ball ${newBalls}.`
var endsAtBat = false
if (newBalls === 4) {
// Walk (advance batter to 1st base)
const advanceResult = advanceRunners(newState, [1,0,0,0]);
newState = { ...newState, ...resetCount(advanceResult.newState)};
endsAtBat = true
console.log(advanceResult)
message = [message, "Walk!", advanceResult.message].join(' ')
} else {
newState.count.balls = newBalls;
}
return { newState, message, endsAtBat };
};
// Handle outs
const handleOut = (prevState) => {
let newState = {...prevState}
newState = {...newState, ...resetCount(newState)}
const newOuts = newState.outs + 1;
let message = `${newOuts} out${newOuts > 1 ? "s":""}`
if (newOuts === 3) {
message += "!"
const switchInningResult = switchInning(newState);
newState = { ...newState, ...switchInningResult.newState};
message = [message, switchInningResult.message].join(" ")
} else {
message += "."
newState = { ...newState, outs: newOuts}
}
return {newState, message, endsAtBat: true};
};
const resetCount = (prevState) => {
return {...prevState, count:{strikes: 0, balls: 0}}
}
// Advance runners and reset 1st base
export const advanceRunners = (gameState, advancements) => {
// Combine batter and runners into a single array for easier processing
var newState = {...gameState}
let runnersInclBatter = [newState.currentBatter, ...newState.bases];
const newBasesInclBatter = [null, null, null, null]; // First, second, third, and batter
const scoredRunners = []; // Runners who score
var message = ""
// Recursive function to handle forced advancement
const moveRunner = (runner, targetBase) => {
if (targetBase >= 4) {
// If targetBase >= 4, the runner scores
scoredRunners.push(runner);
} else if (newBasesInclBatter[targetBase] === null) {
// If targetBase is unoccupied, place the runner there
newBasesInclBatter[targetBase] = runner;
} else {
// If targetBase is occupied, recursively move the occupier to the next base
const displacedRunner = newBasesInclBatter[targetBase];
newBasesInclBatter[targetBase] = runner; // Place current runner here
moveRunner(displacedRunner, targetBase + 1); // Move the displaced runner
}
};
// Process each runner and their advancement
for (let i = 3; i >= 0; i--) {
if (runnersInclBatter[i] !== null) {
const targetBase = i + advancements[i];
moveRunner(runnersInclBatter[i], targetBase);
}
// console.log(i, runnersInclBatter[i], runnersInclBatter)
}
newState.bases = newBasesInclBatter.slice(1);
if (scoredRunners.length > 0){message=`${scoredRunners.length} runner scored!`}
console.log(scoredRunners, scoredRunners.length)
return { newState, scoredRunners, message };
}
const isGameFinal = ({inning, isTopHalf, homeScore, awayScore}) => {
if (inning >= MAX_INNINGS) {
if (isTopHalf && homeScore > awayScore){
console.log(homeScore, awayScore, 'no need for bottom of last inning')
return true;
} else if (!isTopHalf && homeScore != awayScore) {
return true;
} else {
console.log('No decision yet! Keep playing')
return false;
}
} else {
return false
}
}
// Switch innings
const switchInning = (prevState) => {
console.dir(prevState)
let message
let newState = {...prevState,
...resetCount(prevState),
bases: [null, null, null],
outs: 0,
isTopHalf: !prevState.isTopHalf, // Switch halves
inning: prevState.isTopHalf ? prevState.inning : prevState.inning + 1 // Increment inning after Bottom
}
if (isGameFinal(newState)){
console.dir(newState)
newState = {...newState, isFinal: true}
message = "Game over!"
} else {
message = "Next inning."
}
return {newState, message}
};
function App() {
// Game state
const [gameState, setGameState] = useState({
bases: [null, null, null], // [1st, 2nd, 3rd]
currentBatter: null,
inning: 1,
isTopHalf: true, // true = Top of inning (away team bats), false = Bottom (home team bats)
outs: 0,
balls: 0, // Count for current at-bat
strikes: 0, // Count for current at-bat
currentBatterIndex: { away: 0, home: 0 }, // Tracks the current batter for each team
homeScore: 0,
awayScore: 0,
count: {
strikes: 0,
balls: 0
},
score: {
home: 0,
away: 0
},
isFinal: false
});
// Separate lineup state for both teams
const [lineups, setLineups] = useState({
away: [], // Away team's lineup
home: [], // Home team's lineup
const [lineupState, setLineupState] = useState({
away: {
players: [],
currentBatterIndex: 0
},
home: {
players: [],
currentBatterIndex: 0
}
});
const [history, setHistory] = useState([]);
@@ -39,52 +201,61 @@ function App() {
// Simulate fetching the lineups on component mount
useEffect(() => {
const fetchLineups = async () => {
const fetchLineups = async () => (
// Simulate an API call or database query
const fetchedLineups = {
{
away: ["Player A1", "Player A2", "Player A3", "Player A4", "Player A5"],
home: ["Player H1", "Player H2", "Player H3", "Player H4", "Player H5"],
};
setLineups(fetchedLineups);
};
}
);
fetchLineups();
fetchLineups()
.then((fetchedLineups)=>{
const newLineupState = {
home:{...lineupState.home, players: fetchedLineups.home},
away:{...lineupState.away, players: fetchedLineups.away}
}
setLineupState({...newLineupState})
return newLineupState
})
.then((newLineupState)=>{
const activeLineup = getActiveLineup();
const currentIndex = getCurrentBatterIndex();
const currentBatter = activeLineup[currentIndex];
console.log(activeLineup, currentIndex)
setGameState({...gameState, currentBatter});
});
}, []); // Runs only once when the component mounts
// Get the current batter's index
const getCurrentBatterIndex = () =>
gameState.isTopHalf
? gameState.currentBatterIndex.away
: gameState.currentBatterIndex.home;
? lineupState.away.currentBatterIndex
: lineupState.home.currentBatterIndex;
// Update the batter index for the active team
const advanceLineup = () => {
setGameState((prevState) => {
const teamKey = prevState.isTopHalf ? "away" : "home";
const nextIndex =
(prevState.currentBatterIndex[teamKey] + 1) % lineups[teamKey].length;
return {
...prevState,
currentBatterIndex: {
...prevState.currentBatterIndex,
[teamKey]: nextIndex,
},
};
});
const advanceLineup = (prevGameState, prevLineupState, setLineupState) => {
console.log('Advancing Lineup')
const teamKey = prevGameState.isTopHalf ? "away" : "home";
const {currentBatterIndex, players} = prevLineupState[teamKey]
var newLineupState = {...prevLineupState}
const nextBatterIndex =
(currentBatterIndex + 1) % players.length;
newLineupState[teamKey].currentBatterIndex = nextBatterIndex
setLineupState(newLineupState)
};
const renderLineup = (home_or_away) => {
const lineup = lineups[home_or_away];
const currentIndex = gameState.currentBatterIndex[home_or_away];
const {players, currentBatterIndex} = lineupState[home_or_away];
return (
<Table>
<tbody>
{lineup.map((player, index) => (
{players.map((player, index) => (
<tr>
<td
key={index}
className={`lineup-item ${index === currentIndex ? "at-bat" : ""}`}
className={`lineup-item ${index === currentBatterIndex ? "at-bat" : ""}`}
>
{player}
</td>
@@ -97,7 +268,7 @@ function App() {
// Function to determine which lineup is active
const getActiveLineup = () =>
gameState.isTopHalf ? lineups.away : lineups.home;
gameState.isTopHalf ? lineupState.away.players : lineupState.home.players;
// Add an entry to the game log
const addToGameLog = (message) => {
@@ -106,88 +277,55 @@ function App() {
};
// Function to handle play input
const handlePlay = (play) => {
const handlePitch = (pitch) => {
// Save current state to history before modifying it
setHistory((prevHistory) => [...prevHistory, { ...gameState }]);
// const currentBatter = getCurrentBatter();
const lineup = getActiveLineup();
const currentIndex = getCurrentBatterIndex();
var gameLogMessage = `${lineup[currentIndex]} batting: ${play}`
console.log(`Handle play ${play}`)
switch (play) {
case "strike":
const newState = handleStrike(gameState);
gameLogMessage += ` ${newState.isStrikeOut ? "3. Strikeout!": newState.strikes}`
break;
case "ball":
handleBall();
break;
case "out":
handleOut();
break;
case "foul":
handleFoul();
break;
case "hit":
advanceRunners();
break;
default:
console.warn("Unknown play:", play); // Optional: Handle unexpected values
}
console.log('made it to end')
addToGameLog(gameLogMessage)
};
// Handle strikes
const handleStrike = (prevState) => {
let newState
let isStrikeOut = false
const newStrikes = prevState.strikes + 1;
if (newStrikes === 3) {
// Strikeout
handleOut();
isStrikeOut = true
newState = { ...prevState, strikes: 0, balls: 0 }; // Reset count
const currentBatter = lineup[currentIndex];
const gameLogMessageArray = [`${currentBatter} batting: `]
console.log(`Handle play ${pitch}`)
let pitchResultState = {...gameState}
pitchResultState.currentBatter = currentBatter
var endsAtBat
var scoredRunners
if (pitch === "strike") {
const result = handleStrike(pitchResultState)
const {newState, message} = result
endsAtBat = result.endsAtBat
gameLogMessageArray.push(message)
pitchResultState = { ...pitchResultState, ...newState };
} else if (pitch === "foul") {
const result = handleFoul(pitchResultState)
const {newState, message} = result
endsAtBat = result.endsAtBat
gameLogMessageArray.push(message)
pitchResultState = { ...pitchResultState, ...newState};
} else if (pitch === "ball") {
const result = handleBall(pitchResultState)
const {newState, message} = result
endsAtBat = result.endsAtBat
gameLogMessageArray.push(message)
pitchResultState = { ...pitchResultState, ...newState };
} else if (pitch === "out") {
const result = handleOut(pitchResultState)
const {newState, message} = result
endsAtBat = result.endsAtBat
gameLogMessageArray.push(message)
pitchResultState = { ...pitchResultState, ...newState };
} else if (pitch === "hit") {
const result = advanceRunners(pitchResultState, [1,0,0,0]);
const {newState, message} = result
gameLogMessageArray.push(message)
scoredRunners = result.scoredRunners
pitchResultState = {...pitchResultState, ...newState}
} else {
newState = { ...prevState, strikes: newStrikes };
console.warn("Unknown play:", pitch); // Optional: Handle unexpected values
}
setGameState(newState)
return {...newState, isStrikeOut}
};
// Handle fouls
const handleFoul = () => {
setGameState((prevState) => {
const newStrikes = prevState.strikes == 2 ? prevState.strikes : prevState.strikes + 1;
return { ...prevState, strikes: newStrikes };
});
};
// Handle balls
const handleBall = () => {
setGameState((prevState) => {
const newBalls = prevState.balls + 1;
if (newBalls === 4) {
// Walk (advance batter to 1st base)
advanceRunners();
return { ...prevState, strikes: 0, balls: 0 }; // Reset count
}
return { ...prevState, balls: newBalls };
});
};
// Handle outs
const handleOut = () => {
advanceLineup(); // Move to the next batter
setGameState((prevState) => {
const newOuts = prevState.outs + 1;
if (newOuts === 3) {
// Reset outs and switch inning/half
switchInning();
return { ...prevState, outs: 0, strikes: 0, balls: 0 }; // Reset count and outs
}
return { ...prevState, outs: newOuts, strikes: 0, balls: 0 }; // Reset count
});
setGameState(pitchResultState)
if (endsAtBat){advanceLineup(gameState, lineupState, setLineupState)}
addToGameLog([...gameLogMessageArray].join(' '))
};
const scoreRunner = (scoringRunner) => {
@@ -201,53 +339,6 @@ function App() {
})
}
// Advance runners and reset 1st base
const advanceRunners = () => {
setGameState((prevState) => {
const newBases = [...prevState.bases];
const activeLineup = getActiveLineup();
const currentBatter = activeLineup[getCurrentBatterIndex()];
for (let i = 2; i >= 0; i--) {
if (newBases[i]) {
if (i === 2) {
scoreRunner(newBases[i])
if (prevState.inning >= MAX_INNINGS && prevState.homeScore > prevState.awayScore) {
endGame();
}
} else {
newBases[i + 1] = newBases[i];
}
newBases[i] = null; // Clear current base
}
}
newBases[0] = currentBatter; // Add current batter to 1st base
advanceLineup(); // Move to the next batter
return { ...prevState, bases: newBases, strikes: 0, balls: 0 }; // Reset count
});
};
// Switch innings
const switchInning = () => {
setGameState((prevState) => {
if (prevState.inning >= MAX_INNINGS) {
if (prevState.isTopHalf && prevState.homeScore > prevState.awayScore){
endGame();
return {...prevState};
} else if (!prevState.isTopHalf && prevState.homeScore != prevState.awayScore) {
endGame();
return {...prevState};
} else {
console.log('No decision yet! Keep playing')
}
}
return {...prevState,
isTopHalf: !prevState.isTopHalf, // Switch halves
inning: prevState.isTopHalf ? prevState.inning : prevState.inning + 1, // Increment inning after Bottom
bases: [null, null, null]}
});
};
const endGame = () => {
setGameState((prevState)=>({...prevState, isFinal: true}))
}
@@ -309,14 +400,14 @@ function App() {
<Button className="" onClick={()=>handleUndo()} disabled={isUndoDisabled}>
<FontAwesomeIcon icon={faUndo} />
</Button>
<button className="play strike" onClick={() => handlePlay("strike")} disabled={gameState.isFinal}>Strike</button>
<button className="play ball" onClick={() => handlePlay("ball")} disabled={gameState.isFinal}>Ball</button>
<button className="play foul" onClick={() => handlePlay("foul")} disabled={gameState.isFinal}>Foul</button>
<button className="play out" onClick={() => handlePlay("out")} disabled={gameState.isFinal}>Out</button>
<button className="play strike" onClick={() => handlePitch("strike")} disabled={gameState.isFinal}>Strike</button>
<button className="play ball" onClick={() => handlePitch("ball")} disabled={gameState.isFinal}>Ball</button>
<button className="play foul" onClick={() => handlePitch("foul")} disabled={gameState.isFinal}>Foul</button>
<button className="play out" onClick={() => handlePitch("out")} disabled={gameState.isFinal}>Out</button>
<Button className="play hit" onClick={() => handlePlay("hit")}>1</Button>
<Button className="play hit" onClick={() => handlePlay("hit")}>2</Button>
<Button className="play hit" onClick={() => handlePlay("hit")}>3</Button>
<Button className="play hit" onClick={() => handlePitch("hit")}>1</Button>
<Button className="play hit" onClick={() => handlePitch("hit")}>2</Button>
<Button className="play hit" onClick={() => handlePitch("hit")}>3</Button>
</section>
<section>

View File

@@ -1,8 +1,140 @@
import { render, screen } from '@testing-library/react';
import App from './App';
import { advanceRunners } from './App.js';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
// test('renders learn react link', () => {
// render(<App />);
// const linkElement = screen.getByText(/learn react/i);
// expect(linkElement).toBeInTheDocument();
// });
describe('advanceRunners', () => {
it('should advance the batter only on a walk', () => {
const batter = "Batter";
const prevGameState = {bases: ["Player2", null, null], currentBatter: "Batter"}
const advancements = [1, 0, 0, 0];
const {newState, scoredRunners} = advanceRunners(prevGameState, advancements);
const expected = {
newBases: ["Batter", "Player2", null],
scoredRunners: [],
};
expect(newState.bases).toEqual(expected.newBases)
expect(scoredRunners).toEqual(scoredRunners);
});
it('should advance runners correctly with forced advancement', () => {
const batter = "Batter";
const prevGameState = {bases: ["Player2", "Player1", null], currentBatter: "Batter"}
const advancements = [1, 0, 0, 0];
const {newState, scoredRunners} = advanceRunners(prevGameState, advancements);
const expected = {
newBases: ["Batter", "Player2", "Player1"],
scoredRunners: [],
};
expect(newState.bases).toEqual(expected.newBases)
expect(scoredRunners).toEqual(scoredRunners);
});
it('should handle a double correctly', () => {
const batter = "Batter";
const prevGameState = {bases: ["Player1", "Player2", "Player3"], currentBatter: "Batter"}
const advancements = [2, 2, 2, 1];
const {newState, scoredRunners} = advanceRunners(prevGameState, advancements);
const expected = {
newBases: [null, "Batter" , "Player1"],
scoredRunners: ["Player3", "Player2"],
};
expect(newState.bases).toEqual(expected.newBases)
expect(scoredRunners).toEqual(scoredRunners);
});
it('should handle a triple correctly', () => {
const batter = "Batter";
const prevGameState = {bases: ["Player1", "Player2", "Player3"], currentBatter: "Batter"}
const advancements = [3, 3, 2, 1];
const {newState, scoredRunners} = advanceRunners(prevGameState, advancements);
const expected = {
newBases: [null, null, "Batter"],
scoredRunners: ["Player3", "Player2", "Player1"],
};
expect(newState.bases).toEqual(expected.newBases)
expect(scoredRunners).toEqual(scoredRunners);
});
it('should handle a home run correctly', () => {
const batter = "Batter";
const prevGameState = {bases: ["Player1", "Player2", "Player3"], currentBatter: "Batter"}
const advancements = [4, 3, 2, 1];
const {newState, scoredRunners} = advanceRunners(prevGameState, advancements);
const expected = {
newBases: [null, null, null],
scoredRunners: ["Player3", "Player2", "Player1", "Batter"],
};
expect(newState.bases).toEqual(expected.newBases)
expect(scoredRunners).toEqual(scoredRunners);
});
it('should handle no runners correctly', () => {
const batter = "Batter";
const prevGameState = {bases: [null, null, null, null], currentBatter: "Batter"}
const advancements = [0, 0, 0, 0];
const {newState, scoredRunners} = advanceRunners(prevGameState, advancements);
const expected = {
newBases: [null, null, null],
scoredRunners: [],
};
expect(newState.bases).toEqual(expected.newBases)
expect(scoredRunners).toEqual(scoredRunners);
});
it('should handle fully loaded bases with a walk correctly', () => {
const batter = "Batter";
const prevGameState = {bases: ["Player1", "Player2", "Player3"], currentBatter: "Batter"}
const advancements = [1, 0, 0, 0];
const {newState, scoredRunners} = advanceRunners(prevGameState, advancements);
const expected = {
newBases: ["Batter", "Player1", "Player2"],
scoredRunners: ["Player3"],
};
expect(newState.bases).toEqual(expected.newBases)
expect(scoredRunners).toEqual(scoredRunners);
});
it('should handle single with runner on second base correctly', () => {
const batter = "Batter";
const prevGameState = {bases: [null, "Player1", null], currentBatter: "Batter"}
const advancements = [1, 1, 1, 1];
const {newState, scoredRunners} = advanceRunners(prevGameState, advancements);
const expected = {
newBases: ["Batter", null, "Player1"],
scoredRunners: [],
};
expect(newState.bases).toEqual(expected.newBases)
expect(scoredRunners).toEqual(scoredRunners);
});
it('should handle double with runner on second base correctly', () => {
const batter = "Batter";
const prevGameState = {bases: [null, "Player1", null], currentBatter: "Batter"}
const advancements = [2, 2, 2, 1];
const {newState, scoredRunners} = advanceRunners(prevGameState, advancements);
const expected = {
newBases: [null, "Batter", null],
scoredRunners: ["Player1"],
};
expect(newState.bases).toEqual(expected.newBases)
expect(scoredRunners).toEqual(scoredRunners);
});
it('should handle walk with runner on third base correctly', () => {
const batter = "Batter";
const prevGameState = {bases: [null, null, "Player1"], currentBatter: "Batter"}
const advancements = [1, 0, 0, 0];
const {newState, scoredRunners} = advanceRunners(prevGameState, advancements);
const expected = {
newBases: ["Batter", null, "Player1"],
scoredRunners: [],
};
expect(newState.bases).toEqual(expected.newBases)
expect(scoredRunners).toEqual(scoredRunners);
});
});

View File

@@ -13,7 +13,7 @@ function Inning({inning, isTopHalf}){
)
}
function GameStateDisplay({inning, bases, isTopHalf, outs, balls, strikes, awayScore, homeScore, isFinal }){
function GameStateDisplay({inning, bases, isTopHalf, outs, count, awayScore, homeScore, isFinal }){
return (
<div className="gameState">
<header>Scoreboard</header>
@@ -21,7 +21,7 @@ function GameStateDisplay({inning, bases, isTopHalf, outs, balls, strikes, away
<div>
<Row>
<Col><Inning inning={inning} isTopHalf={isTopHalf}></Inning></Col>
<Col>{balls}-{strikes}</Col>
<Col>{count.balls}-{count.strikes}</Col>
<Col>{outs} outs</Col>
<Col>
<FontAwesomeIcon icon={fa1} className={`base ${bases[0] ? "occupied" : ""}`}></FontAwesomeIcon>