feat(#28): add break rule & order to games and UI

- Types: add BreakRule, break metadata to StandardGame, extend NewGameData/steps
- NewGame: add BreakRule and BreakOrder steps with auto-advance
- NewGameScreen: wire new steps into flow
- GameService: set up defaults, persist break order, compute next breaker on +1
- GameDetail: show breaker indicator; treat -1 as undo equivalent

Backfill defaults for existing games via service logic.

Refs #28
This commit is contained in:
Frank Schwenk
2025-10-30 11:34:26 +01:00
parent 147906af59
commit 1bd9919b6b
5 changed files with 186 additions and 7 deletions

View File

@@ -45,7 +45,17 @@ const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, o
className={styles['player-score'] + (name === 'Fränky' ? ' ' + styles['franky'] : '')}
key={name + idx}
>
<span className={styles['player-name']}>{name}</span>
<span className={styles['player-name']}>
{(() => {
const order = (game as any).breakOrder as number[] | undefined;
const breakerIdx = (game as any).currentBreakerIdx as number | undefined;
if (order && typeof breakerIdx === 'number' && order[breakerIdx] === idx + 1) {
return <span title="Break" aria-label="Break" style={{ display: 'inline-block', width: 10, height: 10, borderRadius: '50%', background: '#fff', marginRight: 6, verticalAlign: 'middle' }} />;
}
return null;
})()}
{name}
</span>
<div className={styles['progress-bar']}>
<div
className={styles['progress-fill']}
@@ -72,7 +82,7 @@ const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, o
<button
className={styles['score-button']}
disabled={isCompleted}
onClick={() => handleScoreUpdate(idx+1, -1)}
onClick={() => (onUndo ? onUndo() : undefined)}
aria-label={`Punkt abziehen für ${name}`}
title={`Punkt abziehen für ${name}`}
>-</button>

View File

@@ -14,6 +14,7 @@ import {
FORM_CONFIG,
ERROR_STYLES
} from '../utils/constants';
import type { BreakRule } from '../types/game';
interface PlayerSelectModalProps {
players: string[];
@@ -793,4 +794,94 @@ const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }: RaceToSte
);
};
export { Player1Step, Player2Step, Player3Step, GameTypeStep, RaceToStep };
interface BreakRuleStepProps {
onNext: (rule: BreakRule) => void;
onCancel: () => void;
initialValue?: BreakRule | '';
}
const BreakRuleStep = ({ onNext, onCancel, initialValue = 'winnerbreak' }: BreakRuleStepProps) => {
const [rule, setRule] = useState<BreakRule>(initialValue as BreakRule);
return (
<form className={styles['new-game-form']} aria-label="Break-Regel wählen">
<div className={styles['screen-title']}>Neues Spiel Schritt 4/6</div>
<div style={{ display: 'flex', gap: 12, marginTop: 12 }}>
{[
{ key: 'winnerbreak', label: 'Winnerbreak' },
{ key: 'wechselbreak', label: 'Wechselbreak' },
].map(opt => (
<button
key={opt.key}
type="button"
className={`${styles['quick-pick-btn']} ${rule === (opt.key as BreakRule) ? styles['selected'] : ''}`}
onClick={() => { setRule(opt.key as BreakRule); onNext(opt.key as BreakRule); }}
aria-label={`Break-Regel wählen: ${opt.label}`}
>
{opt.label}
</button>
))}
</div>
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
<button type="button" className={styles['arrow-btn']} aria-label="Zurück" onClick={onCancel} style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}>
&#8592;
</button>
</div>
</form>
);
};
interface BreakOrderStepProps {
players: string[];
rule: BreakRule;
onNext: (first: number, second?: number) => void;
onCancel: () => void;
}
const BreakOrderStep = ({ players, rule, onNext, onCancel }: BreakOrderStepProps) => {
const playerCount = players.filter(Boolean).length;
const [first, setFirst] = useState<number>(1);
const [second, setSecond] = useState<number>(playerCount >= 2 ? 2 : 1);
const handleFirst = (idx: number) => {
setFirst(idx);
if (rule === 'wechselbreak' && playerCount === 2) {
// second implied
onNext(idx);
}
};
const handleSecond = (idx: number) => {
setSecond(idx);
onNext(first, idx);
};
return (
<form className={styles['new-game-form']} aria-label="Break-Reihenfolge wählen">
<div className={styles['screen-title']}>Neues Spiel Schritt 5/6</div>
<div style={{ marginBottom: 16, fontWeight: 600 }}>Wer bricht zuerst?</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{players.filter(Boolean).map((name, idx) => (
<button key={`first-${idx}`} type="button" className={styles['quick-pick-btn']} onClick={() => handleFirst(idx + 1)} aria-label={`Zuerst: ${name}`}>{name}</button>
))}
</div>
{rule === 'wechselbreak' && playerCount === 3 && (
<>
<div style={{ marginTop: 24, marginBottom: 16, fontWeight: 600 }}>Wer bricht als Zweites?</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{players.filter(Boolean).map((name, idx) => (
<button key={`second-${idx}`} type="button" className={styles['quick-pick-btn']} onClick={() => handleSecond(idx + 1)} aria-label={`Zweites Break: ${name}`}>{name}</button>
))}
</div>
</>
)}
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
<button type="button" className={styles['arrow-btn']} aria-label="Zurück" onClick={onCancel} style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}>
&#8592;
</button>
</div>
</form>
);
};
export { Player1Step, Player2Step, Player3Step, GameTypeStep, BreakRuleStep, BreakOrderStep, RaceToStep };

