golfgame/client/state-differ.js
adlee-was-taken 9fc6b83bba 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>
2026-02-14 10:03:45 -05:00

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;
}