🐐
This commit is contained in:
461
backend/ai.py
461
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]}"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user