mantimon-tcg/frontend/src/components/game/AttackMenu.vue
Cal Corum 0d416028c0
Fix prize zone rendering in Mantimon TCG mode (#2)
* Fix hand card rotation direction

Cards now fan outward correctly instead of curling inward

* Update StateRenderer to require MatchScene type for type safety

- Change constructor parameter from Phaser.Scene to MatchScene
- Update scene property type to MatchScene
- Add import for MatchScene type
- Update JSDoc example to reflect type-safe constructor

* Defer Board creation to StateRenderer for correct rules config

- Make board property nullable (Board | null instead of Board?)
- Remove Board and createBoard imports (now handled by StateRenderer)
- Update setupBoard() to skip Board creation
- Add setBoard() method for StateRenderer to call
- Update clearBoard() to use null instead of undefined
- Add JSDoc explaining why Board creation is deferred

* Create Board in StateRenderer with correct layout options

- Add Board and createBoard imports
- Add board property to StateRenderer
- Create Board in render() on first call with correct rules_config
- Add debug logging for Board creation and zone creation
- Update clear() to destroy Board when clearing
- Board now created after we have rules_config from first state

* Add fatal error handling with toast notification and auto-redirect

- Add 'fatal-error' event to GameBridgeEvents type
- Import and initialize useToast in GamePage
- Listen for 'fatal-error' event from Phaser
- Show error toast that persists until redirect
- Show full-screen fatal error overlay with countdown
- Auto-redirect to /play after 3 seconds
- Update StateRenderer to emit 'fatal-error' when Board creation fails

* Gate debug logging with DEV flag

- Add DEBUG_RENDERER constant gated by import.meta.env.DEV
- Update all console.log statements in StateRenderer to only log in development
- Keep console.error and console.warn as they are (always show errors)
- Debug logs now only appear during development, not in production

* Fix code audit issues - add missing imports and improve error UX

Critical fixes:
- Add missing gameBridge import to StateRenderer (fixes runtime error in fatal error handler)
- Add missing Board type import to MatchScene (fixes TypeScript compilation error)

UX improvements:
- Replace fatal error auto-redirect with manual 'Return to Menu' button
- Add toast notification when resignation fails
- Give users unlimited time to read fatal errors before returning

Addresses issues found in frontend code audit:
- errors.missing-import (StateRenderer.ts:166)
- errors.missing-type-import (MatchScene.ts:84)
- errors.catch-only-console (GamePage.vue:145)
- architecture.missing-fatal-error-handling (GamePage.vue:261)

* Add CONTRIBUTING policy and fix pre-existing lint/test errors

- Add CONTRIBUTING.md with strict policy: never use --no-verify without approval
- Add comprehensive testing documentation (TESTING.md, VISUAL-TEST-GUIDE.md)
- Add test-prize-fix.md quick test checklist and verify-fix.sh script

Lint fixes (enables pre-commit hooks):
- Remove unused imports in 9 files
- Fix unused variables (underscore convention)
- Replace 'as any' type assertions with proper VisibleGameState types
- Add missing CARD_WIDTH_MEDIUM import in layout.spec.ts
- All ESLint errors now resolved (only acceptable warnings remain)

Test fixes (all 1000 tests now passing):
- Fix layout.spec.ts: Add missing CARD_WIDTH_MEDIUM import
- Fix PlayPage.spec.ts: Update test to use actual hardcoded UUIDs
- Fix useAuth.spec.ts: Mock API profile fetch in initialization tests
- Fix PhaserGame.spec.ts: Add scenes export to mock and update createGame call expectations

This ensures pre-commit hooks work properly going forward and prevents
bypassing TypeScript/lint checks that catch errors early.

* Add comprehensive test coverage improvement plan

- Create PROJECT_PLAN_TEST_COVERAGE.json with 25 structured tasks
- Create TEST_COVERAGE_PLAN.md with executive summary and roadmap
- Plan addresses critical gaps: game engine (0%), WebSocket (27%)
- 6-week roadmap to reach 85% coverage from current 63%
- Target: Phase 1 (weeks 1-3) - critical game engine and network tests
- Includes quick wins, production blockers, and success metrics

Based on coverage analysis showing:
- Strong: Composables (84%), Components (90%), Stores (88%)
- Critical gaps: Phaser game engine (~5,500 untested lines)
- High priority: WebSocket/multiplayer reliability

See TEST_COVERAGE_PLAN.md for overview and week-by-week breakdown.

* Add coverage tooling and ignore coverage directory

- Add @vitest/coverage-v8 package for coverage analysis
- Add coverage/ directory to .gitignore
- Used during test coverage analysis for PROJECT_PLAN_TEST_COVERAGE.json
2026-02-02 15:30:27 -06:00

712 lines
17 KiB
Vue

<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
}
// Props are used in template via direct reference (show, not props.show)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
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>