diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 64e5d3c..f89d748 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - staging jobs: test: @@ -80,7 +81,7 @@ jobs: name: Deploy to Render needs: [test, integration-test] runs-on: ubuntu-latest - if: success() && github.ref == 'refs/heads/main' + if: success() && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') steps: - name: Trigger Render Deployment run: | diff --git a/backend/snake.db b/backend/snake.db index 3c4e06c..7fb600a 100644 Binary files a/backend/snake.db and b/backend/snake.db differ diff --git a/frontend/src/__tests__/fixes.test.ts b/frontend/src/__tests__/fixes.test.ts new file mode 100644 index 0000000..52bd2e4 --- /dev/null +++ b/frontend/src/__tests__/fixes.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getScoreMultiplier, getFinalScore, createInitialState } from '@/lib/gameLogic'; +import type { GameState } from '@/types'; + +// Mocking browser event behavior for input test logic +describe('Bug Fix Verification', () => { + + describe('Score Multiplier Logic', () => { + it('applies 1.5x multiplier for walls mode', () => { + const mode = 'walls'; + const multiplier = getScoreMultiplier(mode); + expect(multiplier).toBe(1.5); + }); + + it('applies 1x multiplier for pass-through mode', () => { + const mode = 'pass-through'; + const multiplier = getScoreMultiplier(mode); + expect(multiplier).toBe(1); + }); + + it('calculates final score correctly with multiplier', () => { + const state: GameState = { + ...createInitialState('walls'), + score: 100, + mode: 'walls' + }; + const finalScore = getFinalScore(state); + expect(finalScore).toBe(150); // 100 * 1.5 + }); + }); + + describe('Input Event Suppression Logic', () => { + // Simulating the check logic used in useSnakeGame hook + const shouldSuppressInput = (target: any) => { + return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement; + }; + + it('suppresses game controls when input is focused', () => { + const inputElement = document.createElement('input'); + expect(shouldSuppressInput(inputElement)).toBe(true); + }); + + it('suppresses game controls when textarea is focused', () => { + const textareaElement = document.createElement('textarea'); + expect(shouldSuppressInput(textareaElement)).toBe(true); + }); + + it('does not suppress controls when body or div is focused', () => { + const divElement = document.createElement('div'); + const bodyElement = document.body; + + expect(shouldSuppressInput(divElement)).toBe(false); + expect(shouldSuppressInput(bodyElement)).toBe(false); + }); + }); + + describe('User Reset Logic', () => { + // Simulating the useEffect dependency logic + let gameState = createInitialState('pass-through'); + const resetGame = () => { + gameState = createInitialState('pass-through'); + }; + + it('resets game state when simulated user changes', () => { + // Simulate game progress + gameState.score = 50; + gameState.status = 'playing'; + + // Simulate user change trigger + resetGame(); + + expect(gameState.score).toBe(0); + expect(gameState.status).toBe('idle'); + expect(gameState.snake.length).toBe(3); + }); + }); +}); diff --git a/frontend/src/components/game/GameControls.tsx b/frontend/src/components/game/GameControls.tsx index 3580f79..ae073fa 100644 --- a/frontend/src/components/game/GameControls.tsx +++ b/frontend/src/components/game/GameControls.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { Button } from '@/components/ui/button'; import type { GameState, GameMode, Direction } from '@/types'; import { Play, Pause, RotateCcw, ArrowUp, ArrowDown, ArrowLeft, ArrowRight } from 'lucide-react'; -import { cn } from '@/lib/utils'; interface GameControlsProps { gameState: GameState; @@ -10,9 +9,7 @@ interface GameControlsProps { onPause: () => void; onResume: () => void; onReset: () => void; - onModeChange: (mode: GameMode) => void; onDirectionChange: (direction: Direction) => void; - finalScore: number; } export function GameControls({ @@ -21,71 +18,22 @@ export function GameControls({ onPause, onResume, onReset, - onModeChange, onDirectionChange, - finalScore, }: GameControlsProps) { - const { status, mode, score } = gameState; + const { status } = gameState; return ( -
- {/* Score Display */} -
-
-

Score

-

{String(score)}

- {mode === 'walls' && ( -

1.5x multiplier active

- )} -
-
- - {/* Mode Selection */} -
-

Game Mode

-
- - -
-

- {mode === 'pass-through' - ? 'Snake wraps around edges' - : 'Hit a wall = Game Over (1.5x score)'} -

