This commit is contained in:
2026-03-26 00:51:25 +01:00
parent 99db0b3c67
commit ef4496aa5d
31 changed files with 4185 additions and 452 deletions

View File

@@ -19,16 +19,17 @@ 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
JEBRASKA = "jebraska" # Trained neural network plan scorer
def get_random_personality() -> AIPersonality:
"""Returns a random AI personality."""
return random.choice(list(AIPersonality))
# return random.choice(list(AIPersonality))
return AIPersonality.JEBRASKA
def calculate_exact_cost(attack: int, defense: int) -> float:
"""Calculate the exact cost before rounding (matches card.py formula)."""
return min(11.0, max(1.0, ((attack**2 + defense**2)**0.18) / 1.5))
return min(10.0, max(1.0, ((attack**2 + defense**2)**0.18) / 1.5))
def get_power_curve_value(card) -> float:
"""
@@ -54,7 +55,7 @@ def choose_cards(cards: list[Card], difficulty: int, personality: AIPersonality)
defn = np.array([c.defense for c in allowed], dtype=np.float32)
cost = np.array([c.cost for c in allowed], dtype=np.float32)
exact_cost = np.minimum(11.0, np.maximum(1.0, ((atk**2 + defn**2)**0.18) / 1.5))
exact_cost = np.minimum(10.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
@@ -78,21 +79,14 @@ def choose_cards(cards: list[Card], difficulty: int, personality: AIPersonality)
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:
elif personality in (AIPersonality.BALANCED, AIPersonality.JEBRASKA):
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
w = 0.09 * difficulty
scores = w * pcv_norm + (1.0 - w) * np.random.random(len(allowed)).astype(np.float32)
# 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
noise = (max(0,12 - difficulty)**2) * 0.008
scores = scores + np.random.normal(0, noise, len(allowed)).astype(np.float32)
order = np.argsort(-scores)
@@ -105,7 +99,7 @@ def choose_cards(cards: list[Card], difficulty: int, personality: AIPersonality)
AIPersonality.DEFENSIVE: 15, # raised: stable cheap-card base across difficulty levels
AIPersonality.CONTROL: 8,
AIPersonality.BALANCED: 25, # spread the deck across all cost levels
AIPersonality.SHOCKER: 15, # ~15 cost-1 shields, then expensive attackers fill remaining budget
AIPersonality.JEBRASKA: 25, # same as balanced
AIPersonality.ARBITRARY: 8,
}[personality]
@@ -303,15 +297,11 @@ def score_plans_batch(
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 * 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)
score = (0.50 * np.random.random(n).astype(np.float32) +
0.06 * atk_score + 0.06 * block_score + 0.08 * cover_score +
0.05 * net_value_norm + 0.06 * destroy_score +
0.08 * attrition_score + 0.06 * pcv_score + 0.05 * threat_score)
# --- Context adjustments ---
score = np.where(direct_damage >= opponent.life, np.maximum(score, 0.95), score)
@@ -333,12 +323,25 @@ def score_plans_batch(
return np.maximum(0.0, score - sac_penalty)
async def choose_plan(player: PlayerState, opponent: PlayerState, personality: AIPersonality, difficulty: int) -> MovePlan:
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)
if personality == AIPersonality.JEBRASKA:
from nn import NeuralNet
import os
_weights = os.path.join(os.path.dirname(__file__), "nn_weights.json")
if not hasattr(choose_plan, "_neural_net"):
choose_plan._neural_net = NeuralNet.load(_weights) if os.path.exists(_weights) else None
net = choose_plan._neural_net
if net is not None:
from nn import extract_plan_features
scores = net.forward(extract_plan_features(plans, player, opponent))
else: # fallback to BALANCED if weights not found
scores = score_plans_batch(plans, player, opponent, AIPersonality.BALANCED)
else:
scores = score_plans_batch(plans, player, opponent, personality)
noise_scale = (max(0,11 - difficulty)**2) * 0.01 - 0.01
noise_scale = ((max(0,12 - difficulty)**2) - 4) * 0.008
noise = np.random.normal(0, noise_scale, len(scores)).astype(np.float32)
return plans[int(np.argmax(scores + noise))]
@@ -388,7 +391,7 @@ async def run_ai_turn(game_id: str):
pass
# --- Generate and score candidate plans ---
best_plan = await choose_plan(player, opponent, personality, difficulty)
best_plan = choose_plan(player, opponent, personality, difficulty)
logger.info(
f"AI turn: d={difficulty} p={personality.value} plan={best_plan.label} " +