From c7542679e772f3c35e1ebb057e46c3d371f9532c Mon Sep 17 00:00:00 2001 From: "Veit F." Date: Sun, 17 May 2026 23:25:45 +0200 Subject: [PATCH] fix: resolve settings persistence, timer reset, and card reveal bugs --- .../2026-05-17-fix-game-bugs/.openspec.yaml | 2 + .../2026-05-17-fix-game-bugs/design.md | 32 ++++++++++ .../2026-05-17-fix-game-bugs/proposal.md | 25 ++++++++ .../specs/card-reveal-rules/spec.md | 22 +++++++ .../specs/decision-timer/spec.md | 12 ++++ .../specs/game-config-persistence/spec.md | 19 ++++++ .../archive/2026-05-17-fix-game-bugs/tasks.md | 19 ++++++ openspec/specs/card-reveal-rules/spec.md | 27 ++++++++ openspec/specs/decision-timer/spec.md | 11 ++++ .../specs/game-config-persistence/spec.md | 24 +++++++ src/lib/components/DecisionTimer.svelte | 9 +-- src/lib/components/PlayerSeat.svelte | 5 +- src/lib/components/PokerTable.svelte | 18 +++++- src/routes/+page.svelte | 64 ++++++++++++++----- 14 files changed, 265 insertions(+), 24 deletions(-) create mode 100644 openspec/changes/archive/2026-05-17-fix-game-bugs/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-17-fix-game-bugs/design.md create mode 100644 openspec/changes/archive/2026-05-17-fix-game-bugs/proposal.md create mode 100644 openspec/changes/archive/2026-05-17-fix-game-bugs/specs/card-reveal-rules/spec.md create mode 100644 openspec/changes/archive/2026-05-17-fix-game-bugs/specs/decision-timer/spec.md create mode 100644 openspec/changes/archive/2026-05-17-fix-game-bugs/specs/game-config-persistence/spec.md create mode 100644 openspec/changes/archive/2026-05-17-fix-game-bugs/tasks.md create mode 100644 openspec/specs/card-reveal-rules/spec.md create mode 100644 openspec/specs/game-config-persistence/spec.md diff --git a/openspec/changes/archive/2026-05-17-fix-game-bugs/.openspec.yaml b/openspec/changes/archive/2026-05-17-fix-game-bugs/.openspec.yaml new file mode 100644 index 0000000..66da1ae --- /dev/null +++ b/openspec/changes/archive/2026-05-17-fix-game-bugs/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-17 diff --git a/openspec/changes/archive/2026-05-17-fix-game-bugs/design.md b/openspec/changes/archive/2026-05-17-fix-game-bugs/design.md new file mode 100644 index 0000000..21f312c --- /dev/null +++ b/openspec/changes/archive/2026-05-17-fix-game-bugs/design.md @@ -0,0 +1,32 @@ +## Context + +The game page (`+page.svelte`) hardcodes the `trainingMix` preset at initialization. The setup page saves user-selected config to `sessionStorage`, but nothing reads it. The bot timer uses a `$effect` that re-runs on every reactive update, resetting the countdown. Card visibility only checks `isHuman`, ignoring poker rules for showdown and all-in reveals. + +## Goals / Non-Goals + +**Goals:** +- Settings from `/setup` page apply to the game session +- Bot countdown timer decrements correctly (13→12→11...→0) +- Opponent cards reveal at showdown and when all-in with no further betting + +**Non-Goals:** +- Persistent config across browser sessions (localStorage/database) +- Animated card reveals or showmanship +- Multi-player online gameplay + +## Decisions + +### Settings via sessionStorage +Using `sessionStorage` (already in use by setup page) rather than URL params or a state store. It's simple, survives the navigation redirect, and clears on tab close — appropriate for a single-session training game. + +### Timer fix: $derived.timeRemaining with setInterval outside effect +The `$effect` will only anchor on `active` and `duration`, not `remaining`. The interval will update `remaining` directly. This breaks the reactivity cycle since the effect won't re-run when `remaining` changes. + +### Card reveal: computed function in PokerTable, passed to PlayerSeat +Rather than passing the entire GameState to each PlayerSeat (which it already has via player prop), we add a `revealCards` boolean prop. PokerTable computes this based on betting round, player status, and all-in conditions. + +## Risks / Trade-offs + +[sessionStorage read fails] → Parse with fallback to default preset +[Timer still flickers in dev mode] → Svelte 5 fine-grained reactivity may need `$state.raw` for the counter +[All-in reveal timing edge cases] → The "last betting round complete" check requires knowing if any active player can still bet — we approximate by checking if all non-folded players are all-in or matched diff --git a/openspec/changes/archive/2026-05-17-fix-game-bugs/proposal.md b/openspec/changes/archive/2026-05-17-fix-game-bugs/proposal.md new file mode 100644 index 0000000..27cca2a --- /dev/null +++ b/openspec/changes/archive/2026-05-17-fix-game-bugs/proposal.md @@ -0,0 +1,25 @@ +## Why + +Three bugs prevent proper gameplay: table settings from the setup page are ignored by the game, the bot decision timer resets incorrectly causing wrong countdowns, and opponent hand cards never reveal during showdown or all-in situations per poker rules. + +## What Changes + +- **Settings persistence**: Read `sessionStorage` config in the main game page and apply it to initialize game state instead of hardcoding the `trainingMix` preset +- **Bot timer fix**: Break the reactivity loop in `DecisionTimer.svelte` so the countdown decrements correctly without being reset by `$effect` re-runs +- **Card reveal rules**: Implement proper poker card visibility — show opponent hands at showdown, and when all-in players can no longer bet + +## Capabilities + +### New Capabilities +- `game-config-persistence`: Loading table configuration from setup page into the game session +- `card-reveal-rules`: Showing opponent hole cards according to poker showdown and all-in rules + +### Modified Capabilities +- `decision-timer`: Fixing countdown reset behavior so timer decrements correctly per second + +## Impact + +- `src/routes/+page.svelte` — read sessionStorage config, apply to game state +- `src/lib/components/DecisionTimer.svelte` — fix reactivity loop +- `src/lib/components/PlayerSeat.svelte` — add card reveal logic +- `src/lib/components/PokerTable.svelte` — pass reveal context to PlayerSeat diff --git a/openspec/changes/archive/2026-05-17-fix-game-bugs/specs/card-reveal-rules/spec.md b/openspec/changes/archive/2026-05-17-fix-game-bugs/specs/card-reveal-rules/spec.md new file mode 100644 index 0000000..7a1a3c2 --- /dev/null +++ b/openspec/changes/archive/2026-05-17-fix-game-bugs/specs/card-reveal-rules/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: Showdown reveals all remaining player cards +When the betting round reaches `showdown`, the hole cards of all players who have not folded SHALL be visible to the human player. + +#### Scenario: Normal showdown with multiple players +- **WHEN** `bettingRound` is `showdown` and two or more players are still active or all-in +- **THEN** all non-folded players' hole cards are revealed on the table + +### Requirement: All-in player cards reveal when betting ends +When a player goes all-in and no further betting is possible among the remaining active players, the all-in player's hole cards SHALL be revealed. + +#### Scenario: All-in with no further bets possible +- **WHEN** a player is `all-in` and all other active players have matched their bet (no more raises or calls possible) +- **THEN** the all-in player's hole cards become visible + +### Requirement: Folded player cards remain hidden +Players who have folded SHALL never have their hole cards revealed, regardless of game state. + +#### Scenario: Folded player at showdown +- **WHEN** a player has `status === 'folded'` and the hand reaches showdown +- **THEN** the folded player's cards remain face-down (mucked) diff --git a/openspec/changes/archive/2026-05-17-fix-game-bugs/specs/decision-timer/spec.md b/openspec/changes/archive/2026-05-17-fix-game-bugs/specs/decision-timer/spec.md new file mode 100644 index 0000000..0d0f685 --- /dev/null +++ b/openspec/changes/archive/2026-05-17-fix-game-bugs/specs/decision-timer/spec.md @@ -0,0 +1,12 @@ +## MODIFIED Requirements + +### Requirement: Timer decrements correctly without resetting +The decision timer SHALL count down from the configured duration to zero, decrementing exactly once per second, without being reset by reactive effect re-runs. + +#### Scenario: Normal countdown +- **WHEN** a timer is activated with `duration` of 10 seconds +- **THEN** the displayed remaining time follows the sequence: 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0 — each value displayed for approximately one second + +#### Scenario: Timer does not reset mid-countdown +- **WHEN** the timer is counting down and a reactive state change occurs in the component +- **THEN** the countdown continues without being interrupted or reset to the initial duration diff --git a/openspec/changes/archive/2026-05-17-fix-game-bugs/specs/game-config-persistence/spec.md b/openspec/changes/archive/2026-05-17-fix-game-bugs/specs/game-config-persistence/spec.md new file mode 100644 index 0000000..90364c9 --- /dev/null +++ b/openspec/changes/archive/2026-05-17-fix-game-bugs/specs/game-config-persistence/spec.md @@ -0,0 +1,19 @@ +## ADDED Requirements + +### Requirement: Game reads setup config from sessionStorage +When the game page loads, it SHALL read the configuration stored in `sessionStorage` under the key `poker-config` and apply those settings to initialize the game state. + +#### Scenario: Config exists in sessionStorage +- **WHEN** a user navigates from `/setup` after configuring their table +- **THEN** the game page reads `sessionStorage.getItem('poker-config')` and uses the parsed values to create the initial game state + +#### Scenario: No config exists (first visit) +- **WHEN** a user navigates directly to `/` without visiting `/setup` +- **THEN** the game falls back to the default `trainingMix` preset + +### Requirement: All setup parameters are applied +The parsed configuration SHALL include values for `numPlayers`, `startingStack`, `smallBlind`, `bigBlind`, `infoLevel`, `feedbackLevel`, `timerEnabled`, `timerDuration`, `humanTimerMode`, and `humanTimerDuration`. + +#### Scenario: Custom settings are preserved +- **WHEN** a user sets small blind to 50, big blind to 100, and starting stack to 5000 +- **THEN** the initialized game state reflects those exact values in `smallBlind`, `bigBlind`, and player chip counts diff --git a/openspec/changes/archive/2026-05-17-fix-game-bugs/tasks.md b/openspec/changes/archive/2026-05-17-fix-game-bugs/tasks.md new file mode 100644 index 0000000..466feef --- /dev/null +++ b/openspec/changes/archive/2026-05-17-fix-game-bugs/tasks.md @@ -0,0 +1,19 @@ +## 1. Settings Persistence + +- [x] 1.1 Add sessionStorage read logic in `+page.svelte` to parse `poker-config` on mount +- [x] 1.2 Apply parsed config values to `createInitialState` instead of hardcoding `trainingMix` preset +- [x] 1.3 Add fallback to `trainingMix` preset when no config exists or parsing fails + +## 2. Bot Timer Fix + +- [x] 2.1 Refactor `DecisionTimer.svelte` $effect to anchor only on `active` and `duration`, not `remaining` +- [x] 2.2 Ensure interval callback updates `remaining` without triggering effect re-run +- [x] 2.3 Verify countdown follows exact sequence (e.g., 10→9→8→...→0) without resets + +## 3. Card Reveal Rules + +- [x] 3.1 Create `shouldRevealCards(player, gameState)` helper function in PokerTable or utility module +- [x] 3.2 Implement showdown reveal: show cards when `bettingRound === 'showdown'` and player hasn't folded +- [x] 3.3 Implement all-in reveal: detect when betting is no longer possible and all-in players should show +- [x] 3.4 Pass `revealCards` boolean prop from PokerTable to each PlayerSeat +- [x] 3.5 Update PlayerSeat to use `revealCards` prop instead of current `isHuman || holeCards.length === 0` check diff --git a/openspec/specs/card-reveal-rules/spec.md b/openspec/specs/card-reveal-rules/spec.md new file mode 100644 index 0000000..6c4c6b7 --- /dev/null +++ b/openspec/specs/card-reveal-rules/spec.md @@ -0,0 +1,27 @@ +# Card Reveal Rules + +## Purpose +Defines when hole cards are revealed during gameplay, ensuring proper showdown and all-in card visibility while keeping folded cards hidden. + +## Requirements + +### Requirement: Showdown reveals all remaining player cards +When the betting round reaches `showdown`, the hole cards of all players who have not folded SHALL be visible to the human player. + +#### Scenario: Normal showdown with multiple players +- **WHEN** `bettingRound` is `showdown` and two or more players are still active or all-in +- **THEN** all non-folded players' hole cards are revealed on the table + +### Requirement: All-in player cards reveal when betting ends +When a player goes all-in and no further betting is possible among the remaining active players, the all-in player's hole cards SHALL be revealed. + +#### Scenario: All-in with no further bets possible +- **WHEN** a player is `all-in` and all other active players have matched their bet (no more raises or calls possible) +- **THEN** the all-in player's hole cards become visible + +### Requirement: Folded player cards remain hidden +Players who have folded SHALL never have their hole cards revealed, regardless of game state. + +#### Scenario: Folded player at showdown +- **WHEN** a player has `status === 'folded'` and the hand reaches showdown +- **THEN** the folded player's cards remain face-down (mucked) diff --git a/openspec/specs/decision-timer/spec.md b/openspec/specs/decision-timer/spec.md index 7191372..b28700b 100644 --- a/openspec/specs/decision-timer/spec.md +++ b/openspec/specs/decision-timer/spec.md @@ -48,3 +48,14 @@ The system SHALL record the decision time (in seconds) for every action taken by #### Scenario: Slow hesitation is recorded - **WHEN** Bot #5 takes 8.2 seconds to decide before folding - **THEN** the action history records decision_time: 8.2 for that fold + +### Requirement: Timer decrements correctly without resetting +The decision timer SHALL count down from the configured duration to zero, decrementing exactly once per second, without being reset by reactive effect re-runs. + +#### Scenario: Normal countdown +- **WHEN** a timer is activated with `duration` of 10 seconds +- **THEN** the displayed remaining time follows the sequence: 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0 — each value displayed for approximately one second + +#### Scenario: Timer does not reset mid-countdown +- **WHEN** the timer is counting down and a reactive state change occurs in the component +- **THEN** the countdown continues without being interrupted or reset to the initial duration diff --git a/openspec/specs/game-config-persistence/spec.md b/openspec/specs/game-config-persistence/spec.md new file mode 100644 index 0000000..deaa20b --- /dev/null +++ b/openspec/specs/game-config-persistence/spec.md @@ -0,0 +1,24 @@ +# Game Config Persistence + +## Purpose +Ensures table setup configuration is persisted and restored when the game page loads, so user preferences from `/setup` are applied to the game session. + +## Requirements + +### Requirement: Game reads setup config from sessionStorage +When the game page loads, it SHALL read the configuration stored in `sessionStorage` under the key `poker-config` and apply those settings to initialize the game state. + +#### Scenario: Config exists in sessionStorage +- **WHEN** a user navigates from `/setup` after configuring their table +- **THEN** the game page reads `sessionStorage.getItem('poker-config')` and uses the parsed values to create the initial game state + +#### Scenario: No config exists (first visit) +- **WHEN** a user navigates directly to `/` without visiting `/setup` +- **THEN** the game falls back to the default `trainingMix` preset + +### Requirement: All setup parameters are applied +The parsed configuration SHALL include values for `numPlayers`, `startingStack`, `smallBlind`, `bigBlind`, `infoLevel`, `feedbackLevel`, `timerEnabled`, `timerDuration`, `humanTimerMode`, and `humanTimerDuration`. + +#### Scenario: Custom settings are preserved +- **WHEN** a user sets small blind to 50, big blind to 100, and starting stack to 5000 +- **THEN** the initialized game state reflects those exact values in `smallBlind`, `bigBlind`, and player chip counts diff --git a/src/lib/components/DecisionTimer.svelte b/src/lib/components/DecisionTimer.svelte index 88ff1d6..b3cee74 100644 --- a/src/lib/components/DecisionTimer.svelte +++ b/src/lib/components/DecisionTimer.svelte @@ -17,6 +17,7 @@ let remaining = $state(0); let intervalId: ReturnType | null = null; + let tick = $state(0); const percent = $derived(duration > 0 ? (remaining / duration) * 100 : 0); const warning = $derived(remaining <= 3 && active); @@ -26,18 +27,15 @@ remaining = duration; intervalId = setInterval(() => { remaining--; + tick++; if (remaining <= 0) { - if (intervalId) { - clearInterval(intervalId); - intervalId = null; - } + if (intervalId) clearInterval(intervalId); onTimeout?.(); } }, 1000); } else { if (intervalId) { clearInterval(intervalId); - intervalId = null; } remaining = 0; } @@ -45,7 +43,6 @@ return () => { if (intervalId) { clearInterval(intervalId); - intervalId = null; } }; }); diff --git a/src/lib/components/PlayerSeat.svelte b/src/lib/components/PlayerSeat.svelte index b6ee370..1238f25 100644 --- a/src/lib/components/PlayerSeat.svelte +++ b/src/lib/components/PlayerSeat.svelte @@ -2,13 +2,14 @@ import Card from './Card.svelte'; import type { PlayerSeat as PlayerSeatType } from '$lib/types/player'; - let { player, isCurrentTurn = false, isHuman = false }: { + let { player, isCurrentTurn = false, isHuman = false, revealCards = false }: { player: PlayerSeatType; isCurrentTurn?: boolean; isHuman?: boolean; + revealCards?: boolean; } = $props(); - const showCards = $derived(isHuman || player.holeCards.length === 0); + const showCards = $derived(isHuman || revealCards || player.holeCards.length === 0);
diff --git a/src/lib/components/PokerTable.svelte b/src/lib/components/PokerTable.svelte index 81191c9..451a1b7 100644 --- a/src/lib/components/PokerTable.svelte +++ b/src/lib/components/PokerTable.svelte @@ -3,11 +3,26 @@ import PlayerSeat from './PlayerSeat.svelte'; import OpponentInfo from './OpponentInfo.svelte'; import type { GameState } from '$lib/types/game-state'; + import type { PlayerSeat as PlayerSeatType } from '$lib/types/player'; let { gameState }: { gameState: GameState; } = $props(); + function shouldRevealCards(player: PlayerSeatType): boolean { + if (player.status === 'folded') return false; + if (gameState.bettingRound === 'showdown') return true; + if (player.status === 'all-in') { + const nonFoldedOpponents = gameState.players.filter( + p => p.id !== player.id && p.status !== 'folded' + ); + const activeOpponents = nonFoldedOpponents.filter(p => p.status === 'active'); + if (activeOpponents.length === 0) return true; + if (nonFoldedOpponents.length <= 1) return true; + } + return false; + } + const seatPositions = [ { label: 'seat-6', row: 1, col: 3 }, { label: 'seat-5', row: 2, col: 1 }, @@ -36,7 +51,7 @@
Pot: ${gameState.pot}
- {#each seatPositions as pos, i} + {#each seatPositions as pos, i (pos.label)} {@const player = getPlayerAtPosition(i)} {#if player}
@@ -44,6 +59,7 @@ {player} isCurrentTurn={gameState.currentTurn === i} isHuman={i === 0} + revealCards={shouldRevealCards(player)} /> {#if gameState.dealerPosition === i}
D
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 0f0705c..6afc001 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -17,25 +17,59 @@ import { generatePostHandAnalysis, detectReadConfirmation } from '$lib/game/teaching-coach'; import { applyPreset, PRESETS } from '$lib/game/table-presets'; - import type { GameState } from '$lib/types/game-state'; + import type { GameState, InfoLevel, FeedbackLevel } from '$lib/types/game-state'; import type { PostHandAnalysis as PostHandAnalysisType } from '$lib/game/teaching-coach'; import type { BotArchetype } from '$lib/types/bot-archetype'; - import type { TableSetupConfig } from '$lib/game/table-presets'; + import type { TablePreset, TableSetupConfig } from '$lib/game/table-presets'; - // Initialize with training mix preset - const preset = applyPreset('trainingMix'); - let baseState = createInitialState(preset.numPlayers, preset.startingStack, { - infoLevel: preset.infoLevel, - feedbackLevel: preset.feedbackLevel, - timerEnabled: preset.timerEnabled, - timerDuration: preset.timerDuration, - humanTimerMode: preset.humanTimerMode, - humanTimerDuration: preset.humanTimerDuration + // Load config from sessionStorage or fall back to trainingMix preset + let loadedPreset: TablePreset = 'trainingMix'; + let loadedNumPlayers = PRESETS.trainingMix.numPlayers; + let loadedStartingStack = PRESETS.trainingMix.startingStack; + let loadedSmallBlind = PRESETS.trainingMix.smallBlind; + let loadedBigBlind = PRESETS.trainingMix.bigBlind; + let loadedInfoLevel: InfoLevel = PRESETS.trainingMix.infoLevel; + let loadedFeedbackLevel: FeedbackLevel = PRESETS.trainingMix.feedbackLevel; + let loadedTimerEnabled = PRESETS.trainingMix.timerEnabled; + let loadedTimerDuration = PRESETS.trainingMix.timerDuration; + let loadedHumanTimerMode: 'same' | 'noLimit' | 'custom' = PRESETS.trainingMix.humanTimerMode; + let loadedHumanTimerDuration = PRESETS.trainingMix.humanTimerDuration; + + try { + const stored = sessionStorage.getItem('poker-config'); + if (stored) { + const config = JSON.parse(stored); + loadedPreset = config.preset || 'trainingMix'; + loadedNumPlayers = config.numPlayers ?? loadedNumPlayers; + loadedStartingStack = config.startingStack ?? loadedStartingStack; + loadedSmallBlind = config.smallBlind ?? loadedSmallBlind; + loadedBigBlind = config.bigBlind ?? loadedBigBlind; + loadedInfoLevel = config.infoLevel ?? loadedInfoLevel; + loadedFeedbackLevel = config.feedbackLevel ?? loadedFeedbackLevel; + loadedTimerEnabled = config.timerEnabled ?? loadedTimerEnabled; + loadedTimerDuration = config.timerDuration ?? loadedTimerDuration; + loadedHumanTimerMode = config.humanTimerMode ?? loadedHumanTimerMode; + loadedHumanTimerDuration = config.humanTimerDuration ?? loadedHumanTimerDuration; + } + } catch { + // Parsing failed, use defaults + } + + const preset = applyPreset(loadedPreset, loadedNumPlayers); + let baseState = createInitialState(loadedNumPlayers, loadedStartingStack, { + infoLevel: loadedInfoLevel, + feedbackLevel: loadedFeedbackLevel, + timerEnabled: loadedTimerEnabled, + timerDuration: loadedTimerDuration, + humanTimerMode: loadedHumanTimerMode, + humanTimerDuration: loadedHumanTimerDuration }); + baseState.smallBlind = loadedSmallBlind; + baseState.bigBlind = loadedBigBlind; // Apply bot personalities from preset - const seatConfigs = PRESETS.trainingMix.seatConfigs; - for (let i = 0; i < preset.numPlayers - 1 && i < seatConfigs.length; i++) { + const seatConfigs = PRESETS[loadedPreset].seatConfigs; + for (let i = 0; i < loadedNumPlayers - 1 && i < seatConfigs.length; i++) { baseState.players[i + 1]!.personality = seatConfigs[i].archetype; baseState.players[i + 1]!.skillLevel = seatConfigs[i].skillLevel; } @@ -317,10 +351,10 @@ - {#if aiActing && timerActive} + {#if gameState.tableConfig.timerEnabled}