4 Commits

Author SHA1 Message Date
Frank Schwenk
68434f885d 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.
2025-06-22 11:05:51 +02:00
Frank Schwenk
aa5ef1c5b2 feat(14.1): Implement foul system
Implements a comprehensive foul system for the 14.1 game mode as per issue #20.

- **`src/components/GameDetail141.jsx`**

    - Adds `handleFoul` function to manage standard (-1pt) and break (-2pt) fouls.

    - Implements logic for the 3-consecutive-foul rule, applying a -15pt penalty.

    - Adds foul counters and a visual warning for players with 2 consecutive fouls.

    - Resets the consecutive foul counter on a legal (non-foul) turn.

- **`src/components/GameDetail.module.css`**

    - Adds styles for foul buttons (`.foul-btn`).

    - Adds styles for the foul counter indicator (`.foul-indicator`) and warning (`.foul-warning`).

This commit fulfills the requirements for issue #20. The `.gitea` file is left unstaged as it points to the next issue to be worked on.
2025-06-22 10:43:21 +02:00
Frank Schwenk
6a25c18153 feat(game-14-1): Implement re-rack logic and scoring hooks
Implements the re-rack functionality and prepares the scoring system for foul integration, as detailed in issue #19.

- Adds '+14' and '+15' re-rack buttons to the game view.
- Creates a  function to update the number of balls on the table.
- Modifies the  function to accept an optional  argument, preparing it for the next phase of development.

Closes #19
2025-06-20 16:07:38 +02:00
Frank Schwenk
a71c65852d feat(game-14-1): Implement phase 1 foundation
Implements the foundational UI and logic for the 14.1 Endless game mode, as detailed in issue #18.

- Adds a new  component to handle the specific game view.
- Introduces a modal within the game view to select the starting player.
- Replaces text input with a button grid for selecting remaining balls.
- Updates  to correctly initialize the 14.1 game state.

Closes #18
2025-06-20 15:35:38 +02:00
6 changed files with 586 additions and 21 deletions

View File

