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
This commit is contained in:
Veit Fränzer 2026-05-17 18:46:08 +02:00
parent 6930858074
commit a07117efaf
23 changed files with 831 additions and 101 deletions

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-17

View File

@ -0,0 +1,153 @@
## 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?

View File

@ -0,0 +1,36 @@
## Why
The current poker game logic contains multiple critical bugs that violate Texas Hold'em rules, causing rounds to skip, bets to calculate incorrectly, and chips to disappear. The game needs a complete rewrite of its core game flow to produce legitimate, playable poker.
## What Changes
- **Fix `applyCheck`**: Remove phantom chip assignment that corrupts call calculations and round completion
- **Fix betting round completion**: Exclude all-in players from "all matched" check so active players aren't skipped
- **Fix game loop state management**: Always apply returned state from `completeBettingRound` instead of discarding it when rounds don't advance
- **Implement side pot support**: Create and distribute side pots when players go all-in at different levels
- **Enforce minimum raise validation**: Call `validateAction` before applying any action, reject illegal raises
- **Fix fold validation**: Allow folding in all situations per Texas Hold'em rules
- **Fix pot distribution**: Award remainder chips to first winner instead of losing them
- **Add input guardrails**: Disable player controls during AI turns to prevent race conditions
- **Rewrite turn advancement**: Properly track last aggressor and end betting rounds when action returns correctly
## Capabilities
### New Capabilities
- `side-pots`: Handle all-in scenarios by creating main pot and side pots with correct chip distribution among eligible players
- `betting-round-flow`: Complete rewrite of betting round completion logic with proper last-aggressor tracking, all-in handling, and turn advancement
- `game-loop-integrity`: Unified game state machine that prevents race conditions, always applies state transitions, and disables controls during AI turns
### Modified Capabilities
<!-- No existing specs to modify — this is a bug fix for fundamental game rules -->
## Impact
- `src/lib/game/actions.ts` — Complete rewrite of check, call, raise, fold, all-in functions
- `src/lib/game/betting-round.ts` — Rewrite completion logic with side pot awareness and last-aggressor tracking
- `src/lib/game/state.ts` — Add last aggressor tracking, side pot structures to GameState
- `src/lib/types/game-state.ts` — Extend with `lastAggressor`, `sidePots`, betting round metadata
- `src/lib/game/showdown.ts` — Fix pot distribution, handle side pots at showdown
- `src/lib/game/validation.ts` — Fix fold validation, enforce minimum raise rules
- `src/routes/+page.svelte` — Add input disabling during AI turns, proper state machine flow
- `src/lib/game/turn.ts` — Rewrite turn advancement logic

View File

@ -0,0 +1,45 @@
## ADDED Requirements
### Requirement: Check action does not modify player chip count or bet contribution
When a player checks, the system SHALL only mark the player as having matched the current bet level without deducting chips or modifying their `currentBet` value.
#### Scenario: Check with zero current bet
- **WHEN** the current bet is 0 and Player A checks
- **THEN** Player A's `betMatched` becomes true, `currentBet` remains unchanged, and chip count remains unchanged
#### Scenario: Check after matching previous bet
- **WHEN** Player A has already called a bet of 100 (currentBet = 100) and the next player checks (no raise occurred)
- **THEN** Player A's state remains unchanged except `betMatched` is set to true
### Requirement: Betting round completes when action returns to last aggressor
The system SHALL end a betting round when all active players have matched the current bet and action returns to the position of the last player who raised or opened betting.
#### Scenario: Pre-flop BB option (no raises)
- **WHEN** SB posts 10, BB posts 20, and all players check around back to BB
- **THEN** BB may check without posting additional chips, and the betting round completes
#### Scenario: Raise requires action to return to raiser
- **WHEN** Player A raises to 100, Players B and C call, and Player D checks
- **THEN** the round does not complete until Player A either checks or faces no further raises
### Requirement: All-in players are excluded from betting round completion check
When determining if a betting round is complete, the system SHALL only consider active (non-all-in) players for the "all matched" condition.
#### Scenario: Active player skipped by all-in completion bug
- **WHEN** Player A bets 100, Player B goes all-in for 50, and Player C has not yet acted
- **THEN** the betting round does NOT complete; Player C must act before the round advances
#### Scenario: Single active player ends round
- **WHEN** all players except one have folded or gone all-in
- **THEN** the betting round completes immediately and the game proceeds to the next stage
### Requirement: Turn advancement skips non-active players correctly
The system SHALL advance turns to the next player with `status === 'active'`, skipping folded and all-in players.
#### Scenario: Folded player is skipped
- **WHEN** Player A folds and it's their turn position
- **THEN** the turn advances to the next active player clockwise from Player A
#### Scenario: All-in player is skipped during betting
- **WHEN** Player B goes all-in during a betting round
- **THEN** subsequent turns skip Player B and advance to the next active player

View File

