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

@@ -194,9 +194,10 @@ class CardAnimations {
this.startDrawPulse(document.getElementById('deck'));
// Delay card animation to let pulse be visible
const pulseDelay = window.TIMING?.draw?.pulseDelay || 200;
setTimeout(() => {
this._animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete);
}, 250);
}, pulseDelay);
}
_animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) {
@@ -204,6 +205,7 @@ class CardAnimations {
const animCard = this.createAnimCard(deckRect, true, deckColor);
animCard.dataset.animating = 'true'; // Mark as actively animating
const inner = animCard.querySelector('.draw-anim-inner');
const D = window.TIMING?.draw || {};
if (cardData) {
this.setCardContent(animCard, cardData);
@@ -231,36 +233,36 @@ class CardAnimations {
targets: animCard,
translateY: -15,
rotate: [-2, 0],
duration: 63,
duration: D.deckLift || 120,
easing: this.getEasing('lift')
});
// Move to holding position
// Move to holding position with smooth deceleration
timeline.add({
targets: animCard,
left: holdingRect.left,
top: holdingRect.top,
translateY: 0,
duration: 105,
duration: D.deckMove || 250,
easing: this.getEasing('move')
});
// Suspense pause
timeline.add({ duration: 200 });
// Brief pause before flip (easing handles the deceleration feel)
timeline.add({ duration: D.deckRevealPause || 80 });
// Flip to reveal
if (cardData) {
timeline.add({
targets: inner,
rotateY: 0,
duration: 245,
duration: D.deckFlip || 320,
easing: this.getEasing('flip'),
begin: () => this.playSound('flip')
});
}
// Brief pause to see card
timeline.add({ duration: 150 });
timeline.add({ duration: D.deckViewPause || 120 });
this.activeAnimations.set('drawDeck', timeline);
} catch (e) {
@@ -286,15 +288,17 @@ class CardAnimations {
// Pulse discard pile
this.startDrawPulse(document.getElementById('discard'));
const pulseDelay = window.TIMING?.draw?.pulseDelay || 200;
setTimeout(() => {
this._animateDrawDiscardCard(cardData, discardRect, holdingRect, onComplete);
}, 200);
}, pulseDelay);
}
_animateDrawDiscardCard(cardData, discardRect, holdingRect, onComplete) {
const animCard = this.createAnimCard(discardRect, false);
animCard.dataset.animating = 'true'; // Mark as actively animating
this.setCardContent(animCard, cardData);
const D = window.TIMING?.draw || {};
// Hide actual discard pile during animation to prevent visual conflict
const discardPile = document.getElementById('discard');
@@ -326,21 +330,23 @@ class CardAnimations {
targets: animCard,
translateY: -12,
scale: 1.05,
duration: 25
duration: D.discardLift || 80,
easing: this.getEasing('lift')
});
// Direct move to holding
// Direct move to holding with smooth deceleration
timeline.add({
targets: animCard,
left: holdingRect.left,
top: holdingRect.top,
translateY: 0,
scale: 1,
duration: 76
duration: D.discardMove || 200,
easing: this.getEasing('move')
});
// Minimal pause
timeline.add({ duration: 80 });
// Brief settle
timeline.add({ duration: D.discardViewPause || 60 });
this.activeAnimations.set('drawDiscard', timeline);
} catch (e) {
@@ -365,7 +371,7 @@ class CardAnimations {
return;
}
const duration = 245; // 30% faster flip
const duration = window.TIMING?.card?.flip || 320;
try {
const anim = anime({
@@ -408,7 +414,7 @@ class CardAnimations {
cardElement.style.opacity = '0';
const inner = animCard.querySelector('.draw-anim-inner');
const duration = 245; // 30% faster flip
const duration = window.TIMING?.card?.flip || 320;
try {
// Simple smooth flip - no lift/settle
@@ -452,7 +458,7 @@ class CardAnimations {
cardElement.classList.add('swap-out');
const inner = animCard.querySelector('.draw-anim-inner');
const duration = 245; // 30% faster flip
const duration = window.TIMING?.card?.flip || 320;
// Helper to restore card to face-up state
const restoreCard = () => {
@@ -551,7 +557,7 @@ class CardAnimations {
handCardElement.classList.add('swap-out');
const inner = animCard.querySelector('.draw-anim-inner');
const flipDuration = 245; // 30% faster flip
const flipDuration = window.TIMING?.card?.flip || 320;
try {
const timeline = anime.timeline({
@@ -572,7 +578,7 @@ class CardAnimations {
});
// Brief pause to see the card
timeline.add({ duration: 100 });
timeline.add({ duration: 50 });
this.activeAnimations.set(`swap-${Date.now()}`, timeline);
} catch (e) {
@@ -606,7 +612,7 @@ class CardAnimations {
sourceCardElement.classList.add('swap-out');
const inner = animCard.querySelector('.draw-anim-inner');
const flipDuration = 245; // 30% faster flip
const flipDuration = window.TIMING?.card?.flip || 320;
try {
anime.timeline({
@@ -661,8 +667,8 @@ class CardAnimations {
width: discardRect.width,
height: discardRect.height,
scale: 1,
duration: 350,
easing: 'cubicBezier(0.25, 0.1, 0.25, 1)'
duration: window.TIMING?.card?.move || 300,
easing: this.getEasing('arc')
});
this.activeAnimations.set(`discard-${Date.now()}`, timeline);
@@ -686,7 +692,7 @@ class CardAnimations {
this.setCardContent(animCard, card);
const inner = animCard.querySelector('.draw-anim-inner');
const moveDuration = window.TIMING?.card?.move || 270;
const moveDuration = window.TIMING?.card?.move || 300;
try {
const timeline = anime.timeline({
@@ -943,7 +949,7 @@ class CardAnimations {
return;
}
const T = window.TIMING?.swap || { lift: 80, arc: 280, settle: 60 };
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
const handRect = handCardEl.getBoundingClientRect();
const heldRect = heldCardEl.getBoundingClientRect();
const discardRect = this.getDiscardRect();
@@ -1005,6 +1011,7 @@ class CardAnimations {
],
rotate: [0, -3, 0],
duration: T.arc,
easing: this.getEasing('arc'),
}, `-=${T.lift / 2}`);
// Held card arcs to hand slot (parallel)
@@ -1017,14 +1024,16 @@ class CardAnimations {
],
rotate: [0, 3, 0],
duration: T.arc,
easing: this.getEasing('arc'),
}, `-=${T.arc + T.lift / 2}`);
// Settle
// Settle with gentle overshoot
timeline.add({
targets: [travelingHand, travelingHeld],
translateY: 0,
scale: 1,
scale: [1.02, 1],
duration: T.settle,
easing: this.getEasing('settle'),
});
this.activeAnimations.set('physicalSwap', timeline);
@@ -1046,7 +1055,7 @@ class CardAnimations {
// options: { rotation, wasHandFaceDown, onComplete }
animateUnifiedSwap(handCardData, heldCardData, handRect, heldRect, options = {}) {
const { rotation = 0, wasHandFaceDown = false, onComplete } = options;
const T = window.TIMING?.swap || { lift: 80, arc: 280, settle: 60 };
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
const discardRect = this.getDiscardRect();
// Safety checks
@@ -1105,10 +1114,11 @@ class CardAnimations {
// Flip to reveal, then do the swap
this.playSound('flip');
const flipDuration = window.TIMING?.card?.flip || 320;
anime({
targets: inner,
rotateY: 0,
duration: 245,
duration: flipDuration,
easing: this.getEasing('flip'),
complete: () => {
this._doArcSwap(travelingHand, travelingHeld, handRect, heldRect, discardRect, T, rotation, onComplete);
@@ -1157,6 +1167,7 @@ class CardAnimations {
height: discardRect.height,
rotate: [rotation, rotation - 3, 0],
duration: T.arc,
easing: this.getEasing('arc'),
}, `-=${T.lift / 2}`);
// Held card arcs to hand slot (apply rotation to match hand position)
@@ -1171,14 +1182,16 @@ class CardAnimations {
height: handRect.height,
rotate: [0, 3, rotation],
duration: T.arc,
easing: this.getEasing('arc'),
}, `-=${T.arc + T.lift / 2}`);
// Settle
// Settle with gentle overshoot
timeline.add({
targets: [travelingHand, travelingHeld],
translateY: 0,
scale: 1,
scale: [1.02, 1],
duration: T.settle,
easing: this.getEasing('settle'),
});
this.activeAnimations.set('unifiedSwap', timeline);
@@ -1199,7 +1212,7 @@ class CardAnimations {
return;
}
const T = window.TIMING?.swap || { lift: 80, arc: 280, settle: 60 };
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
// Create a traveling card showing the face at the held card's actual position
const travelingCard = this.createCardFromData(cardData, heldRect, 0);
@@ -1242,14 +1255,16 @@ class CardAnimations {
height: discardRect.height,
rotate: [0, -2, 0],
duration: T.arc,
easing: this.getEasing('arc'),
}, `-=${T.lift / 2}`);
// Settle
// Settle with gentle overshoot
timeline.add({
targets: travelingCard,
translateY: 0,
scale: 1,
scale: [1.02, 1],
duration: T.settle,
easing: this.getEasing('settle'),
});
this.activeAnimations.set('heldToDiscard', timeline);
@@ -1291,7 +1306,7 @@ class CardAnimations {
}
_runOpponentDiscard(cardData, holdingRect, discardRect, onComplete) {
const T = window.TIMING?.swap || { lift: 80, arc: 280, settle: 60 };
const T = window.TIMING?.swap || { lift: 100, arc: 320, settle: 100 };
// Create card at holding position, face-up (already revealed by draw animation)
const travelingCard = this.createAnimCard(holdingRect, false);
@@ -1336,14 +1351,16 @@ class CardAnimations {
scale: 1,
rotate: [0, -2, 0],
duration: T.arc,
easing: this.getEasing('arc'),
});
// Settle
// Settle with gentle overshoot
timeline.add({
targets: travelingCard,
translateY: 0,
scale: 1,
scale: [1.02, 1],
duration: T.settle,
easing: this.getEasing('settle'),
});
this.activeAnimations.set('opponentDiscard', timeline);