From 413caa86d0cd251356e97c342ea09454a9d3761d Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sun, 1 Feb 2026 20:51:58 -0600 Subject: [PATCH] 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 --- .../src/composables/useGameActions.spec.ts | 699 ++++++++++++++++++ frontend/src/composables/useGameActions.ts | 453 ++++++++++++ .../src/composables/useGameSocket.spec.ts | 225 ++++++ frontend/src/composables/useGameSocket.ts | 486 ++++++++++++ frontend/src/composables/useGames.ts | 259 +++++++ 5 files changed, 2122 insertions(+) create mode 100644 frontend/src/composables/useGameActions.spec.ts create mode 100644 frontend/src/composables/useGameActions.ts create mode 100644 frontend/src/composables/useGameSocket.spec.ts create mode 100644 frontend/src/composables/useGameSocket.ts create mode 100644 frontend/src/composables/useGames.ts diff --git a/frontend/src/composables/useGameActions.spec.ts b/frontend/src/composables/useGameActions.spec.ts new file mode 100644 index 0000000..68b5846 --- /dev/null +++ b/frontend/src/composables/useGameActions.spec.ts @@ -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 { + 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) + }) + }) +}) diff --git a/frontend/src/composables/useGameActions.ts b/frontend/src/composables/useGameActions.ts new file mode 100644 index 0000000..1c4de2b --- /dev/null +++ b/frontend/src/composables/useGameActions.ts @@ -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(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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + } +} diff --git a/frontend/src/composables/useGameSocket.spec.ts b/frontend/src/composables/useGameSocket.spec.ts new file mode 100644 index 0000000..12f8304 --- /dev/null +++ b/frontend/src/composables/useGameSocket.spec.ts @@ -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 + let gameStore: ReturnType + + 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') + }) +}) diff --git a/frontend/src/composables/useGameSocket.ts b/frontend/src/composables/useGameSocket.ts new file mode 100644 index 0000000..fe3da40 --- /dev/null +++ b/frontend/src/composables/useGameSocket.ts @@ -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 | 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(null) + const isConnecting = ref(false) + const lastHeartbeatAck = ref(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 { + 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((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 { + 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((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 { + 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, + } +} diff --git a/frontend/src/composables/useGames.ts b/frontend/src/composables/useGames.ts new file mode 100644 index 0000000..67acd5d --- /dev/null +++ b/frontend/src/composables/useGames.ts @@ -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 + * + * ``` + */ +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 { + success: boolean + error?: string + data?: T +} + +/** + * Result of fetching active games list. + */ +export interface ActiveGamesResult extends GameResult { + 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([]) + const isFetching = ref(false) + const isCreating = ref(false) + const fetchError = ref(null) + const createError = ref(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 { + if (isFetching.value) { + return { success: false, error: 'Fetch already in progress' } + } + + isFetching.value = true + fetchError.value = null + + try { + const response = await apiClient.get( + '/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> { + if (isCreating.value) { + return { success: false, error: 'Create already in progress' } + } + + isCreating.value = true + createError.value = null + + try { + const response = await apiClient.post( + '/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> { + if (isFetching.value) { + return { success: false, error: 'Fetch already in progress' } + } + + isFetching.value = true + fetchError.value = null + + try { + const response = await apiClient.get( + `/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