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 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-02-01 20:52:07 -06:00
parent 413caa86d0
commit 926dd3732b
4 changed files with 1679 additions and 0 deletions

View File

@ -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<typeof createRouter>
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')
})
})

View File

@ -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: {},
}
}

View File

@ -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')
})
})
})

546
frontend/src/types/ws.ts Normal file
View File

@ -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<string, unknown>
/** 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<string, unknown>
/** 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<T extends ClientMessage['type']>(
type: T,
payload: Omit<Extract<ClientMessage, { type: T }>, 'type' | 'message_id'>
): Extract<ClientMessage, { type: T }> {
return {
type,
message_id: generateMessageId(),
...payload,
} as Extract<ClientMessage, { type: T }>
}