From 926dd3732b2e0da4770079a0a16f113af2a30388 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sun, 1 Feb 2026 20:52:07 -0600 Subject: [PATCH] Add WebSocket message types and related tests - Add complete WebSocket message type definitions - Add game action types (PlayCard, Attack, EndTurn, etc.) - Add client/server message schemas - Add tests for types and store - Add PlayPage tests Co-Authored-By: Claude Sonnet 4.5 --- frontend/src/pages/PlayPage.spec.ts | 427 ++++++++++++++++++++++ frontend/src/stores/game.spec.ts | 405 +++++++++++++++++++++ frontend/src/types/ws.spec.ts | 301 +++++++++++++++ frontend/src/types/ws.ts | 546 ++++++++++++++++++++++++++++ 4 files changed, 1679 insertions(+) create mode 100644 frontend/src/pages/PlayPage.spec.ts create mode 100644 frontend/src/stores/game.spec.ts create mode 100644 frontend/src/types/ws.spec.ts create mode 100644 frontend/src/types/ws.ts diff --git a/frontend/src/pages/PlayPage.spec.ts b/frontend/src/pages/PlayPage.spec.ts new file mode 100644 index 0000000..c5b5351 --- /dev/null +++ b/frontend/src/pages/PlayPage.spec.ts @@ -0,0 +1,427 @@ +/** + * Tests for PlayPage component. + * + * Verifies the game lobby functionality including active games display, + * deck selection, and game creation. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { createRouter, createMemoryHistory } from 'vue-router' +import { computed } from 'vue' + +import PlayPage from './PlayPage.vue' +import { useGames } from '@/composables/useGames' +import { useDecks } from '@/composables/useDecks' +import type { ActiveGameSummary } from '@/types' +import type { Deck } from '@/types' +import type { UseGames } from '@/composables/useGames' +import type { UseDecks } from '@/composables/useDecks' + +// Mock composables +vi.mock('@/composables/useGames') +vi.mock('@/composables/useDecks') + +describe('PlayPage', () => { + let router: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + + // Create router + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: { template: 'Home' } }, + { path: '/game/:id', component: { template: 'Game' } }, + { path: '/decks', component: { template: 'Decks' } }, + ], + }) + + // Reset mocks + vi.clearAllMocks() + }) + + it('renders the page title', () => { + /** + * Test that the PlayPage renders the correct page title. + * + * The page title helps users understand they are in the game lobby + * where they can create or resume games. + */ + vi.mocked(useGames).mockReturnValue({ + activeGames: computed(() => []), + hasActiveGames: computed(() => false), + isLoading: computed(() => false), + error: computed(() => null), + isFetching: computed(() => false), + isCreating: computed(() => false), + fetchActiveGames: vi.fn().mockResolvedValue({ success: true, data: [] }), + createGame: vi.fn(), + fetchGameInfo: vi.fn(), + clear: vi.fn(), + clearError: vi.fn(), + } as unknown as UseGames) + + vi.mocked(useDecks).mockReturnValue({ + decks: computed(() => []), + isLoading: computed(() => false), + error: computed(() => null), + fetchDecks: vi.fn().mockResolvedValue({ success: true, data: [] }), + } as unknown as UseDecks) + + const wrapper = mount(PlayPage, { + global: { + plugins: [router], + }, + }) + + expect(wrapper.text()).toContain('Play') + }) + + it('displays active games when available', async () => { + /** + * Test that active games are displayed in the UI. + * + * Users need to see their active games so they can resume playing. + * Each game should show opponent name, turn status, and resume button. + */ + const mockActiveGames: ActiveGameSummary[] = [ + { + game_id: 'game-1', + game_type: 'freeplay', + opponent_name: 'TestOpponent', + is_your_turn: true, + turn_number: 5, + started_at: new Date().toISOString(), + last_action_at: new Date().toISOString(), + }, + ] + + vi.mocked(useGames).mockReturnValue({ + activeGames: computed(() => mockActiveGames), + hasActiveGames: computed(() => true), + isLoading: computed(() => false), + error: computed(() => null), + isFetching: computed(() => false), + isCreating: computed(() => false), + fetchActiveGames: vi.fn().mockResolvedValue({ success: true, data: mockActiveGames }), + createGame: vi.fn(), + fetchGameInfo: vi.fn(), + clear: vi.fn(), + clearError: vi.fn(), + } as unknown as UseGames) + + vi.mocked(useDecks).mockReturnValue({ + decks: computed(() => []), + isLoading: computed(() => false), + error: computed(() => null), + fetchDecks: vi.fn().mockResolvedValue({ success: true, data: [] }), + } as unknown as UseDecks) + + const wrapper = mount(PlayPage, { + global: { + plugins: [router], + }, + }) + + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('vs TestOpponent') + expect(wrapper.text()).toContain('Your Turn') + expect(wrapper.text()).toContain('Resume') + }) + + it('shows "no active games" message when list is empty', async () => { + /** + * Test that a helpful message is shown when there are no active games. + * + * This guides new users to create their first game. + */ + vi.mocked(useGames).mockReturnValue({ + activeGames: computed(() => []), + hasActiveGames: computed(() => false), + isLoading: computed(() => false), + error: computed(() => null), + isFetching: computed(() => false), + isCreating: computed(() => false), + fetchActiveGames: vi.fn().mockResolvedValue({ success: true, data: [] }), + createGame: vi.fn(), + fetchGameInfo: vi.fn(), + clear: vi.fn(), + clearError: vi.fn(), + } as unknown as UseGames) + + vi.mocked(useDecks).mockReturnValue({ + decks: computed(() => []), + isLoading: computed(() => false), + error: computed(() => null), + fetchDecks: vi.fn().mockResolvedValue({ success: true, data: [] }), + } as unknown as UseDecks) + + const wrapper = mount(PlayPage, { + global: { + plugins: [router], + }, + }) + + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('No active games') + }) + + it('displays deck selector with valid decks', async () => { + /** + * Test that the deck selector shows all valid decks. + * + * Users must select a valid deck to create a game. Invalid decks + * are filtered out to prevent errors during game creation. + */ + const mockDecks: Deck[] = [ + { + id: 'deck-1', + name: 'Fire Deck', + cards: [], + energyCards: {}, + isValid: true, + cardCount: 40, + isStarter: false, + }, + { + id: 'deck-2', + name: 'Water Deck', + cards: [], + energyCards: {}, + isValid: true, + cardCount: 40, + isStarter: true, + }, + { + id: 'deck-3', + name: 'Invalid Deck', + cards: [], + energyCards: {}, + isValid: false, + validationErrors: ['Not enough cards'], + cardCount: 20, + isStarter: false, + }, + ] + + vi.mocked(useGames).mockReturnValue({ + activeGames: computed(() => []), + hasActiveGames: computed(() => false), + isLoading: computed(() => false), + error: computed(() => null), + isFetching: computed(() => false), + isCreating: computed(() => false), + fetchActiveGames: vi.fn().mockResolvedValue({ success: true, data: [] }), + createGame: vi.fn(), + fetchGameInfo: vi.fn(), + clear: vi.fn(), + clearError: vi.fn(), + } as unknown as UseGames) + + vi.mocked(useDecks).mockReturnValue({ + decks: computed(() => mockDecks), + isLoading: computed(() => false), + error: computed(() => null), + fetchDecks: vi.fn().mockResolvedValue({ success: true, data: mockDecks }), + } as unknown as UseGames) + + const wrapper = mount(PlayPage, { + global: { + plugins: [router], + }, + }) + + await wrapper.vm.$nextTick() + + const select = wrapper.find('#deck-select') + expect(select.exists()).toBe(true) + + // Should only show valid decks (2 out of 3) + const options = select.findAll('option') + expect(options.length).toBe(2) + expect(wrapper.text()).toContain('Fire Deck') + expect(wrapper.text()).toContain('Water Deck') + expect(wrapper.text()).not.toContain('Invalid Deck') + }) + + it('creates a game when create button is clicked', async () => { + /** + * Test that clicking the create game button calls the createGame API. + * + * Game creation is the primary action on this page. After successful + * creation, the user should be navigated to the game page. + */ + const mockFetchActiveGames = vi.fn().mockResolvedValue({ success: true, data: [] }) + const mockFetchDecks = vi.fn().mockResolvedValue({ success: true, data: [] }) + const mockCreateGame = vi.fn().mockResolvedValue({ + success: true, + data: { + game_id: 'new-game-123', + ws_url: 'ws://localhost:8000/game', + starting_player_id: 'player-1', + message: 'Game created', + }, + }) + + const mockDecks: Deck[] = [ + { + id: 'deck-1', + name: 'Test Deck', + cards: [], + energyCards: {}, + isValid: true, + cardCount: 40, + isStarter: false, + }, + ] + + vi.mocked(useGames).mockReturnValue({ + activeGames: computed(() => []), + hasActiveGames: computed(() => false), + isLoading: computed(() => false), + error: computed(() => null), + isFetching: computed(() => false), + isCreating: computed(() => false), + fetchActiveGames: mockFetchActiveGames, + createGame: mockCreateGame, + fetchGameInfo: vi.fn(), + clear: vi.fn(), + clearError: vi.fn(), + } as unknown as UseGames) + + vi.mocked(useDecks).mockReturnValue({ + decks: computed(() => mockDecks), + isLoading: computed(() => false), + error: computed(() => null), + fetchDecks: mockFetchDecks, + } as unknown as UseGames) + + const wrapper = mount(PlayPage, { + global: { + plugins: [router], + }, + }) + + // Wait for onMounted to complete + await mockFetchActiveGames() + await mockFetchDecks() + await wrapper.vm.$nextTick() + + // Find the Create Game button + const buttons = wrapper.findAll('button') + const createButton = buttons.find(btn => btn.text().includes('Create Game')) + expect(createButton).toBeDefined() + + await createButton!.trigger('click') + await wrapper.vm.$nextTick() + + expect(mockCreateGame).toHaveBeenCalledWith({ + deck_id: 'deck-1', + opponent_id: 'placeholder-opponent', + opponent_deck_id: 'placeholder-deck', + game_type: 'freeplay', + }) + }) + + it('shows warning when no valid decks are available', async () => { + /** + * Test that a warning is shown when user has no valid decks. + * + * Users cannot create games without a valid deck. The UI should + * guide them to create or fix their decks before playing. + */ + vi.mocked(useGames).mockReturnValue({ + activeGames: computed(() => []), + hasActiveGames: computed(() => false), + isLoading: computed(() => false), + error: computed(() => null), + isFetching: computed(() => false), + isCreating: computed(() => false), + fetchActiveGames: vi.fn().mockResolvedValue({ success: true, data: [] }), + createGame: vi.fn(), + fetchGameInfo: vi.fn(), + clear: vi.fn(), + clearError: vi.fn(), + } as unknown as UseGames) + + vi.mocked(useDecks).mockReturnValue({ + decks: computed(() => []), + isLoading: computed(() => false), + error: computed(() => null), + fetchDecks: vi.fn().mockResolvedValue({ success: true, data: [] }), + } as unknown as UseDecks) + + const wrapper = mount(PlayPage, { + global: { + plugins: [router], + }, + }) + + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('No Valid Decks') + expect(wrapper.text()).toContain('Go to Decks') + }) + + it('navigates to game page when resume button is clicked', async () => { + /** + * Test that clicking resume navigates to the game page. + * + * Users should be able to resume active games by clicking the resume + * button, which navigates them to the game page with the correct game ID. + */ + const mockActiveGames: ActiveGameSummary[] = [ + { + game_id: 'game-123', + game_type: 'freeplay', + opponent_name: 'Opponent', + is_your_turn: false, + turn_number: 3, + started_at: new Date().toISOString(), + last_action_at: new Date().toISOString(), + }, + ] + + vi.mocked(useGames).mockReturnValue({ + activeGames: computed(() => mockActiveGames), + hasActiveGames: computed(() => true), + isLoading: computed(() => false), + error: computed(() => null), + isFetching: computed(() => false), + isCreating: computed(() => false), + fetchActiveGames: vi.fn().mockResolvedValue({ success: true, data: mockActiveGames }), + createGame: vi.fn(), + fetchGameInfo: vi.fn(), + clear: vi.fn(), + clearError: vi.fn(), + } as unknown as UseGames) + + vi.mocked(useDecks).mockReturnValue({ + decks: computed(() => []), + isLoading: computed(() => false), + error: computed(() => null), + fetchDecks: vi.fn().mockResolvedValue({ success: true, data: [] }), + } as unknown as UseDecks) + + const wrapper = mount(PlayPage, { + global: { + plugins: [router], + }, + }) + + await wrapper.vm.$nextTick() + + const buttons = wrapper.findAll('button') + const resumeButton = buttons.find(btn => btn.text().includes('Resume')) + expect(resumeButton).toBeDefined() + + await resumeButton!.trigger('click') + await router.isReady() + + expect(router.currentRoute.value.path).toBe('/game/game-123') + }) +}) diff --git a/frontend/src/stores/game.spec.ts b/frontend/src/stores/game.spec.ts new file mode 100644 index 0000000..472cd78 --- /dev/null +++ b/frontend/src/stores/game.spec.ts @@ -0,0 +1,405 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' + +import { useGameStore } from './game' +import type { VisibleGameState, VisiblePlayerState, Action } from '@/types' +import { ConnectionStatus } from '@/types' + +describe('useGameStore', () => { + beforeEach(() => { + // Create a new pinia instance for each test + setActivePinia(createPinia()) + }) + + // --------------------------------------------------------------------------- + // Initial State + // --------------------------------------------------------------------------- + + it('starts with null game state', () => { + /** + * Test that the game store initializes with no active game. + * + * This ensures we start in a clean state and don't have + * stale data from previous sessions. + */ + const store = useGameStore() + + expect(store.gameState).toBeNull() + expect(store.currentGameId).toBeNull() + expect(store.isInGame).toBe(false) + expect(store.connectionStatus).toBe(ConnectionStatus.DISCONNECTED) + expect(store.lastEventId).toBeNull() + expect(store.pendingActions).toHaveLength(0) + }) + + // --------------------------------------------------------------------------- + // setGameState + // --------------------------------------------------------------------------- + + it('sets game state correctly', () => { + /** + * Test that setGameState updates all relevant state fields. + * + * The setGameState action is called when we receive a game state + * from the server. It should update the game state, game ID, + * and set the in-game flag. + */ + const store = useGameStore() + const mockState: VisibleGameState = createMockGameState('game-123', 'player-1') + + store.setGameState(mockState, 'event-001') + + expect(store.gameState).toEqual(mockState) + expect(store.currentGameId).toBe('game-123') + expect(store.isInGame).toBe(true) + expect(store.lastEventId).toBe('event-001') + expect(store.error).toBeNull() + }) + + it('updates lastEventId when provided', () => { + /** + * Test that event IDs are tracked for reconnection support. + * + * The lastEventId allows the client to request missed events + * after a reconnection, ensuring state consistency. + */ + const store = useGameStore() + const mockState: VisibleGameState = createMockGameState('game-123', 'player-1') + + store.setGameState(mockState, 'event-001') + expect(store.lastEventId).toBe('event-001') + + store.setGameState(mockState, 'event-002') + expect(store.lastEventId).toBe('event-002') + }) + + // --------------------------------------------------------------------------- + // Connection Status + // --------------------------------------------------------------------------- + + it('updates connection status', () => { + /** + * Test that connection status can be updated. + * + * Connection status tracking enables the UI to show appropriate + * feedback when disconnected or reconnecting. + */ + const store = useGameStore() + + expect(store.connectionStatus).toBe(ConnectionStatus.DISCONNECTED) + + store.setConnectionStatus(ConnectionStatus.CONNECTED) + expect(store.connectionStatus).toBe(ConnectionStatus.CONNECTED) + expect(store.isConnected).toBe(true) + + store.setConnectionStatus(ConnectionStatus.RECONNECTING) + expect(store.connectionStatus).toBe(ConnectionStatus.RECONNECTING) + expect(store.isConnected).toBe(false) + }) + + // --------------------------------------------------------------------------- + // Pending Actions + // --------------------------------------------------------------------------- + + it('queues actions when offline', () => { + /** + * Test that actions can be queued for retry. + * + * When the connection is lost, we queue actions locally so they + * can be retried when the connection is restored. + */ + const store = useGameStore() + const action1: Action = { type: 'end_turn' } + const action2: Action = { type: 'attack', attack_index: 0 } + + store.queueAction(action1) + store.queueAction(action2) + + expect(store.pendingActions).toHaveLength(2) + expect(store.pendingActions[0]).toEqual(action1) + expect(store.pendingActions[1]).toEqual(action2) + }) + + it('clears pending actions', () => { + /** + * Test that pending actions can be cleared. + * + * After successfully retrying queued actions or when exiting + * a game, we need to clear the queue. + */ + const store = useGameStore() + const action: Action = { type: 'end_turn' } + + store.queueAction(action) + expect(store.pendingActions).toHaveLength(1) + + store.clearPendingActions() + expect(store.pendingActions).toHaveLength(0) + }) + + it('takes and clears pending actions atomically', () => { + /** + * Test that takePendingActions returns all queued actions and clears the queue. + * + * This atomic operation is used during reconnection to retry all + * pending actions without risking losing them if the operation fails. + */ + const store = useGameStore() + const action1: Action = { type: 'end_turn' } + const action2: Action = { type: 'attack', attack_index: 0 } + + store.queueAction(action1) + store.queueAction(action2) + + const actions = store.takePendingActions() + + expect(actions).toHaveLength(2) + expect(actions[0]).toEqual(action1) + expect(actions[1]).toEqual(action2) + expect(store.pendingActions).toHaveLength(0) + }) + + // --------------------------------------------------------------------------- + // Computed - Player States + // --------------------------------------------------------------------------- + + it('computes my player state correctly', () => { + /** + * Test that myPlayerState computed returns the viewing player's state. + * + * This is a convenience computed that extracts the viewer's player + * state from the game state. + */ + const store = useGameStore() + const mockState: VisibleGameState = createMockGameState('game-123', 'player-1') + + store.setGameState(mockState) + + expect(store.myPlayerState).toBeTruthy() + expect(store.myPlayerState?.player_id).toBe('player-1') + expect(store.myPlayerState?.is_current_player).toBe(true) + }) + + it('computes opponent player state correctly', () => { + /** + * Test that opponentPlayerState computed returns the opponent's state. + * + * This computed finds the player that is not the viewer in the + * players map, which is the opponent in a two-player game. + */ + const store = useGameStore() + const mockState: VisibleGameState = createMockGameState('game-123', 'player-1') + + store.setGameState(mockState) + + expect(store.opponentPlayerState).toBeTruthy() + expect(store.opponentPlayerState?.player_id).toBe('player-2') + expect(store.opponentPlayerState?.is_current_player).toBe(false) + }) + + // --------------------------------------------------------------------------- + // Computed - Game State + // --------------------------------------------------------------------------- + + it('computes isMyTurn correctly', () => { + /** + * Test that isMyTurn computed reflects the viewer's turn status. + * + * isMyTurn is used throughout the UI to enable/disable actions + * based on whose turn it is. + */ + const store = useGameStore() + const mockState: VisibleGameState = createMockGameState('game-123', 'player-1') + mockState.is_my_turn = true + + store.setGameState(mockState) + + expect(store.isMyTurn).toBe(true) + }) + + it('computes currentPhase correctly', () => { + /** + * Test that currentPhase computed returns the current turn phase. + * + * The current phase determines which actions are allowed and + * what UI elements to display. + */ + const store = useGameStore() + const mockState: VisibleGameState = createMockGameState('game-123', 'player-1') + mockState.phase = 'main' + + store.setGameState(mockState) + + expect(store.currentPhase).toBe('main') + }) + + it('computes isGameOver correctly', () => { + /** + * Test that isGameOver computed detects game end state. + * + * isGameOver is used to show the game over modal and prevent + * further actions. + */ + const store = useGameStore() + + // Test with game in progress + const mockStateInProgress: VisibleGameState = createMockGameState('game-123', 'player-1') + mockStateInProgress.winner_id = null + + store.setGameState(mockStateInProgress) + expect(store.isGameOver).toBe(false) + + // Test with game over + const mockStateGameOver: VisibleGameState = createMockGameState('game-123', 'player-1') + mockStateGameOver.winner_id = 'player-1' + + store.setGameState(mockStateGameOver) + expect(store.isGameOver).toBe(true) + }) + + // --------------------------------------------------------------------------- + // Computed - Zone Helpers + // --------------------------------------------------------------------------- + + it('computes myHand correctly', () => { + /** + * Test that myHand computed returns cards in the viewer's hand. + * + * myHand is used to render the hand UI and enable card interactions. + */ + const store = useGameStore() + const mockState: VisibleGameState = createMockGameState('game-123', 'player-1') + + store.setGameState(mockState) + + expect(store.myHand).toBeInstanceOf(Array) + expect(store.myHand.length).toBeGreaterThanOrEqual(0) + }) + + it('computes myActive correctly', () => { + /** + * Test that myActive computed returns the viewer's active Pokemon. + * + * myActive is used to display the active Pokemon and enable + * attack/retreat actions. + */ + const store = useGameStore() + const mockState: VisibleGameState = createMockGameState('game-123', 'player-1') + + store.setGameState(mockState) + + // Could be null or a CardInstance depending on game state + expect(store.myActive === null || typeof store.myActive === 'object').toBe(true) + }) + + it('computes oppHandCount correctly', () => { + /** + * Test that oppHandCount returns the opponent's hand count. + * + * The opponent's hand contents are hidden, but we show the count + * so players can track their opponent's resources. + */ + const store = useGameStore() + const mockState: VisibleGameState = createMockGameState('game-123', 'player-1') + + store.setGameState(mockState) + + expect(typeof store.oppHandCount).toBe('number') + expect(store.oppHandCount).toBeGreaterThanOrEqual(0) + }) + + // --------------------------------------------------------------------------- + // clearGame + // --------------------------------------------------------------------------- + + it('clears all game state on clearGame', () => { + /** + * Test that clearGame resets all relevant state. + * + * When exiting a game, we need to clean up all state to prevent + * stale data from affecting the next game. + */ + const store = useGameStore() + const mockState: VisibleGameState = createMockGameState('game-123', 'player-1') + const action: Action = { type: 'end_turn' } + + // Set up some state + store.setGameState(mockState, 'event-001') + store.setConnectionStatus(ConnectionStatus.CONNECTED) + store.queueAction(action) + store.setError('Some error') + + // Clear game + store.clearGame() + + expect(store.gameState).toBeNull() + expect(store.currentGameId).toBeNull() + expect(store.isInGame).toBe(false) + expect(store.pendingActions).toHaveLength(0) + expect(store.error).toBeNull() + // lastEventId is kept for potential reconnection + expect(store.lastEventId).toBe('event-001') + }) +}) + +// ============================================================================= +// Test Helpers +// ============================================================================= + +/** + * Create a minimal mock VisibleGameState for testing. + */ +function createMockGameState(gameId: string, viewerId: string): VisibleGameState { + const player1: VisiblePlayerState = { + player_id: 'player-1', + is_current_player: true, + deck_count: 40, + hand: { count: 5, cards: [], zone_type: 'hand' }, + prizes_count: 6, + energy_deck_count: 20, + active: { count: 0, cards: [], zone_type: 'active' }, + bench: { count: 0, cards: [], zone_type: 'bench' }, + discard: { count: 0, cards: [], zone_type: 'discard' }, + energy_zone: { count: 0, cards: [], zone_type: 'energy_zone' }, + score: 0, + gx_attack_used: false, + vstar_power_used: false, + } + + const player2: VisiblePlayerState = { + player_id: 'player-2', + is_current_player: false, + deck_count: 40, + hand: { count: 5, cards: [], zone_type: 'hand' }, + prizes_count: 6, + energy_deck_count: 20, + active: { count: 0, cards: [], zone_type: 'active' }, + bench: { count: 0, cards: [], zone_type: 'bench' }, + discard: { count: 0, cards: [], zone_type: 'discard' }, + energy_zone: { count: 0, cards: [], zone_type: 'energy_zone' }, + score: 0, + gx_attack_used: false, + vstar_power_used: false, + } + + return { + game_id: gameId, + viewer_id: viewerId, + players: { + 'player-1': player1, + 'player-2': player2, + }, + current_player_id: viewerId, + turn_number: 1, + phase: 'main', + is_my_turn: true, + winner_id: null, + end_reason: null, + stadium_in_play: null, + stadium_owner_id: null, + forced_action_player: null, + forced_action_type: null, + forced_action_reason: null, + card_registry: {}, + } +} diff --git a/frontend/src/types/ws.spec.ts b/frontend/src/types/ws.spec.ts new file mode 100644 index 0000000..2ed35d0 --- /dev/null +++ b/frontend/src/types/ws.spec.ts @@ -0,0 +1,301 @@ +/** + * Tests for WebSocket message types and utilities. + * + * Verifies type guards, message creation helpers, and type correctness. + */ + +import { describe, it, expect } from 'vitest' +import { + WSErrorCode, + ConnectionStatus, + generateMessageId, + createClientMessage, + isGameStateMessage, + isActionResultMessage, + isErrorMessage, + isTurnStartMessage, + isTurnTimeoutMessage, + isGameOverMessage, + isOpponentStatusMessage, + isHeartbeatAckMessage, + type GameStateMessage, + type ActionResultMessage, + type ErrorMessage, + type ServerMessage, +} from './ws' + +describe('WebSocket Message Types', () => { + describe('generateMessageId', () => { + it('generates a valid UUID v4 string', () => { + /** + * Test that generateMessageId produces valid UUID v4 format. + * + * Message IDs are critical for idempotency and event tracking. + * They must be unique and follow the UUID v4 format. + */ + const id = generateMessageId() + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) + }) + + it('generates unique IDs on repeated calls', () => { + /** + * Test that generateMessageId produces different IDs each time. + * + * Duplicate message IDs would break idempotency handling. + */ + const id1 = generateMessageId() + const id2 = generateMessageId() + expect(id1).not.toBe(id2) + }) + }) + + describe('createClientMessage', () => { + it('creates a join_game message with generated message_id', () => { + /** + * Test that createClientMessage generates a valid JoinGameMessage. + * + * Client message helpers simplify message creation and ensure + * all required fields are present. + */ + const msg = createClientMessage('join_game', { game_id: 'game-123' }) + + expect(msg.type).toBe('join_game') + expect(msg.game_id).toBe('game-123') + expect(msg.message_id).toBeDefined() + expect(msg.message_id).toMatch(/^[0-9a-f-]+$/i) + }) + + it('creates an action message with action payload', () => { + /** + * Test that createClientMessage handles action messages correctly. + * + * Action messages are the primary way players interact with the game, + * so correct message structure is critical. + */ + const msg = createClientMessage('action', { + game_id: 'game-456', + action: { + type: 'end_turn', + }, + }) + + expect(msg.type).toBe('action') + expect(msg.game_id).toBe('game-456') + expect(msg.action.type).toBe('end_turn') + expect(msg.message_id).toBeDefined() + }) + + it('creates a heartbeat message', () => { + /** + * Test that createClientMessage creates heartbeat messages. + * + * Heartbeats keep the connection alive and don't require additional payload. + */ + const msg = createClientMessage('heartbeat', {}) + + expect(msg.type).toBe('heartbeat') + expect(msg.message_id).toBeDefined() + }) + }) + + describe('Type Guards', () => { + it('isGameStateMessage correctly identifies game_state messages', () => { + /** + * Test that isGameStateMessage type guard works correctly. + * + * Type guards enable type-safe message handling by narrowing + * the ServerMessage union to specific message types. + */ + const msg: GameStateMessage = { + type: 'game_state', + message_id: 'msg-1', + timestamp: new Date().toISOString(), + game_id: 'game-1', + state: { + game_id: 'game-1', + viewer_id: 'player-1', + players: {}, + current_player_id: 'player-1', + turn_number: 1, + phase: 'draw', + is_my_turn: true, + winner_id: null, + end_reason: null, + stadium_in_play: null, + stadium_owner_id: null, + forced_action_player: null, + forced_action_type: null, + forced_action_reason: null, + card_registry: {}, + }, + event_id: 'event-1', + spectator_count: 0, + } + + expect(isGameStateMessage(msg)).toBe(true) + expect(isErrorMessage(msg)).toBe(false) + }) + + it('isActionResultMessage correctly identifies action_result messages', () => { + /** + * Test that isActionResultMessage type guard works correctly. + * + * Action result messages confirm whether player actions succeeded, + * so correct identification is critical for UI feedback. + */ + const msg: ActionResultMessage = { + type: 'action_result', + message_id: 'msg-2', + timestamp: new Date().toISOString(), + game_id: 'game-1', + request_message_id: 'req-1', + success: true, + action_type: 'play_card', + } + + expect(isActionResultMessage(msg)).toBe(true) + expect(isGameStateMessage(msg)).toBe(false) + }) + + it('isErrorMessage correctly identifies error messages', () => { + /** + * Test that isErrorMessage type guard works correctly. + * + * Error messages need special handling to show user feedback, + * so correct identification is essential. + */ + const msg: ErrorMessage = { + type: 'error', + message_id: 'msg-3', + timestamp: new Date().toISOString(), + code: WSErrorCode.INVALID_ACTION, + message: 'Invalid action', + } + + expect(isErrorMessage(msg)).toBe(true) + expect(isActionResultMessage(msg)).toBe(false) + }) + + it('isTurnStartMessage correctly identifies turn_start messages', () => { + /** + * Test that isTurnStartMessage type guard works correctly. + */ + const msg: ServerMessage = { + type: 'turn_start', + message_id: 'msg-4', + timestamp: new Date().toISOString(), + game_id: 'game-1', + player_id: 'player-1', + turn_number: 2, + event_id: 'event-2', + } + + expect(isTurnStartMessage(msg)).toBe(true) + }) + + it('isTurnTimeoutMessage correctly identifies turn_timeout messages', () => { + /** + * Test that isTurnTimeoutMessage type guard works correctly. + */ + const msg: ServerMessage = { + type: 'turn_timeout', + message_id: 'msg-5', + timestamp: new Date().toISOString(), + game_id: 'game-1', + remaining_seconds: 10, + is_warning: true, + player_id: 'player-1', + } + + expect(isTurnTimeoutMessage(msg)).toBe(true) + }) + + it('isGameOverMessage correctly identifies game_over messages', () => { + /** + * Test that isGameOverMessage type guard works correctly. + */ + const msg: ServerMessage = { + type: 'game_over', + message_id: 'msg-6', + timestamp: new Date().toISOString(), + game_id: 'game-1', + winner_id: 'player-1', + end_reason: 'prizes_taken', + final_state: { + game_id: 'game-1', + viewer_id: 'player-1', + players: {}, + current_player_id: 'player-1', + turn_number: 10, + phase: 'end', + is_my_turn: false, + winner_id: 'player-1', + end_reason: 'prizes_taken', + stadium_in_play: null, + stadium_owner_id: null, + forced_action_player: null, + forced_action_type: null, + forced_action_reason: null, + card_registry: {}, + }, + event_id: 'event-10', + } + + expect(isGameOverMessage(msg)).toBe(true) + }) + + it('isOpponentStatusMessage correctly identifies opponent_status messages', () => { + /** + * Test that isOpponentStatusMessage type guard works correctly. + */ + const msg: ServerMessage = { + type: 'opponent_status', + message_id: 'msg-7', + timestamp: new Date().toISOString(), + game_id: 'game-1', + opponent_id: 'player-2', + status: ConnectionStatus.DISCONNECTED, + } + + expect(isOpponentStatusMessage(msg)).toBe(true) + }) + + it('isHeartbeatAckMessage correctly identifies heartbeat_ack messages', () => { + /** + * Test that isHeartbeatAckMessage type guard works correctly. + */ + const msg: ServerMessage = { + type: 'heartbeat_ack', + message_id: 'msg-8', + timestamp: new Date().toISOString(), + } + + expect(isHeartbeatAckMessage(msg)).toBe(true) + }) + }) + + describe('Enums', () => { + it('WSErrorCode enum has all expected values', () => { + /** + * Test that WSErrorCode enum matches backend error codes. + * + * Error codes must match the backend exactly for correct error handling. + */ + expect(WSErrorCode.AUTHENTICATION_FAILED).toBe('authentication_failed') + expect(WSErrorCode.GAME_NOT_FOUND).toBe('game_not_found') + expect(WSErrorCode.INVALID_ACTION).toBe('invalid_action') + expect(WSErrorCode.INTERNAL_ERROR).toBe('internal_error') + }) + + it('ConnectionStatus enum has all expected values', () => { + /** + * Test that ConnectionStatus enum is complete. + * + * Connection status values are used for UI state display. + */ + expect(ConnectionStatus.CONNECTED).toBe('connected') + expect(ConnectionStatus.DISCONNECTED).toBe('disconnected') + expect(ConnectionStatus.RECONNECTING).toBe('reconnecting') + }) + }) +}) diff --git a/frontend/src/types/ws.ts b/frontend/src/types/ws.ts new file mode 100644 index 0000000..f026e64 --- /dev/null +++ b/frontend/src/types/ws.ts @@ -0,0 +1,546 @@ +/** + * WebSocket message types for Mantimon TCG real-time communication. + * + * These types mirror the backend schemas from backend/app/schemas/ws_messages.py. + * All messages use a discriminated union pattern with a 'type' field for automatic parsing. + * + * Message Design Principles: + * 1. All messages have a 'type' discriminator field for automatic parsing + * 2. All messages have a 'message_id' for idempotency and event ordering + * 3. Server messages include a 'timestamp' for client-side latency tracking + * 4. Game-scoped messages include 'game_id' for multi-game support + */ + +import type { GameEndReason, VisibleGameState } from './game' + +// ============================================================================= +// Enums and Constants +// ============================================================================= + +/** + * Error codes for WebSocket error messages. + * + * These codes help clients handle errors programmatically without + * parsing error message text. + */ +export enum WSErrorCode { + // Connection errors + AUTHENTICATION_FAILED = 'authentication_failed', + CONNECTION_CLOSED = 'connection_closed', + RATE_LIMITED = 'rate_limited', + + // Game errors + GAME_NOT_FOUND = 'game_not_found', + NOT_IN_GAME = 'not_in_game', + ALREADY_IN_GAME = 'already_in_game', + GAME_FULL = 'game_full', + GAME_ENDED = 'game_ended', + + // Action errors + INVALID_ACTION = 'invalid_action', + NOT_YOUR_TURN = 'not_your_turn', + ACTION_NOT_ALLOWED = 'action_not_allowed', + + // Protocol errors + INVALID_MESSAGE = 'invalid_message', + UNKNOWN_MESSAGE_TYPE = 'unknown_message_type', + + // Server errors + INTERNAL_ERROR = 'internal_error', +} + +/** + * Connection status for opponent status messages. + */ +export enum ConnectionStatus { + CONNECTED = 'connected', + DISCONNECTED = 'disconnected', + RECONNECTING = 'reconnecting', +} + +// ============================================================================= +// Client -> Server Messages +// ============================================================================= + +/** + * Base interface for all client-to-server messages. + * + * All client messages must include a message_id for idempotency. The client + * generates this ID to allow detecting and handling duplicate messages. + */ +interface BaseClientMessage { + /** Client-generated UUID for idempotency and tracking */ + message_id: string +} + +/** + * Request to join or rejoin a game session. + * + * When rejoining after a disconnect, the client can provide last_event_id + * to receive any missed events since that point. + */ +export interface JoinGameMessage extends BaseClientMessage { + type: 'join_game' + /** ID of the game to join */ + game_id: string + /** Last event ID received (for reconnection replay) */ + last_event_id?: string +} + +/** + * Submit a game action for execution. + * + * The action field contains the discriminated union of all possible + * game actions (from backend/app/core/models/actions.py). + */ +export interface ActionMessage extends BaseClientMessage { + type: 'action' + /** ID of the game */ + game_id: string + /** The game action to execute */ + action: Action +} + +/** + * Request to resign from a game. + * + * This is separate from ResignAction in the game engine to allow + * resignation handling at the WebSocket layer. + */ +export interface ResignMessage extends BaseClientMessage { + type: 'resign' + /** ID of the game to resign from */ + game_id: string +} + +/** + * Keep-alive message to maintain connection. + * + * Clients should send heartbeats periodically (e.g., every 30 seconds) + * to prevent connection timeout. The server responds with a HeartbeatAck. + */ +export interface HeartbeatMessage extends BaseClientMessage { + type: 'heartbeat' +} + +/** + * Union type for all client-to-server messages. + */ +export type ClientMessage = + | JoinGameMessage + | ActionMessage + | ResignMessage + | HeartbeatMessage + +// ============================================================================= +// Server -> Client Messages +// ============================================================================= + +/** + * Base interface for all server-to-client messages. + * + * All server messages include a message_id and timestamp. The timestamp + * enables client-side latency calculation and event ordering. + */ +interface BaseServerMessage { + /** Server-generated UUID for tracking */ + message_id: string + /** UTC timestamp when the message was created (ISO 8601 string) */ + timestamp: string +} + +/** + * Full game state update. + * + * Sent when a player joins a game or when a full state sync is needed. + * Contains the complete visible game state from that player's perspective. + */ +export interface GameStateMessage extends BaseServerMessage { + type: 'game_state' + /** ID of the game */ + game_id: string + /** Full visible game state */ + state: VisibleGameState + /** Monotonic event ID for reconnection replay */ + event_id: string + /** Number of users spectating this game */ + spectator_count: number +} + +/** + * Result of a player action. + * + * Sent after processing an ActionMessage to confirm success or failure. + * On success, includes changes that resulted from the action. + */ +export interface ActionResultMessage extends BaseServerMessage { + type: 'action_result' + /** ID of the game */ + game_id: string + /** message_id of the original ActionMessage */ + request_message_id: string + /** Whether the action succeeded */ + success: boolean + /** Type of action that was attempted */ + action_type: string + /** State changes resulting from the action */ + changes?: Record + /** Error code if action failed */ + error_code?: WSErrorCode + /** Human-readable error message */ + error_message?: string +} + +/** + * Error notification for protocol or connection errors. + * + * Used for errors not associated with a specific action, such as + * invalid message format, authentication failures, or server errors. + */ +export interface ErrorMessage extends BaseServerMessage { + type: 'error' + /** Machine-readable error code */ + code: WSErrorCode + /** Human-readable error description */ + message: string + /** Additional error context */ + details?: Record + /** ID of the message that caused the error, if applicable */ + request_message_id?: string +} + +/** + * Notification that a new turn has started. + * + * Sent to both players when a turn begins. Indicates whose turn it is + * and the current turn number. + */ +export interface TurnStartMessage extends BaseServerMessage { + type: 'turn_start' + /** ID of the game */ + game_id: string + /** Player whose turn is starting */ + player_id: string + /** Current turn number */ + turn_number: number + /** Event ID for reconnection replay */ + event_id: string +} + +/** + * Timeout warning or expiration notification. + * + * Sent when a player's turn is approaching timeout (warning) or has + * expired. Warnings give players time to complete their action. + */ +export interface TurnTimeoutMessage extends BaseServerMessage { + type: 'turn_timeout' + /** ID of the game */ + game_id: string + /** Seconds remaining before timeout */ + remaining_seconds: number + /** True if this is a warning, False if timeout has occurred */ + is_warning: boolean + /** Player whose turn is timing out */ + player_id: string +} + +/** + * Notification that the game has ended. + * + * Sent to all players when the game concludes, regardless of the reason. + */ +export interface GameOverMessage extends BaseServerMessage { + type: 'game_over' + /** ID of the game that ended */ + game_id: string + /** Winner player ID, or null for a draw */ + winner_id: string | null + /** Reason the game ended */ + end_reason: GameEndReason + /** Final visible game state */ + final_state: VisibleGameState + /** Event ID for reconnection replay */ + event_id: string +} + +/** + * Notification of opponent connection status change. + * + * Sent when the opponent connects, disconnects, or is reconnecting. + * Allows the UI to show connection status to the player. + */ +export interface OpponentStatusMessage extends BaseServerMessage { + type: 'opponent_status' + /** ID of the game */ + game_id: string + /** Opponent's player ID */ + opponent_id: string + /** Opponent's current connection status */ + status: ConnectionStatus +} + +/** + * Acknowledgment of a client heartbeat. + * + * Sent in response to HeartbeatMessage to confirm the connection is alive. + */ +export interface HeartbeatAckMessage extends BaseServerMessage { + type: 'heartbeat_ack' +} + +/** + * Union type for all server-to-client messages. + */ +export type ServerMessage = + | GameStateMessage + | ActionResultMessage + | ErrorMessage + | TurnStartMessage + | TurnTimeoutMessage + | GameOverMessage + | OpponentStatusMessage + | HeartbeatAckMessage + +// ============================================================================= +// Action Types (subset from backend/app/core/models/actions.py) +// ============================================================================= + +/** + * Base interface for all game actions. + * + * Actions are discriminated by their 'type' field. Each action type + * has specific additional fields for its parameters. + */ +interface BaseAction { + /** Discriminator field - identifies the action type */ + type: string +} + +/** + * Play a card from hand to the board. + */ +export interface PlayCardAction extends BaseAction { + type: 'play_card' + /** Instance ID of the card in hand */ + instance_id: string + /** Target zone to play the card to */ + target_zone?: string + /** Slot index in target zone (for bench) */ + target_slot?: number +} + +/** + * Attach an energy card to a Pokemon. + */ +export interface AttachEnergyAction extends BaseAction { + type: 'attach_energy' + /** Instance ID of the energy card */ + energy_instance_id: string + /** Instance ID of the target Pokemon */ + target_pokemon_id: string +} + +/** + * Evolve a Pokemon by playing an evolution card from hand. + */ +export interface EvolveAction extends BaseAction { + type: 'evolve' + /** Instance ID of the evolution card in hand */ + evolution_card_id: string + /** Instance ID of the Pokemon to evolve */ + target_pokemon_id: string +} + +/** + * Declare and execute an attack. + */ +export interface AttackAction extends BaseAction { + type: 'attack' + /** Index of the attack in the active Pokemon's attacks array */ + attack_index: number + /** Instance ID of the target Pokemon (if attack requires targeting) */ + target_pokemon_id?: string +} + +/** + * Retreat the active Pokemon to the bench. + */ +export interface RetreatAction extends BaseAction { + type: 'retreat' + /** Instance ID of the bench Pokemon to make active */ + new_active_id: string + /** Instance IDs of energy cards to discard for retreat cost */ + energy_to_discard: string[] +} + +/** + * Use a Pokemon's ability. + */ +export interface UseAbilityAction extends BaseAction { + type: 'use_ability' + /** Instance ID of the Pokemon using the ability */ + pokemon_id: string + /** Index of the ability in the Pokemon's abilities array */ + ability_index: number + /** Target instance IDs if ability requires targeting */ + targets?: string[] +} + +/** + * End the current turn. + */ +export interface EndTurnAction extends BaseAction { + type: 'end_turn' +} + +/** + * Select a prize card after knocking out an opponent's Pokemon. + */ +export interface SelectPrizeAction extends BaseAction { + type: 'select_prize' + /** Index of the prize card to claim (0-5) */ + prize_index: number +} + +/** + * Select a new active Pokemon when the current active is knocked out. + */ +export interface SelectNewActiveAction extends BaseAction { + type: 'select_new_active' + /** Instance ID of the bench Pokemon to promote */ + new_active_id: string +} + +/** + * Discard cards from hand (when required by an effect). + */ +export interface DiscardFromHandAction extends BaseAction { + type: 'discard_from_hand' + /** Instance IDs of cards to discard */ + card_ids: string[] +} + +/** + * Union type for all possible game actions. + * + * This is a subset of the actions defined in the backend. Additional + * action types can be added as needed. + */ +export type Action = + | PlayCardAction + | AttachEnergyAction + | EvolveAction + | AttackAction + | RetreatAction + | UseAbilityAction + | EndTurnAction + | SelectPrizeAction + | SelectNewActiveAction + | DiscardFromHandAction + +// ============================================================================= +// Type Guards +// ============================================================================= + +/** + * Type guard for GameStateMessage. + */ +export function isGameStateMessage(msg: ServerMessage): msg is GameStateMessage { + return msg.type === 'game_state' +} + +/** + * Type guard for ActionResultMessage. + */ +export function isActionResultMessage(msg: ServerMessage): msg is ActionResultMessage { + return msg.type === 'action_result' +} + +/** + * Type guard for ErrorMessage. + */ +export function isErrorMessage(msg: ServerMessage): msg is ErrorMessage { + return msg.type === 'error' +} + +/** + * Type guard for TurnStartMessage. + */ +export function isTurnStartMessage(msg: ServerMessage): msg is TurnStartMessage { + return msg.type === 'turn_start' +} + +/** + * Type guard for TurnTimeoutMessage. + */ +export function isTurnTimeoutMessage(msg: ServerMessage): msg is TurnTimeoutMessage { + return msg.type === 'turn_timeout' +} + +/** + * Type guard for GameOverMessage. + */ +export function isGameOverMessage(msg: ServerMessage): msg is GameOverMessage { + return msg.type === 'game_over' +} + +/** + * Type guard for OpponentStatusMessage. + */ +export function isOpponentStatusMessage(msg: ServerMessage): msg is OpponentStatusMessage { + return msg.type === 'opponent_status' +} + +/** + * Type guard for HeartbeatAckMessage. + */ +export function isHeartbeatAckMessage(msg: ServerMessage): msg is HeartbeatAckMessage { + return msg.type === 'heartbeat_ack' +} + +// ============================================================================= +// Utility Functions +// ============================================================================= + +/** + * Generate a unique message ID. + * + * Uses crypto.randomUUID() if available, falls back to a simple UUID v4 implementation. + */ +export function generateMessageId(): string { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID() + } + + // Fallback: simple UUID v4 implementation + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0 + const v = c === 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) +} + +/** + * Create a client message with a generated message_id. + * + * @param type - The message type + * @param payload - Additional message fields + * @returns The complete client message with message_id + * + * @example + * ```ts + * const msg = createClientMessage('join_game', { game_id: '123' }) + * // { type: 'join_game', message_id: '...', game_id: '123' } + * ``` + */ +export function createClientMessage( + type: T, + payload: Omit, 'type' | 'message_id'> +): Extract { + return { + type, + message_id: generateMessageId(), + ...payload, + } as Extract +}