From 4fa0cadc7f1d8567465934cc874504b86b079595 Mon Sep 17 00:00:00 2001 From: NikolajDanger Date: Thu, 19 Mar 2026 22:53:33 +0100 Subject: [PATCH] :goat: --- .gitignore | 5 +- backend/ai.py | 461 +++----- backend/card.py | 12 +- backend/game.py | 2 +- backend/main.py | 6 +- backend/simulate.py | 440 ++++++++ backend/tournament_results.json | 1767 +++++++++++++++++++++++++++++++ 7 files changed, 2399 insertions(+), 294 deletions(-) create mode 100644 backend/simulate.py create mode 100644 backend/tournament_results.json diff --git a/.gitignore b/.gitignore index da91899..56aafce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ .vscode/ __pycache__/ .svelte-kit/ -.env \ No newline at end of file +.env + +backend/simulation_cards.json +backend/tournament_grid.png \ No newline at end of file diff --git a/backend/ai.py b/backend/ai.py index 9240f0e..ef5c03f 100644 --- a/backend/ai.py +++ b/backend/ai.py @@ -3,9 +3,10 @@ import random import logging from dataclasses import dataclass from enum import Enum -from itertools import combinations +from itertools import combinations, permutations +import numpy as np from card import Card -from game import action_play_card, action_sacrifice, action_end_turn, BOARD_SIZE, STARTING_LIFE +from game import action_play_card, action_sacrifice, action_end_turn, BOARD_SIZE, STARTING_LIFE, PlayerState logger = logging.getLogger("app") @@ -18,6 +19,7 @@ class AIPersonality(Enum): GREEDY = "greedy" # Prioritizes high cost cards, willing to sacrifice SWARM = "swarm" # Prefers low cost cards, fills board quickly CONTROL = "control" # Focuses on board control and efficiency + SHOCKER = "shocker" # Cheap high-defense walls + a few powerful high-attack finishers ARBITRARY = "arbitrary" # Just does whatever def get_random_personality() -> AIPersonality: @@ -40,78 +42,70 @@ def get_power_curve_value(card) -> float: def choose_cards(cards: list[Card], difficulty: int, personality: AIPersonality) -> list[Card]: BUDGET = 50 - logger.info(f"Personality: {personality.value}") - logger.info(f"Difficulty: {difficulty}") - card_strings = [ - f"{c.name} {c.cost}" - for c in sorted(cards, key=lambda x: x.cost)[::-1][:20] - ] - logger.info("Cards:\n"+("\n".join(card_strings))) - - # God cards (cost 7-11) are gated by difficulty. Below difficulty 7 they are excluded. - # Each level from 7 upward unlocks a higher cost tier; at difficulty 10 all are allowed. if difficulty >= 6: - max_card_cost = difficulty+1 + max_card_cost = difficulty + 1 else: max_card_cost = 6 allowed = [c for c in cards if c.cost <= max_card_cost] or list(cards) - def card_score(card: Card) -> float: - pcv = get_power_curve_value(card) - # Normalize pcv to [0, 1]. - pcv_norm = max(0.0, min(1.0, pcv)) + # Vectorized scoring over all allowed cards at once + atk = np.array([c.attack for c in allowed], dtype=np.float32) + defn = np.array([c.defense for c in allowed], dtype=np.float32) + cost = np.array([c.cost for c in allowed], dtype=np.float32) - cost_norm = card.cost / max_card_cost # [0, 1]; higher = more expensive - total = card.attack + card.defense - atk_ratio = card.attack / total if total else 0.5 + exact_cost = np.minimum(11.0, np.maximum(1.0, ((atk**2 + defn**2)**0.18) / 1.5)) + pcv_norm = np.clip(exact_cost - cost, 0.0, 1.0) + cost_norm = cost / max_card_cost + totals = atk + defn + atk_ratio = np.where(totals > 0, atk / totals, 0.5) + def_not_one = np.where(defn != 1, 1.0, 0.0) - if personality == AIPersonality.AGGRESSIVE: - # Prefers high-attack cards; slight bias toward high cost for raw power - return 0.50 * atk_ratio + 0.30 * pcv_norm + 0.20 * cost_norm + if personality == AIPersonality.AGGRESSIVE: + # (1-cost_norm) penalizes expensive cards. High-attack cards are inherently expensive, + # so without this the second pass drifts toward costly cards at higher difficulty, + # shrinking the deck. The bonus grows with max_card_cost and exactly offsets that drift. + scores = 0.50 * atk_ratio + 0.35 * pcv_norm + 0.15 * (1.0 - cost_norm) + 0.10 * def_not_one + elif personality == AIPersonality.DEFENSIVE: + # Small (1-cost_norm) for the same anti-shrinkage reason; lighter because high-defense + # cards don't correlate as strongly with cost as high-attack cards do. + scores = 0.10 * (1.0 - atk_ratio) + 0.80 * pcv_norm + 0.10 * cost_norm + elif personality == AIPersonality.GREEDY: + # Small cost_norm keeps flavour without causing severe deck shrinkage at D10 + scores = 0.20 * cost_norm + 0.80 * pcv_norm + elif personality == AIPersonality.SWARM: + scores = 0.40 * (1.0 - cost_norm) + 0.35 * atk_ratio + 0.20 * pcv_norm + 0.05 * def_not_one + elif personality == AIPersonality.CONTROL: + # Small cost_norm keeps flavour without causing severe deck shrinkage at D10 + scores = 0.85 * pcv_norm + 0.15 * cost_norm + elif personality == AIPersonality.BALANCED: + scores = 0.60 * pcv_norm + 0.25 * atk_ratio + 0.15 * (1.0 - atk_ratio) + elif personality == AIPersonality.SHOCKER: + # Both cheap walls and expensive finishers want high attack. + # (1-cost_norm) drives first-pass cheap-card selection; pcv_norm drives second-pass finishers. + # defense_ok zeros out cards with defense==1 on the first term so fragile walls are excluded. + # cost-11 cards have pcv=0 so they score near-zero and never shrink the deck. + scores = atk_ratio * (1.0 - cost_norm) * def_not_one + atk_ratio * pcv_norm + else: # ARBITRARY + w = 0.05 * difficulty + scores = w * pcv_norm + (1.0 - w) * np.random.random(len(allowed)).astype(np.float32) - if personality == AIPersonality.DEFENSIVE: - # Prefers high-defense cards; same cost bias - return 0.50 * (1.0 - atk_ratio) + 0.30 * pcv_norm + 0.20 * cost_norm + # Small noise floor at D10 prevents fully deterministic deck building. + # A locked-in deck loses every game against counters; tiny randomness avoids this. + noise = max(0.03, (10 - difficulty) / 9.0) * 0.50 + scores = scores + np.random.normal(0, noise, len(allowed)).astype(np.float32) - if personality == AIPersonality.GREEDY: - # Fills budget with the fewest, most expensive cards possible - return 0.70 * cost_norm + 0.30 * pcv_norm + order = np.argsort(-scores) + sorted_cards = [allowed[i] for i in order] - if personality == AIPersonality.SWARM: - # Cheap cards - return 0.45 * (1.0 - cost_norm) + 0.35 * atk_ratio + 0.20 * pcv_norm - - if personality == AIPersonality.CONTROL: - # Values efficiency above all: wants cards that are above the power curve, - # with a secondary preference for higher cost - return 0.70 * pcv_norm + 0.30 * cost_norm - - if personality == AIPersonality.BALANCED: - # Blends everything: efficiency, cost spread, and a slight attack lean - return 0.40 * pcv_norm + 0.35 * cost_norm + 0.15 * atk_ratio + 0.10 * (1.0 - atk_ratio) - - # ARBITRARY: mostly random at lower difficulties - return (0.05 * difficulty) * pcv_norm + (1 - (0.05 * difficulty)) * random.random() - - # Higher difficulty -> less noise -> more optimal deck composition - noise = ((10 - difficulty) / 9.0) * 0.50 - - scored = sorted( - [(card_score(c) + random.gauss(0, noise), c) for c in allowed], - key=lambda x: x[0], - reverse=True, - ) - - # Minimum budget reserved for cheap (cost 1-3) cards to ensure early-game presence. - # Without cheap cards the AI will play nothing for the first several turns. early_budget = { - AIPersonality.GREEDY: 4, + AIPersonality.GREEDY: 20, # cheap cards are sacrifice fodder for big plays AIPersonality.SWARM: 12, - AIPersonality.AGGRESSIVE: 8, - AIPersonality.DEFENSIVE: 10, + AIPersonality.AGGRESSIVE: 18, # raised: ensures cheap high-attack fodder regardless of difficulty + AIPersonality.DEFENSIVE: 15, # raised: stable cheap-card base across difficulty levels AIPersonality.CONTROL: 8, - AIPersonality.BALANCED: 10, + AIPersonality.BALANCED: 25, # spread the deck across all cost levels + AIPersonality.SHOCKER: 15, # ~15 cost-1 shields, then expensive attackers fill remaining budget AIPersonality.ARBITRARY: 8, }[personality] @@ -120,7 +114,7 @@ def choose_cards(cards: list[Card], difficulty: int, personality: AIPersonality) # First pass: secure early-game cards cheap_spent = 0 - for _, card in scored: + for card in sorted_cards: if cheap_spent >= early_budget: break if card.cost > 3 or total_cost + card.cost > BUDGET: @@ -131,7 +125,7 @@ def choose_cards(cards: list[Card], difficulty: int, personality: AIPersonality) # Second pass: fill remaining budget greedily by score taken = {id(c) for c in selected} - for _, card in scored: + for card in sorted_cards: if total_cost >= BUDGET: break if id(card) in taken or total_cost + card.cost > BUDGET: @@ -139,13 +133,6 @@ def choose_cards(cards: list[Card], difficulty: int, personality: AIPersonality) selected.append(card) total_cost += card.cost - - card_strings = [ - f"{c.name} {c.cost}" - for c in sorted(selected, key=lambda x: x.cost) - ] - logger.info("Selected:\n"+("\n".join(card_strings))) - return selected @@ -182,13 +169,6 @@ def _plans_for_sacrifice(player, opponent, sacrifice_slots): empty_slots = [i for i, c in enumerate(board) if c is None] en_board = opponent.board - # For scoring: open enemy slots first so the simulation reflects - # direct-damage potential accurately. - scoring_slots = ( - [s for s in empty_slots if en_board[s] is None] + - [s for s in empty_slots if en_board[s] is not None] - ) - return [ MovePlan( sacrifice_slots=list(sacrifice_slots), @@ -196,6 +176,7 @@ def _plans_for_sacrifice(player, opponent, sacrifice_slots): label=f"sac{len(sacrifice_slots)}_play{len(cards)}", ) for cards in _affordable_subsets(hand, energy) + for scoring_slots in permutations(empty_slots, len(cards)) ] @@ -214,230 +195,152 @@ def generate_plans(player, opponent) -> list[MovePlan]: return plans +# ==================== Turn execution ==================== -def score_plan(plan: MovePlan, player, opponent, personality: AIPersonality) -> float: - """ - Score a plan from ~0.0 to ~1.0 based on the projected board state after - executing it. Higher is better. - """ - # Simulate board after sacrifices + plays - board = list(player.board) - energy = player.energy - for slot in plan.sacrifice_slots: - if board[slot] is not None: - board[slot] = None - energy += 1 - for card, slot in plan.plays: - board[slot] = card +def score_plans_batch( + plans: list[MovePlan], + player: PlayerState, + opponent: PlayerState, + personality: AIPersonality, +) -> np.ndarray: + n = len(plans) - en_board = opponent.board - enemy_occupied = sum(1 for c in en_board if c is not None) + # Pre-compute PCV for every hand card once + pcv_cache = { + id(c): max(0.0, min(1.0, get_power_curve_value(c))) + for c in player.hand + } - # --- Combat metrics --- - direct_damage = 0 # AI attacks going straight to opponent life - board_damage = 0 # AI attacks hitting enemy cards - blocking_slots = 0 # Slots where AI blocks an enemy card - cards_destroyed = 0 # Enemy cards the AI would destroy this turn - unblocked_incoming = 0 # Enemy attacks that go straight to AI life - cards_on_board = 0 + # Build board-state arrays with one Python loop (unavoidable) + board_atk = np.zeros((n, BOARD_SIZE), dtype=np.float32) + board_occ = np.zeros((n, BOARD_SIZE), dtype=np.bool_) + n_sac = np.zeros(n, dtype=np.float32) + sac_val = np.zeros(n, dtype=np.float32) + play_val = np.zeros(n, dtype=np.float32) + pcv_score = np.full(n, 0.5, dtype=np.float32) - for slot in range(BOARD_SIZE): - my = board[slot] - en = en_board[slot] - if my: - cards_on_board += 1 - if my and en is None: - direct_damage += my.attack - if my and en: - board_damage += my.attack - blocking_slots += 1 - if my.attack >= en.defense: - cards_destroyed += 1 - if not my and en: - unblocked_incoming += en.attack + for idx, plan in enumerate(plans): + board = list(player.board) + for slot in plan.sacrifice_slots: + board_slot = board[slot] + if board_slot is not None: + sac_val[idx] += board_slot.cost + board[slot] = None + n_sac[idx] = len(plan.sacrifice_slots) + for card, slot in plan.plays: + board[slot] = card + play_val[idx] += card.cost + for slot in range(BOARD_SIZE): + board_slot = board[slot] + if board_slot is not None: + board_atk[idx, slot] = board_slot.attack + board_occ[idx, slot] = True + if plan.plays: + pcv_vals = [pcv_cache.get(id(c), 0.5) for c, _ in plan.plays] + pcv_score[idx] = sum(pcv_vals) / len(pcv_vals) - # --- Normalize to [0, 1] --- - # How threatening is the attack relative to what remains of opponent's life? - atk_score = min(1.0, direct_damage / max(opponent.life, 1)) + # Enemy board — same for every plan + en_atk = np.array([c.attack if c else 0 for c in opponent.board], dtype=np.float32) + en_def = np.array([c.defense if c else 0 for c in opponent.board], dtype=np.float32) + en_occ = np.array([c is not None for c in opponent.board], dtype=np.bool_) + enemy_occupied = int(en_occ.sum()) - # What fraction of enemy slots are blocked? - block_score = (blocking_slots / enemy_occupied) if enemy_occupied > 0 else 1.0 + # --- Metrics (all shape (n,)) --- + direct_damage = (board_atk * ~en_occ).sum(axis=1) + blocking = board_occ & en_occ # (n, 5) + blocking_slots = blocking.sum(axis=1).astype(np.float32) + cards_on_board = board_occ.sum(axis=1).astype(np.float32) + cards_destroyed = ((board_atk >= en_def) & blocking).sum(axis=1).astype(np.float32) + unblocked_in = (en_atk * ~board_occ).sum(axis=1) - # What fraction of all slots are filled? - cover_score = cards_on_board / BOARD_SIZE + atk_score = np.minimum(1.0, direct_damage / max(opponent.life, 1)) + block_score = blocking_slots / enemy_occupied if enemy_occupied > 0 else np.ones(n, dtype=np.float32) + open_slots = BOARD_SIZE - enemy_occupied + cover_score = ( + (cards_on_board - blocking_slots) / open_slots + if open_slots > 0 + else np.ones(n, dtype=np.float32) + ) + destroy_score = cards_destroyed / enemy_occupied if enemy_occupied > 0 else np.zeros(n, dtype=np.float32) + threat_score = 1.0 - np.minimum(1.0, unblocked_in / max(player.life, 1)) - # What fraction of enemy cards do are destroyed? - destroy_score = (cards_destroyed / enemy_occupied) if enemy_occupied > 0 else 0.0 + opp_cards_left = len(opponent.deck) + len(opponent.hand) + enemy_occupied + my_cards_left = len(player.deck) + len(player.hand) + blocking_slots + attrition_score = my_cards_left / (my_cards_left + max(opp_cards_left, 1)) - # How safe is the AI from unblocked hits relative to its own life? - threat_score = 1.0 - min(1.0, unblocked_incoming / max(player.life, 1)) + net_value = play_val - sac_val + net_value_norm = np.clip((net_value + 10) / 20, 0.0, 1.0) - # How many cards compared to the enemy? - opponent_cards_left = len(opponent.deck) + len(opponent.hand) + enemy_occupied - my_cards_left = len(player.deck) + len(player.hand) + blocking_slots - attrition_score = my_cards_left/(my_cards_left + opponent_cards_left) - - # Net value: cost of cards played minus cost of cards sacrificed. - n_sac = len(plan.sacrifice_slots) - sac_value = sum(player.board[s].cost for s in plan.sacrifice_slots if player.board[s] is not None) - play_value = sum(c.cost for c, _ in plan.plays) - net_value = play_value - sac_value - net_value_norm = max(0.0, min(1.0, (net_value + 10) / 20)) - - # Sacrifice penalty. Applied as a flat deduction after personality scoring. - sacrifice_penalty = 0.0 - if n_sac > 0: - # Penalty 1: wasted energy. Each sacrifice gives +1 energy; if that energy - # goes unspent it was pointless. Weighted heavily. - energy_leftover = player.energy + n_sac - play_value - wasted_sac_energy = max(0, min(n_sac, energy_leftover)) - wasted_penalty = wasted_sac_energy / n_sac - - # Penalty 2: low-value swap. Each sacrifice should at minimum unlock a card - # that costs more than the one removed (net_value > n_sac means each - # sacrifice bought at least one extra cost point). Anything less is a bad trade. - swap_penalty = max(0.0, min(1.0, (n_sac - net_value) / max(n_sac, 1))) - - sacrifice_penalty = 0.65 * wasted_penalty + 0.35 * swap_penalty - - # Power curve value of the cards played (are they good value for their cost?) - if plan.plays: - pcv_scores = [max(0.0, min(1.0, get_power_curve_value(c))) for c, _ in plan.plays] - pcv_score = sum(pcv_scores) / len(pcv_scores) - else: - pcv_score = 0.5 + # --- Sacrifice penalty --- + energy_leftover = player.energy + n_sac - play_val + wasted_energy = np.maximum(0, np.minimum(n_sac, energy_leftover)) + wasted_penalty = np.where(n_sac > 0, wasted_energy / np.maximum(n_sac, 1), 0.0) + swap_penalty = np.clip((n_sac - net_value) / np.maximum(n_sac, 1), 0.0, 1.0) + sac_penalty = np.where(n_sac > 0, 0.65 * wasted_penalty + 0.35 * swap_penalty, 0.0) # --- Personality weights --- if personality == AIPersonality.AGGRESSIVE: - # Maximize direct damage - score = ( - 0.40 * atk_score + - 0.10 * block_score + - 0.10 * cover_score + - 0.10 * net_value_norm + - 0.15 * destroy_score + - 0.05 * attrition_score + - 0.05 * pcv_score + - 0.05 * threat_score - ) - + score = (0.30 * atk_score + 0.07 * block_score + 0.15 * cover_score + + 0.08 * net_value_norm + 0.25 * destroy_score + + 0.08 * attrition_score + 0.04 * pcv_score + 0.03 * threat_score) elif personality == AIPersonality.DEFENSIVE: - # Block everything - score = ( - 0.05 * atk_score + - 0.35 * block_score + - 0.20 * cover_score + - 0.05 * net_value_norm + - 0.05 * destroy_score + - 0.10 * attrition_score + - 0.05 * pcv_score + - 0.15 * threat_score - ) - + score = (0.12 * atk_score + 0.20 * block_score + 0.18 * cover_score + + 0.04 * net_value_norm + 0.18 * destroy_score + + 0.15 * attrition_score + 0.05 * pcv_score + 0.08 * threat_score) elif personality == AIPersonality.SWARM: - # Fill the board and press with direct damage - score = ( - 0.25 * atk_score + - 0.10 * block_score + - 0.35 * cover_score + - 0.05 * net_value_norm + - 0.05 * destroy_score + - 0.10 * attrition_score + - 0.05 * pcv_score + - 0.05 * threat_score - ) - + score = (0.25 * atk_score + 0.10 * block_score + 0.35 * cover_score + + 0.05 * net_value_norm + 0.05 * destroy_score + + 0.10 * attrition_score + 0.05 * pcv_score + 0.05 * threat_score) elif personality == AIPersonality.GREEDY: - # High-value card plays, willing to sacrifice weak cards for strong ones - score = ( - 0.20 * atk_score + - 0.05 * block_score + - 0.10 * cover_score + - 0.40 * net_value_norm + - 0.05 * destroy_score + - 0.05 * attrition_score + - 0.10 * pcv_score + - 0.05 * threat_score - ) - + score = (0.15 * atk_score + 0.05 * block_score + 0.18 * cover_score + + 0.38 * net_value_norm + 0.05 * destroy_score + + 0.09 * attrition_score + 0.05 * pcv_score + 0.05 * threat_score) elif personality == AIPersonality.CONTROL: - # Efficiency - score = ( - 0.10 * atk_score + - 0.05 * block_score + - 0.05 * cover_score + - 0.20 * net_value_norm + - 0.05 * destroy_score + - 0.10 * attrition_score + - 0.40 * pcv_score + - 0.05 * threat_score - ) - + score = (0.10 * atk_score + 0.05 * block_score + 0.05 * cover_score + + 0.20 * net_value_norm + 0.05 * destroy_score + + 0.10 * attrition_score + 0.40 * pcv_score + 0.05 * threat_score) elif personality == AIPersonality.BALANCED: - score = ( - 0.10 * atk_score + - 0.15 * block_score + - 0.10 * cover_score + - 0.10 * net_value_norm + - 0.10 * destroy_score + - 0.10 * attrition_score + - 0.15 * pcv_score + - 0.10 * threat_score - ) - + score = (0.12 * atk_score + 0.13 * block_score + 0.15 * cover_score + + 0.10 * net_value_norm + 0.12 * destroy_score + + 0.15 * attrition_score + 0.12 * pcv_score + 0.11 * threat_score) + elif personality == AIPersonality.SHOCKER: + score = (0.25 * destroy_score + 0.33 * cover_score + 0.18 * atk_score + + 0.05 * block_score + 0.8 * attrition_score + 0.02 * threat_score + + 0.05 * net_value_norm + 0.04 * pcv_score) else: # ARBITRARY - score = ( - 0.60 * random.random() + - 0.05 * atk_score + - 0.05 * block_score + - 0.05 * cover_score + - 0.05 * net_value_norm + - 0.05 * destroy_score + - 0.05 * attrition_score + - 0.05 * pcv_score + - 0.05 * threat_score - ) + score = (0.60 * np.random.random(n).astype(np.float32) + + 0.05 * atk_score + 0.05 * block_score + 0.05 * cover_score + + 0.05 * net_value_norm + 0.05 * destroy_score + + 0.05 * attrition_score + 0.05 * pcv_score + 0.05 * threat_score) # --- Context adjustments --- + score = np.where(direct_damage >= opponent.life, np.maximum(score, 0.95), score) + score = np.where(unblocked_in >= player.life, np.minimum(score, 0.05), score) - # Lethal takes priority regardless of personality - if direct_damage >= opponent.life: - score = max(score, 0.95) - - if unblocked_incoming >= player.life: - score = min(score, 0.05) - - # Against god-card decks: cover all slots so their big cards can't attack freely if opponent.deck_type in ("God Card", "Pantheon"): - score = min(1.0, score + 0.08 * cover_score) - - # Against aggro/rush: need to block more urgently + score = np.minimum(1.0, score + 0.08 * cover_score) if opponent.deck_type in ("Aggro", "Rush"): - score = min(1.0, score + 0.06 * block_score + 0.04 * threat_score) - - # Against wall decks: direct damage matters more than destroying cards + score = np.minimum(1.0, score + 0.06 * block_score + 0.04 * threat_score) if opponent.deck_type == "Wall": - score = min(1.0, score + 0.06 * atk_score) - - # Press the advantage when opponent is low on life + score = np.minimum(1.0, score + 0.06 * atk_score) if opponent.life < STARTING_LIFE * 0.3: - score = min(1.0, score + 0.06 * atk_score) - - # Prioritize survival when low on life + score = np.minimum(1.0, score + 0.06 * atk_score) if player.life < STARTING_LIFE * 0.3: - score = min(1.0, score + 0.06 * threat_score + 0.04 * block_score) + score = np.minimum(1.0, score + 0.06 * threat_score + 0.04 * block_score) + if opp_cards_left <= 5: + score = np.where(cards_on_board > 0, np.minimum(1.0, score + 0.05), score) - # Opponent running low on cards: keep a card on board for attrition win condition - if opponent_cards_left <= 5 and cards_on_board > 0: - score = min(1.0, score + 0.05) - - # Apply sacrifice penalty last so it can override all other considerations. - score = max(0.0, score - sacrifice_penalty) - - return score + return np.maximum(0.0, score - sac_penalty) -# ==================== Turn execution ==================== +async def choose_plan(player: PlayerState, opponent: PlayerState, personality: AIPersonality, difficulty: int) -> MovePlan: + plans = generate_plans(player, opponent) + + scores = score_plans_batch(plans, player, opponent, personality) + + noise_scale = (max(0,11 - difficulty)**2) * 0.01 - 0.01 + noise = np.random.normal(0, noise_scale, len(scores)).astype(np.float32) + return plans[int(np.argmax(scores + noise))] async def run_ai_turn(game_id: str): from game_manager import ( @@ -485,24 +388,10 @@ async def run_ai_turn(game_id: str): pass # --- Generate and score candidate plans --- - plans = generate_plans(player, opponent) - - if difficulty <= 2: - # Actively bad - scored = [(score_plan(p, player, opponent, personality) + random.gauss(0, 0.15*difficulty), p) - for p in plans] - best_plan = min(scored, key=lambda x: x[0])[1] - elif difficulty == 3: - # Fully random - best_plan = random.choice(plans) - else: - noise = max(0.0, ((8 - difficulty) / 6.0) * 0.30) - scored = [(score_plan(p, player, opponent, personality) + random.gauss(0, noise), p) - for p in plans] - best_plan = max(scored, key=lambda x: x[0])[1] + best_plan = await choose_plan(player, opponent, personality, difficulty) logger.info( - f"AI turn: d={difficulty} p={personality.value} plan={best_plan.label} plans={len(plans)} " + + f"AI turn: d={difficulty} p={personality.value} plan={best_plan.label} " + f"sac={best_plan.sacrifice_slots} plays={[c.name for c, _ in best_plan.plays]}" ) diff --git a/backend/card.py b/backend/card.py index 6e9807f..2a0e0b1 100644 --- a/backend/card.py +++ b/backend/card.py @@ -541,7 +541,17 @@ async def _get_specific_card_async(title: str) -> Card|None: # Sync entrypoints def generate_cards(size: int) -> list[Card]: - return asyncio.run(_get_cards_async(size)) + cards = [] + remaining = size + while remaining > 0: + batch = min(remaining,10) + logger.warning(f"Generating {batch} cards ({len(cards)}/{size})") + cards += asyncio.run(_get_cards_async(batch)) + remaining = size - len(cards) + if remaining > 0: + sleep(4) + + return cards def generate_card(title: str) -> Card|None: return asyncio.run(_get_specific_card_async(title)) diff --git a/backend/game.py b/backend/game.py index 23bfb6f..24f8999 100644 --- a/backend/game.py +++ b/backend/game.py @@ -6,7 +6,7 @@ from datetime import datetime from models import Card as CardModel -STARTING_LIFE = 500 +STARTING_LIFE = 1000 MAX_ENERGY_CAP = 6 BOARD_SIZE = 5 HAND_SIZE = 5 diff --git a/backend/main.py b/backend/main.py index 95f8852..7165368 100644 --- a/backend/main.py +++ b/backend/main.py @@ -538,11 +538,7 @@ if __name__ == "__main__": from card import generate_cards, Card from time import sleep - all_cards: list[Card] = [] - for i in range(30): - print(i) - all_cards += generate_cards(10) - sleep(5) + all_cards = generate_cards(500) all_cards.sort(key=lambda x: x.cost, reverse=True) diff --git a/backend/simulate.py b/backend/simulate.py new file mode 100644 index 0000000..4ab814c --- /dev/null +++ b/backend/simulate.py @@ -0,0 +1,440 @@ +import json +import os +import random +import uuid +import asyncio +from concurrent.futures import ProcessPoolExecutor +from dotenv import load_dotenv +load_dotenv() + +from datetime import datetime + +from card import Card, CardType, CardRarity, generate_cards, compute_deck_type +from game import ( + CardInstance, PlayerState, GameState, + action_play_card, action_sacrifice, action_end_turn, +) +from ai import AIPersonality, choose_cards, choose_plan + +SIMULATION_CARDS_PATH = os.path.join(os.path.dirname(__file__), "simulation_cards.json") +SIMULATION_CARD_COUNT = 1000 + + +# ==================== Card pool ==================== + +def _card_to_dict(card: Card) -> dict: + return { + "name": card.name, + "created_at": card.created_at.isoformat(), + "image_link": card.image_link, + "card_rarity": card.card_rarity.name, + "card_type": card.card_type.name, + "wikidata_instance": card.wikidata_instance, + "text": card.text, + "attack": card.attack, + "defense": card.defense, + "cost": card.cost, + } + + +def _dict_to_card(d: dict) -> Card: + return Card( + name=d["name"], + created_at=datetime.fromisoformat(d["created_at"]), + image_link=d["image_link"], + card_rarity=CardRarity[d["card_rarity"]], + card_type=CardType[d["card_type"]], + wikidata_instance=d["wikidata_instance"], + text=d["text"], + attack=d["attack"], + defense=d["defense"], + cost=d["cost"], + ) + + +def get_simulation_cards() -> list[Card]: + if os.path.exists(SIMULATION_CARDS_PATH): + with open(SIMULATION_CARDS_PATH, "r", encoding="utf-8") as f: + data = json.load(f) + return [_dict_to_card(d) for d in data] + + print(f"Generating {SIMULATION_CARD_COUNT} cards (this may take a while)...") + cards = generate_cards(SIMULATION_CARD_COUNT) + + with open(SIMULATION_CARDS_PATH, "w", encoding="utf-8") as f: + json.dump([_card_to_dict(c) for c in cards], f, ensure_ascii=False, indent=2) + + print(f"Saved {len(cards)} cards to {SIMULATION_CARDS_PATH}") + return cards + + +# ==================== Single game ==================== + +PLAYER1_ID = "p1" +PLAYER2_ID = "p2" +MAX_TURNS = 300 # safety cap to prevent infinite games + + +def _make_instances(deck: list[Card]) -> list[CardInstance]: + return [ + CardInstance( + instance_id=str(uuid.uuid4()), + card_id=card.name, + name=card.name, + attack=card.attack, + defense=card.defense, + max_defense=card.defense, + cost=card.cost, + card_type=card.card_type.name, + card_rarity=card.card_rarity.name, + image_link=card.image_link or "", + text=card.text or "", + ) + for card in deck + ] + + +async def simulate_game( + cards: list[Card], + difficulty1: int, + personality1: AIPersonality, + difficulty2: int, + personality2: AIPersonality, +) -> str | None: + """ + Simulate a single game between two AIs choosing from `cards`. + Player 1 always goes first. + + Returns "p1", "p2", or None if the game exceeds MAX_TURNS. + + Designed to be awaited inside asyncio.gather() to run many games concurrently. + """ + deck1 = choose_cards(cards, difficulty1, personality1) + deck2 = choose_cards(cards, difficulty2, personality2) + + instances1 = _make_instances(deck1) + instances2 = _make_instances(deck2) + random.shuffle(instances1) + random.shuffle(instances2) + + deck_type1 = compute_deck_type(deck1) or "Balanced" + deck_type2 = compute_deck_type(deck2) or "Balanced" + + p1 = PlayerState(user_id=PLAYER1_ID, username="AI1", deck_type=deck_type1, deck=instances1) + p2 = PlayerState(user_id=PLAYER2_ID, username="AI2", deck_type=deck_type2, deck=instances2) + + # P1 always goes first + p1.increment_energy_cap() + p2.increment_energy_cap() + p1.refill_energy() + p1.draw_to_full() + + state = GameState( + game_id=str(uuid.uuid4()), + players={PLAYER1_ID: p1, PLAYER2_ID: p2}, + player_order=[PLAYER1_ID, PLAYER2_ID], + active_player_id=PLAYER1_ID, + phase="main", + turn=1, + ) + + configs = { + PLAYER1_ID: (difficulty1, personality1), + PLAYER2_ID: (difficulty2, personality2), + } + + for _ in range(MAX_TURNS): + if state.result: + break + + active_id = state.active_player_id + difficulty, personality = configs[active_id] + player = state.players[active_id] + opponent = state.players[state.opponent_id(active_id)] + + plan = await choose_plan(player, opponent, personality, difficulty) + + for slot in plan.sacrifice_slots: + if player.board[slot] is not None: + action_sacrifice(state, slot) + + plays = list(plan.plays) + random.shuffle(plays) + for card, slot in plays: + hand_idx = next((i for i, c in enumerate(player.hand) if c is card), None) + if hand_idx is None: + continue + if player.board[slot] is not None: + continue + if card.cost > player.energy: + continue + action_play_card(state, hand_idx, slot) + + action_end_turn(state) + + if state.result and state.result.winner_id: + return state.result.winner_id + return None + + +# ==================== Process-pool worker ==================== +# These must be module-level so they are picklable. + +_worker_cards: list[Card] = [] + +def _init_worker(cards: list[Card]) -> None: + global _worker_cards + _worker_cards = cards + +def _run_game_sync(args: tuple) -> str | None: + """Synchronous entry point for a worker process.""" + d1, p1_name, d2, p2_name = args + return asyncio.run(simulate_game( + _worker_cards, + d1, AIPersonality(p1_name), + d2, AIPersonality(p2_name), + )) + + +# ==================== Tournament ==================== + +def _all_players(difficulties: list[int] | None = None) -> list[tuple[AIPersonality, int]]: + """Return all (personality, difficulty) combinations for the given difficulties (default 1-10).""" + if difficulties is None: + difficulties = list(range(1, 11)) + return [ + (personality, difficulty) + for personality in AIPersonality + for difficulty in difficulties + ] + + +def _player_label(personality: AIPersonality, difficulty: int) -> str: + return f"{personality.value[:3].upper()}-{difficulty}" + + +async def run_tournament( + cards: list[Card], + games_per_matchup: int = 5, + difficulties: list[int] | None = None, +) -> dict[tuple[int, int], int]: + """ + Pit every (personality, difficulty) pair against every other, as both + first and second player. + + `difficulties` selects which difficulty levels to include (default: 1-10). + + Returns a wins dict keyed by (first_player_index, second_player_index) + where the value is how many of `games_per_matchup` games the first player won. + + Games run in parallel across all CPU cores via ProcessPoolExecutor. + Cards are sent to each worker once at startup, not once per game. + """ + players = _all_players(difficulties) + n = len(players) + + # Build the flat list of (i, j, args) for every game + indexed_args: list[tuple[int, int, tuple]] = [] + for i in range(n): + p1_personality, p1_difficulty = players[i] + for j in range(n): + p2_personality, p2_difficulty = players[j] + args = (p1_difficulty, p1_personality.value, p2_difficulty, p2_personality.value) + for _ in range(games_per_matchup): + indexed_args.append((i, j, args)) + + total_games = len(indexed_args) + n_workers = os.cpu_count() or 1 + print(f"Running {total_games} games across {n_workers} workers " + f"({n} players, {games_per_matchup} games per ordered pair)...") + + done = [0] + report_every = max(1, total_games // 200) + + loop = asyncio.get_running_loop() + + async def tracked(future): + result = await future + done[0] += 1 + if done[0] % report_every == 0 or done[0] == total_games: + pct = done[0] / total_games * 100 + print(f" {done[0]}/{total_games} games done ({pct:.1f}%)", end="\r", flush=True) + return result + + with ProcessPoolExecutor( + max_workers=n_workers, + initializer=_init_worker, + initargs=(cards,), + ) as executor: + futures = [ + loop.run_in_executor(executor, _run_game_sync, args) + for _, _, args in indexed_args + ] + results = await asyncio.gather(*[tracked(f) for f in futures]) + + print("\nFinished") + + wins: dict[tuple[int, int], int] = {} + ties = 0 + for (i, j, _), winner in zip(indexed_args, results): + key = (i, j) + if key not in wins: + wins[key] = 0 + if winner == PLAYER1_ID: + wins[key] += 1 + elif winner is None: + ties += 1 + + print(f"Ties: {ties}") + + return wins + + +def rank_players( + wins: dict[tuple[int, int], int], + games_per_matchup: int, + players: list[tuple[AIPersonality, int]], +) -> list[int]: + """ + Rank player indices by total wins (as first + second player combined). + Returns indices sorted worst-to-best. + """ + n = len(players) + total_wins = [0] * n + + for (i, j), p1_wins in wins.items(): + if i == j: + continue # self-matchups are symmetric; skip to avoid double-counting + p2_wins = games_per_matchup - p1_wins + total_wins[i] += p1_wins + total_wins[j] += p2_wins + + return sorted(range(n), key=lambda k: total_wins[k]) + + +TOURNAMENT_RESULTS_PATH = os.path.join(os.path.dirname(__file__), "tournament_results.json") + + +def save_tournament( + wins: dict[tuple[int, int], int], + games_per_matchup: int, + players: list[tuple[AIPersonality, int]], + path: str = TOURNAMENT_RESULTS_PATH, +): + data = { + "games_per_matchup": games_per_matchup, + "players": [ + {"personality": p.value, "difficulty": d} + for p, d in players + ], + "wins": {f"{i},{j}": w for (i, j), w in wins.items()}, + } + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + print(f"Tournament results saved to {path}") + + +def load_tournament(path: str = TOURNAMENT_RESULTS_PATH) -> tuple[dict[tuple[int, int], int], int, list[tuple[AIPersonality, int]]]: + """Returns (wins, games_per_matchup, players).""" + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + wins = { + (int(k.split(",")[0]), int(k.split(",")[1])): v + for k, v in data["wins"].items() + } + players = [ + (AIPersonality(p["personality"]), p["difficulty"]) + for p in data["players"] + ] + return wins, data["games_per_matchup"], players + + +def draw_grid( + wins: dict[tuple[int, int], int], + games_per_matchup: int = 5, + players: list[tuple[AIPersonality, int]] | None = None, + output_path: str = "tournament_grid.png", +): + """ + Draw a heatmap grid of tournament results. + + Rows = first player + Cols = second player + Color = red if first player won more of their games in that cell + green if second player won more + × = one player swept all games in that cell + """ + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + import matplotlib.colors as mcolors + import numpy as np + + if players is None: + players = _all_players() + n = len(players) + ranked = rank_players(wins, games_per_matchup, players) # worst-to-best indices + + labels = [_player_label(*players[i]) for i in ranked] + + # Build value matrix: (p1_wins - p2_wins) / games_per_matchup ∈ [-1, 1], NaN on diagonal + matrix = np.full((n, n), np.nan) + for row, i in enumerate(ranked): + for col, j in enumerate(ranked): + p1_wins = wins.get((i, j), 0) + matrix[row, col] = (p1_wins - (games_per_matchup - p1_wins)) / games_per_matchup + + cell_size = 0.22 + fig_size = n * cell_size + 3 + fig, ax = plt.subplots(figsize=(fig_size, fig_size)) + + cmap = mcolors.LinearSegmentedColormap.from_list( + "p1_p2", ["#90EE90", "#67A2E0", "#D74E4E"] # pastel green → blue → red + ) + norm = mcolors.Normalize(vmin=-1, vmax=1) + + img = ax.imshow(matrix, cmap=cmap, norm=norm, aspect="equal", interpolation="none") + + # × marks for sweeps + for row, i in enumerate(ranked): + for col, j in enumerate(ranked): + p1_wins = wins.get((i, j), 0) + if p1_wins == games_per_matchup or p1_wins == 0: + ax.text(col, row, "×", ha="center", va="center", + fontsize=5, color="black", fontweight="bold", zorder=3) + + ax.set_xticks(range(n)) + ax.set_yticks(range(n)) + ax.set_xticklabels(labels, rotation=90, fontsize=4) + ax.set_yticklabels(labels, fontsize=4) + ax.xaxis.set_label_position("top") + ax.xaxis.tick_top() + + ax.set_xlabel("Second player", labelpad=8, fontsize=8) + ax.set_ylabel("First player", labelpad=8, fontsize=8) + ax.set_title( + "Tournament results — red: first player wins more, green: second player wins more", + pad=14, fontsize=9, + ) + + plt.colorbar(img, ax=ax, fraction=0.015, pad=0.01, + label="(P1 wins - P2 wins) / games per cell") + + plt.tight_layout() + plt.savefig(output_path, dpi=150, bbox_inches="tight") + plt.close() + print(f"Grid saved to {output_path}") + + +if __name__ == "__main__": + import sys + + GAMES_PER_MATCHUP = 50 + + difficulties = list(range(1, 11)) + + card_pool = get_simulation_cards() + players = _all_players(difficulties) + wins = asyncio.run(run_tournament(card_pool, games_per_matchup=GAMES_PER_MATCHUP, difficulties=difficulties)) + save_tournament(wins, games_per_matchup=GAMES_PER_MATCHUP, players=players) + draw_grid(wins, games_per_matchup=GAMES_PER_MATCHUP, players=players) diff --git a/backend/tournament_results.json b/backend/tournament_results.json new file mode 100644 index 0000000..319f885 --- /dev/null +++ b/backend/tournament_results.json @@ -0,0 +1,1767 @@ +{ + "games_per_matchup": 50, + "players": [ + { + "personality": "aggressive", + "difficulty": 6 + }, + { + "personality": "aggressive", + "difficulty": 7 + }, + { + "personality": "aggressive", + "difficulty": 8 + }, + { + "personality": "aggressive", + "difficulty": 9 + }, + { + "personality": "aggressive", + "difficulty": 10 + }, + { + "personality": "defensive", + "difficulty": 6 + }, + { + "personality": "defensive", + "difficulty": 7 + }, + { + "personality": "defensive", + "difficulty": 8 + }, + { + "personality": "defensive", + "difficulty": 9 + }, + { + "personality": "defensive", + "difficulty": 10 + }, + { + "personality": "balanced", + "difficulty": 6 + }, + { + "personality": "balanced", + "difficulty": 7 + }, + { + "personality": "balanced", + "difficulty": 8 + }, + { + "personality": "balanced", + "difficulty": 9 + }, + { + "personality": "balanced", + "difficulty": 10 + }, + { + "personality": "greedy", + "difficulty": 6 + }, + { + "personality": "greedy", + "difficulty": 7 + }, + { + "personality": "greedy", + "difficulty": 8 + }, + { + "personality": "greedy", + "difficulty": 9 + }, + { + "personality": "greedy", + "difficulty": 10 + }, + { + "personality": "swarm", + "difficulty": 6 + }, + { + "personality": "swarm", + "difficulty": 7 + }, + { + "personality": "swarm", + "difficulty": 8 + }, + { + "personality": "swarm", + "difficulty": 9 + }, + { + "personality": "swarm", + "difficulty": 10 + }, + { + "personality": "control", + "difficulty": 6 + }, + { + "personality": "control", + "difficulty": 7 + }, + { + "personality": "control", + "difficulty": 8 + }, + { + "personality": "control", + "difficulty": 9 + }, + { + "personality": "control", + "difficulty": 10 + }, + { + "personality": "shocker", + "difficulty": 6 + }, + { + "personality": "shocker", + "difficulty": 7 + }, + { + "personality": "shocker", + "difficulty": 8 + }, + { + "personality": "shocker", + "difficulty": 9 + }, + { + "personality": "shocker", + "difficulty": 10 + }, + { + "personality": "arbitrary", + "difficulty": 6 + }, + { + "personality": "arbitrary", + "difficulty": 7 + }, + { + "personality": "arbitrary", + "difficulty": 8 + }, + { + "personality": "arbitrary", + "difficulty": 9 + }, + { + "personality": "arbitrary", + "difficulty": 10 + } + ], + "wins": { + "0,0": 18, + "0,1": 12, + "0,2": 4, + "0,3": 4, + "0,4": 2, + "0,5": 17, + "0,6": 14, + "0,7": 6, + "0,8": 2, + "0,9": 4, + "0,10": 17, + "0,11": 7, + "0,12": 3, + "0,13": 1, + "0,14": 0, + "0,15": 11, + "0,16": 9, + "0,17": 6, + "0,18": 3, + "0,19": 1, + "0,20": 36, + "0,21": 26, + "0,22": 22, + "0,23": 10, + "0,24": 3, + "0,25": 20, + "0,26": 10, + "0,27": 7, + "0,28": 4, + "0,29": 3, + "0,30": 39, + "0,31": 36, + "0,32": 30, + "0,33": 35, + "0,34": 30, + "0,35": 37, + "0,36": 26, + "0,37": 11, + "0,38": 9, + "0,39": 3, + "1,0": 34, + "1,1": 21, + "1,2": 10, + "1,3": 8, + "1,4": 8, + "1,5": 24, + "1,6": 22, + "1,7": 7, + "1,8": 8, + "1,9": 3, + "1,10": 38, + "1,11": 19, + "1,12": 7, + "1,13": 0, + "1,14": 1, + "1,15": 26, + "1,16": 10, + "1,17": 14, + "1,18": 9, + "1,19": 2, + "1,20": 41, + "1,21": 38, + "1,22": 31, + "1,23": 11, + "1,24": 4, + "1,25": 37, + "1,26": 17, + "1,27": 11, + "1,28": 4, + "1,29": 5, + "1,30": 38, + "1,31": 41, + "1,32": 42, + "1,33": 39, + "1,34": 40, + "1,35": 45, + "1,36": 42, + "1,37": 31, + "1,38": 15, + "1,39": 9, + "2,0": 31, + "2,1": 27, + "2,2": 17, + "2,3": 15, + "2,4": 13, + "2,5": 45, + "2,6": 31, + "2,7": 15, + "2,8": 10, + "2,9": 14, + "2,10": 38, + "2,11": 33, + "2,12": 8, + "2,13": 2, + "2,14": 3, + "2,15": 32, + "2,16": 30, + "2,17": 16, + "2,18": 13, + "2,19": 13, + "2,20": 47, + "2,21": 42, + "2,22": 30, + "2,23": 21, + "2,24": 9, + "2,25": 36, + "2,26": 27, + "2,27": 22, + "2,28": 8, + "2,29": 10, + "2,30": 46, + "2,31": 44, + "2,32": 44, + "2,33": 45, + "2,34": 47, + "2,35": 50, + "2,36": 47, + "2,37": 37, + "2,38": 21, + "2,39": 19, + "3,0": 44, + "3,1": 28, + "3,2": 23, + "3,3": 15, + "3,4": 15, + "3,5": 40, + "3,6": 33, + "3,7": 22, + "3,8": 18, + "3,9": 18, + "3,10": 44, + "3,11": 34, + "3,12": 17, + "3,13": 2, + "3,14": 3, + "3,15": 42, + "3,16": 40, + "3,17": 25, + "3,18": 21, + "3,19": 23, + "3,20": 50, + "3,21": 45, + "3,22": 36, + "3,23": 15, + "3,24": 5, + "3,25": 43, + "3,26": 37, + "3,27": 25, + "3,28": 20, + "3,29": 22, + "3,30": 42, + "3,31": 48, + "3,32": 48, + "3,33": 43, + "3,34": 49, + "3,35": 49, + "3,36": 48, + "3,37": 42, + "3,38": 39, + "3,39": 21, + "4,0": 31, + "4,1": 29, + "4,2": 15, + "4,3": 16, + "4,4": 16, + "4,5": 49, + "4,6": 37, + "4,7": 22, + "4,8": 16, + "4,9": 20, + "4,10": 46, + "4,11": 35, + "4,12": 20, + "4,13": 3, + "4,14": 5, + "4,15": 39, + "4,16": 34, + "4,17": 27, + "4,18": 17, + "4,19": 21, + "4,20": 48, + "4,21": 48, + "4,22": 45, + "4,23": 18, + "4,24": 9, + "4,25": 44, + "4,26": 40, + "4,27": 22, + "4,28": 13, + "4,29": 21, + "4,30": 50, + "4,31": 50, + "4,32": 48, + "4,33": 46, + "4,34": 48, + "4,35": 48, + "4,36": 46, + "4,37": 44, + "4,38": 39, + "4,39": 27, + "5,0": 27, + "5,1": 19, + "5,2": 8, + "5,3": 4, + "5,4": 3, + "5,5": 20, + "5,6": 12, + "5,7": 7, + "5,8": 2, + "5,9": 4, + "5,10": 29, + "5,11": 9, + "5,12": 4, + "5,13": 0, + "5,14": 0, + "5,15": 19, + "5,16": 6, + "5,17": 2, + "5,18": 0, + "5,19": 2, + "5,20": 39, + "5,21": 27, + "5,22": 26, + "5,23": 15, + "5,24": 2, + "5,25": 21, + "5,26": 12, + "5,27": 6, + "5,28": 2, + "5,29": 3, + "5,30": 36, + "5,31": 39, + "5,32": 42, + "5,33": 31, + "5,34": 19, + "5,35": 36, + "5,36": 18, + "5,37": 18, + "5,38": 16, + "5,39": 5, + "6,0": 37, + "6,1": 21, + "6,2": 15, + "6,3": 8, + "6,4": 3, + "6,5": 31, + "6,6": 25, + "6,7": 19, + "6,8": 7, + "6,9": 4, + "6,10": 34, + "6,11": 22, + "6,12": 10, + "6,13": 3, + "6,14": 0, + "6,15": 29, + "6,16": 21, + "6,17": 7, + "6,18": 6, + "6,19": 4, + "6,20": 46, + "6,21": 43, + "6,22": 34, + "6,23": 16, + "6,24": 4, + "6,25": 30, + "6,26": 18, + "6,27": 21, + "6,28": 5, + "6,29": 4, + "6,30": 46, + "6,31": 39, + "6,32": 39, + "6,33": 41, + "6,34": 32, + "6,35": 49, + "6,36": 41, + "6,37": 26, + "6,38": 20, + "6,39": 12, + "7,0": 40, + "7,1": 29, + "7,2": 14, + "7,3": 11, + "7,4": 14, + "7,5": 44, + "7,6": 29, + "7,7": 14, + "7,8": 15, + "7,9": 3, + "7,10": 43, + "7,11": 31, + "7,12": 13, + "7,13": 4, + "7,14": 2, + "7,15": 36, + "7,16": 28, + "7,17": 20, + "7,18": 13, + "7,19": 9, + "7,20": 48, + "7,21": 40, + "7,22": 42, + "7,23": 26, + "7,24": 4, + "7,25": 42, + "7,26": 28, + "7,27": 20, + "7,28": 15, + "7,29": 16, + "7,30": 48, + "7,31": 44, + "7,32": 44, + "7,33": 46, + "7,34": 46, + "7,35": 48, + "7,36": 39, + "7,37": 32, + "7,38": 36, + "7,39": 25, + "8,0": 49, + "8,1": 42, + "8,2": 33, + "8,3": 11, + "8,4": 19, + "8,5": 46, + "8,6": 37, + "8,7": 31, + "8,8": 26, + "8,9": 15, + "8,10": 44, + "8,11": 35, + "8,12": 31, + "8,13": 10, + "8,14": 4, + "8,15": 44, + "8,16": 40, + "8,17": 30, + "8,18": 20, + "8,19": 19, + "8,20": 49, + "8,21": 45, + "8,22": 47, + "8,23": 30, + "8,24": 12, + "8,25": 45, + "8,26": 44, + "8,27": 40, + "8,28": 26, + "8,29": 11, + "8,30": 47, + "8,31": 45, + "8,32": 47, + "8,33": 44, + "8,34": 47, + "8,35": 49, + "8,36": 48, + "8,37": 44, + "8,38": 41, + "8,39": 32, + "9,0": 44, + "9,1": 32, + "9,2": 19, + "9,3": 5, + "9,4": 7, + "9,5": 46, + "9,6": 40, + "9,7": 36, + "9,8": 18, + "9,9": 16, + "9,10": 45, + "9,11": 34, + "9,12": 33, + "9,13": 7, + "9,14": 1, + "9,15": 49, + "9,16": 34, + "9,17": 33, + "9,18": 18, + "9,19": 14, + "9,20": 48, + "9,21": 49, + "9,22": 43, + "9,23": 24, + "9,24": 12, + "9,25": 49, + "9,26": 39, + "9,27": 30, + "9,28": 21, + "9,29": 16, + "9,30": 48, + "9,31": 49, + "9,32": 45, + "9,33": 40, + "9,34": 49, + "9,35": 50, + "9,36": 48, + "9,37": 45, + "9,38": 40, + "9,39": 29, + "10,0": 18, + "10,1": 14, + "10,2": 8, + "10,3": 0, + "10,4": 0, + "10,5": 22, + "10,6": 10, + "10,7": 3, + "10,8": 5, + "10,9": 3, + "10,10": 22, + "10,11": 13, + "10,12": 4, + "10,13": 1, + "10,14": 0, + "10,15": 19, + "10,16": 5, + "10,17": 6, + "10,18": 1, + "10,19": 0, + "10,20": 37, + "10,21": 27, + "10,22": 25, + "10,23": 19, + "10,24": 8, + "10,25": 22, + "10,26": 7, + "10,27": 7, + "10,28": 2, + "10,29": 1, + "10,30": 40, + "10,31": 36, + "10,32": 38, + "10,33": 38, + "10,34": 27, + "10,35": 36, + "10,36": 27, + "10,37": 16, + "10,38": 11, + "10,39": 6, + "11,0": 30, + "11,1": 23, + "11,2": 13, + "11,3": 6, + "11,4": 2, + "11,5": 35, + "11,6": 22, + "11,7": 16, + "11,8": 9, + "11,9": 3, + "11,10": 35, + "11,11": 14, + "11,12": 16, + "11,13": 2, + "11,14": 1, + "11,15": 35, + "11,16": 12, + "11,17": 15, + "11,18": 9, + "11,19": 6, + "11,20": 44, + "11,21": 42, + "11,22": 38, + "11,23": 25, + "11,24": 8, + "11,25": 35, + "11,26": 19, + "11,27": 14, + "11,28": 3, + "11,29": 3, + "11,30": 43, + "11,31": 40, + "11,32": 43, + "11,33": 40, + "11,34": 42, + "11,35": 45, + "11,36": 36, + "11,37": 40, + "11,38": 27, + "11,39": 22, + "12,0": 42, + "12,1": 42, + "12,2": 31, + "12,3": 15, + "12,4": 16, + "12,5": 42, + "12,6": 32, + "12,7": 18, + "12,8": 11, + "12,9": 4, + "12,10": 38, + "12,11": 31, + "12,12": 23, + "12,13": 1, + "12,14": 4, + "12,15": 37, + "12,16": 26, + "12,17": 16, + "12,18": 17, + "12,19": 9, + "12,20": 47, + "12,21": 47, + "12,22": 46, + "12,23": 32, + "12,24": 11, + "12,25": 42, + "12,26": 38, + "12,27": 23, + "12,28": 16, + "12,29": 8, + "12,30": 48, + "12,31": 46, + "12,32": 46, + "12,33": 45, + "12,34": 46, + "12,35": 49, + "12,36": 46, + "12,37": 42, + "12,38": 34, + "12,39": 21, + "13,0": 48, + "13,1": 45, + "13,2": 38, + "13,3": 31, + "13,4": 32, + "13,5": 45, + "13,6": 43, + "13,7": 34, + "13,8": 29, + "13,9": 21, + "13,10": 49, + "13,11": 42, + "13,12": 35, + "13,13": 14, + "13,14": 13, + "13,15": 45, + "13,16": 45, + "13,17": 41, + "13,18": 33, + "13,19": 30, + "13,20": 49, + "13,21": 50, + "13,22": 48, + "13,23": 44, + "13,24": 22, + "13,25": 47, + "13,26": 45, + "13,27": 42, + "13,28": 26, + "13,29": 28, + "13,30": 49, + "13,31": 50, + "13,32": 50, + "13,33": 49, + "13,34": 49, + "13,35": 50, + "13,36": 50, + "13,37": 49, + "13,38": 42, + "13,39": 38, + "14,0": 47, + "14,1": 43, + "14,2": 37, + "14,3": 39, + "14,4": 33, + "14,5": 49, + "14,6": 43, + "14,7": 38, + "14,8": 29, + "14,9": 30, + "14,10": 49, + "14,11": 46, + "14,12": 28, + "14,13": 12, + "14,14": 11, + "14,15": 50, + "14,16": 45, + "14,17": 37, + "14,18": 37, + "14,19": 32, + "14,20": 50, + "14,21": 49, + "14,22": 47, + "14,23": 38, + "14,24": 17, + "14,25": 49, + "14,26": 44, + "14,27": 44, + "14,28": 36, + "14,29": 23, + "14,30": 49, + "14,31": 49, + "14,32": 49, + "14,33": 48, + "14,34": 45, + "14,35": 50, + "14,36": 49, + "14,37": 49, + "14,38": 39, + "14,39": 34, + "15,0": 28, + "15,1": 11, + "15,2": 3, + "15,3": 2, + "15,4": 1, + "15,5": 24, + "15,6": 11, + "15,7": 3, + "15,8": 5, + "15,9": 3, + "15,10": 31, + "15,11": 16, + "15,12": 9, + "15,13": 0, + "15,14": 0, + "15,15": 23, + "15,16": 11, + "15,17": 6, + "15,18": 2, + "15,19": 1, + "15,20": 33, + "15,21": 34, + "15,22": 30, + "15,23": 15, + "15,24": 4, + "15,25": 27, + "15,26": 13, + "15,27": 8, + "15,28": 3, + "15,29": 2, + "15,30": 36, + "15,31": 36, + "15,32": 36, + "15,33": 33, + "15,34": 31, + "15,35": 38, + "15,36": 27, + "15,37": 26, + "15,38": 11, + "15,39": 6, + "16,0": 37, + "16,1": 13, + "16,2": 11, + "16,3": 5, + "16,4": 2, + "16,5": 38, + "16,6": 23, + "16,7": 18, + "16,8": 5, + "16,9": 4, + "16,10": 37, + "16,11": 26, + "16,12": 5, + "16,13": 3, + "16,14": 1, + "16,15": 31, + "16,16": 15, + "16,17": 13, + "16,18": 6, + "16,19": 4, + "16,20": 48, + "16,21": 46, + "16,22": 37, + "16,23": 17, + "16,24": 5, + "16,25": 41, + "16,26": 24, + "16,27": 15, + "16,28": 6, + "16,29": 4, + "16,30": 41, + "16,31": 41, + "16,32": 36, + "16,33": 35, + "16,34": 48, + "16,35": 50, + "16,36": 41, + "16,37": 36, + "16,38": 25, + "16,39": 18, + "17,0": 36, + "17,1": 23, + "17,2": 11, + "17,3": 4, + "17,4": 2, + "17,5": 38, + "17,6": 34, + "17,7": 19, + "17,8": 14, + "17,9": 5, + "17,10": 32, + "17,11": 28, + "17,12": 22, + "17,13": 2, + "17,14": 0, + "17,15": 41, + "17,16": 21, + "17,17": 21, + "17,18": 4, + "17,19": 6, + "17,20": 47, + "17,21": 43, + "17,22": 41, + "17,23": 16, + "17,24": 4, + "17,25": 41, + "17,26": 29, + "17,27": 24, + "17,28": 7, + "17,29": 9, + "17,30": 46, + "17,31": 40, + "17,32": 40, + "17,33": 35, + "17,34": 48, + "17,35": 46, + "17,36": 43, + "17,37": 41, + "17,38": 32, + "17,39": 22, + "18,0": 33, + "18,1": 23, + "18,2": 13, + "18,3": 8, + "18,4": 8, + "18,5": 45, + "18,6": 30, + "18,7": 25, + "18,8": 12, + "18,9": 7, + "18,10": 43, + "18,11": 32, + "18,12": 16, + "18,13": 3, + "18,14": 0, + "18,15": 38, + "18,16": 22, + "18,17": 24, + "18,18": 14, + "18,19": 7, + "18,20": 47, + "18,21": 47, + "18,22": 41, + "18,23": 32, + "18,24": 10, + "18,25": 43, + "18,26": 29, + "18,27": 27, + "18,28": 12, + "18,29": 9, + "18,30": 46, + "18,31": 45, + "18,32": 41, + "18,33": 42, + "18,34": 50, + "18,35": 48, + "18,36": 45, + "18,37": 47, + "18,38": 35, + "18,39": 26, + "19,0": 36, + "19,1": 36, + "19,2": 13, + "19,3": 6, + "19,4": 3, + "19,5": 42, + "19,6": 36, + "19,7": 28, + "19,8": 26, + "19,9": 17, + "19,10": 42, + "19,11": 35, + "19,12": 21, + "19,13": 6, + "19,14": 0, + "19,15": 44, + "19,16": 32, + "19,17": 17, + "19,18": 16, + "19,19": 9, + "19,20": 50, + "19,21": 50, + "19,22": 47, + "19,23": 44, + "19,24": 24, + "19,25": 42, + "19,26": 35, + "19,27": 20, + "19,28": 14, + "19,29": 6, + "19,30": 47, + "19,31": 43, + "19,32": 43, + "19,33": 43, + "19,34": 50, + "19,35": 50, + "19,36": 48, + "19,37": 46, + "19,38": 40, + "19,39": 35, + "20,0": 17, + "20,1": 7, + "20,2": 5, + "20,3": 1, + "20,4": 0, + "20,5": 11, + "20,6": 6, + "20,7": 1, + "20,8": 2, + "20,9": 1, + "20,10": 8, + "20,11": 6, + "20,12": 1, + "20,13": 1, + "20,14": 0, + "20,15": 4, + "20,16": 3, + "20,17": 2, + "20,18": 1, + "20,19": 0, + "20,20": 27, + "20,21": 16, + "20,22": 13, + "20,23": 7, + "20,24": 0, + "20,25": 8, + "20,26": 4, + "20,27": 2, + "20,28": 1, + "20,29": 0, + "20,30": 38, + "20,31": 30, + "20,32": 36, + "20,33": 36, + "20,34": 3, + "20,35": 28, + "20,36": 11, + "20,37": 9, + "20,38": 2, + "20,39": 1, + "21,0": 25, + "21,1": 16, + "21,2": 6, + "21,3": 2, + "21,4": 2, + "21,5": 15, + "21,6": 7, + "21,7": 4, + "21,8": 1, + "21,9": 0, + "21,10": 15, + "21,11": 4, + "21,12": 0, + "21,13": 0, + "21,14": 0, + "21,15": 14, + "21,16": 2, + "21,17": 1, + "21,18": 0, + "21,19": 0, + "21,20": 25, + "21,21": 23, + "21,22": 19, + "21,23": 13, + "21,24": 10, + "21,25": 9, + "21,26": 6, + "21,27": 5, + "21,28": 1, + "21,29": 0, + "21,30": 34, + "21,31": 34, + "21,32": 39, + "21,33": 41, + "21,34": 19, + "21,35": 28, + "21,36": 24, + "21,37": 14, + "21,38": 13, + "21,39": 2, + "22,0": 27, + "22,1": 21, + "22,2": 11, + "22,3": 2, + "22,4": 7, + "22,5": 23, + "22,6": 11, + "22,7": 5, + "22,8": 1, + "22,9": 6, + "22,10": 25, + "22,11": 17, + "22,12": 3, + "22,13": 1, + "22,14": 0, + "22,15": 14, + "22,16": 6, + "22,17": 3, + "22,18": 2, + "22,19": 1, + "22,20": 41, + "22,21": 31, + "22,22": 31, + "22,23": 21, + "22,24": 9, + "22,25": 18, + "22,26": 11, + "22,27": 8, + "22,28": 0, + "22,29": 3, + "22,30": 42, + "22,31": 41, + "22,32": 45, + "22,33": 43, + "22,34": 30, + "22,35": 41, + "22,36": 31, + "22,37": 29, + "22,38": 8, + "22,39": 5, + "23,0": 38, + "23,1": 32, + "23,2": 24, + "23,3": 20, + "23,4": 16, + "23,5": 32, + "23,6": 18, + "23,7": 16, + "23,8": 12, + "23,9": 13, + "23,10": 32, + "23,11": 29, + "23,12": 16, + "23,13": 4, + "23,14": 1, + "23,15": 31, + "23,16": 21, + "23,17": 17, + "23,18": 6, + "23,19": 0, + "23,20": 43, + "23,21": 39, + "23,22": 37, + "23,23": 31, + "23,24": 29, + "23,25": 34, + "23,26": 26, + "23,27": 16, + "23,28": 12, + "23,29": 8, + "23,30": 47, + "23,31": 47, + "23,32": 50, + "23,33": 49, + "23,34": 43, + "23,35": 48, + "23,36": 39, + "23,37": 40, + "23,38": 23, + "23,39": 17, + "24,0": 41, + "24,1": 39, + "24,2": 36, + "24,3": 29, + "24,4": 23, + "24,5": 48, + "24,6": 37, + "24,7": 31, + "24,8": 29, + "24,9": 26, + "24,10": 44, + "24,11": 43, + "24,12": 28, + "24,13": 14, + "24,14": 6, + "24,15": 47, + "24,16": 36, + "24,17": 27, + "24,18": 17, + "24,19": 8, + "24,20": 49, + "24,21": 48, + "24,22": 48, + "24,23": 37, + "24,24": 40, + "24,25": 47, + "24,26": 46, + "24,27": 26, + "24,28": 21, + "24,29": 7, + "24,30": 49, + "24,31": 50, + "24,32": 50, + "24,33": 50, + "24,34": 50, + "24,35": 50, + "24,36": 50, + "24,37": 48, + "24,38": 35, + "24,39": 31, + "25,0": 31, + "25,1": 22, + "25,2": 2, + "25,3": 1, + "25,4": 3, + "25,5": 24, + "25,6": 10, + "25,7": 5, + "25,8": 0, + "25,9": 2, + "25,10": 28, + "25,11": 7, + "25,12": 3, + "25,13": 0, + "25,14": 0, + "25,15": 20, + "25,16": 14, + "25,17": 9, + "25,18": 0, + "25,19": 0, + "25,20": 36, + "25,21": 33, + "25,22": 18, + "25,23": 15, + "25,24": 1, + "25,25": 25, + "25,26": 11, + "25,27": 6, + "25,28": 5, + "25,29": 2, + "25,30": 35, + "25,31": 42, + "25,32": 33, + "25,33": 30, + "25,34": 30, + "25,35": 40, + "25,36": 30, + "25,37": 22, + "25,38": 8, + "25,39": 9, + "26,0": 32, + "26,1": 21, + "26,2": 10, + "26,3": 4, + "26,4": 0, + "26,5": 40, + "26,6": 22, + "26,7": 13, + "26,8": 6, + "26,9": 1, + "26,10": 31, + "26,11": 25, + "26,12": 10, + "26,13": 0, + "26,14": 0, + "26,15": 29, + "26,16": 13, + "26,17": 10, + "26,18": 5, + "26,19": 0, + "26,20": 47, + "26,21": 47, + "26,22": 38, + "26,23": 20, + "26,24": 3, + "26,25": 28, + "26,26": 21, + "26,27": 9, + "26,28": 5, + "26,29": 3, + "26,30": 41, + "26,31": 41, + "26,32": 38, + "26,33": 43, + "26,34": 43, + "26,35": 45, + "26,36": 36, + "26,37": 32, + "26,38": 23, + "26,39": 17, + "27,0": 37, + "27,1": 30, + "27,2": 12, + "27,3": 10, + "27,4": 3, + "27,5": 43, + "27,6": 26, + "27,7": 13, + "27,8": 6, + "27,9": 7, + "27,10": 34, + "27,11": 33, + "27,12": 11, + "27,13": 2, + "27,14": 0, + "27,15": 31, + "27,16": 23, + "27,17": 16, + "27,18": 8, + "27,19": 5, + "27,20": 47, + "27,21": 46, + "27,22": 40, + "27,23": 18, + "27,24": 7, + "27,25": 39, + "27,26": 22, + "27,27": 16, + "27,28": 9, + "27,29": 6, + "27,30": 43, + "27,31": 40, + "27,32": 44, + "27,33": 38, + "27,34": 47, + "27,35": 47, + "27,36": 45, + "27,37": 35, + "27,38": 29, + "27,39": 17, + "28,0": 41, + "28,1": 30, + "28,2": 15, + "28,3": 10, + "28,4": 13, + "28,5": 41, + "28,6": 31, + "28,7": 25, + "28,8": 13, + "28,9": 7, + "28,10": 39, + "28,11": 27, + "28,12": 17, + "28,13": 3, + "28,14": 1, + "28,15": 38, + "28,16": 34, + "28,17": 29, + "28,18": 12, + "28,19": 11, + "28,20": 49, + "28,21": 46, + "28,22": 41, + "28,23": 26, + "28,24": 8, + "28,25": 39, + "28,26": 35, + "28,27": 24, + "28,28": 13, + "28,29": 9, + "28,30": 45, + "28,31": 40, + "28,32": 39, + "28,33": 40, + "28,34": 46, + "28,35": 47, + "28,36": 43, + "28,37": 45, + "28,38": 37, + "28,39": 25, + "29,0": 39, + "29,1": 27, + "29,2": 14, + "29,3": 7, + "29,4": 9, + "29,5": 45, + "29,6": 32, + "29,7": 29, + "29,8": 17, + "29,9": 13, + "29,10": 44, + "29,11": 33, + "29,12": 20, + "29,13": 2, + "29,14": 0, + "29,15": 37, + "29,16": 32, + "29,17": 28, + "29,18": 12, + "29,19": 14, + "29,20": 48, + "29,21": 46, + "29,22": 45, + "29,23": 32, + "29,24": 16, + "29,25": 44, + "29,26": 31, + "29,27": 19, + "29,28": 12, + "29,29": 10, + "29,30": 46, + "29,31": 36, + "29,32": 45, + "29,33": 34, + "29,34": 46, + "29,35": 49, + "29,36": 45, + "29,37": 40, + "29,38": 41, + "29,39": 30, + "30,0": 21, + "30,1": 6, + "30,2": 6, + "30,3": 2, + "30,4": 1, + "30,5": 10, + "30,6": 7, + "30,7": 2, + "30,8": 2, + "30,9": 1, + "30,10": 9, + "30,11": 2, + "30,12": 4, + "30,13": 0, + "30,14": 0, + "30,15": 10, + "30,16": 3, + "30,17": 0, + "30,18": 0, + "30,19": 0, + "30,20": 25, + "30,21": 15, + "30,22": 14, + "30,23": 4, + "30,24": 2, + "30,25": 10, + "30,26": 5, + "30,27": 2, + "30,28": 1, + "30,29": 3, + "30,30": 26, + "30,31": 29, + "30,32": 31, + "30,33": 29, + "30,34": 18, + "30,35": 37, + "30,36": 18, + "30,37": 12, + "30,38": 2, + "30,39": 2, + "31,0": 16, + "31,1": 9, + "31,2": 5, + "31,3": 2, + "31,4": 1, + "31,5": 17, + "31,6": 8, + "31,7": 3, + "31,8": 2, + "31,9": 1, + "31,10": 10, + "31,11": 4, + "31,12": 1, + "31,13": 0, + "31,14": 0, + "31,15": 5, + "31,16": 1, + "31,17": 2, + "31,18": 0, + "31,19": 0, + "31,20": 23, + "31,21": 23, + "31,22": 7, + "31,23": 3, + "31,24": 1, + "31,25": 9, + "31,26": 4, + "31,27": 2, + "31,28": 4, + "31,29": 2, + "31,30": 29, + "31,31": 30, + "31,32": 25, + "31,33": 25, + "31,34": 16, + "31,35": 38, + "31,36": 17, + "31,37": 15, + "31,38": 7, + "31,39": 6, + "32,0": 16, + "32,1": 7, + "32,2": 2, + "32,3": 4, + "32,4": 0, + "32,5": 13, + "32,6": 8, + "32,7": 4, + "32,8": 4, + "32,9": 0, + "32,10": 16, + "32,11": 3, + "32,12": 3, + "32,13": 0, + "32,14": 1, + "32,15": 11, + "32,16": 5, + "32,17": 2, + "32,18": 1, + "32,19": 0, + "32,20": 28, + "32,21": 17, + "32,22": 12, + "32,23": 0, + "32,24": 0, + "32,25": 15, + "32,26": 7, + "32,27": 3, + "32,28": 2, + "32,29": 2, + "32,30": 23, + "32,31": 27, + "32,32": 31, + "32,33": 29, + "32,34": 13, + "32,35": 34, + "32,36": 25, + "32,37": 11, + "32,38": 4, + "32,39": 0, + "33,0": 17, + "33,1": 7, + "33,2": 3, + "33,3": 1, + "33,4": 1, + "33,5": 18, + "33,6": 7, + "33,7": 6, + "33,8": 1, + "33,9": 2, + "33,10": 11, + "33,11": 4, + "33,12": 4, + "33,13": 0, + "33,14": 1, + "33,15": 13, + "33,16": 5, + "33,17": 3, + "33,18": 1, + "33,19": 1, + "33,20": 21, + "33,21": 15, + "33,22": 7, + "33,23": 0, + "33,24": 0, + "33,25": 17, + "33,26": 7, + "33,27": 5, + "33,28": 3, + "33,29": 6, + "33,30": 24, + "33,31": 24, + "33,32": 25, + "33,33": 22, + "33,34": 18, + "33,35": 32, + "33,36": 17, + "33,37": 12, + "33,38": 7, + "33,39": 2, + "34,0": 23, + "34,1": 7, + "34,2": 1, + "34,3": 1, + "34,4": 1, + "34,5": 25, + "34,6": 11, + "34,7": 8, + "34,8": 1, + "34,9": 0, + "34,10": 26, + "34,11": 15, + "34,12": 3, + "34,13": 0, + "34,14": 2, + "34,15": 15, + "34,16": 3, + "34,17": 0, + "34,18": 0, + "34,19": 0, + "34,20": 45, + "34,21": 31, + "34,22": 18, + "34,23": 0, + "34,24": 0, + "34,25": 15, + "34,26": 9, + "34,27": 2, + "34,28": 2, + "34,29": 2, + "34,30": 35, + "34,31": 29, + "34,32": 33, + "34,33": 34, + "34,34": 24, + "34,35": 38, + "34,36": 28, + "34,37": 20, + "34,38": 12, + "34,39": 6, + "35,0": 10, + "35,1": 7, + "35,2": 3, + "35,3": 1, + "35,4": 1, + "35,5": 6, + "35,6": 3, + "35,7": 2, + "35,8": 0, + "35,9": 1, + "35,10": 13, + "35,11": 3, + "35,12": 1, + "35,13": 0, + "35,14": 0, + "35,15": 10, + "35,16": 1, + "35,17": 1, + "35,18": 0, + "35,19": 0, + "35,20": 22, + "35,21": 16, + "35,22": 14, + "35,23": 5, + "35,24": 0, + "35,25": 14, + "35,26": 3, + "35,27": 2, + "35,28": 1, + "35,29": 0, + "35,30": 18, + "35,31": 20, + "35,32": 15, + "35,33": 24, + "35,34": 10, + "35,35": 27, + "35,36": 9, + "35,37": 7, + "35,38": 3, + "35,39": 0, + "36,0": 22, + "36,1": 11, + "36,2": 3, + "36,3": 1, + "36,4": 0, + "36,5": 27, + "36,6": 11, + "36,7": 3, + "36,8": 1, + "36,9": 1, + "36,10": 27, + "36,11": 6, + "36,12": 4, + "36,13": 0, + "36,14": 0, + "36,15": 13, + "36,16": 8, + "36,17": 2, + "36,18": 1, + "36,19": 0, + "36,20": 40, + "36,21": 25, + "36,22": 18, + "36,23": 9, + "36,24": 2, + "36,25": 17, + "36,26": 11, + "36,27": 8, + "36,28": 1, + "36,29": 2, + "36,30": 26, + "36,31": 26, + "36,32": 21, + "36,33": 26, + "36,34": 17, + "36,35": 36, + "36,36": 23, + "36,37": 18, + "36,38": 17, + "36,39": 5, + "37,0": 26, + "37,1": 18, + "37,2": 7, + "37,3": 0, + "37,4": 1, + "37,5": 25, + "37,6": 14, + "37,7": 8, + "37,8": 1, + "37,9": 2, + "37,10": 33, + "37,11": 13, + "37,12": 5, + "37,13": 0, + "37,14": 0, + "37,15": 19, + "37,16": 11, + "37,17": 8, + "37,18": 5, + "37,19": 0, + "37,20": 45, + "37,21": 32, + "37,22": 24, + "37,23": 15, + "37,24": 2, + "37,25": 33, + "37,26": 17, + "37,27": 4, + "37,28": 6, + "37,29": 1, + "37,30": 40, + "37,31": 27, + "37,32": 35, + "37,33": 41, + "37,34": 25, + "37,35": 42, + "37,36": 30, + "37,37": 22, + "37,38": 16, + "37,39": 11, + "38,0": 35, + "38,1": 19, + "38,2": 10, + "38,3": 4, + "38,4": 5, + "38,5": 35, + "38,6": 26, + "38,7": 11, + "38,8": 10, + "38,9": 1, + "38,10": 34, + "38,11": 15, + "38,12": 11, + "38,13": 1, + "38,14": 0, + "38,15": 31, + "38,16": 16, + "38,17": 10, + "38,18": 5, + "38,19": 4, + "38,20": 46, + "38,21": 44, + "38,22": 40, + "38,23": 29, + "38,24": 8, + "38,25": 37, + "38,26": 18, + "38,27": 11, + "38,28": 4, + "38,29": 8, + "38,30": 43, + "38,31": 44, + "38,32": 38, + "38,33": 43, + "38,34": 34, + "38,35": 46, + "38,36": 40, + "38,37": 25, + "38,38": 24, + "38,39": 15, + "39,0": 43, + "39,1": 31, + "39,2": 25, + "39,3": 18, + "39,4": 7, + "39,5": 42, + "39,6": 28, + "39,7": 24, + "39,8": 14, + "39,9": 9, + "39,10": 44, + "39,11": 32, + "39,12": 23, + "39,13": 6, + "39,14": 2, + "39,15": 41, + "39,16": 24, + "39,17": 19, + "39,18": 13, + "39,19": 10, + "39,20": 48, + "39,21": 49, + "39,22": 42, + "39,23": 32, + "39,24": 14, + "39,25": 33, + "39,26": 26, + "39,27": 19, + "39,28": 13, + "39,29": 12, + "39,30": 49, + "39,31": 49, + "39,32": 47, + "39,33": 47, + "39,34": 38, + "39,35": 48, + "39,36": 45, + "39,37": 43, + "39,38": 29, + "39,39": 24 + } +} \ No newline at end of file