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:
parent
413caa86d0
commit
926dd3732b
427
frontend/src/pages/PlayPage.spec.ts
Normal file
427
frontend/src/pages/PlayPage.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
405
frontend/src/stores/game.spec.ts
Normal file
405
frontend/src/stores/game.spec.ts
Normal 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: {},
|
||||
}
|
||||
}
|
||||
301
frontend/src/types/ws.spec.ts
Normal file
301
frontend/src/types/ws.spec.ts
Normal 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
546
frontend/src/types/ws.ts
Normal 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 }>
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user