@ -0,0 +1,45 @@
## ADDED Requirements
### Requirement: Game state transitions are always applied atomically
The system SHALL always apply the complete returned state from action functions and never discard intermediate state updates.
#### Scenario: Betting round completion state is preserved
- **WHEN** `completeBettingRound` returns a new state with advanced `currentTurn` and reset bets
- **THEN** the game applies the entire returned state, including turn advancement and bet resets, even if the betting round stage does not change
#### Scenario: Action function results are never lost
- **WHEN** a player performs any action (check, call, raise, fold, all-in)
- **THEN** the returned state from the action function is applied completely before any subsequent processing occurs
### Requirement: Player controls are disabled during AI turns
The system SHALL disable human player input controls while AI players are taking their turns to prevent race conditions.
#### Scenario: Human clicks during AI turn
- **WHEN** an AI player is processing their turn (400ms delay) and the human player clicks a bet control
- **THEN** the action is rejected, the UI shows disabled controls, and no state mutation occurs
#### Scenario: Controls re-enable after AI turn completes
- **WHEN** an AI player completes their action and the turn advances to the human player
- **THEN** the bet controls become enabled and the human player may act
### Requirement: Validation is enforced before applying any action
The system SHALL validate all actions against game rules before applying state changes, returning unchanged state for invalid actions.
#### Scenario: Invalid raise is rejected
- **WHEN** a player attempts to raise below the minimum raise amount
- **THEN** the action is rejected, the state remains unchanged, and an error reason is returned
#### Scenario: Fold always permitted
- **WHEN** a player attempts to fold during any betting round, including pre-flop
- **THEN** the action is accepted regardless of current bet level
### Requirement: Pot distribution preserves all chips
When awarding pots to winners, the system SHALL distribute all chips without losing remainder amounts.
#### Scenario: Odd pot split between two winners
- **WHEN** the pot contains 105 chips and two players tie for the win
- **THEN** one winner receives 53 chips, the other receives 52 chips, and zero chips are lost
#### Scenario: Single winner receives full pot
- **WHEN** a single player wins the pot of 200 chips
- **THEN** the winner receives exactly 200 chips and the pot is reset to 0

View File

@ -0,0 +1,34 @@
## ADDED Requirements
### Requirement: Side pots are created when players go all-in at different bet levels
When one or more players go all-in for less than the current bet level, the system SHALL create separate side pots to ensure only eligible players can win chips they contributed to.
#### Scenario: Single all-in creates main pot and side pot
- **WHEN** Player A bets 100, Player B goes all-in for 50, and Player C calls 100
- **THEN** a main pot of 150 (50×3) is created with all three players eligible, and a side pot of 100 (25×4) is created with Players A and C eligible
#### Scenario: Multiple all-ins create multiple side pots
- **WHEN** Player A bets 200, Player B goes all-in for 100, Player C goes all-in for 150, and Player D calls 200
- **THEN** a main pot of 400 (100×4) is created with all players eligible, a side pot of 100 (50×3) is created with Players A, C, D eligible, and a side pot of 100 (50×2) is created with Players A and D eligible
### Requirement: Side pots are awarded to eligible winners only
When determining winners for each pot, the system SHALL only award a pot to players who contributed chips to that specific pot level.
#### Scenario: All-in player wins main pot only
- **WHEN** Player B is all-in for 50 and has the best hand at showdown, Player A and C are still active with worse hands
- **THEN** Player B receives the main pot amount proportional to their contribution, and Players A/C compete for the side pot
#### Scenario: All-in player loses all pots
- **WHEN** Player B is all-in for 50 and has the worst hand at showdown
- **THEN** Player B receives no chips from any pot, and eligible players split the main pot proportionally
### Requirement: Side pot calculation handles edge cases correctly
The system SHALL correctly calculate side pots when all players go all-in or when an all-in does not constitute a raise.
#### Scenario: All players all-in creates single pot
- **WHEN** all remaining players go all-in during a betting round
- **THEN** a single main pot containing all chips is created with all players eligible, and no side pots are generated
#### Scenario: All-in that doesn't raise does not create side pot
- **WHEN** the current bet is 100 and Player B goes all-in for 80 (less than a full raise)
- **THEN** no side pot is created; the main pot contains all chips and active players may check to continue

View File

