From d926ac90ee38f58c98cd6bcaeff7c023ca999b3d Mon Sep 17 00:00:00 2001 From: jackswitzer Date: Wed, 4 Feb 2026 12:18:34 -0500 Subject: [PATCH] Implement Defect orb system and 65+ card effects Add complete orb system: - OrbManager class with channel, evoke, passive triggers - 4 orb types: Lightning, Frost, Dark, Plasma - Focus modifier support - Loop power (extra passive triggers) - Electrodynamics (lightning hits all) - Lock-On debuff support Implement all Defect card effects: - Orb channeling: Zap, Ball Lightning, Coolheaded, Darkness, Fusion, Glacier, Chaos, Chill, Rainbow, Meteor Strike, Tempest, etc. - Orb evoking: Dualcast, Multi-Cast, Recursion, Fission - Focus: Defragment, Consume, Biased Cognition, Hyperbeam, Reprogram - Orb counting: Barrage, Compile Driver, Blizzard, Thunder Strike - Powers: Echo Form, Creative AI, Storm, Static Discharge, Loop, Heatsinks, Machine Learning, Buffer, Self Repair, Capacitor - Card manipulation: All For One, Hologram, Seek, Reboot - Special: Claw damage scaling, Streamline cost reduction, etc. Add Defect cards to ALL_CARDS registry. Update CombatState with orb_manager field. Add 77 comprehensive tests for all Defect mechanics. Co-Authored-By: Claude Opus 4.5 --- packages/engine/content/cards.py | 9 +- packages/engine/effects/__init__.py | 4 +- packages/engine/effects/defect_cards.py | 811 +++++++++++++++++++ packages/engine/effects/orbs.py | 456 +++++++++++ packages/engine/state/combat.py | 7 +- tests/conftest.py | 6 +- tests/test_defect_cards.py | 986 ++++++++++++++++++++++++ 7 files changed, 2271 insertions(+), 8 deletions(-) create mode 100644 packages/engine/effects/defect_cards.py create mode 100644 packages/engine/effects/orbs.py create mode 100644 tests/test_defect_cards.py diff --git a/packages/engine/content/cards.py b/packages/engine/content/cards.py index 06a5e1d..047e20a 100644 --- a/packages/engine/content/cards.py +++ b/packages/engine/content/cards.py @@ -1795,7 +1795,7 @@ def copy(self) -> 'Card': COOLHEADED = Card( id="Coolheaded", name="Coolheaded", card_type=CardType.SKILL, rarity=CardRarity.COMMON, color=CardColor.BLUE, target=CardTarget.SELF, cost=1, - base_magic=1, upgrade_magic=1, effects=["channel_frost", "draw"], + base_magic=1, upgrade_magic=1, effects=["channel_frost", "draw_cards"], ) HOLOGRAM = Card( @@ -1830,7 +1830,7 @@ def copy(self) -> 'Card': TURBO = Card( id="Turbo", name="Turbo", card_type=CardType.SKILL, rarity=CardRarity.COMMON, color=CardColor.BLUE, target=CardTarget.SELF, cost=0, - base_magic=2, upgrade_magic=1, effects=["gain_energy", "add_void_to_discard"], + base_magic=2, upgrade_magic=1, effects=["gain_energy_magic", "add_void_to_discard"], ) @@ -1968,7 +1968,7 @@ def copy(self) -> 'Card': OVERCLOCK = Card( id="Steam Power", name="Overclock", card_type=CardType.SKILL, rarity=CardRarity.UNCOMMON, color=CardColor.BLUE, target=CardTarget.SELF, cost=0, - base_magic=2, upgrade_magic=1, effects=["draw", "add_burn_to_discard"], + base_magic=2, upgrade_magic=1, effects=["draw_cards", "add_burn_to_discard"], ) RECYCLE = Card( @@ -1992,7 +1992,7 @@ def copy(self) -> 'Card': SKIM = Card( id="Skim", name="Skim", card_type=CardType.SKILL, rarity=CardRarity.UNCOMMON, color=CardColor.BLUE, target=CardTarget.NONE, cost=1, - base_magic=3, upgrade_magic=1, effects=["draw"], + base_magic=3, upgrade_magic=1, effects=["draw_cards"], ) TEMPEST = Card( @@ -3150,6 +3150,7 @@ def copy(self) -> 'Card': **WATCHER_CARDS, **IRONCLAD_CARDS, **SILENT_CARDS, + **DEFECT_CARDS, **COLORLESS_CARDS, **CURSE_CARDS, **STATUS_CARDS, diff --git a/packages/engine/effects/__init__.py b/packages/engine/effects/__init__.py index 6757d36..822b45d 100644 --- a/packages/engine/effects/__init__.py +++ b/packages/engine/effects/__init__.py @@ -60,8 +60,10 @@ def enter_wrath(ctx: EffectContext) -> None: create_executor, ) -# Import cards module to register all effects +# Import cards modules to register all effects from . import cards as _cards # noqa: F401 +from . import defect_cards as _defect_cards # noqa: F401 +from . import orbs as _orbs # noqa: F401 __all__ = [ # Core types diff --git a/packages/engine/effects/defect_cards.py b/packages/engine/effects/defect_cards.py new file mode 100644 index 0000000..38d229d --- /dev/null +++ b/packages/engine/effects/defect_cards.py @@ -0,0 +1,811 @@ +""" +Defect Card Effect Implementations. + +This module registers all Defect card effects using the effect registry. +Effects are implemented as pure functions that modify the EffectContext. + +The effects are organized by category: +- Orb channeling effects (Zap, Ball Lightning, Coolheaded, etc.) +- Orb evoke effects (Dualcast, Multi-Cast, Recursion) +- Focus manipulation (Defragment, Consume, Biased Cognition) +- Orb-counting effects (Barrage, Blizzard, Thunder Strike) +- Card manipulation (All For One, Hologram, Seek, Reboot) +- Powers (Echo Form, Creative AI, Storm, Static Discharge) +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, List, Optional, Dict, Any +import random + +from .registry import ( + effect, effect_simple, effect_custom, EffectContext +) +from .orbs import ( + get_orb_manager, OrbType, channel_orb, channel_random_orb, + evoke_orb, evoke_all_orbs, trigger_orb_passives +) + +if TYPE_CHECKING: + from ..state.combat import EnemyCombatState + + +# ============================================================================= +# Orb Channeling Effects +# ============================================================================= + +@effect_simple("channel_lightning") +def channel_lightning_effect(ctx: EffectContext) -> None: + """Channel 1 Lightning orb (Zap, Ball Lightning).""" + channel_orb(ctx.state, "Lightning") + + +@effect_simple("channel_frost") +def channel_frost_effect(ctx: EffectContext) -> None: + """Channel 1 Frost orb (Cold Snap, Coolheaded).""" + channel_orb(ctx.state, "Frost") + + +@effect_simple("channel_dark") +def channel_dark_effect(ctx: EffectContext) -> None: + """Channel 1 Dark orb (Darkness, Doom and Gloom).""" + channel_orb(ctx.state, "Dark") + + +@effect_simple("channel_plasma") +def channel_plasma_effect(ctx: EffectContext) -> None: + """Channel 1 Plasma orb (Fusion).""" + channel_orb(ctx.state, "Plasma") + + +@effect_simple("channel_2_frost") +def channel_2_frost_effect(ctx: EffectContext) -> None: + """Channel 2 Frost orbs (Glacier).""" + channel_orb(ctx.state, "Frost") + channel_orb(ctx.state, "Frost") + + +@effect_simple("channel_3_plasma") +def channel_3_plasma_effect(ctx: EffectContext) -> None: + """Channel 3 Plasma orbs (Meteor Strike).""" + channel_orb(ctx.state, "Plasma") + channel_orb(ctx.state, "Plasma") + channel_orb(ctx.state, "Plasma") + + +@effect_simple("channel_random_orb") +def channel_random_orb_effect(ctx: EffectContext) -> None: + """Channel random orb(s) (Chaos). Magic number determines count.""" + count = ctx.magic_number if ctx.magic_number > 0 else 1 + for _ in range(count): + channel_random_orb(ctx.state) + + +@effect_simple("channel_frost_per_enemy") +def channel_frost_per_enemy_effect(ctx: EffectContext) -> None: + """Channel 1 Frost per enemy (Chill).""" + enemy_count = len(ctx.living_enemies) + for _ in range(enemy_count): + channel_orb(ctx.state, "Frost") + + +@effect_simple("channel_lightning_frost_dark") +def channel_lightning_frost_dark_effect(ctx: EffectContext) -> None: + """Channel Lightning, Frost, and Dark (Rainbow).""" + channel_orb(ctx.state, "Lightning") + channel_orb(ctx.state, "Frost") + channel_orb(ctx.state, "Dark") + + +@effect_simple("channel_x_lightning") +def channel_x_lightning_effect(ctx: EffectContext) -> None: + """Channel X Lightning orbs (Tempest). X = energy spent.""" + x = ctx.extra_data.get("x_cost", ctx.state.energy) + for _ in range(x): + channel_orb(ctx.state, "Lightning") + + +# ============================================================================= +# Orb Evoke Effects +# ============================================================================= + +@effect_simple("evoke_orb_twice") +def evoke_orb_twice_effect(ctx: EffectContext) -> None: + """Evoke the leftmost orb twice (Dualcast).""" + manager = get_orb_manager(ctx.state) + if manager.has_orbs(): + evoke_orb(ctx.state, times=2) + + +@effect_simple("evoke_first_orb_x_times") +def evoke_first_orb_x_times_effect(ctx: EffectContext) -> None: + """Evoke first orb X times (Multi-Cast). X = energy spent.""" + x = ctx.extra_data.get("x_cost", ctx.state.energy) + if x > 0: + manager = get_orb_manager(ctx.state) + if manager.has_orbs(): + evoke_orb(ctx.state, times=x) + + +@effect_simple("evoke_then_channel_same_orb") +def evoke_then_channel_same_effect(ctx: EffectContext) -> None: + """Evoke first orb, then channel the same type (Recursion).""" + manager = get_orb_manager(ctx.state) + if manager.has_orbs(): + first_orb = manager.get_first_orb() + orb_type = first_orb.orb_type.value + evoke_orb(ctx.state) + channel_orb(ctx.state, orb_type) + + +@effect_simple("remove_orbs_gain_energy_and_draw") +def fission_effect(ctx: EffectContext) -> None: + """ + Remove all orbs, gain 1 energy and draw 1 per orb (Fission). + Upgraded version gains resources, base version does not. + """ + gain_resources = ctx.is_upgraded + result = evoke_all_orbs(ctx.state, gain_resources=gain_resources) + + if gain_resources: + ctx.gain_energy(result["orbs_evoked"]) + ctx.draw_cards(result["orbs_evoked"]) + + +# ============================================================================= +# Focus Manipulation Effects +# ============================================================================= + +@effect_simple("gain_focus") +def gain_focus_effect(ctx: EffectContext) -> None: + """Gain Focus (Defragment). Amount from magic_number.""" + amount = ctx.magic_number if ctx.magic_number > 0 else 1 + ctx.apply_status_to_player("Focus", amount) + + # Also update orb manager's focus + manager = get_orb_manager(ctx.state) + manager.modify_focus(amount) + + +@effect_simple("lose_focus") +def lose_focus_effect(ctx: EffectContext) -> None: + """Lose Focus (Hyperbeam). Amount from magic_number.""" + amount = ctx.magic_number if ctx.magic_number > 0 else 3 + ctx.apply_status_to_player("Focus", -amount) + + manager = get_orb_manager(ctx.state) + manager.modify_focus(-amount) + + +@effect_simple("gain_focus_lose_orb_slot") +def consume_effect(ctx: EffectContext) -> None: + """Gain Focus, lose 1 orb slot (Consume).""" + focus_amount = ctx.magic_number if ctx.magic_number > 0 else 2 + ctx.apply_status_to_player("Focus", focus_amount) + + manager = get_orb_manager(ctx.state) + manager.modify_focus(focus_amount) + manager.remove_orb_slot(1) + + +@effect_simple("gain_focus_lose_focus_each_turn") +def biased_cognition_effect(ctx: EffectContext) -> None: + """ + Gain Focus, lose 1 Focus at start of each turn (Biased Cognition). + + Applies the power that loses focus each turn. + """ + amount = ctx.magic_number if ctx.magic_number > 0 else 4 + ctx.apply_status_to_player("Focus", amount) + ctx.apply_status_to_player("BiasedCognition", 1) + + manager = get_orb_manager(ctx.state) + manager.modify_focus(amount) + + +@effect_simple("lose_focus_gain_strength_dex") +def reprogram_effect(ctx: EffectContext) -> None: + """Lose 1 Focus, gain Strength and Dexterity (Reprogram).""" + amount = ctx.magic_number if ctx.magic_number > 0 else 1 + ctx.apply_status_to_player("Focus", -amount) + ctx.apply_status_to_player("Strength", amount) + ctx.apply_status_to_player("Dexterity", amount) + + manager = get_orb_manager(ctx.state) + manager.modify_focus(-amount) + + +# ============================================================================= +# Orb Slot Effects +# ============================================================================= + +@effect_simple("increase_orb_slots") +def increase_orb_slots_effect(ctx: EffectContext) -> None: + """Increase orb slots (Capacitor).""" + amount = ctx.magic_number if ctx.magic_number > 0 else 2 + ctx.apply_status_to_player("OrbSlots", amount) + + manager = get_orb_manager(ctx.state) + manager.add_orb_slot(amount) + + +# ============================================================================= +# Orb-Counting Damage Effects +# ============================================================================= + +@effect_simple("damage_per_orb") +def damage_per_orb_effect(ctx: EffectContext) -> None: + """Deal damage for each orb channeled (Barrage).""" + manager = get_orb_manager(ctx.state) + orb_count = manager.get_orb_count() + + if ctx.card and ctx.target: + damage = ctx.card.damage + for _ in range(orb_count): + ctx.deal_damage_to_target(damage) + + +@effect_simple("draw_per_unique_orb") +def draw_per_unique_orb_effect(ctx: EffectContext) -> None: + """Draw 1 card per unique orb type (Compile Driver).""" + manager = get_orb_manager(ctx.state) + unique_count = manager.get_unique_orb_types() + ctx.draw_cards(unique_count) + + +@effect_simple("damage_per_frost_channeled") +def damage_per_frost_channeled_effect(ctx: EffectContext) -> None: + """Deal damage per Frost channeled this combat (Blizzard).""" + manager = get_orb_manager(ctx.state) + frost_count = manager.frost_channeled + + damage_per = ctx.magic_number if ctx.magic_number > 0 else 2 + total_damage = frost_count * damage_per + + # All enemies target + for enemy in ctx.living_enemies: + ctx.deal_damage_to_enemy(enemy, total_damage) + + +@effect_simple("damage_per_lightning_channeled") +def damage_per_lightning_channeled_effect(ctx: EffectContext) -> None: + """Deal damage per Lightning channeled this combat (Thunder Strike).""" + manager = get_orb_manager(ctx.state) + lightning_count = manager.lightning_channeled + + if ctx.card: + damage = ctx.card.damage + # Hit random enemies (lightning_count) times + for _ in range(lightning_count): + living = ctx.living_enemies + if living: + target = random.choice(living) + ctx.deal_damage_to_enemy(target, damage) + + +# ============================================================================= +# Orb Passive Trigger Effects +# ============================================================================= + +@effect_simple("trigger_orb_passive_extra") +def loop_effect(ctx: EffectContext) -> None: + """Trigger rightmost orb's passive 1 extra time (Loop power).""" + amount = ctx.magic_number if ctx.magic_number > 0 else 1 + ctx.apply_status_to_player("Loop", amount) + + manager = get_orb_manager(ctx.state) + manager.loop_stacks += amount + + +@effect_simple("lightning_hits_all") +def electrodynamics_lightning_all_effect(ctx: EffectContext) -> None: + """Lightning orbs now hit ALL enemies (Electrodynamics).""" + ctx.apply_status_to_player("Electrodynamics", 1) + + manager = get_orb_manager(ctx.state) + manager.lightning_hits_all = True + + +# ============================================================================= +# Power Card Effects (Defect) +# ============================================================================= + +@effect_simple("channel_lightning_on_power_play") +def storm_power_effect(ctx: EffectContext) -> None: + """When you play a Power, channel 1 Lightning (Storm power).""" + ctx.apply_status_to_player("Storm", 1) + + +@effect_simple("channel_lightning_on_damage") +def static_discharge_effect(ctx: EffectContext) -> None: + """When you take damage, channel 1 Lightning (Static Discharge).""" + amount = ctx.magic_number if ctx.magic_number > 0 else 1 + ctx.apply_status_to_player("StaticDischarge", amount) + + +@effect_simple("draw_on_power_play") +def heatsinks_effect(ctx: EffectContext) -> None: + """When you play a Power, draw cards (Heatsinks).""" + amount = ctx.magic_number if ctx.magic_number > 0 else 1 + ctx.apply_status_to_player("Heatsinks", amount) + + +@effect_simple("play_first_card_twice") +def echo_form_effect(ctx: EffectContext) -> None: + """First card each turn plays twice (Echo Form).""" + ctx.apply_status_to_player("EchoForm", 1) + + +@effect_simple("add_random_power_each_turn") +def creative_ai_effect(ctx: EffectContext) -> None: + """At start of turn, add a random Power to hand (Creative AI).""" + ctx.apply_status_to_player("CreativeAI", 1) + + +@effect_simple("draw_extra_each_turn") +def machine_learning_effect(ctx: EffectContext) -> None: + """Draw 1 additional card each turn (Machine Learning).""" + ctx.apply_status_to_player("MachineLearning", 1) + + +@effect_simple("add_common_card_each_turn") +def hello_world_effect(ctx: EffectContext) -> None: + """At start of turn, add a random common card to hand (Hello World).""" + ctx.apply_status_to_player("HelloWorld", 1) + + +@effect_simple("heal_at_end_of_combat") +def self_repair_effect(ctx: EffectContext) -> None: + """Heal at end of combat (Self Repair).""" + amount = ctx.magic_number if ctx.magic_number > 0 else 7 + ctx.apply_status_to_player("SelfRepair", amount) + + +@effect_simple("prevent_next_hp_loss") +def buffer_effect(ctx: EffectContext) -> None: + """Prevent next HP loss (Buffer).""" + amount = ctx.magic_number if ctx.magic_number > 0 else 1 + ctx.apply_status_to_player("Buffer", amount) + + +@effect_simple("next_power_plays_twice") +def amplify_effect(ctx: EffectContext) -> None: + """Next Power card plays twice (Amplify).""" + amount = ctx.magic_number if ctx.magic_number > 0 else 1 + ctx.apply_status_to_player("Amplify", amount) + + +# ============================================================================= +# Card Manipulation Effects (Defect) +# ============================================================================= + +@effect_simple("return_all_0_cost_from_discard") +def all_for_one_effect(ctx: EffectContext) -> None: + """Return all 0-cost cards from discard to hand (All For One).""" + zero_cost_cards = [] + + # Find all 0-cost cards in discard + for card_id in ctx.state.discard_pile[:]: + # Check card cost (would need card registry in real impl) + # For now, check known 0-cost cards + base_id = card_id.rstrip("+") + zero_cost_ids = { + "Claw", "Go for the Eyes", "Zap", "Turbo", "Steam", "Reboot", + "Seek", "Fission", "Rainbow" + } + card_cost = ctx.state.card_costs.get(card_id, -1) + + if base_id in zero_cost_ids or card_cost == 0: + zero_cost_cards.append(card_id) + + # Move to hand (up to hand limit) + for card_id in zero_cost_cards: + if len(ctx.state.hand) < 10: + ctx.state.discard_pile.remove(card_id) + ctx.state.hand.append(card_id) + + +@effect_simple("return_card_from_discard") +def hologram_effect(ctx: EffectContext) -> None: + """Return a card from discard to hand (Hologram). Requires selection.""" + if ctx.state.discard_pile and len(ctx.state.hand) < 10: + # In simulation, return first card (player would choose) + card_idx = ctx.extra_data.get("hologram_choice", 0) + if 0 <= card_idx < len(ctx.state.discard_pile): + card = ctx.state.discard_pile.pop(card_idx) + ctx.state.hand.append(card) + + +@effect_simple("search_draw_pile") +def seek_effect(ctx: EffectContext) -> None: + """Search draw pile for card(s) to put in hand (Seek).""" + count = ctx.magic_number if ctx.magic_number > 0 else 1 + choice_indices = ctx.extra_data.get("seek_choices", [0] * count) + + for idx in choice_indices[:count]: + if ctx.state.draw_pile and len(ctx.state.hand) < 10: + actual_idx = min(idx, len(ctx.state.draw_pile) - 1) + if actual_idx >= 0: + card = ctx.state.draw_pile.pop(actual_idx) + ctx.state.hand.append(card) + + +@effect_simple("shuffle_hand_and_discard_draw") +def reboot_effect(ctx: EffectContext) -> None: + """Shuffle hand and discard into draw, draw X cards (Reboot).""" + draw_count = ctx.magic_number if ctx.magic_number > 0 else 4 + + # Move hand and discard to draw + ctx.state.draw_pile.extend(ctx.state.hand) + ctx.state.draw_pile.extend(ctx.state.discard_pile) + ctx.state.hand.clear() + ctx.state.discard_pile.clear() + + # Shuffle draw pile + random.shuffle(ctx.state.draw_pile) + + # Draw cards + ctx.draw_cards(draw_count) + + +@effect_simple("exhaust_card_gain_energy") +def recycle_effect(ctx: EffectContext) -> None: + """Exhaust a card, gain energy equal to its cost (Recycle).""" + choice_idx = ctx.extra_data.get("recycle_choice", 0) + if 0 <= choice_idx < len(ctx.state.hand): + card_id = ctx.state.hand[choice_idx] + # Get card cost (simplified - would use registry) + card_cost = ctx.state.card_costs.get(card_id, 1) + ctx.exhaust_hand_idx(choice_idx) + ctx.gain_energy(card_cost) + + +@effect_simple("next_card_on_top_of_draw") +def rebound_effect(ctx: EffectContext) -> None: + """Next card played goes on top of draw pile (Rebound).""" + ctx.apply_status_to_player("Rebound", 1) + + +@effect_simple("add_random_power_to_hand_cost_0") +def white_noise_effect(ctx: EffectContext) -> None: + """Add a random Power card to hand that costs 0 this turn (White Noise).""" + # List of Defect power cards + powers = [ + "Defragment", "Capacitor", "Heatsinks", "Hello World", "Loop", + "Self Repair", "Static Discharge", "Storm", "Biased Cognition", + "Buffer", "Creative AI", "Echo Form", "Electrodynamics", "Machine Learning" + ] + chosen = random.choice(powers) + if len(ctx.state.hand) < 10: + ctx.state.hand.append(chosen) + # Set cost to 0 for this turn + ctx.state.card_costs[chosen] = 0 + + +# ============================================================================= +# Conditional Effects (Defect) +# ============================================================================= + +@effect_simple("if_attacking_apply_weak") +def go_for_the_eyes_effect(ctx: EffectContext) -> None: + """If enemy is attacking, apply Weak (Go for the Eyes).""" + if ctx.target and ctx.target.is_attacking: + amount = ctx.magic_number if ctx.magic_number > 0 else 1 + ctx.apply_status_to_target("Weak", amount) + + +@effect_simple("if_played_less_than_x_draw") +def ftl_effect(ctx: EffectContext) -> None: + """If played fewer than X cards this turn, draw 1 (FTL).""" + threshold = ctx.magic_number if ctx.magic_number > 0 else 3 + if ctx.state.cards_played_this_turn < threshold: + ctx.draw_cards(1) + + +@effect_simple("if_fatal_gain_3_energy") +def sunder_effect(ctx: EffectContext) -> None: + """If this kills the enemy, gain 3 energy (Sunder).""" + if ctx.target and ctx.target.hp <= 0: + ctx.gain_energy(3) + + +@effect_simple("only_if_no_block") +def auto_shields_effect(ctx: EffectContext) -> None: + """Only gain block if you have none (Auto-Shields).""" + if ctx.state.player.block > 0: + # Don't apply block (handled by card's base block being conditional) + ctx.extra_data["auto_shields_blocked"] = True + + +@effect_simple("remove_enemy_block") +def melter_effect(ctx: EffectContext) -> None: + """Remove all enemy block before dealing damage (Melter).""" + if ctx.target: + ctx.target.block = 0 + + +@effect_simple("damage_random_enemy_twice") +def rip_and_tear_effect(ctx: EffectContext) -> None: + """Deal damage to a random enemy twice (Rip and Tear).""" + if ctx.card: + damage = ctx.card.damage + for _ in range(2): + living = ctx.living_enemies + if living: + target = random.choice(living) + ctx.deal_damage_to_enemy(target, damage) + + +@effect_simple("draw_discard_non_zero_cost") +def scrape_effect(ctx: EffectContext) -> None: + """Draw X, discard any non-0-cost cards drawn (Scrape).""" + draw_count = ctx.magic_number if ctx.magic_number > 0 else 4 + drawn = ctx.draw_cards(draw_count) + + # Discard non-0-cost cards that were drawn + for card_id in drawn: + base_id = card_id.rstrip("+") + zero_cost_ids = { + "Claw", "Go for the Eyes", "Zap", "Turbo", "Steam", "Reboot", + "Seek", "Fission", "Rainbow", "FTL", "Beam Cell" + } + card_cost = ctx.state.card_costs.get(card_id, 1) + + if base_id not in zero_cost_ids and card_cost != 0: + if card_id in ctx.state.hand: + ctx.discard_card(card_id) + + +# ============================================================================= +# Block Effects (Defect) +# ============================================================================= + +@effect_simple("block_equals_discard_size") +def stack_effect(ctx: EffectContext) -> None: + """Gain block equal to discard pile size (Stack).""" + discard_count = len(ctx.state.discard_pile) + # Upgraded version adds +3 base block + base = 3 if ctx.is_upgraded else 0 + ctx.gain_block(discard_count + base) + + +@effect_simple("lose_1_block_permanently") +def steam_barrier_effect(ctx: EffectContext) -> None: + """This card permanently loses 1 block each time played (Steam Barrier).""" + # Track in card costs (abusing it for block tracking) + card_id = ctx.card.id if ctx.card else "Steam" + current_block_loss = ctx.extra_data.get(f"steam_barrier_loss_{card_id}", 0) + ctx.extra_data[f"steam_barrier_loss_{card_id}"] = current_block_loss + 1 + # The actual block reduction is handled in card execution + + +@effect_simple("block_x_times") +def reinforced_body_effect(ctx: EffectContext) -> None: + """Gain block X times (Reinforced Body). X = energy spent.""" + x = ctx.extra_data.get("x_cost", ctx.state.energy) + if ctx.card: + block_per = ctx.card.block + for _ in range(x): + ctx.gain_block(block_per) + + +@effect_simple("block_increases_permanently") +def genetic_algorithm_effect(ctx: EffectContext) -> None: + """Block value increases permanently (Genetic Algorithm).""" + increase = ctx.magic_number if ctx.magic_number > 0 else 2 + # Track the increase for this specific card + card_id = ctx.card.id if ctx.card else "Genetic Algorithm" + key = f"genetic_bonus_{card_id}" + current_bonus = ctx.extra_data.get(key, 0) + ctx.extra_data[key] = current_bonus + increase + + +# ============================================================================= +# Energy Effects (Defect) +# ============================================================================= + +@effect_simple("gain_1_energy_next_turn") +def charge_battery_effect(ctx: EffectContext) -> None: + """Gain 1 energy next turn (Charge Battery).""" + ctx.apply_status_to_player("EnergyNextTurn", 1) + + +@effect_simple("gain_energy_magic") +def gain_energy_magic_effect(ctx: EffectContext) -> None: + """Gain energy based on magic number (Turbo).""" + amount = ctx.magic_number if ctx.magic_number > 0 else 1 + ctx.gain_energy(amount) + + +@effect_simple("double_energy") +def double_energy_effect(ctx: EffectContext) -> None: + """Double your energy (Double Energy).""" + ctx.state.energy *= 2 + + +@effect_simple("add_void_to_discard") +def turbo_void_effect(ctx: EffectContext) -> None: + """Add a Void to discard pile (Turbo).""" + ctx.add_card_to_discard("Void") + + +@effect_simple("add_burn_to_discard") +def overclock_burn_effect(ctx: EffectContext) -> None: + """Add a Burn to discard pile (Overclock).""" + ctx.add_card_to_discard("Burn") + + +@effect_simple("gain_energy_per_x_cards_in_draw") +def aggregate_effect(ctx: EffectContext) -> None: + """Gain 1 energy per X cards in draw pile (Aggregate).""" + divisor = ctx.magic_number if ctx.magic_number > 0 else 4 + draw_size = len(ctx.state.draw_pile) + energy_gain = draw_size // divisor + ctx.gain_energy(energy_gain) + + +# ============================================================================= +# Lock-On Effect +# ============================================================================= + +@effect_simple("apply_lockon") +def lockon_effect(ctx: EffectContext) -> None: + """Apply Lock-On to target (Lock-On card).""" + amount = ctx.magic_number if ctx.magic_number > 0 else 2 + ctx.apply_status_to_target("Lock-On", amount) + + +# ============================================================================= +# Retain Effect +# ============================================================================= + +@effect_simple("retain_hand") +def equilibrium_effect(ctx: EffectContext) -> None: + """Retain your hand this turn (Equilibrium).""" + ctx.apply_status_to_player("RetainHand", 1) + + +# ============================================================================= +# Claw Special Effect +# ============================================================================= + +@effect_simple("increase_all_claw_damage") +def claw_effect(ctx: EffectContext) -> None: + """Increase damage of ALL Claws by 2 for rest of combat (Claw).""" + increase = ctx.magic_number if ctx.magic_number > 0 else 2 + current = ctx.extra_data.get("claw_bonus", 0) + ctx.extra_data["claw_bonus"] = current + increase + + +@effect_simple("reduce_cost_permanently") +def streamline_effect(ctx: EffectContext) -> None: + """Reduce cost of this card permanently (Streamline).""" + if ctx.card: + current = ctx.state.card_costs.get(ctx.card.id, ctx.card.cost) + ctx.state.card_costs[ctx.card.id] = max(0, current - 1) + + +@effect_simple("cost_reduces_per_power_played") +def force_field_effect(ctx: EffectContext) -> None: + """Cost reduces by 1 for each Power played this combat (Force Field).""" + # This is tracked passively - the actual cost reduction happens + # when calculating the card's cost + pass + + +# ============================================================================= +# Card Effect Registry for Defect +# ============================================================================= + +DEFECT_CARD_EFFECTS = { + # Basic + "Strike_B": [], + "Defend_B": [], + "Zap": ["channel_lightning"], + "Dualcast": ["evoke_orb_twice"], + + # Common Attacks + "Ball Lightning": ["channel_lightning"], + "Barrage": ["damage_per_orb"], + "Beam Cell": ["apply_vulnerable"], + "Claw": ["increase_all_claw_damage"], + "Cold Snap": ["channel_frost"], + "Compile Driver": ["draw_per_unique_orb"], + "Go for the Eyes": ["if_attacking_apply_weak"], + "Rebound": ["next_card_on_top_of_draw"], + "Streamline": ["reduce_cost_permanently"], + "Sweeping Beam": ["draw_1"], + + # Common Skills + "Conserve Battery": ["gain_1_energy_next_turn"], + "Coolheaded": ["channel_frost", "draw_cards"], + "Hologram": ["return_card_from_discard"], + "Leap": [], + "Redo": ["evoke_then_channel_same_orb"], + "Stack": ["block_equals_discard_size"], + "Steam": ["lose_1_block_permanently"], + "Turbo": ["add_void_to_discard"], + + # Uncommon Attacks + "Blizzard": ["damage_per_frost_channeled"], + "Doom and Gloom": ["channel_dark"], + "FTL": ["if_played_less_than_x_draw"], + "Lockon": ["apply_lockon"], + "Melter": ["remove_enemy_block"], + "Rip and Tear": ["damage_random_enemy_twice"], + "Scrape": ["draw_discard_non_zero_cost"], + "Sunder": ["if_fatal_gain_3_energy"], + + # Uncommon Skills + "Aggregate": ["gain_energy_per_x_cards_in_draw"], + "Auto Shields": ["only_if_no_block"], + "BootSequence": [], + "Chaos": ["channel_random_orb"], + "Chill": ["channel_frost_per_enemy"], + "Consume": ["gain_focus_lose_orb_slot"], + "Darkness": ["channel_dark"], + "Double Energy": ["double_energy"], + "Undo": ["retain_hand"], + "Force Field": ["cost_reduces_per_power_played"], + "Fusion": ["channel_plasma"], + "Genetic Algorithm": ["block_increases_permanently"], + "Glacier": ["channel_2_frost"], + "Steam Power": ["add_burn_to_discard"], + "Recycle": ["exhaust_card_gain_energy"], + "Reinforced Body": ["block_x_times"], + "Reprogram": ["lose_focus_gain_strength_dex"], + "Skim": [], # Just draw + "Tempest": ["channel_x_lightning"], + "White Noise": ["add_random_power_to_hand_cost_0"], + + # Uncommon Powers + "Capacitor": ["increase_orb_slots"], + "Defragment": ["gain_focus"], + "Heatsinks": ["draw_on_power_play"], + "Hello World": ["add_common_card_each_turn"], + "Loop": ["trigger_orb_passive_extra"], + "Self Repair": ["heal_at_end_of_combat"], + "Static Discharge": ["channel_lightning_on_damage"], + "Storm": ["channel_lightning_on_power_play"], + + # Rare Attacks + "All For One": ["return_all_0_cost_from_discard"], + "Core Surge": ["gain_artifact"], + "Hyperbeam": ["lose_focus"], + "Meteor Strike": ["channel_3_plasma"], + "Thunder Strike": ["damage_per_lightning_channeled"], + + # Rare Skills + "Amplify": ["next_power_plays_twice"], + "Fission": ["remove_orbs_gain_energy_and_draw"], + "Multi-Cast": ["evoke_first_orb_x_times"], + "Rainbow": ["channel_lightning_frost_dark"], + "Reboot": ["shuffle_hand_and_discard_draw"], + "Seek": ["search_draw_pile"], + + # Rare Powers + "Biased Cognition": ["gain_focus_lose_focus_each_turn"], + "Buffer": ["prevent_next_hp_loss"], + "Creative AI": ["add_random_power_each_turn"], + "Echo Form": ["play_first_card_twice"], + "Electrodynamics": ["lightning_hits_all", "channel_lightning"], + "Machine Learning": ["draw_extra_each_turn"], +} + + +def get_defect_card_effects(card_id: str) -> List[str]: + """Get the effect names for a Defect card.""" + base_id = card_id.rstrip("+") + return DEFECT_CARD_EFFECTS.get(base_id, []) + + +# ============================================================================= +# Register all effects on module load +# ============================================================================= + +def _ensure_effects_registered(): + """Ensure all effects are registered. Called on module import.""" + pass + + +_ensure_effects_registered() diff --git a/packages/engine/effects/orbs.py b/packages/engine/effects/orbs.py new file mode 100644 index 0000000..744dc7c --- /dev/null +++ b/packages/engine/effects/orbs.py @@ -0,0 +1,456 @@ +""" +Orb System for Defect Character. + +Defect's core mechanic is orbs - channeled elemental abilities that provide +passive effects each turn and evoke effects when triggered. + +Orb Types: +- Lightning: Passive deals damage, Evoke deals more damage +- Frost: Passive gains block, Evoke gains more block +- Dark: Passive gains damage each turn, Evoke deals accumulated damage +- Plasma: Passive gains 1 energy, Evoke gains 2 energy + +Key Mechanics: +- Orb slots: default 3, can be increased (Capacitor, Inserter, Potion of Capacity) +- Focus: increases passive and evoke values (+1 Focus = +1 to each orb's values) +- Channeling: adds orb to rightmost empty slot, evokes leftmost if full +- Evoking: triggers evoke effect and removes orb +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List, Optional, Dict, Any, TYPE_CHECKING +from enum import Enum +import random + +if TYPE_CHECKING: + from ..state.combat import CombatState, EnemyCombatState + + +class OrbType(Enum): + """Types of orbs in the game.""" + LIGHTNING = "Lightning" + FROST = "Frost" + DARK = "Dark" + PLASMA = "Plasma" + + +@dataclass +class Orb: + """ + An orb instance in combat. + + Attributes: + orb_type: The type of orb (Lightning, Frost, Dark, Plasma) + passive_amount: Base passive effect value + evoke_amount: Base evoke effect value (for Dark orbs, this accumulates) + """ + orb_type: OrbType + passive_amount: int + evoke_amount: int + + # For Dark orbs, track accumulated damage + accumulated_damage: int = 0 + + def copy(self) -> Orb: + """Create a copy of this orb.""" + orb = Orb( + orb_type=self.orb_type, + passive_amount=self.passive_amount, + evoke_amount=self.evoke_amount, + accumulated_damage=self.accumulated_damage + ) + return orb + + +# Base orb values +ORB_BASE_VALUES = { + OrbType.LIGHTNING: {"passive": 3, "evoke": 8}, + OrbType.FROST: {"passive": 2, "evoke": 5}, + OrbType.DARK: {"passive": 6, "evoke": 0}, # Dark starts at 0, accumulates + OrbType.PLASMA: {"passive": 1, "evoke": 2}, +} + + +def create_orb(orb_type: OrbType) -> Orb: + """Create a new orb of the specified type.""" + base = ORB_BASE_VALUES[orb_type] + if orb_type == OrbType.DARK: + # Dark orbs start with 6 accumulated damage + return Orb( + orb_type=orb_type, + passive_amount=base["passive"], + evoke_amount=base["evoke"], + accumulated_damage=6 + ) + return Orb( + orb_type=orb_type, + passive_amount=base["passive"], + evoke_amount=base["evoke"] + ) + + +class OrbManager: + """ + Manages orbs for a Defect combat. + + Handles channeling, evoking, passive triggers, and focus modifications. + """ + + def __init__(self, max_slots: int = 3): + """Initialize orb manager with default 3 slots.""" + self.orbs: List[Orb] = [] + self.max_slots: int = max_slots + self.focus: int = 0 + + # Track orb stats for card effects + self.lightning_channeled: int = 0 + self.frost_channeled: int = 0 + self.dark_channeled: int = 0 + self.plasma_channeled: int = 0 + + # For Electrodynamics - lightning hits all enemies + self.lightning_hits_all: bool = False + + # For Loop - extra passive triggers for rightmost orb + self.loop_stacks: int = 0 + + def copy(self) -> OrbManager: + """Create a copy of this orb manager.""" + manager = OrbManager(self.max_slots) + manager.orbs = [orb.copy() for orb in self.orbs] + manager.focus = self.focus + manager.lightning_channeled = self.lightning_channeled + manager.frost_channeled = self.frost_channeled + manager.dark_channeled = self.dark_channeled + manager.plasma_channeled = self.plasma_channeled + manager.lightning_hits_all = self.lightning_hits_all + manager.loop_stacks = self.loop_stacks + return manager + + def channel(self, orb_type: OrbType, state: CombatState) -> Dict[str, Any]: + """ + Channel an orb. + + If slots are full, evokes the leftmost orb first. + + Returns: + Dict with channel results including any evoke that occurred. + """ + result = {"channeled": orb_type.value, "evoked": None, "evoke_result": None} + + # Track channeled orbs for card effects + if orb_type == OrbType.LIGHTNING: + self.lightning_channeled += 1 + elif orb_type == OrbType.FROST: + self.frost_channeled += 1 + elif orb_type == OrbType.DARK: + self.dark_channeled += 1 + elif orb_type == OrbType.PLASMA: + self.plasma_channeled += 1 + + # If full, evoke leftmost + if len(self.orbs) >= self.max_slots: + evoke_result = self.evoke(state) + result["evoked"] = evoke_result.get("orb_type") + result["evoke_result"] = evoke_result + + # Create and add new orb + orb = create_orb(orb_type) + self.orbs.append(orb) + + return result + + def evoke(self, state: CombatState, times: int = 1) -> Dict[str, Any]: + """ + Evoke the leftmost orb (multiple times if specified). + + Returns: + Dict with evoke results. + """ + if not self.orbs: + return {"evoked": False, "orb_type": None, "effect": None} + + orb = self.orbs[0] + result = { + "evoked": True, + "orb_type": orb.orb_type.value, + "effect": None, + "times": times + } + + # Execute evoke effect (potentially multiple times for Multi-Cast) + for _ in range(times): + effect_result = self._execute_evoke(orb, state) + if result["effect"] is None: + result["effect"] = effect_result + else: + # Aggregate results + for key in ["damage", "block", "energy"]: + if key in effect_result: + result["effect"][key] = result["effect"].get(key, 0) + effect_result[key] + + # Remove the orb + self.orbs.pop(0) + + return result + + def evoke_all(self, state: CombatState, gain_resources: bool = True) -> Dict[str, Any]: + """ + Evoke all orbs (for Fission). + + Args: + state: Combat state + gain_resources: If True, gain energy and draw per orb (Fission+) + + Returns: + Dict with results + """ + result = { + "orbs_evoked": len(self.orbs), + "energy_gained": 0, + "cards_drawn": 0, + "total_damage": 0, + "total_block": 0 + } + + while self.orbs: + evoke_result = self.evoke(state) + effect = evoke_result.get("effect", {}) + result["total_damage"] += effect.get("damage", 0) + result["total_block"] += effect.get("block", 0) + + if gain_resources: + result["energy_gained"] += 1 + result["cards_drawn"] += 1 + + return result + + def _execute_evoke(self, orb: Orb, state: CombatState) -> Dict[str, Any]: + """Execute the evoke effect of an orb.""" + result = {} + focus = self.focus + + if orb.orb_type == OrbType.LIGHTNING: + # Deal 8 + Focus damage to random enemy (or all if Electrodynamics) + damage = orb.evoke_amount + focus + result["damage"] = max(0, damage) + + if self.lightning_hits_all: + # Deal to all enemies + for enemy in state.get_living_enemies(): + self._deal_damage_to_enemy(enemy, result["damage"], state) + else: + # Deal to random enemy + living = state.get_living_enemies() + if living: + target = random.choice(living) + self._deal_damage_to_enemy(target, result["damage"], state) + + elif orb.orb_type == OrbType.FROST: + # Gain 5 + Focus block + block = orb.evoke_amount + focus + result["block"] = max(0, block) + state.player.block += result["block"] + + elif orb.orb_type == OrbType.DARK: + # Deal accumulated damage to lowest HP enemy + damage = orb.accumulated_damage + result["damage"] = damage + + living = state.get_living_enemies() + if living: + # Target enemy with lowest HP + target = min(living, key=lambda e: e.hp) + self._deal_damage_to_enemy(target, damage, state) + + elif orb.orb_type == OrbType.PLASMA: + # Gain 2 energy + energy = orb.evoke_amount + result["energy"] = energy + state.energy += energy + + return result + + def trigger_passives(self, state: CombatState) -> Dict[str, Any]: + """ + Trigger all orb passive effects. + + Called at end of turn. + + Returns: + Dict with results from all passive triggers. + """ + result = { + "total_damage": 0, + "total_block": 0, + "total_energy": 0, + "dark_accumulated": 0 + } + + focus = self.focus + + for i, orb in enumerate(self.orbs): + # Check for Loop (extra triggers on rightmost orb) + extra_triggers = self.loop_stacks if i == len(self.orbs) - 1 else 0 + triggers = 1 + extra_triggers + + for _ in range(triggers): + passive_result = self._execute_passive(orb, state, focus) + result["total_damage"] += passive_result.get("damage", 0) + result["total_block"] += passive_result.get("block", 0) + result["total_energy"] += passive_result.get("energy", 0) + result["dark_accumulated"] += passive_result.get("accumulated", 0) + + return result + + def _execute_passive(self, orb: Orb, state: CombatState, focus: int) -> Dict[str, Any]: + """Execute the passive effect of an orb.""" + result = {} + + if orb.orb_type == OrbType.LIGHTNING: + # Deal 3 + Focus damage to random enemy (or all if Electrodynamics) + damage = orb.passive_amount + focus + result["damage"] = max(0, damage) + + if self.lightning_hits_all: + for enemy in state.get_living_enemies(): + self._deal_damage_to_enemy(enemy, result["damage"], state) + else: + living = state.get_living_enemies() + if living: + target = random.choice(living) + self._deal_damage_to_enemy(target, result["damage"], state) + + elif orb.orb_type == OrbType.FROST: + # Gain 2 + Focus block + block = orb.passive_amount + focus + result["block"] = max(0, block) + state.player.block += result["block"] + + elif orb.orb_type == OrbType.DARK: + # Accumulate 6 + Focus damage (does NOT deal damage passively) + accumulate = orb.passive_amount + focus + orb.accumulated_damage += max(0, accumulate) + result["accumulated"] = max(0, accumulate) + + elif orb.orb_type == OrbType.PLASMA: + # Gain 1 energy + energy = orb.passive_amount + result["energy"] = energy + state.energy += energy + + return result + + def _deal_damage_to_enemy(self, enemy: EnemyCombatState, amount: int, state: CombatState) -> int: + """Deal damage to an enemy, accounting for Lock-On.""" + if amount <= 0: + return 0 + + # Apply Lock-On multiplier (50% more damage from orbs) + lockon = enemy.statuses.get("Lock-On", 0) + if lockon > 0: + amount = int(amount * 1.5) + + # Block absorbs damage + blocked = min(enemy.block, amount) + enemy.block -= blocked + hp_damage = amount - blocked + + # Apply HP damage + enemy.hp -= hp_damage + if enemy.hp < 0: + enemy.hp = 0 + + return hp_damage + + def get_unique_orb_types(self) -> int: + """Get count of unique orb types currently channeled.""" + return len(set(orb.orb_type for orb in self.orbs)) + + def get_orb_count(self) -> int: + """Get total number of orbs channeled.""" + return len(self.orbs) + + def has_orbs(self) -> bool: + """Check if any orbs are channeled.""" + return len(self.orbs) > 0 + + def get_first_orb(self) -> Optional[Orb]: + """Get the leftmost (first to be evoked) orb.""" + return self.orbs[0] if self.orbs else None + + def get_last_orb(self) -> Optional[Orb]: + """Get the rightmost orb.""" + return self.orbs[-1] if self.orbs else None + + def add_orb_slot(self, amount: int = 1) -> None: + """Increase max orb slots.""" + self.max_slots += amount + + def remove_orb_slot(self, amount: int = 1) -> None: + """Decrease max orb slots (minimum 0).""" + self.max_slots = max(0, self.max_slots - amount) + # If we have more orbs than slots, evoke leftmost + # (This shouldn't happen in normal gameplay, but handle it) + + def modify_focus(self, amount: int) -> None: + """Modify focus amount.""" + self.focus += amount + + +def get_orb_manager(state: CombatState) -> OrbManager: + """Get or create the orb manager for a combat state.""" + if not hasattr(state, 'orb_manager') or state.orb_manager is None: + # Check for orb slot bonuses from relics/powers + base_slots = 3 + orb_slot_bonus = state.player.statuses.get("OrbSlots", 0) + state.orb_manager = OrbManager(base_slots + orb_slot_bonus) + + # Check for Focus from statuses + focus = state.player.statuses.get("Focus", 0) + state.orb_manager.focus = focus + + return state.orb_manager + + +def channel_orb(state: CombatState, orb_type: str) -> Dict[str, Any]: + """ + Channel an orb by type name. + + Args: + state: Combat state + orb_type: "Lightning", "Frost", "Dark", or "Plasma" + + Returns: + Channel result dict + """ + manager = get_orb_manager(state) + orb_enum = OrbType(orb_type) + return manager.channel(orb_enum, state) + + +def channel_random_orb(state: CombatState) -> Dict[str, Any]: + """Channel a random orb type.""" + orb_type = random.choice(list(OrbType)) + manager = get_orb_manager(state) + return manager.channel(orb_type, state) + + +def evoke_orb(state: CombatState, times: int = 1) -> Dict[str, Any]: + """Evoke the leftmost orb.""" + manager = get_orb_manager(state) + return manager.evoke(state, times) + + +def evoke_all_orbs(state: CombatState, gain_resources: bool = False) -> Dict[str, Any]: + """Evoke all orbs (Fission).""" + manager = get_orb_manager(state) + return manager.evoke_all(state, gain_resources) + + +def trigger_orb_passives(state: CombatState) -> Dict[str, Any]: + """Trigger all orb passives at end of turn.""" + manager = get_orb_manager(state) + return manager.trigger_passives(state) diff --git a/packages/engine/state/combat.py b/packages/engine/state/combat.py index b848ec2..0e6ff4c 100644 --- a/packages/engine/state/combat.py +++ b/packages/engine/state/combat.py @@ -10,7 +10,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Dict, List, Union +from typing import Any, Dict, List, Union # ============================================================================= @@ -236,6 +236,9 @@ class CombatState: # Card costs cache (card_id -> cost, for cards with modified costs) card_costs: Dict[str, int] = field(default_factory=dict) + # Defect-specific: Orb manager (lazy initialized) + orb_manager: Any = None + # ------------------------------------------------------------------------- # Core Methods # ------------------------------------------------------------------------- @@ -286,6 +289,8 @@ def copy(self) -> CombatState: relics=self.relics.copy(), # Card costs cache card_costs=self.card_costs.copy(), + # Defect orb manager + orb_manager=self.orb_manager.copy() if self.orb_manager else None, ) def is_victory(self) -> bool: diff --git a/tests/conftest.py b/tests/conftest.py index 981fe19..8538280 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,8 +11,10 @@ import pytest import sys -# Ensure project root is in path -sys.path.insert(0, '/Users/jackswitzer/Desktop/SlayTheSpireRL') +# Ensure project root is in path - works for both main repo and worktrees +import os +_project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, _project_root) from packages.engine.state.combat import ( CombatState, EntityState, EnemyCombatState, diff --git a/tests/test_defect_cards.py b/tests/test_defect_cards.py new file mode 100644 index 0000000..1afdec9 --- /dev/null +++ b/tests/test_defect_cards.py @@ -0,0 +1,986 @@ +""" +Defect Card Mechanics Tests + +Comprehensive tests for all Defect card implementations covering: +- Orb channeling (Lightning, Frost, Dark, Plasma) +- Orb evoking (Dualcast, Multi-Cast, Recursion) +- Focus manipulation (Defragment, Consume, Biased Cognition) +- Orb-counting cards (Barrage, Blizzard, Thunder Strike) +- Card manipulation (All For One, Hologram, Seek) +- Powers (Echo Form, Creative AI, Storm, etc.) +""" + +import pytest +# Path setup done in conftest.py + +from packages.engine.content.cards import ( + Card, CardType, CardRarity, CardTarget, CardColor, + get_card, + # Defect Basic + STRIKE_D, DEFEND_D, ZAP, DUALCAST, + # Defect Common Attacks + BALL_LIGHTNING, BARRAGE, BEAM_CELL, CLAW, COLD_SNAP, + COMPILE_DRIVER, GO_FOR_THE_EYES, REBOUND, STREAMLINE, SWEEPING_BEAM, + # Defect Common Skills + CHARGE_BATTERY, COOLHEADED, HOLOGRAM, LEAP_D, RECURSION, + STACK, STEAM_BARRIER, TURBO, + # Defect Uncommon Attacks + BLIZZARD, DOOM_AND_GLOOM, FTL, LOCKON, MELTER, + RIP_AND_TEAR, SCRAPE, SUNDER, + # Defect Uncommon Skills + AGGREGATE, AUTO_SHIELDS, CHAOS, CHILL, CONSUME, + DARKNESS_D, DOUBLE_ENERGY, EQUILIBRIUM_D, FORCE_FIELD, + FUSION, GENETIC_ALGORITHM, GLACIER, OVERCLOCK, RECYCLE, + REINFORCED_BODY, REPROGRAM, TEMPEST, WHITE_NOISE, + # Defect Uncommon Powers + CAPACITOR, DEFRAGMENT, HEATSINKS, HELLO_WORLD, LOOP_D, + SELF_REPAIR, STATIC_DISCHARGE, STORM_D, + # Defect Rare Attacks + ALL_FOR_ONE, CORE_SURGE, HYPERBEAM, METEOR_STRIKE, THUNDER_STRIKE, + # Defect Rare Skills + AMPLIFY, FISSION, MULTI_CAST, RAINBOW, REBOOT, SEEK, + # Defect Rare Powers + BIASED_COGNITION, BUFFER, CREATIVE_AI, ECHO_FORM, ELECTRODYNAMICS, + MACHINE_LEARNING, + # Registry + DEFECT_CARDS, ALL_CARDS, +) +from packages.engine.state.combat import ( + CombatState, EntityState, EnemyCombatState, + create_player, create_enemy, create_combat +) +from packages.engine.effects.orbs import ( + OrbManager, OrbType, Orb, get_orb_manager, channel_orb, evoke_orb +) + + +# ============================================================================= +# Test Fixtures +# ============================================================================= + +@pytest.fixture +def basic_combat(): + """Create a basic combat state for testing.""" + player = create_player(hp=70, max_hp=70) + enemy = create_enemy( + id="TestEnemy", + hp=50, + max_hp=50, + move_damage=10, + move_hits=1 + ) + state = CombatState( + player=player, + energy=3, + max_energy=3, + enemies=[enemy], + hand=[], + draw_pile=["Strike_B"] * 5 + ["Defend_B"] * 5, + discard_pile=[], + exhaust_pile=[], + ) + return state + + +@pytest.fixture +def multi_enemy_combat(): + """Create a combat with multiple enemies.""" + player = create_player(hp=70, max_hp=70) + enemies = [ + create_enemy(id="Enemy1", hp=30, move_damage=5), + create_enemy(id="Enemy2", hp=30, move_damage=8), + create_enemy(id="Enemy3", hp=30, move_damage=6), + ] + state = CombatState( + player=player, + energy=3, + max_energy=3, + enemies=enemies, + hand=[], + draw_pile=["Strike_B"] * 10, + discard_pile=[], + ) + return state + + +# ============================================================================= +# BASIC DEFECT CARDS +# ============================================================================= + +class TestBasicDefectCards: + """Test Defect's basic starting cards.""" + + def test_strike_d_base_stats(self): + """Strike (Defect): 1 cost, 6 damage.""" + card = get_card("Strike_B") + assert card.cost == 1 + assert card.damage == 6 + assert card.card_type == CardType.ATTACK + assert card.color == CardColor.BLUE + + def test_defend_d_base_stats(self): + """Defend (Defect): 1 cost, 5 block.""" + card = get_card("Defend_B") + assert card.cost == 1 + assert card.block == 5 + assert card.card_type == CardType.SKILL + assert card.color == CardColor.BLUE + + def test_zap_base_stats(self): + """Zap: 1 cost (0 upgraded), channel Lightning.""" + card = get_card("Zap") + assert card.cost == 1 + assert card.upgrade_cost == 0 + assert "channel_lightning" in card.effects + assert card.card_type == CardType.SKILL + + def test_zap_upgraded(self): + """Zap+: 0 cost.""" + card = get_card("Zap", upgraded=True) + assert card.current_cost == 0 + + def test_dualcast_base_stats(self): + """Dualcast: 1 cost (0 upgraded), evoke leftmost orb twice.""" + card = get_card("Dualcast") + assert card.cost == 1 + assert card.upgrade_cost == 0 + assert "evoke_orb_twice" in card.effects + + +# ============================================================================= +# ORB SYSTEM TESTS +# ============================================================================= + +class TestOrbSystem: + """Test the orb system mechanics.""" + + def test_orb_manager_creation(self, basic_combat): + """OrbManager should be created with default 3 slots.""" + manager = get_orb_manager(basic_combat) + assert manager.max_slots == 3 + assert manager.get_orb_count() == 0 + + def test_channel_lightning(self, basic_combat): + """Channeling Lightning should add orb and track count.""" + manager = get_orb_manager(basic_combat) + result = manager.channel(OrbType.LIGHTNING, basic_combat) + + assert result["channeled"] == "Lightning" + assert manager.get_orb_count() == 1 + assert manager.lightning_channeled == 1 + + def test_channel_frost(self, basic_combat): + """Channeling Frost should add orb and track count.""" + manager = get_orb_manager(basic_combat) + result = manager.channel(OrbType.FROST, basic_combat) + + assert result["channeled"] == "Frost" + assert manager.frost_channeled == 1 + + def test_channel_dark(self, basic_combat): + """Channeling Dark should add orb with accumulated damage.""" + manager = get_orb_manager(basic_combat) + manager.channel(OrbType.DARK, basic_combat) + + assert manager.dark_channeled == 1 + orb = manager.get_first_orb() + assert orb.orb_type == OrbType.DARK + assert orb.accumulated_damage == 6 # Base value + + def test_channel_plasma(self, basic_combat): + """Channeling Plasma should add orb.""" + manager = get_orb_manager(basic_combat) + manager.channel(OrbType.PLASMA, basic_combat) + + assert manager.plasma_channeled == 1 + orb = manager.get_first_orb() + assert orb.orb_type == OrbType.PLASMA + + def test_channel_evokes_when_full(self, basic_combat): + """Channeling when slots full should evoke leftmost.""" + manager = get_orb_manager(basic_combat) + # Fill slots + manager.channel(OrbType.LIGHTNING, basic_combat) + manager.channel(OrbType.FROST, basic_combat) + manager.channel(OrbType.DARK, basic_combat) + + assert manager.get_orb_count() == 3 + + # Channel another - should evoke Lightning first + result = manager.channel(OrbType.PLASMA, basic_combat) + + assert result["evoked"] == "Lightning" + assert manager.get_orb_count() == 3 + # First orb is now Frost + assert manager.get_first_orb().orb_type == OrbType.FROST + + def test_evoke_lightning(self, basic_combat): + """Evoking Lightning deals damage to random enemy.""" + manager = get_orb_manager(basic_combat) + manager.channel(OrbType.LIGHTNING, basic_combat) + + initial_hp = basic_combat.enemies[0].hp + result = manager.evoke(basic_combat) + + assert result["evoked"] == True + assert result["orb_type"] == "Lightning" + # Damage should be 8 (base evoke value) + assert result["effect"]["damage"] == 8 + assert basic_combat.enemies[0].hp < initial_hp + + def test_evoke_frost(self, basic_combat): + """Evoking Frost gains block.""" + manager = get_orb_manager(basic_combat) + manager.channel(OrbType.FROST, basic_combat) + + initial_block = basic_combat.player.block + result = manager.evoke(basic_combat) + + assert result["effect"]["block"] == 5 # Base evoke value + assert basic_combat.player.block == initial_block + 5 + + def test_evoke_plasma(self, basic_combat): + """Evoking Plasma gains energy.""" + manager = get_orb_manager(basic_combat) + manager.channel(OrbType.PLASMA, basic_combat) + + initial_energy = basic_combat.energy + result = manager.evoke(basic_combat) + + assert result["effect"]["energy"] == 2 + assert basic_combat.energy == initial_energy + 2 + + def test_evoke_dark_targets_lowest_hp(self, multi_enemy_combat): + """Evoking Dark deals accumulated damage to lowest HP enemy.""" + manager = get_orb_manager(multi_enemy_combat) + manager.channel(OrbType.DARK, multi_enemy_combat) + + # Set one enemy to lowest HP + multi_enemy_combat.enemies[1].hp = 10 + + result = manager.evoke(multi_enemy_combat) + + # Should target enemy with 10 HP + assert multi_enemy_combat.enemies[1].hp < 10 + + def test_focus_affects_orb_values(self, basic_combat): + """Focus should increase orb passive and evoke values.""" + manager = get_orb_manager(basic_combat) + manager.focus = 2 + manager.channel(OrbType.LIGHTNING, basic_combat) + + result = manager.evoke(basic_combat) + # 8 base + 2 focus = 10 damage + assert result["effect"]["damage"] == 10 + + def test_passive_trigger_frost(self, basic_combat): + """Frost orb passive should gain block at end of turn.""" + manager = get_orb_manager(basic_combat) + manager.channel(OrbType.FROST, basic_combat) + + initial_block = basic_combat.player.block + result = manager.trigger_passives(basic_combat) + + # Frost passive: 2 base block + assert result["total_block"] == 2 + assert basic_combat.player.block == initial_block + 2 + + def test_passive_trigger_dark_accumulates(self, basic_combat): + """Dark orb passive should accumulate damage.""" + manager = get_orb_manager(basic_combat) + manager.channel(OrbType.DARK, basic_combat) + + orb = manager.get_first_orb() + initial_accumulated = orb.accumulated_damage + + manager.trigger_passives(basic_combat) + + # Dark passive: +6 accumulated damage + assert orb.accumulated_damage == initial_accumulated + 6 + + def test_unique_orb_types(self, basic_combat): + """Should correctly count unique orb types.""" + manager = get_orb_manager(basic_combat) + manager.channel(OrbType.LIGHTNING, basic_combat) + manager.channel(OrbType.LIGHTNING, basic_combat) + manager.channel(OrbType.FROST, basic_combat) + + assert manager.get_unique_orb_types() == 2 + + +# ============================================================================= +# ORB CHANNELING CARDS +# ============================================================================= + +class TestOrbChannelingCards: + """Test cards that channel orbs.""" + + def test_ball_lightning_stats(self): + """Ball Lightning: 1 cost, 7 damage (10 upgraded), channel Lightning.""" + card = get_card("Ball Lightning") + assert card.cost == 1 + assert card.damage == 7 + assert "channel_lightning" in card.effects + assert card.card_type == CardType.ATTACK + + upgraded = get_card("Ball Lightning", upgraded=True) + assert upgraded.damage == 10 + + def test_cold_snap_stats(self): + """Cold Snap: 1 cost, 6 damage (9 upgraded), channel Frost.""" + card = get_card("Cold Snap") + assert card.cost == 1 + assert card.damage == 6 + assert "channel_frost" in card.effects + + upgraded = get_card("Cold Snap", upgraded=True) + assert upgraded.damage == 9 + + def test_coolheaded_stats(self): + """Coolheaded: 1 cost, channel Frost, draw 1 (2 upgraded).""" + card = get_card("Coolheaded") + assert card.cost == 1 + assert card.magic_number == 1 + assert "channel_frost" in card.effects + assert "draw_cards" in card.effects + + upgraded = get_card("Coolheaded", upgraded=True) + assert upgraded.magic_number == 2 + + def test_darkness_stats(self): + """Darkness: 1 cost, channel Dark (2 upgraded).""" + card = get_card("Darkness") + assert card.cost == 1 + assert "channel_dark" in card.effects + + def test_doom_and_gloom_stats(self): + """Doom and Gloom: 2 cost, 10 damage (14 upgraded), channel Dark.""" + card = get_card("Doom and Gloom") + assert card.cost == 2 + assert card.damage == 10 + assert "channel_dark" in card.effects + assert card.target == CardTarget.ALL_ENEMY + + upgraded = get_card("Doom and Gloom", upgraded=True) + assert upgraded.damage == 14 + + def test_fusion_stats(self): + """Fusion: 2 cost (1 upgraded), channel Plasma.""" + card = get_card("Fusion") + assert card.cost == 2 + assert card.upgrade_cost == 1 + assert "channel_plasma" in card.effects + + def test_glacier_stats(self): + """Glacier: 2 cost, 7 block (10 upgraded), channel 2 Frost.""" + card = get_card("Glacier") + assert card.cost == 2 + assert card.block == 7 + assert "channel_2_frost" in card.effects + + upgraded = get_card("Glacier", upgraded=True) + assert upgraded.block == 10 + + def test_chaos_stats(self): + """Chaos: 1 cost, channel 1 random orb (2 upgraded).""" + card = get_card("Chaos") + assert card.cost == 1 + assert card.magic_number == 1 + assert "channel_random_orb" in card.effects + + upgraded = get_card("Chaos", upgraded=True) + assert upgraded.magic_number == 2 + + def test_chill_stats(self): + """Chill: 0 cost, channel Frost per enemy, exhaust.""" + card = get_card("Chill") + assert card.cost == 0 + assert card.exhaust == True + assert "channel_frost_per_enemy" in card.effects + + def test_rainbow_stats(self): + """Rainbow: 2 cost, channel Lightning, Frost, Dark, exhaust.""" + card = get_card("Rainbow") + assert card.cost == 2 + assert card.exhaust == True + assert "channel_lightning_frost_dark" in card.effects + + def test_meteor_strike_stats(self): + """Meteor Strike: 5 cost, 24 damage (30 upgraded), channel 3 Plasma.""" + card = get_card("Meteor Strike") + assert card.cost == 5 + assert card.damage == 24 + assert "channel_3_plasma" in card.effects + + upgraded = get_card("Meteor Strike", upgraded=True) + assert upgraded.damage == 30 + + def test_tempest_stats(self): + """Tempest: X cost, channel X Lightning, exhaust.""" + card = get_card("Tempest") + assert card.cost == -1 # X cost + assert card.exhaust == True + assert "channel_x_lightning" in card.effects + + +# ============================================================================= +# ORB EVOKE CARDS +# ============================================================================= + +class TestOrbEvokeCards: + """Test cards that evoke orbs.""" + + def test_dualcast_evokes_twice(self, basic_combat): + """Dualcast should evoke the leftmost orb twice.""" + manager = get_orb_manager(basic_combat) + manager.channel(OrbType.FROST, basic_combat) + + # Manually evoke twice (what Dualcast does) + manager.evoke(basic_combat, times=2) + + # Should have gained 10 block (5 * 2) + assert basic_combat.player.block == 10 + # Orb should be gone + assert manager.get_orb_count() == 0 + + def test_multi_cast_stats(self): + """Multi-Cast: X cost, evoke first orb X times.""" + card = get_card("Multi-Cast") + assert card.cost == -1 # X cost + assert "evoke_first_orb_x_times" in card.effects + + def test_recursion_stats(self): + """Recursion: 1 cost (0 upgraded), evoke then channel same orb type.""" + card = get_card("Redo") # Java ID + assert card.cost == 1 + assert card.upgrade_cost == 0 + assert "evoke_then_channel_same_orb" in card.effects + + +# ============================================================================= +# FOCUS MANIPULATION CARDS +# ============================================================================= + +class TestFocusCards: + """Test cards that manipulate Focus.""" + + def test_defragment_stats(self): + """Defragment: 1 cost, gain 1 Focus (2 upgraded).""" + card = get_card("Defragment") + assert card.cost == 1 + assert card.magic_number == 1 + assert "gain_focus" in card.effects + assert card.card_type == CardType.POWER + + upgraded = get_card("Defragment", upgraded=True) + assert upgraded.magic_number == 2 + + def test_consume_stats(self): + """Consume: 2 cost, gain 2 Focus (3 upgraded), lose 1 orb slot.""" + card = get_card("Consume") + assert card.cost == 2 + assert card.magic_number == 2 + assert "gain_focus_lose_orb_slot" in card.effects + + upgraded = get_card("Consume", upgraded=True) + assert upgraded.magic_number == 3 + + def test_biased_cognition_stats(self): + """Biased Cognition: 1 cost, gain 4 Focus (5 upgraded), lose 1 Focus/turn.""" + card = get_card("Biased Cognition") + assert card.cost == 1 + assert card.magic_number == 4 + assert "gain_focus_lose_focus_each_turn" in card.effects + assert card.card_type == CardType.POWER + + upgraded = get_card("Biased Cognition", upgraded=True) + assert upgraded.magic_number == 5 + + def test_hyperbeam_stats(self): + """Hyperbeam: 2 cost, 26 damage (34 upgraded) to ALL, lose 3 Focus.""" + card = get_card("Hyperbeam") + assert card.cost == 2 + assert card.damage == 26 + assert card.magic_number == 3 + assert "lose_focus" in card.effects + assert card.target == CardTarget.ALL_ENEMY + + upgraded = get_card("Hyperbeam", upgraded=True) + assert upgraded.damage == 34 + + def test_reprogram_stats(self): + """Reprogram: 1 cost, lose 1 Focus, gain 1 Str and 1 Dex (2 upgraded).""" + card = get_card("Reprogram") + assert card.cost == 1 + assert card.magic_number == 1 + assert "lose_focus_gain_strength_dex" in card.effects + + upgraded = get_card("Reprogram", upgraded=True) + assert upgraded.magic_number == 2 + + +# ============================================================================= +# ORB COUNTING CARDS +# ============================================================================= + +class TestOrbCountingCards: + """Test cards that scale with orb counts.""" + + def test_barrage_stats(self): + """Barrage: 1 cost, 4 damage (6 upgraded) per orb.""" + card = get_card("Barrage") + assert card.cost == 1 + assert card.damage == 4 + assert "damage_per_orb" in card.effects + + upgraded = get_card("Barrage", upgraded=True) + assert upgraded.damage == 6 + + def test_compile_driver_stats(self): + """Compile Driver: 1 cost, 7 damage (10 upgraded), draw per unique orb.""" + card = get_card("Compile Driver") + assert card.cost == 1 + assert card.damage == 7 + assert "draw_per_unique_orb" in card.effects + + upgraded = get_card("Compile Driver", upgraded=True) + assert upgraded.damage == 10 + + def test_blizzard_stats(self): + """Blizzard: 1 cost, deal damage = 2 (3 upgraded) per Frost channeled to ALL.""" + card = get_card("Blizzard") + assert card.cost == 1 + assert card.magic_number == 2 + assert "damage_per_frost_channeled" in card.effects + assert card.target == CardTarget.ALL_ENEMY + + upgraded = get_card("Blizzard", upgraded=True) + assert upgraded.magic_number == 3 + + def test_thunder_strike_stats(self): + """Thunder Strike: 3 cost, 7 damage (9 upgraded) per Lightning channeled.""" + card = get_card("Thunder Strike") + assert card.cost == 3 + assert card.damage == 7 + assert "damage_per_lightning_channeled" in card.effects + + upgraded = get_card("Thunder Strike", upgraded=True) + assert upgraded.damage == 9 + + +# ============================================================================= +# POWER CARDS +# ============================================================================= + +class TestDefectPowers: + """Test Defect power cards.""" + + def test_capacitor_stats(self): + """Capacitor: 1 cost, gain 2 orb slots (3 upgraded).""" + card = get_card("Capacitor") + assert card.cost == 1 + assert card.magic_number == 2 + assert "increase_orb_slots" in card.effects + assert card.card_type == CardType.POWER + + upgraded = get_card("Capacitor", upgraded=True) + assert upgraded.magic_number == 3 + + def test_heatsinks_stats(self): + """Heatsinks: 1 cost, draw 1 (2 upgraded) when you play a Power.""" + card = get_card("Heatsinks") + assert card.cost == 1 + assert card.magic_number == 1 + assert "draw_on_power_play" in card.effects + + upgraded = get_card("Heatsinks", upgraded=True) + assert upgraded.magic_number == 2 + + def test_loop_stats(self): + """Loop: 1 cost, rightmost orb triggers passive 1 (2 upgraded) extra time.""" + card = get_card("Loop") + assert card.cost == 1 + assert card.magic_number == 1 + assert "trigger_orb_passive_extra" in card.effects + + upgraded = get_card("Loop", upgraded=True) + assert upgraded.magic_number == 2 + + def test_static_discharge_stats(self): + """Static Discharge: 1 cost, channel 1 Lightning (2 upgraded) when taking damage.""" + card = get_card("Static Discharge") + assert card.cost == 1 + assert card.magic_number == 1 + assert "channel_lightning_on_damage" in card.effects + + upgraded = get_card("Static Discharge", upgraded=True) + assert upgraded.magic_number == 2 + + def test_storm_stats(self): + """Storm: 1 cost, channel Lightning when playing a Power.""" + card = get_card("Storm") + assert card.cost == 1 + assert "channel_lightning_on_power_play" in card.effects + + def test_echo_form_stats(self): + """Echo Form: 3 cost, ethereal, first card each turn plays twice.""" + card = get_card("Echo Form") + assert card.cost == 3 + assert card.ethereal == True + assert "play_first_card_twice" in card.effects + + def test_creative_ai_stats(self): + """Creative AI: 3 cost (2 upgraded), add random Power to hand each turn.""" + card = get_card("Creative AI") + assert card.cost == 3 + assert card.upgrade_cost == 2 + assert "add_random_power_each_turn" in card.effects + + def test_electrodynamics_stats(self): + """Electrodynamics: 2 cost, Lightning hits all, channel 2 (3 upgraded) Lightning.""" + card = get_card("Electrodynamics") + assert card.cost == 2 + assert card.magic_number == 2 + assert "lightning_hits_all" in card.effects + assert "channel_lightning" in card.effects + + upgraded = get_card("Electrodynamics", upgraded=True) + assert upgraded.magic_number == 3 + + def test_machine_learning_stats(self): + """Machine Learning: 1 cost, draw 1 additional card each turn.""" + card = get_card("Machine Learning") + assert card.cost == 1 + assert "draw_extra_each_turn" in card.effects + + def test_buffer_stats(self): + """Buffer: 2 cost, prevent next 1 HP loss (2 upgraded).""" + card = get_card("Buffer") + assert card.cost == 2 + assert card.magic_number == 1 + assert "prevent_next_hp_loss" in card.effects + + upgraded = get_card("Buffer", upgraded=True) + assert upgraded.magic_number == 2 + + def test_self_repair_stats(self): + """Self Repair: 1 cost, heal 7 HP (10 upgraded) at end of combat.""" + card = get_card("Self Repair") + assert card.cost == 1 + assert card.magic_number == 7 + assert "heal_at_end_of_combat" in card.effects + + upgraded = get_card("Self Repair", upgraded=True) + assert upgraded.magic_number == 10 + + +# ============================================================================= +# CARD MANIPULATION CARDS +# ============================================================================= + +class TestCardManipulation: + """Test cards that manipulate other cards.""" + + def test_all_for_one_stats(self): + """All For One: 2 cost, 10 damage (14 upgraded), return 0-cost from discard.""" + card = get_card("All For One") + assert card.cost == 2 + assert card.damage == 10 + assert "return_all_0_cost_from_discard" in card.effects + + upgraded = get_card("All For One", upgraded=True) + assert upgraded.damage == 14 + + def test_hologram_stats(self): + """Hologram: 1 cost, 3 block (5 upgraded), return card from discard, exhaust.""" + card = get_card("Hologram") + assert card.cost == 1 + assert card.block == 3 + assert card.exhaust == True + assert "return_card_from_discard" in card.effects + + upgraded = get_card("Hologram", upgraded=True) + assert upgraded.block == 5 + + def test_seek_stats(self): + """Seek: 0 cost, search draw for 1 card (2 upgraded), exhaust.""" + card = get_card("Seek") + assert card.cost == 0 + assert card.magic_number == 1 + assert card.exhaust == True + assert "search_draw_pile" in card.effects + + upgraded = get_card("Seek", upgraded=True) + assert upgraded.magic_number == 2 + + def test_reboot_stats(self): + """Reboot: 0 cost, shuffle all, draw 4 (6 upgraded), exhaust.""" + card = get_card("Reboot") + assert card.cost == 0 + assert card.magic_number == 4 + assert card.exhaust == True + assert "shuffle_hand_and_discard_draw" in card.effects + + upgraded = get_card("Reboot", upgraded=True) + assert upgraded.magic_number == 6 + + def test_fission_stats(self): + """Fission: 0 cost, remove all orbs. Upgraded: gain energy and draw per orb.""" + card = get_card("Fission") + assert card.cost == 0 + assert card.exhaust == True + assert "remove_orbs_gain_energy_and_draw" in card.effects + + +# ============================================================================= +# CONDITIONAL CARDS +# ============================================================================= + +class TestConditionalCards: + """Test cards with conditional effects.""" + + def test_go_for_the_eyes_stats(self): + """Go for the Eyes: 0 cost, 3 damage (4 upgraded), Weak 1 (2 upgraded) if attacking.""" + card = get_card("Go for the Eyes") + assert card.cost == 0 + assert card.damage == 3 + assert card.magic_number == 1 + assert "if_attacking_apply_weak" in card.effects + + upgraded = get_card("Go for the Eyes", upgraded=True) + assert upgraded.damage == 4 + assert upgraded.magic_number == 2 + + def test_ftl_stats(self): + """FTL: 0 cost, 5 damage (6 upgraded), draw 1 if < 3 (4 upgraded) cards played.""" + card = get_card("FTL") + assert card.cost == 0 + assert card.damage == 5 + assert card.magic_number == 3 + assert "if_played_less_than_x_draw" in card.effects + + upgraded = get_card("FTL", upgraded=True) + assert upgraded.damage == 6 + assert upgraded.magic_number == 4 + + def test_sunder_stats(self): + """Sunder: 3 cost, 24 damage (32 upgraded), gain 3 energy if fatal.""" + card = get_card("Sunder") + assert card.cost == 3 + assert card.damage == 24 + assert "if_fatal_gain_3_energy" in card.effects + + upgraded = get_card("Sunder", upgraded=True) + assert upgraded.damage == 32 + + def test_auto_shields_stats(self): + """Auto-Shields: 1 cost, 11 block (15 upgraded) only if no block.""" + card = get_card("Auto Shields") + assert card.cost == 1 + assert card.block == 11 + assert "only_if_no_block" in card.effects + + upgraded = get_card("Auto Shields", upgraded=True) + assert upgraded.block == 15 + + +# ============================================================================= +# SPECIAL MECHANICS CARDS +# ============================================================================= + +class TestSpecialMechanics: + """Test cards with unique mechanics.""" + + def test_claw_stats(self): + """Claw: 0 cost, 3 damage (5 upgraded), increase all Claw damage by 2.""" + card = get_card("Claw") + assert card.cost == 0 + assert card.damage == 3 + assert card.magic_number == 2 + assert "increase_all_claw_damage" in card.effects + + upgraded = get_card("Claw", upgraded=True) + assert upgraded.damage == 5 + + def test_streamline_stats(self): + """Streamline: 2 cost, 15 damage (20 upgraded), cost reduces by 1 permanently.""" + card = get_card("Streamline") + assert card.cost == 2 + assert card.damage == 15 + assert "reduce_cost_permanently" in card.effects + + upgraded = get_card("Streamline", upgraded=True) + assert upgraded.damage == 20 + + def test_genetic_algorithm_stats(self): + """Genetic Algorithm: 1 cost, 1 block, gain 2 (3 upgraded) permanent block.""" + card = get_card("Genetic Algorithm") + assert card.cost == 1 + assert card.block == 1 + assert card.magic_number == 2 + assert card.exhaust == True + assert "block_increases_permanently" in card.effects + + upgraded = get_card("Genetic Algorithm", upgraded=True) + assert upgraded.magic_number == 3 + + def test_stack_stats(self): + """Stack: 1 cost, block = discard pile size (+3 upgraded).""" + card = get_card("Stack") + assert card.cost == 1 + assert "block_equals_discard_size" in card.effects + + def test_force_field_stats(self): + """Force Field: 4 cost, 12 block (16 upgraded), cost reduces per Power played.""" + card = get_card("Force Field") + assert card.cost == 4 + assert card.block == 12 + assert "cost_reduces_per_power_played" in card.effects + + upgraded = get_card("Force Field", upgraded=True) + assert upgraded.block == 16 + + def test_double_energy_stats(self): + """Double Energy: 1 cost (0 upgraded), double energy, exhaust.""" + card = get_card("Double Energy") + assert card.cost == 1 + assert card.upgrade_cost == 0 + assert card.exhaust == True + assert "double_energy" in card.effects + + def test_lockon_stats(self): + """Lock-On: 1 cost, 8 damage (11 upgraded), apply 2 (3 upgraded) Lock-On.""" + card = get_card("Lockon") + assert card.cost == 1 + assert card.damage == 8 + assert card.magic_number == 2 + assert "apply_lockon" in card.effects + + upgraded = get_card("Lockon", upgraded=True) + assert upgraded.damage == 11 + assert upgraded.magic_number == 3 + + +# ============================================================================= +# CARD REGISTRY TESTS +# ============================================================================= + +class TestDefectCardRegistry: + """Test Defect card registry completeness.""" + + def test_all_defect_cards_registered(self): + """All Defect cards should be in DEFECT_CARDS registry.""" + expected_cards = [ + # Basic + "Strike_B", "Defend_B", "Zap", "Dualcast", + # Common Attacks + "Ball Lightning", "Barrage", "Beam Cell", "Claw", + "Cold Snap", "Compile Driver", "Go for the Eyes", + "Rebound", "Streamline", "Sweeping Beam", + # Common Skills + "Conserve Battery", "Coolheaded", "Hologram", "Leap", + "Redo", "Stack", "Steam", "Turbo", + # Uncommon Attacks + "Blizzard", "Doom and Gloom", "FTL", "Lockon", + "Melter", "Rip and Tear", "Scrape", "Sunder", + # Uncommon Skills + "Aggregate", "Auto Shields", "BootSequence", "Chaos", + "Chill", "Consume", "Darkness", "Double Energy", + "Undo", "Force Field", "Fusion", "Genetic Algorithm", + "Glacier", "Steam Power", "Recycle", "Reinforced Body", + "Reprogram", "Skim", "Tempest", "White Noise", + # Uncommon Powers + "Capacitor", "Defragment", "Heatsinks", "Hello World", + "Loop", "Self Repair", "Static Discharge", "Storm", + # Rare Attacks + "All For One", "Core Surge", "Hyperbeam", + "Meteor Strike", "Thunder Strike", + # Rare Skills + "Amplify", "Fission", "Multi-Cast", "Rainbow", + "Reboot", "Seek", + # Rare Powers + "Biased Cognition", "Buffer", "Creative AI", + "Echo Form", "Electrodynamics", "Machine Learning", + ] + + for card_id in expected_cards: + assert card_id in DEFECT_CARDS, f"Card {card_id} not in DEFECT_CARDS" + + def test_defect_card_count(self): + """Defect should have correct number of cards.""" + # 4 basic + 10 common attacks + 8 common skills + + # 8 uncommon attacks + 20 uncommon skills + 8 uncommon powers + + # 5 rare attacks + 6 rare skills + 6 rare powers = 75 total + assert len(DEFECT_CARDS) >= 70 # Allow some flexibility + + def test_all_defect_cards_have_blue_color(self): + """All Defect cards should have blue color.""" + for card_id, card in DEFECT_CARDS.items(): + assert card.color == CardColor.BLUE, f"{card_id} should be BLUE" + + +# ============================================================================= +# INTEGRATION TESTS +# ============================================================================= + +class TestDefectIntegration: + """Integration tests for Defect mechanics.""" + + def test_orb_manager_persists_across_actions(self, basic_combat): + """Orb manager should persist state across multiple actions.""" + manager = get_orb_manager(basic_combat) + + # Channel some orbs + channel_orb(basic_combat, "Lightning") + channel_orb(basic_combat, "Frost") + + # Get manager again - should have same orbs + manager2 = get_orb_manager(basic_combat) + assert manager2 is manager + assert manager2.get_orb_count() == 2 + + def test_combat_state_copy_preserves_orbs(self, basic_combat): + """Copying combat state should preserve orb manager state.""" + manager = get_orb_manager(basic_combat) + manager.channel(OrbType.LIGHTNING, basic_combat) + manager.channel(OrbType.FROST, basic_combat) + manager.focus = 2 + + # Copy state + copy = basic_combat.copy() + + # Check orb manager was copied + assert copy.orb_manager is not None + assert copy.orb_manager is not basic_combat.orb_manager + assert copy.orb_manager.get_orb_count() == 2 + assert copy.orb_manager.focus == 2 + + def test_electrodynamics_makes_lightning_hit_all(self, multi_enemy_combat): + """Electrodynamics should make Lightning orbs hit all enemies.""" + manager = get_orb_manager(multi_enemy_combat) + manager.lightning_hits_all = True + manager.channel(OrbType.LIGHTNING, multi_enemy_combat) + + initial_hps = [e.hp for e in multi_enemy_combat.enemies] + + # Evoke lightning + manager.evoke(multi_enemy_combat) + + # All enemies should have taken damage + for i, enemy in enumerate(multi_enemy_combat.enemies): + assert enemy.hp < initial_hps[i], f"Enemy {i} should have taken damage" + + def test_loop_triggers_extra_passive(self, basic_combat): + """Loop should trigger rightmost orb's passive extra times.""" + manager = get_orb_manager(basic_combat) + manager.loop_stacks = 1 + manager.channel(OrbType.FROST, basic_combat) + + initial_block = basic_combat.player.block + + # Trigger passives + result = manager.trigger_passives(basic_combat) + + # Should have 2x passive (1 base + 1 loop) = 4 block + assert result["total_block"] == 4 + assert basic_combat.player.block == initial_block + 4