Both 'Inference error' log lines use identical logger strings, making it impossible to tell which except block is catching the crash. Also, broad except blocks were swallowing the full traceback so the real crash site and cause were hidden. Changes: - policy_server.py: logger renamed to 'ml.serving.policy_server'; predict() and predict_batch() error handlers add exc_info=True - routes/policy.py: logger renamed to 'routes.policy.decide'; _infer_from_vector() error handler adds exc_info=True; decide() fallback warning adds exc_info=True The next crash will print the full stack trace, pinpointing the exact line and call chain causing the TypeError.
648 lines
23 KiB
Python
648 lines
23 KiB
Python
"""Commander AI Lab — Policy Decision Routes (Phase 2)
|
|
════════════════════════════════════════════════════════
|
|
Live Forge ↔ policy server IPC endpoints for online training.
|
|
These endpoints are purpose-built for the real-time decision loop
|
|
where Forge's Java side calls the Python policy server at each
|
|
game decision point.
|
|
|
|
Endpoints:
|
|
POST /api/policy/decide — Accept full Forge game state, return action + log_prob + value
|
|
POST /api/policy/collect — Accept batch of (state, action, reward) tuples
|
|
POST /api/policy/reward — Submit end-of-game reward
|
|
GET /api/policy/health — Readiness check for the live IPC loop
|
|
GET /api/policy/stats — Online learning session statistics
|
|
|
|
Refs: Issue #83 Phase 2.2, Step 4 (real Forge board stats)
|
|
"""
|
|
import logging
|
|
import math
|
|
import time
|
|
from typing import Dict, List, Optional
|
|
|
|
import numpy as np
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
|
|
# Unique logger name so log lines are attributable to this module specifically
|
|
logger = logging.getLogger("routes.policy.decide")
|
|
|
|
router = APIRouter(prefix="/api/policy", tags=["policy"])
|
|
|
|
# Expected length of the precomputed global scalar block from GameSession
|
|
_EXPECTED_STATE_VECTOR_LEN = 29
|
|
|
|
|
|
# ================================================================
|
|
# Request / Response Models
|
|
# ================================================================
|
|
|
|
class PlayerZoneState(BaseModel):
|
|
"""Per-player zone state from Forge / GameSession.
|
|
|
|
Fields match DecisionSnapshot.PlayerSnapshot (Java) so the same JSON
|
|
deserialises on both the live IPC path and the JSONL training path.
|
|
All board-stat fields are optional with a default of 0 so older
|
|
snapshots (pre-Step-4) continue to deserialise without errors.
|
|
"""
|
|
seat: int = 0
|
|
name: str = ""
|
|
life: int = 40
|
|
poison: int = 0
|
|
# GameSession sends cmdr_dmg as a dict keyed by opponent seat str
|
|
cmdr_dmg: Dict[str, int] = Field(default_factory=dict)
|
|
|
|
# Commander tax / casts
|
|
cmdr_tax: int = 0
|
|
commanderTax: int = 0 # GameSession alias for cmdr_tax
|
|
commanderCasts: int = 0
|
|
|
|
# Mana
|
|
mana_available: int = 0
|
|
manaAvailable: int = 0 # DecisionSnapshot alias
|
|
mana_pool: Dict[str, int] = Field(default_factory=dict)
|
|
|
|
# Full zone contents
|
|
hand: List[str] = Field(default_factory=list)
|
|
hand_count: int = 0
|
|
handCount: int = 0 # GameSession alias for hand_count
|
|
battlefield: List = Field(default_factory=list) # list[str] or list[dict]
|
|
graveyard: List[str] = Field(default_factory=list)
|
|
exile: List[str] = Field(default_factory=list)
|
|
command_zone: List[str] = Field(default_factory=list)
|
|
commandZone: List[str] = Field(default_factory=list) # GameSession alias
|
|
library_count: int = 0
|
|
|
|
# ── Real Forge board stats (DecisionSnapshot.PlayerSnapshot) ──────────
|
|
# These are pre-computed by Forge/DecisionExtractor and are more accurate
|
|
# than inferring them from zone card-name lists.
|
|
creaturesOnField: int = 0 # actual creature count on battlefield
|
|
totalPowerOnBoard: int = 0 # sum of power of all creatures
|
|
totalToughnessOnBoard: int = 0 # sum of toughness of all creatures
|
|
artifactsOnField: int = 0 # artifact permanents
|
|
enchantmentsOnField: int = 0 # enchantment permanents
|
|
landCount: int = 0 # lands on battlefield
|
|
|
|
# Legacy snake_case aliases (JSONL training path uses these)
|
|
creatures_on_field: int = 0
|
|
total_power_on_board: int = 0
|
|
total_toughness_on_board: int = 0
|
|
artifacts_on_field: int = 0
|
|
enchantments_on_field: int = 0
|
|
land_count: int = 0
|
|
|
|
# ── Resolver helpers ──────────────────────────────────────────────────
|
|
|
|
def resolved_hand(self) -> List[str]:
|
|
return self.hand if self.hand else []
|
|
|
|
def resolved_hand_count(self) -> int:
|
|
if self.hand:
|
|
return len(self.hand)
|
|
return self.handCount or self.hand_count
|
|
|
|
def resolved_cmdr_tax(self) -> int:
|
|
return self.cmdr_tax or self.commanderTax
|
|
|
|
def resolved_command_zone(self) -> List[str]:
|
|
return self.command_zone if self.command_zone else self.commandZone
|
|
|
|
def resolved_battlefield_names(self) -> List[str]:
|
|
names = []
|
|
for entry in self.battlefield:
|
|
if isinstance(entry, str):
|
|
names.append(entry)
|
|
elif isinstance(entry, dict):
|
|
names.append(entry.get("name", entry.get("id", "unknown")))
|
|
return names
|
|
|
|
def resolved_mana(self) -> int:
|
|
"""Return mana from whichever alias is populated."""
|
|
return self.manaAvailable or self.mana_available
|
|
|
|
def resolved_creatures(self) -> int:
|
|
"""Real creature count from Forge; falls back to battlefield name count."""
|
|
real = self.creaturesOnField or self.creatures_on_field
|
|
if real > 0:
|
|
return real
|
|
# Fallback: count non-land names on battlefield (rough)
|
|
return len(self.resolved_battlefield_names())
|
|
|
|
def resolved_lands(self) -> int:
|
|
"""Real land count from Forge; falls back to 0."""
|
|
return self.landCount or self.land_count
|
|
|
|
def resolved_total_power(self) -> int:
|
|
"""Real total power from Forge; falls back to creatures * 3 heuristic."""
|
|
real = self.totalPowerOnBoard or self.total_power_on_board
|
|
if real > 0:
|
|
return real
|
|
return self.resolved_creatures() * 3 # legacy heuristic
|
|
|
|
def resolved_total_toughness(self) -> int:
|
|
return self.totalToughnessOnBoard or self.total_toughness_on_board
|
|
|
|
def resolved_artifacts(self) -> int:
|
|
return self.artifactsOnField or self.artifacts_on_field
|
|
|
|
def resolved_enchantments(self) -> int:
|
|
return self.enchantmentsOnField or self.enchantments_on_field
|
|
|
|
|
|
class DecideRequest(BaseModel):
|
|
"""Full game state for live decision-making.
|
|
|
|
Accepts snapshots from both:
|
|
- The legacy JSONL / batch path (players[] with flat scalar fields)
|
|
- GameSession.buildStateSnapshot() (players[] with zone lists,
|
|
plus a precomputed state_vector float[29] global block)
|
|
|
|
When state_vector is present and length == 29, it is used directly
|
|
as the global scalar block and zone embedding pooling runs over the
|
|
players[] zone contents. This avoids re-deriving scalars that Java
|
|
already computed and normalised.
|
|
"""
|
|
# Game metadata
|
|
game_id: str = ""
|
|
turn: int = 1
|
|
turnNumber: int = 0 # GameSession alias for turn
|
|
phase: str = "main_1"
|
|
active_player: int = 0
|
|
activePlayer: int = 0 # GameSession alias
|
|
priority_player: int = 0
|
|
# Per-player state with full zones
|
|
players: List[PlayerZoneState] = Field(default_factory=list)
|
|
# Stack (spells/abilities currently resolving)
|
|
stack: List[Dict] = Field(default_factory=list)
|
|
# Legal actions from Forge
|
|
legal_actions: List[Dict] = Field(default_factory=list)
|
|
legalActions: List[Dict] = Field(default_factory=list) # GameSession alias
|
|
# Inference params
|
|
playstyle: str = "midrange"
|
|
greedy: bool = False
|
|
temperature: float = 1.0
|
|
# Optional commander info
|
|
commander: str = ""
|
|
deck_name: str = ""
|
|
# Precomputed global scalar vector from GameSession.buildStateVector()
|
|
# float[29] matching STATE_DIMS.global_features in ml/config/scope.py
|
|
state_vector: Optional[List[float]] = None
|
|
state_vector_dim: Optional[int] = None
|
|
# Schema version (GameSession 1.1.0+ includes state_vector)
|
|
snapshot_schema: str = Field(default="1.0.0", alias="schema")
|
|
|
|
model_config = {"populate_by_name": True}
|
|
|
|
def resolved_turn(self) -> int:
|
|
return self.turnNumber if self.turnNumber > 0 else self.turn
|
|
|
|
def resolved_active_seat(self) -> int:
|
|
return self.activePlayer if self.activePlayer > 0 else self.active_player
|
|
|
|
def has_precomputed_vector(self) -> bool:
|
|
"""True when a valid precomputed state_vector is present."""
|
|
return (
|
|
self.state_vector is not None
|
|
and len(self.state_vector) == _EXPECTED_STATE_VECTOR_LEN
|
|
)
|
|
|
|
|
|
class DecideResponse(BaseModel):
|
|
"""Action decision with metadata for online learning."""
|
|
action: str
|
|
action_index: int
|
|
confidence: float
|
|
log_prob: float = 0.0
|
|
value: float = 0.0
|
|
probabilities: Dict[str, float] = Field(default_factory=dict)
|
|
inference_ms: float = 0.0
|
|
vector_source: str = "encoder" # "precomputed" | "encoder"
|
|
error: Optional[str] = None
|
|
|
|
|
|
class CollectRequest(BaseModel):
|
|
"""Batch of online learning tuples from live Forge games."""
|
|
tuples: List[Dict] = Field(default_factory=list)
|
|
count: int = 0
|
|
|
|
|
|
class RewardRequest(BaseModel):
|
|
"""End-of-game reward submission."""
|
|
game_id: str
|
|
reward: float
|
|
winner_seat: int = -1
|
|
turns_played: int = 0
|
|
reason: str = "" # e.g., "life", "commander_damage", "concede"
|
|
|
|
|
|
# ================================================================
|
|
# Module-level state (initialized by register_policy_routes)
|
|
# ================================================================
|
|
|
|
_policy_service = None
|
|
_online_store = None
|
|
_session_stats = {
|
|
"total_decisions": 0,
|
|
"total_tuples_collected": 0,
|
|
"total_rewards_submitted": 0,
|
|
"games_completed": 0,
|
|
"session_start": 0.0,
|
|
"last_decision_ms": 0.0,
|
|
"precomputed_vector_hits": 0,
|
|
"encoder_fallbacks": 0,
|
|
}
|
|
|
|
|
|
def register_policy_routes(app, policy_service, online_store=None):
|
|
"""Register the policy routes on the FastAPI app.
|
|
|
|
Args:
|
|
app: FastAPI application instance
|
|
policy_service: PolicyInferenceService from ml.serving.policy_server
|
|
online_store: Optional OnlineLearningStore for tuple collection
|
|
"""
|
|
global _policy_service, _online_store
|
|
_policy_service = policy_service
|
|
_online_store = online_store
|
|
_session_stats["session_start"] = time.time()
|
|
app.include_router(router)
|
|
logger.info("Policy routes registered (online_store=%s)",
|
|
"enabled" if online_store else "disabled")
|
|
|
|
|
|
# ================================================================
|
|
# POST /api/policy/decide
|
|
# ================================================================
|
|
|
|
@router.post("/decide", response_model=DecideResponse)
|
|
async def decide(req: DecideRequest):
|
|
"""Accept full game state, run policy inference, return action.
|
|
|
|
When the request includes a precomputed state_vector (float[29] from
|
|
GameSession.buildStateVector()), that vector is used directly as the
|
|
global scalar block and only zone embedding pooling is re-run.
|
|
Otherwise falls back to the full StateEncoder.encode() path.
|
|
|
|
This is the primary endpoint for live Forge IPC. The Java
|
|
PolicyClient calls this at each decision point during a real
|
|
Forge game.
|
|
"""
|
|
if _policy_service is None or not _policy_service._loaded:
|
|
raise HTTPException(503, detail="Policy model not loaded")
|
|
|
|
t_start = time.time()
|
|
_session_stats["total_decisions"] += 1
|
|
|
|
playstyle = req.playstyle or "midrange"
|
|
vector_source = "encoder"
|
|
|
|
if req.has_precomputed_vector():
|
|
try:
|
|
state_vec = _encode_with_precomputed_global(
|
|
precomputed_global=req.state_vector,
|
|
players=req.players,
|
|
playstyle=playstyle,
|
|
policy_service=_policy_service,
|
|
)
|
|
vector_source = "precomputed"
|
|
_session_stats["precomputed_vector_hits"] += 1
|
|
logger.debug(
|
|
"[decide] precomputed global block used (schema=%s, turn=%d)",
|
|
req.snapshot_schema, req.resolved_turn(),
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Precomputed vector encode failed — falling back to full encoder",
|
|
exc_info=True,
|
|
)
|
|
state_vec = None
|
|
else:
|
|
state_vec = None
|
|
|
|
if state_vec is None:
|
|
_session_stats["encoder_fallbacks"] += 1
|
|
snapshot = _build_encoder_snapshot(req)
|
|
result = _policy_service.predict(
|
|
snapshot,
|
|
playstyle=playstyle,
|
|
temperature=req.temperature,
|
|
greedy=req.greedy,
|
|
)
|
|
else:
|
|
result = _infer_from_vector(
|
|
state_vec=state_vec,
|
|
policy_service=_policy_service,
|
|
temperature=req.temperature,
|
|
greedy=req.greedy,
|
|
)
|
|
|
|
elapsed_ms = (time.time() - t_start) * 1000
|
|
_session_stats["last_decision_ms"] = elapsed_ms
|
|
|
|
if "error" in result:
|
|
raise HTTPException(500, detail=result["error"])
|
|
|
|
log_prob = 0.0
|
|
try:
|
|
conf = result.get("confidence", 0.5)
|
|
log_prob = math.log(max(conf, 1e-8))
|
|
except Exception:
|
|
pass
|
|
|
|
return DecideResponse(
|
|
action=result["action"],
|
|
action_index=result["action_index"],
|
|
confidence=result["confidence"],
|
|
log_prob=round(log_prob, 6),
|
|
value=0.0,
|
|
probabilities=result.get("probabilities", {}),
|
|
inference_ms=round(elapsed_ms, 2),
|
|
vector_source=vector_source,
|
|
)
|
|
|
|
|
|
# ================================================================
|
|
# POST /api/policy/collect
|
|
# ================================================================
|
|
|
|
@router.post("/collect")
|
|
async def collect_tuples(req: CollectRequest):
|
|
"""Accept a batch of (state, action, reward) tuples from live games
|
|
for online PPO updates.
|
|
"""
|
|
count = len(req.tuples)
|
|
_session_stats["total_tuples_collected"] += count
|
|
|
|
if _online_store is not None:
|
|
try:
|
|
_online_store.add_tuples(req.tuples)
|
|
except Exception as e:
|
|
logger.error("Failed to store tuples: %s", e, exc_info=True)
|
|
raise HTTPException(500, detail=str(e))
|
|
|
|
return {
|
|
"accepted": count,
|
|
"total_buffered": (
|
|
_online_store.size() if _online_store else count
|
|
),
|
|
}
|
|
|
|
|
|
# ================================================================
|
|
# POST /api/policy/reward
|
|
# ================================================================
|
|
|
|
@router.post("/reward")
|
|
async def submit_reward(req: RewardRequest):
|
|
"""Submit end-of-game reward for all tuples in a game."""
|
|
_session_stats["total_rewards_submitted"] += 1
|
|
_session_stats["games_completed"] += 1
|
|
|
|
if _online_store is not None:
|
|
try:
|
|
_online_store.assign_reward(
|
|
game_id=req.game_id,
|
|
reward=req.reward,
|
|
winner_seat=req.winner_seat,
|
|
)
|
|
except Exception as e:
|
|
logger.error("Failed to assign reward: %s", e, exc_info=True)
|
|
raise HTTPException(500, detail=str(e))
|
|
|
|
return {
|
|
"game_id": req.game_id,
|
|
"reward": req.reward,
|
|
"status": "accepted",
|
|
}
|
|
|
|
|
|
# ================================================================
|
|
# GET /api/policy/health
|
|
# ================================================================
|
|
|
|
@router.get("/health")
|
|
async def policy_health():
|
|
"""Readiness check for the live Forge IPC loop."""
|
|
model_loaded = (
|
|
_policy_service is not None and _policy_service._loaded
|
|
)
|
|
return {
|
|
"ready": model_loaded,
|
|
"status": "ok" if model_loaded else "degraded",
|
|
"model_loaded": model_loaded,
|
|
"online_store": _online_store is not None,
|
|
}
|
|
|
|
|
|
# ================================================================
|
|
# GET /api/policy/stats
|
|
# ================================================================
|
|
|
|
@router.get("/stats")
|
|
async def policy_stats():
|
|
"""Online learning session statistics."""
|
|
uptime = time.time() - _session_stats["session_start"]
|
|
return {
|
|
**_session_stats,
|
|
"uptime_seconds": round(uptime, 1),
|
|
"decisions_per_second": (
|
|
round(_session_stats["total_decisions"] / max(uptime, 1), 2)
|
|
),
|
|
"online_store_size": (
|
|
_online_store.size() if _online_store else 0
|
|
),
|
|
}
|
|
|
|
|
|
# ================================================================
|
|
# Internal helpers
|
|
# ================================================================
|
|
|
|
def _encode_with_precomputed_global(
|
|
precomputed_global: List[float],
|
|
players: List[PlayerZoneState],
|
|
playstyle: str,
|
|
policy_service,
|
|
) -> np.ndarray:
|
|
"""Build a 6177-dim state vector using a precomputed global block.
|
|
|
|
Uses the Java-computed float[29] global scalar vector directly and
|
|
runs zone embedding pooling + playstyle one-hot on the Python side.
|
|
|
|
Args:
|
|
precomputed_global: float[29] from GameSession.buildStateVector()
|
|
players: PlayerZoneState list from the request (for zone contents)
|
|
playstyle: deck archetype string
|
|
policy_service: PolicyInferenceService (for encoder access)
|
|
|
|
Returns:
|
|
np.ndarray of shape (6177,)
|
|
"""
|
|
encoder = policy_service.encoder
|
|
if encoder is None:
|
|
raise RuntimeError("StateEncoder not initialised on policy service")
|
|
|
|
# 1. Precomputed global block (29 floats)
|
|
global_vec = np.array(precomputed_global, dtype=np.float32)
|
|
|
|
# 2. Zone embeddings — pool card names from each player's zones
|
|
player_dicts = [
|
|
{
|
|
"hand": p.resolved_hand(),
|
|
"battlefield": p.resolved_battlefield_names(),
|
|
"graveyard": p.graveyard,
|
|
"command_zone": p.resolved_command_zone(),
|
|
}
|
|
for p in players[:2]
|
|
]
|
|
while len(player_dicts) < 2:
|
|
player_dicts.append({
|
|
"hand": [], "battlefield": [], "graveyard": [], "command_zone": []
|
|
})
|
|
|
|
zone_vec = encoder._encode_zones(player_dicts)
|
|
|
|
# 3. Playstyle one-hot (4 floats)
|
|
style_vec = encoder._encode_playstyle(playstyle)
|
|
|
|
state = np.concatenate([global_vec, zone_vec, style_vec])
|
|
|
|
expected = encoder.dim.total_state_dim # 6177
|
|
if state.shape[0] != expected:
|
|
raise ValueError(
|
|
f"State dim mismatch after precomputed encode: "
|
|
f"{state.shape[0]} != {expected}"
|
|
)
|
|
|
|
return state.astype(np.float32)
|
|
|
|
|
|
def _infer_from_vector(state_vec: np.ndarray, policy_service, temperature: float, greedy: bool) -> Dict:
|
|
"""Run policy network inference directly on a prebuilt state vector.
|
|
|
|
Mirrors the core of PolicyInferenceService.predict() but accepts an
|
|
ndarray instead of a snapshot dict, avoiding a second encode() call.
|
|
|
|
Uses _unwrap_logits (same helper as predict()) to guard against
|
|
tuple/dict returns from wrapped or swapped model variants before any
|
|
arithmetic on the logits tensor.
|
|
"""
|
|
import torch
|
|
from ml.config.scope import NUM_ACTIONS, IDX_TO_ACTION
|
|
from ml.serving.policy_server import _unwrap_logits
|
|
|
|
t_start = time.time()
|
|
try:
|
|
state_tensor = torch.from_numpy(state_vec).unsqueeze(0).to(policy_service.device)
|
|
|
|
with torch.no_grad():
|
|
raw_output = policy_service.model(state_tensor)
|
|
# Guard: unwrap tuple/dict returns (DataParallel, torch.compile,
|
|
# PolicyValueNetwork swap) before temperature division / argmax.
|
|
logits = _unwrap_logits(raw_output)
|
|
|
|
if greedy:
|
|
action_idx = logits.argmax(dim=-1).item()
|
|
probs = torch.softmax(logits, dim=-1).cpu().numpy().flatten()
|
|
else:
|
|
scaled = logits / max(temperature, 0.01)
|
|
probs = torch.softmax(scaled, dim=-1).cpu().numpy().flatten()
|
|
action_idx = int(np.random.choice(NUM_ACTIONS, p=probs))
|
|
|
|
elapsed_ms = (time.time() - t_start) * 1000
|
|
action = IDX_TO_ACTION[action_idx]
|
|
confidence = float(probs[action_idx])
|
|
|
|
prob_map = {
|
|
IDX_TO_ACTION[i].value: round(float(probs[i]), 4)
|
|
for i in range(NUM_ACTIONS)
|
|
}
|
|
|
|
return {
|
|
"action": action.value,
|
|
"action_index": action_idx,
|
|
"confidence": round(confidence, 4),
|
|
"probabilities": prob_map,
|
|
"inference_ms": round(elapsed_ms, 2),
|
|
}
|
|
except Exception as e:
|
|
elapsed_ms = (time.time() - t_start) * 1000
|
|
logger.error("Direct inference error in _infer_from_vector: %s", e, exc_info=True)
|
|
return {"error": str(e), "inference_ms": round(elapsed_ms, 2)}
|
|
|
|
|
|
def _build_encoder_snapshot(req: DecideRequest) -> Dict:
|
|
"""Convert a DecideRequest to the flat snapshot dict expected by
|
|
StateEncoder.encode() — normalises GameSession field name aliases
|
|
and wires real Forge board stats (Step 4).
|
|
|
|
Board stat priority (highest to lowest):
|
|
1. Real Forge values from DecisionSnapshot.PlayerSnapshot fields
|
|
(creaturesOnField, landCount, totalPowerOnBoard, etc.)
|
|
2. Inferred from zone card-name lists (battlefield length)
|
|
3. Hardcoded fallback (0)
|
|
"""
|
|
players_out = []
|
|
for p in req.players:
|
|
bf_names = p.resolved_battlefield_names()
|
|
players_out.append({
|
|
"seat": p.seat,
|
|
"life": p.life,
|
|
"cmdr_dmg": sum(p.cmdr_dmg.values()) if p.cmdr_dmg else 0,
|
|
# Step 4: real mana from Forge instead of mana_available stub
|
|
"mana": p.resolved_mana(),
|
|
"cmdr_tax": p.resolved_cmdr_tax(),
|
|
# Step 4: real board counts from Forge pre-computed stats
|
|
"creatures": p.resolved_creatures(),
|
|
"lands": p.resolved_lands(),
|
|
# Step 4: real total power replaces the `creatures * 3` heuristic
|
|
# in StateEncoder._encode_global() index 7
|
|
"total_power": p.resolved_total_power(),
|
|
"total_toughness": p.resolved_total_toughness(),
|
|
"artifacts": p.resolved_artifacts(),
|
|
"enchantments": p.resolved_enchantments(),
|
|
# Zone contents for embedding pooling (unchanged)
|
|
"hand": p.resolved_hand(),
|
|
"battlefield": bf_names,
|
|
"graveyard": p.graveyard,
|
|
"command_zone": p.resolved_command_zone(),
|
|
})
|
|
|
|
return {
|
|
"turn": req.resolved_turn(),
|
|
"phase": _map_forge_phase(req.phase),
|
|
"active_seat": req.resolved_active_seat(),
|
|
"players": players_out,
|
|
"game_id": req.game_id,
|
|
"archetype": req.playstyle,
|
|
}
|
|
|
|
|
|
_FORGE_PHASE_MAP = {
|
|
"UNTAP": "main_1",
|
|
"UPKEEP": "main_1",
|
|
"DRAW": "main_1",
|
|
"MAIN1": "main_1",
|
|
"BEGIN_COMBAT": "combat",
|
|
"DECLARE_ATTACKERS": "combat",
|
|
"DECLARE_BLOCKERS": "combat",
|
|
"DAMAGE": "combat",
|
|
"COMBAT_DAMAGE": "combat",
|
|
"END_COMBAT": "combat",
|
|
"MAIN2": "main_2",
|
|
"END": "end",
|
|
"END_STEP": "end",
|
|
"CLEANUP": "end",
|
|
# GameSession values already normalised
|
|
"main_1": "main_1",
|
|
"main_2": "main_2",
|
|
"combat": "combat",
|
|
"end": "end",
|
|
}
|
|
|
|
|
|
def _map_forge_phase(phase: str) -> str:
|
|
"""Map Forge/GameSession phase string to ML GamePhase enum value."""
|
|
return _FORGE_PHASE_MAP.get(phase.upper() if phase else "MAIN1",
|
|
_FORGE_PHASE_MAP.get(phase, "main_1"))
|