@@ -69,22 +69,92 @@ const App = () => {
const handleCreateGame = useCallback(({ player1, player2, player3, gameType, raceTo }) => { const handleCreateGame = useCallback(({ player1, player2, player3, gameType, raceTo }) => {
const newGame = { const newGame = {
id: Date.now(), id: Date.now(),
player1,
player2,
player3,
score1: 0,
score2: 0,
score3: 0,
gameType, gameType,
raceTo, raceTo: parseInt(raceTo, 10),
status: 'active', status: 'active',
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString(),
log: [],
undoStack: [],
}; };
if (gameType === '14/1 endlos') {
const players = [{ name: player1, score: 0, consecutiveFouls: 0 }, { name: player2, score: 0, consecutiveFouls: 0 }];
if (player3) {
players.push({ name: player3, score: 0, consecutiveFouls: 0 });
}
newGame.players = players;
newGame.currentPlayer = null; // Set to null, will be chosen in GameDetail141
newGame.ballsOnTable = 15;
} else {
newGame.player1 = player1;
newGame.player2 = player2;
newGame.score1 = 0;
newGame.score2 = 0;
if (player3) {
newGame.player3 = player3;
newGame.score3 = 0;
}
}
setGames(g => [newGame, ...g]); setGames(g => [newGame, ...g]);
return newGame.id; return newGame.id;
}, []); }, []);
// Game update for 14.1
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 // Score update
const handleUpdateScore = useCallback((player, change) => { const handleUpdateScore = useCallback((player, change) => {
setGames(games => games.map(game => { setGames(games => games.map(game => {
@@ -100,6 +170,8 @@ const App = () => {
} }
return updated; return updated;
})); }));
setCompletionModal({ open: false, game: null });
setScreen('game-detail');
}, [currentGameId]); }, [currentGameId]);
// Finish game // Finish game
@@ -168,7 +240,7 @@ const App = () => {
setNewGameStep('gameType'); setNewGameStep('gameType');
}; };
const handleGameTypeNext = (type) => { const handleGameTypeNext = (type) => {
setNewGameData(data => ({ ...data, gameType: type })); setNewGameData(data => ({ ...data, gameType: type, raceTo: type === '14/1 endlos' ? '150' : '50' }));
setNewGameStep('raceTo'); setNewGameStep('raceTo');
}; };
const handleRaceToNext = (raceTo) => { const handleRaceToNext = (raceTo) => {
@@ -250,8 +322,11 @@ const App = () => {
<div className="screen-content"> <div className="screen-content">
<GameDetail <GameDetail
game={games.find(g => g.id === currentGameId)} game={games.find(g => g.id === currentGameId)}
onFinishGame={handleFinishGame}
onUpdateScore={handleUpdateScore} onUpdateScore={handleUpdateScore}
onFinishGame={handleFinishGame}
onUpdateGame={handleGameAction}
onUndo={handleUndo}
onForfeit={handleForfeit}
onBack={showGameList} onBack={showGameList}
/> />
</div> </div>

View File

@@ -2,6 +2,57 @@ import { h } from 'preact';
import modalStyles from './Modal.module.css'; import modalStyles from './Modal.module.css';
import styles from './GameCompletionModal.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. * Modal shown when a game is completed.
* @param {object} props * @param {object} props
@@ -14,14 +65,28 @@ import styles from './GameCompletionModal.module.css';
*/ */
const GameCompletionModal = ({ open, game, onConfirm, onClose, onRematch }) => { const GameCompletionModal = ({ open, game, onConfirm, onClose, onRematch }) => {
if (!open || !game) return null; 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]); let playerNames, scores, maxScore, winners, winnerText, gameStats;
const maxScore = Math.max(...scores);
// Find all winners (could be a tie) if (game.gameType === '14/1 endlos') {
const winners = playerNames.filter((name, idx) => scores[idx] === maxScore); playerNames = game.players.map(p => p.name);
const winnerText = winners.length > 1 scores = game.players.map(p => p.score);
? `Unentschieden zwischen ${winners.join(' und ')}` gameStats = calculateStats(game);
: `${winners[0]} hat gewonnen!`; } 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 ( return (
<div id="game-completion-modal" className={modalStyles['modal'] + ' ' + modalStyles['show']} role="dialog" aria-modal="true" aria-labelledby="completion-modal-title"> <div id="game-completion-modal" className={modalStyles['modal'] + ' ' + modalStyles['show']} role="dialog" aria-modal="true" aria-labelledby="completion-modal-title">
<div className={modalStyles['modal-content']}> <div className={modalStyles['modal-content']}>
@@ -39,6 +104,20 @@ const GameCompletionModal = ({ open, game, onConfirm, onClose, onRematch }) => {
))} ))}
</div> </div>
<div className={styles['winner-announcement']}><h3>{winnerText}</h3></div> <div className={styles['winner-announcement']}><h3>{winnerText}</h3></div>
{gameStats && (
<div className={styles['stats-container']}>
<h4 className={styles['stats-title']}>Statistiken</h4>
{playerNames.map(name => (
<div key={name} className={styles['player-stats']}>
<div className={styles['player-name-stats']}>{name}</div>
<div className={styles['stat-item']}>Höchste Serie: <strong>{gameStats[name].highestRun}</strong></div>
<div className={styles['stat-item']}>Punkte / Aufnahme: <strong>{gameStats[name].avgPots}</strong></div>
</div>
))}
</div>
)}
</div> </div>
<div className={modalStyles['modal-footer']}> <div className={modalStyles['modal-footer']}>
<button className={styles['btn'] + ' ' + styles['btn--warning']} onClick={onConfirm} aria-label="Bestätigen">Bestätigen</button> <button className={styles['btn'] + ' ' + styles['btn--warning']} onClick={onConfirm} aria-label="Bestätigen">Bestätigen</button>

View File

@@ -34,9 +34,10 @@
font-weight: 700; font-weight: 700;
} }
.winner-announcement h3 { .winner-announcement h3 {
font-size: 1.2rem;
margin: 0; margin: 0;
color: #fff; font-size: 1.5rem;
color: #2c3e50;
text-align: center;
} }
.btn { .btn {
flex: 1; flex: 1;
@@ -65,3 +66,45 @@
padding: 14px 0; 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;
}

View File

@@ -1,5 +1,6 @@
import { h } from 'preact'; import { h } from 'preact';
import styles from './GameDetail.module.css'; import styles from './GameDetail.module.css';
import GameDetail141 from './GameDetail141.jsx';
/** /**
* Game detail view for a single game. * Game detail view for a single game.
@@ -7,11 +8,19 @@ import styles from './GameDetail.module.css';
* @param {object} props.game * @param {object} props.game
* @param {Function} props.onFinishGame * @param {Function} props.onFinishGame
* @param {Function} props.onUpdateScore * @param {Function} props.onUpdateScore
* @param {Function} props.onUpdateGame
* @param {Function} props.onUndo
* @param {Function} props.onForfeit
* @param {Function} props.onBack * @param {Function} props.onBack
* @returns {import('preact').VNode|null} * @returns {import('preact').VNode|null}
*/ */
const GameDetail = ({ game, onFinishGame, onUpdateScore, onBack }) => { const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, onForfeit, onBack }) => {
if (!game) return null; if (!game) return null;
if (game.gameType === '14/1 endlos') {
return <GameDetail141 game={game} onUpdate={onUpdateGame} onUndo={onUndo} onForfeit={onForfeit} onBack={onBack} />;
}
const isCompleted = game.status === 'completed'; const isCompleted = game.status === 'completed';
const playerNames = [game.player1, game.player2, game.player3].filter(Boolean); const playerNames = [game.player1, game.player2, game.player3].filter(Boolean);
const scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]); const scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]);

