v3.0.0: V3 features, server refactoring, and documentation overhaul

- Extract WebSocket handlers from main.py into handlers.py
- Add V3 feature docs (dealer rotation, dealing animation, round end reveal,
  column pair celebration, final turn urgency, opponent thinking, score tallying,
  card hover/selection, knock early drama, column pair indicator, swap animation
  improvements, draw source distinction, card value tooltips, active rules context,
  discard pile history, realistic card sounds)
- Add V3 refactoring docs (ai.py, main.py/game.py, misc improvements)
- Add installation guide with Docker, systemd, and nginx setup
- Add helper scripts (install.sh, dev-server.sh, docker-build.sh)
- Add animation flow diagrams documentation
- Add test files for handlers, rooms, and V3 features
- Add e2e test specs for V3 features
- Update README with complete project structure and current tech stack
- Update CLAUDE.md with full architecture tree and server layer descriptions
- Update .env.example to reflect PostgreSQL (remove SQLite references)
- Update .gitignore to exclude virtualenv files, .claude/, and .db files
- Remove tracked virtualenv files (bin/, lib64, pyvenv.cfg)
- Remove obsolete game_log.py (SQLite) and games.db

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-02-14 10:03:45 -05:00
parent 13ab5b9017
commit 9fc6b83bba
60 changed files with 11791 additions and 1639 deletions

View File

@@ -0,0 +1,290 @@
# Golf Card Game - V3 Master Plan
## Overview
Transform the current Golf card game into a more natural, physical-feeling experience through enhanced animations, visual feedback, and gameplay flow improvements. The goal is to make the digital game feel as satisfying as playing with real cards.
**Theme:** "Make it feel like a real card game"
---
## Document Structure (VDD)
This plan is split into independent vertical slices ordered by priority and impact. Each document is self-contained and can be worked on by a separate agent.
| Document | Scope | Priority | Effort | Dependencies |
|----------|-------|----------|--------|--------------|
| `V3_01_DEALER_ROTATION.md` | Rotate dealer/first player each round | High | Low | None (server change) |
| `V3_02_DEALING_ANIMATION.md` | Animated card dealing at round start | High | Medium | 01 |
| `V3_03_ROUND_END_REVEAL.md` | Dramatic sequential card reveal | High | Medium | None |
| `V3_04_COLUMN_PAIR_CELEBRATION.md` | Visual feedback for matching pairs | High | Low | None |
| `V3_05_FINAL_TURN_URGENCY.md` | Enhanced final turn visual tension | High | Low | None |
| `V3_06_OPPONENT_THINKING.md` | Visible opponent consideration phase | Medium | Low | None |
| `V3_07_SCORE_TALLYING.md` | Animated score counting | Medium | Medium | 03 |
| `V3_08_CARD_HOVER_SELECTION.md` | Enhanced card selection preview | Medium | Low | None |
| `V3_09_KNOCK_EARLY_DRAMA.md` | Dramatic knock early presentation | Medium | Low | None |
| `V3_10_COLUMN_PAIR_INDICATOR.md` | Visual connector for paired columns | Medium | Low | 04 |
| `V3_11_SWAP_ANIMATION_IMPROVEMENTS.md` | More physical swap motion | Medium | Medium | None |
| `V3_12_DRAW_SOURCE_DISTINCTION.md` | Visual distinction deck vs discard draw | Low | Low | None |
| `V3_13_CARD_VALUE_TOOLTIPS.md` | Long-press card value display | Low | Medium | None |
| `V3_14_ACTIVE_RULES_CONTEXT.md` | Contextual rule highlighting | Low | Low | None |
| `V3_15_DISCARD_PILE_HISTORY.md` | Show recent discards fanned | Low | Medium | None |
| `V3_16_REALISTIC_CARD_SOUNDS.md` | Improved audio feedback | Nice | Medium | None |
---
## Current State (V2)
```
Client (Vanilla JS)
├── app.js - Main game logic (2500+ lines)
├── card-manager.js - DOM card element management (3D flip structure)
├── animation-queue.js - Sequential animation processing
├── card-animations.js - Unified anime.js animation system (replaces draw-animations.js)
├── state-differ.js - State change detection
├── timing-config.js - Centralized animation timing + anime.js easing config
├── anime.min.js - Anime.js library for all animations
└── style.css - Minimal CSS, mostly layout
```
**What works well:**
- **Unified anime.js system** - All card animations use `window.cardAnimations` (CardAnimations class)
- State diffing detects changes and triggers appropriate animations
- Animation queue ensures sequential, non-overlapping animations
- Centralized timing config with anime.js easing presets (`TIMING.anime.easing`)
- Sound effects via Web Audio API
- CardAnimations provides: draw, flip, swap, discard, ambient loops (turn pulse, CPU thinking)
- Opponent turn visibility with CPU action announcements
**Limitations:**
- Cards appear instantly at round start (no dealing animation)
- Round end reveals all cards simultaneously
- No visual celebration for column pairs
- Final turn phase lacks urgency/tension
- Swap animation uses crossfade rather than physical motion
- Limited feedback during card selection
- Discard pile shows only top card
---
## V3 Target Experience
### Physical Card Game Feel Checklist
| Aspect | Physical Game | Current Digital | V3 Target |
|--------|---------------|-----------------|-----------|
| **Dealer Rotation** | Deal passes clockwise each round | Always starts with host | Rotating dealer/first player |
| **Dealing** | Cards dealt one at a time | Cards appear instantly | Animated dealing sequence |
| **Drawing** | Card lifts, player considers | Card pops in | Source-appropriate pickup |
| **Swapping** | Old card slides out, new slides in | Teleport swap | Cross-over motion |
| **Pairing** | "Nice!" moment when match noticed | No feedback | Visual celebration |
| **Round End** | Dramatic reveal, one player at a time | All cards flip at once | Staggered reveal |
| **Scoring** | Count card by card | Score appears | Animated tally |
| **Final Turn** | Tension in the room | Badge shows | Visual urgency |
| **Sounds** | Shuffle, flip, slap | Synth beeps | Realistic card sounds |
---
## Tech Approach
### Animation Strategy
All **card animations** use the unified `CardAnimations` class (`card-animations.js`):
- **Anime.js timelines** for all card animations (flip, swap, draw, discard)
- **CardAnimations methods** - `animateDrawDeck()`, `animateFlip()`, `animateSwap()`, etc.
- **Ambient loops** - `startTurnPulse()`, `startCpuThinking()`, `startInitialFlipPulse()`
- **One-shot effects** - `pulseDiscard()`, `pulseSwap()`, `popIn()`
- **Animation queue** for sequencing multi-step animations
- **State differ** to trigger animations on state changes
**When to use CSS vs anime.js:**
- **Anime.js (CardAnimations)**: Card movements, flips, swaps, draws - anything involving card elements
- **CSS keyframes/transitions**: Simple UI feedback (button hover, badge entrance, status message fades) - non-card elements
**General rule:** If it moves a card, use anime.js. If it's UI chrome, CSS is fine.
### Timing Philosophy
From `timing-config.js`:
```javascript
// Current values - animations are smooth but quick
card: {
flip: 400, // Card flip duration
move: 400, // Card movement
},
pause: {
afterFlip: 0, // No pause - flow into next action
betweenAnimations: 0, // No gaps
},
// Anime.js easing presets
anime: {
easing: {
flip: 'easeInOutQuad',
move: 'easeOutCubic',
lift: 'easeOutQuad',
pulse: 'easeInOutSine',
},
loop: {
turnPulse: { duration: 2000 },
cpuThinking: { duration: 1500 },
initialFlipGlow: { duration: 1500 },
}
}
```
V3 will introduce **optional pauses for drama** without slowing normal gameplay:
- Quick pauses at key moments (pair formed, round end)
- Staggered timing for dealing/reveal (perceived faster than actual)
- User preference for animation speed (future consideration)
### Sound Strategy
Current sounds are oscillator-based (Web Audio API synthesis). V3 options:
1. **Enhanced synthesis** - More realistic waveforms, envelopes
2. **Audio sprites** - Short recordings of real card sounds
3. **Hybrid** - Synthesis for some, samples for others
Recommendation: Start with enhanced synthesis (no asset loading), consider audio sprites later.
---
## Phases & Milestones
### Phase 1: Core Feel (High Priority)
**Goal:** Make the game feel noticeably more physical
| Item | Description | Document |
|------|-------------|----------|
| Dealer rotation | First player rotates each round (like real cards) | 01 |
| Dealing animation | Cards dealt sequentially at round start | 02 |
| Round end reveal | Dramatic staggered flip at round end | 03 |
| Column pair celebration | Glow/pulse when pairs form | 04 |
| Final turn urgency | Visual tension enhancement | 05 |
### Phase 2: Turn Polish (Medium Priority)
**Goal:** Improve the feel of individual turns
| Item | Description | Document |
|------|-------------|----------|
| Opponent thinking | Visible consideration phase | 06 |
| Score tallying | Animated counting | 07 |
| Card hover/selection | Better swap preview | 08 |
| Knock early drama | Dramatic knock presentation | 09 |
| Column pair indicator | Visual pair connector | 10 |
| Swap improvements | Physical swap motion | 11 |
### Phase 3: Polish & Extras (Low Priority)
**Goal:** Nice-to-have improvements
| Item | Description | Document |
|------|-------------|----------|
| Draw distinction | Deck vs discard visual difference | 12 |
| Card value tooltips | Long-press to see points | 13 |
| Active rules context | Highlight relevant rules | 14 |
| Discard history | Show fanned recent cards | 15 |
| Realistic sounds | Better audio feedback | 16 |
---
## File Structure (Changes)
```
server/
├── game.py # Add dealer rotation logic (V3_01)
client/
├── app.js # Enhance existing methods
├── timing-config.js # Add new timing values + anime.js config
├── card-animations.js # Extend with new animation methods
├── animation-queue.js # Add new animation types
├── style.css # Minimal additions (mostly layout)
└── sounds/ # OPTIONAL: Audio sprites
├── shuffle.mp3
├── deal.mp3
└── flip.mp3
```
**Note:** All new animations should be added to `CardAnimations` class in `card-animations.js`. Do not add CSS keyframe animations for card movements.
---
## Acceptance Criteria (V3 Complete)
1. **Dealer rotates properly** - First player advances clockwise each round
2. **Dealing feels physical** - Cards dealt one by one with shuffle sound
3. **Round end is dramatic** - Staggered reveal with tension pause
4. **Pairs are satisfying** - Visual celebration when columns match
5. **Final turn has urgency** - Clear visual indication of tension
6. **Swaps look natural** - Cards appear to exchange positions
7. **No performance regression** - Animations run at 60fps on mobile
8. **Timing is tunable** - All values in timing-config.js
---
## Design Principles
### 1. Enhance, Don't Slow Down
Animations should make the game feel better without making it slower. Use perceived timing tricks:
- Start next animation before previous fully completes
- Stagger start times, not end times
- Quick movements with slight ease-out
### 2. Respect the Player's Time
- First-time experience: full animations
- Repeat plays: consider faster mode option
- Never block input unnecessarily
### 3. Clear Visual Hierarchy
- Active player highlighted
- Current action obvious
- Next expected action hinted
### 4. Consistent Feedback
- Same action = same animation
- Similar duration for similar actions
- Predictable timing helps player flow
### 5. Graceful Degradation
- Animations enhance but aren't required
- State updates should work without animations
- Handle animation interruption gracefully
---
## How to Use These Documents
Each `V3_XX_*.md` document is designed to be:
1. **Self-contained** - Has all context needed to implement that feature
2. **Agent-ready** - Can be given to a Claude agent as the primary context
3. **Testable** - Includes visual verification criteria
4. **Incremental** - Can be implemented and shipped independently
**Workflow:**
1. Pick a document based on current priority
2. Start a new Claude session with that document as context
3. Implement the feature
4. Verify against acceptance criteria
5. Test on mobile and desktop
6. Merge and move to next
---
## Notes for Implementation
- **Don't break existing functionality** - All current animations must still work
- **Use existing infrastructure** - Build on animation-queue, timing-config
- **Test on mobile** - Animations must run smoothly on phones
- **Consider reduced motion** - Respect `prefers-reduced-motion` media query
- **Keep it vanilla** - No new frameworks, Anime.js is sufficient
---
## Success Metrics
After V3 implementation, the game should:
- Feel noticeably more satisfying to play
- Get positive feedback on "polish" or "feel"
- Not feel slower despite more animations
- Work smoothly on all devices
- Be easy to tune timing via config

View File

@@ -0,0 +1,286 @@
# V3-01: Dealer/Starting Player Rotation
## Overview
In physical card games, the deal rotates clockwise after each hand. The player who deals also typically plays last (or the player to their left plays first). Currently, our game always starts with the host/first player each round.
**Dependencies:** None (server-side foundation)
**Dependents:** V3_02 (Dealing Animation needs to know who is dealing)
---
## Goals
1. Track the current dealer position across rounds
2. Rotate dealer clockwise after each round
3. First player to act is to the left of the dealer (next in order)
4. Communicate dealer position to clients
5. Visual indicator of current dealer (client-side, prep for V3_02)
---
## Current State
From `server/game.py`, round start logic:
```python
def start_next_round(self):
"""Start the next round."""
self.current_round += 1
# ... deal cards ...
# Current player is always index 0 (host/first joiner)
self.current_player_idx = 0
```
The `player_order` list is set once at game start and never changes. The first player is always `player_order[0]`.
---
## Design
### Server Changes
#### New State Fields
```python
# In Game class __init__
self.dealer_idx = 0 # Index into player_order of current dealer
```
#### Round Start Logic
```python
def start_next_round(self):
"""Start the next round."""
self.current_round += 1
# Rotate dealer clockwise (next player in order)
if self.current_round > 1:
self.dealer_idx = (self.dealer_idx + 1) % len(self.player_order)
# First player is to the LEFT of dealer (next after dealer)
self.current_player_idx = (self.dealer_idx + 1) % len(self.player_order)
# ... rest of dealing logic ...
```
#### Game State Response
Add dealer info to the game state sent to clients:
```python
def get_state(self, for_player_id: str) -> dict:
return {
# ... existing fields ...
"dealer_id": self.player_order[self.dealer_idx] if self.player_order else None,
"dealer_idx": self.dealer_idx,
# current_player_id already exists
}
```
### Client Changes
#### State Handling
In `app.js`, the `gameState` will now include:
- `dealer_id` - The player ID of the current dealer
- `dealer_idx` - Index for ordering
#### Visual Indicator
Add a dealer chip/badge to the current dealer's area:
```javascript
// In renderGame() or opponent rendering
const isDealer = player.id === this.gameState.dealer_id;
if (isDealer) {
div.classList.add('is-dealer');
// Add dealer chip element
}
```
#### CSS
```css
/* Dealer indicator */
.is-dealer::before {
content: "D";
position: absolute;
top: -8px;
left: -8px;
width: 20px;
height: 20px;
background: #f4a460;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
color: #1a1a2e;
border: 2px solid #fff;
z-index: 10;
}
/* Or use a chip emoji/icon */
.dealer-chip {
position: absolute;
top: -10px;
right: -10px;
font-size: 1.2em;
}
```
---
## Edge Cases
### Player Leaves Mid-Game
If the current dealer leaves:
- Dealer position should stay at the same index
- If that index is now out of bounds, wrap to 0
- The show must go on
```python
def remove_player(self, player_id: str):
# ... existing removal logic ...
# Adjust dealer_idx if needed
if self.dealer_idx >= len(self.player_order):
self.dealer_idx = 0
```
### 2-Player Game
With 2 players, dealer alternates each round:
- Round 1: Player A deals, Player B plays first
- Round 2: Player B deals, Player A plays first
- This works naturally with the modulo logic
### Game Start (Round 1)
For round 1:
- Dealer is the host (player_order[0])
- First player is player_order[1] (or player_order[0] in solo/test)
Option: Could randomize initial dealer, but host-as-first-dealer is traditional.
---
## Test Cases
```python
# server/tests/test_dealer_rotation.py
def test_dealer_starts_as_host():
"""First round dealer is the host (first player)."""
game = create_game_with_players(["Alice", "Bob", "Carol"])
game.start_game()
assert game.dealer_idx == 0
assert game.get_dealer_id() == "Alice"
# First player is to dealer's left
assert game.current_player_idx == 1
assert game.get_current_player_id() == "Bob"
def test_dealer_rotates_each_round():
"""Dealer advances clockwise after each round."""
game = create_game_with_players(["Alice", "Bob", "Carol"])
game.start_game()
# Round 1: Alice deals, Bob plays first
assert game.dealer_idx == 0
complete_round(game)
game.start_next_round()
# Round 2: Bob deals, Carol plays first
assert game.dealer_idx == 1
assert game.current_player_idx == 2
complete_round(game)
game.start_next_round()
# Round 3: Carol deals, Alice plays first
assert game.dealer_idx == 2
assert game.current_player_idx == 0
def test_dealer_wraps_around():
"""Dealer wraps to first player after last player deals."""
game = create_game_with_players(["Alice", "Bob"])
game.start_game()
# Round 1: Alice deals
assert game.dealer_idx == 0
complete_round(game)
game.start_next_round()
# Round 2: Bob deals
assert game.dealer_idx == 1
complete_round(game)
game.start_next_round()
# Round 3: Back to Alice
assert game.dealer_idx == 0
def test_dealer_adjustment_on_player_leave():
"""Dealer index adjusts when players leave."""
game = create_game_with_players(["Alice", "Bob", "Carol"])
game.start_game()
complete_round(game)
game.start_next_round()
# Bob is now dealer (idx 1)
game.remove_player("Carol") # Remove last player
# Dealer idx should still be valid
assert game.dealer_idx == 1
assert game.dealer_idx < len(game.player_order)
def test_state_includes_dealer_info():
"""Game state includes dealer information."""
game = create_game_with_players(["Alice", "Bob"])
game.start_game()
state = game.get_state("Alice")
assert "dealer_id" in state
assert state["dealer_id"] == "Alice"
```
---
## Implementation Order
1. Add `dealer_idx` field to Game class
2. Modify `start_game()` to set initial dealer
3. Modify `start_next_round()` to rotate dealer
4. Modify `get_state()` to include dealer info
5. Handle edge case: player leaves
6. Add tests for dealer rotation
7. Client: Add dealer visual indicator
8. Client: Style the dealer chip/badge
---
## Acceptance Criteria
- [ ] Round 1 dealer is the host (first player in order)
- [ ] Dealer rotates clockwise after each round
- [ ] First player to act is always left of dealer
- [ ] Dealer info included in game state sent to clients
- [ ] Dealer position survives player departure
- [ ] Visual indicator shows current dealer
- [ ] All existing tests still pass
---
## Notes for Agent
- The `player_order` list is established at game start and defines clockwise order
- Keep backward compatibility - games in progress shouldn't break
- The dealer indicator is prep work for V3_02 (dealing animation)
- Consider: Should dealer deal to themselves last? (Traditional, but not gameplay-affecting)
- The visual dealer chip will become important when dealing animation shows cards coming FROM the dealer

View File

@@ -0,0 +1,406 @@
# V3-02: Dealing Animation
## Overview
In physical card games, cards are dealt one at a time from the dealer to each player in turn. Currently, cards appear instantly when a round starts. This feature adds an animated dealing sequence that mimics the physical ritual.
**Dependencies:** V3_01 (Dealer Rotation - need to know who is dealing)
**Dependents:** None
---
## Goals
1. Animate cards being dealt from a central deck position
2. Deal one card at a time to each player in clockwise order
3. Play shuffle sound before dealing begins
4. Play card sound as each card lands
5. Maintain quick perceived pace (stagger start times, not end times)
6. Show dealing from dealer's position (or center as fallback)
---
## Current State
From `app.js`, when `game_started` or `round_started` message received:
```javascript
case 'game_started':
case 'round_started':
this.gameState = data.game_state;
this.playSound('shuffle');
this.showGameScreen();
this.renderGame(); // Cards appear instantly
break;
```
Cards are rendered immediately via `renderGame()` which populates the card grids.
---
## Design
### Animation Sequence
```
1. Shuffle sound plays
2. Brief pause (300ms) - deck appears to shuffle
3. Deal round 1: One card to each player (clockwise from dealer's left)
4. Deal round 2-6: Repeat until all 6 cards dealt to each player
5. Flip discard pile top card
6. Initial flip phase begins (or game starts if initial_flips=0)
```
### Visual Flow
```
[Deck]
|
┌─────────────────┼─────────────────┐
│ │ │
▼ ▼ ▼
[Opponent 1] [Opponent 2] [Opponent 3]
|
[Local Player]
```
Cards fly from deck position to each player's card slot, face-down.
### Timing
```javascript
// New timing values in timing-config.js
dealing: {
shufflePause: 400, // Pause after shuffle sound
cardFlyTime: 150, // Time for card to fly to destination
cardStagger: 80, // Delay between cards (overlap for speed)
roundPause: 50, // Brief pause between deal rounds
discardFlipDelay: 200, // Pause before flipping discard
}
```
Total time for 4-player game (24 cards):
- 400ms shuffle + 24 cards × 80ms stagger + 200ms discard = ~2.5 seconds
This feels unhurried but not slow.
### Implementation Approach
#### Option A: Overlay Animation (Recommended)
Create temporary card elements that animate from deck to destinations, then remove them and show the real cards.
Pros:
- Clean separation from game state
- Easy to skip/interrupt
- No complex state management
Cons:
- Brief flash when swapping to real cards (mitigate with timing)
#### Option B: Animate Real Cards
Start with cards at deck position, animate to final positions.
Pros:
- No element swap
- More "real"
Cons:
- Complex coordination with renderGame()
- State management issues
**Recommendation:** Option A - overlay animation
---
## Implementation
### Add to `card-animations.js`
Add the dealing animation as a method on the existing `CardAnimations` class:
```javascript
// Add to CardAnimations class in card-animations.js
/**
* Run the dealing animation using anime.js timelines
* @param {Object} gameState - The game state with players and their cards
* @param {Function} getPlayerRect - Function(playerId, cardIdx) => {left, top, width, height}
* @param {Function} onComplete - Callback when animation completes
*/
async animateDealing(gameState, getPlayerRect, onComplete) {
const T = window.TIMING?.dealing || {
shufflePause: 400,
cardFlyTime: 150,
cardStagger: 80,
roundPause: 50,
discardFlipDelay: 200,
};
const deckRect = this.getDeckRect();
const discardRect = this.getDiscardRect();
if (!deckRect) {
if (onComplete) onComplete();
return;
}
// Get player order starting from dealer's left
const dealerIdx = gameState.dealer_idx || 0;
const playerOrder = this.getDealOrder(gameState.players, dealerIdx);
// Create container for animation cards
const container = document.createElement('div');
container.className = 'deal-animation-container';
container.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1000;';
document.body.appendChild(container);
// Shuffle sound and pause
this.playSound('shuffle');
await this.delay(T.shufflePause);
// Deal 6 rounds of cards using anime.js
const allCards = [];
for (let cardIdx = 0; cardIdx < 6; cardIdx++) {
for (const player of playerOrder) {
const targetRect = getPlayerRect(player.id, cardIdx);
if (!targetRect) continue;
// Create card at deck position
const deckColor = this.getDeckColor();
const card = this.createAnimCard(deckRect, true, deckColor);
card.classList.add('deal-anim-card');
container.appendChild(card);
allCards.push({ card, targetRect });
// Animate using anime.js
anime({
targets: card,
left: targetRect.left,
top: targetRect.top,
width: targetRect.width,
height: targetRect.height,
duration: T.cardFlyTime,
easing: this.getEasing('move'),
});
this.playSound('card');
await this.delay(T.cardStagger);
}
// Brief pause between rounds
if (cardIdx < 5) {
await this.delay(T.roundPause);
}
}
// Wait for last cards to land
await this.delay(T.cardFlyTime);
// Flip discard pile card
if (discardRect && gameState.discard_top) {
await this.delay(T.discardFlipDelay);
this.playSound('flip');
}
// Clean up
container.remove();
if (onComplete) onComplete();
}
getDealOrder(players, dealerIdx) {
// Rotate so dealing starts to dealer's left
const order = [...players];
const startIdx = (dealerIdx + 1) % order.length;
return [...order.slice(startIdx), ...order.slice(0, startIdx)];
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
```
### CSS for Deal Animation
```css
/* In style.css - minimal, anime.js handles all animation */
/* Deal animation container */
.deal-animation-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 1000;
}
/* Deal cards inherit from .draw-anim-card (already exists in card-animations.js) */
.deal-anim-card {
/* Uses same structure as createAnimCard() */
}
```
### Integration in app.js
```javascript
// In handleMessage, game_started/round_started case:
case 'game_started':
case 'round_started':
this.clearNextHoleCountdown();
this.nextRoundBtn.classList.remove('waiting');
this.roundWinnerNames = new Set();
this.gameState = data.game_state;
this.previousState = JSON.parse(JSON.stringify(data.game_state));
this.locallyFlippedCards = new Set();
this.selectedCards = [];
this.animatingPositions = new Set();
this.opponentSwapAnimation = null;
this.showGameScreen();
// NEW: Run deal animation using CardAnimations
this.runDealAnimation(() => {
this.renderGame();
});
break;
// New method using CardAnimations
runDealAnimation(onComplete) {
// Hide cards initially
this.playerCards.style.visibility = 'hidden';
this.opponentsRow.style.visibility = 'hidden';
// Use the global cardAnimations instance
window.cardAnimations.animateDealing(
this.gameState,
(playerId, cardIdx) => this.getCardSlotRect(playerId, cardIdx),
() => {
// Show real cards
this.playerCards.style.visibility = 'visible';
this.opponentsRow.style.visibility = 'visible';
onComplete();
}
);
}
// Helper to get card slot position
getCardSlotRect(playerId, cardIdx) {
if (playerId === this.playerId) {
// Local player
const cards = this.playerCards.querySelectorAll('.card');
return cards[cardIdx]?.getBoundingClientRect();
} else {
// Opponent
const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area');
for (const area of opponentAreas) {
if (area.dataset.playerId === playerId) {
const cards = area.querySelectorAll('.card');
return cards[cardIdx]?.getBoundingClientRect();
}
}
}
return null;
}
```
---
## Timing Tuning
### Perceived Speed Tricks
1. **Overlap card flights** - Start next card before previous lands
2. **Ease-out timing** - Cards decelerate into position (feels snappier)
3. **Batch by round** - 6 deal rounds feels rhythmic
4. **Quick stagger** - 80ms between cards feels like rapid dealing
### Accessibility
```javascript
// Respect reduced motion preference
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
// Skip animation, just show cards
this.renderGame();
return;
}
```
---
## Edge Cases
### Animation Interrupted
If player disconnects or game state changes during dealing:
- Cancel animation
- Show cards immediately
- Continue with normal game flow
### Varying Player Counts
2-6 players supported:
- Fewer players = faster deal (fewer cards per round)
- 2 players: 12 cards total, ~1.5 seconds
- 6 players: 36 cards total, ~3.5 seconds
### Opponent Areas Not Ready
If opponent areas haven't rendered yet:
- Fall back to animating to center positions
- Or skip animation for that player
---
## Test Scenarios
1. **2-player game** - Dealing alternates correctly
2. **6-player game** - All players receive cards in order
3. **Quick tap through** - Animation can be interrupted
4. **Round 2+** - Dealing starts from correct dealer position
5. **Mobile** - Animation runs smoothly at 60fps
6. **Reduced motion** - Animation skipped appropriately
---
## Acceptance Criteria
- [ ] Cards animate from deck to player positions
- [ ] Deal order follows clockwise from dealer's left
- [ ] Shuffle sound plays before dealing
- [ ] Card sound plays as each card lands
- [ ] Animation completes in < 4 seconds for 6 players
- [ ] Real cards appear after animation (no flash)
- [ ] Reduced motion preference respected
- [ ] Works on mobile (60fps)
- [ ] Can be interrupted without breaking game
---
## Implementation Order
1. Add timing values to `timing-config.js`
2. Create `deal-animation.js` with DealAnimation class
3. Add CSS for deal animation cards
4. Add `data-player-id` to opponent areas for targeting
5. Add `getCardSlotRect()` helper method
6. Integrate animation in game_started/round_started handler
7. Test with various player counts
8. Add reduced motion support
9. Tune timing for best feel
---
## Notes for Agent
- Add `animateDealing()` as a method on the existing `CardAnimations` class
- Use `createAnimCard()` to create deal cards (already exists, handles 3D structure)
- Use anime.js for all card movements, not CSS transitions
- The existing `CardManager` handles persistent cards - don't modify it
- Timing values should all be in `timing-config.js` under `dealing` key
- Consider: Show dealer's hands actually dealing? (complex, skip for V3)
- The shuffle sound already exists - reuse it via `playSound('shuffle')`
- Cards should deal face-down (use `createAnimCard(rect, true, deckColor)`)

