Replaces synthetic stub loop with a live Forge process:
- Spawns Forge with --policy + --policyServer flags so Forge calls
back to /api/policy/decide at each AI decision point
- Reads Forge stdout line-by-line and parses zone/phase/life updates
via the same regex patterns used in BatchRunner
- Syncs full board state (battlefield, hand, graveyard, exile,
command zone, stack) on every Forge state-change line
- Routes human input from humanActionQueue via a side-channel
HTTP POST to /api/policy/override on the policy server
- Handles activePlayer + priorityPlayer correctly for 3-4 player pods
- Falls back to synthetic stub loop if Forge subprocess fails to start
- Watchdog thread kills stalled Forge process after 10 min
Adds a buildStateVector(int seat) method to GameSession that produces
a float[29] global scalar vector matching STATE_DIMS layout in
ml/config/scope.py:
Per-player block (14 features × 2 players = 28):
[0] life_total / 40.0
[1] commander_damage / 21.0 (stub 0.0 until real Forge tracks it)
[2] mana_available / 20.0 (stub 0.0 until mana pool wired)
[3] commander_tax / 10.0
[4] cards_in_hand / 15.0
[5] cards_in_graveyard / 100.0
[6] creatures_on_battlefield / 30.0
[7] total_power_on_board / 100.0 (stub = creature count until P/T data)
[8] is_active_player (0 or 1)
[9] phase one-hot[0] main1
[10] phase one-hot[1] combat
[11] phase one-hot[2] main2
[12] phase one-hot[3] end
[13] (padding / reserved)
Global:
[28] turn_number / max_turns (25)
buildStateSnapshot() now includes "state_vector": float[] so that
PolicyClient.decide(JsonObject) sends a machine-readable vector the
Python /api/policy/decide endpoint can pass directly to the encoder
without re-parsing the nested human-readable fields.
Stub fields (mana_available, commander_damage, total_power_on_board)
are zero until real Forge board state is wired in Step 4.
GameSession.java references these APIs which were missing from
PolicyClient, causing 3 compilation errors. Added:
- getBaseUrl() getter
- PolicyDecision record (action, confidence)
- decide(String, int) overload that delegates to decide(JsonObject)
DecisionExtractor requires verbose Forge output to reconstruct board
states. When quiet=true, Forge runs with -q which strips the verbose
log lines, causing 0 decisions to be extracted. This change passes
quiet=false to BatchRunner when mlLogEnabled is true.
* feat(83): add PolicyClient for live Forge-to-policy-server IPC (#83)
Phase 2.1: Bridge between Forge decision points and Python policy
server for online training. Calls /api/policy/decide endpoint,
collects (state, action, reward) tuples in real-time, and flushes
them to the server for online PPO updates.
Falls back to Forge built-in AI if server is unavailable.
* feat(83): add /api/policy/* routes for live Forge IPC (#83)
Phase 2.2: Policy decision routes for real-time Forge IPC.
- POST /api/policy/decide — full game state inference with log_prob/value
- POST /api/policy/collect — online learning tuple batch collection
- POST /api/policy/reward — end-of-game reward propagation
- GET /api/policy/health — readiness check for Java PolicyClient
- GET /api/policy/stats — session statistics
Includes Forge phase mapping and PlayerZoneState with full zone contents.
* feat(83): integrate PolicyClient into GameSession for bidirectional sync (#83)
- CAST_PATTERN: Add optional (?:Add To Stack:\s*)? prefix to match Forge's verbose log format
- LAND_PLAY_PATTERN: Add optional (?:Land:\s*)? prefix to match Forge's verbose log format
- Add [PARSE] diagnostic logging after combat stats parsing to confirm spells/lands/cmdr stats per seat
Root cause: Forge verbose output prefixes cast lines with "Add To Stack: " and land lines with "Land: "
but the regexes expected Ai(N) at the start of the line, causing 0 matches for all combat stats.
Issue #1 — Winner's life total now captured:
- Parse verbose Forge log for life-total-change events
- Track last-known life per seat via 3 regex patterns
- Winner's final life set from log data (not left at default 40)
- Losers who died to mill/poison retain their actual life total
Issue #2 — Win condition classification formalized:
- New WinCondition enum (schema/WinCondition.java) with 8 categories
- Centralized Forge loss-reason-string → enum mapping (regex-based)
- UNKNOWN fallback with logging for unrecognized loss reasons
- All classification now goes through WinCondition.classify()
Issue #3 — JVM startup overhead reduced:
- Added -XX:+UseSerialGC (lighter GC for batch processes)
- Added -XX:TieredStopAtLevel=1 (faster JIT, less compilation)
- ForgeWorker stub class for future persistent batch mode
- Estimated reduction: ~2-3s → ~1-1.5s per game startup
Issue #4 — Single-threaded throughput optimization:
- New CLI flags: --ai-simplified, --ai-think-time, --benchmark
- JVM flags passed through to Forge subprocess
- Benchmark mode: run 1 game before batch, report wall/Forge/overhead times
- API support: aiSimplified, aiThinkTimeMs in StartRequest
Issue #5 — Threading, progress, and backpressure:
- --threads 0 or -1 auto-detects optimal count from CPU topology
- detectOptimalThreads() estimates physical cores (excludes HT)
- ProgressCallback interface for real-time sims/sec reporting
- Per-game progress emitted as [PROGRESS] lines, parsed by lab_api.py
- --max-queue flag for graceful backpressure (limits concurrent JVMs)
- Final throughput summary printed at batch completion
- StatusResponse now includes simsPerSec field
Also: .gitignore restored, lab_api.py updated with all new API fields