@ -0,0 +1,71 @@
## 1. Type system updates
- [x] 1.1 Add `betMatched: boolean` field to `PlayerSeat` interface in `src/lib/types/player.ts`
- [x] 1.2 Add `lastAggressorIndex: number` field to `GameState` interface in `src/lib/types/game-state.ts`
- [x] 1.3 Define `SidePot` interface with `amount` and `eligiblePlayerIds` fields
- [x] 1.4 Add `sidePots: SidePot[]` field to `GameState` interface
## 2. State initialization & reset
- [x] 2.1 Update `createInitialState` to initialize new fields (`betMatched`, `lastAggressorIndex`, `sidePots`)
- [x] 2.2 Update `startNewHand` to reset all per-round fields including new fields
- [x] 2.3 Update `postBlinds` to set `betMatched` correctly for SB and BB players
## 3. Action function rewrites
- [x] 3.1 Rewrite `applyCheck` to set `betMatched = true` without modifying `currentBet` or chips
- [x] 3.2 Rewrite `applyCall` to use `state.currentBet - player.currentBet` for call amount with correct chip deduction
- [x] 3.3 Rewrite `applyRaise` to update `lastAggressorIndex`, reset other players' `betMatched`, and enforce minimum raise
- [x] 3.4 Rewrite `applyFold` to correctly mark player as folded and advance turn
- [x] 3.5 Rewrite `applyAllIn` to handle partial all-in raises and update `lastAggressorIndex` if applicable
- [x] 3.6 Add `validateAction` call at the start of each action function with early return on invalid actions
## 4. Validation fixes
- [x] 4.1 Fix fold validation to always permit folding (remove pre-flop restriction)
- [x] 4.2 Fix raise validation to correctly calculate minimum raise based on `lastRaiseAmount`
- [x] 4.3 Add validation for all-in actions that don't constitute a full raise
## 5. Betting round completion rewrite
- [x] 5.1 Implement `isRoundComplete` function with last-aggressor tracking logic
- [x] 5.2 Rewrite `completeBettingRound` to use `betMatched` flag and exclude all-in players from match check
- [x] 5.3 Implement BB option: allow BB to check pre-flop when no raises occurred
- [x] 5.4 Ensure `currentTurn` advances correctly after round completion
## 6. Side pot implementation
- [x] 6.1 Implement `calculatePots` function to compute main pot and side pots from player bets
- [x] 6.2 Integrate side pot calculation into all-in action handling
- [x] 6.3 Update `awardPot` to distribute main pot and all side pots correctly
- [x] 6.4 Fix remainder chip distribution (award leftover chips to first winner)
- [x] 6.5 Update showdown logic to determine winners for each pot based on eligibility
## 7. Turn advancement fixes
- [x] 7.1 Rewrite `advanceTurn` to skip folded and all-in players correctly
- [x] 7.2 Ensure turn advancement respects dealer position and betting round start position
- [x] 7.3 Handle edge case where no active players remain for turn advancement
## 8. Game loop rewrite (+page.svelte)
- [x] 8.1 Add `aiActing: boolean` state flag to disable controls during AI turns
- [x] 8.2 Replace `processGame` with unified `processTurn` state machine function
- [x] 8.3 Ensure all state transitions apply returned state atomically (no discarding)
- [x] 8.4 Convert AI turn timing from nested setTimeout to sequential promise chain
- [x] 8.5 Add guard clause in `handleAction` to reject actions when `aiActing` is true
- [x] 8.6 Update BetControls component to accept and respond to `disabled` prop
## 9. Bug fixes discovered during playtesting
- [x] 9.1 Fix deadlock: `completeBettingRound` sets `currentTurn` to first active player instead of hardcoded `dealerPosition + 1`
- [x] 9.2 Simplify `processTurn` to use corrected `currentTurn` directly after round completion
## 10. Testing & verification
- [ ] 10.1 Test pre-flop betting round with all action combinations (check, call, raise, fold)
- [ ] 10.2 Test single all-in scenario verifies correct main pot and side pot creation
- [ ] 10.3 Test multiple all-ins at different levels verifies correct multi-side pot distribution
- [ ] 10.4 Test BB option pre-flop (BB checks when no raises)
- [ ] 10.5 Test full hand flow from deal through showdown with no state corruption
- [ ] 10.6 Test race condition prevention (rapid human clicks during AI turn are rejected)

View File

@ -0,0 +1,49 @@
# betting-round-flow Specification
## Purpose
TBD - created by archiving change fix-texas-holdem-rules. Update Purpose after archive.
## Requirements
### Requirement: Check action does not modify player chip count or bet contribution
When a player checks, the system SHALL only mark the player as having matched the current bet level without deducting chips or modifying their `currentBet` value.
#### Scenario: Check with zero current bet
- **WHEN** the current bet is 0 and Player A checks
- **THEN** Player A's `betMatched` becomes true, `currentBet` remains unchanged, and chip count remains unchanged
#### Scenario: Check after matching previous bet
- **WHEN** Player A has already called a bet of 100 (currentBet = 100) and the next player checks (no raise occurred)
- **THEN** Player A's state remains unchanged except `betMatched` is set to true
### Requirement: Betting round completes when action returns to last aggressor
The system SHALL end a betting round when all active players have matched the current bet and action returns to the position of the last player who raised or opened betting.
#### Scenario: Pre-flop BB option (no raises)
- **WHEN** SB posts 10, BB posts 20, and all players check around back to BB
- **THEN** BB may check without posting additional chips, and the betting round completes
#### Scenario: Raise requires action to return to raiser
- **WHEN** Player A raises to 100, Players B and C call, and Player D checks
- **THEN** the round does not complete until Player A either checks or faces no further raises
### Requirement: All-in players are excluded from betting round completion check
When determining if a betting round is complete, the system SHALL only consider active (non-all-in) players for the "all matched" condition.
#### Scenario: Active player skipped by all-in completion bug
- **WHEN** Player A bets 100, Player B goes all-in for 50, and Player C has not yet acted
- **THEN** the betting round does NOT complete; Player C must act before the round advances
#### Scenario: Single active player ends round
- **WHEN** all players except one have folded or gone all-in
- **THEN** the betting round completes immediately and the game proceeds to the next stage
### Requirement: Turn advancement skips non-active players correctly
The system SHALL advance turns to the next player with `status === 'active'`, skipping folded and all-in players.
#### Scenario: Folded player is skipped
- **WHEN** Player A folds and it's their turn position
- **THEN** the turn advances to the next active player clockwise from Player A
#### Scenario: All-in player is skipped during betting
- **WHEN** Player B goes all-in during a betting round
- **THEN** subsequent turns skip Player B and advance to the next active player

View File

