Veit F. a07117efaf fix: implement correct Texas Hold'em betting rules and game flow
Rewrite core game logic to fix 10 critical bugs violating poker rules:

- Add betMatched flag to separate chip tracking from bet-matching state
- Implement last-aggressor tracking for proper betting round completion
- Rewrite all action functions with validation enforcement
- Add side pot support for multi-level all-in scenarios
- Replace nested setTimeout AI turns with async promise chain
- Add aiActing guard to prevent race conditions during AI play
- Fix currentTurn advancement to always land on active players
2026-05-17 18:46:08 +02:00

154 lines
7.1 KiB
Markdown

## Context
The current PokeR app implements Texas Hold'em poker but contains 10 critical bugs in its game flow logic. The core issues stem from three architectural problems: (1) ambiguous `currentBet` semantics that conflate "player contribution" with "bet matched," (2) lack of last-aggressor tracking for betting round completion, and (3) no side pot support for all-in scenarios. The hand evaluator and card systems are solid; the game orchestration layer needs complete reconstruction.
## Goals / Non-Goals
**Goals:**
- Implement correct Texas Hold'em betting mechanics per official rules
- Fix all identified bugs without introducing new ones
- Support side pots for multi-player all-in scenarios
- Prevent race conditions between human input and AI turns
- Maintain existing Svelte 5 runes-based architecture and component structure
**Non-Goals:**
- Adding new game variants (Omaha, Stud, etc.)
- Implementing GTO or strategic AI improvements
- Multiplayer networking
- Educational features (pot odds, hand ranges)
- UI/visual redesign
## Decisions
### Decision 1: Separate `betMatched` flag from `currentBet`
**Current:** `PlayerSeat.currentBet` serves dual purpose — tracks actual chips contributed AND whether player has matched the current bet level. This causes `applyCheck` to corrupt state by setting `currentBet = state.currentBet`.
**Decision:** Add explicit `betMatched: boolean` field to `PlayerSeat`. `currentBet` becomes purely "chips contributed this round." Check simply sets `betMatched = true` without modifying chip counts.
```
PlayerSeat {
// ... existing fields
currentBet: number // actual chips contributed this round
betMatched: boolean // has player matched or checked this round?
}
```
**Rationale:** Eliminates the fundamental semantic confusion that causes cascading bugs. Call amount becomes `state.currentBet - player.currentBet` with no corruption from check actions.
### Decision 2: Last-aggressor betting round completion
**Current:** `completeBettingRound` checks if all active players have `currentBet >= currentMaxBet`. This fails when all-in players are included and doesn't handle the "return to last aggressor" rule.
**Decision:** Track `lastAggressorIndex` in GameState. A betting round ends when:
- Action returns to `lastAggressorIndex` AND that player has matched, OR
- All active (non-all-in) players have `betMatched = true`, OR
- Only one active player remains
```
// Round completion logic
function isRoundComplete(state): boolean {
const activePlayers = state.players.filter(p => p.status === 'active');
// Single active player — round over
if (activePlayers.length <= 1) return true;
// No raises occurred — everyone checked/called
if (state.lastAggressorIndex === -1) {
return activePlayers.every(p => p.betMatched);
}
// Raises occurred — must return to last aggressor who matches
const lastAgg = state.players[state.lastAggressorIndex];
return lastAgg.status === 'active' &&
state.currentTurn === state.lastAggressorIndex &&
lastAgg.betMatched;
}
```
**Rationale:** Texas Hold'em rules require action to return to the last aggressor. This is the standard implementation pattern used by poker software.
### Decision 3: Side pot algorithm
**Current:** No side pot support. All chips go into a single pot, causing incorrect distributions when players go all-in at different levels.
**Decision:** Implement standard side pot calculation:
1. Sort all-in players by their bet amount (ascending)
2. Create pots from bottom up — each pot contains chips above the next lower all-in level
3. Track which players are eligible for each pot (active or all-in at that level or above)
```
SidePot {
amount: number
eligiblePlayerIds: string[]
}
// Algorithm sketch:
function calculatePots(players, currentPot): { mainPot: number, sidePots: SidePot[] }
// Sort players by bet amount
// Create nested pots from smallest all-in to largest
// Main pot = chips matched by all remaining active/all-in players
```
**Rationale:** Standard poker software approach. Keeps implementation clean and matches real-world rules exactly.
### Decision 4: Unified game state machine in `+page.svelte`
**Current:** `processGame()` has complex branching logic with async AI turns, state discarding, and multiple exit paths. Race conditions occur when human actions interrupt AI chains.
**Decision:** Replace with a clear state machine pattern:
- `aiActing: boolean` flag disables controls during AI turns
- Single `processTurn()` function handles all post-action processing
- State transitions are always applied atomically (`gameState = newState`)
- AI turns use sequential promise chains, not nested timeouts
```
function processTurn() {
// 1. Check for early win (single active player)
// 2. Check if betting round is complete
// 3. If complete: advance to next stage or showdown
// 4. If not complete: check if next player is AI, schedule turn
}
// In handleAction:
if (aiActing) return; // Guard against race conditions
gameState = applyAction(gameState, action);
processTurn();
```
**Rationale:** Eliminates race conditions and state discarding bugs. Single source of truth for game flow logic.
### Decision 5: Validation enforcement
**Current:** `validateAction` exists but is never called by `applyRaise`, `applyFold`, etc. The AI and human actions bypass validation entirely.
**Decision:** All action functions call `validateAction` internally and return unchanged state on invalid actions. The UI layer also validates before rendering buttons.
```
function applyRaise(state, playerId, amount) {
const validation = validateAction(playerId, 'raise', amount, state);
if (!validation.valid) return state;
// ... proceed with raise logic
}
```
**Rationale:** Defense in depth — both the action layer and UI layer enforce rules. Prevents illegal states from being created.
## Risks / Trade-offs
[Side pot complexity] → Side pots add significant complexity to showdown logic. Mitigation: Thoroughly test with edge cases (2 all-ins, 3 all-ins, all-in on flop/turn/river).
[State expansion] → Adding `betMatched`, `lastAggressorIndex`, and `sidePots` to GameState increases mutation surface. Mitigation: Use immutable state updates consistently, reset all fields in `startNewHand`.
[AI timing changes] → Switching from nested timeouts to promise chains changes AI behavior timing. Mitigation: Keep 400ms delay per AI action for consistent feel.
[Performance] → Side pot calculation at showdown could be expensive with many players. Mitigation: 6-max table limits complexity; algorithm is O(n log n) with n ≤ 6.
[currentTurn deadlock] → `completeBettingRound` originally set `currentTurn = dealerPosition + 1`, which could land on a folded/all-in player, causing an infinite loop in the game state machine. Fixed by using `findFirstActivePlayer()` to always point to an active player.
## Open Questions
- Should we implement burn cards (standard in real poker, currently not implemented)?
- How should all-in raises that don't constitute a full raise be handled (half-pot rule)?
- Should the BB option (BB can check pre-flop if no raise) be explicitly documented in the UI?