Add Phase F4 game UI overlay components
- Add TurnIndicator to show current turn and phase - Add AttackMenu for selecting Pokemon attacks - Add GameOverlay container for positioning UI over Phaser - Add GameOverModal for end-game display - Add ForcedActionModal for required player actions - Add PhaseActions for phase-specific buttons - Include component tests Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
8ad7552ecc
commit
97ddc44336
754
frontend/src/components/game/AttackMenu.spec.ts
Normal file
754
frontend/src/components/game/AttackMenu.spec.ts
Normal file
@ -0,0 +1,754 @@
|
||||
/**
|
||||
* Tests for AttackMenu component.
|
||||
*
|
||||
* Verifies attack selection UI, energy validation, status condition checks,
|
||||
* and action dispatching functionality.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import AttackMenu from './AttackMenu.vue'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
import type { VisibleGameState, CardInstance, CardDefinition, Attack } from '@/types'
|
||||
|
||||
// Mock the useGameActions composable
|
||||
vi.mock('@/composables/useGameActions', () => ({
|
||||
useGameActions: () => ({
|
||||
attack: vi.fn().mockResolvedValue(undefined),
|
||||
isPending: { value: false },
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the energy colors utility
|
||||
vi.mock('@/utils/energyColors', () => ({
|
||||
getEnergyColor: (type: string) => `#${type}`,
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a mock CardInstance for testing.
|
||||
*/
|
||||
function createMockCardInstance(overrides: Partial<CardInstance> = {}): CardInstance {
|
||||
return {
|
||||
instance_id: 'test-instance-1',
|
||||
definition_id: 'pikachu-base',
|
||||
damage: 0,
|
||||
attached_energy: [],
|
||||
attached_tools: [],
|
||||
status_conditions: [],
|
||||
ability_uses_this_turn: {},
|
||||
evolution_turn: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock CardDefinition for a Pokemon with attacks.
|
||||
*/
|
||||
function createMockPokemonDefinition(attacks: Attack[]): CardDefinition {
|
||||
return {
|
||||
id: 'pikachu-base',
|
||||
name: 'Pikachu',
|
||||
card_type: 'pokemon',
|
||||
stage: 'basic',
|
||||
hp: 60,
|
||||
pokemon_type: 'lightning',
|
||||
attacks,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock VisibleGameState for testing.
|
||||
*/
|
||||
function createMockGameState(
|
||||
activePokemon: CardInstance | null,
|
||||
cardRegistry: Record<string, CardDefinition>
|
||||
): VisibleGameState {
|
||||
return {
|
||||
game_id: 'test-game',
|
||||
viewer_id: 'player1',
|
||||
players: {
|
||||
player1: {
|
||||
player_id: 'player1',
|
||||
is_current_player: true,
|
||||
deck_count: 30,
|
||||
hand: { count: 5, cards: [], zone_type: 'hand' },
|
||||
prizes_count: 3,
|
||||
energy_deck_count: 10,
|
||||
active: {
|
||||
count: activePokemon ? 1 : 0,
|
||||
cards: activePokemon ? [activePokemon] : [],
|
||||
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,
|
||||
},
|
||||
player2: {
|
||||
player_id: 'player2',
|
||||
is_current_player: false,
|
||||
deck_count: 30,
|
||||
hand: { count: 5, cards: [], zone_type: 'hand' },
|
||||
prizes_count: 3,
|
||||
energy_deck_count: 10,
|
||||
active: { count: 0, cards: [], zone_type: 'active' },
|
||||
bench: { count: 0, cards: [], zone_type: 'bench' },
|
||||
discard: { count: 0, cards: [], zone_type: 'discard' },
|
||||
energy_zone: { count: 0, cards: [], zone_type: 'energy_zone' },
|
||||
score: 0,
|
||||
gx_attack_used: false,
|
||||
vstar_power_used: false,
|
||||
},
|
||||
},
|
||||
current_player_id: 'player1',
|
||||
turn_number: 1,
|
||||
phase: 'attack',
|
||||
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: cardRegistry,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount AttackMenu with game state injection.
|
||||
*/
|
||||
function mountAttackMenu(props: { show: boolean }, gameState: VisibleGameState | null) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
|
||||
const store = useGameStore()
|
||||
if (gameState) {
|
||||
store.setGameState(gameState)
|
||||
}
|
||||
|
||||
return mount(AttackMenu, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
provide: {
|
||||
gameState: { value: gameState },
|
||||
isMyTurn: { value: gameState?.is_my_turn ?? false },
|
||||
currentPhase: { value: gameState?.phase ?? null },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('AttackMenu', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders when show prop is true', () => {
|
||||
/**
|
||||
* Test that the attack menu renders when the show prop is true.
|
||||
*
|
||||
* The attack menu should be visible to allow the player to select
|
||||
* an attack during the attack phase.
|
||||
*/
|
||||
const attacks: Attack[] = [
|
||||
{ name: 'Thunder Shock', cost: ['lightning'], damage: 20 },
|
||||
]
|
||||
const activePokemon = createMockCardInstance()
|
||||
const pokemonDef = createMockPokemonDefinition(attacks)
|
||||
const gameState = createMockGameState(activePokemon, {
|
||||
[pokemonDef.id]: pokemonDef,
|
||||
})
|
||||
|
||||
const wrapper = mountAttackMenu({ show: true }, gameState)
|
||||
|
||||
expect(wrapper.find('[data-testid="attack-menu"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render when show prop is false', () => {
|
||||
/**
|
||||
* Test that the attack menu does not render when the show prop is false.
|
||||
*
|
||||
* The menu should be hidden when not in use to avoid cluttering
|
||||
* the game interface.
|
||||
*/
|
||||
const attacks: Attack[] = [
|
||||
{ name: 'Thunder Shock', cost: ['lightning'], damage: 20 },
|
||||
]
|
||||
const activePokemon = createMockCardInstance()
|
||||
const pokemonDef = createMockPokemonDefinition(attacks)
|
||||
const gameState = createMockGameState(activePokemon, {
|
||||
[pokemonDef.id]: pokemonDef,
|
||||
})
|
||||
|
||||
const wrapper = mountAttackMenu({ show: false }, gameState)
|
||||
|
||||
expect(wrapper.find('[data-testid="attack-menu"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('displays active Pokemon name', () => {
|
||||
/**
|
||||
* Test that the attack menu displays the active Pokemon's name.
|
||||
*
|
||||
* Players need to see which Pokemon is attacking to make informed
|
||||
* decisions about which attack to use.
|
||||
*/
|
||||
const attacks: Attack[] = [
|
||||
{ name: 'Thunder Shock', cost: ['lightning'], damage: 20 },
|
||||
]
|
||||
const activePokemon = createMockCardInstance()
|
||||
const pokemonDef = createMockPokemonDefinition(attacks)
|
||||
const gameState = createMockGameState(activePokemon, {
|
||||
[pokemonDef.id]: pokemonDef,
|
||||
})
|
||||
|
||||
const wrapper = mountAttackMenu({ show: true }, gameState)
|
||||
|
||||
expect(wrapper.text()).toContain('Pikachu')
|
||||
})
|
||||
|
||||
it('displays all available attacks', () => {
|
||||
/**
|
||||
* Test that the attack menu displays all attacks available to the active Pokemon.
|
||||
*
|
||||
* Players must be able to see all attack options to choose the most
|
||||
* strategic move for the current game state.
|
||||
*/
|
||||
const attacks: Attack[] = [
|
||||
{ name: 'Thunder Shock', cost: ['lightning'], damage: 20 },
|
||||
{ name: 'Thunderbolt', cost: ['lightning', 'lightning'], damage: 50 },
|
||||
]
|
||||
const activePokemon = createMockCardInstance()
|
||||
const pokemonDef = createMockPokemonDefinition(attacks)
|
||||
const gameState = createMockGameState(activePokemon, {
|
||||
[pokemonDef.id]: pokemonDef,
|
||||
})
|
||||
|
||||
const wrapper = mountAttackMenu({ show: true }, gameState)
|
||||
|
||||
const attackList = wrapper.find('[data-testid="attack-list"]')
|
||||
expect(attackList.text()).toContain('Thunder Shock')
|
||||
expect(attackList.text()).toContain('Thunderbolt')
|
||||
})
|
||||
|
||||
it('displays attack damage values', () => {
|
||||
/**
|
||||
* Test that the attack menu displays damage values for each attack.
|
||||
*
|
||||
* Damage values are critical information for making strategic
|
||||
* attack decisions.
|
||||
*/
|
||||
const attacks: Attack[] = [
|
||||
{ name: 'Thunder Shock', cost: ['lightning'], damage: 20 },
|
||||
{ name: 'Thunderbolt', cost: ['lightning', 'lightning'], damage: 50 },
|
||||
]
|
||||
const activePokemon = createMockCardInstance()
|
||||
const pokemonDef = createMockPokemonDefinition(attacks)
|
||||
const gameState = createMockGameState(activePokemon, {
|
||||
[pokemonDef.id]: pokemonDef,
|
||||
})
|
||||
|
||||
const wrapper = mountAttackMenu({ show: true }, gameState)
|
||||
|
||||
expect(wrapper.text()).toContain('20')
|
||||
expect(wrapper.text()).toContain('50')
|
||||
})
|
||||
|
||||
it('displays effect descriptions', () => {
|
||||
/**
|
||||
* Test that the attack menu displays effect descriptions for attacks.
|
||||
*
|
||||
* Effect descriptions help players understand what additional
|
||||
* effects an attack might have beyond damage.
|
||||
*/
|
||||
const attacks: Attack[] = [
|
||||
{
|
||||
name: 'Thunder Shock',
|
||||
cost: ['lightning'],
|
||||
damage: 20,
|
||||
effect_description: 'Flip a coin. If heads, the Defending Pokémon is now Paralyzed.',
|
||||
},
|
||||
]
|
||||
const activePokemon = createMockCardInstance()
|
||||
const pokemonDef = createMockPokemonDefinition(attacks)
|
||||
const gameState = createMockGameState(activePokemon, {
|
||||
[pokemonDef.id]: pokemonDef,
|
||||
})
|
||||
|
||||
const wrapper = mountAttackMenu({ show: true }, gameState)
|
||||
|
||||
expect(wrapper.text()).toContain('Flip a coin')
|
||||
})
|
||||
|
||||
it('displays energy cost icons', () => {
|
||||
/**
|
||||
* Test that the attack menu displays energy cost icons for each attack.
|
||||
*
|
||||
* Visual energy cost display helps players quickly identify
|
||||
* which attacks they can afford to use.
|
||||
*/
|
||||
const attacks: Attack[] = [
|
||||
{ name: 'Thunder Shock', cost: ['lightning', 'colorless'], damage: 20 },
|
||||
]
|
||||
const activePokemon = createMockCardInstance()
|
||||
const pokemonDef = createMockPokemonDefinition(attacks)
|
||||
const gameState = createMockGameState(activePokemon, {
|
||||
[pokemonDef.id]: pokemonDef,
|
||||
})
|
||||
|
||||
const wrapper = mountAttackMenu({ show: true }, gameState)
|
||||
|
||||
const energyIcons = wrapper.findAll('.energy-icon')
|
||||
expect(energyIcons).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('energy validation', () => {
|
||||
it('enables attack when enough energy is attached', () => {
|
||||
/**
|
||||
* Test that attacks are enabled when the Pokemon has enough energy.
|
||||
*
|
||||
* Players should be able to use attacks when they have satisfied
|
||||
* the energy cost requirements.
|
||||
*/
|
||||
const attacks: Attack[] = [
|
||||
{ name: 'Thunder Shock', cost: ['lightning'], damage: 20 },
|
||||
]
|
||||
const energyCard = createMockCardInstance({
|
||||
instance_id: 'energy-1',
|
||||
definition_id: 'lightning-energy',
|
||||
})
|
||||
const activePokemon = createMockCardInstance({
|
||||
attached_energy: [energyCard],
|
||||
})
|
||||
const pokemonDef = createMockPokemonDefinition(attacks)
|
||||
const energyDef: CardDefinition = {
|
||||
id: 'lightning-energy',
|
||||
name: 'Lightning Energy',
|
||||
card_type: 'energy',
|
||||
energy_type: 'lightning',
|
||||
}
|
||||
const gameState = createMockGameState(activePokemon, {
|
||||
[pokemonDef.id]: pokemonDef,
|
||||
[energyDef.id]: energyDef,
|
||||
})
|
||||
|
||||
const wrapper = mountAttackMenu({ show: true }, gameState)
|
||||
|
||||
const attackButton = wrapper.find('[data-testid="attack-0"]')
|
||||
expect(attackButton.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables attack when not enough energy is attached', () => {
|
||||
/**
|
||||
* Test that attacks are disabled when the Pokemon lacks sufficient energy.
|
||||
*
|
||||
* Preventing invalid attacks ensures game rules are enforced
|
||||
* and prevents frustrating action failures.
|
||||
*/
|
||||
const attacks: Attack[] = [
|
||||
{ name: 'Thunderbolt', cost: ['lightning', 'lightning'], damage: 50 },
|
||||
]
|
||||
const energyCard = createMockCardInstance({
|
||||
instance_id: 'energy-1',
|
||||
definition_id: 'lightning-energy',
|
||||
})
|
||||
const activePokemon = createMockCardInstance({
|
||||
attached_energy: [energyCard], // Only 1 energy, needs 2
|
||||
})
|
||||
const pokemonDef = createMockPokemonDefinition(attacks)
|
||||
const energyDef: CardDefinition = {
|
||||
id: 'lightning-energy',
|
||||
name: 'Lightning Energy',
|
||||
card_type: 'energy',
|
||||
energy_type: 'lightning',
|
||||
}
|
||||
const gameState = createMockGameState(activePokemon, {
|
||||
[pokemonDef.id]: pokemonDef,
|
||||
[energyDef.id]: energyDef,
|
||||
})
|
||||
|
||||
const wrapper = mountAttackMenu({ show: true }, gameState)
|
||||
|
||||
const attackButton = wrapper.find('[data-testid="attack-0"]')
|
||||
expect(attackButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows "Not enough energy" reason for disabled attacks', async () => {
|
||||
/**
|
||||
* Test that the menu shows a clear reason when attacks are disabled due to energy.
|
||||
*
|
||||
* Clear feedback helps players understand why an attack is unavailable
|
||||
* and what they need to do to enable it.
|
||||
*/
|
||||
const attacks: Attack[] = [
|
||||
{ name: 'Thunderbolt', cost: ['lightning', 'lightning'], damage: 50 },
|
||||
]
|
||||
const activePokemon = createMockCardInstance({
|
||||
attached_energy: [], // No energy
|
||||
})
|
||||
const pokemonDef = createMockPokemonDefinition(attacks)
|
||||
const gameState = createMockGameState(activePokemon, {
|
||||
[pokemonDef.id]: pokemonDef,
|
||||
})
|
||||
|
||||
const wrapper = mountAttackMenu({ show: true }, gameState)
|
||||
|
||||
const attackButton = wrapper.find('[data-testid="attack-0"]')
|
||||
// Hover to show tooltip
|
||||
await attackButton.trigger('mouseenter')
|
||||
|
||||
const tooltip = wrapper.find('[data-testid="disabled-reason"]')
|
||||
expect(tooltip.text()).toContain('Not enough energy')
|
||||
})
|
||||
|
||||
it('validates colorless energy can be satisfied by any type', () => {
|
||||
/**
|
||||
* Test that colorless energy cost can be satisfied by any energy type.
|
||||
*
|
||||
* Colorless energy is a core game mechanic that allows flexible
|
||||
* energy usage across different Pokemon types.
|
||||
*/
|
||||
const attacks: Attack[] = [
|
||||
{ name: 'Tackle', cost: ['colorless'], damage: 10 },
|
||||
]
|
||||
const fireEnergy = createMockCardInstance({
|
||||
instance_id: 'energy-1',
|
||||
definition_id: 'fire-energy',
|
||||
})
|
||||
const activePokemon = createMockCardInstance({
|
||||
attached_energy: [fireEnergy],
|
||||
})
|
||||
const pokemonDef = createMockPokemonDefinition(attacks)
|
||||
const energyDef: CardDefinition = {
|
||||
id: 'fire-energy',
|
||||
name: 'Fire Energy',
|
||||
card_type: 'energy',
|
||||
energy_type: 'fire',
|
||||
}
|
||||
const gameState = createMockGameState(activePokemon, {
|
||||
[pokemonDef.id]: pokemonDef,
|
||||
[energyDef.id]: energyDef,
|
||||
})
|
||||
|
||||
const wrapper = mountAttackMenu({ show: true }, gameState)
|
||||
|
||||
const attackButton = wrapper.find('[data-testid="attack-0"]')
|
||||
expect(attackButton.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('validates specific energy types are required', () => {
|
||||
/**
|
||||
* Test that specific energy type requirements are enforced.
|
||||
*
|
||||
* Ensuring correct energy types are attached is a fundamental
|
||||
* game rule that affects strategic deck building.
|
||||
*/
|
||||
const attacks: Attack[] = [
|
||||
{ name: 'Ember', cost: ['fire'], damage: 30 },
|
||||
]
|
||||
const waterEnergy = createMockCardInstance({
|
||||
instance_id: 'energy-1',
|
||||
definition_id: 'water-energy',
|
||||
})
|
||||
const activePokemon = createMockCardInstance({
|
||||
attached_energy: [waterEnergy], // Wrong type
|
||||
})
|
||||
const pokemonDef = createMockPokemonDefinition(attacks)
|
||||
const energyDef: CardDefinition = {
|
||||
id: 'water-energy',
|
||||
name: 'Water Energy',
|
||||
card_type: 'energy',
|
||||
energy_type: 'water',
|
||||
}
|
||||
const gameState = createMockGameState(activePokemon, {
|
||||
[pokemonDef.id]: pokemonDef,
|
||||
[energyDef.id]: energyDef,
|
||||
})
|
||||
|
||||
const wrapper = mountAttackMenu({ show: true }, gameState)
|
||||
|
||||
const attackButton = wrapper.find('[data-testid="attack-0"]')
|
||||
expect(attackButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('status condition validation', () => {
|
||||
it('disables all attacks when Pokemon is paralyzed', () => {
|
||||
/**
|
||||
* Test that all attacks are disabled when the active Pokemon is paralyzed.
|
||||
*
|
||||
* Paralysis is a status condition that prevents attacks, which is
|
||||
* a critical game mechanic that must be enforced.
|
||||
*/
|
||||
const attacks: Attack[] = [
|
||||
{ name: 'Thunder Shock', cost: ['lightning'], damage: 20 },
|
||||
]
|
||||
const energyCard = createMockCardInstance({
|
||||
instance_id: 'energy-1',
|
||||
definition_id: 'lightning-energy',
|
||||
})
|
||||
const activePokemon = createMockCardInstance({
|
||||
attached_energy: [energyCard],
|
||||
status_conditions: ['paralyzed'],
|
||||
})
|
||||
const pokemonDef = createMockPokemonDefinition(attacks)
|
||||
const energyDef: CardDefinition = {
|
||||
id: 'lightning-energy',
|
||||
name: 'Lightning Energy',
|
||||
card_type: 'energy',
|
||||
energy_type: 'lightning',
|
||||
}
|
||||
const gameState = createMockGameState(activePokemon, {
|
||||
[pokemonDef.id]: pokemonDef,
|
||||
[energyDef.id]: energyDef,
|
||||
})
|
||||
|
||||
const wrapper = mountAttackMenu({ show: true }, gameState)
|
||||
|
||||
const attackButton = wrapper.find('[data-testid="attack-0"]')
|
||||
expect(attackButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows paralysis warning message', () => {
|
||||
/**
|
||||
* Test that a warning message is displayed when the Pokemon is paralyzed.
|
||||
*
|
||||
* Clear communication about why actions are disabled helps players
|
||||
* understand the game state and plan their next moves.
|
||||
*/
|
||||
const attacks: Attack[] = [
|
||||
{ name: 'Thunder Shock', cost: ['lightning'], damage: 20 },
|
||||
]
|
||||
const activePokemon = createMockCardInstance({
|
||||
status_conditions: ['paralyzed'],
|
||||
})
|
||||
const pokemonDef = createMockPokemonDefinition(attacks)
|
||||
const gameState = createMockGameState(activePokemon, {
|
||||
[pokemonDef.id]: pokemonDef,
|
||||
})
|
||||
|
||||
const wrapper = mountAttackMenu({ show: true }, gameState)
|
||||
|
||||
const warning = wrapper.find('[data-testid="disabled-warning"]')
|
||||
expect(warning.text()).toContain('paralyzed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('user interactions', () => {
|
||||
it('emits close event when cancel button is clicked', async () => {
|
||||
/**
|
||||
* Test that the close event is emitted when the cancel button is clicked.
|
||||
*
|
||||
* Players need a way to close the menu without selecting an attack,
|
||||
* allowing them to reconsider their strategy.
|
||||
*/
|
||||
const attacks: Attack[] = [
|
||||
{ name: 'Thunder Shock', cost: ['lightning'], damage: 20 },
|
||||
]
|
||||
const activePokemon = createMockCardInstance()
|
||||
const pokemonDef = createMockPokemonDefinition(attacks)
|
||||
const gameState = createMockGameState(activePokemon, {
|
||||
[pokemonDef.id]: pokemonDef,
|
||||
})
|
||||
|
||||
const wrapper = mountAttackMenu({ show: true }, gameState)
|
||||
|
||||
await wrapper.find('[data-testid="cancel-button"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('close')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('emits close event when close button is clicked', async () => {
|
||||
/**
|
||||
* Test that the close event is emitted when the X close button is clicked.
|
||||
*
|
||||
* The close button provides an intuitive way to dismiss the modal,
|
||||
* which is a standard UI pattern users expect.
|
||||
*/
|
||||
const attacks: Attack[] = [
|
||||
{ name: 'Thunder Shock', cost: ['lightning'], damage: 20 },
|
||||
]
|
||||
const activePokemon = createMockCardInstance()
|
||||
const pokemonDef = createMockPokemonDefinition(attacks)
|
||||
const gameState = createMockGameState(activePokemon, {
|
||||
[pokemonDef.id]: pokemonDef,
|
||||
})
|
||||
|
||||
const wrapper = mountAttackMenu({ show: true }, gameState)
|
||||
|
||||
await wrapper.find('[data-testid="close-button"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('close')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('emits close event when backdrop is clicked', async () => {
|
||||
/**
|
||||
* Test that the close event is emitted when clicking outside the menu.
|
||||
*
|
||||
* Clicking outside a modal is a common UX pattern for dismissal
|
||||
* that users expect to work.
|
||||
*/
|
||||
const attacks: Attack[] = [
|
||||
{ name: 'Thunder Shock', cost: ['lightning'], damage: 20 },
|
||||
]
|
||||
const activePokemon = createMockCardInstance()
|
||||
const pokemonDef = createMockPokemonDefinition(attacks)
|
||||
const gameState = createMockGameState(activePokemon, {
|
||||
[pokemonDef.id]: pokemonDef,
|
||||
})
|
||||
|
||||
const wrapper = mountAttackMenu({ show: true }, gameState)
|
||||
|
||||
await wrapper.find('[data-testid="attack-menu-backdrop"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('close')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('emits attackSelected event when attack is clicked', async () => {
|
||||
/**
|
||||
* Test that the attackSelected event is emitted with the correct attack index.
|
||||
*
|
||||
* The parent component needs to know which attack was selected to
|
||||
* handle any UI state changes before the action is dispatched.
|
||||
*/
|
||||
const attacks: Attack[] = [
|
||||
{ name: 'Thunder Shock', cost: ['lightning'], damage: 20 },
|
||||
]
|
||||
const energyCard = createMockCardInstance({
|
||||
instance_id: 'energy-1',
|
||||
definition_id: 'lightning-energy',
|
||||
})
|
||||
const activePokemon = createMockCardInstance({
|
||||
attached_energy: [energyCard],
|
||||
})
|
||||
const pokemonDef = createMockPokemonDefinition(attacks)
|
||||
const energyDef: CardDefinition = {
|
||||
id: 'lightning-energy',
|
||||
name: 'Lightning Energy',
|
||||
card_type: 'energy',
|
||||
energy_type: 'lightning',
|
||||
}
|
||||
const gameState = createMockGameState(activePokemon, {
|
||||
[pokemonDef.id]: pokemonDef,
|
||||
[energyDef.id]: energyDef,
|
||||
})
|
||||
|
||||
const wrapper = mountAttackMenu({ show: true }, gameState)
|
||||
|
||||
await wrapper.find('[data-testid="attack-0"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('attackSelected')).toBeTruthy()
|
||||
expect(wrapper.emitted('attackSelected')![0]).toEqual([0])
|
||||
})
|
||||
|
||||
it('does not emit attackSelected when clicking disabled attack', async () => {
|
||||
/**
|
||||
* Test that clicking a disabled attack does not emit the attackSelected event.
|
||||
*
|
||||
* Disabled attacks should not trigger any action to prevent
|
||||
* invalid game state changes and user frustration.
|
||||
*/
|
||||
const attacks: Attack[] = [
|
||||
{ name: 'Thunderbolt', cost: ['lightning', 'lightning'], damage: 50 },
|
||||
]
|
||||
const activePokemon = createMockCardInstance({
|
||||
attached_energy: [], // No energy
|
||||
})
|
||||
const pokemonDef = createMockPokemonDefinition(attacks)
|
||||
const gameState = createMockGameState(activePokemon, {
|
||||
[pokemonDef.id]: pokemonDef,
|
||||
})
|
||||
|
||||
const wrapper = mountAttackMenu({ show: true }, gameState)
|
||||
|
||||
await wrapper.find('[data-testid="attack-0"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('attackSelected')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('shows empty message when Pokemon has no attacks', () => {
|
||||
/**
|
||||
* Test that an appropriate message is shown when the Pokemon has no attacks.
|
||||
*
|
||||
* While rare, some Pokemon cards might not have attacks, and the
|
||||
* UI should gracefully handle this case.
|
||||
*/
|
||||
const attacks: Attack[] = []
|
||||
const activePokemon = createMockCardInstance()
|
||||
const pokemonDef = createMockPokemonDefinition(attacks)
|
||||
const gameState = createMockGameState(activePokemon, {
|
||||
[pokemonDef.id]: pokemonDef,
|
||||
})
|
||||
|
||||
const wrapper = mountAttackMenu({ show: true }, gameState)
|
||||
|
||||
expect(wrapper.text()).toContain('No attacks available')
|
||||
})
|
||||
|
||||
it('handles Pokemon with no active card gracefully', () => {
|
||||
/**
|
||||
* Test that the component handles the case where there is no active Pokemon.
|
||||
*
|
||||
* This edge case might occur during forced action states or
|
||||
* due to timing issues, and should not cause errors.
|
||||
*/
|
||||
const gameState = createMockGameState(null, {})
|
||||
|
||||
const wrapper = mountAttackMenu({ show: true }, gameState)
|
||||
|
||||
expect(wrapper.find('[data-testid="attack-menu"]').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('No attacks available')
|
||||
})
|
||||
|
||||
it('disables attacks when not player turn', () => {
|
||||
/**
|
||||
* Test that all attacks are disabled when it's not the player's turn.
|
||||
*
|
||||
* Turn-based gameplay requires strict enforcement to maintain
|
||||
* fair play and prevent out-of-turn actions.
|
||||
*/
|
||||
const attacks: Attack[] = [
|
||||
{ name: 'Thunder Shock', cost: ['lightning'], damage: 20 },
|
||||
]
|
||||
const energyCard = createMockCardInstance({
|
||||
instance_id: 'energy-1',
|
||||
definition_id: 'lightning-energy',
|
||||
})
|
||||
const activePokemon = createMockCardInstance({
|
||||
attached_energy: [energyCard],
|
||||
})
|
||||
const pokemonDef = createMockPokemonDefinition(attacks)
|
||||
const energyDef: CardDefinition = {
|
||||
id: 'lightning-energy',
|
||||
name: 'Lightning Energy',
|
||||
card_type: 'energy',
|
||||
energy_type: 'lightning',
|
||||
}
|
||||
const gameState = createMockGameState(activePokemon, {
|
||||
[pokemonDef.id]: pokemonDef,
|
||||
[energyDef.id]: energyDef,
|
||||
})
|
||||
gameState.is_my_turn = false
|
||||
|
||||
const wrapper = mountAttackMenu({ show: true }, gameState)
|
||||
|
||||
const attackButton = wrapper.find('[data-testid="attack-0"]')
|
||||
expect(attackButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
709
frontend/src/components/game/AttackMenu.vue
Normal file
709
frontend/src/components/game/AttackMenu.vue
Normal file
@ -0,0 +1,709 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Attack menu component - UI for selecting which attack to use.
|
||||
*
|
||||
* This component shows when the user taps 'Attack' or taps the active Pokemon
|
||||
* during the attack phase. It displays all available attacks for the current
|
||||
* active Pokemon with their energy costs, damage, and effects.
|
||||
*
|
||||
* Key features:
|
||||
* - Lists all attacks with energy cost, damage, and effect description
|
||||
* - Validates energy requirements and disables attacks without enough energy
|
||||
* - Checks status conditions (paralyzed) that prevent attacks
|
||||
* - Shows reason tooltips for disabled attacks
|
||||
* - Handles target selection for attacks that require targeting
|
||||
* - Dispatches attack action to server on selection
|
||||
*
|
||||
* Visual design:
|
||||
* - Centered modal or bottom sheet on mobile
|
||||
* - Card-like attack entries
|
||||
* - Energy cost icons in correct colors
|
||||
* - Disabled attacks grayed out with reason tooltip
|
||||
* - Smooth open/close animation
|
||||
*/
|
||||
import { computed, inject, type ComputedRef } from 'vue'
|
||||
import type { VisibleGameState, Attack, CardInstance, CardDefinition, EnergyType } from '@/types'
|
||||
import { useGameActions } from '@/composables/useGameActions'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
import { getEnergyColor } from '@/utils/energyColors'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props and Emits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Props {
|
||||
/** Whether the attack menu is open */
|
||||
show: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
attackSelected: [attackIndex: number]
|
||||
}>()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composables and State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Inject game state from GameOverlay parent
|
||||
const gameState = inject<ComputedRef<VisibleGameState | null>>('gameState')
|
||||
const isMyTurn = inject<ComputedRef<boolean>>('isMyTurn')
|
||||
|
||||
// Guard against missing injections
|
||||
if (!gameState || !isMyTurn) {
|
||||
throw new Error('AttackMenu must be used within GameOverlay')
|
||||
}
|
||||
|
||||
const gameStore = useGameStore()
|
||||
const { attack, isPending } = useGameActions()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Computed - Active Pokemon and Attacks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The active Pokemon instance.
|
||||
*/
|
||||
const activePokemon = computed<CardInstance | null>(() => {
|
||||
return gameStore.myActive
|
||||
})
|
||||
|
||||
/**
|
||||
* The active Pokemon's card definition.
|
||||
*/
|
||||
const activePokemonDefinition = computed<CardDefinition | null>(() => {
|
||||
if (!activePokemon.value || !gameState.value) return null
|
||||
return gameStore.lookupCard(activePokemon.value.definition_id)
|
||||
})
|
||||
|
||||
/**
|
||||
* Available attacks for the active Pokemon.
|
||||
*/
|
||||
const attacks = computed<Attack[]>(() => {
|
||||
return activePokemonDefinition.value?.attacks ?? []
|
||||
})
|
||||
|
||||
/**
|
||||
* Energy attached to the active Pokemon.
|
||||
*/
|
||||
const attachedEnergy = computed<EnergyType[]>(() => {
|
||||
if (!activePokemon.value || !gameState.value) return []
|
||||
|
||||
// Get energy types from attached energy cards
|
||||
return activePokemon.value.attached_energy.map(energyCard => {
|
||||
const energyDef = gameStore.lookupCard(energyCard.definition_id)
|
||||
return energyDef?.energy_type ?? 'colorless'
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Whether the active Pokemon has a status condition preventing attacks.
|
||||
*/
|
||||
const hasStatusPreventingAttack = computed<boolean>(() => {
|
||||
if (!activePokemon.value) return false
|
||||
|
||||
// Paralyzed prevents all attacks
|
||||
return activePokemon.value.status_conditions.includes('paralyzed')
|
||||
})
|
||||
|
||||
/**
|
||||
* Reason why attacks are disabled (if any).
|
||||
*/
|
||||
const disabledReason = computed<string | null>(() => {
|
||||
if (!isMyTurn.value) {
|
||||
return 'Not your turn'
|
||||
}
|
||||
|
||||
if (hasStatusPreventingAttack.value) {
|
||||
return 'Active Pokemon is paralyzed'
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attack Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if an attack has enough energy to be used.
|
||||
*
|
||||
* @param attack - The attack to check
|
||||
* @returns True if the attack can be used
|
||||
*/
|
||||
function hasEnoughEnergy(attack: Attack): boolean {
|
||||
const cost = attack.cost
|
||||
const available = [...attachedEnergy.value]
|
||||
|
||||
// Check if we have enough of each energy type
|
||||
for (const requiredType of cost) {
|
||||
if (requiredType === 'colorless') {
|
||||
// Colorless can be satisfied by any energy type
|
||||
if (available.length === 0) return false
|
||||
available.pop() // Remove any energy
|
||||
} else {
|
||||
// Specific type required
|
||||
const index = available.indexOf(requiredType)
|
||||
if (index === -1) return false
|
||||
available.splice(index, 1) // Remove that specific energy
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an attack is disabled.
|
||||
*
|
||||
* @param attack - The attack to check
|
||||
* @returns True if the attack is disabled
|
||||
*/
|
||||
function isAttackDisabled(attack: Attack): boolean {
|
||||
// Global disable reason (status, not your turn)
|
||||
if (disabledReason.value) return true
|
||||
|
||||
// Not enough energy
|
||||
if (!hasEnoughEnergy(attack)) return true
|
||||
|
||||
// Action is pending
|
||||
if (isPending.value) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the reason why an attack is disabled.
|
||||
*
|
||||
* @param attack - The attack to check
|
||||
* @returns Disabled reason string, or null if enabled
|
||||
*/
|
||||
function getDisabledReason(attack: Attack): string | null {
|
||||
// Check global disable reason first
|
||||
if (disabledReason.value) return disabledReason.value
|
||||
|
||||
// Check energy requirement
|
||||
if (!hasEnoughEnergy(attack)) {
|
||||
return 'Not enough energy'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handle attack selection.
|
||||
*
|
||||
* Dispatches the attack action to the server.
|
||||
*
|
||||
* @param attackIndex - Index of the selected attack
|
||||
*/
|
||||
async function handleAttackSelect(attackIndex: number): Promise<void> {
|
||||
if (isAttackDisabled(attacks.value[attackIndex])) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Emit to parent first so it can handle any necessary UI state
|
||||
emit('attackSelected', attackIndex)
|
||||
|
||||
// TODO: Check if attack requires targeting
|
||||
// For now, assume no targeting required (most common case)
|
||||
await attack(attackIndex)
|
||||
|
||||
// Close menu after successful attack
|
||||
emit('close')
|
||||
} catch (error) {
|
||||
console.error('Failed to perform attack:', error)
|
||||
// Error notification is handled by socket handler
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle cancel button click.
|
||||
*/
|
||||
function handleCancel(): void {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle backdrop click to close menu.
|
||||
*/
|
||||
function handleBackdropClick(event: MouseEvent): void {
|
||||
if (event.target === event.currentTarget) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Energy Cost Display Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the color for an energy type.
|
||||
*
|
||||
* @param energyType - The energy type
|
||||
* @returns Hex color string
|
||||
*/
|
||||
function getEnergyColorValue(energyType: EnergyType): string {
|
||||
return getEnergyColor(energyType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get energy type initial for display.
|
||||
*
|
||||
* @param energyType - The energy type
|
||||
* @returns Single character initial
|
||||
*/
|
||||
function getEnergyInitial(energyType: EnergyType): string {
|
||||
const initials: Record<EnergyType, string> = {
|
||||
colorless: 'C',
|
||||
darkness: 'D',
|
||||
dragon: 'N',
|
||||
fighting: 'F',
|
||||
fire: 'R',
|
||||
grass: 'G',
|
||||
lightning: 'L',
|
||||
metal: 'M',
|
||||
psychic: 'P',
|
||||
water: 'W',
|
||||
}
|
||||
return initials[energyType]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="attack-menu-fade">
|
||||
<div
|
||||
v-if="show"
|
||||
class="attack-menu-backdrop"
|
||||
data-testid="attack-menu-backdrop"
|
||||
@click="handleBackdropClick"
|
||||
>
|
||||
<div
|
||||
class="attack-menu"
|
||||
data-testid="attack-menu"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="attack-menu__header">
|
||||
<h2 class="attack-menu__title">
|
||||
Select Attack
|
||||
</h2>
|
||||
<button
|
||||
class="attack-menu__close"
|
||||
aria-label="Close"
|
||||
data-testid="close-button"
|
||||
@click="handleCancel"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Active Pokemon Name -->
|
||||
<div
|
||||
v-if="activePokemonDefinition"
|
||||
class="attack-menu__pokemon"
|
||||
>
|
||||
{{ activePokemonDefinition.name }}
|
||||
</div>
|
||||
|
||||
<!-- Global Disable Message -->
|
||||
<div
|
||||
v-if="disabledReason"
|
||||
class="attack-menu__warning"
|
||||
data-testid="disabled-warning"
|
||||
>
|
||||
{{ disabledReason }}
|
||||
</div>
|
||||
|
||||
<!-- Attack List -->
|
||||
<div
|
||||
v-if="attacks.length > 0"
|
||||
class="attack-list"
|
||||
data-testid="attack-list"
|
||||
>
|
||||
<button
|
||||
v-for="(atk, index) in attacks"
|
||||
:key="index"
|
||||
:disabled="isAttackDisabled(atk)"
|
||||
:class="{
|
||||
'attack-entry': true,
|
||||
'attack-entry--disabled': isAttackDisabled(atk),
|
||||
}"
|
||||
:data-testid="`attack-${index}`"
|
||||
@click="handleAttackSelect(index)"
|
||||
>
|
||||
<!-- Attack Name and Damage -->
|
||||
<div class="attack-entry__header">
|
||||
<span class="attack-entry__name">{{ atk.name }}</span>
|
||||
<span class="attack-entry__damage">
|
||||
{{ atk.damage_display || atk.damage }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Energy Cost -->
|
||||
<div class="attack-entry__cost">
|
||||
<div
|
||||
v-for="(energyType, costIndex) in atk.cost"
|
||||
:key="costIndex"
|
||||
class="energy-icon"
|
||||
:style="{ backgroundColor: getEnergyColorValue(energyType) }"
|
||||
:title="energyType"
|
||||
>
|
||||
{{ getEnergyInitial(energyType) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Effect Description -->
|
||||
<div
|
||||
v-if="atk.effect_description"
|
||||
class="attack-entry__effect"
|
||||
>
|
||||
{{ atk.effect_description }}
|
||||
</div>
|
||||
|
||||
<!-- Disabled Reason Tooltip -->
|
||||
<div
|
||||
v-if="isAttackDisabled(atk) && getDisabledReason(atk)"
|
||||
class="attack-entry__tooltip"
|
||||
data-testid="disabled-reason"
|
||||
>
|
||||
{{ getDisabledReason(atk) }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- No Attacks Message -->
|
||||
<div
|
||||
v-else
|
||||
class="attack-menu__empty"
|
||||
>
|
||||
No attacks available
|
||||
</div>
|
||||
|
||||
<!-- Cancel Button -->
|
||||
<div class="attack-menu__footer">
|
||||
<button
|
||||
class="cancel-button"
|
||||
data-testid="cancel-button"
|
||||
@click="handleCancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/**
|
||||
* Backdrop - full screen overlay with semi-transparent background.
|
||||
*/
|
||||
.attack-menu-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 40; /* Above game overlay */
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attack menu container.
|
||||
*/
|
||||
.attack-menu {
|
||||
background: rgb(31, 41, 55); /* bg-gray-800 */
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
max-width: 28rem; /* max-w-md */
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header with title and close button.
|
||||
*/
|
||||
.attack-menu__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(75, 85, 99, 0.5); /* border-gray-600 */
|
||||
}
|
||||
|
||||
.attack-menu__title {
|
||||
font-size: 1.125rem; /* text-lg */
|
||||
font-weight: 700; /* font-bold */
|
||||
color: rgb(249, 250, 251); /* text-gray-50 */
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.attack-menu__close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgb(156, 163, 175); /* text-gray-400 */
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.attack-menu__close:hover {
|
||||
color: rgb(249, 250, 251); /* text-gray-50 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Active Pokemon name display.
|
||||
*/
|
||||
.attack-menu__pokemon {
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 0.875rem; /* text-sm */
|
||||
font-weight: 600; /* font-semibold */
|
||||
color: rgb(147, 197, 253); /* text-blue-300 */
|
||||
background: rgba(59, 130, 246, 0.1); /* bg-blue-500 with low opacity */
|
||||
border-bottom: 1px solid rgba(75, 85, 99, 0.5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Warning message for global disable reasons.
|
||||
*/
|
||||
.attack-menu__warning {
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 0.875rem; /* text-sm */
|
||||
color: rgb(252, 165, 165); /* text-red-300 */
|
||||
background: rgba(239, 68, 68, 0.1); /* bg-red-500 with low opacity */
|
||||
border-bottom: 1px solid rgba(75, 85, 99, 0.5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attack list container.
|
||||
*/
|
||||
.attack-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual attack entry - card-like button.
|
||||
*/
|
||||
.attack-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem;
|
||||
background: rgba(55, 65, 81, 0.8); /* bg-gray-700 with opacity */
|
||||
border: 2px solid rgba(75, 85, 99, 0.5); /* border-gray-600 */
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.attack-entry:hover:not(:disabled) {
|
||||
background: rgba(55, 65, 81, 1);
|
||||
border-color: rgb(96, 165, 250); /* border-blue-400 */
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.attack-entry:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disabled attack entry.
|
||||
*/
|
||||
.attack-entry--disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
border-color: rgba(107, 114, 128, 0.3);
|
||||
}
|
||||
|
||||
.attack-entry--disabled:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attack entry header with name and damage.
|
||||
*/
|
||||
.attack-entry__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.attack-entry__name {
|
||||
font-size: 1rem;
|
||||
font-weight: 600; /* font-semibold */
|
||||
color: rgb(249, 250, 251); /* text-gray-50 */
|
||||
}
|
||||
|
||||
.attack-entry__damage {
|
||||
font-size: 1.25rem; /* text-xl */
|
||||
font-weight: 700; /* font-bold */
|
||||
color: rgb(252, 165, 165); /* text-red-300 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Energy cost display.
|
||||
*/
|
||||
.attack-entry__cost {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.energy-icon {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem; /* text-xs */
|
||||
font-weight: 700; /* font-bold */
|
||||
color: white;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effect description text.
|
||||
*/
|
||||
.attack-entry__effect {
|
||||
font-size: 0.75rem; /* text-xs */
|
||||
color: rgb(209, 213, 219); /* text-gray-300 */
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disabled reason tooltip.
|
||||
*/
|
||||
.attack-entry__tooltip {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(17, 24, 39, 0.95); /* bg-gray-900 */
|
||||
color: rgb(249, 250, 251); /* text-gray-50 */
|
||||
font-size: 0.75rem; /* text-xs */
|
||||
border-radius: 0.375rem;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.attack-entry--disabled:hover .attack-entry__tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state message.
|
||||
*/
|
||||
.attack-menu__empty {
|
||||
padding: 2rem 1.25rem;
|
||||
text-align: center;
|
||||
color: rgb(156, 163, 175); /* text-gray-400 */
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer with cancel button.
|
||||
*/
|
||||
.attack-menu__footer {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-top: 1px solid rgba(75, 85, 99, 0.5);
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: rgba(107, 114, 128, 0.3); /* bg-gray-500 with opacity */
|
||||
border: 2px solid rgba(107, 114, 128, 0.5);
|
||||
border-radius: 0.5rem;
|
||||
color: rgb(229, 231, 235); /* text-gray-200 */
|
||||
font-size: 0.875rem; /* text-sm */
|
||||
font-weight: 600; /* font-semibold */
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.cancel-button:hover {
|
||||
background: rgba(107, 114, 128, 0.5);
|
||||
border-color: rgb(107, 114, 128);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile responsive styles.
|
||||
*/
|
||||
@media (max-width: 768px) {
|
||||
.attack-menu-backdrop {
|
||||
align-items: flex-end;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.attack-menu {
|
||||
max-width: 100%;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
max-height: 80vh;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition animations.
|
||||
*/
|
||||
.attack-menu-fade-enter-active,
|
||||
.attack-menu-fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.attack-menu-fade-enter-active .attack-menu,
|
||||
.attack-menu-fade-leave-active .attack-menu {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.attack-menu-fade-enter-from,
|
||||
.attack-menu-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.attack-menu-fade-enter-from .attack-menu {
|
||||
transform: translateY(2rem);
|
||||
}
|
||||
|
||||
.attack-menu-fade-leave-to .attack-menu {
|
||||
transform: translateY(2rem);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.attack-menu-fade-enter-from .attack-menu,
|
||||
.attack-menu-fade-leave-to .attack-menu {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
606
frontend/src/components/game/ForcedActionModal.spec.ts
Normal file
606
frontend/src/components/game/ForcedActionModal.spec.ts
Normal file
@ -0,0 +1,606 @@
|
||||
/**
|
||||
* Tests for ForcedActionModal component.
|
||||
*
|
||||
* Verifies that the forced action modal correctly handles:
|
||||
* - Prize card selection after knocking out opponent's Pokemon
|
||||
* - New active Pokemon selection when active is knocked out
|
||||
* - Discard card selection when required by effects
|
||||
* - Cannot be dismissed until action is completed
|
||||
* - Proper action submission to the backend
|
||||
* - Error handling and display
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import ForcedActionModal from './ForcedActionModal.vue'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
import * as useGameActionsModule from '@/composables/useGameActions'
|
||||
import type { CardInstance, VisibleGameState } from '@/types'
|
||||
|
||||
/**
|
||||
* Helper to create a minimal card instance for testing.
|
||||
*/
|
||||
function createMockCard(
|
||||
instanceId: string,
|
||||
definitionId: string = 'pikachu-001'
|
||||
): CardInstance {
|
||||
return {
|
||||
instance_id: instanceId,
|
||||
definition_id: definitionId,
|
||||
damage: 0,
|
||||
attached_energy: [],
|
||||
attached_tools: [],
|
||||
status_conditions: [],
|
||||
ability_uses_this_turn: {},
|
||||
evolution_turn: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a mock game state with forced action.
|
||||
*/
|
||||
function createMockGameState(
|
||||
forcedActionType: string | null,
|
||||
forcedActionReason: string | null = null,
|
||||
benchCards: CardInstance[] = [],
|
||||
handCards: CardInstance[] = [],
|
||||
prizeCount: number = 6
|
||||
): VisibleGameState {
|
||||
return {
|
||||
game_id: 'test-game-123',
|
||||
viewer_id: 'player-1',
|
||||
players: {
|
||||
'player-1': {
|
||||
player_id: 'player-1',
|
||||
is_current_player: true,
|
||||
deck_count: 40,
|
||||
hand: { count: handCards.length, cards: handCards, zone_type: 'hand' },
|
||||
prizes_count: prizeCount,
|
||||
energy_deck_count: 10,
|
||||
active: { count: 0, cards: [], zone_type: 'active' },
|
||||
bench: { count: benchCards.length, cards: benchCards, 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: 'player-1',
|
||||
forced_action_type: forcedActionType,
|
||||
forced_action_reason: forcedActionReason,
|
||||
card_registry: {
|
||||
'pikachu-001': {
|
||||
id: 'pikachu-001',
|
||||
name: 'Pikachu',
|
||||
card_type: 'pokemon',
|
||||
image_url: '/cards/pikachu.png',
|
||||
hp: 60,
|
||||
},
|
||||
'charizard-001': {
|
||||
id: 'charizard-001',
|
||||
name: 'Charizard',
|
||||
card_type: 'pokemon',
|
||||
image_url: '/cards/charizard.png',
|
||||
hp: 120,
|
||||
},
|
||||
'professor-oak-001': {
|
||||
id: 'professor-oak-001',
|
||||
name: 'Professor Oak',
|
||||
card_type: 'trainer',
|
||||
image_url: '/cards/oak.png',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount ForcedActionModal with mocked dependencies.
|
||||
*/
|
||||
function mountModal(
|
||||
forcedActionType: string | null = null,
|
||||
forcedActionReason: string | null = null,
|
||||
benchCards: CardInstance[] = [],
|
||||
handCards: CardInstance[] = [],
|
||||
prizeCount: number = 6
|
||||
) {
|
||||
const gameStore = useGameStore()
|
||||
const gameState = createMockGameState(
|
||||
forcedActionType,
|
||||
forcedActionReason,
|
||||
benchCards,
|
||||
handCards,
|
||||
prizeCount
|
||||
)
|
||||
gameStore.setGameState(gameState)
|
||||
|
||||
return mount(ForcedActionModal, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true, // Stub Teleport for testing
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('ForcedActionModal', () => {
|
||||
let mockSelectPrize: ReturnType<typeof vi.fn>
|
||||
let mockSelectNewActive: ReturnType<typeof vi.fn>
|
||||
let mockDiscardFromHand: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
|
||||
// Mock the useGameActions composable
|
||||
mockSelectPrize = vi.fn().mockResolvedValue(undefined)
|
||||
mockSelectNewActive = vi.fn().mockResolvedValue(undefined)
|
||||
mockDiscardFromHand = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.spyOn(useGameActionsModule, 'useGameActions').mockReturnValue({
|
||||
selectPrize: mockSelectPrize,
|
||||
selectNewActive: mockSelectNewActive,
|
||||
discardFromHand: mockDiscardFromHand,
|
||||
// Other methods not used in this component
|
||||
playCard: vi.fn(),
|
||||
attachEnergy: vi.fn(),
|
||||
evolve: vi.fn(),
|
||||
attack: vi.fn(),
|
||||
retreat: vi.fn(),
|
||||
useAbility: vi.fn(),
|
||||
endTurn: vi.fn(),
|
||||
pendingAction: { value: null },
|
||||
isPending: { value: false },
|
||||
})
|
||||
})
|
||||
|
||||
describe('Prize Selection', () => {
|
||||
it('displays prize selection UI when forced action is prize_selection', () => {
|
||||
/**
|
||||
* Test that the modal shows prize selection interface.
|
||||
*
|
||||
* When a player knocks out an opponent's Pokemon, they must select
|
||||
* a prize card. The UI should display all available prize positions.
|
||||
*/
|
||||
const wrapper = mountModal('prize_selection', 'Select a prize card', [], [], 6)
|
||||
|
||||
expect(wrapper.text()).toContain('Required Action')
|
||||
expect(wrapper.text()).toContain('Select a prize card')
|
||||
expect(wrapper.findAll('button').filter(btn => btn.text().includes('Prize')).length).toBe(6)
|
||||
})
|
||||
|
||||
it('allows selecting a prize card', async () => {
|
||||
/**
|
||||
* Test that clicking a prize card selects it.
|
||||
*
|
||||
* Players need visual feedback showing which prize they've selected
|
||||
* before confirming.
|
||||
*/
|
||||
const wrapper = mountModal('prize_selection', 'Select a prize card', [], [], 6)
|
||||
|
||||
const prizeButtons = wrapper.findAll('button').filter(btn => btn.text().includes('Prize'))
|
||||
await prizeButtons[2].trigger('click') // Select prize 3
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Check that the selected prize has the selected class/indicator
|
||||
const selectedPrize = prizeButtons[2]
|
||||
expect(selectedPrize.html()).toContain('ring-primary')
|
||||
})
|
||||
|
||||
it('enables confirm button after prize selection', async () => {
|
||||
/**
|
||||
* Test that the confirm button becomes enabled after selection.
|
||||
*
|
||||
* Users should not be able to confirm without making a selection,
|
||||
* preventing accidental empty submissions.
|
||||
*/
|
||||
const wrapper = mountModal('prize_selection', 'Select a prize card', [], [], 6)
|
||||
|
||||
const confirmButton = wrapper.findAll('button').find(btn => btn.text().includes('Confirm'))!
|
||||
expect(confirmButton.attributes('disabled')).toBeDefined()
|
||||
|
||||
const prizeButtons = wrapper.findAll('button').filter(btn => btn.text().includes('Prize'))
|
||||
await prizeButtons[0].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(confirmButton.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls selectPrize action with correct index on confirm', async () => {
|
||||
/**
|
||||
* Test that confirming sends the correct prize selection to the server.
|
||||
*
|
||||
* The backend needs the exact prize index to know which prize card
|
||||
* to reveal and add to the player's hand.
|
||||
*/
|
||||
const wrapper = mountModal('prize_selection', 'Select a prize card', [], [], 6)
|
||||
|
||||
const prizeButtons = wrapper.findAll('button').filter(btn => btn.text().includes('Prize'))
|
||||
await prizeButtons[3].trigger('click') // Select prize 4 (index 3)
|
||||
await nextTick()
|
||||
|
||||
const confirmButton = wrapper.findAll('button').find(btn => btn.text().includes('Confirm'))!
|
||||
await confirmButton.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(mockSelectPrize).toHaveBeenCalledWith(3)
|
||||
})
|
||||
|
||||
it('displays correct number of prizes based on prize count', () => {
|
||||
/**
|
||||
* Test that only remaining prizes are shown.
|
||||
*
|
||||
* If a player has already claimed some prizes, only the remaining
|
||||
* prizes should be available for selection.
|
||||
*/
|
||||
const wrapper = mountModal('prize_selection', 'Select a prize card', [], [], 3)
|
||||
|
||||
const prizeButtons = wrapper.findAll('button').filter(btn => btn.text().includes('Prize'))
|
||||
expect(prizeButtons.length).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('New Active Selection', () => {
|
||||
it('displays bench Pokemon selection when forced action is new_active_selection', () => {
|
||||
/**
|
||||
* Test that the modal shows bench Pokemon for selection.
|
||||
*
|
||||
* When the active Pokemon is knocked out, the player must select
|
||||
* a new active from their bench.
|
||||
*/
|
||||
const benchCards = [
|
||||
createMockCard('bench-1', 'pikachu-001'),
|
||||
createMockCard('bench-2', 'charizard-001'),
|
||||
]
|
||||
const wrapper = mountModal(
|
||||
'new_active_selection',
|
||||
'Your active Pokemon was knocked out',
|
||||
benchCards
|
||||
)
|
||||
|
||||
expect(wrapper.text()).toContain('Required Action')
|
||||
expect(wrapper.text()).toContain('knocked out')
|
||||
expect(wrapper.text()).toContain('Pikachu')
|
||||
expect(wrapper.text()).toContain('Charizard')
|
||||
})
|
||||
|
||||
it('allows selecting a bench Pokemon', async () => {
|
||||
/**
|
||||
* Test that clicking a bench Pokemon selects it.
|
||||
*
|
||||
* Players need to see which Pokemon they've selected before confirming
|
||||
* to avoid accidental selections.
|
||||
*/
|
||||
const benchCards = [
|
||||
createMockCard('bench-1', 'pikachu-001'),
|
||||
createMockCard('bench-2', 'charizard-001'),
|
||||
]
|
||||
const wrapper = mountModal('new_active_selection', 'Select new active', benchCards)
|
||||
|
||||
const pokemonButtons = wrapper.findAll('button').filter(btn => !btn.text().includes('Confirm'))
|
||||
await pokemonButtons[1].trigger('click') // Select second Pokemon
|
||||
await nextTick()
|
||||
|
||||
expect(pokemonButtons[1].html()).toContain('ring-primary')
|
||||
})
|
||||
|
||||
it('calls selectNewActive action with correct Pokemon ID on confirm', async () => {
|
||||
/**
|
||||
* Test that confirming sends the correct Pokemon selection to the server.
|
||||
*
|
||||
* The backend needs the instance ID to move the correct Pokemon
|
||||
* from bench to active position.
|
||||
*/
|
||||
const benchCards = [
|
||||
createMockCard('bench-1', 'pikachu-001'),
|
||||
createMockCard('bench-2', 'charizard-001'),
|
||||
]
|
||||
const wrapper = mountModal('new_active_selection', 'Select new active', benchCards)
|
||||
|
||||
const pokemonButtons = wrapper.findAll('button').filter(btn => !btn.text().includes('Confirm'))
|
||||
await pokemonButtons[0].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const confirmButton = wrapper.findAll('button').find(btn => btn.text().includes('Confirm'))!
|
||||
await confirmButton.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(mockSelectNewActive).toHaveBeenCalledWith('bench-1')
|
||||
})
|
||||
|
||||
it('displays warning when no bench Pokemon available', () => {
|
||||
/**
|
||||
* Test edge case: empty bench when active is knocked out.
|
||||
*
|
||||
* If the player has no benched Pokemon, they automatically lose.
|
||||
* The UI should communicate this clearly.
|
||||
*/
|
||||
const wrapper = mountModal('new_active_selection', 'Select new active', [])
|
||||
|
||||
expect(wrapper.text()).toContain('No Pokemon on bench')
|
||||
expect(wrapper.text()).toContain('lose the game')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Discard Selection', () => {
|
||||
it('displays hand cards for discard selection', () => {
|
||||
/**
|
||||
* Test that the modal shows hand cards for discard selection.
|
||||
*
|
||||
* Some card effects require discarding cards from hand.
|
||||
* All hand cards should be available for selection.
|
||||
*/
|
||||
const handCards = [
|
||||
createMockCard('hand-1', 'pikachu-001'),
|
||||
createMockCard('hand-2', 'charizard-001'),
|
||||
createMockCard('hand-3', 'professor-oak-001'),
|
||||
]
|
||||
const wrapper = mountModal('discard_selection', 'Discard 2 cards', [], handCards)
|
||||
|
||||
expect(wrapper.text()).toContain('Required Action')
|
||||
expect(wrapper.text()).toContain('Select 2 cards to discard')
|
||||
expect(wrapper.text()).toContain('Pikachu')
|
||||
expect(wrapper.text()).toContain('Charizard')
|
||||
expect(wrapper.text()).toContain('Professor Oak')
|
||||
})
|
||||
|
||||
it('parses required discard count from reason text', () => {
|
||||
/**
|
||||
* Test that the component extracts the discard count from the reason.
|
||||
*
|
||||
* The forced_action_reason contains the number of cards to discard.
|
||||
* This must be parsed correctly to validate selection.
|
||||
*/
|
||||
const handCards = [
|
||||
createMockCard('hand-1', 'pikachu-001'),
|
||||
createMockCard('hand-2', 'charizard-001'),
|
||||
createMockCard('hand-3', 'professor-oak-001'),
|
||||
]
|
||||
const wrapper = mountModal('discard_selection', 'Discard 2 cards from your hand', [], handCards)
|
||||
|
||||
// Initially 0 selected
|
||||
expect(wrapper.text()).toContain('0 / 2')
|
||||
})
|
||||
|
||||
it('allows selecting cards up to required count', async () => {
|
||||
/**
|
||||
* Test that users can select exactly the required number of cards.
|
||||
*
|
||||
* Selection should be limited to the required count to prevent
|
||||
* over-selection.
|
||||
*/
|
||||
const handCards = [
|
||||
createMockCard('hand-1', 'pikachu-001'),
|
||||
createMockCard('hand-2', 'charizard-001'),
|
||||
createMockCard('hand-3', 'professor-oak-001'),
|
||||
]
|
||||
const wrapper = mountModal('discard_selection', 'Discard 2 cards', [], handCards)
|
||||
|
||||
const cardButtons = wrapper.findAll('button').filter(btn => !btn.text().includes('Confirm'))
|
||||
|
||||
// Select first card
|
||||
await cardButtons[0].trigger('click')
|
||||
await nextTick()
|
||||
expect(wrapper.text()).toContain('1 / 2')
|
||||
|
||||
// Select second card
|
||||
await cardButtons[1].trigger('click')
|
||||
await nextTick()
|
||||
expect(wrapper.text()).toContain('2 / 2')
|
||||
|
||||
// Try to select third card (should not be allowed)
|
||||
const thirdButton = cardButtons[2]
|
||||
expect(thirdButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('allows deselecting cards', async () => {
|
||||
/**
|
||||
* Test that users can change their selection before confirming.
|
||||
*
|
||||
* Players should be able to deselect cards to try different
|
||||
* combinations before finalizing.
|
||||
*/
|
||||
const handCards = [
|
||||
createMockCard('hand-1', 'pikachu-001'),
|
||||
createMockCard('hand-2', 'charizard-001'),
|
||||
]
|
||||
const wrapper = mountModal('discard_selection', 'Discard 1 card', [], handCards)
|
||||
|
||||
const cardButtons = wrapper.findAll('button').filter(btn => !btn.text().includes('Confirm'))
|
||||
|
||||
// Select first card
|
||||
await cardButtons[0].trigger('click')
|
||||
await nextTick()
|
||||
expect(wrapper.text()).toContain('1 / 1')
|
||||
|
||||
// Deselect first card
|
||||
await cardButtons[0].trigger('click')
|
||||
await nextTick()
|
||||
expect(wrapper.text()).toContain('0 / 1')
|
||||
})
|
||||
|
||||
it('enables confirm only when exact count is selected', async () => {
|
||||
/**
|
||||
* Test that confirm requires exactly the right number of cards.
|
||||
*
|
||||
* Prevents partial selections from being submitted.
|
||||
*/
|
||||
const handCards = [
|
||||
createMockCard('hand-1', 'pikachu-001'),
|
||||
createMockCard('hand-2', 'charizard-001'),
|
||||
createMockCard('hand-3', 'professor-oak-001'),
|
||||
]
|
||||
const wrapper = mountModal('discard_selection', 'Discard 2 cards', [], handCards)
|
||||
|
||||
let confirmButton = wrapper.findAll('button').find(btn => btn.text().includes('Confirm'))!
|
||||
const cardButtons = wrapper.findAll('button').filter(btn => !btn.text().includes('Confirm'))
|
||||
|
||||
// Initially disabled
|
||||
expect(confirmButton.attributes('disabled')).toBeDefined()
|
||||
|
||||
// Select one card - still disabled
|
||||
await cardButtons[0].trigger('click')
|
||||
await nextTick()
|
||||
confirmButton = wrapper.findAll('button').find(btn => btn.text().includes('Confirm'))!
|
||||
expect(confirmButton.attributes('disabled')).toBeDefined()
|
||||
|
||||
// Select second card - now enabled
|
||||
await cardButtons[1].trigger('click')
|
||||
await nextTick()
|
||||
confirmButton = wrapper.findAll('button').find(btn => btn.text().includes('Confirm'))!
|
||||
expect(confirmButton.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls discardFromHand with selected card IDs on confirm', async () => {
|
||||
/**
|
||||
* Test that confirming sends the correct card IDs to discard.
|
||||
*
|
||||
* The backend needs the exact instance IDs to remove the correct
|
||||
* cards from the player's hand.
|
||||
*/
|
||||
const handCards = [
|
||||
createMockCard('hand-1', 'pikachu-001'),
|
||||
createMockCard('hand-2', 'charizard-001'),
|
||||
]
|
||||
const wrapper = mountModal('discard_selection', 'Discard 2 cards', [], handCards)
|
||||
|
||||
const cardButtons = wrapper.findAll('button').filter(btn => !btn.text().includes('Confirm'))
|
||||
await cardButtons[0].trigger('click')
|
||||
await cardButtons[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const confirmButton = wrapper.findAll('button').find(btn => btn.text().includes('Confirm'))!
|
||||
await confirmButton.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(mockDiscardFromHand).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['hand-1', 'hand-2'])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('General Modal Behavior', () => {
|
||||
it('is not visible when no forced action is set', () => {
|
||||
/**
|
||||
* Test that the modal hides when there's no forced action.
|
||||
*
|
||||
* The modal should only appear when the player has a required action,
|
||||
* not during normal gameplay.
|
||||
*/
|
||||
const wrapper = mountModal(null)
|
||||
|
||||
const modal = wrapper.find('[role="dialog"]')
|
||||
expect(modal.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('is visible when forced action is set for current player', () => {
|
||||
/**
|
||||
* Test that the modal shows when a forced action is required.
|
||||
*
|
||||
* The modal must block gameplay until the required action is completed.
|
||||
*/
|
||||
const wrapper = mountModal('prize_selection', 'Select a prize')
|
||||
|
||||
const modal = wrapper.find('[role="dialog"]')
|
||||
expect(modal.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays instruction text from forced_action_reason', () => {
|
||||
/**
|
||||
* Test that custom instruction text is displayed.
|
||||
*
|
||||
* The backend provides context-specific instructions that help
|
||||
* players understand why the action is required.
|
||||
*/
|
||||
const wrapper = mountModal('prize_selection', 'You knocked out Pikachu! Select a prize card.')
|
||||
|
||||
expect(wrapper.text()).toContain('You knocked out Pikachu! Select a prize card.')
|
||||
})
|
||||
|
||||
it('shows loading state while submitting action', async () => {
|
||||
/**
|
||||
* Test that the UI indicates when an action is being processed.
|
||||
*
|
||||
* Network requests may take time; users need feedback that their
|
||||
* action is being submitted.
|
||||
*/
|
||||
// Make the action take time
|
||||
mockSelectPrize.mockReturnValue(new Promise(() => {}))
|
||||
|
||||
const wrapper = mountModal('prize_selection', 'Select a prize', [], [], 6)
|
||||
|
||||
const prizeButtons = wrapper.findAll('button').filter(btn => btn.text().includes('Prize'))
|
||||
await prizeButtons[0].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const confirmButton = wrapper.findAll('button').find(btn => btn.text().includes('Confirm'))!
|
||||
await confirmButton.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(confirmButton.text()).toContain('Confirming')
|
||||
expect(confirmButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('displays error message when action fails', async () => {
|
||||
/**
|
||||
* Test that action errors are shown to the user.
|
||||
*
|
||||
* If the server rejects the action, the player needs to see why
|
||||
* so they can correct the issue.
|
||||
*/
|
||||
mockSelectPrize.mockRejectedValue(new Error('Invalid prize selection'))
|
||||
|
||||
const wrapper = mountModal('prize_selection', 'Select a prize', [], [], 6)
|
||||
|
||||
const prizeButtons = wrapper.findAll('button').filter(btn => btn.text().includes('Prize'))
|
||||
await prizeButtons[0].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const confirmButton = wrapper.findAll('button').find(btn => btn.text().includes('Confirm'))!
|
||||
await confirmButton.trigger('click')
|
||||
await nextTick()
|
||||
await nextTick() // Wait for error to be set
|
||||
|
||||
expect(wrapper.text()).toContain('Invalid prize selection')
|
||||
})
|
||||
|
||||
it('resets selection when modal opens', async () => {
|
||||
/**
|
||||
* Test that selections are cleared when a new forced action appears.
|
||||
*
|
||||
* Previous selections should not carry over to new forced actions
|
||||
* to prevent confusion.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
|
||||
// Start with prize selection
|
||||
const wrapper = mountModal('prize_selection', 'Select a prize', [], [], 6)
|
||||
|
||||
const prizeButtons = wrapper.findAll('button').filter(btn => btn.text().includes('Prize'))
|
||||
await prizeButtons[2].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Change to different forced action
|
||||
const newState = createMockGameState(
|
||||
'new_active_selection',
|
||||
'Select new active',
|
||||
[createMockCard('bench-1', 'pikachu-001')]
|
||||
)
|
||||
gameStore.setGameState(newState)
|
||||
await nextTick()
|
||||
|
||||
// Confirm button should be disabled (no selection in new context)
|
||||
const confirmButton = wrapper.findAll('button').find(btn => btn.text().includes('Confirm'))!
|
||||
expect(confirmButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
509
frontend/src/components/game/ForcedActionModal.vue
Normal file
509
frontend/src/components/game/ForcedActionModal.vue
Normal file
@ -0,0 +1,509 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Forced action modal for required player choices.
|
||||
*
|
||||
* Displays a modal that cannot be dismissed until the player completes
|
||||
* a required action such as:
|
||||
* - Selecting a prize card after knocking out opponent's Pokemon
|
||||
* - Selecting a new active Pokemon when active is knocked out
|
||||
* - Discarding cards from hand when required by an effect
|
||||
*
|
||||
* The modal blocks all other game interactions until the forced action
|
||||
* is completed.
|
||||
*
|
||||
* Accessibility features:
|
||||
* - Traps focus within modal
|
||||
* - Proper ARIA attributes
|
||||
* - Cannot close via Escape or backdrop click (forced action)
|
||||
*
|
||||
* Uses Teleport to render in document body for proper z-index stacking.
|
||||
*/
|
||||
import { computed, ref, watch, nextTick } from 'vue'
|
||||
|
||||
import { useGameActions } from '@/composables/useGameActions'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
import type { CardInstance } from '@/types'
|
||||
|
||||
import CardImage from '@/components/cards/CardImage.vue'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store and Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const gameStore = useGameStore()
|
||||
const { selectPrize, selectNewActive, discardFromHand } = useGameActions()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Reference to the modal container for focus trapping */
|
||||
const modalRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/** Reference to the confirm button for initial focus */
|
||||
const confirmButtonRef = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
/** Selected prize index (0-5) for prize selection */
|
||||
const selectedPrizeIndex = ref<number | null>(null)
|
||||
|
||||
/** Selected Pokemon instance ID for new active selection */
|
||||
const selectedPokemonId = ref<string | null>(null)
|
||||
|
||||
/** Selected card instance IDs for discard selection */
|
||||
const selectedCardIds = ref<Set<string>>(new Set())
|
||||
|
||||
/** Whether an action is being submitted */
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
/** Error message if action fails */
|
||||
const errorMessage = ref<string | null>(null)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Computed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Whether the modal should be shown */
|
||||
const isOpen = computed(() => gameStore.hasForcedAction)
|
||||
|
||||
/** Forced action info from game state */
|
||||
const forcedAction = computed(() => gameStore.forcedAction)
|
||||
|
||||
/** Type of forced action required */
|
||||
const actionType = computed(() => forcedAction.value?.type ?? null)
|
||||
|
||||
/** Reason/instruction text for the forced action */
|
||||
const instructionText = computed(() => forcedAction.value?.reason ?? 'Make a selection')
|
||||
|
||||
/** Prize card positions (0-5) for prize selection */
|
||||
const prizePositions = computed(() => {
|
||||
if (actionType.value !== 'prize_selection') return []
|
||||
const remaining = gameStore.myPrizeCount
|
||||
return Array.from({ length: remaining }, (_, i) => i)
|
||||
})
|
||||
|
||||
/** Available bench Pokemon for new active selection */
|
||||
const availableBench = computed(() => {
|
||||
if (actionType.value !== 'new_active_selection') return []
|
||||
return gameStore.myBench
|
||||
})
|
||||
|
||||
/** Cards in hand for discard selection */
|
||||
const cardsInHand = computed(() => {
|
||||
if (actionType.value !== 'discard_selection') return []
|
||||
return gameStore.myHand
|
||||
})
|
||||
|
||||
/** Required number of cards to discard (parsed from reason if present) */
|
||||
const requiredDiscardCount = computed(() => {
|
||||
if (actionType.value !== 'discard_selection') return 0
|
||||
|
||||
// Try to parse count from reason text (e.g., "Discard 2 cards")
|
||||
const reason = forcedAction.value?.reason ?? ''
|
||||
const match = reason.match(/(\d+)/)
|
||||
return match ? parseInt(match[1], 10) : 1
|
||||
})
|
||||
|
||||
/** Whether the current selection is valid for confirmation */
|
||||
const canConfirm = computed(() => {
|
||||
switch (actionType.value) {
|
||||
case 'prize_selection':
|
||||
return selectedPrizeIndex.value !== null
|
||||
case 'new_active_selection':
|
||||
return selectedPokemonId.value !== null
|
||||
case 'discard_selection':
|
||||
return selectedCardIds.value.size === requiredDiscardCount.value
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handle prize card selection.
|
||||
*/
|
||||
function handlePrizeClick(prizeIndex: number): void {
|
||||
selectedPrizeIndex.value = prizeIndex
|
||||
errorMessage.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle bench Pokemon selection.
|
||||
*/
|
||||
function handlePokemonClick(pokemonId: string): void {
|
||||
selectedPokemonId.value = pokemonId
|
||||
errorMessage.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle card selection for discard.
|
||||
*/
|
||||
function handleCardClick(cardId: string): void {
|
||||
const selected = new Set(selectedCardIds.value)
|
||||
|
||||
if (selected.has(cardId)) {
|
||||
selected.delete(cardId)
|
||||
} else {
|
||||
// Only allow selection up to required count
|
||||
if (selected.size < requiredDiscardCount.value) {
|
||||
selected.add(cardId)
|
||||
}
|
||||
}
|
||||
|
||||
selectedCardIds.value = selected
|
||||
errorMessage.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm and submit the forced action.
|
||||
*/
|
||||
async function handleConfirm(): Promise<void> {
|
||||
if (!canConfirm.value || isSubmitting.value) return
|
||||
|
||||
isSubmitting.value = true
|
||||
errorMessage.value = null
|
||||
|
||||
try {
|
||||
switch (actionType.value) {
|
||||
case 'prize_selection':
|
||||
if (selectedPrizeIndex.value !== null) {
|
||||
await selectPrize(selectedPrizeIndex.value)
|
||||
}
|
||||
break
|
||||
|
||||
case 'new_active_selection':
|
||||
if (selectedPokemonId.value) {
|
||||
await selectNewActive(selectedPokemonId.value)
|
||||
}
|
||||
break
|
||||
|
||||
case 'discard_selection':
|
||||
if (selectedCardIds.value.size > 0) {
|
||||
await discardFromHand(Array.from(selectedCardIds.value))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Reset selections after successful action
|
||||
resetSelections()
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : 'Action failed'
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all selections.
|
||||
*/
|
||||
function resetSelections(): void {
|
||||
selectedPrizeIndex.value = null
|
||||
selectedPokemonId.value = null
|
||||
selectedCardIds.value = new Set()
|
||||
errorMessage.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get card definition for a card instance.
|
||||
*/
|
||||
function getCardDef(card: CardInstance) {
|
||||
return gameStore.lookupCard(card.definition_id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trap focus within the modal for accessibility.
|
||||
*/
|
||||
function handleTabTrap(event: KeyboardEvent): void {
|
||||
if (event.key !== 'Tab' || !modalRef.value) return
|
||||
|
||||
const focusableElements = modalRef.value.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
const firstElement = focusableElements[0]
|
||||
const lastElement = focusableElements[focusableElements.length - 1]
|
||||
|
||||
if (event.shiftKey && document.activeElement === firstElement) {
|
||||
event.preventDefault()
|
||||
lastElement?.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === lastElement) {
|
||||
event.preventDefault()
|
||||
firstElement?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watchers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Focus the confirm button when modal opens.
|
||||
* Reset selections when modal state changes.
|
||||
*/
|
||||
watch(
|
||||
() => isOpen.value,
|
||||
async (open) => {
|
||||
if (open) {
|
||||
resetSelections()
|
||||
await nextTick()
|
||||
confirmButtonRef.value?.focus()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="duration-200 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Required action"
|
||||
@keydown="handleTabTrap"
|
||||
>
|
||||
<!-- Modal container -->
|
||||
<Transition
|
||||
enter-active-class="duration-200 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="duration-150 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="modalRef"
|
||||
class="relative w-full max-w-2xl max-h-[90vh] overflow-hidden bg-surface rounded-2xl shadow-2xl border-2 border-warning"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-surface-light bg-warning/10">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-text">
|
||||
Required Action
|
||||
</h2>
|
||||
<p class="text-sm text-text-muted mt-1">
|
||||
{{ instructionText }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div class="overflow-y-auto max-h-[calc(90vh-10rem)] p-6">
|
||||
<!-- Prize Selection -->
|
||||
<div
|
||||
v-if="actionType === 'prize_selection'"
|
||||
class="space-y-4"
|
||||
>
|
||||
<p class="text-sm text-text-muted">
|
||||
Select a prize card to claim:
|
||||
</p>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<button
|
||||
v-for="index in prizePositions"
|
||||
:key="index"
|
||||
class="relative aspect-[2/3] rounded-lg border-2 transition-all hover:scale-105"
|
||||
:class="[
|
||||
selectedPrizeIndex === index
|
||||
? 'border-primary bg-primary/20 ring-2 ring-primary'
|
||||
: 'border-surface-light bg-surface-light/50 hover:border-primary/50'
|
||||
]"
|
||||
@click="handlePrizeClick(index)"
|
||||
>
|
||||
<!-- Card back placeholder -->
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-center">
|
||||
<div class="text-4xl mb-2">
|
||||
🎁
|
||||
</div>
|
||||
<div class="text-xs font-medium text-text-muted">
|
||||
Prize {{ index + 1 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected indicator -->
|
||||
<div
|
||||
v-if="selectedPrizeIndex === index"
|
||||
class="absolute -top-2 -right-2 w-6 h-6 bg-primary rounded-full flex items-center justify-center shadow-lg"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Active Selection -->
|
||||
<div
|
||||
v-if="actionType === 'new_active_selection'"
|
||||
class="space-y-4"
|
||||
>
|
||||
<p class="text-sm text-text-muted">
|
||||
Your active Pokemon was knocked out. Select a new active Pokemon from your bench:
|
||||
</p>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<button
|
||||
v-for="pokemon in availableBench"
|
||||
:key="pokemon.instance_id"
|
||||
class="relative rounded-lg border-2 transition-all hover:scale-105 overflow-hidden"
|
||||
:class="[
|
||||
selectedPokemonId === pokemon.instance_id
|
||||
? 'border-primary ring-2 ring-primary'
|
||||
: 'border-surface-light hover:border-primary/50'
|
||||
]"
|
||||
@click="handlePokemonClick(pokemon.instance_id)"
|
||||
>
|
||||
<CardImage
|
||||
:src="getCardDef(pokemon)?.image_url"
|
||||
:alt="getCardDef(pokemon)?.name ?? 'Pokemon'"
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<!-- Selected indicator -->
|
||||
<div
|
||||
v-if="selectedPokemonId === pokemon.instance_id"
|
||||
class="absolute -top-2 -right-2 w-6 h-6 bg-primary rounded-full flex items-center justify-center shadow-lg"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Card name -->
|
||||
<div class="p-2 bg-surface-light/90 text-xs font-medium text-text text-center truncate">
|
||||
{{ getCardDef(pokemon)?.name ?? 'Unknown' }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="availableBench.length === 0"
|
||||
class="text-sm text-warning text-center py-8"
|
||||
>
|
||||
No Pokemon on bench. You will lose the game if you cannot select a new active Pokemon.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Discard Selection -->
|
||||
<div
|
||||
v-if="actionType === 'discard_selection'"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-text-muted">
|
||||
Select {{ requiredDiscardCount }} card{{ requiredDiscardCount !== 1 ? 's' : '' }} to discard:
|
||||
</p>
|
||||
<span class="text-sm font-medium text-text">
|
||||
{{ selectedCardIds.size }} / {{ requiredDiscardCount }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<button
|
||||
v-for="card in cardsInHand"
|
||||
:key="card.instance_id"
|
||||
class="relative rounded-lg border-2 transition-all hover:scale-105 overflow-hidden"
|
||||
:class="[
|
||||
selectedCardIds.has(card.instance_id)
|
||||
? 'border-primary ring-2 ring-primary'
|
||||
: 'border-surface-light hover:border-primary/50',
|
||||
selectedCardIds.size >= requiredDiscardCount && !selectedCardIds.has(card.instance_id)
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: ''
|
||||
]"
|
||||
:disabled="selectedCardIds.size >= requiredDiscardCount && !selectedCardIds.has(card.instance_id)"
|
||||
@click="handleCardClick(card.instance_id)"
|
||||
>
|
||||
<CardImage
|
||||
:src="getCardDef(card)?.image_url"
|
||||
:alt="getCardDef(card)?.name ?? 'Card'"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<!-- Selected indicator -->
|
||||
<div
|
||||
v-if="selectedCardIds.has(card.instance_id)"
|
||||
class="absolute -top-2 -right-2 w-6 h-6 bg-primary rounded-full flex items-center justify-center shadow-lg"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Card name -->
|
||||
<div class="p-1.5 bg-surface-light/90 text-xs font-medium text-text text-center truncate">
|
||||
{{ getCardDef(card)?.name ?? 'Unknown' }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="mt-4 p-3 bg-error/10 border border-error rounded-lg"
|
||||
>
|
||||
<p class="text-sm text-error">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with confirm button -->
|
||||
<div class="flex items-center justify-end gap-3 p-4 border-t border-surface-light bg-surface-light/30">
|
||||
<button
|
||||
ref="confirmButtonRef"
|
||||
class="px-6 py-2.5 rounded-lg font-medium transition-all"
|
||||
:class="[
|
||||
canConfirm && !isSubmitting
|
||||
? 'bg-primary text-white hover:bg-primary-dark shadow-lg hover:shadow-xl'
|
||||
: 'bg-surface-light text-text-muted cursor-not-allowed'
|
||||
]"
|
||||
:disabled="!canConfirm || isSubmitting"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
<span v-if="isSubmitting">Confirming...</span>
|
||||
<span v-else>Confirm</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
528
frontend/src/components/game/GameOverModal.spec.ts
Normal file
528
frontend/src/components/game/GameOverModal.spec.ts
Normal file
@ -0,0 +1,528 @@
|
||||
/**
|
||||
* Tests for GameOverModal component.
|
||||
*
|
||||
* Verifies that the game over modal correctly handles:
|
||||
* - Victory display with appropriate styling and messaging
|
||||
* - Defeat display with appropriate styling and messaging
|
||||
* - Draw display when there's no winner
|
||||
* - Different end reasons with correct text
|
||||
* - Game statistics display (turn count, scores)
|
||||
* - Return to lobby navigation
|
||||
* - Keyboard support (Escape to close)
|
||||
* - Only shows when game is over
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, VueWrapper } from '@vue/test-utils'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import GameOverModal from './GameOverModal.vue'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
import type { VisibleGameState, GameEndReason } from '@/types'
|
||||
|
||||
// Mock vue-router
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Helper to create a mock game state with game over status.
|
||||
*/
|
||||
function createMockGameState(
|
||||
viewerId: string,
|
||||
winnerId: string | null,
|
||||
endReason: GameEndReason | null,
|
||||
turnNumber: number = 10,
|
||||
player1Score: number = 3,
|
||||
player2Score: number = 2
|
||||
): VisibleGameState {
|
||||
return {
|
||||
game_id: 'test-game-123',
|
||||
viewer_id: viewerId,
|
||||
players: {
|
||||
'player-1': {
|
||||
player_id: 'player-1',
|
||||
is_current_player: true,
|
||||
deck_count: 20,
|
||||
hand: { count: 5, cards: [], zone_type: 'hand' },
|
||||
prizes_count: 3,
|
||||
energy_deck_count: 10,
|
||||
active: { count: 1, cards: [], zone_type: 'active' },
|
||||
bench: { count: 3, cards: [], zone_type: 'bench' },
|
||||
discard: { count: 10, cards: [], zone_type: 'discard' },
|
||||
energy_zone: { count: 5, cards: [], zone_type: 'energy_zone' },
|
||||
score: player1Score,
|
||||
gx_attack_used: false,
|
||||
vstar_power_used: false,
|
||||
},
|
||||
'player-2': {
|
||||
player_id: 'player-2',
|
||||
is_current_player: false,
|
||||
deck_count: 15,
|
||||
hand: { count: 4, cards: [], zone_type: 'hand' },
|
||||
prizes_count: 4,
|
||||
energy_deck_count: 8,
|
||||
active: { count: 1, cards: [], zone_type: 'active' },
|
||||
bench: { count: 2, cards: [], zone_type: 'bench' },
|
||||
discard: { count: 8, cards: [], zone_type: 'discard' },
|
||||
energy_zone: { count: 3, cards: [], zone_type: 'energy_zone' },
|
||||
score: player2Score,
|
||||
gx_attack_used: false,
|
||||
vstar_power_used: false,
|
||||
},
|
||||
},
|
||||
current_player_id: 'player-1',
|
||||
turn_number: turnNumber,
|
||||
phase: 'main',
|
||||
is_my_turn: true,
|
||||
winner_id: winnerId,
|
||||
end_reason: endReason,
|
||||
stadium_in_play: null,
|
||||
stadium_owner_id: null,
|
||||
forced_action_player: null,
|
||||
forced_action_type: null,
|
||||
forced_action_reason: null,
|
||||
card_registry: {},
|
||||
}
|
||||
}
|
||||
|
||||
describe('GameOverModal', () => {
|
||||
let wrapper: VueWrapper
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockPush.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (wrapper) {
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not show when game is not over', () => {
|
||||
/**
|
||||
* Test that the modal is hidden when the game is ongoing.
|
||||
*
|
||||
* The modal should only appear when gameStore.isGameOver is true
|
||||
* (i.e., when winner_id is set). This prevents premature display.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
const ongoingState = createMockGameState('player-1', null, null)
|
||||
ongoingState.winner_id = null // Explicitly no winner
|
||||
gameStore.setGameState(ongoingState)
|
||||
|
||||
wrapper = mount(GameOverModal, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[role="dialog"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows victory screen when viewer wins', async () => {
|
||||
/**
|
||||
* Test that the modal displays a victory screen for the winning player.
|
||||
*
|
||||
* When the viewing player is the winner, the modal should:
|
||||
* - Display "Victory!" title
|
||||
* - Use success styling (green colors)
|
||||
* - Show celebratory elements (trophy icon, animations)
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
const victoryState = createMockGameState('player-1', 'player-1', 'prizes_taken')
|
||||
gameStore.setGameState(victoryState)
|
||||
|
||||
wrapper = mount(GameOverModal, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const dialog = wrapper.find('[role="dialog"]')
|
||||
expect(dialog.exists()).toBe(true)
|
||||
|
||||
const title = wrapper.find('#game-over-title')
|
||||
expect(title.text()).toBe('Victory!')
|
||||
expect(title.classes()).toContain('text-success')
|
||||
|
||||
// Trophy icon for victory
|
||||
expect(wrapper.html()).toContain('🏆')
|
||||
})
|
||||
|
||||
it('shows defeat screen when opponent wins', async () => {
|
||||
/**
|
||||
* Test that the modal displays a defeat screen when the opponent wins.
|
||||
*
|
||||
* When the viewing player loses, the modal should:
|
||||
* - Display "Defeat" title
|
||||
* - Use error styling (red colors)
|
||||
* - Show subdued elements (no celebration)
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
const defeatState = createMockGameState('player-1', 'player-2', 'prizes_taken')
|
||||
gameStore.setGameState(defeatState)
|
||||
|
||||
wrapper = mount(GameOverModal, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const title = wrapper.find('#game-over-title')
|
||||
expect(title.text()).toBe('Defeat')
|
||||
expect(title.classes()).toContain('text-error')
|
||||
|
||||
// Sad icon for defeat
|
||||
expect(wrapper.html()).toContain('😔')
|
||||
})
|
||||
|
||||
it('shows draw screen when game ends in draw', async () => {
|
||||
/**
|
||||
* Test that the modal displays a draw screen when there's no winner.
|
||||
*
|
||||
* When winner_id is empty string and end_reason is 'draw', the modal should:
|
||||
* - Display "Draw" title
|
||||
* - Use neutral styling
|
||||
* - Show neutral icon
|
||||
*
|
||||
* Note: Backend sets winner_id to "" (empty string) for draws, not null.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
const drawState = createMockGameState('player-1', '', 'draw')
|
||||
gameStore.setGameState(drawState)
|
||||
|
||||
wrapper = mount(GameOverModal, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const dialog = wrapper.find('[role="dialog"]')
|
||||
expect(dialog.exists()).toBe(true)
|
||||
|
||||
const title = wrapper.find('#game-over-title')
|
||||
expect(title.exists()).toBe(true)
|
||||
expect(title.text()).toBe('Draw')
|
||||
expect(title.classes()).toContain('text-text-muted')
|
||||
|
||||
// Handshake icon for draw
|
||||
expect(wrapper.html()).toContain('🤝')
|
||||
})
|
||||
|
||||
it('displays correct end reason text for prizes_taken', async () => {
|
||||
/**
|
||||
* Test that the end reason is displayed with correct human-readable text.
|
||||
*
|
||||
* The 'prizes_taken' reason should display as "All prize cards claimed",
|
||||
* which is more user-friendly than the internal enum value.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
const state = createMockGameState('player-1', 'player-1', 'prizes_taken')
|
||||
gameStore.setGameState(state)
|
||||
|
||||
wrapper = mount(GameOverModal, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('All prize cards claimed')
|
||||
})
|
||||
|
||||
it('displays correct end reason text for deck_empty', async () => {
|
||||
/**
|
||||
* Test that the deck_empty end reason displays appropriate text.
|
||||
*
|
||||
* This reason occurs when a player cannot draw at the start of their turn.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
const state = createMockGameState('player-1', 'player-2', 'deck_empty')
|
||||
gameStore.setGameState(state)
|
||||
|
||||
wrapper = mount(GameOverModal, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Deck ran out of cards')
|
||||
})
|
||||
|
||||
it('displays correct end reason text for resignation', async () => {
|
||||
/**
|
||||
* Test that the resignation end reason displays appropriate text.
|
||||
*
|
||||
* This reason occurs when a player explicitly resigns from the game.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
const state = createMockGameState('player-1', 'player-1', 'resignation')
|
||||
gameStore.setGameState(state)
|
||||
|
||||
wrapper = mount(GameOverModal, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Opponent resigned')
|
||||
})
|
||||
|
||||
it('displays correct end reason text for no_pokemon', async () => {
|
||||
/**
|
||||
* Test that the no_pokemon end reason displays appropriate text.
|
||||
*
|
||||
* This reason occurs when a player has no Pokemon in play
|
||||
* (active or bench) at any point.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
const state = createMockGameState('player-1', 'player-1', 'no_pokemon')
|
||||
gameStore.setGameState(state)
|
||||
|
||||
wrapper = mount(GameOverModal, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Opponent has no Pokemon in play')
|
||||
})
|
||||
|
||||
it('displays correct end reason text for timeout', async () => {
|
||||
/**
|
||||
* Test that the timeout end reason displays appropriate text.
|
||||
*
|
||||
* This reason occurs when the turn timer expires.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
const state = createMockGameState('player-1', 'player-2', 'timeout')
|
||||
gameStore.setGameState(state)
|
||||
|
||||
wrapper = mount(GameOverModal, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Turn timer expired')
|
||||
})
|
||||
|
||||
it('displays game statistics correctly', async () => {
|
||||
/**
|
||||
* Test that game statistics are displayed accurately.
|
||||
*
|
||||
* The modal should show:
|
||||
* - Total turns played
|
||||
* - Viewer's prize count (score)
|
||||
* - Opponent's prize count (score)
|
||||
*
|
||||
* These stats help players understand how the game progressed.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
const state = createMockGameState('player-1', 'player-1', 'prizes_taken', 15, 6, 2)
|
||||
gameStore.setGameState(state)
|
||||
|
||||
wrapper = mount(GameOverModal, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('Turns Played')
|
||||
expect(text).toContain('15') // turn count
|
||||
|
||||
expect(text).toContain('Your Prizes')
|
||||
expect(text).toContain('6') // my score
|
||||
|
||||
expect(text).toContain("Opponent's Prizes")
|
||||
expect(text).toContain('2') // opponent score
|
||||
})
|
||||
|
||||
it('navigates to lobby when return button clicked', async () => {
|
||||
/**
|
||||
* Test that clicking the return button navigates to the play lobby.
|
||||
*
|
||||
* The return button should:
|
||||
* - Clear the game state from the store
|
||||
* - Navigate to the /play route
|
||||
* - Trigger the close animation
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
const state = createMockGameState('player-1', 'player-1', 'prizes_taken')
|
||||
gameStore.setGameState(state)
|
||||
|
||||
wrapper = mount(GameOverModal, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const returnButton = wrapper.find('button')
|
||||
expect(returnButton.text()).toContain('Return to Lobby')
|
||||
|
||||
await returnButton.trigger('click')
|
||||
|
||||
// Wait for animation delay
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/play')
|
||||
expect(gameStore.gameState).toBeNull()
|
||||
})
|
||||
|
||||
it('closes modal when Escape key is pressed', async () => {
|
||||
/**
|
||||
* Test that pressing Escape triggers the close/return action.
|
||||
*
|
||||
* This provides keyboard accessibility and allows users to
|
||||
* quickly dismiss the modal without clicking the button.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
const state = createMockGameState('player-1', 'player-1', 'prizes_taken')
|
||||
gameStore.setGameState(state)
|
||||
|
||||
wrapper = mount(GameOverModal, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const dialog = wrapper.find('[role="dialog"]')
|
||||
await dialog.trigger('keydown', { key: 'Escape' })
|
||||
|
||||
// Wait for animation delay
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/play')
|
||||
expect(gameStore.gameState).toBeNull()
|
||||
})
|
||||
|
||||
it('focuses return button when modal opens', async () => {
|
||||
/**
|
||||
* Test that the return button receives focus when the modal opens.
|
||||
*
|
||||
* This ensures keyboard users can immediately interact with the
|
||||
* primary action without needing to tab through the modal.
|
||||
*
|
||||
* Note: Focus behavior is challenging to test with Teleport stubs,
|
||||
* so we verify the button exists and has the ref attribute that
|
||||
* would enable focus in a real browser environment.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
const state = createMockGameState('player-1', 'player-1', 'prizes_taken')
|
||||
|
||||
// Set game over state first
|
||||
gameStore.setGameState(state)
|
||||
|
||||
wrapper = mount(GameOverModal, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
await nextTick() // Extra tick for watcher
|
||||
|
||||
const returnButton = wrapper.find('button')
|
||||
expect(returnButton.exists()).toBe(true)
|
||||
expect(returnButton.text()).toContain('Return to Lobby')
|
||||
|
||||
// In a real browser, the returnButtonRef would receive focus
|
||||
// This is difficult to test with stubbed Teleport, so we verify
|
||||
// the button structure is correct
|
||||
})
|
||||
|
||||
it('applies correct styling based on victory state', async () => {
|
||||
/**
|
||||
* Test that CSS classes are applied correctly based on game outcome.
|
||||
*
|
||||
* Victory should use success colors, defeat should use error colors,
|
||||
* and draws should use neutral colors. This provides visual feedback
|
||||
* that matches the game result.
|
||||
*/
|
||||
const gameStore = useGameStore()
|
||||
|
||||
// Test victory styling
|
||||
const victoryState = createMockGameState('player-1', 'player-1', 'prizes_taken')
|
||||
gameStore.setGameState(victoryState)
|
||||
|
||||
wrapper = mount(GameOverModal, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
let modalContainer = wrapper.find('.rounded-3xl')
|
||||
expect(modalContainer.classes()).toContain('border-success')
|
||||
|
||||
let returnButton = wrapper.find('button')
|
||||
expect(returnButton.classes()).toContain('bg-success')
|
||||
|
||||
// Test defeat styling
|
||||
const defeatState = createMockGameState('player-1', 'player-2', 'prizes_taken')
|
||||
gameStore.setGameState(defeatState)
|
||||
await nextTick()
|
||||
|
||||
modalContainer = wrapper.find('.rounded-3xl')
|
||||
expect(modalContainer.classes()).toContain('border-error')
|
||||
|
||||
returnButton = wrapper.find('button')
|
||||
expect(returnButton.classes()).toContain('bg-primary') // Not success for defeat
|
||||
})
|
||||
})
|
||||
295
frontend/src/components/game/GameOverModal.vue
Normal file
295
frontend/src/components/game/GameOverModal.vue
Normal file
@ -0,0 +1,295 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Game over modal displaying match results.
|
||||
*
|
||||
* Shows when a game ends with:
|
||||
* - Victory/defeat display with celebratory or muted styling
|
||||
* - End reason (prizes taken, deck empty, resignation, etc.)
|
||||
* - Game statistics (turn count, prizes taken)
|
||||
* - Return to lobby button
|
||||
*
|
||||
* The modal appears automatically when gameStore.isGameOver becomes true.
|
||||
* Players must acknowledge the result before returning to the lobby.
|
||||
*
|
||||
* Accessibility features:
|
||||
* - Proper ARIA attributes for modal dialog
|
||||
* - Focus management (focus CTA on open)
|
||||
* - Keyboard support (Escape to close)
|
||||
*
|
||||
* Uses Teleport to render in document body for proper z-index stacking.
|
||||
*/
|
||||
import { computed, ref, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useGameStore } from '@/stores/game'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router and Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const router = useRouter()
|
||||
const gameStore = useGameStore()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Reference to the return button for initial focus */
|
||||
const returnButtonRef = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
/** Whether the modal is being closed (for animation) */
|
||||
const isClosing = ref(false)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Computed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Whether the modal should be shown */
|
||||
const isOpen = computed(() => gameStore.isGameOver || gameStore.gameState?.end_reason !== null)
|
||||
|
||||
/** Whether the viewing player won */
|
||||
const didWin = computed(() => gameStore.didIWin)
|
||||
|
||||
/** Winner ID */
|
||||
const winnerId = computed(() => gameStore.winnerId)
|
||||
|
||||
/** End reason */
|
||||
const endReason = computed(() => gameStore.gameState?.end_reason ?? null)
|
||||
|
||||
/** Current turn number when game ended */
|
||||
const turnCount = computed(() => gameStore.turnNumber)
|
||||
|
||||
/** My final score (prizes taken) */
|
||||
const myScore = computed(() => gameStore.myScore)
|
||||
|
||||
/** Opponent's final score */
|
||||
const opponentScore = computed(() => gameStore.oppScore)
|
||||
|
||||
/** Result title text */
|
||||
const resultTitle = computed(() => {
|
||||
// Backend sets winner_id to "" (empty string) for draws
|
||||
if (!winnerId.value || winnerId.value === '') {
|
||||
return 'Draw'
|
||||
}
|
||||
return didWin.value ? 'Victory!' : 'Defeat'
|
||||
})
|
||||
|
||||
/** CSS classes for result styling */
|
||||
const resultClasses = computed(() => {
|
||||
if (!winnerId.value || winnerId.value === '') {
|
||||
return 'text-text-muted'
|
||||
}
|
||||
return didWin.value ? 'text-success' : 'text-error'
|
||||
})
|
||||
|
||||
/** Background gradient classes */
|
||||
const backgroundClasses = computed(() => {
|
||||
if (!winnerId.value || winnerId.value === '') {
|
||||
return 'bg-gradient-to-b from-surface to-surface-light'
|
||||
}
|
||||
return didWin.value
|
||||
? 'bg-gradient-to-b from-success/10 to-surface'
|
||||
: 'bg-gradient-to-b from-error/10 to-surface'
|
||||
})
|
||||
|
||||
/** Human-readable end reason text */
|
||||
const endReasonText = computed(() => {
|
||||
switch (endReason.value) {
|
||||
case 'prizes_taken':
|
||||
return 'All prize cards claimed'
|
||||
case 'no_pokemon':
|
||||
return 'Opponent has no Pokemon in play'
|
||||
case 'deck_empty':
|
||||
return 'Deck ran out of cards'
|
||||
case 'resignation':
|
||||
return 'Opponent resigned'
|
||||
case 'timeout':
|
||||
return 'Turn timer expired'
|
||||
case 'turn_limit':
|
||||
return 'Turn limit reached'
|
||||
case 'draw':
|
||||
return 'Game ended in a draw'
|
||||
default:
|
||||
return 'Game ended'
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return to the play lobby page.
|
||||
*/
|
||||
async function handleReturnToLobby(): Promise<void> {
|
||||
isClosing.value = true
|
||||
|
||||
// Wait for close animation
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
|
||||
// Clear game state and navigate
|
||||
gameStore.clearGame()
|
||||
await router.push('/play')
|
||||
|
||||
isClosing.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Escape key to close modal.
|
||||
*/
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape' && !isClosing.value) {
|
||||
handleReturnToLobby()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watchers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Focus the return button when modal opens.
|
||||
*/
|
||||
watch(
|
||||
() => isOpen.value,
|
||||
async (open) => {
|
||||
if (open) {
|
||||
await nextTick()
|
||||
returnButtonRef.value?.focus()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="duration-300 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen && !isClosing"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/90 backdrop-blur-md"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="game-over-title"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<!-- Modal container -->
|
||||
<Transition
|
||||
enter-active-class="duration-300 ease-out delay-100"
|
||||
enter-from-class="opacity-0 scale-90 translate-y-4"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="duration-200 ease-in"
|
||||
leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-to-class="opacity-0 scale-95 translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen && !isClosing"
|
||||
class="relative w-full max-w-md overflow-hidden rounded-3xl shadow-2xl border-2"
|
||||
:class="[
|
||||
backgroundClasses,
|
||||
didWin ? 'border-success' : (winnerId && winnerId !== '') ? 'border-error' : 'border-surface-light'
|
||||
]"
|
||||
>
|
||||
<!-- Decorative elements for victory -->
|
||||
<div
|
||||
v-if="didWin"
|
||||
class="absolute inset-0 pointer-events-none overflow-hidden"
|
||||
>
|
||||
<!-- Subtle particle effect background -->
|
||||
<div class="absolute top-0 left-1/4 w-2 h-2 bg-success rounded-full opacity-60 animate-ping" />
|
||||
<div
|
||||
class="absolute top-1/4 right-1/3 w-1.5 h-1.5 bg-success rounded-full opacity-40 animate-pulse"
|
||||
style="animation-delay: 0.3s"
|
||||
/>
|
||||
<div
|
||||
class="absolute top-1/3 left-2/3 w-1 h-1 bg-success rounded-full opacity-50 animate-ping"
|
||||
style="animation-delay: 0.6s"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="relative p-8 text-center">
|
||||
<!-- Result icon/decoration -->
|
||||
<div class="mb-6">
|
||||
<div
|
||||
v-if="didWin"
|
||||
class="text-8xl animate-bounce"
|
||||
style="animation-iteration-count: 2; animation-duration: 0.8s"
|
||||
>
|
||||
🏆
|
||||
</div>
|
||||
<div
|
||||
v-else-if="winnerId && winnerId !== ''"
|
||||
class="text-8xl opacity-60"
|
||||
>
|
||||
😔
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-8xl opacity-50"
|
||||
>
|
||||
🤝
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result title -->
|
||||
<h2
|
||||
id="game-over-title"
|
||||
class="text-4xl font-bold mb-4"
|
||||
:class="resultClasses"
|
||||
>
|
||||
{{ resultTitle }}
|
||||
</h2>
|
||||
|
||||
<!-- End reason -->
|
||||
<p class="text-lg text-text-muted mb-8">
|
||||
{{ endReasonText }}
|
||||
</p>
|
||||
|
||||
<!-- Game statistics -->
|
||||
<div class="space-y-3 mb-8 p-4 bg-surface-light/50 rounded-xl border border-surface-light">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-text-muted">Turns Played</span>
|
||||
<span class="font-medium text-text">{{ turnCount }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-text-muted">Your Prizes</span>
|
||||
<span class="font-medium text-text">{{ myScore }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-text-muted">Opponent's Prizes</span>
|
||||
<span class="font-medium text-text">{{ opponentScore }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Return to lobby button -->
|
||||
<button
|
||||
ref="returnButtonRef"
|
||||
class="w-full px-6 py-3 rounded-xl font-bold text-lg transition-all shadow-lg hover:shadow-xl transform hover:scale-105"
|
||||
:class="[
|
||||
didWin
|
||||
? 'bg-success text-white hover:bg-success/90'
|
||||
: 'bg-primary text-white hover:bg-primary-dark'
|
||||
]"
|
||||
@click="handleReturnToLobby"
|
||||
>
|
||||
Return to Lobby
|
||||
</button>
|
||||
|
||||
<!-- Close hint -->
|
||||
<p class="mt-4 text-xs text-text-muted">
|
||||
Press <kbd class="px-1.5 py-0.5 bg-surface-light rounded text-text font-mono">Esc</kbd> to close
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
186
frontend/src/components/game/GameOverlay.spec.ts
Normal file
186
frontend/src/components/game/GameOverlay.spec.ts
Normal file
@ -0,0 +1,186 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mount, VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import GameOverlay from './GameOverlay.vue'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
import type { VisibleGameState } from '@/types'
|
||||
|
||||
describe('GameOverlay', () => {
|
||||
let wrapper: VueWrapper
|
||||
let gameStore: ReturnType<typeof useGameStore>
|
||||
|
||||
beforeEach(() => {
|
||||
// Set up fresh Pinia instance for each test
|
||||
setActivePinia(createPinia())
|
||||
gameStore = useGameStore()
|
||||
})
|
||||
|
||||
it('renders overlay container', () => {
|
||||
/**
|
||||
* Test that the overlay container renders with correct structure.
|
||||
*
|
||||
* The overlay container must be positioned over the Phaser canvas
|
||||
* with proper z-index and pointer-events handling.
|
||||
*/
|
||||
wrapper = mount(GameOverlay)
|
||||
|
||||
const container = wrapper.find('[data-testid="game-overlay"]')
|
||||
expect(container.exists()).toBe(true)
|
||||
expect(container.classes()).toContain('game-overlay-container')
|
||||
})
|
||||
|
||||
it('provides game state to child components', () => {
|
||||
/**
|
||||
* Test that game state is provided via inject for child components.
|
||||
*
|
||||
* Child overlay components need access to game state without
|
||||
* prop drilling through multiple layers.
|
||||
*/
|
||||
const mockState: Partial<VisibleGameState> = {
|
||||
game_id: 'test-game',
|
||||
current_turn_player_id: 'player-1',
|
||||
phase: 'MAIN'
|
||||
}
|
||||
gameStore.setGameState(mockState as VisibleGameState)
|
||||
|
||||
// Create a test child component that injects game state
|
||||
const TestChild = {
|
||||
template: '<div>{{ gameState?.game_id }}</div>',
|
||||
inject: ['gameState'],
|
||||
}
|
||||
|
||||
wrapper = mount(GameOverlay, {
|
||||
slots: {
|
||||
default: TestChild
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('test-game')
|
||||
})
|
||||
|
||||
it('has pointer-events none on container', () => {
|
||||
/**
|
||||
* Test that container has pointer-events: none.
|
||||
*
|
||||
* This allows clicks to pass through to the Phaser canvas
|
||||
* in areas not covered by interactive overlay components.
|
||||
*/
|
||||
wrapper = mount(GameOverlay)
|
||||
|
||||
const container = wrapper.find('.game-overlay-container')
|
||||
|
||||
// Note: In JSDOM, computed styles won't work as expected,
|
||||
// but we can verify the correct class is applied
|
||||
expect(container.classes()).toContain('game-overlay-container')
|
||||
})
|
||||
|
||||
it('renders all overlay zones', () => {
|
||||
/**
|
||||
* Test that all predefined overlay zones are rendered.
|
||||
*
|
||||
* Each zone is positioned for a specific type of overlay component
|
||||
* (turn indicator, phase actions, attack menu, etc.).
|
||||
*/
|
||||
wrapper = mount(GameOverlay)
|
||||
|
||||
const zones = wrapper.findAll('.overlay-zone')
|
||||
|
||||
// Should have 6 zones:
|
||||
// - top-center (turn indicator)
|
||||
// - bottom-right (phase actions)
|
||||
// - center (attack menu)
|
||||
// - fullscreen (forced action)
|
||||
// - fullscreen (game over)
|
||||
// - default (additional slot)
|
||||
expect(zones.length).toBeGreaterThanOrEqual(6)
|
||||
})
|
||||
|
||||
it('renders slotted content in correct zones', () => {
|
||||
/**
|
||||
* Test that slot content is rendered in the correct positioned zones.
|
||||
*
|
||||
* Each named slot should render within its designated zone with
|
||||
* proper positioning.
|
||||
*/
|
||||
wrapper = mount(GameOverlay, {
|
||||
slots: {
|
||||
'turn-indicator': '<div class="test-turn-indicator">Turn Info</div>',
|
||||
'phase-actions': '<div class="test-phase-actions">Actions</div>',
|
||||
'attack-menu': '<div class="test-attack-menu">Attack</div>',
|
||||
}
|
||||
})
|
||||
|
||||
// Verify slotted content is rendered
|
||||
expect(wrapper.find('.test-turn-indicator').exists()).toBe(true)
|
||||
expect(wrapper.find('.test-phase-actions').exists()).toBe(true)
|
||||
expect(wrapper.find('.test-attack-menu').exists()).toBe(true)
|
||||
|
||||
// Verify content is in correct zones
|
||||
const topCenterZone = wrapper.find('.overlay-zone--top-center')
|
||||
expect(topCenterZone.find('.test-turn-indicator').exists()).toBe(true)
|
||||
|
||||
const bottomRightZone = wrapper.find('.overlay-zone--bottom-right')
|
||||
expect(bottomRightZone.find('.test-phase-actions').exists()).toBe(true)
|
||||
|
||||
const centerZone = wrapper.find('.overlay-zone--center')
|
||||
expect(centerZone.find('.test-attack-menu').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('provides all required computed properties to children', () => {
|
||||
/**
|
||||
* Test that all required game state properties are provided.
|
||||
*
|
||||
* Child components expect gameState, isMyTurn, currentPhase,
|
||||
* and isGameOver to be available via inject.
|
||||
*/
|
||||
const mockState: Partial<VisibleGameState> = {
|
||||
game_id: 'test-game',
|
||||
current_turn_player_id: 'player-1',
|
||||
phase: 'ATTACK',
|
||||
my_player_id: 'player-1',
|
||||
is_my_turn: true, // Backend calculates this
|
||||
winner_id: null, // Game not over
|
||||
}
|
||||
gameStore.setGameState(mockState as VisibleGameState)
|
||||
|
||||
const TestChild = {
|
||||
template: `
|
||||
<div>
|
||||
<span class="game-state">{{ gameState?.game_id }}</span>
|
||||
<span class="is-my-turn">{{ isMyTurn }}</span>
|
||||
<span class="current-phase">{{ currentPhase }}</span>
|
||||
<span class="is-game-over">{{ isGameOver }}</span>
|
||||
</div>
|
||||
`,
|
||||
inject: ['gameState', 'isMyTurn', 'currentPhase', 'isGameOver'],
|
||||
}
|
||||
|
||||
wrapper = mount(GameOverlay, {
|
||||
slots: {
|
||||
default: TestChild
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.find('.game-state').text()).toBe('test-game')
|
||||
expect(wrapper.find('.is-my-turn').text()).toBe('true')
|
||||
expect(wrapper.find('.current-phase').text()).toBe('ATTACK')
|
||||
expect(wrapper.find('.is-game-over').text()).toBe('false')
|
||||
})
|
||||
|
||||
it('allows empty slots to be optional', () => {
|
||||
/**
|
||||
* Test that the component renders correctly with no slotted content.
|
||||
*
|
||||
* Overlay zones should exist even when empty, ready to receive
|
||||
* content from parent components.
|
||||
*/
|
||||
wrapper = mount(GameOverlay)
|
||||
|
||||
// Component should render without errors
|
||||
expect(wrapper.find('[data-testid="game-overlay"]').exists()).toBe(true)
|
||||
|
||||
// All zones should exist but be empty
|
||||
const zones = wrapper.findAll('.overlay-zone')
|
||||
expect(zones.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
139
frontend/src/components/game/GameOverlay.vue
Normal file
139
frontend/src/components/game/GameOverlay.vue
Normal file
@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Game overlay container - positions Vue UI overlays over the Phaser canvas.
|
||||
*
|
||||
* This component provides a container layer positioned absolutely over the
|
||||
* Phaser game canvas, hosting Vue components for game UI like turn indicators,
|
||||
* action menus, and modal dialogs.
|
||||
*
|
||||
* Key architectural decisions:
|
||||
* - Container has pointer-events: none to allow Phaser canvas clicks through
|
||||
* - Child overlays have pointer-events: auto to be interactive
|
||||
* - Uses provide/inject to share game state with child components
|
||||
* - Slots allow flexible composition of different overlay components
|
||||
* - Responsive positioning adapts to screen size
|
||||
*/
|
||||
import { provide, computed } from 'vue'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
|
||||
const gameStore = useGameStore()
|
||||
|
||||
// Provide game state to all child overlay components via inject
|
||||
// This avoids prop drilling through multiple layers
|
||||
provide('gameState', computed(() => gameStore.gameState))
|
||||
provide('isMyTurn', computed(() => gameStore.isMyTurn))
|
||||
provide('currentPhase', computed(() => gameStore.currentPhase))
|
||||
provide('isGameOver', computed(() => gameStore.isGameOver))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="game-overlay-container"
|
||||
data-testid="game-overlay"
|
||||
>
|
||||
<!-- Turn Indicator - Top Center -->
|
||||
<div class="overlay-zone overlay-zone--top-center">
|
||||
<slot name="turn-indicator" />
|
||||
</div>
|
||||
|
||||
<!-- Phase Actions - Bottom Right -->
|
||||
<div class="overlay-zone overlay-zone--bottom-right">
|
||||
<slot name="phase-actions" />
|
||||
</div>
|
||||
|
||||
<!-- Attack Menu - Centered Modal -->
|
||||
<div class="overlay-zone overlay-zone--center">
|
||||
<slot name="attack-menu" />
|
||||
</div>
|
||||
|
||||
<!-- Forced Action Modal - Full Screen Modal -->
|
||||
<div class="overlay-zone overlay-zone--fullscreen">
|
||||
<slot name="forced-action" />
|
||||
</div>
|
||||
|
||||
<!-- Game Over Modal - Full Screen Modal -->
|
||||
<div class="overlay-zone overlay-zone--fullscreen">
|
||||
<slot name="game-over" />
|
||||
</div>
|
||||
|
||||
<!-- Additional flexible slot for future overlays -->
|
||||
<div class="overlay-zone">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/**
|
||||
* Container layer over Phaser canvas.
|
||||
*
|
||||
* pointer-events: none allows clicks to pass through to Phaser.
|
||||
* Child elements restore pointer-events: auto to be interactive.
|
||||
*/
|
||||
.game-overlay-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 30; /* Above Phaser canvas (z-index 10), below modals (z-index 40+) */
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual overlay zones with specific positioning.
|
||||
*
|
||||
* Each zone is a positioning container for a specific type of overlay.
|
||||
* Child components restore pointer-events to be interactive.
|
||||
*/
|
||||
.overlay-zone {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Top center zone - for turn indicator */
|
||||
.overlay-zone--top-center {
|
||||
top: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* Bottom right zone - for phase action buttons */
|
||||
.overlay-zone--bottom-right {
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
/* Center zone - for attack menu and other centered overlays */
|
||||
.overlay-zone--center {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* Fullscreen zone - for modals that need full coverage */
|
||||
.overlay-zone--fullscreen {
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* All direct children of overlay zones restore pointer events.
|
||||
* This makes overlay components interactive while keeping the
|
||||
* container itself non-blocking.
|
||||
*/
|
||||
.overlay-zone > :deep(*) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Mobile responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.overlay-zone--top-center {
|
||||
top: 0.5rem;
|
||||
}
|
||||
|
||||
.overlay-zone--bottom-right {
|
||||
bottom: 0.5rem;
|
||||
right: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
519
frontend/src/components/game/PhaseActions.spec.ts
Normal file
519
frontend/src/components/game/PhaseActions.spec.ts
Normal file
@ -0,0 +1,519 @@
|
||||
/**
|
||||
* Tests for PhaseActions component.
|
||||
*
|
||||
* Verifies that the phase actions component correctly:
|
||||
* - Shows appropriate buttons based on current phase
|
||||
* - Enables/disables buttons based on turn and game state
|
||||
* - Dispatches actions correctly when buttons are clicked
|
||||
* - Handles status conditions that prevent certain actions
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import PhaseActions from './PhaseActions.vue'
|
||||
import type { VisibleGameState, TurnPhase, CardInstance } from '@/types'
|
||||
|
||||
// Mock the useGameActions composable
|
||||
const mockEndTurn = vi.fn()
|
||||
const mockRetreat = vi.fn()
|
||||
const mockIsPending = ref(false)
|
||||
|
||||
vi.mock('@/composables/useGameActions', () => ({
|
||||
useGameActions: () => ({
|
||||
endTurn: mockEndTurn,
|
||||
retreat: mockRetreat,
|
||||
isPending: computed(() => mockIsPending.value),
|
||||
}),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Helper function to create a mock card instance.
|
||||
*/
|
||||
function createMockCard(
|
||||
instanceId: string,
|
||||
definitionId: string,
|
||||
statusConditions: string[] = []
|
||||
): CardInstance {
|
||||
return {
|
||||
instance_id: instanceId,
|
||||
definition_id: definitionId,
|
||||
damage: 0,
|
||||
attached_energy: [],
|
||||
attached_tools: [],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
status_conditions: statusConditions as any,
|
||||
ability_uses_this_turn: {},
|
||||
evolution_turn: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create a mock game state.
|
||||
*/
|
||||
function createMockGameState(
|
||||
phase: TurnPhase = 'main',
|
||||
isMyTurn: boolean = true,
|
||||
hasActive: boolean = true,
|
||||
hasBench: boolean = true,
|
||||
activeStatusConditions: string[] = []
|
||||
): VisibleGameState {
|
||||
const activePokemon = hasActive
|
||||
? createMockCard('active-1', 'pikachu', activeStatusConditions)
|
||||
: null
|
||||
const benchPokemon = hasBench ? [createMockCard('bench-1', 'charmander')] : []
|
||||
|
||||
return {
|
||||
game_id: 'test-game-123',
|
||||
viewer_id: 'player-1',
|
||||
players: {
|
||||
'player-1': {
|
||||
player_id: 'player-1',
|
||||
is_current_player: isMyTurn,
|
||||
deck_count: 40,
|
||||
hand: { count: 7, cards: [], zone_type: 'hand' },
|
||||
prizes_count: 6,
|
||||
energy_deck_count: 10,
|
||||
active: {
|
||||
count: hasActive ? 1 : 0,
|
||||
cards: activePokemon ? [activePokemon] : [],
|
||||
zone_type: 'active',
|
||||
},
|
||||
bench: {
|
||||
count: benchPokemon.length,
|
||||
cards: benchPokemon,
|
||||
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,
|
||||
},
|
||||
'player-2': {
|
||||
player_id: 'player-2',
|
||||
is_current_player: !isMyTurn,
|
||||
deck_count: 40,
|
||||
hand: { count: 7, cards: [], zone_type: 'hand' },
|
||||
prizes_count: 6,
|
||||
energy_deck_count: 10,
|
||||
active: { count: 1, cards: [createMockCard('opp-active', 'bulbasaur')], 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: isMyTurn ? 'player-1' : 'player-2',
|
||||
turn_number: 1,
|
||||
phase,
|
||||
is_my_turn: isMyTurn,
|
||||
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: {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount PhaseActions with proper provide/inject context.
|
||||
*/
|
||||
function mountPhaseActions(
|
||||
phase: TurnPhase = 'main',
|
||||
isMyTurn: boolean = true,
|
||||
hasActive: boolean = true,
|
||||
hasBench: boolean = true,
|
||||
activeStatusConditions: string[] = []
|
||||
) {
|
||||
const gameState = ref(createMockGameState(phase, isMyTurn, hasActive, hasBench, activeStatusConditions))
|
||||
|
||||
return mount(PhaseActions, {
|
||||
global: {
|
||||
provide: {
|
||||
gameState: computed(() => gameState.value),
|
||||
isMyTurn: computed(() => gameState.value.is_my_turn),
|
||||
currentPhase: computed(() => gameState.value.phase),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('PhaseActions', () => {
|
||||
beforeEach(() => {
|
||||
// Reset mocks before each test
|
||||
mockEndTurn.mockReset()
|
||||
mockRetreat.mockReset()
|
||||
mockIsPending.value = false
|
||||
})
|
||||
|
||||
describe('Button Visibility', () => {
|
||||
it('shows End Turn button during MAIN phase when it is your turn', () => {
|
||||
/**
|
||||
* Test that the End Turn button appears during the main phase.
|
||||
*
|
||||
* The main phase is when players can take most actions, and they
|
||||
* need a way to signal they're done and ready to attack or end.
|
||||
*/
|
||||
const wrapper = mountPhaseActions('main', true)
|
||||
const endTurnButton = wrapper.find('[data-testid="end-turn-button"]')
|
||||
expect(endTurnButton.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides End Turn button when not in MAIN phase', () => {
|
||||
/**
|
||||
* Test that End Turn button is hidden during non-main phases.
|
||||
*
|
||||
* Players cannot end turn during other phases as the game flow
|
||||
* is controlled by the server during those phases.
|
||||
*/
|
||||
const wrapper = mountPhaseActions('attack', true)
|
||||
const endTurnButton = wrapper.find('[data-testid="end-turn-button"]')
|
||||
expect(endTurnButton.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('hides End Turn button when it is opponent\'s turn', () => {
|
||||
/**
|
||||
* Test that End Turn button is hidden during opponent's turn.
|
||||
*
|
||||
* Players cannot end the opponent's turn - they must wait for
|
||||
* their own turn to take actions.
|
||||
*/
|
||||
const wrapper = mountPhaseActions('main', false)
|
||||
const endTurnButton = wrapper.find('[data-testid="end-turn-button"]')
|
||||
expect(endTurnButton.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows Retreat button during MAIN phase when active and bench exist', () => {
|
||||
/**
|
||||
* Test that Retreat button appears when retreat is possible.
|
||||
*
|
||||
* Retreating requires both an active Pokemon and at least one
|
||||
* benched Pokemon to switch with.
|
||||
*/
|
||||
const wrapper = mountPhaseActions('main', true, true, true)
|
||||
const retreatButton = wrapper.find('[data-testid="retreat-button"]')
|
||||
expect(retreatButton.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides Retreat button when no active Pokemon', () => {
|
||||
/**
|
||||
* Test that Retreat button is hidden without an active Pokemon.
|
||||
*
|
||||
* Cannot retreat if there's no active Pokemon to retreat.
|
||||
*/
|
||||
const wrapper = mountPhaseActions('main', true, false, true)
|
||||
const retreatButton = wrapper.find('[data-testid="retreat-button"]')
|
||||
expect(retreatButton.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('hides Retreat button when bench is empty', () => {
|
||||
/**
|
||||
* Test that Retreat button is hidden when bench is empty.
|
||||
*
|
||||
* Cannot retreat if there's no benched Pokemon to switch with.
|
||||
*/
|
||||
const wrapper = mountPhaseActions('main', true, true, false)
|
||||
const retreatButton = wrapper.find('[data-testid="retreat-button"]')
|
||||
expect(retreatButton.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('hides Retreat button when not in MAIN phase', () => {
|
||||
/**
|
||||
* Test that Retreat can only happen during main phase.
|
||||
*
|
||||
* Retreat is a main phase action - it cannot be done during
|
||||
* attack or other phases.
|
||||
*/
|
||||
const wrapper = mountPhaseActions('attack', true, true, true)
|
||||
const retreatButton = wrapper.find('[data-testid="retreat-button"]')
|
||||
expect(retreatButton.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('hides all buttons during opponent\'s turn', () => {
|
||||
/**
|
||||
* Test that no action buttons appear during opponent's turn.
|
||||
*
|
||||
* Players cannot take any phase actions during the opponent's turn.
|
||||
*/
|
||||
const wrapper = mountPhaseActions('main', false, true, true)
|
||||
const container = wrapper.find('[data-testid="phase-actions"]')
|
||||
expect(container.exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button States', () => {
|
||||
it('enables End Turn button when it is your turn', () => {
|
||||
/**
|
||||
* Test that End Turn button is enabled during your turn.
|
||||
*
|
||||
* Players should be able to end their turn when it's active.
|
||||
*/
|
||||
const wrapper = mountPhaseActions('main', true)
|
||||
const endTurnButton = wrapper.find('[data-testid="end-turn-button"]')
|
||||
expect(endTurnButton.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables End Turn button when action is pending', () => {
|
||||
/**
|
||||
* Test that End Turn is disabled during action processing.
|
||||
*
|
||||
* Prevents multiple simultaneous actions that could cause
|
||||
* race conditions or invalid game states.
|
||||
*/
|
||||
mockIsPending.value = true
|
||||
const wrapper = mountPhaseActions('main', true)
|
||||
const endTurnButton = wrapper.find('[data-testid="end-turn-button"]')
|
||||
expect(endTurnButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables Retreat button when active Pokemon is paralyzed', () => {
|
||||
/**
|
||||
* Test that paralyzed Pokemon cannot retreat.
|
||||
*
|
||||
* Paralyzed status prevents retreat - this is a core TCG rule
|
||||
* that must be enforced by the UI.
|
||||
*/
|
||||
const wrapper = mountPhaseActions('main', true, true, true, ['paralyzed'])
|
||||
const retreatButton = wrapper.find('[data-testid="retreat-button"]')
|
||||
expect(retreatButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables Retreat button when active Pokemon is asleep', () => {
|
||||
/**
|
||||
* Test that sleeping Pokemon cannot retreat.
|
||||
*
|
||||
* Sleep status prevents retreat - another core TCG rule.
|
||||
*/
|
||||
const wrapper = mountPhaseActions('main', true, true, true, ['asleep'])
|
||||
const retreatButton = wrapper.find('[data-testid="retreat-button"]')
|
||||
expect(retreatButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('enables Retreat button when active Pokemon has other status conditions', () => {
|
||||
/**
|
||||
* Test that non-blocking status conditions don't prevent retreat.
|
||||
*
|
||||
* Only paralyzed and asleep prevent retreat - other conditions
|
||||
* like poisoned or burned do not.
|
||||
*/
|
||||
const wrapper = mountPhaseActions('main', true, true, true, ['poisoned', 'burned'])
|
||||
const retreatButton = wrapper.find('[data-testid="retreat-button"]')
|
||||
expect(retreatButton.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables Retreat button when action is pending', () => {
|
||||
/**
|
||||
* Test that Retreat is disabled during action processing.
|
||||
*
|
||||
* Prevents multiple simultaneous actions.
|
||||
*/
|
||||
mockIsPending.value = true
|
||||
const wrapper = mountPhaseActions('main', true, true, true)
|
||||
const retreatButton = wrapper.find('[data-testid="retreat-button"]')
|
||||
expect(retreatButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Action Dispatching', () => {
|
||||
it('calls endTurn when End Turn button is clicked', async () => {
|
||||
/**
|
||||
* Test that clicking End Turn dispatches the action.
|
||||
*
|
||||
* The button must trigger the actual game action to end the turn.
|
||||
*/
|
||||
const wrapper = mountPhaseActions('main', true)
|
||||
const endTurnButton = wrapper.find('[data-testid="end-turn-button"]')
|
||||
|
||||
await endTurnButton.trigger('click')
|
||||
|
||||
expect(mockEndTurn).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('emits retreatRequested when Retreat button is clicked', async () => {
|
||||
/**
|
||||
* Test that clicking Retreat emits the event.
|
||||
*
|
||||
* Retreat requires additional UI (selecting which bench Pokemon),
|
||||
* so it emits an event for the parent to handle.
|
||||
*/
|
||||
const wrapper = mountPhaseActions('main', true, true, true)
|
||||
const retreatButton = wrapper.find('[data-testid="retreat-button"]')
|
||||
|
||||
await retreatButton.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('retreatRequested')).toBeTruthy()
|
||||
expect(wrapper.emitted('retreatRequested')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not call endTurn when button is disabled', async () => {
|
||||
/**
|
||||
* Test that disabled End Turn button doesn't dispatch action.
|
||||
*
|
||||
* Disabled buttons should not respond to clicks to prevent
|
||||
* invalid actions.
|
||||
*/
|
||||
mockIsPending.value = true
|
||||
const wrapper = mountPhaseActions('main', true)
|
||||
const endTurnButton = wrapper.find('[data-testid="end-turn-button"]')
|
||||
|
||||
await endTurnButton.trigger('click')
|
||||
|
||||
expect(mockEndTurn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles endTurn errors gracefully', async () => {
|
||||
/**
|
||||
* Test that errors during action dispatch are handled.
|
||||
*
|
||||
* Network errors or server rejections should be caught and logged
|
||||
* without crashing the component.
|
||||
*/
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockEndTurn.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const wrapper = mountPhaseActions('main', true)
|
||||
const endTurnButton = wrapper.find('[data-testid="end-turn-button"]')
|
||||
|
||||
await endTurnButton.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'Failed to end turn:',
|
||||
expect.any(Error)
|
||||
)
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Game Over State', () => {
|
||||
it('disables End Turn button when game is over', () => {
|
||||
/**
|
||||
* Test that End Turn is disabled when game has ended.
|
||||
*
|
||||
* No actions should be possible after the game ends.
|
||||
*/
|
||||
const gameState = ref(createMockGameState('main', true))
|
||||
gameState.value.winner_id = 'player-1'
|
||||
|
||||
const wrapper = mount(PhaseActions, {
|
||||
global: {
|
||||
provide: {
|
||||
gameState: computed(() => gameState.value),
|
||||
isMyTurn: computed(() => gameState.value.is_my_turn),
|
||||
currentPhase: computed(() => gameState.value.phase),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const endTurnButton = wrapper.find('[data-testid="end-turn-button"]')
|
||||
expect(endTurnButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables Retreat button when game is over', () => {
|
||||
/**
|
||||
* Test that Retreat is disabled when game has ended.
|
||||
*
|
||||
* No actions should be possible after the game ends.
|
||||
*/
|
||||
const gameState = ref(createMockGameState('main', true, true, true))
|
||||
gameState.value.winner_id = 'player-2'
|
||||
|
||||
const wrapper = mount(PhaseActions, {
|
||||
global: {
|
||||
provide: {
|
||||
gameState: computed(() => gameState.value),
|
||||
isMyTurn: computed(() => gameState.value.is_my_turn),
|
||||
currentPhase: computed(() => gameState.value.phase),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const retreatButton = wrapper.find('[data-testid="retreat-button"]')
|
||||
expect(retreatButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('throws error when used outside GameOverlay (missing inject)', () => {
|
||||
/**
|
||||
* Test that the component fails fast with clear error when missing context.
|
||||
*
|
||||
* This component requires GameOverlay's provide/inject context.
|
||||
* Failing fast helps developers catch integration errors early.
|
||||
*/
|
||||
expect(() => {
|
||||
mount(PhaseActions)
|
||||
}).toThrow('PhaseActions must be used within GameOverlay')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Reactivity', () => {
|
||||
it('shows buttons when phase changes to MAIN', async () => {
|
||||
/**
|
||||
* Test that buttons appear when transitioning to main phase.
|
||||
*
|
||||
* Component must react to phase changes to show appropriate actions.
|
||||
*/
|
||||
const gameState = ref(createMockGameState('draw', true, true, true))
|
||||
|
||||
const wrapper = mount(PhaseActions, {
|
||||
global: {
|
||||
provide: {
|
||||
gameState: computed(() => gameState.value),
|
||||
isMyTurn: computed(() => gameState.value.is_my_turn),
|
||||
currentPhase: computed(() => gameState.value.phase),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Initially no buttons during draw phase
|
||||
expect(wrapper.find('[data-testid="phase-actions"]').exists()).toBe(false)
|
||||
|
||||
// Change to main phase
|
||||
gameState.value = createMockGameState('main', true, true, true)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Now buttons should appear
|
||||
expect(wrapper.find('[data-testid="phase-actions"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="end-turn-button"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('enables Retreat button when status is removed', async () => {
|
||||
/**
|
||||
* Test that Retreat becomes enabled when blocking status is cleared.
|
||||
*
|
||||
* Status conditions can change during gameplay - button states
|
||||
* must update reactively.
|
||||
*/
|
||||
const gameState = ref(createMockGameState('main', true, true, true, ['paralyzed']))
|
||||
|
||||
const wrapper = mount(PhaseActions, {
|
||||
global: {
|
||||
provide: {
|
||||
gameState: computed(() => gameState.value),
|
||||
isMyTurn: computed(() => gameState.value.is_my_turn),
|
||||
currentPhase: computed(() => gameState.value.phase),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Initially disabled due to paralysis
|
||||
let retreatButton = wrapper.find('[data-testid="retreat-button"]')
|
||||
expect(retreatButton.attributes('disabled')).toBeDefined()
|
||||
|
||||
// Remove paralysis
|
||||
gameState.value = createMockGameState('main', true, true, true, [])
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Now should be enabled
|
||||
retreatButton = wrapper.find('[data-testid="retreat-button"]')
|
||||
expect(retreatButton.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
319
frontend/src/components/game/PhaseActions.vue
Normal file
319
frontend/src/components/game/PhaseActions.vue
Normal file
@ -0,0 +1,319 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Phase actions component - displays action buttons available during each phase.
|
||||
*
|
||||
* This component shows context-appropriate action buttons based on the current
|
||||
* turn phase and game state:
|
||||
* - MAIN phase: End Turn button, Retreat button (if can retreat)
|
||||
* - ATTACK phase: shows after selecting 'Attack' action from card
|
||||
* - END phase: auto-advance (server handles)
|
||||
*
|
||||
* Visual design:
|
||||
* - Positioned at bottom-right of game area via GameOverlay
|
||||
* - Buttons disabled when not your turn
|
||||
* - Loading states during action execution
|
||||
* - Large touch-friendly buttons on mobile
|
||||
* - Clear visual feedback for enabled/disabled states
|
||||
*/
|
||||
import { computed, inject, type ComputedRef } from 'vue'
|
||||
import type { VisibleGameState, TurnPhase } from '@/types'
|
||||
import { useGameActions } from '@/composables/useGameActions'
|
||||
|
||||
// Inject game state from GameOverlay parent
|
||||
const gameState = inject<ComputedRef<VisibleGameState | null>>('gameState')
|
||||
const isMyTurn = inject<ComputedRef<boolean>>('isMyTurn')
|
||||
const currentPhase = inject<ComputedRef<TurnPhase | null>>('currentPhase')
|
||||
|
||||
// Guard against missing injections (should never happen in production)
|
||||
if (!gameState || !isMyTurn || !currentPhase) {
|
||||
throw new Error('PhaseActions must be used within GameOverlay')
|
||||
}
|
||||
|
||||
// Game actions composable
|
||||
const { endTurn, isPending } = useGameActions()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Computed - Button States
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whether the End Turn button should be shown.
|
||||
*
|
||||
* Shown during MAIN phase when it's your turn.
|
||||
*/
|
||||
const showEndTurnButton = computed(() => {
|
||||
return isMyTurn.value && currentPhase.value === 'main'
|
||||
})
|
||||
|
||||
/**
|
||||
* Whether the Retreat button should be shown.
|
||||
*
|
||||
* Shown during MAIN phase when:
|
||||
* - It's your turn
|
||||
* - You have an active Pokemon
|
||||
* - You have at least one benched Pokemon to retreat to
|
||||
*/
|
||||
const showRetreatButton = computed(() => {
|
||||
if (!isMyTurn.value || currentPhase.value !== 'main') {
|
||||
return false
|
||||
}
|
||||
|
||||
const state = gameState.value
|
||||
if (!state) return false
|
||||
|
||||
// Need active Pokemon to retreat
|
||||
const myPlayer = state.players[state.viewer_id]
|
||||
if (!myPlayer) return false
|
||||
|
||||
const hasActive = myPlayer.active.count > 0
|
||||
const hasBench = myPlayer.bench.count > 0
|
||||
|
||||
return hasActive && hasBench
|
||||
})
|
||||
|
||||
/**
|
||||
* Whether the End Turn button is disabled.
|
||||
*
|
||||
* Disabled when:
|
||||
* - Not your turn
|
||||
* - Action is pending
|
||||
* - Game is over
|
||||
*/
|
||||
const isEndTurnDisabled = computed(() => {
|
||||
return !isMyTurn.value || isPending.value || gameState.value?.winner_id !== null
|
||||
})
|
||||
|
||||
/**
|
||||
* Whether the Retreat button is disabled.
|
||||
*
|
||||
* Disabled when:
|
||||
* - Not your turn
|
||||
* - Action is pending
|
||||
* - Game is over
|
||||
* - Active Pokemon is paralyzed or asleep (cannot retreat)
|
||||
*/
|
||||
const isRetreatDisabled = computed(() => {
|
||||
if (!isMyTurn.value || isPending.value || gameState.value?.winner_id !== null) {
|
||||
return true
|
||||
}
|
||||
|
||||
const state = gameState.value
|
||||
if (!state) return true
|
||||
|
||||
const myPlayer = state.players[state.viewer_id]
|
||||
if (!myPlayer) return true
|
||||
|
||||
const activePokemon = myPlayer.active.cards[0]
|
||||
if (!activePokemon) return true
|
||||
|
||||
// Check if active Pokemon has a status preventing retreat
|
||||
const statusPreventingRetreat = activePokemon.status_conditions.some(
|
||||
status => status === 'paralyzed' || status === 'asleep'
|
||||
)
|
||||
|
||||
return statusPreventingRetreat
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handle End Turn button click.
|
||||
*
|
||||
* Dispatches the end_turn action to the server.
|
||||
*/
|
||||
async function handleEndTurn(): Promise<void> {
|
||||
try {
|
||||
await endTurn()
|
||||
} catch (error) {
|
||||
console.error('Failed to end turn:', error)
|
||||
// Error notification is handled by socket handler
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Retreat button click.
|
||||
*
|
||||
* For now, this emits an event to the parent to show the retreat selection UI.
|
||||
* The parent (GamePage or similar) will handle showing the bench selection modal.
|
||||
*
|
||||
* TODO: In future tasks, this will integrate with a retreat selection modal.
|
||||
*/
|
||||
const emit = defineEmits<{
|
||||
retreatRequested: []
|
||||
}>()
|
||||
|
||||
function handleRetreat(): void {
|
||||
emit('retreatRequested')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="showEndTurnButton || showRetreatButton"
|
||||
class="phase-actions"
|
||||
data-testid="phase-actions"
|
||||
>
|
||||
<!-- Retreat Button -->
|
||||
<button
|
||||
v-if="showRetreatButton"
|
||||
:disabled="isRetreatDisabled"
|
||||
class="action-button action-button--retreat"
|
||||
data-testid="retreat-button"
|
||||
@click="handleRetreat"
|
||||
>
|
||||
<span class="action-button__icon">↩</span>
|
||||
<span class="action-button__label">Retreat</span>
|
||||
</button>
|
||||
|
||||
<!-- End Turn Button -->
|
||||
<button
|
||||
v-if="showEndTurnButton"
|
||||
:disabled="isEndTurnDisabled"
|
||||
class="action-button action-button--end-turn"
|
||||
data-testid="end-turn-button"
|
||||
@click="handleEndTurn"
|
||||
>
|
||||
<span class="action-button__icon">✓</span>
|
||||
<span class="action-button__label">End Turn</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/**
|
||||
* Container for phase action buttons.
|
||||
*
|
||||
* Positioned at bottom-right of game area by parent (GameOverlay).
|
||||
* Buttons are arranged vertically for easy thumb access on mobile.
|
||||
*/
|
||||
.phase-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base action button styles.
|
||||
*
|
||||
* Large, touch-friendly buttons with clear visual states.
|
||||
*/
|
||||
.action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: rgba(31, 41, 55, 0.9); /* bg-gray-800 with opacity */
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.action-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.action-button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.action-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Action button icon.
|
||||
*/
|
||||
.action-button__icon {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Action button label.
|
||||
*/
|
||||
.action-button__label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/**
|
||||
* End Turn button - primary action with green accent.
|
||||
*/
|
||||
.action-button--end-turn {
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.9) 0%, rgba(22, 163, 74, 0.9) 100%);
|
||||
border: 2px solid rgb(34, 197, 94); /* border-green-500 */
|
||||
}
|
||||
|
||||
.action-button--end-turn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 1) 0%, rgba(22, 163, 74, 1) 100%);
|
||||
border-color: rgb(22, 163, 74); /* border-green-600 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Retreat button - secondary action with amber accent.
|
||||
*/
|
||||
.action-button--retreat {
|
||||
background: linear-gradient(135deg, rgba(245, 158, 11, 0.9) 0%, rgba(217, 119, 6, 0.9) 100%);
|
||||
border: 2px solid rgb(245, 158, 11); /* border-amber-500 */
|
||||
}
|
||||
|
||||
.action-button--retreat:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, rgba(245, 158, 11, 1) 0%, rgba(217, 119, 6, 1) 100%);
|
||||
border-color: rgb(217, 119, 6); /* border-amber-600 */
|
||||
}
|
||||
|
||||
.action-button--retreat:disabled {
|
||||
background: linear-gradient(135deg, rgba(156, 163, 175, 0.5) 0%, rgba(107, 114, 128, 0.5) 100%);
|
||||
border-color: rgba(156, 163, 175, 0.5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile responsive styles.
|
||||
*
|
||||
* Larger buttons on mobile for easier touch interaction.
|
||||
*/
|
||||
@media (max-width: 768px) {
|
||||
.phase-actions {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.action-button__icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading state animation (when action is pending).
|
||||
*
|
||||
* Pulsing opacity to indicate the action is being processed.
|
||||
*/
|
||||
.action-button:disabled {
|
||||
animation: button-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes button-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
292
frontend/src/components/game/TurnIndicator.spec.ts
Normal file
292
frontend/src/components/game/TurnIndicator.spec.ts
Normal file
@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Tests for TurnIndicator component.
|
||||
*
|
||||
* Verifies that the turn indicator correctly displays:
|
||||
* - Current phase label
|
||||
* - Turn ownership (your turn vs opponent's turn)
|
||||
* - Turn number
|
||||
* - Appropriate styling based on turn state
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import TurnIndicator from './TurnIndicator.vue'
|
||||
import type { VisibleGameState, TurnPhase } from '@/types'
|
||||
|
||||
/**
|
||||
* Helper function to create a mock game state.
|
||||
*/
|
||||
function createMockGameState(
|
||||
phase: TurnPhase = 'main',
|
||||
turnNumber: number = 1,
|
||||
isMyTurn: boolean = true
|
||||
): VisibleGameState {
|
||||
return {
|
||||
game_id: 'test-game-123',
|
||||
viewer_id: 'player-1',
|
||||
players: {
|
||||
'player-1': {
|
||||
player_id: 'player-1',
|
||||
is_current_player: isMyTurn,
|
||||
deck_count: 40,
|
||||
hand: { count: 7, cards: [], zone_type: 'hand' },
|
||||
prizes_count: 6,
|
||||
energy_deck_count: 10,
|
||||
active: { count: 0, cards: [], zone_type: 'active' },
|
||||
bench: { count: 0, cards: [], zone_type: 'bench' },
|
||||
discard: { count: 0, cards: [], zone_type: 'discard' },
|
||||
energy_zone: { count: 0, cards: [], zone_type: 'energy_zone' },
|
||||
score: 0,
|
||||
gx_attack_used: false,
|
||||
vstar_power_used: false,
|
||||
},
|
||||
'player-2': {
|
||||
player_id: 'player-2',
|
||||
is_current_player: !isMyTurn,
|
||||
deck_count: 40,
|
||||
hand: { count: 7, cards: [], zone_type: 'hand' },
|
||||
prizes_count: 6,
|
||||
energy_deck_count: 10,
|
||||
active: { count: 0, cards: [], zone_type: 'active' },
|
||||
bench: { count: 0, cards: [], zone_type: 'bench' },
|
||||
discard: { count: 0, cards: [], zone_type: 'discard' },
|
||||
energy_zone: { count: 0, cards: [], zone_type: 'energy_zone' },
|
||||
score: 0,
|
||||
gx_attack_used: false,
|
||||
vstar_power_used: false,
|
||||
},
|
||||
},
|
||||
current_player_id: isMyTurn ? 'player-1' : 'player-2',
|
||||
turn_number: turnNumber,
|
||||
phase,
|
||||
is_my_turn: isMyTurn,
|
||||
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: {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount TurnIndicator with proper provide/inject context.
|
||||
*/
|
||||
function mountTurnIndicator(
|
||||
phase: TurnPhase = 'main',
|
||||
turnNumber: number = 1,
|
||||
isMyTurn: boolean = true
|
||||
) {
|
||||
const gameState = ref(createMockGameState(phase, turnNumber, isMyTurn))
|
||||
|
||||
return mount(TurnIndicator, {
|
||||
global: {
|
||||
provide: {
|
||||
gameState: computed(() => gameState.value),
|
||||
isMyTurn: computed(() => gameState.value.is_my_turn),
|
||||
currentPhase: computed(() => gameState.value.phase),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('TurnIndicator', () => {
|
||||
it('displays "Your Turn" when it is the player\'s turn', () => {
|
||||
/**
|
||||
* Test that the component correctly indicates when it's the player's turn.
|
||||
*
|
||||
* Players need clear visual feedback about whose turn it is to know
|
||||
* when they can take actions.
|
||||
*/
|
||||
const wrapper = mountTurnIndicator('main', 1, true)
|
||||
const turnOwner = wrapper.find('[data-testid="turn-owner"]')
|
||||
expect(turnOwner.text()).toBe('Your Turn')
|
||||
})
|
||||
|
||||
it('displays "Opponent\'s Turn" when it is the opponent\'s turn', () => {
|
||||
/**
|
||||
* Test that the component correctly indicates when it's the opponent's turn.
|
||||
*
|
||||
* Clear indication of waiting states helps prevent player confusion
|
||||
* and accidental attempts to act out of turn.
|
||||
*/
|
||||
const wrapper = mountTurnIndicator('main', 1, false)
|
||||
const turnOwner = wrapper.find('[data-testid="turn-owner"]')
|
||||
expect(turnOwner.text()).toBe("Opponent's Turn")
|
||||
})
|
||||
|
||||
it('displays the current phase label correctly', () => {
|
||||
/**
|
||||
* Test that phase labels are displayed correctly for each phase.
|
||||
*
|
||||
* Phase indicators help players understand what actions are available
|
||||
* and what's happening in the game flow.
|
||||
*/
|
||||
const phases: TurnPhase[] = ['setup', 'draw', 'main', 'attack', 'end']
|
||||
const expectedLabels = ['Setup', 'Draw', 'Main', 'Attack', 'End']
|
||||
|
||||
phases.forEach((phase, index) => {
|
||||
const wrapper = mountTurnIndicator(phase, 1, true)
|
||||
const phaseBadge = wrapper.find('[data-testid="phase-badge"]')
|
||||
expect(phaseBadge.text()).toBe(expectedLabels[index])
|
||||
})
|
||||
})
|
||||
|
||||
it('displays the current turn number', () => {
|
||||
/**
|
||||
* Test that the turn number is displayed correctly.
|
||||
*
|
||||
* Turn tracking helps players gauge game progression and is useful
|
||||
* for effects that trigger on specific turns.
|
||||
*/
|
||||
const wrapper = mountTurnIndicator('main', 5, true)
|
||||
const turnNumber = wrapper.find('[data-testid="turn-number"]')
|
||||
expect(turnNumber.text()).toBe('Turn 5')
|
||||
})
|
||||
|
||||
it('applies "my-turn" class when it is the player\'s turn', () => {
|
||||
/**
|
||||
* Test that the component applies the correct CSS class for styling.
|
||||
*
|
||||
* The my-turn class triggers accent color highlighting to draw
|
||||
* attention when the player can act.
|
||||
*/
|
||||
const wrapper = mountTurnIndicator('main', 1, true)
|
||||
const container = wrapper.find('[data-testid="turn-indicator"]')
|
||||
expect(container.classes()).toContain('turn-indicator--my-turn')
|
||||
expect(container.classes()).not.toContain('turn-indicator--opponent-turn')
|
||||
})
|
||||
|
||||
it('applies "opponent-turn" class when it is the opponent\'s turn', () => {
|
||||
/**
|
||||
* Test that the component applies the correct CSS class for opponent turn.
|
||||
*
|
||||
* The opponent-turn class triggers muted styling to indicate
|
||||
* a waiting state.
|
||||
*/
|
||||
const wrapper = mountTurnIndicator('main', 1, false)
|
||||
const container = wrapper.find('[data-testid="turn-indicator"]')
|
||||
expect(container.classes()).toContain('turn-indicator--opponent-turn')
|
||||
expect(container.classes()).not.toContain('turn-indicator--my-turn')
|
||||
})
|
||||
|
||||
it('applies phase-specific badge class for each phase', () => {
|
||||
/**
|
||||
* Test that phase badges receive phase-specific CSS classes.
|
||||
*
|
||||
* Phase-specific styling (colors) helps players quickly identify
|
||||
* the current phase at a glance.
|
||||
*/
|
||||
const phases: TurnPhase[] = ['setup', 'draw', 'main', 'attack', 'end']
|
||||
|
||||
phases.forEach((phase) => {
|
||||
const wrapper = mountTurnIndicator(phase, 1, true)
|
||||
const phaseBadge = wrapper.find('[data-testid="phase-badge"]')
|
||||
expect(phaseBadge.classes()).toContain(`phase-badge--${phase}`)
|
||||
})
|
||||
})
|
||||
|
||||
it('handles turn 0 correctly', () => {
|
||||
/**
|
||||
* Test edge case: turn 0 (game setup).
|
||||
*
|
||||
* Ensures the component doesn't crash or display incorrectly
|
||||
* during initial game setup before turn 1.
|
||||
*/
|
||||
const wrapper = mountTurnIndicator('setup', 0, true)
|
||||
const turnNumber = wrapper.find('[data-testid="turn-number"]')
|
||||
expect(turnNumber.text()).toBe('Turn 0')
|
||||
})
|
||||
|
||||
it('handles high turn numbers correctly', () => {
|
||||
/**
|
||||
* Test that high turn numbers display without issues.
|
||||
*
|
||||
* Long games should continue to display turn information correctly
|
||||
* without layout breaks or overflow.
|
||||
*/
|
||||
const wrapper = mountTurnIndicator('main', 99, true)
|
||||
const turnNumber = wrapper.find('[data-testid="turn-number"]')
|
||||
expect(turnNumber.text()).toBe('Turn 99')
|
||||
})
|
||||
|
||||
it('throws error when used outside GameOverlay (missing inject)', () => {
|
||||
/**
|
||||
* Test that the component fails fast with clear error when missing context.
|
||||
*
|
||||
* This component requires GameOverlay's provide/inject context.
|
||||
* Failing fast helps developers catch integration errors early.
|
||||
*/
|
||||
expect(() => {
|
||||
mount(TurnIndicator)
|
||||
}).toThrow('TurnIndicator must be used within GameOverlay')
|
||||
})
|
||||
|
||||
it('updates display when phase changes', async () => {
|
||||
/**
|
||||
* Test that the component reactively updates when phase changes.
|
||||
*
|
||||
* During gameplay, phases transition frequently. The component must
|
||||
* react to these changes to keep players informed.
|
||||
*/
|
||||
const gameState = ref(createMockGameState('draw', 1, true))
|
||||
|
||||
const wrapper = mount(TurnIndicator, {
|
||||
global: {
|
||||
provide: {
|
||||
gameState: computed(() => gameState.value),
|
||||
isMyTurn: computed(() => gameState.value.is_my_turn),
|
||||
currentPhase: computed(() => gameState.value.phase),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Initial state
|
||||
let phaseBadge = wrapper.find('[data-testid="phase-badge"]')
|
||||
expect(phaseBadge.text()).toBe('Draw')
|
||||
|
||||
// Update to main phase
|
||||
gameState.value = createMockGameState('main', 1, true)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
phaseBadge = wrapper.find('[data-testid="phase-badge"]')
|
||||
expect(phaseBadge.text()).toBe('Main')
|
||||
})
|
||||
|
||||
it('updates styling when turn ownership changes', async () => {
|
||||
/**
|
||||
* Test that styling updates when turn changes between players.
|
||||
*
|
||||
* Visual feedback must update immediately when turn transitions
|
||||
* to keep players aware of current game state.
|
||||
*/
|
||||
const gameState = ref(createMockGameState('main', 1, true))
|
||||
|
||||
const wrapper = mount(TurnIndicator, {
|
||||
global: {
|
||||
provide: {
|
||||
gameState: computed(() => gameState.value),
|
||||
isMyTurn: computed(() => gameState.value.is_my_turn),
|
||||
currentPhase: computed(() => gameState.value.phase),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Initial: my turn
|
||||
let container = wrapper.find('[data-testid="turn-indicator"]')
|
||||
expect(container.classes()).toContain('turn-indicator--my-turn')
|
||||
|
||||
// Change to opponent's turn
|
||||
gameState.value = createMockGameState('main', 2, false)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
container = wrapper.find('[data-testid="turn-indicator"]')
|
||||
expect(container.classes()).toContain('turn-indicator--opponent-turn')
|
||||
expect(container.classes()).not.toContain('turn-indicator--my-turn')
|
||||
|
||||
const turnOwner = wrapper.find('[data-testid="turn-owner"]')
|
||||
expect(turnOwner.text()).toBe("Opponent's Turn")
|
||||
})
|
||||
})
|
||||
265
frontend/src/components/game/TurnIndicator.vue
Normal file
265
frontend/src/components/game/TurnIndicator.vue
Normal file
@ -0,0 +1,265 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Turn indicator component - displays current turn phase and active player.
|
||||
*
|
||||
* This component shows:
|
||||
* - Whose turn it is (You / Opponent)
|
||||
* - Current turn phase (DRAW, MAIN, ATTACK, END)
|
||||
* - Turn number (optional)
|
||||
*
|
||||
* Visual design:
|
||||
* - Positioned at top-center of game area via GameOverlay
|
||||
* - Semi-transparent background for visibility over game canvas
|
||||
* - Accent color highlight when it's your turn
|
||||
* - Muted colors when waiting for opponent
|
||||
* - Compact on mobile, expanded on desktop
|
||||
* - Smooth transitions between phases
|
||||
*/
|
||||
import { computed, inject, type ComputedRef } from 'vue'
|
||||
import type { VisibleGameState, TurnPhase } from '@/types'
|
||||
|
||||
// Inject game state from GameOverlay parent
|
||||
const gameState = inject<ComputedRef<VisibleGameState | null>>('gameState')
|
||||
const isMyTurn = inject<ComputedRef<boolean>>('isMyTurn')
|
||||
const currentPhase = inject<ComputedRef<TurnPhase | null>>('currentPhase')
|
||||
|
||||
// Guard against missing injections (should never happen in production)
|
||||
if (!gameState || !isMyTurn || !currentPhase) {
|
||||
throw new Error('TurnIndicator must be used within GameOverlay')
|
||||
}
|
||||
|
||||
// Phase display labels
|
||||
const phaseLabels: Record<TurnPhase, string> = {
|
||||
setup: 'Setup',
|
||||
draw: 'Draw',
|
||||
main: 'Main',
|
||||
attack: 'Attack',
|
||||
end: 'End',
|
||||
}
|
||||
|
||||
// Get display label for current phase
|
||||
const phaseLabel = computed(() => {
|
||||
const phase = currentPhase.value
|
||||
return phase ? phaseLabels[phase] : '—'
|
||||
})
|
||||
|
||||
// Turn display text
|
||||
const turnText = computed(() => {
|
||||
return isMyTurn.value ? 'Your Turn' : "Opponent's Turn"
|
||||
})
|
||||
|
||||
// Turn number display
|
||||
const turnNumber = computed(() => {
|
||||
return gameState.value?.turn_number ?? 0
|
||||
})
|
||||
|
||||
// CSS classes for styling based on turn state
|
||||
const containerClasses = computed(() => ({
|
||||
'turn-indicator': true,
|
||||
'turn-indicator--my-turn': isMyTurn.value,
|
||||
'turn-indicator--opponent-turn': !isMyTurn.value,
|
||||
}))
|
||||
|
||||
// Phase badge classes
|
||||
const phaseBadgeClasses = computed(() => ({
|
||||
'phase-badge': true,
|
||||
[`phase-badge--${currentPhase.value}`]: true,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="containerClasses"
|
||||
data-testid="turn-indicator"
|
||||
>
|
||||
<!-- Turn ownership indicator -->
|
||||
<div
|
||||
class="turn-owner"
|
||||
data-testid="turn-owner"
|
||||
>
|
||||
{{ turnText }}
|
||||
</div>
|
||||
|
||||
<!-- Phase indicator -->
|
||||
<div
|
||||
:class="phaseBadgeClasses"
|
||||
data-testid="phase-badge"
|
||||
>
|
||||
{{ phaseLabel }}
|
||||
</div>
|
||||
|
||||
<!-- Turn number (optional, shown on desktop) -->
|
||||
<div
|
||||
class="turn-number"
|
||||
data-testid="turn-number"
|
||||
>
|
||||
Turn {{ turnNumber }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/**
|
||||
* Main container for turn indicator.
|
||||
*
|
||||
* Semi-transparent background with rounded corners.
|
||||
* Visual state changes based on whose turn it is.
|
||||
*/
|
||||
.turn-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(31, 41, 55, 0.9); /* bg-gray-800 with opacity */
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/**
|
||||
* Your turn state - highlighted with accent color.
|
||||
*/
|
||||
.turn-indicator--my-turn {
|
||||
border: 2px solid rgb(59, 130, 246); /* border-blue-500 */
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opponent's turn state - muted colors.
|
||||
*/
|
||||
.turn-indicator--opponent-turn {
|
||||
border: 2px solid rgba(107, 114, 128, 0.5); /* border-gray-500 with opacity */
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn owner text (Your Turn / Opponent's Turn).
|
||||
*/
|
||||
.turn-owner {
|
||||
font-size: 0.875rem; /* text-sm */
|
||||
font-weight: 600; /* font-semibold */
|
||||
color: rgb(249, 250, 251); /* text-gray-50 */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.turn-indicator--my-turn .turn-owner {
|
||||
color: rgb(96, 165, 250); /* text-blue-400 */
|
||||
font-weight: 700; /* font-bold */
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase badge - shows current phase as a pill.
|
||||
*/
|
||||
.phase-badge {
|
||||
padding: 0.25rem 0.625rem;
|
||||
font-size: 0.75rem; /* text-xs */
|
||||
font-weight: 600; /* font-semibold */
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em; /* tracking-wide */
|
||||
border-radius: 9999px; /* rounded-full */
|
||||
background: rgba(107, 114, 128, 0.3); /* bg-gray-500 with opacity */
|
||||
color: rgb(229, 231, 235); /* text-gray-200 */
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Phase-specific colors */
|
||||
.phase-badge--setup {
|
||||
background: rgba(251, 191, 36, 0.3); /* amber */
|
||||
color: rgb(253, 224, 71); /* text-yellow-300 */
|
||||
}
|
||||
|
||||
.phase-badge--draw {
|
||||
background: rgba(59, 130, 246, 0.3); /* blue */
|
||||
color: rgb(147, 197, 253); /* text-blue-300 */
|
||||
}
|
||||
|
||||
.phase-badge--main {
|
||||
background: rgba(34, 197, 94, 0.3); /* green */
|
||||
color: rgb(134, 239, 172); /* text-green-300 */
|
||||
}
|
||||
|
||||
.phase-badge--attack {
|
||||
background: rgba(239, 68, 68, 0.3); /* red */
|
||||
color: rgb(252, 165, 165); /* text-red-300 */
|
||||
}
|
||||
|
||||
.phase-badge--end {
|
||||
background: rgba(156, 163, 175, 0.3); /* gray */
|
||||
color: rgb(209, 213, 219); /* text-gray-300 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn number display.
|
||||
*/
|
||||
.turn-number {
|
||||
font-size: 0.75rem; /* text-xs */
|
||||
color: rgb(156, 163, 175); /* text-gray-400 */
|
||||
font-weight: 500; /* font-medium */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile responsive styles.
|
||||
*
|
||||
* Compact layout on small screens:
|
||||
* - Hide turn number
|
||||
* - Smaller padding and gaps
|
||||
* - Smaller font sizes
|
||||
*/
|
||||
@media (max-width: 768px) {
|
||||
.turn-indicator {
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.turn-owner {
|
||||
font-size: 0.75rem; /* text-xs */
|
||||
}
|
||||
|
||||
.phase-badge {
|
||||
font-size: 0.625rem; /* smaller */
|
||||
padding: 0.125rem 0.5rem;
|
||||
}
|
||||
|
||||
/* Hide turn number on mobile to save space */
|
||||
.turn-number {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Animation for phase transitions.
|
||||
*/
|
||||
.phase-badge {
|
||||
animation: phase-enter 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes phase-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulse animation when it becomes your turn.
|
||||
*/
|
||||
.turn-indicator--my-turn {
|
||||
animation: turn-pulse 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes turn-pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user