- 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>
533 lines
15 KiB
Markdown
533 lines
15 KiB
Markdown
# 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
|