Spaces:
Sleeping
Sleeping
| """ | |
| Ability Validator - Tests card abilities with AI/User parity checks. | |
| Compares what the AI agent sees (raw legal action bitmask) with what | |
| the User sees in the action bar (serialized legal_actions). | |
| """ | |
| import argparse | |
| import json | |
| import multiprocessing | |
| import os | |
| import sys | |
| import time | |
| import traceback | |
| # --- PATH SETUP --- | |
| CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| PROJECT_ROOT = os.path.abspath(os.path.join(CURRENT_DIR, "..")) | |
| if PROJECT_ROOT not in sys.path: | |
| sys.path.insert(0, PROJECT_ROOT) | |
| ENGINE_PATH = os.path.join(PROJECT_ROOT, "engine") | |
| if ENGINE_PATH not in sys.path: | |
| sys.path.insert(0, ENGINE_PATH) | |
| import engine_rust | |
| from game.data_loader import CardDataLoader | |
| from backend.rust_serializer import RustGameStateSerializer | |
| def get_state_snapshot(gs, p_idx=0): | |
| """Captures a snapshot of the player's state for delta comparison.""" | |
| p = gs.get_player(p_idx) | |
| return { | |
| "hand_size": len(p.hand), | |
| "discard_size": len(p.discard), | |
| "score": p.score, | |
| "energy_size": len(p.energy_zone), | |
| "tapped_count": sum(1 for t in p.tapped_energy if t), | |
| "stage": list(p.stage), | |
| "heart_buffs": [list(h) for h in p.heart_buffs], # List of 7 colors | |
| "blade_buffs": list(p.blade_buffs), | |
| } | |
| def validate_card_worker(args): | |
| """ | |
| Worker function to validate a single card. | |
| Args: | |
| args: tuple containing (card_tuple, plinth_cid, energy_cid, m_ids, l_ids, compiled_json_str, member_db_data, live_db_data, energy_db_data) | |
| """ | |
| (cid, card_obj_data, card_type), plinth_cid, energy_cid, m_ids, l_ids, compiled_json_str, m_db, l_db, e_db = args | |
| # Reconstruct card object data | |
| card_label = card_obj_data.card_no | |
| # Re-initialize Rust DB and Serializer in worker | |
| try: | |
| rust_db = engine_rust.PyCardDatabase(compiled_json_str) | |
| serializer = RustGameStateSerializer(m_db, l_db, e_db) | |
| except Exception as e: | |
| return { | |
| "card_no": card_label, | |
| "status": "CRASH", | |
| "error": f"Worker init failed: {e}", | |
| "traceback": traceback.format_exc(), | |
| } | |
| # Result container for this card | |
| card_result = { | |
| "card_no": card_label, | |
| "status": "UNKNOWN", | |
| "parity_gaps": [], | |
| "crashes": [], | |
| "hangs": [], | |
| "dormant": False, | |
| "skipped": False, | |
| "reason": "", # For skips/dormant | |
| } | |
| # --- NEW CHECK: Lost Compiled Abilities --- | |
| # compiled_json_str is already available. | |
| try: | |
| comp_dict = json.loads(compiled_json_str) | |
| comp_card = comp_dict.get(str(cid)) | |
| has_text = hasattr(card_obj_data, "ability") and card_obj_data.ability and card_obj_data.ability != "なし" | |
| if has_text and comp_card: | |
| if not comp_card.get("abilities", []): | |
| card_result["status"] = "CRASH" | |
| card_result["crashes"].append( | |
| { | |
| "card_no": card_label, | |
| "action_id": -1, | |
| "error": "Card has ability text but generated 0 compiled abilities (Missing TRIGGER / Parser failure)", | |
| "traceback": "", | |
| } | |
| ) | |
| return card_result | |
| except Exception: | |
| pass | |
| try: | |
| # Create fresh game state | |
| gs = engine_rust.PyGameState(rust_db) | |
| # Dummy decks for initialization | |
| p0_deck = [plinth_cid] * 60 | |
| p1_deck = [m_ids[0]] * 60 if m_ids else [plinth_cid] * 60 | |
| p0_energy = [energy_cid] * 60 | |
| p1_energy = [energy_cid] * 60 | |
| p0_lives = l_ids[:3] if len(l_ids) >= 3 else l_ids | |
| p1_lives = l_ids[:3] if len(l_ids) >= 3 else l_ids | |
| # with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): | |
| gs.initialize_game(p0_deck, p1_deck, p0_energy, p1_energy, p0_lives, p1_lives) | |
| # === GOD MODE SETUP === | |
| p0 = gs.get_player(0) | |
| p0.energy_zone = [energy_cid] * 12 | |
| p0.tapped_energy = [False] * 12 | |
| gs.set_player(0, p0) | |
| # Place plinth(s) on stage | |
| gs.set_stage_card(0, 0, plinth_cid) | |
| if card_type == "LIVE": | |
| gs.set_stage_card(0, 1, plinth_cid) | |
| gs.set_stage_card(0, 2, plinth_cid) | |
| # Set hand to target card only | |
| gs.set_hand_cards(0, [cid]) | |
| # Set phase | |
| gs.turn = 1 | |
| gs.current_player = 0 | |
| if card_type == "MEMBER": | |
| gs.phase = 4 # Main | |
| else: | |
| gs.phase = 5 # LiveSet | |
| # Play the card | |
| # Action space: 1-180 for members (slot_idx + hand_idx*3), 400+ for live cards | |
| play_action = 1 if card_type == "MEMBER" else 400 | |
| if card_type == "MEMBER": | |
| legal = gs.get_legal_actions() | |
| if not legal[play_action]: | |
| # Try other slots | |
| for slot in [1, 2]: | |
| alt_action = 1 + slot | |
| if legal[alt_action]: | |
| play_action = alt_action | |
| break | |
| else: | |
| # Still not playable | |
| p0 = gs.get_player(0) | |
| untapped = sum(1 for t in p0.tapped_energy if not t) | |
| card_cost = card_obj_data.cost if hasattr(card_obj_data, "cost") else 0 | |
| card_result["status"] = "SKIPPED" | |
| card_result["skipped"] = True | |
| card_result["reason"] = f"Energy: {untapped}/12, Cost: {card_cost}" | |
| return card_result | |
| # Take snapshot before play | |
| pre_snap = get_state_snapshot(gs, 0) | |
| try: | |
| # with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): | |
| gs.step(play_action) | |
| except Exception as e: | |
| card_result["status"] = "CRASH" | |
| card_result["crashes"].append( | |
| { | |
| "card_no": card_label, | |
| "action_id": play_action, | |
| "error": f"Play action failed: {e}", | |
| "traceback": traceback.format_exc(), | |
| } | |
| ) | |
| return card_result | |
| # Handle Response phase if triggered | |
| response_steps = 0 | |
| while gs.phase == 10 and response_steps < 5: | |
| legal = gs.get_legal_actions() | |
| choices = [i for i, v in enumerate(legal) if v] | |
| if not choices: | |
| break | |
| choice = 0 if legal[0] else choices[0] | |
| try: | |
| # with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): | |
| gs.step(choice) | |
| except Exception as e: | |
| card_result["status"] = "CRASH" | |
| card_result["crashes"].append( | |
| { | |
| "card_no": card_label, | |
| "action_id": choice, | |
| "error": f"Response choice failed: {e}", | |
| "traceback": traceback.format_exc(), | |
| } | |
| ) | |
| return card_result | |
| response_steps += 1 | |
| # Get AI view (raw bitmask) | |
| raw_mask = gs.get_legal_actions() | |
| ai_action_ids = set(i for i, v in enumerate(raw_mask) if v) | |
| # Get User view (serialized for frontend) | |
| state_view = serializer.serialize_state(gs, viewer_idx=0) | |
| user_action_ids = set(a["id"] for a in state_view.get("legal_actions", [])) | |
| # Parity check | |
| missing_from_user = ai_action_ids - user_action_ids | |
| if missing_from_user: | |
| for aid in missing_from_user: | |
| card_result["parity_gaps"].append( | |
| {"card_no": card_label, "action_id": aid, "issue": "AI can see action but User cannot"} | |
| ) | |
| # Execute ability actions (200-299) | |
| ability_actions = [a for a in ai_action_ids if 200 <= a <= 299] | |
| executed_ok = True | |
| for aid in ability_actions: | |
| try: | |
| start = time.time() | |
| # with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): | |
| gs.step(aid) | |
| elapsed = time.time() - start | |
| if elapsed > 2.0: | |
| card_result["hangs"].append({"card_no": card_label, "action_id": aid, "time_sec": elapsed}) | |
| # We don't mark executed_ok = False for hangs, just log it. | |
| except Exception as e: | |
| card_result["crashes"].append({"card_no": card_label, "action_id": aid, "error": str(e)}) | |
| executed_ok = False | |
| # Take snapshot after everything | |
| post_snap = get_state_snapshot(gs, 0) | |
| # Delta Verification | |
| has_ability = hasattr(card_obj_data, "ability") and card_obj_data.ability and card_obj_data.ability != "なし" | |
| if executed_ok and has_ability: | |
| deltas = [] | |
| if post_snap["hand_size"] != pre_snap["hand_size"] - (1 if card_type == "MEMBER" else 0): | |
| deltas.append("hand") | |
| if post_snap["discard_size"] > pre_snap["discard_size"]: | |
| deltas.append("discard") | |
| if post_snap["score"] > pre_snap["score"]: | |
| deltas.append("score") | |
| buff_found = False | |
| for s_idx in range(3): | |
| if sum(post_snap["heart_buffs"][s_idx]) > sum(pre_snap["heart_buffs"][s_idx]): | |
| buff_found = True | |
| if post_snap["blade_buffs"][s_idx] > pre_snap["blade_buffs"][s_idx]: | |
| buff_found = True | |
| if buff_found: | |
| deltas.append("buffs") | |
| if not deltas: | |
| card_result["dormant"] = True | |
| card_result["reason"] = "No state change detected" | |
| if not card_result["crashes"] and not card_result["parity_gaps"]: | |
| card_result["status"] = "SUCCESS" | |
| else: | |
| card_result["status"] = "FAILED" | |
| return card_result | |
| except Exception as e: | |
| return { | |
| "card_no": card_label, | |
| "status": "CRASH", | |
| "error": f"Worker unhandled exception: {e}", | |
| "traceback": traceback.format_exc(), | |
| } | |
| def validate_abilities(limit=None, card_filter=None, verbose=False, parallel=False): | |
| """Main validation function.""" | |
| print("=" * 60) | |
| print("ABILITY VALIDATOR (God Mode + Baton Pass)") | |
| if parallel: | |
| print(f"Parallel Mode: {multiprocessing.cpu_count()} cores") | |
| print("=" * 60) | |
| # Load data | |
| print("Loading card data...") | |
| loader = CardDataLoader("data/cards.json") | |
| member_db, live_db, energy_db = loader.load() | |
| # Find Ultimate Plinth | |
| ULTIMATE_PLINTH = "PL!SP-bp4-004-R+" | |
| plinth_cid = None | |
| for cid, m_card in member_db.items(): | |
| if m_card.card_no == ULTIMATE_PLINTH: | |
| plinth_cid = cid | |
| break | |
| if not plinth_cid: | |
| plinth_cid = max(member_db.keys(), key=lambda c: member_db[c].cost) | |
| # Find energy card ID | |
| energy_cid = list(energy_db.keys())[0] if energy_db else 40000 | |
| compiled_path = "data/cards_compiled.json" | |
| if not os.path.exists(compiled_path): | |
| print(f"ERROR: {compiled_path} not found. Run `uv run python -m compiler.main` first.") | |
| return | |
| with open(compiled_path, "r", encoding="utf-8") as f: | |
| compiled_json_str = f.read() | |
| # Results | |
| results = { | |
| "timestamp": time.strftime("%Y%m%d_%H%M%S"), | |
| "plinth_used": ULTIMATE_PLINTH, | |
| "total_cards": 0, | |
| "tested": 0, | |
| "success": [], | |
| "parity_gaps": [], | |
| "crashes": [], | |
| "hangs": [], | |
| "dormant": [], | |
| "skipped": [], | |
| } | |
| # Prepare test list | |
| members_to_test = [(cid, m, "MEMBER") for cid, m in member_db.items()] | |
| lives_to_test = [(cid, l, "LIVE") for cid, l in live_db.items()] | |
| all_to_test = members_to_test + lives_to_test | |
| if card_filter: | |
| filters = [f.strip().lower() for f in card_filter.split(",")] | |
| all_to_test = [c for c in all_to_test if any(f in c[1].card_no.lower() for f in filters)] | |
| all_to_test.sort(key=lambda x: x[1].card_no) | |
| if limit: | |
| all_to_test = all_to_test[:limit] | |
| m_ids = list(member_db.keys()) | |
| l_ids = list(live_db.keys()) | |
| results["total_cards"] = len(all_to_test) | |
| print(f"Testing {len(all_to_test)} cards (Plinth: {ULTIMATE_PLINTH})...") | |
| print("-" * 60) | |
| start_time = time.time() | |
| # Prepare worker args | |
| worker_args = [] | |
| for card_tuple in all_to_test: | |
| # (card_tuple, plinth_cid, energy_cid, m_ids, l_ids, compiled_json_str, m_db, l_db, e_db) | |
| worker_args.append( | |
| (card_tuple, plinth_cid, energy_cid, m_ids, l_ids, compiled_json_str, member_db, live_db, energy_db) | |
| ) | |
| if parallel: | |
| with multiprocessing.Pool(processes=multiprocessing.cpu_count()) as pool: | |
| # imap_unordered used to yield results as they complete | |
| for i, res in enumerate(pool.imap_unordered(validate_card_worker, worker_args)): | |
| process_result(res, results, verbose) | |
| # Progress update every 10 items | |
| if (i + 1) % 10 == 0: | |
| elapsed = time.time() - start_time | |
| speed = (i + 1) / elapsed | |
| print(f"Progress: {i + 1}/{len(worker_args)} ({speed:.1f} cards/sec)") | |
| else: | |
| # Sequential | |
| for i, args in enumerate(worker_args): | |
| res = validate_card_worker(args) | |
| process_result(res, results, verbose) | |
| if (i + 1) % 10 == 0: | |
| print(f"Progress: {i + 1}/{len(worker_args)}") | |
| elapsed_total = time.time() - start_time | |
| print("-" * 60) | |
| print("RESULTS SUMMARY") | |
| print("-" * 60) | |
| print(f"Total cards: {results['total_cards']}") | |
| print(f"Tested: {results['tested']}") | |
| print(f"Success: {len(results['success'])}") | |
| print(f"Parity Gaps: {len(results['parity_gaps'])}") | |
| print(f"Crashes: {len(results['crashes'])}") | |
| print(f"Hangs: {len(results['hangs'])}") | |
| print(f"Dormant: {len(results['dormant'])}") | |
| print(f"Skipped: {len(results['skipped'])}") | |
| print(f"Total Time: {elapsed_total:.2f}s") | |
| # Save report | |
| os.makedirs("reports", exist_ok=True) | |
| report_path = f"reports/ability_validation_{results['timestamp']}.json" | |
| with open(report_path, "w", encoding="utf-8") as f: | |
| json.dump(results, f, indent=2, ensure_ascii=False) | |
| print(f"Report saved to: {report_path}") | |
| print("=" * 60) | |
| return results | |
| def process_result(res, results, verbose): | |
| """Aggregates a worker result into the main results dict.""" | |
| card_label = res["card_no"] | |
| if res["status"] == "SKIPPED": | |
| results["skipped"].append({"card_no": card_label, "reason": res["reason"]}) | |
| if verbose: | |
| print(f"SKIP {card_label}: {res['reason']}") | |
| elif res["status"] == "CRASH": | |
| results["crashes"].append(res) # Res contains details | |
| if verbose: | |
| print(f"CRASH {card_label}: {res.get('error', '')}") | |
| else: | |
| results["tested"] += 1 | |
| # Parity | |
| for gap in res["parity_gaps"]: | |
| results["parity_gaps"].append(gap) | |
| if verbose: | |
| print(f"PARITY GAP {card_label}: {gap['issue']}") | |
| # Hangs | |
| for hang in res["hangs"]: | |
| results["hangs"].append(hang) | |
| if verbose: | |
| print(f"HANG {card_label}: {hang['time_sec']}s") | |
| # Crashes (partial) | |
| for crash in res["crashes"]: | |
| results["crashes"].append(crash) | |
| if verbose: | |
| print(f"CRASH {card_label} (Action): {crash['error']}") | |
| # Dormant | |
| if res["dormant"]: | |
| results["dormant"].append(card_label) | |
| if verbose: | |
| print(f"DORMANT {card_label}") | |
| # Success | |
| if res["status"] == "SUCCESS": | |
| results["success"].append(card_label) | |
| if verbose and res["status"] == "SUCCESS": | |
| print(f"OK {card_label}") | |
| if __name__ == "__main__": | |
| parser = argparse.ArgumentParser(description="Validate card abilities for AI/User parity") | |
| parser.add_argument("--limit", type=int, default=None, help="Limit number of cards to test") | |
| parser.add_argument("--filter", type=str, default=None, help="Filter cards by card_no substring") | |
| parser.add_argument("--parallel", action="store_true", help="Run in parallel using all CPU cores") | |
| parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") | |
| args = parser.parse_args() | |
| validate_abilities(limit=args.limit, card_filter=args.filter, verbose=args.verbose, parallel=args.parallel) | |