File size: 41,144 Bytes
463f868
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9bd4ce5
463f868
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
# -*- coding: utf-8 -*-
"""New multi-pass ability parser using the pattern registry system.



This parser replaces the legacy 3500-line spaghetti parser with a clean,

modular architecture based on:

1. Declarative patterns organized by phase

2. Multi-pass parsing: Trigger → Conditions → Effects → Modifiers

3. Proper optionality handling (fixes the is_optional bug)

4. Structural Lexing: Balanced-brace scanning instead of greedy regex

"""

import re
from typing import Any, Dict, List, Optional, Tuple

from engine.models.ability import (
    Ability,
    AbilityCostType,
    Condition,
    ConditionType,
    Cost,
    Effect,
    EffectType,
    TargetType,
    TriggerType,
)

from .parser_lexer import StructuredEffect, StructuralLexer
from .parser_costs import parse_pseudocode_costs
from .parser_effect_normalization import normalize_select_hand_effect
from .parser_effect_aliases import resolve_effect_aliases
from .parser_grant_ability import parse_grant_ability_effect
from .parser_play_member_resolution import resolve_play_member_source
from .parser_target_resolution import resolve_target_type
from .parser_semantics import looks_like_condition_instruction, parse_pseudocode_conditions
from .parser_patterns import IGNORED_CONDITIONS, KEYWORD_CONDITIONS, MAX_SELECT_ALL, TRIGGER_ALIASES


