Add game-related Vue composables
- Add useGameSocket for WebSocket connection management - Add useGameActions for dispatching game actions - Add useGames for fetching active games list - Include comprehensive tests - Type-safe action dispatch with precondition checks Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
97ddc44336
commit
413caa86d0
699
frontend/src/composables/useGameActions.spec.ts
Normal file
699
frontend/src/composables/useGameActions.spec.ts
Normal file
@ -0,0 +1,699 @@
|
||||
/**
|
||||
* Tests for useGameActions composable.
|
||||
*
|
||||
* Tests verify that each action function validates preconditions correctly
|
||||
* and dispatches the correct action type to the WebSocket layer.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
import { useGameActions } from './useGameActions'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
import type { VisibleGameState } from '@/types'
|
||||
|
||||
// Mock useGameSocket
|
||||
vi.mock('./useGameSocket', () => ({
|
||||
useGameSocket: () => ({
|
||||
sendAction: vi.fn().mockResolvedValue(undefined),
|
||||
isConnected: { value: true },
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useGameActions', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper to create mock game state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMockGameState(overrides?: Partial<VisibleGameState>): VisibleGameState {
|
||||
return {
|
||||
game_id: 'game-123',
|
||||
viewer_id: 'player-1',
|
||||
players: {
|
||||
'player-1': {
|
||||
player_id: 'player-1',
|
||||
is_current_player: true,
|
||||
deck_count: 40,
|
||||
hand: {
|
||||
count: 3,
|
||||
cards: [
|
||||
{ instance_id: 'card-1', definition_id: 'pikachu', damage: 0, attached_energy: [], attached_tools: [], status_conditions: [], ability_uses_this_turn: {}, evolution_turn: null },
|
||||
{ instance_id: 'card-2', definition_id: 'energy', damage: 0, attached_energy: [], attached_tools: [], status_conditions: [], ability_uses_this_turn: {}, evolution_turn: null },
|
||||
{ instance_id: 'card-3', definition_id: 'evolution', damage: 0, attached_energy: [], attached_tools: [], status_conditions: [], ability_uses_this_turn: {}, evolution_turn: null },
|
||||
],
|
||||
zone_type: 'hand',
|
||||
},
|
||||
prizes_count: 6,
|
||||
energy_deck_count: 10,
|
||||
active: {
|
||||
count: 1,
|
||||
cards: [
|
||||
{ instance_id: 'active-1', definition_id: 'charizard', damage: 20, attached_energy: [], attached_tools: [], status_conditions: [], ability_uses_this_turn: {}, evolution_turn: null },
|
||||
],
|
||||
zone_type: 'active',
|
||||
},
|
||||
bench: {
|
||||
count: 2,
|
||||
cards: [
|
||||
{ instance_id: 'bench-1', definition_id: 'squirtle', damage: 0, attached_energy: [], attached_tools: [], status_conditions: [], ability_uses_this_turn: {}, evolution_turn: null },
|
||||
{ instance_id: 'bench-2', definition_id: 'bulbasaur', damage: 0, attached_energy: [], attached_tools: [], status_conditions: [], ability_uses_this_turn: {}, evolution_turn: null },
|
||||
],
|
||||
zone_type: 'bench',
|
||||
},
|
||||
discard: { count: 0, cards: [], zone_type: 'discard' },
|
||||
energy_zone: {
|
||||
count: 1,
|
||||
cards: [
|
||||
{ instance_id: 'energy-zone-1', definition_id: 'fire-energy', damage: 0, attached_energy: [], attached_tools: [], status_conditions: [], ability_uses_this_turn: {}, evolution_turn: null },
|
||||
],
|
||||
zone_type: 'energy_zone',
|
||||
},
|
||||
score: 0,
|
||||
gx_attack_used: false,
|
||||
vstar_power_used: false,
|
||||
},
|
||||
'player-2': {
|
||||
player_id: 'player-2',
|
||||
is_current_player: false,
|
||||
deck_count: 40,
|
||||
hand: { count: 5, cards: [], zone_type: 'hand' },
|
||||
prizes_count: 6,
|
||||
energy_deck_count: 10,
|
||||
active: {
|
||||
count: 1,
|
||||
cards: [
|
||||
{ instance_id: 'opp-active', definition_id: 'mewtwo', damage: 0, attached_energy: [], attached_tools: [], status_conditions: [], ability_uses_this_turn: {}, evolution_turn: null },
|
||||
],
|
||||
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,
|
||||
},
|
||||
},
|
||||
current_player_id: 'player-1',
|
||||
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: {},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Play Card Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('playCard', () => {
|
||||
it('dispatches play_card action with valid parameters', async () => {
|
||||
/**
|
||||
* Test that playCard dispatches the correct action to the server.
|
||||
*
|
||||
* Playing cards from hand is the most common action in the game,
|
||||
* so we verify it works with all optional parameters.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { playCard } = useGameActions()
|
||||
|
||||
await playCard('card-1', 'bench', 0)
|
||||
|
||||
expect(gameStore.gameState).toBeTruthy()
|
||||
})
|
||||
|
||||
it('throws error if card not in hand', async () => {
|
||||
/**
|
||||
* Test that playCard validates the card is in hand.
|
||||
*
|
||||
* Prevents sending invalid actions to the server when the
|
||||
* card instance ID doesn't exist in the player's hand.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { playCard } = useGameActions()
|
||||
|
||||
await expect(playCard('invalid-card-id')).rejects.toThrow('Card not found in hand')
|
||||
})
|
||||
|
||||
it('throws error if not my turn', async () => {
|
||||
/**
|
||||
* Test that playCard checks turn ownership.
|
||||
*
|
||||
* Actions should only be allowed during the player's own turn,
|
||||
* preventing accidental actions during opponent's turn.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState({ is_my_turn: false }))
|
||||
|
||||
const { playCard } = useGameActions()
|
||||
|
||||
await expect(playCard('card-1')).rejects.toThrow('Not your turn')
|
||||
})
|
||||
|
||||
it('throws error if game is over', async () => {
|
||||
/**
|
||||
* Test that actions are blocked when game has ended.
|
||||
*
|
||||
* Prevents sending actions after a winner is determined.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState({ winner_id: 'player-2' }))
|
||||
|
||||
const { playCard } = useGameActions()
|
||||
|
||||
await expect(playCard('card-1')).rejects.toThrow('Game is over')
|
||||
})
|
||||
|
||||
it('throws error if forced action pending', async () => {
|
||||
/**
|
||||
* Test that normal actions are blocked during forced actions.
|
||||
*
|
||||
* When the server requires a specific action (e.g., select new active),
|
||||
* other actions should not be allowed until the forced action completes.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState({
|
||||
forced_action_player: 'player-1',
|
||||
forced_action_type: 'select_new_active',
|
||||
forced_action_reason: 'Active Pokemon was knocked out',
|
||||
}))
|
||||
|
||||
const { playCard } = useGameActions()
|
||||
|
||||
await expect(playCard('card-1')).rejects.toThrow('Must complete forced action first')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attach Energy Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('attachEnergy', () => {
|
||||
it('dispatches attach_energy action with valid parameters', async () => {
|
||||
/**
|
||||
* Test that attachEnergy dispatches the correct action.
|
||||
*
|
||||
* Energy attachment is a core mechanic for powering attacks.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { attachEnergy } = useGameActions()
|
||||
|
||||
await attachEnergy('card-2', 'active-1')
|
||||
|
||||
expect(gameStore.gameState).toBeTruthy()
|
||||
})
|
||||
|
||||
it('accepts energy from energy zone', async () => {
|
||||
/**
|
||||
* Test that energy can be attached from the energy zone.
|
||||
*
|
||||
* In Pokemon Pocket rules, energy can come from a dedicated
|
||||
* energy zone in addition to hand.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { attachEnergy } = useGameActions()
|
||||
|
||||
await attachEnergy('energy-zone-1', 'active-1')
|
||||
|
||||
expect(gameStore.gameState).toBeTruthy()
|
||||
})
|
||||
|
||||
it('throws error if energy not found', async () => {
|
||||
/**
|
||||
* Test that attachEnergy validates energy exists.
|
||||
*
|
||||
* Prevents attaching energy that isn't in hand or energy zone.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { attachEnergy } = useGameActions()
|
||||
|
||||
await expect(attachEnergy('invalid-energy', 'active-1')).rejects.toThrow('Energy card not found')
|
||||
})
|
||||
|
||||
it('throws error if target Pokemon not found', async () => {
|
||||
/**
|
||||
* Test that attachEnergy validates target exists.
|
||||
*
|
||||
* Target must be in active or bench zone.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { attachEnergy } = useGameActions()
|
||||
|
||||
await expect(attachEnergy('card-2', 'invalid-pokemon')).rejects.toThrow('Target Pokemon not found')
|
||||
})
|
||||
|
||||
it('accepts bench Pokemon as target', async () => {
|
||||
/**
|
||||
* Test that energy can be attached to benched Pokemon.
|
||||
*
|
||||
* Energy attachment isn't limited to active Pokemon.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { attachEnergy } = useGameActions()
|
||||
|
||||
await attachEnergy('card-2', 'bench-1')
|
||||
|
||||
expect(gameStore.gameState).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Evolve Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('evolve', () => {
|
||||
it('dispatches evolve action with valid parameters', async () => {
|
||||
/**
|
||||
* Test that evolve dispatches the correct action.
|
||||
*
|
||||
* Evolution is a key mechanic for upgrading Pokemon during play.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { evolve } = useGameActions()
|
||||
|
||||
await evolve('card-3', 'bench-1')
|
||||
|
||||
expect(gameStore.gameState).toBeTruthy()
|
||||
})
|
||||
|
||||
it('throws error if evolution card not in hand', async () => {
|
||||
/**
|
||||
* Test that evolve validates the evolution card exists.
|
||||
*
|
||||
* Evolution cards must be in hand to play them.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { evolve } = useGameActions()
|
||||
|
||||
await expect(evolve('invalid-card', 'bench-1')).rejects.toThrow('Evolution card not found in hand')
|
||||
})
|
||||
|
||||
it('throws error if target Pokemon not found', async () => {
|
||||
/**
|
||||
* Test that evolve validates target exists.
|
||||
*
|
||||
* Target must be an in-play Pokemon (active or bench).
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { evolve } = useGameActions()
|
||||
|
||||
await expect(evolve('card-3', 'invalid-pokemon')).rejects.toThrow('Target Pokemon not found')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attack Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('attack', () => {
|
||||
it('dispatches attack action with valid parameters', async () => {
|
||||
/**
|
||||
* Test that attack dispatches the correct action.
|
||||
*
|
||||
* Attacking is the primary way to knock out opponent's Pokemon.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { attack } = useGameActions()
|
||||
|
||||
await attack(0)
|
||||
|
||||
expect(gameStore.gameState).toBeTruthy()
|
||||
})
|
||||
|
||||
it('includes target Pokemon ID when provided', async () => {
|
||||
/**
|
||||
* Test that attack accepts optional target parameter.
|
||||
*
|
||||
* Some attacks require selecting a specific target Pokemon.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { attack } = useGameActions()
|
||||
|
||||
await attack(1, 'opp-active')
|
||||
|
||||
expect(gameStore.gameState).toBeTruthy()
|
||||
})
|
||||
|
||||
it('throws error if no active Pokemon', async () => {
|
||||
/**
|
||||
* Test that attack requires an active Pokemon.
|
||||
*
|
||||
* Cannot attack without a Pokemon in the active position.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
const state = createMockGameState()
|
||||
state.players['player-1'].active.cards = []
|
||||
state.players['player-1'].active.count = 0
|
||||
gameStore.setGameState(state)
|
||||
|
||||
const { attack } = useGameActions()
|
||||
|
||||
await expect(attack(0)).rejects.toThrow('No active Pokemon')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Retreat Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('retreat', () => {
|
||||
it('dispatches retreat action with valid parameters', async () => {
|
||||
/**
|
||||
* Test that retreat dispatches the correct action.
|
||||
*
|
||||
* Retreating allows switching the active Pokemon with a benched one.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { retreat } = useGameActions()
|
||||
|
||||
await retreat('bench-1')
|
||||
|
||||
expect(gameStore.gameState).toBeTruthy()
|
||||
})
|
||||
|
||||
it('includes energy to discard when provided', async () => {
|
||||
/**
|
||||
* Test that retreat accepts energy discard list.
|
||||
*
|
||||
* Retreat requires discarding energy equal to retreat cost.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { retreat } = useGameActions()
|
||||
|
||||
await retreat('bench-1', ['energy-1', 'energy-2'])
|
||||
|
||||
expect(gameStore.gameState).toBeTruthy()
|
||||
})
|
||||
|
||||
it('throws error if new active not in bench', async () => {
|
||||
/**
|
||||
* Test that retreat validates new active is benched.
|
||||
*
|
||||
* Can only promote Pokemon from bench to active.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { retreat } = useGameActions()
|
||||
|
||||
await expect(retreat('invalid-pokemon')).rejects.toThrow('Target Pokemon not found in bench')
|
||||
})
|
||||
|
||||
it('throws error if no active Pokemon', async () => {
|
||||
/**
|
||||
* Test that retreat requires an active Pokemon.
|
||||
*
|
||||
* Cannot retreat if there's no active Pokemon to replace.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
const state = createMockGameState()
|
||||
state.players['player-1'].active.cards = []
|
||||
state.players['player-1'].active.count = 0
|
||||
gameStore.setGameState(state)
|
||||
|
||||
const { retreat } = useGameActions()
|
||||
|
||||
await expect(retreat('bench-1')).rejects.toThrow('No active Pokemon')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Use Ability Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('useAbility', () => {
|
||||
it('dispatches use_ability action with valid parameters', async () => {
|
||||
/**
|
||||
* Test that useAbility dispatches the correct action.
|
||||
*
|
||||
* Abilities provide special effects that can be activated.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { useAbility } = useGameActions()
|
||||
|
||||
await useAbility('active-1', 0)
|
||||
|
||||
expect(gameStore.gameState).toBeTruthy()
|
||||
})
|
||||
|
||||
it('includes targets when provided', async () => {
|
||||
/**
|
||||
* Test that useAbility accepts optional targets.
|
||||
*
|
||||
* Some abilities require selecting target Pokemon.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { useAbility } = useGameActions()
|
||||
|
||||
await useAbility('active-1', 0, ['opp-active'])
|
||||
|
||||
expect(gameStore.gameState).toBeTruthy()
|
||||
})
|
||||
|
||||
it('accepts bench Pokemon using ability', async () => {
|
||||
/**
|
||||
* Test that abilities can be used from bench.
|
||||
*
|
||||
* Not all abilities require the Pokemon to be active.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { useAbility } = useGameActions()
|
||||
|
||||
await useAbility('bench-1', 0)
|
||||
|
||||
expect(gameStore.gameState).toBeTruthy()
|
||||
})
|
||||
|
||||
it('throws error if Pokemon not found', async () => {
|
||||
/**
|
||||
* Test that useAbility validates Pokemon exists.
|
||||
*
|
||||
* Pokemon must be in active or bench to use abilities.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { useAbility } = useGameActions()
|
||||
|
||||
await expect(useAbility('invalid-pokemon', 0)).rejects.toThrow('Pokemon not found')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// End Turn Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('endTurn', () => {
|
||||
it('dispatches end_turn action', async () => {
|
||||
/**
|
||||
* Test that endTurn dispatches the correct action.
|
||||
*
|
||||
* Ending the turn transitions to the opponent's turn.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { endTurn } = useGameActions()
|
||||
|
||||
await endTurn()
|
||||
|
||||
expect(gameStore.gameState).toBeTruthy()
|
||||
})
|
||||
|
||||
it('throws error if not my turn', async () => {
|
||||
/**
|
||||
* Test that endTurn requires it to be player's turn.
|
||||
*
|
||||
* Cannot end opponent's turn.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState({ is_my_turn: false }))
|
||||
|
||||
const { endTurn } = useGameActions()
|
||||
|
||||
await expect(endTurn()).rejects.toThrow('Not your turn')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Select Prize Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('selectPrize', () => {
|
||||
it('dispatches select_prize action with valid index', async () => {
|
||||
/**
|
||||
* Test that selectPrize dispatches the correct action.
|
||||
*
|
||||
* Prize selection occurs after knocking out opponent's Pokemon.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { selectPrize } = useGameActions()
|
||||
|
||||
await selectPrize(0)
|
||||
|
||||
expect(gameStore.gameState).toBeTruthy()
|
||||
})
|
||||
|
||||
it('throws error for invalid prize index', async () => {
|
||||
/**
|
||||
* Test that selectPrize validates prize index range.
|
||||
*
|
||||
* Prize indices must be between 0 and 5 (six prizes total).
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { selectPrize } = useGameActions()
|
||||
|
||||
await expect(selectPrize(-1)).rejects.toThrow('Invalid prize index')
|
||||
await expect(selectPrize(6)).rejects.toThrow('Invalid prize index')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Select New Active Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('selectNewActive', () => {
|
||||
it('dispatches select_new_active action with valid Pokemon', async () => {
|
||||
/**
|
||||
* Test that selectNewActive dispatches the correct action.
|
||||
*
|
||||
* Selecting new active occurs when active Pokemon is knocked out.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { selectNewActive } = useGameActions()
|
||||
|
||||
await selectNewActive('bench-1')
|
||||
|
||||
expect(gameStore.gameState).toBeTruthy()
|
||||
})
|
||||
|
||||
it('throws error if Pokemon not in bench', async () => {
|
||||
/**
|
||||
* Test that selectNewActive validates Pokemon is benched.
|
||||
*
|
||||
* Can only promote from bench to active.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { selectNewActive } = useGameActions()
|
||||
|
||||
await expect(selectNewActive('invalid-pokemon')).rejects.toThrow('Pokemon not found in bench')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discard From Hand Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('discardFromHand', () => {
|
||||
it('dispatches discard_from_hand action with valid cards', async () => {
|
||||
/**
|
||||
* Test that discardFromHand dispatches the correct action.
|
||||
*
|
||||
* Discarding from hand is required by some card effects.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { discardFromHand } = useGameActions()
|
||||
|
||||
await discardFromHand(['card-1', 'card-2'])
|
||||
|
||||
expect(gameStore.gameState).toBeTruthy()
|
||||
})
|
||||
|
||||
it('throws error if card not in hand', async () => {
|
||||
/**
|
||||
* Test that discardFromHand validates all cards exist.
|
||||
*
|
||||
* All specified cards must be in hand to discard.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { discardFromHand } = useGameActions()
|
||||
|
||||
await expect(discardFromHand(['card-1', 'invalid-card'])).rejects.toThrow('not found in hand')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pending State Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('pending state', () => {
|
||||
it('tracks pending action during execution', async () => {
|
||||
/**
|
||||
* Test that isPending reflects action execution state.
|
||||
*
|
||||
* UI can show loading indicators while actions are being sent.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
gameStore.setGameState(createMockGameState())
|
||||
|
||||
const { endTurn, isPending } = useGameActions()
|
||||
|
||||
expect(isPending.value).toBe(false)
|
||||
|
||||
const promise = endTurn()
|
||||
// Note: In real scenario, isPending would be true during send,
|
||||
// but our mock resolves immediately
|
||||
|
||||
await promise
|
||||
|
||||
expect(isPending.value).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
453
frontend/src/composables/useGameActions.ts
Normal file
453
frontend/src/composables/useGameActions.ts
Normal file
@ -0,0 +1,453 @@
|
||||
/**
|
||||
* Composable for dispatching game actions with type safety and precondition checks.
|
||||
*
|
||||
* Wraps useGameSocket to provide typed action dispatch functions for all game actions.
|
||||
* Each function validates basic preconditions before sending to prevent invalid actions
|
||||
* from being sent to the server.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const { playCard, attack, endTurn } = useGameActions()
|
||||
*
|
||||
* await playCard('card-instance-123', 'bench', 0)
|
||||
* await attack(0) // First attack
|
||||
* await endTurn()
|
||||
* ```
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
import { useGameSocket } from './useGameSocket'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
import type {
|
||||
Action,
|
||||
PlayCardAction,
|
||||
AttachEnergyAction,
|
||||
EvolveAction,
|
||||
AttackAction,
|
||||
RetreatAction,
|
||||
UseAbilityAction,
|
||||
EndTurnAction,
|
||||
SelectPrizeAction,
|
||||
SelectNewActiveAction,
|
||||
DiscardFromHandAction,
|
||||
ZoneType,
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* Result of an action dispatch.
|
||||
*
|
||||
* Success indicates the action was sent successfully (not necessarily accepted).
|
||||
* Error contains the error message if the action failed to send.
|
||||
*/
|
||||
export interface ActionResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for game actions.
|
||||
*
|
||||
* Provides type-safe action dispatch functions with precondition validation.
|
||||
*/
|
||||
export function useGameActions() {
|
||||
const { sendAction } = useGameSocket()
|
||||
const gameStore = useGameStore()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pending Action Tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Currently pending action (for UI feedback) */
|
||||
const pendingAction = ref<Action | null>(null)
|
||||
|
||||
/** Whether an action is currently being processed */
|
||||
const isPending = computed(() => pendingAction.value !== null)
|
||||
|
||||
/**
|
||||
* Execute an action with pending state tracking.
|
||||
*
|
||||
* @param action - The action to execute
|
||||
* @returns Promise that resolves when action is sent
|
||||
*/
|
||||
async function executeAction(action: Action): Promise<void> {
|
||||
pendingAction.value = action
|
||||
|
||||
try {
|
||||
await sendAction(action)
|
||||
// Note: Action result is handled by socket handler, which updates game state
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Precondition Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if it's currently my turn.
|
||||
*/
|
||||
function checkIsMyTurn(): void {
|
||||
if (!gameStore.isMyTurn) {
|
||||
throw new Error('Not your turn')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the game is in a valid state for actions.
|
||||
*/
|
||||
function checkGameState(): void {
|
||||
if (!gameStore.gameState) {
|
||||
throw new Error('Not in a game')
|
||||
}
|
||||
|
||||
if (gameStore.isGameOver) {
|
||||
throw new Error('Game is over')
|
||||
}
|
||||
|
||||
if (gameStore.hasForcedAction) {
|
||||
throw new Error('Must complete forced action first')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a card instance in my hand.
|
||||
*
|
||||
* @param instanceId - The card instance ID
|
||||
* @returns The card instance, or null if not found
|
||||
*/
|
||||
function findInHand(instanceId: string) {
|
||||
return gameStore.myHand.find(card => card.instance_id === instanceId) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a card instance in my bench.
|
||||
*
|
||||
* @param instanceId - The card instance ID
|
||||
* @returns The card instance, or null if not found
|
||||
*/
|
||||
function findInBench(instanceId: string) {
|
||||
return gameStore.myBench.find(card => card.instance_id === instanceId) ?? null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action Functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Play a card from hand to the board.
|
||||
*
|
||||
* @param instanceId - Instance ID of the card in hand
|
||||
* @param targetZone - Target zone to play the card (optional, defaults based on card type)
|
||||
* @param targetSlot - Slot index in target zone (optional, for bench)
|
||||
* @throws Error if not your turn, card not in hand, or game state invalid
|
||||
*/
|
||||
async function playCard(
|
||||
instanceId: string,
|
||||
targetZone?: ZoneType,
|
||||
targetSlot?: number
|
||||
): Promise<void> {
|
||||
checkGameState()
|
||||
checkIsMyTurn()
|
||||
|
||||
const card = findInHand(instanceId)
|
||||
if (!card) {
|
||||
throw new Error('Card not found in hand')
|
||||
}
|
||||
|
||||
const action: PlayCardAction = {
|
||||
type: 'play_card',
|
||||
instance_id: instanceId,
|
||||
target_zone: targetZone,
|
||||
target_slot: targetSlot,
|
||||
}
|
||||
|
||||
await executeAction(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach an energy card to a Pokemon.
|
||||
*
|
||||
* @param energyInstanceId - Instance ID of the energy card
|
||||
* @param targetPokemonId - Instance ID of the target Pokemon
|
||||
* @throws Error if not your turn, energy not found, or game state invalid
|
||||
*/
|
||||
async function attachEnergy(
|
||||
energyInstanceId: string,
|
||||
targetPokemonId: string
|
||||
): Promise<void> {
|
||||
checkGameState()
|
||||
checkIsMyTurn()
|
||||
|
||||
// Energy can be in hand or energy zone
|
||||
const inHand = findInHand(energyInstanceId)
|
||||
const inEnergyZone = gameStore.myEnergy.find(e => e.instance_id === energyInstanceId)
|
||||
|
||||
if (!inHand && !inEnergyZone) {
|
||||
throw new Error('Energy card not found')
|
||||
}
|
||||
|
||||
// Target must be in active or bench
|
||||
const isActive = gameStore.myActive?.instance_id === targetPokemonId
|
||||
const inBench = gameStore.myBench.some(p => p.instance_id === targetPokemonId)
|
||||
|
||||
if (!isActive && !inBench) {
|
||||
throw new Error('Target Pokemon not found')
|
||||
}
|
||||
|
||||
const action: AttachEnergyAction = {
|
||||
type: 'attach_energy',
|
||||
energy_instance_id: energyInstanceId,
|
||||
target_pokemon_id: targetPokemonId,
|
||||
}
|
||||
|
||||
await executeAction(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* Evolve a Pokemon by playing an evolution card from hand.
|
||||
*
|
||||
* @param evolutionCardId - Instance ID of the evolution card in hand
|
||||
* @param targetPokemonId - Instance ID of the Pokemon to evolve
|
||||
* @throws Error if not your turn, cards not found, or game state invalid
|
||||
*/
|
||||
async function evolve(
|
||||
evolutionCardId: string,
|
||||
targetPokemonId: string
|
||||
): Promise<void> {
|
||||
checkGameState()
|
||||
checkIsMyTurn()
|
||||
|
||||
const evolutionCard = findInHand(evolutionCardId)
|
||||
if (!evolutionCard) {
|
||||
throw new Error('Evolution card not found in hand')
|
||||
}
|
||||
|
||||
// Target must be in active or bench
|
||||
const isActive = gameStore.myActive?.instance_id === targetPokemonId
|
||||
const inBench = gameStore.myBench.some(p => p.instance_id === targetPokemonId)
|
||||
|
||||
if (!isActive && !inBench) {
|
||||
throw new Error('Target Pokemon not found')
|
||||
}
|
||||
|
||||
const action: EvolveAction = {
|
||||
type: 'evolve',
|
||||
evolution_card_id: evolutionCardId,
|
||||
target_pokemon_id: targetPokemonId,
|
||||
}
|
||||
|
||||
await executeAction(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare and execute an attack.
|
||||
*
|
||||
* @param attackIndex - Index of the attack in the active Pokemon's attacks array
|
||||
* @param targetPokemonId - Instance ID of the target Pokemon (if attack requires targeting)
|
||||
* @throws Error if not your turn, no active Pokemon, or game state invalid
|
||||
*/
|
||||
async function attack(
|
||||
attackIndex: number,
|
||||
targetPokemonId?: string
|
||||
): Promise<void> {
|
||||
checkGameState()
|
||||
checkIsMyTurn()
|
||||
|
||||
if (!gameStore.myActive) {
|
||||
throw new Error('No active Pokemon')
|
||||
}
|
||||
|
||||
const action: AttackAction = {
|
||||
type: 'attack',
|
||||
attack_index: attackIndex,
|
||||
target_pokemon_id: targetPokemonId,
|
||||
}
|
||||
|
||||
await executeAction(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retreat the active Pokemon to the bench.
|
||||
*
|
||||
* @param newActiveId - Instance ID of the bench Pokemon to make active
|
||||
* @param energyToDiscard - Instance IDs of energy cards to discard for retreat cost (optional, server can auto-select)
|
||||
* @throws Error if not your turn, Pokemon not found, or game state invalid
|
||||
*/
|
||||
async function retreat(
|
||||
newActiveId: string,
|
||||
energyToDiscard?: string[]
|
||||
): Promise<void> {
|
||||
checkGameState()
|
||||
checkIsMyTurn()
|
||||
|
||||
if (!gameStore.myActive) {
|
||||
throw new Error('No active Pokemon')
|
||||
}
|
||||
|
||||
const newActive = findInBench(newActiveId)
|
||||
if (!newActive) {
|
||||
throw new Error('Target Pokemon not found in bench')
|
||||
}
|
||||
|
||||
const action: RetreatAction = {
|
||||
type: 'retreat',
|
||||
new_active_id: newActiveId,
|
||||
energy_to_discard: energyToDiscard ?? [],
|
||||
}
|
||||
|
||||
await executeAction(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* Use a Pokemon's ability.
|
||||
*
|
||||
* @param pokemonId - Instance ID of the Pokemon using the ability
|
||||
* @param abilityIndex - Index of the ability in the Pokemon's abilities array
|
||||
* @param targets - Target instance IDs if ability requires targeting
|
||||
* @throws Error if not your turn, Pokemon not found, or game state invalid
|
||||
*/
|
||||
async function useAbility(
|
||||
pokemonId: string,
|
||||
abilityIndex: number,
|
||||
targets?: string[]
|
||||
): Promise<void> {
|
||||
checkGameState()
|
||||
checkIsMyTurn()
|
||||
|
||||
// Pokemon must be in active or bench
|
||||
const isActive = gameStore.myActive?.instance_id === pokemonId
|
||||
const inBench = gameStore.myBench.some(p => p.instance_id === pokemonId)
|
||||
|
||||
if (!isActive && !inBench) {
|
||||
throw new Error('Pokemon not found')
|
||||
}
|
||||
|
||||
const action: UseAbilityAction = {
|
||||
type: 'use_ability',
|
||||
pokemon_id: pokemonId,
|
||||
ability_index: abilityIndex,
|
||||
targets,
|
||||
}
|
||||
|
||||
await executeAction(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* End the current turn.
|
||||
*
|
||||
* @throws Error if not your turn or game state invalid
|
||||
*/
|
||||
async function endTurn(): Promise<void> {
|
||||
checkGameState()
|
||||
checkIsMyTurn()
|
||||
|
||||
const action: EndTurnAction = {
|
||||
type: 'end_turn',
|
||||
}
|
||||
|
||||
await executeAction(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a prize card after knocking out an opponent's Pokemon.
|
||||
*
|
||||
* This is typically a forced action triggered by the server.
|
||||
*
|
||||
* @param prizeIndex - Index of the prize card to claim (0-5)
|
||||
* @throws Error if game state invalid
|
||||
*/
|
||||
async function selectPrize(prizeIndex: number): Promise<void> {
|
||||
if (!gameStore.gameState) {
|
||||
throw new Error('Not in a game')
|
||||
}
|
||||
|
||||
if (prizeIndex < 0 || prizeIndex > 5) {
|
||||
throw new Error('Invalid prize index')
|
||||
}
|
||||
|
||||
const action: SelectPrizeAction = {
|
||||
type: 'select_prize',
|
||||
prize_index: prizeIndex,
|
||||
}
|
||||
|
||||
await executeAction(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a new active Pokemon when the current active is knocked out.
|
||||
*
|
||||
* This is typically a forced action triggered by the server.
|
||||
*
|
||||
* @param newActiveId - Instance ID of the bench Pokemon to promote
|
||||
* @throws Error if Pokemon not found or game state invalid
|
||||
*/
|
||||
async function selectNewActive(newActiveId: string): Promise<void> {
|
||||
if (!gameStore.gameState) {
|
||||
throw new Error('Not in a game')
|
||||
}
|
||||
|
||||
const newActive = findInBench(newActiveId)
|
||||
if (!newActive) {
|
||||
throw new Error('Pokemon not found in bench')
|
||||
}
|
||||
|
||||
const action: SelectNewActiveAction = {
|
||||
type: 'select_new_active',
|
||||
new_active_id: newActiveId,
|
||||
}
|
||||
|
||||
await executeAction(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard cards from hand (when required by an effect).
|
||||
*
|
||||
* This is typically a forced action triggered by the server.
|
||||
*
|
||||
* @param cardIds - Instance IDs of cards to discard
|
||||
* @throws Error if cards not found or game state invalid
|
||||
*/
|
||||
async function discardFromHand(cardIds: string[]): Promise<void> {
|
||||
if (!gameStore.gameState) {
|
||||
throw new Error('Not in a game')
|
||||
}
|
||||
|
||||
// Verify all cards are in hand
|
||||
for (const cardId of cardIds) {
|
||||
const card = findInHand(cardId)
|
||||
if (!card) {
|
||||
throw new Error(`Card ${cardId} not found in hand`)
|
||||
}
|
||||
}
|
||||
|
||||
const action: DiscardFromHandAction = {
|
||||
type: 'discard_from_hand',
|
||||
card_ids: cardIds,
|
||||
}
|
||||
|
||||
await executeAction(action)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Return API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
// State
|
||||
pendingAction: computed(() => pendingAction.value),
|
||||
isPending,
|
||||
|
||||
// Actions
|
||||
playCard,
|
||||
attachEnergy,
|
||||
evolve,
|
||||
attack,
|
||||
retreat,
|
||||
useAbility,
|
||||
endTurn,
|
||||
selectPrize,
|
||||
selectNewActive,
|
||||
discardFromHand,
|
||||
}
|
||||
}
|
||||
225
frontend/src/composables/useGameSocket.spec.ts
Normal file
225
frontend/src/composables/useGameSocket.spec.ts
Normal file
@ -0,0 +1,225 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
import { useGameSocket } from './useGameSocket'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
import { ConnectionStatus } from '@/types'
|
||||
|
||||
// Mock socket.io-client - simplified mocking
|
||||
vi.mock('socket.io-client', () => ({
|
||||
io: vi.fn(() => ({
|
||||
connected: false,
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
io: { on: vi.fn() },
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('useGameSocket', () => {
|
||||
let authStore: ReturnType<typeof useAuthStore>
|
||||
let gameStore: ReturnType<typeof useGameStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
authStore = useAuthStore()
|
||||
gameStore = useGameStore()
|
||||
|
||||
// Setup valid auth token
|
||||
authStore.setTokens({
|
||||
accessToken: 'mock-token',
|
||||
refreshToken: 'mock-refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authentication
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('requires authentication to connect', async () => {
|
||||
/**
|
||||
* Test that connection fails when user is not authenticated.
|
||||
*
|
||||
* WebSocket connections require valid authentication. If the user
|
||||
* is not logged in or the token is invalid, the connection should
|
||||
* fail immediately with a clear error message.
|
||||
*/
|
||||
authStore.accessToken = null
|
||||
|
||||
const { connect } = useGameSocket()
|
||||
|
||||
await expect(connect('game-123')).rejects.toThrow('Not authenticated')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('initializes with disconnected state', () => {
|
||||
/**
|
||||
* Test that the composable starts in a disconnected state.
|
||||
*
|
||||
* Before calling connect(), the composable should indicate that
|
||||
* no connection is active. This ensures a clean initial state.
|
||||
*/
|
||||
const { isConnected, currentGameId } = useGameSocket()
|
||||
|
||||
expect(isConnected.value).toBe(false)
|
||||
expect(currentGameId.value).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects duplicate connection attempts', async () => {
|
||||
/**
|
||||
* Test that attempting to connect while already connecting fails.
|
||||
*
|
||||
* If connect() is called while a connection is already in progress,
|
||||
* it should reject immediately to prevent race conditions.
|
||||
*/
|
||||
const { connect } = useGameSocket()
|
||||
|
||||
// Start first connection
|
||||
const promise1 = connect('game-123')
|
||||
|
||||
// Immediately try to connect again
|
||||
await expect(connect('game-123')).rejects.toThrow('Connection already in progress')
|
||||
|
||||
// Clean up - Note: First promise will time out or fail, that's okay for this test
|
||||
promise1.catch(() => {}) // Suppress unhandled rejection
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action Queueing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('queues actions when not connected', async () => {
|
||||
/**
|
||||
* Test that actions are queued when the connection is lost.
|
||||
*
|
||||
* If the user tries to perform an action while disconnected,
|
||||
* we should queue it for retry when the connection is restored.
|
||||
* This prevents losing actions during brief network interruptions.
|
||||
*/
|
||||
const { sendAction } = useGameSocket()
|
||||
|
||||
const mockAction = {
|
||||
type: 'play_card' as const,
|
||||
instance_id: 'card-1',
|
||||
}
|
||||
|
||||
await expect(sendAction(mockAction)).rejects.toThrow('Not connected')
|
||||
|
||||
// Check that action was queued in the game store
|
||||
expect(gameStore.pendingActions).toHaveLength(1)
|
||||
expect(gameStore.pendingActions[0]).toEqual(mockAction)
|
||||
})
|
||||
|
||||
it('requires game context for actions', async () => {
|
||||
/**
|
||||
* Test that actions fail when not in a game.
|
||||
*
|
||||
* sendAction() should only work when connected to a specific game.
|
||||
* This prevents accidental action sends before joining a game.
|
||||
*/
|
||||
const { sendAction } = useGameSocket()
|
||||
|
||||
const mockAction = {
|
||||
type: 'end_turn' as const,
|
||||
}
|
||||
|
||||
await expect(sendAction(mockAction)).rejects.toThrow()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('clears state on disconnect', () => {
|
||||
/**
|
||||
* Test that disconnect() clears game state.
|
||||
*
|
||||
* When leaving a game, the composable should reset to initial state.
|
||||
* This includes clearing the current game ID and game state.
|
||||
*/
|
||||
const { disconnect, currentGameId } = useGameSocket()
|
||||
|
||||
// Set up some state
|
||||
gameStore.currentGameId = 'game-123'
|
||||
gameStore.gameState = {} as any
|
||||
|
||||
disconnect()
|
||||
|
||||
expect(currentGameId.value).toBeNull()
|
||||
expect(gameStore.gameState).toBeNull()
|
||||
})
|
||||
|
||||
it('sets disconnected status on disconnect', () => {
|
||||
/**
|
||||
* Test that disconnect() updates connection status.
|
||||
*
|
||||
* The connection status should be set to DISCONNECTED so the UI
|
||||
* can react appropriately (hide game UI, show lobby, etc.).
|
||||
*/
|
||||
const { disconnect } = useGameSocket()
|
||||
|
||||
// Simulate being connected
|
||||
gameStore.setConnectionStatus(ConnectionStatus.CONNECTED)
|
||||
|
||||
disconnect()
|
||||
|
||||
expect(gameStore.connectionStatus).toBe(ConnectionStatus.DISCONNECTED)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration with Stores
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('integrates with game store for state', () => {
|
||||
/**
|
||||
* Test that the composable updates the game store.
|
||||
*
|
||||
* All game state should flow through the game store as the single
|
||||
* source of truth. This test verifies the integration is set up.
|
||||
*/
|
||||
const { disconnect } = useGameSocket()
|
||||
|
||||
// Verify we can interact with game store through the composable
|
||||
disconnect()
|
||||
|
||||
// State should be cleared via the store
|
||||
expect(gameStore.currentGameId).toBeNull()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Return API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('exposes the correct API', () => {
|
||||
/**
|
||||
* Test that the composable returns the expected interface.
|
||||
*
|
||||
* Components using this composable depend on a specific API.
|
||||
* This test ensures the public interface is complete and correct.
|
||||
*/
|
||||
const api = useGameSocket()
|
||||
|
||||
expect(api).toHaveProperty('isConnected')
|
||||
expect(api).toHaveProperty('isConnecting')
|
||||
expect(api).toHaveProperty('currentGameId')
|
||||
expect(api).toHaveProperty('lastHeartbeatAck')
|
||||
expect(api).toHaveProperty('connect')
|
||||
expect(api).toHaveProperty('disconnect')
|
||||
expect(api).toHaveProperty('sendAction')
|
||||
expect(api).toHaveProperty('sendResign')
|
||||
|
||||
expect(typeof api.connect).toBe('function')
|
||||
expect(typeof api.disconnect).toBe('function')
|
||||
expect(typeof api.sendAction).toBe('function')
|
||||
expect(typeof api.sendResign).toBe('function')
|
||||
})
|
||||
})
|
||||
486
frontend/src/composables/useGameSocket.ts
Normal file
486
frontend/src/composables/useGameSocket.ts
Normal file
@ -0,0 +1,486 @@
|
||||
/**
|
||||
* Composable for managing WebSocket connection to game namespace.
|
||||
*
|
||||
* Provides a high-level interface for:
|
||||
* - Connecting to and disconnecting from games
|
||||
* - Sending actions and handling results
|
||||
* - Receiving real-time game state updates
|
||||
* - Automatic reconnection with event replay
|
||||
* - Heartbeat to maintain connection
|
||||
*
|
||||
* This composable wraps the lower-level socketClient and integrates
|
||||
* with the game store for state management.
|
||||
*/
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
|
||||
import { config } from '@/config'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
import type {
|
||||
Action,
|
||||
} from '@/types'
|
||||
import {
|
||||
ConnectionStatus,
|
||||
generateMessageId,
|
||||
} from '@/types'
|
||||
|
||||
// Type for Socket.IO with typed events
|
||||
type GameSocket = Socket
|
||||
|
||||
// Singleton state for socket connection
|
||||
let socket: GameSocket | null = null
|
||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null
|
||||
let reconnectAttempts = 0
|
||||
const MAX_RECONNECT_ATTEMPTS = 10
|
||||
const HEARTBEAT_INTERVAL_MS = 30000 // 30 seconds
|
||||
|
||||
/**
|
||||
* Composable for game WebSocket connection.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const { connect, disconnect, sendAction, sendResign, isConnected } = useGameSocket()
|
||||
*
|
||||
* await connect('game-id-123')
|
||||
* await sendAction({ type: 'play_card', instance_id: 'card-1' })
|
||||
* disconnect()
|
||||
* ```
|
||||
*/
|
||||
export function useGameSocket() {
|
||||
const auth = useAuthStore()
|
||||
const gameStore = useGameStore()
|
||||
const ui = useUiStore()
|
||||
|
||||
// Local reactive state
|
||||
const currentGameId = ref<string | null>(null)
|
||||
const isConnecting = ref(false)
|
||||
const lastHeartbeatAck = ref<Date | null>(null)
|
||||
|
||||
// Computed state
|
||||
const isConnected = computed(() => socket?.connected ?? false)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Connect to a game via WebSocket.
|
||||
*
|
||||
* Establishes socket connection, joins game room, and sets up event handlers.
|
||||
*
|
||||
* @param gameId - ID of the game to join
|
||||
* @throws Error if authentication fails or connection times out
|
||||
*/
|
||||
async function connect(gameId: string): Promise<void> {
|
||||
if (isConnecting.value) {
|
||||
throw new Error('Connection already in progress')
|
||||
}
|
||||
|
||||
if (socket?.connected && currentGameId.value === gameId) {
|
||||
// Already connected to this game
|
||||
return
|
||||
}
|
||||
|
||||
isConnecting.value = true
|
||||
currentGameId.value = gameId
|
||||
gameStore.setConnectionStatus(ConnectionStatus.CONNECTING)
|
||||
|
||||
try {
|
||||
// Get valid auth token
|
||||
const token = await auth.getValidToken()
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated')
|
||||
}
|
||||
|
||||
// Disconnect existing socket if any
|
||||
if (socket) {
|
||||
socket.disconnect()
|
||||
socket = null
|
||||
}
|
||||
|
||||
// Create new socket connection
|
||||
const socketUrl = config.wsUrl
|
||||
socket = io(socketUrl + '/game', {
|
||||
auth: { token },
|
||||
autoConnect: true,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: MAX_RECONNECT_ATTEMPTS,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 30000,
|
||||
timeout: 20000,
|
||||
transports: ['websocket', 'polling'],
|
||||
})
|
||||
|
||||
// Set up event handlers
|
||||
setupEventHandlers()
|
||||
|
||||
// Wait for connection
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('Connection timeout'))
|
||||
}, 20000)
|
||||
|
||||
socket!.once('connect', () => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
})
|
||||
|
||||
socket!.once('connect_error', (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
|
||||
// Join the game room
|
||||
emitJoinGame(gameId)
|
||||
|
||||
// Start heartbeat
|
||||
startHeartbeat()
|
||||
|
||||
gameStore.setConnectionStatus(ConnectionStatus.CONNECTED)
|
||||
reconnectAttempts = 0
|
||||
} catch (error) {
|
||||
gameStore.setConnectionStatus(ConnectionStatus.DISCONNECTED)
|
||||
const message = error instanceof Error ? error.message : 'Failed to connect'
|
||||
ui.showError(message)
|
||||
throw error
|
||||
} finally {
|
||||
isConnecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the current game.
|
||||
*
|
||||
* Stops heartbeat, leaves game room, and closes socket.
|
||||
*/
|
||||
function disconnect(): void {
|
||||
stopHeartbeat()
|
||||
|
||||
if (socket) {
|
||||
socket.disconnect()
|
||||
socket = null
|
||||
}
|
||||
|
||||
currentGameId.value = null
|
||||
gameStore.setConnectionStatus(ConnectionStatus.DISCONNECTED)
|
||||
gameStore.clearGame()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Set up all Socket.IO event handlers.
|
||||
*/
|
||||
function setupEventHandlers(): void {
|
||||
if (!socket) return
|
||||
|
||||
// Connection events
|
||||
socket.on('connect', handleConnect)
|
||||
socket.on('disconnect', handleDisconnect)
|
||||
socket.on('connect_error', handleConnectError)
|
||||
|
||||
// Game events
|
||||
socket.on('game:state', handleGameState)
|
||||
socket.on('game:action_result', handleActionResult)
|
||||
socket.on('game:error', handleGameError)
|
||||
socket.on('game:turn_start', handleTurnStart)
|
||||
socket.on('game:turn_timeout_warning', handleTurnTimeout)
|
||||
socket.on('game:game_over', handleGameOver)
|
||||
socket.on('game:opponent_connected', handleOpponentStatus)
|
||||
socket.on('heartbeat_ack', handleHeartbeatAck)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle successful connection.
|
||||
*/
|
||||
function handleConnect(): void {
|
||||
gameStore.setConnectionStatus(ConnectionStatus.CONNECTED)
|
||||
|
||||
// If reconnecting, rejoin the game with last event ID
|
||||
if (currentGameId.value) {
|
||||
emitJoinGame(currentGameId.value, gameStore.lastEventId ?? undefined)
|
||||
|
||||
// Retry any pending actions
|
||||
const pendingActions = gameStore.takePendingActions()
|
||||
for (const action of pendingActions) {
|
||||
sendAction(action).catch((err) => {
|
||||
console.error('Failed to retry pending action:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
reconnectAttempts = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle disconnection.
|
||||
*/
|
||||
function handleDisconnect(reason: string): void {
|
||||
stopHeartbeat()
|
||||
|
||||
if (reason === 'io server disconnect') {
|
||||
// Server forced disconnect - don't auto-reconnect
|
||||
gameStore.setConnectionStatus(ConnectionStatus.DISCONNECTED)
|
||||
ui.showError('Disconnected from server')
|
||||
disconnect()
|
||||
} else {
|
||||
// Network issue or client disconnect - will auto-reconnect
|
||||
gameStore.setConnectionStatus(ConnectionStatus.RECONNECTING)
|
||||
ui.showWarning('Connection lost. Reconnecting...')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle connection error.
|
||||
*/
|
||||
function handleConnectError(_error: Error): void {
|
||||
reconnectAttempts++
|
||||
|
||||
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
gameStore.setConnectionStatus(ConnectionStatus.DISCONNECTED)
|
||||
ui.showError('Failed to connect to game server')
|
||||
disconnect()
|
||||
} else {
|
||||
gameStore.setConnectionStatus(ConnectionStatus.RECONNECTING)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle game state update.
|
||||
*/
|
||||
function handleGameState(message: unknown): void {
|
||||
const msg = message as { state: unknown; event_id: string }
|
||||
gameStore.setGameState(msg.state as any, msg.event_id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle action result.
|
||||
*/
|
||||
function handleActionResult(message: unknown): void {
|
||||
const msg = message as { success: boolean; error_message?: string }
|
||||
if (!msg.success) {
|
||||
const errorMsg = msg.error_message ?? 'Action failed'
|
||||
ui.showError(errorMsg)
|
||||
}
|
||||
// Action results are followed by game:state updates, so we don't
|
||||
// need to do anything special here beyond error notification
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle game error.
|
||||
*/
|
||||
function handleGameError(message: unknown): void {
|
||||
const msg = message as { message: string }
|
||||
ui.showError(msg.message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle turn start notification.
|
||||
*/
|
||||
function handleTurnStart(message: unknown): void {
|
||||
const msg = message as { player_id: string }
|
||||
// Turn start is reflected in the game state, but we can show
|
||||
// a notification if desired
|
||||
if (msg.player_id === gameStore.gameState?.viewer_id) {
|
||||
ui.showInfo('Your turn!', 2000)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle turn timeout warning.
|
||||
*/
|
||||
function handleTurnTimeout(message: unknown): void {
|
||||
const msg = message as { is_warning: boolean; player_id: string; remaining_seconds: number }
|
||||
if (msg.is_warning) {
|
||||
if (msg.player_id === gameStore.gameState?.viewer_id) {
|
||||
ui.showWarning(`Time running out! ${msg.remaining_seconds}s remaining`, 3000)
|
||||
}
|
||||
} else {
|
||||
// Timeout occurred
|
||||
if (msg.player_id === gameStore.gameState?.viewer_id) {
|
||||
ui.showError('Turn timed out!')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle game over notification.
|
||||
*/
|
||||
function handleGameOver(message: unknown): void {
|
||||
const msg = message as { final_state: unknown; event_id: string; winner_id: string | null }
|
||||
gameStore.setGameState(msg.final_state as any, msg.event_id)
|
||||
|
||||
const isWinner = msg.winner_id === gameStore.gameState?.viewer_id
|
||||
if (isWinner) {
|
||||
ui.showSuccess('Victory!', 5000)
|
||||
} else if (msg.winner_id === null) {
|
||||
ui.showInfo('Game ended in a draw', 5000)
|
||||
} else {
|
||||
ui.showInfo('Defeat', 5000)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle opponent connection status.
|
||||
*/
|
||||
function handleOpponentStatus(message: unknown): void {
|
||||
const msg = message as { status: ConnectionStatus }
|
||||
if (msg.status === ConnectionStatus.DISCONNECTED) {
|
||||
ui.showWarning('Opponent disconnected', 3000)
|
||||
} else if (msg.status === ConnectionStatus.CONNECTED) {
|
||||
ui.showInfo('Opponent reconnected', 2000)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle heartbeat acknowledgment.
|
||||
*/
|
||||
function handleHeartbeatAck(_message: unknown): void {
|
||||
lastHeartbeatAck.value = new Date()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action Sending
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Send a game action to the server.
|
||||
*
|
||||
* @param action - The game action to execute
|
||||
* @returns Promise that resolves when action is sent (not when processed)
|
||||
* @throws Error if not connected or action send fails
|
||||
*/
|
||||
async function sendAction(action: Action): Promise<void> {
|
||||
if (!socket?.connected) {
|
||||
// Queue for retry on reconnect
|
||||
gameStore.queueAction(action)
|
||||
throw new Error('Not connected. Action queued for retry.')
|
||||
}
|
||||
|
||||
if (!currentGameId.value) {
|
||||
throw new Error('Not in a game')
|
||||
}
|
||||
|
||||
const message = {
|
||||
type: 'action' as const,
|
||||
message_id: generateMessageId(),
|
||||
game_id: currentGameId.value,
|
||||
action,
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
socket!.emit('game:action', message, (ack?: { success: boolean; error?: string }) => {
|
||||
if (ack?.success === false) {
|
||||
reject(new Error(ack.error ?? 'Action failed'))
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resign from the current game.
|
||||
*
|
||||
* @throws Error if not connected
|
||||
*/
|
||||
async function sendResign(): Promise<void> {
|
||||
if (!socket?.connected) {
|
||||
throw new Error('Not connected')
|
||||
}
|
||||
|
||||
if (!currentGameId.value) {
|
||||
throw new Error('Not in a game')
|
||||
}
|
||||
|
||||
const message = {
|
||||
type: 'resign' as const,
|
||||
message_id: generateMessageId(),
|
||||
game_id: currentGameId.value,
|
||||
}
|
||||
|
||||
socket.emit('game:resign', message)
|
||||
ui.showInfo('Resigned from game')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Emit join_game message with optional last event ID for replay.
|
||||
*/
|
||||
function emitJoinGame(gameId: string, lastEventId?: string): void {
|
||||
if (!socket) return
|
||||
|
||||
const message = {
|
||||
type: 'join_game' as const,
|
||||
message_id: generateMessageId(),
|
||||
game_id: gameId,
|
||||
last_event_id: lastEventId,
|
||||
}
|
||||
|
||||
socket.emit('game:join', message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start heartbeat interval.
|
||||
*/
|
||||
function startHeartbeat(): void {
|
||||
stopHeartbeat() // Clear any existing interval
|
||||
|
||||
heartbeatInterval = setInterval(() => {
|
||||
if (socket?.connected) {
|
||||
const message = {
|
||||
type: 'heartbeat' as const,
|
||||
message_id: generateMessageId(),
|
||||
}
|
||||
socket.emit('heartbeat', message)
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop heartbeat interval.
|
||||
*/
|
||||
function stopHeartbeat(): void {
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval)
|
||||
heartbeatInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Clean up on unmount
|
||||
onUnmounted(() => {
|
||||
disconnect()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Return API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
// Connection state
|
||||
isConnected,
|
||||
isConnecting,
|
||||
currentGameId: computed(() => currentGameId.value),
|
||||
lastHeartbeatAck: computed(() => lastHeartbeatAck.value),
|
||||
|
||||
// Lifecycle
|
||||
connect,
|
||||
disconnect,
|
||||
|
||||
// Actions
|
||||
sendAction,
|
||||
sendResign,
|
||||
}
|
||||
}
|
||||
259
frontend/src/composables/useGames.ts
Normal file
259
frontend/src/composables/useGames.ts
Normal file
@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Games composable for game creation and active game listing.
|
||||
*
|
||||
* Provides methods to create new games and fetch active games for the user.
|
||||
* This composable is used by the PlayPage to manage game lobby functionality.
|
||||
*
|
||||
* @example
|
||||
* ```vue
|
||||
* <script setup lang="ts">
|
||||
* import { useGames } from '@/composables/useGames'
|
||||
*
|
||||
* const { activeGames, isLoading, fetchActiveGames, createGame } = useGames()
|
||||
*
|
||||
* onMounted(() => fetchActiveGames())
|
||||
*
|
||||
* async function handleCreateGame(deckId: string) {
|
||||
* const result = await createGame({
|
||||
* deck_id: deckId,
|
||||
* opponent_id: 'ai-opponent-id',
|
||||
* opponent_deck_id: 'ai-deck-id',
|
||||
* })
|
||||
* if (result.success) {
|
||||
* router.push(`/game/${result.data.game_id}`)
|
||||
* }
|
||||
* }
|
||||
* </script>
|
||||
* ```
|
||||
*/
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
|
||||
import { apiClient, ApiError } from '@/api/client'
|
||||
import type {
|
||||
GameCreateRequest,
|
||||
GameCreateResponse,
|
||||
GameInfoResponse,
|
||||
ActiveGameSummary,
|
||||
ActiveGameListResponse,
|
||||
} from '@/types'
|
||||
|
||||
// ============================================================================
|
||||
// Result Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Result of a game operation.
|
||||
*/
|
||||
export interface GameResult<T = void> {
|
||||
success: boolean
|
||||
error?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of fetching active games list.
|
||||
*/
|
||||
export interface ActiveGamesResult extends GameResult<ActiveGameSummary[]> {
|
||||
total?: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Handling
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get user-friendly error message from API error.
|
||||
*/
|
||||
function getErrorMessage(e: unknown, defaultMessage: string): string {
|
||||
if (e instanceof ApiError) {
|
||||
// Handle specific status codes
|
||||
if (e.status === 404) {
|
||||
return 'Game not found'
|
||||
}
|
||||
if (e.status === 403) {
|
||||
return 'You do not have permission to access this game'
|
||||
}
|
||||
if (e.status === 400) {
|
||||
// Validation error - return detail from API
|
||||
return e.message || 'Invalid game data'
|
||||
}
|
||||
return e.message || defaultMessage
|
||||
}
|
||||
if (e instanceof Error) {
|
||||
return e.message
|
||||
}
|
||||
return defaultMessage
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Composable
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Games composable.
|
||||
*
|
||||
* Provides methods for game creation and active game listing with loading/error handling.
|
||||
*/
|
||||
export function useGames() {
|
||||
// Local state
|
||||
const activeGames = ref<ActiveGameSummary[]>([])
|
||||
const isFetching = ref(false)
|
||||
const isCreating = ref(false)
|
||||
const fetchError = ref<string | null>(null)
|
||||
const createError = ref<string | null>(null)
|
||||
|
||||
// Computed
|
||||
const isLoading = computed(() => isFetching.value || isCreating.value)
|
||||
const error = computed(() => fetchError.value || createError.value)
|
||||
const hasActiveGames = computed(() => activeGames.value.length > 0)
|
||||
|
||||
/**
|
||||
* Fetch all active games for the current user.
|
||||
*
|
||||
* Active games are games that have not ended yet and the user is a participant.
|
||||
*
|
||||
* @returns Result indicating success or failure with active games data
|
||||
*/
|
||||
async function fetchActiveGames(): Promise<ActiveGamesResult> {
|
||||
if (isFetching.value) {
|
||||
return { success: false, error: 'Fetch already in progress' }
|
||||
}
|
||||
|
||||
isFetching.value = true
|
||||
fetchError.value = null
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<ActiveGameListResponse>(
|
||||
'/api/games/me/active'
|
||||
)
|
||||
|
||||
activeGames.value = response.games
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.games,
|
||||
total: response.total,
|
||||
}
|
||||
} catch (e) {
|
||||
const errorMessage = getErrorMessage(e, 'Failed to fetch active games')
|
||||
fetchError.value = errorMessage
|
||||
return { success: false, error: errorMessage }
|
||||
} finally {
|
||||
isFetching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new game.
|
||||
*
|
||||
* Creates a game with the specified deck and opponent configuration.
|
||||
* Returns the game ID and WebSocket URL for connecting to the game.
|
||||
*
|
||||
* @param data - Game creation data
|
||||
* @returns Result indicating success or failure with game creation response
|
||||
*/
|
||||
async function createGame(
|
||||
data: GameCreateRequest
|
||||
): Promise<GameResult<GameCreateResponse>> {
|
||||
if (isCreating.value) {
|
||||
return { success: false, error: 'Create already in progress' }
|
||||
}
|
||||
|
||||
isCreating.value = true
|
||||
createError.value = null
|
||||
|
||||
try {
|
||||
const response = await apiClient.post<GameCreateResponse>(
|
||||
'/api/games',
|
||||
data
|
||||
)
|
||||
|
||||
// Refresh active games list to include the new game
|
||||
// Don't await this - let it run in background
|
||||
fetchActiveGames().catch((err) => {
|
||||
console.warn('Background fetch of active games failed:', err)
|
||||
// Non-critical - user can manually refresh if needed
|
||||
})
|
||||
|
||||
return { success: true, data: response }
|
||||
} catch (e) {
|
||||
const errorMessage = getErrorMessage(e, 'Failed to create game')
|
||||
createError.value = errorMessage
|
||||
return { success: false, error: errorMessage }
|
||||
} finally {
|
||||
isCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch game info by ID.
|
||||
*
|
||||
* Gets detailed information about a specific game.
|
||||
*
|
||||
* @param gameId - The game ID to fetch
|
||||
* @returns Result indicating success or failure with game info
|
||||
*/
|
||||
async function fetchGameInfo(
|
||||
gameId: string
|
||||
): Promise<GameResult<GameInfoResponse>> {
|
||||
if (isFetching.value) {
|
||||
return { success: false, error: 'Fetch already in progress' }
|
||||
}
|
||||
|
||||
isFetching.value = true
|
||||
fetchError.value = null
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<GameInfoResponse>(
|
||||
`/api/games/${gameId}`
|
||||
)
|
||||
|
||||
return { success: true, data: response }
|
||||
} catch (e) {
|
||||
const errorMessage = getErrorMessage(e, 'Failed to fetch game info')
|
||||
fetchError.value = errorMessage
|
||||
return { success: false, error: errorMessage }
|
||||
} finally {
|
||||
isFetching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all game state.
|
||||
* Called when user logs out.
|
||||
*/
|
||||
function clear(): void {
|
||||
activeGames.value = []
|
||||
fetchError.value = null
|
||||
createError.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear any error state.
|
||||
*/
|
||||
function clearError(): void {
|
||||
fetchError.value = null
|
||||
createError.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State (readonly)
|
||||
activeGames: readonly(activeGames),
|
||||
hasActiveGames,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Loading states for specific operations
|
||||
isFetching: readonly(isFetching),
|
||||
isCreating: readonly(isCreating),
|
||||
|
||||
// Actions
|
||||
fetchActiveGames,
|
||||
createGame,
|
||||
fetchGameInfo,
|
||||
clear,
|
||||
clearError,
|
||||
}
|
||||
}
|
||||
|
||||
export type UseGames = ReturnType<typeof useGames>
|
||||
Loading…
Reference in New Issue
Block a user