259 lines
10 KiB
Python
259 lines
10 KiB
Python
"""
|
||
Commander AI Lab — RL Scope Configuration
|
||
═══════════════════════════════════════════
|
||
Defines the simplified game scope, playstyle labels,
|
||
macro-action space, and state encoding dimensions for
|
||
the first RL prototype.
|
||
|
||
Design decisions:
|
||
- 1v1 Commander (2 players, 40 life, commander damage 21)
|
||
- ~8 macro-actions per decision point
|
||
- 4 playstyle labels: Aggro, Control, Midrange, Combo
|
||
- State = global features + per-zone card embeddings
|
||
"""
|
||
|
||
from dataclasses import dataclass, field
|
||
from typing import List, Dict
|
||
from enum import Enum
|
||
|
||
# ══════════════════════════════════════════════════════════
|
||
# Game Scope
|
||
# ══════════════════════════════════════════════════════════
|
||
|
||
@dataclass(frozen=True)
|
||
class GameScope:
|
||
"""Simplified Commander rules for RL prototype."""
|
||
pod_size: int = 2 # 1v1
|
||
starting_life: int = 40
|
||
commander_damage_lethal: int = 21
|
||
max_turns: int = 25 # Force game end
|
||
mulligan_rule: str = "london"
|
||
format: str = "commander"
|
||
|
||
GAME_SCOPE = GameScope()
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════
|
||
# Playstyle Labels
|
||
# ══════════════════════════════════════════════════════════
|
||
|
||
class Playstyle(str, Enum):
|
||
"""Deck archetype / playstyle labels.
|
||
|
||
Each label implies a preferred strategic approach:
|
||
AGGRO — Prioritize attacking, fast damage, low curve
|
||
CONTROL — Hold up mana, prioritize removal, card advantage
|
||
MIDRANGE— Balanced: develop board, then attack when advantageous
|
||
COMBO — Protect combo pieces, tutor aggressively, race to assemble
|
||
"""
|
||
AGGRO = "aggro"
|
||
CONTROL = "control"
|
||
MIDRANGE = "midrange"
|
||
COMBO = "combo"
|
||
|
||
|
||
# Heuristic playstyle assignment rules (applied to deck metadata)
|
||
# These map deck characteristics → playstyle label
|
||
PLAYSTYLE_HEURISTICS = {
|
||
Playstyle.AGGRO: {
|
||
"avg_mana_value_max": 3.0, # Low average CMC
|
||
"creature_ratio_min": 0.30, # High creature density
|
||
"removal_ratio_max": 0.08, # Low removal count
|
||
"description": "Low curve, creature-heavy, minimal interaction"
|
||
},
|
||
Playstyle.CONTROL: {
|
||
"avg_mana_value_min": 3.2,
|
||
"removal_ratio_min": 0.10, # High removal/interaction
|
||
"creature_ratio_max": 0.25,
|
||
"description": "High interaction, card advantage, late-game finishers"
|
||
},
|
||
Playstyle.MIDRANGE: {
|
||
"avg_mana_value_min": 2.5,
|
||
"avg_mana_value_max": 3.8,
|
||
"creature_ratio_min": 0.20,
|
||
"creature_ratio_max": 0.35,
|
||
"description": "Balanced curve, flexible strategy"
|
||
},
|
||
Playstyle.COMBO: {
|
||
"combo_pieces_min": 2, # Has known combo cards
|
||
"tutor_count_min": 2, # Has tutors to find them
|
||
"description": "Win via specific card combinations"
|
||
},
|
||
}
|
||
|
||
# One-hot encoding dimension for playstyle
|
||
PLAYSTYLE_DIM = len(Playstyle) # 4
|
||
|
||
# Mapping for one-hot encoding
|
||
PLAYSTYLE_TO_IDX: Dict[Playstyle, int] = {
|
||
Playstyle.AGGRO: 0,
|
||
Playstyle.CONTROL: 1,
|
||
Playstyle.MIDRANGE: 2,
|
||
Playstyle.COMBO: 3,
|
||
}
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════
|
||
# Macro-Action Space
|
||
# ══════════════════════════════════════════════════════════
|
||
|
||
class MacroAction(str, Enum):
|
||
"""High-level actions the RL agent can choose.
|
||
|
||
Each macro-action maps to one or more concrete Forge actions.
|
||
The Java side translates the chosen macro into specific game moves.
|
||
|
||
8 actions for the initial prototype:
|
||
"""
|
||
# Proactive plays
|
||
CAST_CREATURE = "cast_creature" # Play the best available creature
|
||
CAST_REMOVAL = "cast_removal" # Cast removal on strongest enemy threat
|
||
CAST_DRAW = "cast_draw" # Cast card-draw or cantrip spell
|
||
CAST_RAMP = "cast_ramp" # Cast ramp spell or mana rock
|
||
CAST_COMMANDER = "cast_commander" # Cast your commander from command zone
|
||
|
||
# Combat
|
||
ATTACK_OPPONENT = "attack_opponent" # Attack with all profitable attackers
|
||
|
||
# Reactive / passive
|
||
HOLD_MANA = "hold_mana" # Pass priority, keep mana open
|
||
PASS = "pass" # Pass with no action (end step, etc.)
|
||
|
||
|
||
# Number of discrete actions
|
||
NUM_ACTIONS = len(MacroAction) # 8
|
||
|
||
# Action index mapping
|
||
ACTION_TO_IDX: Dict[MacroAction, int] = {a: i for i, a in enumerate(MacroAction)}
|
||
IDX_TO_ACTION: Dict[int, MacroAction] = {i: a for a, i in ACTION_TO_IDX.items()}
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════
|
||
# State Encoding Dimensions
|
||
# ══════════════════════════════════════════════════════════
|
||
|
||
@dataclass(frozen=True)
|
||
class StateDimensions:
|
||
"""Dimensions for the state vector encoding.
|
||
|
||
State = [global_features | zone_embeddings | playstyle_vector]
|
||
|
||
Global features (per player × 2 players):
|
||
- life_total (normalized 0-1 by /40)
|
||
- commander_damage_taken (normalized 0-1 by /21)
|
||
- mana_available (0-20 range, normalized)
|
||
- commander_tax (0-10 range, normalized)
|
||
- cards_in_hand (0-15, normalized)
|
||
- cards_in_graveyard (0-100, normalized)
|
||
- creatures_on_battlefield (0-30, normalized)
|
||
- total_power_on_board (0-100, normalized)
|
||
- is_active_player (0 or 1)
|
||
- turn_number (normalized 0-1 by /max_turns)
|
||
- phase (one-hot: main1, combat, main2, end = 4)
|
||
|
||
Per player = 10 scalar + 4 phase one-hot = 14 features
|
||
2 players = 28 global features
|
||
+ turn_number = 1
|
||
Total global = 29
|
||
"""
|
||
card_embedding_dim: int = 768 # From mtg-embeddings
|
||
zone_pool_dim: int = 768 # Mean-pooled per zone
|
||
|
||
# Zones per player: hand, battlefield, graveyard, command_zone
|
||
zones_per_player: int = 4
|
||
num_players: int = 2 # 1v1
|
||
|
||
# Zone vectors: 4 zones × 2 players × 768 = 6144
|
||
total_zone_dim: int = 4 * 2 * 768 # 6144
|
||
|
||
# Global scalar features
|
||
per_player_scalars: int = 10
|
||
phase_onehot: int = 4 # main1, combat, main2, end
|
||
per_player_features: int = 14 # 10 + 4
|
||
global_features: int = 29 # 14×2 + 1 (turn)
|
||
|
||
# Playstyle vector
|
||
playstyle_dim: int = 4 # One-hot over 4 styles
|
||
|
||
@property
|
||
def total_state_dim(self) -> int:
|
||
"""Total dimension of the flattened state vector."""
|
||
return self.global_features + self.total_zone_dim + self.playstyle_dim
|
||
# 29 + 6144 + 4 = 6177
|
||
|
||
STATE_DIMS = StateDimensions()
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════
|
||
# Game Phase Encoding
|
||
# ══════════════════════════════════════════════════════════
|
||
|
||
class GamePhase(str, Enum):
|
||
MAIN_1 = "main_1"
|
||
COMBAT = "combat"
|
||
MAIN_2 = "main_2"
|
||
END = "end"
|
||
|
||
PHASE_TO_IDX = {
|
||
GamePhase.MAIN_1: 0,
|
||
GamePhase.COMBAT: 1,
|
||
GamePhase.MAIN_2: 2,
|
||
GamePhase.END: 3,
|
||
}
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════
|
||
# Forge Log Action-Label Mapping Keywords
|
||
# ══════════════════════════════════════════════════════════
|
||
|
||
# These keywords in Forge log lines help classify raw actions
|
||
# into macro-actions for supervised learning.
|
||
# Used by ml/actions/labeler.py
|
||
|
||
ACTION_KEYWORDS = {
|
||
MacroAction.CAST_CREATURE: [
|
||
"creature", "token", "summon",
|
||
],
|
||
MacroAction.CAST_REMOVAL: [
|
||
"destroy", "exile", "damage to", "counter",
|
||
"-X/-X", "sacrifice", "bounce",
|
||
],
|
||
MacroAction.CAST_DRAW: [
|
||
"draw", "scry", "look at the top", "reveal",
|
||
"search your library",
|
||
],
|
||
MacroAction.CAST_RAMP: [
|
||
"add {", "mana", "land onto the battlefield",
|
||
"search your library for a basic land",
|
||
],
|
||
MacroAction.CAST_COMMANDER: [], # Detected by matching commander name
|
||
MacroAction.ATTACK_OPPONENT: [
|
||
"attacks", "declare attackers", "combat damage",
|
||
],
|
||
MacroAction.HOLD_MANA: [], # Inferred: priority passed with mana open
|
||
MacroAction.PASS: [], # Inferred: priority passed with no mana / no actions
|
||
}
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════
|
||
# Training Defaults
|
||
# ══════════════════════════════════════════════════════════
|
||
|
||
@dataclass
|
||
class TrainingConfig:
|
||
"""Default hyperparameters for supervised policy learning."""
|
||
batch_size: int = 64
|
||
learning_rate: float = 1e-3
|
||
epochs: int = 50
|
||
hidden_dim: int = 512
|
||
num_layers: int = 3
|
||
dropout: float = 0.1
|
||
activation: str = "silu"
|
||
early_stop_patience: int = 5
|
||
val_split: float = 0.15
|
||
test_split: float = 0.10
|
||
checkpoint_dir: str = "ml/models/checkpoints"
|
||
log_every: int = 100 # Log every N batches
|
||
|
||
TRAINING_CONFIG = TrainingConfig()
|