@ -0,0 +1,49 @@
# game-loop-integrity Specification
## Purpose
TBD - created by archiving change fix-texas-holdem-rules. Update Purpose after archive.
## Requirements
### Requirement: Game state transitions are always applied atomically
The system SHALL always apply the complete returned state from action functions and never discard intermediate state updates.
#### Scenario: Betting round completion state is preserved
- **WHEN** `completeBettingRound` returns a new state with advanced `currentTurn` and reset bets
- **THEN** the game applies the entire returned state, including turn advancement and bet resets, even if the betting round stage does not change
#### Scenario: Action function results are never lost
- **WHEN** a player performs any action (check, call, raise, fold, all-in)
- **THEN** the returned state from the action function is applied completely before any subsequent processing occurs
### Requirement: Player controls are disabled during AI turns
The system SHALL disable human player input controls while AI players are taking their turns to prevent race conditions.
#### Scenario: Human clicks during AI turn
- **WHEN** an AI player is processing their turn (400ms delay) and the human player clicks a bet control
- **THEN** the action is rejected, the UI shows disabled controls, and no state mutation occurs
#### Scenario: Controls re-enable after AI turn completes
- **WHEN** an AI player completes their action and the turn advances to the human player
- **THEN** the bet controls become enabled and the human player may act
### Requirement: Validation is enforced before applying any action
The system SHALL validate all actions against game rules before applying state changes, returning unchanged state for invalid actions.
#### Scenario: Invalid raise is rejected
- **WHEN** a player attempts to raise below the minimum raise amount
- **THEN** the action is rejected, the state remains unchanged, and an error reason is returned
#### Scenario: Fold always permitted
- **WHEN** a player attempts to fold during any betting round, including pre-flop
- **THEN** the action is accepted regardless of current bet level
### Requirement: Pot distribution preserves all chips
When awarding pots to winners, the system SHALL distribute all chips without losing remainder amounts.
#### Scenario: Odd pot split between two winners
- **WHEN** the pot contains 105 chips and two players tie for the win
- **THEN** one winner receives 53 chips, the other receives 52 chips, and zero chips are lost
#### Scenario: Single winner receives full pot
- **WHEN** a single player wins the pot of 200 chips
- **THEN** the winner receives exactly 200 chips and the pot is reset to 0

View File

@ -0,0 +1,38 @@
# side-pots Specification
## Purpose
TBD - created by archiving change fix-texas-holdem-rules. Update Purpose after archive.
## Requirements
### Requirement: Side pots are created when players go all-in at different bet levels
When one or more players go all-in for less than the current bet level, the system SHALL create separate side pots to ensure only eligible players can win chips they contributed to.
#### Scenario: Single all-in creates main pot and side pot
- **WHEN** Player A bets 100, Player B goes all-in for 50, and Player C calls 100
- **THEN** a main pot of 150 (50×3) is created with all three players eligible, and a side pot of 100 (25×4) is created with Players A and C eligible
#### Scenario: Multiple all-ins create multiple side pots
- **WHEN** Player A bets 200, Player B goes all-in for 100, Player C goes all-in for 150, and Player D calls 200
- **THEN** a main pot of 400 (100×4) is created with all players eligible, a side pot of 100 (50×3) is created with Players A, C, D eligible, and a side pot of 100 (50×2) is created with Players A and D eligible
### Requirement: Side pots are awarded to eligible winners only
When determining winners for each pot, the system SHALL only award a pot to players who contributed chips to that specific pot level.
#### Scenario: All-in player wins main pot only
- **WHEN** Player B is all-in for 50 and has the best hand at showdown, Player A and C are still active with worse hands
- **THEN** Player B receives the main pot amount proportional to their contribution, and Players A/C compete for the side pot
#### Scenario: All-in player loses all pots
- **WHEN** Player B is all-in for 50 and has the worst hand at showdown
- **THEN** Player B receives no chips from any pot, and eligible players split the main pot proportionally
### Requirement: Side pot calculation handles edge cases correctly
The system SHALL correctly calculate side pots when all players go all-in or when an all-in does not constitute a raise.
#### Scenario: All players all-in creates single pot
- **WHEN** all remaining players go all-in during a betting round
- **THEN** a single main pot containing all chips is created with all players eligible, and no side pots are generated
#### Scenario: All-in that doesn't raise does not create side pot
- **WHEN** the current bet is 100 and Player B goes all-in for 80 (less than a full raise)
- **THEN** no side pot is created; the main pot contains all chips and active players may check to continue

View File

