mirror of
https://github.com/Bratah123/Spirit-PTCGO.git
synced 2026-09-09 05:05:30 -05:00
FEAT: more scripts
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability, unimplemented
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability
|
||||
from spirit.game.attributes import PokemonTypes, PokemonStage, Rarities
|
||||
from spirit.game.card_effects.attacks_common import discard_opponent_energy_attack
|
||||
|
||||
card = PokemonCardDef(
|
||||
guid="43be4010-a927-5e00-9489-9b5eac6c9ae7",
|
||||
@@ -28,7 +29,7 @@ card = PokemonCardDef(
|
||||
game_text="Discard an Energy from your opponent's Active Pok\u00e9mon.",
|
||||
cost={PokemonTypes.FIGHTING: 1, PokemonTypes.COLORLESS: 2},
|
||||
damage=120,
|
||||
effect=unimplemented,
|
||||
effect=discard_opponent_energy_attack(),
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability, unimplemented
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability
|
||||
from spirit.game.attributes import PokemonTypes, PokemonStage, Rarities
|
||||
from spirit.game.card_effects.attacks_common import discard_opponent_energy_attack
|
||||
|
||||
card = PokemonCardDef(
|
||||
guid="5161798e-9889-5e5d-bb1a-fd0496373bac",
|
||||
@@ -28,7 +29,7 @@ card = PokemonCardDef(
|
||||
game_text="Discard an Energy from your opponent's Active Pok\u00e9mon.",
|
||||
cost={PokemonTypes.FIGHTING: 1, PokemonTypes.COLORLESS: 2},
|
||||
damage=120,
|
||||
effect=unimplemented,
|
||||
effect=discard_opponent_energy_attack(),
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability, unimplemented
|
||||
from spirit.game.attributes import PokemonTypes, PokemonStage, Rarities
|
||||
from spirit.game.card_effects.attacks_common import condition_attack
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack
|
||||
from spirit.game.attributes import PokemonTypes, PokemonStage, Rarities, SpecialConditions
|
||||
|
||||
card = PokemonCardDef(
|
||||
guid="76510cfd-6267-51e3-87ff-07fd08cbdaef",
|
||||
@@ -21,10 +22,10 @@ card = PokemonCardDef(
|
||||
abilities=[
|
||||
Attack(
|
||||
title="Soothing Scent",
|
||||
game_text="Your opponent's Active Pok\u00e9mon is now Asleep.",
|
||||
game_text="Your opponent's Active Pokémon is now Asleep.",
|
||||
cost={PokemonTypes.GRASS: 1, PokemonTypes.COLORLESS: 2},
|
||||
damage=80,
|
||||
effect=unimplemented,
|
||||
effect=condition_attack(SpecialConditions.ASLEEP),
|
||||
),
|
||||
Attack(
|
||||
title="Megaton Lariat",
|
||||
@@ -32,4 +33,4 @@ card = PokemonCardDef(
|
||||
damage=140,
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability, unimplemented
|
||||
from spirit.game.card_effects.support_common import opponent_switches
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability
|
||||
from spirit.game.attributes import PokemonTypes, PokemonStage, Rarities
|
||||
|
||||
|
||||
async def gyro_ball(ctx):
|
||||
"""Switch self with a Benched Pokemon; if you do, the opponent switches too."""
|
||||
await ctx.deal_damage()
|
||||
bench = ctx.my_bench()
|
||||
if not bench:
|
||||
return
|
||||
target = await ctx.choose_pokemon(bench, "Choose your new Active Pokémon")
|
||||
await ctx.switch_active(ctx.player_id, target or bench[0])
|
||||
await ctx.flush_choreography()
|
||||
await opponent_switches(ctx)
|
||||
|
||||
|
||||
card = PokemonCardDef(
|
||||
guid="37bcbf58-8ef1-5834-b648-728a1c85a641",
|
||||
key="SWSH2",
|
||||
@@ -27,10 +41,10 @@ card = PokemonCardDef(
|
||||
),
|
||||
Attack(
|
||||
title="Gyro Ball",
|
||||
game_text="Switch this Pok\u00e9mon with 1 of your Benched Pok\u00e9mon. If you do, your opponent switches their Active Pok\u00e9mon with 1 of their Benched Pok\u00e9mon.",
|
||||
game_text="Switch this Pokémon with 1 of your Benched Pokémon. If you do, your opponent switches their Active Pokémon with 1 of their Benched Pokémon.",
|
||||
cost={PokemonTypes.METAL: 1, PokemonTypes.COLORLESS: 2},
|
||||
damage=70,
|
||||
effect=unimplemented,
|
||||
effect=gyro_ball,
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability, unimplemented
|
||||
from spirit.game.attributes import PokemonTypes, PokemonStage, Rarities
|
||||
from spirit.game.card_effects.attacks_common import condition_attack
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack
|
||||
from spirit.game.attributes import PokemonTypes, PokemonStage, Rarities, SpecialConditions
|
||||
|
||||
card = PokemonCardDef(
|
||||
guid="ae931c91-5f26-58ef-a751-ea6360a3d3b9",
|
||||
@@ -21,10 +22,10 @@ card = PokemonCardDef(
|
||||
abilities=[
|
||||
Attack(
|
||||
title="Dazzle Blast",
|
||||
game_text="Your opponent's Active Pok\u00e9mon is now Confused.",
|
||||
game_text="Your opponent's Active Pokémon is now Confused.",
|
||||
cost={PokemonTypes.LIGHTNING: 1},
|
||||
damage=50,
|
||||
effect=unimplemented,
|
||||
effect=condition_attack(SpecialConditions.CONFUSED),
|
||||
),
|
||||
Attack(
|
||||
title="Electric Ball",
|
||||
@@ -32,4 +33,4 @@ card = PokemonCardDef(
|
||||
damage=130,
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability, unimplemented
|
||||
from spirit.game.attributes import PokemonTypes, PokemonStage, Rarities
|
||||
from spirit.game.card_effects.attacks_common import condition_attack
|
||||
from spirit.game.card_effects.passives_common import protect_next_turn
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack
|
||||
from spirit.game.attributes import PokemonTypes, PokemonStage, Rarities, SpecialConditions
|
||||
|
||||
card = PokemonCardDef(
|
||||
guid="a805e3bb-4d0e-5a00-ba48-67253452ded6",
|
||||
@@ -21,17 +23,17 @@ card = PokemonCardDef(
|
||||
abilities=[
|
||||
Attack(
|
||||
title="Hard Face",
|
||||
game_text="During your opponent's next turn, this Pok\u00e9mon takes 60 less damage from attacks (after applying Weakness and Resistance).",
|
||||
game_text="During your opponent's next turn, this Pokémon takes 60 less damage from attacks (after applying Weakness and Resistance).",
|
||||
cost={PokemonTypes.WATER: 1, PokemonTypes.COLORLESS: 2},
|
||||
damage=90,
|
||||
effect=unimplemented,
|
||||
effect=protect_next_turn(reduce=60),
|
||||
),
|
||||
Attack(
|
||||
title="Cold Breath",
|
||||
game_text="Your opponent's Active Pok\u00e9mon is now Asleep.",
|
||||
game_text="Your opponent's Active Pokémon is now Asleep.",
|
||||
cost={PokemonTypes.WATER: 1, PokemonTypes.COLORLESS: 3},
|
||||
damage=130,
|
||||
effect=unimplemented,
|
||||
effect=condition_attack(SpecialConditions.ASLEEP),
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
from spirit.game.card_effects.pokemon import condition_attack
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability, unimplemented
|
||||
from spirit.game.card_effects.attacks_common import condition_attack
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability, Triggers
|
||||
from spirit.game.attributes import PokemonTypes, PokemonStage, Rarities, SpecialConditions
|
||||
from spirit.game.session.effects import is_evolution_pokemon
|
||||
|
||||
|
||||
async def spider_net(ctx):
|
||||
"""On evolve: you may gust one of the opponent's Benched Evolution Pokemon."""
|
||||
candidates = [p for p in ctx.opponent_bench() if is_evolution_pokemon(p)]
|
||||
if not candidates:
|
||||
return
|
||||
if not await ctx.ask_yes_no(
|
||||
"Switch 1 of your opponent's Benched Evolution Pokémon with their Active Pokémon?"):
|
||||
return
|
||||
target = await ctx.choose_pokemon(
|
||||
candidates, "Choose the opponent's new Active Pokémon"
|
||||
)
|
||||
if target is not None:
|
||||
await ctx.switch_active(ctx.opponent_id, target)
|
||||
|
||||
|
||||
card = PokemonCardDef(
|
||||
guid="41922bdd-3fa5-526a-aaa3-1c10938a5733",
|
||||
@@ -22,15 +39,16 @@ card = PokemonCardDef(
|
||||
abilities=[
|
||||
Ability(
|
||||
title="Spider Net",
|
||||
game_text="When you play this Pok\u00e9mon from your hand to evolve 1 of your Pok\u00e9mon during your turn, you may switch 1 of your opponent's Benched Evolution Pok\u00e9mon with their Active Pok\u00e9mon.",
|
||||
effect=unimplemented,
|
||||
game_text="When you play this Pokémon from your hand to evolve 1 of your Pokémon during your turn, you may switch 1 of your opponent's Benched Evolution Pokémon with their Active Pokémon.",
|
||||
trigger=Triggers.ON_EVOLVE,
|
||||
effect=spider_net,
|
||||
),
|
||||
Attack(
|
||||
title="Poison Sting",
|
||||
game_text="Your opponent's Active Pok\u00e9mon is now Poisoned.",
|
||||
game_text="Your opponent's Active Pokémon is now Poisoned.",
|
||||
cost={PokemonTypes.DARKNESS: 1, PokemonTypes.COLORLESS: 1},
|
||||
damage=30,
|
||||
effect=condition_attack(SpecialConditions.POISONED),
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability, unimplemented
|
||||
from spirit.game.card_effects.support_common import attach_from_discard
|
||||
from spirit.game.card_effects.trainers import is_basic_energy_card
|
||||
from spirit.game.card_effects.pokemon import v_transformation, v_transformation_condition
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability, Activations
|
||||
from spirit.game.attributes import PokemonTypes, PokemonStage, Rarities
|
||||
|
||||
card = PokemonCardDef(
|
||||
@@ -21,13 +24,18 @@ card = PokemonCardDef(
|
||||
Ability(
|
||||
title="V Transformation",
|
||||
game_text="Once during your turn, you may choose a Basic Pok\u00e9mon V from your discard pile and switch it with this Pok\u00e9mon. Any attached cards, damage counters, Special Conditions, turns in play, and any other effects remain on the new Pok\u00e9mon.",
|
||||
effect=unimplemented,
|
||||
activation=Activations.ONCE_PER_TURN,
|
||||
condition=v_transformation_condition,
|
||||
effect=v_transformation,
|
||||
),
|
||||
Attack(
|
||||
title="Stick On",
|
||||
game_text="Attach a basic Energy card from your discard pile to this Pok\u00e9mon.",
|
||||
cost={PokemonTypes.COLORLESS: 1},
|
||||
effect=unimplemented,
|
||||
effect=attach_from_discard(
|
||||
is_basic_energy_card, count=1, target="self",
|
||||
prompt="Choose a basic Energy card to attach to this Pokémon",
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability, unimplemented
|
||||
from spirit.game.attributes import PokemonTypes, PokemonStage, Rarities
|
||||
from spirit.game.card_effects.attacks_common import condition_attack
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack
|
||||
from spirit.game.attributes import PokemonTypes, PokemonStage, Rarities, SpecialConditions
|
||||
|
||||
card = PokemonCardDef(
|
||||
guid="b2217a27-ab61-5380-861e-be283847729a",
|
||||
@@ -21,10 +22,10 @@ card = PokemonCardDef(
|
||||
abilities=[
|
||||
Attack(
|
||||
title="Split Spiral Punch",
|
||||
game_text="Your opponent's Active Pok\u00e9mon is now Confused.",
|
||||
game_text="Your opponent's Active Pokémon is now Confused.",
|
||||
cost={PokemonTypes.FIGHTING: 1, PokemonTypes.COLORLESS: 1},
|
||||
damage=40,
|
||||
effect=unimplemented,
|
||||
effect=condition_attack(SpecialConditions.CONFUSED),
|
||||
),
|
||||
Attack(
|
||||
title="Strength",
|
||||
@@ -32,4 +33,4 @@ card = PokemonCardDef(
|
||||
damage=130,
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability, unimplemented
|
||||
from spirit.game.card_effects.attacks_common import snipe_attack
|
||||
from spirit.game.card_effects.support_common import switch_self_attack
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability
|
||||
from spirit.game.attributes import PokemonTypes, PokemonStage, Rarities
|
||||
|
||||
card = PokemonCardDef(
|
||||
@@ -23,14 +25,14 @@ card = PokemonCardDef(
|
||||
game_text="You may switch this Pok\u00e9mon with 1 of your Benched Pok\u00e9mon.",
|
||||
cost={PokemonTypes.LIGHTNING: 1},
|
||||
damage=30,
|
||||
effect=unimplemented,
|
||||
effect=switch_self_attack(optional=True),
|
||||
),
|
||||
Attack(
|
||||
title="Electrobullet",
|
||||
game_text="This attack also does 30 damage to 1 of your opponent's Benched Pok\u00e9mon. (Don't apply Weakness and Resistance for Benched Pok\u00e9mon.)",
|
||||
cost={PokemonTypes.LIGHTNING: 2, PokemonTypes.COLORLESS: 1},
|
||||
damage=120,
|
||||
effect=unimplemented,
|
||||
effect=snipe_attack(30, also_base=True),
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -1,4 +1,6 @@
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability, unimplemented
|
||||
from spirit.game.card_effects.attacks_common import snipe_attack
|
||||
from spirit.game.card_effects.support_common import switch_self_attack
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability
|
||||
from spirit.game.attributes import PokemonTypes, PokemonStage, Rarities
|
||||
|
||||
card = PokemonCardDef(
|
||||
@@ -23,14 +25,14 @@ card = PokemonCardDef(
|
||||
game_text="You may switch this Pok\u00e9mon with 1 of your Benched Pok\u00e9mon.",
|
||||
cost={PokemonTypes.LIGHTNING: 1},
|
||||
damage=30,
|
||||
effect=unimplemented,
|
||||
effect=switch_self_attack(optional=True),
|
||||
),
|
||||
Attack(
|
||||
title="Electrobullet",
|
||||
game_text="This attack also does 30 damage to 1 of your opponent's Benched Pok\u00e9mon. (Don't apply Weakness and Resistance for Benched Pok\u00e9mon.)",
|
||||
cost={PokemonTypes.LIGHTNING: 2, PokemonTypes.COLORLESS: 1},
|
||||
damage=120,
|
||||
effect=unimplemented,
|
||||
effect=snipe_attack(30, also_base=True),
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability, unimplemented
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability
|
||||
from spirit.game.attributes import PokemonTypes, PokemonStage, Rarities
|
||||
from spirit.game.card_effects.attacks_common import mill_attack, recoil_attack
|
||||
|
||||
card = PokemonCardDef(
|
||||
guid="54717133-93ac-5126-b97d-98e5a2323066",
|
||||
@@ -25,14 +26,14 @@ card = PokemonCardDef(
|
||||
game_text="Discard the top card of your opponent's deck.",
|
||||
cost={PokemonTypes.METAL: 1, PokemonTypes.COLORLESS: 2},
|
||||
damage=150,
|
||||
effect=unimplemented,
|
||||
effect=mill_attack(1),
|
||||
),
|
||||
Attack(
|
||||
title="Max Take Down",
|
||||
game_text="This Pok\u00e9mon also does 30 damage to itself.",
|
||||
cost={PokemonTypes.METAL: 2, PokemonTypes.COLORLESS: 3},
|
||||
damage=270,
|
||||
effect=unimplemented,
|
||||
effect=recoil_attack(30),
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability, unimplemented
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability
|
||||
from spirit.game.attributes import PokemonTypes, PokemonStage, Rarities
|
||||
from spirit.game.card_effects.attacks_common import bonus_if, has_damage, snipe_attack
|
||||
|
||||
card = PokemonCardDef(
|
||||
guid="048c0848-edc8-5b3e-8dad-f3588c2994be",
|
||||
@@ -21,18 +22,18 @@ card = PokemonCardDef(
|
||||
abilities=[
|
||||
Attack(
|
||||
title="Rock Slide",
|
||||
game_text="This attack also does 30 damage to 2 of your opponent's Benched Pok\u00e9mon. (Don't apply Weakness and Resistance for Benched Pok\u00e9mon.)",
|
||||
game_text="This attack also does 30 damage to 2 of your opponent's Benched Pokémon. (Don't apply Weakness and Resistance for Benched Pokémon.)",
|
||||
cost={PokemonTypes.METAL: 1, PokemonTypes.COLORLESS: 2},
|
||||
damage=90,
|
||||
effect=unimplemented,
|
||||
effect=snipe_attack(30, count=2, also_base=True),
|
||||
),
|
||||
Attack(
|
||||
title="Merciless Strike",
|
||||
game_text="If your opponent's Active Pok\u00e9mon already has any damage counters on it, this attack does 150 more damage.",
|
||||
game_text="If your opponent's Active Pokémon already has any damage counters on it, this attack does 150 more damage.",
|
||||
cost={PokemonTypes.METAL: 1, PokemonTypes.COLORLESS: 4},
|
||||
damage=150,
|
||||
damage_operator="+",
|
||||
effect=unimplemented,
|
||||
effect=bonus_if(has_damage("defender"), 150),
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability, unimplemented
|
||||
from spirit.game.card_effects.support_common import search_attach_energy
|
||||
from spirit.game.card_effects.trainers import is_basic_energy_card
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability, is_pokemon_v
|
||||
from spirit.game.attributes import PokemonTypes, PokemonStage, Rarities
|
||||
|
||||
trinity_charge = search_attach_energy(
|
||||
is_basic_energy_card, count=3,
|
||||
target_pred=lambda p: is_pokemon_v(p.archetype_id),
|
||||
prompt="Choose up to 3 basic Energy cards to attach to your Pokémon V.",
|
||||
)
|
||||
|
||||
card = PokemonCardDef(
|
||||
guid="ba8e44a9-5efd-5592-ac85-f1f69a39ae5c",
|
||||
key="SWSH9",
|
||||
@@ -20,9 +28,9 @@ card = PokemonCardDef(
|
||||
abilities=[
|
||||
Attack(
|
||||
title="Trinity Charge",
|
||||
game_text="Search your deck for up to 3 basic Energy cards and attach them to your Pok\u00e9mon V in any way you like. Then, shuffle your deck.",
|
||||
game_text="Search your deck for up to 3 basic Energy cards and attach them to your Pokémon V in any way you like. Then, shuffle your deck.",
|
||||
cost={PokemonTypes.COLORLESS: 2},
|
||||
effect=unimplemented,
|
||||
effect=trinity_charge,
|
||||
),
|
||||
Attack(
|
||||
title="Power Edge",
|
||||
@@ -30,4 +38,4 @@ card = PokemonCardDef(
|
||||
damage=130,
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability, unimplemented
|
||||
from spirit.game.card_effects.support_common import search_attach_energy
|
||||
from spirit.game.card_effects.trainers import is_basic_energy_card
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability, is_pokemon_v
|
||||
from spirit.game.attributes import PokemonTypes, PokemonStage, Rarities
|
||||
|
||||
trinity_charge = search_attach_energy(
|
||||
is_basic_energy_card, count=3,
|
||||
target_pred=lambda p: is_pokemon_v(p.archetype_id),
|
||||
prompt="Choose up to 3 basic Energy cards to attach to your Pokémon V.",
|
||||
)
|
||||
|
||||
card = PokemonCardDef(
|
||||
guid="dd482d08-91fb-55ca-bee4-bffb8820ce77",
|
||||
key="SWSH9",
|
||||
@@ -20,9 +28,9 @@ card = PokemonCardDef(
|
||||
abilities=[
|
||||
Attack(
|
||||
title="Trinity Charge",
|
||||
game_text="Search your deck for up to 3 basic Energy cards and attach them to your Pok\u00e9mon V in any way you like. Then, shuffle your deck.",
|
||||
game_text="Search your deck for up to 3 basic Energy cards and attach them to your Pokémon V in any way you like. Then, shuffle your deck.",
|
||||
cost={PokemonTypes.COLORLESS: 2},
|
||||
effect=unimplemented,
|
||||
effect=trinity_charge,
|
||||
),
|
||||
Attack(
|
||||
title="Power Edge",
|
||||
@@ -30,4 +38,4 @@ card = PokemonCardDef(
|
||||
damage=130,
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability, unimplemented
|
||||
from spirit.game.card_effects.support_common import search_attach_energy
|
||||
from spirit.game.card_effects.trainers import is_basic_energy_card
|
||||
from spirit.game.data_utils import PokemonCardDef, Attack, Ability, is_pokemon_v
|
||||
from spirit.game.attributes import PokemonTypes, PokemonStage, Rarities
|
||||
|
||||
trinity_charge = search_attach_energy(
|
||||
is_basic_energy_card, count=3,
|
||||
target_pred=lambda p: is_pokemon_v(p.archetype_id),
|
||||
prompt="Choose up to 3 basic Energy cards to attach to your Pokémon V.",
|
||||
)
|
||||
|
||||
card = PokemonCardDef(
|
||||
guid="c2ebcef0-5b29-584c-82d0-a6720352f5f8",
|
||||
key="SWSH9",
|
||||
@@ -20,9 +28,9 @@ card = PokemonCardDef(
|
||||
abilities=[
|
||||
Attack(
|
||||
title="Trinity Charge",
|
||||
game_text="Search your deck for up to 3 basic Energy cards and attach them to your Pok\u00e9mon V in any way you like. Then, shuffle your deck.",
|
||||
game_text="Search your deck for up to 3 basic Energy cards and attach them to your Pokémon V in any way you like. Then, shuffle your deck.",
|
||||
cost={PokemonTypes.COLORLESS: 2},
|
||||
effect=unimplemented,
|
||||
effect=trinity_charge,
|
||||
),
|
||||
Attack(
|
||||
title="Power Edge",
|
||||
@@ -30,4 +38,4 @@ card = PokemonCardDef(
|
||||
damage=130,
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
748
spirit/tools/effect_smoke.py
Normal file
748
spirit/tools/effect_smoke.py
Normal file
@@ -0,0 +1,748 @@
|
||||
"""Headless smoke-test harness for card effects.
|
||||
|
||||
Builds a minimal two-AI-player GameSession per test, drives every scripted
|
||||
attack / ability / trainer / energy hook through the real resolve_* entry
|
||||
points, and reports PASS/FAIL per (card, ability) without a client, network
|
||||
stack, or DB.
|
||||
|
||||
Usage: python -m spirit.tools.effect_smoke --set SWSH8 [--card Absol_164] [--all]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import traceback
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from spirit.game.attributes import AbilityTypes, AttrID, PokemonStage, PokemonTypes
|
||||
from spirit.game.data_utils import (
|
||||
Ability,
|
||||
Activations,
|
||||
Attack,
|
||||
CARD_DEFS_BY_GUID,
|
||||
EnergyCardDef,
|
||||
PokemonCardDef,
|
||||
PokemonToolCardDef,
|
||||
StadiumCardDef,
|
||||
TrainerCardDef,
|
||||
Triggers,
|
||||
has_rule_box,
|
||||
unimplemented,
|
||||
)
|
||||
from spirit.game.models.board import CardEntity, EnergyEntity, PokemonEntity
|
||||
from spirit.game.scripts.cards import loader as card_loader
|
||||
from spirit.game.session import passives
|
||||
from spirit.game.session.ai_player import AIPlayer
|
||||
from spirit.game.session.constants import GamePhase
|
||||
from spirit.game.session.effects import (
|
||||
EffectContext,
|
||||
resolve_activated_ability,
|
||||
resolve_attack,
|
||||
resolve_energy_attach_cost,
|
||||
resolve_energy_on_attach,
|
||||
resolve_trainer_effect,
|
||||
resolve_triggered_ability,
|
||||
)
|
||||
from spirit.game.session.game_session import GameOver, GameSession
|
||||
from spirit.game.session.legal_actions import trainer_condition_met
|
||||
|
||||
P1 = "smoke-p1"
|
||||
P2 = "smoke-p2"
|
||||
TEST_TIMEOUT = 30.0
|
||||
GUID_RE = re.compile(r"guid\s*=\s*[\"']([0-9a-fA-F-]{36})[\"']")
|
||||
|
||||
PASS, FAIL, SKIP, UNIMPL = "PASS", "FAIL", "SKIP", "UNIMPL"
|
||||
|
||||
|
||||
class SmokeSession(GameSession):
|
||||
"""GameSession whose game-end path never touches the DB or network."""
|
||||
|
||||
# Skip the choreography pacing sleeps so --all runs finish in seconds.
|
||||
choreography_pauses = False
|
||||
|
||||
async def end_game(self, winner_id: str, reason: str):
|
||||
self.game_phase = GamePhase.GAME_OVER
|
||||
raise GameOver()
|
||||
|
||||
async def prompt_selection_message(self, player, msg_name, value,
|
||||
expected_counter=None):
|
||||
# Every legit prompt path early-returns for AIPlayer; reaching here
|
||||
# with one means a prompt the AI cannot answer (would hang forever).
|
||||
if isinstance(player, AIPlayer):
|
||||
raise RuntimeError(
|
||||
f"prompt '{msg_name}' reached the wire for an AIPlayer -- "
|
||||
f"not auto-answered headlessly"
|
||||
)
|
||||
return await super().prompt_selection_message(
|
||||
player, msg_name, value, expected_counter
|
||||
)
|
||||
|
||||
|
||||
class SmokeResult:
|
||||
def __init__(self, card: str, kind: str, label: str, status: str,
|
||||
detail: str = "", scripted: bool = True):
|
||||
self.card, self.kind, self.label = card, kind, label
|
||||
self.status, self.detail, self.scripted = status, detail, scripted
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Target discovery
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def discover_targets(set_code: str) -> List[Tuple[str, Any]]:
|
||||
"""(script_stem, CardDefinition) for every card script in the set dir."""
|
||||
set_dir = os.path.join(card_loader.scripts_dir, set_code)
|
||||
if not os.path.isdir(set_dir):
|
||||
raise SystemExit(f"No script directory for set '{set_code}' ({set_dir})")
|
||||
targets = []
|
||||
for fname in sorted(os.listdir(set_dir)):
|
||||
if not fname.endswith(".py") or fname == "__init__.py":
|
||||
continue
|
||||
with open(os.path.join(set_dir, fname), "r", encoding="utf-8") as f:
|
||||
m = GUID_RE.search(f.read())
|
||||
stem = fname[:-3]
|
||||
definition = CARD_DEFS_BY_GUID.get(m.group(1).lower()) if m else None
|
||||
if definition is not None:
|
||||
targets.append((stem, definition))
|
||||
return targets
|
||||
|
||||
|
||||
def _def_attr(definition, attr_id: AttrID, default=None):
|
||||
spec = definition.extra_attributes.get(str(attr_id.value))
|
||||
return spec.get("value", default) if isinstance(spec, dict) else default
|
||||
|
||||
|
||||
def pick_filler_basic() -> Any:
|
||||
"""Deterministic vanilla Basic Pokemon used to stock both boards."""
|
||||
candidates = []
|
||||
for definition in CARD_DEFS_BY_GUID.values():
|
||||
if not isinstance(definition, PokemonCardDef):
|
||||
continue
|
||||
if _def_attr(definition, AttrID.STAGE) != PokemonStage.BASIC.value:
|
||||
continue
|
||||
if has_rule_box(definition.guid):
|
||||
continue
|
||||
if any(a.effect is not None or a.passive is not None or a.trigger
|
||||
for a in definition.abilities):
|
||||
continue
|
||||
hp = _def_attr(definition, AttrID.HP, 0)
|
||||
candidates.append((hp, definition.guid, definition))
|
||||
if not candidates:
|
||||
raise SystemExit("No vanilla Basic Pokemon loaded to use as board filler")
|
||||
candidates.sort(key=lambda t: (-t[0], t[1]))
|
||||
return candidates[0][2]
|
||||
|
||||
|
||||
def pick_filler_item() -> Optional[Any]:
|
||||
"""A vanilla Item card (no effect) so 'other Item in hand' conditions hold."""
|
||||
from spirit.game.data_utils import ItemCardDef
|
||||
candidates = [d for d in CARD_DEFS_BY_GUID.values()
|
||||
if isinstance(d, ItemCardDef) and d.effect is None
|
||||
and d.condition is None]
|
||||
candidates.sort(key=lambda d: d.guid)
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
|
||||
def basic_energy_guids() -> Dict[int, str]:
|
||||
"""PokemonTypes value -> Free_Energy basic energy GUID."""
|
||||
out: Dict[int, str] = {}
|
||||
for definition in CARD_DEFS_BY_GUID.values():
|
||||
if isinstance(definition, EnergyCardDef) and definition.set_code == "Free_Energy":
|
||||
out[definition.energy_type.value] = definition.guid
|
||||
if not out:
|
||||
raise SystemExit("Free_Energy basic energies not loaded")
|
||||
return out
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Board construction
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
class Rig:
|
||||
"""One fresh session + stocked board per test."""
|
||||
|
||||
def __init__(self, target_def, filler_def, energy_guids: Dict[int, str],
|
||||
item_def=None):
|
||||
self.target_def = target_def
|
||||
self.filler_def = filler_def
|
||||
self.energy_guids = energy_guids
|
||||
self.item_def = item_def
|
||||
self.session = self._build_session()
|
||||
self.board = self.session.board_state
|
||||
|
||||
def _energy_guid(self, type_value: int) -> str:
|
||||
return self.energy_guids.get(
|
||||
type_value, self.energy_guids[PokemonTypes.WATER.value])
|
||||
|
||||
def _element_type(self) -> int:
|
||||
types = []
|
||||
if isinstance(self.target_def, PokemonCardDef):
|
||||
raw = _def_attr(self.target_def, AttrID.POKEMON_TYPES, "[]")
|
||||
import json as _json
|
||||
try:
|
||||
types = _json.loads(raw) if isinstance(raw, str) else list(raw)
|
||||
except (ValueError, TypeError):
|
||||
types = []
|
||||
return types[0] if types else PokemonTypes.WATER.value
|
||||
|
||||
def _cost_energy_guids(self) -> List[str]:
|
||||
"""Enough basic energies to cover every attack cost, plus spares."""
|
||||
need: Dict[int, int] = {}
|
||||
element = self._element_type()
|
||||
if isinstance(self.target_def, PokemonCardDef):
|
||||
for ability in self.target_def.abilities:
|
||||
cost = getattr(ability, "cost", None) or {}
|
||||
per: Dict[int, int] = {}
|
||||
for ptype, count in cost.items():
|
||||
tval = getattr(ptype, "value", ptype)
|
||||
if tval in (PokemonTypes.COLORLESS.value, PokemonTypes.UNSET.value):
|
||||
tval = element
|
||||
per[tval] = per.get(tval, 0) + count
|
||||
for tval, count in per.items():
|
||||
need[tval] = max(need.get(tval, 0), count)
|
||||
need[element] = need.get(element, 0) + 2
|
||||
guids: List[str] = []
|
||||
for tval, count in need.items():
|
||||
guids.extend([self._energy_guid(tval)] * count)
|
||||
return guids
|
||||
|
||||
def _build_session(self) -> SmokeSession:
|
||||
target_guid = self.target_def.guid
|
||||
filler_guid = self.filler_def.guid
|
||||
water = self.energy_guids[PokemonTypes.WATER.value]
|
||||
psychic = self.energy_guids.get(PokemonTypes.PSYCHIC.value, water)
|
||||
p1_pile = [target_guid] * 4 + [filler_guid] * 12 + self._cost_energy_guids() \
|
||||
+ [water] * 4 + [psychic] * 2
|
||||
if self.item_def is not None:
|
||||
p1_pile += [self.item_def.guid] * 2
|
||||
p2_pile = [filler_guid] * 12 + [water] * 8 + [psychic] * 4
|
||||
pairing = {
|
||||
"is_solo": True,
|
||||
"players": {
|
||||
P1: {"client": None,
|
||||
"deck": {"deckID": str(uuid.uuid4()), "deckName": "SmokeP1",
|
||||
"piles": {"deck": p1_pile}}},
|
||||
P2: {"client": None,
|
||||
"deck": {"deckID": str(uuid.uuid4()), "deckName": "SmokeP2",
|
||||
"piles": {"deck": p2_pile}}},
|
||||
},
|
||||
}
|
||||
return SmokeSession(f"smoke-{uuid.uuid4().hex[:8]}", pairing)
|
||||
|
||||
# -- deck plumbing --------------------------------------------------
|
||||
|
||||
def pull(self, pid: str, predicate) -> Optional[CardEntity]:
|
||||
deck = self.board.find_player_area(pid, "deck")
|
||||
for card in list(deck.children):
|
||||
if predicate(card):
|
||||
return card
|
||||
return None
|
||||
|
||||
def pull_guid(self, pid: str, guid: str) -> Optional[CardEntity]:
|
||||
return self.pull(pid, lambda c: (c.archetype_id or "").lower() == guid.lower())
|
||||
|
||||
def to_area(self, card: Optional[CardEntity], pid: str, area_name: str,
|
||||
position: Optional[int] = None) -> Optional[CardEntity]:
|
||||
if card is None:
|
||||
return None
|
||||
area = self.board.find_player_area(pid, area_name)
|
||||
self.board.move_card(card.entity_id, area.entity_id, position)
|
||||
return card
|
||||
|
||||
def attach(self, card: Optional[CardEntity], pokemon: PokemonEntity):
|
||||
if card is not None:
|
||||
self.board.attach_card(card.entity_id, pokemon.entity_id)
|
||||
|
||||
def attach_energy_type(self, pid: str, pokemon: PokemonEntity, type_value: int):
|
||||
self.attach(self.pull_guid(pid, self._energy_guid(type_value)), pokemon)
|
||||
|
||||
# -- standard layout ------------------------------------------------
|
||||
|
||||
def setup(self, target_kind: str) -> Dict[str, Any]:
|
||||
"""Places actives/bench/hands/discards; returns key entities."""
|
||||
session, board = self.session, self.board
|
||||
ts = session.turn_state
|
||||
ts.begin_turn(P1)
|
||||
ts.begin_turn(P2)
|
||||
ts.begin_turn(P1) # turn 3: past both first-turn restrictions
|
||||
session.first_player_id = P1
|
||||
session.game_phase = GamePhase.TURN_LOOP
|
||||
|
||||
filler_guid = self.filler_def.guid
|
||||
target_guid = self.target_def.guid
|
||||
|
||||
# Opponent side: active + 2 benched fillers, some attached energy.
|
||||
p2_active = self.to_area(self.pull_guid(P2, filler_guid), P2, "activePokemonArea")
|
||||
p2_bench = [self.to_area(self.pull_guid(P2, filler_guid), P2, "bench")
|
||||
for _ in range(2)]
|
||||
if p2_active is not None:
|
||||
self.attach_energy_type(P2, p2_active, PokemonTypes.WATER.value)
|
||||
self.attach_energy_type(P2, p2_active, PokemonTypes.PSYCHIC.value)
|
||||
if p2_bench[0] is not None:
|
||||
self.attach_energy_type(P2, p2_bench[0], PokemonTypes.WATER.value)
|
||||
for _ in range(4):
|
||||
self.to_area(self.pull(P2, lambda c: True), P2, "hand")
|
||||
for _ in range(3):
|
||||
self.to_area(self.pull(P2, lambda c: True), P2, "discard")
|
||||
|
||||
# Own side.
|
||||
target_entity: Optional[CardEntity] = None
|
||||
if target_kind == "pokemon":
|
||||
target_entity = self.to_area(
|
||||
self.pull_guid(P1, target_guid), P1, "activePokemonArea")
|
||||
p1_active = target_entity
|
||||
# A second copy on the bench (own-copy interactions) and a third
|
||||
# in the discard (recursion-style abilities/conditions).
|
||||
self.to_area(self.pull_guid(P1, target_guid), P1, "bench")
|
||||
self.to_area(self.pull_guid(P1, target_guid), P1, "discard")
|
||||
else:
|
||||
p1_active = self.to_area(self.pull_guid(P1, filler_guid), P1, "activePokemonArea")
|
||||
for _ in range(2):
|
||||
self.to_area(self.pull_guid(P1, filler_guid), P1, "bench")
|
||||
if isinstance(p1_active, PokemonEntity):
|
||||
for guid in self._cost_energy_guids():
|
||||
self.attach(self.pull_guid(P1, guid), p1_active)
|
||||
# Hand: fillers + energies + a vanilla Item so discard/"other Item in
|
||||
# hand" style costs and conditions have fodder. Generic pulls must
|
||||
# never consume target copies (the target pull below needs one left).
|
||||
not_target = lambda c: (c.archetype_id or "").lower() != target_guid.lower()
|
||||
for _ in range(3):
|
||||
self.to_area(self.pull_guid(P1, filler_guid), P1, "hand")
|
||||
for _ in range(2):
|
||||
self.to_area(self.pull(
|
||||
P1, lambda c: isinstance(c, EnergyEntity) and not_target(c)),
|
||||
P1, "hand")
|
||||
if self.item_def is not None and self.item_def.guid.lower() != target_guid.lower():
|
||||
self.to_area(self.pull_guid(P1, self.item_def.guid), P1, "hand")
|
||||
for _ in range(2):
|
||||
self.to_area(self.pull(P1, not_target), P1, "discard")
|
||||
|
||||
if target_kind == "trainer":
|
||||
target_entity = self.to_area(self.pull_guid(P1, target_guid), P1, "hand")
|
||||
elif target_kind == "energy":
|
||||
target_entity = self.to_area(self.pull_guid(P1, target_guid), P1, "hand")
|
||||
|
||||
return {
|
||||
"p1_active": p1_active,
|
||||
"p2_active": p2_active,
|
||||
"target": target_entity,
|
||||
}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Test runners
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
async def _flush_ctx(session, ctx: Optional[EffectContext]):
|
||||
if ctx is None:
|
||||
return
|
||||
if ctx._messages:
|
||||
await session._flush_effect_runs(ctx)
|
||||
ctx._messages.clear()
|
||||
await session.resolve_knockouts(ctx)
|
||||
for hook in ctx.deferred_actions:
|
||||
await hook()
|
||||
|
||||
|
||||
def _condition_blocks(definition, ability, rig, entities) -> Optional[str]:
|
||||
"""Evaluate the offer-gating condition; None = proceed, str = skip note.
|
||||
|
||||
On False, retries under fallback board shapes (first turn, emptied hand)
|
||||
so turn-gated / hand-gated cards still get their effect exercised.
|
||||
"""
|
||||
board = rig.board
|
||||
if isinstance(ability, Ability) and ability.condition is not None:
|
||||
check = lambda: bool(ability.condition(board, P1, entities["p1_active"]))
|
||||
label = "ability"
|
||||
elif isinstance(definition, TrainerCardDef) \
|
||||
and getattr(definition, "condition", None) and ability is None:
|
||||
check = lambda: trainer_condition_met(
|
||||
definition.condition, board, P1, entities["target"])
|
||||
label = "card"
|
||||
else:
|
||||
return None
|
||||
if check():
|
||||
return None
|
||||
# Fallback 1: rewind to the player's first turn (Battle VIP Pass).
|
||||
rig.session.turn_state.turn_number = 1
|
||||
if check():
|
||||
return None
|
||||
# Fallback 1b: stamp the target as this turn's draw (Nugget provenance).
|
||||
target = entities.get("target")
|
||||
if target is not None:
|
||||
rig.session.turn_state.turn_draw_entity_ids.add(target.entity_id)
|
||||
if check():
|
||||
return None
|
||||
# Fallback 1c: a 2nd copy of the target in hand (pair items -- Cross Switcher).
|
||||
extra_copy = rig.to_area(rig.pull_guid(P1, definition.guid), P1, "hand")
|
||||
if extra_copy is not None and check():
|
||||
return None
|
||||
# Fallback 2: dump the hand back into the deck (hand-size-gated abilities).
|
||||
hand = board.find_player_area(P1, "hand")
|
||||
deck = board.find_player_area(P1, "deck")
|
||||
target = entities.get("target")
|
||||
for card in list(hand.children):
|
||||
if card is not target:
|
||||
board.move_card(card.entity_id, deck.entity_id)
|
||||
if check():
|
||||
return None
|
||||
# Fallback 3: deal prize piles (prize-gated cards -- Peonia, Shuffle Dance).
|
||||
for pid in (P1, P2):
|
||||
pile = board.find_player_area(pid, "prizePile")
|
||||
if pile is not None and not pile.children:
|
||||
board.deal_from_deck(pid, "prizePile", 6)
|
||||
if check():
|
||||
return None
|
||||
rig.session.turn_state.turn_number = 3
|
||||
return f"{label} condition returned False on the smoke board (all fallbacks)"
|
||||
|
||||
|
||||
async def run_pokemon_ability(rig: Rig, ability: Ability, entities) -> None:
|
||||
session = rig.session
|
||||
pokemon = entities["p1_active"]
|
||||
is_attack = isinstance(ability, Attack) or ability.ability_type in (
|
||||
AbilityTypes.ATTACK, AbilityTypes.NON_DAMAGING_ATTACK)
|
||||
if is_attack:
|
||||
action_id = ability.ability_id or str(uuid.uuid4())
|
||||
await resolve_attack(session, P1, pokemon, ability, action_id)
|
||||
elif ability.trigger is not None:
|
||||
def _setup(ctx):
|
||||
if ability.has_trigger(Triggers.ON_KNOCKED_OUT):
|
||||
ctx.ko_from_attack = True
|
||||
ctx.ko_attacker = entities["p2_active"]
|
||||
await resolve_triggered_ability(session, P1, pokemon, ability, ctx_setup=_setup)
|
||||
else:
|
||||
await resolve_activated_ability(session, P1, pokemon, ability)
|
||||
|
||||
|
||||
async def run_passive_probe(rig: Rig, entities) -> None:
|
||||
board = rig.board
|
||||
p1_active, p2_active = entities["p1_active"], entities["p2_active"]
|
||||
list(passives.active_passives(board))
|
||||
passives.compute_damage(board, p1_active, p2_active, 50, is_attack=True)
|
||||
passives.compute_damage(board, p2_active, p1_active, 50, is_attack=True)
|
||||
passives.effective_max_hp(board, p1_active)
|
||||
passives.effective_retreat_cost(board, p1_active)
|
||||
cost_fn = getattr(passives, "effective_attack_cost", None)
|
||||
if cost_fn is not None:
|
||||
from spirit.game.attributes import CLIENT_POKEMON_TYPE_NAMES
|
||||
for ability in getattr(rig.target_def, "abilities", []) or []:
|
||||
if isinstance(ability, Attack):
|
||||
# Same wire shape legal_actions feeds it: client enum NAME keys.
|
||||
cost = {CLIENT_POKEMON_TYPE_NAMES[k]: v
|
||||
for k, v in (ability.cost or {}).items()}
|
||||
cost_fn(board, p1_active, cost)
|
||||
|
||||
|
||||
async def run_trainer_effect(rig: Rig, entities) -> None:
|
||||
session, board = rig.session, rig.board
|
||||
card = entities["target"]
|
||||
slot = board.find_global_area("activeTrainer")
|
||||
board.move_card(card.entity_id, slot.entity_id)
|
||||
ctx = await resolve_trainer_effect(session, P1, card)
|
||||
await _flush_ctx(session, ctx)
|
||||
# Mirror the engine: the trainer goes to the discard after resolving,
|
||||
# unless the effect already moved it (Hisuian Heavy Ball prize swap).
|
||||
if card.parent is slot:
|
||||
discard = board.find_player_area(P1, "discard")
|
||||
board.move_card(card.entity_id, discard.entity_id)
|
||||
|
||||
|
||||
async def run_stadium_ability(rig: Rig, ability: Ability, entities) -> None:
|
||||
session, board = rig.session, rig.board
|
||||
card = entities["target"]
|
||||
slot = board.find_global_area("activeStadium")
|
||||
board.move_card(card.entity_id, slot.entity_id)
|
||||
await resolve_activated_ability(session, P1, card, ability)
|
||||
|
||||
|
||||
async def run_trainer_trigger(rig: Rig, ability: Ability, entities) -> None:
|
||||
"""Trainer-declared trigger fired on the hand copy (Dream Ball's window)."""
|
||||
await resolve_triggered_ability(rig.session, P1, entities["target"], ability)
|
||||
|
||||
|
||||
async def run_stadium_trigger(rig: Rig, ability: Ability, entities) -> None:
|
||||
"""Stadium-declared triggered ability (Old Cemetery/Gapejaw Bog watchers)."""
|
||||
session, board = rig.session, rig.board
|
||||
card = entities["target"]
|
||||
slot = board.find_global_area("activeStadium")
|
||||
board.move_card(card.entity_id, slot.entity_id)
|
||||
receiver = entities["p1_active"]
|
||||
|
||||
def _setup(ctx):
|
||||
ctx.attaching_player_id = P1
|
||||
ctx.attached_energy = next(iter(receiver.children), None)
|
||||
ctx.energy_receiver = receiver
|
||||
ctx.benching_player_id = P1
|
||||
ctx.benched_pokemon = receiver
|
||||
|
||||
await resolve_triggered_ability(session, P1, card, ability, ctx_setup=_setup)
|
||||
|
||||
|
||||
async def run_tool_granted_ability(rig: Rig, ability: Ability, entities) -> None:
|
||||
session, board = rig.session, rig.board
|
||||
holder = entities["p1_active"]
|
||||
board.attach_card(entities["target"].entity_id, holder.entity_id)
|
||||
await session.refresh_granted_abilities(holder)
|
||||
await resolve_activated_ability(session, P1, holder, ability)
|
||||
|
||||
|
||||
async def run_energy_attach(rig: Rig, entities) -> None:
|
||||
session, board = rig.session, rig.board
|
||||
energy = entities["target"]
|
||||
target = entities["p1_active"]
|
||||
definition = rig.target_def
|
||||
if definition.attach_condition is not None \
|
||||
and not definition.attach_condition(board, P1):
|
||||
raise _SkipTest("attach_condition returned False on the smoke board")
|
||||
if definition.attach_to is not None:
|
||||
definition.attach_to(target)
|
||||
ctx = await resolve_energy_attach_cost(session, P1, energy, target)
|
||||
if ctx is None:
|
||||
return # cost hook cancelled the attach; a legal outcome
|
||||
await _flush_ctx(session, ctx)
|
||||
board.attach_card(energy.entity_id, target.entity_id)
|
||||
on_attach_ctx = await resolve_energy_on_attach(session, P1, energy, target)
|
||||
await _flush_ctx(session, on_attach_ctx)
|
||||
|
||||
|
||||
async def run_energy_carrier_ko(rig: Rig, entities) -> None:
|
||||
session, board = rig.session, rig.board
|
||||
energy = entities["target"]
|
||||
carrier = entities["p2_active"]
|
||||
board.attach_card(energy.entity_id, carrier.entity_id)
|
||||
hook = rig.target_def.on_carrier_knocked_out
|
||||
ctx = EffectContext(session, P2, entities["p1_active"], None)
|
||||
await hook(ctx)
|
||||
await _flush_ctx(session, ctx)
|
||||
|
||||
|
||||
class _SkipTest(Exception):
|
||||
def __init__(self, note: str):
|
||||
self.note = note
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Per-card orchestration
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def _plan_tests(definition) -> List[Tuple[str, str, Any, bool]]:
|
||||
"""(kind_label, ability_label, runner_key, scripted) rows for a card def."""
|
||||
plans: List[Tuple[str, str, Any, bool]] = []
|
||||
# Fossils (plays_as_pokemon trainers) exercise like Pokemon: abilities + passive.
|
||||
if isinstance(definition, PokemonCardDef) \
|
||||
or getattr(definition, "plays_as_pokemon", False):
|
||||
for ability in definition.abilities:
|
||||
scripted = callable(ability.effect)
|
||||
if isinstance(ability, Attack) or ability.ability_type in (
|
||||
AbilityTypes.ATTACK, AbilityTypes.NON_DAMAGING_ATTACK):
|
||||
plans.append(("attack", ability.title, ability, scripted))
|
||||
elif ability.trigger is not None or ability.activation is not None \
|
||||
or callable(ability.effect):
|
||||
plans.append(("ability", ability.title, ability, scripted))
|
||||
elif ability.passive is not None:
|
||||
plans.append(("passive", ability.title, "passive", True))
|
||||
elif ability.effect is unimplemented:
|
||||
plans.append(("ability", ability.title, ability, False))
|
||||
if definition.passive is not None and not any(k == "passive" for k, *_ in plans):
|
||||
plans.append(("passive", "(card passive)", "passive", True))
|
||||
elif isinstance(definition, StadiumCardDef):
|
||||
if callable(definition.effect):
|
||||
plans.append(("trainer", "(on play)", "trainer", True))
|
||||
elif definition.effect is unimplemented:
|
||||
plans.append(("trainer", "(on play)", "trainer", False))
|
||||
if definition.ability is not None:
|
||||
plans.append(("stadium-ability", definition.ability.title,
|
||||
definition.ability,
|
||||
callable(definition.ability.effect)))
|
||||
for ability in definition.abilities:
|
||||
if ability.trigger is not None:
|
||||
plans.append(("stadium-trigger", ability.title, ability,
|
||||
callable(ability.effect)))
|
||||
if definition.passive is not None:
|
||||
plans.append(("passive", "(stadium passive)", "stadium-passive", True))
|
||||
elif isinstance(definition, PokemonToolCardDef):
|
||||
for ability in definition.granted_abilities:
|
||||
plans.append(("tool-ability", ability.title, ability,
|
||||
callable(ability.effect)))
|
||||
if definition.passive is not None:
|
||||
plans.append(("passive", "(tool passive)", "tool-passive", True))
|
||||
if callable(definition.effect):
|
||||
plans.append(("trainer", "(on play)", "trainer", True))
|
||||
elif isinstance(definition, TrainerCardDef):
|
||||
scripted = callable(definition.effect)
|
||||
plans.append(("trainer", "(on play)", "trainer",
|
||||
scripted))
|
||||
for ability in definition.abilities:
|
||||
if ability.trigger is not None:
|
||||
plans.append(("trainer-trigger", ability.title, ability,
|
||||
callable(ability.effect)))
|
||||
elif isinstance(definition, EnergyCardDef):
|
||||
if callable(definition.attach_cost) or callable(definition.on_attach):
|
||||
plans.append(("energy-attach", "(attach hooks)", "energy-attach", True))
|
||||
if callable(definition.on_carrier_knocked_out):
|
||||
plans.append(("energy-ko", "(carrier KO hook)", "energy-ko", True))
|
||||
if definition.passive is not None:
|
||||
plans.append(("passive", "(energy passive)", "energy-passive", True))
|
||||
return plans
|
||||
|
||||
|
||||
async def run_one(stem: str, definition, kind: str, label: str, runner_key,
|
||||
filler_def, energy_guids, scripted: bool,
|
||||
timeout: float, item_def=None) -> SmokeResult:
|
||||
random.seed(0xC0FFEE)
|
||||
rig = Rig(definition, filler_def, energy_guids, item_def)
|
||||
target_kind = "pokemon" if isinstance(definition, PokemonCardDef) \
|
||||
or getattr(definition, "plays_as_pokemon", False) else \
|
||||
"energy" if isinstance(definition, EnergyCardDef) else "trainer"
|
||||
|
||||
async def _drive():
|
||||
entities = rig.setup(target_kind)
|
||||
needed = "p1_active" if target_kind == "pokemon" else "target"
|
||||
if entities.get(needed) is None or entities.get("p1_active") is None:
|
||||
raise RuntimeError(
|
||||
f"harness rig could not place the {target_kind} target on the board")
|
||||
if isinstance(runner_key, Ability):
|
||||
note = _condition_blocks(definition, runner_key, rig, entities)
|
||||
if note:
|
||||
raise _SkipTest(note)
|
||||
if kind == "stadium-ability":
|
||||
await run_stadium_ability(rig, runner_key, entities)
|
||||
elif kind == "stadium-trigger":
|
||||
await run_stadium_trigger(rig, runner_key, entities)
|
||||
elif kind == "trainer-trigger":
|
||||
await run_trainer_trigger(rig, runner_key, entities)
|
||||
elif kind == "tool-ability":
|
||||
await run_tool_granted_ability(rig, runner_key, entities)
|
||||
else:
|
||||
await run_pokemon_ability(rig, runner_key, entities)
|
||||
elif runner_key == "trainer":
|
||||
note = _condition_blocks(definition, None, rig, entities)
|
||||
if note:
|
||||
raise _SkipTest(note)
|
||||
if not scripted:
|
||||
raise _SkipTest("effect text not scripted (unimplemented/none)")
|
||||
await run_trainer_effect(rig, entities)
|
||||
elif runner_key == "energy-attach":
|
||||
await run_energy_attach(rig, entities)
|
||||
elif runner_key == "energy-ko":
|
||||
await run_energy_carrier_ko(rig, entities)
|
||||
elif str(runner_key).endswith("passive") or runner_key == "passive":
|
||||
if runner_key == "tool-passive":
|
||||
rig.board.attach_card(entities["target"].entity_id,
|
||||
entities["p1_active"].entity_id)
|
||||
elif runner_key == "stadium-passive":
|
||||
slot = rig.board.find_global_area("activeStadium")
|
||||
rig.board.move_card(entities["target"].entity_id, slot.entity_id)
|
||||
elif runner_key == "energy-passive":
|
||||
rig.board.attach_card(entities["target"].entity_id,
|
||||
entities["p1_active"].entity_id)
|
||||
await run_passive_probe(rig, entities)
|
||||
|
||||
try:
|
||||
try:
|
||||
await asyncio.wait_for(_drive(), timeout=timeout)
|
||||
return SmokeResult(stem, kind, label, PASS, scripted=scripted)
|
||||
except GameOver:
|
||||
return SmokeResult(stem, kind, label, PASS, "(game ended)", scripted)
|
||||
except _SkipTest as skip:
|
||||
status = UNIMPL if "not scripted" in skip.note else SKIP
|
||||
return SmokeResult(stem, kind, label, status, skip.note, scripted)
|
||||
except asyncio.TimeoutError:
|
||||
return SmokeResult(stem, kind, label, FAIL,
|
||||
f"timed out after {timeout}s (blocked prompt?)",
|
||||
scripted)
|
||||
except Exception:
|
||||
tail = "".join(traceback.format_exc().splitlines(keepends=True)[-6:])
|
||||
return SmokeResult(stem, kind, label, FAIL, tail.rstrip(), scripted)
|
||||
finally:
|
||||
rig.session.cleanup()
|
||||
|
||||
|
||||
async def main_async(args) -> int:
|
||||
card_loader.load_all()
|
||||
filler_def = pick_filler_basic()
|
||||
energy_guids = basic_energy_guids()
|
||||
item_def = pick_filler_item()
|
||||
|
||||
targets = discover_targets(args.set)
|
||||
if args.card:
|
||||
wanted = args.card.lower()
|
||||
targets = [(s, d) for s, d in targets if s.lower() == wanted]
|
||||
if not targets:
|
||||
print(f"No script named '{args.card}' in set {args.set}")
|
||||
return 2
|
||||
|
||||
results: List[SmokeResult] = []
|
||||
for stem, definition in targets:
|
||||
plans = _plan_tests(definition)
|
||||
if not plans:
|
||||
result = SmokeResult(stem, "-", "(no behaviors)", SKIP,
|
||||
"vanilla card: nothing to execute", False)
|
||||
results.append(result)
|
||||
if args.verbose:
|
||||
_print_result(result)
|
||||
continue
|
||||
for kind, label, runner_key, scripted in plans:
|
||||
result = await run_one(stem, definition, kind, label, runner_key,
|
||||
filler_def, energy_guids, scripted,
|
||||
args.timeout, item_def)
|
||||
results.append(result)
|
||||
if args.verbose or result.status == FAIL:
|
||||
_print_result(result)
|
||||
|
||||
_print_summary(results, args)
|
||||
return 1 if any(r.status == FAIL for r in results) else 0
|
||||
|
||||
|
||||
def _print_result(r: SmokeResult):
|
||||
line = f"[{r.status:6}] {r.card:32} {r.kind:15} {r.label}"
|
||||
print(line)
|
||||
if r.detail:
|
||||
for detail_line in r.detail.splitlines():
|
||||
print(f" {detail_line}")
|
||||
|
||||
|
||||
def _print_summary(results: List[SmokeResult], args):
|
||||
counts: Dict[str, int] = {}
|
||||
for r in results:
|
||||
counts[r.status] = counts.get(r.status, 0) + 1
|
||||
scripted = [r for r in results if r.scripted and r.status in (PASS, FAIL)]
|
||||
scripted_pass = sum(1 for r in scripted if r.status == PASS)
|
||||
print()
|
||||
print(f"=== effect smoke: set {args.set} ===")
|
||||
print(f"tests: {len(results)} "
|
||||
+ " ".join(f"{k}: {v}" for k, v in sorted(counts.items())))
|
||||
print(f"scripted effects exercised: {len(scripted)} "
|
||||
f"(PASS {scripted_pass} / FAIL {len(scripted) - scripted_pass})")
|
||||
fails = [r for r in results if r.status == FAIL]
|
||||
if fails:
|
||||
print("\nfailures:")
|
||||
for r in fails:
|
||||
print(f" {r.card} :: {r.kind} :: {r.label}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Headless smoke tests for card-effect scripts.")
|
||||
parser.add_argument("--set", required=True, help="Set code (script dir name)")
|
||||
parser.add_argument("--card", help="Single card script stem, e.g. Absol_164")
|
||||
parser.add_argument("--all", action="store_true",
|
||||
help="Run every card in the set (default when --card omitted)")
|
||||
parser.add_argument("--verbose", action="store_true",
|
||||
help="Print every result, not just failures")
|
||||
parser.add_argument("--timeout", type=float, default=TEST_TIMEOUT)
|
||||
args = parser.parse_args()
|
||||
logging.basicConfig(level=logging.ERROR)
|
||||
if not args.verbose:
|
||||
logging.disable(logging.WARNING)
|
||||
sys.exit(asyncio.run(main_async(args)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1939
spirit/tools/engine_selftest.py
Normal file
1939
spirit/tools/engine_selftest.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user