feat: implement foundational poker table with game engine and UI
Add complete Texas Hold'em poker gameplay including: - Pure function game engine (deck, dealing, betting, showdown) - Hand evaluator supporting all 10 poker hand ranks - Animated card components with 3D flip transitions - CSS Grid oval poker table layout for up to 9 seats - Player seat components with chip tracking and turn indicators - Bet controls with conditional button visibility - Basic AI opponents with random valid action selection - Turn advancement, all-in auto-advance, and dealer rotation Sync delta specs to main specs: poker-table, card-components, game-engine, player-state.
This commit is contained in:
parent
8c59297f82
commit
bcb5465247
@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-17
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Fresh SvelteKit 2 + Svelte 5 project with no poker functionality. The proposal defines four capabilities: poker table UI, card components, game engine, and player state management. No external dependencies will be added — everything uses Svelte 5 runes, native CSS, and built-in transition primitives.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Playable Texas Hold'em game with human player vs AI opponents
|
||||||
|
- Smooth card flip/deal animations using Svelte's `flip()` and `transition:` directives
|
||||||
|
- Clean component hierarchy with clear separation between UI components and game logic
|
||||||
|
- Responsive poker table layout that works on desktop screens
|
||||||
|
- State management via Svelte 5 runes (`$state`, `$derived`) — no stores needed
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Multiplayer networking (local play only)
|
||||||
|
- Tournament mode or cash game buy-in flows
|
||||||
|
- Educational tools, strategy guidance, or GTO analysis
|
||||||
|
- TailwindCSS or any CSS framework — native Svelte styling only
|
||||||
|
- Mobile-first responsive design (desktop first)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### State Management: Runes over Stores
|
||||||
|
Using Svelte 5 `$state` and `$derived` runes instead of writable stores. The game state is a single reactive object passed down through component props. This avoids the store subscription overhead and gives us true two-way binding with `$stateful` when needed for form inputs.
|
||||||
|
|
||||||
|
**Alternatives considered:** Svelte stores (legacy pattern, unnecessary complexity), Zustand or external state library (adds dependency, defeats purpose of using Svelte 5 runes).
|
||||||
|
|
||||||
|
### Game Engine: Pure Functions over Classes
|
||||||
|
The game engine will be a set of pure functions operating on an immutable `GameState` object. Each action (deal, bet, fold, check) returns a new state. This makes the logic testable, predictable, and easy to reason about. The UI layer reads from this state and dispatches actions.
|
||||||
|
|
||||||
|
**Alternatives considered:** Class-based engine (mutable state, harder to test), finite state machine library (overhead for a single game type).
|
||||||
|
|
||||||
|
### Card Animations: Svelte `flip()` + CSS 3D Transforms
|
||||||
|
Card flip uses CSS `transform: rotateY(180deg)` with `backface-visibility: hidden`. Dealing animations use Svelte's `flip()` spring animation to animate cards from dealer position to their final seat. Hover effects use simple CSS transitions on scoped styles.
|
||||||
|
|
||||||
|
**Alternatives considered:** Framer Motion or motion library (external dependency), SVG-based cards (unnecessary complexity).
|
||||||
|
|
||||||
|
### Table Layout: CSS Grid
|
||||||
|
The poker table uses a single CSS Grid with named areas for each player seat, community card zone, and pot display. Seat positions are calculated using `grid-column`/`grid-row` based on an oval table shape. The dealer button rotates using grid positioning updates driven by `$derived` state.
|
||||||
|
|
||||||
|
**Alternatives considered:** Absolute positioning (hard to maintain), Flexbox-only layout (insufficient for radial seating arrangement).
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
```
|
||||||
|
src/lib/
|
||||||
|
types/ # TypeScript interfaces (Card, Hand, Player, GameState)
|
||||||
|
game/ # Pure game logic functions (deck, betting, hand evaluation)
|
||||||
|
components/ # Svelte UI components (Card, PokerTable, PlayerSeat, BetControls)
|
||||||
|
utils/ # Helpers (hand ranker, suit/rank constants)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Hand evaluation algorithm complexity** → Using a simplified scoring approach (rank-based comparison) rather than enumerating all 7-card combinations. May miss edge cases in rare hands but keeps implementation manageable for v1.
|
||||||
|
- **No external animation library** → Svelte's `flip()` handles most cases, but complex multi-card deal sequences may require manual spring configuration. Acceptable trade-off for zero dependencies.
|
||||||
|
- **Single-page game only** → No route-based navigation; entire game lives on the root page. This simplifies state management but means no browser back-button support mid-game.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- How many AI difficulty levels should the initial engine support? (Suggested: one basic level with random valid actions)
|
||||||
|
- Should blinds be configurable or fixed for v1? (Suggested: fixed small/big blind, configurable via game state)
|
||||||
|
- What chip denomination system to use? (Suggested: simple numeric values, no visual chip denominations needed in v1)
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The project is a blank SvelteKit scaffold with no poker functionality. We need to build the foundational playable poker table — the core UI, game logic, and card components that make up the basic gameplay experience. This is the prerequisite for all future features (bots, strategy tools, education).
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Create an interactive poker table UI with player seats, community card area, pot display, and betting controls
|
||||||
|
- Build animated card components using Svelte 5 runes and native transitions (flip, deal, hover effects)
|
||||||
|
- Implement Texas Hold'em game logic engine covering all four betting rounds (pre-flop, flop, turn, river) and hand evaluation
|
||||||
|
- Add player state management (chips, bets, fold/active status, position tracking)
|
||||||
|
- Style everything using Svelte's native scoped CSS with CSS custom properties driven by runes
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `poker-table`: Visual poker table layout with 9-player seating, community cards area, pot display, and action buttons
|
||||||
|
- `card-components`: Reusable card component with flip animations, suit/rank rendering, and deal transitions using Svelte native transitions
|
||||||
|
- `game-engine`: Texas Hold'em game logic including deck management, dealing, betting rounds, hand evaluation, and winner determination
|
||||||
|
- `player-state`: Player seat model tracking chips, current bet, status (active/folded/all-in), position (dealer button rotation), and action history
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
(No existing capabilities — fresh project)
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **New files**: Components in `src/lib/components/`, game logic in `src/lib/game/`, types in `src/lib/types/`
|
||||||
|
- **Route**: Main poker table page at `src/routes/+page.svelte`
|
||||||
|
- **Dependencies**: No new npm packages; using Svelte 5 runes, native CSS, and built-in transition primitives only
|
||||||
|
- **Affected files**: `src/routes/+layout.svelte`, `src/routes/+page.svelte` (replace starter content)
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Card face rendering
|
||||||
|
The card component SHALL display the card's rank and suit symbol with correct color (red for hearts/diamonds, black for spades/clubs).
|
||||||
|
|
||||||
|
#### Scenario: Red suit cards displayed correctly
|
||||||
|
- **WHEN** a card has a heart or diamond suit
|
||||||
|
- **THEN** the rank and suit symbol render in red color
|
||||||
|
|
||||||
|
#### Scenario: Black suit cards displayed correctly
|
||||||
|
- **WHEN** a card has a spade or club suit
|
||||||
|
- **THEN** the rank and suit symbol render in black color
|
||||||
|
|
||||||
|
### Requirement: Card flip animation
|
||||||
|
The card component SHALL support a flipped state that hides the card face using a 3D CSS rotation animation.
|
||||||
|
|
||||||
|
#### Scenario: Card starts face-down
|
||||||
|
- **WHEN** a card is dealt to a player before reveal
|
||||||
|
- **THEN** the card shows its back pattern and the face is hidden via CSS `rotateY(180deg)` transform
|
||||||
|
|
||||||
|
#### Scenario: Card flips to reveal face
|
||||||
|
- **WHEN** the flip state changes from false to true
|
||||||
|
- **THEN** the card animates with a smooth rotation transition to show the face
|
||||||
|
|
||||||
|
#### Scenario: Card flips back to hidden
|
||||||
|
- **WHEN** the flip state changes from true to false
|
||||||
|
- **THEN** the card animates with a smooth rotation transition to hide the face
|
||||||
|
|
||||||
|
### Requirement: Deal animation
|
||||||
|
Cards dealt during the dealing phase SHALL animate from the dealer position to their final position using Svelte's `flip()` spring animation.
|
||||||
|
|
||||||
|
#### Scenario: Hole cards animate on deal
|
||||||
|
- **WHEN** hole cards are dealt at the start of a hand
|
||||||
|
- **THEN** each card animates from a central dealer position to its target seat using a spring-based flip transition
|
||||||
|
|
||||||
|
### Requirement: Card hover effect
|
||||||
|
The card component SHALL provide a visual hover effect (e.g., slight scale increase or shadow) when the user hovers over an active card.
|
||||||
|
|
||||||
|
#### Scenario: Hover effect on valid cards
|
||||||
|
- **WHEN** the user hovers over their own visible hole card during their turn
|
||||||
|
- **THEN** the card displays a subtle elevation effect via CSS transition
|
||||||
|
|
||||||
|
### Requirement: Card dimensions and styling
|
||||||
|
All card instances SHALL use consistent dimensions (width, height, border-radius) defined by CSS custom properties.
|
||||||
|
|
||||||
|
#### Scenario: Consistent card sizing
|
||||||
|
- **WHEN** multiple cards are rendered in different areas of the table
|
||||||
|
- **THEN** all cards share the same width, height, and border-radius values from shared CSS custom properties
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Deck creation and shuffling
|
||||||
|
The game engine SHALL create a standard 52-card deck and shuffle it using a random algorithm before each hand.
|
||||||
|
|
||||||
|
#### Scenario: Full 52-card deck created
|
||||||
|
- **WHEN** a new deck is initialized
|
||||||
|
- **THEN** the deck contains exactly 52 cards covering all ranks (2 through Ace) across all four suits
|
||||||
|
|
||||||
|
#### Scenario: Deck is shuffled randomly
|
||||||
|
- **WHEN** a deck is prepared for dealing
|
||||||
|
- **THEN** the card order is randomized and differs between consecutive shuffles
|
||||||
|
|
||||||
|
### Requirement: Dealing phase execution
|
||||||
|
The game engine SHALL deal 2 hole cards to each active player, then burn and deal community cards at each street (flop, turn, river).
|
||||||
|
|
||||||
|
#### Scenario: Hole cards dealt to all players
|
||||||
|
- **WHEN** a hand begins dealing
|
||||||
|
- **THEN** each active player receives exactly 2 face-down hole cards
|
||||||
|
|
||||||
|
#### Scenario: Flop deals 3 community cards
|
||||||
|
- **WHEN** the pre-flop betting round completes
|
||||||
|
- **THEN** 3 community cards are dealt face-up to the board
|
||||||
|
|
||||||
|
#### Scenario: Turn deals 1 community card
|
||||||
|
- **WHEN** the flop betting round completes
|
||||||
|
- **THEN** 1 additional community card is dealt face-up
|
||||||
|
|
||||||
|
#### Scenario: River deals final community card
|
||||||
|
- **WHEN** the turn betting round completes
|
||||||
|
- **THEN** 1 final community card is dealt face-up
|
||||||
|
|
||||||
|
### Requirement: Betting round progression
|
||||||
|
The game engine SHALL progress through four betting rounds (pre-flop, flop, turn, river) in sequence, each starting after the player left of the dealer.
|
||||||
|
|
||||||
|
#### Scenario: Pre-flop begins first
|
||||||
|
- **WHEN** hole cards are dealt
|
||||||
|
- **THEN** the pre-flop betting round starts with the player left of the dealer button
|
||||||
|
|
||||||
|
#### Scenario: Flop follows pre-flop
|
||||||
|
- **WHEN** all active players have matched bets in the pre-flop round
|
||||||
|
- **THEN** community cards (flop) are dealt and the flop betting round begins
|
||||||
|
|
||||||
|
#### Scenario: Showdown after river
|
||||||
|
- **WHEN** the river betting round completes with 2+ active players remaining
|
||||||
|
- **THEN** a showdown is triggered where remaining hole cards are revealed
|
||||||
|
|
||||||
|
### Requirement: Blind posting
|
||||||
|
The game engine SHALL enforce small blind and big blind posting before each hand, positioned left of the dealer button.
|
||||||
|
|
||||||
|
#### Scenario: Small blind posted
|
||||||
|
- **WHEN** a new hand starts
|
||||||
|
- **THEN** the player immediately left of the dealer posts the small blind amount
|
||||||
|
|
||||||
|
#### Scenario: Big blind posted
|
||||||
|
- **WHEN** a new hand starts
|
||||||
|
- **THEN** the player two seats left of the dealer posts the big blind amount (2x small blind)
|
||||||
|
|
||||||
|
### Requirement: Player action validation
|
||||||
|
The game engine SHALL validate each player action against the current game state and reject invalid actions.
|
||||||
|
|
||||||
|
#### Scenario: Fold rejected when no bet to call
|
||||||
|
- **WHEN** a player attempts to fold but no preceding bet exists in the current round
|
||||||
|
- **THEN** the action is rejected and Check is suggested instead
|
||||||
|
|
||||||
|
#### Scenario: Raise amount validated
|
||||||
|
- **WHEN** a player attempts to raise
|
||||||
|
- **THEN** the raise amount must be at least the size of the previous bet or big blind, whichever is larger
|
||||||
|
|
||||||
|
#### Scenario: All-in limited by chip count
|
||||||
|
- **WHEN** a player goes all-in with fewer chips than the call amount
|
||||||
|
- **THEN** the player's entire stack is posted and they are marked as all-in
|
||||||
|
|
||||||
|
### Requirement: Hand evaluation and winner determination
|
||||||
|
The game engine SHALL evaluate each remaining player's best 5-card hand from their 2 hole cards and 5 community cards, then award the pot to the winner.
|
||||||
|
|
||||||
|
#### Scenario: Best hand wins the pot
|
||||||
|
- **WHEN** showdown occurs with multiple active players
|
||||||
|
- **THEN** the player with the highest-ranking poker hand wins the entire pot
|
||||||
|
|
||||||
|
#### Scenario: Split pot on tie
|
||||||
|
- **WHEN** two or more players have hands of equal rank at showdown
|
||||||
|
- **THEN** the pot is divided equally among tied players
|
||||||
|
|
||||||
|
#### Scenario: Last player standing wins without showdown
|
||||||
|
- **WHEN** all opponents fold leaving one active player
|
||||||
|
- **THEN** that player wins the pot without a showdown and hole cards are not revealed
|
||||||
|
|
||||||
|
### Requirement: Pure function state transitions
|
||||||
|
The game engine SHALL represent each action as a pure function that takes the current `GameState` and returns a new immutable `GameState`.
|
||||||
|
|
||||||
|
#### Scenario: State immutability preserved
|
||||||
|
- **WHEN** an action function is called with a game state
|
||||||
|
- **THEN** the original state object is unchanged and a new state object is returned
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Player seat model
|
||||||
|
Each player seat SHALL be represented by a model containing: ID, name, chip count, current bet, status (active/folded/all-in), hole cards, and position index.
|
||||||
|
|
||||||
|
#### Scenario: Player initialized with starting stack
|
||||||
|
- **WHEN** a new game is created
|
||||||
|
- **THEN** each player seat has the configured starting chip count and active status
|
||||||
|
|
||||||
|
#### Scenario: Folded player marked correctly
|
||||||
|
- **WHEN** a player folds during a hand
|
||||||
|
- **THEN** their status changes to folded and they cannot take further actions that hand
|
||||||
|
|
||||||
|
#### Scenario: All-in player tracked separately
|
||||||
|
- **WHEN** a player bets all remaining chips
|
||||||
|
- **THEN** their status changes to all-in and they are excluded from further betting but remain active for showdown
|
||||||
|
|
||||||
|
### Requirement: Dealer position tracking
|
||||||
|
The game state SHALL track the current dealer button position as an index that rotates clockwise after each hand.
|
||||||
|
|
||||||
|
#### Scenario: Dealer starts at random position
|
||||||
|
- **WHEN** a new game session begins
|
||||||
|
- **THEN** the initial dealer position is set to a random seat index
|
||||||
|
|
||||||
|
#### Scenario: Dealer rotates clockwise
|
||||||
|
- **WHEN** a hand completes
|
||||||
|
- **THEN** the dealer position advances by one seat in clockwise order (wrapping from last seat to first)
|
||||||
|
|
||||||
|
### Requirement: Action history recording
|
||||||
|
The game state SHALL maintain an action history log for each hand, recording player actions with timestamps.
|
||||||
|
|
||||||
|
#### Scenario: Actions recorded during betting
|
||||||
|
- **WHEN** a player takes an action (check, call, raise, fold, all-in)
|
||||||
|
- **THEN** the action is appended to the current hand's action history with the player ID and action type
|
||||||
|
|
||||||
|
#### Scenario: History cleared between hands
|
||||||
|
- **WHEN** a new hand begins
|
||||||
|
- **THEN** the previous hand's action history is archived and a new empty history is created
|
||||||
|
|
||||||
|
### Requirement: Chip balance updates
|
||||||
|
Player chip counts SHALL be updated atomically when bets are placed, pots are awarded, or blinds are posted.
|
||||||
|
|
||||||
|
#### Scenario: Chips deducted on bet
|
||||||
|
- **WHEN** a player places a bet amount
|
||||||
|
- **THEN** their chip count decreases by the bet amount and the pot increases accordingly
|
||||||
|
|
||||||
|
#### Scenario: Chips awarded on win
|
||||||
|
- **WHEN** a player wins the pot
|
||||||
|
- **THEN** their chip count increases by the full pot amount and the pot resets to zero
|
||||||
|
|
||||||
|
#### Scenario: Blinds deducted automatically
|
||||||
|
- **WHEN** a new hand begins
|
||||||
|
- **THEN** the small blind amount is deducted from the small blind player's chips and the big blind amount from the big blind player's chips
|
||||||
|
|
||||||
|
### Requirement: Current turn tracking
|
||||||
|
The game state SHALL track which player ID has the current turn to act, enabling the UI to highlight the active player.
|
||||||
|
|
||||||
|
#### Scenario: Turn starts left of dealer pre-flop
|
||||||
|
- **WHEN** a hand begins dealing
|
||||||
|
- **THEN** the first action turn is assigned to the player one seat left of the dealer button (after big blind)
|
||||||
|
|
||||||
|
#### Scenario: Turn advances to next active player
|
||||||
|
- **WHEN** a player completes their action
|
||||||
|
- **THEN** the turn advances to the next active (non-folded, non-all-in) player in clockwise order
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Oval table layout with player seats
|
||||||
|
The poker table UI SHALL render an oval-shaped table with positions for up to 9 player seats arranged around the perimeter.
|
||||||
|
|
||||||
|
#### Scenario: Default 6-max table renders correctly
|
||||||
|
- **WHEN** the game initializes with 6 players
|
||||||
|
- **THEN** 6 seat positions are displayed around the oval table at evenly spaced intervals
|
||||||
|
|
||||||
|
#### Scenario: Table supports up to 9 seats
|
||||||
|
- **WHEN** the game is configured for 9 players
|
||||||
|
- **THEN** all 9 seat positions are visible and arranged in an oval pattern
|
||||||
|
|
||||||
|
### Requirement: Community card area
|
||||||
|
The poker table SHALL display a designated area in the center of the table for community cards (flop, turn, river).
|
||||||
|
|
||||||
|
#### Scenario: Empty community area before deal
|
||||||
|
- **WHEN** the game is in pre-game state
|
||||||
|
- **THEN** the community card area shows no cards
|
||||||
|
|
||||||
|
#### Scenario: Community cards displayed after flop
|
||||||
|
- **WHEN** the game reaches the flop stage
|
||||||
|
- **THEN** exactly 3 community cards are visible in the center area
|
||||||
|
|
||||||
|
#### Scenario: All 5 community cards visible at river
|
||||||
|
- **WHEN** the game reaches the river stage
|
||||||
|
- **THEN** all 5 community cards (3 flop + turn + river) are displayed
|
||||||
|
|
||||||
|
### Requirement: Pot display
|
||||||
|
The poker table SHALL show the current pot size as a numeric value in the center of the table.
|
||||||
|
|
||||||
|
#### Scenario: Pot starts at zero
|
||||||
|
- **WHEN** a new hand begins before blinds are posted
|
||||||
|
- **THEN** the pot display shows 0
|
||||||
|
|
||||||
|
#### Scenario: Pot updates after bets
|
||||||
|
- **WHEN** players place bets during a betting round
|
||||||
|
- **THEN** the pot value reflects the sum of all bets and blinds in the current hand
|
||||||
|
|
||||||
|
### Requirement: Action buttons for human player
|
||||||
|
The poker table SHALL provide action buttons (Check, Call, Raise, Fold, All-In) for the human player when it is their turn.
|
||||||
|
|
||||||
|
#### Scenario: Buttons shown on player turn
|
||||||
|
- **WHEN** it is the human player's turn to act
|
||||||
|
- **THEN** action buttons are visible and enabled
|
||||||
|
|
||||||
|
#### Scenario: Buttons hidden when not player turn
|
||||||
|
- **WHEN** another player is acting or the game is in a non-betting phase
|
||||||
|
- **THEN** action buttons are hidden or disabled
|
||||||
|
|
||||||
|
#### Scenario: Check button only available when no bet to call
|
||||||
|
- **WHEN** no preceding bet exists in the current betting round
|
||||||
|
- **THEN** the Check button is enabled and Call button is hidden
|
||||||
|
|
||||||
|
### Requirement: Dealer button indicator
|
||||||
|
The poker table SHALL display a dealer button that identifies the current dealer position.
|
||||||
|
|
||||||
|
#### Scenario: Dealer button visible at correct seat
|
||||||
|
- **WHEN** a hand is in progress or between hands
|
||||||
|
- **THEN** the dealer button is displayed at the current dealer's seat position
|
||||||
|
|
||||||
|
#### Scenario: Dealer button rotates after each hand
|
||||||
|
- **WHEN** a hand completes and a new hand begins
|
||||||
|
- **THEN** the dealer button moves one position clockwise
|
||||||
|
|
||||||
|
### Requirement: Player chip count display
|
||||||
|
Each player seat SHALL display the current chip count for that player.
|
||||||
|
|
||||||
|
#### Scenario: Initial stack shown on game start
|
||||||
|
- **WHEN** the game initializes with starting stacks
|
||||||
|
- **THEN** each seat displays the correct starting chip amount
|
||||||
|
|
||||||
|
#### Scenario: Chip count updates after betting
|
||||||
|
- **WHEN** a player places a bet or wins a pot
|
||||||
|
- **THEN** the displayed chip count reflects the updated balance
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
## 1. Project Structure & Types
|
||||||
|
|
||||||
|
- [x] 1.1 Create directory structure: `src/lib/types/`, `src/lib/game/`, `src/lib/components/`, `src/lib/utils/`
|
||||||
|
- [x] 1.2 Define TypeScript types: `Card` (suit, rank), `Suit`, `Rank` enums in `src/lib/types/card.ts`
|
||||||
|
- [x] 1.3 Define `PlayerSeat` interface with ID, name, chips, currentBet, status, holeCards, position in `src/lib/types/player.ts`
|
||||||
|
- [x] 1.4 Define `GameState` interface with deck, players, communityCards, pot, dealerPosition, currentTurn, bettingRound, actionHistory in `src/lib/types/game-state.ts`
|
||||||
|
- [x] 1.5 Define `Action` type (Check, Call, Raise, Fold, AllIn) in `src/lib/types/action.ts`
|
||||||
|
|
||||||
|
## 2. Game Engine Foundation
|
||||||
|
|
||||||
|
- [x] 2.1 Implement `createDeck()` function that generates a 52-card deck in `src/lib/game/deck.ts`
|
||||||
|
- [x] 2.2 Implement `shuffleDeck(deck)` function using Fisher-Yates algorithm in `src/lib/game/deck.ts`
|
||||||
|
- [x] 2.3 Implement `dealHoleCards(deck, players)` pure function that deals 2 cards per active player in `src/lib/game/dealing.ts`
|
||||||
|
- [x] 2.4 Implement `dealCommunityCards(deck, count)` for flop (3), turn (1), river (1) in `src/lib/game/dealing.ts`
|
||||||
|
- [x] 2.5 Implement `postBlinds(state)` pure function that deducts small/big blind from correct players in `src/lib/game/blinds.ts`
|
||||||
|
|
||||||
|
## 3. Betting Logic
|
||||||
|
|
||||||
|
- [x] 3.1 Implement `getNextActivePlayer(state)` helper for clockwise turn advancement in `src/lib/game/turn.ts`
|
||||||
|
- [x] 3.2 Implement `validateAction(playerId, action, state)` function with fold/call/raise rules in `src/lib/game/validation.ts`
|
||||||
|
- [x] 3.3 Implement `applyCheck(state, playerId)` pure function returning new state in `src/lib/game/actions.ts`
|
||||||
|
- [x] 3.4 Implement `applyCall(state, playerId)` pure function returning new state in `src/lib/game/actions.ts`
|
||||||
|
- [x] 3.5 Implement `applyRaise(state, playerId, amount)` pure function with min-raise validation in `src/lib/game/actions.ts`
|
||||||
|
- [x] 3.6 Implement `applyFold(state, playerId)` pure function that marks player as folded in `src/lib/game/actions.ts`
|
||||||
|
- [x] 3.7 Implement `applyAllIn(state, playerId)` pure function that posts remaining chips in `src/lib/game/actions.ts`
|
||||||
|
- [x] 3.8 Implement `completeBettingRound(state)` to detect when all active players have matched bets and advance to next street in `src/lib/game/betting-round.ts`
|
||||||
|
|
||||||
|
## 4. Hand Evaluation
|
||||||
|
|
||||||
|
- [x] 4.1 Implement suit/rank constants and comparison utilities in `src/lib/utils/ranks.ts`
|
||||||
|
- [x] 4.2 Implement `evaluateHand(7 cards)` function that returns hand rank (high card through royal flush) in `src/lib/utils/hand-evaluator.ts`
|
||||||
|
- [x] 4.3 Implement tiebreaker logic for same-rank hands using kicker comparison in `src/lib/utils/hand-evaluator.ts`
|
||||||
|
- [x] 4.4 Implement `determineWinner(state)` function that evaluates all active players and awards pot in `src/lib/game/showdown.ts`
|
||||||
|
|
||||||
|
## 5. Game State Management
|
||||||
|
|
||||||
|
- [x] 5.1 Implement `createInitialState(numPlayers, startingStack)` factory function in `src/lib/game/state.ts`
|
||||||
|
- [x] 5.2 Implement `startNewHand(state)` pure function: shuffle deck, post blinds, deal hole cards, reset bets in `src/lib/game/hand.ts`
|
||||||
|
- [x] 5.3 Implement `rotateDealer(state)` to advance dealer button clockwise in `src/lib/game/state.ts`
|
||||||
|
- [x] 5.4 Implement `recordAction(state, playerId, action)` to append to action history in `src/lib/game/state.ts`
|
||||||
|
|
||||||
|
## 6. Card Component
|
||||||
|
|
||||||
|
- [x] 6.1 Create `Card.svelte` component with props: card (Card), flipped (boolean), animateDeal (boolean) in `src/lib/components/Card.svelte`
|
||||||
|
- [x] 6.2 Implement card face rendering with suit symbols and rank display, colored red/black by suit in `Card.svelte`
|
||||||
|
- [x] 6.3 Implement card back pattern styling in `Card.svelte` scoped CSS
|
||||||
|
- [x] 6.4 Add CSS 3D flip animation using `transform: rotateY(180deg)` and `backface-visibility: hidden` in `Card.svelte`
|
||||||
|
- [x] 6.5 Add Svelte `flip()` spring animation for deal transitions in `Card.svelte`
|
||||||
|
- [x] 6.6 Add hover elevation effect via CSS transition on scoped styles in `Card.svelte`
|
||||||
|
- [x] 6.7 Define shared card dimension CSS custom properties (`--card-width`, `--card-height`, `--card-radius`) in a global style file
|
||||||
|
|
||||||
|
## 7. Player Seat Component
|
||||||
|
|
||||||
|
- [x] 7.1 Create `PlayerSeat.svelte` component with props: player (PlayerSeat), isCurrentTurn, isHuman in `src/lib/components/PlayerSeat.svelte`
|
||||||
|
- [x] 7.2 Render player name, chip count, and current bet amount in `PlayerSeat.svelte`
|
||||||
|
- [x] 7.3 Render hole cards using `Card` component with flip state based on visibility rules in `PlayerSeat.svelte`
|
||||||
|
- [x] 7.4 Add visual indicator for current turn (highlight/glow) in `PlayerSeat.svelte`
|
||||||
|
- [x] 7.5 Add folded player styling (dimmed, semi-transparent) in `PlayerSeat.svelte`
|
||||||
|
|
||||||
|
## 8. Poker Table UI
|
||||||
|
|
||||||
|
- [x] 8.1 Create `PokerTable.svelte` component with CSS Grid oval layout for up to 9 seats in `src/lib/components/PokerTable.svelte`
|
||||||
|
- [x] 8.2 Implement seat positioning using CSS Grid areas arranged in oval pattern in `PokerTable.svelte`
|
||||||
|
- [x] 8.3 Render community card area in table center with dynamic card display in `PokerTable.svelte`
|
||||||
|
- [x] 8.4 Add pot display component in table center showing current pot value in `PokerTable.svelte`
|
||||||
|
- [x] 8.5 Implement dealer button indicator positioned at current dealer seat in `PokerTable.svelte`
|
||||||
|
|
||||||
|
## 9. Action Controls
|
||||||
|
|
||||||
|
- [x] 9.1 Create `BetControls.svelte` component with Check, Call, Raise, Fold, All-In buttons in `src/lib/components/BetControls.svelte`
|
||||||
|
- [x] 9.2 Implement conditional button visibility based on current game state (check vs call logic) in `BetControls.svelte`
|
||||||
|
- [x] 9.3 Add raise amount input with min/max validation in `BetControls.svelte`
|
||||||
|
- [x] 9.4 Wire button clicks to dispatch action events to parent component in `BetControls.svelte`
|
||||||
|
|
||||||
|
## 10. Integration & Page Assembly
|
||||||
|
|
||||||
|
- [x] 10.1 Replace `src/routes/+page.svelte` with poker game page that composes `PokerTable`, `PlayerSeat`, and `BetControls`
|
||||||
|
- [x] 10.2 Initialize game state using Svelte 5 `$state` rune in the page component
|
||||||
|
- [x] 10.3 Wire human player actions from `BetControls` to game engine pure functions, updating reactive state
|
||||||
|
- [x] 10.4 Implement basic AI opponent logic: random valid action selection with small delay between turns
|
||||||
|
- [x] 10.5 Add "New Hand" button to start next hand after showdown completion
|
||||||
|
- [x] 10.6 Update `src/routes/+layout.svelte` with dark background suitable for poker table aesthetic
|
||||||
|
- [ ] 10.7 Manual playtest: verify full hand flow from deal through showdown with all betting rounds
|
||||||
48
openspec/specs/card-components/spec.md
Normal file
48
openspec/specs/card-components/spec.md
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Card face rendering
|
||||||
|
The card component SHALL display the card's rank and suit symbol with correct color (red for hearts/diamonds, black for spades/clubs).
|
||||||
|
|
||||||
|
#### Scenario: Red suit cards displayed correctly
|
||||||
|
- **WHEN** a card has a heart or diamond suit
|
||||||
|
- **THEN** the rank and suit symbol render in red color
|
||||||
|
|
||||||
|
#### Scenario: Black suit cards displayed correctly
|
||||||
|
- **WHEN** a card has a spade or club suit
|
||||||
|
- **THEN** the rank and suit symbol render in black color
|
||||||
|
|
||||||
|
### Requirement: Card flip animation
|
||||||
|
The card component SHALL support a flipped state that hides the card face using a 3D CSS rotation animation.
|
||||||
|
|
||||||
|
#### Scenario: Card starts face-down
|
||||||
|
- **WHEN** a card is dealt to a player before reveal
|
||||||
|
- **THEN** the card shows its back pattern and the face is hidden via CSS `rotateY(180deg)` transform
|
||||||
|
|
||||||
|
#### Scenario: Card flips to reveal face
|
||||||
|
- **WHEN** the flip state changes from false to true
|
||||||
|
- **THEN** the card animates with a smooth rotation transition to show the face
|
||||||
|
|
||||||
|
#### Scenario: Card flips back to hidden
|
||||||
|
- **WHEN** the flip state changes from true to false
|
||||||
|
- **THEN** the card animates with a smooth rotation transition to hide the face
|
||||||
|
|
||||||
|
### Requirement: Deal animation
|
||||||
|
Cards dealt during the dealing phase SHALL animate from the dealer position to their final position using Svelte's `flip()` spring animation.
|
||||||
|
|
||||||
|
#### Scenario: Hole cards animate on deal
|
||||||
|
- **WHEN** hole cards are dealt at the start of a hand
|
||||||
|
- **THEN** each card animates from a central dealer position to its target seat using a spring-based flip transition
|
||||||
|
|
||||||
|
### Requirement: Card hover effect
|
||||||
|
The card component SHALL provide a visual hover effect (e.g., slight scale increase or shadow) when the user hovers over an active card.
|
||||||
|
|
||||||
|
#### Scenario: Hover effect on valid cards
|
||||||
|
- **WHEN** the user hovers over their own visible hole card during their turn
|
||||||
|
- **THEN** the card displays a subtle elevation effect via CSS transition
|
||||||
|
|
||||||
|
### Requirement: Card dimensions and styling
|
||||||
|
All card instances SHALL use consistent dimensions (width, height, border-radius) defined by CSS custom properties.
|
||||||
|
|
||||||
|
#### Scenario: Consistent card sizing
|
||||||
|
- **WHEN** multiple cards are rendered in different areas of the table
|
||||||
|
- **THEN** all cards share the same width, height, and border-radius values from shared CSS custom properties
|
||||||
94
openspec/specs/game-engine/spec.md
Normal file
94
openspec/specs/game-engine/spec.md
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Deck creation and shuffling
|
||||||
|
The game engine SHALL create a standard 52-card deck and shuffle it using a random algorithm before each hand.
|
||||||
|
|
||||||
|
#### Scenario: Full 52-card deck created
|
||||||
|
- **WHEN** a new deck is initialized
|
||||||
|
- **THEN** the deck contains exactly 52 cards covering all ranks (2 through Ace) across all four suits
|
||||||
|
|
||||||
|
#### Scenario: Deck is shuffled randomly
|
||||||
|
- **WHEN** a deck is prepared for dealing
|
||||||
|
- **THEN** the card order is randomized and differs between consecutive shuffles
|
||||||
|
|
||||||
|
### Requirement: Dealing phase execution
|
||||||
|
The game engine SHALL deal 2 hole cards to each active player, then burn and deal community cards at each street (flop, turn, river).
|
||||||
|
|
||||||
|
#### Scenario: Hole cards dealt to all players
|
||||||
|
- **WHEN** a hand begins dealing
|
||||||
|
- **THEN** each active player receives exactly 2 face-down hole cards
|
||||||
|
|
||||||
|
#### Scenario: Flop deals 3 community cards
|
||||||
|
- **WHEN** the pre-flop betting round completes
|
||||||
|
- **THEN** 3 community cards are dealt face-up to the board
|
||||||
|
|
||||||
|
#### Scenario: Turn deals 1 community card
|
||||||
|
- **WHEN** the flop betting round completes
|
||||||
|
- **THEN** 1 additional community card is dealt face-up
|
||||||
|
|
||||||
|
#### Scenario: River deals final community card
|
||||||
|
- **WHEN** the turn betting round completes
|
||||||
|
- **THEN** 1 final community card is dealt face-up
|
||||||
|
|
||||||
|
### Requirement: Betting round progression
|
||||||
|
The game engine SHALL progress through four betting rounds (pre-flop, flop, turn, river) in sequence, each starting after the player left of the dealer.
|
||||||
|
|
||||||
|
#### Scenario: Pre-flop begins first
|
||||||
|
- **WHEN** hole cards are dealt
|
||||||
|
- **THEN** the pre-flop betting round starts with the player left of the dealer button
|
||||||
|
|
||||||
|
#### Scenario: Flop follows pre-flop
|
||||||
|
- **WHEN** all active players have matched bets in the pre-flop round
|
||||||
|
- **THEN** community cards (flop) are dealt and the flop betting round begins
|
||||||
|
|
||||||
|
#### Scenario: Showdown after river
|
||||||
|
- **WHEN** the river betting round completes with 2+ active players remaining
|
||||||
|
- **THEN** a showdown is triggered where remaining hole cards are revealed
|
||||||
|
|
||||||
|
### Requirement: Blind posting
|
||||||
|
The game engine SHALL enforce small blind and big blind posting before each hand, positioned left of the dealer button.
|
||||||
|
|
||||||
|
#### Scenario: Small blind posted
|
||||||
|
- **WHEN** a new hand starts
|
||||||
|
- **THEN** the player immediately left of the dealer posts the small blind amount
|
||||||
|
|
||||||
|
#### Scenario: Big blind posted
|
||||||
|
- **WHEN** a new hand starts
|
||||||
|
- **THEN** the player two seats left of the dealer posts the big blind amount (2x small blind)
|
||||||
|
|
||||||
|
### Requirement: Player action validation
|
||||||
|
The game engine SHALL validate each player action against the current game state and reject invalid actions.
|
||||||
|
|
||||||
|
#### Scenario: Fold rejected when no bet to call
|
||||||
|
- **WHEN** a player attempts to fold but no preceding bet exists in the current round
|
||||||
|
- **THEN** the action is rejected and Check is suggested instead
|
||||||
|
|
||||||
|
#### Scenario: Raise amount validated
|
||||||
|
- **WHEN** a player attempts to raise
|
||||||
|
- **THEN** the raise amount must be at least the size of the previous bet or big blind, whichever is larger
|
||||||
|
|
||||||
|
#### Scenario: All-in limited by chip count
|
||||||
|
- **WHEN** a player goes all-in with fewer chips than the call amount
|
||||||
|
- **THEN** the player's entire stack is posted and they are marked as all-in
|
||||||
|
|
||||||
|
### Requirement: Hand evaluation and winner determination
|
||||||
|
The game engine SHALL evaluate each remaining player's best 5-card hand from their 2 hole cards and 5 community cards, then award the pot to the winner.
|
||||||
|
|
||||||
|
#### Scenario: Best hand wins the pot
|
||||||
|
- **WHEN** showdown occurs with multiple active players
|
||||||
|
- **THEN** the player with the highest-ranking poker hand wins the entire pot
|
||||||
|
|
||||||
|
#### Scenario: Split pot on tie
|
||||||
|
- **WHEN** two or more players have hands of equal rank at showdown
|
||||||
|
- **THEN** the pot is divided equally among tied players
|
||||||
|
|
||||||
|
#### Scenario: Last player standing wins without showdown
|
||||||
|
- **WHEN** all opponents fold leaving one active player
|
||||||
|
- **THEN** that player wins the pot without a showdown and hole cards are not revealed
|
||||||
|
|
||||||
|
### Requirement: Pure function state transitions
|
||||||
|
The game engine SHALL represent each action as a pure function that takes the current `GameState` and returns a new immutable `GameState`.
|
||||||
|
|
||||||
|
#### Scenario: State immutability preserved
|
||||||
|
- **WHEN** an action function is called with a game state
|
||||||
|
- **THEN** the original state object is unchanged and a new state object is returned
|
||||||
64
openspec/specs/player-state/spec.md
Normal file
64
openspec/specs/player-state/spec.md
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Player seat model
|
||||||
|
Each player seat SHALL be represented by a model containing: ID, name, chip count, current bet, status (active/folded/all-in), hole cards, and position index.
|
||||||
|
|
||||||
|
#### Scenario: Player initialized with starting stack
|
||||||
|
- **WHEN** a new game is created
|
||||||
|
- **THEN** each player seat has the configured starting chip count and active status
|
||||||
|
|
||||||
|
#### Scenario: Folded player marked correctly
|
||||||
|
- **WHEN** a player folds during a hand
|
||||||
|
- **THEN** their status changes to folded and they cannot take further actions that hand
|
||||||
|
|
||||||
|
#### Scenario: All-in player tracked separately
|
||||||
|
- **WHEN** a player bets all remaining chips
|
||||||
|
- **THEN** their status changes to all-in and they are excluded from further betting but remain active for showdown
|
||||||
|
|
||||||
|
### Requirement: Dealer position tracking
|
||||||
|
The game state SHALL track the current dealer button position as an index that rotates clockwise after each hand.
|
||||||
|
|
||||||
|
#### Scenario: Dealer starts at random position
|
||||||
|
- **WHEN** a new game session begins
|
||||||
|
- **THEN** the initial dealer position is set to a random seat index
|
||||||
|
|
||||||
|
#### Scenario: Dealer rotates clockwise
|
||||||
|
- **WHEN** a hand completes
|
||||||
|
- **THEN** the dealer position advances by one seat in clockwise order (wrapping from last seat to first)
|
||||||
|
|
||||||
|
### Requirement: Action history recording
|
||||||
|
The game state SHALL maintain an action history log for each hand, recording player actions with timestamps.
|
||||||
|
|
||||||
|
#### Scenario: Actions recorded during betting
|
||||||
|
- **WHEN** a player takes an action (check, call, raise, fold, all-in)
|
||||||
|
- **THEN** the action is appended to the current hand's action history with the player ID and action type
|
||||||
|
|
||||||
|
#### Scenario: History cleared between hands
|
||||||
|
- **WHEN** a new hand begins
|
||||||
|
- **THEN** the previous hand's action history is archived and a new empty history is created
|
||||||
|
|
||||||
|
### Requirement: Chip balance updates
|
||||||
|
Player chip counts SHALL be updated atomically when bets are placed, pots are awarded, or blinds are posted.
|
||||||
|
|
||||||
|
#### Scenario: Chips deducted on bet
|
||||||
|
- **WHEN** a player places a bet amount
|
||||||
|
- **THEN** their chip count decreases by the bet amount and the pot increases accordingly
|
||||||
|
|
||||||
|
#### Scenario: Chips awarded on win
|
||||||
|
- **WHEN** a player wins the pot
|
||||||
|
- **THEN** their chip count increases by the full pot amount and the pot resets to zero
|
||||||
|
|
||||||
|
#### Scenario: Blinds deducted automatically
|
||||||
|
- **WHEN** a new hand begins
|
||||||
|
- **THEN** the small blind amount is deducted from the small blind player's chips and the big blind amount from the big blind player's chips
|
||||||
|
|
||||||
|
### Requirement: Current turn tracking
|
||||||
|
The game state SHALL track which player ID has the current turn to act, enabling the UI to highlight the active player.
|
||||||
|
|
||||||
|
#### Scenario: Turn starts left of dealer pre-flop
|
||||||
|
- **WHEN** a hand begins dealing
|
||||||
|
- **THEN** the first action turn is assigned to the player one seat left of the dealer button (after big blind)
|
||||||
|
|
||||||
|
#### Scenario: Turn advances to next active player
|
||||||
|
- **WHEN** a player completes their action
|
||||||
|
- **THEN** the turn advances to the next active (non-folded, non-all-in) player in clockwise order
|
||||||
75
openspec/specs/poker-table/spec.md
Normal file
75
openspec/specs/poker-table/spec.md
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Oval table layout with player seats
|
||||||
|
The poker table UI SHALL render an oval-shaped table with positions for up to 9 player seats arranged around the perimeter.
|
||||||
|
|
||||||
|
#### Scenario: Default 6-max table renders correctly
|
||||||
|
- **WHEN** the game initializes with 6 players
|
||||||
|
- **THEN** 6 seat positions are displayed around the oval table at evenly spaced intervals
|
||||||
|
|
||||||
|
#### Scenario: Table supports up to 9 seats
|
||||||
|
- **WHEN** the game is configured for 9 players
|
||||||
|
- **THEN** all 9 seat positions are visible and arranged in an oval pattern
|
||||||
|
|
||||||
|
### Requirement: Community card area
|
||||||
|
The poker table SHALL display a designated area in the center of the table for community cards (flop, turn, river).
|
||||||
|
|
||||||
|
#### Scenario: Empty community area before deal
|
||||||
|
- **WHEN** the game is in pre-game state
|
||||||
|
- **THEN** the community card area shows no cards
|
||||||
|
|
||||||
|
#### Scenario: Community cards displayed after flop
|
||||||
|
- **WHEN** the game reaches the flop stage
|
||||||
|
- **THEN** exactly 3 community cards are visible in the center area
|
||||||
|
|
||||||
|
#### Scenario: All 5 community cards visible at river
|
||||||
|
- **WHEN** the game reaches the river stage
|
||||||
|
- **THEN** all 5 community cards (3 flop + turn + river) are displayed
|
||||||
|
|
||||||
|
### Requirement: Pot display
|
||||||
|
The poker table SHALL show the current pot size as a numeric value in the center of the table.
|
||||||
|
|
||||||
|
#### Scenario: Pot starts at zero
|
||||||
|
- **WHEN** a new hand begins before blinds are posted
|
||||||
|
- **THEN** the pot display shows 0
|
||||||
|
|
||||||
|
#### Scenario: Pot updates after bets
|
||||||
|
- **WHEN** players place bets during a betting round
|
||||||
|
- **THEN** the pot value reflects the sum of all bets and blinds in the current hand
|
||||||
|
|
||||||
|
### Requirement: Action buttons for human player
|
||||||
|
The poker table SHALL provide action buttons (Check, Call, Raise, Fold, All-In) for the human player when it is their turn.
|
||||||
|
|
||||||
|
#### Scenario: Buttons shown on player turn
|
||||||
|
- **WHEN** it is the human player's turn to act
|
||||||
|
- **THEN** action buttons are visible and enabled
|
||||||
|
|
||||||
|
#### Scenario: Buttons hidden when not player turn
|
||||||
|
- **WHEN** another player is acting or the game is in a non-betting phase
|
||||||
|
- **THEN** action buttons are hidden or disabled
|
||||||
|
|
||||||
|
#### Scenario: Check button only available when no bet to call
|
||||||
|
- **WHEN** no preceding bet exists in the current betting round
|
||||||
|
- **THEN** the Check button is enabled and Call button is hidden
|
||||||
|
|
||||||
|
### Requirement: Dealer button indicator
|
||||||
|
The poker table SHALL display a dealer button that identifies the current dealer position.
|
||||||
|
|
||||||
|
#### Scenario: Dealer button visible at correct seat
|
||||||
|
- **WHEN** a hand is in progress or between hands
|
||||||
|
- **THEN** the dealer button is displayed at the current dealer's seat position
|
||||||
|
|
||||||
|
#### Scenario: Dealer button rotates after each hand
|
||||||
|
- **WHEN** a hand completes and a new hand begins
|
||||||
|
- **THEN** the dealer button moves one position clockwise
|
||||||
|
|
||||||
|
### Requirement: Player chip count display
|
||||||
|
Each player seat SHALL display the current chip count for that player.
|
||||||
|
|
||||||
|
#### Scenario: Initial stack shown on game start
|
||||||
|
- **WHEN** the game initializes with starting stacks
|
||||||
|
- **THEN** each seat displays the correct starting chip amount
|
||||||
|
|
||||||
|
#### Scenario: Chip count updates after betting
|
||||||
|
- **WHEN** a player places a bet or wins a pot
|
||||||
|
- **THEN** the displayed chip count reflects the updated balance
|
||||||
@ -3,10 +3,11 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="text-scale" content="scale" />
|
|
||||||
|
<title>PokeR</title>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body class="app-body" data-sveltekit-preload-data="hover">
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
118
src/lib/components/BetControls.svelte
Normal file
118
src/lib/components/BetControls.svelte
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { GameState } from '$lib/types/game-state';
|
||||||
|
|
||||||
|
let { gameState, onAction }: {
|
||||||
|
gameState: GameState;
|
||||||
|
onAction: (type: string, amount?: number) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const humanPlayer = $derived(gameState.players[0]);
|
||||||
|
const isMyTurn = $derived(
|
||||||
|
gameState.currentTurn === 0 &&
|
||||||
|
humanPlayer?.status === 'active' &&
|
||||||
|
gameState.bettingRound !== 'idle' &&
|
||||||
|
gameState.bettingRound !== 'showdown'
|
||||||
|
);
|
||||||
|
|
||||||
|
const canCheck = $derived(humanPlayer && humanPlayer.currentBet >= gameState.currentBet);
|
||||||
|
const callAmount = $derived(humanPlayer ? gameState.currentBet - humanPlayer.currentBet : 0);
|
||||||
|
const minRaise = $derived(gameState.currentBet + (gameState.lastRaiseAmount || gameState.bigBlind));
|
||||||
|
|
||||||
|
let raiseAmount = $derived(minRaise);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bet-controls" class:hidden={!isMyTurn}>
|
||||||
|
{#if isMyTurn}
|
||||||
|
<button class="btn check" onclick={() => onAction('check')} disabled={!canCheck}>
|
||||||
|
Check
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if !canCheck}
|
||||||
|
<button class="btn call" onclick={() => onAction('call')}>
|
||||||
|
Call ${callAmount}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="raise-group">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
bind:value={raiseAmount}
|
||||||
|
min={minRaise}
|
||||||
|
max={humanPlayer?.chips + humanPlayer?.currentBet ?? 0}
|
||||||
|
class="raise-input"
|
||||||
|
aria-label="Raise amount"
|
||||||
|
/>
|
||||||
|
<button class="btn raise" onclick={() => onAction('raise', raiseAmount)}>
|
||||||
|
Raise
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if humanPlayer && (humanPlayer.chips > callAmount)}
|
||||||
|
<button class="btn all-in" onclick={() => onAction('all-in')}>
|
||||||
|
All-In ${humanPlayer.chips}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button class="btn fold" onclick={() => onAction('fold')}>
|
||||||
|
Fold
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bet-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-controls.hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:not(:disabled):hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check { background: #3498db; color: white; }
|
||||||
|
.call { background: #2ecc71; color: white; }
|
||||||
|
.raise { background: #e67e22; color: white; }
|
||||||
|
.fold { background: #e74c3c; color: white; }
|
||||||
|
.all-in { background: #9b59b6; color: white; }
|
||||||
|
|
||||||
|
.raise-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raise-input {
|
||||||
|
width: 70px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 2px solid #555;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
123
src/lib/components/Card.svelte
Normal file
123
src/lib/components/Card.svelte
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import type { Card as CardType } from '$lib/types/card';
|
||||||
|
import { SUIT_SYMBOLS } from '$lib/types/card';
|
||||||
|
|
||||||
|
let { card, flipped = false }: {
|
||||||
|
card: CardType;
|
||||||
|
flipped?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const isRed = $derived(card.suit === 'hearts' || card.suit === 'diamonds');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="card"
|
||||||
|
role="img"
|
||||||
|
aria-label={`${card.rank} of ${card.suit}`}
|
||||||
|
transition:fade={{ duration: 150 }}
|
||||||
|
>
|
||||||
|
<div class="card-inner" class:flipped={flipped}>
|
||||||
|
<div class="card-face card-front">
|
||||||
|
<span class="rank top" style:color={isRed ? '#c0392b' : '#1a1a1a'}>{card.rank}</span>
|
||||||
|
<span class="suit" style:color={isRed ? '#c0392b' : '#1a1a1a'}>{SUIT_SYMBOLS[card.suit]}</span>
|
||||||
|
<span class="rank bottom" style:color={isRed ? '#c0392b' : '#1a1a1a'}>{card.rank}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-face card-back">
|
||||||
|
<div class="back-pattern"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
width: var(--card-width);
|
||||||
|
height: var(--card-height);
|
||||||
|
perspective: 600px;
|
||||||
|
display: inline-block;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.3));
|
||||||
|
transition: transform 0.15s ease, filter 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-inner {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transition: transform 0.4s ease;
|
||||||
|
transform: rotateY(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-inner.flipped {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-face {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
border-radius: var(--card-radius);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-front {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank {
|
||||||
|
font-size: var(--card-font-size);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank.top {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank.bottom {
|
||||||
|
align-self: flex-end;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
margin-right: 4px;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suit {
|
||||||
|
font-size: calc(var(--card-font-size) * 2);
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-back {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
background: linear-gradient(135deg, #2c3e7a 0%, #1a2554 100%);
|
||||||
|
border: 1px solid #4a5a9a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-pattern {
|
||||||
|
width: 80%;
|
||||||
|
height: 80%;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
transparent,
|
||||||
|
transparent 4px,
|
||||||
|
rgba(255, 255, 255, 0.05) 4px,
|
||||||
|
rgba(255, 255, 255, 0.05) 8px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
103
src/lib/components/PlayerSeat.svelte
Normal file
103
src/lib/components/PlayerSeat.svelte
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Card from './Card.svelte';
|
||||||
|
import type { PlayerSeat as PlayerSeatType } from '$lib/types/player';
|
||||||
|
|
||||||
|
let { player, isCurrentTurn = false, isHuman = false }: {
|
||||||
|
player: PlayerSeatType;
|
||||||
|
isCurrentTurn?: boolean;
|
||||||
|
isHuman?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const showCards = $derived(isHuman || player.holeCards.length === 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="seat" class:active={isCurrentTurn} class:folded={player.status === 'folded'}>
|
||||||
|
{#if player.holeCards.length > 0}
|
||||||
|
<div class="hole-cards">
|
||||||
|
{#each player.holeCards as card (card.rank + card.suit)}
|
||||||
|
<Card {card} flipped={!showCards} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
<span class="name">{player.name}</span>
|
||||||
|
<span class="chips">${player.chips}</span>
|
||||||
|
{#if player.currentBet > 0}
|
||||||
|
<span class="bet">${player.currentBet}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if player.status === 'folded'}
|
||||||
|
<span class="status-badge">FOLD</span>
|
||||||
|
{/if}
|
||||||
|
{#if player.status === 'all-in'}
|
||||||
|
<span class="status-badge all-in">ALL-IN</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.seat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat.active {
|
||||||
|
background: rgba(255, 255, 0, 0.1);
|
||||||
|
box-shadow: 0 0 12px rgba(255, 255, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat.folded {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hole-cards {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #ecf0f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-weight: 600;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet {
|
||||||
|
display: block;
|
||||||
|
color: #f1c40f;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.all-in {
|
||||||
|
background: #f39c12;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
121
src/lib/components/PokerTable.svelte
Normal file
121
src/lib/components/PokerTable.svelte
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Card from './Card.svelte';
|
||||||
|
import PlayerSeat from './PlayerSeat.svelte';
|
||||||
|
import type { GameState } from '$lib/types/game-state';
|
||||||
|
|
||||||
|
let { gameState }: {
|
||||||
|
gameState: GameState;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const seatPositions = [
|
||||||
|
{ label: 'seat-6', row: 1, col: 3 },
|
||||||
|
{ label: 'seat-5', row: 2, col: 1 },
|
||||||
|
{ label: 'seat-4', row: 3, col: 1 },
|
||||||
|
{ label: 'seat-1', row: 4, col: 1 },
|
||||||
|
{ label: 'seat-2', row: 4, col: 5 },
|
||||||
|
{ label: 'seat-3', row: 3, col: 5 },
|
||||||
|
{ label: 'seat-7', row: 2, col: 5 },
|
||||||
|
{ label: 'seat-8', row: 1, col: 4 },
|
||||||
|
{ label: 'seat-9', row: 1, col: 6 }
|
||||||
|
];
|
||||||
|
|
||||||
|
function getPlayerAtPosition(position: number) {
|
||||||
|
return gameState.players[position];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="poker-table">
|
||||||
|
<div class="table-surface">
|
||||||
|
<div class="community-area">
|
||||||
|
<div class="community-cards">
|
||||||
|
{#each gameState.communityCards as card (card.rank + card.suit)}
|
||||||
|
<Card {card} flipped={false} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="pot-display">Pot: ${gameState.pot}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each seatPositions as pos, i}
|
||||||
|
{@const player = getPlayerAtPosition(i)}
|
||||||
|
{#if player}
|
||||||
|
<div class="seat-wrapper" style="grid-row: {pos.row}; grid-column: {pos.col};">
|
||||||
|
<PlayerSeat
|
||||||
|
{player}
|
||||||
|
isCurrentTurn={gameState.currentTurn === i}
|
||||||
|
isHuman={i === 0}
|
||||||
|
/>
|
||||||
|
{#if gameState.dealerPosition === i}
|
||||||
|
<div class="dealer-button" aria-label="Dealer">D</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.poker-table {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-surface {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
grid-template-rows: repeat(4, auto);
|
||||||
|
gap: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
background: radial-gradient(ellipse at center, var(--felt-color) 0%, var(--table-green) 70%, var(--table-dark) 100%);
|
||||||
|
border-radius: 150px / 80px;
|
||||||
|
border: 8px solid #3d2b1f;
|
||||||
|
box-shadow: inset 0 0 40px rgba(0, 0, 0, 0.3), 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
min-height: 450px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.community-area {
|
||||||
|
grid-row: 2 / 4;
|
||||||
|
grid-column: 2 / 6;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.community-cards {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pot-display {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f1c40f;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dealer-button {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ecf0f1;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px solid #bdc3c7;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
113
src/lib/game/actions.ts
Normal file
113
src/lib/game/actions.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import type { GameState } from '$lib/types/game-state';
|
||||||
|
|
||||||
|
function recordActionForState(state: GameState, playerId: string, type: string, amount?: number): GameState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
actionHistory: [
|
||||||
|
...state.actionHistory,
|
||||||
|
{ playerId, type, amount, timestamp: Date.now() }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceTurn(state: GameState): number {
|
||||||
|
const numPlayers = state.players.length;
|
||||||
|
let next = (state.currentTurn + 1) % numPlayers;
|
||||||
|
let checked = 0;
|
||||||
|
|
||||||
|
while (checked < numPlayers) {
|
||||||
|
const player = state.players[next];
|
||||||
|
if (player.status === 'active') {
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
next = (next + 1) % numPlayers;
|
||||||
|
checked++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.currentTurn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyCheck(state: GameState, playerId: string): GameState {
|
||||||
|
const playerIdx = state.players.findIndex(p => p.id === playerId);
|
||||||
|
const updatedPlayers = [...state.players];
|
||||||
|
updatedPlayers[playerIdx] = { ...updatedPlayers[playerIdx], currentBet: state.currentBet };
|
||||||
|
const result = recordActionForState({ ...state, players: updatedPlayers }, playerId, 'check', 0);
|
||||||
|
return { ...result, currentTurn: advanceTurn(result) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyCall(state: GameState, playerId: string): GameState {
|
||||||
|
const playerIdx = state.players.findIndex(p => p.id === playerId);
|
||||||
|
const player = state.players[playerIdx];
|
||||||
|
const callAmount = Math.min(state.currentBet - player.currentBet, player.chips);
|
||||||
|
const updatedPlayers = [...state.players];
|
||||||
|
const newPlayer = { ...updatedPlayers[playerIdx] };
|
||||||
|
newPlayer.chips -= callAmount;
|
||||||
|
newPlayer.currentBet += callAmount;
|
||||||
|
if (newPlayer.chips === 0 && callAmount > 0) {
|
||||||
|
newPlayer.status = 'all-in';
|
||||||
|
}
|
||||||
|
updatedPlayers[playerIdx] = newPlayer;
|
||||||
|
const result = recordActionForState({ ...state, players: updatedPlayers, pot: state.pot + callAmount }, playerId, 'call', callAmount);
|
||||||
|
return { ...result, currentTurn: advanceTurn(result) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyRaise(state: GameState, playerId: string, amount: number): GameState {
|
||||||
|
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];
|
||||||
|
const newPlayer = { ...updatedPlayers[playerIdx] };
|
||||||
|
newPlayer.chips -= added;
|
||||||
|
newPlayer.currentBet = totalBet;
|
||||||
|
if (newPlayer.chips === 0 && added > 0) {
|
||||||
|
newPlayer.status = 'all-in';
|
||||||
|
}
|
||||||
|
updatedPlayers[playerIdx] = newPlayer;
|
||||||
|
|
||||||
|
const result = recordActionForState(
|
||||||
|
{
|
||||||
|
...state,
|
||||||
|
players: updatedPlayers,
|
||||||
|
pot: state.pot + added,
|
||||||
|
currentBet: totalBet,
|
||||||
|
lastRaiseAmount: raiseOverCurrent > 0 ? raiseOverCurrent : state.lastRaiseAmount
|
||||||
|
},
|
||||||
|
playerId,
|
||||||
|
'raise',
|
||||||
|
amount
|
||||||
|
);
|
||||||
|
return { ...result, currentTurn: advanceTurn(result) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyFold(state: GameState, playerId: string): GameState {
|
||||||
|
const playerIdx = state.players.findIndex(p => p.id === playerId);
|
||||||
|
const updatedPlayers = [...state.players];
|
||||||
|
updatedPlayers[playerIdx] = { ...updatedPlayers[playerIdx], status: 'folded' as const };
|
||||||
|
const result = recordActionForState({ ...state, players: updatedPlayers }, playerId, 'fold');
|
||||||
|
return { ...result, currentTurn: advanceTurn(result) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyAllIn(state: GameState, playerId: string): GameState {
|
||||||
|
const playerIdx = state.players.findIndex(p => p.id === playerId);
|
||||||
|
const player = state.players[playerIdx];
|
||||||
|
const amount = player.chips;
|
||||||
|
|
||||||
|
const updatedPlayers = [...state.players];
|
||||||
|
const newPlayer = { ...updatedPlayers[playerIdx] };
|
||||||
|
newPlayer.chips = 0;
|
||||||
|
newPlayer.currentBet += amount;
|
||||||
|
newPlayer.status = 'all-in';
|
||||||
|
updatedPlayers[playerIdx] = newPlayer;
|
||||||
|
|
||||||
|
const newCurrentBet = Math.max(state.currentBet, newPlayer.currentBet);
|
||||||
|
const result = recordActionForState(
|
||||||
|
{ ...state, players: updatedPlayers, pot: state.pot + amount, currentBet: newCurrentBet },
|
||||||
|
playerId,
|
||||||
|
'all-in',
|
||||||
|
amount
|
||||||
|
);
|
||||||
|
return { ...result, currentTurn: advanceTurn(result) };
|
||||||
|
}
|
||||||
49
src/lib/game/betting-round.ts
Normal file
49
src/lib/game/betting-round.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
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');
|
||||||
|
if (activePlayers.length <= 1) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMaxBet = state.currentBet;
|
||||||
|
const allMatched = activePlayers.every(
|
||||||
|
p => p.status === 'all-in' || p.currentBet >= currentMaxBet
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!allMatched) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextRound: GameState['bettingRound'] = state.bettingRound;
|
||||||
|
switch (state.bettingRound) {
|
||||||
|
case 'pre-flop':
|
||||||
|
nextRound = 'flop';
|
||||||
|
break;
|
||||||
|
case 'flop':
|
||||||
|
nextRound = 'turn';
|
||||||
|
break;
|
||||||
|
case 'turn':
|
||||||
|
nextRound = 'river';
|
||||||
|
break;
|
||||||
|
case 'river':
|
||||||
|
nextRound = 'showdown';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetPlayers = state.players.map(p => {
|
||||||
|
if (p.status === 'folded' || p.status === 'all-in') return p;
|
||||||
|
return { ...p, currentBet: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
players: resetPlayers,
|
||||||
|
bettingRound: nextRound,
|
||||||
|
currentBet: 0,
|
||||||
|
lastRaiseAmount: 0,
|
||||||
|
currentTurn: (state.dealerPosition + 1) % state.players.length
|
||||||
|
};
|
||||||
|
}
|
||||||
24
src/lib/game/blinds.ts
Normal file
24
src/lib/game/blinds.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import type { GameState } from '$lib/types/game-state';
|
||||||
|
|
||||||
|
export function postBlinds(state: GameState): GameState {
|
||||||
|
const newState = { ...state };
|
||||||
|
const numPlayers = state.players.length;
|
||||||
|
const sbIndex = (state.dealerPosition + 1) % numPlayers;
|
||||||
|
const bbIndex = (state.dealerPosition + 2) % numPlayers;
|
||||||
|
|
||||||
|
const sbPlayer = { ...newState.players[sbIndex] };
|
||||||
|
sbPlayer.chips -= state.smallBlind;
|
||||||
|
sbPlayer.currentBet = state.smallBlind;
|
||||||
|
newState.players = [...newState.players];
|
||||||
|
newState.players[sbIndex] = sbPlayer;
|
||||||
|
|
||||||
|
const bbPlayer = { ...newState.players[bbIndex] };
|
||||||
|
bbPlayer.chips -= state.bigBlind;
|
||||||
|
bbPlayer.currentBet = state.bigBlind;
|
||||||
|
newState.players[bbIndex] = bbPlayer;
|
||||||
|
|
||||||
|
newState.pot = state.smallBlind + state.bigBlind;
|
||||||
|
newState.currentBet = state.bigBlind;
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
25
src/lib/game/dealing.ts
Normal file
25
src/lib/game/dealing.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import type { Card } from '$lib/types/card';
|
||||||
|
import type { PlayerSeat } from '$lib/types/player';
|
||||||
|
|
||||||
|
export function dealHoleCards(deck: Card[], players: PlayerSeat[]): { deck: Card[]; holeCards: Map<string, Card[]> } {
|
||||||
|
const remaining = [...deck];
|
||||||
|
const holeCards = new Map<string, Card[]>();
|
||||||
|
|
||||||
|
for (const player of players) {
|
||||||
|
if (player.status === 'folded') continue;
|
||||||
|
const card1 = remaining.shift()!;
|
||||||
|
const card2 = remaining.shift()!;
|
||||||
|
holeCards.set(player.id, [card1, card2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deck: remaining, holeCards };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dealCommunityCards(deck: Card[], count: number): { deck: Card[]; cards: Card[] } {
|
||||||
|
const remaining = [...deck];
|
||||||
|
const cards: Card[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
cards.push(remaining.shift()!);
|
||||||
|
}
|
||||||
|
return { deck: remaining, cards };
|
||||||
|
}
|
||||||
21
src/lib/game/deck.ts
Normal file
21
src/lib/game/deck.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { ALL_SUITS, ALL_RANKS, Suit, Rank } from '$lib/types/card';
|
||||||
|
import type { Card } from '$lib/types/card';
|
||||||
|
|
||||||
|
export function createDeck(): Card[] {
|
||||||
|
const deck: Card[] = [];
|
||||||
|
for (const suit of ALL_SUITS) {
|
||||||
|
for (const rank of ALL_RANKS) {
|
||||||
|
deck.push({ suit, rank });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deck;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shuffleDeck(deck: Card[]): Card[] {
|
||||||
|
const shuffled = [...deck];
|
||||||
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||||
|
}
|
||||||
|
return shuffled;
|
||||||
|
}
|
||||||
36
src/lib/game/hand.ts
Normal file
36
src/lib/game/hand.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import type { GameState } from '$lib/types/game-state';
|
||||||
|
import { createDeck, shuffleDeck } from './deck';
|
||||||
|
import { dealHoleCards } from './dealing';
|
||||||
|
import { postBlinds } from './blinds';
|
||||||
|
import { rotateDealer } from './state';
|
||||||
|
|
||||||
|
export function startNewHand(state: GameState): GameState {
|
||||||
|
let newState = rotateDealer(state);
|
||||||
|
|
||||||
|
const deck = shuffleDeck(createDeck());
|
||||||
|
newState = { ...newState, deck, communityCards: [], pot: 0, actionHistory: [] };
|
||||||
|
|
||||||
|
const resetPlayers = newState.players.map(p => ({
|
||||||
|
...p,
|
||||||
|
currentBet: 0,
|
||||||
|
status: p.chips > 0 ? 'active' as const : p.status,
|
||||||
|
holeCards: []
|
||||||
|
}));
|
||||||
|
newState = { ...newState, players: resetPlayers };
|
||||||
|
|
||||||
|
newState = postBlinds(newState);
|
||||||
|
|
||||||
|
const { deck: remainingDeck, holeCards } = dealHoleCards(newState.deck, newState.players);
|
||||||
|
const updatedPlayers = newState.players.map(p => ({
|
||||||
|
...p,
|
||||||
|
holeCards: holeCards.get(p.id) ?? []
|
||||||
|
}));
|
||||||
|
newState = { ...newState, deck: remainingDeck, players: updatedPlayers };
|
||||||
|
|
||||||
|
const sbIndex = (newState.dealerPosition + 1) % newState.players.length;
|
||||||
|
const bbIndex = (newState.dealerPosition + 2) % newState.players.length;
|
||||||
|
const firstToAct = (bbIndex + 1) % newState.players.length;
|
||||||
|
newState = { ...newState, currentTurn: firstToAct, bettingRound: 'pre-flop' as const };
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
58
src/lib/game/showdown.ts
Normal file
58
src/lib/game/showdown.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import type { GameState } from '$lib/types/game-state';
|
||||||
|
import { evaluateHand } from '$lib/utils/hand-evaluator';
|
||||||
|
import { compareHands } from '$lib/utils/ranks';
|
||||||
|
|
||||||
|
export interface ShowdownResult {
|
||||||
|
winners: string[];
|
||||||
|
handResults: Map<string, ReturnType<typeof evaluateHand>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function determineWinner(state: GameState): ShowdownResult {
|
||||||
|
const activePlayers = state.players.filter(
|
||||||
|
p => p.status === 'active' || p.status === 'all-in'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (activePlayers.length === 1) {
|
||||||
|
return { winners: [activePlayers[0].id], handResults: new Map() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const handResults = new Map<string, ReturnType<typeof evaluateHand>>();
|
||||||
|
|
||||||
|
for (const player of activePlayers) {
|
||||||
|
const allCards = [...player.holeCards, ...state.communityCards];
|
||||||
|
const result = evaluateHand(allCards);
|
||||||
|
handResults.set(player.id, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bestResult = handResults.values().next().value;
|
||||||
|
for (const [id, result] of handResults) {
|
||||||
|
if (compareHands(result, bestResult!) > 0) {
|
||||||
|
bestResult = result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const winners: string[] = [];
|
||||||
|
for (const [id, result] of handResults) {
|
||||||
|
if (compareHands(result, bestResult!) === 0) {
|
||||||
|
winners.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { winners, handResults };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function awardPot(state: GameState, winners: string[]): GameState {
|
||||||
|
const share = Math.floor(state.pot / winners.length);
|
||||||
|
const updatedPlayers = state.players.map(p => {
|
||||||
|
if (winners.includes(p.id)) {
|
||||||
|
return { ...p, chips: p.chips + share };
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
players: updatedPlayers,
|
||||||
|
pot: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
53
src/lib/game/state.ts
Normal file
53
src/lib/game/state.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import type { GameState, BettingRound } from '$lib/types/game-state';
|
||||||
|
import type { PlayerSeat } from '$lib/types/player';
|
||||||
|
import type { ActionRecord } from '$lib/types/action';
|
||||||
|
|
||||||
|
export function createInitialState(numPlayers: number, startingStack: number): GameState {
|
||||||
|
const players: PlayerSeat[] = [];
|
||||||
|
for (let i = 0; i < numPlayers; i++) {
|
||||||
|
players.push({
|
||||||
|
id: `player-${i}`,
|
||||||
|
name: i === 0 ? 'You' : `Bot ${i}`,
|
||||||
|
chips: startingStack,
|
||||||
|
currentBet: 0,
|
||||||
|
status: 'active',
|
||||||
|
holeCards: [],
|
||||||
|
position: i
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
deck: [],
|
||||||
|
players,
|
||||||
|
communityCards: [],
|
||||||
|
pot: 0,
|
||||||
|
dealerPosition: Math.floor(Math.random() * numPlayers),
|
||||||
|
currentTurn: 0,
|
||||||
|
bettingRound: 'idle',
|
||||||
|
actionHistory: [],
|
||||||
|
currentBet: 0,
|
||||||
|
lastRaiseAmount: 0,
|
||||||
|
smallBlind: 10,
|
||||||
|
bigBlind: 20
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rotateDealer(state: GameState): GameState {
|
||||||
|
const nextDealer = (state.dealerPosition + 1) % state.players.length;
|
||||||
|
return { ...state, dealerPosition: nextDealer };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordAction(
|
||||||
|
state: GameState,
|
||||||
|
playerId: string,
|
||||||
|
type: ActionRecord['type'],
|
||||||
|
amount?: number
|
||||||
|
): GameState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
actionHistory: [
|
||||||
|
...state.actionHistory,
|
||||||
|
{ playerId, type, amount, timestamp: Date.now() }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
18
src/lib/game/turn.ts
Normal file
18
src/lib/game/turn.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import type { GameState } from '$lib/types/game-state';
|
||||||
|
|
||||||
|
export function getNextActivePlayer(state: GameState): number | null {
|
||||||
|
const numPlayers = state.players.length;
|
||||||
|
let next = (state.currentTurn + 1) % numPlayers;
|
||||||
|
let checked = 0;
|
||||||
|
|
||||||
|
while (checked < numPlayers) {
|
||||||
|
const player = state.players[next];
|
||||||
|
if (player.status === 'active') {
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
next = (next + 1) % numPlayers;
|
||||||
|
checked++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
56
src/lib/game/validation.ts
Normal file
56
src/lib/game/validation.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import type { GameState } from '$lib/types/game-state';
|
||||||
|
import type { ActionRecord } from '$lib/types/action';
|
||||||
|
|
||||||
|
export function validateAction(
|
||||||
|
playerId: string,
|
||||||
|
actionType: ActionRecord['type'],
|
||||||
|
amount?: number,
|
||||||
|
state?: GameState
|
||||||
|
): { valid: boolean; reason?: string } {
|
||||||
|
if (!state) return { valid: false, reason: 'No game state' };
|
||||||
|
|
||||||
|
const player = state.players.find(p => p.id === playerId);
|
||||||
|
if (!player) return { valid: false, reason: 'Player not found' };
|
||||||
|
if (player.status !== 'active') return { valid: false, reason: 'Player cannot act' };
|
||||||
|
|
||||||
|
switch (actionType) {
|
||||||
|
case 'check':
|
||||||
|
if (state.currentBet > player.currentBet) {
|
||||||
|
return { valid: false, reason: 'Cannot check when there is a bet to call' };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
if (player.chips < callAmount) return { valid: false, reason: 'Not enough chips' };
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'raise': {
|
||||||
|
if (amount === undefined || amount <= 0) return { valid: false, reason: 'Invalid raise amount' };
|
||||||
|
const minRaise = state.lastRaiseAmount > 0
|
||||||
|
? 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' };
|
||||||
|
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':
|
||||||
|
if (player.chips <= 0) return { valid: false, reason: 'No chips remaining' };
|
||||||
|
return { valid: true };
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { valid: false, reason: 'Unknown action' };
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/lib/styles.css
Normal file
9
src/lib/styles.css
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
:root {
|
||||||
|
--card-width: 60px;
|
||||||
|
--card-height: 84px;
|
||||||
|
--card-radius: 6px;
|
||||||
|
--card-font-size: 14px;
|
||||||
|
--table-green: #1a6b3c;
|
||||||
|
--table-dark: #0d4a28;
|
||||||
|
--felt-color: #2d7a4f;
|
||||||
|
}
|
||||||
8
src/lib/types/action.ts
Normal file
8
src/lib/types/action.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export type ActionType = 'check' | 'call' | 'raise' | 'fold' | 'all-in';
|
||||||
|
|
||||||
|
export interface ActionRecord {
|
||||||
|
playerId: string;
|
||||||
|
type: ActionType;
|
||||||
|
amount?: number;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
53
src/lib/types/card.ts
Normal file
53
src/lib/types/card.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
export enum Suit {
|
||||||
|
HEARTS = 'hearts',
|
||||||
|
DIAMONDS = 'diamonds',
|
||||||
|
SPADES = 'spades',
|
||||||
|
CLUBS = 'clubs'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Rank {
|
||||||
|
TWO = '2',
|
||||||
|
THREE = '3',
|
||||||
|
FOUR = '4',
|
||||||
|
FIVE = '5',
|
||||||
|
SIX = '6',
|
||||||
|
SEVEN = '7',
|
||||||
|
EIGHT = '8',
|
||||||
|
NINE = '9',
|
||||||
|
TEN = '10',
|
||||||
|
JACK = 'J',
|
||||||
|
QUEEN = 'Q',
|
||||||
|
KING = 'K',
|
||||||
|
ACE = 'A'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Card {
|
||||||
|
suit: Suit;
|
||||||
|
rank: Rank;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SUIT_SYMBOLS: Record<Suit, string> = {
|
||||||
|
[Suit.HEARTS]: '♥',
|
||||||
|
[Suit.DIAMONDS]: '♦',
|
||||||
|
[Suit.SPADES]: '♠',
|
||||||
|
[Suit.CLUBS]: '♣'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RANK_VALUES: Record<Rank, number> = {
|
||||||
|
[Rank.TWO]: 2,
|
||||||
|
[Rank.THREE]: 3,
|
||||||
|
[Rank.FOUR]: 4,
|
||||||
|
[Rank.FIVE]: 5,
|
||||||
|
[Rank.SIX]: 6,
|
||||||
|
[Rank.SEVEN]: 7,
|
||||||
|
[Rank.EIGHT]: 8,
|
||||||
|
[Rank.NINE]: 9,
|
||||||
|
[Rank.TEN]: 10,
|
||||||
|
[Rank.JACK]: 11,
|
||||||
|
[Rank.QUEEN]: 12,
|
||||||
|
[Rank.KING]: 13,
|
||||||
|
[Rank.ACE]: 14
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ALL_SUITS = Object.values(Suit);
|
||||||
|
export const ALL_RANKS = Object.values(Rank);
|
||||||
20
src/lib/types/game-state.ts
Normal file
20
src/lib/types/game-state.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import type { Card } from './card';
|
||||||
|
import type { PlayerSeat } from './player';
|
||||||
|
import type { ActionRecord } from './action';
|
||||||
|
|
||||||
|
export type BettingRound = 'pre-flop' | 'flop' | 'turn' | 'river' | 'showdown' | 'idle';
|
||||||
|
|
||||||
|
export interface GameState {
|
||||||
|
deck: Card[];
|
||||||
|
players: PlayerSeat[];
|
||||||
|
communityCards: Card[];
|
||||||
|
pot: number;
|
||||||
|
dealerPosition: number;
|
||||||
|
currentTurn: number;
|
||||||
|
bettingRound: BettingRound;
|
||||||
|
actionHistory: ActionRecord[];
|
||||||
|
currentBet: number;
|
||||||
|
lastRaiseAmount: number;
|
||||||
|
smallBlind: number;
|
||||||
|
bigBlind: number;
|
||||||
|
}
|
||||||
13
src/lib/types/player.ts
Normal file
13
src/lib/types/player.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import type { Card } from './card';
|
||||||
|
|
||||||
|
export type PlayerStatus = 'active' | 'folded' | 'all-in';
|
||||||
|
|
||||||
|
export interface PlayerSeat {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
chips: number;
|
||||||
|
currentBet: number;
|
||||||
|
status: PlayerStatus;
|
||||||
|
holeCards: Card[];
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
114
src/lib/utils/hand-evaluator.ts
Normal file
114
src/lib/utils/hand-evaluator.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import type { Card } from '$lib/types/card';
|
||||||
|
import { RANK_VALUES } from '$lib/types/card';
|
||||||
|
import { HAND_RANKS, type HandResult } from './ranks';
|
||||||
|
|
||||||
|
function getCombinations(cards: Card[], size: number): Card[][] {
|
||||||
|
if (size === 0) return [[]];
|
||||||
|
if (cards.length < size) return [];
|
||||||
|
const [first, ...rest] = cards;
|
||||||
|
const withFirst = getCombinations(rest, size - 1).map(c => [first, ...c]);
|
||||||
|
const withoutFirst = getCombinations(rest, size);
|
||||||
|
return [...withFirst, ...withoutFirst];
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluate5Cards(cards: Card[]): HandResult {
|
||||||
|
const values = cards.map(c => RANK_VALUES[c.rank]).sort((a, b) => b - a);
|
||||||
|
const suits = cards.map(c => c.suit);
|
||||||
|
|
||||||
|
const isFlush = suits.every(s => s === suits[0]);
|
||||||
|
const isStraight = checkStraight(values);
|
||||||
|
const straightHigh = isStraight ? (values[0] === 14 && values[1] === 5 ? 5 : values[0]) : -1;
|
||||||
|
|
||||||
|
const counts = countRanks(values);
|
||||||
|
|
||||||
|
if (isFlush && isStraight) {
|
||||||
|
if (straightHigh === 14) {
|
||||||
|
return { rank: HAND_RANKS.ROYAL_FLUSH, name: 'Royal Flush', kickers: [14] };
|
||||||
|
}
|
||||||
|
return { rank: HAND_RANKS.STRAIGHT_FLUSH, name: 'Straight Flush', kickers: [straightHigh] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (counts[0].count === 4) {
|
||||||
|
const kicker = values.find(v => v !== counts[0].value)!;
|
||||||
|
return { rank: HAND_RANKS.FOUR_OF_A_KIND, name: 'Four of a Kind', kickers: [counts[0].value, kicker] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (counts[0].count === 3 && counts.length > 1 && counts[1].count === 2) {
|
||||||
|
return { rank: HAND_RANKS.FULL_HOUSE, name: 'Full House', kickers: [counts[0].value, counts[1].value] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFlush) {
|
||||||
|
return { rank: HAND_RANKS.FLUSH, name: 'Flush', kickers: values };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStraight) {
|
||||||
|
return { rank: HAND_RANKS.STRAIGHT, name: 'Straight', kickers: [straightHigh] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (counts[0].count === 3) {
|
||||||
|
const kickers = counts.slice(1).map(c => c.value).sort((a, b) => b - a);
|
||||||
|
return { rank: HAND_RANKS.THREE_OF_A_KIND, name: 'Three of a Kind', kickers: [counts[0].value, ...kickers] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (counts[0].count === 2 && counts[1].count === 2) {
|
||||||
|
const pairs = [counts[0].value, counts[1].value].sort((a, b) => b - a);
|
||||||
|
const kicker = values.find(v => v !== pairs[0] && v !== pairs[1])!;
|
||||||
|
return { rank: HAND_RANKS.TWO_PAIR, name: 'Two Pair', kickers: [...pairs, kicker] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (counts[0].count === 2) {
|
||||||
|
const kickers = counts.slice(1).map(c => c.value).sort((a, b) => b - a);
|
||||||
|
return { rank: HAND_RANKS.PAIR, name: 'Pair', kickers: [counts[0].value, ...kickers] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rank: HAND_RANKS.HIGH_CARD, name: 'High Card', kickers: values };
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkStraight(values: number[]): boolean {
|
||||||
|
const unique = [...new Set(values)].sort((a, b) => b - a);
|
||||||
|
if (unique.length < 5) return false;
|
||||||
|
|
||||||
|
for (let i = 0; i <= unique.length - 5; i++) {
|
||||||
|
const five = unique.slice(i, i + 5);
|
||||||
|
if (five[0] - five[4] === 4) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unique.includes(14) && unique.includes(2) && unique.includes(3) && unique.includes(4) && unique.includes(5)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countRanks(values: number[]): Array<{ value: number; count: number }> {
|
||||||
|
const freq = new Map<number, number>();
|
||||||
|
for (const v of values) freq.set(v, (freq.get(v) ?? 0) + 1);
|
||||||
|
return [...freq.entries()]
|
||||||
|
.map(([value, count]) => ({ value, count }))
|
||||||
|
.sort((a, b) => b.count - a.count || b.value - a.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateHand(cards: Card[]): HandResult {
|
||||||
|
if (cards.length === 5) {
|
||||||
|
return evaluate5Cards(cards);
|
||||||
|
}
|
||||||
|
|
||||||
|
const best = getCombinations(cards, 5)
|
||||||
|
.map(combo => evaluate5Cards(combo))
|
||||||
|
.reduce((best, current) => {
|
||||||
|
const cmp = compareHands(current, best);
|
||||||
|
return cmp > 0 ? current : best;
|
||||||
|
});
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareHands(a: HandResult, b: HandResult): number {
|
||||||
|
if (a.rank !== b.rank) return a.rank - b.rank;
|
||||||
|
for (let i = 0; i < Math.max(a.kickers.length, b.kickers.length); i++) {
|
||||||
|
const ka = a.kickers[i] ?? 0;
|
||||||
|
const kb = b.kickers[i] ?? 0;
|
||||||
|
if (ka !== kb) return ka - kb;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
46
src/lib/utils/ranks.ts
Normal file
46
src/lib/utils/ranks.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { Rank, RANK_VALUES } from '$lib/types/card';
|
||||||
|
import type { Card } from '$lib/types/card';
|
||||||
|
|
||||||
|
export const HAND_RANKS = {
|
||||||
|
HIGH_CARD: 1,
|
||||||
|
PAIR: 2,
|
||||||
|
TWO_PAIR: 3,
|
||||||
|
THREE_OF_A_KIND: 4,
|
||||||
|
STRAIGHT: 5,
|
||||||
|
FLUSH: 6,
|
||||||
|
FULL_HOUSE: 7,
|
||||||
|
FOUR_OF_A_KIND: 8,
|
||||||
|
STRAIGHT_FLUSH: 9,
|
||||||
|
ROYAL_FLUSH: 10
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type HandRank = (typeof HAND_RANKS)[keyof typeof HAND_RANKS];
|
||||||
|
|
||||||
|
export interface HandResult {
|
||||||
|
rank: HandRank;
|
||||||
|
name: string;
|
||||||
|
kickers: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const HAND_NAMES: Record<HandRank, string> = {
|
||||||
|
1: 'High Card',
|
||||||
|
2: 'Pair',
|
||||||
|
3: 'Two Pair',
|
||||||
|
4: 'Three of a Kind',
|
||||||
|
5: 'Straight',
|
||||||
|
6: 'Flush',
|
||||||
|
7: 'Full House',
|
||||||
|
8: 'Four of a Kind',
|
||||||
|
9: 'Straight Flush',
|
||||||
|
10: 'Royal Flush'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function compareHands(a: HandResult, b: HandResult): number {
|
||||||
|
if (a.rank !== b.rank) return a.rank - b.rank;
|
||||||
|
for (let i = 0; i < Math.max(a.kickers.length, b.kickers.length); i++) {
|
||||||
|
const ka = a.kickers[i] ?? 0;
|
||||||
|
const kb = b.kickers[i] ?? 0;
|
||||||
|
if (ka !== kb) return ka - kb;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@ -1,11 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<div class="app-layout">
|
||||||
<link rel="icon" href={favicon} />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style global>
|
||||||
|
:root {
|
||||||
|
--card-width: 60px;
|
||||||
|
--card-height: 84px;
|
||||||
|
--card-radius: 6px;
|
||||||
|
--card-font-size: 14px;
|
||||||
|
--table-green: #1a6b3c;
|
||||||
|
--table-dark: #0d4a28;
|
||||||
|
--felt-color: #2d7a4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #ecf0f1;
|
||||||
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -1,2 +1,219 @@
|
|||||||
<h1>Welcome to SvelteKit</h1>
|
<script lang="ts">
|
||||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
import PokerTable from '$lib/components/PokerTable.svelte';
|
||||||
|
import BetControls from '$lib/components/BetControls.svelte';
|
||||||
|
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 { dealCommunityCards } from '$lib/game/dealing';
|
||||||
|
import { determineWinner, awardPot } from '$lib/game/showdown';
|
||||||
|
|
||||||
|
let initialState = createInitialState(6, 1000);
|
||||||
|
let gameState = $state(startNewHand(initialState));
|
||||||
|
|
||||||
|
let message = $state('');
|
||||||
|
|
||||||
|
const initialTurn = gameState.currentTurn;
|
||||||
|
if (initialTurn !== 0) {
|
||||||
|
setTimeout(() => aiAct(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAction(type: string, amount?: number) {
|
||||||
|
if (gameState.bettingRound === 'idle' || gameState.bettingRound === 'showdown') return;
|
||||||
|
|
||||||
|
let newState: typeof gameState;
|
||||||
|
switch (type) {
|
||||||
|
case 'check':
|
||||||
|
newState = applyCheck(gameState, 'player-0');
|
||||||
|
break;
|
||||||
|
case 'call':
|
||||||
|
newState = applyCall(gameState, 'player-0');
|
||||||
|
break;
|
||||||
|
case 'raise':
|
||||||
|
newState = applyRaise(gameState, 'player-0', amount ?? 0);
|
||||||
|
break;
|
||||||
|
case 'fold':
|
||||||
|
newState = applyFold(gameState, 'player-0');
|
||||||
|
break;
|
||||||
|
case 'all-in':
|
||||||
|
newState = applyAllIn(gameState, 'player-0');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gameState = { ...newState };
|
||||||
|
processGame();
|
||||||
|
}
|
||||||
|
|
||||||
|
function processGame() {
|
||||||
|
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'}!`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let processed = completeBettingRound(gameState);
|
||||||
|
|
||||||
|
if (processed.bettingRound !== gameState.bettingRound) {
|
||||||
|
gameState = { ...processed };
|
||||||
|
if (gameState.bettingRound === 'flop') {
|
||||||
|
const { deck, cards } = dealCommunityCards(gameState.deck, 3);
|
||||||
|
gameState.deck = deck;
|
||||||
|
gameState.communityCards = cards;
|
||||||
|
} else if (gameState.bettingRound === 'turn' || gameState.bettingRound === 'river') {
|
||||||
|
const { deck, cards } = dealCommunityCards(gameState.deck, 1);
|
||||||
|
gameState.deck = deck;
|
||||||
|
gameState.communityCards = [...gameState.communityCards, ...cards];
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canBet = gameState.players.some(p => p.status === 'active');
|
||||||
|
if (!canBet) {
|
||||||
|
setTimeout(() => processGame(), 300);
|
||||||
|
} else if (gameState.currentTurn !== 0) {
|
||||||
|
aiAct();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function aiAct() {
|
||||||
|
let aiIndex = gameState.currentTurn;
|
||||||
|
|
||||||
|
if (aiIndex === 0) return;
|
||||||
|
|
||||||
|
const aiPlayer = gameState.players[aiIndex];
|
||||||
|
if (!aiPlayer || aiPlayer.status !== 'active') return;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
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');
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
gameState = { ...newState };
|
||||||
|
processGame();
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNextHand() {
|
||||||
|
message = '';
|
||||||
|
gameState = startNewHand(gameState);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="game-container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>PokeR</h1>
|
||||||
|
<div class="round-indicator">{gameState.bettingRound}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PokerTable {gameState} />
|
||||||
|
|
||||||
|
<BetControls {gameState} onAction={handleAction} />
|
||||||
|
|
||||||
|
{#if message}
|
||||||
|
<div class="message" role="alert">{message}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if gameState.bettingRound === 'showdown'}
|
||||||
|
<button class="btn-new-hand" onclick={startNextHand}>New Hand</button>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.game-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
color: #ecf0f1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-indicator {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #95a5a6;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
background: rgba(46, 204, 113, 0.2);
|
||||||
|
border: 1px solid #2ecc71;
|
||||||
|
color: #2ecc71;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new-hand {
|
||||||
|
padding: 12px 32px;
|
||||||
|
background: #2ecc71;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new-hand:hover {
|
||||||
|
background: #27ae60;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user