🐐
This commit is contained in:
@@ -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} " +
|
||||
|
||||
Reference in New Issue
Block a user