4629 lines
183 KiB
Python
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()
|