From 088cb8f7aad1c44bb44b46d69dcf2bf3f1b1c190 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 7 Jul 2025 15:58:05 +0000 Subject: [PATCH 1/2] Implement basic card-based interception combat system - Defines BattleState and integrates it into GameState. - Adds InterceptConfirmationModal to initiate battles from WorldMap UFO clicks. - Implements BattleScreen UI with displays for vehicle/UFO status, player hand, and battle controls. - Implements card mechanics: deck setup, shuffling, drawing (including reshuffling discard), playing cards with energy costs. - Basic damage card effects and win/loss conditions are in place. - Rudimentary turn loop: player turn (play cards, end turn) -> basic UFO attack -> player draws card and refreshes energy. - Adds unit tests for card utility functions (shuffle, draw). --- src/App.tsx | 201 ++++++++- src/components/BattleScreen.tsx | 150 +++++++ src/components/InterceptConfirmationModal.tsx | 91 ++++ src/data/__tests__/cards.test.ts | 149 +++++++ src/data/cards.ts | 393 +++++++++++------- src/types.ts | 3 + 6 files changed, 830 insertions(+), 157 deletions(-) create mode 100644 src/components/BattleScreen.tsx create mode 100644 src/components/InterceptConfirmationModal.tsx create mode 100644 src/data/__tests__/cards.test.ts diff --git a/src/App.tsx b/src/App.tsx index 974765c..6093f8b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,9 +12,12 @@ import EventModal from './components/EventModal'; import YearlyReviewModal from './components/YearlyReviewModal'; import HangarModal from './components/HangarModal'; import ConfirmationDialog from './components/ConfirmationDialog'; -import { GameState, Base, Personnel, ResearchProject, Transaction, UFO, ContinentSelection } from './types'; +import InterceptConfirmationModal from './components/InterceptConfirmationModal'; +import BattleScreen from './components/BattleScreen'; // Import the new BattleScreen component +import { GameState, Base, Personnel, ResearchProject, Transaction, UFO, ContinentSelection, Vehicle, BattleState, BattleCard } from './types'; import { FACILITY_TYPES, upgradeFacility } from './data/facilities'; import { BASE_SALARIES } from './data/personnel'; +import { getStarterDeck, shuffleDeck, drawCards as drawCardsUtil } from './data/cards'; // Card utilities import { MONTHLY_EVENTS, GameEvent, evaluateYearlyPerformance, YearlyReview } from './data/events'; import { hasCapacityForNewFacility, canAssignPersonnelToFacility } from './data/basePersonnel'; import { doesTrajectoryIntersectRadar } from './utils/trajectory'; @@ -56,10 +59,13 @@ function App() { } ] }, - showRadarCoverage: false + showRadarCoverage: false, + activeBattle: null, + selectedVehicleForBattle: null, + selectedUFOForBattle: null, }); - type ModalType = 'intro' | 'base' | 'personnel' | 'research' | 'financial' | 'event' | 'yearlyReview' | 'hangar' | null; + type ModalType = 'intro' | 'base' | 'personnel' | 'research' | 'financial' | 'event' | 'yearlyReview' | 'hangar' | 'interceptConfirmation' | null; const [activeModal, setActiveModal] = useState('intro'); const [selectedBase, setSelectedBase] = useState(null); const [selectedContinent, setSelectedContinent] = useState(null); @@ -67,6 +73,7 @@ function App() { const [yearlyReview, setYearlyReview] = useState(null); const [selectedHangarBase, setSelectedHangarBase] = useState(null); const [researchNotification, setResearchNotification] = useState(null); // State for research start notification + const [targetUFO, setTargetUFO] = useState(null); // UFO targeted for interception // State to track user actions during a turn const [actionsPerformed, setActionsPerformed] = useState(false); @@ -78,6 +85,160 @@ function App() { // Track transactions in the current turn const [currentTurnTransactions, setCurrentTurnTransactions] = useState(0); + + const handleInitiateBattle = useCallback((vehicle: Vehicle, ufo: UFO) => { + console.log(`Initiating battle between ${vehicle.name} and ${ufo.name}`); + + const initialDeck = shuffleDeck(getStarterDeck()); + const initialHandData = drawCardsUtil(initialDeck, [], [], 5); // Draw 5 cards initially + + const initialBattleState: BattleState = { + id: crypto.randomUUID(), + stage: 'engagement', // Start directly in engagement for now + turn: 1, + initiative: { current: vehicle.id, order: [vehicle.id, ufo.id] }, // Placeholder + playerEnergy: vehicle.battleStats?.maxEnergy || 3, // Default to 3 energy + enemyEnergy: ufo.battleStats?.maxEnergy || 3, // Placeholder for UFO energy + playerHand: initialHandData.newHand, + playerDeck: initialHandData.newDeck, + playerDiscard: initialHandData.newDiscardPile, + activeEffects: [], + vehicleStatus: vehicle.battleStats || { // Use existing or provide default + maxHealth: 100, currentHealth: 100, energyPerTurn: 3, maxEnergy: 3, currentEnergy: 3, + accuracy: 75, evasion: 10, criticalChance: 5, cardSlots: 5, equipmentSlots: 2 + }, + ufoStatus: ufo.battleStats || { // Use existing or provide default + maxHealth: 50, currentHealth: 50, energyPerTurn: 3, maxEnergy: 3, currentEnergy: 3, + accuracy: 60, evasion: 5, criticalChance: 5, behaviorDeck: [], threatLevel: ufo.stealthRating // Placeholder + }, + stageObjectives: [], + environmentalConditions: [], + battleLog: [], + }; + + setGameState(prev => ({ + ...prev, + activeBattle: initialBattleState, + selectedVehicleForBattle: vehicle, + selectedUFOForBattle: ufo, + })); + // Typically, you'd hide other modals or UI elements here + setActiveModal(null); + }, []); + + const handlePlayCard = useCallback((cardId: string) => { + setGameState(prev => { + if (!prev.activeBattle) return prev; + + const card = prev.activeBattle.playerHand.find(c => c.id === cardId); + if (!card) { + console.error("Card not found in hand:", cardId); + return prev; + } + + if (prev.activeBattle.playerEnergy < card.cost) { + alert("Not enough energy to play this card!"); + // console.warn("Not enough energy for card:", card.name); + return prev; + } + + console.log("Playing card:", card.name); + // TODO: Implement card effects + // For now, just remove from hand, add to discard, and deduct energy + + const newHand = prev.activeBattle.playerHand.filter(c => c.id !== cardId); + const newDiscard = [...prev.activeBattle.playerDiscard, card]; + const newPlayerEnergy = prev.activeBattle.playerEnergy - card.cost; + + // Example: Simple damage effect for first card in effects array + let newUFOStatus = { ...prev.activeBattle.ufoStatus }; + if (card.effects[0]?.type === 'damage' && typeof card.effects[0].value === 'string') { + // Basic damage range parsing (e.g., "25-35") + const parts = card.effects[0].value.split('-').map(Number); + const damage = parts.length === 2 ? Math.floor(Math.random() * (parts[1] - parts[0] + 1)) + parts[0] : parseInt(card.effects[0].value, 10) || 0; + newUFOStatus.currentHealth = Math.max(0, newUFOStatus.currentHealth - damage); + console.log(`${card.name} dealt ${damage} damage. UFO health: ${newUFOStatus.currentHealth}`); + } else if (card.effects[0]?.type === 'damage' && typeof card.effects[0].value === 'number') { + const damage = card.effects[0].value; + newUFOStatus.currentHealth = Math.max(0, newUFOStatus.currentHealth - damage); + console.log(`${card.name} dealt ${damage} damage. UFO health: ${newUFOStatus.currentHealth}`); + } + + + // Check for battle end + if (newUFOStatus.currentHealth <= 0) { + console.log("UFO Destroyed! Player wins!"); + alert("UFO Destroyed! Player wins!"); + // TODO: Handle victory rewards, cleanup, etc. + return { ...prev, activeBattle: null, selectedUFOForBattle: null, selectedVehicleForBattle: null }; + } + + return { + ...prev, + activeBattle: { + ...prev.activeBattle, + playerHand: newHand, + playerDiscard: newDiscard, + playerEnergy: newPlayerEnergy, + ufoStatus: newUFOStatus, + // battleLog: [...prev.activeBattle.battleLog, { turn: prev.activeBattle.turn, stage: prev.activeBattle.stage, actorId: 'player', actionType: 'card', description: `Played ${card.name}`, timestamp: new Date() }] + } + }; + }); + }, []); + + const handleEndPlayerTurn = useCallback(() => { + setGameState(prev => { + if (!prev.activeBattle) return prev; + console.log("Player ends turn."); + // TODO: Implement UFO turn logic here + // For now, UFO does nothing, player gets energy and draws a card. + + let newPlayerDeck = [...prev.activeBattle.playerDeck]; + let newPlayerHand = [...prev.activeBattle.playerHand]; + let newPlayerDiscard = [...prev.activeBattle.playerDiscard]; + + // Draw 1 card + const drawResult = drawCardsUtil(newPlayerDeck, newPlayerHand, newPlayerDiscard, 1); + newPlayerDeck = drawResult.newDeck; + newPlayerHand = drawResult.newHand; + newPlayerDiscard = drawResult.newDiscardPile; + + // Replenish player energy (example: vehicle's energyPerTurn) + const playerMaxEnergy = prev.activeBattle.vehicleStatus.maxEnergy || 3; + + + // Placeholder UFO turn - simple attack + let newVehicleStatus = { ...prev.activeBattle.vehicleStatus }; + const ufoAttackDamage = 10; // Placeholder + newVehicleStatus.currentHealth = Math.max(0, newVehicleStatus.currentHealth - ufoAttackDamage); + console.log(`UFO attacks for ${ufoAttackDamage} damage! Player health: ${newVehicleStatus.currentHealth}`); + + if (newVehicleStatus.currentHealth <= 0) { + console.log("Player Vehicle Destroyed! UFO wins!"); + alert("Player Vehicle Destroyed! UFO wins!"); + // TODO: Handle defeat, cleanup, etc. + return { ...prev, activeBattle: null, selectedUFOForBattle: null, selectedVehicleForBattle: null }; + } + + + return { + ...prev, + activeBattle: { + ...prev.activeBattle, + turn: prev.activeBattle.turn + 1, + playerEnergy: playerMaxEnergy, // Reset to max energy for simplicity + playerHand: newPlayerHand, + playerDeck: newPlayerDeck, + playerDiscard: newPlayerDiscard, + vehicleStatus: newVehicleStatus, + // battleLog: [...prev.activeBattle.battleLog, { turn: prev.activeBattle.turn, stage: prev.activeBattle.stage, actorId: 'player', actionType: 'status', description: `Ended turn.`, timestamp: new Date() }] + } + }; + }); + }, []); + + // Reset action tracking when a new turn starts const resetActionTracking = useCallback(() => { setActionsPerformed(false); @@ -777,7 +938,11 @@ function App() { showRadarCoverage={gameState.showRadarCoverage} activeUFOs={gameState.activeUFOs} detectedUFOs={gameState.detectedUFOs} - onUFOClick={() => {}} // We'll implement this later + onUFOClick={(ufo) => { + if (gameState.activeBattle) return; // Don't allow new interception if battle is active + setTargetUFO(ufo); + setActiveModal('interceptConfirmation'); + }} showAllUFOTrajectories={gameState.showAllUFOTrajectories} completedResearch={gameState.completedResearch} /> @@ -883,6 +1048,25 @@ function App() { cancelText="Go Back" /> + {activeModal === 'interceptConfirmation' && targetUFO && ( + { + setActiveModal(null); + setTargetUFO(null); + }} + onConfirm={(selectedVehicle) => { + if (targetUFO) { + handleInitiateBattle(selectedVehicle, targetUFO); + } + setActiveModal(null); + setTargetUFO(null); + }} + ufo={targetUFO} + availableVehicles={gameState.bases.flatMap(b => b.vehicles)} // Provide all vehicles from all bases + /> + )} + {/* Add debug panel */} + + {/* Render BattleScreen if a battle is active */} + {gameState.activeBattle && ( + + )} ); } diff --git a/src/components/BattleScreen.tsx b/src/components/BattleScreen.tsx new file mode 100644 index 0000000..dafb0cc --- /dev/null +++ b/src/components/BattleScreen.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import { GameState, BattleState, VehicleBattleStats, UFOBattleStats, BattleCard } from '../types'; // Assuming these types exist + +interface BattleScreenProps { + gameState: GameState; + onPlayCard: (cardId: string) => void; + onEndTurn: () => void; + // onRetreat: () => void; // Retreat can be added later +} + +// Placeholder components for different parts of the battle screen +// VehicleStatusDisplay and UFOStatusDisplay remain the same for now + +const VehicleStatusDisplay: React.FC<{ vehicleStats: VehicleBattleStats | undefined }> = ({ vehicleStats }) => { + if (!vehicleStats) return
Vehicle Battle Stats Missing!
; + return ( +
+

Player Vehicle

+

Health: {vehicleStats.currentHealth} / {vehicleStats.maxHealth}

+

Energy: {vehicleStats.currentEnergy} / {vehicleStats.maxEnergy}

+ {/* Add more stats as needed: accuracy, evasion, etc. */} +
+ ); +}; + +const UFOStatusDisplay: React.FC<{ ufoStats: UFOBattleStats | undefined, ufoName: string | undefined }> = ({ ufoStats, ufoName }) => { + if (!ufoStats) return
UFO Battle Stats Missing!
; + return ( +
+

Target UFO: {ufoName || 'Unknown'}

+

Health: {ufoStats.currentHealth} / {ufoStats.maxHealth}

+

Energy: {ufoStats.currentEnergy} / {ufoStats.maxEnergy}

+ {/* Add more stats as needed: accuracy, evasion, threat etc. */} +
+ ); +}; + +const PlayerHandDisplay: React.FC<{ hand: BattleCard[]; onPlayCard: (cardId: string) => void; playerEnergy: number }> = ({ hand, onPlayCard, playerEnergy }) => { + if (!hand.length) { + return
Hand is empty.
; + } + return ( +
+

Player Hand

+
+ {hand.map(card => { + const canAfford = playerEnergy >= card.cost; + return ( +
canAfford && onPlayCard(card.id)} + className={`bg-slate-600 p-3 rounded shadow-lg w-32 h-48 border border-slate-500 transition-all transform hover:scale-105 + ${canAfford ? 'cursor-pointer hover:border-blue-500' : 'cursor-not-allowed opacity-60'}`} + > +

{card.name}

+

{card.description}

+

Cost: {card.cost}

+
+ ); + })} +
+
+ ); +}; + +const BattleControls: React.FC<{ + currentTurn: number; + playerEnergy: number; + onEndTurn: () => void; + // onRetreat: () => void +}> = ({ currentTurn, playerEnergy, onEndTurn }) => { + return ( +
+
+

Turn: {currentTurn}

+

Player Energy: {playerEnergy}

+
+
+ + +
+
+ ); +}; + + +const BattleScreen: React.FC = ({ gameState, onPlayCard, onEndTurn }) => { + const battleState = gameState.activeBattle; + + if (!battleState) { + // This case should ideally not be reached if BattleScreen is only rendered when a battle is active. + // However, it's a good fallback. + return ( +
+

Error: Battle State is not active.

+
+ ); + } + + const { vehicleStatus, ufoStatus, playerHand, turn, playerEnergy } = battleState; + const vehicleName = gameState.selectedVehicleForBattle?.name; + const ufoName = gameState.selectedUFOForBattle?.name; + + return ( +
+
+

UFO Interception In Progress!

+ +
+ + +
+ + {/* Placeholder for where cards would be played or visual effects shown */} +
+

Battle Action Area

+
+ + + + + + {/* Battle Log could go here */} + {/*
+

Battle Log:

+ {battleState.battleLog.map(entry => ( +

{entry.description}

+ ))} +
*/} +
+
+ ); +}; + +export default BattleScreen; diff --git a/src/components/InterceptConfirmationModal.tsx b/src/components/InterceptConfirmationModal.tsx new file mode 100644 index 0000000..1d0674c --- /dev/null +++ b/src/components/InterceptConfirmationModal.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { UFO, Vehicle } from '../types'; // Ensure Vehicle type is imported + +interface InterceptConfirmationModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (vehicle: Vehicle) => void; // Expect a vehicle to be passed on confirm + ufo: UFO | null; + availableVehicles: Vehicle[]; // Pass available vehicles to the modal +} + +const InterceptConfirmationModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + ufo, + availableVehicles, +}) => { + const [selectedVehicleId, setSelectedVehicleId] = React.useState(null); + + if (!isOpen || !ufo) return null; + + const handleConfirm = () => { + const vehicleToIntercept = availableVehicles.find(v => v.id === selectedVehicleId); + if (vehicleToIntercept) { + onConfirm(vehicleToIntercept); + } else { + alert("Please select a vehicle to intercept."); + } + }; + + // Filter for vehicles that are 'ready' and 'interceptor' type + const readyInterceptors = availableVehicles.filter( + v => v.status === 'ready' && v.type === 'interceptor' + ); + + return ( +
+
+

