Files
FFCardGame/tools/ability_processor.py
2026-02-02 16:28:53 -05:00

4629 lines
183 KiB
Python

#!/usr/bin/env python3
"""
FFTCG Ability Processor
Parses ability text from cards.json into structured effect objects.
Outputs to data/abilities_processed.json for use by the game runtime.
Usage:
python tools/ability_processor.py # Process all cards
python tools/ability_processor.py --card 1-001H # Single card
python tools/ability_processor.py --set 1 # Single set
python tools/ability_processor.py --dry-run # Preview only
python tools/ability_processor.py --verbose # Show parse details
"""
import argparse
import json
import re
import os
from datetime import datetime
from pathlib import Path
from typing import Optional
# Paths
SCRIPT_DIR = Path(__file__).parent
PROJECT_DIR = SCRIPT_DIR.parent
CARDS_PATH = PROJECT_DIR / "data" / "cards.json"
OUTPUT_PATH = PROJECT_DIR / "data" / "abilities_processed.json"
# =============================================================================
# Trigger Patterns
# =============================================================================
TRIGGER_PATTERNS = [
# Enters field
(r"when (.+?) enters the field", "ENTERS_FIELD"),
(r"when (.+?) is played", "ENTERS_FIELD"),
# Leaves field
(r"when (.+?) leaves the field", "LEAVES_FIELD"),
(r"when (.+?) is put from the field into the break zone", "LEAVES_FIELD"),
(r"when (.+?) is broken", "LEAVES_FIELD"),
# Attacks
(r"when (.+?) attacks", "ATTACKS"),
(r"when (.+?) declares an attack", "ATTACKS"),
# Blocks
(r"when (.+?) blocks", "BLOCKS"),
(r"when (.+?) is blocked", "IS_BLOCKED"),
(r"when (.+?) blocks or is blocked", "BLOCKS_OR_IS_BLOCKED"),
# Damage
(r"when (.+?) deals damage to your opponent", "DEALS_DAMAGE_TO_OPPONENT"),
(r"when (.+?) deals damage to a forward", "DEALS_DAMAGE_TO_FORWARD"),
(r"when (.+?) deals damage", "DEALS_DAMAGE"),
# EX BURST
(r"ex burst", "EX_BURST"),
# Turn phases
(r"at the beginning of your turn", "START_OF_TURN"),
(r"at the beginning of the turn", "START_OF_TURN"),
(r"at the end of your turn", "END_OF_TURN"),
(r"at the end of the turn", "END_OF_TURN"),
(r"at the beginning of your main phase", "START_OF_MAIN_PHASE"),
(r"at the beginning of the attack phase", "START_OF_ATTACK_PHASE"),
# Other events
(r"when you cast a summon", "SUMMON_CAST"),
(r"when a summon you cast deals damage", "SUMMON_DEALS_DAMAGE"),
(r"when (.+?) is chosen by .+ summons? or abilities", "CHOSEN_BY_OPPONENT"),
(r"when (.+?) is dulled", "DULLED"),
(r"when (.+?) activates", "ACTIVATED"),
(r"when you draw", "DRAW"),
(r"when you discard", "DISCARD"),
]
# =============================================================================
# Effect Patterns
# =============================================================================
EFFECT_PATTERNS = {
# DAMAGE patterns
"DAMAGE": [
# "Deal it/them X damage"
(r"deal (?:it|them|that forward) (\d+) damage", lambda m: {
"type": "DAMAGE",
"amount": int(m.group(1)),
"target": {"type": "CHOSEN"}
}),
# "Deal X damage to target"
(r"deal (\d+) damage to (.+?)(?:\.|$)", lambda m: {
"type": "DAMAGE",
"amount": int(m.group(1)),
"target": parse_target_text(m.group(2))
}),
# "Deal damage equal to X's power"
(r"deal (?:it|them) damage equal to (.+?)'s power", lambda m: {
"type": "DAMAGE",
"amount": "POWER_OF",
"power_source": m.group(1),
"target": {"type": "CHOSEN"}
}),
# "Deal each of them damage equal to its power"
(r"deal each of them damage equal to (?:its|their) (?:own )?power", lambda m: {
"type": "DAMAGE",
"amount": "TARGET_POWER",
"target": {"type": "CHOSEN"}
}),
# "Deal damage equal to its power minus X"
(r"deal (?:it|them) damage equal to (?:its|their) power minus (\d+)", lambda m: {
"type": "DAMAGE",
"amount": "TARGET_POWER",
"amount_modifier": -int(m.group(1)),
"target": {"type": "CHOSEN"}
}),
# Typo fix: "Def it X damage" -> "Deal it X damage"
(r"def (?:it|them) (\d+) damage", lambda m: {
"type": "DAMAGE",
"amount": int(m.group(1)),
"target": {"type": "CHOSEN"}
}),
# "Deal 1 of them X damage, 1 of them Y damage..."
(r"deal (\d+) of them (\d+) damage", lambda m: {
"type": "SPLIT_DAMAGE_SPECIFIC",
"count": int(m.group(1)),
"amount": int(m.group(2)),
"target": {"type": "CHOSEN"}
}),
],
# DRAW patterns
"DRAW": [
(r"draw (\d+) cards?", lambda m: {
"type": "DRAW",
"amount": int(m.group(1)),
"target": {"type": "CONTROLLER"}
}),
(r"your opponent draws? (\d+) cards?", lambda m: {
"type": "DRAW",
"amount": int(m.group(1)),
"target": {"type": "OPPONENT"}
}),
],
# BREAK patterns
"BREAK": [
(r"break (?:it|them|that forward)", lambda m: {
"type": "BREAK",
"target": {"type": "CHOSEN"}
}),
(r"break (.+?)(?:\.|$)", lambda m: {
"type": "BREAK",
"target": parse_target_text(m.group(1))
}),
],
# DULL patterns
"DULL": [
(r"dull (?:it|them|that forward)", lambda m: {
"type": "DULL",
"target": {"type": "CHOSEN"}
}),
(r"dull all (?:the )?forwards? (.+?) controls?", lambda m: {
"type": "DULL",
"target": {
"type": "ALL",
"zone": "FIELD",
"owner": "OPPONENT" if "opponent" in m.group(1).lower() else "CONTROLLER",
"filter": {"card_type": "FORWARD"}
}
}),
(r"dull (.+?)(?:\.|$)", lambda m: {
"type": "DULL",
"target": parse_target_text(m.group(1))
}),
],
# ACTIVATE patterns
"ACTIVATE": [
(r"activate (?:it|them)", lambda m: {
"type": "ACTIVATE",
"target": {"type": "CHOSEN"}
}),
(r"activate all (?:the )?forwards? you control", lambda m: {
"type": "ACTIVATE",
"target": {
"type": "ALL",
"zone": "FIELD",
"owner": "CONTROLLER",
"filter": {"card_type": "FORWARD"}
}
}),
(r"activate (.+?)(?:\.|$)", lambda m: {
"type": "ACTIVATE",
"target": parse_target_text(m.group(1))
}),
],
# POWER_MOD patterns (gains power)
"POWER_MOD": [
(r"(.+?) gains? \+(\d+) power until the end of the turn", lambda m: {
"type": "POWER_MOD",
"amount": int(m.group(2)),
"duration": "END_OF_TURN",
"target": parse_target_text(m.group(1))
}),
(r"(.+?) gains? \+(\d+) power", lambda m: {
"type": "POWER_MOD",
"amount": int(m.group(2)),
"duration": "PERMANENT",
"target": parse_target_text(m.group(1))
}),
(r"gains? \+(\d+) power until the end of the turn", lambda m: {
"type": "POWER_MOD",
"amount": int(m.group(1)),
"duration": "END_OF_TURN",
"target": {"type": "SELF"}
}),
(r"gains? \+(\d+) power", lambda m: {
"type": "POWER_MOD",
"amount": int(m.group(1)),
"duration": "PERMANENT",
"target": {"type": "SELF"}
}),
# Loses power patterns (negative power mod)
(r"(?:it|they) loses? (\d+) power until the end of the turn", lambda m: {
"type": "POWER_MOD",
"amount": -int(m.group(1)),
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"(?:it|they) loses? (\d+) power", lambda m: {
"type": "POWER_MOD",
"amount": -int(m.group(1)),
"duration": "PERMANENT",
"target": {"type": "CHOSEN"}
}),
(r"loses (\d+) power until the end of the turn", lambda m: {
"type": "POWER_MOD",
"amount": -int(m.group(1)),
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
],
# CONTROL patterns (gain control of target)
"CONTROL": [
(r"(?:you )?gain control of (?:it|them)", lambda m: {
"type": "CONTROL",
"target": {"type": "CHOSEN"}
}),
(r"(?:you )?gain control of (.+)", lambda m: {
"type": "CONTROL",
"target": parse_target_text(m.group(1))
}),
],
# BOTTOM_OF_DECK patterns
"BOTTOM_OF_DECK": [
(r"put (?:it|them) at the bottom of (?:its |their )?owner'?s? deck", lambda m: {
"type": "BOTTOM_OF_DECK",
"target": {"type": "CHOSEN"}
}),
(r"place (?:it|them) at the bottom of (?:your|its owner'?s?) deck", lambda m: {
"type": "BOTTOM_OF_DECK",
"target": {"type": "CHOSEN"}
}),
],
# SEARCH patterns
"SEARCH": [
(r"search for (\d+) (.+?) and add (?:it|them) to your hand", lambda m: {
"type": "SEARCH",
"count": int(m.group(1)),
"filter": parse_card_filter(m.group(2)),
"destination": "HAND"
}),
(r"search for (.+?) and add (?:it|them) to your hand", lambda m: {
"type": "SEARCH",
"count": 1,
"filter": parse_card_filter(m.group(1)),
"destination": "HAND"
}),
],
# PLAY patterns
"PLAY": [
(r"(?:you may )?play (\d+) (.+?) from your hand onto the field(?: dull)?", lambda m: {
"type": "PLAY",
"count": int(m.group(1)),
"optional": "you may" in m.string.lower(),
"filter": parse_card_filter(m.group(2)),
"zone_from": "HAND",
"modifiers": {"enters_dull": "dull" in m.string.lower()}
}),
(r"(?:you may )?play (.+?) from your hand onto the field(?: dull)?", lambda m: {
"type": "PLAY",
"count": 1,
"optional": "you may" in m.string.lower(),
"filter": parse_card_filter(m.group(1)),
"zone_from": "HAND",
"modifiers": {"enters_dull": "dull" in m.string.lower()}
}),
(r"(?:you may )?play (.+?) from your break zone onto the field(?: dull)?", lambda m: {
"type": "PLAY",
"count": 1,
"optional": "you may" in m.string.lower(),
"filter": parse_card_filter(m.group(1)),
"zone_from": "BREAK_ZONE",
"modifiers": {"enters_dull": "dull" in m.string.lower()}
}),
],
# RETURN patterns
"RETURN": [
(r"return (?:it|them) to (?:its |their )?owner'?s? hands?", lambda m: {
"type": "RETURN",
"destination": "HAND",
"target": {"type": "CHOSEN"}
}),
(r"return them to their owners' hands", lambda m: {
"type": "RETURN",
"destination": "HAND",
"target": {"type": "CHOSEN"}
}),
(r"return (.+?) to (?:its |their )?owner'?s? hands?", lambda m: {
"type": "RETURN",
"destination": "HAND",
"target": parse_target_text(m.group(1))
}),
],
# REMOVE_FROM_GAME patterns (exile)
"REMOVE_FROM_GAME": [
(r"remove (?:it|them) from the game", lambda m: {
"type": "REMOVE_FROM_GAME",
"target": {"type": "CHOSEN"}
}),
(r"remove (.+?) from the game", lambda m: {
"type": "REMOVE_FROM_GAME",
"target": parse_target_text(m.group(1))
}),
],
# DAMAGE_REDUCTION patterns
"DAMAGE_REDUCTION": [
(r"(?:the next )?damage dealt to (?:it|them) is reduced by (\d+)", lambda m: {
"type": "DAMAGE_REDUCTION",
"amount": int(m.group(1)),
"target": {"type": "CHOSEN"}
}),
(r"reduce the next damage dealt to (?:it|them) by (\d+)", lambda m: {
"type": "DAMAGE_REDUCTION",
"amount": int(m.group(1)),
"target": {"type": "CHOSEN"}
}),
(r"the next damage dealt to (?:it|them) (?:this turn )?becomes 0", lambda m: {
"type": "DAMAGE_REDUCTION",
"amount": "ALL",
"duration": "NEXT_DAMAGE",
"target": {"type": "CHOSEN"}
}),
(r"reduce the next damage dealt to it this turn by (\d+)", lambda m: {
"type": "DAMAGE_REDUCTION",
"amount": int(m.group(1)),
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
],
# DAMAGE_INCREASE patterns
"DAMAGE_INCREASE": [
(r"if (?:it|they) deals? damage .+ this turn, (?:the )?damage increases by (\d+)", lambda m: {
"type": "DAMAGE_INCREASE",
"amount": int(m.group(1)),
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"the damage increases by (\d+)", lambda m: {
"type": "DAMAGE_INCREASE",
"amount": int(m.group(1)),
"target": {"type": "CHOSEN"}
}),
(r"if (?:it|they) (?:is|are) dealt damage,? double the damage", lambda m: {
"type": "DAMAGE_INCREASE",
"modifier": "DOUBLE",
"target": {"type": "CHOSEN"}
}),
],
# CANCEL patterns
"CANCEL": [
(r"cancel (?:its|their|the) effect", lambda m: {
"type": "CANCEL",
"target": {"type": "CHOSEN"}
}),
(r"cancel (\d+) summon", lambda m: {
"type": "CANCEL",
"count": int(m.group(1)),
"target": {"type": "SUMMON"}
}),
],
# COPY_ABILITY patterns
"COPY_ABILITY": [
(r"gains? (?:its|their) (?:special )?abilities?", lambda m: {
"type": "COPY_ABILITY",
"target": {"type": "CHOSEN"}
}),
],
# DISCARD patterns
"DISCARD": [
(r"(?:your opponent )?discards? (\d+) cards? from (?:his/her|their) hand", lambda m: {
"type": "DISCARD",
"amount": int(m.group(1)),
"target": {"type": "OPPONENT"}
}),
(r"discard (\d+) cards? from your hand", lambda m: {
"type": "DISCARD",
"amount": int(m.group(1)),
"target": {"type": "CONTROLLER"}
}),
],
# ABILITY_GRANT patterns
"ABILITY_GRANT": [
(r"(.+?) gains? (haste|brave|first strike) until the end of the turn", lambda m: {
"type": "ABILITY_GRANT",
"ability": m.group(2).upper().replace(" ", "_"),
"duration": "END_OF_TURN",
"target": parse_target_text(m.group(1))
}),
(r"gains? (haste|brave|first strike) until the end of the turn", lambda m: {
"type": "ABILITY_GRANT",
"ability": m.group(1).upper().replace(" ", "_"),
"duration": "END_OF_TURN",
"target": {"type": "SELF"}
}),
(r"gains? (haste|brave|first strike)", lambda m: {
"type": "ABILITY_GRANT",
"ability": m.group(1).upper().replace(" ", "_"),
"duration": "PERMANENT",
"target": {"type": "SELF"}
}),
],
# PREVENT patterns
"PREVENT": [
# "cannot attack until end of opponent's turn" (longer duration)
(r"(?:it|they) cannot attack until the end of your opponent'?s? (?:next )?turn", lambda m: {
"type": "PREVENT",
"action": "ATTACK",
"duration": "END_OF_OPPONENT_TURN",
"target": {"type": "CHOSEN"}
}),
(r"cannot attack until the end of your opponent'?s? (?:next )?turn", lambda m: {
"type": "PREVENT",
"action": "ATTACK",
"duration": "END_OF_OPPONENT_TURN",
"target": {"type": "CHOSEN"}
}),
# "cannot attack or block until end of opponent's turn" (longer duration)
(r"(?:it|they) cannot attack or block until the end of your opponent'?s? (?:next )?turn", lambda m: {
"type": "PREVENT",
"action": "ATTACK_AND_BLOCK",
"duration": "END_OF_OPPONENT_TURN",
"target": {"type": "CHOSEN"}
}),
(r"they cannot attack or block until the end of (?:your|the) (?:next )?turn", lambda m: {
"type": "PREVENT",
"action": "ATTACK_AND_BLOCK",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
# "cannot be returned to hand"
(r"(?:it )?cannot be returned to (?:its )?owner'?s? hand", lambda m: {
"type": "PREVENT",
"action": "RETURN_TO_HAND",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
# "cannot be broken by opposing"
(r"(?:it )?cannot be broken by oppos(?:ing|ent'?s?)", lambda m: {
"type": "PREVENT",
"action": "BREAK_BY_OPPONENT",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"cannot attack or block until the end of your opponent'?s? (?:next )?turn", lambda m: {
"type": "PREVENT",
"action": "ATTACK_AND_BLOCK",
"duration": "END_OF_OPPONENT_TURN",
"target": {"type": "CHOSEN"}
}),
(r"(?:it|they|that forward) cannot be blocked", lambda m: {
"type": "PREVENT",
"action": "BLOCK",
"target": {"type": "CHOSEN"}
}),
(r"(?:it|they|that forward) cannot block", lambda m: {
"type": "PREVENT",
"action": "BLOCKING",
"target": {"type": "CHOSEN"}
}),
(r"(?:it|they|that forward) cannot be broken this turn", lambda m: {
"type": "PREVENT",
"action": "BREAK",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"(?:it|they|that forward) cannot attack this turn", lambda m: {
"type": "PREVENT",
"action": "ATTACK",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"(?:it|they|that forward) cannot attack or block this turn", lambda m: {
"type": "PREVENT",
"action": "ATTACK_AND_BLOCK",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"(.+?) cannot be chosen by .+ summons? or abilities", lambda m: {
"type": "PREVENT",
"action": "TARGET",
"target": parse_target_text(m.group(1))
}),
(r"cannot block this turn", lambda m: {
"type": "PREVENT",
"action": "BLOCKING",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"cannot attack this turn", lambda m: {
"type": "PREVENT",
"action": "ATTACK",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"cannot be broken this turn", lambda m: {
"type": "PREVENT",
"action": "BREAK",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
],
# FREEZE patterns
"FREEZE": [
(r"freeze (?:it|them)", lambda m: {
"type": "FREEZE",
"target": {"type": "CHOSEN"}
}),
(r"(.+?) doesn'?t activate during .+ next active phase", lambda m: {
"type": "FREEZE",
"target": parse_target_text(m.group(1))
}),
],
# RETRIEVE patterns (add card from break zone to hand)
"RETRIEVE": [
(r"add (?:it|them) to your hand", lambda m: {
"type": "RETRIEVE",
"destination": "HAND",
"target": {"type": "CHOSEN"}
}),
(r"add (.+?) (?:card )?(?:in|from) your break zone to your hand", lambda m: {
"type": "RETRIEVE",
"destination": "HAND",
"zone_from": "BREAK_ZONE",
"filter": parse_card_filter(m.group(1))
}),
],
# PLAY_FROM_ZONE patterns (play chosen card from break zone/hand)
"PLAY_FROM_ZONE": [
(r"play (?:it|them) onto the field(?: dull)?", lambda m: {
"type": "PLAY",
"target": {"type": "CHOSEN"},
"modifiers": {"enters_dull": "dull" in m.string.lower()}
}),
(r"play (?:it|them) onto the field without paying (?:its|their) cost", lambda m: {
"type": "PLAY",
"target": {"type": "CHOSEN"},
"modifiers": {"free_cost": True}
}),
],
# SPLIT_DAMAGE patterns (divide damage among targets)
"SPLIT_DAMAGE": [
(r"divide (\d+) damage among them", lambda m: {
"type": "SPLIT_DAMAGE",
"total_damage": int(m.group(1)),
"target": {"type": "CHOSEN"}
}),
(r"divide (\d+) damage among them equally", lambda m: {
"type": "SPLIT_DAMAGE",
"total_damage": int(m.group(1)),
"distribution": "EQUAL",
"target": {"type": "CHOSEN"}
}),
(r"divide (\d+) damage among them as you like", lambda m: {
"type": "SPLIT_DAMAGE",
"total_damage": int(m.group(1)),
"distribution": "CHOOSE",
"target": {"type": "CHOSEN"}
}),
],
# REMOVE_ABILITIES patterns
"REMOVE_ABILITIES": [
(r"(?:it|they) loses? all (?:its|their) abilities until the end of the turn", lambda m: {
"type": "REMOVE_ABILITIES",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"(?:it|they) loses? all (?:its|their) abilities", lambda m: {
"type": "REMOVE_ABILITIES",
"duration": "PERMANENT",
"target": {"type": "CHOSEN"}
}),
(r"loses all (?:its )?abilities until the end of the turn", lambda m: {
"type": "REMOVE_ABILITIES",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
],
# REMOVE_KEYWORDS patterns (loses specific keywords)
"REMOVE_KEYWORDS": [
(r"(?:it|they) loses? (haste|brave|first strike)(?:,? (?:and )?(haste|brave|first strike))*\s*until the end of the turn", lambda m: {
"type": "REMOVE_KEYWORDS",
"keywords": [kw.upper().replace(" ", "_") for kw in re.findall(r"haste|brave|first strike", m.group(0))],
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"loses (haste|brave|first strike)(?:,? (?:and )?(haste|brave|first strike))*\s*until the end of the turn", lambda m: {
"type": "REMOVE_KEYWORDS",
"keywords": [kw.upper().replace(" ", "_") for kw in re.findall(r"haste|brave|first strike", m.group(0))],
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
],
# ADD_COUNTER patterns
"ADD_COUNTER": [
(r"place (\d+) (.+?) counters? on (?:it|them)", lambda m: {
"type": "ADD_COUNTER",
"count": int(m.group(1)),
"counter_type": m.group(2).upper().replace(" ", "_"),
"target": {"type": "CHOSEN"}
}),
(r"place (\d+) (.+?) counters? on (.+)", lambda m: {
"type": "ADD_COUNTER",
"count": int(m.group(1)),
"counter_type": m.group(2).upper().replace(" ", "_"),
"target": parse_target_text(m.group(3))
}),
],
# GRANT_RESTRICTION patterns (gains "cannot X")
"GRANT_RESTRICTION": [
(r"(?:it|they) gains? \"this forward cannot attack(?:\"|\.)", lambda m: {
"type": "GRANT_RESTRICTION",
"restriction": "CANNOT_ATTACK",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"(?:it|they) gains? \"this forward cannot block(?:\"|\.)", lambda m: {
"type": "GRANT_RESTRICTION",
"restriction": "CANNOT_BLOCK",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"(?:it|they) gains? \"this forward cannot attack or block(?:\"|\.)", lambda m: {
"type": "GRANT_RESTRICTION",
"restriction": "CANNOT_ATTACK_OR_BLOCK",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"(?:it|they) gains? \"this forward cannot be chosen by", lambda m: {
"type": "GRANT_RESTRICTION",
"restriction": "CANNOT_BE_TARGETED",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"(?:it|they) gains? \"if possible, this forward must block(?:\"|\.)", lambda m: {
"type": "GRANT_RESTRICTION",
"restriction": "MUST_BLOCK",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"gains \"this forward cannot attack(?:\"|\.)", lambda m: {
"type": "GRANT_RESTRICTION",
"restriction": "CANNOT_ATTACK",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"gains \"this forward cannot block(?:\"|\.)", lambda m: {
"type": "GRANT_RESTRICTION",
"restriction": "CANNOT_BLOCK",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
],
# SHUFFLE_INTO_DECK patterns
"SHUFFLE_INTO_DECK": [
(r"put (?:it|them) under the top (?:four|4|three|3|two|2) cards? of (?:its |their )?owner'?s? deck", lambda m: {
"type": "SHUFFLE_INTO_DECK",
"position": "NEAR_TOP",
"target": {"type": "CHOSEN"}
}),
(r"shuffle (?:it|them) into (?:its |their )?owner'?s? deck", lambda m: {
"type": "SHUFFLE_INTO_DECK",
"position": "RANDOM",
"target": {"type": "CHOSEN"}
}),
],
# COMBAT_DAMAGE patterns (deal damage equal to power between forwards)
"COMBAT_DAMAGE": [
(r"the former deals damage equal to its power to the latter", lambda m: {
"type": "COMBAT_DAMAGE",
"direction": "FIRST_TO_SECOND",
"target": {"type": "CHOSEN"}
}),
(r"each forward deals damage equal to its power to the other", lambda m: {
"type": "COMBAT_DAMAGE",
"direction": "MUTUAL",
"target": {"type": "CHOSEN"}
}),
(r"deals damage equal to its power to (.+)", lambda m: {
"type": "COMBAT_DAMAGE",
"direction": "TO_TARGET",
"target": parse_target_text(m.group(1))
}),
],
# SET_POWER patterns (set power to specific value)
"SET_POWER": [
(r"(?:its|their) power becomes? (\d+)", lambda m: {
"type": "SET_POWER",
"amount": int(m.group(1)),
"target": {"type": "CHOSEN"}
}),
(r"power becomes? (\d+)", lambda m: {
"type": "SET_POWER",
"amount": int(m.group(1)),
"target": {"type": "CHOSEN"}
}),
],
# MUTUAL_DAMAGE patterns (two cards deal damage to each other)
"MUTUAL_DAMAGE": [
(r"(.+?) and the chosen forward deal damage equal to their power to each other", lambda m: {
"type": "MUTUAL_DAMAGE",
"source_card": m.group(1),
"target": {"type": "CHOSEN"}
}),
(r"and the chosen forward deal damage equal to their power to each other", lambda m: {
"type": "MUTUAL_DAMAGE",
"source_card": "SELF",
"target": {"type": "CHOSEN"}
}),
(r"the first one deals damage equal to its power to the second one", lambda m: {
"type": "MUTUAL_DAMAGE",
"direction": "FIRST_TO_SECOND",
"target": {"type": "CHOSEN"}
}),
# "Cecil and the chosen Forward deal damage..."
(r"(.+?) and the chosen forward deal damage", lambda m: {
"type": "MUTUAL_DAMAGE",
"source_card": m.group(1),
"target": {"type": "CHOSEN"}
}),
# "Deal it and X damage" - damage to both target and self
(r"deal (?:it|them) and (.+?) (\d+) damage", lambda m: {
"type": "SHARED_DAMAGE",
"self_card": m.group(1),
"amount": int(m.group(2)),
"target": {"type": "CHOSEN"}
}),
# "Deal them and X damage"
(r"deal them and (.+?) (\d+) damage", lambda m: {
"type": "SHARED_DAMAGE",
"self_card": m.group(1),
"amount": int(m.group(2)),
"target": {"type": "CHOSEN"}
}),
],
# REDIRECT_ABILITY patterns (change target of summon/ability)
"REDIRECT_ABILITY": [
(r"(?:the )?summon or ability (?:is )?that is choosing only", lambda m: {
"type": "REDIRECT_ABILITY",
"target": {"type": "CHOSEN"}
}),
(r"(?:the )?ability that is choosing only", lambda m: {
"type": "REDIRECT_ABILITY",
"target": {"type": "CHOSEN"}
}),
(r"(?:the )?summon that is choosing only", lambda m: {
"type": "REDIRECT_ABILITY",
"target": {"type": "CHOSEN"}
}),
(r"you may choose another .+ as the new target", lambda m: {
"type": "REDIRECT_ABILITY",
"target": {"type": "CHOSEN"}
}),
],
# OPPONENT_PUTS_BOTTOM patterns
"OPPONENT_PUTS_BOTTOM": [
(r"your opponent puts them at the bottom", lambda m: {
"type": "OPPONENT_PUTS_BOTTOM",
"target": {"type": "CHOSEN"}
}),
],
# TOP_OR_BOTTOM patterns
"TOP_OR_BOTTOM": [
(r"put (?:it|them) at the top or bottom of (?:its )?owner'?s? deck", lambda m: {
"type": "TOP_OR_BOTTOM_OF_DECK",
"target": {"type": "CHOSEN"}
}),
],
# TRANSFORM patterns (monster becomes forward)
"TRANSFORM": [
(r"(?:it )?(?:also )?becomes a forward with (\d+) power", lambda m: {
"type": "TRANSFORM",
"becomes": "FORWARD",
"power": int(m.group(1)),
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
],
# COPY_ACTION_ABILITIES patterns
"COPY_ACTION_ABILITIES": [
(r"(.+?) gains (?:his/her|its) action abilities", lambda m: {
"type": "COPY_ACTION_ABILITIES",
"copy_from": "CHOSEN",
"duration": "END_OF_TURN",
"target": {"type": "SELF"}
}),
(r"gains (?:his/her|its) action abilities", lambda m: {
"type": "COPY_ACTION_ABILITIES",
"copy_from": "CHOSEN",
"duration": "END_OF_TURN",
"target": {"type": "SELF"}
}),
],
# COPY_SPECIAL_ABILITY patterns
"COPY_SPECIAL_ABILITY": [
(r"(.+?) gains (?:his/her|its) special abilit(?:y|ies)", lambda m: {
"type": "COPY_SPECIAL_ABILITY",
"copy_from": "CHOSEN",
"duration": "END_OF_TURN",
"target": {"type": "SELF"}
}),
(r"gains (?:his/her|its) special abilit(?:y|ies)", lambda m: {
"type": "COPY_SPECIAL_ABILITY",
"copy_from": "CHOSEN",
"duration": "END_OF_TURN",
"target": {"type": "SELF"}
}),
],
# BLOCK_RESTRICTION patterns
"BLOCK_RESTRICTION": [
(r"(?:it )?can only be blocked by a forward of cost equal or inferior", lambda m: {
"type": "BLOCK_RESTRICTION",
"restriction": "COST_LESS_OR_EQUAL",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
],
# DAMAGE_TO_FORWARD_INCREASE patterns
"DAMAGE_TO_FORWARD_INCREASE": [
(r"(?:the next )?damage (?:it|they) deals? to a forward (?:is|becomes) (\d+) (?:more|instead)", lambda m: {
"type": "DAMAGE_TO_FORWARD_INCREASE",
"amount": int(m.group(1)),
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"increase the damage by (\d+)", lambda m: {
"type": "DAMAGE_TO_FORWARD_INCREASE",
"amount": int(m.group(1)),
"target": {"type": "CHOSEN"}
}),
],
# DAMAGE_DEALT_INCREASE patterns
"DAMAGE_DEALT_INCREASE": [
(r"(?:the )?damage dealt to (?:it|them) is increased by (\d+)", lambda m: {
"type": "DAMAGE_DEALT_INCREASE",
"amount": int(m.group(1)),
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
],
# GAIN_JOB_SELECT patterns
"GAIN_JOB_SELECT": [
(r"select a job\. (?:it )?gains that job", lambda m: {
"type": "GAIN_JOB",
"job": "SELECTED",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
],
# DAMAGE_EQUAL_TO_HIGHEST_POWER patterns
"DAMAGE_EQUAL_TO_HIGHEST_POWER": [
(r"deal (?:it|them) damage equal to the highest power forward you control", lambda m: {
"type": "DAMAGE",
"amount": "HIGHEST_CONTROLLED_POWER",
"target": {"type": "CHOSEN"}
}),
(r"deal (?:it|them) damage equal to the highest power (\w+) forward you control", lambda m: {
"type": "DAMAGE",
"amount": "HIGHEST_CONTROLLED_POWER",
"amount_filter": {"element": m.group(1).upper()},
"target": {"type": "CHOSEN"}
}),
],
# HIGHEST_COST_TARGET patterns (select forward with highest cost)
"HIGHEST_COST_TARGET": [
(r"choose 1 forward of the highest cost opponent controls", lambda m: {
"type": "DULL", # Typically followed by "cannot block"
"target": {"type": "HIGHEST_COST", "owner": "OPPONENT", "filter": {"card_type": "FORWARD"}}
}),
(r"(?:your )?opponent selects 1 forward of the highest cost they control\. put it into the break zone", lambda m: {
"type": "BREAK",
"target": {"type": "OPPONENT_SELECTS_HIGHEST_COST", "owner": "OPPONENT", "filter": {"card_type": "FORWARD"}}
}),
(r"choose 1 forward of the lowest cost opponent controls", lambda m: {
"type": "BREAK",
"target": {"type": "LOWEST_COST", "owner": "OPPONENT", "filter": {"card_type": "FORWARD"}}
}),
],
# RETURN_BOTH patterns
"RETURN_BOTH": [
(r"return them to their owners' hands?", lambda m: {
"type": "RETURN",
"destination": "HAND",
"target": {"type": "CHOSEN"}
}),
],
# PLAY_FROM_BREAK_ZONE patterns
"PLAY_FROM_BREAK_ZONE": [
(r"play all the forwards among them onto the field", lambda m: {
"type": "PLAY",
"filter": {"card_type": "FORWARD"},
"zone_from": "BREAK_ZONE",
"target": {"type": "CHOSEN"}
}),
],
# FORCE_BLOCK patterns
"FORCE_BLOCK": [
(r"(?:it|they) must block (?:this turn|if possible)", lambda m: {
"type": "FORCE_BLOCK",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"if possible,? (?:it|they) must block", lambda m: {
"type": "FORCE_BLOCK",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"gains \"this forward must block .+ if possible", lambda m: {
"type": "FORCE_BLOCK",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"gains \"if possible, this forward must block", lambda m: {
"type": "FORCE_BLOCK",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"gains? \"this forward must attack", lambda m: {
"type": "FORCE_ATTACK",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
],
# CHANGE_ELEMENT patterns
"CHANGE_ELEMENT": [
(r"(?:its|their) element becomes? (fire|ice|wind|earth|lightning|water|light|dark)", lambda m: {
"type": "CHANGE_ELEMENT",
"new_element": m.group(1).upper(),
"target": {"type": "CHOSEN"}
}),
(r"element becomes? (fire|ice|wind|earth|lightning|water|light|dark)", lambda m: {
"type": "CHANGE_ELEMENT",
"new_element": m.group(1).upper(),
"target": {"type": "CHOSEN"}
}),
],
# TOP_OF_DECK patterns
"TOP_OF_DECK": [
(r"put (?:it|them) on top of (?:your|its owner'?s?) deck", lambda m: {
"type": "TOP_OF_DECK",
"target": {"type": "CHOSEN"}
}),
(r"put (?:it|them) on top of your deck", lambda m: {
"type": "TOP_OF_DECK",
"target": {"type": "CHOSEN"}
}),
],
# REVEAL_AND_ADD patterns
"REVEAL_AND_ADD": [
(r"reveal the top (\d+) cards? of your deck\. add (\d+) cards? among them to your hand", lambda m: {
"type": "REVEAL_AND_ADD",
"reveal_count": int(m.group(1)),
"add_count": int(m.group(2)),
"destination": "HAND"
}),
(r"reveal the top (\d+) cards? of your deck", lambda m: {
"type": "REVEAL_AND_ADD",
"reveal_count": int(m.group(1)),
"destination": "HAND"
}),
],
# REDIRECT_TARGET patterns (change target of ability)
"REDIRECT_TARGET": [
(r"(?:the )?(?:summon|ability) (?:is )?choosing (?:only )?(.+?)\. (?:you may )?choose another", lambda m: {
"type": "REDIRECT_TARGET",
"original_target": m.group(1),
"target": {"type": "CHOSEN"}
}),
(r"you may choose another .+ as the new target", lambda m: {
"type": "REDIRECT_TARGET",
"target": {"type": "CHOSEN"}
}),
],
# GRANT_UNBLOCKABLE patterns
"GRANT_UNBLOCKABLE": [
(r"gains \"this forward cannot be blocked by a forward of cost", lambda m: {
"type": "GRANT_UNBLOCKABLE",
"condition": "COST_RESTRICTION",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"gains \"this forward cannot be blocked(?:\"|\.)", lambda m: {
"type": "GRANT_UNBLOCKABLE",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
],
# GRANT_PROTECTION patterns
"GRANT_PROTECTION": [
(r"gains \"this (?:forward|character) cannot be chosen by your opponent'?s? (?:summons? or )?abilities", lambda m: {
"type": "GRANT_PROTECTION",
"protection_from": "OPPONENT_ABILITIES",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"gains \"this (?:forward|character) cannot be broken(?:\"|\.)", lambda m: {
"type": "GRANT_PROTECTION",
"protection_from": "BREAK",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
],
# CAST_FROM_ZONE patterns
"CAST_FROM_ZONE": [
(r"you (?:can|may) cast (?:it|them) (?:at any time )?(?:you could cast a summon)?", lambda m: {
"type": "CAST_FROM_ZONE",
"timing": "SUMMON_TIMING",
"target": {"type": "CHOSEN"}
}),
(r"during this turn,? you (?:can|may) cast it", lambda m: {
"type": "CAST_FROM_ZONE",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
],
# REMOVE_COUNTER patterns
"REMOVE_COUNTER": [
(r"remove (?:the selected|(\d+)) counters? (?:from (?:it|them))?", lambda m: {
"type": "REMOVE_COUNTER",
"count": int(m.group(1)) if m.group(1) else 1,
"target": {"type": "CHOSEN"}
}),
(r"remove (\d+) (.+?) counters? from (?:it|them)", lambda m: {
"type": "REMOVE_COUNTER",
"count": int(m.group(1)),
"counter_type": m.group(2).upper().replace(" ", "_"),
"target": {"type": "CHOSEN"}
}),
],
# DOUBLE_COUNTER patterns
"DOUBLE_COUNTER": [
(r"double all counters of the same type", lambda m: {
"type": "DOUBLE_COUNTER",
"target": {"type": "CHOSEN"}
}),
(r"place (\d+) additional counters? of the same type", lambda m: {
"type": "ADD_MATCHING_COUNTER",
"count": int(m.group(1)),
"target": {"type": "CHOSEN"}
}),
],
# TRIGGER_EX_BURST patterns
"TRIGGER_EX_BURST": [
(r"(?:you may )?trigger (?:its )?ex burst", lambda m: {
"type": "TRIGGER_EX_BURST",
"optional": "you may" in m.group(0).lower() if hasattr(m, 'group') else False,
"target": {"type": "CHOSEN"}
}),
],
# GAIN_JOB patterns
"GAIN_JOB": [
(r"(?:it )?gains the named job until the end of the turn", lambda m: {
"type": "GAIN_JOB",
"job": "NAMED",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
(r"name 1 job\. (?:it )?gains the named job", lambda m: {
"type": "GAIN_JOB",
"job": "NAMED",
"duration": "END_OF_TURN",
"target": {"type": "CHOSEN"}
}),
],
# CONDITIONAL_BREAK patterns
"CONDITIONAL_BREAK": [
(r"if (?:its|their) cost is equal to or less than", lambda m: {
"type": "CONDITIONAL_BREAK",
"condition": "COST_COMPARISON",
"target": {"type": "CHOSEN"}
}),
(r"if you control (\d+) or more", lambda m: {
"type": "CONDITIONAL_EFFECT",
"condition": "CONTROL_COUNT",
"count": int(m.group(1)),
"target": {"type": "CHOSEN"}
}),
],
# RETURN_MULTIPLE patterns (return self and target)
"RETURN_MULTIPLE": [
(r"return (?:it|them) and (.+?) to their owners?' hands?", lambda m: {
"type": "RETURN_MULTIPLE",
"additional_target": m.group(1),
"destination": "HAND",
"target": {"type": "CHOSEN"}
}),
(r"return it and (.+?) to their owners' hand", lambda m: {
"type": "RETURN_MULTIPLE",
"additional_target": m.group(1),
"destination": "HAND",
"target": {"type": "CHOSEN"}
}),
],
# DEAL_SAME_DAMAGE patterns
"DEAL_SAME_DAMAGE": [
(r"deal (?:it|them) the same amount of damage", lambda m: {
"type": "DEAL_SAME_DAMAGE",
"target": {"type": "CHOSEN"}
}),
],
# HALF_POWER_DAMAGE patterns
"HALF_POWER_DAMAGE": [
(r"deal (?:it|them) damage equal to half of (?:its|their) power", lambda m: {
"type": "HALF_POWER_DAMAGE",
"target": {"type": "CHOSEN"}
}),
],
# WARP_COUNTER patterns
"WARP_COUNTER": [
(r"remove (\d+) warp counters? from it", lambda m: {
"type": "REMOVE_COUNTER",
"count": int(m.group(1)),
"counter_type": "WARP",
"target": {"type": "CHOSEN"}
}),
],
# OPPONENT_BOTTOM_OF_DECK patterns
"OPPONENT_BOTTOM_OF_DECK": [
(r"your opponent puts? (?:it|them) at the bottom of (?:his/her|their) deck", lambda m: {
"type": "OPPONENT_BOTTOM_OF_DECK",
"target": {"type": "CHOSEN"}
}),
],
# CHOOSE patterns (for target selection prefix)
"CHOOSE": [
(r"choose (\d+) (.+?)(?:\.|:)", lambda m: {
"type": "CHOOSE",
"count": int(m.group(1)),
"target": parse_target_text(m.group(2))
}),
(r"choose up to (\d+) (.+?)(?:\.|:)", lambda m: {
"type": "CHOOSE",
"count_up_to": int(m.group(1)),
"target": parse_target_text(m.group(2))
}),
],
# RETURN patterns - additional
"RETURN_ALL": [
(r"return all (?:the )?forwards to their owners'? hands?(?:\.|$)", lambda m: {
"type": "RETURN",
"destination": "HAND",
"target": {"type": "ALL", "filter": {"card_type": "FORWARD"}}
}),
(r"return (.+?) to your hand(?:\.|$)", lambda m: {
"type": "RETURN",
"destination": "HAND",
"target": parse_target_text(m.group(1))
}),
],
# SELF_DAMAGE patterns (X deals you damage)
"SELF_DAMAGE": [
(r"(.+?) deals you (\d+) points? of damage(?:\.|$)", lambda m: {
"type": "DAMAGE_TO_CONTROLLER",
"amount": int(m.group(2)),
"source": m.group(1)
}),
(r"(.+?) deals you 1 point of damage(?:\.|$)", lambda m: {
"type": "DAMAGE_TO_CONTROLLER",
"amount": 1,
"source": m.group(1)
}),
(r"(.+?) deals (?:your )?opponent (\d+) points? of damage(?:\.|$)", lambda m: {
"type": "DAMAGE_TO_OPPONENT",
"amount": int(m.group(2)),
"source": m.group(1)
}),
],
# OPPONENT_MAY_PLAY patterns
"OPPONENT_MAY_PLAY": [
(r"your opponent may play (\d+) (.+?) (?:card )?from (?:his/her|their) hand onto the field(?:\.|$)", lambda m: {
"type": "OPPONENT_MAY_PLAY",
"count": int(m.group(1)),
"filter": parse_card_filter(m.group(2)),
"zone_from": "HAND"
}),
],
# DELAYED_PLAY patterns (play X at end of turn)
"DELAYED_PLAY": [
(r"play (.+?) onto (?:the|your) field at the end of the turn(?:\.|$)", lambda m: {
"type": "DELAYED_PLAY",
"target": parse_target_text(m.group(1)),
"timing": "END_OF_TURN"
}),
(r"add (.+?) to your hand at the end of the turn(?:\.|$)", lambda m: {
"type": "DELAYED_ADD_TO_HAND",
"target": parse_target_text(m.group(1)),
"timing": "END_OF_TURN"
}),
],
# EACH_PLAYER patterns
"EACH_PLAYER": [
(r"each player draws? (\d+) cards?(?:\.|$)", lambda m: {
"type": "EACH_PLAYER_DRAW",
"count": int(m.group(1))
}),
(r"each player may search for (\d+) (.+?) and add (?:it|them) to (?:his/her|their) hands?(?:\.|$)", lambda m: {
"type": "EACH_PLAYER_SEARCH",
"count": int(m.group(1)),
"filter": parse_card_filter(m.group(2)),
"optional": True
}),
(r"each player discards? (\d+) cards?(?:\.|$)", lambda m: {
"type": "EACH_PLAYER_DISCARD",
"count": int(m.group(1))
}),
],
# GRANT_ABILITY_TEXT patterns (X gains "Y")
"GRANT_ABILITY_TEXT": [
(r"(.+?) gains [\"'](.+?)[\"'] until the end of the turn(?:\.|$)", lambda m: {
"type": "GRANT_ABILITY_TEXT",
"target": parse_target_text(m.group(1)),
"ability_text": m.group(2),
"duration": "END_OF_TURN"
}),
(r"(.+?) gains [\"'](.+?)[\"'](?:\.|$)", lambda m: {
"type": "GRANT_ABILITY_TEXT",
"target": parse_target_text(m.group(1)),
"ability_text": m.group(2)
}),
],
# FREEZE patterns
"FREEZE_ALL": [
(r"freeze all (?:the )?forwards other than (.+?)(?:\.|$)", lambda m: {
"type": "FREEZE",
"target": {"type": "ALL", "filter": {"card_type": "FORWARD"}},
"except": parse_target_text(m.group(1))
}),
(r"freeze all (?:the )?(.+?)(?:\.|$)", lambda m: {
"type": "FREEZE",
"target": {"type": "ALL", "filter": parse_card_filter(m.group(1))}
}),
],
# SCRY / TOP_DECK patterns
"SCRY": [
(r"look at the top card of your deck\. you may place (?:the|that) card at the bottom of your deck(?:\.|$)", lambda m: {
"type": "SCRY",
"count": 1,
"options": ["TOP", "BOTTOM"]
}),
(r"look at the top (\d+) cards of your deck\. (?:you may )?return them to the top (?:and/or|or) bottom of your deck in any order(?:\.|$)", lambda m: {
"type": "SCRY",
"count": int(m.group(1)),
"options": ["TOP", "BOTTOM"]
}),
],
# DAMAGE_PREVENTION patterns
"DAMAGE_PREVENTION": [
(r"the next damage dealt to (.+?) becomes 0 (?:this turn)?(?:\.|$)", lambda m: {
"type": "DAMAGE_PREVENTION",
"target": parse_target_text(m.group(1)),
"duration": "NEXT_DAMAGE"
}),
(r"during this turn, the next damage dealt to you becomes 0(?: instead)?(?:\.|$)", lambda m: {
"type": "DAMAGE_PREVENTION",
"target": {"type": "PLAYER", "owner": "CONTROLLER"},
"duration": "NEXT_DAMAGE"
}),
],
# DOUBLE_POWER patterns
"DOUBLE_POWER": [
(r"double the power of (.+?) until the end of the turn(?:\.|$)", lambda m: {
"type": "POWER_MOD",
"modifier": "DOUBLE",
"target": parse_target_text(m.group(1)),
"duration": "END_OF_TURN"
}),
],
# NAME_ELEMENT patterns
"NAME_ELEMENT": [
(r"name (\d+) elements? other than (.+?)\. (?:until the end of the turn, )?the element of (.+?) becomes the named (?:one|element)(?:\.|$)", lambda m: {
"type": "SET_ELEMENT_NAMED",
"except": m.group(2),
"target": parse_target_text(m.group(3)),
"duration": "END_OF_TURN"
}),
],
# REVEAL_SELECT_DISCARD patterns
"REVEAL_SELECT_DISCARD": [
(r"your opponent reveals (?:his/her|their) hand\. select (\d+) cards? (?:of cost (\d+) or more )?in (?:his/her|their) hand\. your opponent discards? (?:this|these) cards?(?:\.|$)", lambda m: {
"type": "REVEAL_SELECT_DISCARD",
"count": int(m.group(1)),
"filter": {"cost_gte": int(m.group(2))} if m.group(2) else None,
"target": {"type": "OPPONENT_HAND"}
}),
],
# CAST_FREE patterns
"CAST_FREE": [
(r"you may cast (\d+) (.+?) from your hand (?:with a cost (?:inferior|less than) (?:to )?that of the summon you cast )?without paying (?:its|the) cost(?:\.|$)", lambda m: {
"type": "CAST_FREE",
"count": int(m.group(1)),
"filter": parse_card_filter(m.group(2)),
"zone_from": "HAND",
"optional": True
}),
(r"cast (\d+) (.+?) from your hand without paying (?:the|its) cost(?:\.|$)", lambda m: {
"type": "CAST_FREE",
"count": int(m.group(1)),
"filter": parse_card_filter(m.group(2)),
"zone_from": "HAND"
}),
],
# DAMAGE_CANNOT_BE_REDUCED patterns
"DAMAGE_CANNOT_REDUCE": [
(r"the damage dealt to forwards (?:your )?opponent controls? cannot be reduced (?:this turn)?(?:\.|$)", lambda m: {
"type": "PREVENT_DAMAGE_REDUCTION",
"target": {"type": "ALL", "owner": "OPPONENT", "filter": {"card_type": "FORWARD"}},
"duration": "END_OF_TURN"
}),
],
# GENERATE_CP patterns (gain element/cp)
"GENERATE_CP": [
(r"gain (\d+) cp of any element(?:\.|$)", lambda m: {
"type": "GENERATE_CP",
"amount": int(m.group(1)),
"element": "ANY"
}),
(r"gain (\d+) (fire|ice|wind|earth|lightning|water|light|dark) cp(?:\.|$)", lambda m: {
"type": "GENERATE_CP",
"amount": int(m.group(1)),
"element": m.group(2).upper()
}),
(r"gain (\d+)? ?\{?(fire|ice|wind|earth|lightning|water|light|dark)\}?(?:\.|$)", lambda m: {
"type": "GENERATE_CP",
"amount": int(m.group(1)) if m.group(1) else 1,
"element": m.group(2).upper()
}),
(r"gain \{?(fire|ice|wind|earth|lightning|water|light|dark)\}?(?:\.|$)", lambda m: {
"type": "GENERATE_CP",
"amount": 1,
"element": m.group(1).upper()
}),
(r"gain (\d+)? ?\[?(fire|ice|wind|earth|lightning|water|light|dark)(?: cp)?\]?(?:\.|$)", lambda m: {
"type": "GENERATE_CP",
"amount": int(m.group(1)) if m.group(1) else 1,
"element": m.group(2).upper()
}),
(r"gain \[?(fire|ice|wind|earth|lightning|water|light|dark)(?: cp)?\]?(?:\.|$)", lambda m: {
"type": "GENERATE_CP",
"amount": 1,
"element": m.group(1).upper()
}),
(r"gain \[(fire|ice|wind|earth|lightning|water|light|dark) cp\](?:\.|$)", lambda m: {
"type": "GENERATE_CP",
"amount": 1,
"element": m.group(1).upper()
}),
],
# DAMAGE_REDUCTION_THIS_TURN patterns
"DAMAGE_REDUCTION_TURN": [
(r"during this turn, if (?:a )?forward you control is dealt damage, reduce the damage by (\d+)(?: instead)?(?:\.|$)", lambda m: {
"type": "DAMAGE_REDUCTION",
"amount": int(m.group(1)),
"target": {"type": "ALL", "owner": "CONTROLLER", "filter": {"card_type": "FORWARD"}},
"duration": "END_OF_TURN"
}),
],
# PARTY_ANY_ELEMENT patterns
"PARTY_ANY_ELEMENT": [
(r"(?:the )?forwards you control can form a party with forwards of any element (?:this turn)?(?:\.|$)", lambda m: {
"type": "PARTY_ANY_ELEMENT",
"target": {"type": "ALL", "owner": "CONTROLLER", "filter": {"card_type": "FORWARD"}},
"duration": "END_OF_TURN"
}),
],
# LOOK_AT_BOTH_DECKS patterns
"DUAL_SCRY": [
(r"look at the top card of your deck and your opponent'?s? deck\. put them on the top or bottom of the respective decks(?:\.|$)", lambda m: {
"type": "DUAL_SCRY",
"count": 1,
"targets": ["CONTROLLER", "OPPONENT"]
}),
],
# REVEAL_SELECT_REMOVE patterns
"REVEAL_SELECT_REMOVE": [
(r"your opponent reveals (?:his/her|their) hand\. select (\d+) cards? in (?:his/her|their) hand\. your opponent removes? (?:it|them) from the game(?:\.|$)", lambda m: {
"type": "REVEAL_SELECT_REMOVE",
"count": int(m.group(1)),
"target": {"type": "OPPONENT_HAND"}
}),
(r"your opponent reveals (\d+) cards? from (?:his/her|their) hand\. select (\d+) cards? among them\. your opponent discards? (?:this|these) cards?(?:\.|$)", lambda m: {
"type": "REVEAL_SELECT_DISCARD",
"reveal_count": int(m.group(1)),
"select_count": int(m.group(2)),
"target": {"type": "OPPONENT_HAND"}
}),
],
# GRANT_ALL patterns (all forwards gain ability)
"GRANT_ALL_ABILITY": [
(r"all (?:the )?forwards you control gain [\"'](.+?)[\"'] until the end of the turn(?:\.|$)", lambda m: {
"type": "GRANT_ABILITY_TEXT",
"target": {"type": "ALL", "owner": "CONTROLLER", "filter": {"card_type": "FORWARD"}},
"ability_text": m.group(1),
"duration": "END_OF_TURN"
}),
],
# EXILE_TOP_CASTABLE patterns
"EXILE_TOP_CASTABLE": [
(r"your opponent removes the top card of (?:his/her|their) deck from the game face down\. you can look at it and/or cast it", lambda m: {
"type": "EXILE_TOP_CASTABLE",
"source": "OPPONENT_DECK",
"caster": "CONTROLLER"
}),
],
# SET_ELEMENT patterns
"SET_ELEMENT": [
(r"name (\d+) elements? other than (.+?)\. (.+?) becomes the named element until the end of the turn(?:\.|$)", lambda m: {
"type": "SET_ELEMENT_NAMED",
"except": m.group(2),
"target": parse_target_text(m.group(3)),
"duration": "END_OF_TURN"
}),
],
# REMOVE_COUNTERS patterns
"REMOVE_COUNTERS": [
(r"remove all (.+?) counters from (.+?)(?:\. each player can use this ability)?(?:\.|$)", lambda m: {
"type": "REMOVE_ALL_COUNTERS",
"counter_type": m.group(1),
"target": parse_target_text(m.group(2))
}),
(r"remove (\d+) (.+?) counters? from (.+?):", lambda m: {
"type": "REMOVE_COUNTER",
"count": int(m.group(1)),
"counter_type": m.group(2),
"target": parse_target_text(m.group(3))
}),
],
# ADD_ALL_REMOVED patterns
"ADD_REMOVED_CARDS": [
(r"add all (?:the )?cards removed by (.+?)'?s? ability to your hand(?:\.|$)", lambda m: {
"type": "ADD_REMOVED_CARDS",
"source": m.group(1)
}),
],
# POWER_LOSS patterns
"POWER_LOSS": [
(r"all (?:the )?forwards (?:your )?opponent controls? lose (\d+) power until the end of the turn(?:\.|$)", lambda m: {
"type": "POWER_MOD",
"amount": -int(m.group(1)),
"target": {"type": "ALL", "owner": "OPPONENT", "filter": {"card_type": "FORWARD"}},
"duration": "END_OF_TURN"
}),
],
# LOOK_TOP_ARRANGE patterns
"LOOK_TOP_ARRANGE": [
(r"look at the top (\d+) cards of your deck\. put (\d+) cards? among them on top of your deck and the other to the bottom of your deck(?:\.|$)", lambda m: {
"type": "SCRY",
"count": int(m.group(1)),
"keep_top": int(m.group(2))
}),
],
# COPY_ACTION_ABILITY patterns
"COPY_ACTION": [
(r"(.+?) uses the same action ability without paying (?:the|its) cost(?:\.|$)", lambda m: {
"type": "COPY_ACTION",
"target": parse_target_text(m.group(1)),
"free": True
}),
(r"use (\d+) (.+?) that (?:a )?character has used this turn", lambda m: {
"type": "USE_ABILITY",
"count": int(m.group(1)),
"filter": m.group(2),
"used_this_turn": True
}),
],
# CAST_RESTRICTION patterns
"CAST_RESTRICTIONS": [
(r"players cannot cast (.+?)(?:\.|$)", lambda m: {
"type": "CAST_RESTRICTION",
"card_filter": m.group(1),
"applies_to": "ALL_PLAYERS"
}),
],
# MUST_BLOCK patterns
"MUST_BLOCK": [
(r"(?:the )?forwards you control must block if possible(?:\.|$)", lambda m: {
"type": "MUST_BLOCK",
"target": {"type": "ALL", "owner": "CONTROLLER", "filter": {"card_type": "FORWARD"}}
}),
],
# COPY_POWER patterns
"COPY_POWER": [
(r"(.+?)'?s? power becomes (?:the )?same as (?:that )?(.+?)'?s? power until the end of the turn(?:\.|$)", lambda m: {
"type": "COPY_POWER",
"target": parse_target_text(m.group(1)),
"copy_from": m.group(2),
"duration": "END_OF_TURN"
}),
],
# SCRY_TOP_BOTTOM patterns
"SCRY_ARRANGE": [
(r"look at the top (\d+) cards of your deck\. return (?:these|them) to the top (?:and/or|or) bottom of your deck in any order(?:\.|$)", lambda m: {
"type": "SCRY",
"count": int(m.group(1)),
"options": ["TOP", "BOTTOM"]
}),
],
# MUST_ATTACK patterns
"MUST_ATTACK": [
(r"(.+?) must attack (?:once per turn )?if possible(?:\.|$)", lambda m: {
"type": "MUST_ATTACK",
"target": parse_target_text(m.group(1))
}),
],
# TRIGGERED_DISCARD patterns (at beginning of X, opponent discards)
"TRIGGERED_DISCARD_PHASE": [
(r"at the beginning of the (.+?) phase during (?:each of )?your turns?, your opponent discards (\d+) cards?(?:\.|$)", lambda m: {
"type": "TRIGGERED_DISCARD",
"trigger": {"phase": m.group(1).upper().replace(" ", "_")},
"target": {"type": "OPPONENT"},
"count": int(m.group(2))
}),
],
# GAIN_CP_ON_ENTER patterns
"GAIN_CP_ON_ENTER": [
(r"gain (\d+) cp(?:\.|$)", lambda m: {
"type": "GENERATE_CP",
"amount": int(m.group(1)),
"element": "ANY"
}),
],
# GAIN_ELEMENT_SYMBOL patterns (various notations)
"GAIN_SYMBOL": [
(r"gain \{?f\}?(?:\.|$)", lambda m: {
"type": "GENERATE_CP",
"amount": 1,
"element": "FIRE"
}),
(r"gain \{?i\}?(?:\.|$)", lambda m: {
"type": "GENERATE_CP",
"amount": 1,
"element": "ICE"
}),
(r"gain \{?w\}?(?:\.|$)", lambda m: {
"type": "GENERATE_CP",
"amount": 1,
"element": "WIND"
}),
(r"gain \{?e\}?(?:\.|$)", lambda m: {
"type": "GENERATE_CP",
"amount": 1,
"element": "EARTH"
}),
(r"gain \{?l\}?(?:\.|$)", lambda m: {
"type": "GENERATE_CP",
"amount": 1,
"element": "LIGHTNING"
}),
(r"gain ⚡(?:\.|$)", lambda m: {
"type": "GENERATE_CP",
"amount": 1,
"element": "LIGHTNING"
}),
(r"gain \{?water\}?(?:\.|$)", lambda m: {
"type": "GENERATE_CP",
"amount": 1,
"element": "WATER"
}),
],
# POWER_CANNOT_DECREASE patterns
"POWER_PROTECTION": [
(r"the power of forwards you control cannot be (?:decreased|reduced) by your opponent'?s? summons? or abilities?(?:\.|$)", lambda m: {
"type": "PROTECT_POWER_DECREASE",
"target": {"type": "ALL", "owner": "CONTROLLER", "filter": {"card_type": "FORWARD"}}
}),
(r"the power of forwards (?:your )?opponent controls? cannot be increased by your opponent'?s? summons? or abilities?(?:\.|$)", lambda m: {
"type": "PREVENT_POWER_INCREASE",
"target": {"type": "ALL", "owner": "OPPONENT", "filter": {"card_type": "FORWARD"}}
}),
],
# CAST_LIMIT patterns
"CAST_LIMIT": [
(r"you can only cast up to (\d+) cards? per turn(?:\.|$)", lambda m: {
"type": "CAST_LIMIT",
"limit": int(m.group(1)),
"per": "TURN"
}),
],
# DISCARD_HAND patterns
"DISCARD_HAND": [
(r"discard your hand(?:\.|$)", lambda m: {
"type": "DISCARD",
"amount": "ALL",
"target": {"type": "CONTROLLER_HAND"}
}),
],
# REDUCE_COST_BEFORE patterns
"REDUCE_COST_BEFORE": [
(r"before paying the cost to cast (.+?), you can pay (.+?) to reduce the cost required to cast (.+?) by (\d+)(?:\.|$)", lambda m: {
"type": "COST_REDUCTION_OPTIONAL",
"card_filter": m.group(1),
"payment": m.group(2),
"reduction": int(m.group(4))
}),
],
# TRIGGER_ON_CAST patterns
"TRIGGER_ON_CAST": [
(r"when you cast a summon, gain (.+?)\. this effect will trigger only once per turn(?:\.|$)", lambda m: {
"type": "ON_CAST_GAIN_CP",
"trigger": "CAST_SUMMON",
"gain": m.group(1),
"once_per_turn": True
}),
],
}
# =============================================================================
# Field Ability Patterns (Continuous Effects)
# =============================================================================
FIELD_PATTERNS = [
# ==========================================================================
# Keywords (simple)
# ==========================================================================
(r"^haste\b", {"type": "KEYWORD", "keyword": "HASTE"}),
(r"^brave\b", {"type": "KEYWORD", "keyword": "BRAVE"}),
(r"^first strike\b", {"type": "KEYWORD", "keyword": "FIRST_STRIKE"}),
# ==========================================================================
# Power modifiers
# ==========================================================================
(r"(.+?) gains? \+(\d+) power", lambda m: {
"type": "POWER_MOD",
"amount": int(m.group(2)),
"condition": m.group(1) if "if" in m.group(1).lower() else None,
"target": {"type": "SELF"}
}),
(r"for each (.+?), (.+?) gains? \+(\d+) power", lambda m: {
"type": "POWER_MOD_SCALING",
"amount_per": int(m.group(3)),
"count_filter": m.group(1),
"target": parse_target_text(m.group(2))
}),
(r"double the power of (.+)", lambda m: {
"type": "POWER_MOD",
"modifier": "DOUBLE",
"target": parse_target_text(m.group(1))
}),
# ==========================================================================
# "Cannot be blocked by X" patterns (BLOCK_IMMUNITY)
# ==========================================================================
(r"(.+?) cannot be blocked by a forward (?:of|with a) (?:cost|power) (\d+) or more", lambda m: {
"type": "BLOCK_IMMUNITY",
"condition": {"comparison": "GTE", "value": int(m.group(2)), "attribute": "cost" if "cost" in m.group(0) else "power"},
"target": parse_target_text(m.group(1))
}),
(r"(.+?) cannot be blocked by a forward with a power greater than (?:his|hers|its|their)", lambda m: {
"type": "BLOCK_IMMUNITY",
"condition": {"comparison": "GT", "attribute": "power", "compare_to": "SELF_POWER"},
"target": parse_target_text(m.group(1))
}),
(r"(.+?) cannot be blocked by a forward with a power less than (?:his|hers|its|their)", lambda m: {
"type": "BLOCK_IMMUNITY",
"condition": {"comparison": "LT", "attribute": "power", "compare_to": "SELF_POWER"},
"target": parse_target_text(m.group(1))
}),
(r"(.+?) cannot be blocked(?:\.|$)", lambda m: {
"type": "BLOCK_IMMUNITY",
"condition": None,
"target": parse_target_text(m.group(1))
}),
# ==========================================================================
# "Cannot block X" patterns (BLOCK_RESTRICTION)
# ==========================================================================
(r"(.+?) cannot block a forward (?:of|with a) (?:cost|power) (\d+) or more", lambda m: {
"type": "BLOCK_RESTRICTION",
"restriction": "CANNOT_BLOCK",
"condition": {"comparison": "GTE", "value": int(m.group(2)), "attribute": "cost" if "cost" in m.group(0) else "power"},
"target": parse_target_text(m.group(1))
}),
(r"(.+?) cannot block a forward with a power greater than (?:his|hers|its|their)", lambda m: {
"type": "BLOCK_RESTRICTION",
"restriction": "CANNOT_BLOCK",
"condition": {"comparison": "GT", "attribute": "power", "compare_to": "SELF_POWER"},
"target": parse_target_text(m.group(1))
}),
# ==========================================================================
# "Cannot be chosen by X" patterns (SELECTION_IMMUNITY)
# ==========================================================================
(r"(.+?) cannot be chosen by (?:your )?opponent'?s? summons?(?:\.|$)", lambda m: {
"type": "SELECTION_IMMUNITY",
"from": "OPPONENT_SUMMONS",
"target": parse_target_text(m.group(1))
}),
(r"(.+?) cannot be chosen by (?:your )?opponent'?s? abilities(?:\.|$)", lambda m: {
"type": "SELECTION_IMMUNITY",
"from": "OPPONENT_ABILITIES",
"target": parse_target_text(m.group(1))
}),
(r"(.+?) cannot be chosen by (?:your )?opponent'?s? summons? or abilities", lambda m: {
"type": "SELECTION_IMMUNITY",
"from": "OPPONENT_SUMMONS_AND_ABILITIES",
"target": parse_target_text(m.group(1))
}),
(r"if you control \[card name \((.+?)\)\], (?:it|they) cannot be chosen by", lambda m: {
"type": "SELECTION_IMMUNITY",
"from": "OPPONENT",
"condition": {"control": m.group(1)},
"target": {"type": "CONDITIONAL"}
}),
# ==========================================================================
# "Cannot attack/block" patterns (RESTRICTION)
# ==========================================================================
(r"(.+?) cannot attack(?:\.|$)", lambda m: {
"type": "RESTRICTION",
"restriction": "CANNOT_ATTACK",
"target": parse_target_text(m.group(1))
}),
(r"(.+?) cannot block(?:\.|$)", lambda m: {
"type": "RESTRICTION",
"restriction": "CANNOT_BLOCK",
"target": parse_target_text(m.group(1))
}),
(r"(.+?) cannot attack or block(?:\.|$)", lambda m: {
"type": "RESTRICTION",
"restriction": "CANNOT_ATTACK_OR_BLOCK",
"target": parse_target_text(m.group(1))
}),
# ==========================================================================
# Cost modification patterns
# NOTE: COST_REDUCTION_SCALING must come BEFORE COST_REDUCTION since it's more specific
# ==========================================================================
(r"the cost (?:required )?(?:to|for) (?:play|cast) (?:your )?(.+?) is reduced by (\d+) for each (.+?)(?:\s*\(it cannot become .+?\))?(?:\.|$)", lambda m: (lambda scale_by, scale_filter: {
"type": "COST_REDUCTION_SCALING",
"card_filter": m.group(1).strip(),
"reduction_per": int(m.group(2)),
"scale_by": scale_by,
"scale_filter": scale_filter if scale_filter else {},
"for_player": "CONTROLLER"
})(*extract_scale_filter(m.group(3)))),
(r"the cost (?:required )?(?:to|for) (?:play|cast) (?:\[card name \()?(.+?)(?:\)\])? (?:onto the field )?is reduced by (\d+)(?:\s*\(it cannot become .+?\))?(?:\.|$)", lambda m: {
"type": "COST_REDUCTION",
"card_filter": m.group(1),
"amount": int(m.group(2)),
"for_player": "CONTROLLER"
}),
(r"the cost (?:required )?for (?:your )?opponent (?:to|for) (?:cast|play) (.+?) increases by (\d+)", lambda m: {
"type": "COST_INCREASE",
"card_filter": m.group(1),
"amount": int(m.group(2)),
"for_player": "OPPONENT"
}),
(r"if you control \[card name \((.+?)\)\], the cost for playing (.+?) is reduced by (\d+)", lambda m: {
"type": "COST_REDUCTION",
"card_filter": m.group(2),
"amount": int(m.group(3)),
"condition": {"control": m.group(1)},
"for_player": "CONTROLLER"
}),
# ==========================================================================
# "Must choose X if possible" (TAUNT)
# ==========================================================================
(r"summons? (?:or|and) abilities? of (?:your )?opponent must choose (.+?) if possible", lambda m: {
"type": "TAUNT",
"target": parse_target_text(m.group(1))
}),
(r"(?:your )?opponent'?s? summons? (?:or|and) abilities? must choose (.+?) if possible", lambda m: {
"type": "TAUNT",
"target": parse_target_text(m.group(1))
}),
# ==========================================================================
# "If X, then Y" conditionals (CONDITIONAL_FIELD)
# ==========================================================================
(r"if (.+?) is on the field, (?:it|they) gains? (.+)", lambda m: {
"type": "CONDITIONAL_FIELD",
"condition": {"card_on_field": m.group(1)},
"effect": m.group(2)
}),
(r"if you control \[card name \((.+?)\)\], (.+)", lambda m: {
"type": "CONDITIONAL_FIELD",
"condition": {"control": m.group(1)},
"effect": m.group(2)
}),
(r"if this forward blocks or is blocked by a forward without first strike, this forward deals damage first", lambda m: {
"type": "CONDITIONAL_FIELD",
"condition": {"combat": "BLOCKS_OR_BLOCKED_BY_NO_FIRST_STRIKE"},
"effect": {"type": "KEYWORD", "keyword": "FIRST_STRIKE"}
}),
# ==========================================================================
# Element/Job modification
# ==========================================================================
(r"(.+?) gains? elements? of (.+)", lambda m: {
"type": "GAIN_ELEMENTS",
"source": m.group(2),
"target": parse_target_text(m.group(1))
}),
(r"(.+?) has all the jobs?", lambda m: {
"type": "HAS_ALL_JOBS",
"target": parse_target_text(m.group(1))
}),
# ==========================================================================
# Damage modifiers
# ==========================================================================
(r"if (.+?) deals damage .+, double the damage", lambda m: {
"type": "DAMAGE_MODIFIER",
"modifier": "DOUBLE",
"condition": m.group(1)
}),
(r"if a forward forming a party with (.+?) is dealt damage, the damage becomes 0 instead", lambda m: {
"type": "DAMAGE_PREVENTION",
"condition": {"party_with": m.group(1)},
"target": {"type": "PARTY_MEMBER"}
}),
(r"the next damage dealt to (.+?) (?:is reduced by|becomes) (\d+)(?: instead)?", lambda m: {
"type": "DAMAGE_REDUCTION",
"amount": int(m.group(2)),
"duration": "NEXT_DAMAGE",
"target": parse_target_text(m.group(1))
}),
(r"(?:during this turn, )?(?:the next )?damage dealt to you becomes 0 instead", lambda m: {
"type": "DAMAGE_PREVENTION",
"target": {"type": "PLAYER"},
"duration": "NEXT_DAMAGE"
}),
# ==========================================================================
# "Return all X" patterns
# ==========================================================================
(r"return all forwards to their owners?' hands?", lambda m: {
"type": "RETURN",
"target": {"type": "ALL", "filter": {"card_type": "FORWARD"}},
"destination": "HAND"
}),
(r"return all (.+?) to their owners?' hands?", lambda m: {
"type": "RETURN",
"target": {"type": "ALL", "filter": parse_card_filter(m.group(1))},
"destination": "HAND"
}),
# ==========================================================================
# "Deals X damage to you/opponent" patterns
# ==========================================================================
(r"(.+?) deals (?:you|its controller) (\d+) points? of damage", lambda m: {
"type": "DAMAGE_TO_CONTROLLER",
"amount": int(m.group(2)),
"target": {"type": "CONTROLLER"}
}),
(r"(.+?) deals (?:your )?opponent (\d+) points? of damage", lambda m: {
"type": "DAMAGE_TO_OPPONENT",
"amount": int(m.group(2)),
"target": {"type": "OPPONENT"}
}),
# ==========================================================================
# "Opponent may X" patterns
# ==========================================================================
(r"(?:your )?opponent may play (\d+) (.+?) from (?:his/her|their) hand onto the field", lambda m: {
"type": "OPPONENT_MAY_PLAY",
"count": int(m.group(1)),
"filter": parse_card_filter(m.group(2)),
"zone_from": "HAND"
}),
(r"(?:your )?opponent may (.+)", lambda m: {
"type": "OPPONENT_MAY",
"action": m.group(1)
}),
# ==========================================================================
# "You may X" patterns
# ==========================================================================
(r"you may play (.+?) the turn (?:it|they) enters? the field", lambda m: {
"type": "HASTE_LIKE",
"applies_to": m.group(1)
}),
(r"this character can attack or use abilities .+ the turn it enters the field", lambda m: {
"type": "HASTE_LIKE",
"target": {"type": "SELF"}
}),
# ==========================================================================
# "Cannot use EX Burst" patterns
# ==========================================================================
(r"any card put in the damage zone .+ cannot use its? ex burst", lambda m: {
"type": "SUPPRESS_EX_BURST",
"condition": "DAMAGE_ZONE"
}),
# ==========================================================================
# "Cannot play X" patterns
# ==========================================================================
(r"you cannot play (.+?) while (?:already )?in control of (.+)", lambda m: {
"type": "PLAY_RESTRICTION",
"cannot_play": m.group(1),
"condition": {"control": m.group(2)}
}),
# ==========================================================================
# Protection effects
# ==========================================================================
(r"(.+?) cannot be broken by .+ summons? or abilities", lambda m: {
"type": "PROTECTION",
"protection_from": "ABILITIES",
"target": parse_target_text(m.group(1))
}),
(r"(.+?) cannot be chosen by .+ summons? or abilities", lambda m: {
"type": "PROTECTION",
"protection_from": "TARGET",
"target": parse_target_text(m.group(1))
}),
# ==========================================================================
# Replacement effects
# ==========================================================================
(r"(?:during this turn, )?if (?:a )?forward you control is dealt damage, reduce the damage by (\d+) instead", lambda m: {
"type": "DAMAGE_REDUCTION",
"amount": int(m.group(1)),
"target": {"type": "ALL", "owner": "CONTROLLER", "filter": {"card_type": "FORWARD"}},
"duration": "END_OF_TURN"
}),
# ==========================================================================
# Select/Choose mode patterns - SPECIAL HANDLING
# These are processed by parse_modal_ability() which extracts full mode options
# The pattern here is just a placeholder that will be overridden
# ==========================================================================
(r"select (?:up to )?\d+ of the \d+ following actions?", lambda m: {
"type": "CHOOSE_MODE_PLACEHOLDER",
"_needs_modal_parsing": True
}),
# ==========================================================================
# "X deals you/opponent damage" patterns (for AUTO abilities)
# ==========================================================================
(r"(.+?) deals you (\d+) points? of damage", lambda m: {
"type": "DAMAGE_TO_CONTROLLER",
"amount": int(m.group(2)),
"source": m.group(1)
}),
(r"(.+?) deals (?:your )?opponent (\d+) points? of damage", lambda m: {
"type": "DAMAGE_TO_OPPONENT",
"amount": int(m.group(2)),
"source": m.group(1)
}),
# ==========================================================================
# "Return all X to hands" patterns
# ==========================================================================
(r"return all forwards to their owners' hands", lambda m: {
"type": "RETURN",
"target": {"type": "ALL", "filter": {"card_type": "FORWARD"}},
"destination": "HAND"
}),
(r"return (.+?) to (?:your|its owner'?s?) hand", lambda m: {
"type": "RETURN",
"target": parse_target_text(m.group(1)),
"destination": "HAND"
}),
# ==========================================================================
# "All X enter field dull" patterns
# ==========================================================================
(r"all (?:the )?forwards (?:other than (.+?) )?enter the field dull", lambda m: {
"type": "ENTERS_DULL",
"target": {"type": "ALL", "filter": {"card_type": "FORWARD"}},
"except": m.group(1) if m.group(1) else None
}),
# ==========================================================================
# "Freeze all X" patterns
# ==========================================================================
(r"freeze all (?:the )?(.+)", lambda m: {
"type": "FREEZE",
"target": {"type": "ALL", "filter": parse_card_filter(m.group(1))}
}),
# ==========================================================================
# "Look at top X cards" patterns
# ==========================================================================
(r"look at the top (\d+)? ?cards? of your deck", lambda m: {
"type": "LOOK_AT_TOP",
"count": int(m.group(1)) if m.group(1) else 1,
"owner": "CONTROLLER"
}),
(r"you may place the card at the bottom of your deck", lambda m: {
"type": "MAY_PUT_BOTTOM",
"target": {"type": "LOOKED_AT"}
}),
# ==========================================================================
# "Name X element" patterns
# ==========================================================================
(r"name (\d+) elements? (?:other than (.+?))?\.", lambda m: {
"type": "NAME_ELEMENT",
"count": int(m.group(1)),
"except": m.group(2) if m.group(2) else None
}),
(r"the element of (.+?) becomes the named one", lambda m: {
"type": "SET_ELEMENT",
"target": parse_target_text(m.group(1)),
"element": "NAMED"
}),
# ==========================================================================
# "Can play 2 or more X" patterns
# ==========================================================================
(r"you can play (\d+) or more (light|dark) characters onto the field", lambda m: {
"type": "ALLOW_MULTIPLE",
"count": int(m.group(1)),
"filter": {"element": m.group(2).upper()}
}),
# ==========================================================================
# "Is also Card Name X" patterns
# ==========================================================================
(r"(.+?) is also (?:\[)?card name(?: \()?([\w\s]+)(?:\))?\]? in all situations", lambda m: {
"type": "ALSO_NAMED",
"target": parse_target_text(m.group(1)),
"also_name": m.group(2).strip()
}),
(r"(.+?) is also a (\w+) in all situations", lambda m: {
"type": "ALSO_TYPE",
"target": parse_target_text(m.group(1)),
"also_type": m.group(2).upper()
}),
# ==========================================================================
# "Can form a party with X" patterns
# ==========================================================================
(r"(.+?) can form a party with (.+?) of any element", lambda m: {
"type": "PARTY_ANY_ELEMENT",
"target": parse_target_text(m.group(1)),
"party_with": m.group(2)
}),
(r"(?:the )?forwards you control can form a party with forwards of any element", lambda m: {
"type": "PARTY_ANY_ELEMENT",
"target": {"type": "ALL", "owner": "CONTROLLER", "filter": {"card_type": "FORWARD"}}
}),
# ==========================================================================
# "Cannot cast X" patterns
# ==========================================================================
(r"you cannot cast (.+)", lambda m: {
"type": "CAST_RESTRICTION",
"card_filter": m.group(1)
}),
# ==========================================================================
# "Play X onto field at end of turn" patterns
# ==========================================================================
(r"play (.+?) onto the field at the end of the turn", lambda m: {
"type": "DELAYED_PLAY",
"target": parse_target_text(m.group(1)),
"timing": "END_OF_TURN"
}),
# ==========================================================================
# "Cast X without paying cost" patterns
# ==========================================================================
(r"cast (\d+) (.+?) from your hand without paying (?:the|its) cost", lambda m: {
"type": "CAST_FREE",
"count": int(m.group(1)),
"filter": parse_card_filter(m.group(2)),
"zone_from": "HAND"
}),
(r"you may cast (\d+) (.+?) from your hand (?:with a cost (?:inferior|less) (?:to|than) .+? )?without paying (?:the|its) cost", lambda m: {
"type": "CAST_FREE",
"count": int(m.group(1)) if m.group(1) else 1,
"filter": parse_card_filter(m.group(2)),
"zone_from": "HAND",
"optional": True
}),
# ==========================================================================
# "The next damage dealt to X becomes 0" patterns
# ==========================================================================
(r"the next damage dealt to (.+?) becomes 0 (?:this turn)?", lambda m: {
"type": "DAMAGE_PREVENTION",
"target": parse_target_text(m.group(1)),
"duration": "NEXT_DAMAGE"
}),
# ==========================================================================
# "If X is dealt damage less than power" patterns
# ==========================================================================
(r"if (?:a )?forward you control is dealt damage less than its power, the damage becomes 0 instead", lambda m: {
"type": "DAMAGE_PREVENTION_CONDITIONAL",
"condition": "DAMAGE_LESS_THAN_POWER",
"target": {"type": "ALL", "owner": "CONTROLLER", "filter": {"card_type": "FORWARD"}}
}),
# ==========================================================================
# "X can produce CP of any Element" patterns
# ==========================================================================
(r"if (.+?) is on the field, (.+?) can produce cp of any element", lambda m: {
"type": "PRODUCE_ANY_CP",
"condition": {"card_on_field": m.group(1)},
"target": parse_target_text(m.group(2))
}),
(r"(.+?) can produce (.+?) cp", lambda m: {
"type": "PRODUCE_CP",
"element": m.group(2).upper() if m.group(2) != "any" else "ANY",
"target": parse_target_text(m.group(1))
}),
# ==========================================================================
# "Cost can be paid with CP of any Element" patterns
# ==========================================================================
(r"the cost (?:required )?to cast (?:your )?(.+?) can be paid with cp of any element", lambda m: {
"type": "COST_ANY_ELEMENT",
"card_filter": m.group(1)
}),
# ==========================================================================
# "Choose 1 Forward that entered field this turn" patterns
# ==========================================================================
(r"choose (\d+) (.+?) that entered the field this turn", lambda m: {
"type": "CHOOSE",
"count": int(m.group(1)),
"filter": {**parse_card_filter(m.group(2)), "entered_this_turn": True}
}),
# ==========================================================================
# "Reveal hand, select and discard" patterns
# ==========================================================================
(r"(?:your )?opponent reveals (?:his/her|their) hand\. select (\d+) card", lambda m: {
"type": "REVEAL_AND_DISCARD",
"count": int(m.group(1)),
"target": {"type": "OPPONENT_HAND"}
}),
# ==========================================================================
# "Damage dealt to Forwards cannot be reduced" patterns
# ==========================================================================
(r"the damage dealt to forwards (?:you|opponent) controls? cannot be reduced (?:this turn)?", lambda m: {
"type": "PREVENT_DAMAGE_REDUCTION",
"target": {"type": "ALL", "filter": {"card_type": "FORWARD"}},
"duration": "END_OF_TURN"
}),
# ==========================================================================
# "Gains 'X'" ability text patterns
# ==========================================================================
(r"(.+?) gains \"(.+?)\" until the end of the turn", lambda m: {
"type": "GRANT_ABILITY_TEXT",
"target": parse_target_text(m.group(1)),
"ability_text": m.group(2),
"duration": "END_OF_TURN"
}),
# ==========================================================================
# "If you control Card Name X" cost reduction patterns
# ==========================================================================
(r"if you control (?:a )?(?:\[)?card name(?: \()?(.+?)(?:\))?(?:\])?, the cost for playing (.+?) (?:onto the field )?is reduced by (\d+)", lambda m: {
"type": "COST_REDUCTION",
"condition": {"control_card_name": m.group(1).strip()},
"card_filter": m.group(2),
"amount": int(m.group(3)),
"for_player": "CONTROLLER"
}),
(r"if you control (?:a )?(?:\[)?category(?: \()?(.+?)(?:\))?(?:\])? character, the cost for playing (.+?) (?:onto the field )?is reduced by (\d+)", lambda m: {
"type": "COST_REDUCTION",
"condition": {"control_category": m.group(1).strip()},
"card_filter": m.group(2),
"amount": int(m.group(3)),
"for_player": "CONTROLLER"
}),
(r"if your opponent controls (\d+) or more (.+?), the cost for playing (.+?) (?:onto the field )?is reduced by (\d+)", lambda m: {
"type": "COST_REDUCTION",
"condition": {"opponent_controls_count": int(m.group(1)), "card_filter": m.group(2)},
"card_filter": m.group(3),
"amount": int(m.group(4)),
"for_player": "CONTROLLER"
}),
# ==========================================================================
# "If X is dealt damage by summon/ability" damage prevention
# ==========================================================================
(r"if (.+?) is dealt damage by a summon or an ability, the damage becomes 0 instead", lambda m: {
"type": "DAMAGE_PREVENTION",
"condition": {"damage_source": "SUMMON_OR_ABILITY"},
"target": parse_target_text(m.group(1))
}),
(r"during this turn, if (?:a )?(.+?) you control is dealt damage(?:, reduce the damage by (\d+))? instead", lambda m: {
"type": "DAMAGE_REDUCTION",
"amount": int(m.group(2)) if m.group(2) else 0,
"target": {"type": "ALL", "owner": "CONTROLLER", "filter": parse_card_filter(m.group(1))},
"duration": "END_OF_TURN"
}),
# ==========================================================================
# "X enters the field dull" patterns
# ==========================================================================
(r"(.+?) enters the field dull", lambda m: {
"type": "ENTERS_DULL",
"target": parse_target_text(m.group(1))
}),
(r"(?:the )?opponent'?s? forwards enter the field dull", lambda m: {
"type": "ENTERS_DULL",
"target": {"type": "ALL", "owner": "OPPONENT", "filter": {"card_type": "FORWARD"}}
}),
# ==========================================================================
# "Each player X" patterns
# ==========================================================================
(r"each player may search for (\d+) (.+?) and add (?:it|them) to (?:his/her|their) hand", lambda m: {
"type": "EACH_PLAYER_SEARCH",
"count": int(m.group(1)),
"filter": parse_card_filter(m.group(2)),
"optional": True
}),
(r"each player draws (\d+) cards?", lambda m: {
"type": "EACH_PLAYER_DRAW",
"count": int(m.group(1))
}),
(r"each player discards (\d+) cards?", lambda m: {
"type": "EACH_PLAYER_DISCARD",
"count": int(m.group(1))
}),
# ==========================================================================
# "During X phase, Y cannot be broken" patterns
# ==========================================================================
(r"during (?:each )?(.+?) phase, (.+?) cannot be broken", lambda m: {
"type": "BREAK_IMMUNITY",
"phase": m.group(1).upper().replace(" ", "_"),
"target": parse_target_text(m.group(2))
}),
# ==========================================================================
# "Deal X damage for each Y" patterns
# ==========================================================================
(r"deal (\d+) damage for each (.+)", lambda m: {
"type": "DAMAGE_SCALING",
"damage_per": int(m.group(1)),
"count_filter": m.group(2),
"target": {"type": "CHOSEN"}
}),
# ==========================================================================
# "Can use action abilities as though had Haste" patterns
# ==========================================================================
(r"(.+?) can use action abilities (?:with .+ in the cost )?as though (?:they|it) had haste", lambda m: {
"type": "ACTION_HASTE",
"target": parse_target_text(m.group(1))
}),
# ==========================================================================
# "When X attacks, Y" patterns (for FIELD abilities)
# ==========================================================================
(r"when (?:a )?(.+?) you control attacks, deal (\d+) damage", lambda m: {
"type": "ON_ATTACK_DAMAGE",
"trigger_filter": parse_card_filter(m.group(1)),
"damage": int(m.group(2)),
"target": {"type": "CHOSEN"}
}),
# ==========================================================================
# "Gain X CP" patterns
# ==========================================================================
(r"gain (\d+) cp(?:\.| of any element)?", lambda m: {
"type": "GENERATE_CP",
"amount": int(m.group(1)),
"element": "ANY"
}),
# ==========================================================================
# "You may discard X. If you do" patterns
# ==========================================================================
(r"you may discard (\d+) cards?\. if you do(?:,| so,?) (.+)", lambda m: {
"type": "DISCARD_THEN_EFFECT",
"discard_count": int(m.group(1)),
"then_effect": m.group(2)
}),
# ==========================================================================
# "Look at top X, return to top/bottom in any order" patterns
# ==========================================================================
(r"look at the top (\d+) cards of your deck\.? (?:you may )?return (?:these|them) to the top (?:and/or|or) bottom of your deck in any order", lambda m: {
"type": "SCRY",
"count": int(m.group(1))
}),
# ==========================================================================
# "When X enters field, you may pay Y" patterns
# ==========================================================================
(r"when (.+?) enters (?:the|your) field, you may pay (.+?)\. when you do so, (.+)", lambda m: {
"type": "ENTERS_FIELD_PAY_TRIGGER",
"source": m.group(1),
"cost": m.group(2),
"effect": m.group(3)
}),
# ==========================================================================
# "X's power becomes same as Y" patterns
# ==========================================================================
(r"(.+?)'?s? power becomes (?:the )?same as (?:that )?(.+?)'?s? power", lambda m: {
"type": "COPY_POWER",
"target": parse_target_text(m.group(1)),
"copy_from": m.group(2)
}),
# ==========================================================================
# "X uses the same action ability" patterns
# ==========================================================================
(r"(.+?) uses the same action ability without paying (?:the|its) cost", lambda m: {
"type": "COPY_ACTION",
"target": parse_target_text(m.group(1)),
"free": True
}),
# ==========================================================================
# "Double the power of X" patterns
# ==========================================================================
(r"double the power of (.+?) until the end of the turn", lambda m: {
"type": "POWER_MOD",
"modifier": "DOUBLE",
"target": parse_target_text(m.group(1)),
"duration": "END_OF_TURN"
}),
# ==========================================================================
# "Gain CP" patterns (various notations)
# ==========================================================================
(r"gain (\d+) cp of any element", lambda m: {
"type": "GENERATE_CP",
"amount": int(m.group(1)),
"element": "ANY"
}),
(r"gain (\d+) (fire|ice|wind|earth|lightning|water|light|dark) cp", lambda m: {
"type": "GENERATE_CP",
"amount": int(m.group(1)),
"element": m.group(2).upper()
}),
(r"gain \{?(fire|ice|wind|earth|lightning|water|light|dark)\}?(?:\.)?$", lambda m: {
"type": "GENERATE_CP",
"amount": 1,
"element": m.group(1).upper()
}),
(r"gain (\d+)? ?\[?(fire|ice|wind|earth|lightning|water|light|dark)\]?(?:\.)?$", lambda m: {
"type": "GENERATE_CP",
"amount": int(m.group(1)) if m.group(1) else 1,
"element": m.group(2).upper()
}),
(r"gain (\d+)? ?\[?(fire|ice|wind|earth|lightning|water|light|dark) cp\]?", lambda m: {
"type": "GENERATE_CP",
"amount": int(m.group(1)) if m.group(1) else 1,
"element": m.group(2).upper()
}),
(r"gain ⚡", lambda m: {
"type": "GENERATE_CP",
"amount": 1,
"element": "LIGHTNING"
}),
# ==========================================================================
# "Name 1 Element" patterns
# ==========================================================================
(r"name 1 element other than (.+?)\. (?:until the end of the turn, )?the element of (.+?) becomes the named (?:one|element)", lambda m: {
"type": "SET_ELEMENT_NAMED",
"except": m.group(1),
"target": parse_target_text(m.group(2)),
"duration": "END_OF_TURN"
}),
(r"(.+?) becomes the named element until the end of the turn", lambda m: {
"type": "SET_ELEMENT_NAMED",
"target": parse_target_text(m.group(1)),
"duration": "END_OF_TURN"
}),
# ==========================================================================
# "Play X onto field at end of turn" patterns (more specific)
# ==========================================================================
(r"play (.+?) onto (?:the|your) field at the end of the turn", lambda m: {
"type": "DELAYED_PLAY",
"target": parse_target_text(m.group(1)),
"timing": "END_OF_TURN"
}),
(r"add (.+?) to your hand at the end of the turn", lambda m: {
"type": "DELAYED_ADD_TO_HAND",
"target": parse_target_text(m.group(1)),
"timing": "END_OF_TURN"
}),
# ==========================================================================
# "Forwards can form party with any Element" patterns
# ==========================================================================
(r"(?:the )?forwards you control can form a party with forwards of any element (?:this turn)?", lambda m: {
"type": "PARTY_ANY_ELEMENT",
"target": {"type": "ALL", "owner": "CONTROLLER", "filter": {"card_type": "FORWARD"}},
"duration": "END_OF_TURN"
}),
# ==========================================================================
# Cast restriction patterns
# ==========================================================================
(r"you can only pay with cp produced by (.+?) to cast (.+)", lambda m: {
"type": "CAST_RESTRICTION_CP_SOURCE",
"cp_source": m.group(1),
"card_filter": m.group(2)
}),
(r"you can only pay with (.+?) cp to cast (.+)", lambda m: {
"type": "CAST_RESTRICTION_CP_TYPE",
"cp_type": m.group(1),
"card_filter": m.group(2)
}),
(r"you cannot play (.+?) from your hand due to summons or abilities", lambda m: {
"type": "PLAY_RESTRICTION",
"card_filter": m.group(1),
"restriction": "NOT_FROM_ABILITIES"
}),
(r"you must control (\d+) or more (.+?) to cast (.+)", lambda m: {
"type": "CAST_REQUIREMENT",
"count": int(m.group(1)),
"control_filter": m.group(2),
"card_filter": m.group(3)
}),
# ==========================================================================
# "X can attack N times" patterns
# ==========================================================================
(r"(.+?) can attack (\d+) times? in the same turn", lambda m: {
"type": "MULTI_ATTACK",
"attack_count": int(m.group(2)),
"target": parse_target_text(m.group(1))
}),
(r"(.+?) can attack twice in the same turn", lambda m: {
"type": "MULTI_ATTACK",
"attack_count": 2,
"target": parse_target_text(m.group(1))
}),
# ==========================================================================
# "Back Attack" keyword
# ==========================================================================
(r"^back attack$", lambda m: {
"type": "KEYWORD",
"keyword": "BACK_ATTACK"
}),
# ==========================================================================
# "Remove X counters: effect" patterns
# ==========================================================================
(r"remove (\d+) (.+?) counters? from (.+?): (.+)", lambda m: {
"type": "ACTIVATED_REMOVE_COUNTERS",
"counter_count": int(m.group(1)),
"counter_type": m.group(2),
"from_target": parse_target_text(m.group(3)),
"effect": m.group(4)
}),
# ==========================================================================
# "Add all cards removed by X's ability to hand" patterns
# ==========================================================================
(r"add all the cards removed by (.+?)'?s? ability to your hand", lambda m: {
"type": "RETRIEVE_REMOVED",
"source": m.group(1)
}),
# ==========================================================================
# "All Forwards lose X power" patterns
# ==========================================================================
(r"all (?:the )?forwards (?:you|opponent) controls? lose (\d+) power", lambda m: {
"type": "POWER_MOD",
"amount": -int(m.group(1)),
"target": {"type": "ALL", "filter": {"card_type": "FORWARD"}},
"duration": "END_OF_TURN"
}),
# ==========================================================================
# "Your opponent may play X" patterns
# ==========================================================================
(r"your opponent may play (\d+) (.+?) (?:of cost (\d+) or less )?from (?:his/her|their) hand onto the field", lambda m: {
"type": "OPPONENT_MAY_PLAY",
"count": int(m.group(1)),
"filter": parse_card_filter(m.group(2)),
"cost_max": int(m.group(3)) if m.group(3) else None,
"zone_from": "HAND"
}),
# ==========================================================================
# "X deals your opponent N damage" patterns
# ==========================================================================
(r"(.+?) deals your opponent (\d+) points? of damage", lambda m: {
"type": "DAMAGE_TO_OPPONENT",
"source": m.group(1),
"amount": int(m.group(2))
}),
# ==========================================================================
# "Your opponent reveals hand, select and discard" patterns
# ==========================================================================
(r"your opponent reveals (?:\d+ cards from )?(?:his/her|their) hand\. select (\d+) cards? (?:among them|in their hand)?\.? your opponent discards", lambda m: {
"type": "REVEAL_SELECT_DISCARD",
"count": int(m.group(1)),
"target": {"type": "OPPONENT_HAND"}
}),
(r"your opponent reveals (?:his/her|their) hand\. select (\d+) cards? (?:of cost (\d+) or more )?(?:among them|in their hand)\.? your opponent discards", lambda m: {
"type": "REVEAL_SELECT_DISCARD",
"count": int(m.group(1)),
"cost_min": int(m.group(2)) if m.group(2) else None,
"target": {"type": "OPPONENT_HAND"}
}),
(r"your opponent reveals (?:his/her|their) hand\. select (\d+) cards? in their hand\. your opponent removes (?:it|them) from the game", lambda m: {
"type": "REVEAL_SELECT_REMOVE",
"count": int(m.group(1)),
"target": {"type": "OPPONENT_HAND"}
}),
# ==========================================================================
# "Your opponent removes top card" patterns
# ==========================================================================
(r"your opponent removes the top card of (?:his/her|their) deck from the game", lambda m: {
"type": "OPPONENT_REMOVE_TOP",
"count": 1
}),
# ==========================================================================
# "Look at top of decks, put on top/bottom" patterns
# ==========================================================================
(r"look at the top card of your deck and your opponent'?s? deck\. put them on the top or bottom", lambda m: {
"type": "LOOK_AT_BOTH_DECKS",
"count": 1,
"reorder": True
}),
# ==========================================================================
# "Opposing Forwards entering field" patterns
# ==========================================================================
(r"opposing forwards entering the field will not trigger any auto-abilities", lambda m: {
"type": "SUPPRESS_AUTO_TRIGGERS",
"target": {"type": "OPPONENT", "filter": {"card_type": "FORWARD"}},
"trigger_type": "ENTERS_FIELD"
}),
# ==========================================================================
# "If X Forward you control is dealt damage by opponent's abilities" patterns
# ==========================================================================
(r"if (?:a )?(.+?) forward you control is dealt damage by your opponent'?s? abilities, the damage becomes 0 instead", lambda m: {
"type": "DAMAGE_PREVENTION",
"condition": {"damage_source": "OPPONENT_ABILITIES"},
"target": {"type": "ALL", "owner": "CONTROLLER", "filter": parse_card_filter(m.group(1))}
}),
# ==========================================================================
# "During opponent's turn, Forwards cannot use action abilities" patterns
# ==========================================================================
(r"during your opponent'?s? turn, (?:the )?forwards (?:you|opponent) controls? cannot use action abilities", lambda m: {
"type": "SUPPRESS_ACTION_ABILITIES",
"during": "OPPONENT_TURN",
"target": {"type": "ALL", "filter": {"card_type": "FORWARD"}}
}),
# ==========================================================================
# "At beginning of X phase, opponent discards" patterns
# ==========================================================================
(r"at the beginning of the (.+?) phase during (?:each of )?your turns?, your opponent discards (\d+) cards?", lambda m: {
"type": "TRIGGERED_DISCARD",
"trigger": {"phase": m.group(1).upper().replace(" ", "_")},
"target": {"type": "OPPONENT"},
"count": int(m.group(2))
}),
# ==========================================================================
# Conditional damage reduction/prevention patterns
# These handle "if X is dealt damage, reduce by Y instead" type effects
# ==========================================================================
(r"if (.+?) receives damage, reduce the damage by (\d+) instead", lambda m: {
"type": "DAMAGE_REDUCTION",
"amount": int(m.group(2)),
"target": parse_target_text(m.group(1))
}),
(r"if (?:an? )?(.+?) (?:other than .+? )?you control is dealt damage, reduce the damage by (\d+) instead", lambda m: {
"type": "DAMAGE_REDUCTION",
"amount": int(m.group(2)),
"target": {"type": "ALL", "owner": "CONTROLLER", "filter": parse_card_filter(m.group(1))}
}),
(r"if (.+?) is dealt damage, reduce the damage by (\d+) instead", lambda m: {
"type": "DAMAGE_REDUCTION",
"amount": int(m.group(2)),
"target": parse_target_text(m.group(1))
}),
(r"if (.+?) is dealt damage other than battle damage, the damage becomes 0 instead", lambda m: {
"type": "DAMAGE_PREVENTION",
"condition": {"damage_type": "NON_BATTLE"},
"target": parse_target_text(m.group(1))
}),
(r"if (.+?) is dealt damage less than (?:his|her|its|their) power, the damage becomes 0 instead", lambda m: {
"type": "DAMAGE_PREVENTION_CONDITIONAL",
"condition": "DAMAGE_LESS_THAN_POWER",
"target": parse_target_text(m.group(1))
}),
(r"if (.+?) is dealt (\d+) damage or more, the damage becomes 0 instead", lambda m: {
"type": "DAMAGE_PREVENTION_CONDITIONAL",
"condition": {"damage_gte": int(m.group(2))},
"target": parse_target_text(m.group(1))
}),
(r"if (.+?) is dealt damage by (?:an )?ability, the damage becomes 0 instead", lambda m: {
"type": "DAMAGE_PREVENTION",
"condition": {"damage_source": "ABILITY"},
"target": parse_target_text(m.group(1))
}),
(r"if (?:a )?forward forming a party (?:with .+? )?you control is dealt damage, the damage becomes 0 instead", lambda m: {
"type": "DAMAGE_PREVENTION",
"condition": {"in_party": True},
"target": {"type": "ALL", "owner": "CONTROLLER", "filter": {"card_type": "FORWARD"}}
}),
(r"if (.+?) forms a party, the damage dealt to (.+?) becomes 0 instead", lambda m: {
"type": "DAMAGE_PREVENTION",
"condition": {"in_party": True},
"target": parse_target_text(m.group(2))
}),
(r"during each turn, if (.+?) is dealt damage by your opponent'?s? summons? or abilities? for the first time in that turn, the damage becomes 0 instead", lambda m: {
"type": "DAMAGE_PREVENTION",
"condition": {"damage_source": "OPPONENT_SUMMONS_OR_ABILITIES", "once_per_turn": True},
"target": parse_target_text(m.group(1))
}),
(r"if (.+?) is dealt damage by your opponent'?s? summons? or abilities?, the damage becomes 0 instead", lambda m: {
"type": "DAMAGE_PREVENTION",
"condition": {"damage_source": "OPPONENT_SUMMONS_OR_ABILITIES"},
"target": parse_target_text(m.group(1))
}),
(r"during this turn, if (?:a )?(.+?) you control is dealt damage by a summon or an ability, the damage becomes 0 instead", lambda m: {
"type": "DAMAGE_PREVENTION",
"condition": {"damage_source": "SUMMON_OR_ABILITY"},
"target": {"type": "ALL", "owner": "CONTROLLER", "filter": parse_card_filter(m.group(1))},
"duration": "END_OF_TURN"
}),
(r"if (?:a )?(.+?) you control is dealt damage by a forward, reduce the damage by (\d+) instead", lambda m: {
"type": "DAMAGE_REDUCTION",
"amount": int(m.group(2)),
"condition": {"damage_source": "FORWARD"},
"target": {"type": "ALL", "owner": "CONTROLLER", "filter": parse_card_filter(m.group(1))}
}),
# ==========================================================================
# Selection immunity - more variations
# ==========================================================================
(r"(.+?) cannot be chosen by (?:your )?opponent'?s? abilities(?:\.|$)", lambda m: {
"type": "SELECTION_IMMUNITY",
"from": "OPPONENT_ABILITIES",
"target": parse_target_text(m.group(1))
}),
(r"(.+?) cannot be chosen by summons(?:\.|$)", lambda m: {
"type": "SELECTION_IMMUNITY",
"from": "SUMMONS",
"target": parse_target_text(m.group(1))
}),
(r"(.+?) cannot be chosen by ex bursts?(?:\.|$)", lambda m: {
"type": "SELECTION_IMMUNITY",
"from": "EX_BURST",
"target": parse_target_text(m.group(1))
}),
(r"during this turn, (?:the )?forwards you control cannot be chosen by ex bursts?", lambda m: {
"type": "SELECTION_IMMUNITY",
"from": "EX_BURST",
"target": {"type": "ALL", "owner": "CONTROLLER", "filter": {"card_type": "FORWARD"}},
"duration": "END_OF_TURN"
}),
(r"(.+?) cannot be chosen by (?:a )?multi-element forward'?s? abilit(?:y|ies)", lambda m: {
"type": "SELECTION_IMMUNITY",
"from": "MULTI_ELEMENT_FORWARD_ABILITIES",
"target": parse_target_text(m.group(1))
}),
(r"(.+?) cannot be chosen by (.+?) summons? or (.+?) abilities", lambda m: {
"type": "SELECTION_IMMUNITY",
"from": {"element": m.group(2).upper()},
"target": parse_target_text(m.group(1))
}),
(r"(.+?) cannot be chosen by summons? or abilities? that share (?:its|their) element", lambda m: {
"type": "SELECTION_IMMUNITY",
"from": "SAME_ELEMENT",
"target": parse_target_text(m.group(1))
}),
(r"(?:the )?forwards you control cannot be chosen by your opponent'?s? backup abilities", lambda m: {
"type": "SELECTION_IMMUNITY",
"from": "OPPONENT_BACKUP_ABILITIES",
"target": {"type": "ALL", "owner": "CONTROLLER", "filter": {"card_type": "FORWARD"}}
}),
(r"(?:until the end of the turn, )?(.+?) cannot be chosen by your opponent'?s? abilities(?:\.|$)", lambda m: {
"type": "SELECTION_IMMUNITY",
"from": "OPPONENT_ABILITIES",
"target": parse_target_text(m.group(1)),
"duration": "END_OF_TURN"
}),
(r"(.+?) cannot be chosen by summons? or abilities? of the named element", lambda m: {
"type": "SELECTION_IMMUNITY",
"from": "NAMED_ELEMENT",
"target": parse_target_text(m.group(1))
}),
(r"(.+?) cannot be chosen by your opponent'?s? abilities of characters with the named job", lambda m: {
"type": "SELECTION_IMMUNITY",
"from": {"job": "NAMED"},
"target": parse_target_text(m.group(1))
}),
(r"(.+?) cannot be chosen by summons during this turn", lambda m: {
"type": "SELECTION_IMMUNITY",
"from": "SUMMONS",
"target": parse_target_text(m.group(1)),
"duration": "END_OF_TURN"
}),
# ==========================================================================
# "X cannot be blocked" patterns - more variations
# ==========================================================================
(r"(.+?) cannot be blocked this turn", lambda m: {
"type": "BLOCK_IMMUNITY",
"target": parse_target_text(m.group(1)),
"duration": "END_OF_TURN"
}),
(r"(.+?) cannot be blocked by a forward of cost (\d+) or less", lambda m: {
"type": "BLOCK_IMMUNITY",
"condition": {"comparison": "LTE", "value": int(m.group(2)), "attribute": "cost"},
"target": parse_target_text(m.group(1))
}),
# ==========================================================================
# Self damage patterns
# ==========================================================================
(r"(.+?) deals you (\d+) points? of damage(?:\.|$)", lambda m: {
"type": "DAMAGE_TO_CONTROLLER",
"amount": int(m.group(2)),
"source": m.group(1)
}),
# ==========================================================================
# Opponent reveal/select/discard patterns
# ==========================================================================
(r"your opponent reveals (?:his/her|their) hand\. select (\d+) cards? (?:of cost (\d+) or more )?in (?:his/her|their) hand\. your opponent discards? (?:this|these) cards?", lambda m: {
"type": "REVEAL_SELECT_DISCARD",
"count": int(m.group(1)),
"filter": {"cost_gte": int(m.group(2))} if m.group(2) else None,
"target": {"type": "OPPONENT_HAND"}
}),
(r"your opponent reveals (\d+) cards? from (?:his/her|their) hand\. select (\d+) cards? among them\. your opponent discards? (?:this|these) cards?", lambda m: {
"type": "REVEAL_SELECT_DISCARD",
"reveal_count": int(m.group(1)),
"select_count": int(m.group(2)),
"target": {"type": "OPPONENT_HAND"}
}),
(r"your opponent reveals (?:his/her|their) hand\. select (\d+) cards? in (?:his/her|their) hand\. your opponent removes? (?:it|them) from the game", lambda m: {
"type": "REVEAL_SELECT_REMOVE",
"count": int(m.group(1)),
"target": {"type": "OPPONENT_HAND"}
}),
# ==========================================================================
# "Your opponent may play" patterns
# ==========================================================================
(r"your opponent may play (\d+) (.+?) from (?:his/her|their) hand onto the field", lambda m: {
"type": "OPPONENT_MAY_PLAY",
"count": int(m.group(1)),
"filter": parse_card_filter(m.group(2)),
"zone_from": "HAND"
}),
(r"your opponent may play (\d+) (.+?) of cost (\d+) or less from (?:his/her|their) hand onto the field", lambda m: {
"type": "OPPONENT_MAY_PLAY",
"count": int(m.group(1)),
"filter": {**parse_card_filter(m.group(2)), "cost_lte": int(m.group(3))},
"zone_from": "HAND"
}),
# ==========================================================================
# Cost increase patterns
# ==========================================================================
(r"the cost (?:required )?to cast (.+?) is increased by (\d+) for each (.+)", lambda m: {
"type": "COST_INCREASE_SCALING",
"card_filter": m.group(1),
"increase_per": int(m.group(2)),
"count_filter": m.group(3)
}),
# ==========================================================================
# "Return X to hand" patterns
# ==========================================================================
(r"return (.+?) to your hand(?:\.|$)", lambda m: {
"type": "RETURN",
"destination": "HAND",
"target": parse_target_text(m.group(1))
}),
# ==========================================================================
# "Forwards lose power for each X" patterns
# ==========================================================================
(r"(?:all )?(?:the )?forwards (?:your )?opponent controls? lose (\d+) power for each (.+)", lambda m: {
"type": "POWER_MOD_SCALING",
"amount_per": -int(m.group(1)),
"count_filter": m.group(2),
"target": {"type": "ALL", "owner": "OPPONENT", "filter": {"card_type": "FORWARD"}}
}),
(r"(?:the )?forwards (?:your )?opponent controls? lose (\d+) power for each (.+?) until the end of the turn", lambda m: {
"type": "POWER_MOD_SCALING",
"amount_per": -int(m.group(1)),
"count_filter": m.group(2),
"target": {"type": "ALL", "owner": "OPPONENT", "filter": {"card_type": "FORWARD"}},
"duration": "END_OF_TURN"
}),
# ==========================================================================
# "Deal damage for each X to all forwards" patterns
# ==========================================================================
(r"deal (\d+) damage for each (.+?) to all (?:the )?forwards (?:your )?opponent controls?", lambda m: {
"type": "DAMAGE_SCALING",
"damage_per": int(m.group(1)),
"count_filter": m.group(2),
"target": {"type": "ALL", "owner": "OPPONENT", "filter": {"card_type": "FORWARD"}}
}),
# ==========================================================================
# "Double the damage" patterns
# ==========================================================================
(r"if (.+?) deals damage to a forward, double the damage", lambda m: {
"type": "DAMAGE_MODIFIER",
"modifier": "DOUBLE",
"condition": {"target_type": "FORWARD"},
"source": m.group(1)
}),
# ==========================================================================
# "The damage dealt to forwards cannot be reduced" patterns
# ==========================================================================
(r"the damage dealt to forwards (?:your )?opponent controls? cannot be reduced (?:this turn)?", lambda m: {
"type": "PREVENT_DAMAGE_REDUCTION",
"target": {"type": "ALL", "owner": "OPPONENT", "filter": {"card_type": "FORWARD"}},
"duration": "END_OF_TURN"
}),
# ==========================================================================
# "Choose 1 forward that entered field this turn" patterns
# ==========================================================================
(r"choose (\d+) forward that entered the field this turn\. deal (?:it|them) (\d+) damage", lambda m: {
"type": "DAMAGE",
"amount": int(m.group(2)),
"target": {"type": "CHOOSE", "count": int(m.group(1)), "filter": {"card_type": "FORWARD", "entered_this_turn": True}}
}),
# ==========================================================================
# "Can only use/play X if Y" patterns
# ==========================================================================
(r"you can only (?:use this ability|play .+?) if (.+)", lambda m: {
"type": "PLAY_CONDITION",
"condition": m.group(1)
}),
# ==========================================================================
# "During this turn" conditional patterns
# ==========================================================================
(r"during this turn, if you attacked with (\d+) or more forwards you controlled, the cost for playing (.+?) onto the field is reduced by (\d+)", lambda m: {
"type": "COST_REDUCTION",
"condition": {"attacked_with_count_gte": int(m.group(1))},
"card_filter": m.group(2),
"amount": int(m.group(3)),
"duration": "END_OF_TURN"
}),
# ==========================================================================
# "Cost is reduced and can be paid with any element" patterns
# ==========================================================================
(r"the cost (?:required )?to play (?:your )?(.+?) onto the field is reduced by (\d+) and can be paid with cp of any element", lambda m: {
"type": "COST_REDUCTION",
"card_filter": m.group(1),
"amount": int(m.group(2)),
"any_element": True,
"for_player": "CONTROLLER"
}),
# ==========================================================================
# "Look at top card of your deck and opponent's deck" patterns
# ==========================================================================
(r"look at the top card of your deck and your opponent'?s? deck\. put them on the top or bottom of the respective decks", lambda m: {
"type": "DUAL_SCRY",
"count": 1,
"targets": ["CONTROLLER", "OPPONENT"]
}),
# ==========================================================================
# "Remove from game face down / look at it / cast it" patterns
# ==========================================================================
(r"your opponent removes the top card of (?:his/her|their) deck from the game face down\. you can look at it and/or cast it", lambda m: {
"type": "EXILE_TOP_CASTABLE",
"source": "OPPONENT_DECK",
"caster": "CONTROLLER"
}),
# ==========================================================================
# "You may cast summon without paying cost" patterns
# ==========================================================================
(r"you may cast (\d+) (.+?) from your hand (?:with a cost (?:inferior|less than) (?:to )?that of the summon you cast )?without paying (?:its|the) cost", lambda m: {
"type": "CAST_FREE",
"count": int(m.group(1)),
"filter": parse_card_filter(m.group(2)),
"zone_from": "HAND",
"optional": True
}),
# ==========================================================================
# Gains ability text patterns - embedded ability as text
# These handle "X gains 'Y cannot be blocked' until end of turn" type effects
# ==========================================================================
(r"(.+?) gains \"(.+? cannot be blocked(?:\.|))\" until the end of the turn", lambda m: {
"type": "GRANT_ABILITY_TEXT",
"target": parse_target_text(m.group(1)),
"ability_text": m.group(2),
"duration": "END_OF_TURN"
}),
(r"(.+?) gains \"(.+? cannot be chosen .+?(?:\.|))\" until the end of (?:the|your opponent'?s?) turn", lambda m: {
"type": "GRANT_ABILITY_TEXT",
"target": parse_target_text(m.group(1)),
"ability_text": m.group(2),
"duration": "END_OF_TURN"
}),
(r"each forward you control with a (.+?) counter on it gains \"(.+?)\"", lambda m: {
"type": "GRANT_ABILITY_TEXT",
"target": {"type": "ALL", "owner": "CONTROLLER", "filter": {"card_type": "FORWARD", "has_counter": m.group(1)}},
"ability_text": m.group(2)
}),
(r"if you have (\d+) or more cards in your hand, (.+?) gains \"(.+?)\"", lambda m: {
"type": "CONDITIONAL_GRANT_ABILITY_TEXT",
"condition": {"hand_count_gte": int(m.group(1))},
"target": parse_target_text(m.group(2)),
"ability_text": m.group(3)
}),
# ==========================================================================
# "End of opponent's turn" duration patterns
# ==========================================================================
(r"(.+?) gains \"(.+?)\" until the end of your opponent'?s? turn", lambda m: {
"type": "GRANT_ABILITY_TEXT",
"target": parse_target_text(m.group(1)),
"ability_text": m.group(2),
"duration": "END_OF_OPPONENT_TURN"
}),
# ==========================================================================
# "If either player has received X damage" patterns
# ==========================================================================
(r"you can only play (.+?) if either player has received (\d+) points? of damage or more", lambda m: {
"type": "PLAY_CONDITION",
"card": m.group(1),
"condition": {"either_player_damage_gte": int(m.group(2))}
}),
# ==========================================================================
# "Gains control" and "opponent gains control" patterns
# ==========================================================================
(r"(?:at .+?, )?if you don'?t have (?:a )?(.+?), your opponent gains control of (.+)", lambda m: {
"type": "CONDITIONAL_CONTROL_CHANGE",
"condition": {"not_control": m.group(1)},
"new_controller": "OPPONENT",
"target": parse_target_text(m.group(2))
}),
# ==========================================================================
# Self damage - "X deals you 1 point of damage" (singular point)
# ==========================================================================
(r"(.+?) deals you 1 point of damage(?:\.|$)", lambda m: {
"type": "DAMAGE_TO_CONTROLLER",
"amount": 1,
"source": m.group(1)
}),
# ==========================================================================
# "Return all forwards to their owners' hands" variations
# ==========================================================================
(r"return all (?:the )?forwards to their owners'? hands?(?:\.|$)", lambda m: {
"type": "RETURN",
"destination": "HAND",
"target": {"type": "ALL", "filter": {"card_type": "FORWARD"}}
}),
# ==========================================================================
# "Freeze all forwards other than X" patterns
# ==========================================================================
(r"freeze all (?:the )?forwards other than (.+?)(?:\.|$)", lambda m: {
"type": "FREEZE",
"target": {"type": "ALL", "filter": {"card_type": "FORWARD"}},
"except": parse_target_text(m.group(1))
}),
# ==========================================================================
# "Look at top card, may place at bottom" patterns
# ==========================================================================
(r"look at the top card of your deck\. you may place (?:the|that) card at the bottom of your deck(?:\.|$)", lambda m: {
"type": "SCRY",
"count": 1,
"options": ["TOP", "BOTTOM"]
}),
# ==========================================================================
# "Can be played even if you control other X" patterns
# ==========================================================================
(r"(.+?) can be played onto the field even if you control other (.+?) characters(?:\.|$)", lambda m: {
"type": "ALLOW_MULTIPLE",
"target": parse_target_text(m.group(1)),
"element": m.group(2).upper()
}),
# ==========================================================================
# "Each player may search" patterns (variations)
# ==========================================================================
(r"each player may search for (\d+) (.+?) and add (?:it|them) to (?:his/her|their) hands?(?:\.|$)", lambda m: {
"type": "EACH_PLAYER_SEARCH",
"count": int(m.group(1)),
"filter": parse_card_filter(m.group(2)),
"optional": True
}),
(r"each player draws? (\d+) cards?(?:\.|$)", lambda m: {
"type": "EACH_PLAYER_DRAW",
"count": int(m.group(1))
}),
# ==========================================================================
# "If a counter is placed on X, X cannot be broken" patterns
# ==========================================================================
(r"if (?:a )?(.+?) counter is placed on (.+?), (.+?) cannot be broken(?:\.|$)", lambda m: {
"type": "CONDITIONAL_BREAK_IMMUNITY",
"condition": {"has_counter": m.group(1)},
"target": parse_target_text(m.group(2))
}),
# ==========================================================================
# Complex "gains ability text" - damage prevention with counter removal
# ==========================================================================
(r"(.+?) gains \"if (.+?) is dealt damage, remove (\d+) (.+?) counter from (.+?) and the damage becomes 0 instead\.?\"", lambda m: {
"type": "GRANT_ABILITY_TEXT",
"target": parse_target_text(m.group(1)),
"ability_text": "if %s is dealt damage, remove %s %s counter from %s and the damage becomes 0 instead" % (m.group(2), m.group(3), m.group(4), m.group(5))
}),
# ==========================================================================
# "Play X onto field at end of turn" variations
# ==========================================================================
(r"play (.+?) onto (?:the|your) field at the end of the turn(?:\.|$)", lambda m: {
"type": "DELAYED_PLAY",
"target": parse_target_text(m.group(1)),
"timing": "END_OF_TURN"
}),
(r"add (.+?) to your hand at the end of the turn(?:\.|$)", lambda m: {
"type": "DELAYED_ADD_TO_HAND",
"target": parse_target_text(m.group(1)),
"timing": "END_OF_TURN"
}),
# ==========================================================================
# "Cast X from hand without paying cost" (no count)
# ==========================================================================
(r"cast (\d+) (.+?) from your hand without paying (?:the|its) cost(?:\.|$)", lambda m: {
"type": "CAST_FREE",
"count": int(m.group(1)),
"filter": parse_card_filter(m.group(2)),
"zone_from": "HAND"
}),
# ==========================================================================
# "During this turn, the next damage dealt to you becomes 0" patterns
# ==========================================================================
(r"during this turn, the next damage dealt to you becomes 0(?: instead)?(?:\.|$)", lambda m: {
"type": "DAMAGE_PREVENTION",
"target": {"type": "PLAYER", "owner": "CONTROLLER"},
"duration": "NEXT_DAMAGE"
}),
# ==========================================================================
# "During this turn, if a X you control is dealt damage" variations
# ==========================================================================
(r"during this turn, if (?:a )?forward you control is dealt damage, reduce the damage by (\d+)(?: instead)?(?:\.|$)", lambda m: {
"type": "DAMAGE_REDUCTION",
"amount": int(m.group(1)),
"target": {"type": "ALL", "owner": "CONTROLLER", "filter": {"card_type": "FORWARD"}},
"duration": "END_OF_TURN"
}),
# ==========================================================================
# "During this turn, if your ability deals damage, double it" patterns
# ==========================================================================
(r"during this turn, if your abilit(?:y|ies) deals? damage to (?:a )?forward, double the damage(?: instead)?(?:\.|$)", lambda m: {
"type": "DAMAGE_MODIFIER",
"modifier": "DOUBLE",
"condition": {"source": "CONTROLLER_ABILITIES", "target_type": "FORWARD"},
"duration": "END_OF_TURN"
}),
# ==========================================================================
# "Reduce the damage by X instead" (standalone effect)
# ==========================================================================
(r"reduce the damage by (\d+)(?: instead)?(?:\.|$)", lambda m: {
"type": "DAMAGE_REDUCTION",
"amount": int(m.group(1)),
"target": {"type": "IMPLICIT"}
}),
# ==========================================================================
# "X gains 'Y cannot be blocked'" with various endings
# ==========================================================================
(r"(.+?) gains [\"'](.+?) cannot be blocked(?:\.)?[\"'](?:\.|$)", lambda m: {
"type": "GRANT_ABILITY_TEXT",
"target": parse_target_text(m.group(1)),
"ability_text": "%s cannot be blocked" % m.group(2)
}),
(r"(.+?) gains [\"'](.+?) cannot be blocked by a forward of cost (\d+) or more(?:\.)?[\"'] until the end of the turn(?:\.|$)", lambda m: {
"type": "GRANT_ABILITY_TEXT",
"target": parse_target_text(m.group(1)),
"ability_text": "%s cannot be blocked by a forward of cost %s or more" % (m.group(2), m.group(3)),
"duration": "END_OF_TURN"
}),
(r"(.+?) gains [\"'](.+?) cannot be broken(?:\.)?[\"'] until the end of the turn(?:\.|$)", lambda m: {
"type": "GRANT_ABILITY_TEXT",
"target": parse_target_text(m.group(1)),
"ability_text": "%s cannot be broken" % m.group(2),
"duration": "END_OF_TURN"
}),
# ==========================================================================
# "During this turn, the next damage dealt to X by a summon or ability becomes 0"
# ==========================================================================
(r"during this turn, the next damage dealt to (.+?) by a summon or an ability,? becomes 0(?: instead)?(?:\.|$)", lambda m: {
"type": "DAMAGE_PREVENTION",
"condition": {"damage_source": "SUMMON_OR_ABILITY"},
"target": parse_target_text(m.group(1)),
"duration": "NEXT_DAMAGE"
}),
# ==========================================================================
# "You may use X by discarding Y instead of Z" patterns
# ==========================================================================
(r"you may use (.+?)'?s? (?:special )?ability by discarding (?:a )?(.+?) instead of discarding (?:a )?(.+?) as part of the cost(?:\.|$)", lambda m: {
"type": "ALTERNATE_COST",
"ability": m.group(1),
"alternate_discard": m.group(2),
"original_discard": m.group(3)
}),
# ==========================================================================
# "When X attacks, deal Y damage for each Z to all forwards opponent controls"
# ==========================================================================
(r"when (?:a )?(.+?) you control attacks?, deal (\d+) damage for each (.+?) to all (?:the )?forwards (?:your )?opponent controls?(?:\.|$)", lambda m: {
"type": "ON_ATTACK_DAMAGE_SCALING",
"trigger_filter": parse_card_filter(m.group(1)),
"damage_per": int(m.group(2)),
"count_filter": m.group(3),
"target": {"type": "ALL", "owner": "OPPONENT", "filter": {"card_type": "FORWARD"}}
}),
# ==========================================================================
# "Deal X damage for each Y to the blocking forward" patterns
# ==========================================================================
(r"deal (\d+) damage for each (.+?) placed on (.+?) to the blocking forward(?:\.|$)", lambda m: {
"type": "DAMAGE_SCALING",
"damage_per": int(m.group(1)),
"count_filter": {"counter_type": m.group(2), "on_card": m.group(3)},
"target": {"type": "BLOCKING_FORWARD"}
}),
(r"deal (\d+) damage for each (.+?) to the blocking forward(?:\.|$)", lambda m: {
"type": "DAMAGE_SCALING",
"damage_per": int(m.group(1)),
"count_filter": m.group(2),
"target": {"type": "BLOCKING_FORWARD"}
}),
# ==========================================================================
# "Then, remove all X counters from Y" patterns
# ==========================================================================
(r"then,? remove all (.+?) counters from (.+?)(?:\.|$)", lambda m: {
"type": "REMOVE_ALL_COUNTERS",
"counter_type": m.group(1),
"target": parse_target_text(m.group(2))
}),
# ==========================================================================
# "Choose 1 forward. Until end of turn, it gains +X power for each Y"
# ==========================================================================
(r"choose (\d+) forward\. until the end of the turn, (?:it|they) gains? \+(\d+) power for each (.+?)(?:\.|$)", lambda m: {
"type": "POWER_MOD_SCALING",
"amount_per": int(m.group(2)),
"count_filter": m.group(3),
"target": {"type": "CHOOSE", "count": int(m.group(1)), "filter": {"card_type": "FORWARD"}},
"duration": "END_OF_TURN"
}),
# ==========================================================================
# "You can only use this ability if X" patterns
# ==========================================================================
(r"you can only use this ability if (\d+) or more (.+?) counters? (?:are|is) placed on (.+?)(?:\.|$)", lambda m: {
"type": "USE_CONDITION",
"condition": {"counter_count_gte": int(m.group(1)), "counter_type": m.group(2), "on_card": m.group(3)}
}),
(r"you can only use this ability if (.+?) is (?:a )?forward(?:\.|$)", lambda m: {
"type": "USE_CONDITION",
"condition": {"card_is_type": "FORWARD", "card": m.group(1)}
}),
# ==========================================================================
# "Hein cannot be chosen by summons or abilities of the named element and if dealt damage by..."
# Complex combined effects with named element
# ==========================================================================
(r"discard (\d+) cards?: name (\d+) elements?\. during this turn, (.+?) cannot be chosen by summons? or abilities? of the named element and if (.+?) is dealt damage by a summon or an ability of the named element, the damage becomes 0 instead(?:\.|$)", lambda m: {
"type": "COMBINED_EFFECT",
"cost": {"discard": int(m.group(1))},
"effects": [
{"type": "NAME_ELEMENT", "count": int(m.group(2))},
{"type": "SELECTION_IMMUNITY", "from": "NAMED_ELEMENT", "target": parse_target_text(m.group(3)), "duration": "END_OF_TURN"},
{"type": "DAMAGE_PREVENTION", "condition": {"damage_source": "NAMED_ELEMENT_SUMMON_OR_ABILITY"}, "target": parse_target_text(m.group(4)), "duration": "END_OF_TURN"}
]
}),
# ==========================================================================
# "Name 1 element. X cannot be chosen by summons or abilities of the named element this turn"
# ==========================================================================
(r"name (\d+) elements?\. (.+?) cannot be chosen by summons? or abilities? of the named element (?:this turn)?(?:\.|$)", lambda m: {
"type": "COMBINED_EFFECT",
"effects": [
{"type": "NAME_ELEMENT", "count": int(m.group(1))},
{"type": "SELECTION_IMMUNITY", "from": "NAMED_ELEMENT", "target": parse_target_text(m.group(2)), "duration": "END_OF_TURN"}
]
}),
# ==========================================================================
# "You may pay {x}. When you do so, gain element for each cp paid"
# ==========================================================================
(r"you may pay \{?x\}?\. when you do so, gain \{?(.+?)\}? for each cp paid as x(?:\.|$)", lambda m: {
"type": "PAY_X_GAIN_CP",
"element": m.group(1).upper()
}),
]
# =============================================================================
# Helper Functions
# =============================================================================
def parse_target_text(text: str) -> dict:
"""Parse target description text into structured target object."""
text = text.strip().lower()
# Self-reference
if text in ["self", "this card", "this forward", "this backup"]:
return {"type": "SELF"}
# All targets
if text.startswith("all "):
target = {"type": "ALL", "zone": "FIELD"}
rest = text[4:].strip()
if "forwards" in rest:
target["filter"] = {"card_type": "FORWARD"}
elif "backups" in rest:
target["filter"] = {"card_type": "BACKUP"}
elif "characters" in rest:
target["filter"] = {"card_type": "CHARACTER"}
if "opponent controls" in rest:
target["owner"] = "OPPONENT"
elif "you control" in rest:
target["owner"] = "CONTROLLER"
else:
target["owner"] = "ANY"
return target
# Choose targets
choose_match = re.match(r"(\d+) (.+)", text)
if choose_match:
count = int(choose_match.group(1))
rest = choose_match.group(2)
target = {"type": "CHOOSE", "count": count, "zone": "FIELD", "filter": {}}
if "forward" in rest:
target["filter"]["card_type"] = "FORWARD"
elif "backup" in rest:
target["filter"]["card_type"] = "BACKUP"
elif "character" in rest:
target["filter"]["card_type"] = "CHARACTER"
elif "summon" in rest:
target["filter"]["card_type"] = "SUMMON"
if "opponent controls" in rest:
target["owner"] = "OPPONENT"
elif "you control" in rest:
target["owner"] = "CONTROLLER"
else:
target["owner"] = "ANY"
# Cost filter
cost_match = re.search(r"cost (\d+) or less", rest)
if cost_match:
target["filter"]["cost_max"] = int(cost_match.group(1))
cost_match = re.search(r"cost (\d+) or more", rest)
if cost_match:
target["filter"]["cost_min"] = int(cost_match.group(1))
# Element filter
for element in ["fire", "ice", "wind", "earth", "lightning", "water", "light", "dark"]:
if element in rest:
target["filter"]["element"] = element.upper()
break
# Dull filter
if "dull" in rest:
target["filter"]["is_dull"] = True
if "active" in rest:
target["filter"]["is_active"] = True
return target
# Default to chosen target
return {"type": "CHOSEN"}
def parse_card_filter(text: str) -> dict:
"""Parse card filter description into filter object."""
text = text.strip().lower()
filter_obj = {}
# Card type
if "forward" in text:
filter_obj["card_type"] = "FORWARD"
elif "backup" in text:
filter_obj["card_type"] = "BACKUP"
elif "summon" in text:
filter_obj["card_type"] = "SUMMON"
elif "monster" in text:
filter_obj["card_type"] = "MONSTER"
# Element
for element in ["fire", "ice", "wind", "earth", "lightning", "water", "light", "dark"]:
if element in text:
filter_obj["element"] = element.upper()
break
# Cost
cost_match = re.search(r"cost (\d+) or less", text)
if cost_match:
filter_obj["cost_max"] = int(cost_match.group(1))
cost_match = re.search(r"cost (\d+) or more", text)
if cost_match:
filter_obj["cost_min"] = int(cost_match.group(1))
cost_match = re.search(r"cost (\d+)(?!\d)", text)
if cost_match and "cost_max" not in filter_obj and "cost_min" not in filter_obj:
filter_obj["cost"] = int(cost_match.group(1))
# Category
category_match = re.search(r"\[category \((.+?)\)\]", text)
if category_match:
filter_obj["category"] = category_match.group(1)
# Card name
name_match = re.search(r"\[card name \((.+?)\)\]", text)
if name_match:
filter_obj["name"] = name_match.group(1)
return filter_obj
def parse_trigger(trigger_text: str, effect_text: str) -> Optional[dict]:
"""Parse trigger condition into structured trigger object."""
# Combine trigger and effect text for full context
full_text = f"{trigger_text} {effect_text}".lower().strip()
if not trigger_text:
return None
trigger_lower = trigger_text.lower().strip()
for pattern, event_type in TRIGGER_PATTERNS:
match = re.search(pattern, trigger_lower)
if match:
trigger = {"event": event_type}
# Extract source from match groups if present
if match.lastindex and match.lastindex >= 1:
source_text = match.group(1).strip()
if source_text in ["this card", "this forward", "this backup", "this character"]:
trigger["source"] = "SELF"
elif source_text.startswith("a ") or source_text.startswith("an "):
trigger["source"] = "ANY"
trigger["source_filter"] = parse_card_filter(source_text)
else:
# Likely a specific card name reference
trigger["source"] = "SELF"
else:
trigger["source"] = "SELF"
return trigger
return None
# =============================================================================
# Modal Action Extraction
# =============================================================================
def extract_modal_actions(effect_text: str) -> list:
"""
Extract individual action options from modal ability text.
Actions are enclosed in quotes and separated by newlines or spaces.
Example input:
'Select 1 of the 3 following actions. If you have 5+ Ifrit...
"Choose 1 Forward. Deal it 7000 damage."
"Choose 1 Monster of cost 3 or less. Break it."
"Deal 3000 damage to all the Forwards opponent controls."'
Returns list of dicts with description and parsed effects.
"""
# Match all quoted strings (using both single quotes and double quotes)
# Handle escaped quotes and multiline
pattern = r'"([^"]+)"'
matches = re.findall(pattern, effect_text)
actions = []
for i, match in enumerate(matches):
action_text = match.strip()
# Parse the effects from this action
effects = parse_effects(action_text)
# If no effects parsed, try field pattern
if not effects:
field_effect = parse_field_ability(action_text, "")
if field_effect:
effects = [field_effect]
actions.append({
"index": i,
"description": action_text,
"effects": normalize_effects(effects) if effects else []
})
return actions
# =============================================================================
# Conditional Effect Parsing
# =============================================================================
def parse_condition(text: str) -> Optional[dict]:
"""
Parse condition text into a structured condition object.
Handles patterns like "if you control X", "if you have received Y damage", etc.
"""
text_lower = text.lower().strip()
# Opponent doesn't control - check early since it has more specific pattern
match = re.search(r"opponent (?:doesn't|does not) control any (.+)", text_lower)
if match:
card_type = match.group(1).strip()
return {
"type": "CONTROL_COUNT",
"comparison": "EQ",
"value": 0,
"card_type": "FORWARD" if "forward" in card_type else card_type.upper(),
"owner": "OPPONENT"
}
# Control count - "if you control X or more Forwards/characters/[type]"
# Check this BEFORE control card to avoid matching "3" as card name
match = re.search(r"control (\d+) or more ([^,\.]+)", text_lower)
if match:
count = int(match.group(1))
target_type = match.group(2).strip()
result = {
"type": "CONTROL_COUNT",
"comparison": "GTE",
"value": count
}
# Determine what type to count
if "forward" in target_type:
result["card_type"] = "FORWARD"
elif "backup" in target_type:
result["card_type"] = "BACKUP"
elif "monster" in target_type:
result["card_type"] = "MONSTER"
elif "character" in target_type:
result["card_type"] = "CHARACTER"
else:
# Could be element or category
result["filter_text"] = target_type
return result
# Control specific card - "if you control [Card Name]" or "if you control a Card Name X"
match = re.search(r"control (?:a |an )?(?:card named |)([^,\.]+)", text_lower)
if match:
card_name = match.group(1).strip()
# Clean up trailing words that aren't part of the name
card_name = re.sub(r'\s+(?:and|or|in|on|from|until|this).*$', '', card_name)
# Skip if it looks like a count pattern we missed
if re.match(r'^\d+', card_name):
pass # Let it fall through
else:
return {
"type": "CONTROL_CARD",
"card_name": card_name.title() # Capitalize card names
}
# Damage received - "if you have received X or more damage" / "X points of damage or more"
match = re.search(r"(?:you )?have received (\d+)(?: or more)? (?:points? of )?damage", text_lower)
if match:
return {
"type": "DAMAGE_RECEIVED",
"comparison": "GTE",
"value": int(match.group(1))
}
# Alternative: "X points of damage or more"
match = re.search(r"(\d+) points? of damage or more", text_lower)
if match:
return {
"type": "DAMAGE_RECEIVED",
"comparison": "GTE",
"value": int(match.group(1))
}
# Break zone count - "if you have X or more [cards] in your break zone"
match = re.search(r"have (?:a total of )?(\d+) or more (.+?) in your break zone", text_lower)
if match:
count = int(match.group(1))
cards_desc = match.group(2).strip()
# Extract card names if specified (e.g., "Card Name Ifrit and/or Card Name Ifrita")
card_names = extract_card_names_from_condition(cards_desc)
return {
"type": "BREAK_ZONE_COUNT",
"comparison": "GTE",
"value": count,
"card_names": card_names if card_names else []
}
# Card is on the field - "if [Card Name] is on the field"
match = re.search(r"(.+?) is on the field", text_lower)
if match:
card_name = match.group(1).strip()
return {
"type": "CONTROL_CARD",
"card_name": card_name.title()
}
# Forward state - "if this forward is dull/active"
match = re.search(r"(?:this forward|it) is (dull|active)", text_lower)
if match:
state = match.group(1).upper()
return {
"type": "FORWARD_STATE",
"state": state,
"check_self": True
}
# Opponent forward state - "if the forward is dull"
match = re.search(r"(?:the |that )forward is (dull|active)", text_lower)
if match:
state = match.group(1).upper()
return {
"type": "FORWARD_STATE",
"state": state,
"check_self": False
}
# Card in hand - "if you have X in your hand"
match = re.search(r"have (.+?) in your hand", text_lower)
if match:
card_desc = match.group(1).strip()
return {
"type": "CARD_IN_ZONE",
"zone": "HAND",
"filter_text": card_desc
}
# Cost comparison - targets "of cost X or less/more"
match = re.search(r"of cost (\d+) or (less|more)", text_lower)
if match:
cost = int(match.group(1))
comparison = "LTE" if match.group(2) == "less" else "GTE"
return {
"type": "COST_COMPARISON",
"comparison": comparison,
"value": cost
}
# Power comparison - targets "with X power or less/more"
match = re.search(r"with (\d+) power or (less|more)", text_lower)
if match:
power = int(match.group(1))
comparison = "LTE" if match.group(2) == "less" else "GTE"
return {
"type": "POWER_COMPARISON",
"comparison": comparison,
"value": power
}
return None
def extract_card_names_from_condition(text: str) -> list:
"""
Extract card names from condition text like "Card Name Ifrit and/or Card Name Ifrita".
"""
names = []
# Look for "Card Name X" patterns
card_name_matches = re.findall(r"card name ([^,]+?)(?:\s+and/or|\s+and|\s+or|$)", text, re.IGNORECASE)
for name in card_name_matches:
clean_name = name.strip()
if clean_name:
names.append(clean_name.title())
# If no "Card Name" prefix, the whole text might be the card name
if not names and text:
# Split on "and/or" or "and" or "or"
parts = re.split(r'\s+(?:and/or|and|or)\s+', text)
for part in parts:
clean = part.strip()
if clean and len(clean) > 2: # Avoid very short strings
names.append(clean.title())
return names
def parse_chained_effect(effect_text: str) -> Optional[dict]:
"""
Parse "If you do so" chained effects.
Pattern: "Do X. If you do so, do Y."
Returns a CHAINED_EFFECT structure where the chain effects only execute
if the primary effect succeeds.
"""
effect_lower = effect_text.lower()
# Split on "if you do" or "when you do" (with optional "so")
split_patterns = [
r"\.?\s*if you do(?:\s*so)?[,.]?\s*",
r"\.?\s*when you do(?:\s*so)?[,.]?\s*",
r"\.?\s*if you do this[,.]?\s*",
]
for pattern in split_patterns:
parts = re.split(pattern, effect_text, flags=re.IGNORECASE)
if len(parts) >= 2:
primary_text = parts[0].strip()
chain_text = parts[1].strip()
# Parse both parts
primary_effects = parse_effects(primary_text)
chain_effects = parse_effects(chain_text)
if primary_effects and chain_effects:
return {
"type": "CHAINED_EFFECT",
"primary_effect": primary_effects[0] if len(primary_effects) == 1 else {
"type": "SEQUENCE",
"effects": normalize_effects(primary_effects)
},
"chain_condition": "IF_YOU_DO",
"chain_effects": normalize_effects(chain_effects)
}
return None
def extract_scale_filter(scale_source: str) -> tuple:
"""
Extract scale_by type and scale_filter from a scale source description.
Args:
scale_source: Text like "Fire Forward you control", "Job Samurai Forward",
"Category (VII) card in your Break Zone", etc.
Returns:
Tuple of (scale_by: str, scale_filter: dict or None)
"""
source_lower = scale_source.lower().strip()
scale_filter = {}
# Determine owner (controller vs opponent)
if "opponent" in source_lower:
scale_filter["owner"] = "OPPONENT"
else:
scale_filter["owner"] = "CONTROLLER"
# Extract element filter
# Patterns: "Fire Forward", "each Ice card", "Wind Backup"
element_match = re.search(
r"\b(fire|ice|wind|earth|lightning|water|light|dark)\b",
source_lower
)
if element_match:
scale_filter["element"] = element_match.group(1).upper()
# Extract job filter
# Patterns: "Job Samurai Forward", "Job Warrior", "each Samurai"
job_match = re.search(r"job[:\s]+(\w+)", source_lower)
if job_match:
scale_filter["job"] = job_match.group(1).title()
else:
# Check for standalone job names (common jobs)
job_names = ["warrior", "knight", "samurai", "ninja", "monk", "dragoon",
"black mage", "white mage", "summoner", "thief", "bard",
"dancer", "red mage", "blue mage", "time mage", "paladin",
"dark knight", "berserker", "ranger", "machinist", "geomancer",
"chemist", "onion knight", "freelancer", "mime", "calculator",
"sky pirate", "l'cie", "soldier", "turk", "avalanche", "chocobo",
"moogle", "tonberry", "cactuar", "bomb", "mandragora", "rebel"]
for job in job_names:
if job in source_lower and "card name" not in source_lower:
scale_filter["job"] = job.title()
break
# Extract category filter
# Patterns: "Category (VII)", "[Category VII]", "Category: FFVII"
category_match = re.search(
r"category[:\s]*\(?([ivxlcdm0-9]+|[a-z\s]+)\)?",
source_lower
)
if category_match:
scale_filter["category"] = category_match.group(1).upper().strip()
# Extract cost filter
# Patterns: "of cost 3", "cost 4 or less", "cost 5 or more"
cost_match = re.search(r"(?:of )?cost (\d+)(?: or (less|more))?", source_lower)
if cost_match:
cost_value = int(cost_match.group(1))
comparison = cost_match.group(2)
if comparison == "less":
scale_filter["cost_comparison"] = "LTE"
scale_filter["cost_value"] = cost_value
elif comparison == "more":
scale_filter["cost_comparison"] = "GTE"
scale_filter["cost_value"] = cost_value
else:
scale_filter["cost"] = cost_value
# Extract card name filter (for break zone counting)
# Patterns: "Card Name Ifrit", "card named Cloud"
card_name_match = re.search(r"card name[d]?\s+([^,\.\s]+(?:\s+[^,\.\s]+)?)", source_lower)
if card_name_match:
scale_filter["card_name"] = card_name_match.group(1).strip().title()
# Determine scale_by based on zone/type
scale_by = "UNKNOWN"
if "damage" in source_lower and "received" in source_lower:
scale_by = "DAMAGE_RECEIVED"
elif "break zone" in source_lower:
scale_by = "CARDS_IN_BREAK_ZONE"
if scale_filter.get("owner") == "OPPONENT":
scale_by = "OPPONENT_BREAK_ZONE"
elif "hand" in source_lower:
scale_by = "CARDS_IN_HAND"
if scale_filter.get("owner") == "OPPONENT":
scale_by = "OPPONENT_HAND"
elif "forward" in source_lower:
if scale_filter.get("owner") == "OPPONENT":
scale_by = "OPPONENT_FORWARDS"
else:
scale_by = "FORWARDS_CONTROLLED"
scale_filter["card_type"] = "FORWARD"
elif "backup" in source_lower:
if scale_filter.get("owner") == "OPPONENT":
scale_by = "OPPONENT_BACKUPS"
else:
scale_by = "BACKUPS_CONTROLLED"
scale_filter["card_type"] = "BACKUP"
elif "monster" in source_lower:
scale_by = "MONSTERS_CONTROLLED"
scale_filter["card_type"] = "MONSTER"
elif "character" in source_lower or "card" in source_lower:
# Generic "card you control" - count all field cards
if scale_filter.get("owner") == "OPPONENT":
scale_by = "OPPONENT_FIELD_CARDS"
else:
scale_by = "FIELD_CARDS_CONTROLLED"
# Clean up filter - remove owner if it's just CONTROLLER (default)
if scale_filter.get("owner") == "CONTROLLER":
del scale_filter["owner"]
# Return None for empty filter (just had owner=CONTROLLER)
if not scale_filter:
return scale_by, None
return scale_by, scale_filter
def parse_scaling_effect(effect_text: str) -> Optional[dict]:
"""
Parse "for each X" scaling effects.
Pattern: "Deal X damage for each Y" or "gains +X power for each Y"
Returns a SCALING_EFFECT structure with optional scale_filter for filtered counts.
"""
effect_lower = effect_text.lower()
# "Deal X damage for each damage/point you have received"
match = re.search(
r"deal (?:it |them )?(\d+) damage for each (?:point of )?damage you have received",
effect_lower
)
if match:
multiplier = int(match.group(1))
return {
"type": "SCALING_EFFECT",
"base_effect": {"type": "DAMAGE", "target": {"type": "CHOSEN"}},
"scale_by": "DAMAGE_RECEIVED",
"multiplier": multiplier
}
# "Deal X damage for each [filtered] Forward/Backup/card"
match = re.search(
r"deal (?:it |them )?(\d+) damage for each (.+?)(?:\.|$)",
effect_lower
)
if match:
multiplier = int(match.group(1))
scale_source = match.group(2).strip()
# Skip if this is the "damage received" pattern (handled above)
if not ("damage" in scale_source and "received" in scale_source):
scale_by, scale_filter = extract_scale_filter(scale_source)
result = {
"type": "SCALING_EFFECT",
"base_effect": {"type": "DAMAGE", "target": {"type": "CHOSEN"}},
"scale_by": scale_by,
"multiplier": multiplier
}
if scale_filter:
result["scale_filter"] = scale_filter
return result
# "gains +X power for each Y"
match = re.search(
r"gains? \+(\d+) power for each (.+?)(?:\.|$)",
effect_lower
)
if match:
multiplier = int(match.group(1))
scale_source = match.group(2).strip()
scale_by, scale_filter = extract_scale_filter(scale_source)
result = {
"type": "SCALING_EFFECT",
"base_effect": {
"type": "POWER_MOD",
"duration": "END_OF_TURN" if "until the end of the turn" in effect_lower else "PERMANENT",
"target": {"type": "SELF"}
},
"scale_by": scale_by,
"multiplier": multiplier
}
if scale_filter:
result["scale_filter"] = scale_filter
return result
# "loses X power for each Y"
match = re.search(
r"loses? (\d+) power for each (.+?)(?:\.|$)",
effect_lower
)
if match:
multiplier = int(match.group(1))
scale_source = match.group(2).strip()
scale_by, scale_filter = extract_scale_filter(scale_source)
result = {
"type": "SCALING_EFFECT",
"base_effect": {
"type": "POWER_MOD",
"amount_modifier": "NEGATIVE", # Indicates subtraction
"duration": "END_OF_TURN" if "until the end of the turn" in effect_lower else "PERMANENT",
"target": {"type": "CHOSEN"}
},
"scale_by": scale_by,
"multiplier": multiplier
}
if scale_filter:
result["scale_filter"] = scale_filter
return result
# "Draw X cards for each Y"
match = re.search(
r"draw (\d+) cards? for each (.+?)(?:\.|$)",
effect_lower
)
if match:
multiplier = int(match.group(1))
scale_source = match.group(2).strip()
scale_by, scale_filter = extract_scale_filter(scale_source)
result = {
"type": "SCALING_EFFECT",
"base_effect": {"type": "DRAW", "target": {"type": "CONTROLLER"}},
"scale_by": scale_by,
"multiplier": multiplier
}
if scale_filter:
result["scale_filter"] = scale_filter
return result
# "reduced by X for each Y" (cost reduction scaling)
match = re.search(
r"reduced by (\d+) for each (.+?)(?:\.|$)",
effect_lower
)
if match:
multiplier = int(match.group(1))
scale_source = match.group(2).strip()
scale_by, scale_filter = extract_scale_filter(scale_source)
result = {
"type": "SCALING_EFFECT",
"base_effect": {"type": "COST_REDUCTION"},
"scale_by": scale_by,
"multiplier": multiplier
}
if scale_filter:
result["scale_filter"] = scale_filter
return result
return None
def parse_conditional_ability(effect_text: str) -> Optional[dict]:
"""
Parse conditional ability text starting with "If X, do Y".
Returns a CONDITIONAL effect that wraps other effects with a condition.
"""
effect_lower = effect_text.lower().strip()
# Match "If X, Y" pattern at the start
if_match = re.match(r"^if\s+(.+?),\s*(.+)$", effect_text, re.IGNORECASE | re.DOTALL)
if not if_match:
return None
condition_text = if_match.group(1).strip()
effect_text_after = if_match.group(2).strip()
# Don't parse "if you do so" as conditional - that's a chained effect
if re.search(r"you do(?:\s*so)?", condition_text.lower()):
return None
# Parse the condition
condition = parse_condition(condition_text)
if not condition:
# Create a raw condition if we can't parse it
condition = {
"type": "UNKNOWN",
"raw": condition_text
}
# Parse the effects after the condition
effects = parse_effects(effect_text_after)
if not effects:
# Try field ability parsing
field_effect = parse_field_ability(effect_text_after, "")
if field_effect:
effects = [field_effect]
if not effects:
return None
return {
"type": "CONDITIONAL",
"condition": condition,
"then_effects": normalize_effects(effects)
}
def parse_modal_ability(effect_text: str) -> Optional[dict]:
"""
Parse a modal ability ("Select X of Y following actions").
Returns structured CHOOSE_MODE effect with all mode options.
"""
effect_lower = effect_text.lower()
# Check for "select X of the Y following actions" pattern
# Also handle "select up to X"
select_match = re.search(
r"select (up to )?(\d+) of the (\d+) following actions?",
effect_lower
)
if not select_match:
return None
is_up_to = select_match.group(1) is not None
select_count = int(select_match.group(2))
mode_count = int(select_match.group(3))
# Extract the quoted action strings
modes = extract_modal_actions(effect_text)
result = {
"type": "CHOOSE_MODE",
"select_count": select_count,
"select_up_to": is_up_to,
"mode_count": mode_count,
"modes": modes
}
# Check for enhanced/conditional upgrade clause
# e.g., "If you have 5+ Ifrit in your Break Zone, select up to 3 instead"
enhanced_match = re.search(
r"if [^.]+,\s*select (up to )?(\d+) of the \d+ following actions instead",
effect_lower
)
if enhanced_match:
# Extract the condition text
condition_match = re.search(r"(if [^,]+)", effect_lower)
condition_text = condition_match.group(1) if condition_match else ""
enhanced_up_to = enhanced_match.group(1) is not None
enhanced_count = int(enhanced_match.group(2))
result["enhanced_condition"] = {
"description": condition_text,
"select_count": enhanced_count,
"select_up_to": enhanced_up_to
}
return result
def parse_effects(effect_text: str) -> list:
"""Parse effect text into list of structured effect objects."""
effects = []
effect_lower = effect_text.lower().strip()
# Check for modal ability first (Select X of Y following actions)
if re.search(r"select (?:up to )?\d+ of the \d+ following actions?", effect_lower):
modal_result = parse_modal_ability(effect_text)
if modal_result:
return [modal_result]
# Check for chained "if you do so" effects
if re.search(r"if you do(?:\s*so)?[,.]|when you do(?:\s*so)?[,.]", effect_lower):
chained_result = parse_chained_effect(effect_text)
if chained_result:
return [chained_result]
# Check for scaling "for each X" effects
if re.search(r"for each (?:point of )?(?:damage|forward|backup|card|character)", effect_lower):
scaling_result = parse_scaling_effect(effect_text)
if scaling_result:
return [scaling_result]
# Check for conditional "If X, Y" effects (but NOT "if you do so")
if effect_lower.startswith("if ") and not re.search(r"^if you do(?:\s*so)?", effect_lower):
conditional_result = parse_conditional_ability(effect_text)
if conditional_result:
return [conditional_result]
# Check for "Choose X" prefix first
choose_match = re.match(r"choose (\d+|up to \d+) (.+?)(?:\. |: )", effect_lower)
if choose_match:
count_str = choose_match.group(1)
target_desc = choose_match.group(2)
if "up to" in count_str:
count = int(re.search(r"\d+", count_str).group())
count_type = "count_up_to"
else:
count = int(count_str)
count_type = "count"
target = parse_target_text(f"{count} {target_desc}")
# Parse the rest of the effect
rest = effect_lower[choose_match.end():].strip()
for effect_type, patterns in EFFECT_PATTERNS.items():
if effect_type == "CHOOSE":
continue
for pattern, builder in patterns:
match = re.search(pattern, rest)
if match:
effect = builder(match)
# Use the parsed target from Choose prefix
if effect.get("target", {}).get("type") == "CHOSEN":
effect["target"] = target
effects.append(effect)
break # Only match first pattern per effect type
if effects:
break # Stop after finding first matching effect
if not effects:
# Couldn't parse the effect, return raw
effects.append({
"type": "UNKNOWN",
"raw": effect_text,
"target": target
})
return effects
# No Choose prefix, parse effects directly
# Only take the FIRST matching effect to avoid duplicates
for effect_type, patterns in EFFECT_PATTERNS.items():
for pattern, builder in patterns:
match = re.search(pattern, effect_lower)
if match:
try:
effect = builder(match)
effects.append(effect)
return effects # Return after first match
except Exception as e:
pass # Skip malformed matches
return effects
def parse_field_ability(effect_text: str, card_name: str) -> Optional[dict]:
"""Parse field ability text into structured effect object."""
effect_lower = effect_text.lower().strip()
# Special handling for modal abilities - need full text to extract quoted actions
if re.search(r"select (?:up to )?\d+ of the \d+ following actions?", effect_lower):
modal_result = parse_modal_ability(effect_text)
if modal_result:
return modal_result
for pattern, result in FIELD_PATTERNS:
if callable(result):
match = re.search(pattern, effect_lower)
if match:
parsed = result(match)
# Skip placeholder - modal parsing should have handled this
if parsed.get("_needs_modal_parsing"):
continue
return parsed
else:
if re.match(pattern, effect_lower):
return result.copy()
return None
# =============================================================================
# Effect Normalization
# Maps fine-grained effect types to canonical types for the Godot runtime
# =============================================================================
EFFECT_TYPE_MAPPINGS = {
# Deck placement effects -> PUT_INTO_DECK with position
"BOTTOM_OF_DECK": ("PUT_INTO_DECK", {"position": "BOTTOM"}),
"TOP_OF_DECK": ("PUT_INTO_DECK", {"position": "TOP"}),
"TOP_OR_BOTTOM_OF_DECK": ("PUT_INTO_DECK", {"position": "CHOICE"}),
"SHUFFLE_INTO_DECK": ("PUT_INTO_DECK", {"position": "SHUFFLE"}),
"OPPONENT_PUTS_BOTTOM": ("PUT_INTO_DECK", {"position": "BOTTOM", "chooser": "OPPONENT"}),
# Control effects -> TAKE_CONTROL
"CONTROL": ("TAKE_CONTROL", {}),
# Force attack/block -> restrictions
"FORCE_ATTACK": ("MUST_ATTACK", {}),
"FORCE_BLOCK": ("MUST_BLOCK", {}),
# Grant effects -> canonical forms
"GRANT_RESTRICTION": None, # Special handling below
"GRANT_PROTECTION": ("PROTECTION", {}),
"GRANT_UNBLOCKABLE": ("UNBLOCKABLE", {}),
# Power setting -> POWER_MOD with mode
"SET_POWER": ("POWER_MOD", {"mode": "SET"}),
# Remove abilities -> REMOVE_ABILITY
"REMOVE_ABILITIES": ("REMOVE_ABILITY", {"ability": "ALL"}),
"REMOVE_KEYWORDS": ("REMOVE_ABILITY", {}), # keeps keywords list
# Copy effects -> COPY
"COPY_ACTION_ABILITIES": ("COPY", {"copy_type": "ACTION_ABILITIES"}),
"COPY_SPECIAL_ABILITY": ("COPY", {"copy_type": "SPECIAL_ABILITIES"}),
# Damage variants -> DAMAGE with modifiers
"COMBAT_DAMAGE": ("DAMAGE", {"amount_source": "POWER"}),
"SHARED_DAMAGE": ("DAMAGE", {"targets": "BOTH"}),
"DEAL_SAME_DAMAGE": ("DAMAGE", {"amount_source": "SAME_AS_DEALT"}),
"HALF_POWER_DAMAGE": ("DAMAGE", {"amount_source": "HALF_POWER"}),
"SPLIT_DAMAGE": ("DAMAGE", {"distribution": "SPLIT"}),
"SPLIT_DAMAGE_SPECIFIC": ("DAMAGE", {"distribution": "SPLIT_SPECIFIC"}),
# Damage modifiers -> DAMAGE_MODIFIER
"DAMAGE_INCREASE": ("DAMAGE_MODIFIER", {"modifier_type": "INCREASE"}),
"DAMAGE_REDUCTION": ("DAMAGE_MODIFIER", {"modifier_type": "REDUCTION"}),
"DAMAGE_DEALT_INCREASE": ("DAMAGE_MODIFIER", {"modifier_type": "DEALT_INCREASE"}),
"DAMAGE_TO_FORWARD_INCREASE": ("DAMAGE_MODIFIER", {"modifier_type": "TO_FORWARD_INCREASE"}),
# Counter effects
"DOUBLE_COUNTER": ("ADD_COUNTER", {"modifier": "DOUBLE"}),
# Special types that need specific handling
"CONDITIONAL_BREAK": ("BREAK", {"conditional": True}),
"CONDITIONAL_EFFECT": None, # Wrapper, keep as-is for now
# Return variants
"RETURN_MULTIPLE": ("RETURN", {"count": "MULTIPLE"}),
# Element/job changes -> MODIFY
"CHANGE_ELEMENT": ("MODIFY", {"property": "ELEMENT"}),
"GAIN_JOB": ("MODIFY", {"property": "JOB"}),
# Block restriction
"BLOCK_RESTRICTION": ("PREVENT", {"action": "BLOCK_BY_COST"}),
# Cast permission
"CAST_FROM_ZONE": ("PERMISSION", {"action": "CAST"}),
}
def normalize_effect(effect: dict) -> dict:
"""Normalize an effect to canonical type for Godot runtime."""
effect_type = effect.get("type", "")
# Special handling for GRANT_RESTRICTION
if effect_type == "GRANT_RESTRICTION":
restriction = effect.get("restriction", "")
if restriction == "CANNOT_ATTACK":
return {**effect, "type": "CANT_ATTACK"}
elif restriction == "CANNOT_BLOCK":
return {**effect, "type": "CANT_BLOCK"}
elif restriction == "CANNOT_ATTACK_OR_BLOCK":
# Split into two effects would be better, but for now use PREVENT
return {**effect, "type": "PREVENT", "action": "ATTACK_AND_BLOCK"}
elif restriction == "CANNOT_BE_TARGETED":
return {**effect, "type": "SELECTION_IMMUNITY"}
elif restriction == "MUST_BLOCK":
return {**effect, "type": "MUST_BLOCK"}
return effect
# Look up mapping
if effect_type in EFFECT_TYPE_MAPPINGS:
mapping = EFFECT_TYPE_MAPPINGS[effect_type]
if mapping is None:
return effect # Keep as-is
new_type, extra_fields = mapping
normalized = {**effect, "type": new_type}
normalized.update(extra_fields)
return normalized
return effect
def normalize_effects(effects: list) -> list:
"""Normalize a list of effects to canonical types."""
return [normalize_effect(e) for e in effects]
def parse_ability(ability: dict, card_id: str, card_name: str, ability_index: int) -> dict:
"""Parse a single ability into structured format."""
ability_type = ability.get("type", "").upper()
trigger_text = ability.get("trigger", "")
effect_text = ability.get("effect", "")
is_ex_burst = ability.get("is_ex_burst", False)
ability_name = ability.get("name", "")
result = {
"ability_index": ability_index,
"original": {
"type": ability_type.lower(),
"name": ability_name,
"trigger": trigger_text,
"effect": effect_text,
"is_ex_burst": is_ex_burst
},
"parsed": None,
"parse_confidence": "LOW",
"parse_notes": None
}
try:
parsed = {
"type": ability_type,
"is_ex_burst": is_ex_burst,
"name": ability_name if ability_name else None
}
if ability_type == "AUTO":
# Parse trigger
trigger = parse_trigger(trigger_text, effect_text)
if trigger:
parsed["trigger"] = trigger
elif is_ex_burst:
parsed["trigger"] = {"event": "EX_BURST"}
# Parse effects and normalize
effects = parse_effects(effect_text)
if effects:
effects = normalize_effects(effects)
parsed["effects"] = effects
result["parse_confidence"] = "HIGH" if all(e.get("type") != "UNKNOWN" for e in effects) else "MEDIUM"
else:
result["parse_notes"] = "Could not parse effects"
elif ability_type == "FIELD":
# Parse as field ability
field_effect = parse_field_ability(effect_text, card_name)
if field_effect:
field_effect = normalize_effect(field_effect)
parsed["effects"] = [field_effect]
result["parse_confidence"] = "HIGH"
else:
# Try parsing as regular effects
effects = parse_effects(effect_text)
if effects:
effects = normalize_effects(effects)
parsed["effects"] = effects
result["parse_confidence"] = "MEDIUM"
else:
result["parse_notes"] = "Could not parse field ability"
elif ability_type in ["ACTION", "SPECIAL"]:
# Parse effects and normalize
effects = parse_effects(effect_text)
if effects:
effects = normalize_effects(effects)
parsed["effects"] = effects
result["parse_confidence"] = "HIGH" if all(e.get("type") != "UNKNOWN" for e in effects) else "MEDIUM"
else:
result["parse_notes"] = "Could not parse effects"
# Parse cost if present
cost = ability.get("cost", {})
if cost:
parsed["cost"] = cost
result["parsed"] = parsed
except Exception as e:
result["parse_notes"] = f"Parse error: {str(e)}"
return result
def process_card(card: dict) -> list:
"""Process all abilities of a card."""
card_id = card.get("id", "unknown")
card_name = card.get("name", "Unknown")
abilities = card.get("abilities", [])
parsed_abilities = []
for i, ability in enumerate(abilities):
parsed = parse_ability(ability, card_id, card_name, i)
parsed_abilities.append(parsed)
return parsed_abilities
def load_cards() -> list:
"""Load cards from cards.json."""
with open(CARDS_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
return data.get("cards", [])
return data
def save_output(output: dict, dry_run: bool = False) -> None:
"""Save processed abilities to output file."""
if dry_run:
print("\n[DRY RUN] Would save to:", OUTPUT_PATH)
return
with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
json.dump(output, f, indent=2, ensure_ascii=False)
print(f"\nSaved to: {OUTPUT_PATH}")
def main():
parser = argparse.ArgumentParser(description="FFTCG Ability Processor")
parser.add_argument("--card", help="Process a single card by ID")
parser.add_argument("--set", type=int, help="Process a single set")
parser.add_argument("--dry-run", action="store_true", help="Preview without saving")
parser.add_argument("--verbose", action="store_true", help="Show parse details")
parser.add_argument("--report-unparsed", action="store_true", help="Generate unparsed abilities report")
args = parser.parse_args()
print("FFTCG Ability Processor")
print("=" * 50)
# Load cards
cards = load_cards()
print(f"Loaded {len(cards)} cards")
# Filter cards if specified
if args.card:
cards = [c for c in cards if c.get("id") == args.card]
if not cards:
print(f"Card {args.card} not found")
return
elif args.set:
set_prefix = f"{args.set}-"
cards = [c for c in cards if c.get("id", "").startswith(set_prefix)]
print(f"Filtered to {len(cards)} cards in set {args.set}")
# Process cards
output = {
"version": "1.0",
"generated_at": datetime.now().isoformat(),
"statistics": {
"total_cards": 0,
"total_abilities": 0,
"parsed_high": 0,
"parsed_medium": 0,
"parsed_low": 0,
"unparsed": 0,
"by_effect_type": {}
},
"abilities": {},
"unparsed": []
}
stats = output["statistics"]
for card in cards:
card_id = card.get("id", "unknown")
parsed_abilities = process_card(card)
if parsed_abilities:
output["abilities"][card_id] = parsed_abilities
stats["total_cards"] += 1
for ab in parsed_abilities:
stats["total_abilities"] += 1
confidence = ab.get("parse_confidence", "LOW")
if confidence == "HIGH":
stats["parsed_high"] += 1
elif confidence == "MEDIUM":
stats["parsed_medium"] += 1
else:
stats["parsed_low"] += 1
# Track effect types
parsed = ab.get("parsed", {})
if parsed and parsed.get("effects"):
for effect in parsed["effects"]:
effect_type = effect.get("type", "UNKNOWN")
stats["by_effect_type"][effect_type] = stats["by_effect_type"].get(effect_type, 0) + 1
# Track unparsed
if confidence == "LOW" or not parsed or not parsed.get("effects"):
stats["unparsed"] += 1
output["unparsed"].append({
"card_id": card_id,
"ability_index": ab["ability_index"],
"original_text": ab["original"].get("effect", ""),
"reason": ab.get("parse_notes", "Could not parse")
})
# Verbose output
if args.verbose:
print(f"\n[{card_id}] Ability {ab['ability_index']} ({ab['original']['type']})")
print(f" Effect: {ab['original']['effect'][:80]}...")
print(f" Confidence: {confidence}")
if parsed and parsed.get("effects"):
for eff in parsed["effects"]:
print(f" -> {eff.get('type', 'UNKNOWN')}: {eff}")
# Print summary
print("\n" + "=" * 50)
print("Summary:")
print(f" Cards processed: {stats['total_cards']}")
print(f" Abilities processed: {stats['total_abilities']}")
print(f" High confidence: {stats['parsed_high']}")
print(f" Medium confidence: {stats['parsed_medium']}")
print(f" Low/unparsed: {stats['parsed_low']}")
print(f"\nEffect types found:")
for effect_type, count in sorted(stats["by_effect_type"].items(), key=lambda x: -x[1]):
print(f" {effect_type}: {count}")
# Save output
save_output(output, args.dry_run)
# Unparsed report
if args.report_unparsed:
print("\n" + "=" * 50)
print(f"Unparsed Abilities Report ({len(output['unparsed'])} abilities)")
print("=" * 50)
for item in output["unparsed"][:50]: # Limit to first 50
print(f"\n{item['card_id']} ability {item['ability_index']}:")
print(f" Text: {item['original_text'][:100]}")
print(f" Reason: {item['reason']}")
if __name__ == "__main__":
main()