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