View File

@@ -0,0 +1,532 @@
# V3-03: Round End Dramatic Reveal
## Overview
When a round ends, all face-down cards must be revealed for scoring. In physical games, this is a dramatic moment - each player flips their hidden cards one at a time while others watch. Currently, all cards flip simultaneously which lacks drama.
**Dependencies:** None
**Dependents:** V3_07 (Score Tallying can follow the reveal)
---
## Goals
1. Reveal cards sequentially, one player at a time
2. Within each player, reveal cards with slight stagger
3. Pause briefly between players for dramatic effect
4. Start with the player who triggered final turn (the "knocker")
5. End with visible score tally moment
6. Play flip sounds for each reveal
---
## Current State
When round ends, the server sends a `round_over` message and clients receive a `game_state` update where all cards are now `face_up: true`. The state differ detects the changes but doesn't sequence the animations - they happen together.
From `showScoreboard()` in app.js:
```javascript
showScoreboard(scores, isFinal, rankings) {
// Cards are already revealed by state update
// Scoreboard appears immediately
}
```
---
## Design
### Reveal Sequence
```
1. Round ends - "Hole Complete!" message
2. VOLUNTARY FLIP WINDOW (4 seconds):
- Players can tap their own face-down cards to peek/flip
- Countdown timer shows remaining time
- "Tap to reveal your cards" prompt
3. AUTO-REVEAL (after timeout or all flipped):
- Knocker's cards reveal first (they went out)
- For each other player (clockwise from knocker):
a. Player area highlights
b. Face-down cards flip with stagger (100ms between)
c. Brief pause to see the reveal (400ms)
4. Score tallying animation (see V3_07)
5. Scoreboard appears
```
### Voluntary Flip Window
Before the dramatic reveal sequence, players get a chance to flip their own hidden cards:
- **Duration:** 4 seconds (configurable)
- **Purpose:** Let players see their own cards before everyone else does
- **UI:** Countdown timer, "Tap your cards to reveal" message
- **Skip:** If all players flip their cards, proceed immediately
### Visual Flow
```
Timeline:
0ms - Round ends, pause
500ms - Knocker highlight, first card flips
600ms - Knocker second card flips (if any)
700ms - Knocker third card flips (if any)
1100ms - Pause to see knocker's hand
1500ms - Player 2 highlight
1600ms - Player 2 cards flip...
...continue for all players...
Final - Scoreboard appears
```
### Timing Configuration
```javascript
// In timing-config.js
reveal: {
voluntaryWindow: 4000, // Time for players to flip their own cards
initialPause: 500, // Pause before auto-reveals start
cardStagger: 100, // Between cards in same hand
playerPause: 400, // Pause after each player's reveal
highlightDuration: 200, // Player area highlight fade-in
}
```
---
## Implementation
### Approach: Intercept State Update
Instead of letting `renderGame()` show all cards instantly, intercept the round_over state and run a reveal sequence.
```javascript
// In handleMessage, game_state case:
case 'game_state':
const oldState = this.gameState;
const newState = data.game_state;
// Check for round end transition
const roundJustEnded = oldState?.phase !== 'round_over' &&
newState.phase === 'round_over';
if (roundJustEnded) {
// Don't update state yet - run reveal animation first
this.runRoundEndReveal(oldState, newState, () => {
this.gameState = newState;
this.renderGame();
});
return;
}
// Normal state update
this.gameState = newState;
this.renderGame();
break;
```
### Voluntary Flip Window Implementation
```javascript
async runVoluntaryFlipWindow(oldState, newState) {
const T = window.TIMING?.reveal || {};
const windowDuration = T.voluntaryWindow || 4000;
// Find which of MY cards need flipping
const myOldCards = oldState?.players?.find(p => p.id === this.playerId)?.cards || [];
const myNewCards = newState?.players?.find(p => p.id === this.playerId)?.cards || [];
const myHiddenPositions = [];
for (let i = 0; i < 6; i++) {
if (!myOldCards[i]?.face_up && myNewCards[i]?.face_up) {
myHiddenPositions.push(i);
}
}
// If I have no hidden cards, skip window
if (myHiddenPositions.length === 0) {
return;
}
// Show prompt and countdown
this.showRevealPrompt(windowDuration);
// Enable clicking on my hidden cards
this.voluntaryFlipMode = true;
this.voluntaryFlipPositions = new Set(myHiddenPositions);
this.renderGame(); // Re-render to make cards clickable
// Wait for timeout or all cards flipped
return new Promise(resolve => {
const checkComplete = () => {
if (this.voluntaryFlipPositions.size === 0) {
this.hideRevealPrompt();
this.voluntaryFlipMode = false;
resolve();
}
};
// Set up interval to check completion
const checkInterval = setInterval(checkComplete, 100);
// Timeout after window duration
setTimeout(() => {
clearInterval(checkInterval);
this.hideRevealPrompt();
this.voluntaryFlipMode = false;
resolve();
}, windowDuration);
});
}
showRevealPrompt(duration) {
// Create countdown overlay
const overlay = document.createElement('div');
overlay.id = 'reveal-prompt';
overlay.className = 'reveal-prompt';
overlay.innerHTML = `
<div class="reveal-prompt-text">Tap your cards to reveal</div>
<div class="reveal-prompt-countdown">${Math.ceil(duration / 1000)}</div>
`;
document.body.appendChild(overlay);
// Countdown timer
const countdownEl = overlay.querySelector('.reveal-prompt-countdown');
let remaining = duration;
this.countdownInterval = setInterval(() => {
remaining -= 100;
countdownEl.textContent = Math.ceil(remaining / 1000);
if (remaining <= 0) {
clearInterval(this.countdownInterval);
}
}, 100);
}
hideRevealPrompt() {
clearInterval(this.countdownInterval);
const overlay = document.getElementById('reveal-prompt');
if (overlay) {
overlay.classList.add('fading');
setTimeout(() => overlay.remove(), 300);
}
}
// Modify handleCardClick to handle voluntary flips
handleCardClick(position) {
// ... existing code ...
// Voluntary flip during reveal window
if (this.voluntaryFlipMode && this.voluntaryFlipPositions?.has(position)) {
const myData = this.getMyPlayerData();
const card = myData?.cards[position];
if (card) {
this.playSound('flip');
this.fireLocalFlipAnimation(position, card);
this.voluntaryFlipPositions.delete(position);
// Update local state to show card flipped
this.locallyFlippedCards.add(position);
this.renderGame();
}
return;
}
// ... rest of existing code ...
}
```
### Reveal Animation Method
```javascript
async runRoundEndReveal(oldState, newState, onComplete) {
const T = window.TIMING?.reveal || {};
// STEP 1: Voluntary flip window - let players peek at their own cards
this.setStatus('Reveal your hidden cards!', 'reveal-window');
await this.runVoluntaryFlipWindow(oldState, newState);
// STEP 2: Auto-reveal remaining hidden cards
// Recalculate what needs flipping (some may have been voluntarily revealed)
const revealsByPlayer = this.getCardsToReveal(oldState, newState);
// Get reveal order: knocker first, then clockwise
const knockerId = newState.finisher_id;
const revealOrder = this.getRevealOrder(newState.players, knockerId);
// Initial dramatic pause before auto-reveals
this.setStatus('Revealing cards...', 'reveal');
await this.delay(T.initialPause || 500);
// Reveal each player's cards
for (const player of revealOrder) {
const cardsToFlip = revealsByPlayer.get(player.id) || [];
if (cardsToFlip.length === 0) continue;
// Highlight player area
this.highlightPlayerArea(player.id, true);
await this.delay(T.highlightDuration || 200);
// Flip each card with stagger
for (const { position, card } of cardsToFlip) {
this.animateRevealFlip(player.id, position, card);
await this.delay(T.cardStagger || 100);
}
// Wait for last flip to complete + pause
await this.delay(400 + (T.playerPause || 400));
// Remove highlight
this.highlightPlayerArea(player.id, false);
}
// All revealed
onComplete();
}
getCardsToReveal(oldState, newState) {
const reveals = new Map();
for (const newPlayer of newState.players) {
const oldPlayer = oldState.players.find(p => p.id === newPlayer.id);
if (!oldPlayer) continue;
const cardsToFlip = [];
for (let i = 0; i < 6; i++) {
const wasHidden = !oldPlayer.cards[i]?.face_up;
const nowVisible = newPlayer.cards[i]?.face_up;
if (wasHidden && nowVisible) {
cardsToFlip.push({
position: i,
card: newPlayer.cards[i]
});
}
}
if (cardsToFlip.length > 0) {
reveals.set(newPlayer.id, cardsToFlip);
}
}
return reveals;
}
getRevealOrder(players, knockerId) {
// Knocker first
const knocker = players.find(p => p.id === knockerId);
const others = players.filter(p => p.id !== knockerId);
// Others in clockwise order (already sorted by player_order)
if (knocker) {
return [knocker, ...others];
}
return others;
}
highlightPlayerArea(playerId, highlight) {
if (playerId === this.playerId) {
this.playerArea.classList.toggle('revealing', highlight);
} else {
const area = this.opponentsRow.querySelector(
`.opponent-area[data-player-id="${playerId}"]`
);
if (area) {
area.classList.toggle('revealing', highlight);
}
}
}
animateRevealFlip(playerId, position, cardData) {
// Reuse existing flip animation
if (playerId === this.playerId) {
this.fireLocalFlipAnimation(position, cardData);
} else {
this.fireFlipAnimation(playerId, position, cardData);
}
}
```
### CSS for Reveal Prompt and Highlights
```css
/* Voluntary reveal prompt */
.reveal-prompt {
position: fixed;
top: 20%;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, #f4a460 0%, #d4845a 100%);
color: white;
padding: 15px 30px;
border-radius: 12px;
text-align: center;
z-index: 200;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
animation: prompt-entrance 0.3s ease-out;
}
.reveal-prompt.fading {
animation: prompt-fade 0.3s ease-out forwards;
}
@keyframes prompt-entrance {
0% { transform: translateX(-50%) translateY(-20px); opacity: 0; }
100% { transform: translateX(-50%) translateY(0); opacity: 1; }
}
@keyframes prompt-fade {
0% { opacity: 1; }
100% { opacity: 0; }
}
.reveal-prompt-text {
font-size: 1.1em;
margin-bottom: 8px;
}
.reveal-prompt-countdown {
font-size: 2em;
font-weight: bold;
}
/* Cards clickable during voluntary reveal */
.player-area.voluntary-flip .card.can-flip {
cursor: pointer;
animation: flip-hint 0.8s ease-in-out infinite;
}
@keyframes flip-hint {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.03); }
}
/* Player area highlight during reveal */
.player-area.revealing,
.opponent-area.revealing {
animation: reveal-highlight 0.3s ease-out;
}
@keyframes reveal-highlight {
0% {
box-shadow: 0 0 0 0 rgba(244, 164, 96, 0);
}
50% {
box-shadow: 0 0 20px 10px rgba(244, 164, 96, 0.4);
}
100% {
box-shadow: 0 0 10px 5px rgba(244, 164, 96, 0.2);
}
}
/* Keep highlight while revealing */
.player-area.revealing,
.opponent-area.revealing {
box-shadow: 0 0 10px 5px rgba(244, 164, 96, 0.2);
}
```
---
## Special Cases
### All Cards Already Face-Up
If a player has no face-down cards (they knocked or flipped everything):
- Skip their reveal in the sequence
- Don't highlight their area
### Player Disconnected
If a player left before round end:
- Their cards still need to reveal for scoring
- Handle missing player areas gracefully
### Single Player (Debug/Test)
If only one player remains:
- Still do the reveal animation for their cards
- Feels consistent
### Quick Mode (Future)
Consider a setting to skip reveal animation:
```javascript
if (this.settings.quickMode) {
this.gameState = newState;
this.renderGame();
return;
}
```
---
## Timing Tuning
The reveal should feel dramatic but not tedious:
| Scenario | Cards to Reveal | Approximate Duration |
|----------|----------------|---------------------|
| 2 players, 2 hidden each | 4 cards | ~2 seconds |
| 4 players, 3 hidden each | 12 cards | ~4 seconds |
| 6 players, 4 hidden each | 24 cards | ~7 seconds |
If too slow, reduce:
- `cardStagger`: 100ms → 60ms
- `playerPause`: 400ms → 250ms
---
## Test Scenarios
1. **Normal round end** - Knocker reveals first, others follow
2. **Knocker has no hidden cards** - Skip knocker, start with next player
3. **All players have hidden cards** - Full reveal sequence
4. **Some players have no hidden cards** - Skip them gracefully
5. **Player disconnected** - Handle gracefully
6. **2-player game** - Both players reveal in order
7. **Quick succession** - Multiple round ends don't overlap
---
## Acceptance Criteria
- [ ] **Voluntary flip window:** 4-second window for players to flip their own cards
- [ ] Countdown timer shows remaining time
- [ ] Players can tap their face-down cards to reveal early
- [ ] Auto-reveal starts after timeout (or if all cards flipped)
- [ ] Cards reveal sequentially during auto-reveal, not all at once
- [ ] Knocker (finisher) reveals first
- [ ] Other players reveal clockwise after knocker
- [ ] Cards within a hand have slight stagger
- [ ] Pause between players for drama
- [ ] Player area highlights during their reveal
- [ ] Flip sound plays for each card
- [ ] Reveal completes before scoreboard appears
- [ ] Handles players with no hidden cards
- [ ] Animation can be interrupted if needed
---
## Implementation Order
1. Add reveal timing to `timing-config.js`
2. Add `data-player-id` to opponent areas (if not done in V3_02)
3. Implement `getCardsToReveal()` method
4. Implement `getRevealOrder()` method
5. Implement `highlightPlayerArea()` method
6. Implement `runRoundEndReveal()` method
7. Intercept round_over state transition
8. Add reveal highlight CSS
9. Test with various player counts and card states
10. Tune timing for best dramatic effect
---
## Notes for Agent
- Use `window.cardAnimations.animateFlip()` or `animateOpponentFlip()` for reveals
- The existing CardAnimations class has all flip animation methods ready
- Don't forget to set `finisher_id` in game state (server may already do this)
- The reveal order should match the physical clockwise order
- Consider: Add a "drum roll" sound before reveals? (Nice to have)
- The scoreboard should NOT appear until all reveals complete
- State update is deferred until animation completes - ensure no race conditions
- All animations use anime.js timelines internally - no CSS keyframes needed

View File

