fix: resolve settings persistence, timer reset, and card reveal bugs

This commit is contained in:
Veit Fränzer 2026-05-17 23:25:45 +02:00
parent 422fa5b3ab
commit c7542679e7
14 changed files with 265 additions and 24 deletions

View File

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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -48,3 +48,14 @@ The system SHALL record the decision time (in seconds) for every action taken by
#### Scenario: Slow hesitation is recorded #### Scenario: Slow hesitation is recorded
- **WHEN** Bot #5 takes 8.2 seconds to decide before folding - **WHEN** Bot #5 takes 8.2 seconds to decide before folding
- **THEN** the action history records decision_time: 8.2 for that fold - **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

View File

@ -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

View File

@ -17,6 +17,7 @@
let remaining = $state(0); let remaining = $state(0);
let intervalId: ReturnType<typeof setInterval> | null = null; let intervalId: ReturnType<typeof setInterval> | null = null;
let tick = $state(0);
const percent = $derived(duration > 0 ? (remaining / duration) * 100 : 0); const percent = $derived(duration > 0 ? (remaining / duration) * 100 : 0);
const warning = $derived(remaining <= 3 && active); const warning = $derived(remaining <= 3 && active);
@ -26,18 +27,15 @@
remaining = duration; remaining = duration;
intervalId = setInterval(() => { intervalId = setInterval(() => {
remaining--; remaining--;
tick++;
if (remaining <= 0) { if (remaining <= 0) {
if (intervalId) { if (intervalId) clearInterval(intervalId);
clearInterval(intervalId);
intervalId = null;
}
onTimeout?.(); onTimeout?.();
} }
}, 1000); }, 1000);
} else { } else {
if (intervalId) { if (intervalId) {
clearInterval(intervalId); clearInterval(intervalId);
intervalId = null;
} }
remaining = 0; remaining = 0;
} }
@ -45,7 +43,6 @@
return () => { return () => {
if (intervalId) { if (intervalId) {
clearInterval(intervalId); clearInterval(intervalId);
intervalId = null;
} }
}; };
}); });

View File

@ -2,13 +2,14 @@
import Card from './Card.svelte'; import Card from './Card.svelte';
import type { PlayerSeat as PlayerSeatType } from '$lib/types/player'; 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; player: PlayerSeatType;
isCurrentTurn?: boolean; isCurrentTurn?: boolean;
isHuman?: boolean; isHuman?: boolean;
revealCards?: boolean;
} = $props(); } = $props();
const showCards = $derived(isHuman || player.holeCards.length === 0); const showCards = $derived(isHuman || revealCards || player.holeCards.length === 0);
</script> </script>
<div class="seat" class:active={isCurrentTurn} class:folded={player.status === 'folded'}> <div class="seat" class:active={isCurrentTurn} class:folded={player.status === 'folded'}>

View File

@ -3,11 +3,26 @@
import PlayerSeat from './PlayerSeat.svelte'; import PlayerSeat from './PlayerSeat.svelte';
import OpponentInfo from './OpponentInfo.svelte'; import OpponentInfo from './OpponentInfo.svelte';
import type { GameState } from '$lib/types/game-state'; import type { GameState } from '$lib/types/game-state';
import type { PlayerSeat as PlayerSeatType } from '$lib/types/player';
let { gameState }: { let { gameState }: {
gameState: GameState; gameState: GameState;
} = $props(); } = $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 = [ const seatPositions = [
{ label: 'seat-6', row: 1, col: 3 }, { label: 'seat-6', row: 1, col: 3 },
{ label: 'seat-5', row: 2, col: 1 }, { label: 'seat-5', row: 2, col: 1 },
@ -36,7 +51,7 @@
<div class="pot-display">Pot: ${gameState.pot}</div> <div class="pot-display">Pot: ${gameState.pot}</div>
</div> </div>
{#each seatPositions as pos, i} {#each seatPositions as pos, i (pos.label)}
{@const player = getPlayerAtPosition(i)} {@const player = getPlayerAtPosition(i)}
{#if player} {#if player}
<div class="seat-wrapper" style="grid-row: {pos.row}; grid-column: {pos.col};"> <div class="seat-wrapper" style="grid-row: {pos.row}; grid-column: {pos.col};">
@ -44,6 +59,7 @@
{player} {player}
isCurrentTurn={gameState.currentTurn === i} isCurrentTurn={gameState.currentTurn === i}
isHuman={i === 0} isHuman={i === 0}
revealCards={shouldRevealCards(player)}
/> />
{#if gameState.dealerPosition === i} {#if gameState.dealerPosition === i}
<div class="dealer-button" aria-label="Dealer">D</div> <div class="dealer-button" aria-label="Dealer">D</div>

View File

@ -17,25 +17,59 @@
import { generatePostHandAnalysis, detectReadConfirmation } from '$lib/game/teaching-coach'; import { generatePostHandAnalysis, detectReadConfirmation } from '$lib/game/teaching-coach';
import { applyPreset, PRESETS } from '$lib/game/table-presets'; 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 { PostHandAnalysis as PostHandAnalysisType } from '$lib/game/teaching-coach';
import type { BotArchetype } from '$lib/types/bot-archetype'; 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 // Load config from sessionStorage or fall back to trainingMix preset
const preset = applyPreset('trainingMix'); let loadedPreset: TablePreset = 'trainingMix';
let baseState = createInitialState(preset.numPlayers, preset.startingStack, { let loadedNumPlayers = PRESETS.trainingMix.numPlayers;
infoLevel: preset.infoLevel, let loadedStartingStack = PRESETS.trainingMix.startingStack;
feedbackLevel: preset.feedbackLevel, let loadedSmallBlind = PRESETS.trainingMix.smallBlind;
timerEnabled: preset.timerEnabled, let loadedBigBlind = PRESETS.trainingMix.bigBlind;
timerDuration: preset.timerDuration, let loadedInfoLevel: InfoLevel = PRESETS.trainingMix.infoLevel;
humanTimerMode: preset.humanTimerMode, let loadedFeedbackLevel: FeedbackLevel = PRESETS.trainingMix.feedbackLevel;
humanTimerDuration: preset.humanTimerDuration 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 // Apply bot personalities from preset
const seatConfigs = PRESETS.trainingMix.seatConfigs; const seatConfigs = PRESETS[loadedPreset].seatConfigs;
for (let i = 0; i < preset.numPlayers - 1 && i < seatConfigs.length; i++) { for (let i = 0; i < loadedNumPlayers - 1 && i < seatConfigs.length; i++) {
baseState.players[i + 1]!.personality = seatConfigs[i].archetype; baseState.players[i + 1]!.personality = seatConfigs[i].archetype;
baseState.players[i + 1]!.skillLevel = seatConfigs[i].skillLevel; baseState.players[i + 1]!.skillLevel = seatConfigs[i].skillLevel;
} }
@ -317,10 +351,10 @@
<PokerTable {gameState} /> <PokerTable {gameState} />
{#if aiActing && timerActive} {#if gameState.tableConfig.timerEnabled}
<DecisionTimer <DecisionTimer
duration={gameState.tableConfig.timerDuration} duration={gameState.tableConfig.timerDuration}
active={timerActive} active={aiActing && timerActive}
playerName={currentTimerPlayer} playerName={currentTimerPlayer}
archetype={currentTimerArchetype || undefined} archetype={currentTimerArchetype || undefined}
onTimeout={handleTimerTimeout} onTimeout={handleTimerTimeout}