Confirm Interception

+

+ UFO Detected: {ufo.name} (Type: {ufo.type}) +

+

+ Location: ({ufo.location.x.toFixed(0)}, {ufo.location.y.toFixed(0)}) +

+ + {readyInterceptors.length > 0 ? ( +
+ + +
+ ) : ( +

No 'ready' interceptors available.

+ )} + +
+ + +
+
+
+ ); +}; + +export default InterceptConfirmationModal; diff --git a/src/data/__tests__/cards.test.ts b/src/data/__tests__/cards.test.ts new file mode 100644 index 0000000..d835f1f --- /dev/null +++ b/src/data/__tests__/cards.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { BattleCard, CardType, CardRarity, CardEffect } from '../../types'; // Adjusted path +import { shuffleDeck, drawCards, getStarterDeck, ALL_CARDS } from '../cards'; // Adjusted path + +// Minimal mock cards for testing utilities +const mockCard = (id: string, name: string, cost: number = 1): BattleCard => ({ + id, + name, + description: `Description for ${name}`, + type: 'action' as CardType, + cost, + effects: [], + rarity: 'common' as CardRarity, +}); + +const card1 = mockCard('c1', 'Card 1'); +const card2 = mockCard('c2', 'Card 2'); +const card3 = mockCard('c3', 'Card 3'); +const card4 = mockCard('c4', 'Card 4'); +const card5 = mockCard('c5', 'Card 5'); + +describe('card utilities', () => { + describe('shuffleDeck', () => { + it('should contain the same cards after shuffling', () => { + const deck = [card1, card2, card3, card4, card5]; + const shuffled = shuffleDeck([...deck]); // Use spread to avoid modifying original + expect(shuffled).toHaveLength(deck.length); + deck.forEach(card => { + expect(shuffled).toContainEqual(card); + }); + }); + + it('should produce a different order (most of the time)', () => { + const deck = [card1, card2, card3, card4, card5]; + // Run a few times to increase chance of different order if shuffling works + let differentOrderFound = false; + for (let i = 0; i < 10; i++) { + const shuffled = shuffleDeck([...deck]); + if (shuffled.map(c => c.id).join('') !== deck.map(c => c.id).join('')) { + differentOrderFound = true; + break; + } + } + expect(differentOrderFound).toBe(true); + }); + + it('should handle an empty deck', () => { + const deck: BattleCard[] = []; + const shuffled = shuffleDeck([...deck]); + expect(shuffled).toHaveLength(0); + }); + }); + + describe('drawCards', () => { + let deck: BattleCard[]; + let hand: BattleCard[]; + let discardPile: BattleCard[]; + + beforeEach(() => { + deck = [card1, card2, card3, card4, card5]; + hand = []; + discardPile = []; + }); + + it('should draw the specified number of cards', () => { + const result = drawCards(deck, hand, discardPile, 3); + expect(result.newHand).toHaveLength(3); + expect(result.newHand).toEqual([card1, card2, card3]); + expect(result.newDeck).toHaveLength(2); + expect(result.newDeck).toEqual([card4, card5]); + expect(result.newDiscardPile).toHaveLength(0); + }); + + it('should draw all remaining cards if count is greater than deck size', () => { + const result = drawCards(deck, hand, discardPile, 10); + expect(result.newHand).toHaveLength(5); + expect(result.newDeck).toHaveLength(0); + }); + + it('should return an empty hand if drawing from an empty deck and empty discard', () => { + const emptyDeck: BattleCard[] = []; + const result = drawCards(emptyDeck, hand, discardPile, 3); + expect(result.newHand).toHaveLength(0); + expect(result.newDeck).toHaveLength(0); + }); + + it('should reshuffle discard pile into deck if deck is empty', () => { + const initialDeck: BattleCard[] = [card1]; + const initialDiscard: BattleCard[] = [card2, card3, card4]; // card4 will be on top after shuffle usually + + // Mock console.log to check for reshuffle message + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Draw the only card from deck + let result = drawCards(initialDeck, hand, initialDiscard, 1); + expect(result.newHand).toEqual([card1]); + expect(result.newDeck).toHaveLength(0); + hand = result.newHand; // Update hand for next draw + + // Try to draw 2 more cards, should trigger reshuffle + result = drawCards(result.newDeck, hand, initialDiscard, 2); + + expect(consoleSpy).toHaveBeenCalledWith("Reshuffling discard pile into deck."); + expect(result.newHand).toHaveLength(1 + 2); // card1 + 2 new cards + expect(result.newDeck).toHaveLength(initialDiscard.length - 2); // 3 - 2 = 1 + expect(result.newDiscardPile).toHaveLength(0); + + // Check that the drawn cards are from the original discard pile + const drawnFromDiscard = result.newHand.slice(1); // Exclude card1 + initialDiscard.forEach(discardedCard => { + // Check if the cards drawn are among those that were in the discard pile + if (drawnFromDiscard.find(c => c.id === discardedCard.id)) { + expect(initialDiscard.map(c=>c.id)).toContain(discardedCard.id); + } + }); + consoleSpy.mockRestore(); + }); + + it('should handle drawing 0 cards', () => { + const result = drawCards(deck, hand, discardPile, 0); + expect(result.newHand).toHaveLength(0); + expect(result.newDeck).toHaveLength(5); + }); + + it('should add to existing hand', () => { + const initialHand = [mockCard('h1', 'Hand Card 1')]; + const result = drawCards(deck, initialHand, discardPile, 2); + expect(result.newHand).toHaveLength(3); + expect(result.newHand[0].id).toBe('h1'); + expect(result.newHand[1].id).toBe('c1'); + expect(result.newHand[2].id).toBe('c2'); + }); + }); + + describe('getStarterDeck', () => { + it('should return only common action cards', () => { + const starterDeck = getStarterDeck(); + starterDeck.forEach(card => { + expect(card.rarity).toBe('common'); + expect(card.type).toBe('action'); + }); + // Based on current ALL_CARDS, this should be 'fire_missiles', 'cannon_burst', 'focus_fire', 'machine_gun_rake' + // Plus any other common action cards. + const commonActionCards = ALL_CARDS.filter(c => c.rarity === 'common' && c.type === 'action'); + expect(starterDeck.length).toBe(commonActionCards.length); + commonActionCards.forEach(cac => expect(starterDeck.map(s=>s.id)).toContain(cac.id)); + }); + }); +}); diff --git a/src/data/cards.ts b/src/data/cards.ts index 8279a67..1a7e69d 100644 --- a/src/data/cards.ts +++ b/src/data/cards.ts @@ -1,62 +1,79 @@ -import { BattleCard } from '../types'; +import { BattleCard, CardEffect, CardRequirement, CardType, CardRarity } from '../types'; + +// Helper functions for creating card parts +const createEffect = ( + type: CardEffect['type'], + target: CardEffect['target'], + value: number | string, + duration?: number +): CardEffect => ({ type, target, value, duration }); + +const createRequirement = ( + type: CardRequirement['type'], + value: number | string, + operator: CardRequirement['operator'] +): CardRequirement => ({ type, value, operator }); // Cards organized by their primary effect type for easier manipulation +// Note: Ensuring all cards use createEffect and createRequirement for consistency export const DAMAGE_CARDS: BattleCard[] = [ { - id: 'fire_missiles', + id: 'fire_missiles', // ID from existing, card_missiles_01 from my plan name: 'Fire Missiles', - description: 'Launch a missile salvo dealing heavy damage.', - type: 'action', + description: 'Launch a missile salvo dealing heavy damage.', // Existing description + type: 'action' as CardType, cost: 3, - effects: [{ type: 'damage', target: 'enemy', value: 50 }], - requirements: [{ type: 'equipment', value: 'missiles', operator: '>=' }], - rarity: 'common', - imageUrl: '/images/cards/fire_missiles.png' + effects: [createEffect('damage', 'enemy', '40-60')], // My plan's effect, existing was static 50 + requirements: [createRequirement('equipment', 'Missile Weapons', '=')], // My plan, existing was 'missiles' >= + cooldown: 0, + rarity: 'common' as CardRarity, + imageUrl: '/images/cards/fire_missiles.png', }, { - id: 'cannon_burst', + id: 'cannon_burst', // ID from existing, card_cannon_01 from my plan name: 'Cannon Burst', - description: 'Short cannon burst that can be fired repeatedly.', - type: 'action', + description: 'Short cannon burst that can be fired repeatedly.', // Existing description + type: 'action' as CardType, cost: 2, - effects: [{ type: 'damage', target: 'enemy', value: 30 }], - requirements: [{ type: 'equipment', value: 'cannon', operator: '>=' }], - rarity: 'common', - imageUrl: '/images/cards/cannon_burst.png' + effects: [createEffect('damage', 'enemy', '25-35')], // My plan's effect, existing was static 30 + requirements: [createRequirement('equipment', 'Cannon', '=')], // My plan, existing was 'cannon' >= + cooldown: 0, + rarity: 'common' as CardRarity, + imageUrl: '/images/cards/cannon_burst.png', }, { id: 'focus_fire', name: 'Focus Fire', description: 'All pilots concentrate fire on the target.', - type: 'action', + type: 'action' as CardType, cost: 3, - effects: [{ type: 'damage', target: 'enemy', value: 40 }], - rarity: 'common', - imageUrl: '/images/cards/focus_fire.png' + effects: [createEffect('damage', 'enemy', 40)], + rarity: 'common' as CardRarity, + imageUrl: '/images/cards/focus_fire.png', }, { id: 'machine_gun_rake', name: 'Machine Gun Rake', description: 'Spray the target with light machine gun fire.', - type: 'action', + type: 'action' as CardType, cost: 1, - effects: [{ type: 'damage', target: 'enemy', value: 20 }], - requirements: [{ type: 'equipment', value: 'machine_gun', operator: '>=' }], - rarity: 'common', - imageUrl: '/images/cards/machine_gun_rake.png' + effects: [createEffect('damage', 'enemy', 20)], + requirements: [createRequirement('equipment', 'machine_gun', '>=')], + rarity: 'common' as CardRarity, + imageUrl: '/images/cards/machine_gun_rake.png', }, { id: 'precision_strike', name: 'Precision Strike', description: 'Target weak points for increased accuracy.', - type: 'action', + type: 'action' as CardType, cost: 3, - effects: [{ type: 'damage', target: 'enemy', value: 45 }], - requirements: [{ type: 'skill', value: 'targeting:70', operator: '>=' }], - rarity: 'uncommon', - imageUrl: '/images/cards/precision_strike.png' - } + effects: [createEffect('damage', 'enemy', 45)], + requirements: [createRequirement('skill', 'targeting:70', '>=')], + rarity: 'uncommon' as CardRarity, + imageUrl: '/images/cards/precision_strike.png', + }, ]; export const HEAL_CARDS: BattleCard[] = [ @@ -64,171 +81,181 @@ export const HEAL_CARDS: BattleCard[] = [ id: 'engineers_patch', name: "Engineer's Patch", description: 'Quick repair to restore some health.', - type: 'crew', + type: 'crew' as CardType, cost: 1, - effects: [{ type: 'heal', target: 'self', value: 20 }], - rarity: 'common', - imageUrl: '/images/cards/engineers_patch.png' + effects: [createEffect('heal', 'self', 20)], + rarity: 'common' as CardRarity, + imageUrl: '/images/cards/engineers_patch.png', }, { id: 'medic_support', name: 'Medic Support', description: 'Treat injuries and remove debuffs.', - type: 'crew', + type: 'crew' as CardType, cost: 2, effects: [ - { type: 'heal', target: 'self', value: 15 }, - { type: 'special', target: 'self', value: 'cleanse' } + createEffect('heal', 'self', 15), + createEffect('special', 'self', 'cleanse'), ], - rarity: 'uncommon', - imageUrl: '/images/cards/medic_support.png' - } + rarity: 'uncommon' as CardRarity, + imageUrl: '/images/cards/medic_support.png', + }, ]; export const BUFF_CARDS: BattleCard[] = [ { - id: 'evasive_maneuvers', + id: 'evasive_maneuvers', // ID from existing, card_evasive_01 from my plan name: 'Evasive Maneuvers', - description: 'Increase dodge chance for a turn.', - type: 'action', + description: 'Increase dodge chance for a turn. Gain stealth.', // Merged description + type: 'action' as CardType, cost: 2, - effects: [{ type: 'buff', target: 'self', value: 30, duration: 1 }], - requirements: [{ type: 'skill', value: 'piloting:50', operator: '>=' }], + effects: [ // From my plan + createEffect('buff', 'self', '+30% dodge', 1), + createEffect('buff', 'self', '+10 stealth', 1), + ], + requirements: [createRequirement('skill', 'piloting', '>=50')], // My plan, existing was 'piloting:50' cooldown: 1, - rarity: 'uncommon', - imageUrl: '/images/cards/evasive_maneuvers.png' + rarity: 'uncommon' as CardRarity, + imageUrl: '/images/cards/evasive_maneuvers.png', }, { - id: 'emergency_boost', + id: 'emergency_boost', // ID from existing, card_boost_01 from my plan name: 'Emergency Boost', - description: 'Overload engines for extra damage next attack.', - type: 'action', - cost: 4, - effects: [{ type: 'buff', target: 'self', value: 50, duration: 1 }], - requirements: [{ type: 'equipment', value: 'advanced-engines', operator: '>=' }], + description: 'Overload engines for extra damage next attack and initiative.', // Merged description + type: 'action' as CardType, + cost: 4, // My plan, existing was 2 + effects: [ // From my plan + createEffect('buff', 'self', '+50% damage next attack', 1), + createEffect('buff', 'self', '+20 initiative', 0) + ], + requirements: [createRequirement('equipment', 'Advanced Engines', '=')], // My plan, existing was 'advanced-engines' >= cooldown: 2, - rarity: 'uncommon', - imageUrl: '/images/cards/emergency_boost.png' + rarity: 'rare' as CardRarity, // My plan, existing was 'uncommon' + imageUrl: '/images/cards/emergency_boost.png', }, { id: 'combat_awareness', name: 'Combat Awareness', description: 'Predict enemy action and gain critical chance.', - type: 'crew', + type: 'crew' as CardType, cost: 2, - effects: [{ type: 'buff', target: 'self', value: 25, duration: 2 }], - requirements: [{ type: 'skill', value: 'awareness:80', operator: '>=' }], + effects: [createEffect('buff', 'self', 25, 2)], // Existing: value: 25, duration: 2. My plan: +25% critical hit + requirements: [createRequirement('skill', 'awareness:80', '>=')], // Existing. My plan: Combat Awareness > 80 cooldown: 3, - rarity: 'rare', - imageUrl: '/images/cards/combat_awareness.png' + rarity: 'rare' as CardRarity, + imageUrl: '/images/cards/combat_awareness.png', }, { id: 'pilot_focus', name: 'Pilot Focus', description: 'Boost pilot initiative for one turn.', - type: 'crew', + type: 'crew' as CardType, cost: 1, - effects: [{ type: 'buff', target: 'self', value: 20, duration: 1 }], - rarity: 'common', - imageUrl: '/images/cards/pilot_focus.png' + effects: [createEffect('buff', 'self', 20, 1)], + rarity: 'common' as CardRarity, + imageUrl: '/images/cards/pilot_focus.png', }, { - id: 'plasma_cannon', - name: 'Plasma Cannon', - description: 'Powerful weapon that ignores armor.', - type: 'equipment', - cost: 0, - effects: [{ type: 'buff', target: 'self', value: 40 }], - requirements: [{ type: 'research', value: 'plasma-weapons', operator: '>=' }], - rarity: 'rare', - imageUrl: '/images/cards/plasma_cannon.png' + id: 'plasma_cannon_equip', // Renamed from 'plasma_cannon' to avoid conflict if a playable 'Plasma Cannon' card is added + name: 'Plasma Cannon Tech', // My name was 'Plasma Cannon Tech' + description: 'Grants +40% damage and ignores 25% armor for equipped cannons. (+1 energy cost to attacks)', // My description + type: 'equipment' as CardType, + cost: 0, // Passive + effects: [ + // Effects for passive equipment are tricky. They usually modify player/vehicle stats directly + // or modify other cards. For now, let's assume it grants a status effect. + createEffect('buff', 'self', 'plasma_cannons_active') + ], + requirements: [createRequirement('research', 'plasma-weapons', '>=')], // Existing, my plan was 'Plasma Weapons' = + rarity: 'rare' as CardRarity, + imageUrl: '/images/cards/plasma_cannon.png', }, { - id: 'stealth_coating', + id: 'stealth_coating_equip', // Renamed name: 'Stealth Coating', description: 'Begin battle with additional stealth.', - type: 'equipment', + type: 'equipment' as CardType, cost: 0, - effects: [{ type: 'buff', target: 'self', value: 50 }], - requirements: [{ type: 'research', value: 'stealth-systems', operator: '>=' }], - rarity: 'uncommon', - imageUrl: '/images/cards/stealth_coating.png' + effects: [createEffect('buff', 'self', 'stealth_coating_active')], // Similar to above + requirements: [createRequirement('research', 'stealth-systems', '>=')], + rarity: 'uncommon' as CardRarity, + imageUrl: '/images/cards/stealth_coating.png', }, { - id: 'advanced_targeting', + id: 'advanced_targeting_equip', // Renamed name: 'Advanced Targeting', description: 'Greatly improve accuracy of next attack.', - type: 'equipment', - cost: 1, - effects: [{ type: 'buff', target: 'self', value: 50, duration: 1 }], - requirements: [{ type: 'research', value: 'advanced-avionics', operator: '>=' }], - rarity: 'uncommon', - imageUrl: '/images/cards/advanced_targeting.png' + type: 'equipment' as CardType, + cost: 1, // This could be an activatable equipment card + effects: [createEffect('buff', 'self', '+50% accuracy next attack', 1)], // Effect more specific + requirements: [createRequirement('research', 'advanced-avionics', '>=')], + rarity: 'uncommon' as CardRarity, + imageUrl: '/images/cards/advanced_targeting.png', }, { - id: 'reinforced_armor', + id: 'reinforced_armor_equip', // Renamed name: 'Reinforced Armor', description: 'Increase maximum health of the aircraft.', - type: 'equipment', + type: 'equipment' as CardType, cost: 0, - effects: [{ type: 'buff', target: 'self', value: 25 }], - rarity: 'common', - imageUrl: '/images/cards/reinforced_armor.png' + effects: [createEffect('buff', 'self', 'reinforced_armor_active')], // Passive + rarity: 'common' as CardRarity, + imageUrl: '/images/cards/reinforced_armor.png', }, { - id: 'high_impact_missiles', + id: 'high_impact_missiles_equip', // Renamed name: 'High-Impact Missiles', description: 'Missiles that deal additional damage.', - type: 'equipment', + type: 'equipment' as CardType, cost: 0, - effects: [{ type: 'buff', target: 'self', value: 30 }], - requirements: [{ type: 'research', value: 'explosives', operator: '>=' }], - rarity: 'uncommon', - imageUrl: '/images/cards/high_impact_missiles.png' + effects: [createEffect('buff', 'self', 'high_impact_missiles_active')], // Passive + requirements: [createRequirement('research', 'explosives', '>=')], + rarity: 'uncommon' as CardRarity, + imageUrl: '/images/cards/high_impact_missiles.png', }, { - id: 'countermeasure_system', + id: 'countermeasure_system_equip', // Renamed name: 'Countermeasure System', description: 'Increase evasion for one turn when activated.', - type: 'equipment', - cost: 1, - effects: [{ type: 'buff', target: 'self', value: 20, duration: 1 }], - rarity: 'common', - imageUrl: '/images/cards/countermeasure_system.png' + type: 'equipment' as CardType, + cost: 1, // Activatable + effects: [createEffect('buff', 'self', '+20% evasion', 1)], // Effect more specific + rarity: 'common' as CardRarity, + imageUrl: '/images/cards/countermeasure_system.png', }, { id: 'clear_skies', name: 'Clear Skies', description: 'Improve accuracy but reduce stealth bonuses.', - type: 'environmental', + type: 'environmental' as CardType, cost: 0, - effects: [{ type: 'buff', target: 'all', value: 15, duration: 2 }], - rarity: 'common', - imageUrl: '/images/cards/clear_skies.png' + effects: [createEffect('buff', 'all', '+15% accuracy', 2), createEffect('debuff', 'all', '-15% stealth effectiveness', 2)], // More specific + rarity: 'common' as CardRarity, + imageUrl: '/images/cards/clear_skies.png', }, { id: 'night_operations', name: 'Night Operations', description: 'Gain stealth bonuses while vision is limited.', - type: 'environmental', + type: 'environmental' as CardType, cost: 0, - effects: [{ type: 'buff', target: 'self', value: 20, duration: 2 }], - rarity: 'uncommon', - imageUrl: '/images/cards/night_operations.png' + effects: [createEffect('buff', 'all', '+20% stealth effectiveness', 2)], // Target 'all' for environmental usually + rarity: 'uncommon' as CardRarity, + imageUrl: '/images/cards/night_operations.png', }, { id: 'mountain_terrain', name: 'Mountain Terrain', description: 'Altitude advantage but radar interference.', - type: 'environmental', + type: 'environmental' as CardType, cost: 0, effects: [ - { type: 'buff', target: 'self', value: 10, duration: 2 }, - { type: 'debuff', target: 'self', value: 10, duration: 2 } + createEffect('buff', 'all', '+10% altitude advantage', 2), // More descriptive + createEffect('debuff', 'all', '-10% radar effectiveness', 2), ], - rarity: 'common', - imageUrl: '/images/cards/mountain_terrain.png' - } + rarity: 'common' as CardRarity, + imageUrl: '/images/cards/mountain_terrain.png', + }, ]; export const DEBUFF_CARDS: BattleCard[] = [ @@ -236,22 +263,22 @@ export const DEBUFF_CARDS: BattleCard[] = [ id: 'storm_front', name: 'Storm Front', description: 'Heavy weather reduces accuracy for both sides.', - type: 'environmental', + type: 'environmental' as CardType, cost: 0, - effects: [{ type: 'debuff', target: 'all', value: 20, duration: 2 }], - rarity: 'common', - imageUrl: '/images/cards/storm_front.png' + effects: [createEffect('debuff', 'all', '-20% accuracy', 2)], + rarity: 'common' as CardRarity, + imageUrl: '/images/cards/storm_front.png', }, { id: 'urban_area', name: 'Urban Area', description: 'Risk of collateral damage in populated zones.', - type: 'environmental', + type: 'environmental' as CardType, cost: 0, - effects: [{ type: 'debuff', target: 'self', value: 10, duration: 2 }], - rarity: 'common', - imageUrl: '/images/cards/urban_area.png' - } + effects: [createEffect('special', 'all', 'collateral_damage_risk', 0)], // Special effect + rarity: 'common' as CardRarity, + imageUrl: '/images/cards/urban_area.png', + }, ]; export const SPECIAL_CARDS: BattleCard[] = [ @@ -259,57 +286,117 @@ export const SPECIAL_CARDS: BattleCard[] = [ id: 'overwatch', name: 'Overwatch', description: 'Prepare to respond to the next UFO action.', - type: 'action', + type: 'action' as CardType, cost: 1, - effects: [{ type: 'special', target: 'enemy', value: 'counter' }], - rarity: 'uncommon', - imageUrl: '/images/cards/overwatch.png' + effects: [createEffect('special', 'self', 'enter_overwatch_stance')], // More descriptive + rarity: 'uncommon' as CardRarity, + imageUrl: '/images/cards/overwatch.png', }, { - id: 'veterans_instinct', + id: 'veterans_instinct', // ID from existing, card_veteran_instinct_01 from my plan name: "Veteran's Instinct", - description: 'Draw additional cards and improve accuracy.', - type: 'crew', + description: 'Draw 2 cards. Gain +10% accuracy this turn.', // My plan's description + type: 'crew' as CardType, cost: 1, - effects: [ - { type: 'special', target: 'self', value: 'draw:2' }, - { type: 'buff', target: 'self', value: 10, duration: 1 } + effects: [ // From my plan + createEffect('special', 'self', 'draw_2_cards'), + createEffect('buff', 'self', '+10% accuracy', 1), ], - requirements: [{ type: 'skill', value: 'experience:75', operator: '>=' }], - cooldown: 3, - rarity: 'rare', - imageUrl: '/images/cards/veterans_instinct.png' + requirements: [createRequirement('skill', 'experience', '>=75')], // My plan, existing 'experience:75' + cooldown: 3, // Existing cooldown + rarity: 'rare' as CardRarity, // Existing rarity + imageUrl: '/images/cards/veterans_instinct.png', }, { id: 'stress_management', name: 'Stress Management', description: 'Remove negative status effects from the crew.', - type: 'crew', + type: 'crew' as CardType, cost: 0, - effects: [{ type: 'special', target: 'self', value: 'cleanse' }], - requirements: [{ type: 'skill', value: 'stress:70', operator: '>=' }], + effects: [createEffect('special', 'self', 'cleanse_debuffs_self')], // More descriptive + requirements: [createRequirement('skill', 'stress:70', '>=')], cooldown: 2, - rarity: 'uncommon', - imageUrl: '/images/cards/stress_management.png' + rarity: 'uncommon' as CardRarity, + imageUrl: '/images/cards/stress_management.png', }, { id: 'ocean_approach', name: 'Ocean Approach', description: 'Long approach over open water.', - type: 'environmental', + type: 'environmental' as CardType, cost: 0, - effects: [{ type: 'special', target: 'self', value: 'long-range' }], - rarity: 'common', - imageUrl: '/images/cards/ocean_approach.png' - } + effects: [createEffect('special', 'all', 'ocean_engagement_rules')], // Special effect + rarity: 'common' as CardRarity, + imageUrl: '/images/cards/ocean_approach.png', + }, ]; -export const BASE_CARDS: BattleCard[] = [ +// Consolidate all cards into a single array for easier global access +export const ALL_CARDS: BattleCard[] = [ ...DAMAGE_CARDS, ...HEAL_CARDS, ...BUFF_CARDS, ...DEBUFF_CARDS, - ...SPECIAL_CARDS + ...SPECIAL_CARDS, ]; -export default BASE_CARDS; +// Function to get a full deck for a player (e.g., all common action cards for starting) +export const getStarterDeck = (): BattleCard[] => { + return ALL_CARDS.filter(card => + card.rarity === ('common' as CardRarity) && + card.type === ('action' as CardType) + ); +}; + +// Function to shuffle a deck +export const shuffleDeck = (deck: BattleCard[]): BattleCard[] => { + const shuffled = [...deck]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled; +}; + +// Function to draw cards from a deck +export const drawCards = ( + deck: BattleCard[], + hand: BattleCard[], + discardPile: BattleCard[], + count: number +): { newDeck: BattleCard[], newHand: BattleCard[], newDiscardPile: BattleCard[] } => { + let currentDeck = [...deck]; + let currentHand = [...hand]; + let currentDiscardPile = [...discardPile]; + + const cardsToDraw = []; + + for (let i = 0; i < count; i++) { + if (currentDeck.length === 0) { + if (currentDiscardPile.length === 0) { + // No cards left anywhere to draw + break; + } + // Reshuffle discard pile into deck + console.log("Reshuffling discard pile into deck."); + currentDeck = shuffleDeck(currentDiscardPile); + currentDiscardPile = []; + if (currentDeck.length === 0) { // Still no cards (empty discard) + break; + } + } + const drawnCard = currentDeck.shift(); // Removes the first card + if (drawnCard) { + cardsToDraw.push(drawnCard); + } + } + + currentHand = [...currentHand, ...cardsToDraw]; + + return { newDeck: currentDeck, newHand: currentHand, newDiscardPile: currentDiscardPile }; +}; + + +// The old export default BASE_CARDS might not be needed if ALL_CARDS is used, +// or it can be an alias for ALL_CARDS. For now, let's keep it as ALL_CARDS. +export default ALL_CARDS; diff --git a/src/types.ts b/src/types.ts index 1a5b849..1a13246 100644 --- a/src/types.ts +++ b/src/types.ts @@ -313,6 +313,9 @@ export interface GameState { showAllUFOTrajectories?: boolean; forceUFOSpawn?: boolean; debugPanelVisible?: boolean; + activeBattle?: BattleState | null; + selectedVehicleForBattle?: Vehicle | null; + selectedUFOForBattle?: UFO | null; } export interface Continent { From 62310a56852995d6e324589d728072d85bfb0334 Mon Sep 17 00:00:00 2001 From: Fernando Gutierrez Date: Mon, 7 Jul 2025 12:06:47 -0400 Subject: [PATCH 2/2] Fix requirements format in cards. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/data/cards.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/cards.ts b/src/data/cards.ts index 1a7e69d..d9082a9 100644 --- a/src/data/cards.ts +++ b/src/data/cards.ts @@ -113,7 +113,7 @@ export const BUFF_CARDS: BattleCard[] = [ createEffect('buff', 'self', '+30% dodge', 1), createEffect('buff', 'self', '+10 stealth', 1), ], - requirements: [createRequirement('skill', 'piloting', '>=50')], // My plan, existing was 'piloting:50' + requirements: [createRequirement('skill', 'piloting:50', '>=')], // Corrected to match expected format cooldown: 1, rarity: 'uncommon' as CardRarity, imageUrl: '/images/cards/evasive_maneuvers.png',