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:
Cal Corum 2026-02-01 20:51:58 -06:00
parent 97ddc44336
commit 413caa86d0
5 changed files with 2122 additions and 0 deletions

View 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)
})
})
})

View 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,
}
}

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

View 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,
}
}

View 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>