@@ -0,0 +1,354 @@
# V3-04: Column Pair Celebration
## Overview
Matching cards in a column (positions 0+3, 1+4, or 2+5) score 0 points - a key strategic mechanic. In physical games, players often exclaim when they make a pair. Currently, there's no visual feedback when a pair is formed, missing a satisfying moment.
**Dependencies:** None
**Dependents:** V3_10 (Column Pair Indicator builds on this)
---
## Goals
1. Detect when a swap creates a new column pair
2. Play satisfying visual celebration on both cards
3. Play a distinct "pair matched" sound
4. Brief but noticeable - shouldn't slow gameplay
5. Works for both local player and opponent swaps
---
## Current State
Column pairs are calculated during scoring but there's no visual indication when a pair forms during play.
From the rules (RULES.md):
```
Column 0: positions (0, 3)
Column 1: positions (1, 4)
Column 2: positions (2, 5)
```
A pair is formed when both cards in a column are face-up and have the same rank.
---
## Design
### Detection
After any swap or flip, check if a new pair was formed:
```javascript
function detectNewPair(oldCards, newCards) {
const columns = [[0, 3], [1, 4], [2, 5]];
for (const [top, bottom] of columns) {
const wasPaired = isPaired(oldCards, top, bottom);
const nowPaired = isPaired(newCards, top, bottom);
if (!wasPaired && nowPaired) {
return { column: columns.indexOf([top, bottom]), positions: [top, bottom] };
}
}
return null;
}
function isPaired(cards, pos1, pos2) {
const card1 = cards[pos1];
const card2 = cards[pos2];
return card1?.face_up && card2?.face_up &&
card1?.rank && card2?.rank &&
card1.rank === card2.rank;
}
```
### Celebration Animation
When a pair forms:
```
1. Both cards pulse/glow simultaneously
2. Brief sparkle effect (optional)
3. "Pair!" sound plays
4. Animation lasts ~400ms
5. Cards return to normal
```
### Visual Effect Options
**Option A: Anime.js Glow Pulse** (Recommended - matches existing animation system)
```javascript
// Add to CardAnimations class
celebratePair(cardElement1, cardElement2) {
this.playSound('pair');
const duration = window.TIMING?.celebration?.pairDuration || 400;
[cardElement1, cardElement2].forEach(el => {
anime({
targets: el,
boxShadow: [
'0 0 0 0 rgba(255, 215, 0, 0)',
'0 0 15px 8px rgba(255, 215, 0, 0.5)',
'0 0 0 0 rgba(255, 215, 0, 0)'
],
scale: [1, 1.05, 1],
duration: duration,
easing: 'easeOutQuad'
});
});
}
```
**Option B: Scale Bounce**
```javascript
anime({
targets: [cardElement1, cardElement2],
scale: [1, 1.1, 1],
duration: 400,
easing: 'easeOutQuad'
});
```
**Option C: Connecting Line**
Draw a brief line connecting the paired cards (more complex).
**Recommendation:** Option A - anime.js glow pulse matches the existing animation system.
---
## Implementation
### Timing Configuration
```javascript
// In timing-config.js
celebration: {
pairDuration: 400, // Celebration animation length
pairDelay: 50, // Slight delay before celebration (let swap settle)
}
```
### Sound
Add a new sound type for pairs:
```javascript
// In playSound() method
} else if (type === 'pair') {
// Two-tone "ding-ding" for pair match
const osc1 = ctx.createOscillator();
const osc2 = ctx.createOscillator();
const gain = ctx.createGain();
osc1.connect(gain);
osc2.connect(gain);
gain.connect(ctx.destination);
osc1.frequency.setValueAtTime(880, ctx.currentTime); // A5
osc2.frequency.setValueAtTime(1108, ctx.currentTime); // C#6
gain.gain.setValueAtTime(0.1, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
osc1.start(ctx.currentTime);
osc2.start(ctx.currentTime);
osc1.stop(ctx.currentTime + 0.3);
osc2.stop(ctx.currentTime + 0.3);
}
```
### Detection Integration
In the state differ or after swap animations:
```javascript
// In triggerAnimationsForStateChange() or after swap completes
checkForNewPairs(oldState, newState, playerId) {
const oldPlayer = oldState?.players?.find(p => p.id === playerId);
const newPlayer = newState?.players?.find(p => p.id === playerId);
if (!oldPlayer || !newPlayer) return;
const columns = [[0, 3], [1, 4], [2, 5]];
for (const [top, bottom] of columns) {
const wasPaired = this.isPaired(oldPlayer.cards, top, bottom);
const nowPaired = this.isPaired(newPlayer.cards, top, bottom);
if (!wasPaired && nowPaired) {
// New pair formed!
setTimeout(() => {
this.celebratePair(playerId, top, bottom);
}, window.TIMING?.celebration?.pairDelay || 50);
}
}
}
isPaired(cards, pos1, pos2) {
const c1 = cards[pos1];
const c2 = cards[pos2];
return c1?.face_up && c2?.face_up && c1?.rank === c2?.rank;
}
celebratePair(playerId, pos1, pos2) {
const cards = this.getCardElements(playerId, pos1, pos2);
if (cards.length === 0) return;
// Use CardAnimations to animate (or add method to CardAnimations)
window.cardAnimations.celebratePair(cards[0], cards[1]);
}
// Add to CardAnimations class in card-animations.js:
celebratePair(cardElement1, cardElement2) {
this.playSound('pair');
const duration = window.TIMING?.celebration?.pairDuration || 400;
[cardElement1, cardElement2].forEach(el => {
if (!el) return;
// Temporarily raise z-index so glow shows above adjacent cards
el.style.zIndex = '10';
anime({
targets: el,
boxShadow: [
'0 0 0 0 rgba(255, 215, 0, 0)',
'0 0 15px 8px rgba(255, 215, 0, 0.5)',
'0 0 0 0 rgba(255, 215, 0, 0)'
],
scale: [1, 1.05, 1],
duration: duration,
easing: 'easeOutQuad',
complete: () => {
el.style.zIndex = '';
}
});
});
}
getCardElements(playerId, ...positions) {
const elements = [];
if (playerId === this.playerId) {
const cards = this.playerCards.querySelectorAll('.card');
for (const pos of positions) {
if (cards[pos]) elements.push(cards[pos]);
}
} else {
const area = this.opponentsRow.querySelector(
`.opponent-area[data-player-id="${playerId}"]`
);
if (area) {
const cards = area.querySelectorAll('.card');
for (const pos of positions) {
if (cards[pos]) elements.push(cards[pos]);
}
}
}
return elements;
}
```
### CSS
No CSS keyframes needed - all animation is handled by anime.js in `CardAnimations.celebratePair()`.
The animation temporarily sets `z-index: 10` on cards during celebration to ensure the glow shows above adjacent cards. For opponent pairs, you can pass a different color parameter:
```javascript
// Optional: Different color for opponent pairs
celebratePair(cardElement1, cardElement2, isOpponent = false) {
const color = isOpponent
? 'rgba(100, 200, 255, 0.4)' // Blue for opponents
: 'rgba(255, 215, 0, 0.5)'; // Gold for local player
// ... anime.js animation with color ...
}
```
---
## Edge Cases
### Pair Broken Then Reformed
If a swap breaks one pair and creates another:
- Only celebrate the new pair
- Don't mourn the broken pair (no negative feedback)
### Multiple Pairs in One Move
Theoretically possible (swap creates pairs in adjacent columns):
- Celebrate all new pairs simultaneously
- Same sound, same animation on all involved cards
### Pair at Round Start (Initial Flip)
If initial flip creates a pair:
- Yes, celebrate it! Early luck deserves recognition
### Negative Card Pairs (2s, Jokers)
Pairing 2s or Jokers is strategically bad (wastes -2 value), but:
- Still celebrate the pair (it's mechanically correct)
- Player will learn the strategy over time
- Consider: different sound/color for "bad" pairs? (Too complex for V3)
---
## Test Scenarios
1. **Local player creates pair** - Both cards glow, sound plays
2. **Opponent creates pair** - Their cards glow, sound plays
3. **Initial flip creates pair** - Celebration after flip animation
4. **Swap breaks one pair, creates another** - Only new pair celebrates
5. **No pair formed** - No celebration
6. **Face-down card in column** - No false celebration
---
## Acceptance Criteria
- [ ] Swap that creates a pair triggers celebration
- [ ] Flip that creates a pair triggers celebration
- [ ] Both paired cards animate simultaneously
- [ ] Distinct "pair" sound plays
- [ ] Animation is brief (~400ms)
- [ ] Works for local player and opponents
- [ ] No celebration when pair isn't formed
- [ ] No celebration for already-existing pairs
- [ ] Animation doesn't block gameplay
---
## Implementation Order
1. Add `pair` sound to `playSound()` method
2. Add celebration timing to `timing-config.js`
3. Implement `isPaired()` helper method
4. Implement `checkForNewPairs()` method
5. Implement `celebratePair()` method
6. Implement `getCardElements()` helper
7. Add CSS animation for pair celebration
8. Integrate into state change detection
9. Test all pair formation scenarios
10. Tune sound and timing for satisfaction
---
## Notes for Agent
- Add `celebratePair()` method to the existing `CardAnimations` class
- Use anime.js for all animation - no CSS keyframes
- Keep the celebration brief - shouldn't slow down fast players
- The glow color (gold) suggests "success" - matches golf scoring concept
- Consider accessibility: animation should be visible but not overwhelming
- The existing swap animation completes before pair check runs
- Don't celebrate pairs that already existed before the action
- Opponent celebration can use slightly different color (optional parameter)

View File

@@ -0,0 +1,411 @@
# V3-05: Final Turn Urgency
## Overview
When a player reveals all their cards, the round enters "final turn" phase - each other player gets one last turn. This is a tense moment in physical games. Currently, only a small badge shows "Final Turn" which lacks urgency.
**Dependencies:** None
**Dependents:** None
---
## Goals
1. Create visual tension when final turn begins
2. Show who triggered final turn (the knocker)
3. Indicate how many players still need to act
4. Make each remaining turn feel consequential
5. Countdown feeling as players take their last turns
---
## Current State
From `app.js`:
```javascript
// Final turn badge exists but is minimal
if (isFinalTurn) {
this.finalTurnBadge.classList.remove('hidden');
} else {
this.finalTurnBadge.classList.add('hidden');
}
```
The badge just shows "FINAL TURN" text - no countdown, no urgency indicator.
---
## Design
### Visual Elements
1. **Pulsing Border** - Game area gets subtle pulsing red/orange border
2. **Enhanced Badge** - Larger badge with countdown
3. **Knocker Indicator** - Show who triggered final turn
4. **Turn Counter** - "2 players remaining" style indicator
### Badge Enhancement
```
Current: [FINAL TURN]
Enhanced: [⚠️ FINAL TURN]
[Player 2 of 3]
```
Or more dramatic:
```
[🔔 LAST CHANCE!]
[2 turns left]
```
### Color Scheme
- Normal play: Green felt background
- Final turn: Subtle warm/orange tint or border pulse
- Not overwhelming, but noticeable shift
---
## Implementation
### Enhanced Final Turn Badge
```html
<!-- Enhanced badge structure -->
<div id="final-turn-badge" class="hidden">
<div class="final-turn-icon"></div>
<div class="final-turn-text">FINAL TURN</div>
<div class="final-turn-remaining">2 turns left</div>
</div>
```
### CSS Enhancements
```css
/* Enhanced final turn badge */
#final-turn-badge {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: linear-gradient(135deg, #ff6b35 0%, #d63031 100%);
color: white;
padding: 12px 24px;
border-radius: 12px;
text-align: center;
z-index: 100;
box-shadow: 0 4px 20px rgba(214, 48, 49, 0.4);
animation: final-turn-pulse 1.5s ease-in-out infinite;
}
#final-turn-badge.hidden {
display: none;
}
.final-turn-icon {
font-size: 1.5em;
margin-bottom: 4px;
}
.final-turn-text {
font-weight: bold;
font-size: 1.2em;
letter-spacing: 0.1em;
}
.final-turn-remaining {
font-size: 0.9em;
opacity: 0.9;
margin-top: 4px;
}
@keyframes final-turn-pulse {
0%, 100% {
transform: translate(-50%, -50%) scale(1);
box-shadow: 0 4px 20px rgba(214, 48, 49, 0.4);
}
50% {
transform: translate(-50%, -50%) scale(1.02);
box-shadow: 0 4px 30px rgba(214, 48, 49, 0.6);
}
}
/* Game area border pulse during final turn */
#game-screen.final-turn-active {
animation: game-area-urgency 2s ease-in-out infinite;
}
@keyframes game-area-urgency {
0%, 100% {
box-shadow: inset 0 0 0 0 rgba(255, 107, 53, 0);
}
50% {
box-shadow: inset 0 0 30px 0 rgba(255, 107, 53, 0.15);
}
}
/* Knocker highlight */
.player-area.is-knocker,
.opponent-area.is-knocker {
border: 2px solid #ff6b35;
}
.knocker-badge {
position: absolute;
top: -10px;
right: -10px;
background: #ff6b35;
color: white;
padding: 2px 8px;
border-radius: 10px;
font-size: 0.7em;
font-weight: bold;
}
```
### JavaScript Updates
```javascript
// In renderGame() or dedicated method
updateFinalTurnDisplay() {
const isFinalTurn = this.gameState?.phase === 'final_turn';
const finisherId = this.gameState?.finisher_id;
// Toggle game area class
this.gameScreen.classList.toggle('final-turn-active', isFinalTurn);
if (isFinalTurn) {
// Calculate remaining turns
const remaining = this.countRemainingTurns();
// Update badge content
this.finalTurnBadge.querySelector('.final-turn-remaining').textContent =
remaining === 1 ? '1 turn left' : `${remaining} turns left`;
// Show badge with entrance animation
this.finalTurnBadge.classList.remove('hidden');
this.finalTurnBadge.classList.add('entering');
setTimeout(() => {
this.finalTurnBadge.classList.remove('entering');
}, 300);
// Mark knocker
this.markKnocker(finisherId);
// Play alert sound on first appearance
if (!this.finalTurnAnnounced) {
this.playSound('alert');
this.finalTurnAnnounced = true;
}
} else {
this.finalTurnBadge.classList.add('hidden');
this.gameScreen.classList.remove('final-turn-active');
this.finalTurnAnnounced = false;
this.clearKnockerMark();
}
}
countRemainingTurns() {
if (!this.gameState || this.gameState.phase !== 'final_turn') return 0;
const finisherId = this.gameState.finisher_id;
const currentIdx = this.gameState.players.findIndex(
p => p.id === this.gameState.current_player_id
);
const finisherIdx = this.gameState.players.findIndex(
p => p.id === finisherId
);
if (currentIdx === -1 || finisherIdx === -1) return 0;
// Count players between current and finisher (not including finisher)
let count = 0;
let idx = currentIdx;
const numPlayers = this.gameState.players.length;
while (idx !== finisherIdx) {
count++;
idx = (idx + 1) % numPlayers;
}
return count;
}
markKnocker(knockerId) {
// Add knocker badge to the player who triggered final turn
this.clearKnockerMark();
if (!knockerId) return;
if (knockerId === this.playerId) {
this.playerArea.classList.add('is-knocker');
// Add badge element
const badge = document.createElement('div');
badge.className = 'knocker-badge';
badge.textContent = 'OUT';
this.playerArea.appendChild(badge);
} else {
const area = this.opponentsRow.querySelector(
`.opponent-area[data-player-id="${knockerId}"]`
);
if (area) {
area.classList.add('is-knocker');
const badge = document.createElement('div');
badge.className = 'knocker-badge';
badge.textContent = 'OUT';
area.appendChild(badge);
}
}
}
clearKnockerMark() {
// Remove all knocker indicators
document.querySelectorAll('.is-knocker').forEach(el => {
el.classList.remove('is-knocker');
});
document.querySelectorAll('.knocker-badge').forEach(el => {
el.remove();
});
}
```
### Alert Sound
```javascript
// In playSound() method
} else if (type === 'alert') {
// Attention-getting sound for final turn
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'triangle';
osc.frequency.setValueAtTime(523, ctx.currentTime); // C5
osc.frequency.setValueAtTime(659, ctx.currentTime + 0.1); // E5
osc.frequency.setValueAtTime(784, ctx.currentTime + 0.2); // G5
gain.gain.setValueAtTime(0.15, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.4);
}
```
---
## Entrance Animation
When final turn starts, badge should appear dramatically:
```css
#final-turn-badge.entering {
animation: badge-entrance 0.3s ease-out;
}
@keyframes badge-entrance {
0% {
transform: translate(-50%, -50%) scale(0.5);
opacity: 0;
}
70% {
transform: translate(-50%, -50%) scale(1.1);
}
100% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
}
```
---
## Turn Countdown Update
Each time a player takes their final turn, update the counter:
```javascript
// In state change detection
if (newState.phase === 'final_turn') {
const oldRemaining = this.lastRemainingTurns;
const newRemaining = this.countRemainingTurns();
if (oldRemaining !== newRemaining) {
this.updateFinalTurnCounter(newRemaining);
this.lastRemainingTurns = newRemaining;
// Pulse the badge on update
this.finalTurnBadge.classList.add('counter-updated');
setTimeout(() => {
this.finalTurnBadge.classList.remove('counter-updated');
}, 200);
}
}
```
```css
#final-turn-badge.counter-updated {
animation: counter-pulse 0.2s ease-out;
}
@keyframes counter-pulse {
0% { transform: translate(-50%, -50%) scale(1); }
50% { transform: translate(-50%, -50%) scale(1.05); }
100% { transform: translate(-50%, -50%) scale(1); }
}
```
---
## Test Scenarios
1. **Enter final turn** - Badge appears with animation, sound plays
2. **Turn counter decrements** - Shows "2 turns left" → "1 turn left"
3. **Last turn** - Shows "1 turn left", extra urgency
4. **Round ends** - Badge disappears, border pulse stops
5. **Knocker marked** - OUT badge on player who triggered
6. **Multiple rounds** - Badge resets between rounds
---
## Acceptance Criteria
- [ ] Final turn badge appears when phase is `final_turn`
- [ ] Badge shows remaining turns count
- [ ] Count updates as players take turns
- [ ] Game area has subtle urgency visual
- [ ] Knocker is marked with badge
- [ ] Alert sound plays when final turn starts
- [ ] Badge has entrance animation
- [ ] All visuals reset when round ends
- [ ] Not overwhelming - tension without annoyance
---
## Implementation Order
1. Update HTML structure for enhanced badge
2. Add CSS for badge, urgency border, knocker indicator
3. Implement `countRemainingTurns()` method
4. Implement `updateFinalTurnDisplay()` method
5. Implement `markKnocker()` and `clearKnockerMark()`
6. Add alert sound to `playSound()`
7. Integrate into `renderGame()` or state change handler
8. Add entrance animation
9. Add counter update pulse
10. Test all scenarios
---
## Notes for Agent
- The urgency should enhance tension, not frustrate players
- Keep the pulsing subtle - not distracting during play
- The knocker badge helps players understand game state
- Consider mobile: badge should fit on small screens
- The remaining turns count helps players plan their last move
- Reset all state between rounds (finalTurnAnnounced flag)

View File

@@ -0,0 +1,376 @@
# V3-06: Opponent Thinking Phase
## Overview
In physical card games, you watch opponents pick up a card, consider it, and decide. Currently, CPU turns happen quickly with minimal visual indication that they're "thinking." This feature adds visible consideration time.
**Dependencies:** None
**Dependents:** None
---
## Goals
1. Show when an opponent is considering their move
2. Highlight which pile they're considering (deck vs discard)
3. Add brief thinking pause before CPU actions
4. Make CPU feel more like a real player
5. Human opponents should also show consideration state
---
## Current State
From `app.js` and `card-animations.js`:
```javascript
// In app.js
updateCpuConsideringState() {
const currentPlayer = this.gameState.players.find(
p => p.id === this.gameState.current_player_id
);
const isCpuTurn = currentPlayer && currentPlayer.is_cpu;
const hasNotDrawn = !this.gameState.has_drawn_card;
if (isCpuTurn && hasNotDrawn) {
this.discard.classList.add('cpu-considering');
} else {
this.discard.classList.remove('cpu-considering');
}
}
// CardAnimations already has CPU thinking glow:
startCpuThinking(element) {
anime({
targets: element,
boxShadow: [
'0 4px 12px rgba(0,0,0,0.3)',
'0 4px 12px rgba(0,0,0,0.3), 0 0 18px rgba(59, 130, 246, 0.5)',
'0 4px 12px rgba(0,0,0,0.3)'
],
duration: 1500,
easing: 'easeInOutSine',
loop: true
});
}
```
The existing `startCpuThinking()` method in CardAnimations provides a looping glow animation. This feature enhances visibility further.
---
## Design
### Enhanced Consideration Display
1. **Opponent area highlight** - Active player's area glows
2. **"Thinking" indicator** - Small animation near their name
3. **Deck/discard highlight** - Show which pile they're eyeing
4. **Held card consideration** - After draw, show they're deciding
### States
```
1. WAITING_TO_DRAW
- Player area highlighted
- Deck and discard both subtly available
- Brief pause before action (CPU)
2. CONSIDERING_DISCARD
- Player looks at discard pile
- Discard pile pulses brighter
- "Eye" indicator on discard
3. DREW_CARD
- Held card visible (existing)
- Player area still highlighted
4. CONSIDERING_SWAP
- Player deciding which card to swap
- Their hand cards subtly indicate options
```
### Timing (CPU only)
```javascript
// In timing-config.js
cpuThinking: {
beforeDraw: 800, // Pause before CPU draws
discardConsider: 400, // Extra pause when looking at discard
beforeSwap: 500, // Pause before CPU swaps
beforeDiscard: 300, // Pause before CPU discards drawn card
}
```
Human players don't need artificial pauses - their actual thinking provides the delay.
---
## Implementation
### Thinking Indicator
Add a small animated indicator near the current player's name:
```html
<!-- In opponent area -->
<div class="opponent-area" data-player-id="...">
<h4>
<span class="thinking-indicator hidden">🤔</span>
<span class="opponent-name">Sofia</span>
...
</h4>
</div>
```
### CSS and Animations
Most animations should use anime.js via CardAnimations class for consistency:
```javascript
// In CardAnimations class - the startCpuThinking method already exists
// Add similar methods for other thinking states:
startOpponentThinking(opponentArea) {
const id = `opponentThinking-${opponentArea.dataset.playerId}`;
this.stopOpponentThinking(opponentArea);
anime({
targets: opponentArea,
boxShadow: [
'0 0 15px rgba(244, 164, 96, 0.4)',
'0 0 25px rgba(244, 164, 96, 0.6)',
'0 0 15px rgba(244, 164, 96, 0.4)'
],
duration: 1500,
easing: 'easeInOutSine',
loop: true
});
}
stopOpponentThinking(opponentArea) {
anime.remove(opponentArea);
opponentArea.style.boxShadow = '';
}
```
Minimal CSS for layout only:
```css
/* Thinking indicator - simple show/hide */
.thinking-indicator {
display: inline-block;
margin-right: 4px;
}
.thinking-indicator.hidden {
display: none;
}
/* Current turn highlight base (animation handled by anime.js) */
.opponent-area.current-turn {
border-color: #f4a460;
}
/* Eye indicator positioning */
.pile-eye-indicator {
position: absolute;
top: -15px;
right: -10px;
font-size: 1.2em;
}
```
For the thinking indicator bobbing, use anime.js:
```javascript
// Animate emoji indicator
startThinkingIndicator(element) {
anime({
targets: element,
translateY: [0, -3, 0],
duration: 800,
easing: 'easeInOutSine',
loop: true
});
}
```
### JavaScript Updates
```javascript
// Enhanced consideration state management
updateConsiderationState() {
const currentPlayer = this.gameState?.players?.find(
p => p.id === this.gameState.current_player_id
);
if (!currentPlayer || currentPlayer.id === this.playerId) {
this.clearConsiderationState();
return;
}
const hasDrawn = this.gameState.has_drawn_card;
const isCpu = currentPlayer.is_cpu;
// Find opponent area
const area = this.opponentsRow.querySelector(
`.opponent-area[data-player-id="${currentPlayer.id}"]`
);
if (!area) return;
// Show thinking indicator for CPUs
const indicator = area.querySelector('.thinking-indicator');
if (indicator) {
indicator.classList.toggle('hidden', !isCpu || hasDrawn);
}
// Add thinking class to area
area.classList.toggle('thinking', !hasDrawn);
// Show which pile they might be considering
if (!hasDrawn && isCpu) {
// CPU AI hint: check if discard is attractive
const discardValue = this.getDiscardValue();
if (discardValue !== null && discardValue <= 4) {
this.discard.classList.add('being-considered');
this.deck.classList.remove('being-considered');
} else {
this.deck.classList.add('being-considered');
this.discard.classList.remove('being-considered');
}
} else {
this.deck.classList.remove('being-considered');
this.discard.classList.remove('being-considered');
}
}
clearConsiderationState() {
// Remove all consideration indicators
this.opponentsRow.querySelectorAll('.thinking-indicator').forEach(el => {
el.classList.add('hidden');
});
this.opponentsRow.querySelectorAll('.opponent-area').forEach(el => {
el.classList.remove('thinking');
});
this.deck.classList.remove('being-considered');
this.discard.classList.remove('being-considered');
}
getDiscardValue() {
const card = this.gameState?.discard_top;
if (!card) return null;
const values = this.gameState?.card_values || {
'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7,
'8': 8, '9': 9, '10': 10, 'J': 10, 'Q': 10, 'K': 0, '★': -2
};
return values[card.rank] ?? 10;
}
```
### Server-Side CPU Thinking Delay
The server should add pauses for CPU thinking (or the client can delay rendering):
```python
# In ai.py or game.py, after CPU makes decision
async def cpu_take_turn(self, game, player_id):
thinking_time = self.profile.get_thinking_time() # 500-1500ms based on profile
# Pre-draw consideration
await asyncio.sleep(thinking_time * 0.5)
# Make draw decision
source = self.decide_draw_source(game, player_id)
# Broadcast "considering" state
await self.broadcast_cpu_considering(game, player_id, source)
await asyncio.sleep(thinking_time * 0.3)
# Execute draw
game.draw_card(player_id, source)
# Post-draw consideration
await asyncio.sleep(thinking_time * 0.4)
# Make swap/discard decision
...
```
Alternatively, handle all delays on the client side by adding pauses before rendering CPU actions.
---
## CPU Personality Integration
Different AI profiles could have different thinking patterns:
```javascript
// Thinking time variance by personality (from ai.py profiles)
const thinkingProfiles = {
'Sofia': { baseTime: 1200, variance: 200 }, // Calculated & Patient
'Maya': { baseTime: 600, variance: 100 }, // Aggressive Closer
'Priya': { baseTime: 1000, variance: 300 }, // Pair Hunter (considers more)
'Marcus': { baseTime: 800, variance: 150 }, // Steady Eddie
'Kenji': { baseTime: 500, variance: 200 }, // Risk Taker (quick)
'Diego': { baseTime: 700, variance: 400 }, // Chaotic Gambler (variable)
'River': { baseTime: 900, variance: 250 }, // Adaptive Strategist
'Sage': { baseTime: 1100, variance: 150 }, // Sneaky Finisher
};
```
---
## Test Scenarios
1. **CPU turn starts** - Area highlights, thinking indicator shows
2. **CPU considering discard** - Discard pile glows if valuable card
3. **CPU draws** - Thinking indicator changes to held card state
4. **CPU swaps** - Brief consideration before swap
5. **Human opponent turn** - Area highlights but no thinking indicator
6. **Local player turn** - No consideration UI (they know what they're doing)
---
## Acceptance Criteria
- [ ] Current opponent's area highlights during their turn
- [ ] CPU players show thinking indicator (emoji)
- [ ] Deck/discard shows which pile CPU is considering
- [ ] Brief pause before CPU actions (feels like thinking)
- [ ] Different CPU personalities have different timing
- [ ] Human opponents highlight without thinking indicator
- [ ] All indicators clear when turn ends
- [ ] Doesn't slow down the game significantly
---
## Implementation Order
1. Add thinking indicator element to opponent areas
2. Add CSS for thinking animations
3. Implement `updateConsiderationState()` method
4. Implement `clearConsiderationState()` method
5. Add pile consideration highlighting
6. Integrate CPU thinking delays (server or client)
7. Test with various CPU profiles
8. Tune timing for natural feel
---
## Notes for Agent
- Use existing CardAnimations methods: `startCpuThinking()`, `stopCpuThinking()`
- Add new methods to CardAnimations for opponent area glow
- Use anime.js for all looping animations, not CSS keyframes
- Keep thinking pauses short enough to not frustrate players
- The goal is to make CPUs feel more human, not slow
- Different profiles should feel distinct in their play speed
- Human players don't need artificial delays
- Consider: Option to speed up CPU thinking? (Future setting)
- The "being considered" pile indicator is a subtle hint at AI logic
- Track animations in `activeAnimations` for proper cleanup

View File

@@ -0,0 +1,484 @@
# V3-07: Animated Score Tallying
## Overview
In physical card games, scoring involves counting cards one by one, noting pairs, and calculating the total. Currently, scores just appear in the scoreboard. This feature adds animated score counting that highlights each card's contribution.
**Dependencies:** V3_03 (Round End Reveal should complete before tallying)
**Dependents:** None
---
## Goals
1. Animate score counting card-by-card
2. Highlight each card as its value is added
3. Show column pairs canceling to zero
4. Running total builds up visibly
5. Special effect for negative cards and pairs
6. Satisfying "final score" reveal
---
## Current State
From `showScoreboard()` in app.js:
```javascript
showScoreboard(scores, isFinal, rankings) {
// Scores appear instantly in table
// No animation of how score was calculated
}
```
The server calculates scores and sends them. The client just displays them.
---
## Design
### Tally Sequence
```
1. Round end reveal completes (V3_03)
2. Brief pause (300ms)
3. For each player (starting with knocker):
a. Highlight player area
b. Count through each column:
- Highlight top card, show value
- Highlight bottom card, show value
- If pair: show "PAIR! +0" effect
- If not pair: add values to running total
c. Show final score with flourish
d. Move to next player
4. Scoreboard slides in with all scores
```
### Visual Elements
- **Card value overlay** - Temporary badge showing card's point value
- **Running total** - Animated counter near player area
- **Pair effect** - Special animation when column pair cancels
- **Final score** - Large number with celebration effect
### Timing
```javascript
// In timing-config.js
tally: {
initialPause: 300, // After reveal, before tally
cardHighlight: 200, // Duration to show each card value
columnPause: 150, // Between columns
pairCelebration: 400, // Pair cancel effect
playerPause: 500, // Between players
finalScoreReveal: 600, // Final score animation
}
```
---
## Implementation
### Card Value Overlay
```javascript
// Create temporary overlay showing card value
showCardValue(cardElement, value, isNegative) {
const overlay = document.createElement('div');
overlay.className = 'card-value-overlay';
if (isNegative) overlay.classList.add('negative');
if (value === 0) overlay.classList.add('zero');
const sign = value > 0 ? '+' : '';
overlay.textContent = `${sign}${value}`;
// Position over the card
const rect = cardElement.getBoundingClientRect();
overlay.style.left = `${rect.left + rect.width / 2}px`;
overlay.style.top = `${rect.top + rect.height / 2}px`;
document.body.appendChild(overlay);
// Animate in
overlay.classList.add('visible');
return overlay;
}
hideCardValue(overlay) {
overlay.classList.remove('visible');
setTimeout(() => overlay.remove(), 200);
}
```
### CSS for Overlays
```css
/* Card value overlay */
.card-value-overlay {
position: fixed;
transform: translate(-50%, -50%) scale(0.5);
background: rgba(30, 30, 46, 0.9);
color: white;
padding: 8px 14px;
border-radius: 8px;
font-size: 1.4em;
font-weight: bold;
opacity: 0;
transition: transform 0.2s ease-out, opacity 0.2s ease-out;
z-index: 200;
pointer-events: none;
}
.card-value-overlay.visible {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
.card-value-overlay.negative {
background: linear-gradient(135deg, #27ae60 0%, #1e8449 100%);
color: white;
}
.card-value-overlay.zero {
background: linear-gradient(135deg, #f4a460 0%, #d4845a 100%);
}
/* Running total */
.running-total {
position: absolute;
bottom: -30px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 4px 12px;
border-radius: 15px;
font-size: 1.2em;
font-weight: bold;
}
.running-total.updating {
animation: total-bounce 0.2s ease-out;
}
@keyframes total-bounce {
0% { transform: translateX(-50%) scale(1); }
50% { transform: translateX(-50%) scale(1.1); }
100% { transform: translateX(-50%) scale(1); }
}
/* Pair cancel effect */
.pair-cancel-overlay {
position: fixed;
transform: translate(-50%, -50%);
font-size: 1.2em;
font-weight: bold;
color: #f4a460;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
animation: pair-cancel 0.6s ease-out forwards;
z-index: 200;
pointer-events: none;
}
@keyframes pair-cancel {
0% {
transform: translate(-50%, -50%) scale(0.5);
opacity: 0;
}
30% {
transform: translate(-50%, -50%) scale(1.2);
opacity: 1;
}
100% {
transform: translate(-50%, -60%) scale(1);
opacity: 0;
}
}
/* Card highlight during tally */
.card.tallying {
box-shadow: 0 0 15px rgba(244, 164, 96, 0.6);
transform: scale(1.05);
transition: box-shadow 0.1s, transform 0.1s;
}
/* Final score reveal */
.final-score-overlay {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: white;
padding: 20px 40px;
border-radius: 15px;
text-align: center;
z-index: 250;
animation: final-score-reveal 0.6s ease-out forwards;
}
.final-score-overlay .player-name {
font-size: 1em;
opacity: 0.8;
margin-bottom: 5px;
}
.final-score-overlay .score-value {
font-size: 3em;
font-weight: bold;
}
.final-score-overlay .score-value.negative {
color: #27ae60;
}
@keyframes final-score-reveal {
0% {
transform: translate(-50%, -50%) scale(0);
}
60% {
transform: translate(-50%, -50%) scale(1.1);
}
100% {
transform: translate(-50%, -50%) scale(1);
}
}
```
### Main Tally Logic
```javascript
async runScoreTally(players, onComplete) {
const T = window.TIMING?.tally || {};
// Initial pause after reveal
await this.delay(T.initialPause || 300);
// Get card values from game state
const cardValues = this.gameState?.card_values || this.getDefaultCardValues();
// Tally each player
for (const player of players) {
const area = this.getPlayerArea(player.id);
if (!area) continue;
// Highlight player area
area.classList.add('tallying-player');
// Create running total display
const runningTotal = document.createElement('div');
runningTotal.className = 'running-total';
runningTotal.textContent = '0';
area.appendChild(runningTotal);
let total = 0;
const cards = area.querySelectorAll('.card');
// Process each column
const columns = [[0, 3], [1, 4], [2, 5]];
for (const [topIdx, bottomIdx] of columns) {
const topCard = cards[topIdx];
const bottomCard = cards[bottomIdx];
const topData = player.cards[topIdx];
const bottomData = player.cards[bottomIdx];
// Highlight top card
topCard.classList.add('tallying');
const topValue = cardValues[topData.rank] ?? 0;
const topOverlay = this.showCardValue(topCard, topValue, topValue < 0);
await this.delay(T.cardHighlight || 200);
// Highlight bottom card
bottomCard.classList.add('tallying');
const bottomValue = cardValues[bottomData.rank] ?? 0;
const bottomOverlay = this.showCardValue(bottomCard, bottomValue, bottomValue < 0);
await this.delay(T.cardHighlight || 200);
// Check for pair
if (topData.rank === bottomData.rank) {
// Pair! Show cancel effect
this.hideCardValue(topOverlay);
this.hideCardValue(bottomOverlay);
this.showPairCancel(topCard, bottomCard);
await this.delay(T.pairCelebration || 400);
} else {
// Add values to total
total += topValue + bottomValue;
this.updateRunningTotal(runningTotal, total);
this.hideCardValue(topOverlay);
this.hideCardValue(bottomOverlay);
}
// Clear card highlights
topCard.classList.remove('tallying');
bottomCard.classList.remove('tallying');
await this.delay(T.columnPause || 150);
}
// Show final score for this player
await this.showFinalScore(player.name, total);
await this.delay(T.finalScoreReveal || 600);
// Clean up
runningTotal.remove();
area.classList.remove('tallying-player');
await this.delay(T.playerPause || 500);
}
onComplete();
}
showPairCancel(card1, card2) {
// Position between the two cards
const rect1 = card1.getBoundingClientRect();
const rect2 = card2.getBoundingClientRect();
const centerX = (rect1.left + rect1.right + rect2.left + rect2.right) / 4;
const centerY = (rect1.top + rect1.bottom + rect2.top + rect2.bottom) / 4;
const overlay = document.createElement('div');
overlay.className = 'pair-cancel-overlay';
overlay.textContent = 'PAIR! +0';
overlay.style.left = `${centerX}px`;
overlay.style.top = `${centerY}px`;
document.body.appendChild(overlay);
// Pulse both cards
card1.classList.add('pair-matched');
card2.classList.add('pair-matched');
setTimeout(() => {
overlay.remove();
card1.classList.remove('pair-matched');
card2.classList.remove('pair-matched');
}, 600);
this.playSound('pair');
}
updateRunningTotal(element, value) {
element.textContent = value >= 0 ? value : value;
element.classList.add('updating');
setTimeout(() => element.classList.remove('updating'), 200);
}
async showFinalScore(playerName, score) {
const overlay = document.createElement('div');
overlay.className = 'final-score-overlay';
overlay.innerHTML = `
<div class="player-name">${playerName}</div>
<div class="score-value ${score < 0 ? 'negative' : ''}">${score}</div>
`;
document.body.appendChild(overlay);
this.playSound(score < 0 ? 'success' : 'card');
await this.delay(800);
overlay.style.opacity = '0';
overlay.style.transition = 'opacity 0.3s';
await this.delay(300);
overlay.remove();
}
getDefaultCardValues() {
return {
'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7,
'8': 8, '9': 9, '10': 10, 'J': 10, 'Q': 10, 'K': 0, '★': -2
};
}
```
---
## Integration with Round End
```javascript
// In runRoundEndReveal completion callback
async runRoundEndReveal(oldState, newState, onComplete) {
// ... existing reveal logic ...
// After all reveals complete
await this.runScoreTally(newState.players, () => {
// Now show the scoreboard
onComplete();
});
}
```
---
## Simplified Mode
For faster games, offer a simplified tally that just shows final scores:
```javascript
if (this.settings.quickTally) {
// Just flash the final scores, skip card-by-card
for (const player of players) {
const score = this.calculateScore(player.cards);
await this.showFinalScore(player.name, score);
await this.delay(400);
}
onComplete();
return;
}
```
---
## Test Scenarios
1. **Normal hand** - Values add up correctly
2. **Paired column** - Shows "PAIR! +0" effect
3. **All pairs** - Total is 0, multiple pair celebrations
4. **Negative cards** - Green highlight, reduces total
5. **Multiple players** - Tallies sequentially
6. **Various scores** - Positive, negative, zero
---
## Acceptance Criteria
- [ ] Cards highlight as they're counted
- [ ] Point values show as temporary overlays
- [ ] Running total updates with each card
- [ ] Paired columns show cancel effect
- [ ] Final score has celebration animation
- [ ] Tally order: knocker first, then clockwise
- [ ] Sound effects enhance the experience
- [ ] Total time under 10 seconds for 4 players
- [ ] Scoreboard appears after tally completes
---
## Implementation Order
1. Add tally timing to `timing-config.js`
2. Create CSS for all overlays and animations
3. Implement `showCardValue()` and `hideCardValue()`
4. Implement `showPairCancel()`
5. Implement `updateRunningTotal()`
6. Implement `showFinalScore()`
7. Implement main `runScoreTally()` method
8. Integrate with round end reveal
9. Test various scoring scenarios
10. Add quick tally option
---
## Notes for Agent
- **CSS vs anime.js**: Use CSS for UI overlays (value badges, running total). Use anime.js for card highlight effects.
- Card highlighting can use `window.cardAnimations` methods or simple anime.js calls
- The tally should feel satisfying, not tedious
- Keep individual card highlight times short
- Pair cancellation is a highlight moment - give it emphasis
- Consider accessibility: values should be readable
- The running total helps players follow the math
- Don't forget to handle house rules affecting card values (use `gameState.card_values`)

View File

@@ -0,0 +1,343 @@
# V3-08: Card Hover/Selection Enhancement
## Overview
When holding a drawn card, players must choose which card to swap with. Currently, clicking a card immediately swaps. This feature adds better hover feedback showing the potential swap before committing.
**Dependencies:** None
**Dependents:** None
---
## Goals
1. Clear visual preview of the swap before clicking
2. Show where the held card will go
3. Show where the hand card will go (discard)
4. Distinct hover states for face-up vs face-down cards
5. Mobile-friendly (no hover, but clear tap targets)
---
## Current State
From `app.js`:
```javascript
handleCardClick(position) {
// ... if holding drawn card ...
if (this.drawnCard) {
this.animateSwap(position); // Immediately swaps
return;
}
}
```
Cards have basic hover effects in CSS but no swap preview.
---
## Design
### Desktop Hover Preview
When hovering over a hand card while holding a drawn card:
```
1. Hovered card lifts slightly and dims
2. Ghost of held card appears in that slot (semi-transparent)
3. Arrow or line hints at the swap direction
4. "Click to swap" tooltip (optional)
```
### Mobile Tap Preview
Since mobile has no hover:
- First tap = select/highlight the card
- Second tap = confirm swap
- Or: long-press shows preview, release to swap
**Recommendation:** Immediate swap on tap (current behavior) is fine for mobile. Focus on desktop hover preview.
---
## Implementation
### CSS Hover Enhancements
```css
/* Card hover when holding drawn card */
.player-area.can-swap .card {
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s, opacity 0.15s;
}
.player-area.can-swap .card:hover {
transform: translateY(-5px) scale(1.02);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
}
/* Dimmed state showing "this will be replaced" */
.player-area.can-swap .card:hover::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
border-radius: inherit;
pointer-events: none;
}
/* Ghost preview of incoming card */
.card-ghost-preview {
position: absolute;
opacity: 0.6;
pointer-events: none;
transform: scale(0.95);
z-index: 5;
border: 2px dashed rgba(244, 164, 96, 0.8);
}
/* Swap indicator arrow */
.swap-indicator {
position: absolute;
pointer-events: none;
z-index: 10;
opacity: 0;
transition: opacity 0.15s;
}
.player-area.can-swap .card:hover ~ .swap-indicator {
opacity: 1;
}
/* Different highlight for face-down cards */
.player-area.can-swap .card.card-back:hover {
box-shadow: 0 8px 20px rgba(244, 164, 96, 0.4);
}
/* "Unknown" indicator for face-down hover */
.card.card-back:hover::before {
content: '?';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 2em;
color: rgba(255, 255, 255, 0.5);
}
```
### JavaScript Implementation
```javascript
// Add swap preview functionality
setupSwapPreview() {
this.ghostPreview = document.createElement('div');
this.ghostPreview.className = 'card-ghost-preview hidden';
this.playerCards.appendChild(this.ghostPreview);
}
// Call during render when player is holding a card
updateSwapPreviewState() {
const canSwap = this.drawnCard && this.isMyTurn();
this.playerArea.classList.toggle('can-swap', canSwap);
if (!canSwap) {
this.ghostPreview?.classList.add('hidden');
return;
}
// Set up ghost preview content
if (this.drawnCard && this.ghostPreview) {
this.ghostPreview.className = 'card-ghost-preview card card-front hidden';
if (this.drawnCard.rank === '★') {
this.ghostPreview.classList.add('joker');
} else if (this.isRedSuit(this.drawnCard.suit)) {
this.ghostPreview.classList.add('red');
} else {
this.ghostPreview.classList.add('black');
}
this.ghostPreview.innerHTML = this.renderCardContent(this.drawnCard);
}
}
// Bind hover events to cards
bindCardHoverEvents() {
const cards = this.playerCards.querySelectorAll('.card');
cards.forEach((card, index) => {
card.addEventListener('mouseenter', () => {
if (!this.drawnCard || !this.isMyTurn()) return;
this.showSwapPreview(card, index);
});
card.addEventListener('mouseleave', () => {
this.hideSwapPreview();
});
});
}
showSwapPreview(targetCard, position) {
if (!this.ghostPreview) return;
// Position ghost at target card location
const rect = targetCard.getBoundingClientRect();
const containerRect = this.playerCards.getBoundingClientRect();
this.ghostPreview.style.left = `${rect.left - containerRect.left}px`;
this.ghostPreview.style.top = `${rect.top - containerRect.top}px`;
this.ghostPreview.style.width = `${rect.width}px`;
this.ghostPreview.style.height = `${rect.height}px`;
this.ghostPreview.classList.remove('hidden');
// Highlight target card
targetCard.classList.add('swap-target');
// Show what will happen
this.setStatus(`Swap with position ${position + 1}`, 'swap-preview');
}
hideSwapPreview() {
this.ghostPreview?.classList.add('hidden');
// Remove target highlight
this.playerCards.querySelectorAll('.card').forEach(card => {
card.classList.remove('swap-target');
});
// Restore normal status
this.updateStatusFromGameState();
}
```
### Card Position Labels (Optional Enhancement)
Show position numbers on cards during swap selection:
```css
.player-area.can-swap .card::before {
content: attr(data-position);
position: absolute;
top: -8px;
left: -8px;
width: 18px;
height: 18px;
background: rgba(0, 0, 0, 0.7);
color: white;
border-radius: 50%;
font-size: 11px;
display: flex;
align-items: center;
justify-content: center;
}
```
```javascript
// In renderGame, add data-position to cards
const cards = this.playerCards.querySelectorAll('.card');
cards.forEach((card, i) => {
card.dataset.position = i + 1;
});
```
---
## Visual Preview Options
### Option A: Ghost Card (Recommended)
Semi-transparent copy of the held card appears over the target slot.
### Option B: Arrow Indicator
Arrow from held card to target slot, and from target to discard.
### Option C: Split Preview
Show both cards side-by-side with swap arrows.
**Recommendation:** Option A is simplest and most intuitive.
---
## Face-Down Card Interaction
When swapping with a face-down card, player is taking a risk:
- Show "?" indicator to emphasize unknown
- Maybe show estimated value range? (Too complex for V3)
- Different hover color (orange = warning)
```css
.player-area.can-swap .card.card-back:hover {
border: 2px solid #f4a460;
}
.player-area.can-swap .card.card-back:hover::after {
content: 'Unknown';
position: absolute;
bottom: -20px;
left: 50%;
transform: translateX(-50%);
font-size: 0.7em;
color: #f4a460;
white-space: nowrap;
}
```
---
## Test Scenarios
1. **Hover over face-up card** - Shows preview, card lifts
2. **Hover over face-down card** - Shows warning styling
3. **Move between cards** - Preview updates smoothly
4. **Mouse leaves card area** - Preview disappears
5. **Not holding card** - No special hover effects
6. **Not my turn** - No hover effects
7. **Mobile tap** - Works without preview (existing behavior)
---
## Acceptance Criteria
- [ ] Cards lift on hover when holding drawn card
- [ ] Ghost preview shows incoming card
- [ ] Face-down cards have distinct hover (unknown warning)
- [ ] Preview disappears on mouse leave
- [ ] No effects when not holding card
- [ ] No effects when not your turn
- [ ] Mobile tap still works normally
- [ ] Smooth transitions, no jank
---
## Implementation Order
1. Add `can-swap` class toggle to player area
2. Add CSS for hover lift effect
3. Create ghost preview element
4. Implement `showSwapPreview()` method
5. Implement `hideSwapPreview()` method
6. Bind mouseenter/mouseleave events
7. Add face-down card distinct styling
8. Test on desktop and mobile
9. Optional: Add position labels
---
## Notes for Agent
- **CSS vs anime.js**: CSS is appropriate for simple hover effects (performant, no JS overhead)
- Keep hover effects performant (CSS transforms preferred)
- Don't break existing click-to-swap behavior
- Mobile should work exactly as before (immediate swap)
- Consider reduced motion preferences
- The ghost preview should match the actual card appearance
- Position labels help new players understand the grid

View File

@@ -0,0 +1,451 @@
# V3-09: Knock Early Drama
## Overview
The "Knock Early" house rule lets players flip all remaining face-down cards (if 2 or fewer) to immediately trigger final turn. This is a high-risk, high-reward move that deserves dramatic presentation.
**Dependencies:** None
**Dependents:** None
---
## Goals
1. Make knock early feel dramatic and consequential
2. Show confirmation dialog (optional - it's risky!)
3. Dramatic animation when knock happens
4. Clear feedback showing the decision
5. Other players see "Player X knocked early!"
---
## Current State
From `app.js`:
```javascript
knockEarly() {
if (!this.gameState || !this.gameState.knock_early) return;
this.send({ type: 'knock_early' });
this.hideToast();
}
```
The knock early button exists but there's no special visual treatment.
---
## Design
### Knock Early Flow
```
1. Player clicks "Knock Early" button
2. Confirmation prompt: "Reveal your hidden cards and go out?"
3. If confirmed:
a. Dramatic sound effect
b. Player's hidden cards flip rapidly in sequence
c. "KNOCK!" banner appears
d. Final turn badge triggers
4. Other players see announcement
```
### Visual Elements
- **Confirmation dialog** - "Are you sure?" with preview
- **Rapid flip animation** - Cards flip faster than normal
- **"KNOCK!" banner** - Large dramatic announcement
- **Screen shake** (subtle) - Impact feeling
---
## Implementation
### Confirmation Dialog
```javascript
knockEarly() {
if (!this.gameState || !this.gameState.knock_early) return;
// Count hidden cards
const myData = this.getMyPlayerData();
const hiddenCards = myData.cards.filter(c => !c.face_up);
if (hiddenCards.length === 0 || hiddenCards.length > 2) {
return; // Can't knock
}
// Show confirmation
this.showKnockConfirmation(hiddenCards.length, () => {
this.executeKnockEarly();
});
}
showKnockConfirmation(hiddenCount, onConfirm) {
// Create modal
const modal = document.createElement('div');
modal.className = 'knock-confirm-modal';
modal.innerHTML = `
<div class="knock-confirm-content">
<div class="knock-confirm-icon">⚡</div>
<h3>Knock Early?</h3>
<p>You'll reveal ${hiddenCount} hidden card${hiddenCount > 1 ? 's' : ''} and trigger final turn.</p>
<p class="knock-warning">This cannot be undone!</p>
<div class="knock-confirm-buttons">
<button class="btn btn-secondary knock-cancel">Cancel</button>
<button class="btn btn-primary knock-confirm">Knock!</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Bind events
modal.querySelector('.knock-cancel').addEventListener('click', () => {
this.playSound('click');
modal.remove();
});
modal.querySelector('.knock-confirm').addEventListener('click', () => {
this.playSound('click');
modal.remove();
onConfirm();
});
// Click outside to cancel
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
}
});
}
async executeKnockEarly() {
// Play dramatic sound
this.playSound('knock');
// Get positions of hidden cards
const myData = this.getMyPlayerData();
const hiddenPositions = myData.cards
.map((card, i) => ({ card, position: i }))
.filter(({ card }) => !card.face_up)
.map(({ position }) => position);
// Start rapid flip animation
await this.animateKnockFlips(hiddenPositions);
// Show KNOCK banner
this.showKnockBanner();
// Send to server
this.send({ type: 'knock_early' });
this.hideToast();
}
async animateKnockFlips(positions) {
// Rapid sequential flips
const flipDelay = 150; // Faster than normal
for (const position of positions) {
const myData = this.getMyPlayerData();
const card = myData.cards[position];
this.fireLocalFlipAnimation(position, card);
this.playSound('flip');
await this.delay(flipDelay);
}
// Wait for last flip
await this.delay(300);
}
showKnockBanner() {
const banner = document.createElement('div');
banner.className = 'knock-banner';
banner.innerHTML = '<span>KNOCK!</span>';
document.body.appendChild(banner);
// Screen shake effect
document.body.classList.add('screen-shake');
// Remove after animation
setTimeout(() => {
banner.classList.add('fading');
document.body.classList.remove('screen-shake');
}, 800);
setTimeout(() => {
banner.remove();
}, 1100);
}
```
### CSS
```css
/* Knock confirmation modal */
.knock-confirm-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 300;
animation: modal-fade-in 0.2s ease-out;
}
@keyframes modal-fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
.knock-confirm-content {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
padding: 30px;
border-radius: 15px;
text-align: center;
max-width: 320px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
animation: modal-scale-in 0.2s ease-out;
}
@keyframes modal-scale-in {
0% { transform: scale(0.9); }
100% { transform: scale(1); }
}
.knock-confirm-icon {
font-size: 3em;
margin-bottom: 10px;
}
.knock-confirm-content h3 {
margin: 0 0 15px;
color: #f4a460;
}
.knock-confirm-content p {
margin: 0 0 10px;
color: rgba(255, 255, 255, 0.8);
}
.knock-warning {
color: #e74c3c !important;
font-size: 0.9em;
}
.knock-confirm-buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.knock-confirm-buttons .btn {
flex: 1;
}
/* KNOCK banner */
.knock-banner {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
z-index: 400;
pointer-events: none;
animation: knock-banner-in 0.3s ease-out forwards;
}
.knock-banner span {
display: block;
font-size: 4em;
font-weight: 900;
color: #f4a460;
text-shadow:
0 0 20px rgba(244, 164, 96, 0.8),
0 0 40px rgba(244, 164, 96, 0.4),
2px 2px 0 #1a1a2e;
letter-spacing: 0.2em;
}
@keyframes knock-banner-in {
0% {
transform: translate(-50%, -50%) scale(0);
opacity: 0;
}
50% {
transform: translate(-50%, -50%) scale(1.2);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
}
.knock-banner.fading {
animation: knock-banner-out 0.3s ease-out forwards;
}
@keyframes knock-banner-out {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(0.8);
opacity: 0;
}
}
/* Screen shake effect */
@keyframes screen-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-3px); }
40% { transform: translateX(3px); }
60% { transform: translateX(-2px); }
80% { transform: translateX(2px); }
}
body.screen-shake {
animation: screen-shake 0.3s ease-out;
}
/* Enhanced knock early button */
#knock-early-btn {
background: linear-gradient(135deg, #ff6b35 0%, #d63031 100%);
animation: knock-btn-pulse 2s ease-in-out infinite;
}
@keyframes knock-btn-pulse {
0%, 100% {
box-shadow: 0 2px 10px rgba(214, 48, 49, 0.3);
}
50% {
box-shadow: 0 2px 20px rgba(214, 48, 49, 0.5);
}
}
```
### Knock Sound
```javascript
// In playSound() method
} else if (type === 'knock') {
// Dramatic "knock" sound - low thud
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'sine';
osc.frequency.setValueAtTime(80, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(40, ctx.currentTime + 0.15);
gain.gain.setValueAtTime(0.4, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.2);
// Secondary impact
setTimeout(() => {
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.connect(gain2);
gain2.connect(ctx.destination);
osc2.type = 'sine';
osc2.frequency.setValueAtTime(60, ctx.currentTime);
gain2.gain.setValueAtTime(0.2, ctx.currentTime);
gain2.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1);
osc2.start(ctx.currentTime);
osc2.stop(ctx.currentTime + 0.1);
}, 100);
}
```
### Opponent Sees Knock
When another player knocks, show announcement:
```javascript
// In state change detection or game_state handler
if (newState.phase === 'final_turn' && oldState?.phase !== 'final_turn') {
const knocker = newState.players.find(p => p.id === newState.finisher_id);
if (knocker && knocker.id !== this.playerId) {
// Someone else knocked
this.showOpponentKnockAnnouncement(knocker.name);
}
}
showOpponentKnockAnnouncement(playerName) {
this.playSound('alert');
const banner = document.createElement('div');
banner.className = 'opponent-knock-banner';
banner.innerHTML = `<span>${playerName} knocked!</span>`;
document.body.appendChild(banner);
setTimeout(() => {
banner.classList.add('fading');
}, 1500);
setTimeout(() => {
banner.remove();
}, 1800);
}
```
---
## Test Scenarios
1. **Knock with 1 hidden card** - Single flip, then knock banner
2. **Knock with 2 hidden cards** - Rapid double flip
3. **Cancel confirmation** - Modal closes, no action
4. **Opponent knocks** - See announcement
5. **Can't knock (3+ hidden)** - Button disabled
6. **Can't knock (all face-up)** - Button disabled
---
## Acceptance Criteria
- [ ] Confirmation dialog appears before knock
- [ ] Dialog shows number of cards to reveal
- [ ] Cancel button works
- [ ] Knock triggers rapid flip animation
- [ ] "KNOCK!" banner appears with fanfare
- [ ] Subtle screen shake effect
- [ ] Other players see announcement
- [ ] Final turn triggers after knock
- [ ] Sound effects enhance the drama
---
## Implementation Order
1. Add knock sound to `playSound()`
2. Implement `showKnockConfirmation()` method
3. Implement `executeKnockEarly()` method
4. Implement `animateKnockFlips()` method
5. Implement `showKnockBanner()` method
6. Add CSS for modal and banner
7. Implement opponent knock announcement
8. Add screen shake effect
9. Test all scenarios
10. Tune timing for maximum drama
---
## Notes for Agent
- **CSS vs anime.js**: CSS is fine for modal/button animations (UI chrome). Screen shake can use anime.js for precision.
- The confirmation prevents accidental knocks (it's irreversible)
- Keep animation fast - drama without delay
- The screen shake should be subtle (accessibility)
- Consider: skip confirmation option for experienced players?
- Make sure knock works even if animations fail

View File

@@ -0,0 +1,394 @@
# V3-10: Column Pair Indicator
## Overview
When two cards in a column match (forming a pair that scores 0), there's currently no persistent visual indicator. This feature adds a subtle connector showing paired columns at a glance.
**Dependencies:** V3_04 (Column Pair Celebration - this builds on that)
**Dependents:** None
---
## Goals
1. Show which columns are currently paired
2. Visual connector between paired cards
3. Score indicator showing "+0" or "locked"
4. Don't clutter the interface
5. Help new players understand pairing
---
## Current State
After V3_04 (celebration), pairs get a brief animation when formed. But after that animation, there's no indication which columns are paired. Players must remember or scan visually.
---
## Design
### Visual Options
**Option A: Connecting Line**
Draw a subtle line or bracket connecting paired cards.
**Option B: Shared Glow**
Both cards have a subtle shared glow color.
**Option C: Zero Badge**
Small "0" badge on the column.
**Option D: Lock Icon**
Small lock icon indicating "locked in" pair.
**Recommendation:** Option A (line) + Option C (badge) - clear and informative.
### Visual Treatment
```
Normal columns: Paired column:
┌───┐ ┌───┐ ┌───┐ ─┐
│ K │ │ 7 │ │ 5 │ │ [0]
└───┘ └───┘ └───┘ │
┌───┐ ┌───┐ ┌───┐ ─┘
│ Q │ │ 3 │ │ 5 │
└───┘ └───┘ └───┘
```
---
## Implementation
### Detecting Pairs
```javascript
getColumnPairs(cards) {
const pairs = [];
const columns = [[0, 3], [1, 4], [2, 5]];
for (let i = 0; i < columns.length; i++) {
const [top, bottom] = columns[i];
const topCard = cards[top];
const bottomCard = cards[bottom];
if (topCard?.face_up && bottomCard?.face_up &&
topCard?.rank && topCard.rank === bottomCard?.rank) {
pairs.push({
column: i,
topPosition: top,
bottomPosition: bottom,
rank: topCard.rank
});
}
}
return pairs;
}
```
### Rendering Pair Indicators
```javascript
renderPairIndicators(playerId, cards) {
const pairs = this.getColumnPairs(cards);
const container = this.getPairIndicatorContainer(playerId);
// Clear existing indicators
container.innerHTML = '';
if (pairs.length === 0) return;
const cardElements = this.getCardElements(playerId);
for (const pair of pairs) {
const topCard = cardElements[pair.topPosition];
const bottomCard = cardElements[pair.bottomPosition];
if (!topCard || !bottomCard) continue;
// Create connector line
const connector = this.createPairConnector(topCard, bottomCard, pair.column);
container.appendChild(connector);
// Add paired class to cards
topCard.classList.add('paired');
bottomCard.classList.add('paired');
}
}
createPairConnector(topCard, bottomCard, columnIndex) {
const connector = document.createElement('div');
connector.className = 'pair-connector';
connector.dataset.column = columnIndex;
// Calculate position
const topRect = topCard.getBoundingClientRect();
const bottomRect = bottomCard.getBoundingClientRect();
const containerRect = topCard.closest('.card-grid').getBoundingClientRect();
// Position connector to the right of the column
const x = topRect.right - containerRect.left + 5;
const y = topRect.top - containerRect.top;
const height = bottomRect.bottom - topRect.top;
connector.style.cssText = `
left: ${x}px;
top: ${y}px;
height: ${height}px;
`;
// Add zero badge
const badge = document.createElement('div');
badge.className = 'pair-badge';
badge.textContent = '0';
connector.appendChild(badge);
return connector;
}
getPairIndicatorContainer(playerId) {
// Get or create indicator container
const area = playerId === this.playerId
? this.playerCards
: this.opponentsRow.querySelector(`[data-player-id="${playerId}"] .card-grid`);
if (!area) return document.createElement('div'); // Fallback
let container = area.querySelector('.pair-indicators');
if (!container) {
container = document.createElement('div');
container.className = 'pair-indicators';
area.style.position = 'relative';
area.appendChild(container);
}
return container;
}
```
### CSS
```css
/* Pair indicators container */
.pair-indicators {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 5;
}
/* Connector line */
.pair-connector {
position: absolute;
width: 3px;
background: linear-gradient(180deg,
rgba(244, 164, 96, 0.6) 0%,
rgba(244, 164, 96, 0.8) 50%,
rgba(244, 164, 96, 0.6) 100%
);
border-radius: 2px;
}
/* Bracket style alternative */
.pair-connector::before,
.pair-connector::after {
content: '';
position: absolute;
left: 0;
width: 8px;
height: 3px;
background: rgba(244, 164, 96, 0.6);
}
.pair-connector::before {
top: 0;
border-radius: 2px 0 0 0;
}
.pair-connector::after {
bottom: 0;
border-radius: 0 0 0 2px;
}
/* Zero badge */
.pair-badge {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #f4a460;
color: #1a1a2e;
font-size: 0.7em;
font-weight: bold;
padding: 2px 6px;
border-radius: 10px;
white-space: nowrap;
}
/* Paired card subtle highlight */
.card.paired {
box-shadow: 0 0 8px rgba(244, 164, 96, 0.3);
}
/* Opponent paired cards - smaller/subtler */
.opponent-area .pair-connector {
width: 2px;
}
.opponent-area .pair-badge {
font-size: 0.6em;
padding: 1px 4px;
}
.opponent-area .card.paired {
box-shadow: 0 0 5px rgba(244, 164, 96, 0.2);
}
```
### Integration with renderGame
```javascript
// In renderGame(), after rendering cards
renderGame() {
// ... existing rendering ...
// Update pair indicators for all players
for (const player of this.gameState.players) {
this.renderPairIndicators(player.id, player.cards);
}
}
```
### Handling Window Resize
Pair connectors are positioned absolutely, so they need updating on resize:
```javascript
constructor() {
// ... existing constructor ...
// Debounced resize handler for pair indicators
window.addEventListener('resize', this.debounce(() => {
if (this.gameState) {
for (const player of this.gameState.players) {
this.renderPairIndicators(player.id, player.cards);
}
}
}, 100));
}
debounce(fn, delay) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn.apply(this, args), delay);
};
}
```
---
## Alternative: CSS-Only Approach
Simpler approach using only CSS classes:
```javascript
// In renderGame, just add classes
for (const player of this.gameState.players) {
const pairs = this.getColumnPairs(player.cards);
const cards = this.getCardElements(player.id);
// Clear previous
cards.forEach(c => c.classList.remove('paired', 'pair-top', 'pair-bottom'));
for (const pair of pairs) {
cards[pair.topPosition]?.classList.add('paired', 'pair-top');
cards[pair.bottomPosition]?.classList.add('paired', 'pair-bottom');
}
}
```
```css
/* CSS-only pair indication */
.card.pair-top {
border-bottom: 3px solid #f4a460;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.card.pair-bottom {
border-top: 3px solid #f4a460;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.card.paired::after {
content: '';
position: absolute;
right: -10px;
top: 0;
bottom: 0;
width: 3px;
background: rgba(244, 164, 96, 0.5);
}
.card.pair-bottom::after {
top: -100%; /* Extend up to connect */
}
```
**Recommendation:** Start with CSS-only approach. Add connector elements if more visual clarity needed.
---
## Test Scenarios
1. **Single pair** - One column shows indicator
2. **Multiple pairs** - Multiple indicators (rare but possible)
3. **No pairs** - No indicators
4. **Pair broken** - Indicator disappears
5. **Pair formed** - Indicator appears (after celebration)
6. **Face-down card in column** - No indicator
7. **Opponent pairs** - Smaller indicators visible
---
## Acceptance Criteria
- [ ] Paired columns show visual connector
- [ ] "0" badge indicates the score contribution
- [ ] Indicators update when cards change
- [ ] Works for local player and opponents
- [ ] Smaller/subtler for opponents
- [ ] Handles window resize
- [ ] Doesn't clutter interface
- [ ] Helps new players understand pairing
---
## Implementation Order
1. Implement `getColumnPairs()` method
2. Choose approach: CSS-only or connector elements
3. If connector: implement `createPairConnector()`
4. Add CSS for indicators
5. Integrate into `renderGame()`
6. Add resize handling
7. Test various pair scenarios
8. Adjust styling for opponents
---
## Notes for Agent
- **CSS vs anime.js**: CSS is appropriate for static indicators (not animated elements)
- Keep indicators subtle - informative not distracting
- Opponent indicators should be smaller/lighter
- CSS-only approach is simpler to maintain
- The badge helps players learning the scoring system
- Consider: toggle option to hide indicators? (For experienced players)
- Make sure indicators don't overlap cards on mobile

View File

@@ -0,0 +1,317 @@
# V3-11: Swap Animation Improvements
## Overview
When swapping a drawn card with a hand card, the current animation uses a "flip in place + teleport" approach. Physical card games have cards that slide past each other. This feature improves the swap animation to feel more physical.
**Dependencies:** None
**Dependents:** None
---
## Goals
1. Cards visibly exchange positions (not teleport)
2. Old card slides toward discard
3. New card slides into hand slot
4. Brief "crossing" moment visible
5. Smooth, performant animation
6. Works for both face-up and face-down swaps
---
## Current State
From `card-animations.js` (CardAnimations class):
```javascript
// Current swap uses anime.js with pulse effect for face-up swaps
// and flip animation for face-down swaps
animateSwap(position, oldCard, newCard, handCardElement, onComplete) {
if (isAlreadyFaceUp) {
// Face-up swap: subtle pulse, no flip needed
this._animateFaceUpSwap(handCardElement, onComplete);
} else {
// Face-down swap: flip reveal then swap
this._animateFaceDownSwap(position, oldCard, handCardElement, onComplete);
}
}
_animateFaceUpSwap(handCardElement, onComplete) {
anime({
targets: handCardElement,
scale: [1, 0.92, 1.08, 1],
filter: ['brightness(1)', 'brightness(0.85)', 'brightness(1.15)', 'brightness(1)'],
duration: 400,
easing: 'easeOutQuad'
});
}
```
The current animation uses a pulse effect for face-up swaps and a flip reveal for face-down swaps. It works but lacks the physical feeling of cards moving past each other.
---
## Design
### Animation Sequence
```
1. If face-down: Flip hand card to reveal (existing)
2. Lift both cards slightly (z-index, shadow)
3. Hand card arcs toward discard pile
4. Held card arcs toward hand slot
5. Cards cross paths visually (middle of arc)
6. Cards land at destinations
7. Landing pulse effect
```
### Arc Paths
Instead of straight lines, cards follow curved paths:
```
Hand card path
╭─────────────────╮
│ │
[Hand] [Discard]
│ │
╰─────────────────╯
Held card path
```
The curves create a visual "exchange" moment.
---
## Implementation
### Enhanced Swap Animation (Add to CardAnimations class)
```javascript
// In card-animations.js - enhance the existing animateSwap method
async animatePhysicalSwap(handCardEl, heldCardEl, handRect, discardRect, holdingRect, onComplete) {
const T = window.TIMING?.swap || {
lift: 80,
arc: 280,
settle: 60,
};
// Create animation elements that will travel
const travelingHandCard = this.createTravelingCard(handCardEl);
const travelingHeldCard = this.createTravelingCard(heldCardEl);
document.body.appendChild(travelingHandCard);
document.body.appendChild(travelingHeldCard);
// Position at start
this.positionAt(travelingHandCard, handRect);
this.positionAt(travelingHeldCard, holdingRect || discardRect);
// Hide originals
handCardEl.style.visibility = 'hidden';
heldCardEl.style.visibility = 'hidden';
this.playSound('card');
// Use anime.js timeline for coordinated arc movement
const timeline = anime.timeline({
easing: this.getEasing('move'),
complete: () => {
travelingHandCard.remove();
travelingHeldCard.remove();
handCardEl.style.visibility = 'visible';
heldCardEl.style.visibility = 'visible';
this.pulseDiscard();
if (onComplete) onComplete();
}
});
// Calculate arc midpoints
const midY1 = (handRect.top + discardRect.top) / 2 - 40; // Arc up
const midY2 = ((holdingRect || discardRect).top + handRect.top) / 2 + 40; // Arc down
// Step 1: Lift both cards with shadow increase
timeline.add({
targets: [travelingHandCard, travelingHeldCard],
translateY: -10,
boxShadow: '0 8px 30px rgba(0, 0, 0, 0.5)',
scale: 1.02,
duration: T.lift,
easing: this.getEasing('lift')
});
// Step 2: Hand card arcs to discard
timeline.add({
targets: travelingHandCard,
left: discardRect.left,
top: [
{ value: midY1, duration: T.arc / 2 },
{ value: discardRect.top, duration: T.arc / 2 }
],
rotate: [0, -5, 0],
duration: T.arc,
}, `-=${T.lift / 2}`);
// Held card arcs to hand (in parallel)
timeline.add({
targets: travelingHeldCard,
left: handRect.left,
top: [
{ value: midY2, duration: T.arc / 2 },
{ value: handRect.top, duration: T.arc / 2 }
],
rotate: [0, 5, 0],
duration: T.arc,
}, `-=${T.arc + T.lift / 2}`);
// Step 3: Settle
timeline.add({
targets: [travelingHandCard, travelingHeldCard],
translateY: 0,
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.3)',
scale: 1,
duration: T.settle,
});
this.activeAnimations.set('physicalSwap', timeline);
}
createTravelingCard(sourceCard) {
const clone = sourceCard.cloneNode(true);
clone.className = 'traveling-card';
clone.style.position = 'fixed';
clone.style.pointerEvents = 'none';
clone.style.zIndex = '1000';
clone.style.borderRadius = '6px';
return clone;
}
positionAt(element, rect) {
element.style.left = `${rect.left}px`;
element.style.top = `${rect.top}px`;
element.style.width = `${rect.width}px`;
element.style.height = `${rect.height}px`;
}
```
### CSS for Traveling Cards
Minimal CSS needed - anime.js handles all animation properties including box-shadow and scale:
```css
/* Traveling card during swap - base styles only */
.traveling-card {
position: fixed;
border-radius: 6px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
/* All animation handled by anime.js */
}
```
### Timing Configuration
```javascript
// In timing-config.js
swap: {
lift: 80, // Time to lift cards
arc: 280, // Time for arc travel
settle: 60, // Time to settle into place
// Total: ~420ms (similar to current)
}
```
### Note on Animation Approach
All swap animations use anime.js timelines, not CSS transitions or Web Animations API. This provides:
- Better coordination between multiple elements
- Consistent with rest of animation system
- Easier timing control via `window.TIMING`
- Proper animation cancellation via `activeAnimations` tracking
---
## Integration Points
### For Local Player Swap
```javascript
// In animateSwap() method
animateSwap(position) {
const cardElements = this.playerCards.querySelectorAll('.card');
const handCardEl = cardElements[position];
// Get positions
const handRect = handCardEl.getBoundingClientRect();
const discardRect = this.discard.getBoundingClientRect();
const holdingRect = this.getHoldingRect();
// If face-down, flip first (existing logic)
// ...
// Then do physical swap
this.animatePhysicalSwap(
handCardEl,
this.heldCardFloating,
handRect,
discardRect,
holdingRect
);
}
```
### For Opponent Swap
The opponent swap animation in `fireSwapAnimation()` can use similar arc logic for the visible card traveling to discard.
---
## Test Scenarios
1. **Swap face-up card** - Direct arc exchange
2. **Swap face-down card** - Flip first, then arc
3. **Fast repeated swaps** - No animation overlap
4. **Mobile** - Animation performs at 60fps
5. **Different screen sizes** - Arcs scale appropriately
---
## Acceptance Criteria
- [ ] Cards visibly travel to new positions (not teleport)
- [ ] Arc paths create "crossing" visual
- [ ] Lift and settle effects enhance physicality
- [ ] Animation total time ~400ms (not slower than current)
- [ ] Works for face-up and face-down cards
- [ ] Performant on mobile (60fps)
- [ ] Landing effect on discard pile
- [ ] Opponent swaps also improved
---
## Implementation Order
1. Add swap timing to `timing-config.js`
2. Implement `createTravelingCard()` helper
3. Implement `animateArc()` with Web Animations API
4. Implement `animatePhysicalSwap()` method
5. Add CSS for traveling cards
6. Integrate with local player swap
7. Integrate with opponent swap animation
8. Test on various devices
9. Tune arc height and timing
---
## Notes for Agent
- Add `animatePhysicalSwap()` to the existing CardAnimations class
- Use anime.js timelines for coordinated multi-element animation
- Arc height should scale with card distance
- The "crossing" moment is the key visual improvement
- Keep total animation time similar to current (~400ms)
- Track animation in `activeAnimations` for proper cancellation
- Consider: option for "fast mode" with simpler animations?
- Make sure sound timing aligns with visual (card leaving hand)
- Existing `animateSwap()` can call this new method internally

View File

@@ -0,0 +1,279 @@
# V3-12: Draw Source Distinction
## Overview
Drawing from the deck (face-down, unknown) vs discard (face-up, known) should feel different. Currently both animations are similar. This feature enhances the visual distinction.
**Dependencies:** None
**Dependents:** None
---
## Goals
1. Deck draw: Card emerges face-down, then flips
2. Discard draw: Card lifts straight up (already visible)
3. Different sound for each source
4. Visual hint about the strategic difference
5. Help new players understand the two options
---
## Current State
From `card-animations.js` (CardAnimations class):
```javascript
// Deck draw: suspenseful pause + flip reveal
animateDrawDeck(cardData, onComplete) {
// Pulse deck, lift card face-down, move to holding, suspense pause, flip
timeline.add({ targets: inner, rotateY: 0, duration: 245 });
}
// Discard draw: quick decisive grab
animateDrawDiscard(cardData, onComplete) {
// Pulse discard, quick lift, direct move to holding (no flip needed)
timeline.add({ targets: animCard, translateY: -12, scale: 1.05, duration: 42 });
}
```
The distinction exists and is already fairly pronounced. This feature enhances it further with:
- More distinct sounds for each source
- Visual "shuffleDeckVisual" effect when drawing from deck
- Better timing contrast
---
## Design
### Deck Draw (Unknown)
```
1. Deck "shuffles" slightly (optional)
2. Top card lifts off deck
3. Card floats to holding position (face-down)
4. Brief suspense pause
5. Card flips to reveal
6. Sound: "mysterious" flip sound
```
### Discard Draw (Known)
```
1. Card lifts directly (quick)
2. No flip needed - already visible
3. Moves to holding position
4. "Picked up" visual on discard pile
5. Sound: quick "pick" sound
```
### Visual Distinction
| Aspect | Deck Draw | Discard Draw |
|--------|-----------|--------------|
| Card state | Face-down → Face-up | Face-up entire time |
| Motion | Float + flip | Direct lift |
| Sound | Suspenseful flip | Quick pick |
| Duration | Longer (suspense) | Shorter (decisive) |
| Deck visual | Cards shuffle | N/A |
| Discard visual | N/A | "Picked up" state |
---
## Implementation
### Enhanced Deck Draw
The existing `animateDrawDeck()` in `card-animations.js` already has most of this functionality. Enhancements to add:
```javascript
// In card-animations.js - enhance existing animateDrawDeck
// The current implementation already:
// - Pulses deck before drawing (startDrawPulse)
// - Lifts card with wobble
// - Adds suspense pause before flip
// - Flips to reveal with sound
// Add distinct sound for deck draws:
animateDrawDeck(cardData, onComplete) {
// ... existing code ...
// Change sound from 'card' to 'draw-deck' for more mysterious feel
this.playSound('draw-deck'); // Instead of 'card'
// ... rest of existing code ...
}
// The shuffleDeckVisual already exists as startDrawPulse:
startDrawPulse(element) {
if (!element) return;
element.classList.add('draw-pulse');
setTimeout(() => {
element.classList.remove('draw-pulse');
}, 450);
}
```
**Key existing features:**
- `startDrawPulse()` - gold ring pulse effect
- Suspense pause of 200ms before flip
- Flip duration 245ms with `easeInOutQuad` easing
### Enhanced Discard Draw
The existing `animateDrawDiscard()` in `card-animations.js` already has quick, decisive animation:
```javascript
// Current implementation already does:
// - Pulses discard before picking up (startDrawPulse)
// - Quick lift (42ms) with scale
// - Direct move (126ms) - much faster than deck draw
// - No flip needed (card already face-up)
// Enhancement: Add distinct sound for discard draws
_animateDrawDiscardCard(cardData, discardRect, holdingRect, onComplete) {
// ... existing code ...
// Change sound from 'card' to 'draw-discard' for decisive feel
this.playSound('draw-discard'); // Instead of 'card'
// ... rest of existing code ...
}
```
**Current timing comparison (already implemented):**
| Phase | Deck Draw | Discard Draw |
|-------|-----------|--------------|
| Pulse delay | 250ms | 200ms |
| Lift | 105ms | 42ms |
| Travel | 175ms | 126ms |
| Suspense | 200ms | 0ms |
| Flip | 245ms | 0ms |
| Settle | 150ms | 80ms |
| **Total** | **~1125ms** | **~448ms** |
The distinction is already pronounced - discard draw is ~2.5x faster.
### Deck Visual Effects
The `draw-pulse` class already exists with a CSS animation (gold ring expanding). For additional deck depth effect, use CSS only:
```css
/* Deck "depth" visual - multiple card shadows */
#deck {
box-shadow:
1px 1px 0 0 rgba(0, 0, 0, 0.1),
2px 2px 0 0 rgba(0, 0, 0, 0.1),
3px 3px 0 0 rgba(0, 0, 0, 0.1),
4px 4px 8px rgba(0, 0, 0, 0.3);
}
/* Existing draw-pulse animation handles the visual feedback */
.draw-pulse {
/* Already defined in style.css */
}
```
### Distinct Sounds
```javascript
// In playSound() method
} else if (type === 'draw-deck') {
// Mysterious "what's this?" sound
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'triangle';
osc.frequency.setValueAtTime(300, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(500, ctx.currentTime + 0.1);
osc.frequency.exponentialRampToValueAtTime(350, ctx.currentTime + 0.15);
gain.gain.setValueAtTime(0.08, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.2);
} else if (type === 'draw-discard') {
// Quick decisive "grab" sound
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'square';
osc.frequency.setValueAtTime(600, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(300, ctx.currentTime + 0.05);
gain.gain.setValueAtTime(0.08, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.06);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.06);
}
```
---
## Timing Comparison
| Phase | Deck Draw | Discard Draw |
|-------|-----------|--------------|
| Lift | 150ms | 80ms |
| Travel | 250ms | 200ms |
| Suspense | 200ms | 0ms |
| Flip | 350ms | 0ms |
| Settle | 150ms | 80ms |
| **Total** | **~1100ms** | **~360ms** |
Deck draw is intentionally longer to build suspense.
---
## Test Scenarios
1. **Draw from deck** - Longer animation with flip
2. **Draw from discard** - Quick decisive grab
3. **Rapid alternating draws** - Animations don't conflict
4. **CPU draws** - Same visual distinction
---
## Acceptance Criteria
- [ ] Deck draw has suspenseful pause before flip
- [ ] Discard draw is quick and direct
- [ ] Different sounds for each source
- [ ] Deck shows visual "dealing" effect
- [ ] Timing difference is noticeable but not tedious
- [ ] Both animations complete cleanly
- [ ] Works for both local player and opponents
---
## Implementation Order
1. Add distinct sounds to `playSound()`
2. Enhance `animateDrawDeck()` with suspense
3. Enhance `animateDrawDiscard()` for quick grab
4. Add deck visual effects (CSS)
5. Add `shuffleDeckVisual()` method
6. Test both draw types
7. Tune timing for feel
---
## Notes for Agent
- Most of this is already implemented in `card-animations.js`
- Main enhancement is adding distinct sounds (`draw-deck` vs `draw-discard`)
- The existing timing difference (1125ms vs 448ms) is already significant
- Deck draw suspense shouldn't be annoying, just noticeable
- Discard draw being faster reflects the strategic advantage (you know what you're getting)
- Consider: Show deck count visual changing? (Nice to have)
- Sound design matters here - different tones communicate different meanings
- Mobile performance should still be smooth

View File

@@ -0,0 +1,399 @@
# V3-13: Card Value Tooltips
## Overview
New players often forget card values, especially special cards (2=-2, K=0, Joker=-2). This feature adds tooltips showing card point values on long-press or hover.
**Dependencies:** None
**Dependents:** None
---
## Goals
1. Show card point value on long-press (mobile) or hover (desktop)
2. Especially helpful for special value cards
3. Show house rule modified values if active
4. Don't interfere with normal gameplay
5. Optional: disable for experienced players
---
## Current State
No card value tooltips exist. Players must remember:
- Standard values: A=1, 2-10=face, J/Q=10, K=0
- Special values: 2=-2, Joker=-2
- House rules: super_kings=-2, ten_penny=1, etc.
---
## Design
### Tooltip Content
```
┌─────────┐
│ K │ ← Normal card display
│ ♠ │
└─────────┘
┌───────┐
│ 0 pts │ ← Tooltip on hover/long-press
└───────┘
```
For special cards:
```
┌────────────┐
│ -2 pts │
│ (negative!)│
└────────────┘
```
### Activation
- **Desktop:** Hover for 500ms (not instant to avoid cluttering)
- **Mobile:** Long-press (300ms threshold)
- **Dismiss:** Mouse leave / touch release
---
## Implementation
### JavaScript
```javascript
// Card tooltip system
initCardTooltips() {
this.tooltip = document.createElement('div');
this.tooltip.className = 'card-value-tooltip hidden';
document.body.appendChild(this.tooltip);
this.tooltipTimeout = null;
this.currentTooltipTarget = null;
}
bindCardTooltipEvents(cardElement, cardData) {
// Desktop hover
cardElement.addEventListener('mouseenter', () => {
this.scheduleTooltip(cardElement, cardData);
});
cardElement.addEventListener('mouseleave', () => {
this.hideCardTooltip();
});
// Mobile long-press
let pressTimer = null;
cardElement.addEventListener('touchstart', (e) => {
pressTimer = setTimeout(() => {
this.showCardTooltip(cardElement, cardData);
// Prevent triggering card click
e.preventDefault();
}, 300);
});
cardElement.addEventListener('touchend', () => {
clearTimeout(pressTimer);
this.hideCardTooltip();
});
cardElement.addEventListener('touchmove', () => {
clearTimeout(pressTimer);
this.hideCardTooltip();
});
}
scheduleTooltip(cardElement, cardData) {
this.hideCardTooltip();
if (!cardData?.face_up || !cardData?.rank) return;
this.tooltipTimeout = setTimeout(() => {
this.showCardTooltip(cardElement, cardData);
}, 500); // 500ms delay on desktop
}
showCardTooltip(cardElement, cardData) {
if (!cardData?.face_up || !cardData?.rank) return;
const value = this.getCardPointValue(cardData);
const special = this.getCardSpecialNote(cardData);
// Build tooltip content
let content = `<span class="tooltip-value ${value < 0 ? 'negative' : ''}">${value} pts</span>`;
if (special) {
content += `<span class="tooltip-note">${special}</span>`;
}
this.tooltip.innerHTML = content;
// Position tooltip
const rect = cardElement.getBoundingClientRect();
const tooltipRect = this.tooltip.getBoundingClientRect();
let left = rect.left + rect.width / 2;
let top = rect.bottom + 8;
// Keep on screen
if (left + tooltipRect.width / 2 > window.innerWidth) {
left = window.innerWidth - tooltipRect.width / 2 - 10;
}
if (left - tooltipRect.width / 2 < 0) {
left = tooltipRect.width / 2 + 10;
}
if (top + tooltipRect.height > window.innerHeight) {
top = rect.top - tooltipRect.height - 8;
}
this.tooltip.style.left = `${left}px`;
this.tooltip.style.top = `${top}px`;
this.tooltip.classList.remove('hidden');
this.currentTooltipTarget = cardElement;
}
hideCardTooltip() {
clearTimeout(this.tooltipTimeout);
this.tooltip.classList.add('hidden');
this.currentTooltipTarget = null;
}
getCardPointValue(cardData) {
const values = this.gameState?.card_values || {
'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7,
'8': 8, '9': 9, '10': 10, 'J': 10, 'Q': 10, 'K': 0, '★': -2
};
return values[cardData.rank] ?? 0;
}
getCardSpecialNote(cardData) {
const rank = cardData.rank;
const value = this.getCardPointValue(cardData);
// Special notes for notable cards
if (value < 0) {
return 'Negative - keep it!';
}
if (rank === 'K' && value === 0) {
return 'Safe card';
}
if (rank === 'K' && value === -2) {
return 'Super King!';
}
if (rank === '10' && value === 1) {
return 'Ten Penny rule';
}
if (rank === 'J' || rank === 'Q') {
return 'High - replace if possible';
}
return null;
}
```
### CSS
```css
/* Card value tooltip */
.card-value-tooltip {
position: fixed;
transform: translateX(-50%);
background: rgba(26, 26, 46, 0.95);
color: white;
padding: 6px 12px;
border-radius: 8px;
font-size: 0.85em;
text-align: center;
z-index: 500;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transition: opacity 0.15s;
}
.card-value-tooltip.hidden {
opacity: 0;
pointer-events: none;
}
.card-value-tooltip::before {
content: '';
position: absolute;
top: -6px;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-bottom-color: rgba(26, 26, 46, 0.95);
}
.tooltip-value {
display: block;
font-size: 1.2em;
font-weight: bold;
}
.tooltip-value.negative {
color: #27ae60;
}
.tooltip-note {
display: block;
font-size: 0.85em;
color: rgba(255, 255, 255, 0.7);
margin-top: 2px;
}
/* Visual indicator that tooltip is available */
.card[data-has-tooltip]:hover {
cursor: help;
}
```
### Integration with renderGame
```javascript
// In renderGame, after creating card elements
renderPlayerCards() {
// ... existing card rendering ...
const cards = this.playerCards.querySelectorAll('.card');
const myData = this.getMyPlayerData();
cards.forEach((cardEl, i) => {
const cardData = myData?.cards[i];
if (cardData?.face_up) {
cardEl.dataset.hasTooltip = 'true';
this.bindCardTooltipEvents(cardEl, cardData);
}
});
}
// Similar for opponent cards
renderOpponentCards(player, container) {
// ... existing card rendering ...
const cards = container.querySelectorAll('.card');
player.cards.forEach((cardData, i) => {
if (cardData?.face_up && cards[i]) {
cards[i].dataset.hasTooltip = 'true';
this.bindCardTooltipEvents(cards[i], cardData);
}
});
}
```
---
## House Rule Awareness
Tooltip values should reflect active house rules:
```javascript
getCardPointValue(cardData) {
// Use server-provided values which include house rules
if (this.gameState?.card_values) {
return this.gameState.card_values[cardData.rank] ?? 0;
}
// Fallback to defaults
return DEFAULT_CARD_VALUES[cardData.rank] ?? 0;
}
```
The server already provides `card_values` in game state that accounts for:
- `super_kings` (K = -2)
- `ten_penny` (10 = 1)
- `lucky_swing` (Joker = -5)
- etc.
---
## Performance Considerations
- Only bind tooltip events to face-up cards
- Remove tooltip events when cards re-render
- Use event delegation if performance becomes an issue
```javascript
// Event delegation approach
this.playerCards.addEventListener('mouseenter', (e) => {
const card = e.target.closest('.card');
if (card && card.dataset.hasTooltip) {
const cardData = this.getCardDataForElement(card);
this.scheduleTooltip(card, cardData);
}
}, true);
```
---
## Settings Option (Optional)
Let players disable tooltips:
```javascript
// In settings
this.showCardTooltips = localStorage.getItem('showCardTooltips') !== 'false';
// Check before showing
showCardTooltip(cardElement, cardData) {
if (!this.showCardTooltips) return;
// ... rest of method
}
```
---
## Test Scenarios
1. **Hover on face-up card** - Tooltip appears after delay
2. **Long-press on mobile** - Tooltip appears
3. **Move mouse away** - Tooltip disappears
4. **Face-down card** - No tooltip
5. **Special cards (K, 2, Joker)** - Show special note
6. **House rules active** - Modified values shown
7. **Rapid card changes** - No stale tooltips
---
## Acceptance Criteria
- [ ] Hover (500ms delay) shows tooltip on desktop
- [ ] Long-press (300ms) shows tooltip on mobile
- [ ] Tooltip shows point value
- [ ] Negative values highlighted green
- [ ] Special notes for notable cards
- [ ] House rule modified values displayed
- [ ] Tooltips don't interfere with gameplay
- [ ] Tooltips position correctly (stay on screen)
- [ ] Face-down cards have no tooltip
---
## Implementation Order
1. Create tooltip element and basic CSS
2. Implement `showCardTooltip()` method
3. Implement `hideCardTooltip()` method
4. Add desktop hover events
5. Add mobile long-press events
6. Integrate with `renderGame()`
7. Add house rule awareness
8. Test on mobile and desktop
9. Optional: Add settings toggle
---
## Notes for Agent
- **CSS vs anime.js**: CSS is appropriate for tooltip show/hide transitions (simple UI)
- The 500ms delay prevents tooltips appearing during normal play
- Mobile long-press should be discoverable but not intrusive
- Use server-provided `card_values` for house rule accuracy
- Consider: Quick reference card in rules screen? (Separate feature)
- Don't show tooltip during swap animation

View File

@@ -0,0 +1,332 @@
# V3-14: Active Rules Context
## Overview
The active rules bar shows which house rules are in effect, but doesn't highlight when a rule is relevant to the current action. This feature adds contextual highlighting to help players understand rule effects.
**Dependencies:** None
**Dependents:** None
---
## Goals
1. Highlight relevant rules during specific actions
2. Brief explanatory tooltip when rule affects play
3. Help players learn how rules work
4. Don't clutter the interface
5. Fade after the moment passes
---
## Current State
From `app.js`:
```javascript
updateActiveRulesBar() {
const rules = this.gameState.active_rules || [];
if (rules.length === 0) {
this.activeRulesList.innerHTML = '<span class="rule-tag standard">Standard</span>';
} else {
// Show rule tags
}
}
```
Rules are listed but never highlighted contextually.
---
## Design
### Contextual Highlighting Moments
| Moment | Relevant Rule(s) | Highlight Text |
|--------|------------------|----------------|
| Discard from deck | flip_mode: always | "Must flip a card!" |
| Player knocks | knock_penalty | "+10 if not lowest!" |
| Player knocks | knock_bonus | "-5 for going out first" |
| Pair negative cards | negative_pairs_keep_value | "Pairs keep -4!" |
| Draw Joker | lucky_swing | "Worth -5!" |
| Round end | underdog_bonus | "-3 for lowest score" |
| Score = 21 | blackjack | "Blackjack! Score → 0" |
| Four Jacks | wolfpack | "-20 Wolfpack bonus!" |
### Visual Treatment
```
Normal: [Speed Golf] [Knock Penalty]
Highlighted: [Speed Golf ← Must flip!] [Knock Penalty]
Pulsing, expanded
```
---
## Implementation
### Rule Highlight Method
```javascript
highlightRule(ruleKey, message, duration = 3000) {
const ruleTag = this.activeRulesList.querySelector(
`[data-rule="${ruleKey}"]`
);
if (!ruleTag) return;
// Add highlight class
ruleTag.classList.add('rule-highlighted');
// Add message
const messageEl = document.createElement('span');
messageEl.className = 'rule-message';
messageEl.textContent = message;
ruleTag.appendChild(messageEl);
// Remove after duration
setTimeout(() => {
ruleTag.classList.remove('rule-highlighted');
messageEl.remove();
}, duration);
}
```
### Integration Points
```javascript
// In handleMessage or state change handlers
// 1. Speed Golf - must flip after discard
case 'can_flip':
if (!data.optional && this.gameState.flip_mode === 'always') {
this.highlightRule('flip_mode', 'Must flip a card!');
}
break;
// 2. Knock penalty warning
knockEarly() {
if (this.gameState.knock_penalty) {
this.highlightRule('knock_penalty', '+10 if not lowest!', 4000);
}
// ... rest of knock logic
}
// 3. Lucky swing Joker
case 'card_drawn':
if (data.card.rank === '★' && this.gameState.lucky_swing) {
this.highlightRule('lucky_swing', 'Worth -5!');
}
break;
// 4. Blackjack at round end
showScoreboard(scores, isFinal, rankings) {
// Check for blackjack
for (const [playerId, score] of Object.entries(scores)) {
if (score === 0 && this.wasOriginallyBlackjack(playerId)) {
this.highlightRule('blackjack', 'Blackjack! 21 → 0');
}
}
// ... rest of scoreboard logic
}
```
### Update Rule Rendering
Add data attributes for targeting:
```javascript
updateActiveRulesBar() {
const rules = this.gameState.active_rules || [];
if (rules.length === 0) {
this.activeRulesList.innerHTML = '<span class="rule-tag standard">Standard</span>';
return;
}
this.activeRulesList.innerHTML = rules
.map(rule => {
const key = this.getRuleKey(rule);
return `<span class="rule-tag" data-rule="${key}">${rule}</span>`;
})
.join('');
}
getRuleKey(ruleName) {
// Convert display name to key
const mapping = {
'Speed Golf': 'flip_mode',
'Endgame Flip': 'flip_mode',
'Knock Penalty': 'knock_penalty',
'Knock Bonus': 'knock_bonus',
'Super Kings': 'super_kings',
'Ten Penny': 'ten_penny',
'Lucky Swing': 'lucky_swing',
'Eagle Eye': 'eagle_eye',
'Underdog': 'underdog_bonus',
'Tied Shame': 'tied_shame',
'Blackjack': 'blackjack',
'Wolfpack': 'wolfpack',
'Flip Action': 'flip_as_action',
'4 of a Kind': 'four_of_a_kind',
'Negative Pairs': 'negative_pairs_keep_value',
'One-Eyed Jacks': 'one_eyed_jacks',
'Knock Early': 'knock_early',
};
return mapping[ruleName] || ruleName.toLowerCase().replace(/\s+/g, '_');
}
```
### CSS
```css
/* Rule tag base */
.rule-tag {
display: inline-flex;
align-items: center;
padding: 4px 10px;
background: rgba(255, 255, 255, 0.15);
border-radius: 12px;
font-size: 0.8em;
transition: all 0.3s ease;
}
/* Highlighted rule */
.rule-tag.rule-highlighted {
background: rgba(244, 164, 96, 0.3);
box-shadow: 0 0 10px rgba(244, 164, 96, 0.4);
animation: rule-pulse 0.5s ease-out;
}
@keyframes rule-pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
/* Message that appears */
.rule-message {
margin-left: 8px;
padding-left: 8px;
border-left: 1px solid rgba(255, 255, 255, 0.3);
font-weight: bold;
color: #f4a460;
animation: message-fade-in 0.3s ease-out;
}
@keyframes message-fade-in {
0% { opacity: 0; transform: translateX(-5px); }
100% { opacity: 1; transform: translateX(0); }
}
/* Ensure bar is visible when highlighted */
#active-rules-bar:has(.rule-highlighted) {
background: rgba(0, 0, 0, 0.4);
}
```
---
## Rule-Specific Triggers
### Flip Mode (Speed Golf/Endgame)
```javascript
// When player must flip
if (this.waitingForFlip && !this.flipIsOptional) {
this.highlightRule('flip_mode', 'Flip a face-down card!');
}
```
### Knock Penalty/Bonus
```javascript
// When someone triggers final turn
if (newState.phase === 'final_turn' && oldState?.phase !== 'final_turn') {
if (this.gameState.knock_penalty) {
this.highlightRule('knock_penalty', '+10 if beaten!');
}
if (this.gameState.knock_bonus) {
this.highlightRule('knock_bonus', '-5 for going out!');
}
}
```
### Negative Pairs
```javascript
// When pair of 2s or Jokers is formed
checkForNewPairs(oldState, newState, playerId) {
// ... pair detection ...
if (nowPaired && this.gameState.negative_pairs_keep_value) {
const isNegativePair = cardRank === '2' || cardRank === '★';
if (isNegativePair) {
this.highlightRule('negative_pairs_keep_value', 'Keeps -4!');
}
}
}
```
### Score Bonuses (Round End)
```javascript
// In showScoreboard
if (this.gameState.underdog_bonus) {
const lowestPlayer = findLowest(scores);
this.highlightRule('underdog_bonus', `${lowestPlayer} gets -3!`);
}
if (this.gameState.tied_shame) {
const ties = findTies(scores);
if (ties.length > 0) {
this.highlightRule('tied_shame', '+5 for ties!');
}
}
```
---
## Test Scenarios
1. **Speed Golf mode** - "Must flip" highlighted when discarding
2. **Knock with penalty** - Warning shown
3. **Draw Lucky Swing Joker** - "-5" highlighted
4. **Blackjack score** - Celebration when 21 → 0
5. **No active rules** - No highlights
6. **Multiple rules trigger** - All relevant ones highlight
---
## Acceptance Criteria
- [ ] Rules have data attributes for targeting
- [ ] Relevant rule highlights during specific actions
- [ ] Highlight message explains the effect
- [ ] Highlight auto-fades after duration
- [ ] Multiple rules can highlight simultaneously
- [ ] Works for all major house rules
- [ ] Doesn't interfere with gameplay flow
---
## Implementation Order
1. Add `data-rule` attributes to rule tags
2. Implement `getRuleKey()` mapping
3. Implement `highlightRule()` method
4. Add CSS for highlight animation
5. Add trigger points for each major rule
6. Test with various rule combinations
7. Tune timing and messaging
---
## Notes for Agent
- **CSS vs anime.js**: CSS is appropriate for rule tag highlights (simple UI feedback)
- Keep highlight messages very short (3-5 words)
- Don't highlight on every single action, just key moments
- The goal is education, not distraction
- Consider: First-time highlight only? (Too complex for V3)
- Make sure the bar is visible when highlighting (expand if collapsed)

View File

@@ -0,0 +1,384 @@
# V3-15: Discard Pile History
## Overview
In physical card games, you can see the top few cards of the discard pile fanned out slightly. This provides memory aid and context for recent play. Currently our discard pile shows only the top card.
**Dependencies:** None
**Dependents:** None
---
## Goals
1. Show 2-3 recent discards visually fanned
2. Help players track what's been discarded recently
3. Subtle visual depth without cluttering
4. Optional: expandable full discard view
5. Authentic card game feel
---
## Current State
From `app.js` and CSS:
```javascript
// Only shows the top card
updateDiscard(cardData) {
this.discard.innerHTML = this.createCardHTML(cardData);
}
```
The discard pile is a single card element with no history visualization.
---
## Design
### Visual Treatment
```
Current: With history:
┌─────┐ ┌─────┐
│ 7 │ │ 7 │ ← Top card (clickable)
│ ♥ │ ╱└─────┘
└─────┘ └─────┘ ← Previous (faded, offset)
└─────┘ ← Older (more faded)
```
### Fan Layout
- Top card: Full visibility, normal position
- Previous card: Offset 3-4px left and up, 50% opacity
- Older card: Offset 6-8px left and up, 25% opacity
- Maximum 3 visible cards (performance + clarity)
---
## Implementation
### Track Discard History
```javascript
// In app.js constructor
this.discardHistory = [];
this.maxVisibleHistory = 3;
// Update when discard changes
updateDiscardHistory(newCard) {
if (!newCard) {
this.discardHistory = [];
return;
}
// Add new card to front
this.discardHistory.unshift(newCard);
// Keep only recent cards
if (this.discardHistory.length > this.maxVisibleHistory) {
this.discardHistory = this.discardHistory.slice(0, this.maxVisibleHistory);
}
}
// Called from state differ or handleMessage
onDiscardChange(newCard, oldCard) {
// Only add if it's a new card (not initial state)
if (oldCard && newCard && oldCard.rank !== newCard.rank) {
this.updateDiscardHistory(newCard);
} else if (newCard && !oldCard) {
this.updateDiscardHistory(newCard);
}
this.renderDiscardPile();
}
```
### Render Fanned Pile
```javascript
renderDiscardPile() {
const container = this.discard;
container.innerHTML = '';
if (this.discardHistory.length === 0) {
container.innerHTML = '<div class="card empty">Empty</div>';
return;
}
// Render from oldest to newest (back to front)
const cards = [...this.discardHistory].reverse();
cards.forEach((cardData, index) => {
const reverseIndex = cards.length - 1 - index;
const card = this.createDiscardCard(cardData, reverseIndex);
container.appendChild(card);
});
}
createDiscardCard(cardData, depthIndex) {
const card = document.createElement('div');
card.className = 'card discard-card';
card.dataset.depth = depthIndex;
// Only top card is interactive
if (depthIndex === 0) {
card.classList.add('top-card');
card.addEventListener('click', () => this.handleDiscardClick());
}
// Set card content
card.innerHTML = this.createCardContentHTML(cardData);
// Apply offset based on depth
const offset = depthIndex * 4;
card.style.setProperty('--depth-offset', `${offset}px`);
return card;
}
```
### CSS Styling
```css
/* Discard pile container */
#discard {
position: relative;
width: var(--card-width);
height: var(--card-height);
}
/* Stacked discard cards */
.discard-card {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
transition: transform 0.2s, opacity 0.2s;
}
/* Depth-based styling */
.discard-card[data-depth="0"] {
z-index: 3;
opacity: 1;
transform: translate(0, 0);
}
.discard-card[data-depth="1"] {
z-index: 2;
opacity: 0.5;
transform: translate(-4px, -4px);
pointer-events: none;
}
.discard-card[data-depth="2"] {
z-index: 1;
opacity: 0.25;
transform: translate(-8px, -8px);
pointer-events: none;
}
/* Using CSS variable for dynamic offset */
.discard-card:not(.top-card) {
transform: translate(
calc(var(--depth-offset, 0px) * -1),
calc(var(--depth-offset, 0px) * -1)
);
}
/* Hover to expand history slightly */
#discard:hover .discard-card[data-depth="1"] {
opacity: 0.7;
transform: translate(-8px, -8px);
}
#discard:hover .discard-card[data-depth="2"] {
opacity: 0.4;
transform: translate(-16px, -16px);
}
/* Animation when new card is discarded */
@keyframes discard-land {
0% {
transform: translate(0, -20px) scale(1.05);
opacity: 0;
}
100% {
transform: translate(0, 0) scale(1);
opacity: 1;
}
}
.discard-card.top-card.just-landed {
animation: discard-land 0.2s ease-out;
}
/* Shift animation for cards moving back */
@keyframes shift-back {
0% { transform: translate(0, 0); }
100% { transform: translate(var(--depth-offset) * -1, var(--depth-offset) * -1); }
}
```
### Integration with State Changes
```javascript
// In state-differ.js or wherever discard changes are detected
detectDiscardChange(oldState, newState) {
const oldDiscard = oldState?.discard_pile?.[oldState.discard_pile.length - 1];
const newDiscard = newState?.discard_pile?.[newState.discard_pile.length - 1];
if (this.cardsDifferent(oldDiscard, newDiscard)) {
return {
type: 'discard_change',
oldCard: oldDiscard,
newCard: newDiscard
};
}
return null;
}
// Handle the change
handleDiscardChange(change) {
this.onDiscardChange(change.newCard, change.oldCard);
}
```
### Round/Game Reset
```javascript
// Clear history at start of new round
onNewRound() {
this.discardHistory = [];
this.renderDiscardPile();
}
// Or when deck is reshuffled (if that's a game mechanic)
onDeckReshuffle() {
this.discardHistory = [];
}
```
---
## Optional: Expandable Full History
For players who want to see all discards:
```javascript
// Toggle full discard view
showDiscardHistory() {
const modal = document.getElementById('discard-history-modal');
modal.innerHTML = this.buildFullDiscardView();
modal.classList.add('visible');
}
buildFullDiscardView() {
// Show all cards in discard pile from game state
const discards = this.gameState.discard_pile || [];
return discards.map(card =>
`<div class="card mini">${this.createCardContentHTML(card)}</div>`
).join('');
}
```
```css
#discard-history-modal {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
background: rgba(26, 26, 46, 0.95);
padding: 12px;
border-radius: 12px;
display: none;
max-width: 90vw;
overflow-x: auto;
}
#discard-history-modal.visible {
display: flex;
gap: 8px;
}
#discard-history-modal .card.mini {
width: 40px;
height: 56px;
font-size: 0.7em;
}
```
---
## Mobile Considerations
On smaller screens, reduce the fan offset:
```css
@media (max-width: 600px) {
.discard-card[data-depth="1"] {
transform: translate(-2px, -2px);
}
.discard-card[data-depth="2"] {
transform: translate(-4px, -4px);
}
/* Skip hover expansion on touch */
#discard:hover .discard-card {
transform: translate(
calc(var(--depth-offset, 0px) * -0.5),
calc(var(--depth-offset, 0px) * -0.5)
);
}
}
```
---
## Test Scenarios
1. **First discard** - Single card shows
2. **Second discard** - Two cards fanned
3. **Third+ discards** - Three cards max, oldest drops off
4. **New round** - History clears
5. **Draw from discard** - Top card removed, others shift forward
6. **Hover interaction** - Cards fan out slightly more
7. **Mobile view** - Smaller offset, still visible
---
## Acceptance Criteria
- [ ] Recent 2-3 discards visible in fanned pile
- [ ] Older cards progressively more faded
- [ ] Only top card is interactive
- [ ] History updates smoothly when cards change
- [ ] History clears on new round
- [ ] Hover expands fan slightly (desktop)
- [ ] Works on mobile with smaller offsets
- [ ] Optional: expandable full history view
---
## Implementation Order
1. Add `discardHistory` array tracking
2. Implement `renderDiscardPile()` method
3. Add CSS for fanned stack
4. Integrate with state change detection
5. Add round reset handling
6. Add hover expansion effect
7. Test on various screen sizes
8. Optional: Add full history modal
---
## Notes for Agent
- **CSS vs anime.js**: CSS is appropriate for static fan layout. If adding "landing" animation for new discards, use anime.js.
- Keep visible history small (3 cards max) for clarity
- The fan offset should be subtle, not dramatic
- History helps players remember what was recently played
- Consider: Should drawing from discard affect history display?
- Mobile: smaller offset but still visible
- Don't overcomplicate - this is a nice-to-have feature

View File

@@ -0,0 +1,632 @@
# V3-16: Realistic Card Sounds
## Overview
Current sounds use simple Web Audio oscillator beeps. Real card games have distinct sounds: shuffling, dealing, flipping, placing. This feature improves audio feedback to feel more physical.
**Dependencies:** None
**Dependents:** None
---
## Goals
1. Distinct sounds for each card action
2. Variation to avoid repetition fatigue
3. Physical "card" quality (paper, snap, thunk)
4. Volume control and mute option
5. Performant (Web Audio API synthesis or small samples)
---
## Current State
From `app.js` and `card-animations.js`:
```javascript
// app.js has the main playSound method
playSound(type) {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
// Simple beep tones for different actions
}
// CardAnimations routes to app.js via window.game.playSound()
playSound(type) {
if (window.game && typeof window.game.playSound === 'function') {
window.game.playSound(type);
}
}
```
Sounds are functional but feel digital/arcade rather than physical. The existing sound types include:
- `card` - general card movement
- `flip` - card flip
- `shuffle` - deck shuffle
---
## Design
### Sound Palette
| Action | Sound Character | Notes |
|--------|-----------------|-------|
| Card flip | Sharp snap | Paper/cardboard flip |
| Card place | Soft thunk | Card landing on table |
| Card draw | Slide + lift | Taking from pile |
| Card shuffle | Multiple snaps | Riffle texture |
| Pair formed | Satisfying click | Success feedback |
| Knock | Table tap | Knuckle on table |
| Deal | Quick sequence | Multiple snaps |
| Turn notification | Subtle chime | Alert without jarring |
| Round end | Flourish | Resolution feel |
### Synthesis vs Samples
**Option A: Synthesized sounds (current approach, enhanced)**
- No external files needed
- Smaller bundle size
- More control over variations
- Can sound artificial
**Option B: Audio samples**
- More realistic
- Larger file size (small samples ~5-10KB each)
- Need to handle loading
- Can use Web Audio for variations
**Recommendation:** Hybrid - synthesized base with sample layering for key sounds.
---
## Implementation
### Enhanced Sound System
```javascript
// sound-system.js
class SoundSystem {
constructor() {
this.ctx = null;
this.enabled = true;
this.volume = 0.5;
this.samples = {};
this.initialized = false;
}
async init() {
if (this.initialized) return;
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
this.masterGain = this.ctx.createGain();
this.masterGain.connect(this.ctx.destination);
this.masterGain.gain.value = this.volume;
// Load settings
this.enabled = localStorage.getItem('soundEnabled') !== 'false';
this.volume = parseFloat(localStorage.getItem('soundVolume') || '0.5');
this.initialized = true;
}
setVolume(value) {
this.volume = Math.max(0, Math.min(1, value));
if (this.masterGain) {
this.masterGain.gain.value = this.volume;
}
localStorage.setItem('soundVolume', this.volume.toString());
}
setEnabled(enabled) {
this.enabled = enabled;
localStorage.setItem('soundEnabled', enabled.toString());
}
async play(type) {
if (!this.enabled) return;
if (!this.ctx || this.ctx.state === 'suspended') {
await this.ctx?.resume();
}
const now = this.ctx.currentTime;
switch (type) {
case 'flip':
this.playFlip(now);
break;
case 'place':
case 'discard':
this.playPlace(now);
break;
case 'draw-deck':
this.playDrawDeck(now);
break;
case 'draw-discard':
this.playDrawDiscard(now);
break;
case 'pair':
this.playPair(now);
break;
case 'knock':
this.playKnock(now);
break;
case 'deal':
this.playDeal(now);
break;
case 'shuffle':
this.playShuffle(now);
break;
case 'turn':
this.playTurn(now);
break;
case 'round-end':
this.playRoundEnd(now);
break;
case 'win':
this.playWin(now);
break;
default:
this.playGeneric(now);
}
}
// Card flip - sharp snap
playFlip(now) {
// White noise burst for paper snap
const noise = this.createNoiseBurst(0.03, 0.02);
// High frequency click
const click = this.ctx.createOscillator();
const clickGain = this.ctx.createGain();
click.connect(clickGain);
clickGain.connect(this.masterGain);
click.type = 'square';
click.frequency.setValueAtTime(2000 + Math.random() * 500, now);
click.frequency.exponentialRampToValueAtTime(800, now + 0.02);
clickGain.gain.setValueAtTime(0.15, now);
clickGain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
click.start(now);
click.stop(now + 0.05);
}
// Card place - soft thunk
playPlace(now) {
// Low thump
const thump = this.ctx.createOscillator();
const thumpGain = this.ctx.createGain();
thump.connect(thumpGain);
thumpGain.connect(this.masterGain);
thump.type = 'sine';
thump.frequency.setValueAtTime(150 + Math.random() * 30, now);
thump.frequency.exponentialRampToValueAtTime(80, now + 0.08);
thumpGain.gain.setValueAtTime(0.2, now);
thumpGain.gain.exponentialRampToValueAtTime(0.001, now + 0.1);
thump.start(now);
thump.stop(now + 0.1);
// Soft noise
this.createNoiseBurst(0.02, 0.04);
}
// Draw from deck - mysterious slide + flip
playDrawDeck(now) {
// Slide sound
const slide = this.ctx.createOscillator();
const slideGain = this.ctx.createGain();
slide.connect(slideGain);
slideGain.connect(this.masterGain);
slide.type = 'triangle';
slide.frequency.setValueAtTime(200, now);
slide.frequency.exponentialRampToValueAtTime(400, now + 0.1);
slideGain.gain.setValueAtTime(0.08, now);
slideGain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
slide.start(now);
slide.stop(now + 0.12);
// Delayed flip
setTimeout(() => this.playFlip(this.ctx.currentTime), 150);
}
// Draw from discard - quick grab
playDrawDiscard(now) {
const grab = this.ctx.createOscillator();
const grabGain = this.ctx.createGain();
grab.connect(grabGain);
grabGain.connect(this.masterGain);
grab.type = 'square';
grab.frequency.setValueAtTime(600, now);
grab.frequency.exponentialRampToValueAtTime(300, now + 0.04);
grabGain.gain.setValueAtTime(0.1, now);
grabGain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
grab.start(now);
grab.stop(now + 0.05);
}
// Pair formed - satisfying double click
playPair(now) {
// Two quick clicks
for (let i = 0; i < 2; i++) {
const click = this.ctx.createOscillator();
const gain = this.ctx.createGain();
click.connect(gain);
gain.connect(this.masterGain);
click.type = 'triangle';
click.frequency.setValueAtTime(800 + i * 200, now + i * 0.08);
gain.gain.setValueAtTime(0.15, now + i * 0.08);
gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.08 + 0.06);
click.start(now + i * 0.08);
click.stop(now + i * 0.08 + 0.06);
}
}
// Knock - table tap
playKnock(now) {
// Low woody thunk
const knock = this.ctx.createOscillator();
const knockGain = this.ctx.createGain();
knock.connect(knockGain);
knockGain.connect(this.masterGain);
knock.type = 'sine';
knock.frequency.setValueAtTime(120, now);
knock.frequency.exponentialRampToValueAtTime(60, now + 0.1);
knockGain.gain.setValueAtTime(0.3, now);
knockGain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
knock.start(now);
knock.stop(now + 0.15);
// Resonance
const resonance = this.ctx.createOscillator();
const resGain = this.ctx.createGain();
resonance.connect(resGain);
resGain.connect(this.masterGain);
resonance.type = 'triangle';
resonance.frequency.setValueAtTime(180, now);
resGain.gain.setValueAtTime(0.1, now);
resGain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
resonance.start(now);
resonance.stop(now + 0.2);
}
// Deal - rapid card sequence
playDeal(now) {
// Multiple quick snaps
for (let i = 0; i < 4; i++) {
setTimeout(() => {
const snap = this.ctx.createOscillator();
const gain = this.ctx.createGain();
snap.connect(gain);
gain.connect(this.masterGain);
snap.type = 'square';
snap.frequency.setValueAtTime(1500 + Math.random() * 300, this.ctx.currentTime);
gain.gain.setValueAtTime(0.08, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.03);
snap.start(this.ctx.currentTime);
snap.stop(this.ctx.currentTime + 0.03);
}, i * 80);
}
}
// Shuffle - riffle texture
playShuffle(now) {
// Many tiny clicks with frequency variation
for (let i = 0; i < 12; i++) {
setTimeout(() => {
this.createNoiseBurst(0.01, 0.01 + Math.random() * 0.02);
}, i * 40 + Math.random() * 20);
}
}
// Turn notification - gentle chime
playTurn(now) {
const freqs = [523, 659]; // C5, E5
freqs.forEach((freq, i) => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.connect(gain);
gain.connect(this.masterGain);
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, now + i * 0.1);
gain.gain.setValueAtTime(0.1, now + i * 0.1);
gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.1 + 0.3);
osc.start(now + i * 0.1);
osc.stop(now + i * 0.1 + 0.3);
});
}
// Round end - resolution flourish
playRoundEnd(now) {
const freqs = [392, 494, 587, 784]; // G4, B4, D5, G5
freqs.forEach((freq, i) => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.connect(gain);
gain.connect(this.masterGain);
osc.type = 'triangle';
osc.frequency.setValueAtTime(freq, now + i * 0.08);
gain.gain.setValueAtTime(0.12, now + i * 0.08);
gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.08 + 0.4);
osc.start(now + i * 0.08);
osc.stop(now + i * 0.08 + 0.4);
});
}
// Win celebration
playWin(now) {
const freqs = [523, 659, 784, 1047]; // C5, E5, G5, C6
freqs.forEach((freq, i) => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.connect(gain);
gain.connect(this.masterGain);
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, now + i * 0.12);
gain.gain.setValueAtTime(0.15, now + i * 0.12);
gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.12 + 0.5);
osc.start(now + i * 0.12);
osc.stop(now + i * 0.12 + 0.5);
});
}
// Generic click
playGeneric(now) {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.connect(gain);
gain.connect(this.masterGain);
osc.type = 'triangle';
osc.frequency.setValueAtTime(440, now);
gain.gain.setValueAtTime(0.1, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.1);
osc.start(now);
osc.stop(now + 0.1);
}
// Helper: Create white noise burst for paper/snap sounds
createNoiseBurst(volume, duration) {
const bufferSize = this.ctx.sampleRate * duration;
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const output = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
output[i] = Math.random() * 2 - 1;
}
const noise = this.ctx.createBufferSource();
noise.buffer = buffer;
const noiseGain = this.ctx.createGain();
noise.connect(noiseGain);
noiseGain.connect(this.masterGain);
const now = this.ctx.currentTime;
noiseGain.gain.setValueAtTime(volume, now);
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + duration);
noise.start(now);
noise.stop(now + duration);
return noise;
}
}
// Export singleton
const soundSystem = new SoundSystem();
export default soundSystem;
```
### Integration with App
The SoundSystem can replace the existing `playSound()` method in `app.js`:
```javascript
// In app.js - replace the existing playSound method
// Option 1: Direct integration (no import needed for non-module setup)
// Create global instance
window.soundSystem = new SoundSystem();
// Initialize on first interaction
document.addEventListener('click', async () => {
await window.soundSystem.init();
}, { once: true });
// Replace existing playSound calls
playSound(type) {
window.soundSystem.play(type);
}
// CardAnimations already routes through window.game.playSound()
// so no changes needed in card-animations.js
```
### Sound Variation
Add slight randomization to prevent repetitive sounds:
```javascript
playFlip(now) {
// Random variation
const pitchVariation = 1 + (Math.random() - 0.5) * 0.1;
const volumeVariation = 1 + (Math.random() - 0.5) * 0.2;
// Apply to sound...
click.frequency.setValueAtTime(2000 * pitchVariation, now);
clickGain.gain.setValueAtTime(0.15 * volumeVariation, now);
}
```
### Settings UI
```javascript
// In settings panel
renderSoundSettings() {
return `
<div class="setting-group">
<label class="setting-toggle">
<input type="checkbox" id="sound-enabled"
${soundSystem.enabled ? 'checked' : ''}>
<span>Sound Effects</span>
</label>
<label class="setting-slider" ${!soundSystem.enabled ? 'style="opacity: 0.5"' : ''}>
<span>Volume</span>
<input type="range" id="sound-volume"
min="0" max="1" step="0.1"
value="${soundSystem.volume}"
${!soundSystem.enabled ? 'disabled' : ''}>
</label>
</div>
`;
}
// Event handlers
document.getElementById('sound-enabled').addEventListener('change', (e) => {
soundSystem.setEnabled(e.target.checked);
});
document.getElementById('sound-volume').addEventListener('input', (e) => {
soundSystem.setVolume(parseFloat(e.target.value));
});
```
---
## CSS for Settings
```css
.setting-group {
margin-bottom: 16px;
}
.setting-toggle {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.setting-slider {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
transition: opacity 0.2s;
}
.setting-slider input[type="range"] {
flex: 1;
-webkit-appearance: none;
background: rgba(255, 255, 255, 0.2);
height: 4px;
border-radius: 2px;
}
.setting-slider input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
background: #f4a460;
border-radius: 50%;
cursor: pointer;
}
```
---
## Test Scenarios
1. **Card flip** - Sharp snap sound
2. **Card place/discard** - Soft thunk
3. **Draw from deck** - Slide + flip sequence
4. **Draw from discard** - Quick grab
5. **Pair formed** - Double click satisfaction
6. **Knock** - Table tap
7. **Deal sequence** - Rapid snaps
8. **Volume control** - Adjusts all sounds
9. **Mute toggle** - Silences all sounds
10. **Settings persist** - Reload maintains preferences
11. **First interaction** - AudioContext initializes
---
## Acceptance Criteria
- [ ] Distinct sounds for each card action
- [ ] Sounds feel physical (not arcade beeps)
- [ ] Variation prevents repetition fatigue
- [ ] Volume slider works
- [ ] Mute toggle works
- [ ] Settings persist in localStorage
- [ ] AudioContext handles browser restrictions
- [ ] No sound glitches or overlaps
- [ ] Performant (no audio lag)
---
## Implementation Order
1. Create SoundSystem class with basic structure
2. Implement individual sound methods
3. Add noise burst helper for paper sounds
4. Add volume/enabled controls
5. Integrate with existing playSound calls
6. Add variation to prevent repetition
7. Add settings UI
8. Test on various browsers
9. Fine-tune sound character
---
## Notes for Agent
- Replaces existing `playSound()` method in `app.js`
- CardAnimations already routes through `window.game.playSound()` - no changes needed there
- Web Audio API has good browser support
- AudioContext must be created after user interaction
- Noise bursts add realistic texture to card sounds
- Keep sounds short (<200ms) to stay responsive
- Volume variation and pitch variation prevent fatigue
- Test with headphones - sounds should be pleasant, not jarring
- Consider: different sound "themes"? (Classic, Minimal, Fun)
- Mobile: test performance impact of audio synthesis
- Settings should persist in localStorage

276
docs/v3/refactor-ai.md Normal file
View File

@@ -0,0 +1,276 @@
# Plan 2: ai.py Refactor
## Overview
`ai.py` is 1,978 lines with a single function (`choose_swap_or_discard`) at **666 lines** and cyclomatic complexity 50+. The goal is to decompose it into testable, understandable pieces without changing any AI behavior.
Key constraint: **AI behavior must remain identical.** This is pure structural refactoring. We can validate with `python server/simulate.py 500` before and after - stats should match within normal variance.
---
## The Problem Functions
| Function | Lines | What It Does |
|----------|-------|-------------|
| `choose_swap_or_discard()` | ~666 | Decides which position (0-5) to swap drawn card into, or None to discard |
| `calculate_swap_score()` | ~240 | Scores a single position for swapping |
| `should_take_discard()` | ~160 | Decides whether to take from discard pile |
| `process_cpu_turn()` | ~240 | Orchestrates a full CPU turn with timing |
---
## Refactoring Plan
### Step 1: Extract Named Constants
Create section at top of `ai.py` (or a separate `ai_constants.py` if preferred):
```python
# =============================================================================
# AI Decision Constants
# =============================================================================
# Expected value of an unknown (face-down) card, based on deck distribution
EXPECTED_HIDDEN_VALUE = 4.5
# Pessimistic estimate for hidden cards (used in go-out safety checks)
PESSIMISTIC_HIDDEN_VALUE = 6.0
# Conservative estimate (used by conservative personality)
CONSERVATIVE_HIDDEN_VALUE = 2.5
# Cards at or above this value should never be swapped into unknown positions
HIGH_CARD_THRESHOLD = 8
# Maximum card value for unpredictability swaps
UNPREDICTABLE_MAX_VALUE = 7
# Pair potential discount when adjacent card matches
PAIR_POTENTIAL_DISCOUNT = 0.25
# Blackjack target score
BLACKJACK_TARGET = 21
# Base acceptable score range for go-out decisions
GO_OUT_SCORE_BASE = 12
GO_OUT_SCORE_MAX = 20
```
**Locations to update:** ~30 magic number sites across the file. Each becomes a named reference.
### Step 2: Extract Column/Pair Utility Functions
The "iterate columns, check pairs" pattern appears 8+ times. Create shared utilities:
```python
def iter_columns(player: Player):
"""Yield (col_index, top_idx, bot_idx, top_card, bot_card) for each column."""
for col in range(3):
top_idx = col
bot_idx = col + 3
yield col, top_idx, bot_idx, player.cards[top_idx], player.cards[bot_idx]
def project_score(player: Player, swap_pos: int, new_card: Card, options: GameOptions) -> int:
"""Calculate what the player's score would be if new_card were swapped into swap_pos.
Handles pair cancellation correctly. Used by multiple decision paths.
"""
total = 0
for col, top_idx, bot_idx, top_card, bot_card in iter_columns(player):
# Substitute the new card if it's in this column
effective_top = new_card if top_idx == swap_pos else top_card
effective_bot = new_card if bot_idx == swap_pos else bot_card
if effective_top.rank == effective_bot.rank:
# Pair cancels (with house rule exceptions)
continue
total += get_ai_card_value(effective_top, options)
total += get_ai_card_value(effective_bot, options)
return total
def count_hidden(player: Player) -> int:
"""Count face-down cards."""
return sum(1 for c in player.cards if not c.face_up)
def hidden_positions(player: Player) -> list[int]:
"""Get indices of face-down cards."""
return [i for i, c in enumerate(player.cards) if not c.face_up]
def known_score(player: Player, options: GameOptions) -> int:
"""Calculate score from face-up cards only, using EXPECTED_HIDDEN_VALUE for unknowns."""
# Centralized version of the repeated estimation logic
...
```
This replaces duplicated loops at roughly lines: 679, 949, 1002, 1053, 1145, 1213, 1232.
### Step 3: Decompose `choose_swap_or_discard()`
Break into focused sub-functions. The current flow is roughly:
1. **Go-out safety check** (lines ~1087-1186) - "I'm about to go out, pick the best swap to minimize my score"
2. **Score all 6 positions** (lines ~1190-1270) - Calculate swap benefit for each position
3. **Filter and rank candidates** (lines ~1270-1330) - Safety filters, personality tie-breaking
4. **Blackjack special case** (lines ~1330-1380) - If blackjack rule enabled, check for 21
5. **Endgame safety** (lines ~1380-1410) - Don't swap 8+ into unknowns in endgame
6. **Denial logic** (lines ~1410-1480) - Block opponent by taking their useful cards
Proposed decomposition:
```python
def choose_swap_or_discard(player, drawn_card, profile, game, ...) -> Optional[int]:
"""Main orchestrator - delegates to focused sub-functions."""
# Check if we should force a go-out swap
go_out_pos = _check_go_out_swap(player, drawn_card, profile, game, ...)
if go_out_pos is not None:
return go_out_pos
# Score all positions
candidates = _score_all_positions(player, drawn_card, profile, game, ...)
# Apply filters and select best
best = _select_best_candidate(candidates, player, drawn_card, profile, game, ...)
if best is not None:
return best
# Try denial as fallback
return _check_denial_swap(player, drawn_card, profile, game, ...)
def _check_go_out_swap(player, drawn_card, profile, game, ...) -> Optional[int]:
"""If player is close to going out, find the best position to minimize final score.
Handles:
- All-but-one face-up: find the best slot for the drawn card
- Acceptable score threshold based on game state and personality
- Pair completion opportunities
"""
# Lines ~1087-1186 of current choose_swap_or_discard
...
def _score_all_positions(player, drawn_card, profile, game, ...) -> list[tuple[int, float]]:
"""Calculate swap benefit score for each of the 6 positions.
Returns list of (position, score) tuples, sorted by score descending.
Each score represents how much the swap improves the player's hand.
"""
# Lines ~1190-1270 - calls calculate_swap_score() for each position
...
def _select_best_candidate(candidates, player, drawn_card, profile, game, ...) -> Optional[int]:
"""From scored candidates, apply personality modifiers and safety filters.
Handles:
- Minimum improvement threshold
- Personality tie-breaking (pair_hunter prefers pair columns, etc.)
- Unpredictability (occasional random choice with value threshold)
- High-card safety filter (never swap 8+ into hidden positions)
- Blackjack special case (swap to reach exactly 21)
- Endgame safety (discard 8+ rather than force into unknown)
"""
# Lines ~1270-1410
...
def _check_denial_swap(player, drawn_card, profile, game, ...) -> Optional[int]:
"""Check if we should swap to deny opponents a useful card.
Only triggers for profiles with denial_aggression > 0.
Skips hidden positions for high cards (8+).
"""
# Lines ~1410-1480
...
```
### Step 4: Simplify `calculate_swap_score()`
Currently ~240 lines. Some of its complexity comes from inlined pair calculations and standings pressure. Extract:
```python
def _pair_improvement(player, position, new_card, options) -> float:
"""Calculate pair-related benefit of swapping into this position."""
# Would the swap create a new pair? Break an existing pair?
...
def _standings_pressure(player, game) -> float:
"""Calculate how much standings position should affect decisions."""
# Shared between calculate_swap_score and should_take_discard
...
```
### Step 5: Simplify `should_take_discard()`
Currently ~160 lines. Much of the complexity is from re-deriving information that `calculate_swap_score` also computes. After Step 2's utilities exist, this should shrink significantly since `project_score()` and `known_score()` handle the repeated estimation logic.
### Step 6: Clean up `process_cpu_turn()`
Currently ~240 lines. This function is the CPU turn orchestrator and is mostly fine structurally, but has some inline logic for:
- Flip-as-action decisions (~30 lines)
- Knock-early decisions (~30 lines)
- Game logging (~20 lines repeated twice)
Extract:
```python
def _should_flip_as_action(player, game, profile) -> Optional[int]:
"""Decide whether to use flip-as-action and which position."""
...
def _should_knock_early(player, game, profile) -> bool:
"""Decide whether to knock early."""
...
def _log_cpu_action(game_id, player, action, card=None, position=None, reason=""):
"""Log a CPU action if logger is available."""
...
```
---
## Execution Order
1. **Step 1** (constants) - Safe, mechanical, reduces cognitive load immediately
2. **Step 2** (utilities) - Foundation for everything else
3. **Step 3** (decompose choose_swap_or_discard) - The big win
4. **Step 4** (simplify calculate_swap_score) - Benefits from Step 2 utilities
5. **Step 5** (simplify should_take_discard) - Benefits from Step 2 utilities
6. **Step 6** (clean up process_cpu_turn) - Lower priority
**Run `python server/simulate.py 500` before Step 1 and after each step to verify identical behavior.**
---
## Validation Strategy
```bash
# Before any changes - capture baseline
python server/simulate.py 500 > /tmp/ai_baseline.txt
# After each step
python server/simulate.py 500 > /tmp/ai_after_stepN.txt
# Compare key metrics:
# - Average scores per personality
# - "Swapped 8+ into unknown" rate (should stay < 0.1%)
# - Win rate distribution
```
---
## Files Touched
- `server/ai.py` - major restructuring (same file, new internal organization)
- No new files needed (all changes within ai.py unless we decide to split constants out)
## Risk Assessment
- **Low risk** if done mechanically (cut-paste into functions, update call sites)
- **Medium risk** if we accidentally change conditional logic order or miss an early return
- Simulation tests are the safety net - run after every step

View File

@@ -0,0 +1,279 @@
# Plan 1: main.py & game.py Refactor
## Overview
Break apart the 575-line WebSocket handler in `main.py` into discrete message handlers, eliminate repeated patterns (logging, locking, error responses), and clean up `game.py`'s scattered house rule display logic and options boilerplate.
No backwards-compatibility concerns - no existing userbase.
---
## Part A: main.py WebSocket Handler Decomposition
### A1. Create `server/handlers.py` - Message Handler Registry
Extract each `elif msg_type == "..."` block from `websocket_endpoint()` into standalone async handler functions. One function per message type:
```python
# server/handlers.py
async def handle_create_room(ws, data, ctx) -> None: ...
async def handle_join_room(ws, data, ctx) -> None: ...
async def handle_get_cpu_profiles(ws, data, ctx) -> None: ...
async def handle_add_cpu(ws, data, ctx) -> None: ...
async def handle_remove_cpu(ws, data, ctx) -> None: ...
async def handle_start_game(ws, data, ctx) -> None: ...
async def handle_flip_initial(ws, data, ctx) -> None: ...
async def handle_draw(ws, data, ctx) -> None: ...
async def handle_swap(ws, data, ctx) -> None: ...
async def handle_discard(ws, data, ctx) -> None: ...
async def handle_cancel_draw(ws, data, ctx) -> None: ...
async def handle_flip_card(ws, data, ctx) -> None: ...
async def handle_skip_flip(ws, data, ctx) -> None: ...
async def handle_flip_as_action(ws, data, ctx) -> None: ...
async def handle_knock_early(ws, data, ctx) -> None: ...
async def handle_next_round(ws, data, ctx) -> None: ...
async def handle_leave_room(ws, data, ctx) -> None: ...
async def handle_leave_game(ws, data, ctx) -> None: ...
async def handle_end_game(ws, data, ctx) -> None: ...
```
**Context object** passed to every handler:
```python
@dataclass
class ConnectionContext:
websocket: WebSocket
connection_id: str
player_id: str
auth_user_id: Optional[str]
authenticated_user: Optional[User]
current_room: Optional[Room] # mutable reference
```
**Handler dispatch** in `websocket_endpoint()` becomes:
```python
HANDLERS = {
"create_room": handle_create_room,
"join_room": handle_join_room,
# ... etc
}
while True:
data = await websocket.receive_json()
handler = HANDLERS.get(data.get("type"))
if handler:
await handler(data, ctx)
```
This takes `websocket_endpoint()` from ~575 lines to ~30 lines.
### A2. Extract Game Action Logger Helper
The pattern repeated 8 times across draw/swap/discard/flip/skip_flip/flip_as_action/knock_early:
```python
game_logger = get_logger()
if game_logger and current_room.game_log_id and player:
game_logger.log_move(
game_id=current_room.game_log_id,
player=player,
is_cpu=False,
action="...",
card=...,
position=...,
game=current_room.game,
decision_reason="...",
)
```
Extract to:
```python
# In handlers.py or a small helpers module
def log_human_action(room, player, action, card=None, position=None, reason=""):
game_logger = get_logger()
if game_logger and room.game_log_id and player:
game_logger.log_move(
game_id=room.game_log_id,
player=player,
is_cpu=False,
action=action,
card=card,
position=position,
game=room.game,
decision_reason=reason,
)
```
Each handler call site becomes a single line.
### A3. Replace Static File Routes with `StaticFiles` Mount
Currently 15+ hand-written `@app.get()` routes for static files (lines 1188-1255). Replace with:
```python
from fastapi.staticfiles import StaticFiles
# Serve specific HTML routes first
@app.get("/")
async def serve_index():
return FileResponse(os.path.join(client_path, "index.html"))
@app.get("/admin")
async def serve_admin():
return FileResponse(os.path.join(client_path, "admin.html"))
@app.get("/replay/{share_code}")
async def serve_replay_page(share_code: str):
return FileResponse(os.path.join(client_path, "index.html"))
# Mount static files for everything else (JS, CSS, SVG, etc.)
app.mount("/", StaticFiles(directory=client_path), name="static")
```
Eliminates ~70 lines and auto-handles any new client files without code changes.
### A4. Clean Up Lifespan Service Init
The lifespan function (lines 83-242) has a deeply nested try/except block initializing ~8 services with lots of `set_*` calls. Simplify by extracting service init:
```python
async def _init_database_services():
"""Initialize all PostgreSQL-dependent services. Returns dict of services."""
# All the import/init/set logic currently in lifespan
...
async def _init_redis(redis_url):
"""Initialize Redis client and rate limiter."""
...
@asynccontextmanager
async def lifespan(app: FastAPI):
if config.REDIS_URL:
await _init_redis(config.REDIS_URL)
if config.POSTGRES_URL:
await _init_database_services()
# health check setup
...
yield
# shutdown...
```
---
## Part B: game.py Cleanup
### B1. Data-Driven Active Rules Display
Replace the 38-line if-chain in `get_state()` (lines 1546-1584) with a declarative approach:
```python
# On GameOptions class or as module-level constant
_RULE_DISPLAY = [
# (attribute, display_name, condition_fn_or_None)
("knock_penalty", "Knock Penalty", None),
("lucky_swing", "Lucky Swing", None),
("eagle_eye", "Eagle-Eye", None),
("super_kings", "Super Kings", None),
("ten_penny", "Ten Penny", None),
("knock_bonus", "Knock Bonus", None),
("underdog_bonus", "Underdog", None),
("tied_shame", "Tied Shame", None),
("blackjack", "Blackjack", None),
("wolfpack", "Wolfpack", None),
("flip_as_action", "Flip as Action", None),
("four_of_a_kind", "Four of a Kind", None),
("negative_pairs_keep_value", "Negative Pairs Keep Value", None),
("one_eyed_jacks", "One-Eyed Jacks", None),
("knock_early", "Early Knock", None),
]
def get_active_rules(self) -> list[str]:
rules = []
# Special: flip mode
if self.options.flip_mode == FlipMode.ALWAYS.value:
rules.append("Speed Golf")
elif self.options.flip_mode == FlipMode.ENDGAME.value:
rules.append("Endgame Flip")
# Special: jokers (only if not overridden by lucky_swing/eagle_eye)
if self.options.use_jokers and not self.options.lucky_swing and not self.options.eagle_eye:
rules.append("Jokers")
# Boolean rules
for attr, display_name, _ in _RULE_DISPLAY:
if getattr(self.options, attr):
rules.append(display_name)
return rules
```
### B2. Simplify `_options_to_dict()`
Replace the 22-line manual dict construction (lines 791-813) with `dataclasses.asdict()` or a simple comprehension:
```python
from dataclasses import asdict
def _options_to_dict(self) -> dict:
return asdict(self.options)
```
Or if we want to exclude `deck_colors` or similar:
```python
def _options_to_dict(self) -> dict:
return {k: v for k, v in asdict(self.options).items()}
```
### B3. Add `GameOptions.to_start_game_dict()` for main.py
The `start_game` handler in main.py (lines 663-689) manually maps 17 `data.get()` calls to `GameOptions()`. Add a classmethod:
```python
@classmethod
def from_client_data(cls, data: dict) -> "GameOptions":
"""Build GameOptions from client WebSocket message data."""
return cls(
flip_mode=data.get("flip_mode", "never"),
initial_flips=max(0, min(2, data.get("initial_flips", 2))),
knock_penalty=data.get("knock_penalty", False),
use_jokers=data.get("use_jokers", False),
lucky_swing=data.get("lucky_swing", False),
super_kings=data.get("super_kings", False),
ten_penny=data.get("ten_penny", False),
knock_bonus=data.get("knock_bonus", False),
underdog_bonus=data.get("underdog_bonus", False),
tied_shame=data.get("tied_shame", False),
blackjack=data.get("blackjack", False),
eagle_eye=data.get("eagle_eye", False),
wolfpack=data.get("wolfpack", False),
flip_as_action=data.get("flip_as_action", False),
four_of_a_kind=data.get("four_of_a_kind", False),
negative_pairs_keep_value=data.get("negative_pairs_keep_value", False),
one_eyed_jacks=data.get("one_eyed_jacks", False),
knock_early=data.get("knock_early", False),
deck_colors=data.get("deck_colors", ["red", "blue", "gold"]),
)
```
This keeps the construction logic on the class that owns it and out of the WebSocket handler.
---
## Execution Order
1. **B2, B3** (game.py small wins) - low risk, immediate cleanup
2. **A2** (log helper) - extract before moving handlers, so handlers are clean from the start
3. **A1** (handler extraction) - the big refactor, each handler is a cut-paste + cleanup
4. **A3** (static file mount) - easy win, independent
5. **B1** (active rules) - can do anytime
6. **A4** (lifespan cleanup) - lower priority, nice-to-have
## Files Touched
- `server/main.py` - major changes (handler extraction, static files, lifespan)
- `server/handlers.py` - **new file** with all message handlers
- `server/game.py` - minor changes (active rules, options_to_dict, from_client_data)
## Testing
- All existing tests in `test_game.py` should continue passing (game.py changes are additive/cosmetic)
- The WebSocket handler refactor is structural only - same logic, just reorganized
- Manual smoke test: create room, add CPU, play a round, verify everything works

175
docs/v3/refactor-misc.md Normal file
View File

@@ -0,0 +1,175 @@
# Plan 3: Miscellaneous Refactoring & Improvements
## Overview
Everything that doesn't fall under the main.py/game.py or ai.py refactors: shared utilities, dead code, test improvements, and structural cleanup.
---
## M1. Duplicate `get_card_value` Functions
There are currently **three** functions that compute card values:
1. `game.py:get_card_value(card: Card, options)` - Takes Card objects
2. `constants.py:get_card_value_for_rank(rank_str, options_dict)` - Takes rank strings
3. `ai.py:get_ai_card_value(card, options)` - AI-specific wrapper (also handles face-down estimation)
**Problem:** `game.py` and `constants.py` do the same thing with different interfaces, and neither handles all house rules identically. The AI version adds face-down logic but duplicates the base value lookup.
**Fix:**
- Keep `game.py:get_card_value()` as the canonical Card-based function (it already is the most complete)
- Keep `constants.py:get_card_value_for_rank()` for string-based lookups from logs/JSON
- Have `ai.py:get_ai_card_value()` delegate to `game.py:get_card_value()` for the base value, only adding its face-down estimation on top
- Add a brief comment in each noting which is canonical and why each variant exists
This is a minor cleanup - the current code works, it's just slightly confusing to have three entry points.
## M2. `GameOptions` Boilerplate Reduction
`GameOptions` currently has 17+ boolean fields. Every time a new house rule is added, you have to update:
1. `GameOptions` dataclass definition
2. `_options_to_dict()` in game.py
3. `get_active_rules()` logic in `get_state()`
4. `from_client_data()` (proposed in Plan 1)
5. `start_game` handler in main.py (currently, will move to handlers.py)
**Fix:** Use `dataclasses.fields()` introspection to auto-generate the dict and client data parsing:
```python
from dataclasses import fields, asdict
# _options_to_dict becomes:
def _options_to_dict(self) -> dict:
return asdict(self.options)
# from_client_data becomes:
@classmethod
def from_client_data(cls, data: dict) -> "GameOptions":
field_defaults = {f.name: f.default for f in fields(cls)}
kwargs = {}
for f in fields(cls):
if f.name in data:
kwargs[f.name] = data[f.name]
# Special validation
kwargs["initial_flips"] = max(0, min(2, kwargs.get("initial_flips", 2)))
return cls(**kwargs)
```
This means adding a new house rule only requires adding the field to `GameOptions` and its entry in the active_rules display table (from Plan 1's B1).
## M3. Consolidate Game Logger Pattern in AI
`ai.py:process_cpu_turn()` has the same logger boilerplate as main.py's human handlers. After Plan 1's A2 creates `log_human_action()`, create a parallel:
```python
def log_cpu_action(game_id, player, action, card=None, position=None, game=None, reason=""):
game_logger = get_logger()
if game_logger and game_id:
game_logger.log_move(
game_id=game_id,
player=player,
is_cpu=True,
action=action,
card=card,
position=position,
game=game,
decision_reason=reason,
)
```
This appears ~4 times in `process_cpu_turn()`.
## M4. `Player.get_player()` Linear Search
`Game.get_player()` does a linear scan of the players list:
```python
def get_player(self, player_id: str) -> Optional[Player]:
for player in self.players:
if player.id == player_id:
return player
return None
```
With max 6 players this is fine performance-wise, but it's called frequently. Could add a `_player_lookup: dict[str, Player]` cache maintained by `add_player`/`remove_player`. Very minor optimization - only worth doing if we're already touching these methods.
## M5. Room Code Collision Potential
`RoomManager._generate_code()` generates random 4-letter codes and retries on collision. With 26^4 = 456,976 possibilities this is fine now, but if we ever scale, the while-True loop could theoretically spin. Low priority, but a simple improvement:
```python
def _generate_code(self, max_attempts=100) -> str:
for _ in range(max_attempts):
code = "".join(random.choices(string.ascii_uppercase, k=4))
if code not in self.rooms:
return code
raise RuntimeError("Could not generate unique room code")
```
## M6. Test Coverage Gaps
Current test files:
- `test_game.py` - Core game logic (good coverage)
- `test_house_rules.py` - House rule scoring
- `test_v3_features.py` - New v3 features
- `test_maya_bug.py` - Specific regression test
- `tests/test_event_replay.py`, `test_persistence.py`, `test_replay.py` - Event system
**Missing:**
- No tests for `room.py` (Room, RoomManager, RoomPlayer)
- No tests for WebSocket message handlers (will be much easier to test after Plan 1's handler extraction)
- No unit tests for individual AI decision functions (will be much easier after Plan 2's decomposition)
**Recommendation:** After Plans 1 and 2 are complete, add:
- `test_handlers.py` - Test each message handler with mock WebSocket/Room
- `test_ai_decisions.py` - Test individual AI sub-functions (go-out logic, denial, etc.)
- `test_room.py` - Test Room/RoomManager CRUD operations
## M7. Unused/Dead Code Audit
Things to verify and potentially remove:
- `score_analysis.py` - Is this used anywhere or was it a one-off analysis tool?
- `game_analyzer.py` - Same question
- `auth.py` (top-level, not in routers/) - Appears to be an old file superseded by `services/auth_service.py`?
- `models/game_state.py` - Check if used or leftover from earlier design
## M8. Type Hints Consistency
Some functions have full type hints, others don't. The AI functions especially are loosely typed. After the ai.py refactor (Plan 2), ensure all new sub-functions have proper type hints:
```python
def _check_go_out_swap(
player: Player,
drawn_card: Card,
profile: CPUProfile,
game: Game,
game_state: dict,
) -> Optional[int]:
```
This helps with IDE navigation and catching bugs during future changes.
---
## Execution Order
1. **M3** (AI logger helper) - Do alongside Plan 1's A2
2. **M2** (GameOptions introspection) - Do alongside Plan 1's B2/B3
3. **M1** (card value consolidation) - Quick cleanup
4. **M7** (dead code audit) - Quick investigation
5. **M5** (room code safety) - 2 lines
6. **M6** (tests) - After Plans 1 and 2 are complete
7. **M4** (player lookup) - Only if touching add/remove_player for other reasons
8. **M8** (type hints) - Ongoing, do as part of Plan 2
## Files Touched
- `server/ai.py` - logger helper, card value delegation
- `server/game.py` - GameOptions introspection
- `server/constants.py` - comments clarifying role
- `server/room.py` - room code safety (minor)
- `server/test_room.py` - **new file** (eventually)
- `server/test_handlers.py` - **new file** (eventually)
- `server/test_ai_decisions.py` - **new file** (eventually)
- Various files checked in dead code audit

View File

@@ -0,0 +1,42 @@
# Remaining Refactor Tasks
Leftover items from the v3 refactor plans that are functional but could benefit from further cleanup.
---
## R1. Decompose `calculate_swap_score()` (from Plan 2, Step 4)
**File:** `server/ai.py` (~236 lines)
Scores a single position for swapping. Still long with inline pair calculations, point gain logic, reveal bonuses, and comeback bonuses. Could extract:
- `_pair_improvement(player, position, new_card, options)` — pair-related benefit of swapping into a position
- `_standings_pressure(player, game)` — how much standings position should affect decisions (shared with `should_take_discard`)
**Validation:** `python server/simulate.py 500` before and after — stats should match within normal variance.
---
## R2. Decompose `should_take_discard()` (from Plan 2, Step 5)
**File:** `server/ai.py` (~148 lines)
Decides whether to take from discard pile. Contains a nested `has_good_swap_option()` helper. After R1's extracted utilities exist, this should shrink since `project_score()` and `known_score()` handle the repeated estimation logic.
**Validation:** Same simulation approach as R1.
---
## R3. New Test Files (from Plan 3, M6)
After Plans 1 and 2, the extracted handlers and AI sub-functions are much easier to unit test. Add:
- **`server/test_handlers.py`** — Test each message handler with mock WebSocket/Room
- **`server/test_ai_decisions.py`** — Test individual AI sub-functions (go-out logic, denial, etc.)
- **`server/test_room.py`** — Test Room/RoomManager CRUD operations
---
## Priority
R1 and R2 are pure structural refactors — no behavior changes, low risk, but also low urgency since the code works fine. R3 adds safety nets for future changes.