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:
parent
6930858074
commit
a07117efaf
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-17
|
||||
@ -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?
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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)
|
||||
49
openspec/specs/betting-round-flow/spec.md
Normal file
49
openspec/specs/betting-round-flow/spec.md
Normal 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
|
||||
|
||||
49
openspec/specs/game-loop-integrity/spec.md
Normal file
49
openspec/specs/game-loop-integrity/spec.md
Normal 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
|
||||
|
||||
38
openspec/specs/side-pots/spec.md
Normal file
38
openspec/specs/side-pots/spec.md
Normal 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
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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,23 +123,39 @@ 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
|
||||
);
|
||||
return { ...result, currentTurn: advanceTurn(result) };
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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
38
src/lib/game/pots.ts
Normal 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 };
|
||||
}
|
||||
@ -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: []
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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: []
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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,21 +35,24 @@ 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' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ export interface PlayerSeat {
|
||||
name: string;
|
||||
chips: number;
|
||||
currentBet: number;
|
||||
betMatched: boolean;
|
||||
status: PlayerStatus;
|
||||
holeCards: Card[];
|
||||
position: number;
|
||||
|
||||
@ -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>
|
||||
@ -216,4 +241,4 @@
|
||||
background: #27ae60;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
Loading…
x
Reference in New Issue
Block a user