View File

@@ -123,3 +123,154 @@
width: 100%; width: 100%;
justify-content: center; justify-content: center;
} }
.franky .player-name {
font-weight: bold;
color: #ff8c00; /* Example color */
}
.active-player {
border: 2px solid #4caf50;
box-shadow: 0 0 10px #4caf50;
}
.turn-indicator {
margin: 20px 0;
font-size: 1.2rem;
text-align: center;
}
.potted-balls-container {
margin-top: 2rem;
padding: 1rem;
background: #2a2a2a;
border-radius: 8px;
}
.potted-balls-header {
text-align: center;
font-size: 1.1rem;
margin-bottom: 1rem;
font-weight: 600;
}
.potted-balls-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(60px, 1fr));
gap: 10px;
}
.potted-ball-btn {
padding: 1rem;
font-size: 1.2rem;
font-weight: bold;
border-radius: 8px;
border: 1px solid #444;
background-color: #333;
color: #fff;
cursor: pointer;
transition: background-color 0.2s, transform 0.2s;
}
.potted-ball-btn:hover:not(:disabled) {
background-color: #45a049;
transform: translateY(-2px);
}
.potted-ball-btn:disabled {
background-color: #222;
color: #555;
cursor: not-allowed;
}
.rerack-controls {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
}
.rerack-btn {
padding: 0.8rem 1.5rem;
font-size: 1rem;
font-weight: bold;
border-radius: 8px;
border: 1px solid #444;
background-color: #3a539b;
color: #fff;
cursor: pointer;
transition: background-color 0.2s;
}
.rerack-btn:hover {
background-color: #4a6fbf;
}
.foul-controls {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
}
.foul-btn {
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: #ffca2c;
}
.foul-btn:disabled {
background-color: #e0e0e0;
color: #9e9e9e;
cursor: not-allowed;
}
.foul-indicator {
font-size: 1rem;
font-weight: bold;
color: #fff;
background-color: #c0392b;
padding: 4px 8px;
border-radius: 4px;
margin-top: 8px;
display: inline-block;
}
.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;
}

View File

