From bcb546524708839b46b7af13369ab9907a1a8bd8 Mon Sep 17 00:00:00 2001 From: "Veit F." Date: Sun, 17 May 2026 17:28:38 +0200 Subject: [PATCH] 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. --- .../.openspec.yaml | 2 + .../design.md | 62 +++++ .../proposal.md | 29 +++ .../specs/card-components/spec.md | 48 ++++ .../specs/game-engine/spec.md | 94 ++++++++ .../specs/player-state/spec.md | 64 +++++ .../specs/poker-table/spec.md | 75 ++++++ .../tasks.md | 83 +++++++ openspec/specs/card-components/spec.md | 48 ++++ openspec/specs/game-engine/spec.md | 94 ++++++++ openspec/specs/player-state/spec.md | 64 +++++ openspec/specs/poker-table/spec.md | 75 ++++++ src/app.html | 5 +- src/lib/components/BetControls.svelte | 118 ++++++++++ src/lib/components/Card.svelte | 123 ++++++++++ src/lib/components/PlayerSeat.svelte | 103 ++++++++ src/lib/components/PokerTable.svelte | 121 ++++++++++ src/lib/game/actions.ts | 113 +++++++++ src/lib/game/betting-round.ts | 49 ++++ src/lib/game/blinds.ts | 24 ++ src/lib/game/dealing.ts | 25 ++ src/lib/game/deck.ts | 21 ++ src/lib/game/hand.ts | 36 +++ src/lib/game/showdown.ts | 58 +++++ src/lib/game/state.ts | 53 +++++ src/lib/game/turn.ts | 18 ++ src/lib/game/validation.ts | 56 +++++ src/lib/styles.css | 9 + src/lib/types/action.ts | 8 + src/lib/types/card.ts | 53 +++++ src/lib/types/game-state.ts | 20 ++ src/lib/types/player.ts | 13 ++ src/lib/utils/hand-evaluator.ts | 114 +++++++++ src/lib/utils/ranks.ts | 46 ++++ src/routes/+layout.svelte | 29 ++- src/routes/+page.svelte | 221 +++++++++++++++++- 36 files changed, 2163 insertions(+), 11 deletions(-) create mode 100644 openspec/changes/archive/2025-05-17-foundational-pokertable/.openspec.yaml create mode 100644 openspec/changes/archive/2025-05-17-foundational-pokertable/design.md create mode 100644 openspec/changes/archive/2025-05-17-foundational-pokertable/proposal.md create mode 100644 openspec/changes/archive/2025-05-17-foundational-pokertable/specs/card-components/spec.md create mode 100644 openspec/changes/archive/2025-05-17-foundational-pokertable/specs/game-engine/spec.md create mode 100644 openspec/changes/archive/2025-05-17-foundational-pokertable/specs/player-state/spec.md create mode 100644 openspec/changes/archive/2025-05-17-foundational-pokertable/specs/poker-table/spec.md create mode 100644 openspec/changes/archive/2025-05-17-foundational-pokertable/tasks.md create mode 100644 openspec/specs/card-components/spec.md create mode 100644 openspec/specs/game-engine/spec.md create mode 100644 openspec/specs/player-state/spec.md create mode 100644 openspec/specs/poker-table/spec.md create mode 100644 src/lib/components/BetControls.svelte create mode 100644 src/lib/components/Card.svelte create mode 100644 src/lib/components/PlayerSeat.svelte create mode 100644 src/lib/components/PokerTable.svelte create mode 100644 src/lib/game/actions.ts create mode 100644 src/lib/game/betting-round.ts create mode 100644 src/lib/game/blinds.ts create mode 100644 src/lib/game/dealing.ts create mode 100644 src/lib/game/deck.ts create mode 100644 src/lib/game/hand.ts create mode 100644 src/lib/game/showdown.ts create mode 100644 src/lib/game/state.ts create mode 100644 src/lib/game/turn.ts create mode 100644 src/lib/game/validation.ts create mode 100644 src/lib/styles.css create mode 100644 src/lib/types/action.ts create mode 100644 src/lib/types/card.ts create mode 100644 src/lib/types/game-state.ts create mode 100644 src/lib/types/player.ts create mode 100644 src/lib/utils/hand-evaluator.ts create mode 100644 src/lib/utils/ranks.ts diff --git a/openspec/changes/archive/2025-05-17-foundational-pokertable/.openspec.yaml b/openspec/changes/archive/2025-05-17-foundational-pokertable/.openspec.yaml new file mode 100644 index 0000000..66da1ae --- /dev/null +++ b/openspec/changes/archive/2025-05-17-foundational-pokertable/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-17 diff --git a/openspec/changes/archive/2025-05-17-foundational-pokertable/design.md b/openspec/changes/archive/2025-05-17-foundational-pokertable/design.md new file mode 100644 index 0000000..1a3b021 --- /dev/null +++ b/openspec/changes/archive/2025-05-17-foundational-pokertable/design.md @@ -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) diff --git a/openspec/changes/archive/2025-05-17-foundational-pokertable/proposal.md b/openspec/changes/archive/2025-05-17-foundational-pokertable/proposal.md new file mode 100644 index 0000000..e1bc1b8 --- /dev/null +++ b/openspec/changes/archive/2025-05-17-foundational-pokertable/proposal.md @@ -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) diff --git a/openspec/changes/archive/2025-05-17-foundational-pokertable/specs/card-components/spec.md b/openspec/changes/archive/2025-05-17-foundational-pokertable/specs/card-components/spec.md new file mode 100644 index 0000000..de9c0fe --- /dev/null +++ b/openspec/changes/archive/2025-05-17-foundational-pokertable/specs/card-components/spec.md @@ -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 diff --git a/openspec/changes/archive/2025-05-17-foundational-pokertable/specs/game-engine/spec.md b/openspec/changes/archive/2025-05-17-foundational-pokertable/specs/game-engine/spec.md new file mode 100644 index 0000000..ded8d53 --- /dev/null +++ b/openspec/changes/archive/2025-05-17-foundational-pokertable/specs/game-engine/spec.md @@ -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 diff --git a/openspec/changes/archive/2025-05-17-foundational-pokertable/specs/player-state/spec.md b/openspec/changes/archive/2025-05-17-foundational-pokertable/specs/player-state/spec.md new file mode 100644 index 0000000..711f426 --- /dev/null +++ b/openspec/changes/archive/2025-05-17-foundational-pokertable/specs/player-state/spec.md @@ -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 diff --git a/openspec/changes/archive/2025-05-17-foundational-pokertable/specs/poker-table/spec.md b/openspec/changes/archive/2025-05-17-foundational-pokertable/specs/poker-table/spec.md new file mode 100644 index 0000000..8293a77 --- /dev/null +++ b/openspec/changes/archive/2025-05-17-foundational-pokertable/specs/poker-table/spec.md @@ -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 diff --git a/openspec/changes/archive/2025-05-17-foundational-pokertable/tasks.md b/openspec/changes/archive/2025-05-17-foundational-pokertable/tasks.md new file mode 100644 index 0000000..652d78a --- /dev/null +++ b/openspec/changes/archive/2025-05-17-foundational-pokertable/tasks.md @@ -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 diff --git a/openspec/specs/card-components/spec.md b/openspec/specs/card-components/spec.md new file mode 100644 index 0000000..de9c0fe --- /dev/null +++ b/openspec/specs/card-components/spec.md @@ -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 diff --git a/openspec/specs/game-engine/spec.md b/openspec/specs/game-engine/spec.md new file mode 100644 index 0000000..ded8d53 --- /dev/null +++ b/openspec/specs/game-engine/spec.md @@ -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 diff --git a/openspec/specs/player-state/spec.md b/openspec/specs/player-state/spec.md new file mode 100644 index 0000000..711f426 --- /dev/null +++ b/openspec/specs/player-state/spec.md @@ -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 diff --git a/openspec/specs/poker-table/spec.md b/openspec/specs/poker-table/spec.md new file mode 100644 index 0000000..8293a77 --- /dev/null +++ b/openspec/specs/poker-table/spec.md @@ -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 diff --git a/src/app.html b/src/app.html index 6a2bb58..d27a1ce 100644 --- a/src/app.html +++ b/src/app.html @@ -3,10 +3,11 @@ - + + PokeR %sveltekit.head% - +
%sveltekit.body%
diff --git a/src/lib/components/BetControls.svelte b/src/lib/components/BetControls.svelte new file mode 100644 index 0000000..0f83f65 --- /dev/null +++ b/src/lib/components/BetControls.svelte @@ -0,0 +1,118 @@ + + +
+ {#if isMyTurn} + + + {#if !canCheck} + + {/if} + +
+ + +
+ + {#if humanPlayer && (humanPlayer.chips > callAmount)} + + {/if} + + + {/if} +
+ + diff --git a/src/lib/components/Card.svelte b/src/lib/components/Card.svelte new file mode 100644 index 0000000..734f76d --- /dev/null +++ b/src/lib/components/Card.svelte @@ -0,0 +1,123 @@ + + + + + diff --git a/src/lib/components/PlayerSeat.svelte b/src/lib/components/PlayerSeat.svelte new file mode 100644 index 0000000..b6ee370 --- /dev/null +++ b/src/lib/components/PlayerSeat.svelte @@ -0,0 +1,103 @@ + + +
+ {#if player.holeCards.length > 0} +
+ {#each player.holeCards as card (card.rank + card.suit)} + + {/each} +
+ {/if} + +
+ {player.name} + ${player.chips} + {#if player.currentBet > 0} + ${player.currentBet} + {/if} +
+ + {#if player.status === 'folded'} + FOLD + {/if} + {#if player.status === 'all-in'} + ALL-IN + {/if} +
+ + diff --git a/src/lib/components/PokerTable.svelte b/src/lib/components/PokerTable.svelte new file mode 100644 index 0000000..bf77634 --- /dev/null +++ b/src/lib/components/PokerTable.svelte @@ -0,0 +1,121 @@ + + +
+
+
+
+ {#each gameState.communityCards as card (card.rank + card.suit)} + + {/each} +
+
Pot: ${gameState.pot}
+
+ + {#each seatPositions as pos, i} + {@const player = getPlayerAtPosition(i)} + {#if player} +
+ + {#if gameState.dealerPosition === i} +
D
+ {/if} +
+ {/if} + {/each} +
+
+ + diff --git a/src/lib/game/actions.ts b/src/lib/game/actions.ts new file mode 100644 index 0000000..5f429ca --- /dev/null +++ b/src/lib/game/actions.ts @@ -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) }; +} diff --git a/src/lib/game/betting-round.ts b/src/lib/game/betting-round.ts new file mode 100644 index 0000000..e4cc7cf --- /dev/null +++ b/src/lib/game/betting-round.ts @@ -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 + }; +} diff --git a/src/lib/game/blinds.ts b/src/lib/game/blinds.ts new file mode 100644 index 0000000..ddfa249 --- /dev/null +++ b/src/lib/game/blinds.ts @@ -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; +} diff --git a/src/lib/game/dealing.ts b/src/lib/game/dealing.ts new file mode 100644 index 0000000..7c9786e --- /dev/null +++ b/src/lib/game/dealing.ts @@ -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 } { + const remaining = [...deck]; + const holeCards = new Map(); + + 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 }; +} diff --git a/src/lib/game/deck.ts b/src/lib/game/deck.ts new file mode 100644 index 0000000..233c02d --- /dev/null +++ b/src/lib/game/deck.ts @@ -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; +} diff --git a/src/lib/game/hand.ts b/src/lib/game/hand.ts new file mode 100644 index 0000000..f63b3d6 --- /dev/null +++ b/src/lib/game/hand.ts @@ -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; +} diff --git a/src/lib/game/showdown.ts b/src/lib/game/showdown.ts new file mode 100644 index 0000000..558de5e --- /dev/null +++ b/src/lib/game/showdown.ts @@ -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>; +} + +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>(); + + 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 + }; +} diff --git a/src/lib/game/state.ts b/src/lib/game/state.ts new file mode 100644 index 0000000..66c322a --- /dev/null +++ b/src/lib/game/state.ts @@ -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() } + ] + }; +} diff --git a/src/lib/game/turn.ts b/src/lib/game/turn.ts new file mode 100644 index 0000000..a402e55 --- /dev/null +++ b/src/lib/game/turn.ts @@ -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; +} diff --git a/src/lib/game/validation.ts b/src/lib/game/validation.ts new file mode 100644 index 0000000..6e3f108 --- /dev/null +++ b/src/lib/game/validation.ts @@ -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' }; + } +} diff --git a/src/lib/styles.css b/src/lib/styles.css new file mode 100644 index 0000000..62b5f3d --- /dev/null +++ b/src/lib/styles.css @@ -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; +} diff --git a/src/lib/types/action.ts b/src/lib/types/action.ts new file mode 100644 index 0000000..1ac154d --- /dev/null +++ b/src/lib/types/action.ts @@ -0,0 +1,8 @@ +export type ActionType = 'check' | 'call' | 'raise' | 'fold' | 'all-in'; + +export interface ActionRecord { + playerId: string; + type: ActionType; + amount?: number; + timestamp: number; +} diff --git a/src/lib/types/card.ts b/src/lib/types/card.ts new file mode 100644 index 0000000..cc12f1a --- /dev/null +++ b/src/lib/types/card.ts @@ -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.HEARTS]: '♥', + [Suit.DIAMONDS]: '♦', + [Suit.SPADES]: '♠', + [Suit.CLUBS]: '♣' +}; + +export const RANK_VALUES: Record = { + [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); diff --git a/src/lib/types/game-state.ts b/src/lib/types/game-state.ts new file mode 100644 index 0000000..74564a5 --- /dev/null +++ b/src/lib/types/game-state.ts @@ -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; +} diff --git a/src/lib/types/player.ts b/src/lib/types/player.ts new file mode 100644 index 0000000..f190d5f --- /dev/null +++ b/src/lib/types/player.ts @@ -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; +} diff --git a/src/lib/utils/hand-evaluator.ts b/src/lib/utils/hand-evaluator.ts new file mode 100644 index 0000000..97cf0cf --- /dev/null +++ b/src/lib/utils/hand-evaluator.ts @@ -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(); + 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; +} diff --git a/src/lib/utils/ranks.ts b/src/lib/utils/ranks.ts new file mode 100644 index 0000000..d6d5020 --- /dev/null +++ b/src/lib/utils/ranks.ts @@ -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 = { + 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; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 9cebde5..02fe42e 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,11 +1,26 @@ - - - +
+ {@render children()} +
-{@render children()} + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index cc88df0..a77f9e8 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,2 +1,219 @@ -

Welcome to SvelteKit

-

Visit svelte.dev/docs/kit to read the documentation

+ + +
+
+

PokeR

+
{gameState.bettingRound}
+
+ + + + + + {#if message} + + {/if} + + {#if gameState.bettingRound === 'showdown'} + + {/if} +
+ +