From 68434f885d49afd565f2dab54fddde32da4149be Mon Sep 17 00:00:00 2001 From: Frank Schwenk Date: Sun, 22 Jun 2025 11:05:51 +0200 Subject: [PATCH] feat(game-14.1): Implement undo, forfeit, log, and stats - Implements a robust undo feature using a state stack. - Adds a 'Forfeit' button to allow a player to concede the game. - Introduces a 'Game Log' to track all turns, fouls, and re-racks. - Calculates and displays post-game statistics (highest run, avg. pots/turn). - All changes are related to issue #21. --- src/components/App.jsx | 64 ++++++++++++- src/components/GameCompletionModal.jsx | 95 +++++++++++++++++-- src/components/GameCompletionModal.module.css | 47 ++++++++- src/components/GameDetail.jsx | 8 +- src/components/GameDetail.module.css | 58 +++++++++-- src/components/GameDetail141.jsx | 36 ++++++- 6 files changed, 277 insertions(+), 31 deletions(-) diff --git a/src/components/App.jsx b/src/components/App.jsx index 9450644..8962ce5 100644 --- a/src/components/App.jsx +++ b/src/components/App.jsx @@ -74,7 +74,8 @@ const App = () => { status: 'active', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - history: [], + log: [], + undoStack: [], }; if (gameType === '14/1 endlos') { @@ -101,9 +102,58 @@ const App = () => { }, []); // Game update for 14.1 - const handleUpdateGame = useCallback((updatedGame) => { - setGames(games => games.map(game => (game.id === currentGameId ? updatedGame : game))); - }, [currentGameId]); + const handleGameAction = useCallback((updatedGame) => { + const originalGame = games.find(game => game.id === currentGameId); + if (!originalGame) return; + + // Add the original state to the undo stack before updating + const newUndoStack = [...(originalGame.undoStack || []), originalGame]; + + const gameWithHistory = { + ...updatedGame, + undoStack: newUndoStack, + updatedAt: new Date().toISOString(), + }; + + setGames(games => games.map(game => (game.id === currentGameId ? gameWithHistory : game))); + + // Check for raceTo completion + if (gameWithHistory.raceTo) { + const winner = gameWithHistory.players.find(p => p.score >= gameWithHistory.raceTo); + if (winner) { + setCompletionModal({ open: true, game: gameWithHistory }); + } + } + }, [games, currentGameId]); + + const handleUndo = useCallback(() => { + const game = games.find(g => g.id === currentGameId); + if (!game || !game.undoStack || game.undoStack.length === 0) return; + + const lastState = game.undoStack[game.undoStack.length - 1]; + const newUndoStack = game.undoStack.slice(0, -1); + + setGames(g => g.map(gme => (gme.id === currentGameId ? { ...lastState, undoStack: newUndoStack } : gme))); + }, [games, currentGameId]); + + const handleForfeit = useCallback(() => { + const game = games.find(g => g.id === currentGameId); + if (!game) return; + + const winner = game.players.find((p, idx) => idx !== game.currentPlayer); + // In a 2 player game, this is simple. For >2, we need a winner selection. + // For now, assume the *other* player wins. This is fine for 2 players. + // We'll mark the game as complete with a note about the forfeit. + const forfeitedGame = { + ...game, + status: 'completed', + winner: winner.name, + forfeitedBy: game.players[game.currentPlayer].name, + updatedAt: new Date().toISOString(), + }; + setGames(g => g.map(gme => (gme.id === currentGameId ? forfeitedGame : gme))); + setCompletionModal({ open: true, game: forfeitedGame }); + }, [games, currentGameId]); // Score update const handleUpdateScore = useCallback((player, change) => { @@ -120,6 +170,8 @@ const App = () => { } return updated; })); + setCompletionModal({ open: false, game: null }); + setScreen('game-detail'); }, [currentGameId]); // Finish game @@ -271,8 +323,10 @@ const App = () => { g.id === currentGameId)} onUpdateScore={handleUpdateScore} - onUpdate={handleUpdateGame} onFinishGame={handleFinishGame} + onUpdateGame={handleGameAction} + onUndo={handleUndo} + onForfeit={handleForfeit} onBack={showGameList} /> diff --git a/src/components/GameCompletionModal.jsx b/src/components/GameCompletionModal.jsx index 641006d..1678085 100644 --- a/src/components/GameCompletionModal.jsx +++ b/src/components/GameCompletionModal.jsx @@ -2,6 +2,57 @@ import { h } from 'preact'; import modalStyles from './Modal.module.css'; import styles from './GameCompletionModal.module.css'; +const calculateStats = (game) => { + if (game.gameType !== '14/1 endlos' || !game.log) { + return null; + } + + const stats = {}; + game.players.forEach(p => { + stats[p.name] = { + totalPots: 0, + turnCount: 0, + highestRun: 0, + currentRun: 0, + }; + }); + + for (const entry of game.log) { + if (entry.type === 'turn') { + const playerStats = stats[entry.player]; + playerStats.turnCount += 1; + + if (entry.ballsPotted > 0) { + playerStats.totalPots += entry.ballsPotted; + playerStats.currentRun += entry.ballsPotted; + } else { + // Run ends on a 0-pot turn + playerStats.highestRun = Math.max(playerStats.highestRun, playerStats.currentRun); + playerStats.currentRun = 0; + } + } else if (entry.type === 'foul') { + const playerStats = stats[entry.player]; + playerStats.turnCount += 1; + // Run ends on a foul + playerStats.highestRun = Math.max(playerStats.highestRun, playerStats.currentRun); + playerStats.currentRun = 0; + } + } + + // Final check for runs that extend to the end of the game + Object.values(stats).forEach(playerStats => { + playerStats.highestRun = Math.max(playerStats.highestRun, playerStats.currentRun); + }); + + // Calculate averages + Object.keys(stats).forEach(playerName => { + const ps = stats[playerName]; + ps.avgPots = ps.turnCount > 0 ? (ps.totalPots / ps.turnCount).toFixed(2) : 0; + }); + + return stats; +}; + /** * Modal shown when a game is completed. * @param {object} props @@ -14,14 +65,28 @@ import styles from './GameCompletionModal.module.css'; */ const GameCompletionModal = ({ open, game, onConfirm, onClose, onRematch }) => { if (!open || !game) return null; - const playerNames = [game.player1, game.player2, game.player3].filter(Boolean); - const scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]); - const maxScore = Math.max(...scores); - // Find all winners (could be a tie) - const winners = playerNames.filter((name, idx) => scores[idx] === maxScore); - const winnerText = winners.length > 1 - ? `Unentschieden zwischen ${winners.join(' und ')}` - : `${winners[0]} hat gewonnen!`; + + let playerNames, scores, maxScore, winners, winnerText, gameStats; + + if (game.gameType === '14/1 endlos') { + playerNames = game.players.map(p => p.name); + scores = game.players.map(p => p.score); + gameStats = calculateStats(game); + } else { + playerNames = [game.player1, game.player2, game.player3].filter(Boolean); + scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]); + } + + if (game.forfeitedBy) { + winnerText = `${game.winner} hat gewonnen, da ${game.forfeitedBy} aufgegeben hat.`; + } else { + maxScore = Math.max(...scores); + winners = playerNames.filter((name, idx) => scores[idx] === maxScore); + winnerText = winners.length > 1 + ? `Unentschieden zwischen ${winners.join(' und ')}` + : `${winners[0]} hat gewonnen!`; + } + return (
diff --git a/src/components/GameCompletionModal.module.css b/src/components/GameCompletionModal.module.css index a3615e3..fd23c34 100644 --- a/src/components/GameCompletionModal.module.css +++ b/src/components/GameCompletionModal.module.css @@ -34,9 +34,10 @@ font-weight: 700; } .winner-announcement h3 { - font-size: 1.2rem; margin: 0; - color: #fff; + font-size: 1.5rem; + color: #2c3e50; + text-align: center; } .btn { flex: 1; @@ -64,4 +65,46 @@ font-size: 1rem; padding: 14px 0; } +} + +.stats-container { + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid #dee2e6; +} + +.stats-title { + text-align: center; + font-size: 1.3rem; + color: #495057; + margin-bottom: 1rem; +} + +.player-stats { + margin-bottom: 1rem; + padding: 0.75rem; + background-color: #f8f9fa; + border-radius: 4px; +} + +.player-name-stats { + font-weight: bold; + margin-bottom: 0.5rem; + color: #343a40; +} + +.stat-item { + display: flex; + justify-content: space-between; + font-size: 0.95rem; + color: #6c757d; +} + +.stat-item strong { + color: #212529; +} + +.btn { + padding: 0.75rem 1.5rem; + border-radius: 4px; } \ No newline at end of file diff --git a/src/components/GameDetail.jsx b/src/components/GameDetail.jsx index 086b376..1f4508f 100644 --- a/src/components/GameDetail.jsx +++ b/src/components/GameDetail.jsx @@ -8,15 +8,17 @@ import GameDetail141 from './GameDetail141.jsx'; * @param {object} props.game * @param {Function} props.onFinishGame * @param {Function} props.onUpdateScore - * @param {Function} props.onUpdate + * @param {Function} props.onUpdateGame + * @param {Function} props.onUndo + * @param {Function} props.onForfeit * @param {Function} props.onBack * @returns {import('preact').VNode|null} */ -const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdate, onBack }) => { +const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, onForfeit, onBack }) => { if (!game) return null; if (game.gameType === '14/1 endlos') { - return ; + return ; } const isCompleted = game.status === 'completed'; diff --git a/src/components/GameDetail.module.css b/src/components/GameDetail.module.css index 2bd1c27..eaf1218 100644 --- a/src/components/GameDetail.module.css +++ b/src/components/GameDetail.module.css @@ -202,19 +202,25 @@ } .foul-btn { - padding: 0.8rem 1.5rem; - font-size: 1rem; - font-weight: bold; - border-radius: 8px; - border: 1px solid #c0392b; - background-color: #e74c3c; - color: #fff; + flex-grow: 1; + background-color: #ffc107; /* Amber */ + color: #212529; + border: none; + padding: 0.75rem; + border-radius: var(--border-radius); cursor: pointer; + font-size: 1rem; transition: background-color 0.2s; } .foul-btn:hover { - background-color: #c0392b; + background-color: #ffca2c; +} + +.foul-btn:disabled { + background-color: #e0e0e0; + color: #9e9e9e; + cursor: not-allowed; } .foul-indicator { @@ -231,4 +237,40 @@ .foul-warning { background-color: #f39c12; color: #000; +} + +/* Game Log Styles */ +.game-log { + width: 100%; + margin-top: 1.5rem; + padding: 1rem; + background-color: #f8f9fa; + border-radius: var(--border-radius); + border: 1px solid #dee2e6; +} + +.log-title { + margin-top: 0; + margin-bottom: 0.5rem; + font-size: 1.2rem; + color: #495057; +} + +.log-list { + list-style-type: none; + padding: 0; + margin: 0; + max-height: 200px; + overflow-y: auto; + font-size: 0.9rem; +} + +.log-entry { + padding: 0.5rem 0.25rem; + border-bottom: 1px solid #e9ecef; + color: #6c757d; +} + +.log-entry:last-child { + border-bottom: none; } \ No newline at end of file diff --git a/src/components/GameDetail141.jsx b/src/components/GameDetail141.jsx index d175d6a..e445346 100644 --- a/src/components/GameDetail141.jsx +++ b/src/components/GameDetail141.jsx @@ -21,7 +21,28 @@ const StartingPlayerModal = ({ players, onSelect, onCancel }) => (
); -const GameDetail141 = ({ game, onUpdate, onBack }) => { +const GameLog = ({ log }) => { + if (!log || log.length === 0) { + return null; + } + + return ( +
+

Game Log

+
    + {log.slice().reverse().map((entry, index) => ( +
  • + {entry.type === 'rerack' && `Re-Rack (+${entry.ballsAdded} balls).`} + {entry.foul && `Foul by ${entry.player}: ${entry.foul} (${entry.totalDeduction} pts).`} + {entry.ballsPotted !== undefined && `${entry.player}: ${entry.ballsPotted} balls potted. Score: ${entry.newScore}`} +
  • + ))} +
+
+ ); +}; + +const GameDetail141 = ({ game, onUpdate, onUndo, onForfeit, onBack }) => { const handleSelectStartingPlayer = (playerIndex) => { onUpdate({ ...game, @@ -56,7 +77,7 @@ const GameDetail141 = ({ game, onUpdate, onBack }) => { players: updatedPlayers, ballsOnTable: remainingBalls, currentPlayer: nextPlayer, - history: [...game.history, { player: currentPlayer.name, ballsPotted, foulPoints, newScore, ballsOnTable: remainingBalls }], + log: [...(game.log || []), { type: 'turn', player: currentPlayer.name, ballsPotted, foulPoints, newScore, ballsOnTable: remainingBalls }], }); }; @@ -95,9 +116,10 @@ const GameDetail141 = ({ game, onUpdate, onBack }) => { ...game, players: updatedPlayers, currentPlayer: nextPlayer, - history: [ - ...game.history, + log: [ + ...(game.log || []), { + type: 'foul', player: currentPlayer.name, foul: foulType, foulPoints, @@ -115,7 +137,7 @@ const GameDetail141 = ({ game, onUpdate, onBack }) => { onUpdate({ ...game, ballsOnTable: newBallsOnTable, - history: [...game.history, { type: 'rerack', player: currentPlayer.name, ballsAdded: ballsToAdd, ballsOnTable: newBallsOnTable }], + log: [...(game.log || []), { type: 'rerack', player: currentPlayer.name, ballsAdded: ballsToAdd, ballsOnTable: newBallsOnTable }], }); }; @@ -172,7 +194,11 @@ const GameDetail141 = ({ game, onUpdate, onBack }) => { + +
+ +