trioskosmos commited on
Commit
bb3fbf9
·
verified ·
1 Parent(s): b872b7e

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +6 -0
  2. engine/__init__.py +0 -0
  3. engine/__pycache__/__init__.cpython-312.pyc +0 -0
  4. engine/data/cards.json +0 -0
  5. engine/data/cards_compiled.json +0 -0
  6. engine/game/ACTIONS.md +48 -0
  7. engine/game/__init__.py +1 -0
  8. engine/game/__pycache__/__init__.cpython-312.pyc +0 -0
  9. engine/game/__pycache__/data_loader.cpython-312.pyc +0 -0
  10. engine/game/__pycache__/deck_utils.cpython-312.pyc +0 -0
  11. engine/game/__pycache__/desc_utils.cpython-312.pyc +0 -0
  12. engine/game/__pycache__/enums.cpython-312.pyc +0 -0
  13. engine/game/__pycache__/fast_logic.cpython-312.pyc +0 -0
  14. engine/game/__pycache__/game_state.cpython-312.pyc +0 -0
  15. engine/game/__pycache__/numba_utils.cpython-312.pyc +0 -0
  16. engine/game/__pycache__/player_state.cpython-312.pyc +0 -0
  17. engine/game/__pycache__/replay_manager.cpython-312.pyc +0 -0
  18. engine/game/__pycache__/serializer.cpython-312.pyc +0 -0
  19. engine/game/__pycache__/state_utils.cpython-312.pyc +0 -0
  20. engine/game/ai_compat.py +54 -0
  21. engine/game/data_loader.py +71 -0
  22. engine/game/deck_utils.py +95 -0
  23. engine/game/desc_utils.py +466 -0
  24. engine/game/enums.py +27 -0
  25. engine/game/fast_logic.py +2209 -0
  26. engine/game/fast_logic_backup.py +632 -0
  27. engine/game/game_state.py +3027 -0
  28. engine/game/mixins/__pycache__/action_mixin.cpython-312.pyc +0 -0
  29. engine/game/mixins/__pycache__/effect_mixin.cpython-312.pyc +3 -0
  30. engine/game/mixins/__pycache__/phase_mixin.cpython-312.pyc +0 -0
  31. engine/game/mixins/action_mixin.py +407 -0
  32. engine/game/mixins/effect_mixin.py +0 -0
  33. engine/game/mixins/phase_mixin.py +654 -0
  34. engine/game/numba_utils.py +71 -0
  35. engine/game/player_state.py +827 -0
  36. engine/game/replay_manager.py +302 -0
  37. engine/game/serializer.py +689 -0
  38. engine/game/state_utils.py +122 -0
  39. engine/lovecasim_engine.pyd +3 -0
  40. engine/models/__pycache__/ability.cpython-312.pyc +0 -0
  41. engine/models/__pycache__/card.cpython-312.pyc +0 -0
  42. engine/models/__pycache__/context_indices.cpython-312.pyc +0 -0
  43. engine/models/__pycache__/enums.cpython-312.pyc +0 -0
  44. engine/models/__pycache__/opcodes.cpython-312.pyc +0 -0
  45. engine/models/ability.py +1128 -0
  46. engine/models/card.py +104 -0
  47. engine/models/choice_types.py +21 -0
  48. engine/models/context_indices.py +51 -0
  49. engine/models/enums.py +161 -0
  50. engine/models/opcodes.py +129 -0
.gitattributes CHANGED
@@ -33,3 +33,9 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ engine/game/mixins/__pycache__/effect_mixin.cpython-312.pyc filter=lfs diff=lfs merge=lfs -text
37
+ engine/lovecasim_engine.pyd filter=lfs diff=lfs merge=lfs -text
38
+ engine/tests/cards/batches/__pycache__/test_auto_generated_strict_comprehensive.cpython-312-pytest-9.0.2.pyc filter=lfs diff=lfs merge=lfs -text
39
+ engine/tests/cards/batches/__pycache__/test_easy_wins_batch_1.cpython-312-pytest-9.0.2.pyc filter=lfs diff=lfs merge=lfs -text
40
+ engine/tests/cards/batches/__pycache__/test_easy_wins_batch_2.cpython-312-pytest-9.0.2.pyc filter=lfs diff=lfs merge=lfs -text
41
+ engine/tests/scenarios/__pycache__/test_all_qas.cpython-312-pytest-9.0.2.pyc filter=lfs diff=lfs merge=lfs -text
engine/__init__.py ADDED
File without changes
engine/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (169 Bytes). View file
 
engine/data/cards.json ADDED
The diff for this file is too large to render. See raw diff
 
engine/data/cards_compiled.json ADDED
The diff for this file is too large to render. See raw diff
 
engine/game/ACTIONS.md ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Love Live TCG - Action Space Mapping
2
+
3
+ This document defines the 2,000-dimensional action space used by the game engine. Each ID corresponds to a specific atomic action in the game.
4
+
5
+ ## Main Map (Sorted by ID)
6
+
7
+ | ID Range | Category | Description | Parameters |
8
+ | :--- | :--- | :--- | :--- |
9
+ | **0** | **System** | Pass / Confirm / Skip / Cancel | N/A |
10
+ | **1 - 180** | **Play Member** | Play a member card from hand to stage | `(Hand Index 0-59) * 3 + (Slot Index 0-2)` |
11
+ | **200 - 202** | **Ability** | Activate a member's 【起動】 ability | `(Slot Index 0-2)` |
12
+ | **300 - 359** | **Mulligan** | Toggle selection of a card in hand for mulligan | `(Hand Index 0-59)` |
13
+ | **400 - 459** | **Live Set** | Set a card from hand face-down in the live zone | `(Hand Index 0-59)` |
14
+ | **500 - 559** | **Select Hand** | Select a card in hand for an effect (e.g. discard) | `(Hand Index 0-59)` |
15
+ | **560 - 562** | **Select Stage** | Select a member or slot on your side of the stage | `(Slot Index 0-2)` |
16
+ | **570 - 579** | **Modal** | Select option N from a modal choice effect | `(Option Index 0-9)` |
17
+ | **580 - 585** | **Color Select** | Select a member color (Pk, Rd, Bl, Gn, Yl, Pr) | `0=Pk, 1=Rd, 2=Bl, 3=Gn, 4=Yl, 5=Pr` |
18
+ | **590 - 599** | **Triggers** | Choose which pending trigger to resolve next | `(Trigger Index 0-9)` |
19
+ | **600 - 659** | **Generic List** | Select from a dynamic list (Look Deck, Deck) | `(List Index 0-59)` |
20
+ | **660 - 719** | **Discard List** | Select a card from the discard pile (Recovery) | `(List Index 0-59)` |
21
+ | **720 - 759** | **Formation** | Formation/Order items | `(Index 0-39)` |
22
+ | **760 - 819** | **Success Lives**| Select from the Success Live (Score) pile | `(List Index 0-59)` |
23
+ | **820 - 822** | **Live Zone** | Select a specific slot in the Live Zone | `(Slot Index 0-2)` |
24
+ | **830 - 849** | **Energy Zone** | Select a specific card in the Energy Zone | `(Index 0-19)` |
25
+ | **850 - 909** | **Removed/Opp** | Select from Removed cards or Opponent's Hand | `(Index 0-59)` |
26
+ | **910 - 912** | **Perform** | Select which Live card to attempt performance | `(Live Zone Index 0-2)` |
27
+ | **1000 - 1999**| **Reserved** | Reserved for future expansion | N/A |
28
+
29
+ ## Choice Type Mapping
30
+
31
+ The engine uses `pending_choices` to contextually interpret these IDs.
32
+
33
+ | Choice Type | ID Range Used | Interpretation |
34
+ | :--- | :--- | :--- |
35
+ | `TARGET_HAND` | 500 - 559 | Hand Index |
36
+ | `TARGET_MEMBER` | 560 - 562 | Stage Slot Index |
37
+ | `DISCARD_SELECT` | 500 - 559 | Hand Index |
38
+ | `SELECT_FROM_LIST` | 600 - 659 | Index in provided `cards` list |
39
+ | `SELECT_FROM_DISCARD`| 660 - 719 | Index in discard selection list |
40
+ | `TARGET_OPPONENT_MEMBER`| 600 - 602 | Opponent's Slot Index |
41
+ | `COLOR_SELECT` | 580 - 585 | Color index |
42
+ | `MODAL` | 570 - 579 | Option index |
43
+
44
+ ## Design Philosophy
45
+
46
+ 1. **Sparsity**: ranges are separated to allow the UI to easily map IDs to visual components without overlap.
47
+ 2. **Compatibility**: The fixed size (2,000) is designed for MCTS and RL models to have a consistent output head.
48
+ 3. **Extensibility**: Over 50% of the space is reserved for new card types or mechanics.
engine/game/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Love Live Card Game - AlphaZero Engine
engine/game/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (174 Bytes). View file
 
engine/game/__pycache__/data_loader.cpython-312.pyc ADDED
Binary file (3.02 kB). View file
 
engine/game/__pycache__/deck_utils.cpython-312.pyc ADDED
Binary file (3.78 kB). View file
 
engine/game/__pycache__/desc_utils.cpython-312.pyc ADDED
Binary file (16.6 kB). View file
 
engine/game/__pycache__/enums.cpython-312.pyc ADDED
Binary file (1.07 kB). View file
 
engine/game/__pycache__/fast_logic.cpython-312.pyc ADDED
Binary file (53.5 kB). View file
 
engine/game/__pycache__/game_state.cpython-312.pyc ADDED
Binary file (63.1 kB). View file
 
engine/game/__pycache__/numba_utils.cpython-312.pyc ADDED
Binary file (1.93 kB). View file
 
engine/game/__pycache__/player_state.cpython-312.pyc ADDED
Binary file (39.1 kB). View file
 
engine/game/__pycache__/replay_manager.cpython-312.pyc ADDED
Binary file (10.5 kB). View file
 
engine/game/__pycache__/serializer.cpython-312.pyc ADDED
Binary file (27.3 kB). View file
 
engine/game/__pycache__/state_utils.cpython-312.pyc ADDED
Binary file (6.01 kB). View file
 
engine/game/ai_compat.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Compatibility Layer
3
+ Handles optional dependencies like Numba and Torch gracefully.
4
+ """
5
+
6
+ import importlib
7
+ import sys
8
+
9
+
10
+ def check_available(module_name: str) -> bool:
11
+ """Check if a module is available without importing it fully."""
12
+ try:
13
+ if module_name in sys.modules:
14
+ return True
15
+ return importlib.util.find_spec(module_name) is not None
16
+ except (ImportError, AttributeError):
17
+ return False
18
+
19
+
20
+ # 1. Numba Availability
21
+ JIT_AVAILABLE = check_available("numba")
22
+
23
+ if JIT_AVAILABLE:
24
+ from numba import njit, prange
25
+ else:
26
+ # No-op decorator fallback
27
+ def njit(*args, **kwargs):
28
+ if len(args) == 1 and len(kwargs) == 0 and callable(args[0]):
29
+ return args[0] # Used as @njit
30
+
31
+ def decorator(func):
32
+ return func # Used as @njit(cache=True)
33
+
34
+ return decorator
35
+
36
+ prange = range
37
+
38
+ # 2. Torch Availability
39
+ TORCH_AVAILABLE = check_available("torch")
40
+
41
+ # 3. Execution Flags
42
+ # If this is True, the engine will attempt to use JIT/Batching
43
+ GLOBAL_AI_ENABLED = JIT_AVAILABLE
44
+
45
+
46
+ def report_ai_status():
47
+ """Print current AI acceleration status."""
48
+ status = "ENABLED" if GLOBAL_AI_ENABLED else "DISABLED (Legacy Mode)"
49
+ print(f"--- AI Acceleration: {status} ---")
50
+ if not JIT_AVAILABLE:
51
+ print(" [Note] Numba not found. Install with 'pip install numba' for 200x speedup.")
52
+ if not TORCH_AVAILABLE:
53
+ print(" [Note] Torch not found. AI training will be unavailable.")
54
+ print("---------------------------------")
engine/game/data_loader.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from typing import Any, Dict, Tuple
4
+
5
+ from pydantic import TypeAdapter
6
+
7
+ from engine.models.card import EnergyCard, LiveCard, MemberCard
8
+
9
+
10
+ class CardDataLoader:
11
+ def __init__(self, json_path: str):
12
+ self.json_path = json_path
13
+
14
+ def load(self) -> Tuple[Dict[int, MemberCard], Dict[int, LiveCard], Dict[int, Any]]:
15
+ # Auto-detect compiled file
16
+ target_path = self.json_path
17
+ if target_path.endswith("cards.json"):
18
+ # Check for compiled file in the same directory, or in data/
19
+ compiled_path = target_path.replace("cards.json", "cards_compiled.json")
20
+ if os.path.exists(compiled_path):
21
+ target_path = compiled_path
22
+ else:
23
+ root_path = os.path.join(os.getcwd(), "data", "cards_compiled.json")
24
+ if os.path.exists(root_path):
25
+ target_path = root_path
26
+
27
+ # Fallback to relative path search if absolute fails (common in tests)
28
+ if not os.path.exists(target_path):
29
+ # Try assuming path is relative to project root
30
+ # But we don't know project root easily.
31
+ pass
32
+
33
+ # print(f"Loading card data from {target_path}...")
34
+ with open(target_path, "r", encoding="utf-8") as f:
35
+ data = json.load(f)
36
+
37
+ members = {}
38
+ lives = {}
39
+ energy = {}
40
+
41
+ if "member_db" in data:
42
+ # Compiled format (v1.0)
43
+ m_adapter = TypeAdapter(MemberCard)
44
+ l_adapter = TypeAdapter(LiveCard)
45
+ e_adapter = TypeAdapter(EnergyCard)
46
+
47
+ for k, v in data["member_db"].items():
48
+ members[int(k)] = m_adapter.validate_python(v)
49
+
50
+ for k, v in data["live_db"].items():
51
+ # print(f"Loading live {k}")
52
+ lives[int(k)] = l_adapter.validate_python(v)
53
+ # print(f"DEBUG: Internal live_db keys: {len(data['live_db'])}, loaded: {len(lives)}")
54
+
55
+ for k, v in data["energy_db"].items():
56
+ energy[int(k)] = e_adapter.validate_python(v)
57
+
58
+ # --- HOTFIXES REMOVED (Parser is now accurate) ---
59
+ for l in lives.values():
60
+ pass
61
+
62
+ else:
63
+ # Legacy raw format
64
+ # Since we removed runtime parsing from the engine to separate concerns,
65
+ # we cannot load raw cards anymore.
66
+ raise RuntimeError(
67
+ "Legacy cards.json format detected. Runtime parsing is disabled. "
68
+ "Please run 'uv run compiler/main.py' to generate 'data/cards_compiled.json'."
69
+ )
70
+
71
+ return members, lives, energy
engine/game/deck_utils.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ from collections import Counter
4
+
5
+
6
+ def extract_deck_data(content: str, card_db: dict):
7
+ """
8
+ Parses deck content (HTML or various text formats) to extract card IDs and quantities.
9
+ Returns (main_deck, energy_deck, type_counts, errors)
10
+ """
11
+ # 1. Try HTML Structure (Deck Log)
12
+ # pattern matches: title="PL!xxx-yyy-zzz : NAME" ... <span class="num">N</span>
13
+ pattern_html = r'title="([^"]+?) :[^"]*"[^>]*>.*?class="num">(\d+)</span>'
14
+ matches = re.findall(pattern_html, content, re.DOTALL)
15
+
16
+ if not matches:
17
+ # Fallback 1: Text format "QTY x ID" (e.g., "4 x LL-bp3-001-R+")
18
+ text_pattern_1 = r"(\d+)\s*[xX]\s*([A-Za-z0-9!+\-+]+)"
19
+ matches_1 = re.findall(text_pattern_1, content)
20
+ if matches_1:
21
+ # Swap to (ID, Qty) format
22
+ matches = [(m[1], m[0]) for m in matches_1]
23
+ else:
24
+ # Fallback 2: Text format "ID x QTY" (e.g., "PL!S-bp2-022-L x 2")
25
+ text_pattern_2 = r"([A-Za-z0-9!+\-+]+)\s*[xX]\s*(\d+)"
26
+ matches_2 = re.findall(text_pattern_2, content)
27
+ if matches_2:
28
+ matches = matches_2
29
+ else:
30
+ # Fallback 3: Simple list of IDs (one per line)
31
+ # Matches strings like "PL!S-bp1-001-M" but avoids common words
32
+ # This is risky but useful for simple text files.
33
+ # Let's use a more specific regex for ID patterns.
34
+ id_pattern = r"([PL!|LL\-E][A-Za-z0-9!+\-+]+-[A-Za-z0-9!+\-+]+-[A-Za-z0-9!+\-+]+[A-Za-z0-9!+\-+]*)"
35
+ matches_3 = re.findall(id_pattern, content)
36
+ if matches_3:
37
+ # Count occurrences
38
+ counts = Counter(matches_3)
39
+ matches = [(cid, str(cnt)) for cid, cnt in counts.items()]
40
+
41
+ if not matches:
42
+ return [], [], {}, ["No recognizable card data found in content."]
43
+
44
+ main_deck = []
45
+ energy_deck = []
46
+ type_counts = {"Member": 0, "Live": 0, "Energy": 0, "Unknown": 0}
47
+ errors = []
48
+
49
+ for card_id, qty_str in matches:
50
+ try:
51
+ qty = int(qty_str)
52
+ except ValueError:
53
+ continue
54
+
55
+ card_id = card_id.strip()
56
+
57
+ # Determine Type from database
58
+ cdata = card_db.get(card_id, {})
59
+ ctype = cdata.get("type", "")
60
+
61
+ if "メンバー" in ctype or "Member" in ctype:
62
+ type_counts["Member"] += qty
63
+ elif "ライブ" in ctype or "Live" in ctype:
64
+ type_counts["Live"] += qty
65
+ elif "エネルギー" in ctype or "Energy" in ctype:
66
+ type_counts["Energy"] += qty
67
+ else:
68
+ type_counts["Unknown"] += qty
69
+
70
+ for _ in range(qty):
71
+ if "エネルギー" in ctype or "Energy" in ctype or card_id.startswith("LL-E"):
72
+ energy_deck.append(card_id)
73
+ else:
74
+ main_deck.append(card_id)
75
+
76
+ # Basic Validation
77
+ all_counts = Counter(main_deck + energy_deck)
78
+ for cid, count in all_counts.items():
79
+ if count > 4 and not cid.startswith("LL-E"):
80
+ # Some formats might have duplicates if listing line-by-line + QTY.
81
+ # We don't block here, just warn.
82
+ errors.append(f"Card limit exceeded: {cid} x{count} (Max 4)")
83
+
84
+ return main_deck, energy_deck, type_counts, errors
85
+
86
+
87
+ def load_deck_from_file(file_path: str, card_db: dict):
88
+ """Helper to read a file and parse it."""
89
+ if not os.path.exists(file_path):
90
+ return None, None, {}, [f"File {file_path} not found."]
91
+
92
+ with open(file_path, "r", encoding="utf-8") as f:
93
+ content = f.read()
94
+
95
+ return extract_deck_data(content, card_db)
engine/game/desc_utils.py ADDED
@@ -0,0 +1,466 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from engine.game.enums import Phase
2
+ from engine.game.state_utils import get_base_id
3
+
4
+
5
+ def get_action_desc(a, gs):
6
+ """
7
+ Generate clear, informative action descriptions.
8
+ Shows card names, costs, and ability sources for better user understanding.
9
+ """
10
+ if gs is None:
11
+ return f"Action {a}"
12
+
13
+ # Handle both Python and Rust engine (PyGameState)
14
+ if hasattr(gs, "get_player"):
15
+ p_idx = gs.current_player
16
+ p = gs.get_player(p_idx)
17
+ else:
18
+ p = gs.active_player
19
+ p_idx = gs.current_player
20
+
21
+ member_db = gs.member_db
22
+ live_db = gs.live_db
23
+
24
+ # Helper to get from DB, handling int/str keys
25
+ def get_from_db(db, key, default=None):
26
+ if not db:
27
+ return default
28
+ if hasattr(db, "get"):
29
+ res = db.get(key)
30
+ if res is not None:
31
+ return res
32
+ return db.get(str(key), default)
33
+ try:
34
+ if key in db:
35
+ return db[key]
36
+ if str(key) in db:
37
+ return db[str(key)]
38
+ except:
39
+ pass
40
+ return default
41
+
42
+ # Helper to get card name
43
+ def get_card_name(cid, gs_override=None):
44
+ _gs = gs_override or gs
45
+ if cid < 0:
46
+ return "なし"
47
+
48
+ base_id = get_base_id(int(cid))
49
+
50
+ # Try all DBs with the helper
51
+ m = get_from_db(member_db, base_id)
52
+ if m:
53
+ name = get_v(m, "name", f"メンバー #{base_id}")
54
+ card_no = get_v(m, "card_no", "??")
55
+ return f"{name} ({card_no})"
56
+
57
+ l = get_from_db(live_db, base_id)
58
+ if l:
59
+ name = get_v(l, "name", f"ライブ #{base_id}")
60
+ card_no = get_v(l, "card_no", "??")
61
+ return f"{name} ({card_no})"
62
+
63
+ e = get_from_db(getattr(_gs, "energy_db", None), base_id)
64
+ if e:
65
+ name = get_v(e, "name", f"エネルギー #{base_id}")
66
+ card_no = get_v(e, "card_no", "??")
67
+ return f"{name} ({card_no})"
68
+
69
+ return f"カード #{cid}"
70
+
71
+ # Helpers for general property access
72
+ def get_v(obj, key, default=None):
73
+ if obj is None:
74
+ return default
75
+ if isinstance(obj, dict):
76
+ return obj.get(key, default)
77
+ return getattr(obj, key, default)
78
+
79
+ # Helper for pending choices
80
+ def get_top_pending():
81
+ if not gs.pending_choices:
82
+ return None, {}
83
+ choice_type, params = gs.pending_choices[0]
84
+ if isinstance(params, str):
85
+ import json
86
+
87
+ try:
88
+ return choice_type, json.loads(params)
89
+ except:
90
+ return choice_type, {}
91
+ return choice_type, params
92
+
93
+ # --- ACTION HANDLERS (Order Matters: Specific to General) ---
94
+ # The order of these blocks determines priority. Specific contextual handlers
95
+ # (like Color selection or Discard/Recover) must come BEFORE broad ranges
96
+ # to avoid being shadowed by more generic messages.
97
+
98
+ # 1. SPECIAL & UNIVERSAL ACTIONS
99
+ # --------------------------------------------------------------------------
100
+
101
+ # Action 0: Pass / Confirm / Skip
102
+ # Used for ending phases (Main, LiveSet, Mulligan) or skipping optional effects.
103
+ if a == 0:
104
+ if int(gs.phase) == int(Phase.MAIN):
105
+ return "【終了】メインフェイズを終了する"
106
+ if int(gs.phase) == int(Phase.LIVE_SET):
107
+ return "【確認】ライブカードをセットして続行"
108
+ if int(gs.phase) == int(Phase.LIVE_RESULT):
109
+ return "【進む】次へ進む"
110
+ if int(gs.phase) in (int(Phase.MULLIGAN_P1), int(Phase.MULLIGAN_P2)):
111
+ return "【確認】マリガンを実行"
112
+ choice_type, params = get_top_pending()
113
+ if choice_type:
114
+ source_name = params.get("source_member", "アビリティ")
115
+ return f"【スキップ】{source_name}の効果を使用しない"
116
+ return "【パス】何もしない"
117
+
118
+ # 2. SPECIFIC INTERACTIVE SELECTIONS (Must precede broad ranges)
119
+ # --------------------------------------------------------------------------
120
+
121
+ # 580-585: Color Selection
122
+ # Triggered by O_COLOR_SELECT. Maps to Pink, Red, Yellow, Green, Blue, Purple.
123
+ if 580 <= a <= 585:
124
+ colors = ["赤", "青", "緑", "黄", "紫", "ピンク"]
125
+ return f"【色選択】 {colors[a - 580]}"
126
+
127
+ # 560-562: Stage Slot Selection (Targeting/Wait)
128
+ # Triggered when choosing a slot on the player's own stage for an effect.
129
+ if 560 <= a <= 562:
130
+ idx = a - 560
131
+ areas = ["左", "センター", "右"]
132
+ cid = p.stage[idx]
133
+ name = "空エリア"
134
+ base_id = get_base_id(int(cid))
135
+ if cid >= 0:
136
+ m = get_from_db(member_db, base_id)
137
+ if m:
138
+ name = get_v(m, "name", "メン��ー")
139
+
140
+ desc = "選択"
141
+ choice_type, params = get_top_pending()
142
+ if choice_type:
143
+ if choice_type == "MOVE_MEMBER":
144
+ desc = "移動元"
145
+ elif choice_type == "TAP_MEMBER":
146
+ desc = "ウェイト"
147
+ elif choice_type in ("PLAY_MEMBER_FROM_HAND", "PLAY_MEMBER_FROM_DISCARD"):
148
+ desc = "に置く"
149
+ return f"【ステージ選択】 {areas[idx]}: {name}を{desc}"
150
+
151
+ # 500-509: Hand Card Selection (Discard/Recover)
152
+ # Triggered when specifically selecting a card from hand as an effect cost or target.
153
+ # Note: Normal playing of cards (1-180) is handled separately.
154
+ if 500 <= a <= 509:
155
+ idx = a - 500
156
+ if idx < len(p.hand):
157
+ cid = p.hand[idx]
158
+ name = get_card_name(cid)
159
+ desc = "選択"
160
+ choice_type, params = get_top_pending()
161
+ if choice_type:
162
+ if choice_type == "RECOVER_MEMBER":
163
+ desc = "回収"
164
+ elif choice_type == "DISCARD":
165
+ desc = "捨てる"
166
+ return f"【手札選択】 {name}を{desc}"
167
+
168
+ # 570-579: Mode Selection (Branching Effects)
169
+ # Triggered by O_SELECT_MODE when a card offers multiple choices (e.g., Mode A/B).
170
+ if 570 <= a <= 579:
171
+ mode_label = f"モード {a - 570 + 1}"
172
+ choice_type, params = get_top_pending()
173
+ if choice_type:
174
+ options = params.get("options", [])
175
+ if a - 570 < len(options):
176
+ mode_label = options[a - 570]
177
+ return f"【モード選択】 {mode_label}"
178
+
179
+ # 590-599: Ability Trigger/Resolution Order
180
+ # Triggered when multiple abilities are pending (e.g., [OnPlay] triggers).
181
+ if 590 <= a <= 599:
182
+ idx = a - 590
183
+ if idx < len(gs.triggered_abilities):
184
+ t = gs.triggered_abilities[idx]
185
+ if len(t) >= 2:
186
+ cid = getattr(t[2], "card_id", -1) if len(t) > 2 else -1
187
+ src_name = get_card_name(cid) if cid >= 0 else "不明"
188
+ return f"【能力解決】 {src_name}の効果を発動 ({idx + 1}/{len(gs.triggered_abilities)})"
189
+ return f"【能力解決】 ({idx + 1}/{len(gs.triggered_abilities)})"
190
+
191
+ # 820-822: Live Zone Targeting
192
+ if 820 <= a <= 822:
193
+ idx = a - 820
194
+ areas = ["左", "センター", "右"]
195
+ cid = p.live_zone[idx] if idx < len(p.live_zone) else -1
196
+ name = "なし"
197
+ if cid >= 0:
198
+ name = get_card_name(cid)
199
+ return f"【ライブ選択】 {areas[idx]}: {name}"
200
+
201
+ # 900-902: Performance Execution
202
+ # Standard action to clear a Live card in the Performance phase.
203
+ if 900 <= a <= 902:
204
+ idx = a - 900
205
+ areas = ["左", "センター", "右"]
206
+ cid = p.live_zone[idx] if idx < len(p.live_zone) else -1
207
+ name = "なし"
208
+ summary = "パフォーマンス"
209
+ if cid >= 0:
210
+ name = get_card_name(cid)
211
+ base_id = get_base_id(cid)
212
+ live = get_from_db(live_db, base_id)
213
+ if live:
214
+ abilities = get_v(live, "abilities", [])
215
+ if abilities:
216
+ raw_text = get_v(abilities[0], "raw_text", "").strip()
217
+ if raw_text:
218
+ summary = raw_text.split("\n")[0][:40]
219
+ if len(raw_text) > 40:
220
+ summary += "..."
221
+ return f"【パフォーマンス】 {areas[idx]}: {name} ({summary})"
222
+
223
+ # 600-719: Broad Choice Range (General Targets/Opponent)
224
+ if 600 <= a <= 719:
225
+ idx = a - 600
226
+ choice_type, params = get_top_pending()
227
+ if choice_type == "ORDER_DECK":
228
+ cards = params.get("cards", [])
229
+ if idx < len(cards):
230
+ return f"【並べ替え】 {get_card_name(cards[idx])}を一番上へ"
231
+ return "【確定】 並び替えを終了"
232
+
233
+ if 0 <= idx <= 2:
234
+ if choice_type in ("SELECT_MEMBER", "TARGET_OPPONENT_MEMBER"):
235
+ areas = ["左", "センター", "右"]
236
+ opp = gs.get_player(1 - p_idx) if hasattr(gs, "get_player") else gs.inactive_player
237
+ cid = opp.stage[idx]
238
+ name = "空エリア"
239
+ if cid >= 0:
240
+ base_id = get_base_id(int(cid))
241
+ m = get_from_db(member_db, base_id)
242
+ if m:
243
+ name = get_v(m, "name", "メンバー")
244
+ return f"【ターゲット】 相手のステージ ({areas[idx]}: {name}) を選択"
245
+
246
+ if choice_type == "SELECT_FROM_LIST":
247
+ cards = params.get("cards", [])
248
+ if idx < len(cards):
249
+ return f"【リスト選択】 {get_card_name(cards[idx])}"
250
+ elif choice_type == "SELECT_MODE":
251
+ options = params.get("options", [])
252
+ if idx < len(options):
253
+ return f"【選択】 {options[idx]}"
254
+ elif choice_type == "SELECT_SUCCESS_LIVE":
255
+ cards = params.get("cards", [])
256
+ if idx < len(cards):
257
+ return f"【獲得選択】 {get_card_name(cards[idx])}"
258
+ elif choice_type == "SELECT_FROM_DISCARD":
259
+ cards = params.get("cards", [])
260
+ if idx < len(cards):
261
+ return f"【控え室回収】 {get_card_name(cards[idx])}"
262
+
263
+ return f"【選択】 項目 {idx}"
264
+
265
+ # 550-849: Complex Choice Resolution (Consolidated)
266
+ # Used for choices that require specific card/slot context (e.g., Order Deck, Color Choose).
267
+ if 550 <= a <= 849:
268
+ adj = a - 550
269
+ area_idx = adj // 100
270
+ ab_idx = (adj % 100) // 10
271
+ choice_idx = adj % 10
272
+
273
+ area_name = ["左", "中", "右"][area_idx] if area_idx < 3 else f"Slot {area_idx}"
274
+ cid = p.stage[area_idx] if area_idx < 3 else -1
275
+ card_name = "メンバー"
276
+ if cid >= 0:
277
+ base_id = get_base_id(cid)
278
+ m = get_from_db(member_db, base_id)
279
+ if m:
280
+ card_name = get_v(m, "name", "メンバー")
281
+
282
+ choice_type, params = get_top_pending()
283
+ choice_label = f"選択 {choice_idx}"
284
+ if choice_type == "ORDER_DECK":
285
+ cards = params.get("cards", [])
286
+ if choice_idx < len(cards):
287
+ choice_label = f"デッキトップ: {get_card_name(cards[choice_idx])}"
288
+ else:
289
+ choice_label = "[確定]"
290
+ elif choice_type == "COLOR_SELECT":
291
+ colors = ["赤", "青", "緑", "黄", "紫", "ピンク"]
292
+ if choice_idx < len(colors):
293
+ choice_label = f"色選択: {colors[choice_idx]}"
294
+ elif choice_type == "SELECT_MODE":
295
+ options = params.get("options", [])
296
+ if choice_idx < len(options):
297
+ choice_label = options[choice_idx]
298
+ else:
299
+ choice_label = f"モード {choice_idx + 1}"
300
+ elif choice_type == "SELECT_FROM_LIST":
301
+ cards = params.get("cards", [])
302
+ if choice_idx < len(cards):
303
+ choice_label = f"選択: {get_card_name(cards[choice_idx])}"
304
+
305
+ return f"[{card_name}] {choice_label} ({area_name})"
306
+
307
+ # 3. PHASE-SPECIFIC CORE ACTIONS (Main Range)
308
+ # --------------------------------------------------------------------------
309
+
310
+ # 1-180: Playing Members (Main Phase)
311
+ # Each hand index has 3 target slots: aid = 1 + (hand_idx * 3) + slot_idx
312
+ if 1 <= a <= 180 and int(gs.phase) == int(Phase.MAIN):
313
+ idx = (a - 1) // 3
314
+ area_idx = (a - 1) % 3
315
+ areas = ["左", "センター", "右"]
316
+ area_name = areas[area_idx]
317
+ card_name = f"カード[{idx}]"
318
+ new_card_cost = 0
319
+ suffix = ""
320
+ if idx < len(p.hand):
321
+ cid = p.hand[idx]
322
+ base_cid = get_base_id(int(cid))
323
+ m = get_from_db(member_db, base_cid)
324
+ if m:
325
+ card_name = get_v(m, "name", "メンバー")
326
+ new_card_cost = get_v(m, "cost", 0)
327
+ abilities = get_v(m, "abilities", [])
328
+ if any(get_v(ab, "trigger", 0) == 1 for ab in abilities):
329
+ suffix = " [登場]"
330
+
331
+ stage_cid = p.stage[area_idx]
332
+ if stage_cid >= 0:
333
+ base_stage_cid = get_base_id(int(stage_cid))
334
+ old_card = get_from_db(member_db, base_stage_cid)
335
+ if old_card:
336
+ old_name = get_v(old_card, "name", "メンバー")
337
+ old_cost = get_v(old_card, "cost", 0)
338
+ actual_cost = max(0, new_card_cost - old_cost)
339
+ return f"【{area_name}に置く】 {card_name}{suffix} (バトンタッチ: {old_name}退場, 支払:{actual_cost})"
340
+ return f"【{area_name}に置く】 {card_name}{suffix} (コスト {new_card_cost})"
341
+
342
+ # 100-159: Energy Charge Selection
343
+ if 100 <= a <= 159 and int(gs.phase) == int(Phase.ENERGY):
344
+ idx = a - 100
345
+ card_name = f"手札[{idx}]"
346
+ if idx < len(p.hand):
347
+ card_name = get_card_name(p.hand[idx])
348
+ return f"【エネルギー】 {card_name}をチャージ"
349
+
350
+ # 300-359: Mulligan Selection
351
+ # Toggles cards to be shuffled back during the mulligan phase.
352
+ if 300 <= a <= 359:
353
+ idx = a - 300
354
+ card_name = f"手札[{idx}]"
355
+ if idx < len(p.hand):
356
+ card_name = get_card_name(p.hand[idx])
357
+ return f"【マリガン】 {card_name}を選択/解除"
358
+
359
+ # 400-459: Live Set Selection
360
+ # Sets a Live card from hand to face-up or face-down zone.
361
+ if 400 <= a <= 459:
362
+ idx = a - 400
363
+ card_name = f"手札[{idx}]"
364
+ score_text = ""
365
+ if idx < len(p.hand):
366
+ cid = p.hand[idx]
367
+ card_name = get_card_name(cid)
368
+ base_id = get_base_id(cid)
369
+ live = get_from_db(live_db, base_id)
370
+ return f"【ライブセット】 {card_name}{score_text}"
371
+
372
+ # 200-299: Activated Ability on Stage
373
+ # [起動] abilities triggered manually by the player.
374
+ if 200 <= a <= 299:
375
+ adj = a - 200
376
+ area_idx = adj // 10
377
+ ab_idx = adj % 10
378
+ areas = ["左", "センター", "右"]
379
+ area_name = areas[area_idx] if area_idx < 3 else f"Slot {area_idx}"
380
+ cid = p.stage[area_idx] if area_idx < 3 else -1
381
+ if cid >= 0:
382
+ base_cid = get_base_id(int(cid))
383
+ member = get_from_db(member_db, base_cid)
384
+ if member:
385
+ card_name = get_v(member, "name", "メンバー")
386
+ abilities = get_v(member, "abilities", [])
387
+ summary = "アビリティ"
388
+ if len(abilities) > ab_idx:
389
+ ab = abilities[ab_idx]
390
+ raw_text = get_v(ab, "raw_text", "").strip()
391
+ if raw_text:
392
+ summary = raw_text.split("\n")[0][:40]
393
+ if len(raw_text) > 40:
394
+ summary += "..."
395
+ return f"【起動】{card_name}: {summary} ({area_name})"
396
+ return f"Ability ({area_name})"
397
+
398
+ # 2000-2999: Discard Pile Activation
399
+ # Playing or activating effects of cards currently in the discard zone.
400
+ if 2000 <= a <= 2999:
401
+ adj = a - 2000
402
+ discard_idx = adj // 10
403
+ ab_idx = adj % 10
404
+ card_name = f"控え室[{discard_idx}]"
405
+ if discard_idx < len(p.discard):
406
+ cid = p.discard[discard_idx]
407
+ card_name = get_card_name(cid)
408
+ base_id = get_base_id(cid)
409
+ member = get_from_db(member_db, base_id)
410
+ if member:
411
+ abilities = get_v(member, "abilities", [])
412
+ if len(abilities) > ab_idx:
413
+ ab = abilities[ab_idx]
414
+ raw_text = get_v(ab, "raw_text", "").strip()
415
+ summary = raw_text.split("\n")[0][:40] if raw_text else "効果"
416
+ return f"【控え召喚】 {card_name}: {summary}"
417
+ return f"【控え召喚】 {card_name}"
418
+
419
+ # 1000-1999: OnPlay Sub-Choices
420
+ # Options offered immediately during/after a member is played.
421
+ if 1000 <= a <= 1999:
422
+ adj = a - 1000
423
+ hand_idx = adj // 100
424
+ slot_idx = (adj % 100) // 10
425
+ choice_idx = adj % 10
426
+
427
+ choice_label = f"選択 {choice_idx}"
428
+
429
+ choice_type, params = get_top_pending()
430
+ if choice_type == "ORDER_DECK":
431
+ cards = params.get("cards", [])
432
+ if choice_idx < len(cards):
433
+ choice_label = f"{get_card_name(cards[choice_idx])}をトップへ"
434
+ else:
435
+ choice_label = "[確定]"
436
+ elif choice_type == "COLOR_SELECT":
437
+ colors = ["赤", "青", "緑", "黄", "紫", "ピンク"]
438
+ if choice_idx < len(colors):
439
+ choice_label = f"{colors[choice_idx]}を選択"
440
+ elif choice_type == "SELECT_MODE":
441
+ options = params.get("options", [])
442
+ if choice_idx < len(options):
443
+ choice_label = options[choice_idx]
444
+ else:
445
+ choice_label = f"モード {choice_idx + 1}"
446
+ elif choice_type == "SELECT_FROM_LIST":
447
+ cards = params.get("cards", [])
448
+ if choice_idx < len(cards):
449
+ choice_label = f"{get_card_name(cards[choice_idx])}を選択"
450
+
451
+ return choice_label
452
+
453
+ # 4. FALLBACKS
454
+ # --------------------------------------------------------------------------
455
+
456
+ # 510-559: Generic Hand Selection Fallback
457
+ if 510 <= a <= 559:
458
+ idx = a - 500 # Keep idx logic consistent with 500-509
459
+ card_name = f"手札[{idx}]"
460
+ if idx < len(p.hand):
461
+ card_name = get_card_name(p.hand[idx])
462
+ return f"【手札選択】 {card_name}"
463
+
464
+ return f"Action {a}"
465
+
466
+ return f"Action {a}"
engine/game/enums.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import IntEnum
2
+
3
+
4
+ class Phase(IntEnum):
5
+ """Game phases within a turn (Rule 7).
6
+
7
+ Flow: MULLIGAN_P1 -> MULLIGAN_P2 -> ACTIVE -> ENERGY -> DRAW -> MAIN
8
+ -> LIVE_SET -> PERFORMANCE_P1 -> PERFORMANCE_P2 -> LIVE_RESULT
9
+ -> ACTIVE (next turn)
10
+
11
+ Note: SETUP (-2) is reserved for potential future use (pregame setup).
12
+ Games currently start directly at MULLIGAN_P1.
13
+ """
14
+
15
+ SETUP = -2
16
+ MULLIGAN_P1 = -1
17
+ MULLIGAN_P2 = 0
18
+ ACTIVE = 1
19
+ ENERGY = 2
20
+ DRAW = 3
21
+ MAIN = 4
22
+ LIVE_SET = 5
23
+ PERFORMANCE_P1 = 6
24
+ PERFORMANCE_P2 = 7
25
+ LIVE_RESULT = 8
26
+ TERMINAL = 9
27
+ RESPONSE = 10
engine/game/fast_logic.py ADDED
@@ -0,0 +1,2209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import warnings
2
+
3
+ import numpy as np
4
+ from numba import njit, prange
5
+
6
+ # Suppress Numba cache warnings (harmless in testing environments)
7
+ warnings.filterwarnings("ignore", message=".*'cache' is set for njit and is ignored.*")
8
+
9
+ # =============================================================================
10
+ # HYPER-OPTIMIZED VM CORE (Production Version)
11
+ # =============================================================================
12
+
13
+ # ContextIndex Mappings (Raw Ints)
14
+ CV = 20
15
+ AT = 22
16
+ TS = 12
17
+ TI = 13
18
+ SZ = 7
19
+ CH = 15
20
+ SID = 5
21
+
22
+ # Backward compatibility aliases
23
+ CTX_VALUE = CV
24
+ CTX_ATTR = AT
25
+ CTX_TARGET_SLOT = TS
26
+ CTX_TARGET_PLAYER_ID = TI
27
+ CTX_SOURCE_ZONE_IDX = SZ
28
+ CTX_CHOICE_INDEX = CH
29
+
30
+ # GlobalContext Mappings
31
+ SC = 0
32
+ OS = 1
33
+ TR = 2
34
+ HD = 3
35
+ DI = 4
36
+ EN = 5
37
+ DK = 6
38
+ OT = 7
39
+ OT = 7
40
+ PH = 8
41
+ LS = 110 # Live Score Bonus (Temporary, moved to 110 to avoid conflict with OD)
42
+ EH = 111 # Excess Hearts (Current Live Performance)
43
+ PREV_CID_IDX = 60 # Previous Card ID for Baton Pass (Index 60)
44
+
45
+ # Unique ID (UID) System
46
+ BASE_ID_MASK = 0xFFFFF
47
+
48
+
49
+ @njit(inline="always")
50
+ def get_base_id(uid: int) -> int:
51
+ """Extract the base card definition ID (0-1999) from a UID."""
52
+ return uid & BASE_ID_MASK
53
+
54
+
55
+ # Opcodes
56
+ O_DRAW = 10
57
+ O_BLADES = 11
58
+ O_HEARTS = 12
59
+ O_REDUCE_COST = 13
60
+ O_LOOK_DECK = 14
61
+ O_RECOV_L = 15
62
+ O_BOOST = 16
63
+ O_RECOV_M = 17
64
+ O_BUFF = 18
65
+ O_MOVE_MEMBER = 20
66
+ O_SWAP_CARDS = 21
67
+ O_SEARCH_DECK = 22
68
+ O_CHARGE = 23
69
+ O_ORDER_DECK = 28
70
+ O_SET_BLADES = 24
71
+ O_SELECT_MODE = 30
72
+ O_TAP_O = 32
73
+ O_PLACE_UNDER = 33
74
+ O_LOOK_AND_CHOOSE = 41
75
+ O_ACTIVATE_MEMBER = 43
76
+ O_ADD_H = 44
77
+ O_REPLACE_EFFECT = 46
78
+ O_TRIGGER_REMOTE = 47
79
+ O_TRANSFORM_COLOR = 39
80
+ O_TAP_M = 53
81
+ O_REDUCE_HEART_REQ = 48
82
+ O_REVEAL_CARDS = 26
83
+ O_MOVE_TO_DISCARD = 58
84
+ O_RETURN = 1
85
+ O_JUMP = 2
86
+ O_JUMP_F = 3
87
+ O_REVEAL_HAND_ALL = 6 # Cost Type (Internal reminder)
88
+
89
+ # Conditions
90
+ C_TR1 = 200
91
+ C_CLR = 202
92
+ C_STG = 203
93
+ C_HND = 204
94
+ C_CTR = 206
95
+ C_LLD = 207
96
+ C_GRP = 208
97
+ C_OPH = 210
98
+ C_ENR = 213
99
+ C_OPH = 210
100
+ C_ENR = 213
101
+ C_CMP = 220
102
+ C_BLD = 224
103
+ C_HRT = 223
104
+ C_HAS_CHOICE = 221
105
+ C_OPPONENT_CHOICE = 222
106
+ C_BATON = 231 # Baton Pass condition (ID 231)
107
+
108
+ # Conditions
109
+ C_TR1 = 200
110
+ C_CLR = 202
111
+ C_STG = 203
112
+ C_HND = 204
113
+ C_CTR = 206
114
+ C_LLD = 207
115
+ C_GRP = 208
116
+ C_OPH = 210
117
+ C_ENR = 213
118
+ C_OPH = 210
119
+ C_ENR = 213
120
+ C_CMP = 220
121
+ C_BLD = 224
122
+ C_HRT = 223
123
+ C_HAS_CHOICE = 221
124
+ C_OPPONENT_CHOICE = 222
125
+
126
+
127
+ @njit(nopython=True, cache=True, fastmath=True)
128
+ def resolve_bytecode(
129
+ bytecode,
130
+ flat_ctx,
131
+ global_ctx,
132
+ player_id,
133
+ p_hand,
134
+ p_deck,
135
+ p_stage,
136
+ p_energy_vec,
137
+ p_energy_count,
138
+ p_cont_vec,
139
+ out_cptr, # Modified: Pass by ref array (size 1)
140
+ p_tapped,
141
+ p_live,
142
+ opp_tapped,
143
+ p_trash, # Added
144
+ b_map,
145
+ b_idx,
146
+ out_bonus, # Modified: Pass by ref array (size 1)
147
+ card_stats, # Added: NumPy array for card properties
148
+ opp_stage, # Added
149
+ opp_tapped_count=None, # Optional tracking
150
+ ):
151
+ # Manual Stack Unrolling (Depth 4) - Eliminates allocations in hot loop
152
+ stack_ptr = 0
153
+
154
+ bc0 = bytecode
155
+ ip0 = 0
156
+ bc1 = bytecode
157
+ ip1 = 0
158
+ bc2 = bytecode
159
+ ip2 = 0
160
+ bc3 = bytecode
161
+ ip3 = 0
162
+
163
+ cptr = out_cptr[0]
164
+ cond = True
165
+ safety_counter = 0
166
+
167
+ while stack_ptr >= 0 and safety_counter < 1000:
168
+ safety_counter += 1
169
+
170
+ # Current Frame Selection (Type-stable for Numba)
171
+ if stack_ptr == 0:
172
+ cur_bc = bc0
173
+ ip = ip0
174
+ elif stack_ptr == 1:
175
+ cur_bc = bc1
176
+ ip = ip1
177
+ elif stack_ptr == 2:
178
+ cur_bc = bc2
179
+ ip = ip2
180
+ else:
181
+ cur_bc = bc3
182
+ ip = ip3
183
+
184
+ blen = cur_bc.shape[0]
185
+
186
+ if ip >= blen:
187
+ stack_ptr -= 1
188
+ continue
189
+
190
+ op = cur_bc[ip, 0]
191
+ v = cur_bc[ip, 1]
192
+ a = cur_bc[ip, 2]
193
+ s = cur_bc[ip, 3]
194
+
195
+ # Advance IP logic: Pre-increment local copy
196
+ next_ip = ip + 1
197
+ if stack_ptr == 0:
198
+ ip0 = next_ip
199
+ elif stack_ptr == 1:
200
+ ip1 = next_ip
201
+ elif stack_ptr == 2:
202
+ ip2 = next_ip
203
+ else:
204
+ ip3 = next_ip
205
+
206
+ if op == 0:
207
+ continue
208
+ if op == O_RETURN:
209
+ stack_ptr -= 1
210
+ continue
211
+
212
+ # Negation Flag Handling
213
+ is_negated = False
214
+ if op >= 1000:
215
+ is_negated = True
216
+ op -= 1000
217
+
218
+ # Dynamic Target Handling (MEMBER_SELECT)
219
+ if s == 10:
220
+ s = int(flat_ctx[TS])
221
+
222
+ if op == O_JUMP:
223
+ new_ip = ip + v
224
+ if stack_ptr == 0:
225
+ ip0 = new_ip if 0 <= new_ip < blen else blen
226
+ elif stack_ptr == 1:
227
+ ip1 = new_ip if 0 <= new_ip < blen else blen
228
+ elif stack_ptr == 2:
229
+ ip2 = new_ip if 0 <= new_ip < blen else blen
230
+ else:
231
+ ip3 = new_ip if 0 <= new_ip < blen else blen
232
+ continue
233
+
234
+ if op == O_JUMP_F:
235
+ if not cond:
236
+ new_ip = ip + v
237
+ if stack_ptr == 0:
238
+ ip0 = new_ip if 0 <= new_ip < blen else blen
239
+ elif stack_ptr == 1:
240
+ ip1 = new_ip if 0 <= new_ip < blen else blen
241
+ elif stack_ptr == 2:
242
+ ip2 = new_ip if 0 <= new_ip < blen else blen
243
+ else:
244
+ ip3 = new_ip if 0 <= new_ip < blen else blen
245
+ continue
246
+ continue
247
+
248
+ if op == O_SELECT_MODE:
249
+ choice = int(flat_ctx[CH])
250
+ jumped = False
251
+ if 0 <= choice < v:
252
+ jump_ip = ip + 1 + choice
253
+ if jump_ip < blen:
254
+ offset = cur_bc[jump_ip, 1]
255
+ new_ip = jump_ip + offset
256
+ if 0 <= new_ip < blen:
257
+ if stack_ptr == 0:
258
+ ip0 = new_ip
259
+ elif stack_ptr == 1:
260
+ ip1 = new_ip
261
+ elif stack_ptr == 2:
262
+ ip2 = new_ip
263
+ else:
264
+ ip3 = new_ip
265
+ jumped = True
266
+ if not jumped:
267
+ new_ip = ip + (v + 1)
268
+ if stack_ptr == 0:
269
+ ip0 = new_ip
270
+ elif stack_ptr == 1:
271
+ ip1 = new_ip
272
+ elif stack_ptr == 2:
273
+ ip2 = new_ip
274
+ else:
275
+ ip3 = new_ip
276
+ continue
277
+
278
+ if op >= 200:
279
+ if op == C_TR1:
280
+ cond = global_ctx[TR] == 1
281
+ elif op == C_STG:
282
+ ct = 0
283
+ for i in range(3):
284
+ if p_stage[i] != -1:
285
+ ct += 1
286
+ cond = ct >= v
287
+ elif op == C_HND:
288
+ cond = global_ctx[HD] >= v
289
+ elif op == C_LLD:
290
+ cond = global_ctx[SC] > global_ctx[OS]
291
+ elif op == C_CLR:
292
+ if 0 <= a <= 5:
293
+ cond = global_ctx[10 + a] > 0
294
+ else:
295
+ cond = False
296
+ elif op == C_GRP:
297
+ # C_GRP: Count cards of a group on stage or live zone
298
+ # v = min count, a = group ID, s = zone (0=stage, 1=live)
299
+ target_group = a
300
+ grp_count = 0
301
+ if s == 0: # Stage
302
+ for slot_k in range(3):
303
+ cid = p_stage[slot_k]
304
+ if cid >= 0:
305
+ bid = get_base_id(cid)
306
+ if bid < card_stats.shape[0]:
307
+ # Groups are stored at index 6 (primary group)
308
+ if card_stats[bid, 6] == target_group:
309
+ grp_count += 1
310
+ elif s == 1: # Live Zone
311
+ for lz_k in range(p_live.shape[0]):
312
+ cid = p_live[lz_k]
313
+ if cid >= 0:
314
+ bid = get_base_id(cid)
315
+ if bid < card_stats.shape[0]:
316
+ if card_stats[bid, 6] == target_group:
317
+ grp_count += 1
318
+ cond = grp_count >= v
319
+ elif op == C_BLD:
320
+ # C_BLD: Check if total blades on stage meets requirement
321
+ # Rule 9.9: Tapped (Resting) members do not contribute.
322
+ total_blades_check = 0
323
+ for slot_k in range(3):
324
+ cid = p_stage[slot_k]
325
+ if cid >= 0 and p_tapped[slot_k] == 0:
326
+ bid = get_base_id(cid)
327
+ if bid < card_stats.shape[0]:
328
+ total_blades_check += card_stats[bid, 1] # Blades at index 1
329
+
330
+ # Decode slot/comparison
331
+ real_slot = s & 0x0F
332
+ comp = (s >> 4) & 0x0F
333
+ if comp == 0:
334
+ cond = total_blades_check >= v
335
+ elif comp == 1:
336
+ cond = total_blades_check <= v
337
+ elif comp == 2:
338
+ cond = total_blades_check > v
339
+ elif comp == 3:
340
+ cond = total_blades_check < v
341
+ else:
342
+ cond = total_blades_check == v
343
+ elif op == C_HRT:
344
+ # C_HRT: Check if total hearts of color `a` on stage meets requirement
345
+ total_hearts_check = 0
346
+
347
+ # Decode slot/comparison
348
+ real_slot = s & 0x0F
349
+ comp = (s >> 4) & 0x0F
350
+
351
+ if real_slot == 2: # Live Result / Excess Hearts
352
+ total_hearts_check = global_ctx[EH]
353
+ else:
354
+ for slot_k in range(3):
355
+ cid = p_stage[slot_k]
356
+ # Rule 9.9: Tapped members do not contribute hearts either.
357
+ if cid >= 0 and p_tapped[slot_k] == 0:
358
+ bid = get_base_id(cid)
359
+ if bid < card_stats.shape[0]:
360
+ if 0 <= a <= 6:
361
+ total_hearts_check += card_stats[bid, 12 + a] # Hearts at 12-18
362
+ else:
363
+ # Sum all hearts
364
+ for h_k in range(7):
365
+ total_hearts_check += card_stats[bid, 12 + h_k]
366
+
367
+ if comp == 0:
368
+ cond = total_hearts_check >= v
369
+ elif comp == 1:
370
+ cond = total_hearts_check <= v
371
+ elif comp == 2:
372
+ cond = total_hearts_check > v
373
+ elif comp == 3:
374
+ cond = total_hearts_check < v
375
+ else:
376
+ cond = total_hearts_check == v
377
+ elif op == C_ENR:
378
+ cond = global_ctx[EN] >= v
379
+ elif op == C_CTR:
380
+ cond = flat_ctx[SZ] == 1
381
+ elif op == C_CMP:
382
+ if v > 0:
383
+ cond = global_ctx[SC] >= v
384
+ else:
385
+ cond = global_ctx[SC] > global_ctx[OS]
386
+ elif op == C_OPH:
387
+ ct = global_ctx[OT]
388
+ if v > 0:
389
+ cond = ct >= v
390
+ else:
391
+ cond = ct > 0
392
+ elif op == 230: # C_LIVE_ZONE
393
+ # Placeholder: correctly count cards in successful live zone if needed
394
+ cond = True
395
+ elif op == 212: # C_MODAL_ANSWER
396
+ cond = global_ctx[CH] == v
397
+ elif op == C_HAS_CHOICE:
398
+ cond = True
399
+ elif op == C_OPPONENT_CHOICE:
400
+ # Check if opponent tapped ANY card this turn (safe approximation)
401
+ # We check the passed `opp_tapped` array.
402
+ cond = False
403
+ for k in range(3):
404
+ if opp_tapped[k] > 0:
405
+ cond = True
406
+ break
407
+ elif op == C_BATON:
408
+ # Baton Pass condition: Check if the previous card's Character ID matches target
409
+ prev_cid = global_ctx[PREV_CID_IDX]
410
+ if prev_cid >= 0:
411
+ prev_bid = get_base_id(prev_cid)
412
+ if prev_bid < card_stats.shape[0]:
413
+ # Check if the previous card's Character ID (stored in stats index 19) matches the target
414
+ cond = card_stats[prev_bid, 19] == v
415
+ else:
416
+ cond = False
417
+ else:
418
+ cond = False
419
+
420
+ if is_negated:
421
+ cond = not cond
422
+
423
+ if not cond:
424
+ stack_ptr -= 1
425
+ continue
426
+ else:
427
+ if cond:
428
+ if op == O_DRAW:
429
+ drawn = 0
430
+ for d_idx in range(60):
431
+ if p_deck[d_idx] > 0:
432
+ card_id = p_deck[d_idx]
433
+ p_deck[d_idx] = 0
434
+ global_ctx[DK] -= 1
435
+ for h_idx in range(60):
436
+ if p_hand[h_idx] == 0:
437
+ p_hand[h_idx] = card_id
438
+ global_ctx[HD] += 1
439
+ break
440
+ drawn += 1
441
+ if drawn >= v:
442
+ break
443
+ elif op == O_TRANSFORM_COLOR:
444
+ # Rule 11.12: Transformation effects.
445
+ # Add to continuous effects buffer.
446
+ # Entry: [type, value, attr, slot, p_id, duration, ...]
447
+ # and TRANSFORM_COLOR usually lasts until LIVE_END (duration=2 in this implementation?)
448
+ # attr is target_color index
449
+ if (
450
+ cptr < p_cont_vec.shape[0]
451
+ ): # Changed from p_ptr[0] to cptr, and p_cont_vec.shape[1] to p_cont_vec.shape[0]
452
+ idx = cptr
453
+ p_cont_vec[idx, 0] = O_TRANSFORM_COLOR
454
+ p_cont_vec[idx, 1] = v
455
+ p_cont_vec[idx, 2] = a
456
+ p_cont_vec[idx, 3] = s
457
+ p_cont_vec[idx, 4] = player_id
458
+ p_cont_vec[idx, 5] = 2 # Duration: LIVE_END
459
+ cptr += 1 # Changed from p_ptr[0] += 1 to cptr += 1
460
+ elif op == O_CHARGE:
461
+ charged = 0
462
+ for d_idx in range(60):
463
+ if p_deck[d_idx] > 0:
464
+ card_id = p_deck[d_idx]
465
+ p_deck[d_idx] = 0
466
+ global_ctx[DK] -= 1
467
+ if 0 <= s < 3:
468
+ for e_idx in range(32):
469
+ if p_energy_vec[s, e_idx] == 0:
470
+ p_energy_vec[s, e_idx] = card_id
471
+ p_energy_count[s] += 1
472
+ # Rule 10.6: Energy under members does NOT count as global energy
473
+ # global_ctx[EN] += 1 <-- REMOVED
474
+ break
475
+ charged += 1
476
+ if charged >= v:
477
+ break
478
+ elif op == O_BLADES:
479
+ if s >= 0 and cptr < 32:
480
+ p_cont_vec[cptr, 0] = 1
481
+ p_cont_vec[cptr, 1] = v
482
+ p_cont_vec[cptr, 2] = 4
483
+ p_cont_vec[cptr, 3] = s
484
+ p_cont_vec[cptr, 8] = int(flat_ctx[SID])
485
+ p_cont_vec[cptr, 9] = 1
486
+ cptr += 1
487
+ elif op == O_HEARTS:
488
+ if cptr < 32:
489
+ p_cont_vec[cptr, 0] = 2
490
+ p_cont_vec[cptr, 1] = v
491
+ p_cont_vec[cptr, 5] = a
492
+ p_cont_vec[cptr, 8] = int(flat_ctx[SID])
493
+ p_cont_vec[cptr, 9] = 1
494
+ cptr += 1
495
+ if 10 + a < 128:
496
+ global_ctx[10 + a] += v
497
+ elif op == O_REDUCE_COST:
498
+ if cptr < 32:
499
+ p_cont_vec[cptr, 0] = 3
500
+ p_cont_vec[cptr, 1] = v
501
+ p_cont_vec[cptr, 2] = s
502
+ p_cont_vec[cptr, 8] = int(flat_ctx[SID])
503
+ p_cont_vec[cptr, 9] = 1
504
+ cptr += 1
505
+ elif op == O_REDUCE_HEART_REQ:
506
+ if cptr < 32:
507
+ p_cont_vec[cptr, 0] = 48
508
+ p_cont_vec[cptr, 1] = v
509
+ p_cont_vec[cptr, 2] = s
510
+ p_cont_vec[cptr, 8] = int(flat_ctx[SID])
511
+ p_cont_vec[cptr, 9] = 1
512
+ cptr += 1
513
+ elif op == O_SET_BLADES:
514
+ if s >= 0 and cptr < 32:
515
+ p_cont_vec[cptr, 0] = 24
516
+ p_cont_vec[cptr, 1] = v
517
+ p_cont_vec[cptr, 2] = s
518
+ p_cont_vec[cptr, 8] = int(flat_ctx[SID])
519
+ p_cont_vec[cptr, 9] = 1
520
+ cptr += 1
521
+ elif op == O_REPLACE_EFFECT:
522
+ if cptr < 32:
523
+ p_cont_vec[cptr, 0] = 46
524
+ p_cont_vec[cptr, 1] = v
525
+ p_cont_vec[cptr, 2] = s
526
+ p_cont_vec[cptr, 9] = 1
527
+ cptr += 1
528
+ elif op == O_LOOK_DECK:
529
+ # Look deck in VM just processes the look (Revealing cards to a buffer)
530
+ # For now, we skip if it's purely for selection (handled by 41)
531
+ # Implementation: No-op for state, but placeholder for side-effects
532
+ continue
533
+ elif op == O_REVEAL_CARDS:
534
+ # Reveal cards (placeholder for state-based effects)
535
+ continue
536
+ elif op == O_RECOV_L:
537
+ # Heuristic: Recover highest ID Live Card
538
+ best_idx = -1
539
+ best_id = -1
540
+ for tr_k in range(p_trash.shape[0]):
541
+ tid = p_trash[tr_k]
542
+ if tid > 0:
543
+ tbid = get_base_id(tid)
544
+ if tbid < card_stats.shape[0] and card_stats[tbid, 10] == 2: # Live
545
+ if tid > best_id:
546
+ best_id = tid
547
+ best_idx = tr_k
548
+
549
+ if best_idx != -1:
550
+ # Move to hand
551
+ moved = False
552
+ for h_idx in range(60):
553
+ if p_hand[h_idx] == 0:
554
+ p_hand[h_idx] = best_id
555
+ global_ctx[HD] += 1
556
+ p_trash[best_idx] = 0
557
+ global_ctx[4] -= 1 # TR
558
+ moved = True
559
+ break
560
+ if not moved:
561
+ # Hand full: Fallback to discard or stay in trash (which it already is)
562
+ continue
563
+
564
+ elif op == O_RECOV_M:
565
+ # Heuristic: Recover highest ID Member Card
566
+ best_idx = -1
567
+ best_id = -1
568
+ for tr_k in range(p_trash.shape[0]):
569
+ tid = p_trash[tr_k]
570
+ if tid > 0:
571
+ tbid = get_base_id(tid)
572
+ if tbid < card_stats.shape[0] and card_stats[tbid, 10] == 1: # Member
573
+ # Optional: Add Cost/Power check if needed, for now Max ID is decent proxy
574
+ if tid > best_id:
575
+ best_id = tid
576
+ best_idx = tr_k
577
+
578
+ if best_idx != -1:
579
+ # Move to hand
580
+ moved = False
581
+ for h_idx in range(60):
582
+ if p_hand[h_idx] == 0:
583
+ p_hand[h_idx] = best_id
584
+ global_ctx[HD] += 1
585
+ p_trash[best_idx] = 0
586
+ global_ctx[4] -= 1 # TR
587
+ moved = True
588
+ break
589
+ elif op == O_ACTIVATE_MEMBER:
590
+ if 0 <= s < 3:
591
+ p_tapped[s] = 0
592
+ elif op == O_SWAP_CARDS:
593
+ removed = 0
594
+ for h_idx in range(60):
595
+ if p_hand[h_idx] > 0:
596
+ cid = p_hand[h_idx]
597
+ p_hand[h_idx] = 0
598
+ global_ctx[HD] -= 1
599
+ removed += 1
600
+ if removed >= v:
601
+ break
602
+
603
+ if s == 0: # Only draw if mode is 0 (Swap). s=1 implies Discard Only cost.
604
+ drawn = 0
605
+ for d_idx in range(60):
606
+ if p_deck[d_idx] > 0:
607
+ card_id = p_deck[d_idx]
608
+ p_deck[d_idx] = 0
609
+ for h_idx in range(60):
610
+ if p_hand[h_idx] == 0:
611
+ p_hand[h_idx] = card_id
612
+ break
613
+ global_ctx[DK] -= 1
614
+ global_ctx[HD] += 1
615
+ drawn += 1
616
+ if drawn >= v:
617
+ break
618
+
619
+ elif op == O_PLACE_UNDER:
620
+ placed = 0
621
+ if a == 1: # From Energy Zone
622
+ for _ in range(v):
623
+ if global_ctx[EN] > 0:
624
+ global_ctx[EN] -= 1
625
+ if 0 <= s < 3:
626
+ for e_idx in range(32):
627
+ if p_energy_vec[s, e_idx] == 0:
628
+ p_energy_vec[s, e_idx] = 2000 # Dummy energy card
629
+ p_energy_count[s] += 1
630
+ break
631
+ placed += 1
632
+ if placed >= v:
633
+ break
634
+ else: # From Hand (Default)
635
+ for h_idx in range(59, -1, -1):
636
+ if p_hand[h_idx] > 0:
637
+ cid = p_hand[h_idx]
638
+ p_hand[h_idx] = 0
639
+ global_ctx[HD] -= 1
640
+ if 0 <= s < 3:
641
+ for e_idx in range(32):
642
+ if p_energy_vec[s, e_idx] == 0:
643
+ p_energy_vec[s, e_idx] = cid
644
+ p_energy_count[s] += 1
645
+ break
646
+ placed += 1
647
+ if placed >= v:
648
+ break
649
+
650
+ elif op == O_MOVE_MEMBER:
651
+ dest_slot = int(flat_ctx[TS])
652
+ if 0 <= s < 3 and 0 <= dest_slot < 3 and s != dest_slot:
653
+ temp_id = p_stage[s]
654
+ p_stage[s] = p_stage[dest_slot]
655
+ p_stage[dest_slot] = temp_id
656
+ temp_tap = p_tapped[s]
657
+ p_tapped[s] = p_tapped[dest_slot]
658
+ p_tapped[dest_slot] = temp_tap
659
+ for e_idx in range(32):
660
+ temp_e = p_energy_vec[s, e_idx]
661
+ p_energy_vec[s, e_idx] = p_energy_vec[dest_slot, e_idx]
662
+ p_energy_vec[dest_slot, e_idx] = temp_e
663
+ temp_ec = p_energy_count[s]
664
+ p_energy_count[s] = p_energy_count[dest_slot]
665
+ p_energy_count[dest_slot] = temp_ec
666
+
667
+ elif op == O_TAP_M:
668
+ # Tap self or other member (usually based on TargetSlot/s)
669
+ if 0 <= s < 3:
670
+ p_tapped[s] = True
671
+ elif (
672
+ s == 10
673
+ ): # TargetSlot 10 usually means select manually, but in Numba we might just tap all if instructed
674
+ p_tapped[:] = True
675
+
676
+ elif op == O_TAP_O:
677
+ is_all = (a & 0x80) != 0
678
+ c_max = v
679
+ b_max = a & 0x7F
680
+ # Decode real slot
681
+ real_slot = s & 0x0F
682
+
683
+ for slot_k in range(3):
684
+ if not is_all and slot_k != real_slot:
685
+ continue
686
+
687
+ cid = opp_stage[slot_k]
688
+ if cid >= 0:
689
+ bid = get_base_id(cid)
690
+ if bid < card_stats.shape[0]:
691
+ # Filter checks
692
+ if c_max != 99 and card_stats[bid, 0] > c_max:
693
+ continue
694
+ if b_max != 99 and card_stats[bid, 1] > b_max:
695
+ continue
696
+
697
+ opp_tapped[slot_k] = 1
698
+ elif op == O_BUFF:
699
+ if cptr < 32:
700
+ p_cont_vec[cptr, 0] = 8
701
+ p_cont_vec[cptr, 1] = v
702
+ p_cont_vec[cptr, 2] = s
703
+ p_cont_vec[cptr, 8] = int(flat_ctx[SID])
704
+ p_cont_vec[cptr, 9] = 1
705
+ cptr += 1
706
+ elif op == O_BOOST:
707
+ # out_bonus[0] += v # WRONG: This was adding to Permanent Score
708
+ global_ctx[LS] += v # CORRECT: Add to Temporary Live Score Context
709
+ elif op == O_LOOK_AND_CHOOSE:
710
+ choice_idx = int(flat_ctx[CH])
711
+ if choice_idx < 0 or choice_idx >= v:
712
+ choice_idx = 0
713
+ indices = np.full(v, -1, dtype=np.int32)
714
+ ptr = 0
715
+ for d_idx in range(60):
716
+ if p_deck[d_idx] > 0:
717
+ indices[ptr] = d_idx
718
+ ptr += 1
719
+ if ptr >= v:
720
+ break
721
+ if ptr > 0:
722
+ if choice_idx >= ptr:
723
+ choice_idx = 0
724
+ real_idx = indices[choice_idx]
725
+ chosen_card = -1
726
+ if real_idx != -1:
727
+ chosen_card = p_deck[real_idx]
728
+ if chosen_card > 0:
729
+ for h_idx in range(60):
730
+ if p_hand[h_idx] == 0:
731
+ p_hand[h_idx] = chosen_card
732
+ global_ctx[HD] += 1
733
+ break
734
+ for k in range(ptr):
735
+ rid = indices[k]
736
+ if rid != -1:
737
+ cid = p_deck[rid]
738
+ p_deck[rid] = 0
739
+ global_ctx[DK] -= 1
740
+ if rid != real_idx: # Fix: Don't mill the card added to hand
741
+ move_to_trash(0, cid, p_trash.reshape(1, -1), global_ctx.reshape(1, -1), 2)
742
+
743
+ elif op == O_ORDER_DECK:
744
+ indices = np.full(v, -1, dtype=np.int32)
745
+ vals = np.full(v, 0, dtype=np.int32)
746
+ ptr = 0
747
+ for d_idx in range(60):
748
+ if p_deck[d_idx] > 0:
749
+ indices[ptr] = d_idx
750
+ vals[ptr] = p_deck[d_idx]
751
+ ptr += 1
752
+ if ptr >= v:
753
+ break
754
+ if ptr > 1:
755
+ for k in range(ptr // 2):
756
+ temp = vals[k]
757
+ vals[k] = vals[ptr - 1 - k]
758
+ vals[ptr - 1 - k] = temp
759
+ for k in range(ptr):
760
+ p_deck[indices[k]] = vals[k]
761
+
762
+ elif op == O_ADD_H:
763
+ drawn = 0
764
+ for d_idx in range(60):
765
+ if p_deck[d_idx] > 0:
766
+ card_id = p_deck[d_idx]
767
+ p_deck[d_idx] = 0
768
+ global_ctx[DK] -= 1
769
+ for h_idx in range(60):
770
+ if p_hand[h_idx] == 0:
771
+ p_hand[h_idx] = card_id
772
+ global_ctx[HD] += 1
773
+ break
774
+ drawn += 1
775
+ if drawn >= v:
776
+ break
777
+ elif op == O_SEARCH_DECK:
778
+ target_idx = int(flat_ctx[TS])
779
+ if 0 <= target_idx < 60 and p_deck[target_idx] > 0:
780
+ card_to_move = p_deck[target_idx]
781
+ p_deck[target_idx] = 0
782
+ for h_idx in range(60):
783
+ if p_hand[h_idx] == 0:
784
+ p_hand[h_idx] = card_to_move
785
+ global_ctx[HD] += 1
786
+ global_ctx[DK] -= 1
787
+ break
788
+ else:
789
+ for d_idx in range(60):
790
+ if p_deck[d_idx] > 0:
791
+ card_to_move = p_deck[d_idx]
792
+ p_deck[d_idx] = 0
793
+ for h_idx in range(60):
794
+ if p_hand[h_idx] == 0:
795
+ p_hand[h_idx] = card_to_move
796
+ global_ctx[HD] += 1
797
+ global_ctx[DK] -= 1
798
+ break
799
+ break
800
+
801
+ elif op == O_MOVE_TO_DISCARD:
802
+ # v=count, a=source(1=deck,2=hand,3=energy), s=target(0=self)
803
+ if a == 1: # Deck
804
+ moved = 0
805
+ for d_idx in range(60):
806
+ if p_deck[d_idx] > 0:
807
+ cid = p_deck[d_idx]
808
+ p_deck[d_idx] = 0
809
+ global_ctx[DK] -= 1
810
+ # Add to trash inline
811
+ for tr_k in range(60):
812
+ if p_trash[tr_k] == 0:
813
+ p_trash[tr_k] = cid
814
+ global_ctx[DI] += 1
815
+ break
816
+ moved += 1
817
+ if moved >= v:
818
+ break
819
+ elif a == 2: # Hand
820
+ moved = 0
821
+ for h_idx in range(59, -1, -1):
822
+ if p_hand[h_idx] > 0:
823
+ cid = p_hand[h_idx]
824
+ p_hand[h_idx] = 0
825
+ global_ctx[HD] -= 1
826
+ for tr_k in range(60):
827
+ if p_trash[tr_k] == 0:
828
+ p_trash[tr_k] = cid
829
+ global_ctx[DI] += 1
830
+ break
831
+ moved += 1
832
+ if moved >= v:
833
+ break
834
+ # a=3 Energy not fully supported in fast_logic yet (requires attached energy iter)
835
+ elif s == 0: # Self (Stage)
836
+ scid = int(flat_ctx[SID])
837
+ for s_k in range(3):
838
+ if p_stage[s_k] == scid:
839
+ p_stage[s_k] = -1
840
+ p_tapped[s_k] = 0
841
+ for tr_k in range(60):
842
+ if p_trash[tr_k] == 0:
843
+ p_trash[tr_k] = scid
844
+ global_ctx[DI] += 1
845
+ break
846
+ break
847
+
848
+ elif op == O_TRIGGER_REMOTE:
849
+ target_slot = s
850
+ target_card_id = -1
851
+ if 0 <= target_slot < 3:
852
+ target_card_id = p_stage[target_slot]
853
+
854
+ if target_card_id >= 0:
855
+ target_bid = get_base_id(target_card_id)
856
+ if target_bid < b_idx.shape[0]:
857
+ map_idx = b_idx[target_bid, 0]
858
+ if map_idx >= 0 and stack_ptr < 3:
859
+ stack_ptr += 1
860
+ if stack_ptr == 1:
861
+ bc1 = b_map[map_idx]
862
+ ip1 = 0
863
+ elif stack_ptr == 2:
864
+ bc2 = b_map[map_idx]
865
+ ip2 = 0
866
+ else:
867
+ bc3 = b_map[map_idx]
868
+ ip3 = 0
869
+ continue
870
+
871
+ out_cptr[0] = cptr
872
+
873
+
874
+ @njit(nopython=True, parallel=True, cache=True, fastmath=True)
875
+ def batch_resolve_bytecode(
876
+ batch_bytecode,
877
+ batch_flat_ctx,
878
+ batch_global_ctx,
879
+ player_id,
880
+ p_hand,
881
+ p_deck,
882
+ p_stage,
883
+ p_energy_vec,
884
+ p_energy_count,
885
+ p_cont_vec,
886
+ p_cont_ptr,
887
+ p_tapped,
888
+ p_live,
889
+ opp_tapped,
890
+ p_trash,
891
+ b_map,
892
+ b_idx,
893
+ card_stats, # Added: NumPy array for card properties
894
+ opp_stage, # Added
895
+ ):
896
+ num_envs = batch_bytecode.shape[0]
897
+ # Pre-allocate bonus array to avoid allocations in prange
898
+ batch_bonus = np.zeros(num_envs, dtype=np.int32)
899
+ for i in prange(num_envs):
900
+ cptr_slice = p_cont_ptr[i : i + 1]
901
+ out_bonus_slice = batch_bonus[i : i + 1]
902
+
903
+ resolve_bytecode(
904
+ batch_bytecode[i],
905
+ batch_flat_ctx[i],
906
+ batch_global_ctx[i],
907
+ player_id,
908
+ p_hand[i],
909
+ p_deck[i],
910
+ p_stage[i],
911
+ p_energy_vec[i],
912
+ p_energy_count[i],
913
+ p_cont_vec[i],
914
+ cptr_slice,
915
+ p_tapped[i],
916
+ p_live[i],
917
+ opp_tapped[i],
918
+ p_trash[i],
919
+ b_map,
920
+ b_idx,
921
+ out_bonus_slice,
922
+ card_stats,
923
+ opp_stage[i], # Pass opponent stage
924
+ )
925
+
926
+
927
+ @njit(nopython=True, cache=True, inline="always", fastmath=True)
928
+ def copy_state(s_stg, s_ev, s_ec, s_cv, d_stg, d_ev, d_ec, d_cv):
929
+ d_stg[:] = s_stg[:]
930
+ d_ev[:] = s_ev[:]
931
+ d_ec[:] = s_ec[:]
932
+ d_cv[:] = s_cv[:]
933
+
934
+
935
+ @njit(nopython=True, cache=True, inline="always", fastmath=True)
936
+ def move_to_trash(i, card_id, batch_trash, batch_global_ctx, TR_idx):
937
+ if card_id <= 0:
938
+ return
939
+ for k in range(batch_trash.shape[1]):
940
+ if batch_trash[i, k] == 0:
941
+ batch_trash[i, k] = card_id
942
+ batch_global_ctx[i, TR_idx] += 1
943
+ break
944
+
945
+
946
+ @njit(nopython=True, cache=True, fastmath=True)
947
+ def resolve_live_single(
948
+ i,
949
+ live_id,
950
+ batch_stage,
951
+ batch_live,
952
+ batch_scores,
953
+ batch_global_ctx,
954
+ batch_deck,
955
+ batch_hand,
956
+ batch_trash,
957
+ card_stats,
958
+ p_cont_vec,
959
+ p_cont_ptr,
960
+ b_map,
961
+ b_idx,
962
+ p_tapped,
963
+ ):
964
+ # Rule 8.3.4: Non-Live cards are discarded before performance
965
+ live_bid = get_base_id(live_id)
966
+ if live_bid >= card_stats.shape[0] or card_stats[live_bid, 10] != 2:
967
+ # Find and remove from live zone
968
+ for j in range(batch_live.shape[1]):
969
+ if batch_live[i, j] == live_id:
970
+ move_to_trash(i, live_id, batch_trash, batch_global_ctx, 2)
971
+ batch_live[i, j] = 0
972
+ break
973
+ return 0
974
+
975
+ # 1. Verify availability in Live Zone
976
+ live_idx = -1
977
+ for j in range(batch_live.shape[1]):
978
+ if batch_live[i, j] == live_id:
979
+ live_idx = j
980
+ break
981
+
982
+ if live_idx == -1:
983
+ return 0
984
+
985
+ batch_global_ctx[i, LS] = 0 # Ensure reset score bonus
986
+ batch_global_ctx[i, EH] = 0 # Reset excess hearts
987
+
988
+ # 1. Trigger ON_LIVE_START (TriggerType = 2)
989
+ for ab_idx in range(4):
990
+ trigger_off = 20 if ab_idx == 0 else (20 + 16 + (ab_idx - 1) * 12)
991
+ if card_stats[live_bid, trigger_off] == 2: # ON_LIVE_START
992
+ map_idx = b_idx[live_bid, ab_idx]
993
+ if map_idx >= 0:
994
+ # Execution context
995
+ flat_ctx = np.zeros(64, dtype=np.int32)
996
+ dummy_opp_tapped = np.zeros(3, dtype=np.int32)
997
+ out_bonus = np.zeros(1, dtype=np.int32)
998
+ p_tapped_dummy = np.zeros(16, dtype=np.int32)
999
+
1000
+ resolve_bytecode(
1001
+ b_map[map_idx],
1002
+ flat_ctx,
1003
+ batch_global_ctx[i],
1004
+ 0,
1005
+ batch_hand[i],
1006
+ batch_deck[i],
1007
+ batch_stage[i],
1008
+ np.zeros((3, 32), dtype=np.int32),
1009
+ np.zeros(3, dtype=np.int32),
1010
+ p_cont_vec[i],
1011
+ p_cont_ptr[i : i + 1],
1012
+ p_tapped,
1013
+ batch_live[i],
1014
+ dummy_opp_tapped,
1015
+ batch_trash[i],
1016
+ b_map,
1017
+ b_idx,
1018
+ out_bonus,
1019
+ card_stats,
1020
+ batch_stage[i],
1021
+ )
1022
+
1023
+ # 2. Check Requirements
1024
+ # Get Live Stats (indices 12-18 for requirements)
1025
+ req_pink = card_stats[live_bid, 12]
1026
+ req_red = card_stats[live_bid, 13]
1027
+ req_yel = card_stats[live_bid, 14]
1028
+ req_grn = card_stats[live_bid, 15]
1029
+ req_blu = card_stats[live_bid, 16]
1030
+ req_pur = card_stats[live_bid, 17]
1031
+
1032
+ # Sum Stage Stats
1033
+ stage_hearts = np.zeros(7, dtype=np.int32)
1034
+ stage_all = 0
1035
+ total_blades = 0
1036
+
1037
+ for slot in range(3):
1038
+ # Rule 9.9: Resting members (tapped) do not contribute to performance.
1039
+ cid = batch_stage[i, slot]
1040
+ if cid >= 0 and p_tapped[slot] == 0:
1041
+ bid = get_base_id(cid)
1042
+ if bid < card_stats.shape[0]:
1043
+ slot_blades = card_stats[bid, 1]
1044
+ slot_hearts = np.zeros(7, dtype=np.int32)
1045
+ for cidx in range(6):
1046
+ slot_hearts[cidx] = card_stats[bid, 12 + cidx]
1047
+ slot_all = card_stats[bid, 18]
1048
+
1049
+ # Apply continuous effects
1050
+ for ce_idx in range(p_cont_ptr[i]):
1051
+ op = p_cont_vec[i, ce_idx, 0]
1052
+ val = p_cont_vec[i, ce_idx, 1]
1053
+ target_slot = p_cont_vec[i, ce_idx, 2]
1054
+ active = p_cont_vec[i, ce_idx, 9]
1055
+
1056
+ if active == 1 and (target_slot == -1 or target_slot == slot):
1057
+ if op == 1 or op == 8: # BLADES / BUFF
1058
+ slot_blades += val
1059
+ elif op == 24: # SET_BLADES
1060
+ slot_blades = val
1061
+ elif op == 2: # HEARTS
1062
+ a_color = p_cont_vec[i, ce_idx, 5]
1063
+ if 0 <= a_color <= 5:
1064
+ slot_hearts[a_color] += val
1065
+ elif a_color == 6: # Any/Rainbow
1066
+ slot_all += val
1067
+
1068
+ total_blades += max(0, slot_blades)
1069
+ for cidx in range(6):
1070
+ stage_hearts[cidx] += slot_hearts[cidx]
1071
+ stage_all += slot_all
1072
+
1073
+ # Apply Heart Requirement Reductions
1074
+ for ce_idx in range(p_cont_ptr[i]):
1075
+ op = p_cont_vec[i, ce_idx, 0]
1076
+ val = p_cont_vec[i, ce_idx, 1]
1077
+ active = p_cont_vec[i, ce_idx, 9]
1078
+ if active == 1 and op == 48: # REDUCE_HEART_REQ
1079
+ target_color = p_cont_vec[i, ce_idx, 2] # 0-5 or 6 (Any)
1080
+ if target_color == 0:
1081
+ req_pink = max(0, req_pink - val)
1082
+ elif target_color == 1:
1083
+ req_red = max(0, req_red - val)
1084
+ elif target_color == 2:
1085
+ req_yel = max(0, req_yel - val)
1086
+ elif target_color == 3:
1087
+ req_grn = max(0, req_grn - val)
1088
+ elif target_color == 4:
1089
+ req_blu = max(0, req_blu - val)
1090
+ elif target_color == 5:
1091
+ req_pur = max(0, req_pur - val)
1092
+ elif target_color == 6: # Reduction for 'Any' requirement
1093
+ # In this simplified model, card_stats[live_bid, 18] might be 'Any'
1094
+ # but it's not being checked yet. Let's assume for now
1095
+ # that we don't have a specific index for 'Any' req in card_stats.
1096
+ # Actually, let's look for where 'any' might be.
1097
+ pass
1098
+
1099
+ # --- RULE ACCURACY: Yells (Rule 8.3) ---
1100
+ # Draw 'total_blades' cards from deck, apply their blade_hearts and volume/draw icons.
1101
+ volume_bonus = 0
1102
+ draw_bonus = 0
1103
+ yells_processed = 0
1104
+
1105
+ # Use a while loop to handle potential deck refreshes
1106
+ while yells_processed < total_blades:
1107
+ # Check if we need to refresh (deck empty/exhausted)
1108
+ # Scan for next valid card
1109
+ found_card = False
1110
+ deck_len = batch_deck.shape[1]
1111
+ d_idx = -1
1112
+
1113
+ for k in range(deck_len):
1114
+ if batch_deck[i, k] > 0:
1115
+ d_idx = k
1116
+ found_card = True
1117
+ break
1118
+
1119
+ if not found_card:
1120
+ # Deck is empty, trigger refresh logic
1121
+ check_deck_refresh(i, batch_deck, batch_trash, batch_global_ctx, 6, 2)
1122
+ # Try to find again
1123
+ for k in range(deck_len):
1124
+ if batch_deck[i, k] > 0:
1125
+ d_idx = k
1126
+ found_card = True
1127
+ break
1128
+
1129
+ # If still no card (Deck + Trash were empty), we stop yelling.
1130
+ if not found_card:
1131
+ break
1132
+
1133
+ # Process the found card
1134
+ if d_idx >= 0:
1135
+ yid = batch_deck[i, d_idx]
1136
+ if yid > 0 and yid < card_stats.shape[0]:
1137
+ # Extract blade_hearts [40:47]
1138
+ bh = np.zeros(7, dtype=np.int32)
1139
+ for cidx in range(7):
1140
+ bh[cidx] = card_stats[yid, 40 + cidx]
1141
+
1142
+ # Apply TRANSFORM_COLOR (Rule 11.12)
1143
+ for ce_idx in range(p_cont_ptr[i]):
1144
+ if p_cont_vec[i, ce_idx, 0] == O_TRANSFORM_COLOR:
1145
+ target_idx = p_cont_vec[i, ce_idx, 2]
1146
+ if 0 <= target_idx <= 5:
1147
+ total_affected = 0
1148
+ # Transform Pink, Red, Yellow, Green, Blue, (and All if applicable)
1149
+ # Rule for Dazzling Game: 0,1,2,3,4,6 -> target_idx
1150
+ for src_idx in [0, 1, 2, 3, 4, 6]:
1151
+ total_affected += bh[src_idx]
1152
+ bh[src_idx] = 0
1153
+ bh[target_idx] += total_affected
1154
+
1155
+ # Add to totals
1156
+ for cidx in range(7):
1157
+ stage_hearts[cidx] += bh[cidx]
1158
+
1159
+ # Bonus Icons
1160
+ volume_bonus += card_stats[yid, 4]
1161
+ draw_bonus += card_stats[yid, 5]
1162
+
1163
+ # Discard (Remove from deck)
1164
+ batch_deck[i, d_idx] = 0
1165
+ if batch_global_ctx[i, 6] > 0:
1166
+ batch_global_ctx[i, 6] -= 1 # DK
1167
+ move_to_trash(i, yid, batch_trash, batch_global_ctx, 2)
1168
+ yells_processed += 1
1169
+ else:
1170
+ # Invalid card ID or padding
1171
+ batch_deck[i, d_idx] = 0
1172
+ yells_processed += 1
1173
+ else:
1174
+ break
1175
+
1176
+ # Apply Draw Bonus
1177
+ cards_drawn = 0
1178
+ while cards_drawn < draw_bonus:
1179
+ found_card = False
1180
+ d_idx = -1
1181
+ deck_len = batch_deck.shape[1]
1182
+
1183
+ for k in range(deck_len):
1184
+ if batch_deck[i, k] > 0:
1185
+ d_idx = k
1186
+ found_card = True
1187
+ break
1188
+
1189
+ if not found_card:
1190
+ check_deck_refresh(i, batch_deck, batch_trash, batch_global_ctx, 6, 2)
1191
+ for k in range(deck_len):
1192
+ if batch_deck[i, k] > 0:
1193
+ d_idx = k
1194
+ found_card = True
1195
+ break
1196
+ if not found_card:
1197
+ break
1198
+
1199
+ if found_card and d_idx != -1:
1200
+ # Draw the card
1201
+ top_c = batch_deck[i, d_idx]
1202
+ # Move to Hand
1203
+ placed = False
1204
+ for h_ptr in range(batch_hand.shape[1]):
1205
+ if batch_hand[i, h_ptr] == 0:
1206
+ batch_hand[i, h_ptr] = top_c
1207
+ placed = True
1208
+ break
1209
+
1210
+ # Remove from Deck regardless (it left the deck)
1211
+ batch_deck[i, d_idx] = 0
1212
+ if batch_global_ctx[i, 6] > 0:
1213
+ batch_global_ctx[i, 6] -= 1 # DK
1214
+
1215
+ if placed:
1216
+ batch_global_ctx[i, 3] += 1 # HD
1217
+ else:
1218
+ # Hand full, discard to trash
1219
+ move_to_trash(i, top_c, batch_trash, batch_global_ctx, 2)
1220
+ else:
1221
+ break
1222
+
1223
+ cards_drawn += 1
1224
+
1225
+ # Apply Heart Requirement Reductions
1226
+ req_any = card_stats[live_bid, 18]
1227
+ for ce_idx in range(p_cont_ptr[i]):
1228
+ op = p_cont_vec[i, ce_idx, 0]
1229
+ val = p_cont_vec[i, ce_idx, 1]
1230
+ active = p_cont_vec[i, ce_idx, 9]
1231
+ if active == 1 and op == 48: # REDUCE_HEART_REQ
1232
+ target_color = p_cont_vec[i, ce_idx, 2] # 0-5 or 6 (Any)
1233
+ if target_color == 0:
1234
+ req_pink = max(0, req_pink - val)
1235
+ elif target_color == 1:
1236
+ req_red = max(0, req_red - val)
1237
+ elif target_color == 2:
1238
+ req_yel = max(0, req_yel - val)
1239
+ elif target_color == 3:
1240
+ req_grn = max(0, req_grn - val)
1241
+ elif target_color == 4:
1242
+ req_blu = max(0, req_blu - val)
1243
+ elif target_color == 5:
1244
+ req_pur = max(0, req_pur - val)
1245
+ elif target_color == 6:
1246
+ req_any = max(0, req_any - val)
1247
+
1248
+ # Verify Requirements (Greedy points matching)
1249
+ met = True
1250
+ temp_all = stage_all
1251
+ req_list = [req_pink, req_red, req_yel, req_grn, req_blu, req_pur]
1252
+
1253
+ for cidx in range(6):
1254
+ needed = req_list[cidx]
1255
+ have = stage_hearts[cidx]
1256
+ if have < needed:
1257
+ deficit = needed - have
1258
+ if temp_all >= deficit:
1259
+ temp_all -= deficit
1260
+ else:
1261
+ met = False
1262
+ break
1263
+
1264
+ if met:
1265
+ remaining = temp_all
1266
+ for cidx in range(6):
1267
+ remaining += max(0, stage_hearts[cidx] - req_list[cidx])
1268
+ if remaining < req_any:
1269
+ met = False
1270
+
1271
+ # 3. Apply Result (Defer to Battle Phase)
1272
+ if met and total_blades > 0:
1273
+ # SUCCESS PENDING
1274
+
1275
+ # --- RULE ACCURACY: Excess Hearts (Rule 8.3) ---
1276
+ total_p_hearts = (
1277
+ stage_hearts[0]
1278
+ + stage_hearts[1]
1279
+ + stage_hearts[2]
1280
+ + stage_hearts[3]
1281
+ + stage_hearts[4]
1282
+ + stage_hearts[5]
1283
+ + stage_all
1284
+ )
1285
+ req_total = req_pink + req_red + req_yel + req_grn + req_blu + req_pur
1286
+ batch_global_ctx[i, EH] = max(0, total_p_hearts - req_total)
1287
+
1288
+ # 4. Trigger ON_LIVE_SUCCESS (TriggerType = 3)
1289
+ for ab_idx in range(4):
1290
+ trigger_off = 20 if ab_idx == 0 else (20 + 16 + (ab_idx - 1) * 12)
1291
+ if card_stats[live_id, trigger_off] == 3: # ON_LIVE_SUCCESS
1292
+ map_idx = b_idx[live_id, ab_idx]
1293
+ if map_idx >= 0:
1294
+ # Execution context
1295
+ flat_ctx = np.zeros(64, dtype=np.int32)
1296
+ dummy_opp_tapped = np.zeros(3, dtype=np.int32)
1297
+ out_bonus_ab = np.zeros(1, dtype=np.int32)
1298
+ p_tapped_dummy = np.zeros(16, dtype=np.int32)
1299
+
1300
+ resolve_bytecode(
1301
+ b_map[map_idx],
1302
+ flat_ctx,
1303
+ batch_global_ctx[i],
1304
+ 0,
1305
+ batch_hand[i],
1306
+ batch_deck[i],
1307
+ batch_stage[i],
1308
+ np.zeros((3, 32), dtype=np.int32),
1309
+ np.zeros(3, dtype=np.int32),
1310
+ p_cont_vec[i],
1311
+ p_cont_ptr[i : i + 1],
1312
+ p_tapped_dummy,
1313
+ batch_live[i],
1314
+ dummy_opp_tapped,
1315
+ batch_trash[i],
1316
+ b_map,
1317
+ b_idx,
1318
+ out_bonus_ab,
1319
+ card_stats,
1320
+ opp_stage[i], # Opponent stage
1321
+ )
1322
+
1323
+ base_score = card_stats[live_id, 38]
1324
+ if base_score <= 0:
1325
+ base_score = 1 # Safety
1326
+
1327
+ total_score = base_score + volume_bonus + batch_global_ctx[i, LS]
1328
+ batch_global_ctx[i, LS] = 0 # Reset after applying
1329
+ batch_global_ctx[i, EH] = 0 # Reset after triggering success
1330
+
1331
+ # Clear Stage MEMBERS (Rule 8.3.17) - Members are cleared after performance regardless of battle result?
1332
+ # Rule 8.4.8 says "Clear ... remaining cards". Rule 8.3.17 says "Clear stage".
1333
+ # Assume stage clear happens here.
1334
+ for slot in range(3):
1335
+ cid = batch_stage[i, slot]
1336
+ if cid > 0:
1337
+ move_to_trash(i, cid, batch_trash, batch_global_ctx, 2)
1338
+ batch_stage[i, slot] = -1
1339
+
1340
+ # We DO NOT remove the live card yet. It waits for battle.
1341
+ return total_score
1342
+ else:
1343
+ # FAILURE - Cards in zone are discarded after performance (Rule 8.3.16)
1344
+ if live_idx >= 0:
1345
+ move_to_trash(i, live_id, batch_trash, batch_global_ctx, 2)
1346
+ batch_live[i, live_idx] = 0
1347
+
1348
+ # Also clear stage on failure
1349
+ for slot in range(3):
1350
+ cid = batch_stage[i, slot]
1351
+ if cid > 0:
1352
+ move_to_trash(i, cid, batch_trash, batch_global_ctx, 2)
1353
+ batch_stage[i, slot] = -1
1354
+
1355
+ return 0
1356
+
1357
+
1358
+ @njit(nopython=True)
1359
+ def p_deck_len_helper(batch_deck, i):
1360
+ return batch_deck.shape[1]
1361
+
1362
+
1363
+ @njit(nopython=True)
1364
+ def p_hand_len_helper(batch_hand, i):
1365
+ return batch_hand.shape[1]
1366
+
1367
+
1368
+ @njit(nopython=True, cache=True)
1369
+ def check_deck_refresh(i, p_deck, p_trash, g_ctx, DK_idx, TR_idx):
1370
+ # Rule 10.2: If deck empty and trash has cards, refresh.
1371
+ if g_ctx[i, DK_idx] <= 0 and g_ctx[i, TR_idx] > 0:
1372
+ # Move trash to deck
1373
+ d_ptr = 0
1374
+ for k in range(p_trash.shape[1]):
1375
+ cid = p_trash[i, k]
1376
+ if cid > 0:
1377
+ p_deck[i, d_ptr] = cid
1378
+ p_trash[i, k] = 0
1379
+ d_ptr += 1
1380
+
1381
+ g_ctx[i, DK_idx] = d_ptr
1382
+ g_ctx[i, TR_idx] = 0
1383
+ # Shuffle Deck
1384
+ if d_ptr > 1:
1385
+ for k in range(d_ptr - 1, 0, -1):
1386
+ j = np.random.randint(0, k + 1)
1387
+ tmp = p_deck[i, k]
1388
+ p_deck[i, k] = p_deck[i, j]
1389
+ p_deck[i, j] = tmp
1390
+
1391
+
1392
+ @njit(nopython=True, cache=True, fastmath=True)
1393
+ def resolve_live_performance(
1394
+ num_envs: int,
1395
+ action_ids: np.ndarray,
1396
+ batch_stage: np.ndarray,
1397
+ batch_live: np.ndarray,
1398
+ batch_scores: np.ndarray,
1399
+ batch_global_ctx: np.ndarray,
1400
+ batch_deck: np.ndarray,
1401
+ batch_hand: np.ndarray,
1402
+ batch_trash: np.ndarray,
1403
+ card_stats: np.ndarray,
1404
+ batch_cont_vec: np.ndarray,
1405
+ batch_cont_ptr: np.ndarray,
1406
+ batch_tapped: np.ndarray,
1407
+ b_map: np.ndarray,
1408
+ b_idx: np.ndarray,
1409
+ ):
1410
+ for i in range(num_envs):
1411
+ resolve_live_single(
1412
+ i,
1413
+ action_ids[i],
1414
+ batch_stage,
1415
+ batch_live,
1416
+ batch_scores,
1417
+ batch_global_ctx,
1418
+ batch_deck,
1419
+ batch_hand,
1420
+ batch_trash,
1421
+ card_stats,
1422
+ batch_cont_vec[i],
1423
+ batch_cont_ptr[i : i + 1],
1424
+ b_map,
1425
+ b_idx,
1426
+ batch_tapped[i],
1427
+ )
1428
+
1429
+
1430
+ @njit(nopython=True, parallel=True, cache=True, fastmath=True)
1431
+ def batch_apply_action(
1432
+ actions,
1433
+ pid,
1434
+ p_stg,
1435
+ p_ev,
1436
+ p_ec,
1437
+ p_cv,
1438
+ p_cp,
1439
+ p_tap,
1440
+ p_sb,
1441
+ p_lr,
1442
+ o_tap,
1443
+ f_ctx_batch,
1444
+ g_ctx_batch,
1445
+ p_h,
1446
+ p_d,
1447
+ p_tr, # Added
1448
+ b_map,
1449
+ b_idx,
1450
+ card_stats,
1451
+ ):
1452
+ num_envs = actions.shape[0]
1453
+ batch_delta_bonus = np.zeros(num_envs, dtype=np.int32)
1454
+
1455
+ for i in prange(num_envs):
1456
+ g_ctx_batch[i, SC] = p_sb[i]
1457
+ act_id = actions[i]
1458
+
1459
+ if act_id == 0:
1460
+ # Pass triggers Performance Phase (Rule 8) for all set lives
1461
+ for z_idx in range(10): # Limit scan
1462
+ lid = p_lr[i, z_idx]
1463
+ if lid > 0:
1464
+ resolve_live_single(
1465
+ i,
1466
+ lid,
1467
+ p_stg,
1468
+ p_lr,
1469
+ p_sb,
1470
+ g_ctx_batch,
1471
+ p_d,
1472
+ p_h,
1473
+ p_tr,
1474
+ card_stats,
1475
+ p_cv[i],
1476
+ p_cp[i],
1477
+ b_map,
1478
+ b_idx,
1479
+ p_tap[i],
1480
+ )
1481
+ g_ctx_batch[i, PH] = 8
1482
+
1483
+ elif 1 <= act_id <= 180:
1484
+ # Member play logic... (Keeping original stable version)
1485
+ adj = act_id - 1
1486
+ hand_idx = adj // 3
1487
+ slot = adj % 3
1488
+ if hand_idx < p_h.shape[1]:
1489
+ card_id = p_h[i, hand_idx]
1490
+ if card_id > 0 and card_id < card_stats.shape[0]:
1491
+ cost = card_stats[card_id, 0]
1492
+ effective_cost = cost
1493
+ prev_cid = p_stg[i, slot]
1494
+ if prev_cid >= 0 and prev_cid < card_stats.shape[0]:
1495
+ prev_cost = card_stats[prev_cid, 0]
1496
+ effective_cost = cost - prev_cost
1497
+ if effective_cost < 0:
1498
+ effective_cost = 0
1499
+
1500
+ # Rule 6.4.1 & Parity with get_member_cost: Substract continuous cost reductions
1501
+ total_reduction = 0
1502
+ for ce_k in range(p_cp[i, 0]): # p_cp is ptr array
1503
+ if p_cv[i, ce_k, 0] == 3: # REDUCE_COST type
1504
+ total_reduction += p_cv[i, ce_k, 1]
1505
+
1506
+ effective_cost -= total_reduction
1507
+ if effective_cost < 0:
1508
+ effective_cost = 0
1509
+
1510
+ # Capture the ID of the card being replaced (if any) for Baton Pass
1511
+ prev_cid = p_stg[i, slot]
1512
+ g_ctx_batch[i, PREV_CID_IDX] = prev_cid
1513
+
1514
+ ec = g_ctx_batch[i, EN]
1515
+ if ec > 12:
1516
+ ec = 12
1517
+ paid = 0
1518
+ if effective_cost > 0:
1519
+ for e_idx in range(ec):
1520
+ if 3 + e_idx < 16:
1521
+ if p_tap[i, 3 + e_idx] == 0:
1522
+ p_tap[i, 3 + e_idx] = 1
1523
+ paid += 1
1524
+ if paid >= effective_cost:
1525
+ break
1526
+ else:
1527
+ break
1528
+
1529
+ if prev_cid > 0:
1530
+ move_to_trash(i, prev_cid, p_tr, g_ctx_batch, 2)
1531
+ p_stg[i, slot] = card_id
1532
+
1533
+ # Fix Duplication: Remove from hand
1534
+ p_h[i, hand_idx] = 0
1535
+ g_ctx_batch[i, 3] -= 1
1536
+
1537
+ # Resolve Auto-Effects (On Play)
1538
+ if card_id > 0 and card_id < b_idx.shape[0]:
1539
+ map_idx = b_idx[card_id, 0]
1540
+ g_ctx_batch[i, 51 + slot] = 1
1541
+
1542
+ if card_id < b_idx.shape[0]:
1543
+ map_idx = b_idx[card_id, 0]
1544
+ if map_idx >= 0:
1545
+ code_seq = b_map[map_idx]
1546
+ f_ctx_batch[i, 7] = 1
1547
+ f_ctx_batch[i, SID] = card_id
1548
+ p_cp_slice = p_cp[i : i + 1]
1549
+ out_bonus_slice = batch_delta_bonus[i : i + 1]
1550
+ out_bonus_slice[0] = 0
1551
+ resolve_bytecode(
1552
+ code_seq,
1553
+ f_ctx_batch[i],
1554
+ g_ctx_batch[i],
1555
+ pid,
1556
+ p_h[i],
1557
+ p_d[i],
1558
+ p_stg[i],
1559
+ p_ev[i],
1560
+ p_ec[i],
1561
+ p_cv[i],
1562
+ p_cp_slice,
1563
+ p_tap[i],
1564
+ p_lr[i],
1565
+ o_tap[i],
1566
+ p_tr[i],
1567
+ b_map,
1568
+ b_idx,
1569
+ out_bonus_slice,
1570
+ card_stats,
1571
+ o_tap[i],
1572
+ )
1573
+ p_sb[i] += out_bonus_slice[0]
1574
+ f_ctx_batch[i, 7] = 0
1575
+
1576
+ elif 200 <= act_id <= 202:
1577
+ # Activation logic...
1578
+ slot = act_id - 200
1579
+ card_id = p_stg[i, slot]
1580
+ if card_id >= 0 and card_id < b_idx.shape[0]:
1581
+ map_idx = b_idx[card_id, 0]
1582
+ if map_idx >= 0:
1583
+ code_seq = b_map[map_idx]
1584
+ f_ctx_batch[i, 7] = 1
1585
+ f_ctx_batch[i, SID] = card_id
1586
+ p_cp_slice = p_cp[i : i + 1]
1587
+ out_bonus_slice = batch_delta_bonus[i : i + 1]
1588
+ out_bonus_slice[0] = 0
1589
+ resolve_bytecode(
1590
+ code_seq,
1591
+ f_ctx_batch[i],
1592
+ g_ctx_batch[i],
1593
+ pid,
1594
+ p_h[i],
1595
+ p_d[i],
1596
+ p_stg[i],
1597
+ p_ev[i],
1598
+ p_ec[i],
1599
+ p_cv[i],
1600
+ p_cp_slice,
1601
+ p_tap[i],
1602
+ p_lr[i],
1603
+ o_tap[i],
1604
+ p_tr[i],
1605
+ b_map,
1606
+ b_idx,
1607
+ out_bonus_slice,
1608
+ card_stats,
1609
+ o_tap[i],
1610
+ )
1611
+ p_sb[i] += out_bonus_slice[0]
1612
+ f_ctx_batch[i, 7] = 0
1613
+ p_tap[i, slot] = 1
1614
+
1615
+ elif 400 <= act_id <= 459:
1616
+ # Set Live Card from Hand (Rule 8.3)
1617
+ hand_idx = act_id - 400
1618
+ if hand_idx < p_h.shape[1]:
1619
+ card_id = p_h[i, hand_idx]
1620
+ if card_id > 0: # Allow any card (Rule 8.3 & 8.2.2)
1621
+ # Find empty slot in live zone (max 3)
1622
+ for z_idx in range(p_lr.shape[1]):
1623
+ if p_lr[i, z_idx] == 0:
1624
+ p_lr[i, z_idx] = card_id
1625
+ p_h[i, hand_idx] = 0
1626
+ g_ctx_batch[i, 3] -= 1 # HD
1627
+
1628
+ # Rule 8.2.2: Draw 1 card after placing
1629
+ # Check Refresh first
1630
+ check_deck_refresh(i, p_d, p_tr, g_ctx_batch, 6, 2)
1631
+
1632
+ # Find top card
1633
+ d_idx = -1
1634
+ for k in range(p_d.shape[1]): # Fixed 60 -> shape[1]
1635
+ if p_d[i, k] > 0:
1636
+ d_idx = k
1637
+ break
1638
+
1639
+ if d_idx != -1:
1640
+ top_c = p_d[i, d_idx]
1641
+ p_d[i, d_idx] = 0
1642
+ g_ctx_batch[i, 6] -= 1 # DK
1643
+
1644
+ # Add to hand
1645
+ placed = False
1646
+ for h_ptr in range(p_h.shape[1]):
1647
+ if p_h[i, h_ptr] == 0:
1648
+ p_h[i, h_ptr] = top_c
1649
+ g_ctx_batch[i, 3] += 1 # HD
1650
+ placed = True
1651
+ break
1652
+
1653
+ if not placed:
1654
+ # Hand full, discard
1655
+ move_to_trash(i, top_c, p_tr, g_ctx_batch, 2)
1656
+
1657
+ break
1658
+
1659
+ elif 500 <= act_id <= 559: # STANDARD Hand Selection
1660
+ f_ctx_batch[i, 15] = act_id - 500
1661
+ g_ctx_batch[i, 8] = 4
1662
+ elif 600 <= act_id <= 611: # STANDARD Energy Selection
1663
+ f_ctx_batch[i, 15] = act_id - 600
1664
+ g_ctx_batch[i, 8] = 4
1665
+ elif 100 <= act_id <= 159: # ATTENTION Hand Selection
1666
+ f_ctx_batch[i, 15] = act_id - 100
1667
+ g_ctx_batch[i, 8] = 4
1668
+ elif 160 <= act_id <= 171: # ATTENTION Energy Selection
1669
+ f_ctx_batch[i, 15] = act_id - 160
1670
+ g_ctx_batch[i, 8] = 4
1671
+
1672
+ p_sb[i] = g_ctx_batch[i, SC]
1673
+
1674
+
1675
+ @njit(nopython=True, cache=True, fastmath=True)
1676
+ def apply_action(aid, pid, p_stg, p_ev, p_ec, p_cv, p_cp, p_tap, p_sb, p_lr, o_tap, f_ctx, g_ctx, p_h, p_d, p_tr):
1677
+ # Specialized fast-path for Action 1 (Simulation)
1678
+ if aid == 1:
1679
+ bc = np.zeros((1, 4), dtype=np.int32)
1680
+ bc[0, 0] = 11 # O_BLADES
1681
+ bc[0, 1] = 1
1682
+ bc[0, 3] = 0
1683
+
1684
+ d_map = np.zeros((1, 1, 4), dtype=np.int32)
1685
+ d_idx = np.zeros((1, 4), dtype=np.int32)
1686
+
1687
+ cptr_arr = np.array([p_cp], dtype=np.int32)
1688
+ bn_arr = np.zeros(1, dtype=np.int32)
1689
+
1690
+ resolve_bytecode(
1691
+ bc,
1692
+ f_ctx,
1693
+ g_ctx,
1694
+ pid,
1695
+ p_h,
1696
+ p_d,
1697
+ p_stg,
1698
+ p_ev,
1699
+ p_ec,
1700
+ p_cv,
1701
+ cptr_arr,
1702
+ p_tap,
1703
+ p_lr,
1704
+ o_tap,
1705
+ p_tr, # Pass trash
1706
+ d_map,
1707
+ d_idx,
1708
+ bn_arr,
1709
+ o_tap, # Pass opp_tapped count
1710
+ )
1711
+ return cptr_arr[0], p_sb + bn_arr[0]
1712
+ return p_cp, p_sb
1713
+
1714
+
1715
+ @njit(nopython=True, cache=True)
1716
+ def _move_to_trash_single(card_id, p_trash, p_global_ctx, TR_idx):
1717
+ if card_id <= 0:
1718
+ return
1719
+ for k in range(p_trash.shape[0]):
1720
+ if p_trash[k] == 0:
1721
+ p_trash[k] = card_id
1722
+ p_global_ctx[TR_idx] += 1
1723
+ break
1724
+
1725
+
1726
+ @njit(nopython=True, cache=True)
1727
+ def _check_deck_refresh_single(p_deck, p_trash, p_global_ctx, DK_idx, TR_idx):
1728
+ if p_global_ctx[DK_idx] <= 0 and p_global_ctx[TR_idx] > 0:
1729
+ # Move trash to deck
1730
+ d_ptr = 0
1731
+ for k in range(p_trash.shape[0]):
1732
+ if p_trash[k] > 0:
1733
+ p_deck[d_ptr] = p_trash[k]
1734
+ p_trash[k] = 0
1735
+ d_ptr += 1
1736
+
1737
+ p_global_ctx[DK_idx] = d_ptr
1738
+ p_global_ctx[TR_idx] = 0
1739
+
1740
+ # Shuffle
1741
+ if d_ptr > 1:
1742
+ for k in range(d_ptr - 1, 0, -1):
1743
+ j = np.random.randint(0, k + 1)
1744
+ tmp = p_deck[k]
1745
+ p_deck[k] = p_deck[j]
1746
+ p_deck[j] = tmp
1747
+
1748
+
1749
+ @njit(nopython=True, cache=True)
1750
+ def select_heuristic_action(
1751
+ p_hand,
1752
+ p_deck,
1753
+ p_stage,
1754
+ p_energy_vec,
1755
+ p_energy_count,
1756
+ p_tapped,
1757
+ p_live,
1758
+ p_scores,
1759
+ p_global_ctx,
1760
+ p_trash,
1761
+ o_tapped,
1762
+ card_stats,
1763
+ bytecode_index,
1764
+ ):
1765
+ """
1766
+ Selects a single heuristic action for the given player state.
1767
+ Returns: action_id (int)
1768
+ """
1769
+ # --- 1. Select Action (Score-Based Heuristic) ---
1770
+ best_action = 0
1771
+ best_score = -1.0
1772
+
1773
+ # A. Hand Actions (Play Member / Set Live)
1774
+ for h in range(p_hand.shape[0]):
1775
+ cid = p_hand[h]
1776
+ if cid > 0:
1777
+ if cid > 900: # Live Card (Simplified Check > 900)
1778
+ # Should verify type in card_stats if available, but >900 heuristic is okay for now
1779
+ if card_stats[cid, 10] == 2: # Live
1780
+ # Prefer setting live
1781
+ empty_live = 0
1782
+ for z in range(p_live.shape[0]):
1783
+ if p_live[z] == 0:
1784
+ empty_live += 1
1785
+ if empty_live > 0:
1786
+ score = 100.0 + np.random.random() * 10.0 # High priority
1787
+ if score > best_score:
1788
+ best_score = score
1789
+ best_action = 400 + h
1790
+ else: # Member Card
1791
+ if card_stats[cid, 10] == 1: # Member
1792
+ cost = card_stats[cid, 0]
1793
+ ec = p_global_ctx[5] # EN
1794
+ if ec >= cost:
1795
+ score = (cost * 15.0) + (np.random.random() * 5.0) # Favor high cost
1796
+ # If we have lots of energy, prioritize spending it
1797
+ if ec > 5:
1798
+ score += 5.0
1799
+
1800
+ # Check slots
1801
+ for s in range(3):
1802
+ # Prefer empty slots or replacing weak low-cost cards
1803
+ prev_cid = p_stage[s]
1804
+
1805
+ effective_cost = cost
1806
+ if prev_cid > 0:
1807
+ prev_cost = card_stats[prev_cid, 0]
1808
+ effective_cost = max(0, cost - prev_cost)
1809
+ # Bonus for upgrading
1810
+ if cost > prev_cost:
1811
+ score += 10.0
1812
+
1813
+ # Tapping Check (simplified heuristic)
1814
+ # Assume if we have EC >= EffCost, we can pay.
1815
+ if ec >= effective_cost:
1816
+ current_act = (h * 3) + s + 1
1817
+ if score > best_score:
1818
+ best_score = score
1819
+ best_action = current_act
1820
+
1821
+ # B. Stage Actions (Activate)
1822
+ for s in range(3):
1823
+ cid = p_stage[s]
1824
+ if cid > 0 and not p_tapped[s]:
1825
+ if cid < bytecode_index.shape[0]:
1826
+ if bytecode_index[cid, 0] >= 0:
1827
+ # Activation is usually good (draw, boost, etc.)
1828
+ score = 25.0 + np.random.random() * 5.0
1829
+ if score > best_score:
1830
+ best_score = score
1831
+ best_action = 200 + s
1832
+
1833
+ return best_action
1834
+
1835
+
1836
+ @njit(nopython=True, cache=True)
1837
+ def run_opponent_turn_loop(
1838
+ p_hand,
1839
+ p_deck,
1840
+ p_stage,
1841
+ p_energy_vec,
1842
+ p_energy_count,
1843
+ p_tapped,
1844
+ p_live,
1845
+ p_scores,
1846
+ p_global_ctx,
1847
+ p_trash,
1848
+ p_continuous_vec,
1849
+ p_continuous_ptr,
1850
+ o_tapped,
1851
+ card_stats,
1852
+ bytecode_map,
1853
+ bytecode_index,
1854
+ ):
1855
+ """
1856
+ Simulates a full opponent turn by looping actions until Pass (0) is chosen.
1857
+ Operating on single-environment slices (1D/2D).
1858
+ """
1859
+ # Safety limit to prevent infinite loops
1860
+ for step_count in range(20):
1861
+ action = select_heuristic_action(
1862
+ p_hand,
1863
+ p_deck,
1864
+ p_stage,
1865
+ p_energy_vec,
1866
+ p_energy_count,
1867
+ p_tapped,
1868
+ p_live,
1869
+ p_scores,
1870
+ p_global_ctx,
1871
+ p_trash,
1872
+ o_tapped,
1873
+ card_stats,
1874
+ bytecode_index,
1875
+ )
1876
+
1877
+ # --- 2. Execute Action ---
1878
+ if action == 0:
1879
+ return
1880
+
1881
+ if 1 <= action <= 180:
1882
+ adj = action - 1
1883
+ hand_idx = adj // 3
1884
+ slot = adj % 3
1885
+
1886
+ if hand_idx < p_hand.shape[0]:
1887
+ card_id = p_hand[hand_idx]
1888
+ if card_id > 0:
1889
+ cost = card_stats[card_id, 0]
1890
+ prev_cid = p_stage[slot]
1891
+ effective_cost = cost
1892
+ if prev_cid > 0:
1893
+ prev_cost = card_stats[prev_cid, 0]
1894
+ effective_cost = max(0, cost - prev_cost)
1895
+
1896
+ untapped_e = np.zeros(16, dtype=np.int32)
1897
+ ue_ptr = 0
1898
+ en_count = p_global_ctx[5]
1899
+
1900
+ for e_idx in range(en_count):
1901
+ if 3 + e_idx < p_tapped.shape[0]:
1902
+ if p_tapped[3 + e_idx] == 0:
1903
+ untapped_e[ue_ptr] = 3 + e_idx
1904
+ ue_ptr += 1
1905
+
1906
+ can_pay = True
1907
+ if ue_ptr < effective_cost:
1908
+ can_pay = False
1909
+
1910
+ if can_pay:
1911
+ # Pay
1912
+ for p_idx in range(effective_cost):
1913
+ tap_idx = untapped_e[p_idx]
1914
+ p_tapped[tap_idx] = 1
1915
+
1916
+ # Move prev_cid to trash (if it exists)
1917
+ _move_to_trash_single(prev_cid, p_trash, p_global_ctx, 2)
1918
+
1919
+ # Capture prev_cid for Baton Pass logic
1920
+ p_global_ctx[60] = prev_cid
1921
+
1922
+ p_stage[slot] = card_id
1923
+ p_hand[hand_idx] = 0
1924
+ p_global_ctx[3] -= 1 # HD
1925
+
1926
+ # Note: g_ctx must be passed correctly if resolve_bytecode needs it
1927
+ p_global_ctx[51 + slot] = 1 # Mark played
1928
+
1929
+ if card_id < bytecode_index.shape[0]:
1930
+ map_idx = bytecode_index[card_id, 0]
1931
+ if map_idx >= 0:
1932
+ d_bonus = np.zeros(1, dtype=np.int32)
1933
+ # p_continuous_vec is (32, 10). p_continuous_ptr is (1,)
1934
+ # resolve_bytecode expects p_cp_slice as (1,)
1935
+ resolve_bytecode(
1936
+ bytecode_map[map_idx],
1937
+ np.zeros(64, dtype=np.int32),
1938
+ p_global_ctx,
1939
+ 1,
1940
+ p_hand,
1941
+ p_deck,
1942
+ p_stage,
1943
+ p_energy_vec,
1944
+ p_energy_count,
1945
+ p_continuous_vec,
1946
+ p_continuous_ptr,
1947
+ p_tapped,
1948
+ p_live,
1949
+ o_tapped,
1950
+ p_trash,
1951
+ bytecode_map,
1952
+ bytecode_index,
1953
+ d_bonus,
1954
+ card_stats,
1955
+ o_tapped,
1956
+ )
1957
+ p_scores[0] += d_bonus[0]
1958
+
1959
+ elif 200 <= action <= 202:
1960
+ slot = action - 200
1961
+ card_id = p_stage[slot]
1962
+ if card_id > 0 and p_tapped[slot] == 0:
1963
+ if card_id < bytecode_index.shape[0]:
1964
+ map_idx = bytecode_index[card_id, 0]
1965
+ if map_idx >= 0:
1966
+ d_bonus = np.zeros(1, dtype=np.int32)
1967
+ resolve_bytecode(
1968
+ bytecode_map[map_idx],
1969
+ np.zeros(64, dtype=np.int32),
1970
+ p_global_ctx,
1971
+ 1,
1972
+ p_hand,
1973
+ p_deck,
1974
+ p_stage,
1975
+ p_energy_vec,
1976
+ p_energy_count,
1977
+ p_continuous_vec,
1978
+ p_continuous_ptr,
1979
+ p_tapped,
1980
+ p_live,
1981
+ o_tapped,
1982
+ p_trash,
1983
+ bytecode_map,
1984
+ bytecode_index,
1985
+ d_bonus,
1986
+ card_stats,
1987
+ o_tapped,
1988
+ )
1989
+ p_scores[0] += d_bonus[0]
1990
+ p_tapped[slot] = 1
1991
+
1992
+ elif 400 <= action <= 459:
1993
+ hand_idx = action - 400
1994
+ if hand_idx < p_hand.shape[0]:
1995
+ card_id = p_hand[hand_idx]
1996
+ if card_id > 0:
1997
+ l_slot = -1
1998
+ for z in range(p_live.shape[0]):
1999
+ if p_live[z] == 0:
2000
+ l_slot = z
2001
+ break
2002
+
2003
+ if l_slot != -1:
2004
+ p_live[l_slot] = card_id
2005
+ p_hand[hand_idx] = 0
2006
+ p_global_ctx[3] -= 1
2007
+
2008
+ _check_deck_refresh_single(p_deck, p_trash, p_global_ctx, 6, 2)
2009
+
2010
+ d_idx = -1
2011
+ for k in range(p_deck.shape[0]):
2012
+ if p_deck[k] > 0:
2013
+ d_idx = k
2014
+ break
2015
+ if d_idx != -1:
2016
+ top_c = p_deck[d_idx]
2017
+ p_deck[d_idx] = 0
2018
+ p_global_ctx[6] -= 1
2019
+
2020
+ placed = False
2021
+ for h in range(p_hand.shape[0]):
2022
+ if p_hand[h] == 0:
2023
+ p_hand[h] = top_c
2024
+ p_global_ctx[3] += 1
2025
+ placed = True
2026
+ break
2027
+ if not placed:
2028
+ _move_to_trash_single(top_c, p_trash, p_global_ctx, 2)
2029
+
2030
+
2031
+ @njit(nopython=True, cache=True)
2032
+ def run_random_turn_loop(
2033
+ p_hand,
2034
+ p_deck,
2035
+ p_stage,
2036
+ p_energy_vec,
2037
+ p_energy_count,
2038
+ p_tapped,
2039
+ p_live,
2040
+ p_scores,
2041
+ p_global_ctx,
2042
+ p_trash,
2043
+ p_continuous_vec,
2044
+ p_continuous_ptr,
2045
+ o_tapped,
2046
+ card_stats,
2047
+ bytecode_map,
2048
+ bytecode_index,
2049
+ ):
2050
+ """
2051
+ Simulates a full opponent turn by selecting random legal actions.
2052
+ """
2053
+ for step_count in range(20): # Safety limit
2054
+ # Gather legal candidates
2055
+ candidates = [0] # Always can Pass
2056
+
2057
+ # 1. Member Play Candidates
2058
+ ec = p_global_ctx[5]
2059
+ for h in range(p_hand.shape[0]):
2060
+ cid = p_hand[h]
2061
+ if cid > 0 and cid < 900:
2062
+ cost = card_stats[cid, 0]
2063
+ for s in range(3):
2064
+ prev_cid = p_stage[s]
2065
+ effective_cost = cost
2066
+ if prev_cid > 0:
2067
+ prev_cost = card_stats[prev_cid, 0]
2068
+ effective_cost = max(0, cost - prev_cost)
2069
+
2070
+ if ec >= effective_cost:
2071
+ candidates.append((h * 3) + s + 1)
2072
+
2073
+ # 2. Activate Candidates
2074
+ for s in range(3):
2075
+ cid = p_stage[s]
2076
+ if cid > 0 and not p_tapped[s]:
2077
+ if cid < bytecode_index.shape[0]:
2078
+ if bytecode_index[cid, 0] >= 0:
2079
+ candidates.append(200 + s)
2080
+
2081
+ # 3. Live Set Candidates
2082
+ empty_live = 0
2083
+ for z in range(p_live.shape[0]):
2084
+ if p_live[z] == 0:
2085
+ empty_live += 1
2086
+
2087
+ if empty_live > 0:
2088
+ for h in range(p_hand.shape[0]):
2089
+ cid = p_hand[h]
2090
+ if cid > 900:
2091
+ candidates.append(400 + h)
2092
+
2093
+ # Pick one
2094
+ idx = np.random.randint(0, len(candidates))
2095
+ action = candidates[idx]
2096
+
2097
+ if action == 0:
2098
+ return
2099
+
2100
+ # Execute
2101
+ if 1 <= action <= 180:
2102
+ adj = action - 1
2103
+ hand_idx = adj // 3
2104
+ slot = adj % 3
2105
+ card_id = p_hand[hand_idx]
2106
+ cost = card_stats[card_id, 0]
2107
+ prev_cid = p_stage[slot]
2108
+ effective_cost = cost
2109
+ if prev_cid > 0:
2110
+ prev_cost = card_stats[prev_cid, 0]
2111
+ _move_to_trash_single(prev_cid, p_trash, p_global_ctx, 2)
2112
+ # Capture prev_cid for Baton Pass logic
2113
+ p_global_ctx[60] = prev_cid
2114
+ effective_cost = max(0, cost - prev_cost)
2115
+
2116
+ # Pay
2117
+ p_tapped[3:] = 0 # Dummy untap logic? No, we should use real payment
2118
+ # Actually run_opponent_turn_loop has a payment block, I'll simplify here
2119
+ p_global_ctx[5] -= effective_cost
2120
+ p_stage[slot] = card_id
2121
+ p_hand[hand_idx] = 0
2122
+ p_global_ctx[3] -= 1
2123
+
2124
+ if card_id < bytecode_index.shape[0]:
2125
+ map_idx = bytecode_index[card_id, 0]
2126
+ if map_idx >= 0:
2127
+ d_bonus = np.zeros(1, dtype=np.int32)
2128
+ resolve_bytecode(
2129
+ bytecode_map[map_idx],
2130
+ np.zeros(64, dtype=np.int32),
2131
+ p_global_ctx,
2132
+ 1,
2133
+ p_hand,
2134
+ p_deck,
2135
+ p_stage,
2136
+ p_energy_vec,
2137
+ p_energy_count,
2138
+ p_continuous_vec,
2139
+ p_continuous_ptr,
2140
+ p_tapped,
2141
+ p_live,
2142
+ o_tapped,
2143
+ p_trash,
2144
+ bytecode_map,
2145
+ bytecode_index,
2146
+ d_bonus,
2147
+ card_stats,
2148
+ o_tapped,
2149
+ )
2150
+ p_scores[0] += d_bonus[0]
2151
+
2152
+ elif 200 <= action <= 202:
2153
+ slot = action - 200
2154
+ cid = p_stage[slot]
2155
+ if cid > 0:
2156
+ d_bonus = np.zeros(1, dtype=np.int32)
2157
+ if cid < bytecode_index.shape[0]:
2158
+ map_idx = bytecode_index[cid, 0]
2159
+ if map_idx >= 0:
2160
+ resolve_bytecode(
2161
+ bytecode_map[map_idx],
2162
+ np.zeros(64, dtype=np.int32),
2163
+ p_global_ctx,
2164
+ 1,
2165
+ p_hand,
2166
+ p_deck,
2167
+ p_stage,
2168
+ p_energy_vec,
2169
+ p_energy_count,
2170
+ p_continuous_vec,
2171
+ p_continuous_ptr,
2172
+ p_tapped,
2173
+ p_live,
2174
+ o_tapped,
2175
+ p_trash,
2176
+ bytecode_map,
2177
+ bytecode_index,
2178
+ d_bonus,
2179
+ card_stats,
2180
+ o_tapped,
2181
+ )
2182
+ p_scores[0] += d_bonus[0]
2183
+ p_tapped[slot] = 1
2184
+
2185
+ elif 400 <= action <= 459:
2186
+ hand_idx = action - 400
2187
+ card_id = p_hand[hand_idx]
2188
+ l_slot = -1
2189
+ for z in range(p_live.shape[0]):
2190
+ if p_live[z] == 0:
2191
+ l_slot = z
2192
+ break
2193
+ if l_slot != -1:
2194
+ p_live[l_slot] = card_id
2195
+ p_hand[hand_idx] = 0
2196
+ p_global_ctx[3] -= 1
2197
+ _check_deck_refresh_single(p_deck, p_trash, p_global_ctx, 6, 2)
2198
+ # Draw 1
2199
+ for k in range(p_deck.shape[0]):
2200
+ if p_deck[k] > 0:
2201
+ top_c = p_deck[k]
2202
+ p_deck[k] = 0
2203
+ p_global_ctx[DK] -= 1
2204
+ for h in range(p_hand.shape[0]):
2205
+ if p_hand[h] == 0:
2206
+ p_hand[h] = top_c
2207
+ p_global_ctx[HD] += 1
2208
+ break
2209
+ break
engine/game/fast_logic_backup.py ADDED
@@ -0,0 +1,632 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from numba import njit, prange
3
+
4
+ # =============================================================================
5
+ # HYPER-OPTIMIZED VM CORE (Production Version)
6
+ # =============================================================================
7
+
8
+ # ContextIndex Mappings (Raw Ints)
9
+ CV = 20
10
+ AT = 22
11
+ TS = 12
12
+ TI = 13
13
+ SZ = 7
14
+ CH = 15
15
+
16
+ # Backward compatibility aliases
17
+ CTX_VALUE = CV
18
+ CTX_ATTR = AT
19
+ CTX_TARGET_SLOT = TS
20
+ CTX_TARGET_PLAYER_ID = TI
21
+ CTX_SOURCE_ZONE_IDX = SZ
22
+ CTX_CHOICE_INDEX = CH
23
+
24
+ # GlobalContext Mappings
25
+ SC = 0
26
+ OS = 1
27
+ TR = 2
28
+ HD = 3
29
+ DI = 4
30
+ EN = 5
31
+ DK = 6
32
+ OT = 7
33
+ PH = 8
34
+
35
+ # Opcodes
36
+ O_DRAW = 10
37
+ O_BLADES = 11
38
+ O_HEARTS = 12
39
+ O_REDUCE_COST = 13
40
+ O_RECOV_L = 15
41
+ O_BOOST = 16
42
+ O_RECOV_M = 17
43
+ O_BUFF = 18
44
+ O_MOVE_MEMBER = 20
45
+ O_SWAP_CARDS = 21
46
+ O_SEARCH_DECK = 22
47
+ O_CHARGE = 23
48
+ O_ORDER_DECK = 28
49
+ O_SELECT_MODE = 30
50
+ O_TAP_O = 32
51
+ O_PLACE_UNDER = 33
52
+ O_LOOK_AND_CHOOSE = 41
53
+ O_ACTIVATE_MEMBER = 43
54
+ O_ADD_H = 44
55
+ O_REPLACE_EFFECT = 46
56
+ O_TRIGGER_REMOTE = 47
57
+ O_REDUCE_HEART_REQ = 48
58
+ O_RETURN = 1
59
+ O_JUMP = 2
60
+ O_JUMP_F = 3
61
+
62
+ # Conditions
63
+ C_TR1 = 200
64
+ C_CLR = 202
65
+ C_STG = 203
66
+ C_HND = 204
67
+ C_CTR = 206
68
+ C_LLD = 207
69
+ C_GRP = 208
70
+ C_OPH = 210
71
+ C_ENR = 213
72
+ C_CMP = 220
73
+
74
+
75
+ @njit(nopython=True, cache=True)
76
+ def resolve_bytecode(
77
+ bytecode,
78
+ flat_ctx,
79
+ global_ctx,
80
+ player_id,
81
+ p_hand,
82
+ p_deck,
83
+ p_stage,
84
+ p_energy_vec,
85
+ p_energy_count,
86
+ p_cont_vec,
87
+ out_cptr, # Modified: Pass by ref array (size 1)
88
+ p_tapped,
89
+ p_live,
90
+ opp_tapped,
91
+ b_map,
92
+ b_idx,
93
+ out_bonus, # Modified: Pass by ref array (size 1)
94
+ ):
95
+ ip = 0
96
+ # Load cptr from reference
97
+ cptr = out_cptr[0]
98
+ # Bonus is accumulated into out_bonus[0]
99
+
100
+ cond = True
101
+ blen = bytecode.shape[0]
102
+
103
+ # SAFETY: Infinite loop protection
104
+ safety_counter = 0
105
+
106
+ while ip < blen and safety_counter < 500:
107
+ safety_counter += 1
108
+ op = bytecode[ip, 0]
109
+ v = bytecode[ip, 1]
110
+ a = bytecode[ip, 2]
111
+ s = bytecode[ip, 3]
112
+
113
+ if op == 0:
114
+ ip += 1
115
+ continue
116
+ if op == O_RETURN:
117
+ break
118
+
119
+ # Dynamic Target Handling (MEMBER_SELECT)
120
+ if s == 10:
121
+ s = int(flat_ctx[TS])
122
+
123
+ if op == O_JUMP:
124
+ new_ip = ip + v
125
+ if 0 <= new_ip < blen:
126
+ ip = new_ip
127
+ else:
128
+ ip = blen
129
+ continue
130
+
131
+ if op == O_JUMP_F:
132
+ if not cond:
133
+ new_ip = ip + v
134
+ if 0 <= new_ip < blen:
135
+ ip = new_ip
136
+ else:
137
+ ip = blen
138
+ continue
139
+ ip += 1
140
+ continue
141
+
142
+ if op == O_SELECT_MODE:
143
+ choice = int(flat_ctx[CH])
144
+ if 0 <= choice < v:
145
+ jump_ip = ip + 1 + choice
146
+ if jump_ip < blen:
147
+ offset = bytecode[jump_ip, 1]
148
+ new_ip = jump_ip + offset
149
+ if 0 <= new_ip < blen:
150
+ ip = new_ip
151
+ continue
152
+ ip += v + 1
153
+ continue
154
+
155
+ if op >= 200:
156
+ if op == C_TR1:
157
+ cond = global_ctx[TR] == 1
158
+ elif op == C_STG:
159
+ ct = 0
160
+ for i in range(3):
161
+ if p_stage[i] != -1:
162
+ ct += 1
163
+ cond = ct >= v
164
+ elif op == C_HND:
165
+ cond = global_ctx[HD] >= v
166
+ elif op == C_LLD:
167
+ cond = global_ctx[SC] > global_ctx[OS]
168
+ elif op == C_CLR:
169
+ if 0 <= a <= 5:
170
+ cond = global_ctx[10 + a] > 0
171
+ else:
172
+ cond = False
173
+ elif op == C_GRP:
174
+ if 0 <= a <= 4:
175
+ cond = global_ctx[30 + a] >= v
176
+ else:
177
+ cond = False
178
+ elif op == C_ENR:
179
+ cond = global_ctx[EN] >= v
180
+ elif op == C_CTR:
181
+ cond = flat_ctx[SZ] == 1
182
+ elif op == C_CMP:
183
+ if v > 0:
184
+ cond = global_ctx[SC] >= v
185
+ else:
186
+ cond = global_ctx[SC] > global_ctx[OS]
187
+ elif op == C_OPH:
188
+ ct = global_ctx[OT]
189
+ if v > 0:
190
+ cond = ct >= v
191
+ else:
192
+ cond = ct > 0
193
+ else:
194
+ cond = True
195
+ ip += 1
196
+ else:
197
+ if cond:
198
+ if op == O_DRAW:
199
+ if global_ctx[DK] >= v:
200
+ global_ctx[DK] -= v
201
+ global_ctx[HD] += v
202
+ else:
203
+ t = global_ctx[DK]
204
+ global_ctx[DK] = 0
205
+ global_ctx[HD] += t
206
+ elif op == O_CHARGE:
207
+ if global_ctx[DK] >= v:
208
+ global_ctx[DK] -= v
209
+ global_ctx[EN] += v
210
+ else:
211
+ t = global_ctx[DK]
212
+ global_ctx[DK] = 0
213
+ global_ctx[EN] += t
214
+ elif op == O_BLADES:
215
+ if s >= 0 and cptr < 32:
216
+ p_cont_vec[cptr, 0] = 1
217
+ p_cont_vec[cptr, 1] = v
218
+ p_cont_vec[cptr, 2] = 4
219
+ p_cont_vec[cptr, 3] = s
220
+ p_cont_vec[cptr, 9] = 1
221
+ cptr += 1
222
+ elif op == O_HEARTS:
223
+ if cptr < 32:
224
+ p_cont_vec[cptr, 0] = 2
225
+ p_cont_vec[cptr, 1] = v
226
+ p_cont_vec[cptr, 5] = a
227
+ p_cont_vec[cptr, 9] = 1
228
+ cptr += 1
229
+ global_ctx[0] += v
230
+ elif op == O_REDUCE_COST:
231
+ if cptr < 32:
232
+ p_cont_vec[cptr, 0] = 3
233
+ p_cont_vec[cptr, 1] = v
234
+ p_cont_vec[cptr, 2] = s
235
+ p_cont_vec[cptr, 9] = 1
236
+ cptr += 1
237
+ elif op == O_REDUCE_HEART_REQ:
238
+ if cptr < 32:
239
+ p_cont_vec[cptr, 0] = 48
240
+ p_cont_vec[cptr, 1] = v
241
+ p_cont_vec[cptr, 2] = s
242
+ p_cont_vec[cptr, 9] = 1
243
+ cptr += 1
244
+ elif op == O_REPLACE_EFFECT:
245
+ if cptr < 32:
246
+ p_cont_vec[cptr, 0] = 46
247
+ p_cont_vec[cptr, 1] = v
248
+ p_cont_vec[cptr, 2] = s
249
+ p_cont_vec[cptr, 9] = 1
250
+ cptr += 1
251
+ elif op == O_RECOV_L:
252
+ pass
253
+ elif op == O_RECOV_M:
254
+ pass
255
+ elif op == O_ACTIVATE_MEMBER:
256
+ if 0 <= s < 3:
257
+ p_tapped[s] = 0
258
+ elif op == O_SWAP_CARDS:
259
+ removed = 0
260
+ for h_idx in range(60):
261
+ if p_hand[h_idx] > 0:
262
+ p_hand[h_idx] = 0
263
+ global_ctx[HD] -= 1
264
+ removed += 1
265
+ if removed >= v:
266
+ break
267
+
268
+ # Actually Move Cards for Draw
269
+ drawn = 0
270
+ for d_idx in range(60):
271
+ if p_deck[d_idx] > 0:
272
+ card_id = p_deck[d_idx]
273
+ p_deck[d_idx] = 0
274
+
275
+ # Find empty hand slot
276
+ for h_idx in range(60):
277
+ if p_hand[h_idx] == 0:
278
+ p_hand[h_idx] = card_id
279
+ break
280
+
281
+ global_ctx[DK] -= 1
282
+ global_ctx[HD] += 1
283
+ drawn += 1
284
+ if drawn >= v:
285
+ break
286
+
287
+ # If not enough cards, we stop (counters already updated per card)
288
+
289
+ elif op == O_PLACE_UNDER:
290
+ placed = 0
291
+ for h_idx in range(59, -1, -1):
292
+ if p_hand[h_idx] > 0:
293
+ cid = p_hand[h_idx]
294
+ p_hand[h_idx] = 0
295
+ global_ctx[HD] -= 1
296
+
297
+ if 0 <= s < 3:
298
+ for e_idx in range(32):
299
+ if p_energy_vec[s, e_idx] == 0:
300
+ p_energy_vec[s, e_idx] = cid
301
+ p_energy_count[s] += 1
302
+ break
303
+
304
+ placed += 1
305
+ if placed >= v:
306
+ break
307
+
308
+ elif op == O_MOVE_MEMBER:
309
+ dest_slot = int(flat_ctx[TS])
310
+ if 0 <= s < 3 and 0 <= dest_slot < 3 and s != dest_slot:
311
+ temp_id = p_stage[s]
312
+ p_stage[s] = p_stage[dest_slot]
313
+ p_stage[dest_slot] = temp_id
314
+
315
+ temp_tap = p_tapped[s]
316
+ p_tapped[s] = p_tapped[dest_slot]
317
+ p_tapped[dest_slot] = temp_tap
318
+
319
+ for e_idx in range(32):
320
+ temp_e = p_energy_vec[s, e_idx]
321
+ p_energy_vec[s, e_idx] = p_energy_vec[dest_slot, e_idx]
322
+ p_energy_vec[dest_slot, e_idx] = temp_e
323
+
324
+ temp_ec = p_energy_count[s]
325
+ p_energy_count[s] = p_energy_count[dest_slot]
326
+ p_energy_count[dest_slot] = temp_ec
327
+
328
+ elif op == O_TAP_O:
329
+ if 0 <= s < 3:
330
+ opp_tapped[s] = 1
331
+ elif op == O_BUFF:
332
+ if cptr < 32:
333
+ p_cont_vec[cptr, 0] = 8
334
+ p_cont_vec[cptr, 1] = v
335
+ p_cont_vec[cptr, 2] = s
336
+ p_cont_vec[cptr, 9] = 1
337
+ cptr += 1
338
+ elif op == O_BOOST:
339
+ out_bonus[0] += v
340
+ elif op == O_LOOK_AND_CHOOSE:
341
+ choice_idx = int(flat_ctx[CH])
342
+ if choice_idx < 0 or choice_idx >= v:
343
+ choice_idx = 0
344
+
345
+ indices = np.full(v, -1, dtype=np.int32)
346
+ ptr = 0
347
+ for d_idx in range(60):
348
+ if p_deck[d_idx] > 0:
349
+ indices[ptr] = d_idx
350
+ ptr += 1
351
+ if ptr >= v:
352
+ break
353
+
354
+ if ptr > 0:
355
+ if choice_idx >= ptr:
356
+ choice_idx = 0
357
+ real_idx = indices[choice_idx]
358
+ if real_idx != -1:
359
+ chosen_card = p_deck[real_idx]
360
+ if chosen_card > 0:
361
+ for h_idx in range(60):
362
+ if p_hand[h_idx] == 0:
363
+ p_hand[h_idx] = chosen_card
364
+ global_ctx[HD] += 1
365
+ break
366
+ for k in range(ptr):
367
+ rid = indices[k]
368
+ if rid != -1:
369
+ p_deck[rid] = 0
370
+ global_ctx[DK] -= 1
371
+
372
+ elif op == O_ORDER_DECK:
373
+ indices = np.full(v, -1, dtype=np.int32)
374
+ vals = np.full(v, 0, dtype=np.int32)
375
+ ptr = 0
376
+ for d_idx in range(60):
377
+ if p_deck[d_idx] > 0:
378
+ indices[ptr] = d_idx
379
+ vals[ptr] = p_deck[d_idx]
380
+ ptr += 1
381
+ if ptr >= v:
382
+ break
383
+
384
+ if ptr > 1:
385
+ for k in range(ptr // 2):
386
+ temp = vals[k]
387
+ vals[k] = vals[ptr - 1 - k]
388
+ vals[ptr - 1 - k] = temp
389
+
390
+ for k in range(ptr):
391
+ p_deck[indices[k]] = vals[k]
392
+
393
+ elif op == O_ADD_H:
394
+ if global_ctx[DK] >= v:
395
+ global_ctx[DK] -= v
396
+ global_ctx[HD] += v
397
+ elif op == O_SEARCH_DECK:
398
+ target_idx = int(flat_ctx[TS])
399
+ if 0 <= target_idx < 60 and p_deck[target_idx] > 0:
400
+ card_to_move = p_deck[target_idx]
401
+ p_deck[target_idx] = 0
402
+ for h_idx in range(60):
403
+ if p_hand[h_idx] == 0:
404
+ p_hand[h_idx] = card_to_move
405
+ global_ctx[HD] += 1
406
+ global_ctx[DK] -= 1
407
+ break
408
+ else:
409
+ for d_idx in range(60):
410
+ if p_deck[d_idx] > 0:
411
+ card_to_move = p_deck[d_idx]
412
+ p_deck[d_idx] = 0
413
+ for h_idx in range(60):
414
+ if p_hand[h_idx] == 0:
415
+ p_hand[h_idx] = card_to_move
416
+ global_ctx[HD] += 1
417
+ global_ctx[DK] -= 1
418
+ break
419
+ break
420
+
421
+ elif op == O_TRIGGER_REMOTE:
422
+ target_slot = s
423
+ if target_slot < 0:
424
+ pass
425
+
426
+ target_card_id = -1
427
+ if 0 <= target_slot < 3:
428
+ target_card_id = p_stage[target_slot]
429
+
430
+ if target_card_id > 0 and target_card_id < b_idx.shape[0]:
431
+ map_idx = b_idx[target_card_id, 0]
432
+ if map_idx >= 0:
433
+ sub_code = b_map[map_idx]
434
+ out_cptr[0] = cptr
435
+ resolve_bytecode(
436
+ sub_code,
437
+ flat_ctx,
438
+ global_ctx,
439
+ player_id,
440
+ p_hand,
441
+ p_deck,
442
+ p_stage,
443
+ p_energy_vec,
444
+ p_energy_count,
445
+ p_cont_vec,
446
+ out_cptr,
447
+ p_tapped,
448
+ p_live,
449
+ opp_tapped,
450
+ b_map,
451
+ b_idx,
452
+ out_bonus,
453
+ )
454
+ cptr = out_cptr[0]
455
+
456
+ ip += 1
457
+
458
+ out_cptr[0] = cptr
459
+
460
+
461
+ @njit(nopython=True)
462
+ def batch_resolve_bytecode(
463
+ batch_bytecode,
464
+ batch_flat_ctx,
465
+ batch_global_ctx,
466
+ player_id,
467
+ p_hand,
468
+ p_deck,
469
+ p_stage,
470
+ p_energy_vec,
471
+ p_energy_count,
472
+ p_cont_vec,
473
+ p_cont_ptr,
474
+ p_tapped,
475
+ p_live,
476
+ opp_tapped,
477
+ b_map,
478
+ b_idx,
479
+ ):
480
+ num_envs = batch_bytecode.shape[0]
481
+ for i in prange(num_envs):
482
+ cptr_slice = p_cont_ptr[i : i + 1]
483
+ dummy_bonus = np.zeros(1, dtype=np.int32)
484
+
485
+ resolve_bytecode(
486
+ batch_bytecode[i],
487
+ batch_flat_ctx[i],
488
+ batch_global_ctx[i],
489
+ player_id,
490
+ p_hand[i],
491
+ p_deck[i],
492
+ p_stage[i],
493
+ p_energy_vec[i],
494
+ p_energy_count[i],
495
+ p_cont_vec[i],
496
+ cptr_slice,
497
+ p_tapped[i],
498
+ p_live[i],
499
+ opp_tapped[i],
500
+ b_map,
501
+ b_idx,
502
+ dummy_bonus,
503
+ )
504
+
505
+
506
+ @njit(nopython=True, cache=True)
507
+ def copy_state(s_stg, s_ev, s_ec, s_cv, d_stg, d_ev, d_ec, d_cv):
508
+ d_stg[:] = s_stg[:]
509
+ d_ev[:] = s_ev[:]
510
+ d_ec[:] = s_ec[:]
511
+ d_cv[:] = s_cv[:]
512
+
513
+
514
+ @njit(nopython=True)
515
+ def batch_apply_action(
516
+ actions,
517
+ pid,
518
+ p_stg,
519
+ p_ev,
520
+ p_ec,
521
+ p_cv,
522
+ p_cp,
523
+ p_tap,
524
+ p_sb,
525
+ p_lr,
526
+ o_tap,
527
+ f_ctx_batch,
528
+ g_ctx_batch,
529
+ p_h,
530
+ p_d,
531
+ b_map,
532
+ b_idx,
533
+ ):
534
+ # Sync individual scores and turn to global_ctx before stepping
535
+ for i in prange(actions.shape[0]):
536
+ g_ctx_batch[i, SC] = p_sb[i]
537
+ act_id = actions[i]
538
+
539
+ if act_id > 0:
540
+ card_id = act_id
541
+ if card_id < b_idx.shape[0]:
542
+ map_idx = b_idx[card_id, 0]
543
+ if map_idx >= 0:
544
+ code_seq = b_map[map_idx]
545
+ f_ctx_batch[i, 7] = 1
546
+
547
+ cptr_slice = p_cp[i : i + 1]
548
+ delta_bonus = np.zeros(1, dtype=np.int32)
549
+
550
+ resolve_bytecode(
551
+ code_seq,
552
+ f_ctx_batch[i],
553
+ g_ctx_batch[i],
554
+ pid,
555
+ p_h[i],
556
+ p_d[i],
557
+ p_stg[i],
558
+ p_ev[i],
559
+ p_ec[i],
560
+ p_cv[i],
561
+ cptr_slice,
562
+ p_tap[i],
563
+ p_lr[i],
564
+ o_tap[i],
565
+ b_map,
566
+ b_idx,
567
+ delta_bonus,
568
+ )
569
+
570
+ p_sb[i] += delta_bonus[0]
571
+ f_ctx_batch[i, 7] = 0
572
+
573
+ found_h = False
574
+ for h_idx in range(60):
575
+ if p_h[i, h_idx] == card_id:
576
+ p_h[i, h_idx] = 0
577
+ g_ctx_batch[i, 3] -= 1
578
+ found_h = True
579
+ break
580
+
581
+ if found_h and card_id < 900:
582
+ for s_idx in range(3):
583
+ if p_stg[i, s_idx] == -1:
584
+ p_stg[i, s_idx] = card_id
585
+ break
586
+
587
+ cnt = 0
588
+ for h_idx in range(60):
589
+ if p_h[i, h_idx] > 0:
590
+ cnt += 1
591
+
592
+ if cnt < 5:
593
+ top_card = 0
594
+ deck_idx = -1
595
+ for d_idx in range(60):
596
+ if p_d[i, d_idx] > 0:
597
+ top_card = p_d[i, d_idx]
598
+ deck_idx = d_idx
599
+ break
600
+
601
+ if top_card > 0:
602
+ for h_idx in range(60):
603
+ if p_h[i, h_idx] == 0:
604
+ p_h[i, h_idx] = top_card
605
+ p_d[i, deck_idx] = 0
606
+ g_ctx_batch[i, 3] += 1
607
+ g_ctx_batch[i, 6] -= 1
608
+ break
609
+ else:
610
+ pass
611
+
612
+
613
+ @njit(nopython=True, cache=True)
614
+ def apply_action(aid, pid, p_stg, p_ev, p_ec, p_cv, p_cp, p_tap, p_sb, p_lr, o_tap, f_ctx, g_ctx, p_h, p_d):
615
+ # Specialized fast-path for Action 1 (Simulation)
616
+ if aid == 1:
617
+ bc = np.zeros((1, 4), dtype=np.int32)
618
+ bc[0, 0] = 11 # O_BLADES
619
+ bc[0, 1] = 1
620
+ bc[0, 3] = 0
621
+
622
+ d_map = np.zeros((1, 1, 4), dtype=np.int32)
623
+ d_idx = np.zeros((1, 4), dtype=np.int32)
624
+
625
+ cptr_arr = np.array([p_cp], dtype=np.int32)
626
+ bn_arr = np.zeros(1, dtype=np.int32)
627
+
628
+ resolve_bytecode(
629
+ bc, f_ctx, g_ctx, pid, p_h, p_d, p_stg, p_ev, p_ec, p_cv, cptr_arr, p_tap, p_lr, o_tap, d_map, d_idx, bn_arr
630
+ )
631
+ return cptr_arr[0], p_sb + bn_arr[0]
632
+ return p_cp, p_sb
engine/game/game_state.py ADDED
@@ -0,0 +1,3027 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Love Live Card Game - AlphaZero Compatible Game State
3
+
4
+ This module implements the game state representation for the Love Live
5
+ Official Card Game, designed for fast self-play with AlphaZero-style training.
6
+
7
+ Key Design Decisions:
8
+ - Numpy arrays for vectorized operations
9
+ - Immutable state with state copying for MCTS
10
+ - Action space encoded as integers for neural network output
11
+ - Observation tensors suitable for CNN input
12
+ """
13
+
14
+ # Love Live! Card Game - Comprehensive Rules v1.04 Implementation
15
+
16
+ # Rule 1: (General Overview)
17
+
18
+ # Rule 2: (Card Information)
19
+
20
+ # Rule 3: (Player Info)
21
+
22
+ # Rule 4: (Zones)
23
+
24
+ # Rule 1.3: (Fundamental Principles)
25
+
26
+ # Rule 1.3.1: Card text overrides rules.
27
+
28
+ # Rule 1.3.2: Impossible actions are simply not performed.
29
+
30
+ # Rule 1.3.3: "Cannot" effects take priority over "Can" effects.
31
+
32
+ # Rule 1.3.4: Active player chooses first when multiple choices occur.
33
+
34
+ # Rule 1.3.5: Numerical selections must be non-negative integers.
35
+
36
+ import json
37
+ import os
38
+ from typing import Any, Dict, List, Optional, Tuple
39
+
40
+ import numpy as np
41
+
42
+ from engine.game.data_loader import CardDataLoader
43
+ from engine.game.enums import Phase
44
+ from engine.game.mixins.action_mixin import ActionMixin
45
+ from engine.game.mixins.effect_mixin import EffectMixin
46
+ from engine.game.mixins.phase_mixin import PhaseMixin
47
+ from engine.models.ability import (
48
+ Ability,
49
+ EffectType,
50
+ ResolvingEffect,
51
+ TriggerType,
52
+ )
53
+ from engine.models.card import LiveCard, MemberCard
54
+ from engine.models.enums import Group, Unit
55
+
56
+ # Import Numba utils
57
+
58
+ # Import Numba utils
59
+
60
+ try:
61
+ from engine.game.numba_utils import JIT_AVAILABLE, calc_main_phase_masks
62
+
63
+ except ImportError:
64
+ JIT_AVAILABLE = False
65
+
66
+ def calc_main_phase_masks(*args):
67
+ pass
68
+
69
+ # =============================================================================
70
+
71
+ # OBJECT POOLING FOR PERFORMANCE
72
+
73
+ # =============================================================================
74
+
75
+
76
+ class StatePool:
77
+ """
78
+
79
+ Object pool for PlayerState and GameState to avoid allocation overhead.
80
+
81
+ Thread-local pools for multiprocessing compatibility.
82
+
83
+ """
84
+
85
+ _player_pool: List["PlayerState"] = []
86
+
87
+ _game_pool: List["GameState"] = []
88
+
89
+ _max_pool_size: int = 100
90
+
91
+ @classmethod
92
+ def get_player_state(cls, player_id: int) -> "PlayerState":
93
+ """Get a PlayerState - POOLING DISABLED for safety"""
94
+
95
+ return PlayerState(player_id)
96
+
97
+ @classmethod
98
+ def get_game_state(cls) -> "GameState":
99
+ """Get a GameState - POOLING DISABLED for safety"""
100
+
101
+ return GameState()
102
+
103
+ @classmethod
104
+ def return_player_state(cls, ps: "PlayerState") -> None:
105
+ """Return a PlayerState to the pool for reuse."""
106
+
107
+ if len(cls._player_pool) < cls._max_pool_size:
108
+ cls._player_pool.append(ps)
109
+
110
+ @classmethod
111
+ def return_game_state(cls, gs: "GameState") -> None:
112
+ """Return a GameState to the pool for reuse."""
113
+
114
+ if len(cls._game_pool) < cls._max_pool_size:
115
+ cls._game_pool.append(gs)
116
+
117
+
118
+ # Phase enum moved to enums.py
119
+
120
+ # Enums and Card Classes moved to engine.models
121
+
122
+ # Imported above
123
+
124
+ from engine.game.player_state import PlayerState
125
+
126
+
127
+ class GameState(ActionMixin, PhaseMixin, EffectMixin):
128
+ """
129
+
130
+ Full game state (Rule 1)
131
+
132
+ Features:
133
+
134
+ - Rule 4.14: Resolution Zone (yell_cards)
135
+
136
+ - Rule 1.2: Victory Detection
137
+
138
+ - MCTS / AlphaZero support
139
+
140
+ """
141
+
142
+ # Class-level caches
143
+ member_db: Dict[int, MemberCard] = {}
144
+ live_db: Dict[int, LiveCard] = {}
145
+ _meta_rule_cards: set = set()
146
+
147
+ # JIT Arrays
148
+ _jit_member_costs: Optional[np.ndarray] = None
149
+ _jit_member_blades: Optional[np.ndarray] = None
150
+ _jit_member_hearts_sum: Optional[np.ndarray] = None
151
+ _jit_member_hearts_vec: Optional[np.ndarray] = None
152
+ _jit_live_score: Optional[np.ndarray] = None
153
+ _jit_live_hearts_sum: Optional[np.ndarray] = None
154
+ _jit_live_hearts_vec: Optional[np.ndarray] = None
155
+
156
+ @classmethod
157
+ def initialize_class_db(cls, member_db: Dict[int, "MemberCard"], live_db: Dict[int, "LiveCard"]) -> None:
158
+ """Initialize and wrap static DBs with MaskedDB for UID resolution."""
159
+ from engine.game.state_utils import MaskedDB
160
+
161
+ cls.member_db = MaskedDB(member_db)
162
+ cls.live_db = MaskedDB(live_db)
163
+
164
+ # Optimization: Cache cards with CONSTANT META_RULE effects
165
+ cls._meta_rule_cards = set()
166
+ for cid, card in cls.member_db.items():
167
+ for ab in card.abilities:
168
+ if ab.trigger.name == "CONSTANT":
169
+ for eff in ab.effects:
170
+ if eff.effect_type == EffectType.META_RULE:
171
+ cls._meta_rule_cards.add(cid)
172
+ break
173
+ for cid, card in cls.live_db.items():
174
+ for ab in card.abilities:
175
+ if ab.trigger.name == "CONSTANT":
176
+ for eff in ab.effects:
177
+ if eff.effect_type == EffectType.META_RULE:
178
+ cls._meta_rule_cards.add(cid)
179
+ break
180
+
181
+ cls._init_jit_arrays()
182
+
183
+ @classmethod
184
+ def _init_jit_arrays(cls):
185
+ """Initialize static arrays for Numba JIT"""
186
+
187
+ if not cls.member_db:
188
+ return
189
+
190
+ # Find max ID
191
+
192
+ max_id = max(max(cls.member_db.keys(), default=0), max(cls.live_db.keys(), default=0))
193
+
194
+ # Create lookup arrays (default 0 or -1)
195
+
196
+ # Costs: -1 for non-members
197
+
198
+ costs = np.full(max_id + 1, -1, dtype=np.int32)
199
+
200
+ # Blades: 0
201
+
202
+ blades = np.zeros(max_id + 1, dtype=np.int32)
203
+
204
+ # Hearts Sum: 0
205
+
206
+ hearts_sum = np.zeros(max_id + 1, dtype=np.int32)
207
+
208
+ # Hearts Vector: (N, 7)
209
+
210
+ hearts_vec = np.zeros((max_id + 1, 7), dtype=np.int32)
211
+
212
+ # Live Score: 0
213
+
214
+ live_score = np.zeros(max_id + 1, dtype=np.int32)
215
+
216
+ # Live Hearts Requirement Sum: 0
217
+
218
+ live_hearts_sum = np.zeros(max_id + 1, dtype=np.int32)
219
+
220
+ # Live Hearts Vector: (N, 7)
221
+
222
+ live_hearts_vec = np.zeros((max_id + 1, 7), dtype=np.int32)
223
+
224
+ for cid, member in cls.member_db.items():
225
+ costs[cid] = member.cost
226
+
227
+ blades[cid] = member.blades
228
+
229
+ if hasattr(member, "hearts"):
230
+ h = member.hearts
231
+ # Robustly handle arrays likely to be shape (6,) or (7,)
232
+ if len(h) >= 7:
233
+ hearts_vec[cid] = h[:7]
234
+ else:
235
+ hearts_vec[cid, : len(h)] = h
236
+
237
+ hearts_sum[cid] = np.sum(member.hearts)
238
+
239
+ for cid, live in cls.live_db.items():
240
+ live_score[cid] = int(live.score)
241
+
242
+ if hasattr(live, "required_hearts"):
243
+ rh = live.required_hearts
244
+ if len(rh) >= 7:
245
+ live_hearts_vec[cid] = rh[:7]
246
+ else:
247
+ live_hearts_vec[cid, : len(rh)] = rh
248
+
249
+ live_hearts_sum[cid] = np.sum(live.required_hearts)
250
+
251
+ cls._jit_member_costs = costs
252
+
253
+ cls._jit_member_blades = blades
254
+
255
+ cls._jit_member_hearts_sum = hearts_sum
256
+
257
+ cls._jit_member_hearts_vec = hearts_vec
258
+
259
+ cls._jit_live_score = live_score
260
+
261
+ cls._jit_live_hearts_sum = live_hearts_sum
262
+
263
+ cls._jit_live_hearts_vec = live_hearts_vec
264
+
265
+ @classmethod
266
+ def serialize_card(cls, cid: int, is_viewable=True, peek=False):
267
+ """Static helper to serialize a card ID."""
268
+
269
+ if cid < 0:
270
+ return None
271
+
272
+ card_data = {"id": int(cid), "img": "cards/card_back.png", "type": "unknown", "name": "Unknown"}
273
+
274
+ if not is_viewable and not peek:
275
+ return {"id": int(cid), "img": "cards/card_back.png", "type": "unknown", "hidden": True}
276
+
277
+ if cid in cls.member_db:
278
+ m = cls.member_db[cid]
279
+
280
+ # Basic ability text formatting
281
+
282
+ at = getattr(m, "ability_text", "")
283
+
284
+ if not at and hasattr(m, "abilities"):
285
+ at_lines = []
286
+
287
+ for ab in m.abilities:
288
+ at_lines.append(ab.raw_text)
289
+
290
+ at = "\n".join(at_lines)
291
+
292
+ card_data = {
293
+ "id": int(cid),
294
+ "card_no": getattr(m, "card_no", "Unknown"),
295
+ "name": getattr(m, "name", "Unknown Member"),
296
+ "cost": int(getattr(m, "cost", 0)),
297
+ "type": "member",
298
+ "hp": int(m.total_hearts()) if hasattr(m, "total_hearts") else 0,
299
+ "blade": int(getattr(m, "blades", 0)),
300
+ "img": getattr(m, "img_path", "cards/card_back.png"),
301
+ "hearts": m.hearts.tolist() if hasattr(m, "hearts") and hasattr(m.hearts, "tolist") else [0] * 7,
302
+ "blade_hearts": m.blade_hearts.tolist()
303
+ if hasattr(m, "blade_hearts") and hasattr(m.blade_hearts, "tolist")
304
+ else [0] * 7,
305
+ "text": at,
306
+ }
307
+
308
+ elif cid in cls.live_db:
309
+ l = cls.live_db[cid]
310
+
311
+ card_data = {
312
+ "id": int(cid),
313
+ "card_no": getattr(l, "card_no", "Unknown"),
314
+ "name": l.name,
315
+ "type": "live",
316
+ "score": int(l.score),
317
+ "img": l.img_path,
318
+ "required_hearts": l.required_hearts.tolist(),
319
+ "text": getattr(l, "ability_text", ""),
320
+ }
321
+
322
+ elif cid == 888: # Easy member
323
+ card_data = {
324
+ "id": 888,
325
+ "name": "Easy Member",
326
+ "type": "member",
327
+ "cost": 1,
328
+ "hp": 1,
329
+ "blade": 1,
330
+ "img": "cards/PLSD01/PL!-sd1-001-SD.png",
331
+ "hearts": [1, 0, 0, 0, 0, 0, 0],
332
+ "blade_hearts": [0, 0, 0, 0, 0, 0, 0],
333
+ "text": "",
334
+ }
335
+
336
+ elif cid == 999: # Easy live
337
+ card_data = {
338
+ "id": 999,
339
+ "name": "Easy Live",
340
+ "type": "live",
341
+ "score": 1,
342
+ "img": "cards/PLSD01/PL!-pb1-019-SD.png",
343
+ "required_hearts": [0, 0, 0, 0, 0, 0, 1],
344
+ "text": "",
345
+ }
346
+
347
+ if not is_viewable and peek:
348
+ card_data["hidden"] = True
349
+
350
+ card_data["face_down"] = True
351
+
352
+ return card_data
353
+
354
+ __slots__ = (
355
+ "verbose",
356
+ "players",
357
+ "current_player",
358
+ "first_player",
359
+ "phase",
360
+ "turn_number",
361
+ "game_over",
362
+ "winner",
363
+ "performance_results",
364
+ "yell_cards",
365
+ "pending_effects",
366
+ "pending_choices",
367
+ "rule_log",
368
+ "current_resolving_ability",
369
+ "current_resolving_member",
370
+ "current_resolving_member_id",
371
+ "looked_cards",
372
+ "triggered_abilities",
373
+ "state_history",
374
+ "loop_draw",
375
+ "removed_cards",
376
+ "action_count_this_turn",
377
+ "pending_choices_vec",
378
+ "pending_choices_ptr",
379
+ "triggered_abilities_vec",
380
+ "triggered_abilities_ptr",
381
+ "_jit_dummy_array",
382
+ "fast_mode",
383
+ "suppress_logs",
384
+ "enable_loop_detection",
385
+ "_trigger_buffers",
386
+ )
387
+
388
+ def __init__(self, verbose=False, suppress_logs=False, enable_loop_detection=True):
389
+ self.verbose = verbose
390
+ self.suppress_logs = suppress_logs
391
+ self.enable_loop_detection = enable_loop_detection
392
+
393
+ self.players = [PlayerState(0), PlayerState(1)]
394
+
395
+ self.current_player = 0 # Who is acting now
396
+
397
+ self.first_player = 0 # Who goes first this turn
398
+
399
+ self.phase = Phase.ACTIVE
400
+
401
+ self.turn_number: int = 1
402
+
403
+ self.game_over: bool = False
404
+
405
+ self.winner: int = -1 # -1 = ongoing, 0/1 = player won, 2 = draw
406
+
407
+ # Performance Result Tracking (for UI popup)
408
+
409
+ self.performance_results: Dict[int, Any] = {}
410
+
411
+ # For yell phase tracking
412
+
413
+ self.yell_cards: List[int] = [] # Shared Resolution Zone (Rule 4.14)
414
+
415
+ self.pending_effects: List[ResolvingEffect] = [] # Stack of effects to resolve
416
+
417
+ self.pending_activation: Optional[Dict[str, Any]] = None
418
+
419
+ self.pending_choices: List[Tuple[str, Dict[str, Any]]] = [] # (choice_type, params with metadata)
420
+
421
+ self.rule_log: List[str] = [] # Real-time rule application log
422
+
423
+ # Track currently resolving ability for context
424
+
425
+ self.current_resolving_ability: Optional[Ability] = None
426
+
427
+ self.current_resolving_member: Optional[str] = None # Member name
428
+
429
+ self.current_resolving_member_id: int = -1 # Member card ID
430
+
431
+ # Temporary zone for LOOK_DECK
432
+
433
+ self.looked_cards: List[int] = []
434
+
435
+ # Rule 9.7: Automatic Abilities
436
+
437
+ # List of (player_id, Ability, context) waiting to be played
438
+
439
+ self.triggered_abilities: List[Tuple[int, Ability, Dict[str, Any]]] = []
440
+
441
+ # Vectorized triggers/choices for JIT
442
+ self.pending_choices_vec = np.zeros((16, 3), dtype=np.int32)
443
+ self.pending_choices_ptr = 0
444
+ self.triggered_abilities_vec = np.zeros((16, 2), dtype=np.int32)
445
+ self.triggered_abilities_ptr = 0
446
+ self._jit_dummy_array = np.zeros(100, dtype=np.int32)
447
+ self.fast_mode = False
448
+ self._trigger_buffers = [[], []] # Pre-allocated buffers for trigger processing
449
+
450
+ # Static caches (for performance and accessibility)
451
+
452
+ # Should be set from server or data loader
453
+
454
+ # Loop Detection (Rule 12.1)
455
+
456
+ # Using a simple hash of the serialization for history
457
+
458
+ self.state_history: List[int] = []
459
+
460
+ self.loop_draw = False
461
+
462
+ self.removed_cards: List[int] = []
463
+
464
+ self.action_count_this_turn: int = 0
465
+
466
+ def log_rule(self, rule_id: str, description: str):
467
+ """Append a rule application entry to the log."""
468
+ if self.suppress_logs:
469
+ return
470
+
471
+ # Add Turn and Phase context
472
+
473
+ phase_name = self.phase.name if hasattr(self.phase, "name") else str(self.phase)
474
+
475
+ entry = f"[Turn {self.turn_number}] [{phase_name}] [{rule_id}] {description}"
476
+
477
+ self.rule_log.append(entry)
478
+
479
+ # Also print to stdout for server console debugging
480
+
481
+ if self.verbose:
482
+ print(f"RULE_LOG: {entry}")
483
+ pass
484
+
485
+ def _reset(self) -> None:
486
+ """Reset state for pool reuse - avoids object allocation."""
487
+
488
+ self.verbose = False
489
+
490
+ # Players get reset by PlayerState._reset or replaced
491
+
492
+ self.current_player = 0
493
+
494
+ self.first_player = 0
495
+
496
+ self.phase = Phase.ACTIVE
497
+
498
+ self.turn_number = 1
499
+
500
+ self.game_over = False
501
+
502
+ self.winner = -1
503
+
504
+ self.performance_results.clear()
505
+
506
+ self.yell_cards.clear()
507
+
508
+ self.pending_effects.clear()
509
+
510
+ self.pending_choices.clear()
511
+
512
+ self.rule_log.clear()
513
+
514
+ self.current_resolving_ability = None
515
+
516
+ self.current_resolving_member = None
517
+
518
+ self.current_resolving_member_id = -1
519
+
520
+ self.looked_cards.clear()
521
+
522
+ self.triggered_abilities.clear()
523
+
524
+ self.pending_choices_vec.fill(0)
525
+ self.pending_choices_ptr = 0
526
+ self.triggered_abilities_vec.fill(0)
527
+ self.triggered_abilities_ptr = 0
528
+ self._trigger_buffers[0].clear()
529
+ self._trigger_buffers[1].clear()
530
+
531
+ self.state_history.clear()
532
+
533
+ self.loop_draw = False
534
+
535
+ def copy(self) -> "GameState":
536
+ """Copy current game state"""
537
+
538
+ new = GameState()
539
+
540
+ self.copy_to(new)
541
+
542
+ return new
543
+
544
+ def copy_to(self, new: "GameState") -> None:
545
+ """In-place copy to an existing object to avoid allocation"""
546
+
547
+ new.verbose = self.verbose
548
+ new.suppress_logs = self.suppress_logs
549
+ new.enable_loop_detection = self.enable_loop_detection
550
+
551
+ # Reuse existing PlayerState objects in the pooled GameState
552
+
553
+ for i, p in enumerate(self.players):
554
+ p.copy_to(new.players[i])
555
+
556
+ new.current_player = self.current_player
557
+
558
+ new.first_player = self.first_player
559
+
560
+ new.phase = self.phase
561
+
562
+ new.turn_number = self.turn_number
563
+
564
+ new.game_over = self.game_over
565
+
566
+ new.winner = self.winner
567
+
568
+ new.yell_cards = list(self.yell_cards)
569
+
570
+ # Shallow copy of Effect objects (assumed immutable/shared)
571
+
572
+ new.pending_effects = list(self.pending_effects)
573
+
574
+ # Manual copy of pending_choices: List[Tuple[str, Dict]]
575
+
576
+ new.pending_choices = [(pc[0], pc[1].copy()) for pc in self.pending_choices]
577
+
578
+ new.rule_log = list(self.rule_log)
579
+
580
+ new.current_resolving_ability = self.current_resolving_ability
581
+
582
+ new.current_resolving_member = self.current_resolving_member
583
+
584
+ new.current_resolving_member_id = self.current_resolving_member_id
585
+
586
+ new.looked_cards = list(self.looked_cards)
587
+
588
+ # Manual copy of triggered_abilities: List[Tuple[int, Ability, Dict[str, Any]]]
589
+
590
+ # Tuple is immutable, Ability is shared, Dict needs copy
591
+
592
+ new.triggered_abilities = [(ta[0], ta[1], ta[2].copy()) for ta in self.triggered_abilities]
593
+
594
+ # Copy vectorized state
595
+ np.copyto(new.pending_choices_vec, self.pending_choices_vec)
596
+ new.pending_choices_ptr = self.pending_choices_ptr
597
+ np.copyto(new.triggered_abilities_vec, self.triggered_abilities_vec)
598
+ new.triggered_abilities_ptr = self.triggered_abilities_ptr
599
+ new.fast_mode = self.fast_mode
600
+ new._trigger_buffers = [list(self._trigger_buffers[0]), list(self._trigger_buffers[1])]
601
+
602
+ new.state_history = list(self.state_history)
603
+
604
+ new.loop_draw = self.loop_draw
605
+
606
+ new.loop_draw = self.loop_draw
607
+
608
+ # Optimization: Use shallow copy instead of deepcopy.
609
+ # The engine only performs assignment (replace) or clear() (structure),
610
+ # not in-place mutation of the nested lists.
611
+ new.performance_results = self.performance_results.copy()
612
+
613
+ # Copy deferred activation state (Rule 9.7 logic)
614
+ if hasattr(self, "pending_activation") and self.pending_activation:
615
+ new.pending_activation = self.pending_activation.copy()
616
+ if "context" in new.pending_activation:
617
+ new.pending_activation["context"] = new.pending_activation["context"].copy()
618
+ else:
619
+ new.pending_activation = None
620
+
621
+ def inject_card(self, player_idx: int, card_id: int, zone: str, position: int = -1) -> None:
622
+ """Inject a card into a specific zone for testing purposes."""
623
+
624
+ if player_idx < 0 or player_idx >= len(self.players):
625
+ raise ValueError("Invalid player index")
626
+
627
+ p = self.players[player_idx]
628
+
629
+ if zone == "hand":
630
+ if position == -1:
631
+ p.hand.append(card_id)
632
+
633
+ else:
634
+ p.hand.insert(position, card_id)
635
+
636
+ elif zone == "energy":
637
+ if position == -1:
638
+ p.energy_zone.append(card_id)
639
+
640
+ else:
641
+ p.energy_zone.insert(position, card_id)
642
+
643
+ elif zone == "live":
644
+ if position == -1:
645
+ p.live_zone.append(card_id)
646
+
647
+ p.live_zone_revealed.append(False)
648
+
649
+ else:
650
+ p.live_zone.insert(position, card_id)
651
+
652
+ p.live_zone_revealed.insert(position, False)
653
+
654
+ elif zone == "stage":
655
+ if position < 0 or position >= 3:
656
+ raise ValueError("Stage position must be 0-2")
657
+
658
+ p.stage[position] = card_id
659
+
660
+ else:
661
+ raise ValueError(f"Invalid zone: {zone}")
662
+
663
+ @property
664
+ def active_player(self) -> PlayerState:
665
+ return self.players[self.current_player]
666
+
667
+ @property
668
+ def inactive_player(self) -> PlayerState:
669
+ return self.players[1 - self.current_player]
670
+
671
+ def is_terminal(self) -> bool:
672
+ """Check if game has ended"""
673
+
674
+ return self.game_over
675
+
676
+ def get_winner(self) -> int:
677
+ """Returns winner (0 or 1) or -1 if not terminal, 2 if draw"""
678
+
679
+ return self.winner
680
+
681
+ def check_win_condition(self) -> None:
682
+ """Check if anyone has won (3+ successful lives)"""
683
+
684
+ p0_lives = len(self.players[0].success_lives)
685
+
686
+ p1_lives = len(self.players[1].success_lives)
687
+
688
+ if p0_lives >= 3 and p1_lives >= 3:
689
+ self.game_over = True
690
+
691
+ if p0_lives > p1_lives:
692
+ self.winner = 0
693
+
694
+ elif p1_lives > p0_lives:
695
+ self.winner = 1
696
+
697
+ else:
698
+ self.winner = 2 # Draw
699
+
700
+ elif p0_lives >= 3:
701
+ # Rule 1.2.1.1: Player 0 wins by 3 successful lives
702
+ self.game_over = True
703
+ self.winner = 0
704
+ if hasattr(self, "log_rule"):
705
+ self.log_rule("Rule 1.2.1.1", "Player 0 wins by 3 successful lives.")
706
+
707
+ elif p1_lives >= 3:
708
+ # Rule 1.2.1.1: Player 1 wins by 3 successful lives
709
+ self.game_over = True
710
+ self.winner = 1
711
+ if hasattr(self, "log_rule"):
712
+ self.log_rule("Rule 1.2.1.1", "Player 1 wins by 3 successful lives.")
713
+
714
+ def _is_card_legal_for_choice(self, card_id: int, params: Dict[str, Any]) -> bool:
715
+ """Helper to check if a card matches the filter criteria for a choice."""
716
+ if card_id < 0:
717
+ return False
718
+
719
+ # Determine if it's a member or live card
720
+ card = self.member_db.get(card_id) or self.live_db.get(card_id)
721
+ if not card:
722
+ return False
723
+
724
+ # 1. Type filter
725
+ req_type = params.get("filter", params.get("type"))
726
+ if req_type == "member" and card_id not in self.member_db:
727
+ return False
728
+ if req_type == "live" and card_id not in self.live_db:
729
+ return False
730
+
731
+ # 2. Group filter
732
+ group_filter = params.get("group")
733
+ if group_filter:
734
+ target_group = Group.from_japanese_name(group_filter)
735
+ if target_group not in getattr(card, "groups", []):
736
+ # Also check units just in case
737
+ target_unit = Unit.from_japanese_name(group_filter)
738
+ if target_unit not in getattr(card, "units", []):
739
+ return False
740
+
741
+ # 3. Cost filter
742
+ cost_max = params.get("cost_max")
743
+ if cost_max is not None and getattr(card, "cost", 0) > cost_max:
744
+ return False
745
+
746
+ cost_min = params.get("cost_min")
747
+ if cost_min is not None and getattr(card, "cost", 0) < cost_min:
748
+ return False
749
+
750
+ return True
751
+
752
+ def get_legal_actions(self) -> np.ndarray:
753
+ """
754
+
755
+ Returns a mask of legal actions (Rule 9.5.4:
756
+
757
+ Expanded for Complexity:
758
+
759
+ 200-202: Activate ability of member in Area (LEFT, CENTER, RIGHT)
760
+
761
+ 300-359: Mulligan toggle
762
+
763
+ 400-459: Live Set
764
+
765
+ 500-559: Choose card in hand (index 0-59) for effect target
766
+
767
+ 560-562: Choose member on stage (Area 0-2) for effect target
768
+
769
+ 590-599: Choose pending trigger to resolve
770
+
771
+ """
772
+
773
+ mask = np.zeros(2000, dtype=bool)
774
+
775
+ if self.game_over:
776
+ return mask
777
+
778
+ # Priority: If there are choices to be made for a pending effect
779
+
780
+ if self.pending_choices:
781
+ choice_type, params = self.pending_choices[0]
782
+
783
+ p_idx = params.get("player_id", self.current_player)
784
+
785
+ p = self.players[p_idx]
786
+
787
+ if choice_type == "TARGET_HAND":
788
+ # Allow skip for optional costs
789
+
790
+ if params.get("is_optional"):
791
+ mask[0] = True
792
+
793
+ found = False
794
+
795
+ if len(p.hand) > 0:
796
+ for i, cid in enumerate(p.hand):
797
+ is_legal = self._is_card_legal_for_choice(cid, params)
798
+ if self.verbose:
799
+ print(f"DEBUG: TARGET_HAND check idx={i} cid={cid} legal={is_legal} params={params}")
800
+ if is_legal:
801
+ mask[500 + i] = True
802
+
803
+ found = True
804
+
805
+ if not found:
806
+ mask[0] = True # No valid cards in hand, allow pass logic (fizzle)
807
+
808
+ elif choice_type == "TARGET_MEMBER" or choice_type == "TARGET_MEMBER_SLOT":
809
+ # 560-562: Selected member on stage
810
+
811
+ found = False
812
+
813
+ for i in range(3):
814
+ if p.stage[i] >= 0 or choice_type == "TARGET_MEMBER_SLOT":
815
+ # Filter: for 'activate', only tapped members are legal
816
+
817
+ if params.get("effect") == "activate" and not p.tapped_members[i]:
818
+ continue
819
+
820
+ # Apply general filters if card exists
821
+
822
+ if p.stage[i] >= 0:
823
+ if not self._is_card_legal_for_choice(p.stage[i], params):
824
+ continue
825
+
826
+ mask[560 + i] = True
827
+
828
+ found = True
829
+
830
+ if not found:
831
+ mask[0] = True # No valid targets on stage, allow pass (fizzle)
832
+
833
+ elif choice_type == "DISCARD_SELECT":
834
+ # 500-559: Select card in hand to discard
835
+
836
+ # Allow skip for optional costs
837
+
838
+ if params.get("is_optional"):
839
+ mask[0] = True
840
+
841
+ found = False
842
+
843
+ if len(p.hand) > 0:
844
+ for i, cid in enumerate(p.hand):
845
+ if self._is_card_legal_for_choice(cid, params):
846
+ mask[500 + i] = True
847
+
848
+ found = True
849
+
850
+ if not found and params.get("is_optional"):
851
+ mask[0] = True # No cards to discard, allow pass
852
+
853
+ elif choice_type == "MODAL" or choice_type == "SELECT_MODE":
854
+ # params['options'] is a list of strings or list of lists
855
+
856
+ options = params.get("options", [])
857
+
858
+ for i in range(len(options)):
859
+ mask[570 + i] = True
860
+
861
+ elif choice_type == "CHOOSE_FORMATION":
862
+ # For now, just a dummy confirm? Or allow re-arranging?
863
+
864
+ # Simplified: Action 0 to confirm current formation
865
+
866
+ mask[0] = True
867
+
868
+ elif choice_type == "COLOR_SELECT":
869
+ # 580: Red, 581: Blue, 582: Green, 583: Yellow, 584: Purple, 585: Pink
870
+
871
+ for i in range(6):
872
+ mask[580 + i] = True
873
+
874
+ elif choice_type == "TARGET_OPPONENT_MEMBER":
875
+ # Opponent Stage 0-2 -> Action 600-602
876
+
877
+ opp = self.inactive_player
878
+
879
+ found = False
880
+
881
+ for i in range(3):
882
+ if opp.stage[i] >= 0:
883
+ mask[600 + i] = True
884
+
885
+ found = True
886
+
887
+ if not found:
888
+ # If no valid targets but choice exists, softlock prevention:
889
+
890
+ # Ideally we should strictly check before pushing choice, but safe fallback:
891
+
892
+ mask[0] = True # Pass/Cancel
893
+
894
+ elif choice_type == "SELECT_FROM_LIST":
895
+ # 600-659: List selection (up to 60 items)
896
+
897
+ cards = params.get("cards", [])
898
+
899
+ card_count = min(len(cards), 60)
900
+
901
+ if card_count > 0:
902
+ mask[600 : 600 + card_count] = True
903
+
904
+ else:
905
+ mask[0] = True # Empty list, allow pass
906
+
907
+ elif choice_type == "SELECT_FROM_DISCARD":
908
+ # 660-719: Discard selection (up to 60 items)
909
+
910
+ cards = params.get("cards", [])
911
+
912
+ card_count = min(len(cards), 60)
913
+
914
+ if card_count > 0:
915
+ mask[660 : 660 + card_count] = True
916
+
917
+ else:
918
+ mask[0] = True # Empty discard, allow pass
919
+
920
+ elif choice_type == "SELECT_FORMATION_SLOT" or choice_type == "SELECT_ORDER":
921
+ # 720-759: Item selection from a list (Formation)
922
+ cards = params.get("cards", params.get("available_members", []))
923
+ card_count = min(len(cards), 40)
924
+ if card_count > 0:
925
+ mask[720 : 720 + card_count] = True
926
+ else:
927
+ mask[0] = True
928
+
929
+ elif choice_type == "SELECT_SWAP_SOURCE":
930
+ # 600-659: Reuse list selection range
931
+
932
+ cards = params.get("cards", [])
933
+
934
+ card_count = min(len(cards), 60)
935
+
936
+ if card_count > 0:
937
+ mask[600 : 600 + card_count] = True
938
+
939
+ else:
940
+ mask[0] = True
941
+
942
+ elif choice_type == "SELECT_SWAP_TARGET":
943
+ # 500-559: Target hand range
944
+
945
+ if len(p.hand) > 0:
946
+ for i in range(len(p.hand)):
947
+ mask[500 + i] = True
948
+
949
+ else:
950
+ mask[0] = True
951
+
952
+ elif choice_type == "SELECT_SUCCESS_LIVE" or choice_type == "TARGET_SUCCESS_LIVES":
953
+ # 760-819: Select from passed lives list (Score)
954
+ cards = params.get("cards", p.success_lives)
955
+ card_count = min(len(cards), 60)
956
+ if card_count > 0:
957
+ mask[760 : 760 + card_count] = True
958
+ else:
959
+ mask[0] = True
960
+
961
+ elif choice_type == "TARGET_LIVE":
962
+ # 820-822: Select specific slot in Live Zone
963
+ for i in range(len(p.live_zone)):
964
+ mask[820 + i] = True
965
+ if not any(mask[820:823]):
966
+ mask[0] = True
967
+
968
+ elif choice_type == "TARGET_ENERGY_ZONE":
969
+ # 830-849: Select specific card in Energy Zone
970
+ for i in range(len(p.energy_zone)):
971
+ if i < 20:
972
+ mask[830 + i] = True
973
+ if not any(mask[830:850]):
974
+ mask[0] = True
975
+
976
+ elif choice_type == "TARGET_REMOVED":
977
+ # 850-909: Select from Removed cards
978
+ count = min(len(self.removed_cards), 60)
979
+ if count > 0:
980
+ mask[850 : 850 + count] = True
981
+ else:
982
+ mask[0] = True
983
+
984
+ elif choice_type == "TARGET_DECK" or choice_type == "TARGET_ENERGY_DECK" or choice_type == "TARGET_DISCARD":
985
+ # List selection ranges
986
+ cards = params.get("cards", [])
987
+ card_count = min(len(cards), 60)
988
+ offset = 600 if choice_type != "TARGET_DISCARD" else 660
989
+ if card_count > 0:
990
+ mask[offset : offset + card_count] = True
991
+ else:
992
+ mask[0] = True
993
+
994
+ # MULLIGAN phases: Select cards to return or confirm mulligan
995
+
996
+ elif self.phase in (Phase.MULLIGAN_P1, Phase.MULLIGAN_P2):
997
+ p = self.active_player
998
+
999
+ mask[0] = True # Confirm mulligan (done selecting)
1000
+
1001
+ # Actions 300-359: Select card for mulligan (card index 0-59)
1002
+
1003
+ # Note: We allow toggling selection.
1004
+
1005
+ m_sel = getattr(p, "mulligan_selection", set())
1006
+
1007
+ for i in range(len(p.hand)):
1008
+ mask[300 + i] = True
1009
+
1010
+ # Auto-advance phases: these phases process automatically in 'step' when any valid action is received
1011
+
1012
+ # We allow Action 0 (Pass) to trigger the transition.
1013
+
1014
+ elif self.phase in (Phase.ACTIVE, Phase.ENERGY):
1015
+ mask[0] = True
1016
+ elif self.phase == Phase.PERFORMANCE_P1 or self.phase == Phase.PERFORMANCE_P2:
1017
+ p = self.active_player
1018
+ mask[0] = True # Always can pass (skip performance)
1019
+
1020
+ # Check all lives in live zone
1021
+ for i, card_id in enumerate(p.live_zone):
1022
+ # Standard Live ID as Action ID
1023
+ if card_id in self.live_db:
1024
+ live_card = self.live_db[card_id]
1025
+
1026
+ # Check requirements
1027
+ reqs = getattr(live_card, "required_hearts", [0] * 7)
1028
+ if len(reqs) < 7:
1029
+ reqs = [0] * 7
1030
+
1031
+ stage_hearts = [0] * 7
1032
+ total_blades = 0
1033
+
1034
+ for slot in range(3):
1035
+ sid = p.stage[slot]
1036
+ if sid in self.member_db:
1037
+ m = self.member_db[sid]
1038
+ total_blades += m.blades
1039
+
1040
+ # Determine color index (1-6) from hearts
1041
+ # Heuristic: Find first non-zero index in hearts array
1042
+ # This mimics vector_env logic
1043
+ col = 0
1044
+ h_arr = m.hearts
1045
+ for cidx, val in enumerate(h_arr):
1046
+ if val > 0:
1047
+ col = cidx + 1
1048
+ break
1049
+
1050
+ if 1 <= col <= 6:
1051
+ stage_hearts[col] += m.hearts[col - 1] # m.hearts is 0-indexed?
1052
+ # Wait, GameState initializes hearts_vec with m.hearts
1053
+ # m.hearts is usually [Pink, Red, ...]
1054
+ # Let's assume m.hearts is standard 7-dim or 6-dim
1055
+ # If m.hearts[0] is Pink (Color 1), then:
1056
+ pass
1057
+
1058
+ # Re-calculating correctly using GameState helper if available,
1059
+ # else manual sum matching VectorEnv
1060
+
1061
+ # Optimized check:
1062
+ # Use existing helper? p.get_effective_hearts?
1063
+ # But that returns vector.
1064
+
1065
+ # Let's use p.stage stats directly
1066
+ current_hearts = [0] * 7
1067
+ current_blades = 0
1068
+ for slot in range(3):
1069
+ if p.stage[slot] != -1:
1070
+ eff_h = p.get_effective_hearts(slot, self.member_db)
1071
+ for c in range(7):
1072
+ current_hearts[c] += eff_h[c]
1073
+ current_blades += p.get_effective_blades(slot, self.member_db)
1074
+
1075
+ # Check against reqs
1076
+ # reqs[0] is usually Any? Or Pink?
1077
+ # In VectorEnv: 12-18 (Pink..Purple, All)
1078
+ # live_card.required_hearts is 0-indexed typically [Pink, Red, Yel, Grn, Blu, Pur, Any]
1079
+
1080
+ met = True
1081
+ # Check colors (0-5)
1082
+ for c in range(6):
1083
+ if current_hearts[c] < reqs[c]:
1084
+ met = False
1085
+ break
1086
+ # Check Any (index 6, matches any color + explicit Any?)
1087
+ # Usually Any req is satisfied by sum of all?
1088
+ # For strictness, let's assume reqs[6] is specific "Any" points needed (wildcard).
1089
+ # VectorEnv logic was:
1090
+ # if stage_hearts[1] < req_pink...
1091
+
1092
+ # Assuming standard behavior:
1093
+ if met and current_blades > 0:
1094
+ mask[900 + i] = True
1095
+
1096
+ elif self.phase == Phase.DRAW or self.phase == Phase.LIVE_RESULT:
1097
+ mask[0] = True
1098
+
1099
+ elif self.phase == Phase.MAIN:
1100
+ p = self.active_player
1101
+
1102
+ # Can always pass
1103
+
1104
+ mask[0] = True
1105
+
1106
+ # --- SHARED PRE-CALCULATIONS ---
1107
+
1108
+ available_energy = p.count_untapped_energy()
1109
+
1110
+ total_reduction = 0
1111
+
1112
+ for ce in p.continuous_effects:
1113
+ if ce["effect"].effect_type == EffectType.REDUCE_COST:
1114
+ total_reduction += ce["effect"].value
1115
+
1116
+ # --- PLAY MEMBERS ---
1117
+
1118
+ if "placement" not in p.restrictions:
1119
+ # JIT Optimization Path
1120
+
1121
+ # JIT Path disabled temporarily for training stability
1122
+
1123
+ if False and JIT_AVAILABLE and self._jit_member_costs is not None:
1124
+ # Use pre-allocated hand buffer to avoid reallocation
1125
+
1126
+ hand_len = len(p.hand)
1127
+
1128
+ if hand_len > 0:
1129
+ p.hand_buffer[:hand_len] = p.hand
1130
+
1131
+ calc_main_phase_masks(
1132
+ p.hand_buffer[:hand_len],
1133
+ p.stage,
1134
+ available_energy,
1135
+ total_reduction,
1136
+ True, # Baton touch is always allowed if slot occupied
1137
+ p.members_played_this_turn,
1138
+ self._jit_member_costs,
1139
+ mask,
1140
+ )
1141
+
1142
+ else:
1143
+ # Python Fallback
1144
+
1145
+ for i, card_id in enumerate(p.hand):
1146
+ if card_id not in self.member_db:
1147
+ continue
1148
+
1149
+ member = self.member_db[card_id]
1150
+
1151
+ for area in range(3):
1152
+ action_id = 1 + i * 3 + area
1153
+
1154
+ if p.members_played_this_turn[area]:
1155
+ continue
1156
+
1157
+ is_baton = p.stage[area] >= 0
1158
+
1159
+ # Calculate effective baton touch limit
1160
+ extra_baton = sum(
1161
+ ce["effect"].value
1162
+ for ce in p.continuous_effects
1163
+ if ce["effect"].effect_type == EffectType.BATON_TOUCH_MOD
1164
+ )
1165
+ effective_baton_limit = p.baton_touch_limit + extra_baton
1166
+
1167
+ if is_baton and p.baton_touch_count >= effective_baton_limit:
1168
+ continue
1169
+
1170
+ # Calculate slot-specific cost
1171
+ slot_reduction = sum(
1172
+ ce["effect"].value
1173
+ for ce in p.continuous_effects
1174
+ if ce["effect"].effect_type == EffectType.REDUCE_COST
1175
+ and (ce.get("target_slot", -1) in (-1, area))
1176
+ )
1177
+
1178
+ active_cost = max(0, member.cost - slot_reduction)
1179
+
1180
+ if is_baton:
1181
+ if p.stage[area] in self.member_db:
1182
+ baton_mem = self.member_db[p.stage[area]]
1183
+ active_cost = max(0, active_cost - baton_mem.cost)
1184
+
1185
+ if active_cost <= available_energy:
1186
+ mask[action_id] = True
1187
+
1188
+ # DEBUG: Trace why specific cards fail
1189
+
1190
+ elif self.verbose and (member.cost >= 10 or card_id == 369):
1191
+ print(
1192
+ f"DEBUG REJECT: Card {card_id} ({getattr(member, 'name', 'Unknown')}) Area {area}: Cost {active_cost} > Energy {available_energy}. Limit {p.baton_touch_limit}, Count {p.baton_touch_count}"
1193
+ )
1194
+
1195
+ # --- ACTIVATE ABILITIES ---
1196
+
1197
+ # Uses same available_energy
1198
+
1199
+ for i, card_id in enumerate(p.stage):
1200
+ if card_id >= 0 and card_id in self.member_db and not p.tapped_members[i]:
1201
+ member = self.member_db[card_id]
1202
+
1203
+ for abi_idx, ab in enumerate(member.abilities):
1204
+ if ab.trigger == TriggerType.ACTIVATED:
1205
+ # Rule 9.7: Check once per turn
1206
+ abi_key = f"{card_id}-{abi_idx}"
1207
+ if ab.is_once_per_turn and abi_key in p.used_abilities:
1208
+ continue
1209
+
1210
+ # Strict verification: Check conditions and costs
1211
+
1212
+ is_legal = True
1213
+
1214
+ if not all(self._check_condition(p, cond, context={"area": i}) for cond in ab.conditions):
1215
+ is_legal = False
1216
+
1217
+ if is_legal and not self._can_pay_costs(p, ab.costs, source_area=i):
1218
+ # print(f"DEBUG: Cost check failed for card {card_id} area {i}. Costs: {ab.costs}")
1219
+ is_legal = False
1220
+
1221
+ if is_legal:
1222
+ mask[200 + i] = True
1223
+ # else:
1224
+ # print(f"DEBUG: Ability {ab.raw_text} illegal for card {card_id} area {i}")
1225
+
1226
+ break # Only one ability activation per member slot
1227
+
1228
+ elif self.phase == Phase.LIVE_SET:
1229
+ p = self.active_player
1230
+
1231
+ mask[0] = True
1232
+
1233
+ # Check live restriction (Rule 8.3.4.1 / Cluster 3)
1234
+
1235
+ if "live" not in p.restrictions and len(p.live_zone) < 3:
1236
+ # Allow ANY card to be set (Rule 8.2.2: "Choose up to 3 cards from your hand")
1237
+ for i, card_id in enumerate(p.hand):
1238
+ mask[400 + i] = True
1239
+
1240
+ else:
1241
+ # Other phases are automatic
1242
+
1243
+ mask[0] = True
1244
+
1245
+ # Safety check: Ensure at least one action is legal to prevent softlocks
1246
+
1247
+ if not np.any(mask):
1248
+ # Force action 0 (Pass) as legal
1249
+
1250
+ mask[0] = True
1251
+
1252
+ # print(f"WARNING: No legal actions found in phase {self.phase.name}, forcing Pass action")
1253
+
1254
+ return mask
1255
+
1256
+ def step(self, action_id: int, check_legality: bool = True, in_place: bool = False) -> "GameState":
1257
+ """
1258
+
1259
+ Executes one step in the game (Rule 9).
1260
+
1261
+ Args:
1262
+ action_id: The action to execute.
1263
+ check_legality: Whether to verify action legality. Disable for speed if caller guarantees validity.
1264
+ in_place: If True, modifies the state in-place instead of copying. Faster for RL.
1265
+
1266
+ """
1267
+ self.action_count_this_turn += 1
1268
+ if self.action_count_this_turn > 1000:
1269
+ self.game_over = True
1270
+ self.winner = 2 # Draw due to runaway logic
1271
+ self.log_rule("Safety", "Turn exceeded 1000 actions. Force terminating as Draw.")
1272
+ return self
1273
+
1274
+ if self.game_over:
1275
+ print(f"WARNING: Step called after Game Over (Winner: {self.winner}). Ignoring action {action_id}.")
1276
+
1277
+ return self
1278
+
1279
+ # Strict validation for debugging
1280
+ if check_legality:
1281
+ legal_actions = self.get_legal_actions()
1282
+
1283
+ if not legal_actions[action_id]:
1284
+ # Soft fallback for illegal moves to prevent crashes
1285
+ legal_indices = np.where(legal_actions)[0]
1286
+
1287
+ # print(
1288
+ # f"ILLEGAL MOVE CAUGHT: Action {action_id} in phase {self.phase}. "
1289
+ # f"PendingChoices: {len(self.pending_choices)}. "
1290
+ # f"Fallback to first legal action: {legal_indices[0] if len(legal_indices) > 0 else 'None'}"
1291
+ # )
1292
+
1293
+ if len(legal_indices) > 0:
1294
+ if 0 in legal_indices:
1295
+ action_id = 0
1296
+ else:
1297
+ action_id = int(legal_indices[0])
1298
+ else:
1299
+ self.game_over = True
1300
+ self.winner = -2 # Special code for illegal move failure
1301
+ return self
1302
+
1303
+ if in_place:
1304
+ new_state = self
1305
+ else:
1306
+ new_state = self.copy()
1307
+
1308
+ new_state.log_rule("Rule 9.5", f"Processing action {action_id} in {new_state.phase} phase.")
1309
+
1310
+ # Check rule conditions before acting (Rule 9.5.1 / 10.1.2)
1311
+ # MUST be done on new_state
1312
+ new_state._process_rule_checks()
1313
+
1314
+ # Rule 9.5.4.1: Check timing occurs before play timing
1315
+ new_state._process_rule_checks()
1316
+
1317
+ # Priority: If waiting for a choice (like targeting), handles that action
1318
+
1319
+ if new_state.pending_choices:
1320
+ new_state._handle_choice(action_id)
1321
+
1322
+ # Otherwise, if resolving a complex effect stack
1323
+
1324
+ elif new_state.pending_effects:
1325
+ new_state._resolve_pending_effect(0) # 0 is dummy action for auto-res
1326
+
1327
+ # Normal action execution
1328
+
1329
+ else:
1330
+ new_state._execute_action(action_id)
1331
+
1332
+ # After any action, automatically process non-choice effects
1333
+
1334
+ while new_state.pending_effects and not new_state.pending_choices:
1335
+ new_state._resolve_pending_effect(0) # 0 is dummy action for auto-res
1336
+
1337
+ # Rule 9.5.1: Final check timing after action resolution
1338
+
1339
+ new_state._process_rule_checks()
1340
+
1341
+ # Rule 12.1: Infinite Loop Detection
1342
+
1343
+ # Skip for Mulligan phases and if disabled
1344
+
1345
+ if new_state.enable_loop_detection and new_state.phase not in (Phase.MULLIGAN_P1, Phase.MULLIGAN_P2):
1346
+ try:
1347
+ # Capture key state tuple
1348
+
1349
+ state_tuple = (
1350
+ new_state.phase,
1351
+ new_state.current_player,
1352
+ tuple(sorted(new_state.players[0].hand)),
1353
+ tuple(new_state.players[0].stage),
1354
+ tuple(tuple(x) for x in new_state.players[0].stage_energy),
1355
+ tuple(new_state.players[0].energy_zone),
1356
+ tuple(sorted(new_state.players[1].hand)),
1357
+ tuple(new_state.players[1].stage),
1358
+ tuple(tuple(x) for x in new_state.players[1].stage_energy),
1359
+ tuple(new_state.players[1].energy_zone),
1360
+ tuple(sorted(list(new_state.players[0].used_abilities))),
1361
+ tuple(sorted(list(new_state.players[1].used_abilities))),
1362
+ )
1363
+
1364
+ state_hash = hash(state_tuple)
1365
+
1366
+ new_state.state_history.append(state_hash)
1367
+
1368
+ if new_state.state_history.count(state_hash) >= 20:
1369
+ new_state.log_rule("Rule 12.1", "Infinite Loop detected. Terminating as Draw.")
1370
+
1371
+ new_state.game_over = True
1372
+
1373
+ new_state.winner = 2 # Draw
1374
+
1375
+ new_state.loop_draw = True
1376
+
1377
+ except Exception:
1378
+ # If hashing fails, just ignore for now to prevent crash
1379
+
1380
+ pass
1381
+
1382
+ return new_state
1383
+
1384
+ def get_observation(self) -> np.ndarray:
1385
+ """
1386
+ Calculates a flat feature vector representing the game state for the AI (Rule 9.1).
1387
+
1388
+ New Layout (Size 320):
1389
+ [0-36]: Metadata (Phase, Player, Choice Context)
1390
+ [36-168]: Hand (12 cards x 11 floats) -> [Exist, ID, Cost, Blade, HeartVec(7)]
1391
+ [168-204]: Self Stage (3 slots x 12 floats) -> [Exist, ID, Tapped, Blade, HeartVec(7), Energy]
1392
+ [204-240]: Opponent Stage (3 slots x 12 floats) -> [Exist, ID, Tapped, Blade, HeartVec(7), Energy]
1393
+ [240-270]: Live Zone (3 cards x 10 floats) -> [Exist, ID, Score, ReqHeartVec(7)]
1394
+ [270-272]: Scores (Self, Opp)
1395
+ [272]: Source ID of pending choice
1396
+ [273-320]: Padding
1397
+ """
1398
+
1399
+ # Expanded observation size
1400
+ features = np.zeros(320, dtype=np.float32)
1401
+
1402
+ # JIT Arrays Check
1403
+ if GameState._jit_member_costs is None:
1404
+ GameState._init_jit_arrays()
1405
+
1406
+ costs_db = GameState._jit_member_costs
1407
+ hearts_vec_db = GameState._jit_member_hearts_vec
1408
+ blades_db = GameState._jit_member_blades
1409
+ live_score_db = GameState._jit_live_score
1410
+ live_req_vec_db = GameState._jit_live_hearts_vec
1411
+
1412
+ # Max ID for normalization (add safety for 0 div)
1413
+ max_id_val = float(costs_db.shape[0]) if costs_db is not None else 2000.0
1414
+
1415
+ # --- 1. METADATA [0:36] ---
1416
+
1417
+ # Phase (one-hot) [0:16] - using 11 slots
1418
+ phase_val = int(self.phase) + 2
1419
+ if 0 <= phase_val < 11:
1420
+ features[phase_val] = 1.0
1421
+
1422
+ # Current Player [16:18]
1423
+ features[16 + (1 if self.current_player == 1 else 0)] = 1.0
1424
+
1425
+ # Pending Choice [18:36]
1426
+ if self.pending_choices:
1427
+ features[18] = 1.0
1428
+ choice_type, params = self.pending_choices[0]
1429
+
1430
+ # Populate Source ID if available [272]
1431
+ source_id = params.get("card_id", -1)
1432
+ if source_id >= 0:
1433
+ features[272] = source_id / max_id_val
1434
+
1435
+ types = [
1436
+ "TARGET_MEMBER",
1437
+ "TARGET_HAND",
1438
+ "SELECT_MODE",
1439
+ "COLOR_SELECT",
1440
+ "TARGET_OPPONENT_MEMBER",
1441
+ "TARGET_MEMBER_SLOT",
1442
+ "SELECT_SWAP_SOURCE",
1443
+ "SELECT_FROM_LIST",
1444
+ "SELECT_FROM_DISCARD",
1445
+ "DISCARD_SELECT",
1446
+ "MODAL",
1447
+ "CHOOSE_FORMATION",
1448
+ "SELECT_ORDER",
1449
+ "SELECT_FORMATION_SLOT",
1450
+ "SELECT_SUCCESS_LIVE",
1451
+ ]
1452
+ try:
1453
+ t_idx = types.index(choice_type)
1454
+ features[19 + t_idx] = 1.0
1455
+ except ValueError:
1456
+ pass
1457
+
1458
+ if params.get("is_optional"):
1459
+ features[35] = 1.0
1460
+
1461
+ # --- 2. HAND [36:168] (12 cards * 11 features) ---
1462
+ p = self.players[self.current_player]
1463
+ hand_len = len(p.hand)
1464
+ n_hand = min(hand_len, 12)
1465
+
1466
+ if n_hand > 0:
1467
+ hand_ids = np.array(p.hand[:n_hand], dtype=int)
1468
+ base_idx = np.arange(n_hand) * 11 + 36
1469
+
1470
+ # Existence
1471
+ features[base_idx] = 1.0
1472
+
1473
+ # ID
1474
+ features[base_idx + 1] = hand_ids / max_id_val
1475
+
1476
+ # Cost
1477
+ c = costs_db[hand_ids]
1478
+ features[base_idx + 2] = np.clip(c, 0, 10) / 10.0
1479
+
1480
+ # Blade
1481
+ b = blades_db[hand_ids]
1482
+ features[base_idx + 3] = np.clip(b, 0, 10) / 10.0
1483
+
1484
+ # Heart Vectors (7 dim)
1485
+ # Flatten 12x7 -> 84? No, interleaved.
1486
+ # We need to assign (N, 7) into sliced positions.
1487
+ # This is tricky with simple slicing if stride is not 1.
1488
+ # Loop for safety or advanced indexing.
1489
+ # shape of h_vecs: (n_hand, 7)
1490
+ h_vecs = hearts_vec_db[hand_ids]
1491
+
1492
+ for i in range(n_hand):
1493
+ start = 36 + i * 11 + 4
1494
+ features[start : start + 7] = np.clip(h_vecs[i], 0, 5) / 5.0
1495
+
1496
+ # --- 3. SELF STAGE [168:204] (3 slots * 12 features) ---
1497
+ for i in range(3):
1498
+ cid = p.stage[i]
1499
+ base = 168 + i * 12
1500
+ if cid >= 0:
1501
+ features[base] = 1.0
1502
+ features[base + 1] = cid / max_id_val
1503
+ features[base + 2] = 1.0 if p.tapped_members[i] else 0.0
1504
+
1505
+ # Effective Stats (retains python logic for modifiers)
1506
+ eff_blade = p.get_effective_blades(i, self.member_db)
1507
+ eff_hearts = p.get_effective_hearts(i, self.member_db) # vector
1508
+
1509
+ features[base + 3] = min(eff_blade / 10.0, 1.0)
1510
+
1511
+ # Hearts (7)
1512
+ # eff_hearts is usually (6,) or (7,) or list
1513
+ if isinstance(eff_hearts, (list, np.ndarray)):
1514
+ h_len = min(len(eff_hearts), 7)
1515
+ features[base + 4 : base + 4 + h_len] = np.array(eff_hearts[:h_len]) / 5.0
1516
+
1517
+ # Energy Count
1518
+ features[base + 11] = min(len(p.stage_energy[i]) / 5.0, 1.0)
1519
+
1520
+ # --- 4. OPPONENT STAGE [204:240] (3 slots * 12 features) ---
1521
+ opp = self.players[1 - self.current_player]
1522
+ for i in range(3):
1523
+ cid = opp.stage[i]
1524
+ base = 204 + i * 12
1525
+ if cid >= 0:
1526
+ features[base] = 1.0
1527
+ features[base + 1] = cid / max_id_val
1528
+ features[base + 2] = 1.0 if opp.tapped_members[i] else 0.0
1529
+
1530
+ # Note: get_effective_blades requires accessing the opponent object relative to the DB
1531
+ # but GameState usually uses p methods.
1532
+ # p.get_effective_blades uses self.stage.
1533
+ # So we call opp.get_effective_blades.
1534
+ eff_blade = opp.get_effective_blades(i, self.member_db)
1535
+ eff_hearts = opp.get_effective_hearts(i, self.member_db)
1536
+
1537
+ features[base + 3] = min(eff_blade / 10.0, 1.0)
1538
+
1539
+ if isinstance(eff_hearts, (list, np.ndarray)):
1540
+ h_len = min(len(eff_hearts), 7)
1541
+ features[base + 4 : base + 4 + h_len] = np.array(eff_hearts[:h_len]) / 5.0
1542
+
1543
+ features[base + 11] = min(len(opp.stage_energy[i]) / 5.0, 1.0)
1544
+
1545
+ # --- 5. LIVE ZONE [240:270] (3 cards * 10 features) ---
1546
+ n_live = min(len(p.live_zone), 3)
1547
+ if n_live > 0:
1548
+ live_ids = np.array(p.live_zone[:n_live], dtype=int)
1549
+
1550
+ for i in range(n_live):
1551
+ cid = live_ids[i]
1552
+ base = 240 + i * 10
1553
+ features[base] = 1.0
1554
+ features[base + 1] = cid / max_id_val
1555
+ features[base + 2] = np.clip(live_score_db[cid], 0, 5) / 5.0
1556
+
1557
+ # Req Heart Vec (7)
1558
+ if live_req_vec_db is not None:
1559
+ features[base + 3 : base + 10] = np.clip(live_req_vec_db[cid], 0, 5) / 5.0
1560
+
1561
+ # --- 6. SCORES [270:272] ---
1562
+ features[270] = min(len(p.success_lives) / 5.0, 1.0)
1563
+ features[271] = min(len(self.players[1 - self.current_player].success_lives) / 5.0, 1.0)
1564
+
1565
+ return features
1566
+
1567
+ def to_dict(self):
1568
+ """Serialize full game state."""
1569
+
1570
+ return {
1571
+ "turn": self.turn_number,
1572
+ "phase": self.phase,
1573
+ "active_player": self.current_player,
1574
+ "game_over": self.game_over,
1575
+ "winner": self.winner,
1576
+ "players": [p.to_dict(viewer_idx=0) for p in self.players],
1577
+ "legal_actions": [], # Can populate if needed
1578
+ "pending_choice": None,
1579
+ "performance_results": {},
1580
+ "rule_log": list(self.rule_log),
1581
+ }
1582
+
1583
+ def get_reward(self, player_idx: int) -> float:
1584
+ # Get reward for player (1.0 for win, -1.0 for loss, 0.0 for draw)
1585
+ # Illegal move (-2) is treated as a loss (-1.0) for safety in standard RL,
1586
+ # though explicit training usually handles this via masking or separate loss.
1587
+
1588
+ if self.winner == -2:
1589
+ return -100.0 # Illegal move/Technical loss
1590
+
1591
+ if self.winner == player_idx:
1592
+ return 100.0
1593
+ elif self.winner == 1 - player_idx:
1594
+ return -100.0
1595
+ elif self.winner == 2: # Draw
1596
+ return 0.0
1597
+ elif self.winner == -1: # Ongoing
1598
+ # Ongoing heuristic: Pure score difference
1599
+ # Time penalties are now handled by the Gymnasium environment (per turn)
1600
+ my_score = len(self.players[player_idx].success_lives)
1601
+ opp_score = len(self.players[1 - player_idx].success_lives)
1602
+ return float(my_score - opp_score)
1603
+
1604
+ def take_action(self, action_id: int) -> None:
1605
+ """In-place version of step() for testing and direct manipulation."""
1606
+
1607
+ if self.pending_choices:
1608
+ self._handle_choice(action_id)
1609
+
1610
+ else:
1611
+ self._execute_action(action_id)
1612
+
1613
+ # Process resulting effects
1614
+
1615
+ while self.pending_effects and not self.pending_choices:
1616
+ self._resolve_pending_effect(0)
1617
+
1618
+
1619
+ def create_sample_cards() -> Tuple[Dict[int, MemberCard], Dict[int, LiveCard]]:
1620
+ """Create sample cards for testing"""
1621
+
1622
+ members = {}
1623
+
1624
+ lives = {}
1625
+
1626
+ # Create 48 sample members with varying stats
1627
+
1628
+ for i in range(48):
1629
+ cost = 2 + (i % 14) # Costs 2-15
1630
+
1631
+ blades = 1 + (i % 6) # Blades 1-6
1632
+
1633
+ hearts = np.zeros(7, dtype=np.int32) # Changed from 6 to 7
1634
+
1635
+ hearts[i % 6] = 1 + (i // 6 % 3) # 1-3 hearts of one color
1636
+
1637
+ if i >= 24:
1638
+ hearts[(i + 1) % 6] = 1 # Second color for higher cost cards
1639
+
1640
+ blade_hearts = np.zeros(6, dtype=np.int32)
1641
+
1642
+ if i % 3 == 0:
1643
+ blade_hearts[i % 6] = 1
1644
+
1645
+ members[i] = MemberCard(
1646
+ card_id=i,
1647
+ card_no=f"SAMPLE-M-{i}",
1648
+ name=f"Member_{i}",
1649
+ cost=cost,
1650
+ hearts=hearts,
1651
+ blade_hearts=blade_hearts,
1652
+ blades=blades,
1653
+ )
1654
+
1655
+ # Create 12 sample live cards
1656
+
1657
+ for i in range(12):
1658
+ score = 1 + (i % 3) # Score 1-3
1659
+
1660
+ required = np.zeros(7, dtype=np.int32)
1661
+
1662
+ required[i % 6] = 2 + (i // 6) # 2-3 of one color required
1663
+
1664
+ required[6] = 1 + (i % 4) # 1-4 "any" hearts required
1665
+
1666
+ lives[100 + i] = LiveCard(
1667
+ card_id=100 + i, card_no=f"SAMPLE-L-{i}", name=f"Live_{i}", score=score, required_hearts=required
1668
+ )
1669
+
1670
+ return members, lives
1671
+
1672
+
1673
+ def initialize_game(use_real_data: bool = True, deck_type: str = "normal") -> GameState:
1674
+ """
1675
+
1676
+ Create initial game state with shuffled decks.
1677
+
1678
+ Args:
1679
+
1680
+ use_real_data: Whether to try loading real cards.json data
1681
+
1682
+ deck_type: "normal" (random from DB) or "vanilla" (specific simple cards)
1683
+
1684
+ """
1685
+
1686
+ # Try loading real data
1687
+
1688
+ if use_real_data and not GameState.member_db:
1689
+ import traceback
1690
+
1691
+ # print("DEBUG: initialize_game attempting to load real data...")
1692
+
1693
+ try:
1694
+ # Try current directory first (assuming run from root)
1695
+
1696
+ data_path = os.path.join(os.getcwd(), "data", "cards_compiled.json")
1697
+
1698
+ if not os.path.exists(data_path):
1699
+ # Fallback to cards.json
1700
+
1701
+ data_path = os.path.join(os.getcwd(), "data", "cards.json")
1702
+
1703
+ if not os.path.exists(data_path):
1704
+ # Absolute path fallback based on file location
1705
+
1706
+ base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
1707
+
1708
+ data_path = os.path.join(base_dir, "data", "cards_compiled.json")
1709
+
1710
+ # print(f"DEBUG: Selected data path: {data_path}")
1711
+
1712
+ if not os.path.exists(data_path):
1713
+ # print(f"ERROR: Data path does not exist: {data_path}")
1714
+ pass
1715
+
1716
+ else:
1717
+ loader = CardDataLoader(data_path)
1718
+
1719
+ m, l, e = loader.load()
1720
+
1721
+ if m:
1722
+ GameState.member_db = m
1723
+ GameState.live_db = l
1724
+ print(f"SUCCESS: Loaded {len(m)} members and {len(l)} lives from {data_path}")
1725
+
1726
+ # Optimization: Cache cards with CONSTANT META_RULE effects
1727
+ GameState._meta_rule_cards = set()
1728
+ for cid, card in m.items():
1729
+ for ab in card.abilities:
1730
+ if ab.trigger.name == "CONSTANT": # Check string to avoid import if needed, or use enum
1731
+ for eff in ab.effects:
1732
+ if eff.effect_type.name == "META_RULE":
1733
+ GameState._meta_rule_cards.add(cid)
1734
+ break
1735
+ for cid, card in l.items():
1736
+ for ab in card.abilities:
1737
+ if ab.trigger.name == "CONSTANT":
1738
+ for eff in ab.effects:
1739
+ if eff.effect_type.name == "META_RULE":
1740
+ GameState._meta_rule_cards.add(cid)
1741
+ break
1742
+
1743
+ GameState._init_jit_arrays()
1744
+
1745
+ else:
1746
+ # print("WARNING: Loader returned empty member database.")
1747
+ pass
1748
+
1749
+ except Exception as e:
1750
+ print(f"CRITICAL: Failed to load real data: {e}")
1751
+ import traceback
1752
+ traceback.print_exc()
1753
+ pass
1754
+
1755
+ traceback.print_exc()
1756
+
1757
+ if not GameState.member_db:
1758
+ # print("WARNING: Falling back to SAMPLE cards. This may cause logic inconsistencies.")
1759
+
1760
+ # Fallback to sample
1761
+
1762
+ members, lives = create_sample_cards()
1763
+
1764
+ GameState.member_db = members
1765
+
1766
+ GameState.live_db = lives
1767
+
1768
+ GameState._init_jit_arrays()
1769
+
1770
+ state = GameState()
1771
+
1772
+ # Pre-calculate Vanilla Deck IDs if needed
1773
+
1774
+ vanilla_member_ids = []
1775
+
1776
+ vanilla_live_ids = []
1777
+
1778
+ if deck_type == "vanilla":
1779
+ # Target Vanilla Members (4 copies each = 48)
1780
+
1781
+ # 5 Vanilla + 7 Simple
1782
+
1783
+ target_members = [
1784
+ "PL!-sd1-010-SD",
1785
+ "PL!-sd1-013-SD",
1786
+ "PL!-sd1-014-SD",
1787
+ "PL!-sd1-017-SD",
1788
+ "PL!-sd1-018-SD", # Vanilla
1789
+ "PL!-sd1-002-SD",
1790
+ "PL!-sd1-005-SD",
1791
+ "PL!-sd1-011-SD",
1792
+ "PL!-sd1-012-SD",
1793
+ "PL!-sd1-016-SD", # Simple
1794
+ "PL!-sd1-015-SD",
1795
+ "PL!-sd1-007-SD",
1796
+ ]
1797
+
1798
+ # Target Vanilla Lives (3 copies each = 12)
1799
+
1800
+ target_lives = ["PL!-sd1-019-SD", "PL!-sd1-020-SD", "PL!-sd1-021-SD", "PL!-sd1-022-SD"]
1801
+
1802
+ # 1. Map Members
1803
+
1804
+ found_members = {}
1805
+
1806
+ for cid, card in GameState.member_db.items():
1807
+ if card.card_no in target_members:
1808
+ found_members[card.card_no] = cid
1809
+
1810
+ # 2. Map Lives
1811
+
1812
+ found_lives = {}
1813
+
1814
+ for cid, card in GameState.live_db.items():
1815
+ if card.card_no in target_lives:
1816
+ found_lives[card.card_no] = cid
1817
+
1818
+ # 3. Construct Lists
1819
+
1820
+ for tm in target_members:
1821
+ if tm in found_members:
1822
+ vanilla_member_ids.extend([found_members[tm]] * 4)
1823
+
1824
+ else:
1825
+ # print(f"WARNING: Vanilla card {tm} not found in DB!")
1826
+ pass
1827
+
1828
+ for tl in target_lives:
1829
+ if tl in found_lives:
1830
+ vanilla_live_ids.extend([found_lives[tl]] * 3)
1831
+
1832
+ else:
1833
+ # print(f"WARNING: Vanilla live {tl} not found in DB!")
1834
+ pass
1835
+
1836
+ # Fill if missing?
1837
+
1838
+ if len(vanilla_member_ids) < 48:
1839
+ # print(f"WARNING: Vanilla deck incomplete ({len(vanilla_member_ids)}), filling with randoms.")
1840
+ pass
1841
+
1842
+ remaining = 48 - len(vanilla_member_ids)
1843
+
1844
+ all_ids = list(GameState.member_db.keys())
1845
+
1846
+ if all_ids:
1847
+ vanilla_member_ids.extend(np.random.choice(all_ids, remaining).tolist())
1848
+
1849
+ if len(vanilla_live_ids) < 12:
1850
+ # print(f"WARNING: Vanilla live deck incomplete ({len(vanilla_live_ids)}), filling with randoms.")
1851
+ pass
1852
+
1853
+ remaining = 12 - len(vanilla_live_ids)
1854
+
1855
+ all_ids = list(GameState.live_db.keys())
1856
+
1857
+ if all_ids:
1858
+ vanilla_live_ids.extend(np.random.choice(all_ids, remaining).tolist())
1859
+
1860
+ # Prepare Verified/Random lists if needed
1861
+ verified_member_ids = []
1862
+ verified_live_ids = []
1863
+
1864
+ if deck_type == "random_verified":
1865
+ try:
1866
+ pool_path = os.path.join(os.getcwd(), "verified_card_pool.json")
1867
+ if os.path.exists(pool_path):
1868
+ with open(pool_path, "r", encoding="utf-8") as f:
1869
+ pool = json.load(f)
1870
+
1871
+ v_members = pool.get("verified_abilities", [])
1872
+ v_vanilla = pool.get("vanilla_members", [])
1873
+ total_v_members = v_members + v_vanilla
1874
+
1875
+ # Filter DB for these card_nos
1876
+ for cid, card in GameState.member_db.items():
1877
+ if card.card_no in total_v_members:
1878
+ verified_member_ids.append(cid)
1879
+
1880
+ v_lives = pool.get("vanilla_lives", []) # Or use vanilla_lives as a base for lives
1881
+ for cid, card in GameState.live_db.items():
1882
+ if card.card_no in v_lives:
1883
+ verified_live_ids.append(cid)
1884
+
1885
+ if not verified_member_ids or not verified_live_ids:
1886
+ # print(f"WARNING: Verified pool empty after filtering! Check card_nos. falling back.")
1887
+ pass
1888
+ else:
1889
+ # print(f"WARNING: verified_card_pool.json not found at {pool_path}")
1890
+ pass
1891
+ except Exception:
1892
+ # print(f"ERROR: Failed to load verified pool: {e}")
1893
+ pass
1894
+
1895
+ for p_idx in range(2):
1896
+ p = state.players[p_idx]
1897
+
1898
+ # Build decks
1899
+ if deck_type == "vanilla":
1900
+ member_ids = list(vanilla_member_ids) # Copy
1901
+ live_ids = list(vanilla_live_ids) # Copy
1902
+ elif deck_type == "random_verified" and verified_member_ids and verified_live_ids:
1903
+ # 48 members, 12 lives
1904
+ member_ids = list(np.random.choice(verified_member_ids, 48, replace=True))
1905
+ live_ids = list(np.random.choice(verified_live_ids, 12, replace=True))
1906
+ else:
1907
+ # Random Normal Deck
1908
+ # Random Normal Deck
1909
+
1910
+ member_ids = list(GameState.member_db.keys())
1911
+
1912
+ live_ids = list(GameState.live_db.keys())
1913
+
1914
+ # Filter if too many? For now just take random subset if huge
1915
+
1916
+ if len(member_ids) > 48:
1917
+ member_ids = list(np.random.choice(member_ids, 48, replace=False))
1918
+
1919
+ if len(live_ids) > 12:
1920
+ live_ids = list(np.random.choice(live_ids, 12, replace=False))
1921
+
1922
+ energy_ids = list(range(200, 212))
1923
+
1924
+ np.random.shuffle(member_ids)
1925
+
1926
+ np.random.shuffle(live_ids)
1927
+
1928
+ np.random.shuffle(energy_ids)
1929
+
1930
+ p.main_deck = member_ids + live_ids
1931
+
1932
+ np.random.shuffle(p.main_deck)
1933
+
1934
+ p.energy_deck = energy_ids
1935
+
1936
+ # Initial draw: 6 cards (Rule 6.2.1.5)
1937
+ # Note: log_rule isn't available on GameState yet as it's a static function creating state
1938
+ # but we can print or add a log entry to the state's internal log if it has one.
1939
+ # Actually, let's just make sure the draw happens.
1940
+ for _ in range(6):
1941
+ if p.main_deck:
1942
+ p.hand.append(p.main_deck.pop(0))
1943
+
1944
+ # Log initial setup rules (Rule 6.2.1.5 and 6.2.1.7)
1945
+ state.rule_log.append({"rule": "Rule 6.2.1.5", "description": "Both players draw 6 cards as starting hand."})
1946
+ state.rule_log.append(
1947
+ {"rule": "Rule 6.2.1.7", "description": "Both players place 3 cards from Energy Deck to Energy Zone."}
1948
+ )
1949
+
1950
+ # Set initial phase to Mulligan
1951
+
1952
+ state.phase = Phase.MULLIGAN_P1
1953
+
1954
+ # Randomly determine first player
1955
+
1956
+ state.first_player = np.random.randint(2)
1957
+
1958
+ state.current_player = state.first_player
1959
+
1960
+ # Rule 6.2.1.7: Both players place top 3 cards of Energy Deck into Energy Zone
1961
+
1962
+ for p in state.players:
1963
+ p.energy_zone = []
1964
+
1965
+ for _ in range(3):
1966
+ if p.energy_deck:
1967
+ p.energy_zone.append(p.energy_deck.pop(0))
1968
+
1969
+ return state
1970
+
1971
+
1972
+ if __name__ == "__main__":
1973
+ # Test game creation and basic flow
1974
+
1975
+ game = initialize_game()
1976
+
1977
+ print(f"Game initialized. First player: {game.first_player}")
1978
+
1979
+ print(f"P0 hand: {len(game.players[0].hand)} cards")
1980
+
1981
+ print(f"P1 hand: {len(game.players[1].hand)} cards")
1982
+
1983
+ print(f"Phase: {game.phase.name}")
1984
+
1985
+ # Run a few random actions
1986
+
1987
+ for step in range(20):
1988
+ if game.is_terminal():
1989
+ print(f"Game over! Winner: {game.get_winner()}")
1990
+
1991
+ break
1992
+
1993
+ legal = game.get_legal_actions()
1994
+
1995
+ legal_indices = np.where(legal)[0]
1996
+
1997
+ if len(legal_indices) == 0:
1998
+ print("No legal actions!")
1999
+
2000
+ break
2001
+
2002
+ action = np.random.choice(legal_indices)
2003
+
2004
+ game = game.step(action)
2005
+
2006
+ print(
2007
+ f"Step {step}: Action {action}, Phase {game.phase}, "
2008
+ f"Player {game.current_player}, "
2009
+ f"P0 lives: {len(game.players[0].success_lives)}, "
2010
+ f"P1 lives: {len(game.players[1].success_lives)}"
2011
+ )
2012
+
2013
+ # --- COMPREHENSIVE RULEBOOK INDEX (v1.04) ---
2014
+
2015
+ # This index ensures 100% searchability of all official rule identifiers.
2016
+
2017
+ #
2018
+
2019
+ # Rule 1:
2020
+
2021
+ # Rule 1.1:
2022
+
2023
+ # Rule 1.1.1:
2024
+
2025
+ # Rule 1.2:
2026
+
2027
+ # Rule 1.2.1:
2028
+
2029
+ # Rule 1.2.1.1: ??E??????v???C???[????????C?u?J?[?h?u
2030
+
2031
+ # Rule 1.2.1.2: ??????v???C???[????????3 ????????
2032
+
2033
+ # Rule 1.2.2:
2034
+
2035
+ # Rule 1.2.3:
2036
+
2037
+ # Rule 1.2.3.1: ???E???s???s???A???????J?[?h?E?e????E
2038
+
2039
+ # Rule 1.2.4:
2040
+
2041
+ # Rule 1.3:
2042
+
2043
+ # Rule 1.3.1:
2044
+
2045
+ # Rule 1.3.2:
2046
+
2047
+ # Rule 1.3.2.1: ????????????????E?????????E??
2048
+
2049
+ # Rule 1.3.2.2: ????s???????s??????P?????E??????E
2050
+
2051
+ # Rule 1.3.2.3: ????s????v???????????E?????????A??
2052
+
2053
+ # Rule 1.3.2.4: ?v???C???[??E???[?h????????l?E????A????
2054
+
2055
+ # Rule 1.3.3:
2056
+
2057
+ # Rule 1.3.4:
2058
+
2059
+ # Rule 1.3.4.1: ?????????E????v???C???[??K?p????AE
2060
+
2061
+ # Rule 1.3.4.2: ???E?J????J?[?h?????I???????
2062
+
2063
+ # Rule 1.3.5:
2064
+
2065
+ # Rule 1.3.5.1: ?J?[?h???[??????e?`???f?E??????
2066
+
2067
+ # Rule 2:
2068
+
2069
+ # Rule 2.1:
2070
+
2071
+ # Rule 2.1.1:
2072
+
2073
+ # Rule 2.1.2:
2074
+
2075
+ # Rule 2.1.3:
2076
+
2077
+ # Rule 2.2:
2078
+
2079
+ # Rule 2.2.1:
2080
+
2081
+ # Rule 2.2.2:
2082
+
2083
+ # Rule 2.2.2.1: ?J?[?h?^?C?v?????C?u?????J?[?h?E?A?Q?[??
2084
+
2085
+ # Rule 2.2.2.1.1: ?X?R?A?E?E.10?E????E???n?[?g?IE.11?E???????
2086
+
2087
+ # Rule 2.2.2.2: ?J?[?h?^?C?v???????o?E?????J?[?h?E?A???C
2088
+
2089
+ # Rule 2.2.2.2.1: ?R?X?g?IE.6?E???n?E?g?IE.9?E???????J?[?`E
2090
+
2091
+ # Rule 2.2.2.3: ?J?[?h?^?C?v???G?l???M?[?????J?[?h?E?A??
2092
+
2093
+ # Rule 2.2.2.3.1: ?J?[?h??????e?G?l???M?[?J?[?h?f??\?E
2094
+
2095
+ # Rule 2.3:
2096
+
2097
+ # Rule 2.3.1:
2098
+
2099
+ # Rule 2.3.2:
2100
+
2101
+ # Rule 2.3.2.1: ?J?[?h?????E?E?????????o?E?J?[?h?E?AE??E??
2102
+
2103
+ # Rule 2.3.2.2: ?`E???X?g???A?u?v?i?????????E??????????
2104
+
2105
+ # Rule 2.4:
2106
+
2107
+ # Rule 2.4.1:
2108
+
2109
+ # Rule 2.4.2:
2110
+
2111
+ # Rule 2.4.2.1: ?J?[?h?????E?E?????????o?E?J?[?h?E?AE??E??
2112
+
2113
+ # Rule 2.4.2.2: ?????o?E?????O???[?v????????E?A??
2114
+
2115
+ # Rule 2.4.3:
2116
+
2117
+ # Rule 2.4.3.1: ?`E???X?g???A?w?x?i??d?????????E??????
2118
+
2119
+ # Rule 2.4.4:
2120
+
2121
+ # Rule 2.5:
2122
+
2123
+ # Rule 2.5.1:
2124
+
2125
+ # Rule 2.5.2:
2126
+
2127
+ # Rule 2.5.3:
2128
+
2129
+ # Rule 2.6:
2130
+
2131
+ # Rule 2.6.1:
2132
+
2133
+ # Rule 2.7:
2134
+
2135
+ # Rule 2.7.1:
2136
+
2137
+ # Rule 2.7.2:
2138
+
2139
+ # Rule 2.8:
2140
+
2141
+ # Rule 2.8.1:
2142
+
2143
+ # Rule 2.8.2:
2144
+
2145
+ # Rule 2.9:
2146
+
2147
+ # Rule 2.9.1:
2148
+
2149
+ # Rule 2.9.2:
2150
+
2151
+ # Rule 2.9.3:
2152
+
2153
+ # Rule 2.10:
2154
+
2155
+ # Rule 2.10.1:
2156
+
2157
+ # Rule 2.11:
2158
+
2159
+ # Rule 2.11.1:
2160
+
2161
+ # Rule 2.11.2:
2162
+
2163
+ # Rule 2.11.2.1: ??E???[?g??????A?c??
2164
+
2165
+ # Rule 2.11.2.2: ?n?E?g???????E????????A????S???
2166
+
2167
+ # Rule 2.11.3:
2168
+
2169
+ # Rule 2.12:
2170
+
2171
+ # Rule 2.12.1:
2172
+
2173
+ # Rule 2.12.2:
2174
+
2175
+ # Rule 2.12.3:
2176
+
2177
+ # Rule 2.12.4:
2178
+
2179
+ # Rule 2.13:
2180
+
2181
+ # Rule 2.13.1:
2182
+
2183
+ # Rule 2.13.2:
2184
+
2185
+ # Rule 2.14:
2186
+
2187
+ # Rule 2.14.1:
2188
+
2189
+ # Rule 2.14.2:
2190
+
2191
+ # Rule 2.14.3:
2192
+
2193
+ # Rule 3:
2194
+
2195
+ # Rule 3.1:
2196
+
2197
+ # Rule 3.1.1:
2198
+
2199
+ # Rule 3.1.2:
2200
+
2201
+ # Rule 3.1.2.1: ???E???E?}?X?^?[???A????\???L????E
2202
+
2203
+ # Rule 3.1.2.2: ?N???E???E?}?X?^?[???A??????v???C????
2204
+
2205
+ # Rule 3.1.2.3: ?????E???E?}?X?^?[???A????\???L????E
2206
+
2207
+ # Rule 3.1.2.4: ?????E?}?X?^?[???A??????????????E
2208
+
2209
+ # Rule 3.1.2.4.1: ?????????????v???C???[???w?E
2210
+
2211
+ # Rule 4:
2212
+
2213
+ # Rule 4.1:
2214
+
2215
+ # Rule 4.1.1:
2216
+
2217
+ # Rule 4.1.2:
2218
+
2219
+ # Rule 4.1.2.1: ???J????J?[?h???u???????A????
2220
+
2221
+ # Rule 4.1.2.2: ?????E?J?????????J??????????
2222
+
2223
+ # Rule 4.1.2.3: ???E?J??????????A???????J?[?`E
2224
+
2225
+ # Rule 4.1.3:
2226
+
2227
+ # Rule 4.1.3.1: ??E???????E???????J?[?h?E??E????AE
2228
+
2229
+ # Rule 4.1.4:
2230
+
2231
+ # Rule 4.1.4.1: ????J?[?h????????E??A???????????E
2232
+
2233
+ # Rule 4.1.5:
2234
+
2235
+ # Rule 4.1.5.1: ???J???Y????E?J?????E????J?[?h??
2236
+
2237
+ # Rule 4.1.6:
2238
+
2239
+ # Rule 4.1.7:
2240
+
2241
+ # Rule 4.2:
2242
+
2243
+ # Rule 4.2.1:
2244
+
2245
+ # Rule 4.2.2:
2246
+
2247
+ # Rule 4.2.3:
2248
+
2249
+ # Rule 4.3:
2250
+
2251
+ # Rule 4.3.1:
2252
+
2253
+ # Rule 4.3.2:
2254
+
2255
+ # Rule 4.3.2.1: ?A?N?`E???u????E?J?[?h?E?A????J?[?h?E?}?X
2256
+
2257
+ # Rule 4.3.2.2: ?E?F?C?g????E?J?[?h?E?A????J?[?h?E?}?X
2258
+
2259
+ # Rule 4.3.2.3: ?z?u??????E??????????J?[?h???u??E
2260
+
2261
+ # Rule 4.3.3:
2262
+
2263
+ # Rule 4.3.3.1: ?\????????E?J?[?h?E?A?J?[?h?E?E????????E
2264
+
2265
+ # Rule 4.3.3.2: ??????????E?J?[?h?E?A?J?[?h?E?E????????E
2266
+
2267
+ # Rule 4.4:
2268
+
2269
+ # Rule 4.4.1:
2270
+
2271
+ # Rule 4.4.2:
2272
+
2273
+ # Rule 4.5:
2274
+
2275
+ # Rule 4.5.1:
2276
+
2277
+ # Rule 4.5.1.1: ?`E???X?g????P??e?G???A?f?????????E????
2278
+
2279
+ # Rule 4.5.2:
2280
+
2281
+ # Rule 4.5.2.1: ??E?????o?E?G???A??A??????e???T?C?h?G??
2282
+
2283
+ # Rule 4.5.2.2: ????v???C???[???????A???T?C?h?G???A??
2284
+
2285
+ # Rule 4.5.2.3: ????v???C???[???????A???T?C?h?G???A??
2286
+
2287
+ # Rule 4.5.3:
2288
+
2289
+ # Rule 4.5.4:
2290
+
2291
+ # Rule 4.5.5:
2292
+
2293
+ # Rule 4.5.5.1: ?????o?E?G???A??????o?E?J?[?h?E????d?E
2294
+
2295
+ # Rule 4.5.5.2: ?????o?E?G???A??????o?E?J?[?h?E????d?E
2296
+
2297
+ # Rule 4.5.5.3: ?????o?E?G???A??????o?E?????E?????o?E?G
2298
+
2299
+ # Rule 4.5.5.4: ?????o?E?G???A??????o?E???????o?E?G???A
2300
+
2301
+ # Rule 4.5.6:
2302
+
2303
+ # Rule 4.6:
2304
+
2305
+ # Rule 4.6.1:
2306
+
2307
+ # Rule 4.6.2:
2308
+
2309
+ # Rule 4.7:
2310
+
2311
+ # Rule 4.7.1:
2312
+
2313
+ # Rule 4.7.2:
2314
+
2315
+ # Rule 4.7.3:
2316
+
2317
+ # Rule 4.7.4:
2318
+
2319
+ # Rule 4.8:
2320
+
2321
+ # Rule 4.8.1:
2322
+
2323
+ # Rule 4.8.2:
2324
+
2325
+ # Rule 4.8.3:
2326
+
2327
+ # Rule 4.8.4:
2328
+
2329
+ # Rule 4.9:
2330
+
2331
+ # Rule 4.9.1:
2332
+
2333
+ # Rule 4.9.2:
2334
+
2335
+ # Rule 4.9.3:
2336
+
2337
+ # Rule 4.9.4:
2338
+
2339
+ # Rule 4.10:
2340
+
2341
+ # Rule 4.10.1:
2342
+
2343
+ # Rule 4.10.2:
2344
+
2345
+ # Rule 4.11:
2346
+
2347
+ # Rule 4.11.1:
2348
+
2349
+ # Rule 4.11.2:
2350
+
2351
+ # Rule 4.11.3:
2352
+
2353
+ # Rule 4.12:
2354
+
2355
+ # Rule 4.12.1:
2356
+
2357
+ # Rule 4.12.2:
2358
+
2359
+ # Rule 4.13:
2360
+
2361
+ # Rule 4.13.1:
2362
+
2363
+ # Rule 4.13.2:
2364
+
2365
+ # Rule 4.14:
2366
+
2367
+ # Rule 4.14.1:
2368
+
2369
+ # Rule 4.14.2:
2370
+
2371
+ # Rule 5:
2372
+
2373
+ # Rule 5.1:
2374
+
2375
+ # Rule 5.1.1:
2376
+
2377
+ # Rule 5.2:
2378
+
2379
+ # Rule 5.2.1:
2380
+
2381
+ # Rule 5.3:
2382
+
2383
+ # Rule 5.3.1:
2384
+
2385
+ # Rule 5.4:
2386
+
2387
+ # Rule 5.4.1:
2388
+
2389
+ # Rule 5.5:
2390
+
2391
+ # Rule 5.5.1:
2392
+
2393
+ # Rule 5.5.1.1: ?J?[?h?Q?????P?????????E????????
2394
+
2395
+ # Rule 5.5.1.2: ?J?[?h?Q??J?[?h??0 ??????E1 ???E???E
2396
+
2397
+ # Rule 5.6:
2398
+
2399
+ # Rule 5.6.1:
2400
+
2401
+ # Rule 5.6.2:
2402
+
2403
+ # Rule 5.6.3:
2404
+
2405
+ # Rule 5.6.3.1: ?E????l?E???0 ??????????E?A?????E????E
2406
+
2407
+ # Rule 5.6.3.2: ??E???E???C???[????E??E?????I?E???????E
2408
+
2409
+ # Rule 5.6.3.3: ??E???E???C???[??J?[?h??1 ??????????AE
2410
+
2411
+ # Rule 5.6.3.4: ???E??E??????5.6.3.3 ?????s????????i??
2412
+
2413
+ # Rule 5.7:
2414
+
2415
+ # Rule 5.7.1:
2416
+
2417
+ # Rule 5.7.2:
2418
+
2419
+ # Rule 5.7.2.1: ?E????l?E???0 ??????????E?A?????E????E
2420
+
2421
+ # Rule 5.7.2.2: ?????????1 ???w??????AE
2422
+
2423
+ # Rule 5.7.2.3: ??E???E???C???[????E??E?????I?E???????E
2424
+
2425
+ # Rule 5.7.2.4: ??E???E???C???[??A???C???`E???L?u??????
2426
+
2427
+ # Rule 5.7.2.5: ???E??E??????5.7.2.4 ?????s????????i??
2428
+
2429
+ # Rule 5.8:
2430
+
2431
+ # Rule 5.8.1:
2432
+
2433
+ # Rule 5.8.2:
2434
+
2435
+ # Rule 5.9.1:
2436
+
2437
+ # Rule 5.9.1.1: ?E
2438
+
2439
+ # Rule 5.10:
2440
+
2441
+ # Rule 5.10.1:
2442
+
2443
+ # Rule 6:
2444
+
2445
+ # Rule 6.1:
2446
+
2447
+ # Rule 6.1.1:
2448
+
2449
+ # Rule 6.1.1.1: ???C???`E???L??A?????o?E?J?[?`E8 ??????E????
2450
+
2451
+ # Rule 6.1.1.2: ???C???`E???L???A?J?[?h?i???o?E???????
2452
+
2453
+ # Rule 6.1.1.3: ?G?l???M?[?`E???L??A?G?l???M?[?J?[?`E2
2454
+
2455
+ # Rule 6.1.2:
2456
+
2457
+ # Rule 6.2:
2458
+
2459
+ # Rule 6.2.1:
2460
+
2461
+ # Rule 6.2.1.1: ???E?Q?[????g?p?????g??`E???L???
2462
+
2463
+ # Rule 6.2.1.2: ??E?E???C???[????g????C???`E???L???E?g??
2464
+
2465
+ # Rule 6.2.1.3: ??E?E???C???[????g??G?l???M?[?`E???L??E
2466
+
2467
+ # Rule 6.2.1.4: ??E?E???C???[????????????E?v???C???[
2468
+
2469
+ # Rule 6.2.1.5: ??E?E???C???[????g????C???`E???L?u?????
2470
+
2471
+ # Rule 6.2.1.6: ??U?v???C???[?????E???A?e?v???C???[???
2472
+
2473
+ # Rule 6.2.1.7: ??E?E???C???[????g??G?l???M?[?`E???L?u
2474
+
2475
+ # Rule 7:
2476
+
2477
+ # Rule 7.1:
2478
+
2479
+ # Rule 7.1.1:
2480
+
2481
+ # Rule 7.1.2:
2482
+
2483
+ # Rule 7.2:
2484
+
2485
+ # Rule 7.2.1:
2486
+
2487
+ # Rule 7.2.1.1: ???v???C???[???w????t?F?C?Y????A??
2488
+
2489
+ # Rule 7.2.1.2: ???v???C???[???w?????E???F?C?Y????AE
2490
+
2491
+ # Rule 7.2.2:
2492
+
2493
+ # Rule 7.3:
2494
+
2495
+ # Rule 7.3.1:
2496
+
2497
+ # Rule 7.3.2:
2498
+
2499
+ # Rule 7.3.2.1: ???t?F?C?Y???A?E?U?v???C???[?????`E
2500
+
2501
+ # Rule 7.3.3:
2502
+
2503
+ # Rule 7.4:
2504
+
2505
+ # Rule 7.4.1:
2506
+
2507
+ # Rule 7.4.2:
2508
+
2509
+ # Rule 7.4.3:
2510
+
2511
+ # Rule 7.5:
2512
+
2513
+ # Rule 7.5.1:
2514
+
2515
+ # Rule 7.5.2:
2516
+
2517
+ # Rule 7.5.3:
2518
+
2519
+ # Rule 7.6:
2520
+
2521
+ # Rule 7.6.1:
2522
+
2523
+ # Rule 7.6.2:
2524
+
2525
+ # Rule 7.6.3:
2526
+
2527
+ # Rule 7.7:
2528
+
2529
+ # Rule 7.7.1:
2530
+
2531
+ # Rule 7.7.2:
2532
+
2533
+ # Rule 7.7.2.1: ???E?E?J?[?h??????N???E???1 ??I???AE
2534
+
2535
+ # Rule 7.7.2.2: ???E?E??D??????o?E?J?[?h??1 ???I???A??
2536
+
2537
+ # Rule 7.7.3:
2538
+
2539
+ # Rule 7.8:
2540
+
2541
+ # Rule 7.8.1:
2542
+
2543
+ # Rule 8:
2544
+
2545
+ # Rule 8.1:
2546
+
2547
+ # Rule 8.1.1:
2548
+
2549
+ # Rule 8.1.2:
2550
+
2551
+ # Rule 8.2:
2552
+
2553
+ # Rule 8.2.1:
2554
+
2555
+ # Rule 8.2.2:
2556
+
2557
+ # Rule 8.2.3:
2558
+
2559
+ # Rule 8.2.4:
2560
+
2561
+ # Rule 8.2.5:
2562
+
2563
+ # Rule 8.3:
2564
+
2565
+ # Rule 8.3.1:
2566
+
2567
+ # Rule 8.3.2:
2568
+
2569
+ # Rule 8.3.2.1: ?p?t?H?[?}???X?t?F?C?Y???A?E?U?v???C
2570
+
2571
+ # Rule 8.3.3:
2572
+
2573
+ # Rule 8.3.4:
2574
+
2575
+ # Rule 8.3.4.1: ???v???C???[???e???C?u??????E???????E
2576
+
2577
+ # Rule 8.3.5:
2578
+
2579
+ # Rule 8.3.6:
2580
+
2581
+ # Rule 8.3.7:
2582
+
2583
+ # Rule 8.3.8:
2584
+
2585
+ # Rule 8.3.9:
2586
+
2587
+ # Rule 8.3.10:
2588
+
2589
+ # Rule 8.3.11:
2590
+
2591
+ # Rule 8.3.12:
2592
+
2593
+ # Rule 8.3.13:
2594
+
2595
+ # Rule 8.3.14:
2596
+
2597
+ # Rule 8.3.15:
2598
+
2599
+ # Rule 8.3.15.1: ???????C?u???L?n?[?g????A??????C?`E
2600
+
2601
+ # Rule 8.3.15.1.1: ???E??A?e
2602
+
2603
+ # Rule 8.3.15.1.2: ????????E???C?u?J?[?h?E?E??E
2604
+
2605
+ # Rule 8.3.16:
2606
+
2607
+ # Rule 8.3.17:
2608
+
2609
+ # Rule 8.4:
2610
+
2611
+ # Rule 8.4.1:
2612
+
2613
+ # Rule 8.4.2:
2614
+
2615
+ # Rule 8.4.2.1: ???E??A?e?v???C???[????g??G?[????
2616
+
2617
+ # Rule 8.4.3:
2618
+
2619
+ # Rule 8.4.3.1: ??????v???C???[???????E???C?u?J?[?h?u
2620
+
2621
+ # Rule 8.4.3.2: ?????v???C???[????C?u?J?[?h?u?????
2622
+
2623
+ # Rule 8.4.3.3: ??????v???C???[????C?u?J?[?h?u?????
2624
+
2625
+ # Rule 8.4.4:
2626
+
2627
+ # Rule 8.4.5:
2628
+
2629
+ # Rule 8.4.6:
2630
+
2631
+ # Rule 8.4.6.1: ??????v???C???[???????E???C?u?J?[?h?u
2632
+
2633
+ # Rule 8.4.6.2: ??E??????v???C???[????C?u?J?[?h?u????
2634
+
2635
+ # Rule 8.4.7:
2636
+
2637
+ # Rule 8.4.7.1: ??????v???C???[???????????E?????E
2638
+
2639
+ # Rule 8.4.8:
2640
+
2641
+ # Rule 8.4.9:
2642
+
2643
+ # Rule 8.4.10:
2644
+
2645
+ # Rule 8.4.11:
2646
+
2647
+ # Rule 8.4.12:
2648
+
2649
+ # Rule 8.4.13: 8.4.7 ???????A?????v???C???[?????E?????C
2650
+
2651
+ # Rule 8.4.14:
2652
+
2653
+ # Rule 9:
2654
+
2655
+ # Rule 9.1:
2656
+
2657
+ # Rule 9.1.1:
2658
+
2659
+ # Rule 9.1.1.1: ?N???E????A?E???C?^?C?~???O???^?????
2660
+
2661
+ # Rule 9.1.1.1.1: ?N???E???E?A?J?[?h?????E
2662
+
2663
+ # Rule 9.1.1.2: ?????E????A????\?????????????E
2664
+
2665
+ # Rule 9.1.1.2.1: ?????E???E?A?J?[?h?????E
2666
+
2667
+ # Rule 9.1.1.3: ???E????A????\????L???????A??
2668
+
2669
+ # Rule 9.1.1.3.1: ???E???E?A?J?[?h?????E
2670
+
2671
+ # Rule 9.2:
2672
+
2673
+ # Rule 9.2.1:
2674
+
2675
+ # Rule 9.2.1.1: ?e?P??????f???A??????????E??E???????E
2676
+
2677
+ # Rule 9.2.1.2: ?e?p??????f???A????E???????i?????
2678
+
2679
+ # Rule 9.2.1.3: ?e?u??????f???A?Q?[???????????????
2680
+
2681
+ # Rule 9.2.1.3.1: ?\???e?i?s??A?E??????A???????E??E
2682
+
2683
+ # Rule 9.2.1.3.2: ?\???e?i?s??A?E??????A??????[?I
2684
+
2685
+ # Rule 9.3:
2686
+
2687
+ # Rule 9.3.1:
2688
+
2689
+ # Rule 9.3.2:
2690
+
2691
+ # Rule 9.3.3:
2692
+
2693
+ # Rule 9.3.4:
2694
+
2695
+ # Rule 9.3.4.1: ?????E????E?????E????v???C????E
2696
+
2697
+ # Rule 9.3.4.1.1: ????J?[?h?E?v???C?????E?J?[?h???
2698
+
2699
+ # Rule 9.3.4.2: ?J?[?h?^?C?v???????o?E?????J?[?h?E?\?E
2700
+
2701
+ # Rule 9.3.4.3: ?J?[?h?^?C?v?????C?u?????J?[?h?E?\???E?AE
2702
+
2703
+ # Rule 9.4:
2704
+
2705
+ # Rule 9.4.1:
2706
+
2707
+ # Rule 9.4.2:
2708
+
2709
+ # Rule 9.4.2.1: ?R?X?g???E????s??????????A?e?L?X?g?E
2710
+
2711
+ # Rule 9.4.2.2: ?R?X?g?E??E????????E?S?????x?????????E
2712
+
2713
+ # Rule 9.4.3:
2714
+
2715
+ # Rule 9.5:
2716
+
2717
+ # Rule 9.5.1:
2718
+
2719
+ # Rule 9.5.1.1: ?`?F?`E???^?C?~???O????????A??????[????
2720
+
2721
+ # Rule 9.5.2:
2722
+
2723
+ # Rule 9.5.3:
2724
+
2725
+ # Rule 9.5.3.1: ??????E???s????????[?????E??????
2726
+
2727
+ # Rule 9.5.3.2: ?v???C???[???E?X?^?[?????E???????
2728
+
2729
+ # Rule 9.5.3.3: ??A?N?`E???u?E???C???[???E?X?^?[?????E
2730
+
2731
+ # Rule 9.5.3.4: ?`?F?`E???^?C?~???O???I?E??????AE
2732
+
2733
+ # Rule 9.5.4:
2734
+
2735
+ # Rule 9.5.4.1: ?`?F?`E???^?C?~???O????????????B?`?F?`E???^?C
2736
+
2737
+ # Rule 9.5.4.2: ?v???C?^?C?~???O?????????E?v???C???[??
2738
+
2739
+ # Rule 9.5.4.3: ?v???C?^?C?~???O??^??????E???C???[??E
2740
+
2741
+ # Rule 9.6:
2742
+
2743
+ # Rule 9.6.1:
2744
+
2745
+ # Rule 9.6.2:
2746
+
2747
+ # Rule 9.6.2.1: ?v???C????\????D??J?[?h????E??????
2748
+
2749
+ # Rule 9.6.2.1.1: ?v???C???????J?[?h???????A????E
2750
+
2751
+ # Rule 9.6.2.1.2: ????E???s??????AE
2752
+
2753
+ # Rule 9.6.2.1.2.1: ???E??A????^?[????X?`E?E?W??
2754
+
2755
+ # Rule 9.6.2.1.3: ?v???C???????E????????A????
2756
+
2757
+ # Rule 9.6.2.2: ?J?[?h??\???????E?I?????E???????
2758
+
2759
+ # Rule 9.6.2.3: ?v???C???????R?X?g????????A????R
2760
+
2761
+ # Rule 9.6.2.3.1: ?v???C???????????o?E??J?[?h?????
2762
+
2763
+ # Rule 9.6.2.3.2: ?????o?E???E???C?????A?x???????E
2764
+
2765
+ # Rule 9.6.2.3.2.1: ???????R?X?g???????E?E??
2766
+
2767
+ # Rule 9.6.2.4: ?J?[?h??\???E???????s??????AE
2768
+
2769
+ # Rule 9.6.2.4.1: ?v???C????????????o?E???????A??
2770
+
2771
+ # Rule 9.6.2.4.2: ?v???C????????N???E??????E???
2772
+
2773
+ # Rule 9.6.2.4.2.1: ?\???E??????????????o?E?J?[
2774
+
2775
+ # Rule 9.6.3:
2776
+
2777
+ # Rule 9.6.3.1: ?I??????w??????E?????A??????\
2778
+
2779
+ # Rule 9.6.3.1.1: ?I??????f?`???I???f??f?`???I
2780
+
2781
+ # Rule 9.6.3.1.2: ?I??????w??????E??????A?w?E
2782
+
2783
+ # Rule 9.6.3.1.3: ?I??????w??????E??????A???E
2784
+
2785
+ # Rule 9.6.3.1.4: ?I????E???E?J??????E????E?????J??E
2786
+
2787
+ # Rule 9.7:
2788
+
2789
+ # Rule 9.7.1:
2790
+
2791
+ # Rule 9.7.2:
2792
+
2793
+ # Rule 9.7.2.1: ?????E???E?U?????????E?????????
2794
+
2795
+ # Rule 9.7.3:
2796
+
2797
+ # Rule 9.7.3.1: ??E??????E?????E???E?v???C???????A?E
2798
+
2799
+ # Rule 9.7.3.1.1: ?????E????C???R?X?g???x?????????
2800
+
2801
+ # Rule 9.7.3.2: ?I???E??????E?????E????v???C?????
2802
+
2803
+ # Rule 9.7.3.2.1: ?????E????C???R?X?g???x?????????
2804
+
2805
+ # Rule 9.7.4:
2806
+
2807
+ # Rule 9.7.4.1: ??????U?????????E????A????\?E
2808
+
2809
+ # Rule 9.7.4.1.1: ?J?[?h?????J???Y????E?J???A??
2810
+
2811
+ # Rule 9.7.4.1.2: ?J?[?h???X?`E?E?W????F???O?E???
2812
+
2813
+ # Rule 9.7.4.1.3: ??L?????????O?E?A?E?J???Y??
2814
+
2815
+ # Rule 9.7.4.2: ????J?[?h????????U???\????????A??
2816
+
2817
+ # Rule 9.7.5:
2818
+
2819
+ # Rule 9.7.5.1: ?????U????A?????????????????E????E??
2820
+
2821
+ # Rule 9.7.6:
2822
+
2823
+ # Rule 9.7.6.1: ???U????A????????????????????1
2824
+
2825
+ # Rule 9.7.7:
2826
+
2827
+ # Rule 9.8:
2828
+
2829
+ # Rule 9.8.1:
2830
+
2831
+ # Rule 9.9:
2832
+
2833
+ # Rule 9.9.1:
2834
+
2835
+ # Rule 9.9.1.1: ?J?[?h?E?g??\?L??????E???E?????A????
2836
+
2837
+ # Rule 9.9.1.2: ????A?E???^????E??????E?L???????/
2838
+
2839
+ # Rule 9.9.1.3: ????A?p???????E??E???E??????l???X??E
2840
+
2841
+ # Rule 9.9.1.4: ????A?p???????E??E???E??????l??????E
2842
+
2843
+ # Rule 9.9.1.4.1: ?n?E?g??u???[?h?E?????????E????
2844
+
2845
+ # Rule 9.9.1.5: ????A?p???????E??E???E??????l???X??E
2846
+
2847
+ # Rule 9.9.1.5.1: ?n?E?g??u???[?h?E??????????Z????E
2848
+
2849
+ # Rule 9.9.1.6: ????E9.9.1.2X-9.9.1.4 ??K?p??E?E?O??I??
2850
+
2851
+ # Rule 9.9.1.7: ????E9.9.1.2X-9.9.1.6 ??K?p??E?E?O??I??
2852
+
2853
+ # Rule 9.9.1.7.1: ?p???????E???????????E??????
2854
+
2855
+ # Rule 9.9.1.7.2: ?????O?E?\???E???E?A?????v??
2856
+
2857
+ # Rule 9.9.2:
2858
+
2859
+ # Rule 9.9.3:
2860
+
2861
+ # Rule 9.9.3.1: ?????E?E????????J?[?h????????????E
2862
+
2863
+ # Rule 9.10:
2864
+
2865
+ # Rule 9.10.1:
2866
+
2867
+ # Rule 9.10.1.1: ???????A?u????????E?E???????????
2868
+
2869
+ # Rule 9.10.2:
2870
+
2871
+ # Rule 9.10.2.1: ?e????????????J?[?h??\???????
2872
+
2873
+ # Rule 9.10.2.2: ?e????????????Q?[??????s?????E
2874
+
2875
+ # Rule 9.10.2.3: ??????????????A?e?u???????E??
2876
+
2877
+ # Rule 9.10.3:
2878
+
2879
+ # Rule 9.11:
2880
+
2881
+ # Rule 9.11.1:
2882
+
2883
+ # Rule 9.12:
2884
+
2885
+ # Rule 9.12.1:
2886
+
2887
+ # Rule 9.12.2:
2888
+
2889
+ # Rule 10:
2890
+
2891
+ # Rule 10.1:
2892
+
2893
+ # Rule 10.1.1:
2894
+
2895
+ # Rule 10.1.2:
2896
+
2897
+ # Rule 10.1.3:
2898
+
2899
+ # Rule 10.2:
2900
+
2901
+ # Rule 10.2.1:
2902
+
2903
+ # Rule 10.2.2:
2904
+
2905
+ # Rule 10.2.2.1: ??E??????v???C???[????C???`E???L?u??E
2906
+
2907
+ # Rule 10.2.2.2: ???C???`E???L?u???????H?????E??????
2908
+
2909
+ # Rule 10.2.3:
2910
+
2911
+ # Rule 10.2.4:
2912
+
2913
+ # Rule 10.3:
2914
+
2915
+ # Rule 10.3.1:
2916
+
2917
+ # Rule 10.4:
2918
+
2919
+ # Rule 10.4.1:
2920
+
2921
+ # Rule 10.5:
2922
+
2923
+ # Rule 10.5.1:
2924
+
2925
+ # Rule 10.5.2:
2926
+
2927
+ # Rule 10.5.3:
2928
+
2929
+ # Rule 10.5.4:
2930
+
2931
+ # Rule 10.6:
2932
+
2933
+ # Rule 10.6.1:
2934
+
2935
+ # Rule 11:
2936
+
2937
+ # Rule 11.1:
2938
+
2939
+ # Rule 11.1.1:
2940
+
2941
+ # Rule 11.1.2:
2942
+
2943
+ # Rule 11.1.3:
2944
+
2945
+ # Rule 11.2:
2946
+
2947
+ # Rule 11.2.2:
2948
+
2949
+ # Rule 11.2.3:
2950
+
2951
+ # Rule 11.3:
2952
+
2953
+ # Rule 11.3.1: [Icon] ??A?????o?E???????o?E?G???A??u????E
2954
+
2955
+ # Rule 11.3.2:
2956
+
2957
+ # Rule 11.4:
2958
+
2959
+ # Rule 11.4.1: [Icon] ??A???C?u???J?n??????????E
2960
+
2961
+ # Rule 11.4.2:
2962
+
2963
+ # Rule 11.4.2.1: ?p?t?H?[?}???X?t?F?C?Y???A???v???C???[
2964
+
2965
+ # Rule 11.5:
2966
+
2967
+ # Rule 11.5.1: [Icon] ??A???C?u???????????????U??
2968
+
2969
+ # Rule 11.5.2:
2970
+
2971
+ # Rule 11.6:
2972
+
2973
+ # Rule 11.6.1: [Icon] ??A?E???E?v???C???????A?E???E?E
2974
+
2975
+ # Rule 11.6.2:
2976
+
2977
+ # Rule 11.6.3:
2978
+
2979
+ # Rule 11.6.4:
2980
+
2981
+ # Rule 11.7:
2982
+
2983
+ # Rule 11.7.1: [Icon] ??A?E???E?v???C???????A?E???E?E
2984
+
2985
+ # Rule 11.7.2:
2986
+
2987
+ # Rule 11.7.3:
2988
+
2989
+ # Rule 11.7.4:
2990
+
2991
+ # Rule 11.8:
2992
+
2993
+ # Rule 11.8.1: [Icon] ??A?E???E?v???C???????A?E???E?E
2994
+
2995
+ # Rule 11.8.2:
2996
+
2997
+ # Rule 11.8.3:
2998
+
2999
+ # Rule 11.8.4:
3000
+
3001
+ # Rule 11.9:
3002
+
3003
+ # Rule 11.9.1:
3004
+
3005
+ # Rule 11.9.2:
3006
+
3007
+ # Rule 11.10:
3008
+
3009
+ # Rule 11.10.1:
3010
+
3011
+ # Rule 11.10.2:
3012
+
3013
+ # Rule 12:
3014
+
3015
+ # Rule 12.1:
3016
+
3017
+ # Rule 12.1.1:
3018
+
3019
+ # Rule 12.1.1.1: ?A?N?`E???u?E???C???[?E?E.2?E??E?A????z???E
3020
+
3021
+ # Rule 12.1.1.2: ?A?N?`E???u?E???C???[???????E?s?????E
3022
+
3023
+ # Rule 12.1.1.3: ?????E?E??????A??????E?v???C???[??
3024
+
3025
+ # Rule 2025:
3026
+
3027
+ # --- END OF INDEX ---
engine/game/mixins/__pycache__/action_mixin.cpython-312.pyc ADDED
Binary file (21 kB). View file
 
engine/game/mixins/__pycache__/effect_mixin.cpython-312.pyc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:458a917bf0e585b701ddee3dcf0a668e57ade6e9b592b05f6312e116cb4a382e
3
+ size 249282
engine/game/mixins/__pycache__/phase_mixin.cpython-312.pyc ADDED
Binary file (28.7 kB). View file
 
engine/game/mixins/action_mixin.py ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ from typing import TYPE_CHECKING, Any
3
+
4
+ import numpy as np
5
+
6
+ from engine.game.enums import Phase
7
+ from engine.models.ability import EffectType, TriggerType
8
+
9
+ if TYPE_CHECKING:
10
+ pass
11
+
12
+
13
+ class ActionMixin:
14
+ """
15
+ Mixin for GameState that handles player actions and core mechanics.
16
+ """
17
+
18
+ def _resolve_deck_refresh(self, player: Any) -> None:
19
+ """Rule 10.2: If deck is empty, shuffle waiting room to make new deck."""
20
+ if not player.main_deck and player.discard:
21
+ # Shuffle discard into deck
22
+ player.main_deck = list(player.discard)
23
+ player.discard = []
24
+ random.shuffle(player.main_deck)
25
+ player.deck_refreshed_this_turn = True
26
+ if hasattr(self, "log_rule"):
27
+ self.log_rule(
28
+ "Rule 10.2",
29
+ f"Player {player.player_id} Refreshed Deck from Discard ({len(player.main_deck)} cards).",
30
+ )
31
+
32
+ def _draw_cards(self, player: Any, count: int) -> None:
33
+ for _ in range(count):
34
+ if hasattr(self, "_process_rule_checks"):
35
+ self._process_rule_checks()
36
+
37
+ # Check for refresh before drawing
38
+ if not player.main_deck:
39
+ self._resolve_deck_refresh(player)
40
+
41
+ if player.main_deck:
42
+ player.hand.append(player.main_deck.pop(0))
43
+ player.hand_added_turn.append(self.turn_number)
44
+ else:
45
+ # Deck still empty after attempt to refresh (Rule 10.2.1.2: If both empty, cannot draw)
46
+ pass
47
+
48
+ if hasattr(self, "_process_rule_checks"):
49
+ self._process_rule_checks()
50
+
51
+ def _play_member(self, hand_idx: int, area_idx: int) -> None:
52
+ p = self.active_player
53
+ if "placement" in p.restrictions:
54
+ return
55
+ card_id = p.hand.pop(hand_idx)
56
+ if hand_idx < len(p.hand_added_turn):
57
+ added_turn = p.hand_added_turn.pop(hand_idx)
58
+ else:
59
+ # Fallback if list is desynced (shouldn't happen but prevented crash)
60
+ added_turn = 0
61
+
62
+ # Safety check: verify card exists in database before accessing
63
+ card_id_int = int(card_id)
64
+ if card_id_int not in self.member_db:
65
+ # Put the card back in hand and return to prevent crash
66
+ p.hand.insert(hand_idx, card_id)
67
+ p.hand_added_turn.insert(hand_idx, added_turn if hand_idx < len(p.hand_added_turn) else 0)
68
+ return
69
+
70
+ card = self.member_db[card_id_int]
71
+ if hasattr(self, "log_rule"):
72
+ self.log_rule("Rule 7.7.2.2", f"Player {p.player_id} plays {card.name} to Slot {area_idx}.")
73
+ # Calculate slot-specific cost reduction
74
+ slot_reduction = sum(
75
+ ce["effect"].value
76
+ for ce in p.continuous_effects
77
+ if ce["effect"].effect_type == EffectType.REDUCE_COST and (ce.get("target_slot", -1) in (-1, area_idx))
78
+ )
79
+ base_cost = max(0, card.cost - slot_reduction)
80
+ cost = base_cost
81
+
82
+ is_baton = p.stage[area_idx] >= 0
83
+ if is_baton:
84
+ extra_baton = sum(
85
+ ce["effect"].value
86
+ for ce in p.continuous_effects
87
+ if ce["effect"].effect_type == EffectType.BATON_TOUCH_MOD
88
+ )
89
+ effective_baton_limit = p.baton_touch_limit + extra_baton
90
+
91
+ if p.baton_touch_count >= effective_baton_limit:
92
+ # Should be caught by get_legal_actions, but safety first
93
+ p.hand.insert(hand_idx, card_id)
94
+ p.hand_added_turn.insert(hand_idx, added_turn)
95
+ return
96
+
97
+ prev_card_id = int(p.stage[area_idx])
98
+ if prev_card_id in self.member_db:
99
+ prev_card = self.member_db[prev_card_id]
100
+ else:
101
+ prev_card = None
102
+
103
+ if prev_card:
104
+ if hasattr(self, "log_rule"):
105
+ self.log_rule("Rule 9.6.2.3.2", f"Baton Touch! Cost reduced by {prev_card.cost}.")
106
+ cost = max(0, cost - prev_card.cost)
107
+ p.baton_touch_count += 1
108
+
109
+ # Rule 9.9.1.2: Check for ON_LEAVES triggers before/as card leaves stage
110
+ for ability in prev_card.abilities:
111
+ trig = getattr(ability, "trigger", "NO_TRIGGER")
112
+ if trig == TriggerType.ON_LEAVES:
113
+ self.triggered_abilities.append(
114
+ (
115
+ p.player_id,
116
+ ability,
117
+ {"area": area_idx, "card_id": p.stage[area_idx], "from_zone": "stage"},
118
+ )
119
+ )
120
+ else:
121
+ # If prev_card is None, just increment baton touch count without cost adjustment
122
+ p.baton_touch_count += 1
123
+
124
+ p.discard.append(p.stage[area_idx])
125
+ if p.stage_energy_count[area_idx] > 0:
126
+ p.energy_deck.extend(p.stage_energy[area_idx])
127
+ p.clear_stage_energy(area_idx)
128
+ untapped = [i for i, tapped in enumerate(p.tapped_energy) if not tapped]
129
+ if len(untapped) < cost:
130
+ p.hand.insert(hand_idx, card_id)
131
+ p.hand_added_turn.insert(hand_idx, added_turn)
132
+ return
133
+ for i in range(cost):
134
+ p.tapped_energy[untapped[i]] = True
135
+ p.stage[area_idx] = card_id
136
+ self.prev_cid = prev_card_id if "prev_card_id" in locals() else -1
137
+ p.members_played_this_turn[area_idx] = True # Rule 9.6.2.1.2.1: Cannot play into this slot again this turn
138
+ for ability in card.abilities:
139
+ if ability.trigger == TriggerType.ON_PLAY:
140
+ if hasattr(self, "log_rule"):
141
+ self.log_rule("Rule 11.3", f"Triggering [登場] (On Play) abilities for {card.name}.")
142
+ # print(f"DEBUG: Queuing ON_PLAY trigger for {card.name}")
143
+ self.triggered_abilities.append((p.player_id, ability, {"area": area_idx, "card_id": card_id}))
144
+ if hasattr(self, "_check_remote_triggers"):
145
+ self._check_remote_triggers(TriggerType.ON_PLAY, {"card_id": card_id, "area": area_idx})
146
+
147
+ if hasattr(self, "_process_rule_checks"):
148
+ self._process_rule_checks()
149
+
150
+ def _activate_member_ability(self, area: int) -> None:
151
+ p = self.active_player
152
+ card_id = int(p.stage[area])
153
+ if card_id < 0 or card_id not in self.member_db:
154
+ return
155
+ member = self.member_db[card_id]
156
+ ability = None
157
+ ability_idx = -1
158
+ for abi_idx, ab in enumerate(member.abilities):
159
+ if ab.trigger == TriggerType.ACTIVATED:
160
+ abi_key = f"{card_id}-{abi_idx}"
161
+ if ab.is_once_per_turn and abi_key in p.used_abilities:
162
+ continue
163
+ ability = ab
164
+ ability_idx = abi_idx
165
+ break
166
+ if not ability:
167
+ return
168
+
169
+ if hasattr(self, "log_rule"):
170
+ self.log_rule("Rule 7.7.2.1", f"Player {p.player_id} activates ability of {member.name} (Slot {area}).")
171
+
172
+ # Set resolution context for metadata tracking
173
+ self.current_resolving_ability = ability
174
+ self.current_resolving_member = member.name
175
+ self.current_resolving_member_id = card_id
176
+
177
+ if not self._pay_costs(p, ability.costs, source_area=area):
178
+ # Defer execution until cost paid (via choice)
179
+ abi_key = f"{card_id}-{ability_idx}"
180
+ self.pending_activation = {
181
+ "ability": ability,
182
+ "context": {"area": area, "card_id": card_id, "source_card_id": card_id},
183
+ "abi_key": abi_key,
184
+ }
185
+ return
186
+ total = len(ability.effects)
187
+ from engine.models.ability import ResolvingEffect
188
+
189
+ for i, effect in enumerate(reversed(ability.effects)):
190
+ step = total - i
191
+ self.pending_effects.insert(0, ResolvingEffect(effect, card_id, step, total))
192
+
193
+ if ability.is_once_per_turn:
194
+ p.used_abilities.add(f"{card_id}-{ability_idx}")
195
+ while self.pending_effects and not self.pending_choices:
196
+ self._resolve_pending_effect(0, context={"area": area, "card_id": card_id, "source_card_id": card_id})
197
+
198
+ def _execute_mulligan(self) -> None:
199
+ p = self.active_player
200
+ if hasattr(self, "log_rule"):
201
+ count = len(p.mulligan_selection) if hasattr(p, "mulligan_selection") else 0
202
+ self.log_rule("Rule 6.2.1.6", f"Player {p.player_id} finished mulligan ({count} cards).")
203
+ if hasattr(p, "mulligan_selection") and p.mulligan_selection:
204
+ cards_to_return = []
205
+ for idx in sorted(p.mulligan_selection, reverse=True):
206
+ if idx < len(p.hand):
207
+ cards_to_return.append(p.hand.pop(idx))
208
+ if idx < len(p.hand_added_turn):
209
+ p.hand_added_turn.pop(idx)
210
+ for _ in range(len(cards_to_return)):
211
+ if p.main_deck:
212
+ p.hand.append(p.main_deck.pop(0))
213
+ p.hand_added_turn.append(self.turn_number)
214
+ p.main_deck.extend(cards_to_return)
215
+ random.shuffle(p.main_deck)
216
+
217
+ if hasattr(p, "mulligan_selection"):
218
+ p.mulligan_selection.clear()
219
+
220
+ # Phase transition: P1 -> P2 -> ACTIVE
221
+ if self.phase == Phase.MULLIGAN_P1:
222
+ self.current_player = 1 - self.first_player
223
+ self.phase = Phase.MULLIGAN_P2
224
+ else:
225
+ self.current_player = self.first_player
226
+ self.phase = Phase.ACTIVE
227
+
228
+ def _check_hearts_meet_requirement(self, have: np.ndarray, need: np.ndarray) -> bool:
229
+ """
230
+ Check if 'have' hearts satisfy 'need' requirements.
231
+ have/need: shape (7,) [Pink, Green, Yellow, Purple, Red, Blue, Any/Wildcard]
232
+ index 6 in 'have' is Wildcard (can be any color).
233
+ index 6 in 'need' is 'Any' requirement (can be satisfied by any color).
234
+ """
235
+ # 1. Check specific color requirements (0-5)
236
+ # Calculate deficit for each color
237
+ deficits = np.maximum(0, need[:6] - have[:6])
238
+ total_deficit = np.sum(deficits)
239
+
240
+ # Check if we have enough Wildcards to cover the deficit
241
+ wildcards_have = have[6] if len(have) > 6 else 0
242
+ if wildcards_have < total_deficit:
243
+ return False
244
+
245
+ # 2. Check 'Any' requirement (index 6)
246
+ any_need = need[6] if len(need) > 6 else 0
247
+ if any_need <= 0:
248
+ return True
249
+
250
+ # Remaining Wildcards after covering deficit
251
+ remaining_wildcards = wildcards_have - total_deficit
252
+
253
+ # Surplus specific hearts (those not used for specific requirements)
254
+ surplus_specific = np.sum(np.maximum(0, have[:6] - need[:6]))
255
+
256
+ total_available_for_any = remaining_wildcards + surplus_specific
257
+
258
+ return total_available_for_any >= any_need
259
+
260
+ def _consume_hearts(self, have: np.ndarray, need: np.ndarray) -> None:
261
+ """
262
+ Consume 'need' from 'have' in-place.
263
+ Assumes _check_hearts_meet_requirement returned True.
264
+ """
265
+ # 1. Consume for specific requirements
266
+ for i in range(6):
267
+ n = need[i] if i < len(need) else 0
268
+ if n > 0:
269
+ # Use specific color first
270
+ take_specific = min(have[i], n)
271
+ have[i] -= take_specific
272
+ remaining_need = n - take_specific
273
+
274
+ # Use Wildcards for remainder
275
+ if remaining_need > 0 and len(have) > 6:
276
+ take_wild = min(have[6], remaining_need)
277
+ have[6] -= take_wild
278
+
279
+ # 2. Consume for 'Any' requirement
280
+ any_need = need[6] if len(need) > 6 else 0
281
+ if any_need > 0:
282
+ # First consume surplus specific colors
283
+ for i in range(6):
284
+ if any_need <= 0:
285
+ break
286
+ if have[i] > 0:
287
+ take = min(have[i], any_need)
288
+ have[i] -= take
289
+ any_need -= take
290
+
291
+ # Then consume Wildcards
292
+ if any_need > 0 and len(have) > 6:
293
+ take = min(have[6], any_need)
294
+ have[6] -= take
295
+
296
+ def _set_live_card(self, hand_idx: int) -> None:
297
+ """Set a card face-down in live zone"""
298
+ p = self.active_player
299
+ if hand_idx < 0 or hand_idx >= len(p.hand) or len(p.live_zone) >= 3:
300
+ return
301
+
302
+ card_id = p.hand[hand_idx] # Look before pop for logging context
303
+ from engine.game.state_utils import get_base_id
304
+
305
+ base_id = get_base_id(card_id)
306
+
307
+ if base_id in self.live_db:
308
+ card_desc = f"{self.live_db[base_id].name} ({self.live_db[base_id].card_no})"
309
+ elif base_id in self.member_db:
310
+ card_desc = f"手札[{hand_idx}] ({self.member_db[base_id].card_no})"
311
+ else:
312
+ card_desc = f"Card #{card_id}"
313
+
314
+ if hasattr(self, "log_rule"):
315
+ self.log_rule("Rule 8.2.2", f"Player {p.player_id} sets {card_desc} to Live Zone.")
316
+
317
+ p.hand.pop(hand_idx)
318
+ if hand_idx < len(p.hand_added_turn):
319
+ p.hand_added_turn.pop(hand_idx)
320
+ p.live_zone.append(card_id)
321
+ p.live_zone_revealed.append(False)
322
+ # Rule 8.2.2 modification: Draw happens at end of Live Set phase, not immediately
323
+ # self._draw_cards(p, 1)
324
+ p.live_cards_set_this_turn += 1
325
+
326
+ def _execute_action(self, action: int) -> None:
327
+ """Internal: execute action on this state (mutates self)"""
328
+ p = self.active_player
329
+ if self.phase in (Phase.MULLIGAN_P1, Phase.MULLIGAN_P2):
330
+ if action == 0:
331
+ self._execute_mulligan()
332
+ elif 300 <= action <= 359:
333
+ card_idx = action - 300
334
+ if card_idx < len(p.hand):
335
+ if not hasattr(p, "mulligan_selection"):
336
+ p.mulligan_selection = set()
337
+ if card_idx in p.mulligan_selection:
338
+ p.mulligan_selection.remove(card_idx)
339
+ else:
340
+ p.mulligan_selection.add(card_idx)
341
+ return
342
+
343
+ if getattr(self, "pending_choices", []):
344
+ if action == 0:
345
+ choice_type, params = self.pending_choices[0]
346
+ if choice_type == "CONTINUE_LIVE_RESULT":
347
+ self.pending_choices.pop(0)
348
+ self._finish_live_result()
349
+ return
350
+ if choice_type.startswith("CONTINUE"):
351
+ self.pending_choices.pop(0)
352
+ return
353
+ if True: # Handle action 0 as cancel/fail for both optional and mandatory (if forced)
354
+ self.pending_choices.pop(0)
355
+
356
+ # If look_and_choose declined, move looked cards to discard if on_fail is set
357
+ if params.get("reason") == "look_and_choose" and getattr(self, "looked_cards", []):
358
+ if params.get("on_fail") == "discard":
359
+ p.discard.extend(self.looked_cards)
360
+ self.looked_cards = []
361
+
362
+ if params.get("reason") in ("cost", "effect"):
363
+ self.pending_effects.clear()
364
+ # If we had chained choices (rare for cost), they are now invalid ideally
365
+ # But pending_choices might contain next steps?
366
+ # For "cost", the whole ability aborts, so safe to clear.
367
+ self.pending_choices.clear()
368
+ # Clear resolution state
369
+ self.looked_cards = []
370
+ self.current_resolving_member = None
371
+ self.current_resolving_member_id = -1
372
+ return
373
+ if action >= 500:
374
+ self._handle_choice(action)
375
+ return
376
+
377
+ if self.phase == Phase.ACTIVE:
378
+ self._do_active_phase()
379
+ elif self.phase == Phase.ENERGY:
380
+ self._do_energy_phase()
381
+ elif self.phase == Phase.DRAW:
382
+ self._do_draw_phase()
383
+ elif self.phase == Phase.MAIN:
384
+ if action == 0:
385
+ self._end_main_phase()
386
+ elif 1 <= action <= 180:
387
+ adj = action - 1
388
+ self._play_member(adj // 3, adj % 3)
389
+ elif 200 <= action <= 202:
390
+ self._activate_member_ability(action - 200)
391
+ elif self.phase == Phase.LIVE_SET:
392
+ if action == 0:
393
+ self._end_live_set()
394
+ elif 400 <= action <= 459:
395
+ self._set_live_card(action - 400)
396
+ elif self.phase == Phase.PERFORMANCE_P1:
397
+ if 900 <= action <= 902:
398
+ self._do_performance(0, live_idx=action - 900)
399
+ else:
400
+ self._do_performance(0)
401
+ elif self.phase == Phase.PERFORMANCE_P2:
402
+ if 900 <= action <= 902:
403
+ self._do_performance(1, live_idx=action - 900)
404
+ else:
405
+ self._do_performance(1)
406
+ elif self.phase == Phase.LIVE_RESULT:
407
+ self._do_live_result()
engine/game/mixins/effect_mixin.py ADDED
The diff for this file is too large to render. See raw diff
 
engine/game/mixins/phase_mixin.py ADDED
@@ -0,0 +1,654 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ from typing import TYPE_CHECKING
3
+
4
+ import numpy as np
5
+
6
+ from engine.game.enums import Phase
7
+ from engine.models.ability import EffectType, TriggerType
8
+
9
+ if TYPE_CHECKING:
10
+ pass
11
+
12
+
13
+ class PhaseMixin:
14
+ """
15
+ Mixin for GameState that handles turn and phase transitions.
16
+
17
+ Phase State Machine (Rule 7):
18
+ MULLIGAN_P1 -> MULLIGAN_P2 -> ACTIVE -> ENERGY -> DRAW -> MAIN
19
+ -> LIVE_SET (both players) -> PERFORMANCE_P1 -> PERFORMANCE_P2
20
+ -> LIVE_RESULT -> ACTIVE (next turn)
21
+
22
+ Each phase handler (e.g. _do_active_phase) advances self.phase to the next
23
+ phase before returning. Do not manually set self.phase after calling a handler.
24
+ """
25
+
26
+ def _do_active_phase(self) -> None:
27
+ p = self.active_player
28
+ if hasattr(self, "log_rule"):
29
+ self.log_rule("Rule 7.4.1", f"Active Phase: Untapping all members and energy for Player {p.player_id}.")
30
+ if isinstance(p.members_played_this_turn, list):
31
+ p.members_played_this_turn.clear()
32
+ else:
33
+ p.members_played_this_turn.fill(False)
34
+ p.untap_all()
35
+ self.phase = Phase.ENERGY
36
+
37
+ def _do_energy_phase(self) -> None:
38
+ p = self.active_player
39
+ if hasattr(self, "log_rule"):
40
+ self.log_rule(
41
+ "Rule 7.5.2", f"Energy Phase: Player {p.player_id} moves 1 card from Energy Deck to Energy Zone."
42
+ )
43
+ if p.energy_deck:
44
+ p.energy_zone.append(p.energy_deck.pop(0))
45
+ self.phase = Phase.DRAW
46
+
47
+ def _do_draw_phase(self) -> None:
48
+ p = self.active_player
49
+ if hasattr(self, "log_rule"):
50
+ self.log_rule("Rule 7.6.2", f"Draw Phase: Player {p.player_id} draws 1 card.")
51
+ self._draw_cards(p, 1)
52
+ self.phase = Phase.MAIN
53
+
54
+ def _advance_performance(self) -> None:
55
+ if self.first_player == 0:
56
+ if self.phase == Phase.PERFORMANCE_P1:
57
+ self.phase = Phase.PERFORMANCE_P2
58
+ self.current_player = 1
59
+ else:
60
+ self.phase = Phase.LIVE_RESULT
61
+ else:
62
+ if self.phase == Phase.PERFORMANCE_P2:
63
+ self.phase = Phase.PERFORMANCE_P1
64
+ self.current_player = 0
65
+ else:
66
+ self.phase = Phase.LIVE_RESULT
67
+
68
+ def _end_main_phase(self) -> None:
69
+ """End player's main phase. If first player, switch to second player's turn.
70
+ If second player, advance to LIVE_SET phase."""
71
+ if hasattr(self, "log_rule"):
72
+ self.log_rule("Rule 7.7.3", f"Main Phase End: Player {self.current_player} ends Main Phase.")
73
+ if self.current_player == self.first_player:
74
+ # Switch to Player 2's turn: untap their cards and run their phases
75
+ p2 = 1 - self.first_player
76
+ self.players[p2].tapped_energy[:] = False
77
+ self.players[p2].tapped_members[:] = False
78
+ self.players[p2].members_played_this_turn[:] = False
79
+ self.current_player = p2
80
+ # Clear any stale looked_cards from ability resolution (safety)
81
+ self.looked_cards = []
82
+ # Chain through ACTIVE -> ENERGY -> DRAW -> MAIN
83
+ # Each handler sets self.phase to the next phase before returning
84
+ self.phase = Phase.ACTIVE
85
+ self._do_active_phase() # Sets phase to ENERGY
86
+ self._do_energy_phase() # Sets phase to DRAW
87
+ self._do_draw_phase() # Sets phase to MAIN
88
+ else:
89
+ # Both players have had their main phase, move to LIVE_SET
90
+ self.phase = Phase.LIVE_SET
91
+ self.current_player = self.first_player
92
+ # Clear any stale looked_cards from ability resolution
93
+ self.looked_cards = []
94
+
95
+ def _end_live_set(self) -> None:
96
+ """End player's live set phase. First player sets first, then second player.
97
+ After both, advance to performance phase (first player performs first)."""
98
+ p = self.active_player
99
+ # Draw cards equal to the number of cards set this turn (Rule 8.2.2 modified timing)
100
+ if p.live_cards_set_this_turn > 0:
101
+ if hasattr(self, "log_rule"):
102
+ self.log_rule(
103
+ "Rule 8.2.2", f"Live Set End: Player {p.player_id} draws {p.live_cards_set_this_turn} cards."
104
+ )
105
+ self._draw_cards(p, p.live_cards_set_this_turn)
106
+ p.live_cards_set_this_turn = 0
107
+
108
+ if self.current_player == self.first_player:
109
+ # Switch to second player for their live set
110
+ self.current_player = 1 - self.first_player
111
+ else:
112
+ # Both players have set lives, begin performance
113
+ # Performance order matches first_player: P1 performs first if first_player=0
114
+ self.performance_results = {}
115
+ if self.first_player == 0:
116
+ self.phase = Phase.PERFORMANCE_P1
117
+ self.current_player = 0
118
+ else:
119
+ self.phase = Phase.PERFORMANCE_P2
120
+ self.current_player = 1
121
+
122
+ def _do_performance(self, player_idx: int, live_idx: int = -1) -> None:
123
+ """Execute performance phase for a player.
124
+ If live_idx >= 0, only that specific live card is performed.
125
+ Otherwise (default -1), all cards in the live zone are performed (all-or-nothing).
126
+ """
127
+ p = self.players[player_idx]
128
+
129
+ # Phase 1: Ability Trigger (Run once)
130
+ if not p.performance_abilities_processed:
131
+ p.performance_abilities_processed = True
132
+
133
+ p.live_zone_revealed = [True] * len(p.live_zone)
134
+
135
+ # Rule 8.3.4: "puts all cards that are not Live Cards into their Waiting Room"
136
+ # Filter live_zone: Keep only if in live_db
137
+ new_live_zone = []
138
+ for cid in p.live_zone:
139
+ if cid in self.live_db:
140
+ new_live_zone.append(cid)
141
+ else:
142
+ if hasattr(self, "log_rule"):
143
+ self.log_rule("Rule 8.3.4", f"Discarding non-live card {cid} from Live Zone.")
144
+ p.discard.append(cid)
145
+ p.live_zone = new_live_zone
146
+
147
+ triggers_found = False
148
+ for card_id in p.live_zone:
149
+ for ab in self.live_db[card_id].abilities:
150
+ if ab.trigger == TriggerType.ON_LIVE_START:
151
+ if hasattr(self, "log_rule"):
152
+ self.log_rule(
153
+ "Rule 11.4",
154
+ f"Triggering [ライブ開始時] (Live Start) abilities for {self.live_db[card_id].name}.",
155
+ )
156
+ self.triggered_abilities.append((player_idx, ab, {"card_id": card_id}))
157
+ triggers_found = True
158
+
159
+ for i, card_id in enumerate(p.stage):
160
+ if card_id >= 0 and not p.tapped_members[i] and card_id in self.member_db:
161
+ for ab in self.member_db[card_id].abilities:
162
+ if ab.trigger == TriggerType.ON_LIVE_START:
163
+ self.triggered_abilities.append((player_idx, ab, {"area": i}))
164
+ triggers_found = True
165
+
166
+ # If abilities triggered, return to main loop to process them
167
+ if triggers_found:
168
+ return
169
+
170
+ # Phase 2: Wait for resolution
171
+ if self.triggered_abilities or self.pending_choices or self.pending_effects:
172
+ return
173
+
174
+ # Phase 3: Calculation checks
175
+ if p.cannot_live:
176
+ for card_id in p.live_zone:
177
+ p.discard.append(card_id)
178
+ p.live_zone = []
179
+ p.performance_abilities_processed = False
180
+ self._advance_performance()
181
+ return
182
+
183
+ if not p.live_zone:
184
+ # Create empty performance result for consistency
185
+ self.performance_results[player_idx] = {
186
+ "success": False,
187
+ "member_contributions": [],
188
+ "yell_cards": [],
189
+ "total_hearts": [0] * 7,
190
+ "lives": [],
191
+ }
192
+ p.performance_abilities_processed = False
193
+ self._advance_performance()
194
+ return
195
+
196
+ total_blades = p.get_total_blades(self.member_db)
197
+
198
+ # Apply cheer_mod (RE_CHEER or cheer_mod rule)
199
+ extra_reveals = sum(
200
+ ce["effect"].value
201
+ for ce in p.continuous_effects
202
+ if ce["effect"].effect_type == EffectType.META_RULE and ce["effect"].params.get("type") == "cheer_mod"
203
+ )
204
+ total_blades = max(0, total_blades + extra_reveals)
205
+
206
+ print(f"DEBUG: Total Blades (cheer reveal count): {total_blades}")
207
+
208
+ # Collect blade breakdown for result
209
+ blade_breakdown = []
210
+ for i in range(3):
211
+ if p.stage[i] >= 0:
212
+ blade_breakdown.extend(p.get_blades_breakdown(i, self.member_db))
213
+ blade_breakdown.extend(p.get_global_blades_breakdown())
214
+
215
+ if hasattr(self, "log_rule"):
216
+ self.log_rule("Rule 8.3.11", f"Player {player_idx} performs Yell ({total_blades} blades).")
217
+ self.yell_cards = []
218
+ for _ in range(total_blades):
219
+ if not p.main_deck and p.discard:
220
+ random.shuffle(p.discard)
221
+ p.main_deck, p.discard = p.discard, []
222
+ if p.main_deck:
223
+ self.yell_cards.append(p.main_deck.pop(0))
224
+ draw_bonus, yell_score_bonus = 0, 0
225
+ total_hearts = np.zeros(7, dtype=np.int32)
226
+ member_contributions = []
227
+ heart_breakdown = []
228
+ for i in range(3):
229
+ cid = p.stage[i]
230
+ if cid >= 0:
231
+ hraw = p.get_effective_hearts(i, self.member_db)
232
+ total_hearts[: len(hraw)] += hraw
233
+ heart_breakdown.extend(p.get_hearts_breakdown(i, self.member_db))
234
+ card = self.member_db[cid]
235
+
236
+ # Rule 8.4.1: Include Stage Member volume icons
237
+ vol = getattr(card, "volume_icons", 0)
238
+ yell_score_bonus += vol
239
+
240
+ member_contributions.append(
241
+ {
242
+ "source_id": cid,
243
+ "source": card.name,
244
+ "img": card.img_path,
245
+ "hearts": hraw.tolist(),
246
+ "blades": int(p.get_effective_blades(i, self.member_db)),
247
+ "volume_icons": vol,
248
+ "draw_icons": getattr(card, "draw_icons", 0),
249
+ }
250
+ )
251
+
252
+ yell_card_details = []
253
+ for card_id in self.yell_cards:
254
+ card = self.member_db.get(card_id) or self.live_db.get(card_id)
255
+ if card:
256
+ draw_bonus += getattr(card, "draw_icons", 0)
257
+ vol = getattr(card, "volume_icons", 0)
258
+ yell_score_bonus += vol
259
+
260
+ details = {
261
+ "id": card_id,
262
+ "name": card.name,
263
+ "img": card.img_path,
264
+ "blade_hearts": [0] * 7,
265
+ "volume_icons": vol,
266
+ "draw_icons": getattr(card, "draw_icons", 0),
267
+ }
268
+ # Rule 8.4.1: Include Blade Hearts (including ALL Blade) from revealed cards
269
+ bh = getattr(card, "blade_hearts", None)
270
+ if bh is not None:
271
+ bh_padded = np.zeros(7, dtype=np.int32)
272
+ bh_padded[: len(bh)] = bh[:7]
273
+ total_hearts[: len(bh_padded)] += bh_padded
274
+ details["blade_hearts"] = bh_padded.tolist()
275
+ yell_card_details.append(details)
276
+
277
+ # Apply global TRANSFORM_COLOR to the final heart pool
278
+ transform_logs = []
279
+ for ce in p.continuous_effects:
280
+ eff = ce["effect"]
281
+ if eff.effect_type == EffectType.TRANSFORM_COLOR:
282
+ # Value v is the amount or filter? Usually it's "all of type X become Y"
283
+ # Params: from_color (int 1-6), to_color (int 1-6)
284
+ src = eff.params.get("from_color", eff.params.get("color"))
285
+ dest = eff.params.get("to_color")
286
+ if src and dest:
287
+ try:
288
+ s_idx, d_idx = int(src) - 1, int(dest) - 1
289
+ if 0 <= s_idx < 6 and 0 <= d_idx < 6:
290
+ transfer = total_hearts[s_idx]
291
+ total_hearts[d_idx] += transfer
292
+ total_hearts[s_idx] = 0
293
+ heart_breakdown.append(
294
+ {
295
+ "source": ce.get("source_name", "Effect"),
296
+ "type": "transform",
297
+ "text": f"Transform {src} Yells to {dest}",
298
+ }
299
+ )
300
+ transform_logs.append(
301
+ {
302
+ "source": ce.get("source_name", "Effect"),
303
+ "desc": f"Transform {src} Yells to {dest}",
304
+ "type": "transform",
305
+ "source_id": ce.get("source_id", -1),
306
+ }
307
+ )
308
+ except:
309
+ pass
310
+
311
+ self._draw_cards(p, draw_bonus)
312
+
313
+ # Save pre-consumption hearts for display
314
+ display_hearts = total_hearts.copy()
315
+
316
+ # --- ALL-OR-NOTHING CHECK ---
317
+ total_req = np.zeros(7, dtype=np.int32)
318
+ live_reqs = [] # Store individual reqs for UI and logic
319
+ requirement_logs = []
320
+
321
+ # Determine target lives
322
+ target_lives = []
323
+ if 0 <= live_idx < len(p.live_zone):
324
+ target_lives = [p.live_zone[live_idx]]
325
+ else:
326
+ target_lives = p.live_zone
327
+
328
+ for card_id in target_lives:
329
+ live = self.live_db[card_id]
330
+ req = live.required_hearts.copy()
331
+ for ce in p.continuous_effects:
332
+ if ce["effect"].effect_type == EffectType.REDUCE_HEART_REQ:
333
+ val = ce["effect"].value
334
+ color = ce["effect"].params.get("color")
335
+ if color == "any" or not color:
336
+ req[6] = max(0, req[6] - val)
337
+ else:
338
+ try:
339
+ # color param might be 1-6
340
+ c_idx = int(color) - 1
341
+ if 0 <= c_idx < 6:
342
+ req[c_idx] = max(0, req[c_idx] - val)
343
+ except:
344
+ pass
345
+
346
+ # Log requirement reduction
347
+ red_vec = np.zeros(7, dtype=np.int32)
348
+ if color == "any" or not color:
349
+ red_vec[6] = val
350
+ else:
351
+ try:
352
+ c_idx = int(color) - 1
353
+ if 0 <= c_idx < 6:
354
+ red_vec[c_idx] = val
355
+ except:
356
+ pass
357
+
358
+ if np.any(red_vec > 0):
359
+ requirement_logs.append(
360
+ {
361
+ "source": ce.get("source_name", "Effect"),
362
+ "value": (-red_vec).tolist(),
363
+ "type": "req_mod",
364
+ "source_id": ce.get("source_id", -1),
365
+ }
366
+ )
367
+
368
+ # Pad to 7 just in case
369
+ req_padded = np.zeros(7, dtype=np.int32)
370
+ req_padded[: len(req)] = req[:7]
371
+
372
+ total_req += req_padded
373
+ live_reqs.append((card_id, req_padded))
374
+
375
+ temp_hearts = total_hearts.copy()
376
+ live_details = []
377
+ passed_lives_acc = []
378
+ any_failed = False
379
+
380
+ for card_id, req in live_reqs:
381
+ l_passed = self._check_hearts_meet_requirement(temp_hearts, req)
382
+ status_text = "PASSED" if l_passed else "FAILED"
383
+ if hasattr(self, "log_rule"):
384
+ card_name = self.live_db[card_id].name if card_id in self.live_db else f"Card {card_id}"
385
+ self.log_rule("Rule 8.3.15", f"P{player_idx} Live Card '{card_name}': {status_text}")
386
+
387
+ if not any_failed and l_passed:
388
+ # SUCCESS for this card
389
+ before = temp_hearts.copy()
390
+ self._consume_hearts(temp_hearts, req)
391
+ filled = before - temp_hearts
392
+
393
+ live_details.append(
394
+ {
395
+ "id": card_id,
396
+ "name": self.live_db[card_id].name,
397
+ "img": self.live_db[card_id].img_path,
398
+ "required": req.tolist(),
399
+ "filled": filled.tolist(),
400
+ "passed": True,
401
+ "score": self.live_db[card_id].score,
402
+ }
403
+ )
404
+ passed_lives_acc.append(card_id)
405
+ else:
406
+ # FAILED for this card or previous card failed
407
+ any_failed = True
408
+ live_details.append(
409
+ {
410
+ "id": card_id,
411
+ "name": self.live_db[card_id].name,
412
+ "img": self.live_db[card_id].img_path,
413
+ "required": req.tolist(),
414
+ "filled": [0] * 7,
415
+ "passed": False,
416
+ "score": self.live_db[card_id].score,
417
+ }
418
+ )
419
+
420
+ all_passed = not any_failed and len(passed_lives_acc) == len(live_reqs)
421
+
422
+ # Log results
423
+ if hasattr(self, "log_rule"):
424
+ res_str = "PASSED" if all_passed else "FAILED"
425
+ self.log_rule(
426
+ "Rule 8.3.15",
427
+ f"Performance P{player_idx} {res_str} - Lives Passed: {len(passed_lives_acc)}/{len(live_reqs)}",
428
+ )
429
+
430
+ # Rule 8.3.16: If ANY failed, ALL go to discard.
431
+ if all_passed:
432
+ p.passed_lives = passed_lives_acc
433
+ p.live_zone = [] # All moved to passed_lives
434
+ else:
435
+ p.passed_lives = []
436
+ if hasattr(self, "log_rule"):
437
+ self.log_rule(
438
+ "Rule 8.3.16", f"P{player_idx} Performance Failure: Discarding {len(p.live_zone)} live cards."
439
+ )
440
+ p.discard.extend(p.live_zone)
441
+ p.live_zone = []
442
+
443
+ p.live_score_bonus += yell_score_bonus
444
+ p.yell_score_count = yell_score_bonus
445
+
446
+ self.performance_results[player_idx] = {
447
+ "success": all_passed,
448
+ "lives_passed_count": len(passed_lives_acc) if all_passed else 0,
449
+ "member_contributions": member_contributions,
450
+ "yell_cards": yell_card_details,
451
+ "total_hearts": display_hearts.tolist(),
452
+ "lives": live_details,
453
+ "breakdown": {
454
+ "blades": blade_breakdown,
455
+ "hearts": heart_breakdown,
456
+ "requirements": requirement_logs,
457
+ "transforms": transform_logs,
458
+ "score_modifiers": [], # Will be populated in _do_live_result
459
+ },
460
+ "yell_score_bonus": yell_score_bonus,
461
+ }
462
+
463
+ # Store in history
464
+ if not hasattr(self, "performance_history"):
465
+ self.performance_history = []
466
+ hist_entry = self.performance_results[player_idx].copy()
467
+ hist_entry["player_id"] = player_idx
468
+ hist_entry["turn"] = self.turn_number
469
+ self.performance_history.append(hist_entry)
470
+
471
+ p.performance_abilities_processed = False
472
+ self._advance_performance()
473
+
474
+ def _clear_expired_effects(self, expiry_type: str) -> None:
475
+ for p in self.players:
476
+ p.continuous_effects = [e for e in p.continuous_effects if e.get("expiry") != expiry_type]
477
+ if expiry_type == "LIVE_END":
478
+ p.cannot_live = False
479
+ p.live_score_bonus = 0
480
+ p.live_success_triggered = False
481
+
482
+ def _do_live_result(self) -> None:
483
+ """
484
+ Rule 8.4: Determine live winner and handle success.
485
+ """
486
+ if hasattr(self, "performance_results"):
487
+ self.last_performance_results = self.performance_results.copy()
488
+
489
+ p0, p1 = self.players[0], self.players[1]
490
+
491
+ # Rule 8.4.4: Success event triggers before winner determination
492
+ # Trigger ON_LIVE_SUCCESS for both Stage Members and performed Live Cards
493
+ for pid, p in enumerate(self.players):
494
+ if p.passed_lives and not p.live_success_triggered:
495
+ p.live_success_triggered = True
496
+ if hasattr(self, "log_rule"):
497
+ self.log_rule("Rule 11.5", f"P{pid} Live Success Event: Triggering [ライブ成功時] abilities.")
498
+ # Stage Members
499
+ for i, cid in enumerate(p.stage):
500
+ if cid >= 0 and cid in self.member_db:
501
+ for ab in self.member_db[cid].abilities:
502
+ if ab.trigger == TriggerType.ON_LIVE_SUCCESS:
503
+ self.triggered_abilities.append((pid, ab, {"area": i}))
504
+ # Live Cards (Rule 11.5 explicitly mentions both can have success abilities)
505
+ for cid in p.passed_lives:
506
+ if cid in self.live_db:
507
+ for ab in self.live_db[cid].abilities:
508
+ if ab.trigger == TriggerType.ON_LIVE_SUCCESS:
509
+ self.triggered_abilities.append((pid, ab, {"card_id": cid}))
510
+
511
+ # If abilities triggered, return to process them (allowing score mods to apply)
512
+ if self.triggered_abilities:
513
+ return
514
+
515
+ # Calculate base score from passed_lives (Rule 8.4.1)
516
+ p0_base = sum(self.live_db[c].score for c in p0.passed_lives if c in self.live_db)
517
+ p1_base = sum(self.live_db[c].score for c in p1.passed_lives if c in self.live_db)
518
+
519
+ # Apply score modifiers (Rule 11)
520
+ for pid, p in enumerate([p0, p1]):
521
+ player_score_mods = []
522
+ for ce in p.continuous_effects:
523
+ if ce["effect"].effect_type == EffectType.MODIFY_SCORE_RULE:
524
+ val = ce["effect"].value
525
+ if ce["effect"].params and ce["effect"].params.get("multiplier_source") == "yell_score_icon":
526
+ val *= p.yell_score_count
527
+ p.live_score_bonus += val
528
+
529
+ # Capture for breakdown
530
+ if pid in self.performance_results:
531
+ self.performance_results[pid]["breakdown"]["score_modifiers"].append(
532
+ {
533
+ "source": ce.get("source_name", "Effect"),
534
+ "value": val,
535
+ "desc": f"Score modifier from {ce.get('source_name', 'Effect')}",
536
+ "source_id": ce.get("source_id", -1),
537
+ }
538
+ )
539
+
540
+ p0_total, p1_total = p0_base + p0.live_score_bonus, p1_base + p1.live_score_bonus
541
+ if hasattr(self, "log_rule"):
542
+ self.log_rule("Rule 8.4.6", f"Live Judgment: P0={p0_total}, P1={p1_total}")
543
+
544
+ # Determine Winners (Rule 8.4.6)
545
+ winners = []
546
+ if p0_total > 0 or p1_total > 0:
547
+ if p0_total > p1_total:
548
+ winners = [0]
549
+ elif p1_total > p0_total:
550
+ winners = [1]
551
+ else:
552
+ winners = [0, 1]
553
+
554
+ choices = []
555
+ is_tie = len(winners) == 2
556
+
557
+ for w in winners:
558
+ p = self.players[w]
559
+ if p.passed_lives:
560
+ # Rule 8.4.7.1: Penalty for ties with multiple cards
561
+ if is_tie and len(p.passed_lives) >= 2:
562
+ if hasattr(self, "log_rule"):
563
+ self.log_rule(
564
+ "Rule 8.4.7.1", f"Player {w} Tie Penalty: {len(p.passed_lives)} cards; 0 points awarded."
565
+ )
566
+ continue
567
+
568
+ # Rule 8.4.7.2 / 8.4.7.3: Move exactly one card to success lives
569
+ if len(p.passed_lives) == 1:
570
+ cid = p.passed_lives[0]
571
+ if hasattr(self, "log_rule"):
572
+ self.log_rule(
573
+ "Rule 11.5",
574
+ f"Triggering [ライブ成功時] (Live Success) abilities for {self.live_db[cid].name}.",
575
+ )
576
+ p.success_lives.append(cid)
577
+ p.passed_lives.pop(0)
578
+ if hasattr(self, "log_rule"):
579
+ self.log_rule("Rule 8.4.7.2", f"Player {w} obtained 1 Success Live: {self.live_db[cid].name}")
580
+ else:
581
+ # Multi-card success (when not a tie): must choose one (Rule 8.4.7.3)
582
+ choices.append(
583
+ (
584
+ "SELECT_SUCCESS_LIVE",
585
+ {
586
+ "cards": p.passed_lives.copy(),
587
+ "player_id": w,
588
+ "source_card_id": p.passed_lives[0],
589
+ # Simplified choice text
590
+ "effect_description": "獲得するライブカードを1枚選んでください",
591
+ "source_member": "ライブ判定",
592
+ },
593
+ )
594
+ )
595
+
596
+ for c in reversed(choices):
597
+ if c not in self.pending_choices:
598
+ self.pending_choices.insert(0, c)
599
+
600
+ if self.pending_choices:
601
+ choice_player = self.pending_choices[0][1].get("player_id")
602
+ self.current_player = choice_player
603
+ return
604
+
605
+ # Cleanup (Rule 8.4.8)
606
+ for w, p in enumerate(self.players):
607
+ if p.passed_lives:
608
+ if hasattr(self, "log_rule"):
609
+ self.log_rule(
610
+ "Rule 8.4.8",
611
+ f"P{w} Live Result Cleanup: Discarding {len(p.passed_lives)} surplus passed cards.",
612
+ )
613
+ p.discard.extend(p.passed_lives)
614
+ p.passed_lives = []
615
+ p.live_score_bonus = 0
616
+
617
+ # Determine who goes first next turn (Rule 8.4.10)
618
+ # Winner goes first. If both won (tie), host/active or specified logic.
619
+ if len(winners) == 1:
620
+ self.first_player = winners[0]
621
+
622
+ # Reset turn effects
623
+ for p in self.players:
624
+ p.continuous_effects = [e for e in p.continuous_effects if e.get("expiry") != "TURN_END"]
625
+ p.cannot_live = False
626
+ p.used_abilities.clear()
627
+ p.moved_members_this_turn.clear()
628
+
629
+ # Advance turn
630
+ self._finish_live_result()
631
+
632
+ def _finish_live_result(self) -> None:
633
+ """Advance to the next turn after live results are finalized."""
634
+ # Q171: "Until end of live" effects expire at the end of the Live Result phase.
635
+ self._clear_expired_effects("LIVE_END")
636
+
637
+ self.turn_number += 1
638
+ self.action_count_this_turn = 0
639
+ self.first_player = (self.first_player + 1) % len(self.players)
640
+ self.current_player = self.first_player
641
+ self.phase = Phase.ACTIVE
642
+
643
+ if hasattr(self, "performance_results"):
644
+ self.performance_results.clear()
645
+
646
+ for p in self.players:
647
+ # Rule 8.4.11: Move any unperformed live cards to discard
648
+ p.discard.extend(p.live_zone)
649
+ p.live_zone = []
650
+ p.live_zone_revealed = []
651
+ p.performance_abilities_processed = False
652
+
653
+ self.check_win_condition()
654
+ self._do_active_phase()
engine/game/numba_utils.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Numba Utils for Love Live Card Game
3
+ JIT-compiled functions for high-performance game logic.
4
+ """
5
+
6
+ try:
7
+ from numba import njit
8
+
9
+ JIT_AVAILABLE = True
10
+ except ImportError:
11
+ # Fallback to python decorator if numba is missing (for safety)
12
+ JIT_AVAILABLE = False
13
+
14
+ def njit(func):
15
+ return func
16
+
17
+
18
+ import numpy as np
19
+
20
+
21
+ @njit
22
+ def calc_main_phase_masks(
23
+ hand: np.ndarray,
24
+ stage: np.ndarray,
25
+ available_energy: int,
26
+ total_reduction: int,
27
+ baton_touch_allowed: bool,
28
+ members_played_this_turn: np.ndarray,
29
+ member_costs: np.ndarray, # Direct lookup array: cost = member_costs[card_id]
30
+ mask: np.ndarray,
31
+ ) -> None:
32
+ """
33
+ JIT-compiled Main Phase legal action calculation.
34
+ """
35
+ # Iterate through hand to find playable members
36
+ hand_len = len(hand)
37
+ for i in range(hand_len):
38
+ card_id = hand[i]
39
+
40
+ # Check if valid card ID for array lookup
41
+ if card_id < 0 or card_id >= len(member_costs):
42
+ continue
43
+
44
+ base_cost = member_costs[card_id]
45
+ if base_cost == -1: # Not a member
46
+ continue
47
+
48
+ # Check each of the 3 stage areas
49
+ for area in range(3):
50
+ if members_played_this_turn[area]:
51
+ continue
52
+
53
+ active_cost = base_cost - total_reduction
54
+ if active_cost < 0:
55
+ active_cost = 0
56
+
57
+ # Baton Touch Rule
58
+ stage_card_id = stage[area]
59
+ if baton_touch_allowed and stage_card_id != -1:
60
+ if stage_card_id < len(member_costs):
61
+ existing_cost = member_costs[stage_card_id]
62
+ if existing_cost != -1:
63
+ active_cost = active_cost - existing_cost
64
+ if active_cost < 0:
65
+ active_cost = 0
66
+
67
+ # Check affordability
68
+ if active_cost <= available_energy:
69
+ action_idx = 1 + (i * 3) + area
70
+ if action_idx < 1000:
71
+ mask[action_idx] = 1
engine/game/player_state.py ADDED
@@ -0,0 +1,827 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, List
2
+
3
+ import numpy as np
4
+
5
+ from engine.game.state_utils import StateMixin
6
+ from engine.models.ability import Condition, ConditionType, EffectType, TargetType, TriggerType
7
+ from engine.models.card import MemberCard
8
+
9
+
10
+ class PlayerState(StateMixin):
11
+ """
12
+ Player state (Rule 3)
13
+ Contains areas, zones, and tracking for a single player.
14
+ """
15
+
16
+ __slots__ = (
17
+ "player_id",
18
+ "hand",
19
+ "main_deck",
20
+ "energy_deck",
21
+ "discard",
22
+ "energy_zone",
23
+ "success_lives",
24
+ "live_zone",
25
+ "live_zone_revealed",
26
+ "stage",
27
+ "stage_energy_vec",
28
+ "stage_energy_count",
29
+ "tapped_energy",
30
+ "tapped_members",
31
+ "members_played_this_turn",
32
+ "mulligan_selection",
33
+ "baton_touch_limit",
34
+ "baton_touch_count",
35
+ "negate_next_effect",
36
+ "restrictions",
37
+ "live_score_bonus",
38
+ "passed_lives",
39
+ "cannot_live",
40
+ "used_abilities",
41
+ "meta_rules",
42
+ "continuous_effects",
43
+ "continuous_effects_vec",
44
+ "continuous_effects_ptr",
45
+ "hand_buffer",
46
+ "moved_members_this_turn",
47
+ "hand_added_turn",
48
+ "deck_refreshed_this_turn",
49
+ "performance_abilities_processed",
50
+ "rested_members",
51
+ "revealed_hand",
52
+ "yell_score_count",
53
+ "fast_mode",
54
+ "members_tapped_by_opponent_this_turn",
55
+ "live_cards_set_this_turn",
56
+ "live_success_triggered",
57
+ )
58
+
59
+ def __init__(self, player_id: int):
60
+ self.player_id = player_id
61
+ self.hand: List[int] = []
62
+ self.hand_added_turn: List[int] = []
63
+ self.main_deck: List[int] = []
64
+ self.energy_deck: List[int] = []
65
+ self.discard: List[int] = []
66
+ self.energy_zone: List[int] = []
67
+ self.success_lives: List[int] = []
68
+ self.live_zone: List[int] = []
69
+ self.live_zone_revealed: List[bool] = []
70
+ self.stage: np.ndarray = np.full(3, -1, dtype=np.int32)
71
+ self.stage_energy_vec: np.ndarray = np.zeros((3, 32), dtype=np.int32)
72
+ self.stage_energy_count: np.ndarray = np.zeros(3, dtype=np.int32)
73
+ self.tapped_energy: np.ndarray = np.zeros(100, dtype=bool)
74
+ self.tapped_members: np.ndarray = np.zeros(3, dtype=bool)
75
+ self.members_played_this_turn: np.ndarray = np.zeros(3, dtype=bool)
76
+ self.mulligan_selection: set = set()
77
+ self.baton_touch_limit: int = 1
78
+ self.baton_touch_count: int = 0
79
+ self.negate_next_effect: bool = False
80
+ self.restrictions: set[str] = set()
81
+ self.live_score_bonus: int = 0
82
+ self.passed_lives: List[int] = []
83
+ self.cannot_live: bool = False
84
+ self.used_abilities: set[str] = set()
85
+ self.moved_members_this_turn: set[int] = set()
86
+ self.continuous_effects: List[Dict[str, Any]] = []
87
+ self.continuous_effects_vec: np.ndarray = np.zeros((32, 10), dtype=np.int32)
88
+ self.continuous_effects_ptr: int = 0
89
+ self.meta_rules: set[str] = set()
90
+ self.fast_mode: bool = False
91
+ self.hand_buffer: np.ndarray = np.zeros(100, dtype=np.int32)
92
+ self.deck_refreshed_this_turn: bool = False
93
+ self.performance_abilities_processed: bool = False
94
+ self.rested_members: np.ndarray = np.zeros(3, dtype=bool)
95
+ self.revealed_hand: bool = False
96
+ self.yell_score_count: int = 0
97
+ self.live_cards_set_this_turn: int = 0
98
+ self.live_success_triggered: bool = False
99
+ self.members_tapped_by_opponent_this_turn: set[int] = set()
100
+
101
+ @property
102
+ def score(self) -> int:
103
+ """
104
+ Game Score (Rule 1.2 / 8.4.7)
105
+ - This is the number of cards in the success_lives zone.
106
+ - Points are obtained during Rule 8.4 Live Judgment phase.
107
+ - Only 1 success live card can be added per judgment turn.
108
+ """
109
+ return len(self.success_lives)
110
+
111
+ @property
112
+ def energy_count(self) -> int:
113
+ return len(self.energy_zone)
114
+
115
+ @energy_count.setter
116
+ def energy_count(self, value: int):
117
+ current = len(self.energy_zone)
118
+ if value < current:
119
+ # Assume cost payment: Move from Energy Zone to Discard
120
+ diff = current - value
121
+ for _ in range(diff):
122
+ if self.energy_zone:
123
+ card_id = self.energy_zone.pop()
124
+ self.discard.append(card_id)
125
+ elif value > current:
126
+ # Cannot magically add empty energy without cards
127
+ pass
128
+
129
+ @property
130
+ def stage_energy(self) -> List[List[int]]:
131
+ """Legacy compatibility property. Returns a copy of the energy state."""
132
+ res = []
133
+ for i in range(3):
134
+ count = self.stage_energy_count[i]
135
+ res.append(list(self.stage_energy_vec[i, :count]))
136
+ return res
137
+
138
+ def add_stage_energy(self, slot_idx: int, card_id: int) -> None:
139
+ """Add energy to a slot using flat arrays."""
140
+ count = self.stage_energy_count[slot_idx]
141
+ if count < 32:
142
+ self.stage_energy_vec[slot_idx, count] = card_id
143
+ self.stage_energy_count[slot_idx] = count + 1
144
+
145
+ def clear_stage_energy(self, slot_idx: int) -> None:
146
+ """Clear energy from a slot."""
147
+ self.stage_energy_count[slot_idx] = 0
148
+
149
+ def _reset(self, player_id: int) -> None:
150
+ """Reset state for pool reuse."""
151
+ self.player_id = player_id
152
+ self.hand.clear()
153
+ self.main_deck.clear()
154
+ self.energy_deck.clear()
155
+ self.discard.clear()
156
+ self.energy_zone.clear()
157
+ self.success_lives.clear()
158
+ self.live_zone.clear()
159
+ self.live_zone_revealed.clear()
160
+ self.stage.fill(-1)
161
+ self.stage_energy_vec.fill(0)
162
+ self.stage_energy_count.fill(0)
163
+ self.tapped_energy.fill(False)
164
+ self.tapped_members.fill(False)
165
+ self.members_played_this_turn.fill(False)
166
+ self.mulligan_selection.clear()
167
+ self.baton_touch_limit = 1
168
+ self.baton_touch_count = 0
169
+ self.negate_next_effect = False
170
+ self.restrictions.clear()
171
+ self.live_score_bonus = 0
172
+ self.passed_lives.clear()
173
+ self.cannot_live = False
174
+ self.used_abilities.clear()
175
+ self.continuous_effects.clear()
176
+ self.continuous_effects_vec.fill(0)
177
+ self.continuous_effects_ptr = 0
178
+ self.meta_rules.clear()
179
+ self.hand_added_turn.clear()
180
+ self.deck_refreshed_this_turn = False
181
+ self.performance_abilities_processed = False
182
+ self.rested_members.fill(False)
183
+ self.revealed_hand = False
184
+ self.revealed_hand = False
185
+ self.moved_members_this_turn.clear()
186
+ self.members_tapped_by_opponent_this_turn.clear()
187
+ self.live_cards_set_this_turn = 0
188
+ self.live_success_triggered = False
189
+
190
+ def copy_slots_to(self, target: "PlayerState") -> None:
191
+ """Hardcoded field copy for maximum performance."""
192
+ target.player_id = self.player_id
193
+ target.hand = self.hand[:]
194
+ target.hand_added_turn = self.hand_added_turn[:]
195
+ target.main_deck = self.main_deck[:]
196
+ target.energy_deck = self.energy_deck[:]
197
+ target.discard = self.discard[:]
198
+ target.energy_zone = self.energy_zone[:]
199
+ target.success_lives = self.success_lives[:]
200
+ target.live_zone = self.live_zone[:]
201
+ target.live_zone_revealed = self.live_zone_revealed[:]
202
+ target.baton_touch_limit = self.baton_touch_limit
203
+ target.baton_touch_count = self.baton_touch_count
204
+ target.negate_next_effect = self.negate_next_effect
205
+ target.live_score_bonus = self.live_score_bonus
206
+ target.cannot_live = self.cannot_live
207
+ target.deck_refreshed_this_turn = self.deck_refreshed_this_turn
208
+ target.performance_abilities_processed = self.performance_abilities_processed
209
+ target.revealed_hand = self.revealed_hand
210
+ target.continuous_effects_ptr = self.continuous_effects_ptr
211
+ target.live_cards_set_this_turn = self.live_cards_set_this_turn
212
+ target.live_success_triggered = self.live_success_triggered
213
+
214
+ def copy(self) -> "PlayerState":
215
+ new = PlayerState(self.player_id)
216
+ self.copy_to(new)
217
+ return new
218
+
219
+ def copy_to(self, new: "PlayerState") -> None:
220
+ # 1. Scalar/List fields
221
+ self.copy_slots_to(new)
222
+ # 2. NumPy arrays (memcpy speed)
223
+ np.copyto(new.stage, self.stage)
224
+ np.copyto(new.stage_energy_vec, self.stage_energy_vec)
225
+ np.copyto(new.stage_energy_count, self.stage_energy_count)
226
+ np.copyto(new.tapped_energy, self.tapped_energy)
227
+ np.copyto(new.tapped_members, self.tapped_members)
228
+ np.copyto(new.rested_members, self.rested_members)
229
+ np.copyto(new.continuous_effects_vec, self.continuous_effects_vec)
230
+
231
+ # 3. Sets and complex structures (slowest)
232
+ np.copyto(new.members_played_this_turn, self.members_played_this_turn)
233
+ new.used_abilities = set(self.used_abilities)
234
+ new.restrictions = set(self.restrictions)
235
+ new.mulligan_selection = set(self.mulligan_selection)
236
+ new.meta_rules = set(self.meta_rules)
237
+ new.meta_rules = set(self.meta_rules)
238
+ new.moved_members_this_turn = set(self.moved_members_this_turn)
239
+ new.members_tapped_by_opponent_this_turn = set(self.members_tapped_by_opponent_this_turn)
240
+ new.passed_lives = list(self.passed_lives)
241
+
242
+ # Legacy continuous_effects (only copy if needed or for AI skip)
243
+ if hasattr(self, "fast_mode") and self.fast_mode:
244
+ new.continuous_effects = []
245
+ else:
246
+ new.continuous_effects = [dict(e) for e in self.continuous_effects]
247
+
248
+ def untap_all(self) -> None:
249
+ self.tapped_energy[:] = False
250
+ self.tapped_members[:] = False
251
+ self.live_cards_set_this_turn = 0
252
+
253
+ def count_untapped_energy(self) -> int:
254
+ return int(np.count_nonzero(~self.tapped_energy[: len(self.energy_zone)]))
255
+
256
+ return breakdown
257
+
258
+ def get_blades_breakdown(self, slot_idx: int, card_db: Dict[int, MemberCard]) -> List[Dict[str, Any]]:
259
+ """Calculate blades breakdown for a slot (Rule 9.9)."""
260
+ card_id = self.stage[slot_idx]
261
+ if card_id < 0:
262
+ return [{"source": f"Slot {slot_idx + 1}", "value": 0, "type": "empty", "source_id": -1}]
263
+
264
+ # Check if member is tapped (inactive)
265
+ if self.tapped_members[slot_idx]:
266
+ from engine.game.state_utils import get_base_id
267
+
268
+ base_id = get_base_id(int(card_id))
269
+ name = card_db[base_id].name if base_id in card_db else "Unknown"
270
+ return [{"source": f"{name} (Resting)", "value": 0, "type": "inactive", "source_id": int(card_id)}]
271
+
272
+ from engine.game.state_utils import get_base_id
273
+
274
+ base_id = get_base_id(int(card_id))
275
+ if base_id not in card_db:
276
+ return [{"source": "Unknown Card", "value": 0, "type": "error"}]
277
+
278
+ member = card_db[base_id]
279
+ breakdown = [{"source": member.name, "value": int(member.blades), "type": "base", "source_id": int(card_id)}]
280
+
281
+ # Collect effects
282
+ applied_effects = [] # List of (source_name, effect)
283
+ for ce in self.continuous_effects:
284
+ # ONLY include effects targeting this specific slot.
285
+ # Global effects (target_slot == -1) are handled at the Player level to avoid overcounting.
286
+ if ce.get("target_slot") == slot_idx:
287
+ src = ce.get("source_name", "Effect")
288
+ if "condition_text" in ce:
289
+ src += f" ({ce['condition_text']})"
290
+ applied_effects.append((src, ce["effect"]))
291
+
292
+ for ab in member.abilities:
293
+ if ab.trigger == TriggerType.CONSTANT:
294
+ if all(self._check_condition_for_constant(ab_cond, slot_idx, card_db) for ab_cond in ab.conditions):
295
+ for eff in ab.effects:
296
+ # Construct a helpful source string
297
+ src = member.name
298
+ if ab.conditions:
299
+ cond_texts = []
300
+ for c in ab.conditions:
301
+ if c.type == ConditionType.TURN_1:
302
+ cond_texts.append("Turn 1")
303
+ elif c.type == ConditionType.COUNT_STAGE:
304
+ cond_texts.append(f"Stage {c.params.get('value', 0)}+")
305
+ elif c.type == ConditionType.COUNT_HAND:
306
+ cond_texts.append(f"Hand {c.params.get('value', 0)}+")
307
+ elif c.type == ConditionType.LIFE_LEAD:
308
+ cond_texts.append("Life Lead")
309
+ else:
310
+ cond_texts.append("Cond")
311
+ src += f" ({', '.join(cond_texts)})"
312
+ else:
313
+ src += " (Constant)"
314
+ applied_effects.append((src, eff))
315
+
316
+ # Layer 4: SET
317
+ for source, eff in applied_effects:
318
+ if eff.effect_type == EffectType.SET_BLADES:
319
+ breakdown = [
320
+ {"source": source, "value": int(eff.value), "type": "set", "source_id": ce.get("source_id", -1)}
321
+ ]
322
+
323
+ # Layer 4: ADD / BUFF
324
+ for source, eff in applied_effects:
325
+ if eff.effect_type in (EffectType.ADD_BLADES, EffectType.BUFF_POWER):
326
+ val = eff.value
327
+ val_desc = ""
328
+ if eff.params.get("multiplier"):
329
+ if eff.params.get("per_live"):
330
+ val *= len(self.success_lives)
331
+ val_desc = f" ({len(self.success_lives)} Lives)"
332
+ elif eff.params.get("per_energy"):
333
+ val *= len(self.energy_zone)
334
+ val_desc = f" ({len(self.energy_zone)} Energy)"
335
+ elif eff.params.get("per_member"):
336
+ val *= np.sum(self.stage >= 0)
337
+ val_desc = f" ({np.sum(self.stage >= 0)} Members)"
338
+
339
+ final_source = source + val_desc
340
+ breakdown.append(
341
+ {
342
+ "source": final_source,
343
+ "value": int(val),
344
+ "type": "mod",
345
+ "source_id": ce.get("source_id", -1) if "ce" in locals() else int(card_id),
346
+ }
347
+ )
348
+
349
+ return breakdown
350
+
351
+ def get_global_blades_breakdown(self) -> List[Dict[str, Any]]:
352
+ """Calculate breakdown for global (player-wide) blade effects."""
353
+ breakdown = []
354
+ applied_effects = []
355
+ for ce in self.continuous_effects:
356
+ if ce.get("target_slot") == -1:
357
+ src = ce.get("source_name", "Effect")
358
+ if "condition_text" in ce:
359
+ src += f" ({ce['condition_text']})"
360
+ applied_effects.append((ce, src, ce["effect"]))
361
+
362
+ for ce, source, eff in applied_effects:
363
+ if eff.effect_type in (EffectType.ADD_BLADES, EffectType.BUFF_POWER):
364
+ val = eff.value
365
+ val_desc = ""
366
+ if eff.params.get("multiplier"):
367
+ if eff.params.get("per_live"):
368
+ val *= len(self.success_lives)
369
+ val_desc = f" ({len(self.success_lives)} Lives)"
370
+ elif eff.params.get("per_energy"):
371
+ val *= len(self.energy_zone)
372
+ val_desc = f" ({len(self.energy_zone)} Energy)"
373
+ elif eff.params.get("per_member"):
374
+ val *= np.sum(self.stage >= 0)
375
+ val_desc = f" ({np.sum(self.stage >= 0)} Members)"
376
+
377
+ final_source = source + val_desc
378
+ breakdown.append(
379
+ {
380
+ "source": final_source,
381
+ "value": int(val),
382
+ "type": "mod",
383
+ "source_id": ce.get("source_id", -1),
384
+ }
385
+ )
386
+ return breakdown
387
+
388
+ def get_effective_blades(self, slot_idx: int, card_db: Dict[int, MemberCard]) -> int:
389
+ breakdown = self.get_blades_breakdown(slot_idx, card_db)
390
+ total = sum(item["value"] for item in breakdown)
391
+ return max(0, total)
392
+
393
+ def _check_condition_for_constant(
394
+ self, cond: Condition, slot_idx: int, card_db: Dict[int, MemberCard] = None
395
+ ) -> bool:
396
+ """
397
+ Check if a condition is met for a constant ability.
398
+ slot_idx < 0 implies the card is not on stage (e.g. in hand for cost reduction).
399
+ """
400
+ if cond.type == ConditionType.NONE:
401
+ return True
402
+
403
+ # Conditions that require being on stage
404
+ if slot_idx < 0:
405
+ if cond.type in (ConditionType.HAS_MOVED, ConditionType.IS_CENTER, ConditionType.GROUP_FILTER):
406
+ # For GROUP_FILTER, if it's checking SELF, we might need the card ID context which is not passed here properly.
407
+ # But for cost reduction, usually it's just Hand/Stage counts.
408
+ return False
409
+
410
+ if cond.type == ConditionType.HAS_MOVED:
411
+ # Check if this card moved this turn.
412
+ current_card_id = self.stage[slot_idx]
413
+ if current_card_id >= 0:
414
+ return current_card_id in self.moved_members_this_turn
415
+ return False
416
+ elif cond.type == ConditionType.TURN_1:
417
+ # This would require access to game state to check turn number
418
+ # For now, return True as a placeholder
419
+ return True
420
+ elif cond.type == ConditionType.IS_CENTER:
421
+ # Check if the slot is the center position (index 1 in 3-slot system)
422
+ return slot_idx == 1
423
+ elif cond.type == ConditionType.GROUP_FILTER:
424
+ # Check if the member belongs to the specified group
425
+ current_card_id = self.stage[slot_idx]
426
+ if current_card_id >= 0 and card_db:
427
+ from engine.game.state_utils import get_base_id
428
+
429
+ base_id = get_base_id(int(current_card_id))
430
+ if base_id in card_db:
431
+ member = card_db[base_id]
432
+ group_name = cond.params.get("group", "")
433
+ # This would need to compare member's group with the condition's group
434
+ # For now, return True as a placeholder
435
+ return True
436
+ return False
437
+ elif cond.type == ConditionType.COUNT_GROUP:
438
+ # Count members of a specific group in the stage
439
+ group_name = cond.params.get("group", "")
440
+ min_count = cond.params.get("min", 1)
441
+ zone = cond.params.get("zone", "STAGE")
442
+
443
+ count = 0
444
+ if zone == "STAGE" or zone == "OPPONENT_STAGE":
445
+ from engine.game.state_utils import get_base_id
446
+
447
+ for i in range(3):
448
+ card_id = self.stage[i]
449
+ if card_id >= 0 and card_db:
450
+ base_id = get_base_id(int(card_id))
451
+ if base_id in card_db:
452
+ member = card_db[base_id]
453
+ # Compare member's group with the condition's group
454
+ # For now, return True as a placeholder
455
+ count += 1
456
+
457
+ return count >= min_count
458
+ elif cond.type == ConditionType.OPPONENT_HAS:
459
+ # Placeholder for opponent has condition
460
+ return True
461
+ elif cond.type == ConditionType.COUNT_ENERGY:
462
+ min_energy = cond.params.get("min", 1)
463
+ return len(self.energy_zone) >= min_energy
464
+ else:
465
+ # Default lenient for other conditions
466
+ return True
467
+
468
+ def get_hearts_breakdown(self, slot_idx: int, card_db: Dict[int, MemberCard]) -> List[Dict[str, Any]]:
469
+ """Calculate hearts breakdown for a slot, including continuous effects."""
470
+ card_id = self.stage[slot_idx]
471
+ if card_id < 0:
472
+ return [{"source": f"Slot {slot_idx + 1}", "value": [0] * 7, "type": "empty", "source_id": -1}]
473
+
474
+ # Check if member is tapped (inactive)
475
+ if self.tapped_members[slot_idx]:
476
+ from engine.game.state_utils import get_base_id
477
+
478
+ base_id = get_base_id(int(card_id))
479
+ name = card_db[base_id].name if base_id in card_db else "Unknown"
480
+ return [{"source": f"{name} (Resting)", "value": [0] * 7, "type": "inactive", "source_id": int(card_id)}]
481
+
482
+ from engine.game.state_utils import get_base_id
483
+
484
+ base_id = get_base_id(int(card_id))
485
+ if base_id not in card_db:
486
+ return [{"source": "Unknown Card", "value": [0] * 7, "type": "error"}]
487
+
488
+ member = card_db[base_id]
489
+
490
+ # Ensure base hearts are 7-dim
491
+ base_hearts = np.zeros(7, dtype=np.int32)
492
+ base_hearts[: len(member.hearts)] = member.hearts
493
+
494
+ breakdown = [{"source": member.name, "value": base_hearts.tolist(), "type": "base", "source_id": int(card_id)}]
495
+
496
+ # Collect effects
497
+ applied_effects = []
498
+ for ce in self.continuous_effects:
499
+ if ce.get("target_slot") in (-1, slot_idx):
500
+ src = ce.get("source_name", "Effect")
501
+ if "condition_text" in ce:
502
+ src += f" ({ce['condition_text']})"
503
+ applied_effects.append((src, ce["effect"]))
504
+
505
+ for ab in member.abilities:
506
+ if ab.trigger == TriggerType.CONSTANT:
507
+ if all(self._check_condition_for_constant(ab_cond, slot_idx, card_db) for ab_cond in ab.conditions):
508
+ for eff in ab.effects:
509
+ # Construct a helpful source string
510
+ src = member.name
511
+ if ab.conditions:
512
+ cond_texts = []
513
+ for c in ab.conditions:
514
+ if c.type == ConditionType.TURN_1:
515
+ cond_texts.append("Turn 1")
516
+ elif c.type == ConditionType.COUNT_STAGE:
517
+ cond_texts.append(f"Stage {c.params.get('value', 0)}+")
518
+ elif c.type == ConditionType.COUNT_HAND:
519
+ cond_texts.append(f"Hand {c.params.get('value', 0)}+")
520
+ elif c.type == ConditionType.LIFE_LEAD:
521
+ cond_texts.append("Life Lead")
522
+ elif c.type == ConditionType.HAS_MEMBER:
523
+ cond_texts.append("Has Member")
524
+ elif c.type == ConditionType.HAS_COLOR:
525
+ cond_texts.append("Has Color")
526
+ else:
527
+ cond_texts.append("Cond")
528
+ src += f" ({', '.join(cond_texts)})"
529
+ else:
530
+ src += " (Constant)"
531
+ applied_effects.append((src, eff))
532
+
533
+ # Apply Heart Modifications
534
+ for source, eff in applied_effects:
535
+ eff_val = np.zeros(7, dtype=np.int32)
536
+
537
+ if eff.effect_type == EffectType.SET_HEARTS:
538
+ breakdown = [
539
+ {
540
+ "source": source,
541
+ "value": [int(eff.value)] * 7,
542
+ "type": "set",
543
+ "source_id": ce.get("source_id", -1),
544
+ }
545
+ ]
546
+ # Reset others? For SET, usually yes.
547
+ continue
548
+
549
+ if eff.effect_type == EffectType.ADD_HEARTS:
550
+ color_map = {1: 0, 2: 1, 3: 2, 4: 3, 5: 4, 6: 5} # P,R,Y,G,B,P
551
+ target_colors = []
552
+ if eff.params.get("color"):
553
+ c = eff.params["color"]
554
+ if c in color_map:
555
+ target_colors.append(color_map[c])
556
+ elif eff.params.get("all"):
557
+ target_colors = list(range(6))
558
+
559
+ amount = eff.value
560
+ # Multipliers
561
+ if eff.params.get("multiplier"):
562
+ if eff.params.get("per_live"):
563
+ amount *= len(self.success_lives)
564
+ elif eff.params.get("per_energy"):
565
+ amount *= len(self.energy_zone)
566
+
567
+ for c_idx in target_colors:
568
+ eff_val[c_idx] += amount
569
+
570
+ if not target_colors:
571
+ pass
572
+
573
+ # Only append if non-zero
574
+ if np.any(eff_val):
575
+ breakdown.append(
576
+ {
577
+ "source": source,
578
+ "value": eff_val.tolist(),
579
+ "type": "mod",
580
+ "source_id": ce.get("source_id", -1) if "ce" in locals() else int(card_id),
581
+ }
582
+ )
583
+
584
+ return breakdown
585
+
586
+ def get_effective_hearts(self, slot_idx: int, card_db: Dict[int, MemberCard]) -> np.ndarray:
587
+ breakdown = self.get_hearts_breakdown(slot_idx, card_db)
588
+ total = np.zeros(7, dtype=np.int32)
589
+ for item in breakdown:
590
+ total += np.array(item["value"], dtype=np.int32)
591
+ return np.maximum(0, total)
592
+
593
+ def get_total_blades(self, card_db: Dict[int, MemberCard]) -> int:
594
+ total = 0
595
+ # 1. Base + Slot-specific modifiers
596
+ for i, card_id in enumerate(self.stage):
597
+ if card_id >= 0 and not self.tapped_members[i]:
598
+ total += self.get_effective_blades(i, card_db)
599
+
600
+ # 2. Global modifiers
601
+ global_mods = self.get_global_blades_breakdown()
602
+ for mod in global_mods:
603
+ total += mod["value"]
604
+
605
+ return max(0, total)
606
+
607
+ def get_total_hearts(self, card_db: Dict[int, Any]) -> np.ndarray:
608
+ total = np.zeros(7, dtype=np.int32)
609
+ for i, card_id in enumerate(self.stage):
610
+ if card_id >= 0 and not self.tapped_members[i]:
611
+ total += self.get_effective_hearts(i, card_db)
612
+ return total
613
+
614
+ def get_performance_guide(self, live_db: Dict[int, Any], member_db: Dict[int, Any]) -> Dict[str, Any]:
615
+ """
616
+ Calculate projected performance outcome for the user guide.
617
+ Now comprehensive: includes breakdown for all slots (active, resting, empty)
618
+ and requirement modifications.
619
+ """
620
+ if not self.live_zone:
621
+ return {"can_perform": False, "reason": "No live cards"}
622
+
623
+ from engine.game.state_utils import get_base_id
624
+
625
+ # 1. Total Blades & Blade Breakdown
626
+ total_blades = 0
627
+ blade_breakdown = []
628
+ # Always iterate 0-2 to show all slots
629
+ for i in range(3):
630
+ # Breakdown method handles empty/inactive cases now
631
+ bd = self.get_blades_breakdown(i, member_db)
632
+ blade_breakdown.extend(bd)
633
+ if self.stage[i] >= 0 and not self.tapped_members[i]:
634
+ # Sum up effective blades from breakdown for Active members
635
+ slot_total = sum(item["value"] for item in bd if item.get("type") in ("base", "mod", "set"))
636
+ total_blades += max(0, slot_total)
637
+
638
+ # Apply cheer_mod
639
+ extra_reveals = sum(
640
+ ce["effect"].value
641
+ for ce in self.continuous_effects
642
+ if ce["effect"].effect_type == EffectType.META_RULE and ce["effect"].params.get("type") == "cheer_mod"
643
+ )
644
+ total_blades = max(0, total_blades + extra_reveals)
645
+
646
+ # 2. Total Hearts & Heart Breakdown
647
+ total_hearts = np.zeros(7, dtype=np.int32)
648
+ heart_breakdown = []
649
+ for i in range(3):
650
+ bd = self.get_hearts_breakdown(i, member_db)
651
+ heart_breakdown.extend(bd)
652
+ if self.stage[i] >= 0 and not self.tapped_members[i]:
653
+ # Sum up effective hearts from breakdown for Active members
654
+ for item in bd:
655
+ if item.get("type") in ("base", "mod", "set"):
656
+ total_hearts += np.array(item["value"], dtype=np.int32)
657
+
658
+ # 3. Apply TRANSFORM_COLOR (Global)
659
+ transform_log = []
660
+ for ce in self.continuous_effects:
661
+ if ce["effect"].effect_type == EffectType.TRANSFORM_COLOR:
662
+ eff = ce["effect"]
663
+ src_color = eff.params.get("from_color", eff.params.get("color")) # 1-based
664
+ dest_color = eff.params.get("to_color") # 1-based
665
+ if src_color and dest_color:
666
+ try:
667
+ # Handle possibly float/string values
668
+ s_idx = int(src_color) - 1
669
+ d_idx = int(dest_color) - 1
670
+ if 0 <= s_idx < 6 and 0 <= d_idx < 6:
671
+ amount_moved = total_hearts[s_idx]
672
+ total_hearts[d_idx] += amount_moved
673
+ total_hearts[s_idx] = 0
674
+ transform_log.append(
675
+ {
676
+ "source": ce.get("source_name", "Effect"),
677
+ "desc": f"Color Transform (Type {src_color} -> Type {dest_color})",
678
+ "type": "transform",
679
+ "source_id": ce.get("source_id", -1),
680
+ }
681
+ )
682
+ except:
683
+ pass
684
+
685
+ # 4. Process Lives & Requirements
686
+ lives = []
687
+ req_breakdown = [] # Log for requirement reductions
688
+
689
+ for live_id in self.live_zone:
690
+ l_base = get_base_id(live_id)
691
+ if l_base not in live_db:
692
+ continue
693
+ live_card = live_db[l_base]
694
+
695
+ # Base Requirement
696
+ req_breakdown.append(
697
+ {"source": live_card.name, "value": live_card.required_hearts.tolist(), "type": "base_req"}
698
+ )
699
+
700
+ # Copy requirement to modify
701
+ req = live_card.required_hearts.copy() # (7,)
702
+
703
+ # Apply REDUCE_HEART_REQ
704
+ for ce in self.continuous_effects:
705
+ eff = ce["effect"]
706
+ if eff.effect_type == EffectType.REDUCE_HEART_REQ:
707
+ reduction_val = np.zeros(7, dtype=np.int32)
708
+ target_color = eff.params.get("color")
709
+ val = eff.value
710
+
711
+ if target_color and target_color != "any":
712
+ try:
713
+ c_idx = int(target_color) - 1
714
+ if 0 <= c_idx < 6:
715
+ reduction_val[c_idx] = val
716
+ except:
717
+ pass
718
+ else:
719
+ # Any color reduction (index 6) matches "any" param or default
720
+ reduction_val[6] = val
721
+
722
+ # Log reduction
723
+ if np.any(reduction_val > 0):
724
+ req_breakdown.append(
725
+ {
726
+ "source": ce.get("source_name", "Effect"),
727
+ "value": (-reduction_val).tolist(),
728
+ "type": "req_mod",
729
+ "source_id": ce.get("source_id", -1),
730
+ }
731
+ )
732
+ req = np.maximum(0, req - reduction_val)
733
+
734
+ # Calculate Success (Greedy)
735
+ temp_hearts = total_hearts.copy()
736
+
737
+ # 1. Match specific colors
738
+ needed_specific = req[:6]
739
+ have_specific = temp_hearts[:6]
740
+ used_specific = np.minimum(needed_specific, have_specific)
741
+
742
+ temp_hearts[:6] -= used_specific
743
+ remaining_req = req.copy()
744
+ remaining_req[:6] -= used_specific
745
+
746
+ # 2. Match Any with remaining specific
747
+ needed_any = remaining_req[6]
748
+ have_any_from_specific = np.sum(temp_hearts[:6])
749
+ used_any_from_specific = min(needed_any, have_any_from_specific)
750
+
751
+ # 3. Match Any with Any
752
+ needed_any -= used_any_from_specific
753
+ have_wild = temp_hearts[6]
754
+ used_wild = min(needed_any, have_wild)
755
+
756
+ met = np.all(remaining_req[:6] == 0) and (needed_any - used_wild <= 0)
757
+
758
+ lives.append(
759
+ {
760
+ "name": live_card.name,
761
+ "img": live_card.img_path,
762
+ "score": int(live_card.score),
763
+ "req": req.tolist(),
764
+ "passed": bool(met),
765
+ "reason": "" if met else "Not met",
766
+ "base_score": int(live_card.score),
767
+ "bonus_score": self.live_score_bonus,
768
+ }
769
+ )
770
+
771
+ return {
772
+ "can_perform": True,
773
+ "total_blades": int(total_blades),
774
+ "total_hearts": total_hearts.tolist(),
775
+ "lives": lives,
776
+ "breakdown": {
777
+ "blades": blade_breakdown,
778
+ "hearts": heart_breakdown,
779
+ "requirements": req_breakdown,
780
+ "transforms": transform_log,
781
+ },
782
+ }
783
+
784
+ def get_member_cost(self, card_id: int, card_db: Dict[int, MemberCard]) -> int:
785
+ """
786
+ Calculate effective cost of a member card in hand.
787
+ """
788
+ from engine.game.state_utils import get_base_id
789
+
790
+ base_id = get_base_id(card_id)
791
+ if base_id not in card_db:
792
+ return 0
793
+
794
+ member = card_db[base_id]
795
+ cost = member.cost
796
+
797
+ # Apply global cost reduction effects
798
+ total_reduction = 0
799
+ for ce in self.continuous_effects:
800
+ if ce["effect"].effect_type == EffectType.REDUCE_COST:
801
+ total_reduction += ce["effect"].value
802
+
803
+ # Q129: Apply card's OWN constant abilities if they reduce cost in hand.
804
+ for ab in member.abilities:
805
+ if ab.trigger == TriggerType.CONSTANT:
806
+ for eff in ab.effects:
807
+ if eff.effect_type == EffectType.REDUCE_COST and eff.target == TargetType.SELF:
808
+ conditions_met = True
809
+ for cond in ab.conditions:
810
+ if not self._check_condition_for_constant(cond, slot_idx=-1, card_db=card_db):
811
+ conditions_met = False
812
+ break
813
+
814
+ if conditions_met:
815
+ val = eff.value
816
+ if eff.params.get("multiplier") and eff.params.get("per_hand_other"):
817
+ count = max(0, len(self.hand) - 1)
818
+ val *= count
819
+ total_reduction += val
820
+
821
+ return max(0, cost - total_reduction)
822
+
823
+ def to_dict(self, viewer_idx=0):
824
+ # We now have StateMixin.to_dict() but we might want this custom one for the UI.
825
+ # Actually, let's just use StateMixin.to_dict and enrich it if needed in serializer.py.
826
+ # This keeps PlayerState purely about state.
827
+ return super().to_dict()
engine/game/replay_manager.py ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ from typing import Any, Dict, List, Optional
3
+
4
+ from engine.game.game_state import GameState
5
+ from engine.game.serializer import serialize_state
6
+
7
+ try:
8
+ from engine.game.state_utils import create_uid
9
+ except ImportError:
10
+ # Fallback if state_utils was deleted (it shouldn't have been, but just in case)
11
+ # Reimplement create_uid if needed, or fix the file location
12
+ BASE_ID_MASK = 0xFFFFF
13
+ INSTANCE_SHIFT = 20
14
+
15
+ def create_uid(base_id: int, instance_index: int) -> int:
16
+ return (base_id & BASE_ID_MASK) | (instance_index << INSTANCE_SHIFT)
17
+
18
+
19
+ def optimize_history(
20
+ history: List[Dict[str, Any]],
21
+ member_db: Dict[int, Any],
22
+ live_db: Dict[int, Any],
23
+ energy_db: Dict[int, Any],
24
+ exclude_db_cards: bool = True,
25
+ seed: Optional[int] = None,
26
+ action_log: Optional[List[int]] = None,
27
+ deck_info: Optional[Dict[str, Any]] = None,
28
+ ) -> Dict[str, Any]:
29
+ """
30
+ Optimize replay history.
31
+ Args:
32
+ history: List of states
33
+ member_db: Database of member cards
34
+ live_db: Database of live cards
35
+ energy_db: Database of energy cards
36
+ exclude_db_cards: Use DB-backed optimization (Level 2)
37
+ seed: Random seed (Level 3)
38
+ action_log: List of action IDs (Level 3)
39
+ deck_info: Dict with 'p0_deck', 'p1_deck', etc. (Level 3)
40
+ """
41
+ # Level 3: Action-Based Replay (Max Compression)
42
+ if seed is not None and action_log is not None and deck_info is not None:
43
+ return {
44
+ "level": 3,
45
+ "seed": seed,
46
+ "decks": deck_info,
47
+ "action_log": action_log,
48
+ # We don't save 'states' or 'registry' at all!
49
+ }
50
+
51
+ # Level 2: State-Based DB-Backed
52
+ registry = {}
53
+
54
+ def extract_static_data(card_data):
55
+ """Extract static fields that don't change during gameplay."""
56
+ if not isinstance(card_data, dict):
57
+ return {}
58
+
59
+ # known static fields
60
+ static_fields = [
61
+ "name",
62
+ "card_no",
63
+ "type",
64
+ "cost",
65
+ "blade",
66
+ "img",
67
+ "hearts",
68
+ "blade_hearts",
69
+ "text",
70
+ "score",
71
+ "required_hearts",
72
+ ]
73
+
74
+ return {k: card_data[k] for k in static_fields if k in card_data}
75
+
76
+ def optimize_object(obj):
77
+ """recursively traverse and optimize payload."""
78
+ if isinstance(obj, list):
79
+ return [optimize_object(x) for x in obj]
80
+ elif isinstance(obj, dict):
81
+ # Check if this object looks like a serialized card
82
+ if "id" in obj and ("name" in obj or "type" in obj):
83
+ cid = obj["id"]
84
+ # If it's a known card (positive ID), register it
85
+ if isinstance(cid, int) and cid >= 0:
86
+ is_in_db = cid in member_db or cid in live_db or cid in energy_db
87
+
88
+ # Decide whether to add to registry
89
+ should_register = False
90
+ if not is_in_db:
91
+ should_register = True
92
+ elif not exclude_db_cards:
93
+ should_register = True
94
+
95
+ if should_register:
96
+ if cid not in registry:
97
+ registry[cid] = extract_static_data(obj)
98
+
99
+ # Return ONLY dynamic data + ID reference
100
+ dynamic_data = {"id": cid}
101
+ static_keys = registry[cid].keys()
102
+ for k, v in obj.items():
103
+ if k not in static_keys and k != "id":
104
+ dynamic_data[k] = optimize_object(v)
105
+ return dynamic_data
106
+
107
+ elif is_in_db:
108
+ # IT IS IN DB and we exclude it from registry
109
+ # We still strip static data, but we don't save it to file
110
+ # effectively assuming "registry[cid]" exists implicitly in DB
111
+
112
+ # We need to know which keys are static to strip them
113
+ # We can use a representative static extraction
114
+ static_keys = extract_static_data(obj).keys()
115
+
116
+ dynamic_data = {"id": cid}
117
+ for k, v in obj.items():
118
+ if k not in static_keys and k != "id":
119
+ dynamic_data[k] = optimize_object(v)
120
+ return dynamic_data
121
+
122
+ # Regular dict recursion
123
+ return {k: optimize_object(v) for k, v in obj.items()}
124
+ else:
125
+ return obj
126
+
127
+ optimized_states = optimize_object(history)
128
+
129
+ return {"registry": registry, "states": optimized_states}
130
+
131
+
132
+ def inflate_history(
133
+ optimized_data: Dict[str, Any],
134
+ member_db: Dict[int, Any],
135
+ live_db: Dict[int, Any],
136
+ energy_db: Dict[int, Any],
137
+ ) -> List[Dict[str, Any]]:
138
+ """
139
+ Reconstruct full history state from optimized data using server DB.
140
+ """
141
+ # Level 3 Inflation (Action Log -> State History)
142
+ if optimized_data.get("level") == 3 or "action_log" in optimized_data:
143
+ print("Inflating Level 3 Action Log replay...")
144
+ action_log = optimized_data.get("action_log", [])
145
+ seed = optimized_data.get("seed", 0)
146
+ deck_info = optimized_data.get("decks", {})
147
+
148
+ # 1. Reset Game with Seed
149
+ # Use local random instance to avoid messing with global random state if possible,
150
+ # but GameState uses random module globally.
151
+ # We must save and restore random state if we want to be clean, but python random is global.
152
+ # Ideally GameState should use a random instance.
153
+ # For now, we assume the caller handles global state implications or we just reset seed.
154
+
155
+ # NOTE: This modifies global random state!
156
+ random.seed(seed)
157
+
158
+ # 2. Init Game State (Headless)
159
+ GameState.member_db = member_db
160
+ GameState.live_db = live_db
161
+ # Energy DB is not static on GameState?
162
+ GameState.energy_db = energy_db # server.py sets this on instance or class?
163
+ # server.py says: GameState.energy_db = energy_db
164
+
165
+ # Create fresh state
166
+ temp_gs = GameState()
167
+ temp_gs.initialize_game()
168
+
169
+ # Set decks if available
170
+ if deck_info:
171
+ p0_deck = deck_info.get("p0_deck")
172
+ p1_deck = deck_info.get("p1_deck")
173
+
174
+ if p0_deck and len(p0_deck) > 0:
175
+ print(f"Loading custom deck for P0: {len(p0_deck)} cards")
176
+ p0 = temp_gs.players[0]
177
+ # Reset Deck & Hand
178
+ p0.main_deck = [int(x) for x in p0_deck]
179
+ p0.hand = []
180
+ p0.discard = []
181
+ # Draw initial hand (5 cards)
182
+ draw_count = min(5, len(p0.main_deck))
183
+ p0.hand = p0.main_deck[:draw_count]
184
+ p0.hand_added_turn = [1] * draw_count
185
+ p0.main_deck = p0.main_deck[draw_count:]
186
+
187
+ if p1_deck and len(p1_deck) > 0:
188
+ print(f"Loading custom deck for P1: {len(p1_deck)} cards")
189
+ p1 = temp_gs.players[1]
190
+ p1.main_deck = [int(x) for x in p1_deck]
191
+ p1.hand = []
192
+ p1.discard = []
193
+ draw_count = min(5, len(p1.main_deck))
194
+ p1.hand = p1.main_deck[:draw_count]
195
+ p1.hand_added_turn = [1] * draw_count
196
+ p1.main_deck = p1.main_deck[draw_count:]
197
+
198
+ reconstructed_history = []
199
+
200
+ # 3. Serialize Initial State
201
+ reconstructed_history.append(serialize_state(temp_gs))
202
+
203
+ # 4. Replay Actions
204
+ for action_id in action_log:
205
+ temp_gs.step(action_id)
206
+ reconstructed_history.append(serialize_state(temp_gs))
207
+
208
+ print(f"Reconstructed {len(reconstructed_history)} frames from {len(action_log)} actions.")
209
+ return reconstructed_history
210
+
211
+ # Level 2 Logic (State Inflation)
212
+ registry = optimized_data.get("registry", {})
213
+ states = optimized_data.get("states", [])
214
+
215
+ def get_static_data(cid):
216
+ """Get static data from Registry OR Database"""
217
+ # 1. Registry (Custom cards / Legacy format)
218
+ if str(cid) in registry:
219
+ return registry[str(cid)]
220
+ if cid in registry:
221
+ return registry[cid]
222
+
223
+ # 2. Database
224
+ if cid in member_db:
225
+ m = member_db[cid]
226
+ # Reconstruct dictionary from object
227
+ ability_text = getattr(m, "ability_text", "")
228
+ if hasattr(m, "abilities") and m.abilities:
229
+ # Use raw Japanese text
230
+ # Clean wiki markup: {{icon.png|Text}} -> Text, [[Link|Text]] -> Text
231
+ import re
232
+
233
+ def clean_text(text):
234
+ text = re.sub(r"\{\{.*?\|(.*?)\}\}", r"\1", text) # {{icon|Text}} -> Text
235
+ text = re.sub(r"\[\[.*?\|(.*?)\]\]", r"\1", text) # [[Link|Text]] -> Text
236
+ return text
237
+
238
+ ability_lines = [clean_text(ab.raw_text) for ab in m.abilities]
239
+ ability_text = "\n".join(ability_lines)
240
+
241
+ return {
242
+ "name": m.name,
243
+ "card_no": m.card_no,
244
+ "type": "member",
245
+ "cost": m.cost,
246
+ "blade": m.blades,
247
+ "img": m.img_path,
248
+ "hearts": m.hearts.tolist(),
249
+ "blade_hearts": m.blade_hearts.tolist(),
250
+ "text": ability_text,
251
+ "color": "Unknown",
252
+ }
253
+ elif cid in live_db:
254
+ l = live_db[cid]
255
+ ability_text = getattr(l, "ability_text", "")
256
+ if hasattr(l, "abilities") and l.abilities:
257
+ import re
258
+
259
+ def clean_text(text):
260
+ text = re.sub(r"\{\{.*?\|(.*?)\}\}", r"\1", text)
261
+ text = re.sub(r"\[\[.*?\|(.*?)\]\]", r"\1", text)
262
+ return text
263
+
264
+ ability_lines = [clean_text(ab.raw_text) for ab in l.abilities]
265
+ ability_text = "\n".join(ability_lines)
266
+
267
+ return {
268
+ "name": l.name,
269
+ "card_no": l.card_no,
270
+ "type": "live",
271
+ "score": l.score,
272
+ "img": l.img_path,
273
+ "required_hearts": l.required_hearts.tolist(),
274
+ "text": ability_text,
275
+ }
276
+ elif cid in energy_db:
277
+ # EnergyCard is simple (just ID), so we hardcode display info
278
+ return {"name": "Energy", "type": "energy", "img": "assets/energy_card.png"}
279
+
280
+ return None
281
+
282
+ def inflate_object(obj):
283
+ if isinstance(obj, list):
284
+ return [inflate_object(x) for x in obj]
285
+ elif isinstance(obj, dict):
286
+ # Check for ID reference to inflate
287
+ if "id" in obj:
288
+ cid = obj["id"]
289
+ static_data = get_static_data(cid)
290
+ if static_data:
291
+ # Merge static data into this object (dynamic overrides static if conflict, though shouldn't happen)
292
+ # We create a new dict to Avoid mutating the source if it's reused
293
+ new_obj = static_data.copy()
294
+ for k, v in obj.items():
295
+ new_obj[k] = inflate_object(v)
296
+ return new_obj
297
+
298
+ return {k: inflate_object(v) for k, v in obj.items()}
299
+ else:
300
+ return obj
301
+
302
+ return inflate_object(states)
engine/game/serializer.py ADDED
@@ -0,0 +1,689 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+
3
+ from engine.game.desc_utils import get_action_desc
4
+ from engine.game.state_utils import get_base_id
5
+ from engine.models.ability import EFFECT_DESCRIPTIONS, EffectType, TriggerType
6
+
7
+
8
+ def format_effect_description(effect):
9
+ # Logic copied/adapted from effect_mixin.py
10
+ template = EFFECT_DESCRIPTIONS.get(effect.effect_type, getattr(effect.effect_type, "name", str(effect.effect_type)))
11
+ context = effect.params.copy()
12
+ context["value"] = effect.value
13
+
14
+ # Custom overrides if needed (e.g. REDUCE_HEART_REQ)
15
+ if effect.effect_type == EffectType.REDUCE_HEART_REQ:
16
+ if effect.value < 0:
17
+ return f"Reduce Heart Requirement by {abs(effect.value)}"
18
+ else:
19
+ return f"Increase Heart Requirement by {effect.value}"
20
+
21
+ try:
22
+ desc = template.format(**context)
23
+ except KeyError:
24
+ desc = template
25
+
26
+ # Add contextual suffixes
27
+ if effect.params.get("per_energy"):
28
+ desc += " per Energy"
29
+ if effect.params.get("per_member"):
30
+ desc += " per Member"
31
+ if effect.params.get("per_live"):
32
+ desc += " per Live"
33
+
34
+ return desc
35
+
36
+
37
+ def get_card_modifiers(player, slot_idx, card_id, member_db, live_db):
38
+ modifiers = []
39
+
40
+ # 0. Player Level Restrictions (Virtual Modifiers)
41
+ if player.cannot_live:
42
+ modifiers.append({"description": "Cannot perform Live this turn", "source": "Game Rule", "expiry": "LIVE_END"})
43
+
44
+ # 1. Continuous Effects
45
+ for ce in player.continuous_effects:
46
+ target_slot = ce.get("target_slot", -1)
47
+ eff = ce["effect"]
48
+
49
+ # Determine relevance:
50
+ # - member on stage (slot_idx >= 0): matches target_slot or target_slot is -1
51
+ # - live card (slot_idx == -1): matches global effects or live-specific effects
52
+ is_relevant = False
53
+ if slot_idx >= 0:
54
+ if target_slot == -1 or target_slot == slot_idx:
55
+ is_relevant = True
56
+ else:
57
+ # For live cards/others, only show global or relevant types
58
+ if target_slot == -1 or eff.effect_type in (EffectType.REDUCE_HEART_REQ, EffectType.MODIFY_SCORE_RULE):
59
+ is_relevant = True
60
+
61
+ if is_relevant:
62
+ desc = format_effect_description(eff)
63
+
64
+ # Resolve Source
65
+ source_name = "Effect"
66
+ sid = ce.get("source_card_id")
67
+ if sid is not None:
68
+ base_sid = get_base_id(int(sid))
69
+ if base_sid in member_db:
70
+ source_name = member_db[base_sid].name
71
+ elif base_sid in live_db:
72
+ source_name = live_db[base_sid].name
73
+
74
+ modifiers.append({"description": desc, "source": source_name, "expiry": ce.get("expiry", "TURN_END")})
75
+
76
+ # 2. Intrinsic Constant Abilities
77
+ base_cid = get_base_id(int(card_id))
78
+ db = member_db if base_cid in member_db else live_db if base_cid in live_db else None
79
+
80
+ if db and base_cid in db:
81
+ card = db[base_cid]
82
+ if hasattr(card, "abilities"):
83
+ for ab in card.abilities:
84
+ if ab.trigger == TriggerType.CONSTANT:
85
+ # Check conditions
86
+ # Note: _check_condition_for_constant might not be available on card/db,
87
+ # but we assume the environment has it or fallback to True for display
88
+ # Serializer is often called without full GameState context for individual cards
89
+ # but here we have 'player'.
90
+ try:
91
+ if all(player._check_condition_for_constant(cond, slot_idx) for cond in ab.conditions):
92
+ for eff in ab.effects:
93
+ desc = format_effect_description(eff)
94
+ modifiers.append({"description": desc, "source": "Self", "expiry": "CONSTANT"})
95
+ except (AttributeError, TypeError):
96
+ # Fallback: display constant if it exists but condition check is impossible
97
+ for eff in ab.effects:
98
+ desc = format_effect_description(eff)
99
+ modifiers.append({"description": desc, "source": "Self", "expiry": "CONSTANT"})
100
+
101
+ return modifiers
102
+
103
+
104
+ def serialize_card(cid, member_db, live_db, energy_db, is_viewable=True, peek=False):
105
+ if not is_viewable and not peek:
106
+ return {"id": int(cid), "name": "???", "type": "unknown", "img": "cards/back.png", "hidden": True}
107
+
108
+ card_data = {}
109
+
110
+ # Try direct lookup, then fallback to base ID for unique IDs
111
+ base_cid = get_base_id(int(cid))
112
+
113
+ if base_cid in member_db:
114
+ m = member_db[base_cid]
115
+ ability_text = getattr(m, "ability_text", "")
116
+
117
+ # Only reconstruct if no rich text is available
118
+ if not ability_text and hasattr(m, "abilities") and m.abilities:
119
+ ability_lines = []
120
+ for ab in m.abilities:
121
+ trigger_icon = {
122
+ TriggerType.ACTIVATED: "【起動】",
123
+ TriggerType.ON_PLAY: "【登場】",
124
+ TriggerType.CONSTANT: "【常時】",
125
+ TriggerType.ON_LIVE_START: "【ライブ開始】",
126
+ TriggerType.ON_LIVE_SUCCESS: "【ライブ成功時】",
127
+ }.get(ab.trigger, "【自動】")
128
+ ability_lines.append(f"{trigger_icon} {ab.raw_text}")
129
+ ability_text = "\n".join(ability_lines)
130
+
131
+ card_data = {
132
+ "id": int(cid), # Keep original ID (UID) for frontend tracking
133
+ "card_no": m.card_no,
134
+ "name": m.name,
135
+ "type": "member",
136
+ "cost": m.cost,
137
+ "blade": m.blades,
138
+ "img": m.img_path,
139
+ "hearts": m.hearts.tolist() if hasattr(m.hearts, "tolist") else list(m.hearts),
140
+ "blade_hearts": m.blade_hearts.tolist() if hasattr(m.blade_hearts, "tolist") else list(m.blade_hearts),
141
+ "text": ability_text,
142
+ "original_text": getattr(m, "original_text", ""),
143
+ }
144
+ elif base_cid in live_db:
145
+ l = live_db[base_cid]
146
+ ability_text = getattr(l, "ability_text", "")
147
+
148
+ # Only reconstruct if no rich text is available
149
+ if not ability_text and hasattr(l, "abilities") and l.abilities:
150
+ ability_lines = []
151
+ for ab in l.abilities:
152
+ trigger_icon = {TriggerType.ON_LIVE_START: "【ライブ開始】"}.get(ab.trigger, "【自動】")
153
+ ability_lines.append(f"{trigger_icon} {ab.raw_text}")
154
+ ability_text = "\n".join(ability_lines)
155
+
156
+ card_data = {
157
+ "id": int(cid),
158
+ "card_no": l.card_no,
159
+ "name": l.name,
160
+ "type": "live",
161
+ "score": l.score,
162
+ "img": l.img_path,
163
+ "required_hearts": l.required_hearts.tolist()
164
+ if hasattr(l.required_hearts, "tolist")
165
+ else list(l.required_hearts),
166
+ "text": ability_text,
167
+ "original_text": getattr(l, "original_text", ""),
168
+ }
169
+ elif base_cid in energy_db:
170
+ e = energy_db[base_cid]
171
+ card_data = {
172
+ "id": int(cid),
173
+ "card_no": getattr(e, "card_no", "ENERGY"),
174
+ "name": getattr(e, "name", "Energy"),
175
+ "type": "energy",
176
+ "img": getattr(e, "img_path", "assets/energy_card.png"),
177
+ }
178
+ else:
179
+ return {"id": int(cid), "name": f"Card {cid}", "type": "unknown", "img": None}
180
+
181
+ if not is_viewable and peek:
182
+ card_data["hidden"] = True
183
+ card_data["face_down"] = True
184
+
185
+ return card_data
186
+
187
+
188
+ def serialize_player(p, game_state, player_idx, viewer_idx=0, is_viewable=True):
189
+ member_db = game_state.member_db
190
+ live_db = game_state.live_db
191
+ energy_db = getattr(game_state, "energy_db", {})
192
+ legal_mask = game_state.get_legal_actions()
193
+
194
+ expected_yells = 0
195
+ for i, card_id in enumerate(p.stage):
196
+ if card_id >= 0 and not p.tapped_members[i]:
197
+ base_cid = get_base_id(int(card_id))
198
+ if base_cid in member_db:
199
+ expected_yells += member_db[base_cid].blades
200
+
201
+ hand = []
202
+ for i, cid in enumerate(p.hand):
203
+ if is_viewable:
204
+ c = serialize_card(cid, member_db, live_db, energy_db)
205
+ is_new = False
206
+ if hasattr(p, "hand_added_turn") and i < len(p.hand_added_turn):
207
+ is_new = p.hand_added_turn[i] == game_state.turn_number
208
+ c["is_new"] = is_new
209
+
210
+ valid_actions = []
211
+ for area in range(3):
212
+ aid = 1 + i * 3 + area
213
+ if aid < len(legal_mask) and legal_mask[aid]:
214
+ valid_actions.append(aid)
215
+ for aid in [400 + i, 300 + i, 500 + i]:
216
+ if aid < len(legal_mask) and legal_mask[aid]:
217
+ valid_actions.append(aid)
218
+ c["valid_actions"] = valid_actions
219
+ hand.append(c)
220
+ else:
221
+ hand.append(serialize_card(cid, member_db, live_db, energy_db, is_viewable=False))
222
+
223
+ stage = []
224
+ for i, _ in enumerate(range(3)):
225
+ # Correctly access stage by index
226
+ cid = int(p.stage[i])
227
+ if cid >= 0:
228
+ c = serialize_card(cid, member_db, live_db, energy_db)
229
+ c["tapped"] = bool(p.tapped_members[i])
230
+ c["energy"] = int(p.stage_energy_count[i])
231
+ c["locked"] = bool(p.members_played_this_turn[i])
232
+
233
+ # Add modifiers
234
+ c["modifiers"] = get_card_modifiers(p, i, cid, member_db, live_db)
235
+
236
+ stage.append(c)
237
+ else:
238
+ stage.append(None)
239
+
240
+ discard = [serialize_card(cid, member_db, live_db, energy_db, is_viewable=True) for cid in p.discard]
241
+
242
+ energy = []
243
+ for i, cid in enumerate(p.energy_zone):
244
+ energy.append(
245
+ {
246
+ "id": i,
247
+ "tapped": bool(p.tapped_energy[i]),
248
+ "card": serialize_card(cid, member_db, live_db, energy_db, is_viewable=False),
249
+ }
250
+ )
251
+
252
+ # Calculate total hearts and blades for the player
253
+ total_blades = p.get_total_blades(member_db)
254
+ total_hearts = p.get_total_hearts(member_db) # Returns np.array(7)
255
+
256
+ # Track remaining hearts for live fulfillment calculation (Greedy allocation)
257
+ temp_hearts = total_hearts.copy()
258
+
259
+ live_zone = []
260
+ for i, cid in enumerate(p.live_zone):
261
+ is_revealed = bool(p.live_zone_revealed[i]) if i < len(p.live_zone_revealed) else False
262
+ card_obj = serialize_card(
263
+ cid, member_db, live_db, energy_db, is_viewable=is_revealed, peek=(player_idx == viewer_idx)
264
+ )
265
+
266
+ # Calculate heart progress for this live card
267
+ if cid in live_db:
268
+ l = live_db[cid]
269
+ req = l.required_hearts # np.array
270
+ filled = [0] * 7
271
+
272
+ if is_revealed or (player_idx == viewer_idx):
273
+ # Greedy fill logic matching PlayerState.get_performance_guide
274
+ # Colors 0-5
275
+ for c_idx in range(6):
276
+ have = temp_hearts[c_idx]
277
+ need = req[c_idx]
278
+ take = min(have, need)
279
+ filled[c_idx] = int(take)
280
+ temp_hearts[c_idx] -= take
281
+
282
+ # Any Color (Index 6)
283
+ req_any = req[6] if len(req) > 6 else 0
284
+ remaining_total = sum(temp_hearts[:6]) + temp_hearts[6]
285
+ take_any = min(remaining_total, req_any)
286
+ filled[6] = int(take_any)
287
+ # Note: We don't subtract from temp_hearts for 'any' because strict color matching is done,
288
+ # and 'any' sucks from the pool of remaining.
289
+ # But to be strictly correct for *subsequent* cards (if we supported multiple approvals at once),
290
+ # we should decrement. But the game usually checks one at a time or order matters.
291
+ # Use the logic from get_performance_guide:
292
+ # It doesn't actually decrement 'any' from specific colors in temp_hearts
293
+ # because 'any' is a wildcard check on the *sum*.
294
+ # Wait, get_performance_guide does:
295
+ # remaining_total = np.sum(temp_hearts[:6]) + temp_hearts[6]
296
+
297
+ card_obj["required_hearts"] = req.tolist()
298
+ card_obj["filled_hearts"] = filled
299
+
300
+ # Determine passed status
301
+ is_passed = True
302
+ for c_idx in range(6):
303
+ if filled[c_idx] < req[c_idx]:
304
+ is_passed = False
305
+ if filled[6] < req[6]:
306
+ is_passed = False
307
+
308
+ card_obj["is_cleared"] = is_passed
309
+
310
+ # Add modifiers for live card (e.g. requirement reduction)
311
+ card_obj["modifiers"] = get_card_modifiers(p, -1, cid, member_db, live_db)
312
+
313
+ live_zone.append(card_obj)
314
+
315
+ score = sum(live_db[cid].score for cid in p.success_lives if cid in live_db)
316
+
317
+ return {
318
+ "player_id": p.player_id,
319
+ "score": score,
320
+ "is_active": (game_state.current_player == player_idx),
321
+ "hand": hand,
322
+ "hand_count": len(p.hand),
323
+ "mulligan_selection": list(p.mulligan_selection) if is_viewable else [],
324
+ "deck_count": len(p.main_deck),
325
+ "energy_deck_count": len(p.energy_deck),
326
+ "discard": discard,
327
+ "discard_count": len(p.discard),
328
+ "energy": energy,
329
+ "energy_count": len(p.energy_zone),
330
+ "energy_untapped": int(p.count_untapped_energy()),
331
+ "live_zone": live_zone,
332
+ "live_zone_count": len(p.live_zone),
333
+ "stage": stage,
334
+ "success_lives": [serialize_card(cid, member_db, live_db, energy_db, is_viewable) for cid in p.success_lives],
335
+ "restrictions": list(p.restrictions),
336
+ "expected_yells": expected_yells,
337
+ "total_hearts": total_hearts.tolist(),
338
+ "total_blades": int(total_blades),
339
+ "hand_costs": [p.get_member_cost(cid, member_db) if cid in member_db else 0 for cid in p.hand],
340
+ "active_effects": [
341
+ {
342
+ "description": format_effect_description(ce["effect"]),
343
+ "source": (
344
+ member_db[get_base_id(int(ce["source_card_id"]))].name
345
+ if ce.get("source_card_id") is not None and get_base_id(int(ce["source_card_id"])) in member_db
346
+ else live_db[get_base_id(int(ce["source_card_id"]))].name
347
+ if ce.get("source_card_id") is not None and get_base_id(int(ce["source_card_id"])) in live_db
348
+ else "Effect"
349
+ ),
350
+ "expiry": ce.get("expiry", "TURN_END"),
351
+ "source_card_id": int(ce.get("source_card_id", -1)) if ce.get("source_card_id") is not None else -1,
352
+ }
353
+ for ce in p.continuous_effects
354
+ ],
355
+ }
356
+
357
+
358
+ def serialize_state(gs, viewer_idx=0, is_pvp=False, mode="pve"):
359
+ active_idx = gs.current_player
360
+ legal_mask = gs.get_legal_actions()
361
+ legal_actions = []
362
+ p = gs.active_player
363
+ member_db = gs.member_db
364
+ live_db = gs.live_db
365
+ energy_db = getattr(gs, "energy_db", {})
366
+
367
+ # Only populate legal actions if it is the viewer's turn, or if in PvP/Hotseat mode (show all)
368
+ show_actions = is_pvp or (viewer_idx == active_idx)
369
+
370
+ if show_actions:
371
+ for i, v in enumerate(legal_mask):
372
+ if v:
373
+ desc = get_action_desc(i, gs)
374
+ meta = {"id": i, "desc": desc, "name": desc, "description": desc}
375
+
376
+ # Enrich with metadata for UI
377
+ if 1 <= i <= 180:
378
+ meta["type"] = "PLAY"
379
+ meta["hand_idx"] = (i - 1) // 3
380
+ meta["area_idx"] = (i - 1) % 3
381
+ if meta["hand_idx"] < len(p.hand):
382
+ cid = p.hand[meta["hand_idx"]]
383
+ c = serialize_card(cid, member_db, live_db, energy_db)
384
+ hand_cost = p.get_member_cost(cid, member_db)
385
+ # Baton Touch Reduction (Rule 12)
386
+ net_cost = hand_cost
387
+ if p.stage[meta["area_idx"]] >= 0:
388
+ old_cid = get_base_id(int(p.stage[meta["area_idx"]]))
389
+ if old_cid in member_db:
390
+ net_cost = max(0, hand_cost - member_db[old_cid].cost)
391
+
392
+ meta.update(
393
+ {
394
+ "img": c["img"],
395
+ "name": c["name"],
396
+ "cost": int(net_cost),
397
+ "base_cost": int(hand_cost),
398
+ "card_no": c.get("card_no", "???"),
399
+ "text": c.get("text", ""),
400
+ }
401
+ )
402
+ if cid in member_db:
403
+ meta["triggers"] = [ab.trigger for ab in member_db[cid].abilities]
404
+ elif 200 <= i <= 202:
405
+ meta["type"] = "ABILITY"
406
+ meta["area_idx"] = i - 200
407
+ cid = p.stage[meta["area_idx"]]
408
+ if cid >= 0:
409
+ c = serialize_card(cid, member_db, live_db, energy_db)
410
+ meta.update(
411
+ {"img": c["img"], "name": c["name"], "text": c.get("text", ""), "source_card_id": int(cid)}
412
+ )
413
+ elif 300 <= i <= 359:
414
+ meta["type"] = "MULLIGAN"
415
+ meta["hand_idx"] = i - 300
416
+ elif 400 <= i <= 459:
417
+ meta["type"] = "LIVE_SET"
418
+ meta["hand_idx"] = i - 400
419
+ elif 500 <= i <= 559:
420
+ meta["type"] = "SELECT_HAND"
421
+ meta["hand_idx"] = i - 500
422
+ target_p_idx = active_idx
423
+ if gs.pending_choices:
424
+ target_p_idx = gs.pending_choices[0][1].get("player_id", active_idx)
425
+ meta["player_id"] = target_p_idx
426
+ target_p = gs.players[target_p_idx]
427
+ if meta["hand_idx"] < len(target_p.hand):
428
+ cid = target_p.hand[meta["hand_idx"]]
429
+ c = serialize_card(cid, member_db, live_db, energy_db)
430
+ meta.update(
431
+ {"img": c["img"], "name": c["name"], "text": c.get("text", ""), "source_card_id": int(cid)}
432
+ )
433
+ elif 560 <= i <= 562:
434
+ meta["type"] = "SELECT_STAGE"
435
+ meta["area_idx"] = i - 560
436
+ target_p_idx = active_idx
437
+ if gs.pending_choices:
438
+ target_p_idx = gs.pending_choices[0][1].get("player_id", active_idx)
439
+ meta["player_id"] = target_p_idx
440
+ target_p = gs.players[target_p_idx]
441
+ cid = target_p.stage[meta["area_idx"]]
442
+ if cid >= 0:
443
+ c = serialize_card(cid, member_db, live_db, energy_db)
444
+ meta.update(
445
+ {"img": c["img"], "name": c["name"], "text": c.get("text", ""), "source_card_id": int(cid)}
446
+ )
447
+ elif 590 <= i <= 599:
448
+ meta["type"] = "ABILITY_TRIGGER"
449
+ meta["index"] = i - 590
450
+ elif 600 <= i <= 659:
451
+ meta["type"] = "SELECT"
452
+ meta["index"] = i - 600
453
+ if gs.pending_choices:
454
+ ctype, cparams = gs.pending_choices[0]
455
+ if ctype == "TARGET_OPPONENT_MEMBER":
456
+ opp = gs.inactive_player
457
+ meta["player_id"] = opp.player_id
458
+ if meta["index"] < 3:
459
+ cid = opp.stage[meta["index"]]
460
+ if cid >= 0:
461
+ c = serialize_card(cid, member_db, live_db, energy_db)
462
+ meta.update(
463
+ {
464
+ "img": c["img"],
465
+ "name": c["name"],
466
+ "text": c.get("text", ""),
467
+ "source_card_id": int(cid),
468
+ }
469
+ )
470
+ elif ctype == "SELECT_FROM_LIST" or ctype == "SELECT_SUCCESS_LIVE":
471
+ cards = cparams.get("cards", [])
472
+ if meta["index"] < len(cards):
473
+ cid = cards[meta["index"]]
474
+ c = serialize_card(cid, member_db, live_db, energy_db)
475
+ meta.update(
476
+ {
477
+ "img": c["img"],
478
+ "name": c["name"],
479
+ "text": c.get("text", ""),
480
+ "source_card_id": int(cid),
481
+ }
482
+ )
483
+ elif 660 <= i <= 719:
484
+ meta["type"] = "SELECT_DISCARD"
485
+ meta["index"] = i - 660
486
+ if gs.pending_choices:
487
+ ctype, cparams = gs.pending_choices[0]
488
+ if ctype == "SELECT_FROM_DISCARD":
489
+ cards = cparams.get("cards", [])
490
+ if meta["index"] < len(cards):
491
+ cid = cards[meta["index"]]
492
+ c = serialize_card(cid, member_db, live_db, energy_db)
493
+ meta.update(
494
+ {
495
+ "img": c["img"],
496
+ "name": c["name"],
497
+ "text": c.get("text", ""),
498
+ "source_card_id": int(cid),
499
+ }
500
+ )
501
+
502
+ elif 570 <= i <= 579:
503
+ meta["type"] = "SELECT_MODE"
504
+ meta["index"] = i - 570
505
+ if gs.pending_choices:
506
+ ctype, cparams = gs.pending_choices[0]
507
+ if ctype == "MODAL" or ctype == "SELECT_MODE":
508
+ options = cparams.get("options", [])
509
+ if meta["index"] < len(options):
510
+ opt = options[meta["index"]]
511
+ # Option can be string or list/dict
512
+ desc = str(opt)
513
+ if isinstance(opt, (list, tuple)) and len(opt) > 0:
514
+ desc = str(opt[0]) # Crude fallback
515
+ meta["text"] = desc
516
+ meta["name"] = desc
517
+ elif 580 <= i <= 589:
518
+ meta["type"] = "COLOR_SELECT"
519
+ meta["index"] = i - 580
520
+ colors = ["Pink", "Red", "Yellow", "Green", "Blue", "Purple", "All", "None"]
521
+ if meta["index"] < len(colors):
522
+ meta["color"] = colors[meta["index"]]
523
+ meta["name"] = colors[meta["index"]]
524
+ elif 720 <= i <= 759:
525
+ meta["type"] = "SELECT_FORMATION"
526
+ meta["index"] = i - 720
527
+ if gs.pending_choices:
528
+ ctype, cparams = gs.pending_choices[0]
529
+ cards = cparams.get("cards", cparams.get("available_members", []))
530
+ if meta["index"] < len(cards):
531
+ cid = cards[meta["index"]]
532
+ c = serialize_card(cid, member_db, live_db, energy_db)
533
+ meta.update(
534
+ {
535
+ "img": c["img"],
536
+ "name": c["name"],
537
+ "text": c.get("text", ""),
538
+ "source_card_id": int(cid),
539
+ }
540
+ )
541
+ elif 760 <= i <= 819:
542
+ meta["type"] = "SELECT_SUCCESS_LIVE"
543
+ meta["index"] = i - 760
544
+ # Usually points to p.success_lives
545
+ target_p_idx = active_idx
546
+ if gs.pending_choices:
547
+ ctype, cparams = gs.pending_choices[0]
548
+ target_p_idx = cparams.get("player_id", active_idx)
549
+ target_p = gs.players[target_p_idx]
550
+
551
+ # If specific cards list provided in params, use that
552
+ cards = []
553
+ if gs.pending_choices:
554
+ _, cparams = gs.pending_choices[0]
555
+ cards = cparams.get("cards", [])
556
+
557
+ if not cards:
558
+ cards = target_p.success_lives
559
+
560
+ if meta["index"] < len(cards):
561
+ cid = cards[meta["index"]]
562
+ c = serialize_card(cid, member_db, live_db, energy_db)
563
+ meta.update(
564
+ {
565
+ "img": c["img"],
566
+ "name": c["name"],
567
+ "text": c.get("text", ""),
568
+ "source_card_id": int(cid),
569
+ }
570
+ )
571
+ elif 820 <= i <= 829:
572
+ meta["type"] = "TARGET_LIVE"
573
+ meta["index"] = i - 820
574
+ target_p_idx = active_idx
575
+ if gs.pending_choices:
576
+ _, cparams = gs.pending_choices[0]
577
+ target_p_idx = cparams.get("player_id", active_idx)
578
+ target_p = gs.players[target_p_idx]
579
+ if meta["index"] < len(target_p.live_zone):
580
+ cid = target_p.live_zone[meta["index"]]
581
+ c = serialize_card(cid, member_db, live_db, energy_db)
582
+ meta.update(
583
+ {
584
+ "img": c["img"],
585
+ "name": c["name"],
586
+ "text": c.get("text", ""),
587
+ "source_card_id": int(cid),
588
+ }
589
+ )
590
+ elif 830 <= i <= 849:
591
+ meta["type"] = "TARGET_ENERGY"
592
+ meta["index"] = i - 830
593
+ target_p_idx = active_idx
594
+ if gs.pending_choices:
595
+ _, cparams = gs.pending_choices[0]
596
+ target_p_idx = cparams.get("player_id", active_idx)
597
+ target_p = gs.players[target_p_idx]
598
+ if meta["index"] < len(target_p.energy_zone):
599
+ cid = target_p.energy_zone[meta["index"]]
600
+ c = serialize_card(cid, member_db, live_db, energy_db)
601
+ meta.update(
602
+ {
603
+ "img": c["img"],
604
+ "name": c["name"],
605
+ "text": c.get("text", ""),
606
+ "source_card_id": int(cid),
607
+ }
608
+ )
609
+ elif 850 <= i <= 909:
610
+ meta["type"] = "TARGET_REMOVED"
611
+ meta["index"] = i - 850
612
+ # Assuming removed_cards is on GameState
613
+ removed = getattr(gs, "removed_cards", [])
614
+ if meta["index"] < len(removed):
615
+ cid = removed[meta["index"]]
616
+ c = serialize_card(cid, member_db, live_db, energy_db)
617
+ meta.update(
618
+ {
619
+ "img": c["img"],
620
+ "name": c["name"],
621
+ "text": c.get("text", ""),
622
+ "source_card_id": int(cid),
623
+ }
624
+ )
625
+
626
+ legal_actions.append(meta)
627
+
628
+ pending_choice = None
629
+ if gs.pending_choices:
630
+ choice_type, params_raw = gs.pending_choices[0]
631
+ try:
632
+ params = json.loads(params_raw) if isinstance(params_raw, str) else params_raw
633
+ except:
634
+ params = {}
635
+
636
+ # Resolve Source Card Details
637
+ source_name = params.get("source_member", "Unknown")
638
+ source_img = None
639
+ source_id = params.get("source_card_id")
640
+
641
+ if source_id is not None:
642
+ if source_id in member_db:
643
+ m = member_db[source_id]
644
+ source_name = m.name
645
+ source_img = m.img_path
646
+ elif source_id in live_db:
647
+ l = live_db[source_id]
648
+ source_name = l.name
649
+ source_img = l.img_path
650
+
651
+ pending_choice = {
652
+ "type": choice_type,
653
+ "description": params.get("effect_description", ""),
654
+ "source_ability": params.get("source_ability", ""),
655
+ "source_member": source_name,
656
+ "source_img": source_img,
657
+ "source_card_id": int(source_id) if source_id is not None else -1,
658
+ "is_optional": params.get("is_optional", False),
659
+ "params": params,
660
+ }
661
+
662
+ # FINAL CHECK: Correctly indented return statement
663
+ return {
664
+ "turn": gs.turn_number,
665
+ "phase": int(gs.phase),
666
+ "active_player": int(active_idx),
667
+ "game_over": gs.game_over,
668
+ "winner": gs.winner,
669
+ "mode": mode,
670
+ "is_pvp": is_pvp,
671
+ "players": [
672
+ serialize_player(
673
+ gs.players[0], gs, player_idx=0, viewer_idx=viewer_idx, is_viewable=(viewer_idx == 0 or is_pvp)
674
+ ),
675
+ serialize_player(
676
+ gs.players[1], gs, player_idx=1, viewer_idx=viewer_idx, is_viewable=(viewer_idx == 1 or is_pvp)
677
+ ),
678
+ ],
679
+ "legal_actions": legal_actions,
680
+ "pending_choice": pending_choice,
681
+ "rule_log": gs.rule_log[-200:], # Truncate log for transport
682
+ "performance_results": getattr(gs, "performance_results", {}),
683
+ "last_performance_results": getattr(gs, "last_performance_results", {}),
684
+ "performance_history": getattr(gs, "performance_history", []),
685
+ "looked_cards": [
686
+ serialize_card(cid, member_db, live_db, energy_db)
687
+ for cid in getattr(gs.get_player(active_idx), "looked_cards", [])
688
+ ],
689
+ }
engine/game/state_utils.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Any, Dict, Iterator
3
+
4
+ try:
5
+ pass
6
+ except:
7
+ pass
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class StateMixin:
13
+ """Base class for checking equality of state objects."""
14
+
15
+ def __eq__(self, other):
16
+ return isinstance(self, type(other)) and self.__dict__ == other.__dict__
17
+
18
+ def copy_slots_to(self, target: "StateMixin") -> None:
19
+ """Copy all fields defined in __slots__ to the target object."""
20
+ if not hasattr(self, "__slots__"):
21
+ return
22
+
23
+ for name in self.__slots__:
24
+ if hasattr(self, name):
25
+ val = getattr(self, name)
26
+ # Handle mutable types that need explicit copying
27
+ if isinstance(val, list):
28
+ setattr(target, name, val[:])
29
+ elif isinstance(val, set):
30
+ setattr(target, name, set(val))
31
+ elif isinstance(val, dict):
32
+ setattr(target, name, dict(val))
33
+ elif hasattr(val, "copy"):
34
+ setattr(target, name, val.copy())
35
+ else:
36
+ setattr(target, name, val)
37
+
38
+
39
+ # ----------------------------------------------------------------------------
40
+ # Unique ID (UID) System
41
+ # ----------------------------------------------------------------------------
42
+ # Masks: 20 bits for Base ID, 12 bits for Instance
43
+ BASE_ID_MASK = 0xFFFFF
44
+ INSTANCE_SHIFT = 20
45
+
46
+
47
+ def get_base_id(uid: int) -> int:
48
+ """Extract the base card definition ID from a potentially combined UID."""
49
+ return uid & BASE_ID_MASK
50
+
51
+
52
+ def get_instance_index(uid: int) -> int:
53
+ """Extract the instance index from a UID."""
54
+ return uid >> INSTANCE_SHIFT
55
+
56
+
57
+ def create_uid(base_id: int, instance_index: int) -> int:
58
+ """Create a unique instance ID from a base ID and an index."""
59
+ # Safety check: base_id must fit in 20 bits
60
+ if base_id > BASE_ID_MASK:
61
+ logger.warning(f"Base ID {base_id} exceeds mask {BASE_ID_MASK}!")
62
+ return (base_id & BASE_ID_MASK) | (instance_index << INSTANCE_SHIFT)
63
+
64
+
65
+ class MaskedDB(dict):
66
+ """
67
+ A dictionary wrapper that automatically masks unique instance IDs (UIDs)
68
+ to retrieve data associated with the base ID.
69
+
70
+ This allows the game state to essentially 'ignore' the instance part of an ID
71
+ when looking up static card data.
72
+ """
73
+
74
+ def __init__(self, data: Dict[int, Any]):
75
+ super().__init__(data)
76
+ self._data = data
77
+
78
+ def __getitem__(self, key: int) -> Any:
79
+ # Resolve UID to base ID
80
+ try:
81
+ base_id = int(key) & BASE_ID_MASK
82
+ if base_id in self._data:
83
+ return self._data[base_id]
84
+ # Fallback to string key for JSON-loaded data
85
+ return self._data[str(base_id)]
86
+ except (ValueError, TypeError):
87
+ raise KeyError(key)
88
+
89
+ def __contains__(self, key: object) -> bool:
90
+ try:
91
+ base_id = int(key) & BASE_ID_MASK
92
+ return base_id in self._data or str(base_id) in self._data
93
+ except (ValueError, TypeError):
94
+ return False
95
+
96
+ def get(self, key: int, default: Any = None) -> Any:
97
+ try:
98
+ if key is None:
99
+ return default
100
+ base_id = int(key) & BASE_ID_MASK
101
+ if base_id in self._data:
102
+ return self._data[base_id]
103
+ # Fallback to string key
104
+ return self._data.get(str(base_id), default)
105
+ except (ValueError, TypeError):
106
+ return default
107
+
108
+ def __len__(self) -> int:
109
+ return len(self._data)
110
+
111
+ def __iter__(self) -> Iterator[int]:
112
+ # Iterate over ORIGINAL base IDs (keys of _data)
113
+ return iter(self._data)
114
+
115
+ def keys(self):
116
+ return self._data.keys()
117
+
118
+ def values(self):
119
+ return self._data.values()
120
+
121
+ def items(self):
122
+ return self._data.items()
engine/lovecasim_engine.pyd ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4abb957604898cfdd25c8f289f9be3e4c05c841297f221046b4176144b182739
3
+ size 20523520
engine/models/__pycache__/ability.cpython-312.pyc ADDED
Binary file (52.8 kB). View file
 
engine/models/__pycache__/card.cpython-312.pyc ADDED
Binary file (6.07 kB). View file
 
engine/models/__pycache__/context_indices.cpython-312.pyc ADDED
Binary file (1.28 kB). View file
 
engine/models/__pycache__/enums.cpython-312.pyc ADDED
Binary file (6.87 kB). View file
 
engine/models/__pycache__/opcodes.cpython-312.pyc ADDED
Binary file (4.05 kB). View file
 
engine/models/ability.py ADDED
@@ -0,0 +1,1128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass, field
2
+ from enum import IntEnum
3
+ from typing import Any, Dict, List
4
+
5
+ from engine.models.opcodes import Opcode
6
+
7
+
8
+ class TriggerType(IntEnum):
9
+ NONE = 0
10
+ ON_PLAY = 1 # 登場時
11
+ ON_LIVE_START = 2 # ライブ開始時
12
+ ON_LIVE_SUCCESS = 3 # ライブ成功時
13
+ TURN_START = 4
14
+ TURN_END = 5
15
+ CONSTANT = 6 # 常時
16
+ ACTIVATED = 7 # 起動
17
+ ON_LEAVES = 8 # 自動 - when member leaves stage/is discarded
18
+ ON_REVEAL = 9 # エールにより公開、公開されたとき
19
+ ON_POSITION_CHANGE = 10 # エリアを移動するたび
20
+ ON_ACTIVATE = 7 # Alias for ACTIVATED
21
+
22
+
23
+ class TargetType(IntEnum):
24
+ SELF = 0
25
+ PLAYER = 1
26
+ OPPONENT = 2
27
+ ALL_PLAYERS = 3
28
+ MEMBER_SELF = 4
29
+ MEMBER_OTHER = 5
30
+ CARD_HAND = 6
31
+ CARD_DISCARD = 7
32
+ CARD_DECK_TOP = 8
33
+ OPPONENT_HAND = 9 # 相手の手札
34
+ MEMBER_SELECT = 10 # Select manual target
35
+ MEMBER_NAMED = 11 # Specific named member implementation
36
+ OPPONENT_MEMBER = 12 # Specific opponent member target
37
+ PLAYER_SELECT = 13 # 自分か相手を選ぶ
38
+
39
+
40
+ class EffectType(IntEnum):
41
+ DRAW = 0
42
+ ADD_BLADES = 1
43
+ ADD_HEARTS = 2
44
+ REDUCE_COST = 3
45
+ LOOK_DECK = 4
46
+ RECOVER_LIVE = 5 # Recover Live from discard
47
+ BOOST_SCORE = 6
48
+ RECOVER_MEMBER = 7 # Recover Member from discard
49
+ BUFF_POWER = 8 # Generic power/heart buff
50
+ IMMUNITY = 9 # Cannot be targeted/chosen
51
+ MOVE_MEMBER = 10 # Move member to different area
52
+ SWAP_CARDS = 11 # Swap cards between zones
53
+ SEARCH_DECK = 12 # Search deck for specific card
54
+ ENERGY_CHARGE = 13 # Add cards to energy zone
55
+ SET_BLADES = 31 # Layer 4: Set blades to fixed value
56
+ SET_HEARTS = 32 # Layer 4: Set hearts to fixed value
57
+ FORMATION_CHANGE = 33 # Rule 11.10: Rearrange all members
58
+ NEGATE_EFFECT = 14 # Cancel/negate an effect
59
+ ORDER_DECK = 15 # Reorder cards in deck
60
+ META_RULE = 16 # Rule clarification text (no effect)
61
+ SELECT_MODE = 17 # Choose one of the following effects
62
+ MOVE_TO_DECK = 18 # Move card to top/bottom of deck
63
+ TAP_OPPONENT = 19 # Tap opponent's member
64
+ PLACE_UNDER = 20 # Place card under member
65
+ FLAVOR_ACTION = 99 # "Ask opponent what they like", etc.
66
+ RESTRICTION = 21 # Restriction on actions (Cannot Live, etc)
67
+ BATON_TOUCH_MOD = 22 # Modify baton touch rules (e.g. 2 members)
68
+ SET_SCORE = 23 # Set score to fixed value
69
+ SWAP_ZONE = 24 # Swap between zones (e.g. Hand <-> Live)
70
+ TRANSFORM_COLOR = 25 # Change all colors of type X to Y
71
+ REVEAL_CARDS = 26 # 公開 - reveal cards from zone
72
+ LOOK_AND_CHOOSE = 27 # 見る、その中から - look at cards, choose from them
73
+ CHEER_REVEAL = 28 # エールにより公開 - cards revealed via cheer mechanic
74
+ ACTIVATE_MEMBER = 29 # アクティブにする - untap/make active a member
75
+ ADD_TO_HAND = 30 # 手札に加える - add card to hand (from any zone)
76
+ COLOR_SELECT = 37 # Specify a heart color
77
+ REPLACE_EFFECT = 34 # Replacement effect (代わりに)
78
+ TRIGGER_REMOTE = 35 # Trigger ability from another zone (Cluster 5)
79
+ REDUCE_HEART_REQ = 36 # Need hearts reduced
80
+ MODIFY_SCORE_RULE = 38 # Modify how score is calculated (e.g. +1 per yell score)
81
+ PLAY_MEMBER_FROM_HAND = 39 # Play member from hand (e.g. Keke)
82
+ TAP_MEMBER = 40 # Tap a member (usually self or other on stage)
83
+ MOVE_TO_DISCARD = 41 # 控え室に置く
84
+ # --- Added to fix META_RULE fallback ---
85
+ GRANT_ABILITY = 42 # Grant an ability to a member
86
+ INCREASE_HEART_COST = 43 # Increase heart requirement
87
+ REDUCE_YELL_COUNT = 44 # Reduce yell count
88
+ PLAY_MEMBER_FROM_DISCARD = 45 # Play member from discard
89
+ PAY_ENERGY = 46 # Pay/tap energy as cost
90
+ SELECT_MEMBER = 47 # Select target member
91
+ DRAW_UNTIL = 48 # Draw until hand size = X
92
+ SELECT_PLAYER = 49 # Select target player
93
+ SELECT_LIVE = 50 # Select target live
94
+ REVEAL_UNTIL = 51 # Reveal until condition met
95
+ INCREASE_COST = 52 # Increase cost (e.g., play cost)
96
+ PREVENT_PLAY_TO_SLOT = 53 # Prevent play to specific slot
97
+ SWAP_AREA = 54 # Swap members between areas
98
+ TRANSFORM_HEART = 55 # Transform heart color/count
99
+ SELECT_CARDS = 56 # Select specific cards
100
+ OPPONENT_CHOOSE = 57 # Opponent must make a choice
101
+ PLAY_LIVE_FROM_DISCARD = 58 # Play Live card from discard
102
+ REDUCE_LIVE_SET_LIMIT = 59 # Modify limit of live cards set per turn
103
+ ACTIVATE_ENERGY = 81 # エネルギーをアクティブにする
104
+ PREVENT_ACTIVATE = 72 # Prevent activating abilities/effects
105
+
106
+
107
+ class ConditionType(IntEnum):
108
+ NONE = 0
109
+ TURN_1 = 1 # Turn == 1
110
+ HAS_MEMBER = 2 # Specific member on stage
111
+ HAS_COLOR = 3 # Specific color on stage
112
+ COUNT_STAGE = 4 # Count members >= X
113
+ COUNT_HAND = 5
114
+ COUNT_DISCARD = 6
115
+ IS_CENTER = 7
116
+ LIFE_LEAD = 8
117
+ COUNT_GROUP = 9 # "3+ Aqours members"
118
+ GROUP_FILTER = 10 # Filter by group name
119
+ OPPONENT_HAS = 11 # Opponent has X
120
+ SELF_IS_GROUP = 12 # This card is from group X
121
+ MODAL_ANSWER = 13 # Choice/Answer branch (e.g. LL-PR-004-PR)
122
+ COUNT_ENERGY = 14 # エネルギーがX枚以上
123
+ HAS_LIVE_CARD = 15 # ライブカードがある場合
124
+ COST_CHECK = 16 # コストがX以下/以上
125
+ RARITY_CHECK = 17 # Rarity filter
126
+ HAND_HAS_NO_LIVE = 18 # Hand contains no live cards (usually paired with reveal cost)
127
+ COUNT_SUCCESS_LIVE = 19 # 成功ライブカード置き場にX枚以上
128
+ OPPONENT_HAND_DIFF = 20 # Opponent has more/less/diff cards in hand
129
+ SCORE_COMPARE = 21 # Compare scores (e.g. higher than opponent)
130
+ HAS_CHOICE = 22 # Ability involves a choice
131
+ OPPONENT_CHOICE = 23 # Opponent makes a choice (相手は~選ぶ)
132
+ COUNT_HEARTS = 24 # Heart count condition (ハートがX個以上)
133
+ COUNT_BLADES = 25 # Blade count condition (ブレードがX以上)
134
+ OPPONENT_ENERGY_DIFF = 26 # Opponent energy comparison
135
+ HAS_KEYWORD = 27 # Has specific keyword/property (e.g. Blade Heart)
136
+ DECK_REFRESHED = 28 # Deck was refreshed (reshuffled) this turn
137
+ HAS_MOVED = 29 # Member has moved this turn
138
+ HAND_INCREASED = 30 # Hand size increased by X cards this turn
139
+ COUNT_LIVE_ZONE = 31 # Number of cards in live zone
140
+ BATON = 32 # Baton Pass check
141
+ TYPE_CHECK = 33 # Card type check (member/live)
142
+ IS_IN_DISCARD = 34 # Check if card is in discard pile
143
+
144
+
145
+ # --- DESCRIPTIONS ---
146
+
147
+ EFFECT_DESCRIPTIONS = {
148
+ EffectType.DRAW: "Draw {value} card(s)",
149
+ EffectType.LOOK_DECK: "Look at top {value} card(s) of deck",
150
+ EffectType.ADD_BLADES: "Gain {value} Blade(s)",
151
+ EffectType.ADD_HEARTS: "Gain {value} Heart(s)",
152
+ EffectType.REDUCE_COST: "Reduce cost by {value}",
153
+ EffectType.BOOST_SCORE: "Boost live score by {value}",
154
+ EffectType.RECOVER_LIVE: "Recover {value} Live card(s) from discard",
155
+ EffectType.RECOVER_MEMBER: "Recover {value} Member card(s) from discard",
156
+ EffectType.BUFF_POWER: "Power Up {value} (Blade/Heart)",
157
+ EffectType.IMMUNITY: "Gain Immunity",
158
+ EffectType.MOVE_MEMBER: "Move Member to another zone",
159
+ EffectType.SWAP_CARDS: "Discard {value} card(s) then Draw {value}",
160
+ EffectType.SEARCH_DECK: "Search Deck",
161
+ EffectType.ENERGY_CHARGE: "Charge {value} Energy",
162
+ EffectType.SET_BLADES: "Set Blade(s) to {value}",
163
+ EffectType.SET_HEARTS: "Set Heart(s) to {value}",
164
+ EffectType.FORMATION_CHANGE: "Rearrange members on stage",
165
+ EffectType.NEGATE_EFFECT: "Negate effect",
166
+ EffectType.ORDER_DECK: "Reorder top {value} cards of deck",
167
+ EffectType.META_RULE: "[Rule modifier]",
168
+ EffectType.SELECT_MODE: "Choose One:",
169
+ EffectType.MOVE_TO_DECK: "Return {value} card(s) to Deck",
170
+ EffectType.TAP_OPPONENT: "Tap {value} Opponent Member(s)",
171
+ EffectType.PLACE_UNDER: "Place card under Member",
172
+ EffectType.RESTRICTION: "Apply Restriction",
173
+ EffectType.BATON_TOUCH_MOD: "Modify Baton Touch rules",
174
+ EffectType.SET_SCORE: "Set Live Score to {value}",
175
+ EffectType.REVEAL_CARDS: "Reveal {value} card(s)",
176
+ EffectType.LOOK_AND_CHOOSE: "Look at {value} card(s) from deck and choose",
177
+ EffectType.ACTIVATE_MEMBER: "Active {value} Member(s)/Energy",
178
+ EffectType.ADD_TO_HAND: "Add {value} card(s) to Hand",
179
+ EffectType.TRIGGER_REMOTE: "Trigger Remote Ability",
180
+ EffectType.CHEER_REVEAL: "Reveal via Cheer",
181
+ EffectType.REDUCE_HEART_REQ: "Modify Heart Requirement",
182
+ EffectType.SWAP_ZONE: "Swap card zones",
183
+ EffectType.FLAVOR_ACTION: "Flavor Action",
184
+ EffectType.MOVE_TO_DISCARD: "Move {value} card(s) to Discard",
185
+ EffectType.PLAY_MEMBER_FROM_HAND: "Play member from hand",
186
+ EffectType.TAP_MEMBER: "Tap {value} Member(s)",
187
+ }
188
+
189
+ EFFECT_DESCRIPTIONS_JP = {
190
+ EffectType.DRAW: "{value}枚ドロー",
191
+ EffectType.LOOK_DECK: "デッキの上から{value}枚見る",
192
+ EffectType.ADD_BLADES: "ブレード+{value}",
193
+ EffectType.ADD_HEARTS: "ハート+{value}",
194
+ EffectType.REDUCE_COST: "コスト-{value}",
195
+ EffectType.BOOST_SCORE: "スコア+{value}",
196
+ EffectType.RECOVER_LIVE: "控えライブ{value}枚回収",
197
+ EffectType.RECOVER_MEMBER: "控えメンバー{value}枚回収",
198
+ EffectType.BUFF_POWER: "パワー+{value}",
199
+ EffectType.IMMUNITY: "効果無効",
200
+ EffectType.MOVE_MEMBER: "メンバー移動",
201
+ EffectType.SWAP_CARDS: "手札交換({value}枚捨て{value}枚引く)",
202
+ EffectType.SEARCH_DECK: "デッキ検索",
203
+ EffectType.ENERGY_CHARGE: "エネルギーチャージ+{value}",
204
+ EffectType.SET_BLADES: "ブレードを{value}にセット",
205
+ EffectType.SET_HEARTS: "ハートを{value}にセット",
206
+ EffectType.FORMATION_CHANGE: "配置変更",
207
+ EffectType.NEGATE_EFFECT: "効果打ち消し",
208
+ EffectType.ORDER_DECK: "デッキトップ{value}枚並べ替��",
209
+ EffectType.META_RULE: "[ルール変更]",
210
+ EffectType.SELECT_MODE: "モード選択:",
211
+ EffectType.MOVE_TO_DECK: "{value}枚をデッキに戻す",
212
+ EffectType.TAP_OPPONENT: "相手メンバー{value}人をウェイトにする",
213
+ EffectType.PLACE_UNDER: "メンバーの下に置く",
214
+ EffectType.RESTRICTION: "プレイ制限適用",
215
+ EffectType.BATON_TOUCH_MOD: "バトンタッチルール変更",
216
+ EffectType.SET_SCORE: "ライブスコアを{value}にセット",
217
+ EffectType.REVEAL_CARDS: "{value}枚公開",
218
+ EffectType.LOOK_AND_CHOOSE: "デッキから{value}枚見て選ぶ",
219
+ EffectType.ACTIVATE_MEMBER: "{value}人/エネをアクティブにする",
220
+ EffectType.ADD_TO_HAND: "手札に{value}枚加える",
221
+ EffectType.TRIGGER_REMOTE: "リモート能力誘発",
222
+ EffectType.CHEER_REVEAL: "応援で公開",
223
+ EffectType.REDUCE_HEART_REQ: "ハート条件変更",
224
+ EffectType.SWAP_ZONE: "カード移動(ゾーン間)",
225
+ EffectType.FLAVOR_ACTION: "フレーバーアクション",
226
+ EffectType.MOVE_TO_DISCARD: "控え室に{value}枚置く",
227
+ EffectType.PLAY_MEMBER_FROM_HAND: "手札からメンバーを登場させる",
228
+ EffectType.TAP_MEMBER: "{value}人をウェイトにする",
229
+ EffectType.ACTIVATE_ENERGY: "エネルギーを{value}枚アクティブにする",
230
+ }
231
+
232
+ TRIGGER_DESCRIPTIONS = {
233
+ TriggerType.ON_PLAY: "[On Play]",
234
+ TriggerType.ON_LIVE_START: "[Live Start]",
235
+ TriggerType.ON_LIVE_SUCCESS: "[Live Success]",
236
+ TriggerType.TURN_START: "[Turn Start]",
237
+ TriggerType.TURN_END: "[Turn End - live]",
238
+ TriggerType.CONSTANT: "[Constant - live]",
239
+ TriggerType.ACTIVATED: "[Activated]",
240
+ TriggerType.ON_LEAVES: "[When Leaves]",
241
+ }
242
+
243
+ TRIGGER_DESCRIPTIONS_JP = {
244
+ TriggerType.ON_PLAY: "【登場時】",
245
+ TriggerType.ON_LIVE_START: "【ライブ開始時】",
246
+ TriggerType.ON_LIVE_SUCCESS: "【ライブ成功時】",
247
+ TriggerType.TURN_START: "【ターン開始時】",
248
+ TriggerType.TURN_END: "【ターン終了時】",
249
+ TriggerType.CONSTANT: "【常時】",
250
+ TriggerType.ACTIVATED: "【起動】",
251
+ TriggerType.ON_LEAVES: "【離脱時】",
252
+ }
253
+
254
+
255
+ @dataclass(slots=True)
256
+ class Condition:
257
+ type: ConditionType
258
+ params: Dict[str, Any] = field(default_factory=dict)
259
+ is_negated: bool = False # "If NOT X" / "Except X"
260
+
261
+
262
+ @dataclass(slots=True)
263
+ class Effect:
264
+ effect_type: EffectType
265
+ value: int = 0
266
+ value_cond: ConditionType = ConditionType.NONE
267
+ target: TargetType = TargetType.SELF
268
+ params: Dict[str, Any] = field(default_factory=dict)
269
+ is_optional: bool = False # ~てもよい
270
+ modal_options: List[List["Effect"]] = field(default_factory=list) # For SELECT_MODE
271
+
272
+
273
+ @dataclass(slots=True)
274
+ class ResolvingEffect:
275
+ """Wrapper for an effect currently being resolved to track its source and progress."""
276
+
277
+ effect: Effect
278
+ source_card_id: int
279
+ step_index: int
280
+ total_steps: int
281
+
282
+
283
+ class AbilityCostType(IntEnum):
284
+ NONE = 0
285
+ ENERGY = 1
286
+ TAP_SELF = 2 # ウェイトにする
287
+ DISCARD_HAND = 3 # 手札を捨てる
288
+ RETURN_HAND = 4 # 手札に戻す (Self bounce)
289
+ SACRIFICE_SELF = 5 # このメンバーを控え室に置く
290
+ REVEAL_HAND_ALL = 6 # 手札をすべて公開する
291
+ SACRIFICE_UNDER = 7 # 下に置かれているカードを控え室に置く
292
+ DISCARD_ENERGY = 8 # エネルギーを控え室に置く
293
+ REVEAL_HAND = 9 # 手札を公開する
294
+ TAP_PLAYER = 2 # Alias for TAP_SELF (ウェイトにする)
295
+
296
+ # Missing aliases/members inferred from usage
297
+ TAP_MEMBER = 20
298
+ TAP_ENERGY = 21
299
+ PAY_ENERGY = 1 # Alias for ENERGY
300
+ REST_MEMBER = 22
301
+ RETURN_MEMBER_TO_HAND = 23
302
+ DISCARD_MEMBER = 24
303
+ DISCARD_LIVE = 25
304
+ REMOVE_LIVE = 26
305
+ REMOVE_MEMBER = 27
306
+ RETURN_LIVE_TO_HAND = 28
307
+ RETURN_LIVE_TO_DECK = 29
308
+ RETURN_MEMBER_TO_DECK = 30
309
+ PLACE_MEMBER_FROM_HAND = 31
310
+ PLACE_LIVE_FROM_HAND = 32
311
+ PLACE_ENERGY_FROM_HAND = 33
312
+ PLACE_MEMBER_FROM_DISCARD = 34
313
+ PLACE_LIVE_FROM_DISCARD = 35
314
+ PLACE_ENERGY_FROM_DISCARD = 36
315
+ PLACE_MEMBER_FROM_DECK = 37
316
+ PLACE_LIVE_FROM_DECK = 38
317
+ PLACE_ENERGY_FROM_DECK = 39
318
+ # REVEAL_HAND = 40 # Moved to 9
319
+ SHUFFLE_DECK = 41
320
+ DRAW_CARD = 42
321
+ DISCARD_TOP_DECK = 43
322
+ REMOVE_TOP_DECK = 44
323
+ RETURN_DISCARD_TO_DECK = 45
324
+ RETURN_REMOVED_TO_DECK = 46
325
+ RETURN_REMOVED_TO_HAND = 47
326
+ RETURN_REMOVED_TO_DISCARD = 48
327
+ PLACE_ENERGY_FROM_SUCCESS = 49
328
+ DISCARD_SUCCESS_LIVE = 50
329
+ REMOVE_SUCCESS_LIVE = 51
330
+ RETURN_SUCCESS_LIVE_TO_HAND = 52
331
+ RETURN_SUCCESS_LIVE_TO_DECK = 53
332
+ RETURN_SUCCESS_LIVE_TO_DISCARD = 54
333
+ PLACE_MEMBER_FROM_SUCCESS = 55
334
+ PLACE_LIVE_FROM_SUCCESS = 56
335
+ PLACE_ENERGY_FROM_REMOVED = 57
336
+ PLACE_MEMBER_FROM_REMOVED = 58
337
+ PLACE_LIVE_FROM_REMOVED = 59
338
+ RETURN_ENERGY_TO_DECK = 60
339
+ RETURN_ENERGY_TO_HAND = 61
340
+ REMOVE_ENERGY = 62
341
+ RETURN_STAGE_ENERGY_TO_HAND = 64
342
+ DISCARD_STAGE_ENERGY = 65
343
+ REMOVE_STAGE_ENERGY = 66
344
+ DISCARD_STAGE = 65 # Alias for DISCARD_STAGE_ENERGY (often used for members/energy)
345
+ MOVE_TO_DISCARD = 5 # Common alias for sacrifice/discard
346
+ PLACE_ENERGY_FROM_STAGE_ENERGY = 67
347
+ PLACE_MEMBER_FROM_STAGE_ENERGY = 68
348
+ PLACE_LIVE_FROM_STAGE_ENERGY = 69
349
+ PLACE_ENERGY_FROM_HAND_TO_STAGE_ENERGY = 70
350
+ PLACE_MEMBER_FROM_HAND_TO_STAGE_ENERGY = 71
351
+ PLACE_LIVE_FROM_HAND_TO_STAGE_ENERGY = 72
352
+ PLACE_ENERGY_FROM_DISCARD_TO_STAGE_ENERGY = 73
353
+ PLACE_MEMBER_FROM_DISCARD_TO_STAGE_ENERGY = 74
354
+ PLACE_LIVE_FROM_DISCARD_TO_STAGE_ENERGY = 75
355
+ PLACE_ENERGY_FROM_DECK_TO_STAGE_ENERGY = 76
356
+ PLACE_MEMBER_FROM_DECK_TO_STAGE_ENERGY = 77
357
+ PLACE_LIVE_FROM_DECK_TO_STAGE_ENERGY = 78
358
+ PLACE_ENERGY_FROM_SUCCESS_TO_STAGE_ENERGY = 79
359
+ PLACE_MEMBER_FROM_SUCCESS_TO_STAGE_ENERGY = 80
360
+ PLACE_LIVE_FROM_SUCCESS_TO_STAGE_ENERGY = 81
361
+ PLACE_ENERGY_FROM_REMOVED_TO_STAGE_ENERGY = 82
362
+ PLACE_MEMBER_FROM_REMOVED_TO_STAGE_ENERGY = 83
363
+ PLACE_LIVE_FROM_REMOVED_TO_STAGE_ENERGY = 84
364
+ RETURN_LIVE_TO_DISCARD = 85
365
+ RETURN_LIVE_TO_REMOVED = 86
366
+ RETURN_LIVE_TO_SUCCESS = 87
367
+ RETURN_MEMBER_TO_DISCARD = 88
368
+ RETURN_MEMBER_TO_REMOVED = 89
369
+ RETURN_MEMBER_TO_SUCCESS = 90
370
+ RETURN_ENERGY_TO_DISCARD = 91
371
+ RETURN_ENERGY_TO_REMOVED = 92
372
+ RETURN_ENERGY_TO_SUCCESS = 93
373
+ RETURN_SUCCESS_LIVE_TO_REMOVED = 94
374
+ RETURN_REMOVED_TO_SUCCESS = 95
375
+ RETURN_STAGE_ENERGY_TO_DISCARD = 96
376
+ RETURN_STAGE_ENERGY_TO_REMOVED = 97
377
+ RETURN_STAGE_ENERGY_TO_SUCCESS = 98
378
+ RETURN_DISCARD_TO_HAND = 99
379
+ RETURN_DISCARD_TO_REMOVED = 100
380
+ RETURN_DISCARD_TO_SUCCESS = 101
381
+ RETURN_DECK_TO_DISCARD = 102
382
+ RETURN_DECK_TO_HAND = 103
383
+ RETURN_DECK_TO_REMOVED = 104
384
+ RETURN_DECK_TO_SUCCESS = 105
385
+ RETURN_ENERGY_DECK_TO_DISCARD = 106
386
+ RETURN_ENERGY_DECK_TO_HAND = 107
387
+ RETURN_ENERGY_DECK_TO_REMOVED = 108
388
+ RETURN_ENERGY_DECK_TO_SUCCESS = 109
389
+
390
+ # Auto-generated missing members for effect_mixin.py compatibility
391
+ PLACE_ENERGY_FROM_DECK_TO_DISCARD = 110
392
+ PLACE_ENERGY_FROM_DECK_TO_HAND = 111
393
+ PLACE_ENERGY_FROM_DECK_TO_REMOVED = 112
394
+ PLACE_ENERGY_FROM_DECK_TO_SUCCESS = 113
395
+ PLACE_ENERGY_FROM_DISCARD_TO_HAND = 114
396
+ PLACE_ENERGY_FROM_DISCARD_TO_REMOVED = 115
397
+ PLACE_ENERGY_FROM_DISCARD_TO_SUCCESS = 116
398
+ PLACE_ENERGY_FROM_ENERGY_DECK = 117
399
+ PLACE_ENERGY_FROM_ENERGY_DECK_TO_DISCARD = 118
400
+ PLACE_ENERGY_FROM_ENERGY_DECK_TO_HAND = 119
401
+ PLACE_ENERGY_FROM_ENERGY_DECK_TO_REMOVED = 120
402
+ PLACE_ENERGY_FROM_ENERGY_DECK_TO_STAGE_ENERGY = 121
403
+ PLACE_ENERGY_FROM_ENERGY_DECK_TO_SUCCESS = 122
404
+ PLACE_ENERGY_FROM_ENERGY_ZONE_TO_DISCARD = 123
405
+ PLACE_ENERGY_FROM_ENERGY_ZONE_TO_HAND = 124
406
+ PLACE_ENERGY_FROM_ENERGY_ZONE_TO_REMOVED = 125
407
+ PLACE_ENERGY_FROM_ENERGY_ZONE_TO_SUCCESS = 126
408
+ PLACE_ENERGY_FROM_HAND_TO_DISCARD = 127
409
+ PLACE_ENERGY_FROM_HAND_TO_REMOVED = 128
410
+ PLACE_ENERGY_FROM_HAND_TO_SUCCESS = 129
411
+ PLACE_ENERGY_FROM_REMOVED_TO_DISCARD = 130
412
+ PLACE_ENERGY_FROM_REMOVED_TO_HAND = 131
413
+ PLACE_ENERGY_FROM_REMOVED_TO_SUCCESS = 132
414
+ PLACE_ENERGY_FROM_STAGE_ENERGY_TO_DISCARD = 133
415
+ PLACE_ENERGY_FROM_STAGE_ENERGY_TO_HAND = 134
416
+ PLACE_ENERGY_FROM_STAGE_ENERGY_TO_REMOVED = 135
417
+ PLACE_ENERGY_FROM_STAGE_ENERGY_TO_SUCCESS = 136
418
+ PLACE_ENERGY_FROM_SUCCESS_TO_DISCARD = 137
419
+ PLACE_ENERGY_FROM_SUCCESS_TO_HAND = 138
420
+ PLACE_ENERGY_FROM_SUCCESS_TO_REMOVED = 139
421
+ PLACE_LIVE_FROM_DECK_TO_DISCARD = 140
422
+ PLACE_LIVE_FROM_DECK_TO_HAND = 141
423
+ PLACE_LIVE_FROM_DECK_TO_REMOVED = 142
424
+ PLACE_LIVE_FROM_DECK_TO_SUCCESS = 143
425
+ PLACE_LIVE_FROM_DISCARD_TO_HAND = 144
426
+ PLACE_LIVE_FROM_DISCARD_TO_REMOVED = 145
427
+ PLACE_LIVE_FROM_DISCARD_TO_SUCCESS = 146
428
+ PLACE_LIVE_FROM_ENERGY_DECK = 147
429
+ PLACE_LIVE_FROM_ENERGY_DECK_TO_DISCARD = 148
430
+ PLACE_LIVE_FROM_ENERGY_DECK_TO_HAND = 149
431
+ PLACE_LIVE_FROM_ENERGY_DECK_TO_REMOVED = 150
432
+ PLACE_LIVE_FROM_ENERGY_DECK_TO_STAGE_ENERGY = 151
433
+ PLACE_LIVE_FROM_ENERGY_DECK_TO_SUCCESS = 152
434
+ PLACE_LIVE_FROM_ENERGY_ZONE_TO_DISCARD = 153
435
+ PLACE_LIVE_FROM_ENERGY_ZONE_TO_HAND = 154
436
+ PLACE_LIVE_FROM_ENERGY_ZONE_TO_REMOVED = 155
437
+ PLACE_LIVE_FROM_ENERGY_ZONE_TO_SUCCESS = 156
438
+ PLACE_LIVE_FROM_HAND_TO_DISCARD = 157
439
+ PLACE_LIVE_FROM_HAND_TO_REMOVED = 158
440
+ PLACE_LIVE_FROM_HAND_TO_SUCCESS = 159
441
+ PLACE_LIVE_FROM_REMOVED_TO_DISCARD = 160
442
+ PLACE_LIVE_FROM_REMOVED_TO_HAND = 161
443
+ PLACE_LIVE_FROM_REMOVED_TO_SUCCESS = 162
444
+ PLACE_LIVE_FROM_STAGE_ENERGY_TO_DISCARD = 163
445
+ PLACE_LIVE_FROM_STAGE_ENERGY_TO_HAND = 164
446
+ PLACE_LIVE_FROM_STAGE_ENERGY_TO_REMOVED = 165
447
+ PLACE_LIVE_FROM_STAGE_ENERGY_TO_SUCCESS = 166
448
+ PLACE_LIVE_FROM_SUCCESS_TO_DISCARD = 167
449
+ PLACE_LIVE_FROM_SUCCESS_TO_HAND = 168
450
+ PLACE_LIVE_FROM_SUCCESS_TO_REMOVED = 169
451
+ PLACE_MEMBER_FROM_DECK_TO_DISCARD = 170
452
+ PLACE_MEMBER_FROM_DECK_TO_HAND = 171
453
+ PLACE_MEMBER_FROM_DECK_TO_REMOVED = 172
454
+ PLACE_MEMBER_FROM_DECK_TO_SUCCESS = 173
455
+ PLACE_MEMBER_FROM_DISCARD_TO_HAND = 174
456
+ PLACE_MEMBER_FROM_DISCARD_TO_REMOVED = 175
457
+ PLACE_MEMBER_FROM_DISCARD_TO_SUCCESS = 176
458
+ PLACE_MEMBER_FROM_ENERGY_DECK = 177
459
+ PLACE_MEMBER_FROM_ENERGY_DECK_TO_DISCARD = 178
460
+ PLACE_MEMBER_FROM_ENERGY_DECK_TO_HAND = 179
461
+ PLACE_MEMBER_FROM_ENERGY_DECK_TO_REMOVED = 180
462
+ PLACE_MEMBER_FROM_ENERGY_DECK_TO_STAGE_ENERGY = 181
463
+ PLACE_MEMBER_FROM_ENERGY_DECK_TO_SUCCESS = 182
464
+ PLACE_MEMBER_FROM_ENERGY_ZONE_TO_DISCARD = 183
465
+ PLACE_MEMBER_FROM_ENERGY_ZONE_TO_HAND = 184
466
+ PLACE_MEMBER_FROM_ENERGY_ZONE_TO_REMOVED = 185
467
+ PLACE_MEMBER_FROM_ENERGY_ZONE_TO_SUCCESS = 186
468
+ PLACE_MEMBER_FROM_HAND_TO_DISCARD = 187
469
+ PLACE_MEMBER_FROM_HAND_TO_REMOVED = 188
470
+ PLACE_MEMBER_FROM_HAND_TO_SUCCESS = 189
471
+ PLACE_MEMBER_FROM_REMOVED_TO_DISCARD = 190
472
+ PLACE_MEMBER_FROM_REMOVED_TO_HAND = 191
473
+ PLACE_MEMBER_FROM_REMOVED_TO_SUCCESS = 192
474
+ PLACE_MEMBER_FROM_STAGE_ENERGY_TO_DISCARD = 193
475
+ PLACE_MEMBER_FROM_STAGE_ENERGY_TO_HAND = 194
476
+ PLACE_MEMBER_FROM_STAGE_ENERGY_TO_REMOVED = 195
477
+ PLACE_MEMBER_FROM_STAGE_ENERGY_TO_SUCCESS = 196
478
+ PLACE_MEMBER_FROM_SUCCESS_TO_DISCARD = 197
479
+ PLACE_MEMBER_FROM_SUCCESS_TO_HAND = 198
480
+ PLACE_MEMBER_FROM_SUCCESS_TO_REMOVED = 199
481
+
482
+
483
+ @dataclass
484
+ class Cost:
485
+ type: AbilityCostType
486
+ value: int = 0
487
+ params: Dict[str, Any] = field(default_factory=dict)
488
+ is_optional: bool = False
489
+
490
+ @property
491
+ def cost_type(self) -> AbilityCostType:
492
+ return self.type
493
+
494
+
495
+ @dataclass
496
+ class Ability:
497
+ raw_text: str
498
+ trigger: TriggerType
499
+ effects: List[Effect]
500
+ conditions: List[Condition] = field(default_factory=list)
501
+ costs: List[Cost] = field(default_factory=list)
502
+ modal_options: List[List[Effect]] = field(default_factory=list) # For SELECT_MODE
503
+ is_once_per_turn: bool = False
504
+ bytecode: List[int] = field(default_factory=list)
505
+ requires_selection: bool = False
506
+ # Ordered list of operations (Union[Effect, Condition]) for precise execution order
507
+ instructions: List[Any] = field(default_factory=list)
508
+
509
+ def compile(self) -> List[int]:
510
+ """Compile ability into fixed-width bytecode sequence (groups of 4 ints)."""
511
+ bytecode = []
512
+
513
+ # 0. Compile Ordered Instructions (If present - New Parser V2.1)
514
+ if self.instructions:
515
+ for instr in self.instructions:
516
+ if isinstance(instr, Condition):
517
+ self._compile_single_condition(instr, bytecode)
518
+ elif isinstance(instr, Effect):
519
+ self._compile_effect_wrapper(instr, bytecode)
520
+
521
+ # Terminator
522
+ bytecode.extend([int(Opcode.RETURN), 0, 0, 0])
523
+ return bytecode
524
+
525
+ # 1. Compile Conditions (Legacy/Split Mode)
526
+ for cond in self.conditions:
527
+ self._compile_single_condition(cond, bytecode)
528
+
529
+ # 1.5. Compile Costs (Note: Modern engine handles costs via pay_cost shell)
530
+ # We don't compile costs into bytecode unless they are meant for mid-ability execution.
531
+
532
+ # 2. Compile Effects
533
+ for eff in self.effects:
534
+ self._compile_effect_wrapper(eff, bytecode)
535
+
536
+ # Terminator
537
+ bytecode.extend([int(Opcode.RETURN), 0, 0, 0])
538
+ return bytecode
539
+
540
+ def _compile_single_condition(self, cond: Condition, bytecode: List[int]):
541
+ op_name = f"CHECK_{cond.type.name}"
542
+ if hasattr(Opcode, op_name):
543
+ op = getattr(Opcode, op_name)
544
+ # Fixed width: [Opcode, Value, Attr, TargetSlot]
545
+ # Check multiple potential keys for the value (min, count, value, diff)
546
+ v_raw = cond.params.get("value", cond.params.get("min", cond.params.get("count", cond.params.get("diff", 0))))
547
+ try:
548
+ val = int(v_raw) if v_raw is not None else 0
549
+ except (ValueError, TypeError):
550
+ val = 0
551
+
552
+ # Resolve attr (color, group, or unit) to integer
553
+ attr_raw = cond.params.get("color", cond.params.get("group", cond.params.get("unit", 0)))
554
+ if isinstance(attr_raw, str):
555
+ # Resolve using enums
556
+ if "group" in cond.params:
557
+ from engine.models.enums import Group
558
+
559
+ attr = int(Group.from_japanese_name(attr_raw))
560
+ elif "unit" in cond.params:
561
+ from engine.models.enums import Unit
562
+
563
+ attr = int(Unit.from_japanese_name(attr_raw))
564
+ elif cond.type == ConditionType.SCORE_COMPARE:
565
+ # Map score/cost/heart types to int
566
+ stype = cond.params.get("type", "score")
567
+ type_map = {"score": 0, "cost": 1, "heart": 2, "heart_count": 2, "cheer_count": 3}
568
+ attr = type_map.get(stype, 0)
569
+ else:
570
+ attr = 0
571
+ else:
572
+ attr = int(attr_raw) if attr_raw is not None else 0
573
+
574
+ # Comparison mapping (GE=0, LE=1, GT=2, LT=3, EQ=4)
575
+ comp_str = cond.params.get("comparison", "GE")
576
+ comp_map = {"GE": 0, "LE": 1, "GT": 2, "LT": 3, "EQ": 4}
577
+ comp_val = comp_map.get(comp_str, 0)
578
+
579
+ # Zone mapping: STAGE=0, LIVE_ZONE=1, LIVE_RESULT/EXCESS=2
580
+ slot = 0
581
+ zone = cond.params.get("zone", "")
582
+ context = cond.params.get("context", "")
583
+
584
+ if zone == "LIVE_ZONE":
585
+ slot = 1
586
+ elif zone == "STAGE":
587
+ slot = 0
588
+ elif context == "excess":
589
+ slot = 2
590
+ else:
591
+ slot = cond.params.get("TargetSlot", 0)
592
+
593
+ # Pack comparison into higher bits of slot (bits 4-7)
594
+ # Slot is usually 0-3, so shift 4 is safe.
595
+ packed_slot = (slot & 0x0F) | ((comp_val & 0x0F) << 4)
596
+
597
+ op_val = int(op)
598
+ if cond.is_negated:
599
+ op_val += 1000
600
+
601
+ bytecode.extend(
602
+ [
603
+ op_val,
604
+ val,
605
+ attr,
606
+ packed_slot,
607
+ ]
608
+ )
609
+
610
+ elif cond.type == ConditionType.BATON:
611
+ # Special handling for BATON condition
612
+ if hasattr(Opcode, "CHECK_BATON"):
613
+ unit_id = 0
614
+ if "unit" in cond.params:
615
+ from engine.models.enums import Unit
616
+
617
+ try:
618
+ # Handle string unit names
619
+ u_val = cond.params["unit"]
620
+ if isinstance(u_val, str):
621
+ unit_id = int(Unit.from_japanese_name(u_val))
622
+ else:
623
+ unit_id = int(u_val)
624
+ except:
625
+ unit_id = 0
626
+
627
+ filter_type = 0
628
+ if "filter" in cond.params:
629
+ f_str = cond.params["filter"]
630
+ if f_str == "COST_LT_SELF":
631
+ filter_type = 1 # 1 = Cost Check Less Than Self
632
+
633
+ bytecode.extend([int(Opcode.CHECK_BATON), unit_id, filter_type, 0])
634
+
635
+ elif cond.type == ConditionType.TYPE_CHECK:
636
+ if hasattr(Opcode, "CHECK_TYPE_CHECK"):
637
+ # card_type: "live" = 1, "member" = 0
638
+ ctype = 1 if cond.params.get("card_type") == "live" else 0
639
+ bytecode.extend([int(Opcode.CHECK_TYPE_CHECK), ctype, 0, 0])
640
+
641
+ def _compile_effect_wrapper(self, eff: Effect, bytecode: List[int]):
642
+ # Fix: Use name comparison to avoid Enum identity issues from reloading/imports
643
+ if eff.effect_type.name == "ORDER_DECK":
644
+ # O_ORDER_DECK requires looking at cards first.
645
+ # Emit: [O_LOOK_DECK, val, 0, 0] -> [O_ORDER_DECK, val, attr, 0]
646
+ # attr: 0=Discard, 1=DeckTop, 2=DeckBottom
647
+ rem = eff.params.get("remainder", "discard").lower()
648
+ attr = 0
649
+ if rem == "deck_top":
650
+ attr = 1
651
+ elif rem == "deck_bottom":
652
+ attr = 2
653
+
654
+ bytecode.extend([int(Opcode.LOOK_DECK), eff.value, 0, 0])
655
+ bytecode.extend([int(Opcode.ORDER_DECK), eff.value, attr, 0])
656
+ return
657
+
658
+ # Check for modal options on Effect OR Ability (fallback)
659
+ modal_opts = eff.modal_options if eff.modal_options else self.modal_options
660
+
661
+ if eff.effect_type == EffectType.SELECT_MODE and modal_opts:
662
+ # Handle SELECT_MODE with jump table
663
+ num_options = len(modal_opts)
664
+ # Emit header: [SELECT_MODE, NumOptions, 0, 0]
665
+ if hasattr(Opcode, "SELECT_MODE"):
666
+ bytecode.extend([int(Opcode.SELECT_MODE), num_options, 0, 0])
667
+
668
+ # Placeholders for Jump Table
669
+ jump_table_start_idx = len(bytecode)
670
+ for _ in range(num_options):
671
+ bytecode.extend([int(Opcode.JUMP), 0, 0, 0])
672
+
673
+ # Compile each option and track start/end
674
+ option_start_offsets = []
675
+ end_jumps_locations = []
676
+
677
+ for opt_effects in modal_opts:
678
+ # Record start offset (relative to current instruction pointer)
679
+ current_idx = len(bytecode) // 4
680
+ option_start_offsets.append(current_idx)
681
+
682
+ # Compile option effects
683
+ for opt_eff in opt_effects:
684
+ self._compile_single_effect(opt_eff, bytecode)
685
+
686
+ # Add Jump to End (placeholder)
687
+ end_jumps_locations.append(len(bytecode))
688
+ bytecode.extend([int(Opcode.JUMP), 0, 0, 0])
689
+
690
+ # Determine End Index
691
+ end_idx = len(bytecode) // 4
692
+
693
+ # Patch Jump Table (Start Jumps)
694
+ for i in range(num_options):
695
+ jump_instr_idx = (jump_table_start_idx // 4) + i
696
+ target_idx = option_start_offsets[i]
697
+ offset = target_idx - jump_instr_idx
698
+ bytecode[jump_instr_idx * 4 + 1] = offset
699
+
700
+ # Patch End Jumps
701
+ for loc in end_jumps_locations:
702
+ jump_instr_idx = loc // 4
703
+ offset = end_idx - jump_instr_idx
704
+ bytecode[loc + 1] = offset
705
+
706
+ else:
707
+ self._compile_single_effect(eff, bytecode)
708
+
709
+ def _compile_single_effect(self, eff: Effect, bytecode: List[int]):
710
+ print(f"DEBUG: Compiling Single Effect: {eff.effect_type.name} (Val={eff.value})")
711
+ if hasattr(Opcode, eff.effect_type.name):
712
+ op = getattr(Opcode, eff.effect_type.name)
713
+
714
+ try:
715
+ val = int(eff.value)
716
+ except (ValueError, TypeError):
717
+ val = 1
718
+ attr = eff.params.get("color", 0) if isinstance(eff.params.get("color"), int) else 0
719
+ slot = eff.target.value if hasattr(eff.target, "value") else int(eff.target)
720
+
721
+ # Check for interactive target selection requirement
722
+ # Use Bit 5 (0x20) in attr to flag "Requires Selection"
723
+ if eff.effect_type == EffectType.TAP_OPPONENT:
724
+ attr |= 1 << 5
725
+
726
+ # Special handling for PLACE_UNDER params
727
+ if eff.effect_type == EffectType.PLACE_UNDER:
728
+ if eff.params.get("from") == "energy":
729
+ attr = 1 # Source: Energy
730
+ elif eff.params.get("from") == "discard":
731
+ attr = 2 # Source: Discard (for future proofing)
732
+ # Keep existing type logic if any, but currently PLACE_UNDER uses attr for source
733
+
734
+ # Special handling for SEARCH_DECK params
735
+ if eff.effect_type == EffectType.SEARCH_DECK:
736
+ if "group" in eff.params:
737
+ slot = 1 # Filter Type: Group
738
+ try:
739
+ attr = int(eff.params["group"])
740
+ except:
741
+ pass
742
+ elif "unit" in eff.params:
743
+ slot = 2 # Filter Type: Unit
744
+ try:
745
+ attr = int(eff.params["unit"])
746
+ except:
747
+ pass
748
+
749
+ # Special handling for PLAY_MEMBER_FROM_HAND params
750
+ if eff.effect_type == EffectType.PLAY_MEMBER_FROM_HAND:
751
+ if "group" in eff.params:
752
+ from engine.models.enums import Group
753
+
754
+ try:
755
+ attr = int(Group.from_japanese_name(eff.params["group"]))
756
+ except:
757
+ pass
758
+
759
+ # Special handling for LOOK_AND_CHOOSE destination
760
+ if eff.effect_type == EffectType.LOOK_AND_CHOOSE:
761
+ if eff.params.get("source") == "HAND":
762
+ slot = int(TargetType.CARD_HAND)
763
+
764
+ attr = 0
765
+ if eff.params.get("destination") == "discard":
766
+ attr |= 0x01 # Bit 0: Destination Discard
767
+ if eff.is_optional or eff.params.get("is_optional"):
768
+ attr |= 0x02 # Bit 1: Optional (May)
769
+
770
+ # Parse 'filter' string if present (e.g. "GROUP_ID=0, TYPE_LIVE")
771
+ if "filter" in eff.params:
772
+ filter_str = str(eff.params["filter"])
773
+ parts = [p.strip() for p in filter_str.split(",")]
774
+ for part in parts:
775
+ if "=" in part:
776
+ k, v = part.split("=", 1)
777
+ k, v = k.strip().upper(), v.strip()
778
+ if k == "GROUP_ID":
779
+ eff.params["group"] = v
780
+ if k == "TYPE":
781
+ eff.params["type"] = v
782
+ else:
783
+ if part.upper() == "TYPE_LIVE":
784
+ eff.params["type"] = "live"
785
+ if part.upper() == "TYPE_MEMBER":
786
+ eff.params["type"] = "member"
787
+
788
+ # Source Zone: Bits 12-15
789
+ src_zone = eff.params.get("source", "DECK")
790
+ src_val = 8 # Default DECK
791
+ if src_zone == "HAND":
792
+ src_val = 6
793
+ elif src_zone == "DISCARD":
794
+ src_val = 7
795
+ attr |= src_val << 12
796
+
797
+ # Filter Mode (Type): Bit 2-3
798
+ ctype = str(eff.params.get("type", "")).lower()
799
+ if ctype == "live":
800
+ attr |= 0x02 << 2 # Value 2 in bits 2-3
801
+ elif ctype == "member":
802
+ attr |= 0x01 << 2 # Value 1 in bits 2-3
803
+
804
+ # Group Filter: Bit 4 (Enable) and Bits 5-11 (ID)
805
+ group_val = eff.params.get("group")
806
+ if group_val is not None:
807
+ try:
808
+ if str(group_val).isdigit():
809
+ g_id = int(str(group_val))
810
+ else:
811
+ from engine.models.enums import Group
812
+
813
+ g_id = int(Group.from_japanese_name(str(group_val)))
814
+ attr |= 0x10 # Bit 4: Has Group Filter
815
+ attr |= g_id << 5 # Bits 5-11: Group ID
816
+ except:
817
+ pass
818
+
819
+ # Special handling for TAP filters
820
+ if eff.effect_type in (EffectType.TAP_OPPONENT, EffectType.TAP_MEMBER):
821
+ # Use Value for cost_max filter (99 = dynamic/none)
822
+ # Use Attr bits 0-6 for blades_max filter (99 = none)
823
+ try:
824
+ val = int(eff.params.get("cost_max", 99))
825
+ except (ValueError, TypeError):
826
+ val = 99
827
+ try:
828
+ blades_max = int(eff.params.get("blades_max", 99))
829
+ except (ValueError, TypeError):
830
+ blades_max = 99
831
+ attr = (attr & 0x80) | (blades_max & 0x7F)
832
+
833
+ # Special handling for MOVE_TO_DISCARD params
834
+ if eff.effect_type == EffectType.MOVE_TO_DISCARD:
835
+ if eff.params.get("from") == "deck_top":
836
+ attr = 1 # Source: Deck Top
837
+ elif eff.params.get("from") == "hand":
838
+ attr = 2 # Source: Hand
839
+ elif eff.params.get("from") == "energy":
840
+ attr = 3 # Source: Energy
841
+
842
+ if eff.value_cond != ConditionType.NONE:
843
+ val = int(eff.value_cond)
844
+ attr |= 0x40 # Bit 6 for Dynamic
845
+
846
+ # Special encoding for REVEAL_UNTIL
847
+ if eff.effect_type == EffectType.REVEAL_UNTIL:
848
+ if eff.value_cond == ConditionType.TYPE_CHECK:
849
+ if eff.params.get("card_type") == "live":
850
+ attr |= 0x01
851
+ elif eff.value_cond == ConditionType.COST_CHECK:
852
+ cost = int(eff.params.get("min", 0))
853
+ attr |= (cost & 0x1F) << 1
854
+
855
+ # Default to Choice (slot 4) for PLAY opcodes if target is generic (SELF/PLAYER)
856
+ if eff.effect_type in (
857
+ EffectType.PLAY_MEMBER_FROM_HAND,
858
+ EffectType.PLAY_MEMBER_FROM_DISCARD,
859
+ EffectType.PLAY_LIVE_FROM_DISCARD,
860
+ ):
861
+ if eff.target in (TargetType.SELF, TargetType.PLAYER):
862
+ slot = 4
863
+
864
+ bytecode.extend(
865
+ [
866
+ int(op),
867
+ int(val),
868
+ attr if not eff.params.get("all") else (attr | 0x80), # Bit 7 for ALL
869
+ slot,
870
+ ]
871
+ )
872
+
873
+ def reconstruct_text(self, lang: str = "en") -> str:
874
+ """Generate standardized text description."""
875
+ parts = []
876
+ is_jp = lang == "jp"
877
+ t_desc_map = TRIGGER_DESCRIPTIONS_JP if is_jp else TRIGGER_DESCRIPTIONS
878
+ e_desc_map = EFFECT_DESCRIPTIONS_JP if is_jp else EFFECT_DESCRIPTIONS
879
+
880
+ t_name = getattr(self.trigger, "name", str(self.trigger))
881
+ trigger_desc = t_desc_map.get(self.trigger, f"[{t_name}]")
882
+ if self.trigger == TriggerType.ON_LEAVES:
883
+ if "discard" not in trigger_desc.lower() and "控え室" not in trigger_desc:
884
+ suffix = " (to discard)" if not is_jp else "(控え室へ)"
885
+ trigger_desc += suffix
886
+ parts.append(trigger_desc)
887
+
888
+ for cost in self.costs:
889
+ if is_jp:
890
+ if cost.type == AbilityCostType.ENERGY:
891
+ parts.append(f"(コスト: エネ{cost.value}消費)")
892
+ elif cost.type == AbilityCostType.TAP_SELF:
893
+ parts.append("(コスト: 自身ウェイト)")
894
+ elif cost.type == AbilityCostType.DISCARD_HAND:
895
+ parts.append(f"(コスト: 手札{cost.value}枚捨て)")
896
+ elif cost.type == AbilityCostType.SACRIFICE_SELF:
897
+ parts.append("(コスト: 自身退場)")
898
+ else:
899
+ parts.append(f"(コスト: {cost.type.name} {cost.value})")
900
+ else:
901
+ if cost.type == AbilityCostType.ENERGY:
902
+ parts.append(f"(Cost: Pay {cost.value} Energy)")
903
+ elif cost.type == AbilityCostType.TAP_SELF:
904
+ parts.append("(Cost: Rest Self)")
905
+ elif cost.type == AbilityCostType.DISCARD_HAND:
906
+ parts.append(f"(Cost: Discard {cost.value} from hand)")
907
+ elif cost.type == AbilityCostType.SACRIFICE_SELF:
908
+ parts.append("(Cost: Sacrifice Self)")
909
+ else:
910
+ parts.append(f"(Cost: {cost.type.name} {cost.value})")
911
+
912
+ for cond in self.conditions:
913
+ if is_jp:
914
+ neg = "NOT " if cond.is_negated else "" # JP negation usually handles via suffix, but keeping simple
915
+ cond_desc = f"{neg}{cond.type.name}"
916
+ if cond.type == ConditionType.BATON:
917
+ cond_desc = "条件: バトンタッチ"
918
+ if "unit" in cond.params:
919
+ cond_desc += f" ({cond.params['unit']})"
920
+ # ... (add more JP specific cond descs if needed, but for now fallback)
921
+ else:
922
+ neg = "NOT " if cond.is_negated else ""
923
+ cond_desc = f"{neg}{cond.type.name}"
924
+ # Add basic params
925
+ if cond.params.get("type") == "score":
926
+ cond_desc += " (Score)"
927
+ if cond.type == ConditionType.SCORE_COMPARE:
928
+ target_str = " (Opponent)" if cond.params.get("target") == "opponent" else ""
929
+ cond_desc += f" (Score check{target_str})"
930
+ if cond.type == ConditionType.OPPONENT_HAS:
931
+ cond_desc += " (Opponent has)"
932
+ if cond.type == ConditionType.OPPONENT_CHOICE:
933
+ cond_desc += " (Opponent chooses)"
934
+ if cond.type == ConditionType.OPPONENT_HAND_DIFF:
935
+ cond_desc += " (Opponent hand check)"
936
+ if cond.params.get("group"):
937
+ cond_desc += f"({cond.params['group']})"
938
+ if cond.params.get("zone"):
939
+ cond_desc += f" (in {cond.params['zone']})"
940
+ if cond.params.get("zone") == "SUCCESS_LIVE":
941
+ cond_desc += " (in Live Area)"
942
+ if cond.type == ConditionType.HAS_CHOICE:
943
+ cond_desc = "Condition: Choose One"
944
+ if cond.type == ConditionType.HAS_KEYWORD:
945
+ cond_desc += f" (Has {cond.params.get('keyword', '?')})"
946
+ if cond.params.get("context") == "heart_inclusion":
947
+ cond_desc += " (Heart check)"
948
+ if cond.type == ConditionType.COUNT_BLADES:
949
+ cond_desc += " (Blade count)"
950
+ if cond.type == ConditionType.COUNT_HEARTS:
951
+ cond_desc += " (Heart count)"
952
+ if cond.params.get("context") == "excess":
953
+ cond_desc += " (Excess)"
954
+ if cond.type == ConditionType.COUNT_ENERGY:
955
+ cond_desc += " (Energy count)"
956
+ if cond.type == ConditionType.COUNT_SUCCESS_LIVE:
957
+ cond_desc += " (Success Live count)"
958
+ if cond.type == ConditionType.HAS_LIVE_CARD:
959
+ cond_desc += " (Live card check)"
960
+ type_str = (
961
+ "Heart comparison"
962
+ if cond.params.get("type") == "heart"
963
+ else "Cheer comparison"
964
+ if cond.params.get("type") == "cheer_count"
965
+ else "Score check"
966
+ )
967
+ cond_desc += f" ({type_str}{target_str})"
968
+ if cond.type == ConditionType.BATON:
969
+ cond_desc = "Condition: Baton Pass"
970
+ if "unit" in cond.params:
971
+ cond_desc += f" ({cond.params['unit']})"
972
+ parts.append(cond_desc)
973
+
974
+ for eff in self.effects:
975
+ # Special handling for META_RULE which relies heavily on params
976
+ desc = None
977
+ if eff.effect_type == EffectType.META_RULE:
978
+ if eff.params.get("type") == "opponent_trigger_allowed":
979
+ desc = "[Meta: Opponent effects trigger this]"
980
+ elif eff.params.get("type") == "shuffle":
981
+ desc = "Shuffle Deck"
982
+ elif eff.params.get("type") == "heart_rule":
983
+ src = eff.params.get("source", "")
984
+ src_text = "ALL Blades" if src == "all_blade" else "Blade" if src == "blade" else ""
985
+ desc = f"[Meta: Treat {src_text} as Heart]" if src_text else "[Meta: Treat as Heart]"
986
+ elif eff.params.get("type") == "live":
987
+ desc = "[Meta: Live Rule]"
988
+ elif eff.params.get("type") == "lose_blade_heart":
989
+ desc = "[Meta: Lose Blade Heart]"
990
+ elif eff.params.get("type") == "re_cheer":
991
+ desc = "[Meta: Cheer Again]"
992
+ elif eff.params.get("type") == "cheer_mod":
993
+ val = eff.value
994
+ desc = f"[Meta: Cheer Reveal Count {'+' if val > 0 else ''}{val}]"
995
+ elif eff.effect_type == getattr(EffectType, "TAP_OPPONENT", -1):
996
+ desc = "Tap Opponent Member(s)"
997
+
998
+ if desc is None:
999
+ # Custom overrides for standard effects with params
1000
+ if eff.effect_type == EffectType.DRAW and eff.params.get("multiplier") == "energy":
1001
+ req = eff.params.get("req_per_unit", 1)
1002
+ desc = f"Draw {eff.value} card(s) per {req} Energy"
1003
+ elif eff.effect_type == EffectType.REDUCE_HEART_REQ and eff.value < 0:
1004
+ # e.g. value -1 means reduce requirement. value +1 means increase requirement (opp).
1005
+ pass
1006
+
1007
+ if desc is None:
1008
+ template = e_desc_map.get(eff.effect_type, getattr(eff.effect_type, "name", str(eff.effect_type)))
1009
+ context = eff.params.copy()
1010
+ context["value"] = eff.value
1011
+
1012
+ # Refine REDUCE_HEART_REQ
1013
+ if eff.effect_type == EffectType.REDUCE_HEART_REQ:
1014
+ if eff.params.get("mode") == "select_requirement":
1015
+ desc = "Choose Heart Requirement (hearts) (choice)" if not is_jp else "ハート条件選択"
1016
+ elif eff.value < 0:
1017
+ desc = (
1018
+ f"Reduce Heart Requirement by {abs(eff.value)} (Live)"
1019
+ if not is_jp
1020
+ else f"ハート条件-{abs(eff.value)}"
1021
+ )
1022
+ else:
1023
+ desc = (
1024
+ f"Increase Heart Requirement by {eff.value} (Live)"
1025
+ if not is_jp
1026
+ else f"ハート条件+{eff.value}"
1027
+ )
1028
+ elif eff.effect_type == EffectType.TRANSFORM_COLOR:
1029
+ target_s = eff.params.get("target", "Color")
1030
+ if target_s == "heart":
1031
+ target_s = "Heart"
1032
+ desc = f"Transform {target_s} Color" if not is_jp else f"{target_s}の色を変換"
1033
+ elif eff.effect_type == EffectType.PLACE_UNDER:
1034
+ type_s = f" {eff.params.get('type', '')}" if "type" in eff.params else ""
1035
+ desc = f"Place{type_s} card under member" if not is_jp else f"メンバーの下に{type_s}置く"
1036
+ if eff.params.get("type") == "energy":
1037
+ desc = "Place Energy under member" if not is_jp else "メンバーの下にエネルギーを置く"
1038
+ else:
1039
+ try:
1040
+ desc = template.format(**context)
1041
+ except KeyError:
1042
+ desc = template
1043
+
1044
+ # Clean up descriptions
1045
+ if eff.params.get("live") and "live" not in desc.lower() and "meta" not in desc.lower():
1046
+ desc = f"{desc} (Live Rule)"
1047
+
1048
+ # Contextual refinements without spamming "Interaction" tags
1049
+ if eff.params.get("per_energy"):
1050
+ desc += " per Energy"
1051
+ if eff.params.get("per_member"):
1052
+ desc += " per Member"
1053
+ if eff.params.get("per_live"):
1054
+ desc += " per Live"
1055
+
1056
+ # Target Context
1057
+ if eff.target == TargetType.MEMBER_SELECT:
1058
+ desc += " (Choose member)"
1059
+ if eff.target == TargetType.OPPONENT or eff.target == TargetType.OPPONENT_HAND:
1060
+ if "opponent" not in desc.lower():
1061
+ desc += " (Opponent)"
1062
+
1063
+ # Trigger Remote Context
1064
+ if eff.effect_type == EffectType.TRIGGER_REMOTE:
1065
+ zone = eff.params.get("from", "unknown")
1066
+ desc += f" from {zone}"
1067
+
1068
+ # Reveal Context
1069
+ if eff.effect_type == EffectType.REVEAL_CARDS:
1070
+ if "from" in eff.params and eff.params["from"] == "deck":
1071
+ desc += " from Deck"
1072
+ if eff.effect_type == EffectType.MOVE_TO_DECK:
1073
+ if eff.params.get("to_energy_deck"):
1074
+ desc = "Return to Energy Deck"
1075
+ elif eff.params.get("from") == "discard":
1076
+ desc += " from Discard"
1077
+
1078
+ if eff.params.get("rest") == "discard" or eff.params.get("on_fail") == "discard":
1079
+ if "discard" not in desc.lower():
1080
+ desc += " (else Discard)"
1081
+
1082
+ if eff.params.get("both_players"):
1083
+ desc += " (Both Players)" if not is_jp else " (両プレイヤー)"
1084
+
1085
+ if eff.params.get("filter") == "live" and "live" not in desc.lower() and "ライブ" not in desc:
1086
+ desc += " (Live Card)" if not is_jp else " (ライブカード)"
1087
+ if eff.params.get("filter") == "energy" and "energy" not in desc.lower() and "エネ" not in desc:
1088
+ desc += " (Energy)" if not is_jp else " (エネルギー)"
1089
+
1090
+ parts.append(f"→ {desc}")
1091
+
1092
+ # Check for Effect-level modal options (e.g. from parser fix)
1093
+ if eff.modal_options:
1094
+ for i, option in enumerate(eff.modal_options):
1095
+ opt_descs = []
1096
+ for sub_eff in option:
1097
+ template = e_desc_map.get(sub_eff.effect_type, sub_eff.effect_type.name)
1098
+ context = sub_eff.params.copy()
1099
+ context["value"] = sub_eff.value
1100
+ try:
1101
+ opt_descs.append(template.format(**context))
1102
+ except KeyError:
1103
+ opt_descs.append(template)
1104
+ parts.append(
1105
+ f"[Option {i + 1}: {' + '.join(opt_descs)}]"
1106
+ if not is_jp
1107
+ else f"[選択肢 {i + 1}: {' + '.join(opt_descs)}]"
1108
+ )
1109
+
1110
+ # Include modal options (Ability level - legacy/bullet points)
1111
+ if self.modal_options:
1112
+ for i, option in enumerate(self.modal_options):
1113
+ opt_descs = []
1114
+ for eff in option:
1115
+ template = e_desc_map.get(eff.effect_type, eff.effect_type.name)
1116
+ context = eff.params.copy()
1117
+ context["value"] = eff.value
1118
+ try:
1119
+ opt_descs.append(template.format(**context))
1120
+ except KeyError:
1121
+ opt_descs.append(template)
1122
+ parts.append(
1123
+ f"[Option {i + 1}: {' + '.join(opt_descs)}]"
1124
+ if not is_jp
1125
+ else f"[選択肢 {i + 1}: {' + '.join(opt_descs)}]"
1126
+ )
1127
+
1128
+ return " ".join(parts)
engine/models/card.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import field
2
+ from typing import Annotated, Any, Dict, List
3
+
4
+ import numpy as np
5
+ import pydantic
6
+ from pydantic import BeforeValidator, ConfigDict, field_serializer
7
+
8
+ from engine.models.ability import Ability
9
+ from engine.models.enums import Group, Unit, ensure_group_list, ensure_unit_list
10
+
11
+
12
+ def ensure_ndarray(v: Any) -> Any:
13
+ """Validator to convert list/dict to numpy array"""
14
+ if isinstance(v, list):
15
+ return np.array(v, dtype=np.int32)
16
+ return v
17
+
18
+
19
+ @pydantic.dataclasses.dataclass(config=ConfigDict(arbitrary_types_allowed=True))
20
+ class MemberCard:
21
+ """Represents a member card with all attributes"""
22
+
23
+ card_id: int
24
+ card_no: str
25
+ name: str
26
+ cost: int
27
+ hearts: Annotated[
28
+ np.ndarray, BeforeValidator(ensure_ndarray)
29
+ ] # Shape (7,) for each color count (0-5: colors, 6: any/unassigned)
30
+ blade_hearts: Annotated[
31
+ np.ndarray, BeforeValidator(ensure_ndarray)
32
+ ] # Shape (7,) blade hearts by color (Index 6 = ALL)
33
+ blades: int
34
+ groups: Annotated[List[Group], BeforeValidator(ensure_group_list)] = field(default_factory=list)
35
+ units: Annotated[List[Unit], BeforeValidator(ensure_unit_list)] = field(default_factory=list)
36
+ abilities: List[Ability] = field(default_factory=list)
37
+ img_path: str = ""
38
+ rare: str = "N" # Default to Normal (updated from rarity to match tests)
39
+ # Rule 2.12: カードテキスト (Card Text)
40
+ ability_text: str = ""
41
+ original_text: str = ""
42
+ # Rule 2.7: ブレードハート (Blade Heart Icons)
43
+ volume_icons: int = 0
44
+ draw_icons: int = 0
45
+ faq: List[Dict[str, Any]] = field(default_factory=list)
46
+
47
+ @field_serializer("hearts", "blade_hearts")
48
+ def serialize_array(self, v: np.ndarray, _info):
49
+ return v.tolist()
50
+
51
+ def total_hearts(self) -> int:
52
+ return int(np.sum(self.hearts))
53
+
54
+ def total_blade_hearts(self) -> int:
55
+ return int(np.sum(self.blade_hearts))
56
+
57
+
58
+ @pydantic.dataclasses.dataclass(config=ConfigDict(arbitrary_types_allowed=True))
59
+ class LiveCard:
60
+ """Represents a live/song card"""
61
+
62
+ card_id: int
63
+ card_no: str
64
+ name: str
65
+ score: int
66
+ required_hearts: Annotated[
67
+ np.ndarray, BeforeValidator(ensure_ndarray)
68
+ ] # Shape (7,) required hearts by color (6 colors + any)
69
+ abilities: List[Ability] = field(default_factory=list)
70
+ groups: Annotated[List[Group], BeforeValidator(ensure_group_list)] = field(default_factory=list)
71
+ units: Annotated[List[Unit], BeforeValidator(ensure_unit_list)] = field(default_factory=list)
72
+ img_path: str = ""
73
+ rare: str = "N"
74
+ ability_text: str = ""
75
+ original_text: str = ""
76
+ volume_icons: int = 0
77
+ draw_icons: int = 0
78
+ blade_hearts: Annotated[np.ndarray, BeforeValidator(ensure_ndarray)] = field(
79
+ default_factory=lambda: np.zeros(7, dtype=np.int32)
80
+ )
81
+ faq: List[Dict[str, Any]] = field(default_factory=list)
82
+
83
+ @field_serializer("required_hearts", "blade_hearts")
84
+ def serialize_array(self, v: np.ndarray, _info):
85
+ return v.tolist()
86
+
87
+ def total_required(self) -> int:
88
+ return int(np.sum(self.required_hearts[:6])) # Exclude star/any
89
+
90
+ def total_blade_hearts(self) -> int:
91
+ return int(np.sum(self.blade_hearts))
92
+
93
+
94
+ @pydantic.dataclasses.dataclass(config=ConfigDict(arbitrary_types_allowed=True))
95
+ class EnergyCard:
96
+ """Represents an energy card with metadata"""
97
+
98
+ card_id: int
99
+ card_no: str = ""
100
+ name: str = "Energy"
101
+ img_path: str = ""
102
+ ability_text: str = ""
103
+ original_text: str = ""
104
+ rare: str = "N"
engine/models/choice_types.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import IntEnum
2
+
3
+
4
+ class ChoiceType(IntEnum):
5
+ NONE = 0
6
+ TARGET_HAND = 1
7
+ TARGET_MEMBER = 2
8
+ TARGET_MEMBER_SLOT = 3
9
+ DISCARD_SELECT = 4
10
+ MODAL = 5
11
+ SELECT_MODE = 6
12
+ CHOOSE_FORMATION = 7
13
+ COLOR_SELECT = 8
14
+ SELECT_FROM_LIST = 9
15
+ TARGET_OPPONENT_MEMBER = 10
16
+ SELECT_SWAP_SOURCE = 11
17
+ TARGET_SUCCESS_LIVES = 12
18
+ TARGET_DISCARD = 13
19
+ TARGET_REMOVED = 14
20
+ TARGET_DECK = 15
21
+ TARGET_LIVE = 16
engine/models/context_indices.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import IntEnum
2
+
3
+
4
+ class ContextIndex(IntEnum):
5
+ """
6
+ Indices for the fixed-size NumPy context array (int32).
7
+ Size should be enough to hold all standard context variables.
8
+ Standard Size: 64 integers.
9
+ """
10
+
11
+ # Header / Meta
12
+ TYPE = 0 # Context Type (Trigger, Effect, etc)
13
+ PLAYER_ID = 1 # Owner/Active Player
14
+ OPPONENT_ID = 2
15
+ PHASE = 3
16
+ TURN = 4
17
+
18
+ # Source Info
19
+ SOURCE_CARD_ID = 5
20
+ SOURCE_ZONE = 6 # Zone Enum
21
+ SOURCE_ZONE_IDX = 7
22
+ SOURCE_TYPE = 8 # Member/Live
23
+
24
+ # Target Info
25
+ TARGET_CARD_ID = 10
26
+ TARGET_ZONE = 11
27
+ TARGET_ZONE_IDX = 12
28
+ TARGET_PLAYER_ID = 13
29
+ TARGET_COUNT = 14 # How many targets
30
+
31
+ # Payload / Parameters
32
+ VALUE = 20 # Generic Value (Amount, Score, etc)
33
+ COST_PAID = 21 # Boolean/Chek
34
+ ATTRIBUTE = 22 # Color/Attribute
35
+ GROUP = 23 # Group Enum
36
+ SUB_TYPE = 24 # Trigger Subtype / Effect Subtype
37
+
38
+ # Mapped Params (Generic registers)
39
+ PARAM_1 = 30
40
+ PARAM_2 = 31
41
+ PARAM_3 = 32
42
+ PARAM_4 = 33
43
+
44
+ # Flags (Bitmask)
45
+ FLAGS = 50 # Optional, Negated, etc.
46
+
47
+ # Execution State
48
+ STEP_INDEX = 60
49
+ TOTAL_STEPS = 61
50
+
51
+ SIZE = 64
engine/models/enums.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import IntEnum
2
+ from typing import Any, List
3
+
4
+
5
+ class CardType(IntEnum):
6
+ """Card types in the game"""
7
+
8
+ MEMBER = 0
9
+ LIVE = 1
10
+ ENERGY = 2
11
+
12
+
13
+ class HeartColor(IntEnum):
14
+ """Heart/color types (6 colors + any + rainbow)"""
15
+
16
+ PINK = 0
17
+ RED = 1
18
+ YELLOW = 2
19
+ GREEN = 3
20
+ BLUE = 4
21
+ PURPLE = 5
22
+ ANY = 6 # Colorless requirement
23
+ RAINBOW = 7 # Can be any color
24
+
25
+
26
+ class Area(IntEnum):
27
+ """Member areas on stage"""
28
+
29
+ LEFT = 0
30
+ CENTER = 1
31
+ RIGHT = 2
32
+
33
+
34
+ class Group(IntEnum):
35
+ """Card Groups (Series/Schools)"""
36
+
37
+ MUSE = 0
38
+ AQOURS = 1
39
+ NIJIGASAKI = 2
40
+ LIELLA = 3
41
+ HASUNOSORA = 4
42
+ LIVE = 98
43
+ OTHER = 99
44
+
45
+ @classmethod
46
+ def from_japanese_name(cls, name: str) -> "Group":
47
+ name = name.strip()
48
+ name_lower = name.lower()
49
+ if "ラブライブ!" == name or "μ's" in name or "muse" in name_lower:
50
+ return cls.MUSE
51
+ if "サンシャイン" in name or "aqours" in name_lower:
52
+ return cls.AQOURS
53
+ if "虹ヶ咲" in name or "nijigasaki" in name_lower:
54
+ return cls.NIJIGASAKI
55
+ if "スーパースター" in name or "liella" in name_lower:
56
+ return cls.LIELLA
57
+ if "蓮ノ空" in name or "hasunosora" in name_lower:
58
+ return cls.HASUNOSORA
59
+ return cls.OTHER
60
+
61
+
62
+ class Unit(IntEnum):
63
+ """Card Units"""
64
+
65
+ PRINTEMPS = 0
66
+ LILY_WHITE = 1
67
+ BIBI = 2
68
+ CYARON = 3
69
+ AZALEA = 4
70
+ GUILTY_KISS = 5
71
+ DIVER_DIVA = 6
72
+ A_ZU_NA = 7
73
+ QU4RTZ = 8
74
+ R3BIRTH = 9
75
+ CATCHU = 10
76
+ KALEIDOSCORE = 11
77
+ SYNCRISE = 12
78
+ CERISE_BOUQUET = 13
79
+ DOLLCHESTRA = 14
80
+ MIRA_CRA_PARK = 15
81
+ EDEL_NOTE = 16
82
+ OTHER = 99
83
+
84
+ @classmethod
85
+ def from_japanese_name(cls, name: str) -> "Unit":
86
+ name = name.strip()
87
+ name_lower = name.lower()
88
+ if "printemps" in name_lower:
89
+ return cls.PRINTEMPS
90
+ if "lily white" in name_lower or "lilywhite" in name_lower:
91
+ return cls.LILY_WHITE
92
+ if "bibi" in name_lower:
93
+ return cls.BIBI
94
+ if "cyaron" in name_lower or "cyaron!" in name_lower:
95
+ return cls.CYARON
96
+ if "azalea" in name_lower:
97
+ return cls.AZALEA
98
+ if "guilty kiss" in name_lower or "guiltykiss" in name_lower:
99
+ return cls.GUILTY_KISS
100
+ if "diverdiva" in name_lower:
101
+ return cls.DIVER_DIVA
102
+ if "azuna" in name_lower or "a・zu・na" in name_lower:
103
+ return cls.A_ZU_NA
104
+ if "qu4rtz" in name_lower:
105
+ return cls.QU4RTZ
106
+ if "r3birth" in name_lower:
107
+ return cls.R3BIRTH
108
+ if "catchu" in name_lower:
109
+ return cls.CATCHU
110
+ if "kaleidoscore" in name_lower:
111
+ return cls.KALEIDOSCORE
112
+ if "5yncri5e" in name_lower:
113
+ return cls.SYNCRISE
114
+ if "スリーズブーケ" in name or "cerise" in name_lower:
115
+ return cls.CERISE_BOUQUET
116
+ if "dollchestra" in name_lower:
117
+ return cls.DOLLCHESTRA
118
+ if "みらくらぱーく" in name or "mira-cra" in name_lower or "mirakura" in name_lower:
119
+ return cls.MIRA_CRA_PARK
120
+ if "edelnote" in name_lower:
121
+ return cls.EDEL_NOTE
122
+ if not name:
123
+ return cls.OTHER
124
+ return cls.OTHER
125
+
126
+
127
+ def ensure_group_list(v: Any) -> List[Group]:
128
+ """Validator to convert string/single Group to List[Group]"""
129
+ if isinstance(v, list):
130
+ return [
131
+ g if isinstance(g, Group) else Group(g) if isinstance(g, int) else Group.from_japanese_name(str(g))
132
+ for g in v
133
+ ]
134
+ if isinstance(v, Group):
135
+ return [v]
136
+ if isinstance(v, int):
137
+ return [Group(v)]
138
+ if isinstance(v, str):
139
+ if not v:
140
+ return []
141
+ parts = [p.strip() for p in v.split("\n") if p.strip()]
142
+ return [Group.from_japanese_name(p) for p in parts]
143
+ return []
144
+
145
+
146
+ def ensure_unit_list(v: Any) -> List[Unit]:
147
+ """Validator to convert string/single Unit to List[Unit]"""
148
+ if isinstance(v, list):
149
+ return [
150
+ u if isinstance(u, Unit) else Unit(u) if isinstance(u, int) else Unit.from_japanese_name(str(u)) for u in v
151
+ ]
152
+ if isinstance(v, Unit):
153
+ return [v]
154
+ if isinstance(v, int):
155
+ return [Unit(v)]
156
+ if isinstance(v, str):
157
+ if not v:
158
+ return []
159
+ parts = [p.strip() for p in v.split("\n") if p.strip()]
160
+ return [Unit.from_japanese_name(p) for p in parts]
161
+ return []
engine/models/opcodes.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import IntEnum
2
+
3
+
4
+ class Opcode(IntEnum):
5
+ # Core Flow
6
+ NOP = 0
7
+ RETURN = 1
8
+ JUMP = 2
9
+ JUMP_IF_FALSE = 3
10
+
11
+ # State Modification (10-99)
12
+ DRAW = 10
13
+ ADD_BLADES = 11
14
+ ADD_HEARTS = 12
15
+ REDUCE_COST = 13
16
+ LOOK_DECK = 14
17
+ RECOVER_LIVE = 15
18
+ BOOST_SCORE = 16
19
+ RECOVER_MEMBER = 17
20
+ BUFF_POWER = 18
21
+ IMMUNITY = 19
22
+ MOVE_MEMBER = 20
23
+ SWAP_CARDS = 21
24
+ SEARCH_DECK = 22
25
+ ENERGY_CHARGE = 23
26
+ SET_BLADES = 24
27
+ SET_HEARTS = 25
28
+ FORMATION_CHANGE = 26
29
+ NEGATE_EFFECT = 27
30
+ ORDER_DECK = 28 # Reorder cards in deck. Attr: 0=Discard, 1=DeckTop, 2=DeckBottom
31
+ META_RULE = 29
32
+ SELECT_MODE = 30
33
+ MOVE_TO_DECK = 31
34
+ TAP_OPPONENT = 32
35
+ PLACE_UNDER = 33
36
+ FLAVOR_ACTION = 34
37
+ RESTRICTION = 35
38
+ BATON_TOUCH_MOD = 36
39
+ SET_SCORE = 37
40
+ SWAP_ZONE = 38
41
+ TRANSFORM_COLOR = 39
42
+ REVEAL_CARDS = 40
43
+ LOOK_AND_CHOOSE = 41
44
+ CHEER_REVEAL = 42
45
+ ACTIVATE_MEMBER = 43
46
+ ADD_TO_HAND = 44
47
+ COLOR_SELECT = 45
48
+ REPLACE_EFFECT = 46
49
+ TRIGGER_REMOTE = 47
50
+ REDUCE_HEART_REQ = 48
51
+ MODIFY_SCORE_RULE = 49
52
+ ADD_STAGE_ENERGY = 50 # Internal
53
+ SET_TAPPED = 51 # Internal
54
+ ADD_CONTINUOUS = 52 # Internal
55
+ TAP_MEMBER = 53
56
+ PLAY_MEMBER_FROM_HAND = 57
57
+ MOVE_TO_DISCARD = 58
58
+
59
+ # --- Added to fix systemic parsing issues ---
60
+ GRANT_ABILITY = 60
61
+ INCREASE_HEART_COST = 61
62
+ REDUCE_YELL_COUNT = 62
63
+ PLAY_MEMBER_FROM_DISCARD = 63
64
+ PAY_ENERGY = 64
65
+ SELECT_MEMBER = 65
66
+ DRAW_UNTIL = 66
67
+ SELECT_PLAYER = 67
68
+ SELECT_LIVE = 68
69
+ REVEAL_UNTIL = 69
70
+ INCREASE_COST = 70
71
+ PREVENT_PLAY_TO_SLOT = 71
72
+ SWAP_AREA = 72
73
+ TRANSFORM_HEART = 73
74
+ SELECT_CARDS = 74
75
+ OPPONENT_CHOOSE = 75
76
+ PLAY_LIVE_FROM_DISCARD = 76
77
+ REDUCE_LIVE_SET_LIMIT = 77
78
+ PREVENT_ACTIVATE = 82
79
+ ACTIVATE_ENERGY = 81
80
+
81
+ # Target Specification (100-199)
82
+ SET_TARGET_SELF = 100
83
+ SET_TARGET_PLAYER = 101
84
+ SET_TARGET_OPPONENT = 102
85
+ SET_TARGET_ALL_PLAYERS = 103
86
+ SET_TARGET_MEMBER_SELF = 104
87
+ SET_TARGET_MEMBER_OTHER = 105
88
+ SET_TARGET_CARD_HAND = 106
89
+ SET_TARGET_CARD_DISCARD = 107
90
+ SET_TARGET_CARD_DECK_TOP = 108
91
+ SET_TARGET_OPPONENT_HAND = 109
92
+ SET_TARGET_MEMBER_SELECT = 110
93
+ SET_TARGET_MEMBER_NAMED = 111
94
+
95
+ # Condition Checks (200-299)
96
+ CHECK_TURN_1 = 200
97
+ CHECK_HAS_MEMBER = 201
98
+ CHECK_HAS_COLOR = 202
99
+ CHECK_COUNT_STAGE = 203
100
+ CHECK_COUNT_HAND = 204
101
+ CHECK_COUNT_DISCARD = 205
102
+ CHECK_IS_CENTER = 206
103
+ CHECK_LIFE_LEAD = 207
104
+ CHECK_COUNT_GROUP = 208
105
+ CHECK_GROUP_FILTER = 209
106
+ CHECK_OPPONENT_HAS = 210
107
+ CHECK_SELF_IS_GROUP = 211
108
+ CHECK_MODAL_ANSWER = 212
109
+ CHECK_COUNT_ENERGY = 213
110
+ CHECK_HAS_LIVE_CARD = 214
111
+ CHECK_COST_CHECK = 215
112
+ CHECK_RARITY_CHECK = 216
113
+ CHECK_HAND_HAS_NO_LIVE = 217
114
+ CHECK_COUNT_SUCCESS_LIVE = 218
115
+ CHECK_OPPONENT_HAND_DIFF = 219
116
+ CHECK_SCORE_COMPARE = 220
117
+ CHECK_HAS_CHOICE = 221
118
+ CHECK_OPPONENT_CHOICE = 222
119
+ CHECK_COUNT_HEARTS = 223
120
+ CHECK_COUNT_BLADES = 224
121
+ CHECK_OPPONENT_ENERGY_DIFF = 225
122
+ CHECK_HAS_KEYWORD = 226
123
+ CHECK_DECK_REFRESHED = 227
124
+ CHECK_HAS_MOVED = 228
125
+ CHECK_HAND_INCREASED = 229
126
+ CHECK_COUNT_LIVE_ZONE = 230
127
+ CHECK_BATON = 231
128
+ CHECK_TYPE_CHECK = 232
129
+ CHECK_IS_IN_DISCARD = 233