View File

@@ -1,6 +1,6 @@
import { h } from 'preact';
import { Screen } from '../ui/Layout';
import { Player1Step, Player2Step, Player3Step, GameTypeStep, RaceToStep } from '../NewGame';
import { Player1Step, Player2Step, Player3Step, GameTypeStep, BreakRuleStep, BreakOrderStep, RaceToStep } from '../NewGame';
import type { NewGameStep, NewGameData } from '../../types/game';
interface NewGameScreenProps {
@@ -44,6 +44,16 @@ export default function NewGameScreen({
gameType: type as any, // Type assertion for now, could be improved with proper validation
raceTo: '8'
});
onStepChange('breakRule');
};
const handleBreakRuleNext = (rule: 'winnerbreak' | 'wechselbreak') => {
onDataChange({ breakRule: rule });
onStepChange('breakOrder');
};
const handleBreakOrderNext = (first: number, second?: number) => {
onDataChange({ breakFirst: first, breakSecond: second ?? '' });
onStepChange('raceTo');
};
@@ -63,9 +73,15 @@ export default function NewGameScreen({
case 'gameType':
onStepChange('player3');
break;
case 'raceTo':
case 'breakRule':
onStepChange('gameType');
break;
case 'breakOrder':
onStepChange('breakRule');
break;
case 'raceTo':
onStepChange('breakOrder');
break;
default:
onCancel();
}
@@ -108,6 +124,23 @@ export default function NewGameScreen({
/>
)}
{step === 'breakRule' && (
<BreakRuleStep
onNext={handleBreakRuleNext}
onCancel={handleStepBack}
initialValue={(data.breakRule as any) || 'winnerbreak'}
/>
)}
{step === 'breakOrder' && (
<BreakOrderStep
players={[data.player1, data.player2, data.player3]}
rule={(data.breakRule as any) || 'winnerbreak'}
onNext={handleBreakOrderNext}
onCancel={handleStepBack}
/>
)}
{step === 'raceTo' && (
<RaceToStep
onNext={handleRaceToNext}

View File

@@ -1,4 +1,4 @@
import type { Game, GameType, StandardGame, EndlosGame, NewGameData } from '../types/game';
import type { Game, GameType, StandardGame, EndlosGame, NewGameData, BreakRule } from '../types/game';
import { IndexedDBService } from './indexedDBService';
const LOCAL_STORAGE_KEY = 'bscscore_games';
@@ -163,6 +163,25 @@ export class GameService {
player2: gameData.player2,
score1: 0,
score2: 0,
breakRule: (gameData.breakRule as BreakRule) || 'winnerbreak',
breakOrder: (() => {
// Determine break order from inputs, fallback defaults
const players: number[] = [1, 2];
if (gameData.player3?.trim()) players.push(3);
const first = (typeof gameData.breakFirst === 'number' ? gameData.breakFirst : 1) as number;
const second = (typeof gameData.breakSecond === 'number' ? gameData.breakSecond : (players.includes(2) && first !== 2 ? 2 : (players.includes(3) ? 3 : 2))) as number;
const order = [first];
if (players.length === 2) {
order.push(first === 1 ? 2 : 1);
} else {
// 3 players: add chosen second, then the remaining third
order.push(second);
const third = [1,2,3].find(p => p !== first && p !== second)!;
order.push(third);
}
return order;
})(),
currentBreakerIdx: 0,
};
if (gameData.player3) {
@@ -185,6 +204,23 @@ export class GameService {
updated.score3 = Math.max(0, updated.score3 + change);
}
// Breaker logic
if (change > 0) {
if ((updated.breakRule || 'winnerbreak') === 'winnerbreak') {
// Winner keeps break: set breaker to this player
const order = updated.breakOrder || [1,2].concat(updated.score3 !== undefined ? [3] : []);
updated.breakOrder = order;
const idx = order.findIndex(p => p === player);
updated.currentBreakerIdx = idx >= 0 ? idx : 0;
} else {
// Wechselbreak: rotate to next
const order = updated.breakOrder || [1,2].concat(updated.score3 !== undefined ? [3] : []);
updated.breakOrder = order;
const curr = typeof updated.currentBreakerIdx === 'number' ? updated.currentBreakerIdx : 0;
updated.currentBreakerIdx = (curr + 1) % order.length;
}
}
updated.updatedAt = new Date().toISOString();
return updated;
}

View File

@@ -33,6 +33,8 @@ export interface BaseGame {
lastModified?: number;
}
export type BreakRule = 'winnerbreak' | 'wechselbreak';
export interface StandardGame extends BaseGame {
player1: string;
player2: string;
@@ -40,6 +42,10 @@ export interface StandardGame extends BaseGame {
score1: number;
score2: number;
score3?: number;
// Break management
breakRule?: BreakRule; // default winnerbreak for backfill
breakOrder?: number[]; // 1-based player indices, e.g. [1,2] or [1,2,3]
currentBreakerIdx?: number; // index into breakOrder
}
export interface EndlosGame extends BaseGame {
@@ -58,8 +64,11 @@ export interface NewGameData {
player3: string;
gameType: GameType | '';
raceTo: string;
breakRule?: BreakRule | '';
breakFirst?: number | '';
breakSecond?: number | '';
}
export type NewGameStep = 'player1' | 'player2' | 'player3' | 'gameType' | 'raceTo' | null;
export type NewGameStep = 'player1' | 'player2' | 'player3' | 'gameType' | 'breakRule' | 'breakOrder' | 'raceTo' | null;
export type GameFilter = 'all' | 'active' | 'completed';