class AbilityParserV2:
    """Multi-pass ability parser using pattern registry."""

    def __init__(self):
        pass

    def parse(self, text: str) -> List[Ability]:
        """Parse ability text into structured Ability objects."""
        # Preprocess
        text = text.replace("<br>", "\n")

        # Detect format
        triggers = ["TRIGGER:", "CONDITION:", "EFFECT:", "COST:"]

        # Behavior blocks are handled if present, else fallback to pseudocode
        if text.strip().upper().startswith("BEHAVIOR:"):
            # Check if behavior parser exists (it was legacy/retired in some versions)
            if hasattr(self, "_parse_behavior_block"):
                return self._parse_behavior_block(text)
            return self._parse_pseudocode_block(text)

        return self._parse_pseudocode_block(text)

    # =========================================================================
    # Pseudocode Parsing (Inverse of tools/simplify_cards.py)
    # =========================================================================

    def _parse_pseudocode_block(self, text: str) -> List[Ability]:
        """Parse one or more abilities from pseudocode format."""
        # Normalized splitting: ensure each keyword starts a new line
        for kw in ["TRIGGER:", "CONDITION:", "EFFECT:", "COST:"]:
            text = text.replace(f"; {kw}", f"\n{kw}").replace(f";{kw}", f"\n{kw}")

        # Split by keywords but respect quotes
        # We want to identify blocks that belong together.
        # A block starts with one or more TRIGGER: lines followed by a body.

        lines = [line.strip() for line in text.split("\n") if line.strip()]

        # Filter out reminder-only lines at the top level
        # (e.g. (エールで出たスコア...))
        filtered_lines = []
        for line in lines:
            cleaned = line.strip()
            if (
                cleaned.startswith("(")
                and cleaned.endswith(")")
                and not any(kw in cleaned.upper() for kw in ["TRIGGER:", "EFFECT:", "COST:", "CONDITION:"])
            ):
                continue
            filtered_lines.append(line)
        lines = filtered_lines

        if not lines:
            return []

        # Group lines into logical abilities
        # Each ability has a set of triggers and a set of instructions.
        current_triggers = []
        current_body = []
        ability_specs = []

        for line in lines:
            # Check if it's a trigger line (can be multiple on one line if separated by semicolon)
            if line.upper().startswith("TRIGGER:"):
                # If we have a body from a previous trigger group, finalize it
                if current_body:
                    ability_specs.append((current_triggers, current_body))
                    current_triggers = []
                    current_body = []

                # Split and add all triggers from this line
                # Use regex to find all TRIGGER: instances
                matches = re.finditer(r"TRIGGER:\s*([^;]+)(?:;|$)", line, re.I)
                for m in matches:
                    t_text = m.group(1).strip()
                    if t_text:
                        split_triggers = StructuralLexer.split_respecting_nesting(t_text, delimiter=",")
                        for split_trigger in split_triggers:
                            split_trigger = split_trigger.strip()
                            if split_trigger:
                                current_triggers.append(split_trigger)
            else:
                current_body.append(line)

        # Finalize last block
        if current_triggers or current_body:
            ability_specs.append((current_triggers, current_body))

        abilities = []
        for triggers, body in ability_specs:
            if not triggers:
                # Default to ACTIVATED if body exists but no trigger
                # BUT ONLY IF THERE IS SUBSTANCE (not just keywords or reminders)
                has_substance = False
                for line in body:
                    cleaned = line.upper()
                    if any(kw in cleaned for kw in ["EFFECT:", "COST:", "CONDITION:"]):
                        has_substance = True
                        break
                    # Also check for non-parenthesized text
                    if line.strip() and not (line.strip().startswith("(") and line.strip().endswith(")")):
                        has_substance = True
                        break

                if not has_substance:
                    continue

                triggers = ["ACTIVATED"]

            # For each trigger, create a separate ability but sharing the same body content
            body_text = "\n".join(body)
            for t_val in triggers:
                full_text = f"TRIGGER: {t_val}\n{body_text}"
                ability = self._parse_single_pseudocode(full_text)
                if ability:
                    abilities.append(ability)

        return abilities

    def _parse_single_pseudocode(self, text: str) -> Ability:
        """Parse a single ability from pseudocode format."""
        # Clean up lines but preserve structure for Options: parsing
        lines = [line.strip() for line in text.split("\n") if line.strip()]

        trigger = TriggerType.NONE
        costs = []
        conditions = []
        effects = []
        instructions = []
        is_once_per_turn = False

        # New: Track nested options for SELECT_MODE
        # If we see "Options:", the next lines until the next keyword belong to it
        i = 0
        last_target = TargetType.PLAYER

        # Pre-pass: Remove parenthesized reminder text at the very start of the ability
        # unless it starts with a keyword.
        if lines and lines[0].startswith("(") and lines[0].endswith(")"):
            # If it's just reminder text like (エールで出たスコア...), skip it if no keywords inside
            inner = lines[0][1:-1].lower()
            keywords = ["trigger:", "effect:", "cost:", "condition:"]
            if not any(kw in inner for kw in keywords):
                lines = lines[1:]

        while i < len(lines):
            line = lines[i]
            upper_line = line.upper()

            # Check for Once per turn/Game flags globally for any instruction line
            low_line = line.lower()
            if "once per turn" in low_line or "once per game" in low_line or "(once per turn)" in low_line:
                is_once_per_turn = True

            if upper_line.startswith("TRIGGER:"):
                t_name = line[len("TRIGGER:") :].strip().upper()
                # Strip all content in parentheses (...) or braces {...}
                t_name = re.sub(r"\(.*?\)", "", t_name)
                t_name = re.sub(r"\{.*?\}", "", t_name).strip()

                # Use module-level constant for trigger aliases
                t_name = TRIGGER_ALIASES.get(t_name, t_name)
                try:
                    trigger = TriggerType[t_name]
                except (KeyError, ValueError):
                    trigger = getattr(TriggerType, t_name, TriggerType.NONE)

            elif upper_line.startswith("COST:"):
                cost_str = line[len("COST:") :].strip()
                new_costs = self._parse_pseudocode_costs(cost_str)
                for c in new_costs:
                    # SYSTEMIC FIX: Sort costs between Activation Phase (Shell) and Execution Phase (Bytecode)
                    # Mandatory initial costs stay in 'costs' for shell pre-checks.
                    # Optional or mid-ability costs move to 'instructions' so the bytecode
                    # interpreter can suspend for pay/skip interactions.
                    # Complex costs (SELECT_MEMBER, etc.) MUST be in bytecode
                    is_complex = c.type == AbilityCostType.NONE and c.params.get("cost_type_name") != "SELECT_SELF_OR_DISCARD"

                    if not c.is_optional and not instructions and not is_complex:
                        costs.append(c)
                    else:
                        instructions.append(c)

            elif upper_line.startswith("CONDITION:"):
                cond_str = line[len("CONDITION:") :].strip()
                new_conditions = self._parse_pseudocode_conditions(cond_str)
                # Only add to pre-activation pre-check conditions if NO effects or costs have been encountered yet
                if not effects and not costs:
                    conditions.extend(new_conditions)
                instructions.extend(new_conditions)

            elif upper_line.startswith("EFFECT:"):
                eff_str = line[len("EFFECT:") :].strip()
                new_effects = self._parse_pseudocode_effects(eff_str, last_target=last_target, full_text=text)
                if new_effects:
                    last_target = new_effects[-1].target if isinstance(new_effects[-1], Effect) else last_target

                # Filter out Conditions from the 'effects' list to avoid AttributeErrors in compiler
                effects.extend([e for e in new_effects if isinstance(e, Effect)])
                instructions.extend(new_effects)

            elif upper_line.startswith("OPTIONS:"):
                # The most recently added effect should be SELECT_MODE
                if effects and effects[-1].effect_type == EffectType.SELECT_MODE:
                    # Parse subsequent lines until next major keyword
                    modal_options = []
                    i += 1
                    while i < len(lines) and not any(
                        lines[i].upper().startswith(kw) for kw in ["TRIGGER:", "COST:", "CONDITION:", "EFFECT:"]
                    ):
                        # Format: N: EFFECT1, EFFECT2
                        option_match = re.match(r"\d+:\s*(.*)", lines[i])
                        if option_match:
                            option_text = option_match.group(1)
                            sub_effects = self._parse_pseudocode_effects_compact(option_text)
                            modal_options.append(sub_effects)
                        i += 1
                    effects[-1].modal_options = modal_options
                    continue  # Already incremented i

            elif upper_line.startswith("OPTION:"):
                # Format: OPTION: Description | EFFECT: Effect1; Effect2 | COST: Cost1
                if effects and effects[-1].effect_type == EffectType.SELECT_MODE:
                    # Parse the option line
                    parts = line.replace("OPTION:", "").split("|")
                    opt_desc = parts[0].strip()

                    # Store description in select_mode effect params
                    if "options" not in effects[-1].params:
                        effects[-1].params["options"] = []
                    effects[-1].params["options"].append(opt_desc)

                    sub_instructions = []

                    # Parse Costs
                    cost_part = next((p.strip() for p in parts if p.strip().startswith("COST:")), None)
                    if cost_part:
                        cost_str = cost_part.replace("COST:", "").strip()
                        sub_costs = self._parse_pseudocode_costs(cost_str)
                        sub_instructions.extend(sub_costs)

                    # Parse Effects
                    eff_part = next((p.strip() for p in parts if p.strip().startswith("EFFECT:")), None)
                    if eff_part:
                        eff_str = eff_part.replace("EFFECT:", "").strip()
                        # Use standard effect parser as these can be complex
                        sub_effects = self._parse_pseudocode_effects(eff_str)
                        # Filter for modal_options which expects List[Effect] usually, or Union
                        sub_instructions.extend(sub_effects)

                    # Initialize modal_options if needed
                    if not hasattr(effects[-1], "modal_options") or effects[-1].modal_options is None:
                        effects[-1].modal_options = []

                    effects[-1].modal_options.append(sub_instructions)

            i += 1

        return Ability(
            raw_text=text,
            trigger=trigger,
            costs=costs,
            conditions=conditions,
            effects=effects,
            is_once_per_turn=is_once_per_turn,
            instructions=instructions,
            pseudocode=text,
        )

    def _parse_pseudocode_effects_compact(self, text: str) -> List[Effect]:
        """Special parser for compact effects in Options list (comma separated)."""
        # Format example: DRAW(1)->SELF {PARAMS}, MOVE_TO_DECK(1)->SELF {PARAMS}
        # Split by comma but not inside {}
        parts = []
        current = ""
        depth = 0
        for char in text:
            if char == "{":
                depth += 1
            elif char == "}":
                depth -= 1
            elif char == "," and depth == 0:
                parts.append(current.strip())
                current = ""
                continue
            current += char
        if current:
            parts.append(current.strip())

        effects = []
        for p in parts:
            # Format: NAME(VAL)->TARGET {PARAMS} or NAME(VAL)->EFFECT(VAL2)
            # Try to match name and val first
            m = re.match(r"(\w+)\((.*?)\)(.*)", p)
            if m:
                name, val_part, rest = m.groups()
                name_up = name.upper()
                etype = getattr(EffectType, name_up, EffectType.DRAW)

                # Check for target or chained effect in rest
                target = last_target
                chained_effect_str = ""

                arrow_match = re.search(r"->\s*([\w!]+)(\(.*\))?(.*)", rest)
                if arrow_match:
                    target_or_eff_name = arrow_match.group(1).upper()
                    inner_val = arrow_match.group(2)
                    extra_rest = arrow_match.group(3)

                    if hasattr(EffectType, target_or_eff_name):
                        # Chained effect!
                        chained_effect_str = f"{target_or_eff_name}{inner_val if inner_val else '()'} {extra_rest}"
                        target = last_target  # Keep last target for the first effect
                    else:
                        target = getattr(TargetType, target_or_eff_name, TargetType.PLAYER)
                        rest = extra_rest  # Parameters belong to the first effect

                params = self._parse_pseudocode_params(rest)

                val_int = 0
                val_cond = ConditionType.NONE

                # Check if val_part is a condition type or contains multiple params
                val_up = str(val_part).upper()
                if "," in val_up:
                    # Positional params in parentheses: META_RULE(SCORE_RULE, ALL_ENERGY_ACTIVE)
                    v_parts = [vp.strip() for vp in val_part.split(",")]
                    for vp in v_parts:
                        vp_up = vp.upper()
                        if vp_up == "SCORE_RULE":
                            params["type"] = "SCORE_RULE"
                        elif vp_up == "ALL_ENERGY_ACTIVE":
                            params["rule"] = "ALL_ENERGY_ACTIVE"
                            val_int = 1  # v=1 for SCORE_RULE: ALL_ENERGY_ACTIVE
                        else:
                            if "=" in vp:
                                k, v = vp.split("=", 1)
                                params[k.strip().lower()] = v.strip().strip("\"'")
                            else:
                                try:
                                    if val_int == 0:
                                        val_int = int(vp)
                                except (TypeError, ValueError):
                                    pass
                elif hasattr(ConditionType, val_up):
                    val_cond = getattr(ConditionType, val_up)
                else:
                    try:
                        val_int = int(val_part)
                    except ValueError:
                        val_int = 1
                        params["raw_val"] = val_part

                effects.append(Effect(etype, val_int, val_cond, target, params))

                if chained_effect_str:
                    # Recursively parse the chained effect
                    effects.extend(self._parse_pseudocode_effects(chained_effect_str, last_target=target))

        return effects

    def _parse_pseudocode_params(self, param_str: str) -> Dict[str, Any]:
        """Parse parameters in {KEY=VAL, ...} format."""
        if not param_str or "{" not in param_str:
            return {}

        params = {}
        if not param_str or param_str == "{}":
            return params

        # Remove outer braces
        content = param_str.strip()
        if content.startswith("{") and content.endswith("}"):
            content = content[1:-1]

        # Split by comma but respect quotes and brackets
        parts = []
        current = ""
        in_quotes = False
        depth = 0
        for char in content:
            if char == '"':
                in_quotes = not in_quotes
            elif char == "[":
                depth += 1
            elif char == "]":
                depth -= 1

            if char == "," and not in_quotes and depth == 0:
                parts.append(current.strip())
                current = ""
                continue
            current += char
        if current:
            parts.append(current.strip())

        for p in parts:
            # Handle special formats like COUNT_EQ_2 (without = sign)
            # Pattern: KEY_EQ_N or KEY_LE_N or KEY_GE_N etc.
            special_match = re.match(r"(COUNT_EQ|COUNT_LE|COUNT_GE|COUNT_LT|COUNT_GT)_(\d+)$", p.strip(), re.I)
            if special_match:
                key_part = special_match.group(1).upper()
                num_val = int(special_match.group(2))
                params[key_part] = num_val
                continue

            if "=" in p:
                k, v = p.split("=", 1)
                k = k.strip().upper()
                v = v.strip().strip('"').strip("'")

                # Handle list values like [1, 2, 3]
                if v.startswith("[") and v.endswith("]"):
                    items = [i.strip().strip('"').strip("'") for i in v[1:-1].split(",") if i.strip()]
                    # Convert to ints if numeric
                    v = [int(i) if i.isdigit() else i for i in items]
                # Handle numeric values
                elif v.isdigit():
                    v = int(v)
                elif v.upper() == "TRUE":
                    v = True
                elif v.upper() == "FALSE":
                    v = False

                # HEART_TYPE / HEART_0x mapping
                if k == "HEART_TYPE" or k == "HEART":
                    if isinstance(v, str) and v.startswith("HEART_0"):
                        # Map HEART_00..05 to 0..5
                        try:
                            v = int(v[7:])
                        except ValueError:
                            pass

                # Special color mapping for FILTER strings
                if k == "FILTER" and isinstance(v, str):
                    h_map = {
                        "HEART_00": "COLOR_PINK",
                        "HEART_01": "COLOR_RED",
                        "HEART_02": "COLOR_YELLOW",
                        "HEART_03": "COLOR_GREEN",
                        "HEART_04": "COLOR_BLUE",
                        "HEART_05": "COLOR_PURPLE",
                    }
                    for old, new in h_map.items():
                        v = v.replace(old, new)

                params[k] = v
            else:
                # Single word like "UNIQUE_NAMES" or "ALL_AREAS"
                k = p.strip().upper()
                if k:
                    params[k] = True
        return params

    def _parse_pseudocode_costs(self, text: str) -> List[Cost]:
        return parse_pseudocode_costs(self, text)

    def _serialize_condition_clause(self, cond: Condition) -> Dict[str, Any]:
        return {
            "type": int(cond.type),
            "value": int(cond.value),
            "attr": int(cond.attr),
            "is_negated": bool(cond.is_negated),
            "params": dict(cond.params),
        }

    def _parse_pseudocode_conditions(self, text: str) -> List[Condition]:
        return parse_pseudocode_conditions(self, text)

    def _parse_pseudocode_effects(self, text: str, last_target: TargetType = TargetType.PLAYER, full_text: str = "") -> List[Effect]:
        effects = []
        # Use the shared split method
        parts = StructuralLexer.split_respecting_nesting(text, delimiter=";")

        for p in parts:
            if not p:
                continue

            # Special handling for GRANT_ABILITY(TARGET, "ABILITY")
            if "GRANT_ABILITY" in p:
                grant_effects = parse_grant_ability_effect(p)
                if grant_effects:
                    effects.extend(grant_effects)
                    continue

            # Special handling for CONDITION: inner instruction
            if p.upper().startswith("CONDITION:"):
                # Recursive call to condition parser
                cond_str = p[10:].strip()
                # These will be filtered out by the caller (parse method)
                effects.extend(self._parse_pseudocode_conditions(cond_str))
                continue

            p = p.strip()
            # More robust regex that handles underscores, varies spacing, and mid-string (Optional)
            # Format: NAME(VAL) (Optional)? {PARAMS}? -> TARGET? REST
            m = re.match(r"^([\w_]+)(?:\((.*?)\))?\s*(?:\(Optional\)\s*)?(?:(\{.*?\})\s*)?(?:->\s*([\w, _]+))?(.*)$", p)
            if m:
                name, val, param_block, target_name, rest = m.groups()
                rest = rest or ""

                # If we matched (Optional) via the explicit group, we should ensure is_optional is set later.
                # The current logic checks for "(Optional)" in rest or p, which is sufficient.

                # Extract params only from the isolated {...} block if found
                params = self._parse_pseudocode_params(param_block) if param_block else {}
                
                # FALLBACK: If params block was at the end (after target), it will be in 'rest'
                if not params and rest and "{" in rest:
                    import re as re_mod
                    m_param = re_mod.search(r"(\{.*?\})", rest)
                    if m_param:
                        params = self._parse_pseudocode_params(m_param.group(1))

                # Preserve chained destinations like:
                # SELECT_HAND(...) -> PLAY_STAGE_EMPTY -> TARGET_PLAYED
                chain_destinations = []
                if target_name:
                    chain_destinations.append(target_name.strip().upper())
                if rest:
                    chain_destinations.extend(m.group(1).strip().upper() for m in re.finditer(r"->\s*([\w_]+)", rest))
                if chain_destinations:
                    params["chain_destinations"] = chain_destinations
                    params.setdefault("destination", chain_destinations[0].lower())
                    if len(chain_destinations) > 1:
                        params["capture"] = chain_destinations[-1].lower()

                # Target Resolution
                target, is_chained = resolve_target_type(target_name, last_target, params)

                # Legacy SELF mapping (If -> SELF exists in text)
                if "-> SELF" in p or "-> self" in p:
                    target = TargetType.MEMBER_SELF

                # Apply effect aliases using module-level constants
                name_up = name.upper()

                # Normalize aliases in one helper so the parser only owns structure.
                name_up, params, target = resolve_effect_aliases(name_up, params, target)

                # Special cases that need dynamic handling
                if name_up == "ADD_TAG":
                    name_up = "META_RULE"
                    params["tag"] = val

                if name_up == "SELECT_HAND":
                    name_up, target = normalize_select_hand_effect(name_up, params, target)

                if name_up.startswith("PLAY_MEMBER"):
                    name_up = resolve_play_member_source(name_up, params, p, full_text)

                etype = getattr(EffectType, name_up, None)

                # Special handling for SELECT_MODE labels in inline params
                if etype == EffectType.SELECT_MODE:
                    option_names = []
                    for j in range(1, 11):
                        key = f"OPTION_{j}"
                        if val_from_params := (params.get(key) or params.get(key.lower())):
                            option_names.append(str(val_from_params))
                    if option_names:
                        params["options"] = option_names

                if target_name and not is_chained:
                    target_name_up = target_name.upper()
                    if "CARD_HAND" in target_name_up:
                        target = TargetType.CARD_HAND
                    elif "CARD_DISCARD" in target_name_up:
                        target = TargetType.CARD_DISCARD
                    elif target_name_up in {"ALL_PLAYERS", "PLAYER_AND_OPPONENT"}:
                        target = TargetType.ALL_PLAYERS
                    else:
                        t_part = target_name.split(",")[0].strip()
                        target = getattr(TargetType, t_part.upper(), last_target)

                    if "DISCARD_REMAINDER" in target_name_up:
                        params["destination"] = "discard"

                    # Variable targeting support: if target is "TARGET" or "TARGET_MEMBER" or "SLOT", use last_target
                    if target_name_up in ["TARGET", "TARGET_MEMBER", "SLOT"]:
                        target = last_target
                    elif target_name_up == "ACTIVATE_AND_SELF":
                        # Special case for "activate and self" -> targets player but implied multi-target
                        # For now default to player or member self
                        target = TargetType.PLAYER
                elif not is_chained:
                    target = TargetType.PLAYER

                if name.upper() == "LOOK_AND_CHOOSE_REVEAL" and "DISCARD_REMAINDER" in p.upper():
                    params["destination"] = "discard"

                if etype is None:
                    # Fallback to condition parser if name is a known condition alias
                    if looks_like_condition_instruction(p):
                        # Recursive call to condition parser for this single instruction
                        effects.extend(self._parse_pseudocode_conditions(p))
                        continue
                    
                    # Safe fallback: unknown instructions become NOP (NONE) instead of broken META_RULE
                    etype = EffectType.NONE
                    params["raw_effect"] = name.upper()

                if target_name and target_name.upper() == "SLOT" and params.get("self"):
                    target = TargetType.MEMBER_SELF
                is_opt = "(Optional)" in rest or "(Optional)" in p

                val_int = 0
                val_cond = ConditionType.NONE

                # SPECIAL HANDLING FOR REVEAL_UNTIL WITH COMMA-SEPARATED FILTERS
                # Must happen BEFORE comma parsing to properly extract TYPE and COST conditions
                if etype == EffectType.REVEAL_UNTIL and val and "," in val:
                    # Process: REVEAL_UNTIL(TYPE_LIVE, COST_GE_10) or REVEAL_UNTIL(TYPE_MEMBER, COST_GE_10)
                    parts = [p.strip() for p in val.split(",")]
                    for part in parts:
                        if "TYPE_LIVE" in part:
                            val_cond = ConditionType.TYPE_CHECK
                            params["card_type"] = "live"
                        elif "TYPE_MEMBER" in part:
                            val_cond = ConditionType.TYPE_CHECK
                            params["card_type"] = "member"
                        elif part.startswith("COST_"):
                            # Extract COST_GE=10 or COST_GE_10, COST_LE=X, etc.
                            cost_match = re.search(r"COST_(GE|LE|GT|LT|EQ)([=_])(\d+)", part)
                            if cost_match:
                                comp, sep, cval = cost_match.groups()
                                val_cond = ConditionType.COST_CHECK
                                params["comparison"] = comp
                                params["value"] = int(cval)
                        else:
                            # Unknown part, store as raw
                            if part and not part.startswith("COST_"):
                                params[part.lower()] = True
                # Check for comma-separated positional params in val (e.g. META_RULE(SCORE_RULE, ALL_ENERGY_ACTIVE))
                elif val and "," in val:
                    v_parts = [vp.strip() for vp in val.split(",")]
                    for vp in v_parts:
                        vp_up = vp.upper()
                        if vp_up == "SCORE_RULE":
                            params["type"] = "SCORE_RULE"
                        elif vp_up == "ALL_ENERGY_ACTIVE":
                            params["rule"] = "ALL_ENERGY_ACTIVE"
                            val_int = 1  # v=1 for SCORE_RULE: ALL_ENERGY_ACTIVE
                        else:
                            if "=" in vp:
                                k, v = vp.split("=", 1)
                                params[k.strip().lower()] = v.strip().strip("\"'")
                            else:
                                try:
                                    if val_int == 0:
                                        val_int = int(vp)
                                except (TypeError, ValueError):
                                    pass
                # Check if val is a condition type (e.g. COUNT_STAGE)
                elif val and hasattr(ConditionType, val):
                    val_cond = getattr(ConditionType, val)
                elif etype == EffectType.REVEAL_UNTIL and val:
                    # Special parsing for REVEAL_UNTIL(CONDITION) - single condition
                    if "TYPE_LIVE" in val:
                        val_cond = ConditionType.TYPE_CHECK
                        params["card_type"] = "live"
                    elif "TYPE_MEMBER" in val:
                        val_cond = ConditionType.TYPE_CHECK
                        params["card_type"] = "member"

                    # Handle COST_GE/LE in REVEAL_UNTIL (supports both = and _ separators)
                    if "COST_" in val:
                        # Extract COST_GE=10 or COST_GE_10, COST_LE=X, etc.
                        cost_match = re.search(r"COST_(GE|LE|GT|LT|EQ)([=_])(\d+)", val)
                        if cost_match:
                            comp, sep, cval = cost_match.groups()
                            # If we also have TYPE check, we need to combine them?
                            # Bytecode only supports one condition on REVEAL_UNTIL.
                            # We'll prioritize COST check if present, or maybe the engine supports compound?
                            # For now, map to COST_CHECK condition.
                            val_cond = ConditionType.COST_CHECK
                            params["comparison"] = comp
                            params["value"] = int(cval)

                    if "COST_GE" in val and val_cond == ConditionType.NONE:
                        val_cond = ConditionType.COST_CHECK
                        m_cost = re.search(r"COST_GE([=_])(\d+)", val)
                        if m_cost:
                            params["min"] = int(m_cost.group(2))

                    if val_cond == ConditionType.NONE:
                        try:
                            val_int = int(val)
                        except ValueError:
                            val_int = 1
                            if val:
                                params["raw_val"] = val
                else:
                    # Handle comma-separated values inside parentheses, e.g., NAME(VAL, OPT="X")
                    if val and "," in val:
                        inner_parts = val.split(",")
                        val = inner_parts[0].strip()
                        for inner_p in inner_parts[1:]:
                            inner_p = inner_p.strip()
                            if "=" in inner_p:
                                ik, iv = inner_p.split("=", 1)
                                ik = ik.strip().lower()
                                iv = iv.strip().strip('"').strip("'")
                                params[ik] = iv
                            else:
                                params[inner_p.lower()] = True

                    try:
                        val_int = int(val) if val else 1
                    except ValueError:
                        val_int = 1  # Fallback for non-numeric val (e.g. "ALL")
                        if val:
                            params["raw_val"] = val
                        if val == "ALL":
                            val_int = MAX_SELECT_ALL
                        elif val == "OPPONENT":
                            target = TargetType.OPPONENT
                            target_name = "OPPONENT"
                        elif val == "PLAYER":
                            target = TargetType.PLAYER
                            target_name = "PLAYER"
                if etype == EffectType.ENERGY_CHARGE:
                    if params.get("wait") or params.get("mode") == "WAIT":
                        params["wait"] = True

                # Special parsing for TRANSFORM_COLOR(ALL) -> X
                if etype == EffectType.TRANSFORM_COLOR:
                    # If val was "ALL", int(val) would have yielded 1 or 99.
                    # We want 'a' (source) to be 0 (all) and 'v' (destination) to be the target number.
                    if val == "ALL":
                        # Determine destination from target_name (e.g. -> 5)
                        try:
                            # The pseudocode usually uses 1-indexed colors (1=Pink, 5=Blue).
                            # The engine uses 0-indexed (0=Pink, 4=Blue).
                            val_int = int(target_name) - 1
                        except (ValueError, TypeError):
                            val_int = 0  # Default to pink if unknown
                        # Source (a) is encoded in attr in ability.py, so we just set it here
                        params["source_color"] = 0
                        target = TargetType.PLAYER  # Reset target so it doesn't try to target member '5'
                    elif val and val.isdigit():
                        # Standard TRANSFORM_COLOR(src) -> dst
                        try:
                            source_color = int(val)
                            dest_color = int(target_name) - 1 if target_name and target_name.isdigit() else 0
                            val_int = max(0, dest_color)
                            params["source_color"] = source_color
                            target = TargetType.PLAYER
                        except (TypeError, ValueError):
                            pass

                # Special parsing for TRANSFORM_BLADES(ALL) -> X
                if etype == EffectType.TRANSFORM_BLADES:
                    # If val was "ALL", we want to extract the destination from target_name
                    if val == "ALL" and target_name:
                        try:
                            val_int = int(target_name)
                            # Clear destination since we've extracted the value
                            if "destination" in params and params["destination"] == target_name:
                                del params["destination"]
                            target = TargetType.MEMBER_SELF  # targeting the selected member
                        except (ValueError, TypeError):
                            val_int = 99  # Fallback to ALL if not a number
                    elif val and val.isdigit():
                        # Direct value: TRANSFORM_BLADES(3) -> TARGET
                        val_int = int(val)

                if etype == EffectType.LOOK_AND_CHOOSE and "choose_count" not in params:
                    params["choose_count"] = 1

                # Special handling for SET_HEART_COST - parse array format [2,2,3,3,6,6]
                if etype == EffectType.SET_HEART_COST and val:
                    # Check if val is an array format like [2,2,3,3,6,6]
                    if val.startswith("[") and val.endswith("]"):
                        # Parse the array and convert to raw_val format
                        params["raw_val"] = val
                        val_int = 0
                    else:
                        # Try to parse as heart cost string like "2xYELLOW,2xGREEN,2xPURPLE"
                        # Strip quotes from the value
                        clean_val = val.strip('"').strip("'")
                        params["raw_val"] = clean_val

                effects.append(Effect(etype, val_int, val_cond, target, params, is_optional=is_opt))
                last_target = target

                # --- SELECT_MODE: Convert inline OPTION_N params to modal_options ---
                if etype == EffectType.SELECT_MODE:
                    option_keys = sorted(
                        [k for k in params if re.match(r"OPTION_\d+", str(k), re.I)],
                        key=lambda k: int(re.search(r"\d+", k).group()),
                    )
                    if option_keys:
                        modal_opts = []
                        for ok in option_keys:
                            opt_text = str(params[ok])
                            # Parse each option value as an effect string
                            sub_effects = self._parse_pseudocode_effects(opt_text)
                            modal_opts.append(sub_effects)
                        effects[-1].modal_options = modal_opts
                        # Clean up OPTION_N keys from params
                        for ok in option_keys:
                            del params[ok]

                if is_chained:
                    chained_str = f"{target_name}{rest}"
                    effects.extend(self._parse_pseudocode_effects(chained_str, last_target=target))
        return effects


# Convenience function
def parse_ability_text(text: str) -> List[Ability]:
    """Parse ability text using the V2 parser."""
    parser = AbilityParserV2()
    return parser.parse(text)