-
- - {/* Game Controls */} -
+
+ {/* Game Action Buttons - Compact Row */} +
{status === 'idle' && ( )} @@ -93,50 +41,59 @@ export function GameControls({ )} {status === 'paused' && ( - + <> + + + )} - {(status === 'game-over' || status === 'paused') && ( + {status === 'game-over' && ( )}
- {/* Mobile D-Pad Controls */} -
-

Touch Controls

-
+ {/* Mobile D-Pad Controls - Larger & Centered */} +
+
@@ -145,41 +102,31 @@ export function GameControls({ size="icon" onClick={() => onDirectionChange('LEFT')} disabled={status !== 'playing'} - className="aspect-square" + className="aspect-square h-16 w-16 rounded-2xl bg-background/50 border-primary/30 active:scale-95 transition-transform active:bg-primary/20" > - + -
- -
-
- - {/* Keyboard hints */} -
-

Controls:

-

Arrow Keys or WASD to move

-

Space to pause • R to restart

-
); } + diff --git a/frontend/src/components/game/GameOverlay.tsx b/frontend/src/components/game/GameOverlay.tsx new file mode 100644 index 0000000..6f2198c --- /dev/null +++ b/frontend/src/components/game/GameOverlay.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { Badge } from '@/components/ui/badge'; +import { GameState, GameMode } from '@/types'; +import { cn } from '@/lib/utils'; +import { Settings2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface GameOverlayProps { + score: number; + mode: GameMode; + onOpenMenu: () => void; +} + +export function GameOverlay({ score, mode, onOpenMenu }: GameOverlayProps) { + return ( +
+
+
+ Score + + {score} + +
+
+ +
+ + {mode === 'pass-through' ? 'NO WALLS' : 'WALLS'} + + + +
+
+ ); +} diff --git a/frontend/src/components/game/MenuModal.tsx b/frontend/src/components/game/MenuModal.tsx new file mode 100644 index 0000000..e48ca0a --- /dev/null +++ b/frontend/src/components/game/MenuModal.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from '@/components/ui/button'; +import { GameMode } from '@/types'; +import { cn } from '@/lib/utils'; +import { Keyboard, MousePointer2 } from 'lucide-react'; + +interface MenuModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + currentMode: GameMode; + onModeChange: (mode: GameMode) => void; + gameStatus: string; +} + +export function MenuModal({ + open, + onOpenChange, + currentMode, + onModeChange, + gameStatus +}: MenuModalProps) { + return ( + + + + Game Settings + + Configure your game experience + + + +
+
+

Game Mode

+
+ + +
+
+ +
+

+ Controls +

+
+
+ Move + WASD / Arrows +
+
+ Pause + Space +
+
+ Restart + R +
+
+ +
+ + Use on-screen controls or swipe +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 527e99a..c0730dc 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React from 'react'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -7,102 +7,94 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { AuthModal } from '@/components/auth/AuthModal'; import { useAuthContext } from '@/contexts/AuthContext'; import { User, LogOut, Trophy, Gamepad2 } from 'lucide-react'; interface HeaderProps { activeTab: string; onTabChange: (tab: string) => void; + onAuthRequest: () => void; } -export function Header({ activeTab, onTabChange }: HeaderProps) { - const [authModalOpen, setAuthModalOpen] = useState(false); +export function Header({ activeTab, onTabChange, onAuthRequest }: HeaderProps) { const { user, isAuthenticated, logout } = useAuthContext(); return ( - <> -
-
-
- {/* Logo */} -
-
- -
-

- SNAKE -

+
+
+
+
+
+
+

+ SNAKE +

+
- {/* Navigation Tabs */} - + - {/* User Menu */} - {isAuthenticated && user ? ( - - - - - -
- Signed in as -
- {user.email} -
- - logout()}> - - Log out - -
-
- ) : ( - - )} -
+ {isAuthenticated && user ? ( + + + + + +
+ Signed in as +
+ {user.email} +
+ + logout()}> + + Log out + +
+
+ ) : ( + + )}
-
- - - +
+
); } diff --git a/frontend/src/hooks/useSnakeGame.ts b/frontend/src/hooks/useSnakeGame.ts index bb764ae..61ddb78 100644 --- a/frontend/src/hooks/useSnakeGame.ts +++ b/frontend/src/hooks/useSnakeGame.ts @@ -107,9 +107,20 @@ export function useSnakeGame(initialMode: GameMode = 'pass-through'): UseSnakeGa }; }, [gameState.status, gameState.speed, processDirectionQueue]); + // Reset game when user changes (new login/logout) + useEffect(() => { + setGameState(createInitialState(initialMode)); + directionQueueRef.current = []; + }, [user?.username]); // Use username as trigger + // Keyboard controls useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + // Prevent game controls when typing in inputs + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return; + } + if (gameState.status !== 'playing') return; const keyToDirection: Record = { diff --git a/frontend/src/pages/Index.tsx b/frontend/src/pages/Index.tsx index b2a08ef..51fcb07 100644 --- a/frontend/src/pages/Index.tsx +++ b/frontend/src/pages/Index.tsx @@ -5,61 +5,91 @@ import { GameBoard } from '@/components/game/GameBoard'; import { GameControls } from '@/components/game/GameControls'; import { Leaderboard } from '@/components/game/Leaderboard'; import { SpectatorView } from '@/components/game/SpectatorView'; +import { GameOverlay } from '@/components/game/GameOverlay'; +import { MenuModal } from '@/components/game/MenuModal'; +import { AuthModal } from '@/components/auth/AuthModal'; import { useSnakeGame } from '@/hooks/useSnakeGame'; function GamePage() { const [activeTab, setActiveTab] = useState('play'); + const [menuOpen, setMenuOpen] = useState(false); + const [authOpen, setAuthOpen] = useState(false); const game = useSnakeGame('pass-through'); + // Pause game when modals are open + React.useEffect(() => { + if (menuOpen || authOpen) { + game.pauseGame(); + } + }, [menuOpen, authOpen]); + return ( -
-
+
+
setAuthOpen(true)} + /> -
+
{activeTab === 'play' && ( -
-
- {/* Game Board - Constrained height to prevent scrolling */} -
-
- -
-
+
+ + {/* Game Controls & Info Header */} +
+ setMenuOpen(true)} + /> +
- {/* Controls Sidebar */} -
- + {/* Game Area Container - Dynamic Fitting */} +
+
+
+ + {/* Controls Area */} +
+ +
)} {activeTab === 'leaderboard' && ( -
+
)} {activeTab === 'spectate' && ( -
+
)}
- {/* Footer */} -
-

SNAKE GAME

-
+ { + game.setMode(mode); + setMenuOpen(false); + }} + gameStatus={game.gameState.status} + /> + +
); }