@ -1,13 +1,15 @@
<script lang="ts">
import type { GameState } from '$lib/types/game-state';
let { gameState, onAction }: {
let { gameState, onAction, disabled = false }: {
gameState: GameState;
onAction: (type: string, amount?: number) => void;
disabled?: boolean;
} = $props();
const humanPlayer = $derived(gameState.players[0]);
const isMyTurn = $derived(
!disabled &&
gameState.currentTurn === 0 &&
humanPlayer?.status === 'active' &&
gameState.bettingRound !== 'idle' &&
@ -38,7 +40,7 @@
type="number"
bind:value={raiseAmount}
min={minRaise}
max={humanPlayer?.chips + humanPlayer?.currentBet ?? 0}
max={humanPlayer.chips + humanPlayer.currentBet}
class="raise-input"
aria-label="Raise amount"
/>

View File

@ -1,6 +1,8 @@
import type { GameState } from '$lib/types/game-state';
import type { ActionType } from '$lib/types/action';
import { validateAction } from './validation';
function recordActionForState(state: GameState, playerId: string, type: string, amount?: number): GameState {
function recordActionForState(state: GameState, playerId: string, type: ActionType, amount?: number): GameState {
return {
...state,
actionHistory: [
@ -27,15 +29,30 @@ function advanceTurn(state: GameState): number {
return state.currentTurn;
}
function resetBetMatched(state: GameState, excludePlayerId?: string): GameState {
const updatedPlayers = state.players.map(p => {
if (p.id === excludePlayerId) return p;
if (p.status === 'active') return { ...p, betMatched: false };
return p;
});
return { ...state, players: updatedPlayers };
}
export function applyCheck(state: GameState, playerId: string): GameState {
const validation = validateAction(playerId, 'check', undefined, state);
if (!validation.valid) return state;
const playerIdx = state.players.findIndex(p => p.id === playerId);
const updatedPlayers = [...state.players];
updatedPlayers[playerIdx] = { ...updatedPlayers[playerIdx], currentBet: state.currentBet };
updatedPlayers[playerIdx] = { ...updatedPlayers[playerIdx], betMatched: true };
const result = recordActionForState({ ...state, players: updatedPlayers }, playerId, 'check', 0);
return { ...result, currentTurn: advanceTurn(result) };
}
export function applyCall(state: GameState, playerId: string): GameState {
const validation = validateAction(playerId, 'call', undefined, state);
if (!validation.valid) return state;
const playerIdx = state.players.findIndex(p => p.id === playerId);
const player = state.players[playerIdx];
const callAmount = Math.min(state.currentBet - player.currentBet, player.chips);
@ -43,6 +60,7 @@ export function applyCall(state: GameState, playerId: string): GameState {
const newPlayer = { ...updatedPlayers[playerIdx] };
newPlayer.chips -= callAmount;
newPlayer.currentBet += callAmount;
newPlayer.betMatched = true;
if (newPlayer.chips === 0 && callAmount > 0) {
newPlayer.status = 'all-in';
}
@ -52,16 +70,29 @@ export function applyCall(state: GameState, playerId: string): GameState {
}
export function applyRaise(state: GameState, playerId: string, amount: number): GameState {
const validation = validateAction(playerId, 'raise', amount, state);
if (!validation.valid) return state;
const playerIdx = state.players.findIndex(p => p.id === playerId);
const player = state.players[playerIdx];
const totalBet = Math.min(amount, player.chips + player.currentBet);
const added = totalBet - player.currentBet;
const raiseOverCurrent = totalBet - state.currentBet;
const updatedPlayers = [...state.players];
let newState: GameState = {
...state,
currentBet: totalBet,
lastRaiseAmount: raiseOverCurrent > 0 ? raiseOverCurrent : state.lastRaiseAmount,
lastAggressorIndex: playerIdx
};
newState = resetBetMatched(newState, playerId);
const updatedPlayers = [...newState.players];
const newPlayer = { ...updatedPlayers[playerIdx] };
newPlayer.chips -= added;
newPlayer.currentBet = totalBet;
newPlayer.betMatched = true;
if (newPlayer.chips === 0 && added > 0) {
newPlayer.status = 'all-in';
}
@ -69,11 +100,9 @@ export function applyRaise(state: GameState, playerId: string, amount: number):
const result = recordActionForState(
{
...state,
...newState,
players: updatedPlayers,
pot: state.pot + added,
currentBet: totalBet,
lastRaiseAmount: raiseOverCurrent > 0 ? raiseOverCurrent : state.lastRaiseAmount
pot: newState.pot + added
},
playerId,
'raise',
@ -83,6 +112,9 @@ export function applyRaise(state: GameState, playerId: string, amount: number):
}
export function applyFold(state: GameState, playerId: string): GameState {
const validation = validateAction(playerId, 'fold', undefined, state);
if (!validation.valid) return state;
const playerIdx = state.players.findIndex(p => p.id === playerId);
const updatedPlayers = [...state.players];
updatedPlayers[playerIdx] = { ...updatedPlayers[playerIdx], status: 'folded' as const };
@ -91,20 +123,36 @@ export function applyFold(state: GameState, playerId: string): GameState {
}
export function applyAllIn(state: GameState, playerId: string): GameState {
const validation = validateAction(playerId, 'all-in', undefined, state);
if (!validation.valid) return state;
const playerIdx = state.players.findIndex(p => p.id === playerId);
const player = state.players[playerIdx];
const amount = player.chips;
const totalBet = player.currentBet + amount;
const raiseOverCurrent = totalBet - state.currentBet;
const updatedPlayers = [...state.players];
let newState: GameState = { ...state };
if (raiseOverCurrent > 0) {
newState = {
...newState,
currentBet: totalBet,
lastRaiseAmount: Math.max(raiseOverCurrent, state.lastRaiseAmount),
lastAggressorIndex: playerIdx
};
newState = resetBetMatched(newState, playerId);
}
const updatedPlayers = [...newState.players];
const newPlayer = { ...updatedPlayers[playerIdx] };
newPlayer.chips = 0;
newPlayer.currentBet += amount;
newPlayer.currentBet = totalBet;
newPlayer.status = 'all-in';
newPlayer.betMatched = true;
updatedPlayers[playerIdx] = newPlayer;
const newCurrentBet = Math.max(state.currentBet, newPlayer.currentBet);
const result = recordActionForState(
{ ...state, players: updatedPlayers, pot: state.pot + amount, currentBet: newCurrentBet },
{ ...newState, players: updatedPlayers, pot: newState.pot + amount },
playerId,
'all-in',
amount

View File

@ -1,17 +1,40 @@
import type { GameState } from '$lib/types/game-state';
export function completeBettingRound(state: GameState): GameState {
const activePlayers = state.players.filter(p => p.status === 'active' || p.status === 'all-in');
export function isRoundComplete(state: GameState): boolean {
const activePlayers = state.players.filter(p => p.status === 'active');
if (activePlayers.length <= 1) {
return state;
return true;
}
const currentMaxBet = state.currentBet;
const allMatched = activePlayers.every(
p => p.status === 'all-in' || p.currentBet >= currentMaxBet
);
if (state.lastAggressorIndex === -1) {
return activePlayers.every(p => p.betMatched);
}
if (!allMatched) {
const lastAgg = state.players[state.lastAggressorIndex];
if (lastAgg.status !== 'active') {
return activePlayers.every(p => p.betMatched);
}
return state.currentTurn === state.lastAggressorIndex && lastAgg.betMatched;
}
function findFirstActivePlayer(state: GameState): number {
const numPlayers = state.players.length;
let pos = (state.dealerPosition + 1) % numPlayers;
let checked = 0;
while (checked < numPlayers) {
if (state.players[pos].status === 'active') {
return pos;
}
pos = (pos + 1) % numPlayers;
checked++;
}
return state.dealerPosition;
}
export function completeBettingRound(state: GameState): GameState {
if (!isRoundComplete(state)) {
return state;
}
@ -35,15 +58,18 @@ export function completeBettingRound(state: GameState): GameState {
const resetPlayers = state.players.map(p => {
if (p.status === 'folded' || p.status === 'all-in') return p;
return { ...p, currentBet: 0 };
return { ...p, currentBet: 0, betMatched: false };
});
const nextTurn = findFirstActivePlayer(state);
return {
...state,
players: resetPlayers,
bettingRound: nextRound,
currentBet: 0,
lastRaiseAmount: 0,
currentTurn: (state.dealerPosition + 1) % state.players.length
lastAggressorIndex: -1,
currentTurn: nextTurn
};
}

View File

@ -9,12 +9,14 @@ export function postBlinds(state: GameState): GameState {
const sbPlayer = { ...newState.players[sbIndex] };
sbPlayer.chips -= state.smallBlind;
sbPlayer.currentBet = state.smallBlind;
sbPlayer.betMatched = false;
newState.players = [...newState.players];
newState.players[sbIndex] = sbPlayer;
const bbPlayer = { ...newState.players[bbIndex] };
bbPlayer.chips -= state.bigBlind;
bbPlayer.currentBet = state.bigBlind;
bbPlayer.betMatched = false;
newState.players[bbIndex] = bbPlayer;
newState.pot = state.smallBlind + state.bigBlind;

View File

@ -8,11 +8,12 @@ export function startNewHand(state: GameState): GameState {
let newState = rotateDealer(state);
const deck = shuffleDeck(createDeck());
newState = { ...newState, deck, communityCards: [], pot: 0, actionHistory: [] };
newState = { ...newState, deck, communityCards: [], pot: 0, actionHistory: [], lastAggressorIndex: -1, sidePots: [] };
const resetPlayers = newState.players.map(p => ({
...p,
currentBet: 0,
betMatched: false,
status: p.chips > 0 ? 'active' as const : p.status,
holeCards: []
}));

38
src/lib/game/pots.ts Normal file
View File

@ -0,0 +1,38 @@
import type { GameState, SidePot } from '$lib/types/game-state';
export function calculatePots(state: GameState): { mainPot: number; sidePots: SidePot[] } {
const nonFolded = state.players.filter(p => p.status !== 'folded');
if (nonFolded.length === 0) return { mainPot: state.pot, sidePots: [] };
const sortedByBet = [...nonFolded].sort((a, b) => a.currentBet - b.currentBet);
const allInPlayers = nonFolded.filter(p => p.status === 'all-in');
if (allInPlayers.length === 0 || allInPlayers.length === nonFolded.length) {
return { mainPot: state.pot, sidePots: [] };
}
const sidePots: SidePot[] = [];
let previousLevel = 0;
for (let i = 0; i < allInPlayers.length; i++) {
const player = sortedByBet.find(p => p.status === 'all-in' && p.currentBet >= allInPlayers[i].currentBet)!;
const currentLevel = player.currentBet;
const diff = currentLevel - previousLevel;
if (diff <= 0) continue;
const eligible = nonFolded.filter(p => p.currentBet >= currentLevel || p.status === 'all-in');
const potAmount = eligible.length * diff;
if (i < allInPlayers.length - 1) {
sidePots.push({ amount: potAmount, eligiblePlayerIds: eligible.map(p => p.id) });
} else {
sidePots.push({ amount: potAmount, eligiblePlayerIds: eligible.map(p => p.id) });
}
previousLevel = currentLevel;
}
const mainPotAmount = state.pot - sidePots.reduce((sum, sp) => sum + sp.amount, 0);
return { mainPot: Math.max(0, mainPotAmount), sidePots };
}

View File

@ -1,6 +1,7 @@
import type { GameState } from '$lib/types/game-state';
import type { GameState, SidePot } from '$lib/types/game-state';
import { evaluateHand } from '$lib/utils/hand-evaluator';
import { compareHands } from '$lib/utils/ranks';
import { calculatePots } from './pots';
export interface ShowdownResult {
winners: string[];
@ -25,7 +26,7 @@ export function determineWinner(state: GameState): ShowdownResult {
}
let bestResult = handResults.values().next().value;
for (const [id, result] of handResults) {
for (const [, result] of handResults) {
if (compareHands(result, bestResult!) > 0) {
bestResult = result;
}
@ -41,11 +42,46 @@ export function determineWinner(state: GameState): ShowdownResult {
return { winners, handResults };
}
function awardSinglePot(potAmount: number, eligibleWinners: string[]): { chipAwards: Map<string, number>; remainder: number } {
const awards = new Map<string, number>();
if (eligibleWinners.length === 0) return { chipAwards: awards, remainder: potAmount };
const share = Math.floor(potAmount / eligibleWinners.length);
let distributed = 0;
for (let i = 0; i < eligibleWinners.length; i++) {
const winnerId = eligibleWinners[i];
const amount = i === eligibleWinners.length - 1 ? potAmount - distributed : share;
awards.set(winnerId, (awards.get(winnerId) || 0) + amount);
distributed += amount;
}
return { chipAwards: awards, remainder: 0 };
}
export function awardPot(state: GameState, winners: string[]): GameState {
const share = Math.floor(state.pot / winners.length);
const { mainPot, sidePots } = calculatePots(state);
const allAwards = new Map<string, number>();
const eligibleMainWinners = winners;
const { chipAwards: mainAwards } = awardSinglePot(mainPot, eligibleMainWinners.length > 0 ? eligibleMainWinners : winners);
for (const [id, amount] of mainAwards) {
allAwards.set(id, (allAwards.get(id) || 0) + amount);
}
for (const sidePot of sidePots) {
const eligibleWinners = winners.filter(id => sidePot.eligiblePlayerIds.includes(id));
const { chipAwards: sideAwards } = awardSinglePot(sidePot.amount, eligibleWinners.length > 0 ? eligibleWinners : sidePot.eligiblePlayerIds.slice(0, 1));
for (const [id, amount] of sideAwards) {
allAwards.set(id, (allAwards.get(id) || 0) + amount);
}
}
const updatedPlayers = state.players.map(p => {
if (winners.includes(p.id)) {
return { ...p, chips: p.chips + share };
const award = allAwards.get(p.id) || 0;
if (award > 0) {
return { ...p, chips: p.chips + award };
}
return p;
});
@ -53,6 +89,7 @@ export function awardPot(state: GameState, winners: string[]): GameState {
return {
...state,
players: updatedPlayers,
pot: 0
pot: 0,
sidePots: []
};
}

View File

@ -10,6 +10,7 @@ export function createInitialState(numPlayers: number, startingStack: number): G
name: i === 0 ? 'You' : `Bot ${i}`,
chips: startingStack,
currentBet: 0,
betMatched: false,
status: 'active',
holeCards: [],
position: i
@ -28,7 +29,9 @@ export function createInitialState(numPlayers: number, startingStack: number): G
currentBet: 0,
lastRaiseAmount: 0,
smallBlind: 10,
bigBlind: 20
bigBlind: 20,
lastAggressorIndex: -1,
sidePots: []
};
}

View File

@ -16,3 +16,19 @@ export function getNextActivePlayer(state: GameState): number | null {
return null;
}
export function getFirstActivePlayerLeftOfDealer(state: GameState): number | null {
const numPlayers = state.players.length;
let pos = (state.dealerPosition + 1) % numPlayers;
let checked = 0;
while (checked < numPlayers) {
if (state.players[pos].status === 'active') {
return pos;
}
pos = (pos + 1) % numPlayers;
checked++;
}
return null;
}

View File

@ -23,11 +23,10 @@ export function validateAction(
case 'call': {
const callAmount = state.currentBet - player.currentBet;
if (callAmount <= 0) return { valid: false, reason: 'Nothing to call' };
if (player.chips < callAmount && player.chips > 0) {
if (player.chips > 0) {
return { valid: true };
}
if (player.chips < callAmount) return { valid: false, reason: 'Not enough chips' };
return { valid: true };
return { valid: false, reason: 'Not enough chips' };
}
case 'raise': {
@ -36,19 +35,22 @@ export function validateAction(
? state.currentBet + state.lastRaiseAmount
: state.currentBet + state.bigBlind;
if (amount < minRaise) return { valid: false, reason: `Minimum raise is ${minRaise}` };
if (player.chips - player.currentBet < amount) return { valid: false, reason: 'Not enough chips' };
const totalNeeded = amount - player.currentBet;
if (totalNeeded > player.chips) return { valid: false, reason: 'Not enough chips' };
return { valid: true };
}
case 'fold':
if (state.currentBet <= player.currentBet && state.bettingRound !== 'pre-flop') {
return { valid: false, reason: 'Cannot check; use fold or check' };
}
return { valid: true };
case 'all-in':
case 'all-in': {
if (player.chips <= 0) return { valid: false, reason: 'No chips remaining' };
const allInTotal = player.chips + player.currentBet;
if (allInTotal <= state.currentBet) {
return { valid: false, reason: 'All-in amount does not exceed current bet' };
}
return { valid: true };
}
default:
return { valid: false, reason: 'Unknown action' };

View File

@ -4,6 +4,11 @@ import type { ActionRecord } from './action';
export type BettingRound = 'pre-flop' | 'flop' | 'turn' | 'river' | 'showdown' | 'idle';
export interface SidePot {
amount: number;
eligiblePlayerIds: string[];
}
export interface GameState {
deck: Card[];
players: PlayerSeat[];
@ -17,4 +22,6 @@ export interface GameState {
lastRaiseAmount: number;
smallBlind: number;
bigBlind: number;
lastAggressorIndex: number;
sidePots: SidePot[];
}

View File

@ -7,6 +7,7 @@ export interface PlayerSeat {
name: string;
chips: number;
currentBet: number;
betMatched: boolean;
status: PlayerStatus;
holeCards: Card[];
position: number;

View File

@ -4,7 +4,7 @@
import { createInitialState, rotateDealer } from '$lib/game/state';
import { startNewHand } from '$lib/game/hand';
import { applyCheck, applyCall, applyRaise, applyFold, applyAllIn } from '$lib/game/actions';
import { completeBettingRound } from '$lib/game/betting-round';
import { completeBettingRound, isRoundComplete } from '$lib/game/betting-round';
import { dealCommunityCards } from '$lib/game/dealing';
import { determineWinner, awardPot } from '$lib/game/showdown';
@ -12,13 +12,17 @@
let gameState = $state(startNewHand(initialState));
let message = $state('');
let aiActing = $state(false);
const initialTurn = gameState.currentTurn;
if (initialTurn !== 0) {
setTimeout(() => aiAct(), 100);
function checkInitialTurn() {
if (gameState.currentTurn !== 0) {
setTimeout(() => aiAct(), 100);
}
}
checkInitialTurn();
function handleAction(type: string, amount?: number) {
if (aiActing) return;
if (gameState.bettingRound === 'idle' || gameState.bettingRound === 'showdown') return;
let newState: typeof gameState;
@ -42,25 +46,29 @@
return;
}
if (newState === gameState) return;
gameState = { ...newState };
processGame();
processTurn();
}
function processGame() {
function processTurn() {
if (gameState.bettingRound === 'idle' || gameState.bettingRound === 'showdown') return;
const activeCount = gameState.players.filter(p => p.status === 'active' || p.status === 'all-in').length;
if (activeCount <= 1) {
const winner = gameState.players.find(p => p.status !== 'folded')!;
gameState = awardPot(gameState, [winner.id]);
gameState.bettingRound = 'showdown';
message = `${winner.name} wins $${gameState.pot > 0 ? gameState.pot : 'the pot'}!`;
message = `${winner.name} wins the pot!`;
return;
}
let processed = completeBettingRound(gameState);
if (processed.bettingRound !== gameState.bettingRound) {
if (isRoundComplete(gameState)) {
const processed = completeBettingRound(gameState);
gameState = { ...processed };
if (gameState.bettingRound === 'flop') {
const { deck, cards } = dealCommunityCards(gameState.deck, 3);
gameState.deck = deck;
@ -74,73 +82,90 @@
if (gameState.bettingRound === 'showdown') {
const { winners } = determineWinner(gameState);
gameState = awardPot(gameState, winners);
message = `${winners.map(id => gameState.players.find(p => p.id === id)?.name).join(', ')} wins!`;
} else {
const canBet = gameState.players.some(p => p.status === 'active');
if (canBet && gameState.currentTurn !== 0) {
aiAct();
} else if (!canBet) {
setTimeout(() => processGame(), 300);
}
const winnerNames = winners.map(id => gameState.players.find(p => p.id === id)?.name).filter(Boolean);
message = `${winnerNames.join(', ')} wins!`;
return;
}
if (gameState.currentTurn !== 0) {
aiAct();
}
return;
}
const canBet = gameState.players.some(p => p.status === 'active');
if (!canBet) {
setTimeout(() => processGame(), 300);
} else if (gameState.currentTurn !== 0) {
aiAct();
if (gameState.currentTurn !== 0) {
const nextPlayer = gameState.players[gameState.currentTurn];
if (nextPlayer && nextPlayer.status === 'active') {
aiAct();
}
}
}
function aiAct() {
let aiIndex = gameState.currentTurn;
async function aiAct() {
aiActing = true;
if (aiIndex === 0) return;
await new Promise(resolve => setTimeout(resolve, 400));
const aiIndex = gameState.currentTurn;
if (aiIndex === 0) {
aiActing = false;
return;
}
const aiPlayer = gameState.players[aiIndex];
if (!aiPlayer || aiPlayer.status !== 'active') return;
if (!aiPlayer || aiPlayer.status !== 'active') {
aiActing = false;
processTurn();
return;
}
setTimeout(() => {
const actions: string[] = [];
if (aiPlayer.currentBet >= gameState.currentBet) actions.push('check');
else actions.push('call');
const actions: string[] = [];
if (aiPlayer.currentBet >= gameState.currentBet) actions.push('check');
else actions.push('call');
if (aiPlayer.chips > gameState.currentBet - aiPlayer.currentBet + gameState.bigBlind) {
actions.push('raise');
}
if (Math.random() < 0.15) actions.push('fold');
if (aiPlayer.chips > gameState.currentBet - aiPlayer.currentBet + gameState.bigBlind) {
actions.push('raise');
}
if (Math.random() < 0.15) actions.push('fold');
const action = actions[Math.floor(Math.random() * actions.length)];
const action = actions[Math.floor(Math.random() * actions.length)];
let newState: typeof gameState;
switch (action) {
case 'check':
newState = applyCheck(gameState, aiPlayer.id);
break;
case 'call':
newState = applyCall(gameState, aiPlayer.id);
break;
case 'raise':
const raiseAmt = Math.min(gameState.currentBet + gameState.bigBlind, aiPlayer.chips + aiPlayer.currentBet);
newState = applyRaise(gameState, aiPlayer.id, raiseAmt);
break;
case 'fold':
newState = applyFold(gameState, aiPlayer.id);
break;
default:
newState = applyCall(gameState, aiPlayer.id);
}
let newState: typeof gameState;
switch (action) {
case 'check':
newState = applyCheck(gameState, aiPlayer.id);
break;
case 'call':
newState = applyCall(gameState, aiPlayer.id);
break;
case 'raise':
const raiseAmt = Math.min(gameState.currentBet + gameState.bigBlind, aiPlayer.chips + aiPlayer.currentBet);
newState = applyRaise(gameState, aiPlayer.id, raiseAmt);
break;
case 'fold':
newState = applyFold(gameState, aiPlayer.id);
break;
default:
newState = applyCall(gameState, aiPlayer.id);
}
if (newState !== gameState) {
gameState = { ...newState };
processGame();
}, 400);
}
aiActing = false;
processTurn();
}
function startNextHand() {
message = '';
aiActing = false;
gameState = startNewHand(gameState);
const initialTurn = gameState.currentTurn;
if (initialTurn !== 0) {
setTimeout(() => aiAct(), 100);
}
}
</script>
@ -152,7 +177,7 @@
<PokerTable {gameState} />
<BetControls {gameState} onAction={handleAction} />
<BetControls {gameState} onAction={handleAction} disabled={aiActing} />
{#if message}
<div class="message" role="alert">{message}</div>