- 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>
166 lines
6.5 KiB
JavaScript
166 lines
6.5 KiB
JavaScript
// StateDiffer - Detects what changed between game states
|
|
// Generates movement instructions for the animation queue
|
|
|
|
class StateDiffer {
|
|
constructor() {
|
|
this.previousState = null;
|
|
}
|
|
|
|
// Compare old and new state, return array of movements
|
|
diff(oldState, newState) {
|
|
const movements = [];
|
|
|
|
if (!oldState || !newState) {
|
|
return movements;
|
|
}
|
|
|
|
// Check for initial flip phase - still animate initial flips
|
|
if (oldState.waiting_for_initial_flip && !newState.waiting_for_initial_flip) {
|
|
// Initial flip just completed - detect which cards were flipped
|
|
for (const newPlayer of newState.players) {
|
|
const oldPlayer = oldState.players.find(p => p.id === newPlayer.id);
|
|
if (oldPlayer) {
|
|
for (let i = 0; i < 6; i++) {
|
|
if (!oldPlayer.cards[i].face_up && newPlayer.cards[i].face_up) {
|
|
movements.push({
|
|
type: 'flip',
|
|
playerId: newPlayer.id,
|
|
position: i,
|
|
faceUp: true,
|
|
card: newPlayer.cards[i]
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return movements;
|
|
}
|
|
|
|
// Still in initial flip selection - no animations
|
|
if (newState.waiting_for_initial_flip) {
|
|
return movements;
|
|
}
|
|
|
|
// Check for turn change - the previous player just acted
|
|
const previousPlayerId = oldState.current_player_id;
|
|
const currentPlayerId = newState.current_player_id;
|
|
const turnChanged = previousPlayerId !== currentPlayerId;
|
|
|
|
// Detect if a swap happened (discard changed AND a hand position changed)
|
|
const newTop = newState.discard_top;
|
|
const oldTop = oldState.discard_top;
|
|
const discardChanged = newTop && (!oldTop ||
|
|
oldTop.rank !== newTop.rank ||
|
|
oldTop.suit !== newTop.suit);
|
|
|
|
// Find hand changes for the player who just played
|
|
if (turnChanged && previousPlayerId) {
|
|
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
|
|
const newPlayer = newState.players.find(p => p.id === previousPlayerId);
|
|
|
|
if (oldPlayer && newPlayer) {
|
|
// First pass: detect swaps (card identity changed)
|
|
const swappedPositions = new Set();
|
|
for (let i = 0; i < 6; i++) {
|
|
const oldCard = oldPlayer.cards[i];
|
|
const newCard = newPlayer.cards[i];
|
|
|
|
// Card identity changed = swap happened at this position
|
|
if (this.cardIdentityChanged(oldCard, newCard)) {
|
|
swappedPositions.add(i);
|
|
|
|
// Use discard_top for the revealed card (more reliable for opponents)
|
|
const revealedCard = newState.discard_top || { ...oldCard, face_up: true };
|
|
|
|
movements.push({
|
|
type: 'swap',
|
|
playerId: previousPlayerId,
|
|
position: i,
|
|
oldCard: revealedCard,
|
|
newCard: newCard
|
|
});
|
|
break; // Only one swap per turn
|
|
}
|
|
}
|
|
|
|
// Second pass: detect flips (card went from face_down to face_up, not a swap)
|
|
for (let i = 0; i < 6; i++) {
|
|
if (swappedPositions.has(i)) continue; // Skip if already detected as swap
|
|
|
|
const oldCard = oldPlayer.cards[i];
|
|
const newCard = newPlayer.cards[i];
|
|
|
|
if (this.cardWasFlipped(oldCard, newCard)) {
|
|
movements.push({
|
|
type: 'flip',
|
|
playerId: previousPlayerId,
|
|
position: i,
|
|
faceUp: true,
|
|
card: newCard
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Detect drawing (current player just drew)
|
|
if (newState.has_drawn_card && !oldState.has_drawn_card) {
|
|
// Discard pile decreased = drew from discard
|
|
const drewFromDiscard = !newState.discard_top ||
|
|
(oldState.discard_top &&
|
|
(!newState.discard_top ||
|
|
oldState.discard_top.rank !== newState.discard_top.rank ||
|
|
oldState.discard_top.suit !== newState.discard_top.suit));
|
|
|
|
movements.push({
|
|
type: drewFromDiscard ? 'draw-discard' : 'draw-deck',
|
|
playerId: currentPlayerId,
|
|
card: drewFromDiscard ? oldState.discard_top : null // Include card for discard draw animation
|
|
});
|
|
}
|
|
|
|
return movements;
|
|
}
|
|
|
|
// Check if the card identity (rank+suit) changed between old and new
|
|
// Returns true if definitely different cards, false if same or unknown
|
|
cardIdentityChanged(oldCard, newCard) {
|
|
// If both have rank/suit data, compare directly
|
|
if (oldCard.rank && newCard.rank) {
|
|
return oldCard.rank !== newCard.rank || oldCard.suit !== newCard.suit;
|
|
}
|
|
// Can't determine - assume same card (flip, not swap)
|
|
return false;
|
|
}
|
|
|
|
// Check if a card was just flipped (same card, now face up)
|
|
cardWasFlipped(oldCard, newCard) {
|
|
return !oldCard.face_up && newCard.face_up;
|
|
}
|
|
|
|
// Get a summary of movements for debugging
|
|
summarize(movements) {
|
|
return movements.map(m => {
|
|
switch (m.type) {
|
|
case 'flip':
|
|
return `Flip: Player ${m.playerId} position ${m.position}`;
|
|
case 'swap':
|
|
return `Swap: Player ${m.playerId} position ${m.position}`;
|
|
case 'discard':
|
|
return `Discard: ${m.card.rank}${m.card.suit} from player ${m.fromPlayerId}`;
|
|
case 'draw-deck':
|
|
return `Draw from deck: Player ${m.playerId}`;
|
|
case 'draw-discard':
|
|
return `Draw from discard: Player ${m.playerId}`;
|
|
default:
|
|
return `Unknown: ${m.type}`;
|
|
}
|
|
}).join('\n');
|
|
}
|
|
}
|
|
|
|
// Export for use in app.js
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = StateDiffer;
|
|
}
|