@@ -0,0 +1,208 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import styles from './GameDetail.module.css';
import modalStyles from './PlayerSelectModal.module.css';
const StartingPlayerModal = ({ players, onSelect, onCancel }) => (
<div className={modalStyles.modalOverlay}>
<div className={modalStyles.modalContent} onClick={e => e.stopPropagation()}>
<div className={modalStyles.modalHeader}>
<h3>Welcher Spieler fängt an?</h3>
{/* A cancel button isn't strictly needed if a choice is mandatory */}
</div>
<div className={modalStyles.playerList}>
{players.map((player, index) => (
<button key={player.name} className={modalStyles.playerItem} onClick={() => onSelect(index)}>
{player.name}
</button>
))}
</div>
</div>
</div>
);
const GameLog = ({ log }) => {
if (!log || log.length === 0) {
return null;
}
return (
<div className={styles['game-log']}>
<h4 className={styles['log-title']}>Game Log</h4>
<ul className={styles['log-list']}>
{log.slice().reverse().map((entry, index) => (
<li key={index} className={styles['log-entry']}>
{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}`}
</li>
))}
</ul>
</div>
);
};
const GameDetail141 = ({ game, onUpdate, onUndo, onForfeit, onBack }) => {
const handleSelectStartingPlayer = (playerIndex) => {
onUpdate({
...game,
currentPlayer: playerIndex,
});
};
// If no player is selected yet, show the modal
if (game.currentPlayer === null || game.currentPlayer === undefined) {
return <StartingPlayerModal players={game.players} onSelect={handleSelectStartingPlayer} />;
}
const currentPlayer = game.players[game.currentPlayer];
const handleTurnEnd = (remainingBalls, foulPoints = 0) => {
if (remainingBalls > game.ballsOnTable) {
console.error("Cannot leave more balls than are on the table.");
return;
}
const ballsPotted = game.ballsOnTable - remainingBalls;
const newScore = currentPlayer.score + ballsPotted - foulPoints;
const updatedPlayers = game.players.map((p, index) =>
index === game.currentPlayer ? { ...p, score: newScore, consecutiveFouls: 0 } : p
);
const nextPlayer = (game.currentPlayer + 1) % game.players.length;
onUpdate({
...game,
players: updatedPlayers,
ballsOnTable: remainingBalls,
currentPlayer: nextPlayer,
log: [...(game.log || []), { type: 'turn', player: currentPlayer.name, ballsPotted, foulPoints, newScore, ballsOnTable: remainingBalls }],
});
};
const handleFoul = (foulType) => {
let foulPoints = 0;
let penalty = 0;
const newConsecutiveFouls = (currentPlayer.consecutiveFouls || 0) + 1;
if (foulType === 'standard') {
foulPoints = 1;
} else if (foulType === 'break') {
foulPoints = 2;
}
if (newConsecutiveFouls === 3) {
penalty = 15;
}
const totalDeduction = foulPoints + penalty;
const newScore = currentPlayer.score - totalDeduction;
const updatedPlayers = game.players.map((p, index) => {
if (index === game.currentPlayer) {
return {
...p,
score: newScore,
consecutiveFouls: newConsecutiveFouls === 3 ? 0 : newConsecutiveFouls,
};
}
return p;
});
const nextPlayer = (game.currentPlayer + 1) % game.players.length;
onUpdate({
...game,
players: updatedPlayers,
currentPlayer: nextPlayer,
log: [
...(game.log || []),
{
type: 'foul',
player: currentPlayer.name,
foul: foulType,
foulPoints,
penalty,
totalDeduction,
newScore,
consecutiveFouls: newConsecutiveFouls
},
],
});
};
const handleReRack = (ballsToAdd) => {
const newBallsOnTable = game.ballsOnTable + ballsToAdd;
onUpdate({
...game,
ballsOnTable: newBallsOnTable,
log: [...(game.log || []), { type: 'rerack', player: currentPlayer.name, ballsAdded: ballsToAdd, ballsOnTable: newBallsOnTable }],
});
};
return (
<div className={styles['game-detail']}>
<div className={styles['game-title']}>
14/1 endlos | Race to {game.raceTo}
</div>
<div className={styles['scores-container']}>
{game.players.map((p, idx) => (
<div
className={`${styles['player-score']} ${idx === game.currentPlayer ? styles['active-player'] : ''}`}
key={p.name}
>
<span className={styles['player-name']}>{p.name}</span>
<span className={styles['score']}>{p.score}</span>
{p.consecutiveFouls > 0 && (
<span className={`${styles['foul-indicator']} ${p.consecutiveFouls === 2 ? styles['foul-warning'] : ''}`}>
Fouls: {p.consecutiveFouls}
</span>
)}
</div>
))}
</div>
<div className={styles['turn-indicator']}>
Aktueller Spieler: <strong>{currentPlayer.name}</strong> ({game.ballsOnTable} Bälle auf dem Tisch)
</div>
<div className={styles['potted-balls-container']}>
<p className={styles['potted-balls-header']}>Bälle am Ende der Aufnahme:</p>
<div className={styles['potted-balls-grid']}>
{Array.from({ length: 16 }, (_, i) => i).map(num => (
<button
key={num}
onClick={() => handleTurnEnd(num)}
disabled={num > game.ballsOnTable}
className={styles['potted-ball-btn']}
>
{num}
</button>
))}
</div>
</div>
<div className={styles['rerack-controls']}>
<button onClick={() => handleReRack(14)} className={styles['rerack-btn']}>+14 Re-Rack</button>
<button onClick={() => handleReRack(15)} className={styles['rerack-btn']}>+15 Re-Rack</button>
</div>
<div className={styles['foul-controls']}>
<button onClick={() => handleFoul('standard')} className={styles['foul-btn']}>Standard Foul (-1)</button>
<button onClick={() => handleFoul('break')} className={styles['foul-btn']}>Break Foul (-2)</button>
</div>
<GameLog log={game.log} />
<div className={styles['game-detail-controls']}>
<button className="btn" onClick={onUndo} disabled={!game.undoStack || game.undoStack.length === 0}>Undo</button>
<button className="btn btn-danger" onClick={onForfeit}>Forfeit</button>
<button className="btn" onClick={onBack}>Zurück zur Liste</button>
</div>
</div>
);
};
export default GameDetail141;