Compare commits

..
Author SHA1 Message Date
fez5587andClaude Sonnet 5.5 7d92387bb8 coach: accept rules_context in build_user_prompt (every Coach request failed)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
2026-10-26 23:01:48 -05:00
fez5587andClaude Sonnet 5.5 a09eb5b21a farmhouse: sync Forge fixes (display, one deck folder, absolute result path) and phase model-seat safety (pre-validation, circuit-breaker, cost counters)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
2026-10-26 22:32:14 -05:00
fez5587andClaude Sonnet 5.5 0efd4847a3 FarmHouse: Forge in the image, rules-accurate simulation with phase-rs, model seats
Forge restored as the default batch engine (and documented: farmhouse/forge). New: routes/farmhouse_phase.py and ui/phase-sim.html (four saved decks play real Commander games on the phase-rs engine; wins by seat, 95% interval, warnings), reference copies of the phase-sim service, Dockerfiles, deployment and the llm-seat crate (model-driven seats that choose among the engine's own legal options, with fallback to phase's AI and a call cap) under farmhouse/phase. Patches registered in lab_api.py, linked from the home page; Forge batches clamped to one thread.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
2026-10-26 20:20:10 -05:00
fez5587andClaude Sonnet 5.5 946a3706da docs: assessment of phase-rs and commander-playtester for the Magic component
Researched from public sources only; nothing run or vendored. phase: rules engine, headless ai-duel, phase-llm model seats speaking the System One protocol (Laya/Kev), 96% of the saved decks' cards supported. playtester: browser app on phase, no licence file.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
2026-10-26 19:50:51 -05:00
fez5587andClaude Sonnet 5.5 6569bb6f0d FarmHouse: Laya connections (Forge concrete path, extra questions, capture, fine-tuning data, card roles)
farmhouse_laya.py: Laya as a second opinion over Forge's concrete candidate ids (shadow by default; assist never overrides a pick Forge tagged stops_immediate_win/wins_now), two extra typed questions in the same forward pass, a laya-shadow-v1 capture log, batched cached card-role classification. scripts/farmhouse_laya_dataset.py turns the capture log into train/valid files with the baselines to beat; farmhouse_laya_roles_eval.py measures role accuracy. Hooks into farmhouse_decide.py. Reference copies of the Laya image, manifests and CI in farmhouse/laya/. docs/LAYA-CONNECTIONS.md. 39 offline tests. All off by default.

The matching hook in routes/policy.py is in the working tree only: that route belongs to the pending strategic-evaluator work.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
2026-10-26 19:39:01 -05:00
fez5587andClaude Sonnet 5.5 32ab268f88 FarmHouse: card search returns each card once (index has one entry per printing)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
2026-10-26 18:48:20 -05:00
fez5587andClaude Sonnet 5.5 abb26a679c FARMHOUSE-CHANGES: decision model section, settings, ollama package
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
2026-10-26 18:23:07 -05:00
fez5587andClaude Sonnet 5.5 635890deb6 Fix 34e0fc6: keep deepseek_brain.py to the decision-model wiring only
That commit also picked up uncommitted work from another editor of the same working tree (strategic_context.py and candidate_actions), importing a module that is not in the repository. This restores the file to the decision wiring alone; the other work is untouched in the working tree.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
2026-10-26 18:05:47 -05:00
fez5587andClaude Sonnet 5.5 34e0fc6b2f FarmHouse: decision-model client (Laya/Kev) with shadow and assist modes
farmhouse_decide.py asks a /v1/systemone server which action to take; DECISION_MODE=shadow records agreement with the LLM, assist uses it when DECISION_MIN_CONF sure; off by default. scripts/farmhouse_decision_eval.py replays recorded decisions through it. 17 offline tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
2026-10-26 18:05:07 -05:00
fez5587andClaude Sonnet 5.5 b779987802 FARMHOUSE-CHANGES: notes on making LLM-opponent turns faster
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
2026-10-26 16:53:23 -05:00
fez5587andClaude Sonnet 5.5 fee8ded52d FarmHouse: run Python-only on a local LLM; simulator plays real games
Starts from upstream 54cc5e9. Cards reach the simulator with type and text (filled from the Scryfall bulk DB) and the right quantities; the LLM engine asks several questions per turn and with reasoning off; Moxfield and Archidekt deck links work in every import and card names resolve locally; deck builds are fitted to the commander's colour identity; Scryfall format/User-Agent/429 handling fixed; coach init fixed. See FARMHOUSE-CHANGES.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
2026-10-26 16:42:58 -05:00
rgriffithandClaude Opus 4.6 54cc5e9b2e fix(lan-client): swap P3/P4 grid positions for clockwise turn order
Swapped CSS grid-area, zone-row anchoring, commander-row anchoring,
label corners, and life-panel positions for .p3/.p4 so the turn
sequence P1→P2→P3→P4 reads clockwise around the battlefield.
Also added PLAYER_INDEX_TO_QUADRANT lookup in player-panels.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 22:22:32 -04:00
rgriffithandClaude Opus 4.6 9b79d18201 feat(lan-client): triggers panel + CMD DMG score badges + fix encoding project-wide
Left action panel revised to show triggers list + Create Token/Auto-Arrange.
CMD DMG badges added to center life-panel score areas (click opens matrix).
Removed renderEnhancedPanels calls that split the battlefield on damage adjust.
Added encoding='utf-8' to all bare open() calls across 17 Python files to
prevent cp1252 crashes on MTG card text with smart quotes/em-dashes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 22:14:56 -04:00
rgriffithandClaude Opus 4.6 45ca961064 fix(lan-client): wire exit button to location.reload()
executeReset() references #game-board which doesn't exist in this HTML,
so the function threw a TypeError and silently did nothing. Swap the
exit button's onclick to location.reload() — the browser-refresh path
the user confirmed works to return to the setup screen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 18:36:25 -04:00
rgriffithandClaude Opus 4.6 69bdd94ef2 feat(lan-client): hide AI hands + persist Scryfall image cache
AI hand privacy (rendering layer only):
- renderHandZone(): when player.isAI, render face-down violet card backs
  (dark purple diagonal hatch + ornate ring) instead of face-up thumbnails.
  Count stays public (standard MTG). No image, name, preview, drag, or
  click-to-open-modal on face-down thumbs.
- renderZoneModal(): filter the "Hand" tab out of the zone modal when
  inspecting an AI opponent; reset activeZoneTab to 'graveyard' if it was
  'hand' when an AI modal opens.
- New CSS: .hand-thumb.face-down (+ .overflow variant) with violet hatch
  pattern, radial glow, and centered purple ring pseudo-element.

Scryfall image cache persistence (eliminates per-game refetching):
- _scryfallCache now saves to localStorage under commanderLab.scryfallCache.v1
- 30-day TTL, 20,000-entry cap, graceful quota-exceeded handling
- Debounced auto-save (750ms) after each batch enrichment
- Cache-hit logging: logs hit ratio when refetching, or "from cache" when
  a whole game's card pool is already in localStorage
- clearScryfallCache() helper exposed on window for manual purges

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 18:32:43 -04:00
rgriffithandClaude Opus 4.6 8fbebc21f3 feat(lan-client): charcoal felt battlefield + top-bar polish + return-to-start
Battlefield (charcoal felt):
- Darken --felt / --felt-light / --felt-dark to #181818 / #202020 / #0d0d0d
- Tighter --felt-line opacities (0.06 / 0.12) on the darker surface
- Finer-grain noise texture (baseFrequency 1.2, tile 180px) at 0.14 alpha
  with mix-blend-mode: overlay so the grain rides on the felt
- Deeper vignette (35% center, rgba(0,0,0,0.6) at edges)

Top bar:
- .top-bar-logo: 14px → 28px, align-self: stretch, flex-centered so
  "COMMANDER" fills the full 44px height
- .phase-step: 10px → 11px, roomier padding (2px 8px), gap 2→4px,
  letter-spacing 0.02em
- .btn-util: 22×22 → 32×32, font 10px → 15px (readable icons again)
- New .btn-util.btn-util-exit ✕ button (red-tinted, wired to
  confirmReset() → return to setup screen)
- Remove deck generator (⚡) and collection (📚) util buttons from the
  top bar (those panels are still reachable from other entry points)

Setup screen:
- Swap .setup-title from MTG gold to the same neon violet sign effect
  as the top-bar logo (4-layer text-shadow, uppercase, ls 0.08em)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 18:05:47 -04:00
rgriffithandClaude Opus 4.6 7cfdecb2c3 feat(lan-client): polish setup screen — charcoal bg, no rainbow bar, fit-to-screen
- .setup-card background: rgba(26,26,46,0.97) violet-tint → rgba(22,22,22,0.97)
  true charcoal that matches --color-surface-3
- Remove the 3px rainbow p1→p4 gradient strip from .setup-card::before
  (display: none)
- Compress spacing so the full lobby fits without scrolling:
  - padding 2.5rem → 1.25rem 1.5rem
  - title text-3xl → text-2xl, subtitle 2rem margin → 0.85rem
  - section margin 1.5rem → 0.75rem
  - label text-sm → text-xs with tighter margin
  - player-count-btn padding 0.75rem → 0.4rem
  - deck-precon-list max-height 140px → 90px
  - btn-start padding 1rem → 0.6rem

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 17:53:27 -04:00
rgriffithandClaude Opus 4.6 38f3837f5a feat(lan-client): animated smoky purple summoning-sick overlay
Replace the nearly-invisible black diagonal stripes with a layered
animated purple hatch so sick creatures read instantly as "can't attack":

- Double-layer repeating-linear-gradient (two purple stripe opacities)
- sick-drift animation (3s linear) — stripes crawl diagonally
- sick-pulse animation (2.4s ease-in-out) — soft opacity breathing
- Inset 14px purple box-shadow for hazy edge glow

Matches the --accent / violet palette from the AI Lab redesign.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 17:42:27 -04:00
rgriffithandClaude Opus 4.6 681888adcd feat(lan-client): port pure-black + violet + neon palette from AI Lab redesign
Apply the same 5-step visual redesign to the LAN client's MTG battlefield
theme while preserving functional game colors (mana, felt table, player
quadrants p1-p4).

- Swap Google Fonts import from Cinzel+Inter to Barlow Condensed + Barlow
- Pure black surfaces (#080808 / 0e / 11 / 16 / 1a)
- Swap primary accent from blue #5b8dd9 to electric violet #8b5cf6
- Square industrial radii (2/4/6/8px)
- Replace all hardcoded 'Cinzel' / 'Inter' with var(--font-display) /
  var(--font-body)
- Pure black top bar with violet underglow shadow
- Convert .top-bar-logo "Commander" title from gold to 4-layer violet
  neon sign with hover brightening
- Sweep 42 hardcoded rgba(91,141,217,...) blue values downstream to
  rgba(139,92,246,...) violet across buttons, zone tabs, stack items,
  combat UI, etc.

MTG functional colors (mana W/U/B/R/G, felt table, player quadrants)
remain intact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 17:39:30 -04:00
rgriffithandClaude Opus 4.6 88c0d94d39 feat(ui): swap Inter for Barlow / Barlow Condensed display font
Load Barlow + Barlow Condensed from Google Fonts across all 8 HTML
entry points. In global.css introduce --font-display (Barlow Condensed)
alongside --font (Barlow), then wire the display font into every
chrome/heading selector: h1-h6, .page-header, .panel-header, .btn,
.stat-label/.stat-value, .form-label, .badge-status, .data-table
thead, .tag, .nav-brand, .nav-links. Body copy stays in Barlow for
readability.

Also normalize a few hardcoded font-families to design-system vars:
collection.css Cascadia Code stacks and deckgenerator.css Courier New
now use var(--font-mono); canvas rendering in distillation.js and
ai-lab.js swapped from Inter to Barlow so charts match the new type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 17:26:06 -04:00
KoalaTrapLord 4fdddf332e fix: add logging.basicConfig to trainer.py so training output is visible
The trainer used logger.info() for all output but never configured a
handler, so training ran silently with no console output.
2026-04-05 18:10:09 +00:00
KoalaTrapLord 213b9dc32d fix: total_mem → total_memory in trainer.py (PyTorch API) 2026-04-05 18:06:50 +00:00
KoalaTrapLord 2af1d58104 fix: write ML JSONL to commander-ai-lab/results/ (not Forge CWD)
LabCli passed relative 'results' to enableMlLogging(), which resolved
to Forge-Repo/forge-gui/results/ because the Java subprocess CWD is
forgeWorkDir. Now uses outputPath's parent directory (absolute) so
JSONL training data goes to commander-ai-lab/results/ where the
Python ML pipeline and Training page expect it.
2026-04-05 17:29:21 +00:00
KoalaTrapLord 547a84d8c2 fix: DecisionExtractor regex patterns to match actual Forge verbose output
Forge verbose log uses different verb forms and prefixes than the
patterns expected:
  - Turn: 'Turn: Turn 5 (Ai(1)' not 'Turn 5 (Ai(1)'
  - Cast: 'cast' (no s) not 'casts', 'Add To Stack:' prefix, no period
  - Land: 'played' not 'plays', 'Land:' prefix, card ID suffix '(76)'
  - Draw: 'drew' variant, card ID suffix

Updated all four patterns to match both old and new Forge output
formats. This is the final fix for the training data pipeline —
DecisionExtractor will now find cast/land/draw/attack events in
the Forge game log and emit JSONL training snapshots.
2026-04-05 17:24:34 +00:00
KoalaTrapLord 71b86b74eb fix: compile error in BatchRunner deck name log line 2026-04-05 16:55:38 +00:00
KoalaTrapLord d00093897a fix: read Name= from .dck header for Forge deck lookup
Forge's sim mode looks up decks by the Name= field inside the .dck
file metadata header, NOT by filename. For example:
  Filename: Counter_Intelligence.dck
  Header:   Name=Counter Intelligence
  Forge expects: 'Counter Intelligence' (with space)

BatchRunner now reads the Name= field from the .dck file and passes
that to Forge. Falls back to filename with underscores→spaces if
the Name= field can't be read.
2026-04-05 16:54:38 +00:00
KoalaTrapLord 0b01f1c467 fix: pass bare deck names to Forge sim (not full paths)
Forge's sim mode looks up decks by NAME in its own
%APPDATA%/Forge/decks/commander/ directory. Passing a full path like
D:\...\precon-decks\Counterpunch.dck causes 'Could not load deck'.

Now strips the path and .dck extension so Forge sees just the bare
name (e.g. 'Counterpunch') and finds it in its deck folder.

Precon .dck files must be copied to %APPDATA%/Forge/decks/commander/
for Forge to find them.
2026-04-05 16:47:54 +00:00
KoalaTrapLord 65873555bc fix: show all batch log lines in Live Output (was truncated to last 30)
Frontend: removed .slice(-30) — now shows all lines from backend.
Backend: bumped log line limit from 200 to 2000 so full Forge game
output is captured for debugging.
2026-04-05 16:35:21 +00:00
KoalaTrapLord 8995d743af fix: remove !quiet guard from ML decision logging in BatchRunner
The condition 'mlLoggingEnabled && !quiet && mlDecisionLogger != null'
silently skipped ML training data extraction whenever quiet mode was
active. DecisionExtractor works with the same log lines regardless of
quiet mode, so the guard was unnecessary and prevented JSONL generation.

Now: mlLoggingEnabled && mlDecisionLogger != null — ML logging runs in
all modes as long as --ml-log is passed.
2026-04-05 16:19:56 +00:00
KoalaTrapLord 14ff543135 fix: DecisionLogger JSONL filename prefix — ml-decisions-sim-* for dataset_builder discovery
The Java DecisionLogger was writing files as ml-decisions-{batchId}.jsonl
but the Python dataset_builder.py scans for ml-decisions-sim-*.jsonl to
categorize them as 'forge' source training data.

This one-line fix adds the 'sim-' prefix so the Forge batch heuristic
path now feeds directly into the ML training pipeline:

  BatchRunner → DecisionExtractor → DecisionLogger
    → results/ml-decisions-sim-{batchId}.jsonl
    → dataset_builder.py discovers as 'forge' source
    → encode → train

The full chain was already wired (Issue #83 Phase 1 infrastructure),
this naming mismatch was the only thing preventing it from working.

Refs: Issue #83 Phase 1
2026-04-05 16:04:12 +00:00
rgriffithandClaude Opus 4.6 ea23f97b9c chore: ignore runtime artifacts, add patch scripts & training manifest
Ignore static/, logs/, data/rag_chroma/, api/, image_cache/, .claude/,
precon-decks/, and dependency-reduced-pom.xml — all runtime/build
artifacts that were polluting git status. Also stage the one-off
patch scripts (fix_paths.py, patch_eval.py, patch_phase1.py,
forge_evaluator_FIXED.py) and training_manifest.json so they're
tracked alongside the code they support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 10:48:35 -04:00
KoalaTrapLord a23fdc55a4 feat: PolicyControllerAi integration — Forge AI decision interception via policy server
## New File
- PolicyControllerAi.java (840 lines): Extends Forge's PlayerControllerAi
  to intercept AI decisions at chooseSpellAbilityToPlay() and route them
  through the Python policy server. Falls back to Forge built-in AI on
  any failure. Inner PolicyAiController overrides AiController with full
  legal action gathering via ComputerUtilAbility.

## Wiring
- GameSession.java: Import + Javadoc for PolicyControllerAi decision flow.
  Logs decision pipeline when policy server connects. End-of-game reward
  submission via PolicyControllerAi lifecycle.
- BatchRunner.java: New setPolicyServer(url) method. Appends
  --policy midrange --policyServer <url> to Forge subprocess command when
  policy server URL is configured.

## Path Updates (Forge-Repo rename)
- README.md: Forge link → KoalaTrapLord/Forge-Repo, build path updated
- .env.example: FORGE_JAR_PATH and FORGE_WORKING_DIR → Forge-Repo
- lab_api.py: Auto-detect paths try Forge-Repo first, forge-repo as legacy

## MacroActionExecutor
- Added public isRemovalCard(), isDrawCard(), isRampCard() wrappers so
  PolicyControllerAi can consult the same card-role databases.

Refs: Issue #83 Phase 2.4
2026-04-05 14:47:15 +00:00
rgriffithandClaude Opus 4.6 4cae97ab44 fix(lan-client): compact phase pills, wrap NEXT PHASE/NEXT TURN labels
The phase track (BEGIN · MAIN 1 · COMBAT · END) was losing labels to
ellipsis on narrow top bars, and the primary action buttons were
clipping "NEXT PHASE"/"NEXT TURN" because they had white-space: nowrap
and a fixed height.

.phase-step:
  - tighter padding (1px 5px) and gap (2px)
  - no letter-spacing, smaller border-radius, flex-shrink: 1 so pills
    compress uniformly when the top bar is crowded
  - merged duplicate .phase-step.active rule and made it bolder so the
    active step still reads well after the shrink

.btn-phase (NEXT PHASE / NEXT TURN):
  - white-space: normal + word-break: break-word enable two-line wrap
  - height: auto + min-height: 34px lets the label breathe inside
    the 44px top bar
  - min-width: 64px / max-width: 80px keeps the buttons square-ish
  - display: flex centers the wrapped text vertically & horizontally
  - align-items: stretch on .top-bar-actions so both buttons match
    the tallest sibling's height

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 10:39:18 -04:00
rgriffithandClaude Opus 4.6 d308e8f413 fix: AI land-only bug — enforce 1-land/turn, populate legal moves, tune policy
Addresses the three root causes in commander-ai-lab-land-only-bug-analysis.md
plus several collateral fixes discovered along the way.

P0 — Land-per-turn enforcement (engine.py, deepseek_engine.py, turn_manager.py)
  * Guard _play_land() against double plays by tracking
    _lands_played_this_turn vs _land_plays_this_turn_limit on each player
  * Reset per-turn counters at the start of each player's turn
  * Ramp cards that grant additional plays can bump the limit

P0 — Legal move pipeline (new legal_move_generator.py, game_state.py)
  * New generate_legal_moves() emits the full macro-action set
    (play_land, cast_creature, cast_removal, cast_ramp, cast_draw,
    cast_commander, attack, block, pass_priority) based on phase,
    active player, land-drop state, mana, and commander tax
  * get_legal_moves() delegates to the new generator instead of the
    pass-only stub that blocked the turn_manager/policy-server path
  * apply_move() gains handlers for play_land, cast_spell,
    cast_commander, and declare_attacker — spells resolve to
    battlefield or graveyard, stats update correctly

P1 — Policy exploration (LearnedPolicy.java, scope.py)
  * Default greedy=false so the neural policy samples its
    distribution instead of always taking argmax
  * Raise ppo_entropy_coeff 0.01 → 0.04 to keep the action
    distribution flat during PPO self-play and prevent the
    pass/land collapse

Collateral fixes pulled in during the same session:
  * lan-client/precon-loader.js: await enrichAllZonesWithScryfall()
    so opening-hand cards have typeLine/cmc before the AI's first turn
  * lan-client/ui.js: closeZoneModal auto-shuffles the library
    whenever the zone modal closes
  * routes/game.py: BatchState() was missing total_games/threads,
    crashing POST /api/game/start
  * routes/deckgen.py: dead code after raise left headers undefined,
    breaking the Anthropic API path

All sim/phase/integration tests pass (650+).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 10:34:37 -04:00
rgriffithandClaude Opus 4.6 44123f7622 fix: AI only plays lands — sync card enrichment, mana pool check, field alignment
- Add _enrichCardFromCache() in precon-loader.js for synchronous card
  enrichment at draw time (typeLine, cmc, oracle roles available before
  async Scryfall batch completes)
- Call _enrichCardFromCache in drawCard() so AI has complete card data
  on its first turn
- Improve _canAfford in ai-bridge.js: check actual mana pool before
  falling back to untapped land count; free spells (cmc 0) always castable
- Fix totalCards field name mismatch in ui.js and engine.js (lab.py
  returns totalCards, not card_count/total_cards)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 01:43:29 -04:00
rgriffithandClaude Opus 4.6 9eaa41a4ae feat: deck dropdown, user-decks pipeline, deckgen fixes, gpt-oss removal
- Add user-decks/ directory and USER_DECKS_DIR config for exported decks
- Update export-to-sim to write to user-decks/ with static mount in lab_api
- Replace import/paste UI with API-backed "My Decks" dropdown in LAN client
  and AI Lab, fetching from GET /api/lab/decks with source grouping
- Fix GET /api/lab/decks to JOIN deck_cards for totalCards count
- Fix deckgenerator: add missing DOM elements (dg-strategy-bar, dg-stats-bar,
  dg-card-groups) inside #dg-results, null guards on render functions,
  fix setLoading always hiding results
- Replace gpt-oss:20b with claude-opus-4-20250514 across all files
- Add user-decks/ to _BUILTIN_DECK_DIRS in deck_service.py
- Enrich missing type_line from Forge card cache in DB deck path
- Collapse import bar behind <details> toggle in AI Lab

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 01:17:30 -04:00
rgriffithandClaude Opus 4.6 409aba26f4 feat: AI bug fixes, deck/collection migration, and enrichment pipeline
- Fix AI turn pipeline: add cmc to Scryfall cache, tag isRamp/isRemoval/
  isBoardWipe from oracle text during enrichment, enable post-combat spell
  casting for all difficulty levels
- Migrate deck builder, deck generator, and collection from ui/ to
  lan-client/ as iframe-embedded panels with LAB_API_BASE patching
- Fix ai-bridge.js: turnNumber, deck_name, battlefield serialization,
  5-playstyle mapping, window exports for cross-script access
- Fix engine.js: battlefieldCards flat array (not player.battlefield),
  aiDeclareAttacks extraction, combat overlay fixes
- Fix collection.js: template literal, column visibility alignment,
  set_code key, oracle text column, sortable category
- Add exile/graveyard last-card image rendering in ui.js
- Make state_encoder.py resilient to mixed input types
- Enrich type_line from Scryfall at card insert in deckbuilder.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 00:05:23 -04:00
rgriffithandClaude Opus 4.6 30c3ab5a89 fix: pass gameState to _canAfford in MacroActionMapper
Static method was referencing bare gameState which is always null.
Now accepts gameState as parameter, and all 5 callers pass it through.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 17:02:05 -04:00
KoalaTrapLord b7f9c119cb feat(overnight): add --ds-weight arg to pass DeepSeek upsampling weight to /api/ml/train 2026-04-04 17:01:10 -04:00
rgriffithandClaude Opus 4.6 bbb7f7a966 fix: _getBattlefieldCards reads battlefieldCards instead of battlefield
Was reading gameState.battlefield (doesn't exist) causing AI to
receive empty board state. Now reads gameState.battlefieldCards
and matches by ownerIndex.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 17:00:39 -04:00
rgriffithandClaude Opus 4.6 ef739e1f49 feat: UI improvements — silver commander border, life panel border, hand drag-and-drop, card placement
- Add bright silver 3D smoky border around commander card zones
- Wrap all 4 life panels in a single bordered container
- Add drag-and-drop from hand zone to battlefield with ghost preview
- Update card placement to center of battlefield, avoiding overlaid zones
- Fix card dimensions in placement calc (110x150 -> 63x84)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 16:50:56 -04:00
rgriffithandClaude Opus 4.6 920d7d9787 fix: remove player color border and owner bar from battlefield cards
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 15:55:56 -04:00
rgriffithandClaude Opus 4.6 52d51ebd5e feat: battlefield UI overhaul — life panels, card sizing, layout
- Move life/cmd-dmg/poison into 3-box life panels as a top-level
  overlay (z-index 50) centered at quadrant intersections
- Remove center emblem
- Match battlefield card size to hand cards (63x84px)
- Overlay zone-row and commander-row on battlefield edges to
  eliminate dead space and maximize battlefield area
- Restructure poison badges from per-player absolute positioning
  into life-panel counters box

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 15:50:35 -04:00
rgriffithandClaude Opus 4.6 db3675aa6a feat: enlarge hand card thumbnails to fill zone height
Scale .hand-thumb from 32x45px to 63x84px so cards fill the
hand zone vertically while maintaining MTG card proportions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 14:36:50 -04:00
134 changed files with 25655 additions and 633 deletions

No files matched your search

+4 -3
View File
@@ -8,12 +8,12 @@ XIMILAR_API_KEY=your-ximilar-key-here
# ── Forge JAR Path ────────────────────────────────────────────────
# Point this to your Forge GUI desktop fat JAR.
FORGE_JAR_PATH=D:/ForgeCommander/forge-repo/forge-gui-desktop/target/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar
FORGE_JAR_PATH=D:/ForgeCommander/Forge-Repo/forge-gui-desktop/target/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar
# IMPORTANT: Forge MUST be launched from the forge-gui directory (not forge-gui-desktop/target).
# The JAR looks for res/ relative to its working directory. Without this, you get
# ExceptionInInitializerError on startup.
FORGE_WORKING_DIR=D:/ForgeCommander/forge-repo/forge-gui
FORGE_WORKING_DIR=D:/ForgeCommander/Forge-Repo/forge-gui
# ── Deck Paths ────────────────────────────────────────────────────
# Directory containing your precon .dck files.
@@ -23,4 +23,5 @@ FORGE_WORKING_DIR=D:/ForgeCommander/forge-repo/forge-gui
# PRECON_DECKS_DIR is kept below for backwards compat but is NOT used by the server.
PRECON_DIR=D:\ForgeCommander\commander-ai-lab\precon-decks
PRECON_DECKS_DIR=D:\ForgeCommander\commander-ai-lab\precon-decks
FORGE_DECKS_DIR=D:\ForgeCommander\commander-ai-lab\precon-decks
USER_DECKS_DIR=D:\ForgeCommander\commander-ai-lab\user-decks
FORGE_DECKS_DIR=D:\ForgeCommander\commander-ai-lab\user-decks
+15
View File
@@ -32,9 +32,24 @@ embeddings/
checkpoints/
imported-decks/
# User-created decks (generated at runtime, not committed)
user-decks/*.dck
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Runtime artifacts / caches
static/
api/
image_cache/
data/rag_chroma/
.claude/
dependency-reduced-pom.xml
# Precon decks (generated / imported at runtime)
precon-decks/
+93
View File
@@ -0,0 +1,93 @@
# FarmHouse changes
This branch (`farmhouse`) is `KoalaTrapLord/commander-ai-lab` at commit `54cc5e9` plus the changes below. They were made to run the app
**Python-only** (no Forge/Java, no PyTorch) in a Kubernetes namespace, with a local LLM behind an OpenAI-compatible gateway instead of
Anthropic/DeepSeek, and they were each found by actually running the app, not by reading it. Written 2026-10-26.
Every change started life as an anchored text patch (`patch_app.py` in the farmhouse-os repo, which still builds the deployed image from
the upstream commit); here they are ordinary source edits. Lines carry a `# FARMHOUSE_...` marker where the patch left one.
## What was wrong, and what changed
### The simulator played nothing (the big one)
| Problem | Change |
|---|---|
| Every card reached the simulator with **no type line and no rules text**. The loaders join the user's *collection* and a *card_records* table (both empty on a fresh install) and the Forge card cache that was meant to fill the gap is not installed. A land was indistinguishable from a creature: an 8-game batch cast 0 spells, dealt 0 damage and lost every game. | `services/farmhouse_sim.py` `fill_card_data` fills type, text, mana cost, cmc, P/T, keywords and colour identity from the 113k-card Scryfall bulk database. Wired into `services/forge_runner.py` (batch) and `routes/deepseek.py` (`_fetch_deck_card_data`, which the Monte Carlo and single-deck routes use). |
| The batch loader **did not repeat cards by quantity**: a 100-card deck loaded as 66-84 cards with *one* Island. | `expand_quantities` (same module). |
| The LLM engine asked the model **once per turn** and applied the answer as one action. | `src/commander_ai_lab/sim/farmhouse_turns.py`: up to `BRAIN_STEPS` (default 4) questions per turn (land, spell, spell, attack), re-reading the board after each; stops on attack/hold/no change. `BRAIN_STEPS=1` restores the original. |
| A model that "thinks" spent ~46 s on a one-line move. | `deepseek_brain.py` sends `reasoning_effort` from `LLM_REASONING` (46 s -> 1.8 s per move). |
| The engine dropdown defaulted to **Forge**, which cannot run without Java. | `ui/ai-lab.js`: only "LLM opponents" is offered. |
After the fixes: a 40-game Monte Carlo takes 0.4 s with real numbers (10 spells and 12.7 damage per game); two LLM-piloted games took
212 s with 49 model calls and no fallbacks.
### Deck import
| Problem | Change |
|---|---|
| **Moxfield links were accepted nowhere**, and the Deck Builder import took only pasted text. | `services/farmhouse_deck_links.py`: Moxfield (public decks, `api2.moxfield.com`) and Archidekt links in the simulator URL import and in both Deck Builder imports (a lone link in the paste box). A private deck says to use Moxfield's Export > Copy as text. |
| The Deck Builder import looked **every card up on scryfall.com**; after a few dozen it was rate-limited (HTTP 429) and the app reported the rest as "not found" (250-card deck: 55 matched; even plain `Sol Ring` failed). | `local_card()`: names resolve from the local bulk database first; the website is only the fallback. |
### Deck generation
- `deck_builder/pipeline/farmhouse_fit.py`: the model's list is **filtered to the commander's colour identity** (from the bulk DB), de-duplicated,
fitted to per-category targets and trimmed to 99 + commander. Before this the model returned off-colour cards and 359-card lists.
- Scryfall bulk format changed (`jsonl_download_uri`, gzip JSONL; no `download_uri`).
- Scryfall answers **HTTP 400 to the default `python-requests` User-Agent** (every lookup became "Commander not found"): a named UA is sent everywhere.
- Scryfall 429s: requests back off on `Retry-After` and the pace is 0.25 s.
- `LLM_REASONING` / `LLM_MAX_TOKENS` are sent by the deck builder and coach.
### Coach / startup
- The coach was only initialised in `main()`, not when the app starts under uvicorn; `check_connection_sync()` was called but never defined;
the coach used the first model the gateway listed rather than the configured one. All fixed.
- Status label says "Language model" instead of the hardcoded "Claude Opus".
- `RAG_AUTOBUILD` (default off in our deployment) controls whether the card-search index is built at startup (113k embeddings; about an hour).
### Decision model (Laya / Kev), wired and off
`src/commander_ai_lab/sim/farmhouse_decide.py` asks a `/v1/systemone` server which of the nine actions to take, in one forward pass.
`DECISION_MODE=shadow` records whether it agrees with the LLM (the game still uses the LLM); `assist` uses it when `DECISION_MIN_CONF`
sure; `off` (default) does nothing. `scripts/farmhouse_decision_eval.py` replays recorded decisions through it.
**Measured 2026-10-26 on 149 recorded LLM decisions: zero-shot, both Laya checkpoints agreed with the LLM less often than always
guessing the commonest action** (English 16.1%, typed-decisions 18.6%, baseline 27.5%), at 2-4 s per answer on a 4-thread CPU. It is only
worth using after fine-tuning on this game's decisions; the write-up is `LAYA-EVALUATION.md` in the farmhouse-os repo's commander-lab folder.
### Laya connections (all off by default)
Forge's concrete priority path, extra typed questions in the same call, a capture log, a fine-tuning dataset exporter, and card-role classification:
`src/commander_ai_lab/sim/farmhouse_laya.py`, `scripts/farmhouse_laya_dataset.py`, `scripts/farmhouse_laya_roles_eval.py`, tests `tests/farmhouse/test_farmhouse_laya*.py`.
How it fits the strategic-intelligence work, what is measured and how to fine-tune: [docs/LAYA-CONNECTIONS.md](docs/LAYA-CONNECTIONS.md). Reference copies of the Laya
image, manifests and CI are in `farmhouse/laya/`.
### Forge, and rules-accurate simulation with phase-rs
- **Forge is installed** in the cluster image (it was left out by mistake): `farmhouse/forge/README.md`. Batches are clamped to one game at a time (`FORGE_MAX_THREADS`).
- **Rules-accurate simulation** through the phase-rs engine (Rust, headless): `routes/farmhouse_phase.py`, `ui/phase-sim.html` (linked from the home page) and the service in
`farmhouse/phase/`. Four saved decks play real Commander games; the page shows wins by seat with a 95% interval and warns about cards the engine did not recognise.
Seats can be put on a **model** (twin-turbo through Olla, or Laya): the model chooses among the engine's own legal options only, anything wrong or slow is played by phase's AI,
and a call cap bounds the cost (`farmhouse/phase/README.md`). Assessment and plan: `docs/PHASE-AND-PLAYTESTER.md`.
## Settings (environment variables)
| Variable | Meaning |
|---|---|
| `LLM_URL`, `LLM_MODEL` | OpenAI-compatible endpoint (`.../v1`) and model for the coach and deck builder |
| `LLM_REASONING` (`none`), `LLM_MAX_TOKENS`, `LLM_TIMEOUT` | keep requests lean: reasoning off, cap on answer length |
| `COACH_PROVIDER`, `DECK_GEN_PROVIDER` = `local`, `DECK_GEN_MODEL` | use the local model, not Anthropic |
| `OLLAMA_BASE_URL`, `RAG_EMBEDDING_MODEL`, `RAG_AUTOBUILD` | card-search embeddings (one host only: vectors differ between hosts) |
| `BRAIN_API_BASE` (**no** `/v1`; the client appends it), `BRAIN_MODEL`, `BRAIN_MAX_TOKENS`, `BRAIN_STEPS` | the simulator's LLM opponents |
| `DECISION_URL`, `DECISION_MODEL`, `DECISION_MODE` (`off`/`shadow`/`assist`), `DECISION_MIN_CONF`, `DECISION_TIMEOUT` | the decision model above |
| `ollama` Python package | required by the card-search index build (ChromaDB's Ollama embedder imports it) |
## Tests
`tests/farmhouse/` (standard library only, no network): `python -I -X utf8 tests/farmhouse/test_farmhouse_deck_links.py`, `..._sim.py`, `..._fit.py`.
Run them with `-I` and from outside the package folders; do not put `services/` on `sys.path` (`services/logging.py` shadows the standard library).
## Things still worth doing
- **Make the LLM-opponent turns faster** (a piloted move averages ~7.5 s: about 2.2k tokens in, ~30 out, so reading the prompt dominates).
These are ideas, not measured; none is implemented:
- *Shared prefix:* about 1.2k tokens of every prompt (the system prompt) are identical each time; a llama.cpp server can reuse them
if the calls share the prefix, so only the board state is read each move.
- *Smaller prompt:* send a compact board state instead of the indented JSON the engine builds now (`json.dumps(snapshot, indent=2)`
in `deepseek_brain.py`).
- *Parallel games:* run several games at once, which would also fill the GPU while one game waits on a move. Caveat: for long prompts
two requests at once gave no extra throughput on a V100 (reading the prompt is compute-bound), so measure before relying on it.
- Opponents are the engine's heuristics; only the piloted seat uses the model.
- Build the card-search index and measure it.
- The upstream `LICENSE` is missing (`pyproject.toml` says MIT). Ask the author to add one before publishing a derivative widely.
- Forge-based features (the Java simulator, training) remain unavailable by design.
+2 -2
View File
@@ -171,13 +171,13 @@ OCR pipeline for physical Magic cards using the Ximilar API. Scan cards from pho
| Maven | 3.8+ |
| Node.js | 18+ (for LAN client scripts) |
| Ollama | Latest (for local LLM inference) |
| [Forge](https://github.com/Card-Forge/forge) | Built at `D:\ForgeCommander\forge-repo` |
| [Forge](https://github.com/KoalaTrapLord/Forge-Repo) | Built at `D:\ForgeCommander\Forge-Repo` |
### Setup (Windows)
```powershell
# 1. Build Forge (one-time)
cd D:\ForgeCommander\forge-repo
cd D:\ForgeCommander\Forge-Repo
mvn package -pl forge-gui-desktop "-DskipTests" "-Drevision=2.0.12-SNAPSHOT" -am
# 2. Build Commander AI Lab
+5 -5
View File
@@ -77,7 +77,7 @@ class CoachService:
logger.warning("Deck report not found: %s", deck_id)
return None
try:
with open(report_path, "r") as f:
with open(report_path, "r", encoding="utf-8") as f:
data = json.load(f)
return DeckReport(**data)
except Exception as e:
@@ -95,7 +95,7 @@ class CoachService:
"""Persist a coaching session to disk and SQLite."""
COACH_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
path = COACH_SESSIONS_DIR / f"{session.sessionId}.json"
with open(path, "w") as f:
with open(path, "w", encoding="utf-8") as f:
f.write(session.model_dump_json(indent=2))
if _DB_AVAILABLE:
try:
@@ -147,7 +147,7 @@ class CoachService:
if not path.exists():
return None
try:
with open(path, "r") as f:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return CoachSession(**data)
except Exception as e:
@@ -168,7 +168,7 @@ class CoachService:
sessions = []
for f in sorted(COACH_SESSIONS_DIR.glob("*.json"), reverse=True):
try:
with open(f, "r") as fh:
with open(f, "r", encoding="utf-8") as fh:
data = json.load(fh)
if deck_id and data.get("deckId", "") != deck_id:
continue
@@ -549,7 +549,7 @@ class CoachService:
status.llmModels = []
status.error = "Anthropic API key not configured. Set ANTHROPIC_API_KEY env var."
else:
llm_status = self.llm.check_connection()
llm_status = self.llm.check_connection_sync() # FARMHOUSE_COACH_STATUS_V1 (was an un-awaited coroutine)
status.llmConnected = llm_status.get("connected", False)
status.llmModel = llm_status.get("active_model")
status.llmModels = llm_status.get("models", [])
+1 -1
View File
@@ -13,7 +13,7 @@ LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.paren
# ── Ollama (OpenAI-compatible API) ────────────────────────────
LLM_URL = os.environ.get("LLM_URL", "http://localhost:11434/v1")
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss:20b")
LLM_MODEL = os.environ.get("LLM_MODEL", "claude-opus-4-20250514")
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "360")) # seconds
LLM_MAX_RETRIES = 3
+20 -1
View File
@@ -74,7 +74,7 @@ class LLMClient:
try:
status = await self.check_connection()
if status.get("connected") and status.get("active_model"):
self._resolved_model = status["active_model"]
self._resolved_model = self.model if self.model in (status.get("models") or []) else status["active_model"] # FARMHOUSE_MODEL_PIN_V1
logger.info("Resolved Ollama model: %s", self._resolved_model)
return self._resolved_model
except Exception:
@@ -103,6 +103,14 @@ class LLMClient:
}
if force_json:
body["response_format"] = {"type": "json_object"}
# FARMHOUSE_LLM_KNOBS_V1 (see patch_app.py)
import os as _fh_os
_fh_effort = _fh_os.environ.get("LLM_REASONING", "").strip()
if _fh_effort:
body["reasoning_effort"] = _fh_effort
_fh_cap = int(_fh_os.environ.get("LLM_MAX_TOKENS", "0") or 0)
if _fh_cap:
body["max_tokens"] = min(body["max_tokens"], _fh_cap)
return body
# ── Response parsing ──────────────────────────────────────
@@ -260,6 +268,17 @@ class LLMClient:
f"LLM call failed after {LLM_MAX_RETRIES} attempts: {last_error}"
)
def check_connection_sync(self) -> dict: # FARMHOUSE_LLM_SYNC_V1
"""Blocking version of check_connection (upstream called it and never defined it)."""
try:
_r = httpx.get(f"{self.base_url}/models", timeout=5.0, headers={"Content-Type": "application/json"})
_r.raise_for_status()
_ids = [m.get("id", "unknown") for m in _r.json().get("data", [])]
return {"connected": True, "models": _ids,
"active_model": self.model if self.model in _ids else (_ids[0] if _ids else self.model)}
except Exception as _e:
return {"connected": False, "error": str(_e), "models": [], "active_model": None}
async def check_connection(self) -> dict:
"""Check if Ollama is reachable. Returns status dict."""
try:
+15
View File
@@ -546,6 +546,21 @@ def build_quick_system_prompt(report: DeckReport,
def build_user_prompt(report: DeckReport,
candidates: Dict[str, List[dict]] = None,
rag_cards: List[dict] = None,
rules_context: List[dict] = None) -> str: # FARMHOUSE_RULES_CONTEXT
text = _farmhouse_build_user_prompt(report, candidates, rag_cards)
rules = []
for item in (rules_context or [])[:3]:
body = (item.get('text') or item.get('document') or item.get('content')) if isinstance(item, dict) else str(item)
if body:
rules.append('- ' + str(body).strip()[:700])
if rules:
text += '\n\n## Relevant rules (for grounding; do not quote them back)\n' + '\n'.join(rules)
return text
def _farmhouse_build_user_prompt(report: DeckReport,
candidates: Dict[str, List[dict]] = None,
rag_cards: List[dict] = None) -> str:
"""Build the user prompt with full deck report data.
+67
View File
@@ -0,0 +1,67 @@
# Laya connections
Laya (and Kev: same `POST /v1/systemone` API) is a **decision model**: typed multiple-choice, yes/no and score questions about a text, answered in
one forward pass, no generated text. This page is where it plugs into the strategic-intelligence work in this repository
([INTELLIGENCE-HANDOFF.md](INTELLIGENCE-HANDOFF.md)), what is measured, and how to make it worth using. Written 2026-10-26.
**Short version.** Every connection below is built, tested offline and **off by default**. Zero-shot, Laya is a poor action chooser
(below the always-guess baseline) but a reasonable card classifier (48% against 17% by chance); it is too slow on a CPU for Forge's
priority path (8 s against a 1.5 s budget). The way forward is fine-tuning on the decisions these connections record.
## What connects where
| Connection | Code | Mode | Notes |
|---|---|---|---|
| Farmhouse macro choice: Laya picks among CSIE's candidate macros, reading the CSIE ranking and ITI objective in its text | `farmhouse_decide.py` (called from `deepseek_brain.py`) | `DECISION_MODE=shadow` records agreement with the LLM; `assist` uses a pick it is `DECISION_MIN_CONF` sure of | existing; only offered candidates are accepted |
| **Forge concrete priority choice**: Laya picks among the same concrete candidate IDs Forge sent to `/api/policy/decide/concrete` | `farmhouse_laya.advise_concrete` / `attach_concrete`, called from `routes/policy.py` | `shadow`: adds `laya` to the response, selection unchanged. `assist`: may replace `selected_id`, never one Forge tagged `stops_immediate_win` / `wins_now`; the original is kept as `csie_selected_id` | candidate described by kind, card, cost, target, tags (never by its opaque id); Forge still executes only an ID it sent |
| **Extra questions in the same call**: "how dangerous are the opponents' boards?" (none/low/moderate/high/lethal) and "is there a threat that must be answered?" | `farmhouse_laya._extra_questions` | whenever Laya is asked | one forward pass answers all questions, so they cost almost nothing; recorded as weak signals for the learned-threat work, **not used to decide** |
| **Capture**: every call is appended to `data/decisions/laya-shadow-<date>.jsonl` (`laya-shadow-v1`) | `farmhouse_laya.capture` | shadow / assist | the text Laya read, the offered options, its answer and extras, and the other deciders' picks (`labels`: `llm`, `csie`, `selected`) |
| **Fine-tuning data**: capture log -> `train.jsonl` / `valid.jsonl` / `manifest.json`, split by game, with the baselines to beat | `scripts/farmhouse_laya_dataset.py` | offline | the manifest reports commonest-action, CSIE-vs-teacher and Laya-zero-shot agreement on the validation side |
| **Card role evidence**: removal, board wipe, ramp, card draw, counterspell, protection, tutor, creature, other, from Oracle text | `farmhouse_laya.classify_roles` (batched, cached, with `source: laya:<model>` provenance); `scripts/farmhouse_laya_roles_eval.py` measures it | on demand | evidence for ITI / Tagger-style use, never a rules fact |
Not built (still the open items in the handoff, none of which Laya can substitute for): compiling and running the Forge controller, the bounded
combo executor, the multi-turn planner, the calibrated learned-threat model. Laya's "threat" and "must answer" extras are only raw material for that last one.
## Where the hooks are
- `src/commander_ai_lab/sim/farmhouse_decide.py` (committed): the macro-choice client gained the extra questions, the state text/options in its
answer, and `record()` writes the capture line.
- `routes/policy.py` (**working tree only, uncommitted**: the route itself is part of the pending strategic-evaluator work, so the hook was
left for that commit). It is one block in `decide_concrete`, after the result is computed:
`if _L.mode() != "off": advice = await run_in_threadpool(_L.advise_concrete, ...); result = _L.attach_concrete(...)`, guarded by `except ImportError`.
- Everything else is new files: `farmhouse_laya.py`, the two scripts, `tests/farmhouse/test_farmhouse_laya*.py`, `farmhouse/laya/`.
## Settings
`DECISION_URL` (e.g. `http://laya.commander-lab.svc.cluster.local:8080`), `DECISION_MODE` (`off` default / `shadow` / `assist`), `DECISION_MODEL`
(a checkpoint such as `typed-decisions`), `DECISION_MIN_CONF` (0.7), `DECISION_TIMEOUT` (5 s), `DECISION_TIMEOUT_FORGE` (**1.5 s**: Forge waits on this),
`DECISION_API_KEY`, `LAYA_CAPTURE_DIR` (`data/decisions`), `LAYA_CAPTURE=0` to stop writing capture lines. Three failures in a row pause asking for a minute.
## Rules the code enforces
1. Text is built from public facts and the acting player's own hand. An opponent's `hand` field is never read, even if a client sends one (tested).
2. Laya's answer must be one of the options that were offered; anything else is discarded.
3. In assist, a pick Forge marked `stops_immediate_win` / `wins_now` is never overridden, and the response says what CSIE chose.
4. A missing, slow or broken server returns "no answer"; nothing raises and the game proceeds as without Laya.
5. Scores and probabilities are relative signals; Laya's probabilities are not calibrated for this game.
## What was measured (2026-10-26, `typed-decisions` checkpoint, 4-thread CPU)
- **Macro action vs the LLM, 149 recorded decisions:** 16.1% (English) and 18.6% (typed-decisions); always guessing the commonest action 27.5%.
- **Forge-shaped priority choice, 3 scenarios, with CSIE's ranking in the text:** agreed with CSIE all three times (expected: the ranking is in the
text it reads), 42-48% sure, **7.5-8.4 s per call** with the extra questions. That is five times Forge's 1.5 s budget: on this CPU the Forge path
would time out and fall back; it needs a GPU (33 ms quoted) or a smaller text.
- **Extras:** "threat" said *high* for a lethal stack, a calm board and a behind board alike (useless zero-shot); "must answer" gave 0.48 for the
lethal stack against 0.25 and 0.29 for the others (right direction, weak).
- **Card roles from Oracle text, 150 unambiguous cards:** 48.0% (16.7% by chance among the six roles present); counterspell 76%, board wipe 64%,
removal 40%, tutor 40%, ramp 36%, card draw 32%; when 70% sure, 69% right but only 11% of cards reach that. **3.2 s per card** in batches on the CPU:
fine for the cards in a hand (cached), not for tagging 30,000 cards (about a day).
## How to make it worth using
1. Run games with `DECISION_URL` set and `DECISION_MODE=shadow` (Farmhouse LLM games, and Forge games through `/api/policy/decide/concrete`).
2. `python scripts/farmhouse_laya_dataset.py --teacher llm` (or `csie`, or `selected`). Read `manifest.json`: the **validation baselines** are the bar.
A student of CSIE can at best equal CSIE, which is free; a student of the LLM is the useful one. Several thousand rows are needed.
3. Fine-tune with Laya's own notebook/script (github.com/NandhaKishorM/laya, `notebooks/`; 2xT4 about 4-5 hours, or Apple silicon). Its worked example
(browser agent choosing among ~45 concrete candidates) went from 0.10 zero-shot to 0.66, so a concrete-candidate head is the shape that works.
Adapt the row field names if its loader differs.
4. Serve the result (`LAYA_EXTRA_MODELS` / `DECISION_MODEL`), rerun `scripts/farmhouse_decision_eval.py` and the dataset manifest, and only then
try `assist`. A GPU host makes the Forge path possible.
5. Keep game result and decision quality separate (handoff boundary 6 / learned-threat contract): a captured label is a teacher's choice, not proof it was right.
Deployment reference copies are in `farmhouse/laya/` (image, manifests, CI); the source of truth is the farmhouse-os GitOps repo.
+74
View File
@@ -0,0 +1,74 @@
# phase-rs and commander-playtester: assessment and plan
Researched 2026-10-26 from the public repositories and data files only. **Nothing from either project has been run, vendored or deployed.**
Facts marked (verified) were read from the source; (unverified) are inferences to check before building on them.
## phase-rs/phase: a rules engine
A Rust rules engine (turns, priority, the stack, combat, layers, triggers, replacement effects) that compiles to native and WebAssembly, with a game
client, a WebSocket server and its own AI. Dual licensed Apache-2.0 / MIT (`LICENSE-APACHE`, `LICENSE-MIT`; GitHub's licence field shows "none" only
because there are two files). Card data from MTGJSON (MIT); card images come from Scryfall at runtime; the repo says it bundles no card art or
Comprehensive Rules text and has a DMCA/fan-content policy. One maintainer, young (weeks to months), fast-moving (latest engine tag v0.100.0).
Why it matters here: our Python simulator has no rules stack (the handoff says so: `stack_supported: false`) and Forge needs a JDK and Maven that
are not in the image. Phase is a rules-accurate engine that can run headless.
### Coverage of the decks in this app (verified, from phase's published `coverage-data.json`)
Phase reports 88.9% of 36,037 cards implemented overall and 92.5% for Commander. For the five saved decks, distinct cards supported:
Empress V8 72/77, Landfall 65/66, God and Anime 69/71, Im not dead yet 82/84, Pew Pew 88/93: **376 of 391 (96.2%)**. The 15 unsupported cards have
one or two parsing gaps each (e.g. Gale Waterdeep Prodigy, Hall of Echoes, Precursor Golem, Invigorate, Mavinda, Drana and Linvala, Tibalt's Trickery).
"Supported" means every ability parsed; it is not a guarantee that each card plays correctly. How the engine treats an unsupported card in a game
(blank, no-op, refused) is **unverified**; test it before trusting a result that involves one.
### The parts that fit
| Part | What it gives us | Notes |
|---|---|---|
| `ai-duel` (crate `phase-ai`) | headless AI-vs-AI games from the command line: seeded, deterministic "measurement mode", batches, JSON reports, a 4-player Commander suite, paired-seed comparison | decks are plain JSON `{"name", "main":[{"name","count"}]}` (verified). **No prebuilt binary is released**: it needs a Rust build (in CI or a container, not on the workstation). How a custom Commander deck and commander are supplied to `--commander-suite` is unverified (that suite reads a feed file) |
| `phase-server` | a WebSocket game server (lobby, hidden information, spectators) | a prebuilt image is published (`ghcr.io/phase-rs/phase-server`, tags `latest`, `preview`, `sha-...`). A Python client could play seats and record every decision with real rules (protocol documentation not yet read) |
| `phase-llm` | **model-driven AI seats**: the engine hands the model its legal option list, the model returns an index, the engine re-validates it, and any failure falls back to the heuristic AI | same principle as `farmhouse_laya.py`. Speaks OpenAI-style chat (so Olla / twin-turbo) **and the System One protocol Laya and Kev serve** (verified: `WireProtocol::SystemOneRelay`, question id `pick`, 2 to 255 options) |
| `POST /jev/systemone` relay in `phase-server` | forwards the System One request to the URL in `TYPESAFE_API_URL` (verified, env var, `http` allowed for local setups) | point it at `http://laya...:8080/v1/systemone` to put Laya or Kev in a seat. The relay sends the key as `Authorization: Bearer` and does not follow redirects |
The attraction for the Laya work: a captured decision from phase is an **engine-issued option list plus the pick**, in rules-accurate games. That
is a better fine-tuning source than this repository's approximate simulator, and the exporter (`scripts/farmhouse_laya_dataset.py`) could take it with a small adapter.
## RafaelAugustScherer/commander-playtester: a browser app on top of phase
TypeScript/React single-page app, no backend. Runs phase's engine as WebAssembly in a Web Worker. Deck library with Moxfield / Archidekt / plain-text
import, a goldfishing Monte Carlo (mulligan rate, screw and flood, land drops, ramp by turn 3, curve), real engine matches (watch, or pilot one seat),
and 1-50 match head-to-head runs with a win rate and 95% confidence interval. Pins the phase engine in `engine-manifest.json` (v0.100.0; its README
still says v0.57.0) and fetches the WASM and card data with SHA-256 checks. Zero stars, last pushed 2026-10-24.
**It has no LICENSE file** (verified: the repository metadata shows none). Without a licence the code is all rights reserved: it can be read and used
as a reference, but not copied into this repository or redistributed. Ask the author to add a licence (MIT would do) before reusing any of it. What is
worth taking as ideas, not code: the goldfishing metrics, the head-to-head confidence-interval report, the name-only deck payload for the engine, and
the engine-smoke gate that boots the real engine and plays a match to completion after every upgrade.
## Recommended plan, smallest useful step first
1. **Prove it on one deck.** Build `ai-duel` from the pinned phase tag in CI (Rust, container), convert one saved deck to the JSON format, run a seeded
batch against a baseline deck, and read the result. Answers: does it run a Commander game end to end, what does it do with an unsupported card, how
long is a game. This replaces the guesswork about the Python simulator's win rates.
2. **A `phase-sim` service** in the commander-lab namespace (no egress, called only by the app) with an endpoint "run N rules-accurate games for deck X
against Y", returning win rate, confidence interval, wins by seat and the game log; the app's results panel reads it. The Python simulator stays for
instant, rough Monte Carlo.
3. **Model seats.** Run `phase-server` with `TYPESAFE_API_URL` at Laya (or at a fine-tuned one later) and the OpenAI-compatible seat at Olla; compare
heuristic-only, LLM and Laya seats. Record every decision for the Laya dataset.
4. **Only then** consider the playtester's UI ideas, after the author adds a licence or as a clean reimplementation.
## What was built from this plan (2026-10-27)
1. **The headless runner** (`ai-commander`, Commander four-player, batch mode) is built from the v0.118.0 tag with the card database that release was published with, in a
container (`farmhouse/phase/Dockerfile`). Not `ai-duel` for the decks: `ai-commander` takes a feed JSON of four decks, which is exactly what the app has.
2. **The service and the app side**: `farmhouse/phase/phase_sim.py` (HTTP, jobs, parser, warnings) and `routes/farmhouse_phase.py` + `ui/phase-sim.html`. Games that stall, hit the
action cap or panic are reported and never counted.
3. **Model seats**: not a setting. phase's model support lives only in its browser client (`engine-wasm` calls `phase-llm`; the HTTP is done by TypeScript), and
`phase-llm` depends on `phase-ai`, so the headless binary cannot use it. `farmhouse/phase/llm-seat/` is a new crate that includes the runner and installs a hook
(two anchored edits to phase's source). The server-side Jev relay and the browser client were not needed. Details: `farmhouse/phase/README.md`.
Not done: the playtester's UI (no licence), a Python client for phase's WebSocket server (a large, fast-moving protocol), feeding the captured model-seat decisions into
`scripts/farmhouse_laya_dataset.py` (the natural next step: each is an engine-issued option list plus a pick).
## Risks and open questions
- Phase is a one-person, fast-moving project: pin tags, keep a smoke test that plays a full game, expect breaking changes.
- Rust builds are heavy; keep them in CI/containers. Resource and image size are unmeasured.
- Card coverage is parse-level; correctness per card is unmeasured. Unsupported cards must be flagged in any result.
- The phase server and its AI use CPU; its measured game speed is unknown (the README says a game is thousands of AI decisions).
- Legal: phase is Apache/MIT with Wizards fan-content wording; the playtester has no licence. The card data is MTGJSON (MIT).
- Not yet read: phase's WebSocket protocol, how `--commander-suite` accepts custom decks, and whether the server can run unattended self-play.
+32
View File
@@ -0,0 +1,32 @@
# Forge in the cluster image
The batch simulator on the Simulator page runs on **Forge**, the Java rules engine. The first cluster builds left it out by mistake ("Python-only"
was read as "no Java"), so the page answered `Forge JAR not found`. It is now built into the image (reference copy of the Dockerfile stage:
`infrastructure/apps/commander-lab/image/Dockerfile` in the farmhouse-os repo).
What the image build does:
1. **Stage 1** (`maven:3.9-eclipse-temurin-17`): fetch the official `Card-Forge/forge` at commit `edb28158f6d8` (the commit `KoalaTrapLord/Forge-Repo`
is on; that fork is 0 commits ahead of upstream, so this is the code the lab's Java was written against; newer Forge may have changed APIs), then
`mvn install -pl forge-gui-desktop -am -DskipTests -Drevision=2.0.12-SNAPSHOT`, retried up to three times (Maven Central downloads fail now and then).
2. **The lab's Java tool** (`LabCli`, from this repository's `pom.xml`) is built against it. Forge's installed poms keep `${revision}` as their parent
version, which Maven cannot resolve for a consumer even with `-Drevision`; the placeholder is rewritten in the installed `.pom` files first.
3. **Runtime**: the full `openjdk-17-jre` (not `-headless`) plus `xvfb`, the two jars, and Forge's `res/` data without `adventure`, `music`, `sound`, `quest` and `skins`
(about 190 MB the headless simulator never reads). The environment the app reads: `FORGE_JAR`, `FORGE_DIR`, `LAB_JAR`, `FORGE_DECKS_DIR`, `PRECON_DIR`, `JAVA_HOME`.
4. `FORGE_MAX_THREADS` (default 1) clamps the batch's thread count: every Forge game is its own JVM with up to a 4 GB heap, and the cluster has two small nodes.
5. CI starts the app, begins a batch through the app's own `/api/lab/start` and requires a Commander game with turns played and a readable `/api/lab/result` before the image is published.
## Things that only showed up by running it in the cluster (2026-10-27)
Each of these made a batch fail while every individual piece looked installed. Together they are why the CI test starts a batch through the app's own API.
- **Forge needs a display, even for `sim`.** `GuiDesktop`'s static initialiser asks AWT for the default screen device; the headless JRE throws `HeadlessException`, and
`forge.view.Main` then exits 1 with no output at all (the lab tool records every game as an instant draw: "Forge produced NO output"). Fix: the full `openjdk-17-jre`,
`xvfb`, `DISPLAY=:99`, and the entrypoint starts `Xvfb :99`.
- **Three folders, one deck.** The app writes saved decks to `USER_DECKS_DIR`, the lab's Java tool resolves deck files through `PRECON_DECKS_DIR`, and Forge finds a deck BY
NAME in its own commander folder ("Could not load deck"). All three are the Forge folder (`FORGE_DECKS_DIR`), which the entrypoint seeds with the 163 bundled precons.
- **Writable folders.** The tool writes `results/` beside Forge's data and Forge a debug log beside its jar; both folders are owned by uid 10001 in the image.
- **Result path.** The app asked for `results/ml-decision-<id>.json` (relative) but ran the tool from Forge's folder, so the file landed there and `/api/lab/result` answered
"Result file not found". The path is made absolute (patch `forge-results-abs`) and `/app/results` is linked to `/state/results`.
- `docker cp` cannot write into a tmpfs and Docker mounts `/tmp` `noexec` (it stops SQLite's native library loading): test-harness traps, not cluster problems.
Speed: about 25 seconds per game, one at a time (the project's own figure is ~0.04 simulations/s single-threaded). For many games with real rules, the
phase-rs engine (`farmhouse/phase/`) is far faster. Forge is GPL-3.0: private use, do not redistribute the built image.
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
# Keep the app's state on the persistent volume (/state) while the code stays in the image.
# The upstream app writes next to its code: collection.db, scryfall_cache.db, data/ (Scryfall bulk DB, ChromaDB index),
# user-decks/. Point each at /state with a symlink; seed data/ and user-decks/ from the image the first time.
set -eu
S="${STATE_DIR:-/state}"
mkdir -p "$S"
for d in data user-decks results; do
if [ ! -e "$S/$d" ]; then
if [ -d "/app/$d" ]; then cp -a "/app/$d" "$S/$d"; else mkdir -p "$S/$d"; fi
fi
rm -rf "/app/$d"
ln -s "$S/$d" "/app/$d"
done
for f in collection.db scryfall_cache.db; do
rm -f "/app/$f"
ln -s "$S/$f" "/app/$f" # sqlite creates the target on first open
done
# Forge looks decks up by name in its user folder; the app exports the decks it simulates there.
mkdir -p "${FORGE_DECKS_DIR:-/tmp/.forge/decks/commander}"
# Seed it with the bundled precon decks (163 small files); never overwrite a deck that is already there.
if [ -d /app/precon-decks ]; then cp -n /app/precon-decks/*.dck "${FORGE_DECKS_DIR:-/tmp/.forge/decks/commander}"/ 2>/dev/null || true; fi
# A virtual display for Forge (it initialises AWT even in command-line mode; see the Dockerfile). Best effort: the app itself does not need it.
if command -v Xvfb >/dev/null 2>&1; then
mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix 2>/dev/null || true
Xvfb "${DISPLAY:-:99}" -screen 0 1280x800x24 -nolisten tcp >/tmp/xvfb.log 2>&1 &
for _ in 1 2 3 4 5 6 7 8 9 10; do
[ -S "/tmp/.X11-unix/X${DISPLAY#:}" ] && break
sleep 0.3
done
fi
exec "$@"
+34
View File
@@ -0,0 +1,34 @@
# Laya (https://github.com/NandhaKishorM/laya, Apache-2.0): a "decision model". It answers typed multiple-choice / yes-no / score
# questions about a piece of text in ONE forward pass (about 33 ms on a GPU) and never writes text. Here it is staged for the Commander
# AI Lab simulator, which asks an LLM "which action?" about 25 times per game; a small classifier can answer that class of question
# far faster, if it is accurate enough (it is measured in shadow mode first, see ../README.md).
#
# CPU only. The weights are baked in at a pinned Hugging Face commit and the pod has NO network access (NetworkPolicy), so the
# running service fetches nothing. Two checkpoints are included so they can be compared: the English base and the fine-tuned
# "typed-decisions" one (about 840 MB each).
FROM python:3.12-slim-bookworm
ARG LAYA_VERSION=0.4.15
ARG LAYA_HF_REVISION=7b928d828b7b0e022f929d9bd2e44165aa270148
ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1 PYTHONUNBUFFERED=1 HF_HOME=/opt/hf
RUN pip install torch --index-url https://download.pytorch.org/whl/cpu \
&& pip install "laya[serve]==${LAYA_VERSION}"
# Weights at the pinned commit. HF's offline lookup resolves the "main" ref, which a commit-pinned download does not create, so it is
# written by hand to the same commit.
RUN python -c "from huggingface_hub import snapshot_download; snapshot_download('convaiinnovations/laya', revision='${LAYA_HF_REVISION}', ignore_patterns=['multilingual/*','assets/*','eval/*'])" \
&& mkdir -p /opt/hf/hub/models--convaiinnovations--laya/refs \
&& printf '%s' "${LAYA_HF_REVISION}" > /opt/hf/hub/models--convaiinnovations--laya/refs/main \
&& chmod -R a+rX /opt/hf
RUN useradd --uid 10001 --no-create-home --shell /usr/sbin/nologin laya
USER 10001
ENV HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 \
LAYA_HOST=0.0.0.0 LAYA_PORT=8080 \
LAYA_MODELS=english,typed-decisions LAYA_DEFAULT_MODEL=english LAYA_MAX_LOADED=2 LAYA_PRELOAD=1 LAYA_THREADS=4
EXPOSE 8080
CMD ["laya-serve"]
+52
View File
@@ -0,0 +1,52 @@
# Laya as the simulator's "which action?" model: evaluation, 2026-10-26
**Result: not usable as shipped.** Both checkpoints agreed with the LLM less often than a model that always answers the commonest action.
## What was measured
The simulator's LLM brain ("twin-turbo") picks one of nine actions about 25 times a game. A decision model (Laya, or Kev; both serve
`POST /v1/systemone`) could answer that kind of question in one forward pass instead of a 7-second generation. To find out whether it
could stand in, every LLM decision the app had recorded (`/app/logs/decisions/*.jsonl`: the game state and the action the LLM chose)
was replayed through Laya with the same question, and the answers compared. The LLM is the *teacher*, not the truth: this measures
whether a fast model can imitate the slow one, not which plays better.
Script: `image/farmhouse_decision_eval.py` (in the app image as `/app/scripts/farmhouse_decision_eval.py`):
`DECISION_URL=http://laya:8080 python scripts/farmhouse_decision_eval.py /app/logs/decisions english typed-decisions`
## Numbers (149 recorded decisions)
LLM's choices: play_land 41, hold 31, attack_all 28, cast_creature 20, cast_spell 13, attack_safe 7, cast_removal 5, cast_ramp 4.
| | agrees with the LLM | answers | time per answer | what it picked |
|---|---|---|---|---|
| always "play_land" (baseline) | **27.5%** | 149 | 0 | |
| `english` (ModernBERT-large, base) | 16.1% | 149 | 1.9 s | cast_creature 105, attack_all 26; never hold, almost never play_land |
| `typed-decisions` (fine-tuned) | 18.6% | 140 (9 timed out at 5 s) | ~4 s (shared CPU) | cast_creature 99, attack_all 26 |
It could say "cast_creature" and "attack_all" and little else; it never recognised "hold" (31 of the LLM's choices) or a removal spell.
## Why, and caveats
- The shipped checkpoints are trained on generic ticket-triage style decisions. Laya's own README says fine-tuning "on decisions from your
own domain is where accuracy jumps" (0.766 against 0.362 for the base on its own benchmark). This game's decisions were never in its training.
- Speed on this cluster's CPU (4 threads) was 2-4 s per answer, not the ~33 ms quoted for a GPU. Against the LLM's ~7.5 s that is not the
speed-up that was hoped for either, without a GPU.
- The question wording (the nine action descriptions) and the compact text it reads (`farmhouse_decide.compact_state`) are mine; a
better prompt might help a little, not from 18% to usable.
- Part of the recorded data predates the card-data fix (v10), so some states had thinner hands than they would now.
- The LLM being the "truth" also caps the result: where the LLM is wrong, agreeing with it is not a win.
## Where this leaves it
- Laya is **staged but scaled to 0 replicas** (`deployment-laya.yaml`: `replicas: 0`; set to 1 to run it). The image
(`ghcr.io/fez5587/farmhouse-laya:v1`, offline, weights baked in), the service and the network policy remain.
- The app is wired to use it (`DECISION_URL`, `DECISION_MODE`) and `DECISION_MODE` is `off`. `shadow` records agreement without
affecting games; `assist` uses it when it is `DECISION_MIN_CONF` sure. Kev speaks the same API, so swapping is a URL.
- To make it worth using: fine-tune on this game's decisions (Laya ships a Kaggle 2xT4 notebook, about 4 hours, and an Apple/CPU
script). The labels would be the LLM's recorded decisions; a few thousand are needed, so generate them first (LLM games at ~7 s a
decision, or label states with the engine's own heuristic for the cheap, abundant version). Then re-run this evaluation.
- Node memory: it needed about 2 GB (3.6 GB with both checkpoints, which pushed the control-plane node to 92%; it is pinned to the other node).
## Later the same day: the new connections (see docs/LAYA-CONNECTIONS.md in the fork)
- **Forge-shaped priority choice** (CSIE's ranking in the text, three scenarios): agreed with CSIE each time, 42-48% sure, 7.5-8.4 s per call on the CPU against Forge's
1.5 s budget. Not usable on a CPU; the code falls back silently.
- **Card roles from Oracle text** (150 unambiguous cards, 6 roles present, 9 offered): 48% against 17% by chance (counterspell 76%, board wipe 64%, removal 40%, tutor 40%,
ramp 36%, card draw 32%), 3.2 s per card in batches. Much better than the action choice; useful as on-demand evidence for the cards in a hand, too slow to tag the whole pool.
- **Extra questions** in the same call: "threat" said *high* for everything; "must answer" gave 0.48 for a lethal stack against 0.25-0.29 otherwise (right direction, weak).
- Capture, dataset export and the rest are built and off by default; fine-tuning on the captured decisions is the path to making it useful.
+21
View File
@@ -0,0 +1,21 @@
# Laya in this repository
Laya (https://github.com/NandhaKishorM/laya, Apache-2.0) is a **decision model**: it answers typed multiple-choice, yes/no and score
questions about a piece of text in one forward pass and never writes text. Here it is a fast second opinion beside the LLM and CSIE.
Kev (https://github.com/jaredpalmer/kev) serves the same `POST /v1/systemone` API, so swapping is a URL change (Kev needs a GPU).
| File | What |
|---|---|
| `Dockerfile` | CPU image: `laya[serve]` 0.4.15 + CPU torch, English and `typed-decisions` checkpoints baked in at Hugging Face commit `7b928d8`; runs offline |
| `deployment-laya.yaml` | Kubernetes Deployment + Service + NetworkPolicy (no egress; only the app may call it). Currently `replicas: 0` |
| `build-farmhouse-laya.workflow.yml` | the CI workflow that builds and smoke-tests the image offline (it lives in `.github/workflows/` of the farmhouse-os GitOps repo) |
| `LAYA-EVALUATION.md` | why it is off: zero-shot both checkpoints agreed with the LLM less often than always guessing the commonest action |
The deployment source of truth is the farmhouse-os repo (`infrastructure/apps/commander-lab/`); these are reference copies so the
fork is self-contained. Things learned running it:
- A Kubernetes Service named `laya` injects `LAYA_PORT=tcp://...`, which `laya-serve` reads as its own port setting and refuses. Set
`enableServiceLinks: false` and `LAYA_PORT=8080` explicitly.
- Two checkpoints resident use 3.6 GB; one uses about 1.9 GB. Do not place it on a control-plane node.
- The CPU answers in 2-4 s, not the ~33 ms quoted for a GPU.
- How the code in this repository connects to it, and how to fine-tune it: `docs/LAYA-CONNECTIONS.md`.
@@ -0,0 +1,96 @@
name: Build FarmHouse Laya
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "infrastructure/apps/commander-lab/laya/**"
- ".github/workflows/build-farmhouse-laya.yml"
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
env:
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/farmhouse-laya
# Bump together with the image tag in infrastructure/apps/commander-lab/deployment-laya.yaml.
IMAGE_TAG: v1
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set image name lowercase
shell: bash
run: |
echo "IMAGE=$(echo "${IMAGE_NAME}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build validation image
uses: docker/build-push-action@v6
with:
context: infrastructure/apps/commander-lab/laya
load: true
push: false
tags: laya:test
# The service must answer with NO network (that is how it runs in the cluster) as the unprivileged user, for both checkpoints.
- name: Smoke test (boots offline, answers a game-state question with both checkpoints)
shell: bash
run: |
set -euo pipefail
docker run -d --name laya --network none laya:test
cat > /tmp/ask.py <<'PY'
import json, sys, time, urllib.request
model = sys.argv[1]
body = {"model": model,
"state": {"body": "Turn 4. You have 4 lands untapped. Hand: Llanowar Elves, Swords to Plowshares, Forest. Opponent has a 6/6 creature attacking you."},
"questions": {"action": {"type": "choice", "instructions": "Which action is best this turn?",
"criteria": {"cast_creature": "put a creature onto the battlefield",
"cast_removal": "destroy or exile an opposing permanent",
"play_land": "play a land",
"hold": "do nothing this turn"}}}}
req = urllib.request.Request("http://127.0.0.1:8080/v1/systemone", data=json.dumps(body).encode(), headers={"content-type": "application/json"})
t = time.time()
out = json.load(urllib.request.urlopen(req, timeout=120))
print(model, "%.0f ms" % ((time.time() - t) * 1000), json.dumps(out)[:600])
assert "answers" in out and "action" in out["answers"], out
PY
docker cp /tmp/ask.py laya:/tmp/ask.py
ok=0
for i in $(seq 1 120); do
if docker exec laya python -c "import socket; socket.create_connection(('127.0.0.1', 8080), 2)" 2>/dev/null; then ok=1; break; fi
sleep 3
done
if [ "$ok" != 1 ]; then echo "laya did not open its port"; docker logs laya | tail -60; exit 1; fi
for m in english typed-decisions; do docker exec laya python /tmp/ask.py "$m"; done
docker exec laya id
docker rm -f laya >/dev/null
echo SMOKE OK
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and publish
uses: docker/build-push-action@v6
with:
context: infrastructure/apps/commander-lab/laya
push: true
tags: |
${{ env.IMAGE }}:${{ env.IMAGE_TAG }}
${{ env.IMAGE }}:${{ github.sha }}
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
+93
View File
@@ -0,0 +1,93 @@
# Laya, a CPU decision model (laya/Dockerfile), staged for the simulator. Reached only by the app, over the cluster network, on 8080.
# No internet, no volume (weights are in the image), no service-account token.
apiVersion: apps/v1
kind: Deployment
metadata:
name: laya
namespace: commander-lab
labels: {app: laya}
spec:
# 0 since 2026-10-26: both checkpoints scored BELOW the always-guess baseline against the LLM's recorded decisions (English 16.1%, typed-decisions
# 18.6%, baseline 27.5%, 149 states), and ~2 GB of the node's memory was needed for the card-search index build. Set to 1 to bring it back
# (image, service and policy stay in place); it is only worth running once it is fine-tuned on this game's decisions (README).
replicas: 0
selector:
matchLabels: {app: laya}
template:
metadata:
labels: {app: laya}
spec:
automountServiceAccountToken: false
# Kubernetes injects LAYA_PORT=tcp://<ip>:8080 for a Service named laya, and laya-serve reads LAYA_PORT as its own port setting
# ("invalid LAYA_PORT 'tcp://...'", crash loop). Service-link variables are not wanted here at all.
enableServiceLinks: false
# NOT on talos-gmw: that node runs the Kubernetes API server, and Laya's first placement there (two checkpoints, 3.6 GB) took it from
# 68% to 92% memory on 2026-10-26.
nodeSelector: {kubernetes.io/hostname: talos-noy-0r4}
imagePullSecrets:
- name: ghcr-pull
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
seccompProfile: {type: RuntimeDefault}
containers:
- name: laya
image: ghcr.io/fez5587/farmhouse-laya:v1
ports:
- {name: http, containerPort: 8080}
env:
- {name: LAYA_THREADS, value: "4"}
- {name: LAYA_PORT, value: "8080"}
# One checkpoint only (about 1.9 GB): the English base scored below the always-guess baseline against the LLM; typed-decisions is the
# fine-tuned one still being measured.
- {name: LAYA_MODELS, value: "typed-decisions"}
- {name: LAYA_DEFAULT_MODEL, value: "typed-decisions"}
- {name: LAYA_MAX_LOADED, value: "1"}
securityContext:
allowPrivilegeEscalation: false
capabilities: {drop: [ALL]}
resources:
requests: {cpu: "500m", memory: 2500Mi}
limits: {cpu: "4", memory: 3Gi}
# Loading two checkpoints takes a while; the port opens when the server is up.
startupProbe:
tcpSocket: {port: http}
periodSeconds: 5
failureThreshold: 90
readinessProbe:
tcpSocket: {port: http}
periodSeconds: 10
livenessProbe:
tcpSocket: {port: http}
periodSeconds: 30
failureThreshold: 6
---
apiVersion: v1
kind: Service
metadata:
name: laya
namespace: commander-lab
spec:
type: ClusterIP
selector: {app: laya}
ports:
- {name: http, port: 8080, targetPort: http}
---
# IN: only the app. OUT: nothing at all (the weights are in the image; it needs no DNS and calls no one).
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: laya
namespace: commander-lab
spec:
podSelector:
matchLabels: {app: laya}
policyTypes: [Ingress, Egress]
ingress:
- from:
- podSelector:
matchLabels: {app: commander-lab}
ports:
- {protocol: TCP, port: 8080}
egress: []
+61
View File
@@ -0,0 +1,61 @@
# phase-sim: rules-accurate four-player Commander games with phase-rs's headless AI (github.com/phase-rs/phase), behind a small HTTP service.
#
# phase is a Rust rules engine (stack, priority, combat, layers, triggers, replacement effects), dual licensed Apache-2.0 / MIT, with card
# data from MTGJSON (MIT). Pinned to release v0.118.0 (commit ad0de6a) and to the card database that release was published with
# (`card-data-5e541a38c99b97f0.json`, named in data.phase-rs.dev/card-data-meta.json for that commit); the file's SHA-256 is checked, so a
# changed file fails the build instead of silently changing results. Nothing is fetched at run time, and the pod has no network.
#
# Built: the `ai-commander` binary (4-player Commander, batch mode) and `ai-duel` with the repo's `server-release` profile (unwinding
# panics, so one game's panic cannot kill a batch). The toolchain is whatever rust-toolchain.toml pins (a dated nightly).
FROM rust:slim-bookworm AS build
ARG PHASE_REPO=https://github.com/phase-rs/phase.git
ARG PHASE_TAG=v0.118.0
ARG PHASE_SHA=ad0de6adf00153a6e6eb89604f6670be5b1d5168
ARG CARD_DATA_URL=https://data.phase-rs.dev/card-data-5e541a38c99b97f0.json
ARG CARD_DATA_SHA256=5e541a38c99b97f0ef12c81f7b249e7cd253f38b32bd5c156cf19608cb6f1218
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates curl build-essential pkg-config python3 \
&& rm -rf /var/lib/apt/lists/*
RUN set -eux; \
mkdir /phase; cd /phase; \
git init -q; \
git remote add origin "$PHASE_REPO"; \
git fetch -q --depth 1 origin "$PHASE_SHA"; \
git checkout -q FETCH_HEAD; \
test "$(git rev-parse HEAD)" = "$PHASE_SHA"
WORKDIR /phase
# phase's server-release profile uses thin LTO and 16 code-generation units; compiling its big engine crate that way ran a 7 GB CI machine
# out of memory ("cannot allocate memory", 2026-10-27). LTO off and two parallel jobs trade some build time (and a few percent of run
# speed) for a build that fits; the optimisation level and panic=unwind (which batch isolation needs) are unchanged.
ENV CARGO_BUILD_JOBS=2 \
CARGO_PROFILE_SERVER_RELEASE_LTO=off \
CARGO_PROFILE_SERVER_RELEASE_CODEGEN_UNITS=8
RUN cargo build --profile server-release -p phase-ai --bin ai-commander --bin ai-duel
RUN set -eux; \
curl -fsSL --retry 3 -o /card-data.json "$CARD_DATA_URL"; \
echo "$CARD_DATA_SHA256 /card-data.json" | sha256sum -c -; \
cp /phase/client/public/feeds/mtggoldfish-commander.json /sample-feed.json
FROM python:3.12-slim-bookworm
ARG PHASE_TAG=v0.118.0
RUN groupadd -g 10001 phase && useradd -u 10001 -g 10001 -d /tmp -s /usr/sbin/nologin phase
COPY --from=build /phase/target/server-release/ai-commander /opt/phase/ai-commander
COPY --from=build /phase/target/server-release/ai-duel /opt/phase/ai-duel
COPY --from=build /card-data.json /opt/phase/data/card-data.json
COPY --from=build /sample-feed.json /opt/phase/sample-feed.json
COPY phase_sim.py phase_smoke.py /opt/phase/
ENV PHASE_ENGINE_VERSION="phase-rs ${PHASE_TAG}" \
PHASE_AI_COMMANDER=/opt/phase/ai-commander \
PHASE_CARD_DATA=/opt/phase/data/card-data.json \
PYTHONUNBUFFERED=1 PORT=8090
USER 10001
WORKDIR /tmp
EXPOSE 8090
CMD ["python", "/opt/phase/phase_sim.py"]
+72
View File
@@ -0,0 +1,72 @@
# phase-sim: rules-accurate four-player Commander games with phase-rs's headless AI (github.com/phase-rs/phase), behind a small HTTP service.
#
# phase is a Rust rules engine (stack, priority, combat, layers, triggers, replacement effects), dual licensed Apache-2.0 / MIT, with card
# data from MTGJSON (MIT). Pinned to release v0.118.0 (commit ad0de6a) and to the card database that release was published with
# (`card-data-5e541a38c99b97f0.json`, named in data.phase-rs.dev/card-data-meta.json for that commit); the file's SHA-256 is checked, so a
# changed file fails the build instead of silently changing results. Nothing is fetched at run time, and the pod has no network.
#
# Built: the `ai-commander` binary (4-player Commander, batch mode) and `ai-duel` with the repo's `server-release` profile (unwinding
# panics, so one game's panic cannot kill a batch). The toolchain is whatever rust-toolchain.toml pins (a dated nightly).
FROM rust:slim-bookworm AS build
ARG PHASE_REPO=https://github.com/phase-rs/phase.git
ARG PHASE_TAG=v0.118.0
ARG PHASE_SHA=ad0de6adf00153a6e6eb89604f6670be5b1d5168
ARG CARD_DATA_URL=https://data.phase-rs.dev/card-data-5e541a38c99b97f0.json
ARG CARD_DATA_SHA256=5e541a38c99b97f0ef12c81f7b249e7cd253f38b32bd5c156cf19608cb6f1218
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates curl build-essential pkg-config python3 \
&& rm -rf /var/lib/apt/lists/*
RUN set -eux; \
mkdir /phase; cd /phase; \
git init -q; \
git remote add origin "$PHASE_REPO"; \
git fetch -q --depth 1 origin "$PHASE_SHA"; \
git checkout -q FETCH_HEAD; \
test "$(git rev-parse HEAD)" = "$PHASE_SHA"
WORKDIR /phase
# phase's server-release profile uses thin LTO and 16 code-generation units; compiling its big engine crate that way ran a 7 GB CI machine
# out of memory ("cannot allocate memory", 2026-10-27). LTO off and two parallel jobs trade some build time (and a few percent of run
# speed) for a build that fits; the optimisation level and panic=unwind (which batch isolation needs) are unchanged.
ENV CARGO_BUILD_JOBS=2 \
CARGO_PROFILE_SERVER_RELEASE_LTO=off \
CARGO_PROFILE_SERVER_RELEASE_CODEGEN_UNITS=8
RUN cargo build --profile server-release -p phase-ai --bin ai-commander --bin ai-duel
# ---- model seats (this Dockerfile only) -------------------------------------------------------------------------------------------
# Everything above is identical to Dockerfile, so the long compile above comes from the build cache. These steps add `ai-commander-llm`: the
# same runner with a hook that lets a language model or a decision model (Laya / Kev) choose for named seats among the engine's OWN legal
# options. Two anchored edits to phase's source (apply_phase_patches.py: the build stops if phase moved either line) and one new crate.
# phase-llm depends on phase-ai, so the hook cannot live in phase-ai's binary; the new crate depends on both and includes the runner.
COPY llm-seat /llm-seat
RUN python3 /llm-seat/apply_phase_patches.py /phase /llm-seat
RUN cargo build --profile server-release -p farmhouse-llm-seat --bin ai-commander-llm
RUN set -eux; \
curl -fsSL --retry 3 -o /card-data.json "$CARD_DATA_URL"; \
echo "$CARD_DATA_SHA256 /card-data.json" | sha256sum -c -; \
cp /phase/client/public/feeds/mtggoldfish-commander.json /sample-feed.json
FROM python:3.12-slim-bookworm
ARG PHASE_TAG=v0.118.0
RUN groupadd -g 10001 phase && useradd -u 10001 -g 10001 -d /tmp -s /usr/sbin/nologin phase
COPY --from=build /phase/target/server-release/ai-commander /opt/phase/ai-commander
COPY --from=build /phase/target/server-release/ai-duel /opt/phase/ai-duel
COPY --from=build /phase/target/server-release/ai-commander-llm /opt/phase/ai-commander-llm
COPY --from=build /card-data.json /opt/phase/data/card-data.json
COPY --from=build /sample-feed.json /opt/phase/sample-feed.json
COPY phase_sim.py phase_smoke.py /opt/phase/
ENV PHASE_ENGINE_VERSION="phase-rs ${PHASE_TAG}" \
PHASE_AI_COMMANDER=/opt/phase/ai-commander \
PHASE_AI_COMMANDER_LLM=/opt/phase/ai-commander-llm \
PHASE_CARD_DATA=/opt/phase/data/card-data.json \
PYTHONUNBUFFERED=1 PORT=8090
USER 10001
WORKDIR /tmp
EXPOSE 8090
CMD ["python", "/opt/phase/phase_sim.py"]
+36
View File
@@ -0,0 +1,36 @@
# phase-sim: rules-accurate Commander games with phase-rs
Reference copies of what runs in the cluster (source of truth: `infrastructure/apps/commander-lab/` in the farmhouse-os repo). Background and the assessment
that led here: `docs/PHASE-AND-PLAYTESTER.md`.
| File | What |
|---|---|
| `phase_sim.py` | the service: stdlib HTTP, one job at a time. `POST /run` four decks -> `GET /job/<id>` per-game results, wins by seat, 95% interval, warnings. Optional model seats and two loopback-only relays (`/llm/v1/chat/completions`, `/jev/systemone`) |
| `Dockerfile` | builds phase-rs `v0.118.0` (`ai-commander`, `ai-duel`) and takes the card database that release was published with (SHA-256 checked) |
| `Dockerfile.llm` | the same plus `ai-commander-llm`: model-driven seats |
| `llm-seat/` | the Rust crate and the two anchored edits to phase's source that make model seats possible |
| `phase_smoke.py`, `test_phase_sim.py` | CI: real games through the service; 49 offline checks against a stub engine |
| `deployment-phase.yaml` | Deployment, Service and a NetworkPolicy (in: the app only; out: DNS, Olla and Laya only) |
The app side is `routes/farmhouse_phase.py` (four saved decks -> the service) and `ui/phase-sim.html`; both are real source in this repository, registered by
`lab_api.py` and linked from the home page.
## Model seats: how they work and why they are safe
phase's headless runner has no model support; the model seats exist only in phase's browser client. `phase-llm` (the crate) deliberately does no networking:
it renders the engine's option list into a prompt, builds the HTTP request, and parses the reply, and the host performs the call. `llm-seat/` is that host
for the headless runner:
1. `apply_phase_patches.py` adds a process-wide `ACTION_OVERRIDE` hook in `phase-ai/src/auto_play.rs` (asked before the built-in AI chooses) and makes the runner's `main` `pub`.
2. The crate `farmhouse-llm-seat` includes the runner's source as a module (`#[path]`) and installs the hook. `phase-llm` depends on `phase-ai`, so the hook cannot
live inside phase-ai's own binary.
3. For each decision on a model seat: the engine issues its option list (`AiDecisionContract`); phase-llm builds the prompt (a forced move with one option is never asked);
this crate makes the HTTP call (blocking, no TLS, 20 s limit); phase-llm turns the reply into one of the engine's options; the choice is checked against the contract
here and again by `run_ai_action`. **Any** problem returns "no opinion" and phase's own AI plays the move. A cap on model calls per run (default 300) bounds the cost.
4. The service supplies the endpoints through its own loopback-only relays: the chat relay switches the model's thinking off (twin-turbo otherwise thinks for tens of
seconds a move) and caps the answer; the decision relay unwraps phase's `Jev` envelope and forwards the System One request to Laya or Kev.
The model never authors an action and never sees hidden cards beyond what phase-llm renders for that seat.
## Status
Written 2026-10-27. The service logic is tested offline; the Rust crate has not compiled outside CI when this was written (no Rust toolchain on the workstation), so
its first CI run is the first real test. Check `.github/workflows/build-farmhouse-phase-sim.yml` results in the farmhouse-os repo.
+111
View File
@@ -0,0 +1,111 @@
# phase-sim (phase/Dockerfile): rules-accurate four-player Commander games over a small HTTP API. Only the app may call it; it has no
# network and no volume (engine and card data are in the image). One game at a time, CPU heavy.
apiVersion: apps/v1
kind: Deployment
metadata:
name: phase-sim
namespace: commander-lab
labels: {app: phase-sim}
spec:
replicas: 1
selector:
matchLabels: {app: phase-sim}
template:
metadata:
labels: {app: phase-sim}
spec:
automountServiceAccountToken: false
# A Service named like the app injects PHASE_SIM_* variables into the pod; nothing here wants them (the same kind of collision
# crash-looped Laya on LAYA_PORT).
enableServiceLinks: false
imagePullSecrets:
- name: ghcr-pull
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
seccompProfile: {type: RuntimeDefault}
containers:
- name: phase-sim
image: ghcr.io/fez5587/farmhouse-phase-sim:v1-llm # -llm = the baseline plus ai-commander-llm (model seats)
ports:
- {name: http, containerPort: 8090}
env:
- {name: PORT, value: "8090"}
- {name: PHASE_SIM_MAX_GAMES, value: "50"}
- {name: PHASE_SIM_TIMEOUT, value: "3600"}
# Model seats. The engine talks to THIS service's loopback-only relays, which forward to these (see phase_sim.py): chat = an
# OpenAI-style endpoint (Olla -> twin-turbo; the relay switches thinking off and caps the answer), decision = a System One server
# (Laya / Kev; the deployment is scaled to 0 until it is fine-tuned, so a decision-seat run answers 502 and falls back to phase's AI).
- {name: PHASE_LLM_CHAT_URL, value: "http://olla.ai-system.svc.cluster.local:40114/olla/openai/v1"}
- {name: PHASE_LLM_DECISION_URL, value: "http://laya.commander-lab.svc.cluster.local:8080/v1/systemone"}
- {name: PHASE_LLM_REASONING, value: "none"}
- {name: PHASE_LLM_MAX_TOKENS_CAP, value: "256"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: [ALL]}
volumeMounts:
- {name: tmp, mountPath: /tmp}
resources:
requests: {cpu: "500m", memory: 1Gi}
limits: {cpu: "3", memory: 3Gi}
readinessProbe:
httpGet: {path: /health, port: http}
periodSeconds: 10
livenessProbe:
httpGet: {path: /health, port: http}
periodSeconds: 30
failureThreshold: 6
volumes:
- name: tmp
emptyDir: {sizeLimit: 512Mi}
---
apiVersion: v1
kind: Service
metadata:
name: phase-sim
namespace: commander-lab
spec:
type: ClusterIP
selector: {app: phase-sim}
ports:
- {name: http, port: 8090, targetPort: http}
---
# IN: only the app. OUT: DNS and the two model endpoints the relays use (Olla for the chat model, Laya for the decision model); nothing else.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: phase-sim
namespace: commander-lab
spec:
podSelector:
matchLabels: {app: phase-sim}
policyTypes: [Ingress, Egress]
ingress:
- from:
- podSelector:
matchLabels: {app: commander-lab}
ports:
- {protocol: TCP, port: 8090}
egress:
- to:
- namespaceSelector:
matchLabels: {kubernetes.io/metadata.name: kube-system}
podSelector:
matchLabels: {k8s-app: kube-dns}
ports:
- {protocol: UDP, port: 53}
- {protocol: TCP, port: 53}
- to:
- namespaceSelector:
matchLabels: {kubernetes.io/metadata.name: ai-system}
podSelector:
matchLabels: {app.kubernetes.io/name: olla}
ports:
- {protocol: TCP, port: 40114}
- to:
- podSelector:
matchLabels: {app: laya}
ports:
- {protocol: TCP, port: 8080}
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "farmhouse-llm-seat"
version.workspace = true
edition = "2021"
license.workspace = true
publish = false
# FarmHouse: phase's headless four-player Commander runner (`ai-commander`) with MODEL-DRIVEN SEATS. The runner's own source is included
# as a module (src/main.rs, #[path]); this crate adds only the network transport phase-llm deliberately leaves to the host. phase-llm
# depends on phase-ai, so the hook cannot live inside phase-ai's own binary: a new crate that depends on both is the way round that.
[[bin]]
name = "ai-commander-llm"
path = "src/main.rs"
[dependencies]
engine = { path = "../engine", package = "phase-engine" }
phase-ai = { path = "../phase-ai" }
phase-llm = { path = "../phase-llm" }
serde = { workspace = true }
serde_json = "1"
rand = "0.9"
tracing = "0.1"
mimalloc = "0.1"
# Blocking HTTP, no TLS: the model endpoints are cluster-internal http:// relays (phase-sim's own /llm and /jev routes).
ureq = { version = "2", default-features = false }
@@ -0,0 +1,74 @@
"""Apply the FarmHouse edits to a phase-rs checkout (run in the Docker build, from the repository root).
python apply_phase_patches.py <phase-checkout> <llm-seat-crate-dir>
1. crates/phase-ai/src/auto_play.rs: a process-wide ACTION_OVERRIDE hook, asked before the built-in AI chooses. It returns Option<GameAction>;
None (the default, and what every failure returns) leaves phase's own choice untouched. The chosen action still goes through the same
contract check (`contract.permits`) as before.
2. crates/phase-ai/src/bin/ai_commander.rs: `fn main` becomes `pub fn main` so the new crate can include and call it.
3. The new crate is copied into crates/farmhouse-llm-seat (the workspace takes every crates/* member).
Every anchor must match exactly once or the build stops, so a phase update that moves a line is reported here, not in a compile error."""
import os
import shutil
import sys
root, crate = sys.argv[1], sys.argv[2]
def edit(rel, edits):
path = os.path.join(root, rel)
text = open(path, encoding="utf-8", newline="").read()
for old, new in edits:
n = text.count(old)
if n != 1:
raise SystemExit("%s: anchor found %d times (need 1): %r" % (rel, n, old[:80]))
text = text.replace(old, new)
open(path, "w", encoding="utf-8", newline="").write(text)
print("patched", rel)
HOOK = '''use crate::search::choose_action_with_session;
/// FarmHouse: an optional process-wide override, asked BEFORE the built-in AI chooses for a seat. A host (see crates/farmhouse-llm-seat)
/// installs one to let a model pick among the engine's own options. It returns None to leave the choice to the built-in AI, which is also
/// what it must return on any failure; whatever it returns still has to pass the same decision-contract check below.
pub type ActionOverride = Box<
dyn Fn(
&engine::types::game_state::GameState,
&engine::ai_support::AiDecisionContract,
engine::types::player::PlayerId,
engine::types::player::PlayerId,
) -> Option<engine::types::actions::GameAction>
+ Send
+ Sync,
>;
static ACTION_OVERRIDE: std::sync::OnceLock<ActionOverride> = std::sync::OnceLock::new();
/// Install the override. Returns false if one is already installed (it can be set once per process).
pub fn install_action_override(hook: ActionOverride) -> bool {
ACTION_OVERRIDE.set(hook).is_ok()
}
'''
edit("crates/phase-ai/src/auto_play.rs", [
("use crate::search::choose_action_with_session;\n", HOOK),
(" let action = match choose_action_with_session(state, semantic_owner, config, rng, session) {\n Some(action) => action,\n None => {\n tracing::warn!(player = ?actor, \"choose_action returned None",
" // The override's pick must be one the engine will really APPLY, not only one it lists: a model chose both modes of a modal spell whose\n"
" // entwine cost it could not pay, the contract listed the option, apply refused it, and the whole game stalled (2026-10-27). Try it on a\n"
" // copy of the game first; anything the engine refuses is dropped and the built-in AI plays the move. (Stack-pass rechecks have their\n"
" // own verified path and are left alone.)\n"
" let farmhouse_choice = ACTION_OVERRIDE\n .get()\n .and_then(|hook| hook(&*state, &contract, semantic_owner, actor))\n"
" .filter(|candidate| {\n"
" verified_ai_stack_pass_player(&*state, candidate).is_some()\n"
" || apply_interaction(&mut (*state).clone(), actor, semantic_owner, candidate.clone()).is_ok()\n"
" });\n"
" let action = match farmhouse_choice\n .or_else(|| choose_action_with_session(state, semantic_owner, config, rng, session))\n {\n Some(action) => action,\n None => {\n tracing::warn!(player = ?actor, \"choose_action returned None"),
])
edit("crates/phase-ai/src/bin/ai_commander.rs", [("\nfn main() {\n", "\npub fn main() {\n")])
dest = os.path.join(root, "crates", "farmhouse-llm-seat")
os.makedirs(os.path.join(dest, "src"), exist_ok=True)
shutil.copy(os.path.join(crate, "Cargo.toml"), os.path.join(dest, "Cargo.toml"))
shutil.copy(os.path.join(crate, "src", "main.rs"), os.path.join(dest, "src", "main.rs"))
print("added crates/farmhouse-llm-seat")
+237
View File
@@ -0,0 +1,237 @@
//! ai-commander with model-driven seats (FarmHouse).
//!
//! phase's headless runner plays four-player Commander games with the built-in AI on every seat. This binary is that same runner (its
//! source is included below, unchanged but for `fn main` being `pub`) plus a hook, installed before the games start, that asks a
//! language model or a decision model (Laya / Kev, over the System One protocol) to choose for the seats named in `PHASE_LLM_SEATS`.
//!
//! The model never authors an action. For each decision the engine issues its own option list (`AiDecisionContract`), phase-llm renders
//! it and builds the HTTP request, this file performs the HTTP call (the one thing phase-llm leaves to the host), and phase-llm turns the
//! reply into one of the engine's options. The pick is then checked against the contract a second time here, and again by
//! `run_ai_action`. ANY problem (forced move, timeout, an unreadable or stale answer, the call cap) returns `None` and phase's own AI
//! plays the move, so a model that is down, slow or wrong cannot stall or corrupt a game.
//!
//! Settings (environment): PHASE_LLM_SEATS "1,3" (seats 0-3; empty = none, the plain runner), PHASE_LLM_PROVIDER (OpenAiCompatible | Jev),
//! PHASE_LLM_BASE_URL, PHASE_LLM_MODEL, PHASE_LLM_API_KEY (leave empty for http:// endpoints), PHASE_LLM_MAX_TOKENS (256),
//! PHASE_LLM_TEMPERATURE (0.2), PHASE_LLM_TIMEOUT_S (20), PHASE_LLM_MAX_CALLS (total model calls for the whole process, default 300),
//! PHASE_LLM_DIFFICULTY (Medium: the strength hint in the prompt), PHASE_LLM_MAX_FAILS (4: model failures IN A ROW before every model seat
//! switches itself off for the rest of the process).
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use engine::ai_support::AiDecisionContract;
use engine::database::CardDatabase;
use engine::types::actions::GameAction;
use engine::types::game_state::GameState;
use engine::types::player::PlayerId;
use phase_ai::auto_play::install_action_override;
use phase_ai::config::AiDifficulty;
use phase_llm::{LlmEndpointConfig, LlmProvider, LlmReply};
// The unchanged runner, with `fn main` made `pub` by apply_phase_patches.py.
#[path = "../../phase-ai/src/bin/ai_commander.rs"]
#[allow(dead_code, unused_imports, unused_variables)]
mod commander;
struct Seats {
seats: HashSet<u8>,
endpoint: LlmEndpointConfig,
difficulty: AiDifficulty,
agent: ureq::Agent,
cards_path: PathBuf,
db: OnceLock<Option<CardDatabase>>,
max_calls: u64,
asked: [AtomicU64; 4],
used: [AtomicU64; 4],
fell_back: [AtomicU64; 4],
total: AtomicU64,
// Cost visibility and the circuit-breaker. A late-game prompt is thousands of tokens and a big-model server needs longer than the
// per-call limit to read it; every such call used to burn the whole limit (a 9-minute game, 25 questions, 1 answer used). After
// `max_fails` failures IN A ROW the model seats switch themselves off for the rest of the process and phase's AI plays on.
timeouts: AtomicU64,
consecutive_fails: AtomicU64,
max_fails: u64,
disabled: AtomicBool,
millis: AtomicU64,
answered: AtomicU64,
prompt_chars: AtomicU64,
}
impl Seats {
fn database(&self) -> Option<&CardDatabase> {
self.db
.get_or_init(|| CardDatabase::from_export(&self.cards_path.join("card-data.json")).ok())
.as_ref()
}
/// The model's pick for `actor`, or None to let phase's AI play. Never panics on a bad answer.
/// One failed model call (timeout, error status, unusable answer). Returns None so callers can `return self.failed(..)`.
fn failed(&self, timeout: bool) -> Option<GameAction> {
if timeout {
self.timeouts.fetch_add(1, Ordering::Relaxed);
}
let in_a_row = self.consecutive_fails.fetch_add(1, Ordering::Relaxed) + 1;
if in_a_row >= self.max_fails && !self.disabled.swap(true, Ordering::Relaxed) {
println!(
"LLM-SEAT-DISABLED after {in_a_row} consecutive failures ({} timeouts of {} questions): phase's own AI plays the rest",
self.timeouts.load(Ordering::Relaxed),
self.total.load(Ordering::Relaxed)
);
}
None
}
fn decide(&self, state: &GameState, contract: &AiDecisionContract, actor: PlayerId) -> Option<GameAction> {
if self.disabled.load(Ordering::Relaxed) {
return None;
}
let seat = (actor.0 as usize).min(3);
// A forced move (one legal option) is never put to a model: build_game_decision_prompt refuses it.
let request = phase_llm::build_game_decision_prompt(
state,
contract,
self.difficulty,
self.database(),
&Vec::new(),
)
.ok()?;
if self.total.fetch_add(1, Ordering::Relaxed) >= self.max_calls {
return None; // the call cap: every later decision is phase's AI
}
self.asked[seat].fetch_add(1, Ordering::Relaxed);
self.prompt_chars.fetch_add(request.prompt.char_count() as u64, Ordering::Relaxed);
let http = phase_llm::build_chat_request(&self.endpoint, &request.prompt).ok()?;
let mut call = self.agent.request(http.method, &http.url);
for header in &http.headers {
call = call.set(&header.name, &header.value);
}
let started = Instant::now();
let (status, body) = match call.send_string(&http.body) {
Ok(response) => {
let status = response.status();
match response.into_string() {
Ok(text) => (status, text),
Err(_) => return self.failed(true),
}
}
// A 4xx/5xx still carries a body phase-llm knows how to classify.
Err(ureq::Error::Status(code, response)) => (code, response.into_string().unwrap_or_default()),
Err(_) => return self.failed(true), // transport error: a timeout, or the endpoint is down
};
self.millis.fetch_add(started.elapsed().as_millis() as u64, Ordering::Relaxed);
self.answered.fetch_add(1, Ordering::Relaxed);
let selection = match phase_llm::select_action_from_response(
state,
contract,
&request.fingerprint,
LlmReply { provider: self.endpoint.provider, status, body: &body },
) {
Ok(selection) => selection,
Err(_) => return self.failed(false),
};
if !contract.contains_action(state, &selection.action) {
return self.failed(false);
}
self.consecutive_fails.store(0, Ordering::Relaxed);
self.used[seat].fetch_add(1, Ordering::Relaxed);
Some(selection.action)
}
fn report(&self, seat: usize) {
let asked = self.asked[seat].load(Ordering::Relaxed);
if asked > 0 && asked % 25 == 0 {
let answered = self.answered.load(Ordering::Relaxed).max(1);
let questions = self.total.load(Ordering::Relaxed).max(1);
println!(
"LLM-SEAT seat={seat} asked={asked} used={} total_calls={} timeouts={} avg_ms={} avg_prompt_chars={}",
self.used[seat].load(Ordering::Relaxed),
self.total.load(Ordering::Relaxed),
self.timeouts.load(Ordering::Relaxed),
self.millis.load(Ordering::Relaxed) / answered,
self.prompt_chars.load(Ordering::Relaxed) / questions
);
}
}
}
fn env_or(name: &str, default: &str) -> String {
std::env::var(name).ok().filter(|v| !v.trim().is_empty()).unwrap_or_else(|| default.to_string())
}
fn install_model_seats() {
let seats: HashSet<u8> = env_or("PHASE_LLM_SEATS", "")
.split(',')
.filter_map(|s| s.trim().parse::<u8>().ok())
.filter(|s| *s < 4)
.collect();
if seats.is_empty() {
println!("LLM seats: none (plain ai-commander)");
return;
}
let provider = LlmProvider::from_label(&env_or("PHASE_LLM_PROVIDER", "OpenAiCompatible"));
let endpoint = LlmEndpointConfig {
provider,
base_url: std::env::var("PHASE_LLM_BASE_URL").ok().filter(|v| !v.trim().is_empty()),
api_key: env_or("PHASE_LLM_API_KEY", ""),
model: env_or("PHASE_LLM_MODEL", "model"),
max_output_tokens: env_or("PHASE_LLM_MAX_TOKENS", "256").parse().ok(),
temperature: env_or("PHASE_LLM_TEMPERATURE", "0.2").parse().ok(),
};
let timeout = env_or("PHASE_LLM_TIMEOUT_S", "20").parse::<u64>().unwrap_or(20);
// The runner's first positional argument is the data folder (card-data.json lives there).
let cards_path = PathBuf::from(std::env::args().nth(1).unwrap_or_else(|| ".".to_string()));
let state = Seats {
seats: seats.clone(),
endpoint,
difficulty: AiDifficulty::from_label(&env_or("PHASE_LLM_DIFFICULTY", "Medium")),
agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(timeout)).redirects(0).build(),
cards_path,
db: OnceLock::new(),
max_calls: env_or("PHASE_LLM_MAX_CALLS", "300").parse().unwrap_or(300),
asked: Default::default(),
used: Default::default(),
fell_back: Default::default(),
total: AtomicU64::new(0),
timeouts: AtomicU64::new(0),
consecutive_fails: AtomicU64::new(0),
max_fails: env_or("PHASE_LLM_MAX_FAILS", "4").parse().unwrap_or(4),
disabled: AtomicBool::new(false),
millis: AtomicU64::new(0),
answered: AtomicU64::new(0),
prompt_chars: AtomicU64::new(0),
};
println!(
"LLM seats: {:?} provider={} model={} max_calls={}",
{
let mut v: Vec<u8> = seats.iter().copied().collect();
v.sort();
v
},
provider.label(),
state.endpoint.model,
state.max_calls
);
let installed = install_action_override(Box::new(move |game, contract, _owner, actor| {
if !state.seats.contains(&actor.0) {
return None;
}
let seat = (actor.0 as usize).min(3);
let pick = state.decide(game, contract, actor);
if pick.is_none() {
state.fell_back[seat].fetch_add(1, Ordering::Relaxed);
}
state.report(seat);
pick
}));
if !installed {
eprintln!("LLM seats: an action override was already installed; model seats are off");
}
}
fn main() {
install_model_seats();
commander::main();
}
+501
View File
@@ -0,0 +1,501 @@
"""phase-sim: run rules-accurate four-player Commander games with phase-rs's headless `ai-commander` and report what happened.
Standard library only. One job runs at a time (a game is CPU-heavy); more wait in a queue. The service never touches the network.
GET /health {"ok": true, "engine": ..., "queued": n, "running": id|null}
POST /run {"decks": [4 x {"name", "commander": ["Card"], "main": [{"name","count"}, ...]}],
"games": 1..MAX_GAMES, "seed": int, "difficulty": "Medium", "seat_difficulty": {"2": "Hard"}, "action_cap": int}
-> {"job": id}
GET /job/<id> status queued|running|done|error, games finished, and when done: per-game rows, wins by seat and by deck,
win rate with a 95% interval, the cards the engine could not resolve, and the tail of the engine's output
Seat i is the i-th deck in the request (P0 = first). The engine treats every seat as AI; difficulty is per seat if given.
A game that stalls, hits the action cap, or panics is reported as such and is NEVER counted as a win or a draw.
Model seats (optional, needs the image's ai-commander-llm binary): "llm_seats": [1, 3] and "llm": {"kind": "chat" | "decision", "model": "...",
"max_calls": 300}. Those seats ask a model to pick among the ENGINE'S OWN legal options (phase-llm builds the prompt and checks the answer;
anything wrong or slow falls back to phase's AI). kind "chat" goes to PHASE_LLM_CHAT_URL (an OpenAI-style endpoint, e.g. Olla) through this
service's /llm relay, which switches the model's thinking off and caps its answer; kind "decision" goes to PHASE_LLM_DECISION_URL (a System One
server: Laya or Kev) through /jev/systemone. Both relays accept connections from this machine only.
"""
import json
import math
import os
import re
import subprocess
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.request
import uuid
from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
BIN = os.environ.get("PHASE_AI_COMMANDER", "/opt/phase/ai-commander")
CARD_DATA = os.environ.get("PHASE_CARD_DATA", "/opt/phase/data/card-data.json")
ENGINE = os.environ.get("PHASE_ENGINE_VERSION", "phase-rs (version unknown)")
MAX_GAMES = int(os.environ.get("PHASE_SIM_MAX_GAMES", "50"))
JOB_TIMEOUT = int(os.environ.get("PHASE_SIM_TIMEOUT", "3600"))
PORT = int(os.environ.get("PORT", "8090"))
DIFFICULTIES = ("VeryEasy", "Easy", "Medium", "Hard", "VeryHard")
BIN_LLM = os.environ.get("PHASE_AI_COMMANDER_LLM", "/opt/phase/ai-commander-llm")
CHAT_URL = os.environ.get("PHASE_LLM_CHAT_URL", "").rstrip("/") # e.g. http://olla...:40114/olla/openai/v1
DECISION_URL = os.environ.get("PHASE_LLM_DECISION_URL", "") # e.g. http://laya...:8080/v1/systemone
DECISION_KEY = os.environ.get("PHASE_LLM_DECISION_KEY", "")
CHAT_REASONING = os.environ.get("PHASE_LLM_REASONING", "none")
CHAT_MAX_TOKENS = int(os.environ.get("PHASE_LLM_MAX_TOKENS_CAP", "256"))
MAX_LLM_CALLS = int(os.environ.get("PHASE_LLM_MAX_CALLS", "600"))
LOCAL_ONLY = ("127.0.0.1", "::1")
# A late-game prompt is thousands of tokens; reading it takes a 27B model on a V100 20-30 s. The engine waits MODEL_TIMEOUT per question and the
# relay a little less, and the engine switches the model seats off after PHASE_LLM_MAX_FAILS failures in a row, so a dead model costs a few minutes at most.
RELAY_TIMEOUT = float(os.environ.get("PHASE_LLM_RELAY_TIMEOUT", "55"))
MODEL_TIMEOUT = os.environ.get("PHASE_LLM_TIMEOUT_S", "60")
MAX_DECK_ENTRIES = 400
_jobs = {}
_queue = deque()
_cv = threading.Condition()
_running = {"id": None}
# ---------- request -> engine inputs ------------------------------------------------------------------------------------------
class BadRequest(ValueError):
pass
def validate(req):
decks = req.get("decks")
if not isinstance(decks, list) or len(decks) != 4:
raise BadRequest("decks must be a list of exactly 4 decks (one per seat)")
feed = []
for i, d in enumerate(decks):
if not isinstance(d, dict) or not str(d.get("name") or "").strip():
raise BadRequest("deck %d needs a name" % i)
cmd = d.get("commander")
if isinstance(cmd, str):
cmd = [cmd]
if not isinstance(cmd, list) or not cmd or not all(isinstance(c, str) and c.strip() for c in cmd):
raise BadRequest("deck %d needs commander: [\"Card Name\"]" % i)
main = d.get("main")
if main is None and isinstance(d.get("cards"), list):
counts = {}
for n in d["cards"]:
counts[str(n)] = counts.get(str(n), 0) + 1
main = [{"name": k, "count": v} for k, v in counts.items()]
if not isinstance(main, list) or not main or len(main) > MAX_DECK_ENTRIES:
raise BadRequest("deck %d needs a non-empty main list (at most %d entries)" % (i, MAX_DECK_ENTRIES))
rows = []
for e in main:
try:
name, count = str(e["name"]), int(e["count"])
except (KeyError, TypeError, ValueError):
raise BadRequest("deck %d has a bad main entry: %r" % (i, e))
if not name.strip() or not 1 <= count <= 99:
raise BadRequest("deck %d has a bad main entry: %r" % (i, e))
rows.append({"name": name, "count": count})
feed.append({"name": str(d["name"]), "commander": [c.strip() for c in cmd], "main": rows})
try:
games = int(req.get("games", 1))
seed = int(req.get("seed", 1))
except (TypeError, ValueError):
raise BadRequest("games and seed must be integers")
if not 1 <= games <= MAX_GAMES:
raise BadRequest("games must be 1 to %d" % MAX_GAMES)
difficulty = req.get("difficulty", "Medium")
if difficulty not in DIFFICULTIES:
raise BadRequest("difficulty must be one of %s" % ", ".join(DIFFICULTIES))
seat_diff = {}
for k, v in (req.get("seat_difficulty") or {}).items():
if str(k) not in ("0", "1", "2", "3") or v not in DIFFICULTIES:
raise BadRequest("seat_difficulty keys are seats 0-3, values one of %s" % ", ".join(DIFFICULTIES))
seat_diff[int(k)] = v
cap = req.get("action_cap")
if cap is not None:
try:
cap = int(cap)
except (TypeError, ValueError):
raise BadRequest("action_cap must be an integer")
if not 100 <= cap <= 200000:
raise BadRequest("action_cap must be 100 to 200000")
llm = None
seats = req.get("llm_seats") or []
if seats:
if not isinstance(seats, list) or not all(isinstance(x, int) and 0 <= x <= 3 for x in seats):
raise BadRequest("llm_seats must be a list of seats 0-3")
cfg = req.get("llm") or {}
kind = cfg.get("kind", "chat")
if kind not in ("chat", "decision"):
raise BadRequest("llm.kind must be chat or decision")
if (kind == "chat" and not CHAT_URL) or (kind == "decision" and not DECISION_URL):
raise BadRequest("this service has no %s model endpoint configured" % kind)
if not os.path.isfile(BIN_LLM) and not os.environ.get("PHASE_SIM_ALLOW_MISSING_LLM_BIN"):
raise BadRequest("this image has no model-seat engine (ai-commander-llm)")
model = str(cfg.get("model") or "").strip()
if kind == "chat" and not model:
raise BadRequest("llm.model is required for a chat model")
try:
max_calls = int(cfg.get("max_calls", 300))
except (TypeError, ValueError):
raise BadRequest("llm.max_calls must be an integer")
if not 1 <= max_calls <= MAX_LLM_CALLS:
raise BadRequest("llm.max_calls must be 1 to %d" % MAX_LLM_CALLS)
llm = {"seats": sorted(set(seats)), "kind": kind, "model": model or "decision-model", "max_calls": max_calls}
return {"feed": feed, "games": games, "seed": seed, "difficulty": difficulty, "seat_difficulty": seat_diff, "action_cap": cap, "llm": llm}
def engine_env(spec):
"""The environment for the engine process: model-seat settings, or None (inherit) for the plain runner."""
llm = spec.get("llm")
if not llm:
return None
env = dict(os.environ)
local = "http://127.0.0.1:%d" % PORT
env.update(PHASE_LLM_SEATS=",".join(str(x) for x in llm["seats"]), PHASE_LLM_MODEL=llm["model"], PHASE_LLM_API_KEY="",
PHASE_LLM_MAX_CALLS=str(llm["max_calls"]), PHASE_LLM_DIFFICULTY=spec["difficulty"], PHASE_LLM_TIMEOUT_S=MODEL_TIMEOUT)
if llm["kind"] == "chat":
env.update(PHASE_LLM_PROVIDER="OpenAiCompatible", PHASE_LLM_BASE_URL=local + "/llm/v1")
else:
env.update(PHASE_LLM_PROVIDER="Jev", PHASE_LLM_BASE_URL=local)
return env
def command(spec, root):
cmd = [BIN_LLM if spec.get("llm") else BIN, root, "--feed", "feed.json", "--games-file", os.path.join(root, "games.txt")]
for seat, level in sorted(spec["seat_difficulty"].items()):
cmd += ["--difficulty-p%d" % seat, level]
if spec["action_cap"]:
cmd += ["--action-cap", str(spec["action_cap"])]
return cmd
# ---------- engine output -> results ------------------------------------------------------------------------------------------
GAME_HEAD = re.compile(r"^--- GAME\b(.*)$")
WINNER = re.compile(r"Game ended cleanly\. Winner: (draw|P(\d))")
TURNS = re.compile(r"^Turns played: (\d+)")
ELIM = re.compile(r"^ELIMINATED: P(\d)\s+turn=(\d+)")
RESOLVED = re.compile(r"^\s+P(\d)\s+main=\s*(\d+)\s+commander=(\d+)")
LLM_STAT = re.compile(r"^LLM-SEAT seat=(\d) asked=(\d+) used=(\d+)(?: total_calls=(\d+))?(?: timeouts=(\d+))?(?: avg_ms=(\d+))?(?: avg_prompt_chars=(\d+))?")
LLM_OFF = re.compile(r"^LLM-SEAT-DISABLED after (\d+) consecutive failures")
SKIP = re.compile(r"^\s+SKIP (.+?): commander '(.+)' not in card db")
PRE = re.compile(r"^\s+(.+?)\s+\|\s+commander: (.+?)\s+\|\s+main: (\d+) cards")
def parse(output):
"""Per-game rows and the deck-resolution facts from the engine's stdout. Games that did not finish are kept, labelled, never counted."""
lines = output.splitlines()
pre, resolved, skipped, games, cur = [], {}, [], [], None
def finish():
if cur is not None:
games.append(cur)
def new_game(label=""):
return {"label": label, "status": "unfinished", "winner_seat": None, "turns": None, "eliminated": []}
result_line = re.compile(r"Game ended cleanly|^Turns played|^ELIMINATED:|^STALL:|ABORT: hit action cap|PANICKED")
for ln in lines:
if cur is None and not games: # the preamble: decks listed, then their resolved sizes
m = PRE.match(ln)
if m:
pre.append({"deck": m.group(1), "commander": m.group(2), "listed": int(m.group(3))})
continue
m = SKIP.match(ln)
if m:
skipped.append({"deck": m.group(1), "commander": m.group(2)})
continue
m = RESOLVED.match(ln)
if m and int(m.group(1)) not in resolved:
resolved[int(m.group(1))] = {"main": int(m.group(2)), "commanders": int(m.group(3))}
continue
if GAME_HEAD.match(ln):
finish()
cur = new_game(GAME_HEAD.match(ln).group(1).strip(" -"))
continue
if cur is None:
if not result_line.search(ln):
continue # banner and preamble lines are not a game
cur = new_game() # a single-game run prints no "--- GAME" header
m = WINNER.search(ln)
if m:
cur["status"] = "draw" if m.group(1) == "draw" else "won"
cur["winner_seat"] = None if m.group(2) is None else int(m.group(2))
continue
m = TURNS.match(ln)
if m:
cur["turns"] = int(m.group(1))
continue
m = ELIM.match(ln)
if m:
cur["eliminated"].append({"seat": int(m.group(1)), "turn": int(m.group(2))})
continue
if ln.startswith("STALL:"):
cur["status"] = "stalled"
cur.setdefault("detail", ln)
elif "ABORT: hit action cap" in ln:
cur["status"] = "action_cap"
elif re.search(r"GAME .* PANICKED", ln):
cur["status"] = "panicked"
cur["detail"] = ln[:300]
finish()
games = [g for g in games if g["status"] != "unfinished" or g["turns"] is not None or g["eliminated"] or g["label"]]
return {"pre": pre, "resolved": resolved, "skipped": skipped, "games": games}
def llm_summary(llm, lines):
"""The model seats' own counters (the engine prints one every 25th question), last value per seat, plus the breaker and the cost."""
per_seat, extra, disabled = {}, {}, None
for ln in lines:
m = LLM_STAT.match(ln)
if m:
per_seat[int(m.group(1))] = {"asked": int(m.group(2)), "used": int(m.group(3))}
for key, grp in (("timeouts", 5), ("avg_ms", 6), ("avg_prompt_chars", 7)):
if m.group(grp) is not None:
extra[key] = int(m.group(grp))
continue
m = LLM_OFF.match(ln)
if m:
disabled = int(m.group(1))
out = {"seats": llm["seats"], "kind": llm["kind"], "model": llm["model"], "max_calls": llm["max_calls"], "per_seat": per_seat,
"note": "counters are printed every 25th question, so the last few may be missing"}
out.update(extra)
if disabled is not None:
out["switched_off_after_failures_in_a_row"] = disabled
return out
def wilson(wins, n, z=1.96):
if n == 0:
return [0.0, 0.0]
p = wins / n
d = 1 + z * z / n
c = p + z * z / (2 * n)
h = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
return [round(max(0.0, (c - h) / d), 4), round(min(1.0, (c + h) / d), 4)]
def summarize(spec, parsed, seeds):
decks = [d["name"] for d in spec["feed"]]
finished = [g for g in parsed["games"] if g["status"] in ("won", "draw")]
won = [g for g in finished if g["status"] == "won"]
by_seat = [sum(1 for g in won if g["winner_seat"] == i) for i in range(4)]
n = len(finished)
warnings = []
for s in parsed["skipped"]:
warnings.append("deck %r was skipped: commander %r is not in the engine's card database" % (s["deck"], s["commander"]))
for i, pre in enumerate(parsed["pre"][:4]):
got = (parsed["resolved"].get(i) or {}).get("main")
if got is not None and got < pre["listed"]:
warnings.append("seat %d (%s): %d of %d cards were not recognised by the engine and were left out" % (
i, pre["deck"], pre["listed"] - got, pre["listed"]))
bad = [g for g in parsed["games"] if g["status"] not in ("won", "draw")]
if bad:
warnings.append("%d game(s) did not finish (%s): not counted as wins or draws" % (
len(bad), ", ".join(sorted({g["status"] for g in bad}))))
for i, g in enumerate(parsed["games"]):
if i < len(seeds):
g["seed"] = seeds[i]
return {"games_requested": spec["games"], "games_finished": n, "games_not_finished": len(bad), "draws": n - len(won),
"wins_by_seat": by_seat,
"decks": [{"seat": i, "name": decks[i], "wins": by_seat[i], "win_rate": round(by_seat[i] / n, 4) if n else None,
"win_rate_95": wilson(by_seat[i], n)} for i in range(4)],
"avg_turns": round(sum(g["turns"] or 0 for g in finished) / n, 1) if n else None,
"warnings": warnings, "games": parsed["games"]}
# ---------- jobs ----------------------------------------------------------------------------------------------------------------
def run_job(job):
spec = job["spec"]
root = tempfile.mkdtemp(prefix="phase-sim-")
out_lines = []
try:
os.symlink(CARD_DATA, os.path.join(root, "card-data.json"))
with open(os.path.join(root, "feed.json"), "w", encoding="utf-8") as f:
json.dump({"decks": spec["feed"]}, f)
seeds = [spec["seed"] + i for i in range(spec["games"])]
with open(os.path.join(root, "games.txt"), "w", encoding="utf-8") as f:
f.write("".join("%d,%s\n" % (s, spec["difficulty"]) for s in seeds))
job["status"], job["started"] = "running", time.time()
proc = subprocess.Popen(command(spec, root), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors="replace", cwd=root,
env=engine_env(spec))
timer = threading.Timer(JOB_TIMEOUT, proc.kill)
timer.start()
try:
for ln in proc.stdout:
out_lines.append(ln.rstrip("\n"))
if GAME_HEAD.match(ln):
job["games_started"] += 1
if WINNER.search(ln) or "GAME" in ln and "PANICKED" in ln or ln.startswith("STALL:") or "ABORT: hit action cap" in ln:
job["games_done"] += 1
code = proc.wait()
finally:
timer.cancel()
text = "\n".join(out_lines)
parsed = parse(text)
job["result"] = summarize(spec, parsed, seeds)
if spec.get("llm"):
job["result"]["llm"] = llm_summary(spec["llm"], out_lines)
if "switched_off_after_failures_in_a_row" in job["result"]["llm"]:
job["result"]["warnings"].append(
"the model seats switched themselves off after %d failures in a row (usually timeouts: late-game prompts are thousands of tokens "
"and the model server could not read them in time); phase's own AI played the rest of the run, so the later moves are NOT the model's"
% job["result"]["llm"]["switched_off_after_failures_in_a_row"])
job["result"]["warnings"].append(
"model seats %s: the model is asked about each non-forced decision, up to %d calls for the whole run; an answer the engine "
"cannot use, a timeout, or any decision after the cap is played by phase's own AI instead" % (spec["llm"]["seats"], spec["llm"]["max_calls"]))
job["engine_exit_code"] = code
job["output_tail"] = out_lines[-40:]
job["status"] = "done" if parsed["games"] else "error"
if not parsed["games"]:
job["error"] = "the engine produced no game results (exit code %s); see output_tail" % code
except Exception as exc: # the job fails, the service does not
job["status"], job["error"] = "error", "%s: %s" % (type(exc).__name__, exc)
job["output_tail"] = out_lines[-40:]
finally:
job["finished"] = time.time()
try:
for name in os.listdir(root):
os.unlink(os.path.join(root, name))
os.rmdir(root)
except OSError:
pass
def worker():
while True:
with _cv:
while not _queue:
_cv.wait()
job = _queue.popleft()
_running["id"] = job["id"]
run_job(job)
with _cv:
_running["id"] = None
def submit(req):
spec = validate(req)
job = {"id": uuid.uuid4().hex[:12], "status": "queued", "created": time.time(), "spec": spec, "games_started": 0, "games_done": 0}
with _cv:
_jobs[job["id"]] = job
_queue.append(job)
for old in sorted(_jobs.values(), key=lambda j: j["created"])[:-50]: # remember the last 50
if old["status"] in ("done", "error"):
_jobs.pop(old["id"], None)
_cv.notify()
return job["id"]
def public(job):
out = {k: job[k] for k in ("id", "status", "games_started", "games_done") if k in job}
out["games_requested"] = job["spec"]["games"]
for k in ("result", "error", "output_tail", "engine_exit_code"):
if k in job:
out[k] = job[k]
if "started" in job:
out["seconds"] = round((job.get("finished") or time.time()) - job["started"], 1)
return out
def forward(url, data, headers):
req = urllib.request.Request(url, data=data, headers=dict({"content-type": "application/json"}, **headers))
try:
with urllib.request.urlopen(req, timeout=RELAY_TIMEOUT) as r:
return r.status, json.loads(r.read() or b"{}")
except urllib.error.HTTPError as exc:
try:
return exc.code, json.loads(exc.read() or b"{}")
except ValueError:
return exc.code, {"error": "upstream error"}
except (urllib.error.URLError, OSError, ValueError):
return 502, {"error": "the model endpoint is not reachable"}
def relay_chat(body, remote):
"""POST /llm/v1/chat/completions -> the OpenAI-style endpoint, with the model's thinking off and its answer capped."""
if remote not in LOCAL_ONLY or not CHAT_URL:
return 403, {"error": "not available"}
try:
req = json.loads(body)
req["max_tokens"] = min(int(req.get("max_tokens") or CHAT_MAX_TOKENS), CHAT_MAX_TOKENS)
req["stream"] = False
if CHAT_REASONING:
req["reasoning_effort"] = CHAT_REASONING
data = json.dumps(req).encode()
except (ValueError, TypeError):
return 400, {"error": "bad chat request"}
return forward(CHAT_URL + "/chat/completions", data, {})
def relay_decision(body, remote):
"""POST /jev/systemone: phase's Jev envelope {"apiKey", "request": {...}} -> a System One server (Laya / Kev); the key comes from the environment."""
if remote not in LOCAL_ONLY or not DECISION_URL:
return 403, {"error": "not available"}
try:
data = json.dumps(json.loads(body)["request"]).encode()
except (ValueError, KeyError, TypeError):
return 400, {"error": "bad decision request"}
return forward(DECISION_URL, data, {"authorization": "Bearer " + DECISION_KEY} if DECISION_KEY else {})
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "phase-sim/1"
def log_message(self, fmt, *args):
if os.environ.get("PHASE_SIM_LOG") == "1":
sys.stderr.write("%s\n" % (fmt % args))
def _send(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/health":
with _cv:
return self._send(200, {"ok": os.path.isfile(BIN) and os.path.isfile(CARD_DATA), "engine": ENGINE,
"queued": len(_queue), "running": _running["id"], "max_games": MAX_GAMES})
m = re.match(r"^/job/([0-9a-f]+)$", self.path)
if m:
with _cv:
job = _jobs.get(m.group(1))
return self._send(200, public(job)) if job else self._send(404, {"error": "no such job"})
self._send(404, {"error": "not found"})
def do_POST(self):
if self.path in ("/llm/v1/chat/completions", "/jev/systemone"):
n = int(self.headers.get("Content-Length") or 0)
if n > 4_000_000:
return self._send(413, {"error": "request too large"})
fn = relay_chat if self.path.startswith("/llm") else relay_decision
code, obj = fn(self.rfile.read(n), self.client_address[0])
return self._send(code, obj)
if self.path != "/run":
return self._send(404, {"error": "not found"})
try:
n = int(self.headers.get("Content-Length") or 0)
if n > 2_000_000:
return self._send(413, {"error": "request too large"})
req = json.loads(self.rfile.read(n) or b"{}")
if not isinstance(req, dict):
raise BadRequest("the body must be a JSON object")
self._send(202, {"job": submit(req)})
except (BadRequest, json.JSONDecodeError) as exc:
self._send(400, {"error": str(exc)})
def main():
threading.Thread(target=worker, daemon=True).start()
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
if __name__ == "__main__":
main()
+108
View File
@@ -0,0 +1,108 @@
"""Smoke test, run INSIDE the image (CI and by hand): four real decks from phase's own sample feed play real games through the service.
python /opt/phase/phase_smoke.py [games] [difficulty] plain engine
python /opt/phase/phase_smoke.py --llm model seats against a stub model inside this process
Exits non-zero unless at least one game finished with a winner or a draw. Prints what the engine reported, including any cards it did
not recognise, so a bad data/engine pairing shows up here and not in a result someone trusts.
--llm starts a stub OpenAI-style model on 127.0.0.1:9099 (the service must have been started with PHASE_LLM_CHAT_URL=http://127.0.0.1:9099/v1),
puts seats 1 and 3 on it, and passes when the engine's own counters show the model was asked AND its answers were used by the engine: that
proves the Rust hook, the relay and phase's answer check end to end without any outside service. The stub always answers option 0, so the
game itself may not finish (it can hit the action cap); that is not what is being tested.
"""
import json
import sys
import threading
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
BASE = "http://127.0.0.1:8090"
LLM = "--llm" in sys.argv
args = [a for a in sys.argv[1:] if not a.startswith("--")]
games = int(args[0]) if args else (1 if LLM else 2)
difficulty = args[1] if len(args) > 1 else "VeryEasy"
ASKED = []
class StubModel(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)))
ASKED.append(body)
out = json.dumps({"choices": [{"index": 0, "message": {"role": "assistant", "content": "{\"choice\": 0, \"reason\": \"stub\"}"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}).encode()
self.send_response(200)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(out)))
self.end_headers()
self.wfile.write(out)
if LLM:
threading.Thread(target=HTTPServer(("127.0.0.1", 9099), StubModel).serve_forever, daemon=True).start()
def call(path, body=None):
req = urllib.request.Request(BASE + path, data=None if body is None else json.dumps(body).encode(), headers={"content-type": "application/json"})
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r)
for _ in range(60):
try:
h = call("/health")
break
except Exception:
time.sleep(1)
else:
sys.exit("the service did not come up")
print("health:", h)
assert h["ok"], "binary or card data missing"
feed = json.load(open("/opt/phase/sample-feed.json", encoding="utf-8"))["decks"]
decks = []
for d in feed:
cmd = d.get("commander") or [d["name"]]
decks.append({"name": d["name"], "commander": cmd, "main": d["main"]})
if len(decks) == 4:
break
request = {"decks": decks, "games": games, "seed": 7, "difficulty": difficulty, "action_cap": 60000}
if LLM:
request.update(action_cap=6000, llm_seats=[1, 3], llm={"kind": "chat", "model": "stub-model", "max_calls": 60})
job = call("/run", request)["job"]
t0 = time.time()
while True:
j = call("/job/" + job)
if j["status"] in ("done", "error"):
break
if time.time() - t0 > 1500:
sys.exit("timed out; last: %s" % json.dumps(j)[:300])
time.sleep(3)
print("job: %s in %.0f s" % (j["status"], time.time() - t0))
res = j.get("result") or {}
print(json.dumps({k: res.get(k) for k in ("games_requested", "games_finished", "games_not_finished", "draws", "wins_by_seat", "avg_turns", "warnings")}, indent=1))
for g in res.get("games", []):
print(" game seed=%s %s winner=%s turns=%s eliminated=%s" % (g.get("seed"), g["status"], g.get("winner_seat"), g.get("turns"), g.get("eliminated")))
if LLM:
llm = res.get("llm") or {}
print("model seats:", json.dumps(llm), "| requests the stub model received:", len(ASKED))
print("\n".join((j.get("output_tail") or [])[-15:]))
seats = llm.get("per_seat") or {}
asked = sum(v["asked"] for v in seats.values())
used = sum(v["used"] for v in seats.values())
reasoned = [a for a in ASKED if a.get("reasoning_effort") == "none"]
if not ASKED or not asked or not used:
sys.exit("the model seats were not exercised: stub got %d requests, engine counters asked=%d used=%d" % (len(ASKED), asked, used))
if len(reasoned) != len(ASKED):
sys.exit("the relay did not switch the model's thinking off on every request")
print("PHASE LLM SMOKE OK (asked %d, engine used %d, stub saw %d requests, all with thinking off)" % (asked, used, len(ASKED)))
sys.exit(0)
if j["status"] != "done" or res.get("games_finished", 0) < 1:
print("\n".join(j.get("output_tail") or []))
sys.exit("no game finished")
print("PHASE SMOKE OK")
+272
View File
@@ -0,0 +1,272 @@
"""Run: python -I -X utf8 test_phase_sim.py (offline: a stub engine prints output in the format phase's ai-commander prints)"""
import json
import os
import sys
import tempfile
import threading
import time
import urllib.request
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import phase_sim as P # noqa: E402
FAILS = 0
def ck(name, cond, detail=""):
global FAILS
print(" %s %s%s" % ("ok " if cond else "FAIL", name, (" -- " + str(detail)[:260]) if detail and not cond else ""))
if not cond:
FAILS += 1
def deck(name, cmd, n=3):
return {"name": name, "commander": [cmd], "main": [{"name": "Sol Ring", "count": 1}, {"name": "Forest", "count": n}]}
GOOD = {"decks": [deck("A", "Cmd A"), deck("B", "Cmd B"), deck("C", "Cmd C"), deck("D", "Cmd D")], "games": 3, "seed": 10}
# --- validation
def bad(mut, why):
req = json.loads(json.dumps(GOOD))
mut(req)
try:
P.validate(req)
ck("rejects " + why, False)
except P.BadRequest as exc:
ck("rejects " + why, True, exc)
ck("a good request validates", P.validate(GOOD)["games"] == 3)
bad(lambda r: r["decks"].pop(), "three decks")
bad(lambda r: r["decks"][0].pop("commander"), "a deck with no commander")
bad(lambda r: r["decks"][1].update(main=[]), "an empty main list")
bad(lambda r: r["decks"][2]["main"].append({"name": "X", "count": 0}), "a zero count")
bad(lambda r: r.update(games=P.MAX_GAMES + 1), "too many games")
bad(lambda r: r.update(difficulty="Impossible"), "an unknown difficulty")
bad(lambda r: r.update(seat_difficulty={"7": "Easy"}), "a seat outside 0-3")
bad(lambda r: r.update(action_cap=5), "a tiny action cap")
v = P.validate(dict(GOOD, decks=[dict(deck("A", "Cmd A"), commander="Cmd A", main=None, cards=["Forest", "Forest", "Sol Ring"])] + GOOD["decks"][1:]))
ck("a plain card list becomes counted rows and a commander string becomes a list",
v["feed"][0]["commander"] == ["Cmd A"] and {"name": "Forest", "count": 2} in v["feed"][0]["main"], v["feed"][0])
# --- parsing the engine's output
OUT = """=== 4-player Commander AI test ===
Feed: feed.json
Batch mode: 5 games from games.txt
A | commander: Cmd A | main: 99 cards
B | commander: Cmd B | main: 99 cards
SKIP C: commander 'Cmd C' not in card db
D | commander: Cmd D | main: 99 cards
Resolved deck sizes (post name-resolution, 0-indexed by seat):
P0 main= 99 commander=1
P1 main= 97 commander=1
P2 main= 99 commander=1
P3 main= 99 commander=1
ExecutionMode: measurement
--- GAME seed=10 difficulty=Medium ---
Game started. 4 players.
ELIMINATED: P1 turn=9 actions= 400 elapsed=3.1s
ELIMINATED: P3 turn=12 actions= 600 elapsed=5.0s
=== RESULT ===
Elapsed: 8.0s
Turns played: 14
Game ended cleanly. Winner: P2
--- GAME seed=11 difficulty=Medium ---
Game started. 4 players.
=== RESULT ===
Turns played: 40
Game ended cleanly. Winner: draw
--- GAME seed=12 difficulty=Medium ---
=== RESULT ===
Turns played: 7
STALL: waiting_for=ChooseTarget turn=7 active=P0 priority=P0 pending_cast=none
Game did NOT reach GameOver. waiting_for = ChooseTarget
--- GAME seed=13 difficulty=Medium ---
ABORT: hit action cap=50000
=== RESULT ===
Turns played: 31
Game did NOT reach GameOver. waiting_for = Priority
--- GAME seed=14 difficulty=Medium ---
GAME 14 PANICKED: index out of bounds
"""
parsed = P.parse(OUT)
st = [g["status"] for g in parsed["games"]]
ck("five games found with their outcomes in order", st == ["won", "draw", "stalled", "action_cap", "panicked"], st)
g0 = parsed["games"][0]
ck("winner seat, turns and elimination order are read", g0["winner_seat"] == 2 and g0["turns"] == 14
and [e["seat"] for e in g0["eliminated"]] == [1, 3], g0)
ck("a draw has no winner seat", parsed["games"][1]["winner_seat"] is None)
ck("pre-resolution and resolved deck sizes and the skipped deck are read", len(parsed["pre"]) == 3 and parsed["resolved"][1]["main"] == 97
and parsed["skipped"] == [{"deck": "C", "commander": "Cmd C"}], (parsed["pre"], parsed["resolved"], parsed["skipped"]))
spec = P.validate(dict(GOOD, games=5))
res = P.summarize(spec, parsed, [10, 11, 12, 13, 14])
ck("only finished games are counted", res["games_finished"] == 2 and res["games_not_finished"] == 3 and res["draws"] == 1, res)
ck("wins by seat and per-deck win rate", res["wins_by_seat"] == [0, 0, 1, 0] and res["decks"][2]["win_rate"] == 0.5 and res["decks"][0]["win_rate"] == 0.0)
lo, hi = res["decks"][2]["win_rate_95"]
ck("a 95% interval wraps the rate and stays in 0..1", 0 <= lo < 0.5 < hi <= 1)
w = " | ".join(res["warnings"])
ck("warnings: a skipped deck, unrecognised cards, and unfinished games (never counted)",
"was skipped" in w and "2 of 99" in w and "not counted as wins or draws" in w and "stalled" in w, w)
ck("each game carries its seed", [g["seed"] for g in res["games"]] == [10, 11, 12, 13, 14])
ck("interval of nothing is zeros, of a sweep is high", P.wilson(0, 0) == [0.0, 0.0] and P.wilson(10, 10)[0] > 0.7)
# --- a whole job through the real worker path with a stub engine
STUB = os.path.join(tempfile.mkdtemp(), "stub_engine.py")
with open(STUB, "w", encoding="utf-8") as f:
f.write('''import sys, os
root = sys.argv[1]
seeds = [l.split(",")[0] for l in open(os.path.join(root, "games.txt")) if l.strip()]
feed = __import__("json").load(open(os.path.join(root, "feed.json")))
assert os.path.exists(os.path.join(root, "card-data.json")) and len(feed["decks"]) == 4
for d in feed["decks"]:
print(" %s | commander: %s | main: %d cards" % (d["name"], d["commander"][0], sum(e["count"] for e in d["main"])))
print("Resolved deck sizes (post name-resolution, 0-indexed by seat):")
for i, d in enumerate(feed["decks"]):
print(" P%d main=%3d commander=1" % (i, sum(e["count"] for e in d["main"])))
for k, s in enumerate(seeds):
print("--- GAME seed=%s ---" % s)
print("Turns played: %d" % (10 + k))
print("Game ended cleanly. Winner: P%d" % (k % 4))
''')
CD = os.path.join(os.path.dirname(STUB), "card-data.json")
open(CD, "w").write("{}")
P.CARD_DATA = CD
ORIG_COMMAND = P.command
P.command = lambda spec, root: [sys.executable, STUB, root]
job_id = P.submit(dict(GOOD, games=4))
threading.Thread(target=P.worker, daemon=True).start()
for _ in range(100):
j = P._jobs[job_id]
if j["status"] in ("done", "error"):
break
time.sleep(0.1)
pub = P.public(P._jobs[job_id])
ck("a queued job runs to done with four finished games, one win each", pub["status"] == "done" and pub["result"]["games_finished"] == 4
and pub["result"]["wins_by_seat"] == [1, 1, 1, 1] and pub["games_done"] == 4, pub)
ck("no warnings when every card resolved", pub["result"]["warnings"] == [], pub["result"]["warnings"])
ck("the temp folder is cleaned up", not [d for d in os.listdir(tempfile.gettempdir()) if d.startswith("phase-sim-")])
# --- the HTTP surface
srv = P.ThreadingHTTPServer(("127.0.0.1", 0), P.Handler)
threading.Thread(target=srv.serve_forever, daemon=True).start()
base = "http://127.0.0.1:%d" % srv.server_port
def call(path, body=None):
req = urllib.request.Request(base + path, data=None if body is None else json.dumps(body).encode(), headers={"content-type": "application/json"})
try:
r = urllib.request.urlopen(req, timeout=10)
return r.status, json.load(r)
except urllib.error.HTTPError as e:
return e.code, json.load(e)
code, h = call("/health")
ck("/health answers (ok is false only because the real binary is not here)", code == 200 and "engine" in h and h["max_games"] == P.MAX_GAMES, h)
code, r = call("/run", dict(GOOD, games=1))
ck("POST /run is accepted with a job id", code == 202 and len(r["job"]) == 12, r)
for _ in range(100):
code, j = call("/job/" + r["job"])
if j["status"] in ("done", "error"):
break
time.sleep(0.1)
ck("GET /job/<id> returns the result", code == 200 and j["status"] == "done" and j["result"]["games_finished"] == 1, j)
ck("a bad request is a 400 with a reason", call("/run", {"decks": []})[0] == 400)
ck("an unknown job is a 404", call("/job/deadbeef")[0] == 404)
ck("an unknown path is a 404", call("/nope")[0] == 404)
# --- model seats: validation, environment, relays
from http.server import BaseHTTPRequestHandler, HTTPServer
UP = []
class Upstream(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)))
UP.append((self.path, body, self.headers.get("authorization")))
out = json.dumps({"echo": True, "path": self.path}).encode()
self.send_response(200)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(out)))
self.end_headers()
self.wfile.write(out)
up = HTTPServer(("127.0.0.1", 0), Upstream)
threading.Thread(target=up.serve_forever, daemon=True).start()
UPBASE = "http://127.0.0.1:%d" % up.server_port
os.environ["PHASE_SIM_ALLOW_MISSING_LLM_BIN"] = "1"
LLMREQ = dict(GOOD, llm_seats=[1, 3], llm={"kind": "chat", "model": "twin-turbo", "max_calls": 120})
P.CHAT_URL, P.DECISION_URL = "", ""
try:
P.validate(LLMREQ)
ck("model seats are refused when no model endpoint is configured", False)
except P.BadRequest as exc:
ck("model seats are refused when no model endpoint is configured", "no chat model endpoint" in str(exc), exc)
P.CHAT_URL, P.DECISION_URL, P.DECISION_KEY = UPBASE + "/v1", UPBASE + "/v1/systemone", "sekret"
v = P.validate(LLMREQ)
ck("a chat model seat request validates", v["llm"] == {"seats": [1, 3], "kind": "chat", "model": "twin-turbo", "max_calls": 120}, v["llm"])
ck("a request with no model seats has no llm settings", P.validate(GOOD)["llm"] is None)
for mut, why in ((lambda r: r.update(llm_seats=[4]), "a seat outside 0-3"),
(lambda r: r["llm"].pop("model"), "a chat model with no name"),
(lambda r: r["llm"].update(max_calls=P.MAX_LLM_CALLS + 1), "too many model calls"),
(lambda r: r["llm"].update(kind="telepathy"), "an unknown kind")):
req = json.loads(json.dumps(LLMREQ))
mut(req)
try:
P.validate(req)
ck("rejects " + why, False)
except P.BadRequest:
ck("rejects " + why, True)
dv = P.validate(dict(GOOD, llm_seats=[0], llm={"kind": "decision"}))
ck("a decision-model seat needs no model name", dv["llm"]["kind"] == "decision" and dv["llm"]["model"] == "decision-model")
env = P.engine_env(v)
ck("chat seats: the engine is pointed at this service's /llm relay as an OpenAI-compatible endpoint with no key",
env["PHASE_LLM_SEATS"] == "1,3" and env["PHASE_LLM_PROVIDER"] == "OpenAiCompatible" and env["PHASE_LLM_BASE_URL"].endswith("/llm/v1")
and env["PHASE_LLM_MODEL"] == "twin-turbo" and env["PHASE_LLM_API_KEY"] == "" and env["PHASE_LLM_MAX_CALLS"] == "120", env)
denv = P.engine_env(dv)
ck("decision seats: provider Jev, base is this service (the engine adds /jev/systemone)", denv["PHASE_LLM_PROVIDER"] == "Jev"
and denv["PHASE_LLM_BASE_URL"] == "http://127.0.0.1:%d" % P.PORT)
ck("no model seats: no special environment, the plain engine", P.engine_env(P.validate(GOOD)) is None
and ORIG_COMMAND(P.validate(GOOD), "/r")[0] == P.BIN and ORIG_COMMAND(v, "/r")[0] == P.BIN_LLM)
code, out = P.relay_chat(json.dumps({"model": "twin-turbo", "messages": [{"role": "user", "content": "x"}], "max_tokens": 4000, "stream": True}).encode(), "127.0.0.1")
path, body, auth = UP[-1]
ck("the chat relay forwards to /chat/completions with thinking off, the answer capped and streaming off",
code == 200 and path == "/v1/chat/completions" and body["reasoning_effort"] == "none" and body["max_tokens"] == P.CHAT_MAX_TOKENS
and body["stream"] is False and body["model"] == "twin-turbo", (code, path, body))
ck("a request without max_tokens gets the cap", (P.relay_chat(b'{"model": "m", "messages": []}', "127.0.0.1"), UP[-1][1]["max_tokens"] == P.CHAT_MAX_TOKENS)[1])
ck("the relays refuse anyone but this machine", P.relay_chat(b"{}", "10.1.2.3")[0] == 403 and P.relay_decision(b"{}", "10.1.2.3")[0] == 403)
ck("a malformed chat body is a 400, not an exception", P.relay_chat(b"not json", "127.0.0.1")[0] == 400)
envelope = {"apiKey": "ignored-by-us", "request": {"state": {"body": "t"}, "questions": {"pick": {"type": "choice"}}}}
code, out = P.relay_decision(json.dumps(envelope).encode(), "127.0.0.1")
path, body, auth = UP[-1]
ck("the decision relay unwraps phase's envelope and sends only the request, with OUR key", code == 200 and path == "/v1/systemone"
and body == envelope["request"] and auth == "Bearer sekret", (code, path, body, auth))
ck("a decision body with no request is a 400", P.relay_decision(b'{"apiKey": "k"}', "127.0.0.1")[0] == 400)
P.CHAT_URL = "http://127.0.0.1:1/v1"
ck("an unreachable model endpoint is a 502", P.relay_chat(b'{"model": "m", "messages": []}', "127.0.0.1")[0] == 502)
P.CHAT_URL = UPBASE + "/v1"
summ = P.llm_summary({"seats": [1], "kind": "chat", "model": "m", "max_calls": 9},
["LLM-SEAT seat=1 asked=25 used=20 total_calls=25", "noise", "LLM-SEAT seat=1 asked=50 used=41 total_calls=50"])
ck("the model seats' counters are read from the engine's output (last value wins)", summ["per_seat"] == {1: {"asked": 50, "used": 41}}, summ)
summ2 = P.llm_summary({"seats": [1], "kind": "chat", "model": "m", "max_calls": 9},
["LLM-SEAT seat=1 asked=25 used=1 total_calls=25 timeouts=20 avg_ms=1800 avg_prompt_chars=31000",
"LLM-SEAT-DISABLED after 4 consecutive failures (20 timeouts of 25 questions): phase's own AI plays the rest"])
ck("cost counters and the circuit-breaker are read from the engine's output", summ2["timeouts"] == 20 and summ2["avg_ms"] == 1800
and summ2["avg_prompt_chars"] == 31000 and summ2["switched_off_after_failures_in_a_row"] == 4 and summ2["per_seat"] == {1: {"asked": 25, "used": 1}}, summ2)
ck("old three-field counter lines still parse", P.llm_summary({"seats": [1], "kind": "chat", "model": "m", "max_calls": 9},
["LLM-SEAT seat=2 asked=25 used=24 total_calls=25"])["per_seat"] == {2: {"asked": 25, "used": 24}})
code, r = call("/llm/v1/chat/completions", {"model": "m", "messages": []})
ck("over HTTP from this machine the chat relay answers", code == 200 and r["echo"] is True, (code, r))
print("\nFAILURES: %d" % FAILS)
sys.exit(1 if FAILS else 0)
+42
View File
@@ -0,0 +1,42 @@
with open('ml/eval/forge_evaluator.py', 'rb') as f:
data = f.read()
# Fix 1: absolute JAR path in subprocess call
data = data.replace(
b'"--forge-jar", self.cfg.forge_jar,',
b'"--forge-jar", str(Path(self.cfg.forge_jar).resolve()),'
)
# Fix 2: resolve forge_work_dir to absolute path too
data = data.replace(
b'"--forge-dir", self.cfg.forge_work_dir,',
b'"--forge-dir", str(Path(self.cfg.forge_work_dir).resolve()),'
)
# Fix 3: change default forge_work_dir
data = data.replace(
b'forge_work_dir: str = "forge"',
b'forge_work_dir: str = "D:/ForgeCommander/forge-repo/forge-gui"'
)
with open('ml/eval/forge_evaluator.py', 'wb') as f:
f.write(data)
import py_compile
py_compile.compile('ml/eval/forge_evaluator.py', doraise=True)
print('forge_evaluator.py - Syntax OK')
# Fix eval_policy.py too
with open('scripts/eval_policy.py', 'rb') as f:
data = f.read()
data = data.replace(
b'"--forge-dir", default="forge"',
b'"--forge-dir", default="D:/ForgeCommander/forge-repo/forge-gui"'
)
with open('scripts/eval_policy.py', 'wb') as f:
f.write(data)
py_compile.compile('scripts/eval_policy.py', doraise=True)
print('eval_policy.py - Syntax OK')
+14 -3
View File
@@ -38,6 +38,7 @@ from routes.lab import router as lab_router
from routes.scanner import router as scanner_router
from routes.rag import router as rag_router
from routes.deepseek import router as deepseek_router
from routes.farmhouse_phase import router as farmhouse_phase_router # FARMHOUSE_PHASE_IMPORT
from routes.deckgen import router as deckgen_router
from routes.coach import router as coach_router, init_coach_service
from routes.ml import router as ml_router
@@ -62,6 +63,7 @@ async def _lifespan(application: FastAPI):
CFG.precon_dir = _resolve_precon_dir()
init_collection_db()
validate_forge_config()
init_coach_service() # FARMHOUSE_COACH_INIT_V1
if not PRECON_INDEX:
download_precon_database()
if not COMMANDER_META:
@@ -85,7 +87,7 @@ async def _lifespan(application: FastAPI):
try:
from services.rag_store import check_staleness, build_index
info = check_staleness()
if info.get("stale"):
if info.get("stale") and os.environ.get("RAG_AUTOBUILD", "0") == "1": # FARMHOUSE_RAG_MONITOR_V1
log.info("RAG staleness monitor: %s — triggering rebuild.", info["reason"])
loop.run_in_executor(None, lambda: build_index(force=True))
else:
@@ -128,6 +130,7 @@ app.include_router(deckbuilder_router)
app.include_router(precon_router)
app.include_router(import_router)
app.include_router(lab_router)
app.include_router(farmhouse_phase_router)
app.include_router(scanner_router)
app.include_router(rag_router)
app.include_router(deepseek_router)
@@ -170,6 +173,7 @@ from pathlib import Path as _Path
_legacy_ui_dir = _Path(__file__).parent / "ui"
_lan_client_dir = _Path(__file__).parent / "lan-client"
_precon_decks_dir = _Path(__file__).parent / "precon-decks"
_user_decks_dir = _Path(__file__).parent / "user-decks"
_spa_dir = _Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
# Mount LAN client at /lan (served before the root catch-all)
@@ -180,6 +184,10 @@ if _lan_client_dir.exists():
if _precon_decks_dir.exists():
app.mount("/precon-decks", StaticFiles(directory=str(_precon_decks_dir)), name="precon-decks")
# Mount user-decks/ so exported .dck files are fetchable at /user-decks/{file}.dck
if _user_decks_dir.exists():
app.mount("/user-decks", StaticFiles(directory=str(_user_decks_dir)), name="user-decks")
if _legacy_ui_dir.exists():
app.mount("/", StaticFiles(directory=str(_legacy_ui_dir), html=True), name="ui")
elif _spa_dir.exists():
@@ -251,7 +259,9 @@ def _resolve_forge_dir() -> str:
project_root = Path(__file__).parent
for candidate in [
project_root / "forge",
project_root.parent / "forge-repo",
project_root.parent / "Forge-Repo",
project_root.parent / "Forge-Repo" / "forge-gui",
project_root.parent / "forge-repo", # legacy fallback
project_root.parent / "forge-repo" / "forge-gui",
project_root.parent / "forge",
project_root / "forge-gui-desktop",
@@ -277,7 +287,8 @@ def _resolve_forge_jar() -> str:
project_root = Path(__file__).parent
candidates = [
project_root / "forge-gui-desktop" / "target",
project_root.parent / "forge-repo" / "forge-gui-desktop" / "target",
project_root.parent / "Forge-Repo" / "forge-gui-desktop" / "target",
project_root.parent / "forge-repo" / "forge-gui-desktop" / "target", # legacy fallback
project_root.parent / "forge" / "forge-gui-desktop" / "target",
]
forge_dir_env = os.environ.get("FORGE_DIR", "") or os.environ.get("FORGE_WORKING_DIR", "")
+38 -17
View File
@@ -366,7 +366,11 @@ class GameStateBridge {
seat_type: p.isAI
? this._mapAiProfile(gameState.aiDifficulty)
: 'Human',
deck_name: p.commanderCardName || '',
deck_name: p.deckFileName || (
p.commanderCardName
? p.commanderCardName.replace(/[^a-zA-Z0-9]/g, '_') + '.dck'
: ''
),
player_name: p.name,
}));
@@ -394,11 +398,13 @@ class GameStateBridge {
*/
_mapAiProfile(difficulty) {
const map = {
easy: 'AI',
normal: 'AI Aggro',
hard: 'AI Control',
easy: 'AI',
normal: 'AI Midrange',
hard: 'AI Control',
combo: 'AI Combo',
aggro: 'AI Aggro',
};
return map[difficulty] || 'AI';
return map[difficulty] || 'AI Midrange';
}
// ── Game State Serialization ──────────────────────────────
@@ -451,7 +457,10 @@ class GameStateBridge {
// Zone contents — card names
hand: p.zones.hand.map(c => c.name || 'Unknown'),
hand_count: p.zones.hand.length,
battlefield: bfCards.map(c => ({
// Flat string list for the state encoder
battlefield: bfCards.map(c => c.name || 'Unknown'),
// Structured data for backend logic that needs detail
battlefield_detail: bfCards.map(c => ({
name: c.name || 'Unknown',
type: c.typeLine || '',
power: c.power || '0',
@@ -488,7 +497,7 @@ class GameStateBridge {
return {
game_id: this._sessionGameId || '',
turn: gameState.turnCounter || 1,
turn: gameState.turnNumber || gameState.turnCounter || 1,
phase: phaseMap[phaseId] || 'main_1',
active_player: playerIdx,
players: players,
@@ -505,9 +514,9 @@ class GameStateBridge {
* Get all battlefield cards for a player.
*/
_getBattlefieldCards(player, gameState) {
if (!gameState || !gameState.battlefield) return [];
return (gameState.battlefield || []).filter(
c => c.controller === player.id || c.owner === player.id
if (!gameState || !gameState.battlefieldCards) return [];
return gameState.battlefieldCards.filter(
c => c.ownerIndex === player.id
);
}
@@ -707,7 +716,7 @@ class MacroActionMapper {
if (castable.length === 0) return false;
castable.sort((a, b) => (MacroActionMapper._cmc(b) - MacroActionMapper._cmc(a)));
for (const card of castable) {
if (MacroActionMapper._canAfford(playerIdx, card)) {
if (MacroActionMapper._canAfford(playerIdx, card, gameState)) {
const idx = hand.indexOf(card);
if (idx !== -1) {
if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card);
@@ -728,7 +737,7 @@ class MacroActionMapper {
});
for (const card of creatures) {
if (MacroActionMapper._canAfford(playerIdx, card)) {
if (MacroActionMapper._canAfford(playerIdx, card, gameState)) {
const idx = hand.indexOf(card);
if (idx !== -1) {
if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card);
@@ -752,7 +761,7 @@ class MacroActionMapper {
if (removal.length === 0) return false;
for (const card of removal) {
if (MacroActionMapper._canAfford(playerIdx, card)) {
if (MacroActionMapper._canAfford(playerIdx, card, gameState)) {
const idx = hand.indexOf(card);
if (idx !== -1) {
if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card);
@@ -781,7 +790,7 @@ class MacroActionMapper {
);
for (const card of drawSpells) {
if (MacroActionMapper._canAfford(playerIdx, card)) {
if (MacroActionMapper._canAfford(playerIdx, card, gameState)) {
const idx = hand.indexOf(card);
if (idx !== -1) {
if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card);
@@ -826,7 +835,7 @@ class MacroActionMapper {
);
for (const card of rampSpells) {
if (MacroActionMapper._canAfford(playerIdx, card)) {
if (MacroActionMapper._canAfford(playerIdx, card, gameState)) {
const idx = hand.indexOf(card);
if (idx !== -1) {
if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card);
@@ -884,12 +893,24 @@ class MacroActionMapper {
return 0;
}
static _canAfford(playerIdx, card) {
static _canAfford(playerIdx, card, gameState) {
if (!gameState) return false;
const cmc = MacroActionMapper._cmc(card);
if (cmc === 0) return true; // Free spells always castable
// Prefer actual mana pool if populated
const player = gameState.players[playerIdx];
const pool = player?.manaPool;
if (pool) {
const total = Object.values(pool).reduce((s, v) => s + (v || 0), 0);
if (total > 0) return total >= cmc;
}
// Fallback: count untapped lands
const available = typeof aiCountUntappedLands === 'function'
? aiCountUntappedLands(playerIdx)
: 0;
return available >= MacroActionMapper._cmc(card);
return available >= cmc;
}
static _creatureScore(card) {
File diff suppressed because it is too large. Load diff
+34
View File
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Commander AI Lab — Collection</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="global.css" />
<link rel="stylesheet" href="collection.css" />
<style>
/* No nav bar when embedded in LAN client iframe */
:root { --nav-height: 0px; }
</style>
</head>
<body>
<!-- nav.js removed — collection is embedded via iframe in the LAN client -->
<div id="collection-container"></div>
<script>
window.LAB_API_BASE = window.LAB_API_BASE || '';
</script>
<script src="collection.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
Collection.init();
});
</script>
<!-- Card Preview Popup (hover tooltip) -->
<div id="coll-card-preview" style="display:none; position:fixed; pointer-events:none; z-index:9999;">
<img id="coll-card-preview-img" src="" alt="Card preview" style="width:220px; border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,0.7);" />
</div>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+495
View File
@@ -0,0 +1,495 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Commander AI Lab — Deck Builder</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="global.css" />
<link rel="stylesheet" href="deckbuilder.css" />
<style>
/* No nav bar when embedded in LAN client iframe */
:root { --nav-height: 0px; }
</style>
</head>
<body>
<!-- nav.js removed — deck builder is embedded via iframe in the LAN client -->
<div id="deck-builder-page">
<!-- TOP BAR -->
<header class="deck-topbar">
<div class="deck-topbar-commander">
<div class="deck-commander-portrait" id="deck-commander-portrait">
<span class="deck-commander-portrait-placeholder">⚔</span>
<img class="deck-commander-portrait-img" id="deck-commander-img" src="" alt="" style="display:none" />
</div>
<div class="deck-name-block">
<input
type="text"
class="deck-name-input"
id="deck-name-input"
placeholder="Untitled Deck"
value=""
/>
<div class="deck-commander-label" id="deck-commander-label">No commander selected</div>
</div>
<div class="deck-color-chips" id="deck-color-chips"></div>
</div>
<div class="deck-topbar-center">
<div class="deck-count-display">
<span class="deck-count-num" id="deck-total-count">0</span>
<span class="deck-count-sep">/</span>
<span class="deck-count-target">100</span>
<span class="deck-count-label">cards</span>
</div>
<button class="deck-btn deck-btn-ghost" id="deck-new-btn">+ New Deck</button>
<div class="deck-selector-wrap">
<select class="deck-selector" id="deck-selector">
<option value="">— Select Deck —</option>
</select>
</div>
<button class="deck-btn deck-btn-danger" id="deck-delete-btn" title="Delete this deck" style="display:none;">✕ Delete</button>
<button class="deck-btn deck-btn-danger-outline" id="deck-clear-all-btn" title="Delete all decks">Clear All</button>
</div>
<div class="deck-topbar-actions">
<button class="deck-btn deck-btn-import" id="deck-import-btn" title="Import a decklist">
<span>⬆</span> Import
</button>
<button class="deck-btn deck-btn-ghost" id="deck-export-btn" title="Export as .dck file">
<span>⬇</span> Export .dck
</button>
<button class="deck-btn deck-btn-ghost" id="deck-export-sim-btn" title="Export deck as .dck to Forge sim folder">
<span>&#x1F4E4;</span> Export to Sim
</button>
<button class="deck-btn deck-btn-ghost" id="deck-export-game-btn" title="Send deck to LAN game setup" style="border-color:rgba(91,141,217,0.4);color:rgba(91,141,217,0.9);">
<span>&#x1F3AE;</span> Use in Game
</button>
<button class="deck-btn deck-btn-sim" id="deck-sim-btn" title="Run quick simulation">
<span class="deck-btn-icon">▶</span> Run Simulation
</button>
</div>
</header>
<!-- MAIN 3-COLUMN LAYOUT -->
<main class="deck-main">
<!-- LEFT COLUMN: Collection Search -->
<aside class="deck-col-left">
<div class="deck-panel deck-panel-search">
<div class="deck-panel-header">
<span class="deck-panel-title">Collection Search</span>
<span class="deck-search-count" id="deck-search-count">0 results</span>
</div>
<div class="deck-search-bar-wrap">
<input
type="search"
class="deck-search-bar"
id="deck-search-bar"
placeholder="Search cards..."
autocomplete="off"
/>
<span class="deck-search-icon">🔍</span>
</div>
<div class="deck-filter-row">
<select class="deck-filter-select" id="deck-type-filter">
<option value="">All Types</option>
<option value="Land">Land</option>
<option value="Creature">Creature</option>
<option value="Instant">Instant</option>
<option value="Sorcery">Sorcery</option>
<option value="Artifact">Artifact</option>
<option value="Enchantment">Enchantment</option>
<option value="Planeswalker">Planeswalker</option>
</select>
<div class="deck-cmc-filter">
<input type="number" class="deck-cmc-input" id="deck-cmc-min" placeholder="0" min="0" max="20" />
<span class="deck-cmc-sep">–</span>
<input type="number" class="deck-cmc-input" id="deck-cmc-max" placeholder="∞" min="0" max="20" />
</div>
</div>
<div class="deck-color-filter-row" id="deck-color-filter">
<button class="deck-color-pip deck-color-W" data-color="W" title="White">W</button>
<button class="deck-color-pip deck-color-U" data-color="U" title="Blue">U</button>
<button class="deck-color-pip deck-color-B" data-color="B" title="Black">B</button>
<button class="deck-color-pip deck-color-R" data-color="R" title="Red">R</button>
<button class="deck-color-pip deck-color-G" data-color="G" title="Green">G</button>
<button class="deck-color-pip deck-color-C" data-color="C" title="Colorless">C</button>
</div>
<!-- Results list -->
<div class="deck-search-results" id="deck-search-results">
<div class="deck-empty-state">
<div class="deck-empty-icon">🃏</div>
<div class="deck-empty-text">Search your collection to add cards</div>
</div>
</div>
<div class="deck-search-footer">
<button class="deck-btn deck-btn-success deck-btn-full" id="deck-add-all-owned-btn" style="display:none">
Add All Matching (Owned)
</button>
</div>
</div>
</aside>
<!-- CENTER COLUMN: Deck Card List -->
<section class="deck-col-center">
<div class="deck-panel deck-panel-cards" id="deck-card-list-panel">
<div class="deck-panel-header deck-card-list-header">
<span class="deck-panel-title">Deck Contents</span>
<div class="deck-card-list-actions">
<span class="deck-card-list-meta" id="deck-card-list-meta">Select or create a deck</span>
</div>
</div>
<!-- Card groups by type -->
<div class="deck-card-groups" id="deck-card-groups">
<div class="deck-empty-state deck-empty-state-lg">
<div class="deck-empty-icon">⚔</div>
<div class="deck-empty-text">Create or select a deck to start building</div>
<button class="deck-btn deck-btn-accent" id="deck-empty-new-btn">+ New Deck</button>
</div>
</div>
<!-- Quick add by name -->
<div class="deck-quickadd-row" id="deck-quickadd-row" style="display:none">
<div class="deck-quickadd-wrap">
<input
type="text"
class="deck-quickadd-input"
id="deck-quickadd-input"
placeholder="Add card by name..."
autocomplete="off"
/>
<div class="deck-quickadd-dropdown" id="deck-quickadd-dropdown" style="display:none"></div>
</div>
<button class="deck-btn deck-btn-accent-sm" id="deck-quickadd-btn">Add</button>
</div>
<!-- Deck stats footer -->
<div class="deck-stats-footer" id="deck-stats-footer" style="display:none">
<div class="deck-stat-pill">
<span class="deck-stat-pill-num" id="deck-footer-total">0</span>
<span class="deck-stat-pill-label">total</span>
</div>
<div class="deck-stat-pill">
<span class="deck-stat-pill-num deck-stat-owned" id="deck-footer-owned">0</span>
<span class="deck-stat-pill-label">owned</span>
</div>
<div class="deck-stat-pill">
<span class="deck-stat-pill-num" id="deck-footer-avg-cmc">0.0</span>
<span class="deck-stat-pill-label">avg CMC</span>
</div>
</div>
</div>
</section>
<!-- RIGHT COLUMN: EDH Advisor -->
<aside class="deck-col-right">
<!-- Ratio Dashboard -->
<div class="deck-panel deck-panel-ratios" id="deck-ratio-panel">
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-ratio-body">
<span class="deck-panel-title">📊 Ratio Dashboard</span>
<span class="deck-collapse-arrow">▼</span>
</div>
<div class="deck-panel-body" id="deck-ratio-body">
<div class="deck-ratio-bars" id="deck-ratio-bars">
<div class="deck-ratio-placeholder">Load a deck to see ratios</div>
</div>
</div>
</div>
<!-- Fix My Ratios -->
<div class="deck-panel deck-panel-fix" id="deck-fix-panel">
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-fix-body">
<span class="deck-panel-title">🔧 Fix My Ratios</span>
<span class="deck-collapse-arrow">▼</span>
</div>
<div class="deck-panel-body" id="deck-fix-body">
<div class="deck-shortfalls" id="deck-shortfalls">
<div class="deck-ratio-placeholder">No analysis available</div>
</div>
<div class="deck-fix-actions" id="deck-fix-actions" style="display:none">
<button class="deck-btn deck-btn-ghost deck-btn-full" id="deck-autosuggest-btn">
⚡ Auto-suggest from Collection
</button>
</div>
<div class="deck-fix-suggestions" id="deck-fix-suggestions" style="display:none"></div>
</div>
</div>
<!-- EDHREC Recommendations -->
<div class="deck-panel deck-panel-edhrec" id="deck-edhrec-panel">
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-edhrec-body">
<span class="deck-panel-title">✨ EDHREC Recs</span>
<span class="deck-collapse-arrow">▼</span>
</div>
<div class="deck-panel-body" id="deck-edhrec-body">
<div class="deck-edhrec-controls">
<label class="deck-toggle-label">
<input type="checkbox" class="deck-toggle-check" id="deck-edhrec-owned-only" />
<span class="deck-toggle-track"><span class="deck-toggle-thumb"></span></span>
Owned Only
</label>
<button class="deck-btn deck-btn-ghost deck-btn-sm" id="deck-edhrec-refresh-btn">
↺ Refresh
</button>
</div>
<div class="deck-edhrec-list" id="deck-edhrec-list">
<div class="deck-ratio-placeholder">Load a deck to see recommendations</div>
</div>
<div class="deck-edhrec-bulk" id="deck-edhrec-bulk" style="display:none">
<button class="deck-btn deck-btn-success deck-btn-full" id="deck-edhrec-bulk-owned-btn">
Add All Owned (respect ratios)
</button>
</div>
</div>
</div>
<!-- AI Research (Perplexity) — hidden until pplx status check -->
<div class="deck-panel deck-panel-ai" id="deck-ai-research-panel" style="display:none">
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-ai-research-body">
<span class="deck-panel-title">🧠 AI Research</span>
<span class="deck-collapse-arrow">▼</span>
</div>
<div class="deck-panel-body" id="deck-ai-research-body">
<div class="deck-ai-controls">
<div class="deck-form-field deck-form-field-sm">
<label class="deck-form-label-sm">Analysis Goal</label>
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-research-goal"
placeholder="Identify weaknesses and suggest upgrades" />
</div>
<div class="deck-ai-row">
<div class="deck-form-field deck-form-field-sm deck-form-field-half">
<label class="deck-form-label-sm">Budget ($)</label>
<input type="number" class="deck-form-input deck-form-input-sm" id="ai-research-budget"
placeholder="No limit" min="0" step="10" />
</div>
<label class="deck-toggle-label deck-toggle-label-sm">
<input type="checkbox" class="deck-toggle-check" id="ai-research-use-collection" checked />
<span class="deck-toggle-track"><span class="deck-toggle-thumb"></span></span>
Use Collection
</label>
</div>
<div class="deck-form-field deck-form-field-sm">
<label class="deck-form-label-sm">Omit Cards (comma-separated)</label>
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-research-omit"
placeholder="Dockside Extortionist, Rhystic Study" />
</div>
<button class="deck-btn deck-btn-ai deck-btn-full" id="ai-research-btn" disabled>
🔍 Analyze Deck
</button>
</div>
<div class="deck-ai-loading" id="ai-research-loading" style="display:none">
<div class="deck-ai-spinner"></div>
<span>Analyzing with Claude Opus...</span>
</div>
<div class="deck-ai-results" id="ai-research-results" style="display:none"></div>
</div>
</div>
<!-- AI Deck Generate (Perplexity) — hidden until pplx status check -->
<div class="deck-panel deck-panel-ai" id="deck-ai-generate-panel" style="display:none">
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-ai-generate-body">
<span class="deck-panel-title">✨ AI Generate Deck</span>
<span class="deck-collapse-arrow">▼</span>
</div>
<div class="deck-panel-body" id="deck-ai-generate-body">
<div class="deck-ai-controls">
<div class="deck-form-field deck-form-field-sm">
<label class="deck-form-label-sm">Commander</label>
<div class="deck-ai-commander-wrap">
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-gen-commander"
placeholder="Search for a commander..." autocomplete="off" />
<div class="deck-ai-commander-dropdown" id="ai-gen-commander-dropdown" style="display:none"></div>
</div>
</div>
<div class="deck-ai-row">
<div class="deck-form-field deck-form-field-sm deck-form-field-half">
<label class="deck-form-label-sm">Budget ($)</label>
<input type="number" class="deck-form-input deck-form-input-sm" id="ai-gen-budget"
placeholder="No limit" min="0" step="10" />
</div>
<label class="deck-toggle-label deck-toggle-label-sm">
<input type="checkbox" class="deck-toggle-check" id="ai-gen-use-collection" checked />
<span class="deck-toggle-track"><span class="deck-toggle-thumb"></span></span>
Use Collection
</label>
</div>
<div class="deck-form-field deck-form-field-sm">
<label class="deck-form-label-sm">Omit Cards (comma-separated)</label>
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-gen-omit"
placeholder="Rhystic Study, Cyclonic Rift" />
</div>
<button class="deck-btn deck-btn-ai deck-btn-full" id="ai-gen-btn">
✨ Generate Full Deck
</button>
</div>
<div class="deck-ai-loading" id="ai-gen-loading" style="display:none">
<div class="deck-ai-spinner"></div>
<span>Generating deck with Claude Opus...</span>
</div>
<div class="deck-ai-results" id="ai-gen-results" style="display:none"></div>
</div>
</div>
</aside>
</main>
<!-- CARD PREVIEW TOOLTIP -->
<div class="deck-card-preview" id="deck-card-preview" style="display:none">
<img class="deck-card-preview-img" id="deck-card-preview-img" src="" alt="" />
</div>
<!-- TOAST CONTAINER -->
<div class="deck-toast-container" id="deck-toast-container"></div>
<!-- CONFIRM MODAL -->
<div class="deck-modal-overlay" id="deck-confirm-modal" style="display:none">
<div class="deck-modal">
<div class="deck-modal-header">
<h3 class="deck-modal-title" id="deck-confirm-title">Confirm Action</h3>
<button class="deck-modal-close-btn" id="deck-confirm-close">✕</button>
</div>
<div class="deck-modal-body">
<p class="deck-confirm-message" id="deck-confirm-message"></p>
<div class="deck-confirm-changes" id="deck-confirm-changes"></div>
</div>
<div class="deck-modal-footer">
<button class="deck-btn deck-btn-ghost" id="deck-confirm-cancel">Cancel</button>
<button class="deck-btn deck-btn-accent" id="deck-confirm-ok">Confirm</button>
</div>
</div>
</div>
<!-- NEW DECK MODAL -->
<div class="deck-modal-overlay" id="deck-new-modal" style="display:none">
<div class="deck-modal">
<div class="deck-modal-header">
<h3 class="deck-modal-title">Create New Deck</h3>
<button class="deck-modal-close-btn" id="deck-new-modal-close">✕</button>
</div>
<div class="deck-modal-body">
<div class="deck-form-field">
<label class="deck-form-label">Deck Name</label>
<input type="text" class="deck-form-input" id="deck-new-name" placeholder="My Commander Deck" />
</div>
<div class="deck-form-field">
<label class="deck-form-label">Commander (optional)</label>
<div class="deck-commander-search-wrap">
<input
type="text"
class="deck-form-input"
id="deck-new-commander-search"
placeholder="Search Scryfall for commander..."
autocomplete="off"
/>
<div class="deck-commander-dropdown" id="deck-new-commander-dropdown" style="display:none"></div>
</div>
<div class="deck-new-commander-selected" id="deck-new-commander-selected" style="display:none">
<span class="deck-selected-commander-name" id="deck-new-commander-name"></span>
<button class="deck-clear-commander-btn" id="deck-clear-commander-btn">✕</button>
</div>
</div>
</div>
<div class="deck-modal-footer">
<button class="deck-btn deck-btn-ghost" id="deck-new-cancel">Cancel</button>
<button class="deck-btn deck-btn-accent" id="deck-new-create">Create Deck</button>
</div>
</div>
</div>
<!-- IMPORT MODAL -->
<div class="deck-modal-overlay" id="deck-import-modal" style="display:none">
<div class="deck-modal deck-modal-wide">
<div class="deck-modal-header">
<h3 class="deck-modal-title">Import Decklist</h3>
<button class="deck-modal-close-btn" id="deck-import-modal-close">✕</button>
</div>
<div class="deck-modal-body">
<div class="deck-import-tabs">
<button class="deck-import-tab active" data-tab="paste">Paste Text</button>
<button class="deck-import-tab" data-tab="file">Upload File</button>
</div>
<div id="deck-import-paste-panel" class="deck-import-panel">
<textarea id="deck-import-textarea" rows="14" placeholder="Paste your decklist here...
Supported formats:
[Commander]
1 Korvold, Fae-Cursed King
[Deck]
1 Sol Ring
1 Cultivate
37 Forest
Or plain list:
Sol Ring
Cultivate
Forest"></textarea>
</div>
<div id="deck-import-file-panel" class="deck-import-panel" style="display:none">
<div class="deck-import-dropzone" id="deck-import-dropzone">
<div class="deck-import-dropzone-label">Drop a .dck or .txt file here, or click to browse</div>
<input type="file" id="deck-import-file" accept=".dck,.txt,.dec,.mwDeck" style="display:none" />
</div>
<div class="deck-import-file-info" id="deck-import-file-name"></div>
</div>
<div class="deck-import-options">
<label class="deck-import-option-label">
<input type="radio" name="import-mode" value="new" checked /> Create new deck
</label>
<label class="deck-import-option-label">
<input type="radio" name="import-mode" value="add" /> Add to current deck
</label>
<label class="deck-import-option-label">
<input type="radio" name="import-mode" value="replace" /> Replace current deck cards
</label>
</div>
<div id="deck-import-new-name-row" class="deck-import-name-row">
<label>Deck name (optional):</label>
<input type="text" id="deck-import-name" placeholder="Auto-detected from commander" />
</div>
<div class="deck-import-progress-wrap" id="deck-import-progress" style="display:none">
<div class="deck-import-progress-label">Importing cards (Scryfall lookup)...</div>
<div class="deck-import-progress-track">
<div class="deck-import-progress-fill" id="deck-import-progress-bar"></div>
</div>
</div>
<div id="deck-import-result" style="display:none"></div>
</div>
<div class="deck-modal-footer">
<button class="deck-btn deck-btn-ghost" id="deck-import-cancel">Cancel</button>
<button class="deck-btn deck-btn-accent" id="deck-import-go">Import</button>
</div>
</div>
</div>
</div><!-- /#deck-builder-page -->
<script>
// Set API base URL — when served from a different host, override this value
window.LAB_API_BASE = window.LAB_API_BASE || '';
</script>
<script src="deckbuilder.js"></script>
<!-- Card Preview Popup (hover tooltip) -->
<div id="deck-card-preview" style="display:none; position:fixed; pointer-events:none; z-index:60;">
<img id="deck-card-preview-img" src="" alt="Card preview" style="width:220px; border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,0.7);" />
</div>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+245
View File
@@ -0,0 +1,245 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Commander AI Lab — Auto Deck Generator V3</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="global.css" />
<link rel="stylesheet" href="deckgenerator.css" />
<style>
/* No nav bar when embedded in LAN client iframe */
:root { --nav-height: 0px; }
</style>
</head>
<body>
<!-- nav.js removed — deck generator is embedded via iframe in the LAN client -->
<div id="deckgen-page">
<!-- MAIN LAYOUT -->
<main class="dg-main">
<!-- LEFT: Configuration -->
<section class="dg-col-config">
<!-- Commander Search -->
<div class="dg-panel dg-panel-commander">
<div class="dg-panel-header">
<span class="dg-panel-title">Commander</span>
</div>
<div class="dg-panel-body">
<div class="dg-commander-search-wrap">
<input
type="text"
class="dg-input"
id="dg-commander-input"
placeholder="Search for a legendary creature..."
autocomplete="off"
/>
<div class="dg-commander-dropdown" id="dg-commander-dropdown"></div>
</div>
<div class="dg-commander-selected" id="dg-commander-selected" style="display:none">
<div class="dg-commander-card">
<button class="dg-commander-clear" id="dg-commander-clear" title="Clear commander">&times;</button>
<img class="dg-commander-img" id="dg-commander-img" src="" alt="" />
<div class="dg-commander-info">
<div class="dg-commander-name" id="dg-commander-name"></div>
<div class="dg-commander-type" id="dg-commander-type"></div>
<div class="dg-commander-colors" id="dg-commander-colors"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Strategy & Bracket -->
<div class="dg-panel">
<div class="dg-panel-header">
<span class="dg-panel-title">Strategy &amp; Power</span>
</div>
<div class="dg-panel-body">
<input
type="text"
class="dg-input"
id="dg-strategy"
placeholder="e.g. zombie tokens, voltron, spellslinger..."
title="Describe your deck strategy — the AI uses this to pick thematically aligned cards"
/>
<div class="dg-bracket-row">
<label class="dg-label">
Target Bracket
<span class="tip-icon" data-tip="B1 = precon-level casual. B2 = upgraded with synergies. B3 = high power with strong combos. B4 = cEDH with maximum optimization, fast mana, and instant-speed interaction.">?</span>
</label>
<div class="dg-bracket-btns" id="dg-bracket-btns">
<button class="dg-bracket-btn" data-bracket="1" title="Casual — precon level">B1</button>
<button class="dg-bracket-btn" data-bracket="2" title="Upgraded Casual">B2</button>
<button class="dg-bracket-btn dg-bracket-active" data-bracket="3" title="High Power">B3</button>
<button class="dg-bracket-btn" data-bracket="4" title="cEDH — maximum power">B4</button>
</div>
</div>
</div>
</div>
<!-- Deck Name -->
<div class="dg-panel">
<div class="dg-panel-header">
<span class="dg-panel-title">Deck Name</span>
</div>
<div class="dg-panel-body">
<input
type="text"
class="dg-input"
id="dg-deck-name"
placeholder="Auto-generated from commander name"
/>
</div>
</div>
<!-- Budget -->
<div class="dg-panel">
<div class="dg-panel-header">
<span class="dg-panel-title">Budget</span>
</div>
<div class="dg-panel-body">
<div class="dg-budget-row">
<input
type="number"
class="dg-input dg-input-narrow"
id="dg-budget"
placeholder="No limit"
min="0"
step="25"
/>
<select class="dg-select" id="dg-budget-mode" title="Total Deck: caps the combined price of all 100 cards. Per Card Max: caps each individual card price.">
<option value="total">Total Deck $</option>
<option value="per_card">Per Card Max $</option>
</select>
</div>
</div>
</div>
<!-- Options -->
<div class="dg-panel">
<div class="dg-panel-header">
<span class="dg-panel-title">Options</span>
</div>
<div class="dg-panel-body">
<label class="dg-toggle-row">
<span class="dg-toggle-label">
Use my collection
<span class="tip-icon" data-tip="Prioritizes cards you already own before pulling from the full card pool. Saves money and ensures the deck is ready to play. Requires an imported collection.">?</span>
</span>
<input type="checkbox" class="dg-toggle-check" id="dg-use-collection" checked />
<span class="dg-toggle-track"><span class="dg-toggle-thumb"></span></span>
</label>
<label class="dg-toggle-row">
<span class="dg-toggle-label">
Smart Substitution
<span class="tip-icon" data-tip="When a suggested card isn't in your collection, automatically finds the closest functional replacement you do own. Keeps the deck playable without buying extra cards.">?</span>
</span>
<input type="checkbox" class="dg-toggle-check" id="dg-substitution" checked />
<span class="dg-toggle-track"><span class="dg-toggle-thumb"></span></span>
</label>
<div class="dg-model-row">
<label class="dg-label">
AI Model
<span class="tip-icon" data-tip="Deck generation is powered by Claude Opus 4, Anthropic's most capable model.">?</span>
</label>
<input type="hidden" id="dg-model" value="claude-opus-4-20250514" />
<span style="font-size:0.8rem; color:var(--text-muted, #888);">claude-opus-4-20250514</span>
</div>
</div>
</div>
<!-- Omit Cards (chips input) -->
<div class="dg-panel">
<div class="dg-panel-header">
<span class="dg-panel-title">Omit Cards</span>
</div>
<div class="dg-panel-body">
<div class="dg-omit-wrap" id="dg-omit-wrap">
<div class="dg-omit-chips" id="dg-omit-chips"></div>
<input
type="text"
class="dg-input dg-omit-input"
id="dg-omit-input"
placeholder="Type a card name and press Enter…"
/>
</div>
<small class="dg-hint">Cards listed here will be excluded from the generated deck.</small>
</div>
</div>
<!-- Action Buttons -->
<div class="dg-actions">
<button class="dg-btn dg-btn-preview" id="dg-preview-btn" disabled>
<span class="dg-btn-icon">&#9889;</span> Generate Deck
</button>
</div>
<!-- Conversation Log -->
<div id="dg-conversation-log" class="dg-conversation-log">
<div class="dg-log-entry dg-log-info"><span class="dg-log-ts">[Ready]</span> Waiting for deck generation...</div>
</div>
</section>
<!-- RIGHT: Preview Results -->
<section class="dg-col-results">
<!-- Empty State -->
<div class="dg-empty-state" id="dg-empty-state">
<div class="dg-empty-icon">&#9889;</div>
<div class="dg-empty-title">V3 AI Deck Generator</div>
<div class="dg-empty-text">Select a commander, set your bracket and strategy, then click "Generate Deck" to build a complete 100-card deck using Claude Opus with structured output.</div>
</div>
<!-- Loading State -->
<div class="dg-loading" id="dg-loading" style="display:none">
<div class="dg-spinner"></div>
<div class="dg-loading-text" id="dg-loading-text">Generating deck via Claude Opus 4...</div>
</div>
<div class="dg-progress-wrap" id="dg-progress-wrap" style="display:none">
<div class="dg-progress-bar">
<div class="dg-progress-fill" id="dg-progress-fill"></div>
</div>
<div class="dg-progress-meta">
<span class="dg-progress-phase" id="dg-progress-phase"></span>
<span class="dg-progress-elapsed" id="dg-progress-elapsed">0s elapsed</span>
</div>
</div>
<!-- Results -->
<div class="dg-results" id="dg-results" style="display:none">
<div class="dg-results-header">
<div id="dg-strategy-bar"></div>
<div id="dg-stats-bar" class="dg-stats-bar"></div>
<div id="dg-sub-summary" style="display:none"></div>
<div class="dg-result-actions">
<button class="dg-btn dg-btn-secondary" id="dg-regenerate-btn">&#8635; Regenerate</button>
<button class="dg-btn dg-btn-primary" id="dg-save-btn">&#128190; Save to Deck Builder</button>
<button class="dg-btn dg-btn-ghost" id="dg-export-csv">CSV</button>
<button class="dg-btn dg-btn-ghost" id="dg-export-dck">Forge .dck</button>
<button class="dg-btn dg-btn-ghost" id="dg-export-moxfield">Moxfield</button>
<button class="dg-btn dg-btn-ghost" id="dg-export-shopping">&#128722; Shopping</button>
</div>
</div>
<div id="dg-card-groups" class="dg-card-groups"></div>
</div>
</section>
</main>
<!-- TOAST CONTAINER -->
<div class="dg-toast-container" id="dg-toast-container"></div>
</div>
<script>
window.LAB_API_BASE = window.LAB_API_BASE || '';
</script>
<script src="deckgenerator.js"></script>
</body>
</html>
+795
View File
@@ -0,0 +1,795 @@
/**
* Commander AI Lab — Auto Deck Generator V3 UI
* ═══════════════════════════════════════════════
*
* V3 Endpoints:
* GET /api/deck/v3/status — check V3 generator status
* GET /api/deck-generator/commander-search — autocomplete commanders
* POST /api/deck/v3/generate — generate deck (local AI structured output)
* POST /api/deck/v3/commit — generate + save to Deck Builder
* POST /api/deck/v3/export/csv — export as CSV
* POST /api/deck/v3/export/dck — export as Forge .dck
* POST /api/deck/v3/export/moxfield — export in Moxfield paste format
* POST /api/deck/v3/export/shopping — shopping list of missing cards
*/
const DeckGenerator = (() => {
'use strict';
const API_BASE = window.LAB_API_BASE || window.location.origin;
const DEBOUNCE_MS = 350;
const COLOR_MAP = {
W: { label: 'W', cls: 'dg-pip-W' },
U: { label: 'U', cls: 'dg-pip-U' },
B: { label: 'B', cls: 'dg-pip-B' },
R: { label: 'R', cls: 'dg-pip-R' },
G: { label: 'G', cls: 'dg-pip-G' },
C: { label: 'C', cls: 'dg-pip-C' },
};
const TYPE_ORDER = ['Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land', 'Battle', 'Other'];
const TYPE_ICONS = {
'Creature': '🐉', 'Instant': '⚡', 'Sorcery': '🌀', 'Artifact': '🔧',
'Enchantment': '✨', 'Planeswalker': '🌟', 'Land': '🏔', 'Battle': '⚔',
'Other': '🃏',
};
const STATUS_BADGES = {
owned: { label: 'Owned', cls: 'dg-badge-owned' },
substituted: { label: 'Substituted', cls: 'dg-badge-substituted' },
missing: { label: 'Missing', cls: 'dg-badge-missing' },
};
// ── State ──────────────────────────────────────────────
let state = {
commander: null,
targetBracket: 3,
previewResult: null,
lastRequestBody: null,
isLoading: false,
v3Ready: false,
};
let searchTimeout = null;
// ── Progress bar state ──────────────────────────────
const PROGRESS_PHASES = [
{ at: 0, pct: 5, msg: 'Connecting to AI model...' },
{ at: 3, pct: 10, msg: 'Resolving commander on Scryfall...' },
{ at: 6, pct: 18, msg: 'Building collection summary...' },
{ at: 10, pct: 25, msg: 'Sending prompt to LLM...' },
{ at: 20, pct: 40, msg: 'AI is building the 99...' },
{ at: 35, pct: 55, msg: 'Selecting cards and filling slots...' },
{ at: 50, pct: 65, msg: 'Running smart substitution...' },
{ at: 70, pct: 78, msg: 'Cross-referencing with collection...' },
{ at: 90, pct: 88, msg: 'Almost there — finalizing deck...' },
{ at: 110, pct: 93, msg: 'Still working — large model can take a moment...' },
];
let _progressInterval = null;
let _progressStart = 0;
function startProgress() {
_progressStart = Date.now();
const wrap = $('dg-progress-wrap');
const fill = $('dg-progress-fill');
const phase = $('dg-progress-phase');
const elapsed = $('dg-progress-elapsed');
if (!wrap) return;
wrap.style.display = 'block';
fill.style.width = '0%';
phase.textContent = '';
elapsed.textContent = '0s elapsed';
let lastPhaseIdx = -1;
_progressInterval = setInterval(() => {
const secs = Math.floor((Date.now() - _progressStart) / 1000);
elapsed.textContent = secs + 's elapsed';
for (let i = PROGRESS_PHASES.length - 1; i >= 0; i--) {
if (secs >= PROGRESS_PHASES[i].at) {
fill.style.width = PROGRESS_PHASES[i].pct + '%';
if (i !== lastPhaseIdx) {
phase.textContent = PROGRESS_PHASES[i].msg;
logMessage(PROGRESS_PHASES[i].msg, 'info');
lastPhaseIdx = i;
}
break;
}
}
}, 1000);
}
function stopProgress(success) {
if (_progressInterval) {
clearInterval(_progressInterval);
_progressInterval = null;
}
const fill = $('dg-progress-fill');
const phase = $('dg-progress-phase');
const elapsed = $('dg-progress-elapsed');
if (fill) fill.style.width = success ? '100%' : '0%';
if (phase) phase.textContent = success ? 'Done!' : 'Failed';
const secs = Math.floor((Date.now() - _progressStart) / 1000);
if (elapsed) elapsed.textContent = secs + 's total';
}
// ── Omit Cards (chips) ─────────────────────────────
let omitCards = [];
function addOmitCard(name) {
const trimmed = name.trim();
if (!trimmed) return;
if (omitCards.some(c => c.toLowerCase() === trimmed.toLowerCase())) return;
omitCards.push(trimmed);
renderOmitChips();
}
function removeOmitCard(idx) {
omitCards.splice(idx, 1);
renderOmitChips();
}
function renderOmitChips() {
const container = $('dg-omit-chips');
if (!container) return;
container.innerHTML = omitCards.map((name, i) =>
'<span class="dg-chip">' +
escHtml(name) +
'<span class="dg-chip-x" data-idx="' + i + '">&times;</span>' +
'</span>'
).join('');
container.querySelectorAll('.dg-chip-x').forEach(el => {
el.addEventListener('click', () => removeOmitCard(parseInt(el.dataset.idx, 10)));
});
}
const $ = (id) => document.getElementById(id);
// ── Init ────────────────────────────────────────────────
async function init() {
await checkV3Status();
bindEvents();
checkQueryParams();
}
async function checkV3Status() {
try {
const resp = await fetch(API_BASE + '/api/deck/v3/status');
const data = await resp.json();
state.v3Ready = data.initialized;
if (!state.v3Ready) {
// Warn but do not block — local/Ollama mode may still work
console.warn('V3 status: not initialized. Error:', data.error || 'unknown');
toast('V3 generator not ready — check that the Anthropic API key is configured', 'warning');
}
} catch (e) {
console.warn('V3 status check failed:', e);
}
}
function bindEvents() {
// Commander search
const input = $('dg-commander-input');
input.addEventListener('input', () => {
clearTimeout(searchTimeout);
const q = input.value.trim();
if (q.length < 2) { hideDropdown(); return; }
searchTimeout = setTimeout(() => searchCommander(q), DEBOUNCE_MS);
});
input.addEventListener('focus', () => {
const q = input.value.trim();
if (q.length >= 2) searchCommander(q);
});
document.addEventListener('click', (e) => {
const wrap = document.querySelector('.dg-commander-search-wrap');
if (wrap && !wrap.contains(e.target)) hideDropdown();
});
$('dg-commander-clear').addEventListener('click', clearCommander);
// Bracket buttons
document.querySelectorAll('.dg-bracket-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.dg-bracket-btn').forEach(b => b.classList.remove('dg-bracket-active'));
btn.classList.add('dg-bracket-active');
state.targetBracket = parseInt(btn.dataset.bracket, 10);
});
});
// Generate & save
$('dg-preview-btn').addEventListener('click', generateDeck);
$('dg-save-btn').addEventListener('click', commitDeck);
$('dg-regenerate-btn').addEventListener('click', generateDeck);
// Export buttons
$('dg-export-csv').addEventListener('click', () => exportDeck('csv'));
$('dg-export-dck').addEventListener('click', () => exportDeck('dck'));
$('dg-export-moxfield').addEventListener('click', () => exportMoxfield());
$('dg-export-shopping').addEventListener('click', () => exportShopping());
// Omit cards chips input
const omitInput = $('dg-omit-input');
if (omitInput) {
omitInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
addOmitCard(omitInput.value);
omitInput.value = '';
}
});
}
const omitWrap = $('dg-omit-wrap');
if (omitWrap) {
omitWrap.addEventListener('click', () => omitInput && omitInput.focus());
}
}
function checkQueryParams() {
const params = new URLSearchParams(window.location.search);
const name = params.get('commander');
if (name) {
$('dg-commander-input').value = name;
searchCommander(name);
}
}
// ── Build Request Body ──────────────────────────────────
function buildRequestBody() {
const budget = parseFloat($('dg-budget').value) || null;
return {
commander_name: state.commander.name,
strategy: $('dg-strategy').value.trim(),
target_bracket: state.targetBracket,
budget_usd: budget,
budget_mode: $('dg-budget-mode').value,
omit_cards: omitCards.slice(),
use_collection: $('dg-use-collection').checked,
run_substitution: $('dg-substitution').checked,
model: $('dg-model').value,
deck_name: $('dg-deck-name').value.trim() || '',
};
}
// ── Commander Search ────────────────────────────────────
function positionDropdown() {
const input = $('dg-commander-input');
const dropdown = $('dg-commander-dropdown');
const rect = input.getBoundingClientRect();
dropdown.style.top = (rect.bottom + 4) + 'px';
dropdown.style.left = rect.left + 'px';
dropdown.style.width = rect.width + 'px';
}
async function searchCommander(q) {
const dropdown = $('dg-commander-dropdown');
dropdown.innerHTML = '<div class="dg-dropdown-loading">Searching...</div>';
dropdown.classList.add('open');
positionDropdown();
try {
const resp = await fetch(API_BASE + '/api/deck-generator/commander-search?q=' + encodeURIComponent(q));
const data = await resp.json();
renderDropdown(data.results || []);
} catch (e) {
dropdown.innerHTML = '<div class="dg-dropdown-empty">Search failed</div>';
}
}
function renderDropdown(results) {
const dropdown = $('dg-commander-dropdown');
if (results.length === 0) {
dropdown.innerHTML = '<div class="dg-dropdown-empty">No legendary creatures found</div>';
return;
}
dropdown.innerHTML = results.map((r, idx) => {
const colors = (r.color_identity || [])
.map(c => COLOR_MAP[c] ? '<span class="dg-color-pip ' + COLOR_MAP[c].cls + '">' + COLOR_MAP[c].label + '</span>' : '')
.join('');
const badge = r.in_collection
? '<span class="dg-dropdown-item-badge dg-badge-owned">Owned</span>'
: '<span class="dg-dropdown-item-badge dg-badge-scryfall">Scryfall</span>';
const imgSrc = r.image_url ? r.image_url.replace('version=normal', 'version=small') : '';
const imgTag = imgSrc
? '<img class="dg-dropdown-item-img" src="' + imgSrc + '" alt="" loading="lazy" />'
: '<div class="dg-dropdown-item-img" style="background:var(--lab-surface)"></div>';
return '<div class="dg-dropdown-item" data-idx="' + idx + '">'
+ imgTag
+ '<div class="dg-dropdown-item-info">'
+ ' <div class="dg-dropdown-item-name">' + escHtml(r.name) + '</div>'
+ ' <div class="dg-dropdown-item-type">' + escHtml(r.type_line || '') + '</div>'
+ '</div>'
+ '<div style="display:flex;gap:3px;align-items:center">' + colors + '</div>'
+ badge
+ '</div>';
}).join('');
dropdown.querySelectorAll('.dg-dropdown-item').forEach(el => {
el.addEventListener('click', () => {
selectCommander(results[parseInt(el.dataset.idx, 10)]);
hideDropdown();
});
});
}
function selectCommander(cmdr) {
state.commander = cmdr;
$('dg-commander-input').style.display = 'none';
$('dg-commander-selected').style.display = 'block';
$('dg-commander-name').textContent = cmdr.name;
$('dg-commander-type').textContent = cmdr.type_line || '';
$('dg-commander-colors').innerHTML = (cmdr.color_identity || [])
.map(c => COLOR_MAP[c] ? '<span class="dg-color-pip ' + COLOR_MAP[c].cls + '">' + COLOR_MAP[c].label + '</span>' : '')
.join('');
const img = $('dg-commander-img');
if (cmdr.image_url) {
var imgUrl = cmdr.image_url;
if (imgUrl.indexOf('version=small') !== -1) {
imgUrl = imgUrl.replace('version=small', 'version=normal');
} else if (imgUrl.indexOf('version=') === -1 && imgUrl.indexOf('?') !== -1) {
imgUrl += '&version=normal';
} else if (imgUrl.indexOf('version=') === -1) {
imgUrl += '?version=normal';
}
img.src = imgUrl;
img.style.display = 'block';
} else {
img.style.display = 'none';
}
$('dg-preview-btn').disabled = false;
// Auto-fill deck name if empty
const deckNameInput = $('dg-deck-name');
if (deckNameInput && !deckNameInput.value.trim()) {
deckNameInput.value = 'Auto - ' + cmdr.name;
}
}
function clearCommander() {
state.commander = null;
$('dg-commander-input').style.display = '';
$('dg-commander-input').value = '';
$('dg-commander-selected').style.display = 'none';
$('dg-preview-btn').disabled = true;
}
function hideDropdown() {
$('dg-commander-dropdown').classList.remove('open');
}
// -- Conversation Log Helper --
function logMessage(msg, type = 'info') {
const log = $('dg-conversation-log');
if (!log) return;
const entry = document.createElement('div');
entry.className = 'dg-log-entry dg-log-' + type;
const ts = new Date().toLocaleTimeString();
entry.innerHTML = '<span class="dg-log-ts">[' + ts + ']</span> ' + msg;
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
}
function clearLog() {
const log = $('dg-conversation-log');
if (!log) return;
log.innerHTML = '';
}
// ── Generate Deck ───────────────────────────────────────
async function generateDeck() {
clearLog();
logMessage('Connecting to Claude AI...', 'info');
if (!state.commander) { toast('Select a commander first', 'error'); return; }
// Non-blocking warning only — local Ollama mode does not require v3Ready
if (!state.v3Ready) {
logMessage('Warning: V3 status check failed. Attempting generation anyway via local AI...', 'warning');
}
logMessage('Building request body with commander: <b>' + state.commander.name + '</b>', 'info');
setLoading(true, 'Generating deck via Local AI...');
startProgress();
const body = buildRequestBody();
logMessage('Sending request to AI model... this may take a moment.', 'info');
state.lastRequestBody = body;
try {
const resp = await fetch(API_BASE + '/api/deck/v3/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || err.detail || 'Generation failed');
}
state.previewResult = await resp.json();
logMessage('AI response received! Processing deck data...', 'success');
const stats = state.previewResult.stats || {};
const cardCount = (stats.total_cards || 0);
logMessage('Deck generated: <b>' + cardCount + ' cards</b> | $' + (stats.total_price_usd || 0).toFixed(2) + ' estimated', 'success');
stopProgress(true);
renderResults(state.previewResult);
toast('Deck generated: ' + (stats.total_cards || 0) + ' cards | $' + (stats.total_price_usd || 0), 'success');
} catch (e) {
stopProgress(false);
toast(e.message || 'Generation failed', 'error');
logMessage('Error: ' + (e.message || 'Unknown error'), 'error');
setLoading(false);
}
}
function setLoading(loading, text) {
state.isLoading = loading;
$('dg-empty-state').style.display = 'none';
$('dg-loading').style.display = loading ? 'flex' : 'none';
$('dg-results').style.display = loading ? 'none' : 'block';
$('dg-preview-btn').disabled = loading || !state.commander;
if (text) $('dg-loading-text').textContent = text;
}
// ── Render Results ──────────────────────────────────────
function renderResults(result) {
$('dg-loading').style.display = 'none';
$('dg-results').style.display = 'block';
$('dg-preview-btn').disabled = false;
renderStrategyBar(result);
renderStatsBar(result);
renderSubstitutionSummary(result);
renderCardGroups(result);
renderUseInGameButton(result);
}
function renderUseInGameButton(result) {
// Only show when running inside the LAN client iframe
if (window.parent === window) return;
var existing = document.getElementById('dg-export-game-btn');
if (existing) existing.remove();
var btn = document.createElement('button');
btn.id = 'dg-export-game-btn';
btn.className = 'dg-btn dg-btn-preview';
btn.style.cssText = 'margin:12px auto;display:block;border-color:rgba(91,141,217,0.4);color:rgba(91,141,217,0.9);';
btn.innerHTML = '<span class="dg-btn-icon">&#x1F3AE;</span> Use in Game';
btn.addEventListener('click', function() {
var cards = (result.cards || []).map(function(c) {
return { name: c.name, quantity: c.count || 1, is_commander: false };
});
if (result.commander) {
cards.unshift({ name: result.commander.name, quantity: 1, is_commander: true });
}
window.parent.postMessage({
type: 'DECK_BUILDER_EXPORT',
deck: cards,
deckName: result.commander ? result.commander.name + ' Deck' : 'Generated Deck',
commander: result.commander ? result.commander.name : '',
}, '*');
toast('Deck sent to game setup!', 'success');
});
$('dg-results').appendChild(btn);
}
function renderStrategyBar(result) {
const bar = $('dg-strategy-bar');
if (!bar) return;
const bracket = result.bracket || {};
const archetype = result.archetype || '';
const strategy = result.strategy_summary || '';
bar.innerHTML = '<div class="dg-strategy-info">'
+ '<span class="dg-bracket-badge dg-bracket-' + (bracket.level || 0) + '">B' + (bracket.level || '?') + '</span>'
+ '<span class="dg-archetype-badge">' + escHtml(archetype) + '</span>'
+ '<span class="dg-strategy-text">' + escHtml(strategy) + '</span>'
+ '</div>'
+ (bracket.reasoning ? '<div class="dg-bracket-reasoning">' + escHtml(bracket.reasoning) + '</div>' : '')
+ (bracket.game_changers && bracket.game_changers.length
? '<div class="dg-game-changers">Game Changers: ' + bracket.game_changers.map(c => '<span class="dg-gc-chip">' + escHtml(c) + '</span>').join('') + '</div>'
: '');
}
function renderStatsBar(result) {
const stats = result.stats || {};
const container = $('dg-stats-bar');
if (!container) return;
const chips = [
chipHtml('Total', stats.total_cards || 0, ''),
chipHtml('Owned', (stats.by_status || {}).owned || 0, 'dg-stat-owned'),
chipHtml('Subbed', (stats.by_status || {}).substituted || 0, 'dg-stat-substituted'),
chipHtml('Missing', (stats.by_status || {}).missing || 0, 'dg-stat-missing'),
chipHtml('Lands', stats.land_count || 0, ''),
chipHtml('$' + (stats.total_price_usd || 0).toFixed(0), 'Total', 'dg-stat-price'),
];
container.innerHTML = chips.join('');
}
function chipHtml(label, num, cls) {
return '<div class="dg-stat-chip ' + cls + '">'
+ '<span class="dg-stat-num">' + num + '</span>'
+ '<span class="dg-stat-label">' + label + '</span>'
+ '</div>';
}
function renderSubstitutionSummary(result) {
const el = $('dg-sub-summary');
const sub = result.substitution_stats;
if (!sub) { el.style.display = 'none'; return; }
el.style.display = 'block';
el.innerHTML = '<div class="dg-sub-bar">'
+ '<span class="dg-sub-stat dg-sub-owned">' + sub.owned + ' owned</span>'
+ '<span class="dg-sub-stat dg-sub-substituted">' + sub.substituted + ' substituted</span>'
+ '<span class="dg-sub-stat dg-sub-missing">' + sub.missing + ' still missing</span>'
+ '</div>';
}
function renderCardGroups(result) {
const container = $('dg-card-groups');
if (!container) return;
const cards = result.cards || [];
const commander = result.commander;
const groups = {};
for (const card of cards) {
const cat = card.category || 'Other';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(card);
}
const sortedTypes = TYPE_ORDER.filter(t => groups[t] && groups[t].length > 0);
for (const t of Object.keys(groups)) {
if (!sortedTypes.includes(t)) sortedTypes.push(t);
}
let html = '';
if (commander) {
html += '<div class="dg-card-group">'
+ '<div class="dg-group-header">'
+ '<span class="dg-group-title">⚔ Commander</span>'
+ '<span class="dg-group-count">1</span>'
+ '</div>'
+ '<div class="dg-group-body">'
+ '<div class="dg-card-row dg-card-row-commander">'
+ '<span class="dg-card-row-name" style="font-weight:600;color:#fff">' + escHtml(commander.name) + '</span>'
+ '<span class="dg-card-row-source dg-src-collection">Commander</span>'
+ '</div>'
+ '</div></div>';
}
for (const type of sortedTypes) {
const groupCards = groups[type];
if (!groupCards || groupCards.length === 0) continue;
groupCards.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const icon = TYPE_ICONS[type] || '🃏';
html += '<div class="dg-card-group">'
+ '<div class="dg-group-header" data-type="' + type + '">'
+ '<span class="dg-group-title">' + icon + ' ' + escHtml(type) + '</span>'
+ '<span class="dg-group-count">' + groupCards.reduce(function(s,c){ return s + (c.count || 1); }, 0) + '</span>'
+ '</div>'
+ '<div class="dg-group-body" data-group="' + type + '">';
for (const card of groupCards) {
html += renderCardRow(card);
}
html += '</div></div>';
}
container.innerHTML = html;
container.querySelectorAll('.dg-group-header').forEach(header => {
header.addEventListener('click', () => {
const type = header.dataset.type;
if (!type) return;
const body = container.querySelector('.dg-group-body[data-group="' + type + '"]');
if (body) body.classList.toggle('collapsed');
});
});
}
function renderCardRow(card) {
const statusInfo = STATUS_BADGES[card.status] || STATUS_BADGES.missing;
const statusBadge = '<span class="dg-card-status ' + statusInfo.cls + '">' + statusInfo.label + '</span>';
const roleTags = (card.role_tags || []).slice(0, 3)
.map(r => '<span class="dg-role-chip">' + escHtml(r) + '</span>')
.join('');
let subHtml = '';
if (card.status === 'substituted' && card.selected_substitute) {
subHtml = '<div class="dg-card-sub-line">'
+ '<span class="dg-sub-arrow">→</span> '
+ '<span class="dg-sub-name">' + escHtml(card.selected_substitute) + '</span>'
+ '</div>';
} else if (card.status === 'missing' && card.alternatives && card.alternatives.length > 0) {
subHtml = '<div class="dg-card-sub-line dg-sub-suggestions">'
+ '<span class="dg-sub-label">Alternatives:</span> '
+ card.alternatives.slice(0, 3).map(a =>
'<span class="dg-alt-chip" title="' + escAttr(a.reason || '') + '">'
+ escHtml(a.name) + ' (' + (a.similarity_score * 100).toFixed(0) + '%)'
+ '</span>'
).join('')
+ '</div>';
}
const price = card.estimated_price_usd
? '<span class="dg-card-price">$' + card.estimated_price_usd.toFixed(2) + '</span>'
: '';
return '<div class="dg-card-row dg-card-status-' + card.status + '">'
+ '<div class="dg-card-row-main">'
+ '<span class="dg-card-row-name">' + ((card.count || 1) > 1 ? '<span class="dg-card-count">' + card.count + 'x</span> ' : '') + escHtml(card.name) + '</span>'
+ '<span class="dg-card-row-roles">' + roleTags + '</span>'
+ statusBadge
+ price
+ '</div>'
+ (card.reason ? '<div class="dg-card-reason">' + escHtml(card.reason) + '</div>' : '')
+ subHtml
+ '</div>';
}
// ── Commit Deck ─────────────────────────────────────────
async function commitDeck() {
if (!state.commander) { toast('No commander selected', 'error'); return; }
$('dg-save-btn').disabled = true;
$('dg-save-btn').textContent = 'Saving...';
try {
const body = state.lastRequestBody || buildRequestBody();
const resp = await fetch(API_BASE + '/api/deck/v3/commit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || err.detail || 'Save failed');
}
const result = await resp.json();
toast('Deck saved: ' + (result.deck_name || 'Unknown'), 'success');
setTimeout(() => {
window.location.href = 'deckbuilder.html?deck_id=' + result.deck_id;
}, 1200);
} catch (e) {
toast(e.message || 'Save failed', 'error');
} finally {
$('dg-save-btn').disabled = false;
$('dg-save-btn').innerHTML = '<span class="dg-btn-icon">💾</span> Save to Deck Builder';
}
}
// ── Exports ─────────────────────────────────────────────
async function exportDeck(format) {
if (!state.commander) { toast('Generate a deck first', 'error'); return; }
const body = state.lastRequestBody || buildRequestBody();
try {
const resp = await fetch(API_BASE + '/api/deck/v3/export/' + format, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) throw new Error('Export failed');
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (state.commander.name || 'deck').replace(/\s+/g, '_') + '.' + (format === 'csv' ? 'csv' : 'dck');
a.click();
URL.revokeObjectURL(url);
toast('Exported ' + format.toUpperCase(), 'success');
} catch (e) {
toast(e.message || 'Export failed', 'error');
}
}
async function exportMoxfield() {
if (!state.commander) { toast('Generate a deck first', 'error'); return; }
const body = state.lastRequestBody || buildRequestBody();
try {
const resp = await fetch(API_BASE + '/api/deck/v3/export/moxfield', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await resp.json();
if (data.content) {
await navigator.clipboard.writeText(data.content);
toast('Moxfield deck copied to clipboard', 'success');
}
} catch (e) {
toast('Moxfield export failed', 'error');
}
}
async function exportShopping() {
if (!state.commander) { toast('Generate a deck first', 'error'); return; }
const body = state.lastRequestBody || buildRequestBody();
try {
const resp = await fetch(API_BASE + '/api/deck/v3/export/shopping', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await resp.json();
const list = data.shopping_list || [];
if (list.length === 0) {
toast('All cards are owned — nothing to buy', 'success');
return;
}
const lines = ['Shopping List — ' + (data.commander || 'Deck'), ''];
for (const item of list) {
lines.push(item.count + 'x ' + item.name + ' ($' + (item.estimated_price_usd || 0).toFixed(2) + ') — ' + item.category);
}
lines.push('', 'Total: ' + list.length + ' cards, ~$' + (data.estimated_cost_usd || 0).toFixed(2));
await navigator.clipboard.writeText(lines.join('\n'));
toast('Shopping list copied (' + list.length + ' cards, ~$' + (data.estimated_cost_usd || 0).toFixed(0) + ')', 'success');
} catch (e) {
toast('Shopping list failed', 'error');
}
}
// ── Toast ───────────────────────────────────────────────
function toast(msg, type) {
type = type || 'info';
const container = $('dg-toast-container');
const el = document.createElement('div');
el.className = 'dg-toast dg-toast-' + type;
el.textContent = msg;
container.appendChild(el);
setTimeout(() => el.remove(), 4000);
}
// ── Helpers ─────────────────────────────────────────────
function escHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function escAttr(str) {
return str.replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
return { init };
})();
document.addEventListener('DOMContentLoaded', () => {
DeckGenerator.init();
});
+200 -105
View File
@@ -2088,7 +2088,7 @@ function autoResolveTriggerEffect(trigger) {
// Helper: find the card on battlefield for counter placement
function findCardOnBattlefield(cardName, ownerIdx) {
var bf = (gameState.players[ownerIdx] || {}).battlefield || [];
var bf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === ownerIdx; });
for (var i = 0; i < bf.length; i++) {
if (bf[i].name === cardName) return bf[i];
}
@@ -2233,7 +2233,7 @@ function autoResolveTriggerEffect(trigger) {
var opps4 = getOpponents(oi);
var placed = false;
for (var cmi = 0; cmi < opps4.length && !placed; cmi++) {
var cmBf = (gameState.players[opps4[cmi]] || {}).battlefield || [];
var cmBf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === opps4[cmi]; });
for (var cmj = 0; cmj < cmBf.length && !placed; cmj++) {
if (cmBf[cmj].type_line && cmBf[cmj].type_line.toLowerCase().indexOf('creature') !== -1) {
cmBf[cmj].counters = cmBf[cmj].counters || {};
@@ -2250,7 +2250,7 @@ function autoResolveTriggerEffect(trigger) {
case 'counter_all': {
// +1/+1 counter on each creature you control
var caBf = (gameState.players[oi] || {}).battlefield || [];
var caBf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === oi; });
var caCount = 0;
for (var cai = 0; cai < caBf.length; cai++) {
if (caBf[cai].type_line && caBf[cai].type_line.toLowerCase().indexOf('creature') !== -1) {
@@ -2362,7 +2362,7 @@ function autoResolveTriggerEffect(trigger) {
}
case 'untap_all': {
var uaBf = (gameState.players[oi] || {}).battlefield || [];
var uaBf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === oi; });
var uaCount = 0;
for (var uai = 0; uai < uaBf.length; uai++) {
if (uaBf[uai].tapped) {
@@ -3247,19 +3247,15 @@ function addCardToBattlefield(name, imageUrl, typeLine, ownerIndex, x, y, oracle
} else {
// Place cards by type in designated battlefield zones
var zone = getCardTypeZone(typeLine || '');
var cardW = 110, cardH = 150;
var cardW = 63, cardH = 84;
// Zone regions (percentage of section area)
// Creatures: top center (30%-70% x, 5%-45% y)
// Artifacts/Enchantments: top right (70%-95% x, 5%-45% y)
// Lands: bottom left (5%-35% x, 55%-90% y)
// Instants/Sorceries: bottom center (35%-65% x, 55%-90% y)
// Other: center (30%-70% x, 25%-75% y)
// Offset from edges to avoid overlapping the overlaid zone-row/commander-row
var zones = {
creature: { x1: 0.25, x2: 0.65, y1: 0.05, y2: 0.42 },
artifact: { x1: 0.68, x2: 0.95, y1: 0.05, y2: 0.42 },
land: { x1: 0.03, x2: 0.32, y1: 0.55, y2: 0.90 },
spell: { x1: 0.35, x2: 0.65, y1: 0.55, y2: 0.90 },
other: { x1: 0.25, x2: 0.70, y1: 0.25, y2: 0.75 }
creature: { x1: 0.25, x2: 0.70, y1: 0.20, y2: 0.55 },
artifact: { x1: 0.70, x2: 0.95, y1: 0.20, y2: 0.55 },
land: { x1: 0.03, x2: 0.35, y1: 0.55, y2: 0.85 },
spell: { x1: 0.35, x2: 0.65, y1: 0.55, y2: 0.85 },
other: { x1: 0.25, x2: 0.70, y1: 0.30, y2: 0.70 }
};
var z = zones[zone] || zones.other;
var areaW = rect.width;
@@ -5290,8 +5286,7 @@ function setLife(playerId, newLife) {
eliminatePlayer(playerId, 'life total reached 0');
}
checkStateBasedActions();
// Sync enhanced player panels
if (typeof renderEnhancedPanels === 'function') renderEnhancedPanels();
if (typeof renderCmdDmgInline === 'function') renderCmdDmgInline();
}
// ============================================================
@@ -5326,8 +5321,7 @@ function adjustCommanderDamage(targetPlayerId, sourcePlayerId, amount) {
panelContainer.replaceWith(newPanel);
}
checkStateBasedActions();
// Sync enhanced player panels
if (typeof renderEnhancedPanels === 'function') renderEnhancedPanels();
if (typeof renderCmdDmgInline === 'function') renderCmdDmgInline();
}
// ============================================================
@@ -5360,8 +5354,6 @@ function adjustPoison(playerId, amount) {
panelContainer.replaceWith(newPanel);
}
if (gameState.rulesEngine && gameState.rulesEngine.sbaChecks) checkStateBasedActions();
// Sync enhanced player panels
if (typeof renderEnhancedPanels === 'function') renderEnhancedPanels();
}
// ============================================================
@@ -8245,6 +8237,7 @@ function drawCard(playerId) {
}
const card = player.zones.library.shift(); // Take from top (index 0)
if (typeof _enrichCardFromCache === 'function') _enrichCardFromCache(card);
player.zones.hand.push(card);
addLogEntry('<strong>' + player.name + '</strong> drew a card (' + player.zones.hand.length + ' in hand)');
updatePlayerZones();
@@ -12560,32 +12553,6 @@ function renderDeckSelectionGrid() {
'<div style="color:var(--color-text-secondary);font-size:var(--text-sm);">AI Deck (auto-generated)</div>';
} else {
var idx = i;
// Build saved decks options
var savedDecks = (typeof getDecks === 'function') ? getDecks() : [];
var savedDecksHtml = '';
if (savedDecks.length === 0) {
savedDecksHtml = '<div style="color:var(--color-text-faint);font-size:var(--text-sm);padding:var(--space-3);">No saved decks. Build one in Deck Builder first.</div>';
} else {
savedDecksHtml = '<div class="setup-custom-deck-list" id="setup-custom-deck-list-' + idx + '">';
for (var di = 0; di < savedDecks.length; di++) {
var sd = savedDecks[di];
var cardCount = 0;
if (sd.cards) sd.cards.forEach(function(c) { cardCount += (c.quantity || 1); });
if (sd.commander) cardCount++;
var deckColors = '';
if (sd.commander && sd.commander.colorIdentity && sd.commander.colorIdentity.length) {
var manaSymbols = { W: '\u2600', U: '\uD83D\uDCA7', B: '\uD83D\uDC80', R: '\uD83D\uDD25', G: '\uD83C\uDF33' };
sd.commander.colorIdentity.forEach(function(c) { deckColors += (manaSymbols[c] || c) + ' '; });
}
savedDecksHtml += '<button class="setup-custom-deck-btn" id="setup-custom-deck-btn-' + idx + '-' + di + '"' +
' onclick="selectSetupCustomDeck(' + idx + ',\'' + sd.id.replace(/'/g, "\\'") + '\')">' +
'<span class="setup-custom-deck-name">' + (sd.name || 'Untitled').replace(/</g, '&lt;') + '</span>' +
'<span class="setup-custom-deck-meta">' + deckColors + cardCount + ' cards</span>' +
'</button>';
}
savedDecksHtml += '</div>';
}
panel.innerHTML =
'<div class="deck-select-panel-header">' +
'<div class="player-color-dot" style="background:' + color + '"></div>' +
@@ -12595,7 +12562,6 @@ function renderDeckSelectionGrid() {
'<div class="deck-select-tabs">' +
'<button class="deck-select-tab active" id="setup-deck-tab-precon-' + idx + '" onclick="switchSetupDeckTab(' + idx + ',\'precon\')">Precon</button>' +
'<button class="deck-select-tab" id="setup-deck-tab-mydecks-' + idx + '" onclick="switchSetupDeckTab(' + idx + ',\'mydecks\')">My Decks</button>' +
'<button class="deck-select-tab" id="setup-deck-tab-import-' + idx + '" onclick="switchSetupDeckTab(' + idx + ',\'import\')">Import</button>' +
'</div>' +
'<div class="deck-select-tab-content active" id="setup-deck-content-precon-' + idx + '">' +
'<select class="deck-select-precon-select" id="setup-deck-precon-select-' + idx + '" onchange="selectSetupPrecon(' + idx + ')">' +
@@ -12603,20 +12569,95 @@ function renderDeckSelectionGrid() {
'</select>' +
'</div>' +
'<div class="deck-select-tab-content" id="setup-deck-content-mydecks-' + idx + '">' +
savedDecksHtml +
'</div>' +
'<div class="deck-select-tab-content" id="setup-deck-content-import-' + idx + '">' +
'<textarea class="deck-select-import-text" id="setup-deck-import-text-' + idx + '" placeholder="Paste deck list here...&#10;&#10;// Commander&#10;1 Atraxa, Praetors\' Voice&#10;&#10;// Deck&#10;1 Sol Ring&#10;..."></textarea>' +
'<button class="deck-select-import-btn" onclick="importSetupDeck(' + idx + ')">\uD83D\uDCE5 Validate &amp; Use This List</button>' +
'<div class="setup-custom-deck-list" id="setup-custom-deck-list-' + idx + '">' +
'<div style="color:var(--color-text-faint);font-size:var(--text-sm);padding:var(--space-3);">Loading user decks...</div>' +
'</div>' +
'</div>';
}
grid.appendChild(panel);
}
// Fetch user-created decks from the API and populate My Decks tabs
_loadUserDecksForSetup();
}
function _loadUserDecksForSetup() {
var apiBase = (typeof LAB_API_BASE !== 'undefined' && LAB_API_BASE) ? LAB_API_BASE : '';
fetch(apiBase + '/api/lab/decks')
.then(function(res) { return res.json(); })
.then(function(data) {
var decks = (data.decks || []).filter(function(d) { return d.source === 'deckbuilder'; });
for (var i = 0; i < setupPlayerCount; i++) {
if (setupAiPlayers.has(i)) continue;
var listEl = document.getElementById('setup-custom-deck-list-' + i);
if (!listEl) continue;
if (decks.length === 0) {
listEl.innerHTML = '<div style="color:var(--color-text-faint);font-size:var(--text-sm);padding:var(--space-3);">No saved decks. Build one in Deck Builder first.</div>';
continue;
}
var html = '';
for (var di = 0; di < decks.length; di++) {
var d = decks[di];
var cardCount = d.totalCards || d.card_count || d.total_cards || 0;
var cmdr = d.commander || '';
html += '<button class="setup-custom-deck-btn" id="setup-custom-deck-btn-' + i + '-' + di + '"' +
' onclick="selectSetupUserDeck(' + i + ',' + d.deck_id + ',\'' + (d.name || 'Untitled').replace(/'/g, "\\'") + '\')">' +
'<span class="setup-custom-deck-name">' + (d.name || 'Untitled').replace(/</g, '&lt;') + '</span>' +
'<span class="setup-custom-deck-meta">' + (cmdr ? cmdr + ' \u2014 ' : '') + cardCount + ' cards</span>' +
'</button>';
}
listEl.innerHTML = html;
}
})
.catch(function(e) {
console.warn('[Setup] Could not load user decks:', e);
for (var i = 0; i < setupPlayerCount; i++) {
if (setupAiPlayers.has(i)) continue;
var listEl = document.getElementById('setup-custom-deck-list-' + i);
if (listEl) listEl.innerHTML = '<div style="color:var(--color-text-faint);font-size:var(--text-sm);padding:var(--space-3);">Could not load decks. Is the server running?</div>';
}
});
}
function selectSetupUserDeck(playerIdx, deckId, deckName) {
var statusEl = document.getElementById('setup-deck-status-' + playerIdx);
// Highlight selected button
var listEl = document.getElementById('setup-custom-deck-list-' + playerIdx);
if (listEl) {
listEl.querySelectorAll('.setup-custom-deck-btn').forEach(function(b) { b.classList.remove('selected'); });
}
// Find clicked button and highlight it
var allBtns = listEl ? listEl.querySelectorAll('.setup-custom-deck-btn') : [];
allBtns.forEach(function(b) {
if (b.textContent.indexOf(deckName) !== -1) b.classList.add('selected');
});
// Export the deck to a .dck file, then store it as a precon-style choice
var apiBase = (typeof LAB_API_BASE !== 'undefined' && LAB_API_BASE) ? LAB_API_BASE : '';
if (statusEl) { statusEl.textContent = 'Exporting...'; statusEl.className = 'deck-select-status'; }
fetch(apiBase + '/api/decks/' + deckId + '/export-to-sim', { method: 'POST' })
.then(function(res) { return res.json(); })
.then(function(result) {
if (result.success) {
setupDeckChoices[playerIdx] = { type: 'user-deck', deckName: result.deckName, fileName: result.deckName + '.dck' };
if (statusEl) { statusEl.textContent = deckName + ' (' + (result.totalCards || '?') + ' cards)'; statusEl.className = 'deck-select-status loaded'; }
} else {
if (statusEl) { statusEl.textContent = 'Export failed'; statusEl.className = 'deck-select-status'; }
}
})
.catch(function(e) {
console.error('[Setup] Export failed:', e);
if (statusEl) { statusEl.textContent = 'Export error'; statusEl.className = 'deck-select-status'; }
});
}
function switchSetupDeckTab(playerIdx, tab) {
var tabs = ['precon', 'mydecks', 'import'];
var tabs = ['precon', 'mydecks'];
tabs.forEach(function(t) {
var tabBtn = document.getElementById('setup-deck-tab-' + t + '-' + playerIdx);
var content = document.getElementById('setup-deck-content-' + t + '-' + playerIdx);
@@ -12645,51 +12686,6 @@ function selectSetupPrecon(playerIdx) {
if (statusEl) { statusEl.textContent = 'Precon: ' + preconName; statusEl.className = 'deck-select-status loaded'; }
}
function importSetupDeck(playerIdx) {
var textarea = document.getElementById('setup-deck-import-text-' + playerIdx);
var statusEl = document.getElementById('setup-deck-status-' + playerIdx);
if (!textarea) return;
var text = textarea.value.trim();
if (!text) {
if (statusEl) { statusEl.textContent = 'Paste a deck list first.'; statusEl.className = 'deck-select-status'; }
return;
}
var parsed = parseDeckList(text);
if (!parsed.length) {
if (statusEl) { statusEl.textContent = 'Could not parse cards from list.'; statusEl.className = 'deck-select-status'; }
return;
}
var total = parsed.reduce(function(sum, e) { return sum + e.qty; }, 0);
setupDeckChoices[playerIdx] = { type: 'import', text: text };
if (statusEl) { statusEl.textContent = 'Deck ready (' + total + ' cards)'; statusEl.className = 'deck-select-status loaded'; }
}
function selectSetupCustomDeck(playerIdx, deckId) {
var decks = (typeof getDecks === 'function') ? getDecks() : [];
var deck = decks.find(function(d) { return d.id === deckId; });
if (!deck) return;
var statusEl = document.getElementById('setup-deck-status-' + playerIdx);
// Highlight selected button, unhighlight others
var listEl = document.getElementById('setup-custom-deck-list-' + playerIdx);
if (listEl) {
listEl.querySelectorAll('.setup-custom-deck-btn').forEach(function(b) { b.classList.remove('selected'); });
}
var btns = document.querySelectorAll('[id^="setup-custom-deck-btn-' + playerIdx + '-"]');
btns.forEach(function(b) { b.classList.remove('selected'); });
// Find the right button by matching deckId in onclick
btns.forEach(function(b) {
if (b.getAttribute('onclick') && b.getAttribute('onclick').indexOf(deckId) !== -1) {
b.classList.add('selected');
}
});
setupDeckChoices[playerIdx] = { type: 'custom', deckId: deckId };
var cardCount = 0;
if (deck.cards) deck.cards.forEach(function(c) { cardCount += (c.quantity || 1); });
if (deck.commander) cardCount++;
if (statusEl) { statusEl.textContent = deck.name + ' (' + cardCount + ' cards)'; statusEl.className = 'deck-select-status loaded'; }
}
async function loadCustomDeckForPlayer(playerIdx, deckId) {
var decks = (typeof getDecks === 'function') ? getDecks() : [];
@@ -12783,6 +12779,9 @@ async function loadPreconForPlayer(playerIdx, preconId) {
if (commandCards.length > 0) {
player.commanderCardName = commandCards[0].name || '';
}
if (precon.fileName) {
player.deckFileName = precon.fileName;
}
// Shuffle library
for (var si = libraryCards.length - 1; si > 0; si--) {
@@ -12801,6 +12800,28 @@ async function loadPreconForPlayer(playerIdx, preconId) {
addLogEntry('\uD83D\uDCCB <strong>' + player.name + '</strong> loaded precon "' + precon.name + '" (' + (libraryCards.length + commandCards.length) + ' cards)');
}
async function loadUserDeckForPlayer(playerIdx, deckName) {
var player = gameState.players[playerIdx];
if (!player || !deckName) return;
// Fetch the .dck file content from user-decks/ via static mount or API
var apiBase = (typeof LAB_API_BASE !== 'undefined' && LAB_API_BASE) ? LAB_API_BASE : '';
var deckText = '';
try {
var resp = await fetch(apiBase + '/user-decks/' + encodeURIComponent(deckName) + '.dck');
if (resp.ok) deckText = await resp.text();
} catch (e) {
console.warn('[loadUserDeckForPlayer] Could not fetch .dck file:', e);
}
if (!deckText) {
addLogEntry('\u26A0 Could not load user deck "' + deckName + '"');
return;
}
await importDeckForPlayer(playerIdx, deckText);
player.deckFileName = deckName + '.dck';
}
async function importDeckForPlayer(playerIdx, deckText) {
var player = gameState.players[playerIdx];
if (!player || !deckText.trim()) return;
@@ -12900,8 +12921,8 @@ async function startGameWithDecks() {
await loadPreconForPlayer(hd.idx, hd.choice.preconId);
} else if (hd.choice.type === 'custom') {
await loadCustomDeckForPlayer(hd.idx, hd.choice.deckId);
} else if (hd.choice.type === 'import') {
await importDeckForPlayer(hd.idx, hd.choice.text);
} else if (hd.choice.type === 'user-deck') {
await loadUserDeckForPlayer(hd.idx, hd.choice.deckName);
}
}
} catch (err) {
@@ -13024,6 +13045,8 @@ function startGame() {
renderHandArea();
renderPhaseTracker();
updateTurnDisplay();
if (typeof renderCmdDmgInline === 'function') renderCmdDmgInline();
if (typeof refreshLeftPanelTriggers === 'function') refreshLeftPanelTriggers();
// Set default search focus
setTimeout(() => {
@@ -16229,6 +16252,72 @@ function aiPickMultiTargetAttacks(playerIdx, creatures, difficulty) {
return assignments;
}
// ---- AI Helper: Declare attacks for an AI player (used by ai-bridge) ----
function aiDeclareAttacks(playerIdx) {
if (!gameState) return;
var player = gameState.players[playerIdx];
if (!player || player.eliminated) return;
var difficulty = gameState.aiDifficulty || 'easy';
var creatures = aiGetUntappedCreatures(playerIdx).filter(function(c) {
return !hasSummoningSickness(c);
});
if (creatures.length === 0) return;
var attackerCards = [];
if (difficulty === 'easy') {
attackerCards = creatures.filter(function() { return Math.random() > 0.5; });
if (attackerCards.length === 0 && creatures.length > 0) attackerCards = [creatures[0]];
} else {
var primaryTarget = aiPickAttackTarget(playerIdx);
if (primaryTarget === -1) return;
creatures.forEach(function(c) {
var eval_ = aiEvaluateAttack(c, primaryTarget);
if (difficulty === 'normal') {
if (eval_.shouldAttack || eval_.risk === 'trade') attackerCards.push(c);
} else {
if (eval_.shouldAttack) attackerCards.push(c);
else if (creatures.length >= 4 && eval_.risk === 'bad-trade') attackerCards.push(c);
}
});
if (difficulty === 'hard' && attackerCards.length > 2) {
var sortedByToughness = attackerCards.slice().sort(function(a, b) {
return getCreatureToughness(b) - getCreatureToughness(a);
});
var holdBack = sortedByToughness.find(function(c) {
return !hasKeyword(c, 'vigilance') && getCreatureToughness(c) >= 3;
});
if (holdBack) {
attackerCards = attackerCards.filter(function(c) { return c.id !== holdBack.id; });
}
}
}
if (attackerCards.length === 0) return;
var assignments = aiPickMultiTargetAttacks(playerIdx, attackerCards, difficulty);
if (assignments.length === 0) return;
gameState.combat.attackers = assignments.map(function(a) {
return { cardId: a.card.id, targetPlayerId: a.targetIdx };
});
attackerCards.forEach(function(c) {
if (!hasKeyword(c, 'vigilance')) {
c.tapped = true;
var el = document.getElementById('bc-' + c.id);
if (el) el.classList.add('tapped');
}
var el2 = document.getElementById('bc-' + c.id);
if (el2) el2.classList.add('attacking');
});
fireBattlefieldTriggers('attack');
}
// ---- AI Helper: Check if AI has a counterspell and should use it ----
function aiCheckForCounterspell(playerIdx) {
if (!gameState) return null;
@@ -16597,7 +16686,7 @@ function _aiRunPostCombat(playerIdx, player, difficulty, heldCards) {
var postDelay = 600;
// MAIN PHASE 2 (same logic as inline version)
if (difficulty !== 'easy') {
{
postActions.push({ delay: postDelay, fn: function() {
var bluffReserve = 0;
var bluffCardName = '';
@@ -17209,12 +17298,12 @@ function _runLocalAiTurn() {
}
// ========================================
// MAIN PHASE 2: Post-combat spell casting (normal & hard)
// MAIN PHASE 2: Post-combat spell casting
// Hard: play held-back removal + instants + any remaining affordable spells
// Normal: try to play any remaining affordable spells
// Normal/Easy: try to play any remaining affordable spells
// BLUFFING: On Normal/Hard, hold back mana if we have instant-speed answers
// ========================================
if (difficulty !== 'easy') {
{
actions.push({ delay: delay, fn: function() {
if (_aiBlockerPauseState) return; // Skip — human blocker flow handles this
var bluffReserve = 0;
@@ -25638,3 +25727,9 @@ function _safeRender(fn, ...args) {
try { window[fn](...args); } catch(e) {}
}
}
// Expose functions needed by ai-bridge.js
window.aiTryCastCommander = aiTryCastCommander;
window.aiDeclareAttacks = aiDeclareAttacks;
window.playCardFromHand = playCardFromHand;
window.aiCountUntappedLands = aiCountUntappedLands;
+955
View File
@@ -0,0 +1,955 @@
/* ═══════════════════════════════════════════════════════════
Commander AI Lab — Global Design System
Dark & Polished · Premium Dashboard Aesthetic
═══════════════════════════════════════════════════════════ */
/* ── Reset ──────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ── Design Tokens ──────────────────────────────────────── */
:root {
/* Surfaces — layered dark with subtle warmth */
--bg: #0c0e14;
--surface-0: #12141c;
--surface-1: #171a24;
--surface-2: #1c1f2b;
--surface-3: #222533;
--surface-hover: #252839;
/* Borders */
--border: #2a2d3a;
--border-subtle: #1e2130;
--border-focus: #4a6fa5;
/* Text */
--text: #e1e4eb;
--text-secondary:#8b90a0;
--text-tertiary: #5c6070;
--text-inverse: #0c0e14;
/* Accent — cool blue with subtle depth */
--accent: #5b9ef0;
--accent-hover: #7ab4ff;
--accent-dim: rgba(91, 158, 240, 0.12);
--accent-glow: rgba(91, 158, 240, 0.25);
/* Semantic */
--success: #4ade80;
--success-dim: rgba(74, 222, 128, 0.12);
--warning: #fbbf24;
--warning-dim: rgba(251, 191, 36, 0.12);
--error: #f87171;
--error-dim: rgba(248, 113, 113, 0.12);
--purple: #a78bfa;
--purple-dim: rgba(167, 139, 250, 0.12);
--gold: #fcd34d;
/* Radius */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
--shadow-md: 0 4px 16px rgba(0,0,0,0.35);
--shadow-lg: 0 12px 40px rgba(0,0,0,0.45);
--shadow-glow: 0 0 20px var(--accent-dim);
/* Transitions */
--ease: cubic-bezier(0.16, 1, 0.3, 1);
--duration: 180ms;
/* Typography */
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
/* Spacing scale */
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px;
--sp-4: 16px; --sp-5: 20px; --sp-6: 24px;
--sp-8: 32px; --sp-10: 40px; --sp-12: 48px;
--sp-16: 64px;
/* Layout */
--content-max: 1280px;
--sidebar-width: 280px;
--nav-height: 56px;
}
/* ── Base ───────────────────────────────────────────────── */
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
scroll-behavior: smooth;
}
body {
min-height: 100vh;
font-family: var(--font);
font-size: 14px;
line-height: 1.6;
color: var(--text);
background: var(--bg);
}
h1, h2, h3, h4, h5, h6 {
color: #fff;
font-weight: 600;
line-height: 1.25;
}
h1 { font-size: 24px; }
h2 { font-size: 18px; }
h3 { font-size: 16px; }
a { color: var(--accent); text-decoration: none; transition: color var(--duration) var(--ease); }
a:hover { color: var(--accent-hover); }
img, svg { display: block; max-width: 100%; }
button { cursor: pointer; font: inherit; }
input, select, textarea { font: inherit; color: inherit; }
table { border-collapse: collapse; width: 100%; }
::selection {
background: var(--accent-dim);
color: #fff;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* ── Scrollbar ──────────────────────────────────────────── */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
/* ── Navigation ─────────────────────────────────────────── */
.nav {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
height: var(--nav-height);
padding: 0 var(--sp-6);
background: rgba(12, 14, 20, 0.85);
backdrop-filter: blur(16px) saturate(1.5);
-webkit-backdrop-filter: blur(16px) saturate(1.5);
border-bottom: 1px solid var(--border-subtle);
}
.nav-brand {
display: flex;
align-items: center;
gap: var(--sp-3);
font-size: 15px;
font-weight: 700;
color: #fff;
letter-spacing: -0.01em;
text-decoration: none;
white-space: nowrap;
margin-right: var(--sp-8);
}
.nav-brand svg {
width: 28px;
height: 28px;
flex-shrink: 0;
}
.nav-links {
display: flex;
align-items: center;
gap: var(--sp-1);
overflow-x: auto;
scrollbar-width: none;
}
.nav-links::-webkit-scrollbar { display: none; }
.nav-links a {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 6px 12px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
border-radius: var(--radius-sm);
white-space: nowrap;
transition: all var(--duration) var(--ease);
text-decoration: none;
}
.nav-links a:hover {
color: var(--text);
background: var(--surface-2);
}
.nav-links a.active {
color: #fff;
background: var(--accent-dim);
}
.nav-links a .nav-icon {
width: 16px;
height: 16px;
opacity: 0.7;
}
.nav-links a.active .nav-icon,
.nav-links a:hover .nav-icon {
opacity: 1;
}
/* ── Page Layout ────────────────────────────────────────── */
.page {
max-width: var(--content-max);
margin: 0 auto;
padding: var(--sp-8) var(--sp-6);
}
.page-header {
margin-bottom: var(--sp-8);
}
.page-header h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: var(--sp-2);
}
.page-header p {
color: var(--text-secondary);
font-size: 15px;
}
/* ── Cards / Panels ─────────────────────────────────────── */
.panel {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--sp-6);
margin-bottom: var(--sp-6);
transition: border-color var(--duration) var(--ease);
}
.panel:hover {
border-color: var(--border-focus);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--sp-5);
}
.panel-header h2 {
font-size: 16px;
font-weight: 600;
}
.panel-header .badge {
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: var(--radius-sm);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ── Stat Cards ─────────────────────────────────────────── */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--sp-4);
margin-bottom: var(--sp-6);
}
.stat-card {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--sp-5);
transition: all var(--duration) var(--ease);
}
.stat-card:hover {
border-color: var(--border-focus);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.stat-label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: var(--sp-2);
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #fff;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.stat-sub {
font-size: 12px;
color: var(--text-tertiary);
margin-top: var(--sp-1);
}
/* ── Buttons ────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
transition: all var(--duration) var(--ease);
white-space: nowrap;
line-height: 1.4;
}
.btn-primary {
background: var(--accent);
color: var(--text-inverse);
}
.btn-primary:hover {
background: var(--accent-hover);
box-shadow: 0 4px 14px var(--accent-glow);
transform: translateY(-1px);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-primary:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.btn-secondary {
background: var(--surface-3);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--surface-hover);
border-color: var(--text-tertiary);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
padding: 6px 12px;
}
.btn-ghost:hover {
color: var(--text);
background: var(--surface-2);
}
.btn-success { background: var(--success); color: var(--text-inverse); }
.btn-success:hover { box-shadow: 0 4px 14px var(--success-dim); }
.btn-purple { background: var(--purple); color: var(--text-inverse); }
.btn-purple:hover { box-shadow: 0 4px 14px var(--purple-dim); }
.btn-danger { background: var(--error); color: #fff; }
.btn-danger:hover { box-shadow: 0 4px 14px var(--error-dim); }
.btn-sm { padding: 5px 12px; font-size: 12px; }
.btn-lg { padding: 12px 24px; font-size: 15px; }
.btn-icon-only { padding: 8px; width: 36px; height: 36px; }
/* ── Forms ──────────────────────────────────────────────── */
.form-group {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.form-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: 9px 14px;
font-size: 14px;
color: var(--text);
background: var(--surface-0);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
transition: all var(--duration) var(--ease);
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
outline: none;
}
.form-input::placeholder,
.form-textarea::placeholder {
color: var(--text-tertiary);
}
.form-select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%235c6070' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
.form-textarea {
resize: vertical;
min-height: 100px;
}
.form-checkbox {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
}
.form-checkbox input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--sp-4);
}
/* ── Progress Bar ───────────────────────────────────────── */
.progress-container {
margin-top: var(--sp-4);
padding: var(--sp-4);
background: var(--surface-0);
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
}
.progress-header {
display: flex;
align-items: center;
gap: var(--sp-3);
margin-bottom: var(--sp-3);
}
.progress-label {
font-size: 13px;
font-weight: 500;
color: var(--text);
}
.progress-track {
height: 6px;
background: var(--surface-3);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent-hover));
border-radius: 3px;
transition: width 0.3s var(--ease);
}
.progress-detail {
margin-top: var(--sp-2);
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-tertiary);
line-height: 1.6;
}
/* ── Phase Badge ────────────────────────────────────────── */
.badge-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: var(--radius-sm);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-status::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.badge-idle { background: var(--surface-3); color: var(--text-secondary); }
.badge-idle::before { background: var(--text-tertiary); }
.badge-running { background: var(--accent-dim); color: var(--accent); }
.badge-running::before { background: var(--accent); animation: pulse-dot 1.5s ease-in-out infinite; }
.badge-success { background: var(--success-dim); color: var(--success); }
.badge-success::before { background: var(--success); }
.badge-error { background: var(--error-dim); color: var(--error); }
.badge-error::before { background: var(--error); }
.badge-warning { background: var(--warning-dim); color: var(--warning); }
.badge-warning::before { background: var(--warning); }
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ── Empty State ────────────────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--sp-12) var(--sp-6);
text-align: center;
color: var(--text-tertiary);
}
.empty-state svg {
width: 48px;
height: 48px;
margin-bottom: var(--sp-4);
opacity: 0.3;
}
.empty-state p {
font-size: 14px;
margin-bottom: var(--sp-3);
}
/* ── Tables ─────────────────────────────────────────────── */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.data-table thead th {
text-align: left;
padding: var(--sp-3) var(--sp-4);
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid var(--border);
background: var(--surface-0);
}
.data-table tbody td {
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border-subtle);
vertical-align: middle;
}
.data-table tbody tr:hover {
background: var(--surface-2);
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
/* ── Tags ───────────────────────────────────────────────── */
.tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
border-radius: var(--radius-sm);
white-space: nowrap;
}
.tag-blue { background: var(--accent-dim); color: var(--accent); }
.tag-green { background: var(--success-dim); color: var(--success); }
.tag-yellow { background: var(--warning-dim); color: var(--warning); }
.tag-red { background: var(--error-dim); color: var(--error); }
.tag-purple { background: var(--purple-dim); color: var(--purple); }
/* ── Toast / Notification ───────────────────────────────── */
.toast {
position: fixed;
bottom: var(--sp-6);
right: var(--sp-6);
padding: var(--sp-3) var(--sp-5);
background: var(--surface-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
font-size: 13px;
color: var(--text);
z-index: 1000;
animation: toast-in 0.3s var(--ease);
}
.toast-success { border-left: 3px solid var(--success); }
.toast-error { border-left: 3px solid var(--error); }
.toast-warning { border-left: 3px solid var(--warning); }
@keyframes toast-in {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Loading Spinner ────────────────────────────────────── */
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── Divider ────────────────────────────────────────────── */
.divider {
height: 1px;
background: var(--border);
margin: var(--sp-5) 0;
}
/* ── Utility Classes ────────────────────────────────────── */
.hidden { display: none !important; }
.text-accent { color: var(--accent); }
.text-success { color: var(--success); }
.text-warning { color: var(--warning); }
.text-error { color: var(--error); }
.text-muted { color: var(--text-secondary); }
.text-faint { color: var(--text-tertiary); }
.text-mono { font-family: var(--font-mono); }
.text-sm { font-size: 12px; }
.text-xs { font-size: 11px; }
.font-bold { font-weight: 700; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.mt-2 { margin-top: var(--sp-2); }
.mt-4 { margin-top: var(--sp-4); }
.mt-6 { margin-top: var(--sp-6); }
.mb-2 { margin-bottom: var(--sp-2); }
.mb-4 { margin-bottom: var(--sp-4); }
.mb-6 { margin-bottom: var(--sp-6); }
.gap-2 { gap: var(--sp-2); }
.gap-4 { gap: var(--sp-4); }
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.flex-1 { flex: 1; }
.grid { display: grid; }
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border-width: 0;
}
/* ── Responsive ─────────────────────────────────────────── */
@media (max-width: 768px) {
.page {
padding: var(--sp-5) var(--sp-4);
}
.nav {
padding: 0 var(--sp-4);
}
.nav-brand {
margin-right: var(--sp-4);
font-size: 13px;
}
.stat-grid {
grid-template-columns: repeat(2, 1fr);
}
.form-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.stat-grid {
grid-template-columns: 1fr;
}
}
/* ═══════════════════════════════════════════════════════════
PREMIUM UPGRADE LAYER
═══════════════════════════════════════════════════════════ */
/* ── Ambient background glow (body) ─────────────────────── */
body {
background-image:
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(91,158,240,0.07) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 90% 110%, rgba(167,139,250,0.05) 0%, transparent 60%);
background-attachment: fixed;
}
/* ── Page title: subtle gradient text ───────────────────── */
.page-header h1 {
background: linear-gradient(135deg, #fff 60%, rgba(91,158,240,0.85) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ── Nav: stronger glass + accent underline ─────────────── */
.nav {
background: rgba(9,11,16,0.80);
backdrop-filter: blur(20px) saturate(1.8);
-webkit-backdrop-filter: blur(20px) saturate(1.8);
box-shadow: 0 1px 0 rgba(91,158,240,0.08), 0 4px 24px rgba(0,0,0,0.3);
border-bottom-color: rgba(255,255,255,0.06);
}
.nav-brand svg { filter: drop-shadow(0 0 8px rgba(91,158,240,0.4)); }
.nav-links a.active { box-shadow: inset 0 0 0 1px rgba(91,158,240,0.2); }
/* ── Panels: gradient border on hover ───────────────────── */
.panel {
position: relative;
transition: border-color var(--duration) var(--ease),
box-shadow var(--duration) var(--ease);
}
.panel::before {
content: '';
position: absolute;
inset: 0;
border-radius: var(--radius-lg);
padding: 1px;
background: linear-gradient(135deg, rgba(91,158,240,0.18), transparent 50%, rgba(167,139,250,0.1));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
opacity: 0;
transition: opacity var(--duration) var(--ease);
}
.panel:hover::before { opacity: 1; }
.panel:hover {
box-shadow: 0 8px 32px rgba(0,0,0,0.35), 0 0 0 1px rgba(91,158,240,0.08);
}
/* ── Stat cards: gradient surface + shimmer top edge ─────── */
.stat-card {
position: relative;
background: linear-gradient(145deg, var(--surface-2), var(--surface-1));
overflow: hidden;
}
.stat-card::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.12), transparent);
pointer-events: none;
}
.stat-card:hover {
box-shadow: var(--shadow-md), 0 0 20px rgba(91,158,240,0.08);
transform: translateY(-2px);
}
/* ── Buttons: gradient fills + inner highlight ───────────── */
.btn { position: relative; overflow: hidden; }
.btn::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 50%;
background: linear-gradient(to bottom, rgba(255,255,255,0.07), transparent);
border-radius: inherit;
pointer-events: none;
}
.btn-primary {
background: linear-gradient(135deg, var(--accent), #4a8de0);
box-shadow: 0 2px 8px rgba(91,158,240,0.25);
}
.btn-primary:hover {
background: linear-gradient(135deg, var(--accent-hover), var(--accent));
box-shadow: 0 4px 20px rgba(91,158,240,0.4);
}
.btn-success {
background: linear-gradient(135deg, var(--success), #38c46c);
box-shadow: 0 2px 8px rgba(74,222,128,0.2);
}
.btn-success:hover { box-shadow: 0 4px 20px rgba(74,222,128,0.4); transform: translateY(-1px); }
.btn-purple {
background: linear-gradient(135deg, var(--purple), #9171f0);
box-shadow: 0 2px 8px rgba(167,139,250,0.2);
}
.btn-purple:hover { box-shadow: 0 4px 20px rgba(167,139,250,0.4); transform: translateY(-1px); }
/* ── Tags: subtle colored border ────────────────────────── */
.tag-blue { border: 1px solid rgba(91,158,240,0.25); }
.tag-green { border: 1px solid rgba(74,222,128,0.25); }
.tag-yellow { border: 1px solid rgba(251,191,36,0.25); }
.tag-red { border: 1px solid rgba(248,113,113,0.25); }
.tag-purple { border: 1px solid rgba(167,139,250,0.25); }
/* ── Progress fill: glow ─────────────────────────────────── */
.progress-fill { box-shadow: 0 0 8px rgba(91,158,240,0.5); }
/* ── Badge running: glow dot ─────────────────────────────── */
.badge-running { box-shadow: 0 0 12px rgba(91,158,240,0.15); }
.badge-running::before { box-shadow: 0 0 6px var(--accent); }
/* ── Toast: glassmorphism + colored glow ─────────────────── */
.toast { backdrop-filter: blur(12px); }
.toast-success { box-shadow: var(--shadow-lg), 0 0 20px rgba(74,222,128,0.1); }
.toast-error { box-shadow: var(--shadow-lg), 0 0 20px rgba(248,113,113,0.1); }
/* ── Divider: fade-out gradient ──────────────────────────── */
.divider {
background: linear-gradient(90deg, transparent, var(--border), transparent);
}
/* ── Inputs: inset shadow for depth ─────────────────────── */
.form-input, .form-select, .form-textarea {
box-shadow: inset 0 1px 3px rgba(0,0,0,0.25);
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
box-shadow: 0 0 0 3px var(--accent-dim), inset 0 1px 3px rgba(0,0,0,0.1);
}
/* ══════════════════════════════════════════════════════════════
Tooltip System (.tip-icon + data-tip)
Shared across all pages — do not duplicate in page-specific CSS
══════════════════════════════════════════════════════════════ */
.tip-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border-radius: 50%;
background: var(--surface-3);
color: var(--text-secondary);
font-size: 10px;
font-weight: 700;
font-style: normal;
cursor: default;
position: relative;
vertical-align: middle;
margin-left: 4px;
flex-shrink: 0;
transition: background 0.15s, color 0.15s;
user-select: none;
}
.tip-icon:hover {
background: var(--accent-dim);
color: var(--accent);
}
.tip-icon::after {
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--surface-0, #1a1a2e);
color: var(--text, #e0e0e0);
border: 1px solid var(--border, #333);
border-radius: var(--radius-md, 6px);
padding: 8px 12px;
font-size: 12px;
font-weight: 400;
line-height: 1.5;
white-space: normal;
width: 240px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 1000;
text-align: left;
text-transform: none;
letter-spacing: 0;
}
.tip-icon::before {
content: '';
position: absolute;
bottom: calc(100% + 3px);
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--border, #333);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 1001;
}
.tip-icon:hover::after,
.tip-icon:hover::before {
opacity: 1;
}
.tip-icon.tip-below::after {
bottom: auto;
top: calc(100% + 8px);
}
.tip-icon.tip-below::before {
bottom: auto;
top: calc(100% + 3px);
border-top-color: transparent;
border-bottom-color: var(--border, #333);
}
+326 -33
View File
@@ -51,6 +51,8 @@
<button class="btn-util" onclick="checkStateBasedActions()" title="Check State-Based Actions">SBA</button>
<button class="btn-util" onclick="toggleSettingsPanel()" title="Settings">&#x2699;</button>
<button class="btn-util" id="btn-search-toggle" onclick="document.getElementById('card-search-sidebar').classList.toggle('collapsed')" title="Card Search (S)">&#x1F50D;</button>
<button class="btn-util" id="btn-deckbuilder-toggle" onclick="toggleDeckBuilderPanel()" title="Deck Builder">&#x1F0CF;</button>
<button class="btn-util btn-util-exit" onclick="location.reload()" title="Return to Start Screen">&#x2715;</button>
</div>
<!-- Storm counter -->
@@ -67,15 +69,6 @@
<div class="corner-accent bl"></div>
<div class="corner-accent br"></div>
<!-- CENTER EMBLEM -->
<div class="center-emblem">
<svg viewBox="0 0 100 100" fill="none">
<circle cx="50" cy="50" r="42" stroke="rgba(255,255,255,0.2)" stroke-width="1.5"/>
<circle cx="50" cy="50" r="28" stroke="rgba(255,255,255,0.1)" stroke-width="1"/>
<polygon points="50,15 58.5,37 82,37 63,51 69,74 50,60 31,74 37,51 18,37 41.5,37"
fill="rgba(255,255,255,0.1)" stroke="rgba(255,255,255,0.2)" stroke-width="0.8"/>
</svg>
</div>
<!-- ================================================
4-QUADRANT BATTLEFIELD
@@ -85,12 +78,6 @@
<!-- PLAYER 1: Top-Left (Crimson) -->
<div class="player-area p1" id="player-area-1">
<span class="player-label" id="player-label-1">Player 1</span>
<div class="life-counter" id="life-counter-1">
<button onclick="adjustLife(0,-1)" aria-label="Decrease P1 life">&#x2212;</button>
<span class="life-counter-value" id="life-0" onclick="showSetLifeDialog(0)">40</span>
<button onclick="adjustLife(0,1)" aria-label="Increase P1 life">+</button>
</div>
<div class="poison-badge" id="poison-0" style="display:none;" onclick="adjustPoison(0,1)" title="Poison counters (click +1, right-click -1)" oncontextmenu="event.preventDefault();adjustPoison(0,-1)">0 poison</div>
<!-- Zone Row: Library, Exile, Graveyard, Hand -->
<div class="zone-row" id="zone-row-1">
@@ -139,12 +126,6 @@
<!-- PLAYER 2: Top-Right (Sapphire) -->
<div class="player-area p2" id="player-area-2">
<span class="player-label" id="player-label-2">Player 2</span>
<div class="life-counter" id="life-counter-2">
<button onclick="adjustLife(1,-1)" aria-label="Decrease P2 life">&#x2212;</button>
<span class="life-counter-value" id="life-1" onclick="showSetLifeDialog(1)">40</span>
<button onclick="adjustLife(1,1)" aria-label="Increase P2 life">+</button>
</div>
<div class="poison-badge" id="poison-1" style="display:none;" onclick="adjustPoison(1,1)" oncontextmenu="event.preventDefault();adjustPoison(1,-1)">0 poison</div>
<div class="zone-row" id="zone-row-2">
<div class="zone library" id="lib-zone-2" data-player="2" data-zone="library"
@@ -229,12 +210,6 @@
</div>
<span class="player-label" id="player-label-3">Player 3</span>
<div class="life-counter" id="life-counter-3">
<button onclick="adjustLife(2,-1)" aria-label="Decrease P3 life">&#x2212;</button>
<span class="life-counter-value" id="life-2" onclick="showSetLifeDialog(2)">40</span>
<button onclick="adjustLife(2,1)" aria-label="Increase P3 life">+</button>
</div>
<div class="poison-badge" id="poison-2" style="display:none;" onclick="adjustPoison(2,1)" oncontextmenu="event.preventDefault();adjustPoison(2,-1)">0 poison</div>
</div>
<!-- PLAYER 4: Bottom-Right (Gold) -->
@@ -279,16 +254,89 @@
</div>
<span class="player-label" id="player-label-4">Player 4</span>
<div class="life-counter" id="life-counter-4">
<button onclick="adjustLife(3,-1)" aria-label="Decrease P4 life">&#x2212;</button>
<span class="life-counter-value" id="life-3" onclick="showSetLifeDialog(3)">40</span>
<button onclick="adjustLife(3,1)" aria-label="Increase P4 life">+</button>
</div>
<div class="poison-badge" id="poison-3" style="display:none;" onclick="adjustPoison(3,1)" oncontextmenu="event.preventDefault();adjustPoison(3,-1)">0 poison</div>
</div>
</div><!-- /.battlefield -->
<!-- LIFE PANELS OVERLAY (top-most layer) -->
<div id="life-panels-overlay">
<div class="life-panel lp-p1" id="life-panel-1">
<div class="life-panel-box life-box">
<span class="life-panel-label">Life</span>
<div class="life-counter" id="life-counter-1">
<button onclick="adjustLife(0,-1)" aria-label="Decrease P1 life">&#x2212;</button>
<span class="life-counter-value" id="life-0" onclick="showSetLifeDialog(0)">40</span>
<button onclick="adjustLife(0,1)" aria-label="Increase P1 life">+</button>
</div>
</div>
<div class="life-panel-box cmd-dmg-box" id="cmd-dmg-inline-0" onclick="openCmdDmgMatrix()" title="Open Commander Damage matrix">
<span class="life-panel-label">Cmd Dmg</span>
<div class="cmd-dmg-inline-row">&#8212;</div>
</div>
<div class="life-panel-box counters-box">
<span class="life-panel-label">Counters</span>
<div class="poison-badge" id="poison-0" onclick="adjustPoison(0,1)" title="Poison counters (click +1, right-click -1)" oncontextmenu="event.preventDefault();adjustPoison(0,-1)">0 poison</div>
</div>
</div>
<div class="life-panel lp-p2" id="life-panel-2">
<div class="life-panel-box life-box">
<span class="life-panel-label">Life</span>
<div class="life-counter" id="life-counter-2">
<button onclick="adjustLife(1,-1)" aria-label="Decrease P2 life">&#x2212;</button>
<span class="life-counter-value" id="life-1" onclick="showSetLifeDialog(1)">40</span>
<button onclick="adjustLife(1,1)" aria-label="Increase P2 life">+</button>
</div>
</div>
<div class="life-panel-box cmd-dmg-box" id="cmd-dmg-inline-1" onclick="openCmdDmgMatrix()" title="Open Commander Damage matrix">
<span class="life-panel-label">Cmd Dmg</span>
<div class="cmd-dmg-inline-row">&#8212;</div>
</div>
<div class="life-panel-box counters-box">
<span class="life-panel-label">Counters</span>
<div class="poison-badge" id="poison-1" onclick="adjustPoison(1,1)" oncontextmenu="event.preventDefault();adjustPoison(1,-1)">0 poison</div>
</div>
</div>
<div class="life-panel lp-p3" id="life-panel-3">
<div class="life-panel-box life-box">
<span class="life-panel-label">Life</span>
<div class="life-counter" id="life-counter-3">
<button onclick="adjustLife(2,-1)" aria-label="Decrease P3 life">&#x2212;</button>
<span class="life-counter-value" id="life-2" onclick="showSetLifeDialog(2)">40</span>
<button onclick="adjustLife(2,1)" aria-label="Increase P3 life">+</button>
</div>
</div>
<div class="life-panel-box cmd-dmg-box" id="cmd-dmg-inline-2" onclick="openCmdDmgMatrix()" title="Open Commander Damage matrix">
<span class="life-panel-label">Cmd Dmg</span>
<div class="cmd-dmg-inline-row">&#8212;</div>
</div>
<div class="life-panel-box counters-box">
<span class="life-panel-label">Counters</span>
<div class="poison-badge" id="poison-2" onclick="adjustPoison(2,1)" oncontextmenu="event.preventDefault();adjustPoison(2,-1)">0 poison</div>
</div>
</div>
<div class="life-panel lp-p4" id="life-panel-4">
<div class="life-panel-box life-box">
<span class="life-panel-label">Life</span>
<div class="life-counter" id="life-counter-4">
<button onclick="adjustLife(3,-1)" aria-label="Decrease P4 life">&#x2212;</button>
<span class="life-counter-value" id="life-3" onclick="showSetLifeDialog(3)">40</span>
<button onclick="adjustLife(3,1)" aria-label="Increase P4 life">+</button>
</div>
</div>
<div class="life-panel-box cmd-dmg-box" id="cmd-dmg-inline-3" onclick="openCmdDmgMatrix()" title="Open Commander Damage matrix">
<span class="life-panel-label">Cmd Dmg</span>
<div class="cmd-dmg-inline-row">&#8212;</div>
</div>
<div class="life-panel-box counters-box">
<span class="life-panel-label">Counters</span>
<div class="poison-badge" id="poison-3" onclick="adjustPoison(3,1)" oncontextmenu="event.preventDefault();adjustPoison(3,-1)">0 poison</div>
</div>
</div>
</div>
<!-- COMBAT SVG OVERLAY (arrows) -->
<svg id="combat-overlay" xmlns="http://www.w3.org/2000/svg" style="position:absolute;inset:44px 0 0 0;pointer-events:none;z-index:30;">
<defs>
@@ -321,6 +369,24 @@
<!-- Sidebar edge tab -->
<div id="sidebar-edge-tab" class="sidebar-edge-tab" title="Toggle card search">&#x2039;</div>
<!-- LEFT ACTION PANEL (triggers + quick actions) -->
<div id="left-action-panel" class="collapsed">
<div class="left-panel-header">
<span class="left-panel-title">Triggers <span id="left-panel-trigger-count" class="left-panel-count">0</span></span>
<button class="sidebar-toggle" onclick="toggleLeftPanel()" title="Close panel">&#x2715;</button>
</div>
<div class="left-panel-actions">
<button class="left-panel-btn" onclick="openTokenModal((typeof gameState !== 'undefined' && gameState) ? (gameState.currentPlayerIndex || 0) : 0)" title="Create token">&#x2728; Create Token</button>
<button class="left-panel-btn" onclick="autoArrangeBattlefield()" title="Auto-arrange battlefield">&#x21C4; Auto-Arrange</button>
</div>
<div class="left-panel-body" id="left-panel-triggers">
<div class="left-panel-empty">No pending triggers.</div>
</div>
</div>
<!-- Left panel edge tab -->
<div id="left-panel-edge-tab" class="left-panel-edge-tab" onclick="toggleLeftPanel()" title="Toggle quick actions">&#x203A;</div>
</div><!-- /.table-surface / #game-battlefield -->
<!-- ====================================================
@@ -642,10 +708,237 @@ document.addEventListener('DOMContentLoaded', function() {
indicator.className = 'backend-status ' +
(status.connected ? (status.modelLoaded ? 'connected-ml' : 'connected') : 'disconnected');
}
// Wire up WebSocket push events from backend
AIBridge.connector.on('ai:decision', function(msg) {
if (!gameState || !msg.action) return;
var playerIdx = gameState.currentPlayerIndex;
var player = gameState.players[playerIdx];
if (player && player.isAI) {
AIBridge.MacroActionMapper.execute(msg.action, playerIdx, gameState, msg);
}
});
AIBridge.connector.on('state:snapshot', function(msg) {
if (typeof AI_BRIDGE_CONFIG !== 'undefined' && AI_BRIDGE_CONFIG.verbose) {
console.log('[WS] State snapshot received from backend');
}
});
});
}
});
</script>
<!-- ====================================================
DECK BUILDER PANEL (iframe)
==================================================== -->
<div id="panel-deckbuilder" class="fullscreen-panel hidden">
<div class="fullscreen-panel-header">
<span>Deck Builder</span>
<button class="btn-util" onclick="toggleDeckBuilderPanel()" title="Close">&times;</button>
</div>
<iframe src="deckbuilder.html"
style="width:100%;height:calc(100vh - 36px);border:none;"
id="deckbuilder-iframe">
</iframe>
</div>
<div id="panel-collection" class="fullscreen-panel hidden">
<div class="fullscreen-panel-header">
<span>Collection</span>
<button class="btn-util" onclick="toggleCollectionPanel()" title="Close">&times;</button>
</div>
<iframe src="collection.html"
style="width:100%;height:calc(100vh - 36px);border:none;"
id="collection-iframe">
</iframe>
</div>
<div id="panel-deckgenerator" class="fullscreen-panel hidden">
<div class="fullscreen-panel-header">
<span>Auto Deck Generator</span>
<button class="btn-util" onclick="toggleDeckGenPanel()" title="Close">&times;</button>
</div>
<iframe src="deckgenerator.html"
style="width:100%;height:calc(100vh - 36px);border:none;"
id="deckgenerator-iframe">
</iframe>
</div>
<script>
function toggleDeckBuilderPanel() {
var panel = document.getElementById('panel-deckbuilder');
if (panel) panel.classList.toggle('hidden');
}
function toggleDeckGenPanel() {
var panel = document.getElementById('panel-deckgenerator');
if (panel) panel.classList.toggle('hidden');
}
function toggleCollectionPanel() {
var panel = document.getElementById('panel-collection');
if (panel) panel.classList.toggle('hidden');
}
function toggleLeftPanel() {
var panel = document.getElementById('left-action-panel');
if (panel) {
panel.classList.toggle('collapsed');
if (!panel.classList.contains('collapsed') && typeof refreshLeftPanelTriggers === 'function') {
refreshLeftPanelTriggers();
}
}
}
// Render the triggers list inside the left action panel.
// Mirrors #trigger-stack-body rendering but stays embedded in the panel.
function refreshLeftPanelTriggers() {
var body = document.getElementById('left-panel-triggers');
var countEl = document.getElementById('left-panel-trigger-count');
if (!body) return;
if (typeof gameState === 'undefined' || !gameState) {
if (countEl) countEl.textContent = '0';
body.innerHTML = '<div class="left-panel-empty">No pending triggers.</div>';
return;
}
var pending = (gameState.triggerStack || []).filter(function(t){ return !t.resolved; });
if (countEl) countEl.textContent = String(pending.length);
if (pending.length === 0) {
body.innerHTML = '<div class="left-panel-empty">No pending triggers.</div>';
return;
}
var icons = (typeof TRIGGER_TYPE_ICONS !== 'undefined') ? TRIGGER_TYPE_ICONS : {};
var labels = (typeof TRIGGER_TYPE_LABELS !== 'undefined') ? TRIGGER_TYPE_LABELS : {};
var html = '';
pending.forEach(function(t) {
var icon = icons[t.type] || '\u2747';
var label = labels[t.type] || t.type || '';
var owner = (gameState.players && gameState.players[t.ownerIndex]) || {};
var color = owner.color || '#888';
var name = (t.cardName || 'Unknown').replace(/</g,'&lt;');
var snip = (t.oracleSnippet || '').replace(/</g,'&lt;');
html +=
'<div class="lpt-item">' +
'<div class="lpt-icon">' + icon + '</div>' +
'<div class="lpt-body">' +
'<div class="lpt-title">' + name + '</div>' +
'<div class="lpt-type">' + label + '</div>' +
'<div class="lpt-text">' + snip + '</div>' +
'</div>' +
'<div class="lpt-dot" style="background:' + color + '"></div>' +
'<button class="lpt-dismiss" title="Dismiss" onclick="if(typeof dismissTrigger===\'function\')dismissTrigger(' + t.id + ');refreshLeftPanelTriggers();">&#x2715;</button>' +
'</div>';
});
body.innerHTML = html;
}
// Populate each seat's Cmd Dmg score-area box with per-opponent badges.
// Skips the seat's own player number from the display.
function renderCmdDmgInline() {
if (typeof gameState === 'undefined' || !gameState || !gameState.players) return;
var players = gameState.players;
for (var i = 0; i < players.length; i++) {
var box = document.getElementById('cmd-dmg-inline-' + i);
if (!box) continue;
var row = box.querySelector('.cmd-dmg-inline-row');
if (!row) continue;
var me = players[i];
if (!me) continue;
var html = '';
for (var j = 0; j < players.length; j++) {
if (j === i) continue; // leave own player number out
var op = players[j];
if (!op) continue;
var dmg = (me.commanderDamage && me.commanderDamage[op.id]) || 0;
var danger = dmg >= 21 ? ' danger' : dmg >= 15 ? ' crit' : dmg >= 8 ? ' warn' : '';
html +=
'<span class="cdi-badge' + danger + '" title="Commander damage from ' + (op.name||'P'+(j+1)) + '">' +
'<span class="cdi-dot" style="background:' + (op.color||'#888') + '"></span>' +
'P' + (j + 1) + ' <b>' + dmg + '</b>' +
'</span>';
}
row.innerHTML = html || '&#8212;';
}
}
// Hook into the trigger-stack badge updater so the left panel stays in sync.
(function() {
if (typeof window === 'undefined') return;
var tryHook = function() {
if (typeof window.updateTriggerBadge === 'function' && !window.updateTriggerBadge._leftPanelHooked) {
var orig = window.updateTriggerBadge;
window.updateTriggerBadge = function() {
var r = orig.apply(this, arguments);
try { refreshLeftPanelTriggers(); } catch(e) {}
return r;
};
window.updateTriggerBadge._leftPanelHooked = true;
}
// Also wrap adjustCommanderDamage so badges refresh after matrix clicks.
if (typeof window.adjustCommanderDamage === 'function' && !window.adjustCommanderDamage._cdmInlineHooked) {
var origAdj = window.adjustCommanderDamage;
window.adjustCommanderDamage = function() {
var r = origAdj.apply(this, arguments);
try { renderCmdDmgInline(); } catch(e) {}
return r;
};
window.adjustCommanderDamage._cdmInlineHooked = true;
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', tryHook);
} else {
tryHook();
}
// Also attempt the hook again a tick later (engine.js loads after this script in some orderings)
setTimeout(tryHook, 0);
setTimeout(tryHook, 500);
})();
// Listen for deck exports from the deck builder iframe
window.addEventListener('message', function(e) {
if (!e.data || e.data.type !== 'DECK_BUILDER_EXPORT') return;
var deckData = e.data;
// Build a decklist text string compatible with importDeckForPlayer()
var lines = [];
var commanders = deckData.deck.filter(function(c) { return c.is_commander; });
var nonCmd = deckData.deck.filter(function(c) { return !c.is_commander; });
if (commanders.length > 0) {
lines.push('[Commander]');
commanders.forEach(function(c) { lines.push('1 ' + c.name); });
lines.push('');
}
lines.push('[Deck]');
nonCmd.forEach(function(c) { lines.push((c.quantity || 1) + ' ' + c.name); });
// Store on window for the setup screen to pick up
window._pendingDeckImport = {
text: lines.join('\n'),
deckName: deckData.deckName || 'Imported Deck',
commander: deckData.commander || '',
};
// If a game is in progress, import into current player's slot
if (typeof gameState !== 'undefined' && gameState && typeof importDeckForPlayer === 'function') {
var playerIdx = gameState.currentPlayerIndex || 0;
importDeckForPlayer(playerIdx, window._pendingDeckImport.text).then(function() {
if (typeof renderHandArea === 'function') renderHandArea();
if (typeof renderPlayerPanels === 'function') renderPlayerPanels();
if (typeof addLogEntry === 'function') {
addLogEntry('Imported deck "' + window._pendingDeckImport.deckName + '" from Deck Builder');
}
});
} else if (typeof addLogEntry === 'function') {
addLogEntry('Deck "' + window._pendingDeckImport.deckName + '" ready — start a game to use it.');
}
// Close the deck builder panel
toggleDeckBuilderPanel();
});
</script>
</body>
</html>
+6 -1
View File
@@ -228,6 +228,11 @@ function removeEmblem(playerId, idx) {
renderEnhancedPanels();
}
// index: 0 1 2 3 → area: 1 2 4 3
// players[2] → bottom-RIGHT (area-4), players[3] → bottom-LEFT (area-3)
// Keeps turn order P1→P2→P3→P4 visually clockwise.
var PLAYER_INDEX_TO_QUADRANT = [1, 2, 4, 3];
/**
* Re-render all enhanced player panels in their quadrant positions.
*/
@@ -236,7 +241,7 @@ function renderEnhancedPanels() {
for (var i = 0; i < gameState.players.length; i++) {
var player = gameState.players[i];
var quadrant = i + 1;
var quadrant = PLAYER_INDEX_TO_QUADRANT[i] || (i + 1);
var areaEl = document.getElementById('player-area-' + quadrant);
if (!areaEl) continue;
+138 -11
View File
@@ -8,12 +8,75 @@
'use strict';
// In-memory Scryfall image cache: cardName -> { imageUrl, typeLine, oracleText, manaCost, power, toughness }
const _scryfallCache = {};
// Scryfall collection endpoint — accepts up to 75 card identifiers per call
const SCRYFALL_COLLECTION_URL = 'https://api.scryfall.com/cards/collection';
// ────────────────────────────────────────────────────────────
// Scryfall image cache: cardName -> { imageUrl, typeLine, ... }
// Persisted to localStorage so repeat games don't re-hit Scryfall.
// ────────────────────────────────────────────────────────────
const _SCRYFALL_CACHE_KEY = 'commanderLab.scryfallCache.v1';
const _SCRYFALL_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const _SCRYFALL_CACHE_MAX_ENTRIES = 20000;
let _scryfallCache = {};
(function _loadScryfallCacheFromLocalStorage() {
try {
const raw = localStorage.getItem(_SCRYFALL_CACHE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return;
const now = Date.now();
const stamp = parsed.__stamp || 0;
if (now - stamp > _SCRYFALL_CACHE_TTL_MS) {
console.log('[scryfall-cache] expired (> 30 days), clearing');
localStorage.removeItem(_SCRYFALL_CACHE_KEY);
return;
}
delete parsed.__stamp;
_scryfallCache = parsed;
const n = Object.keys(_scryfallCache).length;
console.log('[scryfall-cache] loaded ' + n + ' entries from localStorage');
} catch (e) {
console.warn('[scryfall-cache] load failed:', e);
try { localStorage.removeItem(_SCRYFALL_CACHE_KEY); } catch (_) {}
_scryfallCache = {};
}
})();
let _scryfallCacheSaveTimer = null;
function _persistScryfallCache() {
// Debounced — coalesce rapid writes during batch enrichment
if (_scryfallCacheSaveTimer) return;
_scryfallCacheSaveTimer = setTimeout(function () {
_scryfallCacheSaveTimer = null;
try {
// Trim to MAX_ENTRIES if over (drop entries with no imageUrl first)
const keys = Object.keys(_scryfallCache);
if (keys.length > _SCRYFALL_CACHE_MAX_ENTRIES) {
const trimmed = {};
const sorted = keys.sort((a, b) =>
(_scryfallCache[b].imageUrl ? 1 : 0) - (_scryfallCache[a].imageUrl ? 1 : 0)
).slice(0, _SCRYFALL_CACHE_MAX_ENTRIES);
for (const k of sorted) trimmed[k] = _scryfallCache[k];
_scryfallCache = trimmed;
}
const payload = Object.assign({ __stamp: Date.now() }, _scryfallCache);
localStorage.setItem(_SCRYFALL_CACHE_KEY, JSON.stringify(payload));
} catch (e) {
// Quota exceeded — clear and skip. Cache will rebuild next session.
console.warn('[scryfall-cache] persist failed (quota?):', e);
try { localStorage.removeItem(_SCRYFALL_CACHE_KEY); } catch (_) {}
}
}, 750);
}
function clearScryfallCache() {
_scryfallCache = {};
try { localStorage.removeItem(_SCRYFALL_CACHE_KEY); } catch (_) {}
console.log('[scryfall-cache] cleared');
}
// ============================================================
// startGameWithDecks
// Called by ui.js startGame() when players have selected precon decks.
@@ -50,8 +113,18 @@ async function startGameWithDecks() {
if (typeof renderPlayerPanels === 'function') renderPlayerPanels();
if (typeof updateActivePlayerHighlight === 'function') updateActivePlayerHighlight();
// Kick off image enrichment for all players in the background (non-blocking)
enrichAllZonesWithScryfall();
// Block on Scryfall enrichment BEFORE the game becomes interactive. This is
// critical: the opening 7 cards are dealt via player.zones.hand.push(lib.shift())
// directly (bypassing drawCard()), so they don't get _enrichCardFromCache'd at
// draw time. Without awaiting here, the AI's first turn fires with empty
// typeLine/cmc/oracleText/isRamp/isRemoval flags and can only play lands.
addLogEntry('\u23F3 Loading card data from Scryfall...');
try {
await enrichAllZonesWithScryfall();
} catch (e) {
console.warn('[precon-loader] Enrichment failed, continuing anyway:', e);
}
addLogEntry('\u2705 Card data loaded. Game ready.');
}
@@ -158,18 +231,32 @@ async function enrichAllZonesWithScryfall() {
// Gather all unique card names across all players and all zones
const nameSet = new Set();
let totalCards = 0;
let cacheHits = 0;
for (const player of gameState.players) {
for (const zone of Object.values(player.zones)) {
for (const card of zone) {
if (card.name && !_scryfallCache[card.name]) nameSet.add(card.name);
if (!card.name) continue;
totalCards++;
if (_scryfallCache[card.name]) {
cacheHits++;
} else {
nameSet.add(card.name);
}
}
}
}
if (nameSet.size === 0) return;
if (nameSet.size === 0) {
// Everything was in cache — just apply and bail, no network
addLogEntry('\u2705 Card images loaded from cache (' + cacheHits + ' cards).');
_applyScryfallDataToAllCards();
return;
}
const names = Array.from(nameSet);
addLogEntry('\uD83C\uDF0D Fetching card images from Scryfall (' + names.length + ' unique cards)...');
const hitRatio = totalCards > 0 ? Math.round((cacheHits / totalCards) * 100) : 0;
addLogEntry('\uD83C\uDF0D Fetching ' + names.length + ' new cards from Scryfall (' + hitRatio + '% cache hit rate)...');
// Split into batches of 75 (Scryfall max)
const BATCH = 75;
@@ -214,6 +301,7 @@ async function _fetchScryfallBatch(names) {
typeLine: card.type_line || '',
oracleText: card.oracle_text || (card.card_faces && card.card_faces[0] ? card.card_faces[0].oracle_text : '') || '',
manaCost: card.mana_cost || (card.card_faces && card.card_faces[0] ? card.card_faces[0].mana_cost : '') || '',
cmc: card.cmc || 0,
power: card.power || '',
toughness: card.toughness || '',
};
@@ -221,8 +309,10 @@ async function _fetchScryfallBatch(names) {
// Mark not-found cards so we don't re-fetch them on next enrichment
for (const name of names) {
if (!_scryfallCache[name]) _scryfallCache[name] = { imageUrl: '', typeLine: '', oracleText: '', manaCost: '', power: '', toughness: '' };
if (!_scryfallCache[name]) _scryfallCache[name] = { imageUrl: '', typeLine: '', oracleText: '', manaCost: '', cmc: 0, power: '', toughness: '' };
}
_persistScryfallCache();
}
function _applyScryfallDataToAllCards() {
@@ -236,8 +326,15 @@ function _applyScryfallDataToAllCards() {
card.typeLine = data.typeLine || card.typeLine;
card.oracleText = data.oracleText || card.oracleText;
card.manaCost = data.manaCost || card.manaCost;
card.cmc = data.cmc || card.cmc || 0;
if (data.power) card.power = data.power;
if (data.toughness) card.toughness = data.toughness;
// Tag roles from oracle text so aiCategorizeHand works
const ot = (card.oracleText || '').toLowerCase();
if (!card.isRamp) card.isRamp = /search your library for a.*land|add \{|adds? one mana/i.test(ot);
if (!card.isRemoval) card.isRemoval = /destroy target|exile target|counter target spell/i.test(ot);
if (!card.isBoardWipe) card.isBoardWipe = /destroy all|exile all|all creatures get -/i.test(ot);
}
}
}
@@ -246,11 +343,17 @@ function _applyScryfallDataToAllCards() {
for (const card of gameState.battlefieldCards) {
if (card.ownerIndex !== player.id) continue;
const data = _scryfallCache[card.name];
if (data && !card.imageUrl) {
card.imageUrl = data.imageUrl || '';
if (data) {
if (!card.imageUrl) card.imageUrl = data.imageUrl || '';
card.typeLine = data.typeLine || card.typeLine;
card.oracleText = data.oracleText || card.oracleText;
card.manaCost = data.manaCost || card.manaCost;
card.cmc = data.cmc || card.cmc || 0;
const ot = (card.oracleText || '').toLowerCase();
if (!card.isRamp) card.isRamp = /search your library for a.*land|add \{|adds? one mana/i.test(ot);
if (!card.isRemoval) card.isRemoval = /destroy target|exile target|counter target spell/i.test(ot);
if (!card.isBoardWipe) card.isBoardWipe = /destroy all|exile all|all creatures get -/i.test(ot);
}
}
}
@@ -267,6 +370,30 @@ function _applyScryfallDataToAllCards() {
if (typeof updateZoneCounts === 'function') updateZoneCounts();
}
/**
* Synchronously enrich a single card from the Scryfall cache.
* Called at draw time so cards have typeLine/cmc/oracle data
* before the AI's first turn, even if async enrichment hasn't completed.
*/
function _enrichCardFromCache(card) {
if (!card || !card.name) return;
const data = _scryfallCache[card.name];
if (!data) return;
card.imageUrl = data.imageUrl || card.imageUrl || '';
card.typeLine = data.typeLine || card.typeLine || '';
card.oracleText = data.oracleText || card.oracleText || '';
card.manaCost = data.manaCost || card.manaCost || '';
card.cmc = data.cmc || card.cmc || 0;
if (data.power) card.power = data.power;
if (data.toughness) card.toughness = data.toughness;
// Tag oracle roles
const ot = (card.oracleText || '').toLowerCase();
if (!card.isRamp) card.isRamp = /search your library for a.*land|add \{|adds? one mana/i.test(ot);
if (!card.isRemoval) card.isRemoval = /destroy target|exile target|counter target spell/i.test(ot);
if (!card.isBoardWipe) card.isBoardWipe = /destroy all|exile all|all creatures get -/i.test(ot);
}
/**
* Enrich a single card object on-demand (useful for cards drawn or played after initial load).
* Returns a Promise that resolves when the card's imageUrl is set.
+697 -173
View File
File diff suppressed because it is too large. Load diff
+230 -24
View File
@@ -17,6 +17,12 @@ let undoHistoryVisible = false;
let aiTurnActive = false;
let floatingPreviewTimeout = null;
// Hand-to-battlefield drag state
let isDraggingFromHand = false;
let handDragPlayerIdx = null;
let handDragCardIdx = null;
let handDragGhost = null;
// ============================================================
// LOG SYSTEM (override engine stub)
// ============================================================
@@ -144,23 +150,13 @@ function updateZoneCountsForPlayer(playerIdx) {
const libCount = document.getElementById('lib-count-' + pNum);
if (libCount) libCount.textContent = player.zones.library.length;
// Update exile count
const exCount = document.getElementById('exile-count-' + pNum);
if (exCount) {
exCount.textContent = player.zones.exile.length;
exCount.style.display = player.zones.exile.length > 0 ? 'block' : 'none';
}
// Update graveyard count
const gvCount = document.getElementById('gv-count-' + pNum);
if (gvCount) {
gvCount.textContent = player.zones.graveyard.length;
gvCount.style.display = player.zones.graveyard.length > 0 ? 'block' : 'none';
}
// Update hand count
const handCount = document.getElementById('hand-count-' + pNum);
if (handCount) handCount.textContent = player.zones.hand.length;
// Exile + Graveyard: render last card image face-up
renderLastCardInZone(playerIdx, 'exile');
renderLastCardInZone(playerIdx, 'graveyard');
}
function updatePlayerZones() {
@@ -211,6 +207,50 @@ function renderCommanderZone(playerIdx) {
}
}
// ============================================================
// EXILE / GRAVEYARD — Show last card image face-up
// ============================================================
function renderLastCardInZone(playerIdx, zoneName) {
if (!gameState) return;
const player = gameState.players[playerIdx];
if (!player) return;
const pNum = playerIdx + 1;
const zoneIdPrefix = (zoneName === 'exile') ? 'exile-zone-' : 'gv-zone-';
const zoneEl = document.getElementById(zoneIdPrefix + pNum);
if (!zoneEl) return;
const cards = player.zones[zoneName] || [];
const lastCard = cards[cards.length - 1]; // most recently added card
if (lastCard && lastCard.imageUrl) {
const safeImg = (lastCard.imageUrl || '').replace(/'/g, "\\'");
const safeName = (lastCard.name || '').replace(/'/g, "\\'");
const safeType = (lastCard.typeLine || '').replace(/'/g, "\\'");
zoneEl.innerHTML =
'<img src="' + safeImg + '" alt="' + (lastCard.name || zoneName) + '" ' +
'style="width:100%;height:100%;object-fit:cover;border-radius:6px;" ' +
'onmouseenter="showFloatingCardPreview(\'' + safeImg + '\', event, {name:\'' + safeName + '\', typeLine:\'' + safeType + '\'})" ' +
'onmousemove="positionFloatingPreview(event)" ' +
'onmouseleave="hideFloatingCardPreview()" ' +
'draggable="false" />';
// Always show count badge on top
if (cards.length > 0) {
zoneEl.innerHTML += '<span class="card-count" id="' + (zoneName === 'exile' ? 'exile' : 'gv') + '-count-' + pNum + '" ' +
'style="display:block;">' + cards.length + '</span>';
}
} else {
// Fallback: icon + name + count (original look)
const icon = (zoneName === 'exile') ? '&#x2B21;' : '&#x2620;';
const label = (zoneName === 'exile') ? 'Exile' : 'Grave';
const countId = (zoneName === 'exile') ? 'exile-count-' : 'gv-count-';
zoneEl.innerHTML =
'<span class="card-count" id="' + countId + pNum + '" style="' + (cards.length > 0 ? 'display:block;' : 'display:none;') + '">' + cards.length + '</span>' +
'<span class="zone-icon">' + icon + '</span>' +
'<span class="zone-name">' + label + '</span>';
}
}
// ============================================================
// HAND ZONE RENDERING (thumbnails in zone.hand)
// ============================================================
@@ -223,6 +263,7 @@ function renderHandZone(playerIdx) {
if (!handEl) return;
const hand = player.zones.hand || [];
const hideFaces = !!player.isAI; // AI hands render face-down
// Update count badge
const countEl = document.getElementById('hand-count-' + pNum);
@@ -240,13 +281,23 @@ function renderHandZone(playerIdx) {
const maxShow = Math.min(hand.length, 7); // limit thumbnails
for (let i = 0; i < maxShow; i++) {
const card = hand[i];
if (hideFaces) {
// AI hand — show a face-down card back (no name, no image, no preview)
thumbsHtml +=
'<div class="hand-thumb face-down" ' +
'data-player="' + playerIdx + '" data-hand-index="' + i + '" ' +
'title="Opponent hand (hidden)"></div>';
continue;
}
const imgSrc = card.imageUrl || '';
const escapedName = (card.name || '').replace(/'/g, "\\'");
const escapedType = (card.typeLine || '').replace(/'/g, "\\'");
if (imgSrc) {
thumbsHtml +=
'<div class="hand-thumb" ' +
'data-player="' + playerIdx + '" data-hand-index="' + i + '" ' +
'onclick="openZoneModal(' + playerIdx + ')" ' +
'onmousedown="startHandDrag(event,' + playerIdx + ',' + i + ',\'' + imgSrc.replace(/'/g, "\\'") + '\')" ' +
'onmouseenter="showFloatingCardPreview(\'' + imgSrc.replace(/'/g, "\\'") + '\',event,{name:\'' + escapedName + '\',typeLine:\'' + escapedType + '\'})" ' +
'onmousemove="positionFloatingPreview(event)" ' +
'onmouseleave="hideFloatingCardPreview()">' +
@@ -259,7 +310,11 @@ function renderHandZone(playerIdx) {
}
if (hand.length > maxShow) {
thumbsHtml += '<div class="hand-thumb" onclick="openZoneModal(' + playerIdx + ')" style="background:rgba(255,255,255,0.05);display:flex;align-items:center;justify-content:center;font-size:9px;color:rgba(255,255,255,0.4);">+' + (hand.length - maxShow) + '</div>';
if (hideFaces) {
thumbsHtml += '<div class="hand-thumb face-down overflow" title="Opponent hand (hidden)">+' + (hand.length - maxShow) + '</div>';
} else {
thumbsHtml += '<div class="hand-thumb" onclick="openZoneModal(' + playerIdx + ')" style="background:rgba(255,255,255,0.05);display:flex;align-items:center;justify-content:center;font-size:9px;color:rgba(255,255,255,0.4);">+' + (hand.length - maxShow) + '</div>';
}
}
handEl.innerHTML = thumbsHtml;
@@ -290,7 +345,6 @@ function renderBattlefieldCard(cardData) {
card.style.top = (cardData.y || 10) + 'px';
const playerColor = (gameState.players[cardData.ownerIndex] || {}).color || 'transparent';
card.style.borderColor = playerColor;
if (cardData.tapped) card.classList.add('tapped');
if (cardData.faceDown) card.classList.add('face-down');
@@ -324,7 +378,6 @@ function renderBattlefieldCard(cardData) {
card.innerHTML =
protBadge +
'<img src="' + imgUrl + '" alt="' + altName + '" draggable="false" loading="lazy"/>' +
'<div class="card-owner-bar" style="background:' + playerColor + '"></div>' +
kwBadgeHtml;
// Counter badges
@@ -523,6 +576,63 @@ document.addEventListener('mouseup', e => {
}
});
// ============================================================
// HAND-TO-BATTLEFIELD DRAG & DROP
// ============================================================
function startHandDrag(e, playerIdx, handIndex, imgSrc) {
// Only left mouse button
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
isDraggingFromHand = true;
handDragPlayerIdx = playerIdx;
handDragCardIdx = handIndex;
hideFloatingCardPreview();
// Create ghost element
handDragGhost = document.createElement('div');
handDragGhost.className = 'hand-drag-ghost';
handDragGhost.innerHTML = '<img src="' + imgSrc + '" draggable="false"/>';
handDragGhost.style.left = e.clientX + 'px';
handDragGhost.style.top = e.clientY + 'px';
document.body.appendChild(handDragGhost);
}
document.addEventListener('mousemove', function(e) {
if (!isDraggingFromHand || !handDragGhost) return;
handDragGhost.style.left = e.clientX + 'px';
handDragGhost.style.top = e.clientY + 'px';
});
document.addEventListener('mouseup', function(e) {
if (!isDraggingFromHand) return;
// Clean up ghost
if (handDragGhost) {
handDragGhost.remove();
handDragGhost = null;
}
// Check if dropped over a battle-zone
const pNum = handDragPlayerIdx + 1;
const battleZone = document.getElementById('battle-zone-' + pNum);
if (battleZone) {
const rect = battleZone.getBoundingClientRect();
if (e.clientX >= rect.left && e.clientX <= rect.right &&
e.clientY >= rect.top && e.clientY <= rect.bottom) {
// Play the card
if (typeof playCardFromHand === 'function') {
playCardFromHand(handDragPlayerIdx, handDragCardIdx);
}
}
}
isDraggingFromHand = false;
handDragPlayerIdx = null;
handDragCardIdx = null;
});
// ============================================================
// PHASE TRACKER
// ============================================================
@@ -746,6 +856,20 @@ function openZoneModal(playerId, zone) {
}
function closeZoneModal() {
// Auto-shuffle the library when closing, and ONLY the library
if (zoneModalPlayerId !== null && gameState) {
const player = gameState.players[zoneModalPlayerId];
if (player && player.zones.library.length > 1) {
const lib = player.zones.library;
for (let i = lib.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[lib[i], lib[j]] = [lib[j], lib[i]];
}
addLogEntry('&#x1F500; <strong>' + player.name + "'s</strong> library shuffled.");
updateZoneCountsForPlayer(zoneModalPlayerId);
}
}
const modal = document.getElementById('zone-modal');
if (modal) modal.classList.remove('visible');
zoneModalPlayerId = null;
@@ -762,19 +886,28 @@ function renderZoneModal() {
const player = gameState.players[zoneModalPlayerId];
if (!player) return;
// AI players' hands are private — don't allow the Hand tab to be active
if (player.isAI && activeZoneTab === 'hand') {
activeZoneTab = 'graveyard';
}
const titleEl = document.getElementById('zone-modal-title');
if (titleEl) titleEl.textContent = player.name + "'s Zones";
// Render tabs
const tabsEl = document.getElementById('zone-modal-tabs');
if (tabsEl) {
const zones = [
let zones = [
{ key: 'graveyard', icon: '&#x2620;', label: 'Graveyard' },
{ key: 'exile', icon: '&#x2B21;', label: 'Exile' },
{ key: 'command', icon: '&#x1F451;', label: 'Command' },
{ key: 'hand', icon: '&#x270B;', label: 'Hand' },
{ key: 'library', icon: '&#x1F4DA;', label: 'Library' },
];
// Strip the Hand tab when inspecting an AI opponent (hand is private)
if (player.isAI) {
zones = zones.filter(z => z.key !== 'hand');
}
tabsEl.innerHTML = zones.map(z => {
const count = (player.zones[z.key] || []).length;
@@ -992,6 +1125,8 @@ function initSetupScreen() {
'<button class="ai-diff-btn active" onclick="window._setAiDiff(this,\'easy\')" data-diff="easy" style="font-size:11px;padding:3px 10px;border-radius:4px;border:1px solid rgba(255,255,255,0.1);background:rgba(91,141,217,0.2);color:rgba(255,255,255,0.7);cursor:pointer;">Easy</button>' +
'<button class="ai-diff-btn" onclick="window._setAiDiff(this,\'normal\')" data-diff="normal" style="font-size:11px;padding:3px 10px;border-radius:4px;border:1px solid rgba(255,255,255,0.1);background:transparent;color:rgba(255,255,255,0.5);cursor:pointer;">Normal</button>' +
'<button class="ai-diff-btn" onclick="window._setAiDiff(this,\'hard\')" data-diff="hard" style="font-size:11px;padding:3px 10px;border-radius:4px;border:1px solid rgba(255,255,255,0.1);background:transparent;color:rgba(255,255,255,0.5);cursor:pointer;">Hard</button>' +
'<button class="ai-diff-btn" onclick="window._setAiDiff(this,\'aggro\')" data-diff="aggro" style="font-size:11px;padding:3px 10px;border-radius:4px;border:1px solid rgba(255,255,255,0.1);background:transparent;color:rgba(255,255,255,0.5);cursor:pointer;">Aggro</button>' +
'<button class="ai-diff-btn" onclick="window._setAiDiff(this,\'combo\')" data-diff="combo" style="font-size:11px;padding:3px 10px;border-radius:4px;border:1px solid rgba(255,255,255,0.1);background:transparent;color:rgba(255,255,255,0.5);cursor:pointer;">Combo</button>' +
'</div>' +
'</div>' +
@@ -1012,6 +1147,12 @@ function initSetupScreen() {
'<div id="deck-assignment-grid" class="deck-assignment-grid"></div>' +
'</div>' +
'<div class="setup-section" style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap;">' +
'<button class="setup-tool-btn" onclick="toggleDeckBuilderPanel()" style="display:flex;align-items:center;gap:6px;padding:8px 16px;border-radius:6px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.04);color:rgba(255,255,255,0.7);cursor:pointer;font-size:12px;">&#x1F0CF; Deck Builder</button>' +
'<button class="setup-tool-btn" onclick="toggleDeckGenPanel()" style="display:flex;align-items:center;gap:6px;padding:8px 16px;border-radius:6px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.04);color:rgba(255,255,255,0.7);cursor:pointer;font-size:12px;">&#x26A1; Auto Gen</button>' +
'<button class="setup-tool-btn" onclick="toggleCollectionPanel()" style="display:flex;align-items:center;gap:6px;padding:8px 16px;border-radius:6px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.04);color:rgba(255,255,255,0.7);cursor:pointer;font-size:12px;">&#x1F4DA; Collection</button>' +
'</div>' +
'<button class="btn-start" onclick="startGame()">Begin Battle</button>' +
'</div>';
@@ -1302,18 +1443,83 @@ function switchDeckTab(playerIdx, tab) {
case 'mydecks':
content.innerHTML =
'<div class="deck-mydecks-content">' +
'<div class="deck-tab-empty">' +
'<div style="margin-bottom:8px;">Import a decklist:</div>' +
'<textarea class="deck-import-textarea" id="deck-import-' + playerIdx + '" ' +
'placeholder="Paste decklist here...\n1 Sol Ring\n1 Command Tower\n..."></textarea>' +
'<button class="btn btn-sm" style="margin-top:6px;" ' +
'onclick="importDeckFromTextarea(' + playerIdx + ')">Import Decklist</button>' +
'<div class="deck-mydecks-list" id="mydecks-list-' + playerIdx + '">' +
'<div style="color:var(--color-text-faint,#888);font-size:12px;padding:12px;">Loading user decks...</div>' +
'</div>' +
'</div>';
_loadMyDecksTab(playerIdx);
break;
}
}
function _loadMyDecksTab(playerIdx) {
var listEl = document.getElementById('mydecks-list-' + playerIdx);
if (!listEl) return;
var apiBase = (typeof LAB_API_BASE !== 'undefined' && LAB_API_BASE) ? LAB_API_BASE : '';
fetch(apiBase + '/api/lab/decks')
.then(function(res) { return res.json(); })
.then(function(data) {
var decks = (data.decks || []).filter(function(d) { return d.source === 'deckbuilder'; });
if (decks.length === 0) {
listEl.innerHTML = '<div style="color:var(--color-text-faint,#888);font-size:12px;padding:12px;">No saved decks. Use the Deck Builder or Auto Gen to create one.</div>';
return;
}
var html = '';
decks.forEach(function(d) {
var name = d.name || 'Untitled';
var cmdr = d.commander || d.commander_name || '';
var count = d.totalCards || d.card_count || d.total_cards || 0;
html += '<button class="deck-precon-item" onclick="selectUserDeck(' + playerIdx + ',' + d.deck_id + ',\'' + name.replace(/'/g, "\\'") + '\')">' +
'<span class="deck-precon-name">' + name.replace(/</g, '&lt;') + '</span>' +
'<span class="deck-precon-cmdr">' + (cmdr ? cmdr.replace(/</g, '&lt;') + ' \u2014 ' : '') + count + ' cards</span>' +
'</button>';
});
listEl.innerHTML = html;
})
.catch(function(e) {
console.warn('[_loadMyDecksTab] Error:', e);
listEl.innerHTML = '<div style="color:var(--color-text-faint,#888);font-size:12px;padding:12px;">Could not load decks. Is the server running?</div>';
});
}
function selectUserDeck(playerIdx, deckId, deckName) {
var statusEl = document.getElementById('deck-status-' + playerIdx);
if (statusEl) { statusEl.textContent = 'Exporting...'; statusEl.className = 'deck-select-status'; }
// Highlight selected button
var listEl = document.getElementById('mydecks-list-' + playerIdx);
if (listEl) {
listEl.querySelectorAll('.deck-precon-item').forEach(function(b) { b.classList.remove('selected'); });
// Find the clicked button
listEl.querySelectorAll('.deck-precon-item').forEach(function(b) {
if (b.querySelector('.deck-precon-name') && b.querySelector('.deck-precon-name').textContent === deckName) {
b.classList.add('selected');
}
});
}
// Export to .dck then store the choice
var apiBase = (typeof LAB_API_BASE !== 'undefined' && LAB_API_BASE) ? LAB_API_BASE : '';
fetch(apiBase + '/api/decks/' + deckId + '/export-to-sim', { method: 'POST' })
.then(function(res) { return res.json(); })
.then(function(result) {
if (result.success) {
if (typeof setupDeckChoices !== 'undefined') {
setupDeckChoices[playerIdx] = { type: 'user-deck', deckName: result.deckName, fileName: result.deckName + '.dck' };
}
if (statusEl) { statusEl.textContent = deckName + ' (' + (result.totalCards || '?') + ' cards)'; statusEl.className = 'deck-select-status loaded'; }
} else {
if (statusEl) { statusEl.textContent = 'Export failed'; statusEl.className = 'deck-select-status'; }
}
})
.catch(function(e) {
console.error('[selectUserDeck] Export failed:', e);
if (statusEl) { statusEl.textContent = 'Export error'; statusEl.className = 'deck-select-status'; }
});
}
function filterPrecons(playerIdx, query) {
var list = document.getElementById('precon-list-' + playerIdx);
if (!list) return;
+1 -1
View File
@@ -330,7 +330,7 @@ def label_decisions_file(jsonl_path: str) -> Tuple[np.ndarray, List[dict]]:
labels = []
raw = []
with open(jsonl_path, "r") as f:
with open(jsonl_path, "r", encoding="utf-8") as f:
for i, line in enumerate(f):
line = line.strip()
if not line:
+9 -1
View File
@@ -292,7 +292,15 @@ class DistillationDefaults:
ppo_episodes_per_iter: int = 64
ppo_lr: float = 3e-4
ppo_clip_epsilon: float = 0.2
ppo_entropy_coeff: float = 0.01
# Increased from 0.01 to 0.04 to prevent premature convergence on the
# "pass"/"land" macro-actions. The policy-collapse bug (see
# commander-ai-lab-land-only-bug-analysis.md) is exacerbated by a low
# entropy bonus: PPO settled on argmax very early in training, so the
# model never learned to cast creatures / removal / draw spells even
# when they were affordable. A higher entropy bonus keeps the action
# distribution flat enough during self-play for those alternatives to
# accumulate reward signal.
ppo_entropy_coeff: float = 0.04
ppo_eval_episodes: int = 100
# Dataset mixing — Forge-only by default (Phase 5 follow-up decision)
+1 -1
View File
@@ -266,7 +266,7 @@ def build_dataset(
logger.info("Processing %s [%s] ...", jsonl_file.name, source)
file_count = 0
with open(jsonl_file, "r") as f:
with open(jsonl_file, "r", encoding="utf-8") as f:
for line_num, line in enumerate(f):
line = line.strip()
if not line:
+2 -2
View File
@@ -523,7 +523,7 @@ class QualityGate:
report_path = os.path.join(
self.config.reports_dir, f"quality_{timestamp}.json"
)
with open(report_path, "w") as f:
with open(report_path, "w", encoding="utf-8") as f:
json.dump(metrics, f, indent=2)
logger.info("Quality report saved: %s", report_path)
@@ -582,6 +582,6 @@ class QualityGate:
path = os.path.join(self.config.reports_dir, "verdict_history.json")
history = [v.to_dict() for v in self.verdicts]
with open(path, "w") as f:
with open(path, "w", encoding="utf-8") as f:
json.dump(history, f, indent=2)
logger.info("Verdict history saved: %s (%d entries)", path, len(history))
+17 -5
View File
@@ -141,8 +141,17 @@ class CardEmbeddingIndex:
logger.error("Auto-download failed: %s", e)
return None
def get_embedding(self, card_name: str) -> np.ndarray:
"""Get embedding vector for a card name. Returns zero vector if not found."""
def get_embedding(self, card_name) -> np.ndarray:
"""Get embedding vector for a card name. Returns zero vector if not found.
Accepts either a plain string or a dict with a 'name' key (defensive
against the LAN client sending structured battlefield objects).
"""
# Handle dict objects from the LAN client's battlefield_detail
if isinstance(card_name, dict):
card_name = card_name.get('name', '')
if not card_name or not isinstance(card_name, str):
return self._zero_vec
if not self._loaded:
return self._zero_vec
idx = self._name_to_idx.get(card_name.lower())
@@ -155,8 +164,11 @@ class CardEmbeddingIndex:
return self._embeddings[idx]
return self._zero_vec
def mean_pool_zone(self, card_names: List[str]) -> np.ndarray:
"""Mean-pool embeddings for a list of card names (a game zone)."""
def mean_pool_zone(self, card_names: list) -> np.ndarray:
"""Mean-pool embeddings for a list of card names (a game zone).
Each entry may be a plain string or a dict with a 'name' key.
"""
if not card_names:
return self._zero_vec
vectors = [self.get_embedding(name) for name in card_names]
@@ -403,7 +415,7 @@ def encode_decisions_file(
states = []
raw = []
with open(jsonl_path, "r") as f:
with open(jsonl_path, "r", encoding="utf-8") as f:
for i, line in enumerate(f):
if max_samples and i >= max_samples:
break
+2 -2
View File
@@ -130,7 +130,7 @@ class EloHistory:
def _load(self):
if os.path.exists(self.path):
try:
with open(self.path, "r") as f:
with open(self.path, "r", encoding="utf-8") as f:
data = json.load(f)
self.entries = data.get("entries", [])
except Exception:
@@ -138,7 +138,7 @@ class EloHistory:
def save(self):
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
with open(self.path, "w") as f:
with open(self.path, "w", encoding="utf-8") as f:
json.dump({"entries": self.entries}, f, indent=2)
def append(self, generation: int, ratings: Dict[str, float]):
+2 -2
View File
@@ -280,7 +280,7 @@ class ForgeEvaluator:
if not Path(batch_path).exists():
logger.warning("[Eval] Batch JSON not found at %s — falling back to synthetic", batch_path)
return self._run_synthetic_eval(service)
with open(batch_path) as f:
with open(batch_path, encoding="utf-8") as f:
batch = json.load(f)
games_list = batch.get("games", [])
if not games_list:
@@ -543,6 +543,6 @@ class ForgeEvaluator:
"games": [asdict(r) for r in self._records],
}
os.makedirs(self.cfg.results_dir, exist_ok=True)
with open(self._result_path, "w") as f:
with open(self._result_path, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
logger.info("[Eval %s] Results written to %s", self.cfg.run_id, self._result_path)
+548
View File
@@ -0,0 +1,548 @@
"""
Commander AI Lab — Forge Evaluator (Issue #153)
═════════════════════════════════════════════════
Reusable eval harness that:
1. Starts the policy inference server pointing at a given checkpoint
2. Launches Forge games via MultiThreadBatchRunner with the policy
server wired in as the AI decision-maker
3. Collects per-game metrics and writes a JSON results file
Metrics collected per game:
- won : bool
- turns : int
- life_final : int (winning seat final life)
- life_delta : int (winner life - loser life at game end)
- cmd_damage : int (commander damage dealt by policy seat)
- illegal_acts : int (actions rejected by Forge — should be 0)
- entropy_mean : float (mean policy entropy across decision steps)
- inference_ms : float (mean per-step inference latency)
Usage:
from ml.eval.forge_evaluator import ForgeEvaluator, EvalConfig
cfg = EvalConfig(
checkpoint_path="ml/models/checkpoints/forge-trained/final.pt",
forge_jar="forge/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar",
deck_files=["decks/cedh_test_a.dck", "decks/cedh_test_b.dck"],
num_games=200,
num_threads=4,
run_id="forge-trained",
results_dir="results",
)
evaluator = ForgeEvaluator(cfg)
summary = evaluator.run()
print(summary)
"""
import json
import logging
import os
import sys
import time
import uuid
import subprocess
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Dict, List, Optional
import numpy as np
project_root = str(Path(__file__).parent.parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
logger = logging.getLogger("ml.eval.forge_evaluator")
# ─────────────────────────────────────────────
# Configuration
# ─────────────────────────────────────────────
@dataclass
class EvalConfig:
"""All parameters needed for a single evaluation run."""
# Model
checkpoint_path: str = "ml/models/checkpoints/best_policy.pt"
embeddings_dir: str = "embeddings"
device: str = None # None = auto-detect
# Forge
forge_jar: str = "forge/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar"
forge_work_dir: str = "forge"
java_path: str = "java"
deck_files: List[str] = field(default_factory=list)
deck_names: List[str] = field(default_factory=list)
policy_server_url: str = "http://localhost:8091"
clock_seconds: int = 120
# Run
num_games: int = 200
num_threads: int = 4
seed: Optional[int] = None
run_id: str = ""
results_dir: str = "results"
# Inference
greedy: bool = True
temperature: float = 1.0
def __post_init__(self):
if not self.run_id:
self.run_id = str(uuid.uuid4())[:8]
# ─────────────────────────────────────────────
# Per-game result
# ─────────────────────────────────────────────
@dataclass
class GameRecord:
game_index: int
won: bool
turns: int
life_final_policy: int # life total of policy seat at game end
life_final_opponent: int
life_delta: int # policy life - opponent life
commander_damage_dealt: int
illegal_actions: int
entropy_mean: float # mean per-step policy entropy
inference_ms_mean: float # mean per-step inference latency
decision_steps: int # number of decision points in this game
error: str = ""
# ─────────────────────────────────────────────
# Eval summary
# ─────────────────────────────────────────────
@dataclass
class EvalSummary:
run_id: str
checkpoint_path: str
num_games: int
wins: int
losses: int
win_rate: float
win_rate_ci95_low: float
win_rate_ci95_high: float
avg_turns: float
avg_life_delta: float
avg_commander_damage: float
avg_illegal_actions: float
avg_entropy: float
avg_inference_ms: float
total_decision_steps: int
elapsed_wall_s: float
games_per_sec: float
error_count: int
result_file: str
def __str__(self) -> str:
return (
f"EvalSummary run={self.run_id}\n"
f" checkpoint : {self.checkpoint_path}\n"
f" games : {self.num_games} ({self.wins}W / {self.losses}L)\n"
f" win rate : {self.win_rate:.1%} "
f"[{self.win_rate_ci95_low:.1%}, {self.win_rate_ci95_high:.1%}] 95% CI\n"
f" avg turns : {self.avg_turns:.1f}\n"
f" avg life Δ : {self.avg_life_delta:+.1f}\n"
f" avg cmd dmg: {self.avg_commander_damage:.1f}\n"
f" illegal : {self.avg_illegal_actions:.2f}/game\n"
f" entropy : {self.avg_entropy:.4f}\n"
f" latency : {self.avg_inference_ms:.1f} ms/step\n"
f" throughput : {self.games_per_sec:.2f} games/sec\n"
f" result file: {self.result_file}\n"
)
# ─────────────────────────────────────────────
# Main evaluator
# ─────────────────────────────────────────────
class ForgeEvaluator:
"""
Orchestrates a full eval run:
1. Spin up PolicyInferenceService on cfg.policy_server_url port
2. Run cfg.num_games Forge games via MultiThreadBatchRunner
3. Parse Forge JSONL output for per-game metrics
4. Write results to {results_dir}/eval-{run_id}.json
"""
def __init__(self, cfg: EvalConfig):
self.cfg = cfg
self._records: List[GameRecord] = []
self._result_path = os.path.join(
cfg.results_dir, f"eval-{cfg.run_id}.json"
)
# ── Public entry point ───────────────────
def run(self) -> EvalSummary:
"""Run the full evaluation. Returns an EvalSummary."""
t0 = time.time()
os.makedirs(self.cfg.results_dir, exist_ok=True)
logger.info("[Eval %s] Starting %d-game eval run", self.cfg.run_id, self.cfg.num_games)
logger.info("[Eval %s] Checkpoint: %s", self.cfg.run_id, self.cfg.checkpoint_path)
# Load policy service
service = self._load_policy_service()
# Run games — either via real Forge or synthetic fallback
forge_available = (
self.cfg.forge_jar
and Path(self.cfg.forge_jar).exists()
and len(self.cfg.deck_files) >= 3
)
if forge_available:
self._records = self._run_forge_games(service)
else:
logger.warning(
"[Eval %s] Forge JAR or decks not found — running synthetic eval.",
self.cfg.run_id
)
self._records = self._run_synthetic_eval(service)
elapsed = time.time() - t0
summary = self._build_summary(elapsed)
self._write_results(summary)
logger.info("[Eval %s] Done. %s", self.cfg.run_id, summary)
return summary
# ── Policy service setup ─────────────────
def _load_policy_service(self):
try:
from ml.serving.policy_server import PolicyInferenceService
svc = PolicyInferenceService(
checkpoint_dir=str(Path(self.cfg.checkpoint_path).parent),
embeddings_dir=self.cfg.embeddings_dir,
device=self.cfg.device,
)
svc.load()
ok = svc.reload(self.cfg.checkpoint_path)
if not ok:
logger.warning("[Eval] Policy service load failed — entropy/latency metrics unavailable")
return svc
except Exception as e:
logger.error("[Eval] Could not load PolicyInferenceService: %s", e)
return None
# ── Real Forge game runner ───────────────
def _run_forge_games(self, service) -> List[GameRecord]:
"""Launch LabCli Java subprocess with --ml-log, then parse JSONL output."""
logger.info("[Eval %s] Launching Forge CLI subprocess for %d games", self.cfg.run_id, self.cfg.num_games)
deck_args = []
for i, df in enumerate(self.cfg.deck_files[:4]):
deck_args.extend([f"--deck{i+1}", Path(df).stem])
# Resolve Java 17
java_exe = self.cfg.java_path
for c in [r"C:\Program Files\Eclipse Adoptium\jdk-17.0.18.8-hotspot\bin\java.exe",
r"C:\Program Files\Java\jdk-17\bin\java.exe"]:
if Path(c).exists():
java_exe = c
break
lab_jar = os.path.join(project_root, "target", "commander-ai-lab-1.0.0-SNAPSHOT.jar")
if not Path(lab_jar).exists():
logger.error("[Eval] Lab JAR not found at %s -- run mvn package -DskipTests first", lab_jar)
return self._run_synthetic_eval(service)
cmd = [
java_exe, "-jar", lab_jar,
"--forge-jar", self.cfg.forge_jar,
"--forge-dir", self.cfg.forge_work_dir,
*deck_args,
"--games", str(self.cfg.num_games),
"--threads", str(self.cfg.num_threads),
"--clock", str(self.cfg.clock_seconds),
"--ml-log",
"--output", os.path.join(self.cfg.results_dir, f"eval-{self.cfg.run_id}-batch.json"),
]
if self.cfg.seed is not None:
cmd.extend(["--seed", str(self.cfg.seed)])
logger.info("[Eval %s] CMD: %s", self.cfg.run_id, " ".join(cmd))
try:
result = subprocess.run(cmd, capture_output=True, text=True,
timeout=self.cfg.clock_seconds * self.cfg.num_games + 300)
if result.returncode != 0:
logger.warning("[Eval %s] Forge CLI exit %d: %s", self.cfg.run_id, result.returncode, result.stderr[:500])
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
logger.error("[Eval %s] Forge CLI failed: %s -- falling back to synthetic", self.cfg.run_id, e)
return self._run_synthetic_eval(service)
return self._parse_batch_json(service)
def _parse_batch_json(self, service) -> List[GameRecord]:
"""Parse the batch JSON output from LabCli Java subprocess."""
batch_path = os.path.join(
self.cfg.results_dir, f"eval-{self.cfg.run_id}-batch.json"
)
if not Path(batch_path).exists():
logger.warning("[Eval] Batch JSON not found at %s — falling back to synthetic", batch_path)
return self._run_synthetic_eval(service)
with open(batch_path, encoding="utf-8") as f:
batch = json.load(f)
games_list = batch.get("games", [])
if not games_list:
logger.warning("[Eval] No games in batch JSON — falling back to synthetic")
return self._run_synthetic_eval(service)
records = []
for idx, g in enumerate(games_list):
won = g.get("winningSeat", -1) == 0
turns = g.get("totalTurns", 0)
pr = g.get("playerResults", [])
lp = pr[0].get("finalLife", 40) if len(pr) > 0 else 40
lo = pr[1].get("finalLife", 40) if len(pr) > 1 else 40
records.append(GameRecord(
game_index=idx,
won=won,
turns=turns,
life_final_policy=lp,
life_final_opponent=lo,
life_delta=lp - lo,
commander_damage_dealt=0,
illegal_actions=0,
entropy_mean=0.0,
inference_ms_mean=0.0,
decision_steps=turns,
))
if len(records) >= self.cfg.num_games:
break
logger.info("[Eval %s] Parsed %d games from batch JSON", self.cfg.run_id, len(records))
return records
def _run_forge_via_subprocess(self, service) -> List[GameRecord]:
"""Parse JSONL files produced by a separately-launched Forge batch."""
records = []
results_path = Path(self.cfg.results_dir)
pattern = f"ml-decisions-forge-eval-{self.cfg.run_id}*.jsonl"
all_files = sorted(results_path.glob(pattern))
if not all_files:
logger.warning("[Eval] No JSONL files found matching %s — falling back to synthetic", pattern)
return self._run_synthetic_eval(service)
from ml.training.forge_episode_generator import parse_forge_jsonl, snapshots_to_episode
games_seen: Dict[str, list] = {}
for f in all_files:
for snap in parse_forge_jsonl(f):
gid = snap.get("game_id", "g0")
games_seen.setdefault(gid, []).append(snap)
for idx, (gid, snaps) in enumerate(games_seen.items()):
records.append(self._snaps_to_record(idx, gid, snaps, service))
if len(records) >= self.cfg.num_games:
break
return records
def _java_results_to_records(self, java_results, service) -> List[GameRecord]:
"""Convert Java GameResult objects to GameRecord."""
records = []
for jr in java_results:
try:
won = getattr(jr, "winningSeat", 0) == 0 # seat 0 = policy
records.append(GameRecord(
game_index=getattr(jr, "gameIndex", len(records)),
won=won,
turns=getattr(jr, "turns", 0),
life_final_policy=getattr(jr, "lifeAtEnd", [40])[0] if hasattr(jr, "lifeAtEnd") else 40,
life_final_opponent=getattr(jr, "lifeAtEnd", [40, 40])[1] if hasattr(jr, "lifeAtEnd") else 40,
life_delta=0,
commander_damage_dealt=0,
illegal_actions=getattr(jr, "illegalActions", 0),
entropy_mean=0.0,
inference_ms_mean=0.0,
decision_steps=0,
))
except Exception as e:
logger.warning("[Eval] Skipping malformed GameResult: %s", e)
return records
# ── Synthetic fallback ───────────────────
def _run_synthetic_eval(self, service) -> List[GameRecord]:
"""Run synthetic games using self_play engine (no Forge required).
Used when Forge JAR is missing or deck files aren't configured.
Metrics like illegal_actions and commander_damage are 0 in this mode.
"""
import warnings
warnings.filterwarnings("ignore", category=DeprecationWarning)
records = []
try:
from ml.training.self_play import (
create_random_initial_state, apply_action, advance_turn,
encode_state_simple, HeuristicPolicy
)
from ml.config.scope import NUM_ACTIONS, IDX_TO_ACTION
import torch
import torch.nn.functional as F
except ImportError as e:
logger.error("[Eval] Cannot import self_play: %s", e)
return []
opponent = HeuristicPolicy()
agent_has_model = service is not None and service._loaded
for game_idx in range(self.cfg.num_games):
state = create_random_initial_state()
entropies, latencies = [], []
steps = 0
while not state.game_over and steps < 60:
sv = encode_state_simple(state, 0, "midrange")
t0 = time.time()
if agent_has_model:
snap = state.to_snapshot()
result = service.predict(
snap, playstyle="midrange",
greedy=self.cfg.greedy,
temperature=self.cfg.temperature,
)
action_idx = result.get("action_index", 7)
probs_map = result.get("probabilities", {})
probs = np.array(list(probs_map.values())) if probs_map else np.ones(NUM_ACTIONS) / NUM_ACTIONS
else:
action_idx = np.random.randint(NUM_ACTIONS)
probs = np.ones(NUM_ACTIONS) / NUM_ACTIONS
latencies.append((time.time() - t0) * 1000)
p = np.clip(probs, 1e-9, 1.0)
p /= p.sum()
entropies.append(float(-np.sum(p * np.log(p))))
state = apply_action(state, action_idx, 0)
if not state.game_over:
opp_sv = encode_state_simple(state, 1, "midrange")
opp_a, _ = opponent.select_action(opp_sv)
state = apply_action(state, opp_a, 1)
if not state.game_over:
state = advance_turn(state)
steps += 1
won = state.winner == 0
lp = state.players[0].life_total
lo = state.players[1].life_total
records.append(GameRecord(
game_index=game_idx,
won=won,
turns=state.turn,
life_final_policy=lp,
life_final_opponent=lo,
life_delta=lp - lo,
commander_damage_dealt=0,
illegal_actions=0,
entropy_mean=float(np.mean(entropies)) if entropies else 0.0,
inference_ms_mean=float(np.mean(latencies)) if latencies else 0.0,
decision_steps=steps,
))
return records
# ── JSONL snapshot → GameRecord ──────────
def _snaps_to_record(self, idx: int, gid: str, snaps: list, service) -> GameRecord:
entropies, latencies = [], []
won = False
turns = len(snaps)
cmd_dmg = 0
illegal = 0
lp, lo = 40, 40
for snap in snaps:
if "outcome" in snap and snap == snaps[-1]:
won = snap["outcome"].get("winner_seat", -1) == 0
lp = snap["outcome"].get("life_remaining", 40)
lo = snap["outcome"].get("opp_life_remaining", 0)
cmd_dmg = snap["outcome"].get("commander_damage_dealt", 0)
if service and service._loaded and "state_vector" in snap:
t0 = time.time()
result = service.predict(snap, playstyle="midrange", greedy=self.cfg.greedy)
latencies.append((time.time() - t0) * 1000)
probs_map = result.get("probabilities", {})
if probs_map:
p = np.clip(list(probs_map.values()), 1e-9, 1.0)
p /= p.sum()
entropies.append(float(-np.sum(p * np.log(p))))
return GameRecord(
game_index=idx,
won=won,
turns=turns,
life_final_policy=lp,
life_final_opponent=lo,
life_delta=lp - lo,
commander_damage_dealt=cmd_dmg,
illegal_actions=illegal,
entropy_mean=float(np.mean(entropies)) if entropies else 0.0,
inference_ms_mean=float(np.mean(latencies)) if latencies else 0.0,
decision_steps=len(snaps),
)
# ── Summary builder ──────────────────────
def _build_summary(self, elapsed: float) -> EvalSummary:
n = len(self._records)
if n == 0:
return EvalSummary(
run_id=self.cfg.run_id,
checkpoint_path=self.cfg.checkpoint_path,
num_games=0, wins=0, losses=0,
win_rate=0.0, win_rate_ci95_low=0.0, win_rate_ci95_high=0.0,
avg_turns=0.0, avg_life_delta=0.0, avg_commander_damage=0.0,
avg_illegal_actions=0.0, avg_entropy=0.0, avg_inference_ms=0.0,
total_decision_steps=0, elapsed_wall_s=elapsed, games_per_sec=0.0,
error_count=0, result_file=self._result_path,
)
wins = sum(1 for r in self._records if r.won)
losses = n - wins
wr = wins / n
# Wilson score 95% CI
z = 1.96
denom = 1 + z**2 / n
centre = (wr + z**2 / (2 * n)) / denom
margin = (z * (wr * (1 - wr) / n + z**2 / (4 * n**2)) ** 0.5) / denom
ci_lo = max(0.0, centre - margin)
ci_hi = min(1.0, centre + margin)
errors = sum(1 for r in self._records if r.error)
return EvalSummary(
run_id=self.cfg.run_id,
checkpoint_path=self.cfg.checkpoint_path,
num_games=n,
wins=wins,
losses=losses,
win_rate=wr,
win_rate_ci95_low=ci_lo,
win_rate_ci95_high=ci_hi,
avg_turns=float(np.mean([r.turns for r in self._records])),
avg_life_delta=float(np.mean([r.life_delta for r in self._records])),
avg_commander_damage=float(np.mean([r.commander_damage_dealt for r in self._records])),
avg_illegal_actions=float(np.mean([r.illegal_actions for r in self._records])),
avg_entropy=float(np.mean([r.entropy_mean for r in self._records])),
avg_inference_ms=float(np.mean([r.inference_ms_mean for r in self._records])),
total_decision_steps=sum(r.decision_steps for r in self._records),
elapsed_wall_s=elapsed,
games_per_sec=n / max(elapsed, 0.001),
error_count=errors,
result_file=self._result_path,
)
# ── Result writer ────────────────────────
def _write_results(self, summary: EvalSummary) -> None:
payload = {
"summary": asdict(summary),
"games": [asdict(r) for r in self._records],
}
os.makedirs(self.cfg.results_dir, exist_ok=True)
with open(self._result_path, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
logger.info("[Eval %s] Results written to %s", self.cfg.run_id, self._result_path)
+1 -1
View File
@@ -417,7 +417,7 @@ def main():
# Save results
output_path = args.output or os.path.join(ckpt_dir, "tournament_results.json")
with open(output_path, "w") as f:
with open(output_path, "w", encoding="utf-8") as f:
json.dump(result.to_dict(), f, indent=2)
logger.info("Results saved: %s", output_path)
+2 -2
View File
@@ -38,7 +38,7 @@ def load_precon_cards(precon_dir: str) -> dict:
cards = {"commander": None, "creatures": [], "spells": [], "lands": [], "artifacts": [], "all": []}
section = "Main"
with open(dck_file, "r") as f:
with open(dck_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("//") or line.startswith("Name="):
@@ -380,7 +380,7 @@ def main():
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
total_decisions = 0
with open(args.output, "w") as f:
with open(args.output, "w", encoding="utf-8") as f:
for game_idx in range(args.games):
# Pick two random decks
d1_name, d2_name = random.sample(deck_names, 2)
+3 -3
View File
@@ -68,7 +68,7 @@ def cmd_status(args):
for f in jsonl_files:
lines = 0
try:
with open(f) as fh:
with open(f, encoding="utf-8") as fh:
lines = sum(1 for _ in fh)
except Exception:
pass
@@ -130,7 +130,7 @@ def cmd_inspect(args):
print(f"\n Inspecting: {filepath.name}\n")
count = 0
with open(filepath) as f:
with open(filepath, encoding="utf-8") as f:
for i, line in enumerate(f):
if i >= args.limit:
break
@@ -150,7 +150,7 @@ def cmd_inspect(args):
except Exception as e:
print(f" [{i:>4}] ERROR: {e}")
with open(filepath) as f:
with open(filepath, encoding="utf-8") as f:
total = sum(1 for _ in f)
print(f"\n Showing {count} of {total:,} total decisions.\n")
+1 -1
View File
@@ -253,7 +253,7 @@ class DecisionExporter:
filename = f"ml-decisions-ppo-{self._batch_id}.jsonl"
filepath = os.path.join(self.config.output_dir, filename)
with open(filepath, "w") as f:
with open(filepath, "w", encoding="utf-8") as f:
for record in self._records:
f.write(json.dumps(record) + "\n")
+2 -2
View File
@@ -759,7 +759,7 @@ class DistillationLoop:
"""Save generation history to JSON."""
path = os.path.join(self.config.history_dir, "generations.json")
history = [g.to_dict() for g in self.generations]
with open(path, "w") as f:
with open(path, "w", encoding="utf-8") as f:
json.dump(history, f, indent=2)
logger.info(" Generation history saved: %s", path)
@@ -793,7 +793,7 @@ class DistillationLoop:
def _save_summary(self, summary: Dict):
"""Save final summary to JSON."""
path = os.path.join(self.config.history_dir, "summary.json")
with open(path, "w") as f:
with open(path, "w", encoding="utf-8") as f:
json.dump(summary, f, indent=2, default=str)
logger.info(" Summary saved: %s", path)
+1 -1
View File
@@ -91,7 +91,7 @@ def parse_forge_jsonl(path: Path) -> List[Dict[str, Any]]:
game_id, turn, phase, active_seat, state, action, outcome
"""
snapshots = []
with open(path, "r") as f:
with open(path, "r", encoding="utf-8") as f:
for lineno, line in enumerate(f, 1):
line = line.strip()
if not line:
+1 -1
View File
@@ -696,7 +696,7 @@ class PPOTrainer:
self.save_checkpoint()
history_path = os.path.join(cfg.checkpoint_dir, "ppo_history.json")
with open(history_path, "w") as f:
with open(history_path, "w", encoding="utf-8") as f:
json.dump(self.history, f, indent=2, default=str)
summary = {
+10 -4
View File
@@ -322,7 +322,7 @@ class SupervisedTrainer:
}
serializable_history.append(entry)
with open(history_path, "w") as f:
with open(history_path, "w", encoding="utf-8") as f:
json.dump(serializable_history, f, indent=2)
logger.info(" History saved: %s", history_path)
@@ -547,7 +547,7 @@ def get_best_device() -> str:
return "cpu"
if torch.cuda.is_available():
name = torch.cuda.get_device_name(0)
mem = torch.cuda.get_device_properties(0).total_mem / (1024**3)
mem = torch.cuda.get_device_properties(0).total_memory / (1024**3)
logger.info(" CUDA device: %s (%.1f GB)", name, mem)
return "cuda"
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
@@ -562,6 +562,12 @@ def get_best_device() -> str:
# ══════════════════════════════════════════════════════════
def main():
# Ensure logger output is visible on console
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
)
parser = argparse.ArgumentParser(
description="Commander AI Lab — Train Policy Network"
)
@@ -638,7 +644,7 @@ def main():
# Save evaluation results
eval_path = os.path.join(ckpt_dir, "eval_results.json")
with open(eval_path, "w") as f:
with open(eval_path, "w", encoding="utf-8") as f:
json.dump(results, f, indent=2)
logger.info(f" Results saved: {eval_path}")
return
@@ -696,7 +702,7 @@ def main():
print_evaluation(results)
eval_path = os.path.join(ckpt_dir, "eval_results.json")
with open(eval_path, "w") as f:
with open(eval_path, "w", encoding="utf-8") as f:
json.dump(results, f, indent=2)
logger.info(" Eval results saved: %s", eval_path)
else:
+5
View File
@@ -35,6 +35,7 @@ class Config:
forge_jar: str = ""
forge_dir: str = ""
forge_decks_dir: str = ""
user_decks_dir: str = ""
lab_jar: str = ""
precon_dir: str = ""
results_dir: str = "results"
@@ -57,6 +58,10 @@ CFG.lab_jar = os.environ.get("LAB_JAR", "")
# PRECON_DIR / PRECON_DECKS_DIR are aliases -- prefer PRECON_DIR
CFG.precon_dir = os.environ.get("PRECON_DIR") or os.environ.get("PRECON_DECKS_DIR", "")
CFG.forge_decks_dir = os.environ.get("FORGE_DECKS_DIR", "")
CFG.user_decks_dir = os.environ.get(
"USER_DECKS_DIR",
os.path.join(os.path.dirname(__file__), "..", "user-decks")
)
def validate_forge_config() -> None:
+19 -5
View File
@@ -50,6 +50,7 @@ DEFAULT_CONFIG = {
"train_lr": 0.001, # learning rate
"train_batch_size": 256, # training batch size
"train_patience": 15, # early stopping patience
"train_ds_weight": 4.0, # DeepSeek source upsampling weight (ds-*.jsonl files)
# Java-only settings (ignored for DeepSeek):
"java_threads": 4,
"java_clock": 300,
@@ -279,14 +280,21 @@ def run_update_weights(cfg):
return False
def start_training(base_url, epochs, lr, batch_size, patience):
"""Trigger ML training and return immediately."""
def start_training(base_url, epochs, lr, batch_size, patience, ds_weight=4.0):
"""Trigger ML training and return immediately.
ds_weight: upsampling multiplier for DeepSeek decision files
(ml-decisions-ds-*.jsonl). Matches the default in
DatasetConfig.source_weights added in the recent
dataset_builder.py update.
"""
resp = api_post(base_url, "/api/ml/train", {
"epochs": epochs,
"lr": lr,
"batchSize": batch_size,
"patience": patience,
"rebuildDataset": True,
"dsWeight": ds_weight,
})
return resp
@@ -416,6 +424,7 @@ def run_overnight(cfg):
log.info(f" Engine: {engine}")
log.info(f" Auto-update weights: {'Yes' if cfg['auto_update_weights'] else 'No'}")
log.info(f" Auto-train after: {'Yes' if cfg['auto_train'] else 'No'}")
log.info(f" DS upsampling weight: {cfg['train_ds_weight']}x")
log.info("")
while True:
@@ -510,7 +519,8 @@ def run_overnight(cfg):
log.info("Starting ML training pipeline...")
log.info(f" Epochs: {cfg['train_epochs']}, LR: {cfg['train_lr']}, "
f"Batch: {cfg['train_batch_size']}, Patience: {cfg['train_patience']}")
f"Batch: {cfg['train_batch_size']}, Patience: {cfg['train_patience']}, "
f"DS Weight: {cfg['train_ds_weight']}x")
try:
start_training(
@@ -519,6 +529,7 @@ def run_overnight(cfg):
lr=cfg["train_lr"],
batch_size=cfg["train_batch_size"],
patience=cfg["train_patience"],
ds_weight=cfg["train_ds_weight"],
)
log.info("Training started, monitoring progress...")
success = wait_for_training(base_url)
@@ -556,8 +567,8 @@ Examples:
# Just run sims, skip both weight update and training:
python overnight-run.py --no-train --no-update-weights
# Custom training settings:
python overnight-run.py --epochs 200 --lr 0.0005 --patience 20
# Custom training settings with higher DS upsampling:
python overnight-run.py --epochs 200 --lr 0.0005 --patience 20 --ds-weight 6.0
""",
)
parser.add_argument("--url", default="http://localhost:8080",
@@ -590,6 +601,8 @@ Examples:
help="Training batch size (default: 256)")
parser.add_argument("--patience", type=int, default=15,
help="Early stopping patience (default: 15)")
parser.add_argument("--ds-weight", type=float, default=4.0,
help="DeepSeek source upsampling weight for training (default: 4.0)")
parser.add_argument("--threads", type=int, default=4,
help="Java engine threads (default: 4)")
parser.add_argument("--clock", type=int, default=300,
@@ -611,6 +624,7 @@ Examples:
cfg["train_lr"] = args.lr
cfg["train_batch_size"] = args.batch_size
cfg["train_patience"] = args.patience
cfg["train_ds_weight"] = args.ds_weight
cfg["java_threads"] = args.threads
cfg["java_clock"] = args.clock
+68
View File
@@ -0,0 +1,68 @@
import re
with open('ml/eval/forge_evaluator.py', 'r', encoding='utf-8') as f:
content = f.read()
# Fix 1: >= 2 to >= 3
content = content.replace('deck_files) >= 2', 'deck_files) >= 3')
# Fix 2: replace call to _run_forge_via_subprocess with _parse_batch_json
content = content.replace(
'return self._run_forge_via_subprocess(service)',
'return self._parse_batch_json(service)'
)
# Fix 3: Insert _parse_batch_json method before _run_forge_via_subprocess
new_method = '''
def _parse_batch_json(self, service) -> List[GameRecord]:
"""Parse the batch JSON output from LabCli Java subprocess."""
batch_path = os.path.join(
self.cfg.results_dir, f"eval-{self.cfg.run_id}-batch.json"
)
if not Path(batch_path).exists():
logger.warning("[Eval] Batch JSON not found at %s -- falling back to synthetic", batch_path)
return self._run_synthetic_eval(service)
with open(batch_path, encoding='utf-8') as f:
batch = json.load(f)
games_list = batch.get("games", [])
if not games_list:
logger.warning("[Eval] No games in batch JSON -- falling back to synthetic")
return self._run_synthetic_eval(service)
records = []
for idx, g in enumerate(games_list):
won = g.get("winningSeat", -1) == 0
turns = g.get("totalTurns", 0)
pr = g.get("playerResults", [])
lp = pr[0].get("finalLife", 40) if len(pr) > 0 else 40
lo = pr[1].get("finalLife", 40) if len(pr) > 1 else 40
records.append(GameRecord(
game_index=idx,
won=won,
turns=turns,
life_final_policy=lp,
life_final_opponent=lo,
life_delta=lp - lo,
commander_damage_dealt=0,
illegal_actions=0,
entropy_mean=0.0,
inference_ms_mean=0.0,
decision_steps=turns,
))
if len(records) >= self.cfg.num_games:
break
logger.info("[Eval %s] Parsed %d games from batch JSON", self.cfg.run_id, len(records))
return records
'''
content = content.replace(
' def _run_forge_via_subprocess(',
new_method + ' def _run_forge_via_subprocess('
)
with open('ml/eval/forge_evaluator.py', 'w', encoding='utf-8') as f:
f.write(content)
import py_compile
py_compile.compile('ml/eval/forge_evaluator.py', doraise=True)
print('Syntax OK - patch applied')
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""
Phase 1 patch: no-op mana pool display functions in lan-client/ui.js
Run from anywhere — edit UI_JS_PATH to point at your file.
"""
import re
UI_JS_PATH = r"D:\ForgeCommander\commander-ai-lab\lan-client\ui.js"
with open(UI_JS_PATH, "r", encoding="utf-8") as f:
content = f.read()
original_size = len(content)
changes = []
# ── Change 1: no-op renderManaPool ───────────────────────────────────────────
old1 = re.search(
r'function renderManaPool\(playerIdx\) \{[^}]*?display\.style\.display[^}]*?\}',
content, re.DOTALL
)
if old1:
content = content[:old1.start()] + \
"function renderManaPool(playerIdx) {\n // mana pool display removed (Phase 1)\n}" + \
content[old1.end():]
changes.append("renderManaPool -> stub")
else:
print("WARNING: renderManaPool pattern not matched — skipping")
# ── Change 2: no-op showManaPool ─────────────────────────────────────────────
old2 = re.search(
r'function showManaPool\(\) \{.*?\}',
content, re.DOTALL
)
if old2:
content = content[:old2.start()] + \
"function showManaPool() {\n // mana pool display removed (Phase 1)\n}" + \
content[old2.end():]
changes.append("showManaPool -> stub")
else:
print("WARNING: showManaPool pattern not matched — skipping")
# ── Change 3: remove Mana Auto-Clear row from renderSettingsPanel ─────────────
mana_row = (
" +\n '<div class=\"settings-row\"><span>Mana Auto-Clear</span>'"
" + renderToggle('manaAutoClear', gameState.manaAutoClear) + '</div>';"
)
if mana_row in content:
content = content.replace(mana_row, ";", 1)
changes.append("Mana Auto-Clear row removed from renderSettingsPanel")
else:
print("WARNING: Mana Auto-Clear row not found — skipping")
# ── Write ─────────────────────────────────────────────────────────────────────
if changes:
with open(UI_JS_PATH, "w", encoding="utf-8") as f:
f.write(content)
print(f"Done. {original_size} -> {len(content)} chars ({len(content)-original_size:+d})")
for c in changes:
print(f" ✓ {c}")
else:
print("No changes applied.")
+35 -7
View File
@@ -245,6 +245,20 @@ async def add_deck_card(deck_id: int, req: AddDeckCardRequest):
).fetchone()
card_name = ce_row["name"] if ce_row else (req.card_name or "")
# Ensure card_records has type_line for this card (Scryfall lookup if missing)
if card_name:
existing_cr = conn.execute(
"SELECT type_line FROM card_records WHERE name = ? LIMIT 1", (card_name,)
).fetchone()
if not existing_cr or not existing_cr["type_line"]:
enriched = _enrich_from_scryfall(card_name)
if enriched and enriched.get("type_line"):
conn.execute(
"INSERT OR IGNORE INTO card_records (name, type_line, oracle_text, cmc) VALUES (?,?,?,?)",
(card_name, enriched["type_line"], enriched.get("oracle_text", ""), enriched.get("cmc", 0))
)
conn.commit()
existing = conn.execute(
"SELECT id, quantity FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?",
(deck_id, req.scryfall_id)
@@ -782,10 +796,12 @@ async def export_deck_to_sim(deck_id: int):
if not safe_name:
safe_name = f"deck_{deck_id}"
save_dir = CFG.forge_decks_dir
if not save_dir or not os.path.isdir(save_dir):
save_dir = os.path.join(os.path.dirname(__file__), "exported-decks")
os.makedirs(save_dir, exist_ok=True)
save_dir = (
CFG.user_decks_dir
or CFG.forge_decks_dir
or os.path.join(os.path.dirname(__file__), "..", "user-decks")
)
os.makedirs(save_dir, exist_ok=True)
out_path = Path(save_dir) / f"{safe_name}.dck"
out_path.write_text(content, encoding="utf-8")
@@ -872,7 +888,11 @@ async def import_decklist(deck_id: int, request: FastAPIRequest):
"""
deck = _get_deck_or_404(deck_id)
body = await request.json()
text = body.get('text', '')
try: # FARMHOUSE_DECK_LINKS_V1b
from services.farmhouse_deck_links import expand as _fh_expand
text, _fh_name = _fh_expand(body.get('text', '') or body.get('url', ''))
except ValueError as _fh_err:
return JSONResponse({'error': str(_fh_err)}, status_code=400)
clear_first = body.get('clearFirst', False)
if not text.strip():
@@ -964,8 +984,12 @@ async def import_decklist_new(request: FastAPIRequest):
Body: { "text": "...", "name": "My Deck" }
"""
body = await request.json()
text = body.get('text', '')
deck_name = body.get('name', '').strip()
try: # FARMHOUSE_DECK_LINKS_V1c
from services.farmhouse_deck_links import expand as _fh_expand
text, _fh_name = _fh_expand(body.get('text', '') or body.get('url', ''))
except ValueError as _fh_err:
return JSONResponse({'error': str(_fh_err)}, status_code=400)
deck_name = body.get('name', '').strip() or (_fh_name or '')
if not text.strip():
return JSONResponse({'error': 'No decklist text provided'}, status_code=400)
@@ -1048,6 +1072,10 @@ def _scryfall_fuzzy_lookup(name: str) -> Optional[dict]:
Fuzzy-search Scryfall for a card name (used by the scanner pipeline).
Returns the raw Scryfall JSON dict, or None on failure.
"""
from services.farmhouse_deck_links import local_card as _fh_local # FARMHOUSE_LOCAL_LOOKUP
_fh_hit = _fh_local(name)
if _fh_hit:
return _fh_hit
_scryfall_rate_limit()
try:
encoded = quote(name)
+8 -7
View File
@@ -861,12 +861,13 @@ async def _call_anthropic_api(messages: list[dict], max_tokens: int = 4096, temp
else:
if not CFG.anthropic_api_key:
raise HTTPException(400, 'Anthropic Claude API key not configured. Set anthropic_api_key env var or --anthropic-key.')
base_url = 'https://api.anthropic.com'
model = ANTHROPIC_MODEL
headers = {
base_url = 'https://api.anthropic.com'
model = ANTHROPIC_MODEL
headers = {
'Content-Type': 'application/json',
'x-api-key': CFG.anthropic_api_key, 'anthropic-version': '2023-06-01',
}
'x-api-key': CFG.anthropic_api_key,
'anthropic-version': '2023-06-01',
}
# Separate system messages for Anthropic API format
system_msgs = [m['content'] for m in messages if m.get('role') == 'system']
user_msgs = [m for m in messages if m.get('role') != 'system']
@@ -1320,7 +1321,7 @@ async def deck_gen_v3_status():
return {
'initialized': _coach._deck_gen_v3 is not None,
'anthropic_configured': bool(CFG.anthropic_api_key),
'model': getattr(_coach._deck_gen_v3, 'model', getattr(getattr(_coach._deck_gen_v3, 'anthropic_client', None), 'model', 'ollama/gpt-oss:20b')) if _coach._deck_gen_v3 else None,
'model': getattr(_coach._deck_gen_v3, 'model', getattr(getattr(_coach._deck_gen_v3, 'anthropic_client', None), 'model', 'claude-opus-4-20250514')) if _coach._deck_gen_v3 else None,
'embeddings_loaded': False,
'embedding_cards': 0,
'error': _coach._deck_gen_v3_error,
@@ -1341,7 +1342,7 @@ async def deck_gen_v3_generate(req: DeckGenV3Request):
6. Return complete deck with substitution data
"""
if _coach._deck_gen_v3 is None:
raise HTTPException(503, 'V3 Deck Generator not initialized. Ensure Ollama is running with gpt-oss:20b model.')
raise HTTPException(503, 'V3 Deck Generator not initialized. Ensure the Anthropic API key is configured.')
if not req.commander_name or len(req.commander_name.strip()) < 2:
raise HTTPException(400, 'Commander name is required (min 2 chars)')
+3 -1
View File
@@ -226,7 +226,7 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
results_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'results')
os.makedirs(results_dir, exist_ok=True)
batch_path = os.path.join(results_dir, f'ml-decision-sim-{sim_id}.json')
with open(batch_path, 'w') as f:
with open(batch_path, 'w', encoding='utf-8') as f:
json.dump(batch_data, f, indent=2, default=str)
logger.info(f'Saved batch results to {batch_path}')
@@ -516,6 +516,8 @@ def _fetch_deck_card_data(deck_id):
'keywords': r[7] or '',
'mana_cost': r[8] or '',
})
from services.farmhouse_sim import fill_card_data as _fh_fill # FARMHOUSE_SIM_DATA_B
_fh_fill(card_data)
return deck_name, card_data
+116
View File
@@ -0,0 +1,116 @@
"""Rules-accurate simulation through phase-sim (copied into the app as routes/farmhouse_phase.py by the Dockerfile).
The phase-rs engine (Rust, headless) plays four-player Commander with a real stack, priority, combat, layers and triggers; the
phase-sim service wraps it. This route turns four SAVED decks into the service's request, relays the job, and returns what the
engine reported. Nothing is simulated here, and a game that stalled, hit the action cap or panicked is reported, never counted as a win.
GET /api/farmhouse/phase/status the service's health (is the engine there, how many jobs are waiting)
POST /api/farmhouse/phase/run {"deckIds": [4 saved deck ids], "games": 1..50, "seed": 1, "difficulty": "Medium",
"seatDifficulty": {"2": "Hard"}} -> {"job": id, "decks": [names]}
GET /api/farmhouse/phase/job/{job} status, progress and, when finished, wins by seat, win rate with a 95% interval,
warnings (cards the engine did not recognise) and each game's outcome
Setting: PHASE_SIM_URL (e.g. http://phase-sim.commander-lab.svc.cluster.local:8090). Unset, every endpoint answers 503 and nothing else changes.
"""
import json
import os
import urllib.error
import urllib.request
from typing import Dict, List, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services.database import _get_db_conn
router = APIRouter(tags=["farmhouse-phase"])
class PhaseRun(BaseModel):
deckIds: List[int]
games: int = 1
seed: int = 1
difficulty: str = "Medium"
seatDifficulty: Optional[Dict[str, str]] = None
actionCap: Optional[int] = None
# Seats (0-3) a MODEL plays instead of phase's built-in AI, and which kind: {"kind": "chat"|"decision", "model": "twin-turbo", "max_calls": 300}.
# The model only ever picks among the engine's own legal options; the service falls back to phase's AI on any problem.
llmSeats: Optional[List[int]] = None
llm: Optional[Dict] = None
def _base():
url = os.environ.get("PHASE_SIM_URL", "").rstrip("/")
if not url:
raise HTTPException(503, "the rules engine service is not configured (PHASE_SIM_URL)")
return url
def _call(path, body=None, timeout=20):
req = urllib.request.Request(_base() + path, data=None if body is None else json.dumps(body).encode(),
headers={"content-type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.load(r)
except urllib.error.HTTPError as exc:
try:
detail = json.load(exc).get("error", "")
except Exception:
detail = ""
raise HTTPException(exc.code if exc.code in (400, 404, 413) else 502, detail or "the rules engine service refused the request")
except (urllib.error.URLError, OSError, ValueError):
raise HTTPException(503, "the rules engine service is not reachable")
def deck_feed(deck_id):
"""A saved deck as the phase-sim feed entry: {"name", "commander": [...], "main": [{"name", "count"}]}."""
conn = _get_db_conn()
deck = conn.execute("SELECT id, name, commander_name FROM decks WHERE id = ?", (deck_id,)).fetchone()
if not deck:
raise HTTPException(404, "deck %s not found" % deck_id)
rows = conn.execute("SELECT card_name, quantity, is_commander FROM deck_cards WHERE deck_id = ?", (deck_id,)).fetchall()
commanders, main = [], {}
for r in rows:
name, qty = (r["card_name"] or "").strip(), int(r["quantity"] or 1)
if not name:
continue
if r["is_commander"]:
if name not in commanders:
commanders.append(name)
else:
main[name] = main.get(name, 0) + qty
if not commanders and (deck["commander_name"] or "").strip():
commanders = [deck["commander_name"].strip()]
if not commanders:
raise HTTPException(400, "deck %r has no commander set" % deck["name"])
main = {n: c for n, c in main.items() if n not in commanders}
if not main:
raise HTTPException(400, "deck %r has no cards" % deck["name"])
return {"name": deck["name"], "commander": commanders, "main": [{"name": n, "count": c} for n, c in sorted(main.items())]}
@router.get("/api/farmhouse/phase/status")
def phase_status():
return _call("/health", timeout=5)
@router.post("/api/farmhouse/phase/run")
def phase_run(req: PhaseRun):
if len(req.deckIds) != 4:
raise HTTPException(400, "pick exactly 4 decks (one per seat)")
feed = [deck_feed(i) for i in req.deckIds]
body = {"decks": feed, "games": req.games, "seed": req.seed, "difficulty": req.difficulty}
if req.seatDifficulty:
body["seat_difficulty"] = req.seatDifficulty
if req.actionCap:
body["action_cap"] = req.actionCap
if req.llmSeats:
body["llm_seats"] = req.llmSeats
body["llm"] = req.llm or {}
out = _call("/run", body)
return {"job": out["job"], "decks": [d["name"] for d in feed]}
@router.get("/api/farmhouse/phase/job/{job}")
def phase_job(job: str):
return _call("/job/" + "".join(c for c in job if c in "0123456789abcdef"), timeout=10)
+1 -1
View File
@@ -147,7 +147,7 @@ async def start_game(req: StartGameRequest):
# Build a BatchState so the forge_runner watchdog and progress tracking
# work the same way they do for overnight batch runs.
state = BatchState(batch_id=game_id)
state = BatchState(batch_id=game_id, total_games=1, threads=1)
_sessions[game_id] = {
"game_id": game_id,
+12 -3
View File
@@ -98,6 +98,7 @@ async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
if req.threads < 1 or req.threads > 32:
raise HTTPException(400, "threads must be 1-32")
req.threads = min(req.threads, int(os.environ.get("FORGE_MAX_THREADS", "1"))) # FARMHOUSE_FORGE_THREADS
if not os.path.exists(CFG.forge_jar):
raise HTTPException(500, f"Forge JAR not found: {CFG.forge_jar}")
if not os.path.isdir(CFG.forge_dir):
@@ -108,7 +109,7 @@ async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
active_batches[batch_id] = state
os.makedirs(CFG.results_dir, exist_ok=True)
output_path = os.path.join(CFG.results_dir, f"ml-decision-{batch_id}.json")
output_path = os.path.abspath(os.path.join(CFG.results_dir, f"ml-decision-{batch_id}.json")) # FARMHOUSE_RESULTS_ABS
state.result_path = output_path
# Auto-export deckbuilder decks to .dck if not already exported
@@ -259,7 +260,14 @@ async def list_decks():
try:
conn = _get_db_conn()
rows = conn.execute(
"SELECT id, name, commander_name FROM decks ORDER BY id DESC"
"""
SELECT d.id, d.name, d.commander_name,
COALESCE(SUM(dc.quantity), 0) AS total_cards
FROM decks d
LEFT JOIN deck_cards dc ON dc.deck_id = d.id
GROUP BY d.id
ORDER BY d.id DESC
"""
).fetchall()
for row in rows:
db_name = row["name"]
@@ -270,6 +278,7 @@ async def list_decks():
"source": "deckbuilder",
"deck_id": row["id"],
"commander": row["commander_name"] or "",
"totalCards": row["total_cards"],
})
seen_names.add(db_name.lower())
except Exception as e:
@@ -446,7 +455,7 @@ async def get_log(batchId: str):
state = active_batches.get(batchId)
if not state:
raise HTTPException(404, f"Batch {batchId} not found")
return {"lines": state.log_lines[-200:]}
return {"lines": state.log_lines[-2000:]}
@router.get("/api/lab/debug-log")
+11 -11
View File
@@ -130,7 +130,7 @@ async def ml_status():
for f in sorted(results_dir.glob("ml-decisions-*.jsonl")):
lines = 0
try:
with open(f) as fh:
with open(f, encoding="utf-8") as fh:
lines = sum(1 for _ in fh)
except Exception:
pass
@@ -168,7 +168,7 @@ async def ml_get_decisions(filename: str, limit: int = 100, offset: int = 0):
raise HTTPException(404, f"ML decisions file not found: {filename}")
decisions = []
try:
with open(filepath) as f:
with open(filepath, encoding="utf-8") as f:
for i, line in enumerate(f):
if i < offset:
continue
@@ -436,7 +436,7 @@ def _run_training_pipeline(
best_model, _ = load_checkpoint(summary["checkpoint_path"], device)
eval_results = evaluate_model(best_model, test_states, test_labels, device)
eval_path = os.path.join(ckpt_dir, "eval_results.json")
with open(eval_path, "w") as f:
with open(eval_path, "w", encoding="utf-8") as f:
json.dump(eval_results, f, indent=2)
# Done
with _training_lock:
@@ -530,7 +530,7 @@ async def ml_data_status():
for f in sorted(results_dir.glob("ml-decisions-*.jsonl")):
line_count = 0
try:
with open(f, "r") as fh:
with open(f, "r", encoding="utf-8") as fh:
line_count = sum(1 for _ in fh)
except Exception:
pass
@@ -561,7 +561,7 @@ async def ml_data_status():
eval_results = None
if eval_path.exists():
try:
with open(eval_path, "r") as f:
with open(eval_path, "r", encoding="utf-8") as f:
eval_results = json.load(f)
except Exception:
pass
@@ -705,7 +705,7 @@ def _run_tournament_pipeline(episodes: int, playstyle: str):
playstyle=playstyle,
)
output_path = os.path.join(ckpt_dir, "tournament_results.json")
with open(output_path, "w") as f:
with open(output_path, "w", encoding="utf-8") as f:
json.dump(result.to_dict(), f, indent=2)
with _tournament_lock:
_tournament_state.phase = "done"
@@ -758,7 +758,7 @@ async def ml_tournament_results():
project_root = Path(__file__).resolve().parent.parent
results_path = project_root / "ml" / "models" / "checkpoints" / "tournament_results.json"
if results_path.exists():
with open(results_path, "r") as f:
with open(results_path, "r", encoding="utf-8") as f:
return json.load(f)
return {"error": "No tournament results found. Run a tournament first."}
@@ -798,7 +798,7 @@ async def ml_stats():
eval_path = ckpt_dir / "eval_results.json"
if eval_path.exists():
try:
with open(eval_path, "r") as f:
with open(eval_path, "r", encoding="utf-8") as f:
eval_results = json.load(f)
except Exception:
pass
@@ -1057,14 +1057,14 @@ async def ml_distillation_history():
if history_path.exists():
try:
with open(history_path, "r") as f:
with open(history_path, "r", encoding="utf-8") as f:
result["generations"] = json.load(f)
except Exception:
pass
if summary_path.exists():
try:
with open(summary_path, "r") as f:
with open(summary_path, "r", encoding="utf-8") as f:
result["summary"] = json.load(f)
except Exception:
pass
@@ -1203,7 +1203,7 @@ async def ml_elo_history():
history_path = project_root / "data" / "elo_history.json"
if history_path.exists():
try:
with open(history_path, "r") as f:
with open(history_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
+2 -2
View File
@@ -113,7 +113,7 @@ def load_result(path: str) -> dict:
print(f"ERROR: File not found: {path}", file=sys.stderr)
sys.exit(2)
try:
with open(p) as f:
with open(p, encoding="utf-8") as f:
data = json.load(f)
except json.JSONDecodeError as e:
print(f"ERROR: Invalid JSON in {path}: {e}", file=sys.stderr)
@@ -289,7 +289,7 @@ def main() -> int:
"passed": delta >= args.min_delta and pval < args.alpha,
}
Path(args.json_out).parent.mkdir(parents=True, exist_ok=True)
with open(args.json_out, "w") as jf:
with open(args.json_out, "w", encoding="utf-8") as jf:
json.dump(out, jf, indent=2)
print(f"Comparison JSON written to: {args.json_out}")
+75
View File
@@ -0,0 +1,75 @@
"""How often does a decision model (Laya / Kev) pick the same action as the LLM did, on game states the app has already recorded?
DECISION_URL=http://laya:8080 python scripts/farmhouse_decision_eval.py [decisions_dir] [model ...]
Reads the brain's own decision logs (logs/decisions/*.jsonl: the snapshot and the LLM's answer), asks the decision model the same
question for each, and prints agreement overall, at several confidence levels, per action, the "always the most common action"
baseline, and the time per answer. The LLM is the teacher here, not the truth: this measures how well a fast model can stand in
for the slow one, not how well either plays.
"""
import collections
import glob
import importlib.util
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
spec = importlib.util.spec_from_file_location("farmhouse_decide", os.path.join(HERE, "..", "src", "commander_ai_lab", "sim", "farmhouse_decide.py"))
D = importlib.util.module_from_spec(spec)
spec.loader.exec_module(D)
def load(dirpath):
rows = []
for f in sorted(glob.glob(os.path.join(dirpath, "*.jsonl"))):
for line in open(f, encoding="utf-8"):
try:
r = json.loads(line)
except ValueError:
continue
if (r.get("decision") or {}).get("source") == "llm" and r.get("game_state") and r["decision"].get("action") in D.ACTION_HELP:
rows.append(r)
return rows
def main():
dirpath = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "..", "logs", "decisions")
models = sys.argv[2:] or [os.environ.get("DECISION_MODEL", "")]
rows = load(dirpath)
if not rows:
print("no recorded LLM decisions in", dirpath)
return 1
truth = [r["decision"]["action"] for r in rows]
common, n_common = collections.Counter(truth).most_common(1)[0]
print("%d recorded decisions; LLM action mix: %s" % (len(rows), dict(collections.Counter(truth))))
print("baseline 'always %s': %.1f%%\n" % (common, 100.0 * n_common / len(rows)))
for model in models:
os.environ["DECISION_MODEL"] = model
D.STATS.update({"asked": 0, "agreed": 0, "answered_conf": 0, "errors": 0, "ms_total": 0.0, "by_action": {}})
got = []
for r in rows:
a = D.ask(r["game_state"])
got.append(a)
D.record(a, r["decision"]["action"])
ok = [(g, t) for g, t in zip(got, truth) if g]
print("== model %r: answered %d of %d, %.0f ms each" % (model or "auto", len(ok), len(rows), D.stats()["avg_ms"] or 0))
if not ok:
continue
print(" agrees with the LLM on %.1f%% (baseline %.1f%%)" % (100.0 * sum(g["choice"] == t for g, t in ok) / len(ok), 100.0 * n_common / len(rows)))
for lo in (0.5, 0.7, 0.85):
sel = [(g, t) for g, t in ok if g["confidence"] >= lo]
if sel:
print(" when it is >= %.2f sure (%d answers, %.0f%%): agrees %.1f%%" % (
lo, len(sel), 100.0 * len(sel) / len(ok), 100.0 * sum(g["choice"] == t for g, t in sel) / len(sel)))
by = collections.defaultdict(lambda: [0, 0])
for g, t in ok:
by[t][0] += 1
by[t][1] += g["choice"] == t
print(" per LLM action:", {k: "%d/%d" % (v[1], v[0]) for k, v in sorted(by.items())})
print(" it picked:", dict(collections.Counter(g["choice"] for g, t in ok)))
return 0
if __name__ == "__main__":
sys.exit(main())
+124
View File
@@ -0,0 +1,124 @@
"""Turn the laya-shadow-v1 capture log into fine-tuning data for Laya (or Kev), plus the numbers a fine-tuned model must beat.
python scripts/farmhouse_laya_dataset.py [--in data/decisions] [--out data/laya-train] [--teacher llm|csie|selected]
[--source all|farmhouse|forge] [--holdout 0.2] [--seed 193]
Reads every `laya-shadow-*.jsonl` (written when DECISION_MODE is shadow or assist: one line per call, with the text Laya read, the
offered options, Laya's own zero-shot answer and the other deciders' picks), keeps the lines whose chosen teacher label is one of the
offered options, drops exact duplicates, and splits BY GAME so no game appears on both sides (lines with no game id are split by
the hash of their text, which is the same guarantee for identical states). Writes:
train.jsonl, valid.jsonl one /v1/systemone-shaped example per line: state, questions (with the offered options), answer, meta
manifest.json counts, teacher, class balance, and the baselines on the VALIDATION side:
- always the commonest action
- CSIE's own pick vs the teacher (how much a fine-tuned model must add over the free heuristic)
- Laya zero-shot vs the teacher (what was captured before any training)
Which teacher: `llm` (the slow model; the point is to make a fast student of it), `csie` (the deterministic heuristic; cheap and
unlimited but a student can at best equal it), `selected` (what Forge executed). The labels are the teacher's choices, not proof they
were right: a student trained on the LLM inherits its mistakes. Laya's own fine-tuning notebook/script (github.com/NandhaKishorM/laya,
notebooks/) trains on this kind of typed-decision data; adapt the field names there if its loader differs.
"""
import argparse
import collections
import glob
import hashlib
import importlib.util
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
_spec = importlib.util.spec_from_file_location("farmhouse_laya", os.path.join(HERE, "..", "src", "commander_ai_lab", "sim", "farmhouse_laya.py"))
LAYA = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(LAYA)
def read_records(directory):
for path in sorted(glob.glob(os.path.join(directory, "laya-shadow-*.jsonl"))):
for line in open(path, encoding="utf-8"):
try:
rec = json.loads(line)
except ValueError:
continue
if rec.get("schema") == LAYA.CAPTURE_SCHEMA:
yield rec
def bucket(rec, seed):
key = rec.get("game_id") or rec.get("state_text") or ""
return int(hashlib.sha256(("%s|%s" % (seed, key)).encode("utf-8")).hexdigest()[:8], 16) / 0xFFFFFFFF
def build(records, teacher="llm", source="all", holdout=0.2, seed=193):
train, valid, seen = [], [], set()
for rec in records:
if source != "all" and rec.get("source") != source:
continue
row = LAYA.training_row(rec, teacher)
if not row:
continue
key = (rec["state_text"], row["answer"]["action"], json.dumps(row["questions"]["action"]["criteria"], sort_keys=True))
if key in seen:
continue
seen.add(key)
(valid if bucket(rec, seed) < holdout else train).append((row, rec))
return train, valid
def baselines(pairs, teacher):
"""Agreement with the teacher on `pairs` of (row, record): commonest-action, CSIE, and Laya zero-shot."""
n = len(pairs)
if not n:
return {"n": 0}
targets = [r["answer"]["action"] for r, _ in pairs]
common, k = collections.Counter(t.rsplit(":", 1)[-1] for t in targets).most_common(1)[0]
out = {"n": n, "commonest_action": common, "commonest_agreement": round(k / n, 4)}
csie = [(rec.get("labels") or {}).get("csie") for _, rec in pairs]
if teacher != "csie":
have = [(c, t) for c, t in zip(csie, targets) if c]
if have:
out["csie_vs_teacher"] = {"n": len(have), "agreement": round(sum(c == t for c, t in have) / len(have), 4)}
laya = [((rec.get("laya") or {}).get("choice"), t) for (_, rec), t in zip(pairs, targets)]
have = [(c, t) for c, t in laya if c]
if have:
out["laya_zero_shot_vs_teacher"] = {"n": len(have), "agreement": round(sum(c == t for c, t in have) / len(have), 4)}
return out
def write(out_dir, train, valid, teacher, source, holdout, seed):
os.makedirs(out_dir, exist_ok=True)
for name, rows in (("train.jsonl", train), ("valid.jsonl", valid)):
with open(os.path.join(out_dir, name), "w", encoding="utf-8", newline="\n") as f:
for row, _ in rows:
f.write(json.dumps(row, ensure_ascii=False, separators=(",", ":")) + "\n")
balance = collections.Counter(r["answer"]["action"].rsplit(":", 1)[-1] for r, _ in train)
manifest = {"schema": "laya-train-manifest-v1", "teacher": teacher, "source": source, "holdout": holdout, "seed": seed,
"train": len(train), "valid": len(valid), "train_class_balance": dict(balance.most_common()),
"validation_baselines": baselines(valid, teacher),
"note": "Labels are the teacher's choices, not proof they were right. Split by game id (or text hash)."}
with open(os.path.join(out_dir, "manifest.json"), "w", encoding="utf-8", newline="\n") as f:
json.dump(manifest, f, indent=2)
return manifest
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
p.add_argument("--in", dest="inp", default=os.path.join("data", "decisions"))
p.add_argument("--out", default=os.path.join("data", "laya-train"))
p.add_argument("--teacher", choices=("llm", "csie", "selected"), default="llm")
p.add_argument("--source", choices=("all", "farmhouse", "forge"), default="all")
p.add_argument("--holdout", type=float, default=0.2)
p.add_argument("--seed", type=int, default=193)
a = p.parse_args(argv)
train, valid = build(read_records(a.inp), a.teacher, a.source, a.holdout, a.seed)
if not train and not valid:
print("no usable records in %s for teacher %r (turn on DECISION_MODE=shadow and play some games first)" % (a.inp, a.teacher))
return 1
m = write(a.out, train, valid, a.teacher, a.source, a.holdout, a.seed)
print(json.dumps(m, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
+88
View File
@@ -0,0 +1,88 @@
"""How well does a decision model classify a card's role from its Oracle text, zero-shot?
DECISION_URL=http://laya:8080 python scripts/farmhouse_laya_roles_eval.py [bulk_db] [per_role=25] [model]
Builds a sample from the local Scryfall bulk database using plain text patterns that give a card exactly ONE role (unambiguous cards
only; many real cards have several roles, so this is a floor on how hard the task is, not a full test), classifies them in batches
through farmhouse_laya.classify_roles, and prints accuracy, a confusion table and the time per card. Laya's output here would be
evidence with provenance for ITI / Tagger-style role data, never a rules fact; this tells you whether that evidence is worth having.
"""
import collections
import importlib.util
import os
import random
import re
import sqlite3
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
spec = importlib.util.spec_from_file_location("farmhouse_laya", os.path.join(HERE, "..", "src", "commander_ai_lab", "sim", "farmhouse_laya.py"))
L = importlib.util.module_from_spec(spec)
spec.loader.exec_module(L)
PATTERNS = {
"removal": re.compile(r"\b(destroy|exile) target (creature|permanent|nonland permanent|artifact|enchantment|planeswalker)", re.I),
"board_wipe": re.compile(r"\b(destroy|exile) all (creatures|nonland permanents|permanents)|each creature (gets|is dealt)", re.I),
"ramp": re.compile(r"search your library for (a|up to \w+) basic land|\badd \{[WUBRGC]\}", re.I),
"card_draw": re.compile(r"\bdraw (a|two|three|\w+) cards?\b", re.I),
"counterspell": re.compile(r"\bcounter target (spell|noncreature spell|creature spell|activated or triggered ability)", re.I),
"tutor": re.compile(r"search your library for (a|an|up to \w+) (?!basic land)(card|creature|artifact|instant|sorcery|enchantment)", re.I),
}
def sample(db, per_role, seed=193):
conn = sqlite3.connect(db)
rows = conn.execute("SELECT name, type_line, oracle_text FROM cards WHERE oracle_text != '' AND type_line NOT LIKE '%Land%'").fetchall()
random.Random(seed).shuffle(rows)
out, taken, seen = [], collections.Counter(), set()
for name, type_line, oracle in rows:
if name in seen or len(oracle) > 700:
continue
hits = [r for r, p in PATTERNS.items() if p.search(oracle)]
if len(hits) != 1 or taken[hits[0]] >= per_role:
continue
seen.add(name)
taken[hits[0]] += 1
out.append({"name": name, "type_line": type_line, "oracle": oracle, "truth": hits[0]})
if all(taken[r] >= per_role for r in PATTERNS):
break
return out
def main():
db = sys.argv[1] if len(sys.argv) > 1 else os.path.join("data", "scryfall_bulk.db")
per_role = int(sys.argv[2]) if len(sys.argv) > 2 else 25
if len(sys.argv) > 3:
os.environ["DECISION_MODEL"] = sys.argv[3]
if not os.environ.get("DECISION_URL"):
print("set DECISION_URL")
return 1
cards = sample(db, per_role)
t0 = time.time()
got = L.classify_roles(cards)
dt = time.time() - t0
answered = [(c["truth"], got[c["name"]]["role"], got[c["name"]]["confidence"]) for c in cards if c["name"] in got]
print("%d cards sampled (%s); %d classified in %.1f s (%.2f s per card)" % (
len(cards), dict(collections.Counter(c["truth"] for c in cards)), len(answered), dt, dt / max(len(cards), 1)))
if not answered:
return 1
ok = sum(t == p for t, p, _ in answered)
print("accuracy %.1f%% (always-one-role baseline %.1f%%; there are %d roles to choose among)" % (
100.0 * ok / len(answered), 100.0 / len(PATTERNS), len(L.ROLE_HELP)))
for lo in (0.5, 0.7, 0.85):
sel = [(t, p) for t, p, c in answered if c >= lo]
if sel:
print(" when >= %.2f sure: %d cards (%.0f%%), accuracy %.1f%%" % (lo, len(sel), 100.0 * len(sel) / len(answered),
100.0 * sum(t == p for t, p in sel) / len(sel)))
conf = collections.defaultdict(collections.Counter)
for t, p, _ in answered:
conf[t][p] += 1
print("truth -> what it said:")
for t in PATTERNS:
print(" %-13s %s" % (t, dict(conf[t].most_common(4))))
return 0
if __name__ == "__main__":
sys.exit(main())
+18 -1
View File
@@ -21,6 +21,7 @@ _BUILTIN_DECK_DIRS = [
_REPO_ROOT / "precon-decks",
_REPO_ROOT / "sample-decks",
_REPO_ROOT / "imported-decks",
_REPO_ROOT / "user-decks",
]
@@ -352,7 +353,23 @@ def _load_deck_cards_by_name(deck_name: str) -> list:
) cr ON cr.name = dc.card_name
WHERE dc.deck_id = ?
""", (deck_id,)).fetchall()
return [_row_to_dict(r) for r in rows]
result = [_row_to_dict(r) for r in rows]
# Enrich cards missing type_line from Forge card cache
try:
from commander_ai_lab.sim.forge_card_loader import lookup_forge_card
for card in result:
if not card.get("type_line"):
forge = lookup_forge_card(card.get("card_name", ""))
if forge:
card["type_line"] = forge.type_line
card["oracle_text"] = forge.oracle_text
card["cmc"] = forge.cmc
card["mana_cost"] = forge.mana_cost
except ImportError:
pass # Forge card loader not available
return result
# -- 2. .dck file fallback --
dck_path = _find_dck_file(deck_name)
+154
View File
@@ -0,0 +1,154 @@
"""Deck LINKS for every deck import (copied into the app as services/farmhouse_deck_links.py by the Dockerfile).
Upstream could import from an Archidekt or EDHREC link on the simulator page only; the Deck Builder import took pasted text and
nothing else, and Moxfield (where most people keep decks) was not accepted anywhere. This reads a Moxfield or Archidekt deck link and
returns what the existing importers already understand: a profile for the simulator import, or plain "[Commander] / [Main]" text for
the Deck Builder's two imports (so a link pasted into the same box works, and the card-by-card Scryfall matching is unchanged).
Moxfield has no official public API; api2.moxfield.com answers server-side requests for PUBLIC decks (tested from the cluster
2026-10-26: HTTP 200, a named User-Agent is enough). A private or deleted deck comes back 401/403/404, and the message says so and
says what to do instead (Moxfield: Export > Copy as text, then paste that). Nothing here logs in to anything.
"""
import json
import re
import time
from urllib.error import HTTPError
from urllib.request import Request, urlopen
UA = "CommanderAILab/3.0 (self-hosted)"
MOXFIELD_API = "https://api2.moxfield.com/v2/decks/all/"
_MOX = re.compile(r"^https?://(?:www\.)?moxfield\.com/decks/([A-Za-z0-9_-]+)(?:[/?#].*)?$", re.I)
_ARC = re.compile(r"^https?://(?:www\.)?archidekt\.com/decks/(\d+)(?:[/?#].*)?$", re.I)
UNSUPPORTED = ("That link is not one I can read. Moxfield and Archidekt deck links work; for anything else, paste the decklist as text "
"(Moxfield: Export > Copy as text).")
def link_kind(text):
"""(kind, id, url) when `text` is nothing but one http(s) link, else None. kind: moxfield | archidekt | unsupported."""
t = (text or "").strip()
if not t or len(t.split()) != 1 or not re.match(r"^https?://", t, re.I):
return None
m = _MOX.match(t)
if m:
return "moxfield", m.group(1), t
m = _ARC.match(t)
if m:
return "archidekt", m.group(1), t
return "unsupported", None, t
def _get_json(url):
with urlopen(Request(url, headers={"User-Agent": UA, "Accept": "application/json"}), timeout=25) as resp:
return json.loads(resp.read().decode("utf-8", "replace"))
def fetch_moxfield(deck_id, attempts=4):
last = None
for i in range(attempts):
try:
return _get_json(MOXFIELD_API + deck_id)
except HTTPError as exc:
if exc.code in (401, 403, 404):
raise ValueError("Moxfield would not share that deck (it is private, unlisted for sharing, or the link is wrong). "
"Open it in Moxfield, choose Export > Copy as text, and paste that here instead.")
if exc.code in (429, 500, 502, 503, 504):
last = exc
try:
wait = float(exc.headers.get("Retry-After") or 0)
except (TypeError, ValueError):
wait = 0.0
time.sleep(max(wait, 2.0) + i * 2.0)
continue
raise ValueError("Moxfield answered HTTP %s for that deck." % exc.code)
except (ValueError, OSError) as exc:
raise ValueError("Could not read the deck from Moxfield (%s)." % str(exc)[:80])
raise ValueError("Moxfield is rate-limiting this server right now (HTTP %s). Try again in a minute." % (last.code if last else "429"))
def _board(deck, key):
out = {}
for name, entry in (deck.get(key) or {}).items():
card = (entry or {}).get("card") or {}
label = card.get("name") or name
try:
qty = int((entry or {}).get("quantity") or 1)
except (TypeError, ValueError):
qty = 1
out[label] = out.get(label, 0) + max(qty, 1)
return out
def moxfield_profile(deck, url):
"""The same profile dict the Archidekt importer builds. Sideboard, maybeboard and companions are left out (the 99 + commander)."""
commanders, main = _board(deck, "commanders"), _board(deck, "mainboard")
profile = {
"name": deck.get("name") or "Moxfield deck",
"commander": next(iter(commanders), None),
"source": "Moxfield",
"sourceUrl": deck.get("publicUrl") or url,
"commanders": commanders,
"mainboard": main,
"colorIdentity": list(deck.get("colorIdentity") or []),
"totalCards": sum(commanders.values()) + sum(main.values()),
}
if profile["commander"] and not deck.get("name"):
profile["name"] = "%s — Moxfield" % profile["commander"]
return profile
def fetch_profile(url):
kind = link_kind(url)
if not kind or kind[0] == "unsupported":
raise ValueError(UNSUPPORTED)
if kind[0] == "moxfield":
profile = moxfield_profile(fetch_moxfield(kind[1]), kind[2])
else:
from services.import_helpers import _fetch_archidekt_deck # the app's own Archidekt reader
try:
profile = _fetch_archidekt_deck(kind[1])
except HTTPError as exc:
raise ValueError("Archidekt would not share that deck (HTTP %s). Is it public?" % exc.code)
if not profile["commanders"] and not profile["mainboard"]:
raise ValueError("That deck has no cards in it (or no main deck I can read).")
return profile
def profile_to_text(profile):
"""Plain text in the Deck Builder's own format: [Commander] then [Main], one 'qty name' per line."""
lines = ["[Commander]"]
lines += ["%d %s" % (q, n) for n, q in profile["commanders"].items()]
lines += ["", "[Main]"]
lines += ["%d %s" % (q, n) for n, q in profile["mainboard"].items()]
return "\n".join(lines) + "\n"
def local_card(name, conn=None):
"""{'id', 'name'} from the local Scryfall bulk database (113k cards, no network), or None. Matches the exact name, or the front
face of a double-faced / split card ('Fire' finds 'Fire // Ice'). The Deck Builder's importers only use these two fields, and
looking each card up on Scryfall's website instead hit its rate limit (HTTP 429) after a few dozen cards of a 100-card list,
which the app reported as 'not found'."""
try:
if conn is None:
from services.scryfall_bulk import get_bulk_db
conn = get_bulk_db()
n = (name or "").strip()
if not n:
return None
like = n.replace("!", "!!").replace("%", "!%").replace("_", "!_") + " // %"
row = conn.execute("SELECT scryfall_id, name FROM cards WHERE LOWER(name) = LOWER(?) OR LOWER(name) LIKE LOWER(?) ESCAPE '!' "
"ORDER BY (LOWER(name) = LOWER(?)) DESC, edhrec_rank ASC LIMIT 1", (n, like, n)).fetchone()
return {"id": row[0], "name": row[1]} if row else None
except Exception:
return None
def expand(text):
"""(text, deck name). A lone Moxfield/Archidekt link becomes the decklist text; anything else comes back unchanged.
A lone link we cannot read raises ValueError with a message meant for the person."""
kind = link_kind(text)
if kind is None:
return text, None
profile = fetch_profile(kind[2])
return profile_to_text(profile), profile["name"]
+69
View File
@@ -0,0 +1,69 @@
"""Card data for the simulators (copied into the app as services/farmhouse_sim.py by the Dockerfile).
Found 2026-10-26 from a real run: every card of every saved deck reached the simulator with no type line and no rules text (the
loaders join the user's *collection* and a *card_records* table, both empty here; the Forge card cache that was meant to fill the gap
is not installed). The simulator could not tell a land from a creature, so a whole 8-game batch cast 0 spells, dealt 0 damage and lost
every game. The batch loader also did not repeat cards by quantity, so a 100-card deck loaded as 66-84 cards with ONE Island.
fill_card_data fills the gaps from the local 113k-card Scryfall bulk database; expand_quantities repeats a row `quantity` times.
"""
def _name(card):
return (card.get("card_name") or card.get("name") or "").strip()
def _lookup(conn, name, cache):
key = name.lower()
if key in cache:
return cache[key]
like = name.replace("!", "!!").replace("%", "!%").replace("_", "!_") + " // %"
row = conn.execute(
"SELECT type_line, oracle_text, mana_cost, cmc, power, toughness, keywords, color_identity FROM cards "
"WHERE LOWER(name) = LOWER(?) OR LOWER(name) LIKE LOWER(?) ESCAPE '!' "
"ORDER BY (LOWER(name) = LOWER(?)) DESC, edhrec_rank ASC LIMIT 1", (name, like, name)).fetchone()
cache[key] = row
return row
def fill_card_data(cards, conn=None):
"""Fill type_line / oracle_text / mana_cost / cmc / power / toughness / keywords / color_identity where a card dict lacks them.
Never overwrites a value that is already there. Returns the same list."""
try:
if conn is None:
from services.scryfall_bulk import get_bulk_db
conn = get_bulk_db()
cache = {}
for card in cards:
n = _name(card)
if not n or card.get("type_line"):
continue
row = _lookup(conn, n, cache)
if not row:
continue
for field, value in zip(("type_line", "oracle_text", "mana_cost", "cmc", "power", "toughness", "keywords", "color_identity"), row):
if value not in (None, "") and not card.get(field):
card[field] = value
except Exception:
pass
return cards
def expand_quantities(cards):
"""One dict per physical card: a row with quantity 23 becomes 23 rows (each quantity 1)."""
out = []
for card in cards:
try:
q = int(card.get("quantity") or 1)
except (TypeError, ValueError):
q = 1
for _ in range(max(q, 1)):
copy = dict(card)
copy["quantity"] = 1
out.append(copy)
return out
def prepare(cards):
"""Rows as the batch loader returns them -> one filled-in dict per physical card."""
return fill_card_data(expand_quantities(cards))
+4 -2
View File
@@ -449,7 +449,7 @@ def _get_deepseek_brain():
from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig
cfg = DeepSeekConfig(
api_base=os.environ.get("BRAIN_API_BASE", "http://localhost:11434"),
model=os.environ.get("BRAIN_MODEL", "gpt-oss:20b"),
model=os.environ.get("BRAIN_MODEL", "qwen2.5:7b"),
temperature=float(os.environ.get("BRAIN_TEMPERATURE", "0.3")),
max_tokens=int(os.environ.get("BRAIN_MAX_TOKENS", "1024")),
request_timeout=float(os.environ.get("BRAIN_TIMEOUT", "300.0")),
@@ -499,7 +499,7 @@ def _run_deepseek_batch_thread(
for fname in sorted(os.listdir(results_dir), reverse=True):
if fname.startswith('ml-decision-') and fname.endswith('.json'):
try:
with open(os.path.join(results_dir, fname), 'r') as rf:
with open(os.path.join(results_dir, fname), 'r', encoding='utf-8') as rf:
past = json.loads(rf.read())
for dd in past.get('decks', []):
dname = dd.get('deckName', '')
@@ -515,6 +515,8 @@ def _run_deepseek_batch_thread(
deck_meta = {}
for dn in deck_names:
raw_cards = _load_deck_cards_by_name(dn)
from services.farmhouse_sim import prepare as _fh_prepare # FARMHOUSE_SIM_DATA
raw_cards = _fh_prepare(raw_cards or [])
if not raw_cards:
state.log_lines.append(f'[DeepSeek Batch] WARNING: Could not load deck "{dn}", skipping.')
continue
+3
View File
@@ -97,6 +97,9 @@ def _import_from_url(url: str) -> dict:
if not m:
raise ValueError("Could not parse Archidekt deck ID from URL")
return _fetch_archidekt_deck(m.group(1))
if "moxfield.com/decks/" in url: # FARMHOUSE_DECK_LINKS_V1
from services.farmhouse_deck_links import fetch_profile as _fh_fetch_profile
return _fh_fetch_profile(url)
if "edhrec.com/average-decks/" in url:
slug = re.search(r"/average-decks/([^/?#]+)", url).group(1)
return _fetch_edhrec_average(slug.replace("-", " "))
+9 -1
View File
@@ -368,6 +368,9 @@ def build_index(force: bool = False, batch_size: int = DEFAULT_BATCH_SIZE) -> di
def ensure_rag_store() -> None:
"""Build the ChromaDB vector store if it is missing or stale."""
global _rag_ready
if os.environ.get("RAG_AUTOBUILD", "0") != "1": # FARMHOUSE_RAG_AUTOBUILD_V1
logger.info("RAG vector store auto-build is off (RAG_AUTOBUILD is not 1).")
return
with _rag_lock:
if _rag_ready:
return
@@ -445,7 +448,7 @@ def query_cards(
try:
has_filters = bool(color_identity or type_filter or require_trigger)
fetch_n = n_results * 4 if has_filters else n_results
fetch_n = n_results * 12 # FARMHOUSE_RAG_DEDUPE (one index entry per printing)
query_kwargs = dict(
query_texts=[query],
n_results=min(fetch_n, 200),
@@ -462,6 +465,7 @@ def query_cards(
return []
cards: list[dict] = []
_fh_seen = set() # FARMHOUSE_RAG_DEDUPE
ids = results["ids"][0]
docs = results["documents"][0] if results.get("documents") else [""] * len(ids)
metas = results["metadatas"][0] if results.get("metadatas") else [{}] * len(ids)
@@ -490,6 +494,10 @@ def query_cards(
continue
# Parse oracle from document — handle both old pipe format and new NL format
_fh_name = meta.get("name", "")
if _fh_name in _fh_seen:
continue
_fh_seen.add(_fh_name)
parts = doc.split(" | ", 3)
oracle = parts[-1] if len(parts) > 1 else doc
+12 -6
View File
@@ -109,7 +109,7 @@ def _get_bulk_download_url() -> str:
meta = json.loads(resp.read())
for entry in meta.get("data", []):
if entry.get("type") == "default_cards":
return entry["download_uri"]
return entry.get("jsonl_download_uri") or entry["download_uri"] # FARMHOUSE_SCRYFALL_URL_V1
raise RuntimeError("Could not find default_cards entry in Scryfall bulk-data manifest")
@@ -122,12 +122,18 @@ def _download_and_index(conn: sqlite3.Connection) -> int:
url = _get_bulk_download_url()
logger.info("Downloading Scryfall default_cards bulk export (~100 MB)...")
req = Request(url, headers=_API_HEADERS)
with urlopen(req, timeout=300) as resp:
raw_bytes = resp.read()
req = Request(url, headers=_API_HEADERS) # FARMHOUSE_SCRYFALL_JSONL_V1
_resp = urlopen(req, timeout=300)
cards_json: list[dict] = json.loads(raw_bytes)
logger.info(f"Received {len(cards_json)} card objects from Scryfall.")
def _cards_stream():
import gzip
stream = gzip.GzipFile(fileobj=_resp) if url.split("?")[0].endswith(".gz") else _resp
for _line in stream:
_line = _line.strip().rstrip(b",")
if _line and _line not in (b"[", b"]"):
yield json.loads(_line)
cards_json = _cards_stream()
# Wipe existing data for a clean rebuild
conn.execute("DELETE FROM cards")
+1 -1
View File
@@ -26,7 +26,7 @@ class DeckBuilderAdapter:
def __init__(
self,
db_conn_factory: Callable[[], sqlite3.Connection] = None,
model: str = "gpt-oss:20b",
model: str = "claude-opus-4-20250514",
):
self.db_conn_factory = db_conn_factory
self.model = model
@@ -1,5 +1,5 @@
"""
Ollama gpt-oss:20b client for the Commander AI Deck Builder.
Ollama client for the Commander AI Deck Builder.
Uses the OpenAI-compatible API exposed by Ollama at localhost:11434.
Provides 6 core functions:
@@ -23,8 +23,9 @@ from openai import OpenAI
logger = logging.getLogger(__name__)
# Ollama exposes an OpenAI-compatible endpoint
DEFAULT_BASE_URL = "http://localhost:11434/v1"
DEFAULT_MODEL = "qwen2.5:7b"
import os as _farmhouse_os # FARMHOUSE_LLM_ENV_V1
DEFAULT_BASE_URL = _farmhouse_os.environ.get("LLM_URL", "http://localhost:11434/v1")
DEFAULT_MODEL = _farmhouse_os.environ.get("LLM_MODEL", "qwen2.5:7b")
def _get_client(base_url: str = DEFAULT_BASE_URL) -> OpenAI:
@@ -118,6 +119,13 @@ def chat(
if json_mode:
kwargs["response_format"] = {"type": "json_object"}
# FARMHOUSE_LLM_KNOBS_V1
_fh_effort = _farmhouse_os.environ.get("LLM_REASONING", "").strip()
if _fh_effort:
kwargs["extra_body"] = {"reasoning_effort": _fh_effort}
_fh_cap = int(_farmhouse_os.environ.get("LLM_MAX_TOKENS", "0") or 0)
if _fh_cap:
kwargs["max_tokens"] = min(kwargs["max_tokens"], _fh_cap)
response = client.chat.completions.create(**kwargs)
return response.choices[0].message.content or ""
@@ -16,8 +16,26 @@ import requests
from ..core.models import CardEntry
BASE_URL = "https://api.scryfall.com"
_SF_HEADERS = {"User-Agent": "CommanderAILab/3.0 (self-hosted)", "Accept": "application/json"} # FARMHOUSE_SCRYFALL_HEADERS_V1
def _fh_request(method, url, **kw):
"""requests.<method> that backs off on Scryfall's 429 / 5xx, honouring Retry-After (a build makes hundreds of
calls; upstream had no retry, so one 429 anywhere ended it). Gives up after 5 tries and returns the last response."""
import time as _t
resp = None
for attempt in range(5):
resp = getattr(requests, method)(url, **kw)
if resp.status_code not in (429, 500, 502, 503, 504):
return resp
try:
wait = float(resp.headers.get("Retry-After", "") or 0)
except ValueError:
wait = 0.0
_t.sleep(max(wait, 2.0) + attempt * 2.0)
return resp
_last_request_time: float = 0.0
REQUEST_DELAY = 0.1 # 100ms between requests (Scryfall asks for 50-100ms)
REQUEST_DELAY = 0.25 # 250ms: upstream used 100ms (10/s, Scryfall's ceiling) and drew 429s when builds ran back to back
def _throttle() -> None:
@@ -32,7 +50,7 @@ def _throttle() -> None:
def _get(endpoint: str, params: Optional[Dict] = None) -> Dict:
"""Make a throttled GET request to Scryfall."""
_throttle()
resp = requests.get(f"{BASE_URL}{endpoint}", params=params, timeout=15)
resp = _fh_request("get", f"{BASE_URL}{endpoint}", params=params, headers=_SF_HEADERS, timeout=15)
resp.raise_for_status()
return resp.json()
@@ -113,7 +131,7 @@ def search_cards_by_identity(
if not next_url:
break
_throttle()
resp = requests.get(next_url, timeout=15)
resp = _fh_request("get", next_url, headers=_SF_HEADERS, timeout=15)
resp.raise_for_status()
data = resp.json()
@@ -168,9 +186,9 @@ def get_cards_by_names(names: List[str]) -> List[CardEntry]:
batch = names[i : i + 75]
identifiers = [{"name": n} for n in batch]
_throttle()
resp = requests.post(
f"{BASE_URL}/cards/collection",
json={"identifiers": identifiers},
resp = _fh_request(
"post", f"{BASE_URL}/cards/collection",
json={"identifiers": identifiers}, headers=_SF_HEADERS,
timeout=30,
)
resp.raise_for_status()
+2 -2
View File
@@ -87,8 +87,8 @@ def cli() -> None:
)
parser.add_argument(
"--model",
default="gpt-oss:20b",
help="Ollama model name (default: gpt-oss:20b)",
default="claude-opus-4-20250514",
help="AI model name (default: claude-opus-4-20250514)",
)
parser.add_argument(
"--output",
@@ -119,6 +119,9 @@ def validate_card_names(
# ---------------------------------------------------------------------------
from .farmhouse_fit import fit_deck as _farmhouse_fit_deck # FARMHOUSE_FIT_V1
def build_deck(request: BuildRequest) -> BuildResult:
"""
Execute the full 7-step deck build pipeline.
@@ -306,6 +309,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
logger.warning(f"Ratio enforcement returned {total} cards")
# ── Step 7: Assemble final deck ────────────────────────────────────────
adjusted = _farmhouse_fit_deck(adjusted, target_ratios, commander_ci, warnings) # FARMHOUSE_FIT_V1b
logger.info("Step 7: Assembling final deck")
# Build a globally-deduplicated name list. enforce_deck_ratios can
@@ -0,0 +1,67 @@
"""Deterministic guards for the deck build, copied into the app by the Dockerfile (pipeline/farmhouse_fit.py).
Upstream's step 6 asks the LLM to trim the candidate pool to 99 cards by category and step 5 promises a colour-identity filter that
is not in the code. With a small local model that gave decks with an off-colour card (a red card in a four-colour deck) and, with
a larger one, 359 cards instead of 99; either made the app reject its own result ("Value error ... outside commander identity" /
"Deck must contain exactly 99 cards"). This does both jobs in plain code, using the Scryfall card database the app already keeps
(services.scryfall_bulk, 100,000+ cards): drop every card outside the commander's colour identity, drop duplicates, keep each
category to its target count (candidates arrive best-first: EDHREC, then Scryfall, then the model), and never exceed 99.
Anything the database does not know is kept, not dropped. The existing back-fill adds basic lands if the total is short.
"""
import json
import logging
logger = logging.getLogger(__name__)
def _identity_checker(allowed):
try:
from services.scryfall_bulk import get_bulk_db
conn = get_bulk_db()
except Exception as exc: # no database: do not guess, keep everything
logger.warning("farmhouse_fit: Scryfall card database unavailable (%s); colour identity not checked", exc)
return lambda name: True
def ok(name):
try:
row = conn.execute("SELECT color_identity FROM cards WHERE name = ? OR name LIKE ? LIMIT 1",
(name, name + " // %")).fetchone()
if not row:
return True
return set(json.loads(row[0] or "[]")) <= allowed
except Exception:
return True
return ok
def fit_deck(adjusted, target_ratios, commander_ci, warnings, total=99):
"""Return {category: [names]} that is colour-legal, duplicate-free, within each category's target, and at most `total` cards."""
allowed = set(commander_ci or [])
ok = _identity_checker(allowed)
out, seen, off_colour = {}, set(), []
for cat, names in adjusted.items():
keep = []
for name in names:
if name in seen:
continue
if not ok(name):
off_colour.append(name)
continue
seen.add(name)
keep.append(name)
limit = target_ratios.get(cat)
out[cat] = keep[:limit] if isinstance(limit, int) and limit >= 0 else keep
if off_colour:
warnings.append("Dropped %d card(s) outside the commander's colour identity: %s" % (len(off_colour), ", ".join(off_colour[:8])))
count = sum(len(v) for v in out.values())
if count > total:
# Over: trim from the tail of the largest category (the model's late picks), one card at a time.
excess = count - total
while excess > 0:
cat = max(out, key=lambda c: len(out[c]))
if not out[cat]:
break
out[cat].pop()
excess -= 1
warnings.append("Trimmed the candidate list from %d to %d cards." % (count, total))
return out
@@ -587,6 +587,13 @@ class DeepSeekBrain:
cached["latency_ms"] = round((time.time() - t_start) * 1000, 1)
return cached
from commander_ai_lab.sim import farmhouse_decide as _fd # FARMHOUSE_DECISION
_fd_mode = _fd.mode()
if _fd_mode == "assist":
_fd_ans = _fd.ask(snapshot)
if _fd_ans and _fd_ans["confidence"] >= float(os.environ.get("DECISION_MIN_CONF", "0.7")):
return _fd.as_decision(_fd_ans, t_start)
if not self._connected:
return self._fallback_action(snapshot, t_start, "not_connected")
@@ -595,6 +602,8 @@ class DeepSeekBrain:
try:
result = self._call_llm(snapshot)
result["source"] = "llm"
if _fd_mode == "shadow": # FARMHOUSE_DECISION_SHADOW
_fd.record(_fd.ask(snapshot), result.get("action"))
result["latency_ms"] = round((time.time() - t_start) * 1000, 1)
if self.config.cache_enabled:
@@ -632,6 +641,8 @@ class DeepSeekBrain:
"top_p": self.config.top_p,
"stream": False,
}
if os.environ.get("LLM_REASONING"): # FARMHOUSE_BRAIN_REASONING
payload["reasoning_effort"] = os.environ["LLM_REASONING"]
url = self.config.api_base.rstrip("/") + "/v1/chat/completions"
req_data = json.dumps(payload).encode("utf-8")
@@ -880,4 +891,5 @@ class DeepSeekBrain:
"api_base": self.config.api_base,
"pending_log_entries": len(self._decision_log),
"current_game_id": self._current_game_id,
"decision": __import__("commander_ai_lab.sim.farmhouse_decide", fromlist=["stats"]).stats(), # FARMHOUSE_DECISION_STATS
}
+29 -2
View File
@@ -148,6 +148,11 @@ class DeepSeekGameEngine:
p.stats.turns_alive += 1
phase_events: list[str] = []
# ── Reset per-turn counters (land drop, etc.) ──
p._lands_played_this_turn = 0
if not hasattr(p, '_land_plays_this_turn_limit'):
p._land_plays_this_turn_limit = 1
# ── Draw ──
if p.library:
drawn = p.library[-1]
@@ -242,7 +247,16 @@ class DeepSeekGameEngine:
# DeepSeek-powered turn
# ──────────────────────────────────────────────────────────
def _play_turn_deepseek(
def _play_turn_deepseek( # FARMHOUSE_MULTISTEP
self, sim: SimState, pi: int, turn: int, events: list[str],
deck_context: dict | None = None,
) -> str:
from commander_ai_lab.sim import farmhouse_turns as _ft
if _ft.steps() <= 1:
return self._play_turn_deepseek_single(sim, pi, turn, events, deck_context)
return _ft.play_turn(self, sim, pi, turn, events, deck_context)
def _play_turn_deepseek_single(
self, sim: SimState, pi: int, turn: int, events: list[str],
deck_context: dict | None = None,
) -> str:
@@ -357,8 +371,20 @@ class DeepSeekGameEngine:
self, sim: SimState, pi: int,
preferred_name: str | None, events: list[str] | None
):
"""Play a land from hand. If preferred_name given, try that first."""
"""Play a land from hand. If preferred_name given, try that first.
Enforces the one-land-per-turn rule (MTG CR 305.2). Cards that grant
additional land plays (e.g. Exploration, Oracle of Mul Daya) may bump
``_land_plays_this_turn_limit`` on the player to a higher value.
"""
p = sim.players[pi]
# ── LAND-PER-TURN ENFORCEMENT ──
max_land_plays = getattr(p, '_land_plays_this_turn_limit', 1)
lands_this_turn = getattr(p, '_lands_played_this_turn', 0)
if lands_this_turn >= max_land_plays:
return # Already used land drop(s) this turn
land_idx = -1
if preferred_name:
@@ -385,6 +411,7 @@ class DeepSeekGameEngine:
sim.next_card_id += 1
sim.add_to_battlefield(pi, land_card)
p.stats.lands_played += 1
p._lands_played_this_turn = lands_this_turn + 1
if events is not None and self.record_log:
events.append(f"Played land: {land_card.name}")
+19 -1
View File
@@ -135,6 +135,11 @@ class GameEngine:
sim.active_player_index = pi
phase_events: list[str] = []
# ── Reset per-turn counters (land drop, etc.) ──
p._lands_played_this_turn = 0
if not hasattr(p, '_land_plays_this_turn_limit'):
p._land_plays_this_turn_limit = 1
# ── Capture ML snapshot BEFORE turn actions ──
pre_snapshot = None
if self.ml_log:
@@ -454,8 +459,20 @@ class GameEngine:
return sim
def _play_land(self, sim: SimState, pi: int) -> None:
"""Play the first land from hand onto the battlefield."""
"""Play the first land from hand onto the battlefield.
Enforces the one-land-per-turn rule (MTG CR 305.2). Cards that grant
additional land plays may bump ``_land_plays_this_turn_limit`` on the
player to a higher value.
"""
p = sim.players[pi]
# ── LAND-PER-TURN ENFORCEMENT ──
max_land_plays = getattr(p, '_land_plays_this_turn_limit', 1)
lands_this_turn = getattr(p, '_lands_played_this_turn', 0)
if lands_this_turn >= max_land_plays:
return # Already used land drop(s) this turn
land_idx = -1
for i, card in enumerate(p.hand):
if card.is_land():
@@ -470,6 +487,7 @@ class GameEngine:
sim.next_card_id += 1
sim.add_to_battlefield(pi, land_card)
p.stats.lands_played += 1
p._lands_played_this_turn = lands_this_turn + 1
@staticmethod
def _send_to_graveyard(sim: SimState, card, owner_idx: int) -> None:
@@ -0,0 +1,173 @@
"""A fast "which action?" answer from a decision model (Laya, or Kev: both serve POST /v1/systemone).
The LLM brain answers the same question about 25 times a game, each taking seconds. A decision model reads a short text and picks ONE of
a fixed list of options in a single forward pass (tens of milliseconds on a GPU, about a second on a small CPU) and never writes text.
It cannot choose a target card; the engine's heuristics do that, exactly as when the LLM names no target.
Modes (DECISION_MODE; with no DECISION_URL nothing here runs at all):
off do nothing
shadow ask it too and RECORD whether it agreed with the LLM; the game is played with the LLM's answer (the safe way to measure)
assist ask it first; use its answer when it is at least DECISION_MIN_CONF sure, otherwise ask the LLM as before
Settings: DECISION_URL (e.g. http://laya:8080), DECISION_MODEL (a Laya checkpoint name such as english or typed-decisions),
DECISION_MIN_CONF (default 0.7, on the probability of the chosen option), DECISION_TIMEOUT (seconds, default 5), DECISION_API_KEY.
After three failures in a row the model is not asked again for a minute, so a down server costs the game nothing.
"""
import json
import os
import threading
import time
import urllib.request
ACTION_HELP = {
"play_land": "play a land from hand (always do this first)",
"cast_creature": "cast a creature spell",
"cast_removal": "use removal on the opponent's best threat",
"cast_board_wipe": "wipe the board when behind on creatures",
"cast_ramp": "cast mana ramp to accelerate",
"cast_spell": "cast any other spell such as an enchantment or artifact",
"attack_all": "attack with all untapped creatures",
"attack_safe": "attack only with evasive or large creatures",
"hold": "pass and save mana",
}
_lock = threading.Lock()
_fail = {"n": 0, "until": 0.0}
STATS = {"asked": 0, "agreed": 0, "answered_conf": 0, "errors": 0, "ms_total": 0.0, "by_action": {}}
def mode():
m = os.environ.get("DECISION_MODE", "off").strip().lower()
return m if m in ("shadow", "assist") and os.environ.get("DECISION_URL") else "off"
def _card_text(c):
if isinstance(c, str):
return c
bits = [str(c.get("type") or "").strip()]
if c.get("cmc") is not None:
bits.append("cmc %s" % c["cmc"])
if c.get("pt"):
bits.append(str(c["pt"]))
if c.get("castable") is not None:
bits.append("castable" if c["castable"] else "not castable")
roles = c.get("roles")
if roles:
bits.append("/".join(roles) if isinstance(roles, (list, tuple)) else str(roles))
return "%s (%s)" % (c.get("name", "?"), ", ".join(b for b in bits if b))
def compact_state(snap):
"""The snapshot as a short paragraph (a decision model reads about 500 tokens, the LLM's indented JSON is two to three times that)."""
hand = snap.get("my_hand") or []
mine = [c for c in (snap.get("my_board") or []) if isinstance(c, dict)]
theirs = [c for c in (snap.get("opp_board") or []) if isinstance(c, dict)]
my_creatures = [c for c in mine if "creature" in str(c.get("type", "")).lower()]
opp_creatures = [c for c in theirs if "creature" in str(c.get("type", "")).lower()]
threat = snap.get("opp_threat") or {}
cmdr = snap.get("commander") or {}
parts = ["Turn %s. Your life %s, opponent life %s. Mana available %s." % (
snap.get("turn"), snap.get("my_life"), snap.get("opp_life"), snap.get("my_mana_total", snap.get("my_mana_available")))]
parts.append("Opponent threat %s, total power %s." % (threat.get("level", "?"), threat.get("total_power", "?")))
if cmdr:
parts.append("Your commander %s is in the %s." % (cmdr.get("name", "?"), cmdr.get("zone", "?")))
parts.append("Hand: %s." % ("; ".join(_card_text(c) for c in hand[:10]) if hand else "empty"))
parts.append("Your creatures: %s." % (", ".join(_card_text(c) for c in my_creatures[:8]) if my_creatures else "none"))
parts.append("Your other permanents: %d." % (len(mine) - len(my_creatures)))
parts.append("Opponent creatures: %s." % (", ".join(_card_text(c) for c in opp_creatures[:8]) if opp_creatures else "none"))
parts.append("Opponent other permanents: %d." % (len(theirs) - len(opp_creatures)))
advice = snap.get("strategic_advice") or {}
ranking = (advice.get("ranking") or {}).get("ranked") or []
if ranking:
parts.append("CSIE relative ranking: %s." % "; ".join(
"%s %.2f (%s)" % (r["id"], r["score"], r["explanation"])
for r in ranking[:5]))
if snap.get("stack_supported") and snap.get("stack"):
parts.append("Visible stack: %s." % json.dumps(snap["stack"], separators=(",", ":")))
return " ".join(parts)
def ask(snap, actions=None):
"""{'choice', 'confidence', 'probabilities', 'ms', 'model'} or None (not configured, backing off, or an error)."""
url = os.environ.get("DECISION_URL")
if not url:
return None
now = time.time()
with _lock:
if _fail["until"] > now:
return None
acts = [a for a in (actions or ACTION_HELP) if a in ACTION_HELP]
body = {"state": {"body": compact_state(snap)},
"questions": {"action": {"type": "choice", "instructions": "Which action is best for this turn?",
"criteria": {a: ACTION_HELP[a] for a in acts}}}}
model = os.environ.get("DECISION_MODEL")
if model:
body["model"] = model
headers = {"content-type": "application/json"}
if os.environ.get("DECISION_API_KEY"):
headers["authorization"] = "Bearer " + os.environ["DECISION_API_KEY"]
try: # extra questions ride in the same forward pass (farmhouse_laya.py); optional when the package is not importable
from commander_ai_lab.sim import farmhouse_laya as _L
body["questions"].update(_L._extra_questions())
except ImportError:
_L = None
t0 = time.time()
try:
req = urllib.request.Request(url.rstrip("/") + "/v1/systemone", data=json.dumps(body).encode(), headers=headers)
out = json.load(urllib.request.urlopen(req, timeout=float(os.environ.get("DECISION_TIMEOUT", "5"))))
ans = out["answers"]["action"]
probs = ans.get("probabilities") or {}
choice = ans["choice"]
if choice not in acts:
raise ValueError("Decision model selected an action outside the supplied candidates")
with _lock:
_fail["n"] = 0
return {"choice": choice, "confidence": float(probs.get(choice, ans.get("answer_confidence", 0.0))),
"probabilities": probs, "ms": round((time.time() - t0) * 1000, 1), "model": model or "auto",
"extras": _L._extras(out["answers"]) if _L else {}, "state_text": body["state"]["body"],
"options": {a: ACTION_HELP[a] for a in acts},
"csie_selected": ((snap.get("strategic_advice") or {}).get("ranking") or {}).get("selected_id")}
except Exception:
with _lock:
_fail["n"] += 1
STATS["errors"] += 1
if _fail["n"] >= 3:
_fail["until"] = time.time() + 60
_fail["n"] = 0
return None
def as_decision(answer, t_start):
"""The brain's own result dict for a decision model's answer (no target card: the engine's heuristics pick it)."""
return {"action": answer["choice"], "target_card": "", "source": "decision",
"reasoning": "decision model %s, p=%.2f" % (answer["model"], answer["confidence"]),
"latency_ms": round((time.time() - t_start) * 1000, 1)}
def record(answer, llm_action):
"""Count one shadow comparison."""
if not answer:
return
with _lock:
STATS["asked"] += 1
STATS["ms_total"] += answer["ms"]
agree = answer["choice"] == llm_action
STATS["agreed"] += 1 if agree else 0
if answer["confidence"] >= float(os.environ.get("DECISION_MIN_CONF", "0.7")):
STATS["answered_conf"] += 1
row = STATS["by_action"].setdefault(llm_action, {"n": 0, "agreed": 0})
row["n"] += 1
row["agreed"] += 1 if agree else 0
try: # one laya-shadow-v1 line per comparison: the raw material for fine-tuning (scripts/farmhouse_laya_dataset.py)
from commander_ai_lab.sim import farmhouse_laya as _L
_L.capture({"source": "farmhouse", "state_text": answer.get("state_text"), "options": answer.get("options"),
"laya": {k: answer.get(k) for k in ("choice", "confidence", "probabilities", "extras", "ms", "model")},
"labels": {"llm": llm_action, "csie": answer.get("csie_selected")}})
except ImportError:
pass
def stats():
with _lock:
n = STATS["asked"]
return dict(STATS, agreement=round(STATS["agreed"] / n, 3) if n else None, avg_ms=round(STATS["ms_total"] / n, 1) if n else None)
+305
View File
@@ -0,0 +1,305 @@
"""Laya connections beyond "which macro action?" (the decision model also serves Kev: POST /v1/systemone).
Laya answers typed questions about a short text in ONE forward pass, so extra questions in the same request cost almost nothing. This
module is where the repository uses that, without touching CSIE, ITI or PBIE:
advise_concrete / attach_concrete Forge's priority path. Laya picks among the SAME concrete candidate IDs Forge sent (never an
ID it did not offer). shadow: recorded beside CSIE's pick. assist: may replace it when sure,
but never when CSIE's pick carries a Forge-established `stops_immediate_win` or `wins_now`.
extra questions "how dangerous are the opponents' boards?" and "is there a threat that must be answered?" ride
in the same call and are recorded (a free weak signal for the learned-threat work).
capture every shadow/assist call is appended to data/decisions/laya-shadow-<date>.jsonl as
`laya-shadow-v1`: the text Laya read, the options, its answer, and the other deciders' picks.
This is the raw material for fine-tuning (scripts/farmhouse_laya_dataset.py): Laya is a base to
specialise, and zero-shot it is no better than guessing (farmhouse/laya/LAYA-EVALUATION.md).
classify_roles card role evidence from Oracle text (batch, cached), with provenance, for ITI / Tagger-style use.
Hidden information: text is built from public facts and the ACTING player's own hand. An opponent's `hand` field is never read.
Failures (no server, timeout, a bad answer) return None and never raise; three in a row pause asking for a minute.
Settings (shared with farmhouse_decide): DECISION_URL, DECISION_MODE (off|shadow|assist), DECISION_MODEL, DECISION_MIN_CONF,
DECISION_TIMEOUT (5 s), DECISION_TIMEOUT_FORGE (1.5 s: Forge waits on this), DECISION_API_KEY, LAYA_CAPTURE_DIR, LAYA_CAPTURE=0 to disable.
"""
import datetime
import json
import os
import threading
import time
import urllib.request
CAPTURE_SCHEMA = "laya-shadow-v1"
THREAT_LEVELS = {
"none": "no meaningful pressure on you",
"low": "a small board that is not a danger yet",
"moderate": "a board that will become dangerous if ignored",
"high": "a board that threatens you within a turn or two",
"lethal": "a board or stack that can kill you or win the game very soon",
}
ROLE_HELP = {
"removal": "destroys, exiles or otherwise removes one permanent or creature",
"board_wipe": "destroys or removes many creatures or permanents at once",
"ramp": "adds mana or fetches lands so you have more mana",
"card_draw": "draws or otherwise gives you extra cards",
"counterspell": "counters a spell or ability",
"protection": "protects your permanents or players from removal or damage",
"tutor": "searches your library for a card other than a basic land",
"creature": "a creature whose main job is attacking, blocking or generating value on the battlefield",
"other": "none of the above",
}
_lock = threading.Lock()
_fail = {"n": 0, "until": 0.0}
_role_cache = {}
_capture_lock = threading.Lock()
def mode():
m = os.environ.get("DECISION_MODE", "off").strip().lower()
return m if m in ("shadow", "assist") and os.environ.get("DECISION_URL") else "off"
def min_conf():
try:
return float(os.environ.get("DECISION_MIN_CONF", "0.7"))
except ValueError:
return 0.7
def _post(path, body, timeout):
"""JSON reply or None. Circuit breaker: three failures in a row pause for a minute."""
url = os.environ.get("DECISION_URL")
if not url:
return None
with _lock:
if _fail["until"] > time.time():
return None
headers = {"content-type": "application/json"}
if os.environ.get("DECISION_API_KEY"):
headers["authorization"] = "Bearer " + os.environ["DECISION_API_KEY"]
try:
req = urllib.request.Request(url.rstrip("/") + path, data=json.dumps(body).encode(), headers=headers)
out = json.load(urllib.request.urlopen(req, timeout=timeout))
with _lock:
_fail["n"] = 0
return out
except Exception:
with _lock:
_fail["n"] += 1
if _fail["n"] >= 3:
_fail["until"] = time.time() + 60
_fail["n"] = 0
return None
def _extra_questions():
return {"threat": {"type": "choice", "instructions": "How dangerous are the opponents' boards to you right now?",
"criteria": dict(THREAT_LEVELS)},
"must_answer": {"type": "noul", "instructions": "Is there an immediate threat that must be answered this turn?"}}
def _extras(answers):
out = {}
t = (answers or {}).get("threat") or {}
if t.get("choice") in THREAT_LEVELS:
probs = t.get("probabilities") or {}
out["threat"] = {"choice": t["choice"], "confidence": round(float(probs.get(t["choice"], 0.0)), 4)}
m = (answers or {}).get("must_answer") or {}
if "noul" in m:
try:
out["must_answer"] = round(float(m["noul"]), 4)
except (TypeError, ValueError):
pass
return out
# ---------- Forge's priority path -------------------------------------------------------------------------------------------
def _names(battlefield, limit=8):
out = []
for c in battlefield or []:
out.append(str(c.get("name", "?")) if isinstance(c, dict) else str(c))
if len(out) >= limit:
break
return out
def observation_text(obs, ranking=None):
"""Public facts plus the acting player's own hand. An opponent's `hand` field is NEVER read."""
actor = int(obs.get("priority_seat", 0))
players = obs.get("players") or []
parts = ["Phase %s, seat %s has priority (active seat %s)." % (obs.get("phase", "?"), actor, obs.get("active_seat", actor))]
for p in players:
seat = p.get("seat")
head = "You (seat %s)" % seat if seat == actor else "Opponent seat %s" % seat
bits = ["life %s" % p.get("life", "?"), "power on board %s" % p.get("total_power", 0)]
if seat == actor:
bits.append("mana available %s" % p.get("mana_available", "?"))
hand = p.get("hand") # the actor's own hand only
if isinstance(hand, list) and hand:
bits.append("hand: " + ", ".join(_names(hand, 10)))
else:
bits.append("%s cards in hand" % p.get("cards_in_hand", "?"))
else:
bits.append("%s cards in hand" % p.get("cards_in_hand", p.get("hand_count", "?")))
board = _names(p.get("battlefield"))
bits.append("board: %s" % (", ".join(board) if board else "empty"))
parts.append("%s: %s." % (head, "; ".join(bits)))
stack = obs.get("stack") or []
if stack:
parts.append("Stack: %s." % "; ".join(
"%s by seat %s%s" % (s.get("card", s.get("name", "a spell")), s.get("controller_seat", "?"),
" (immediate win threat)" if s.get("immediate_win_threat") else "") for s in stack[:5]))
else:
parts.append("The stack is empty.")
rows = (ranking or {}).get("ranked") or []
if rows:
parts.append("CSIE relative ranking: %s." % "; ".join(
"%s %.2f" % (r["id"].rsplit(":", 1)[-1], r["score"]) for r in rows[:5]))
return " ".join(parts)
def describe_candidate(c):
"""What the candidate IS (kind, card, cost, tags). The opaque request-scoped ID is the key, never the description."""
kind = str(c.get("kind", "other"))
if kind == "pass":
return "pass priority and do nothing"
bits = [kind.replace("_", " ")]
name = c.get("card_name") or c.get("card") or c.get("name")
if name:
bits.append(str(name))
if c.get("mana_value") not in (None, ""):
bits.append("mana value %s" % c["mana_value"])
if c.get("target_seat") is not None:
bits.append("targeting seat %s" % c["target_seat"])
tags = [str(t) for t in (c.get("tags") or [])]
if tags:
bits.append("tags " + ", ".join(tags))
return ", ".join(bits)
def advise_concrete(observation, candidates, ranking=None):
"""Laya's pick among Forge's concrete candidate IDs, or None. Shape: choice, confidence, probabilities, extras, ms, model, state_text, options."""
ids = [c.get("id") for c in candidates if isinstance(c.get("id"), str)]
if not ids or len(ids) != len(set(ids)) or mode() == "off":
return None
text = observation_text(observation, ranking)
options = {c["id"]: describe_candidate(c) for c in candidates if isinstance(c.get("id"), str)}
questions = {"action": {"type": "choice", "instructions": "Which action is best right now?", "criteria": options}}
questions.update(_extra_questions())
body = {"state": {"body": text}, "questions": questions}
if os.environ.get("DECISION_MODEL"):
body["model"] = os.environ["DECISION_MODEL"]
t0 = time.time()
out = _post("/v1/systemone", body, float(os.environ.get("DECISION_TIMEOUT_FORGE", "1.5")))
try:
ans = out["answers"]["action"]
choice, probs = ans["choice"], ans.get("probabilities") or {}
if choice not in options:
return None
return {"choice": choice, "confidence": float(probs.get(choice, ans.get("answer_confidence", 0.0))),
"probabilities": probs, "extras": _extras(out["answers"]), "ms": round((time.time() - t0) * 1000, 1),
"model": os.environ.get("DECISION_MODEL") or "auto", "state_text": text, "options": options}
except Exception:
return None
def attach_concrete(result, observation, candidates, advice):
"""Add Laya's view to the concrete-decision response; in assist mode it may replace the selection (guarded).
result is the dict /api/policy/decide/concrete returns ({"selected_id", "ranked", ...}). Forge executes only an ID it sent."""
if not advice:
return result
by_id = {c.get("id"): c for c in candidates}
csie_pick = result.get("selected_id")
view = {k: advice[k] for k in ("choice", "confidence", "probabilities", "extras", "ms", "model")}
view["agrees_with_selected"] = advice["choice"] == csie_pick
out = dict(result)
out["laya"] = view
protected = bool(set((by_id.get(csie_pick) or {}).get("tags") or []) & {"stops_immediate_win", "wins_now"})
if (mode() == "assist" and advice["choice"] in by_id and advice["choice"] != csie_pick
and advice["confidence"] >= min_conf() and not protected):
out["csie_selected_id"] = csie_pick
out["selected_id"] = advice["choice"]
out["laya_override"] = True
capture({"source": "forge", "state_text": advice["state_text"], "options": advice["options"],
"laya": view, "labels": {"csie": csie_pick, "selected": out["selected_id"]},
"game_id": (observation or {}).get("game_id")})
return out
# ---------- recording (also the fine-tuning raw material) --------------------------------------------------------------------
def capture(record):
"""Append one laya-shadow-v1 record. Never raises."""
if os.environ.get("LAYA_CAPTURE", "1") == "0" or mode() == "off":
return
try:
d = os.environ.get("LAYA_CAPTURE_DIR", os.path.join("data", "decisions"))
os.makedirs(d, exist_ok=True)
rec = dict(record, schema=CAPTURE_SCHEMA, ts=round(time.time(), 3), mode=mode())
path = os.path.join(d, "laya-shadow-%s.jsonl" % datetime.datetime.utcnow().strftime("%Y%m%d"))
with _capture_lock, open(path, "a", encoding="utf-8", newline="\n") as f:
f.write(json.dumps(rec, ensure_ascii=False, separators=(",", ":")) + "\n")
except Exception:
pass
# ---------- card role evidence ------------------------------------------------------------------------------------------------
def classify_roles(cards, batch_size=32):
"""{name: {"role", "confidence", "probabilities", "source"}} for cards given as {"name", "oracle", "type_line"}.
One batched request per `batch_size` unseen cards (the server shares forward passes), cached by name. This is evidence with
provenance (source says model and mode), never a rules fact; a card the server cannot classify is simply absent."""
out, todo = {}, []
for c in cards:
n = c.get("name")
if not n:
continue
if n in _role_cache:
out[n] = _role_cache[n]
elif all(n != t["name"] for t in todo):
todo.append(c)
questions = {"role": {"type": "choice", "instructions": "What is this card's main role in a Commander deck?", "criteria": dict(ROLE_HELP)}}
model = os.environ.get("DECISION_MODEL") or "auto"
for i in range(0, len(todo), batch_size):
chunk = todo[i:i + batch_size]
body = {"states": [{"body": ("%s. %s. %s" % (c["name"], c.get("type_line", ""), c.get("oracle", "")))[:1500]} for c in chunk],
"questions": questions}
if os.environ.get("DECISION_MODEL"):
body["model"] = os.environ["DECISION_MODEL"]
res = _post("/v1/systemone/batch", body, float(os.environ.get("DECISION_TIMEOUT", "5")) * max(2, len(chunk) // 4))
results = (res or {}).get("results") or []
for c, r in zip(chunk, results):
try:
a = r["answers"]["role"]
probs = a.get("probabilities") or {}
if a["choice"] not in ROLE_HELP:
continue
row = {"role": a["choice"], "confidence": round(float(probs.get(a["choice"], 0.0)), 4), "probabilities": probs,
"source": "laya:%s" % model}
_role_cache[c["name"]] = out[c["name"]] = row
except Exception:
continue
return out
# ---------- fine-tuning rows --------------------------------------------------------------------------------------------------
def training_row(rec, label="llm"):
"""One /v1/systemone-shaped training example from a laya-shadow-v1 record, or None when it has no usable label.
label: which decider is the teacher: llm, csie, selected. The row carries provenance so a student trained on one teacher can be
told apart from one trained on another, and the options (not just the winner) so the model learns to choose among them."""
labels = rec.get("labels") or {}
target = labels.get(label)
options = rec.get("options") or {}
if not rec.get("state_text") or target not in options:
return None
return {"state": {"body": rec["state_text"]},
"questions": {"action": {"type": "choice", "instructions": "Which action is best right now?", "criteria": options}},
"answer": {"action": target},
"meta": {"schema": "laya-train-v1", "source": rec.get("source"), "teacher": label, "game_id": rec.get("game_id"),
"laya_choice": (rec.get("laya") or {}).get("choice"), "ts": rec.get("ts")}}
@@ -0,0 +1,66 @@
"""A DeepSeek-engine turn that asks the model more than once (copied to commander_ai_lab/sim/farmhouse_turns.py by the Dockerfile).
Upstream asked the model ONE question per turn and applied the answer as a single action, then filled in with a heuristic. That is
one model call per turn for a turn that really has several decisions (land, a spell, another spell, attack). This keeps asking, up
to BRAIN_STEPS times (default 4), re-reading the board after each action, and stops when the model attacks or holds, when an action
changes nothing (so it cannot loop), or at the limit. Whatever is left uses the engine's own heuristic exactly as before.
Everything it touches is an existing engine method, so the rules enforcement (one land a turn, mana, the two-spell cap per call) is
the engine's own.
"""
import os
CAST = ("cast_creature", "cast_removal", "cast_board_wipe", "cast_ramp", "cast_spell")
ATTACK = ("attack_all", "attack_safe")
def steps():
try:
return max(1, min(int(os.environ.get("BRAIN_STEPS", "4")), 8))
except ValueError:
return 4
def play_turn(engine, sim, pi, turn, events, deck_context=None):
"""Same contract as DeepSeekGameEngine._play_turn_deepseek: plays the turn, returns the ML action type of the first decision."""
p = sim.players[pi]
first_action = None
first_mana = engine._count_untapped_lands(sim, pi)
combat_done = False
for step in range(steps()):
mana = engine._count_untapped_lands(sim, pi)
decision = engine.brain.choose_action(sim, pi, turn, mana, deck_context=deck_context)
action = decision.get("action", "hold")
target = decision.get("target_card", "") or ""
if first_action is None:
first_action = action
if engine.record_log:
events.append("[AI Brain: %s] Step %d: %s%s (%s)" % (decision.get("source", "?"), step + 1, action,
(" → " + target) if target else "", decision.get("reasoning", "")))
if step == 0: # the land drop, as upstream always does
engine._play_land(sim, pi, target if action == "play_land" else None, events)
mana = engine._count_untapped_lands(sim, pi)
if action in CAST:
before = (len(p.hand), engine._count_untapped_lands(sim, pi))
engine._play_spells_targeted(sim, pi, mana, action, target, events)
if (len(p.hand), engine._count_untapped_lands(sim, pi)) == before:
break # nothing castable: stop asking, fall through to combat
continue
if action in ATTACK:
engine._resolve_combat_deepseek(sim, pi, turn, action, events)
combat_done = True
break
if action == "hold":
if engine.record_log:
events.append("Holds — no attack")
combat_done = True
break
# play_land or anything unknown: try a heuristic spell once, then combat
engine._play_spells_heuristic(sim, pi, engine._count_untapped_lands(sim, pi), events)
break
if not combat_done:
engine._resolve_combat_heuristic(sim, pi, turn, events)
return engine._map_action_to_ml_type(first_action or "hold", first_mana)
+173 -43
View File
@@ -229,61 +229,54 @@ class CommanderGameState:
def stack_is_empty(self) -> bool:
return len(self.stack) == 0
# Bug 9 fix: stub get_legal_moves() and apply_move() so turn_manager
# doesn't crash. These delegate to the rules engine once implemented.
def get_legal_moves(self, seat: int) -> list[dict]:
"""Return legal moves for a seat.
"""Return legal moves for ``seat``.
During DECLARE_BLOCKERS phase, emits block moves for defending
creatures against each attacker in sim_state.combat.
Otherwise returns pass-only (stub for remaining rules engine).
Delegates to :mod:`legal_move_generator` which produces macro
actions (play_land, cast_creature, cast_removal, cast_ramp,
cast_draw, cast_commander, attack, block, pass_priority).
"""
moves: list[dict] = [{"id": 0, "category": "pass_priority", "description": "Pass"}]
combat = self.sim_state.combat
# Local import to avoid a circular reference at module load
from commander_ai_lab.sim.legal_move_generator import generate_legal_moves
return generate_legal_moves(self, seat)
# Emit block moves during declare_blockers phase
if (
combat
and combat.active
and self.current_phase in (Phase.DECLARE_BLOCKERS, "declare_blockers")
and seat != self.active_player_seat
):
move_id = 1000
my_creatures = [
c for c in self.sim_state.get_battlefield(seat)
if c.is_creature() and not c.tapped
]
for atk_id in combat.attackers:
atk = next(
(c for bf in self.sim_state.battlefields for c in bf if c.id == atk_id),
None,
)
if atk is None:
continue
for blocker in my_creatures:
moves.append({
"id": move_id,
"category": "block",
"blocker_id": blocker.id,
"attacker_id": atk_id,
"description": f"Block {atk.name} with {blocker.name}",
})
move_id += 1
return moves
def apply_move(self, seat: int, move_id: int) -> None:
"""Apply a chosen move.
"""Apply a chosen move to the game state.
Handles 'block' moves by updating sim_state.combat.blockers.
Other categories are no-op stubs until the rules engine is wired.
Supports all move categories produced by
:mod:`legal_move_generator`. For categories that require full
rules simulation (spell resolution, combat damage), this is
still a thin layer — it mutates the game state and delegates
final resolution to the engine/turn manager.
"""
# Find the move dict
all_moves = self.get_legal_moves(seat)
move = next((m for m in all_moves if m["id"] == move_id), None)
if move is None:
return
if move.get("category") == "block":
category = move.get("category")
if category == "pass_priority":
return
if category == "play_land":
self._apply_play_land(seat, move)
return
if category in ("cast_creature", "cast_removal", "cast_ramp",
"cast_draw", "cast_spell"):
self._apply_cast_spell(seat, move)
return
if category == "cast_commander":
self._apply_cast_commander(seat, move)
return
if category == "attack":
self._apply_declare_attacker(seat, move)
return
if category == "block":
combat = self.sim_state.combat
if combat and combat.active:
atk_id = move["attacker_id"]
@@ -291,6 +284,143 @@ class CommanderGameState:
if atk_id not in combat.blockers:
combat.blockers[atk_id] = []
combat.blockers[atk_id].append(blocker_id)
return
# ── Move execution helpers ───────────────────────────────
def _apply_play_land(self, seat: int, move: dict) -> None:
"""Remove the chosen land from hand, place on battlefield, bump counter."""
if not (0 <= seat < len(self.commander_players)):
return
player = self.commander_players[seat]
# Enforce land-per-turn at apply-time as a second safety check
if player.lands_played_this_turn >= player.land_plays_allowed:
return
card_name = move.get("card_name")
idx = next(
(i for i, c in enumerate(player.hand)
if c.name == card_name and c.is_land()),
-1,
)
if idx < 0:
return
land = player.hand.pop(idx)
land.owner_id = seat
land.tapped = False
land.turn_played = self.turn
land.id = self.sim_state.next_card_id
self.sim_state.next_card_id += 1
self.sim_state.add_to_battlefield(seat, land)
player.lands_played_this_turn += 1
self.land_drop_used = True
if hasattr(player, "base") and hasattr(player.base, "stats"):
player.base.stats.lands_played += 1
def _apply_cast_spell(self, seat: int, move: dict) -> None:
"""Remove the spell from hand, place it on the battlefield.
This is a simplified resolution (no stack, no targeting) sufficient
for macro-action training and live games until the full stack
engine is wired.
"""
if not (0 <= seat < len(self.commander_players)):
return
player = self.commander_players[seat]
card_name = move.get("card_name")
idx = next(
(i for i, c in enumerate(player.hand) if c.name == card_name),
-1,
)
if idx < 0:
return
card = player.hand.pop(idx)
card.owner_id = seat
card.turn_played = self.turn
card.id = self.sim_state.next_card_id
self.sim_state.next_card_id += 1
# Creatures and permanents land on the battlefield; instants and
# sorceries go straight to the graveyard after resolving.
type_line = (card.type_line or "").lower()
if "instant" in type_line or "sorcery" in type_line:
player.graveyard.append(card)
else:
card.tapped = False
self.sim_state.add_to_battlefield(seat, card)
if hasattr(player, "base") and hasattr(player.base, "stats"):
player.base.stats.spells_cast += 1
if card.is_creature():
player.base.stats.creatures_played += 1
def _apply_cast_commander(self, seat: int, move: dict) -> None:
"""Cast the commander from the command zone (increments tax)."""
if not (0 <= seat < len(self.commander_players)):
return
player = self.commander_players[seat]
card_name = move.get("card_name")
idx = next(
(i for i, c in enumerate(player.commander_zone)
if c.name == card_name),
-1,
)
if idx < 0 and player.commander_zone:
idx = 0
if idx < 0:
return
cmd = player.commander_zone.pop(idx)
cmd.owner_id = seat
cmd.turn_played = self.turn
cmd.tapped = False
cmd.is_commander = True
cmd.id = self.sim_state.next_card_id
self.sim_state.next_card_id += 1
self.sim_state.add_to_battlefield(seat, cmd)
player.commander_cast_count += 1
if hasattr(player, "base") and hasattr(player.base, "stats"):
player.base.stats.spells_cast += 1
if cmd.is_creature():
player.base.stats.creatures_played += 1
def _apply_declare_attacker(self, seat: int, move: dict) -> None:
"""Mark a creature as attacking in the current combat state.
``combat.attackers`` is a ``dict[int, int]`` mapping attacker
card id to defending seat. If no defending seat has been
chosen yet, fall back to the first non-active, non-eliminated
opponent.
"""
combat = self.sim_state.combat
if combat is None:
return
atk_id = move.get("attacker_id")
if atk_id is None:
return
# Tap the attacker unless it has vigilance
for card in self.sim_state.get_battlefield(seat):
if card.id == atk_id:
has_vigilance = (
card.has_keyword("vigilance")
if hasattr(card, "has_keyword") else False
)
if not has_vigilance:
card.tapped = True
break
# Determine defending seat
defending_seat = combat.defending_seat
if defending_seat < 0 or defending_seat == seat:
for i, p in enumerate(self.commander_players):
if i != seat and not p.eliminated:
defending_seat = i
break
if defending_seat >= 0:
combat.defending_seat = defending_seat
if defending_seat >= 0:
combat.attackers[atk_id] = defending_seat
combat.active = True
def living_players(self) -> list[CommanderPlayer]:
return [p for p in self.commander_players if not p.eliminated]
@@ -0,0 +1,230 @@
"""
Legal Move Generator
====================
Generates the set of legal moves a player can take from a
:class:`CommanderGameState`. This replaces the "pass-only" stub in
``CommanderGameState.get_legal_moves`` so the turn_manager/policy-server
path can drive real games instead of just passing priority every turn.
The emitted moves are intentionally coarse (macro-actions: play_land,
cast_creature, cast_removal, cast_ramp, cast_draw, cast_commander,
attack, block, pass_priority) so that both the neural policy and
heuristic/LLM brains can pick from the same list.
Long-term, when the Forge IPC path is fully wired, Forge's own legal
action list will replace the output of this module. Until then, this
is the authoritative move source for the Python rules layer.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from commander_ai_lab.sim.models import Card, Phase
if TYPE_CHECKING:
from commander_ai_lab.sim.game_state import CommanderGameState
# Map from card enrichment flags to macro-action categories the AI brain
# recognizes. Order matters: the first True flag wins.
_SPELL_FLAG_TO_CATEGORY: tuple[tuple[str, str], ...] = (
("is_ramp", "cast_ramp"),
("is_removal", "cast_removal"),
("is_board_wipe", "cast_removal"),
)
def _classify_spell(card: Card) -> str:
"""Map a Card to a macro-action category string.
Matches the 8-action macro-action space used by the policy network
(``scope.py``): cast_creature, cast_removal, cast_draw, cast_ramp,
cast_commander, attack_opponent, hold_mana, pass. Cards that don't
fit any flag fall back to ``cast_spell`` so downstream code can
still execute them.
"""
for flag, category in _SPELL_FLAG_TO_CATEGORY:
if getattr(card, flag, False):
return category
# Heuristic: draw spells often mention "draw" in oracle text
ot = (getattr(card, "oracle_text", "") or "").lower()
if "draw" in ot and ("card" in ot or "cards" in ot):
return "cast_draw"
if card.is_creature():
return "cast_creature"
return "cast_spell"
def _count_available_mana(game_state: "CommanderGameState", seat: int) -> int:
"""Rough available mana = floating pool + untapped lands on battlefield.
This is intentionally permissive. A production rules engine would
also consider mana dorks, mana rocks, and color requirements, but
for macro-action gating CMC-vs-total-mana is sufficient.
"""
player = game_state.players[seat]
mana = 0
# Floating mana pool (if ManaPool has ``total``)
pool = getattr(player, "mana_pool", None)
if pool is not None:
total_fn = getattr(pool, "total", None)
if callable(total_fn):
try:
mana += int(total_fn())
except Exception:
pass
# Untapped lands on battlefield (each produces ~1 mana)
for card in game_state.sim_state.get_battlefield(seat):
if card.is_land() and not getattr(card, "tapped", False):
mana += 1
return mana
def _phase_is(current: object, target: Phase) -> bool:
"""Safe phase comparison that works with Phase enum or raw string."""
if current == target:
return True
if isinstance(current, str) and current == target.value:
return True
return False
def generate_legal_moves(game_state: "CommanderGameState", seat: int) -> list[dict]:
"""Produce the list of legal moves for ``seat`` given ``game_state``.
Always includes a pass-priority move (id 0) as a safe fallback.
Additional moves are emitted based on phase, turn ownership, and
available resources.
"""
moves: list[dict] = [
{"id": 0, "category": "pass_priority", "description": "Pass"}
]
if not (0 <= seat < len(game_state.players)):
return moves
player = game_state.players[seat]
if getattr(player, "eliminated", False):
return moves
current_phase = game_state.current_phase
is_active = (seat == game_state.active_player_seat)
move_id = 1
# ── Sorcery-speed actions (main phases, active player only) ──
if is_active and (
_phase_is(current_phase, Phase.MAIN1)
or _phase_is(current_phase, Phase.MAIN2)
):
# Land drop — respect one-land-per-turn unless modified
lands_played = getattr(player, "lands_played_this_turn", 0)
land_limit = getattr(player, "land_plays_allowed", 1)
if lands_played < land_limit:
for card in player.hand:
if card.is_land():
moves.append({
"id": move_id,
"category": "play_land",
"card_name": card.name,
"description": f"Play {card.name}",
})
move_id += 1
# Castable spells from hand
available_mana = _count_available_mana(game_state, seat)
for card in player.hand:
if card.is_land():
continue
cost = int(getattr(card, "cmc", 0) or 0)
if cost <= available_mana:
category = _classify_spell(card)
moves.append({
"id": move_id,
"category": category,
"card_name": card.name,
"cmc": cost,
"description": f"Cast {card.name} ({cost} mana)",
})
move_id += 1
# Commander from the command zone (respect commander tax)
tax = 0
if hasattr(player, "commander_tax"):
try:
tax = int(player.commander_tax())
except Exception:
tax = 0
for cmd in getattr(player, "commander_zone", []):
base_cost = int(getattr(cmd, "cmc", 0) or 0)
total_cost = base_cost + tax
if total_cost <= available_mana:
moves.append({
"id": move_id,
"category": "cast_commander",
"card_name": cmd.name,
"cmc": total_cost,
"description": (
f"Cast {cmd.name} from command zone "
f"(base={base_cost}, tax={tax})"
),
})
move_id += 1
# ── Declare attackers — one attack move per legal attacker ──
if is_active and _phase_is(current_phase, Phase.DECLARE_ATTACKERS):
bf = game_state.sim_state.get_battlefield(seat)
for creature in bf:
if not creature.is_creature():
continue
if getattr(creature, "tapped", False):
continue
# Summoning sickness: played this turn and no haste
turn_played = getattr(creature, "turn_played", -1)
has_haste = creature.has_keyword("haste") if hasattr(creature, "has_keyword") else False
if turn_played == game_state.turn and not has_haste:
continue
moves.append({
"id": move_id,
"category": "attack",
"attacker_id": creature.id,
"card_name": creature.name,
"description": f"Attack with {creature.name}",
})
move_id += 1
# ── Declare blockers — emit block pairings for defending player ──
if _phase_is(current_phase, Phase.DECLARE_BLOCKERS) and not is_active:
combat = game_state.sim_state.combat
if combat and getattr(combat, "active", False):
defender_creatures = [
c for c in game_state.sim_state.get_battlefield(seat)
if c.is_creature() and not getattr(c, "tapped", False)
]
for atk_id in getattr(combat, "attackers", []):
atk = None
for bf in game_state.sim_state.battlefields:
for c in bf:
if c.id == atk_id:
atk = c
break
if atk is not None:
break
if atk is None:
continue
for blocker in defender_creatures:
moves.append({
"id": move_id,
"category": "block",
"blocker_id": blocker.id,
"attacker_id": atk_id,
"description": f"Block {atk.name} with {blocker.name}",
})
move_id += 1
return moves
+4
View File
@@ -267,6 +267,10 @@ class CommanderTurnManager:
player = self.gs.players[seat]
logger.debug("Turn %d — %s's turn begins", turn_num, player.name)
# ── Reset per-turn counters at the start of each turn ──
# Enforces MTG CR 305.2 (one land per turn unless modified).
player.lands_played_this_turn = 0
for phase in PHASES:
if self.game_over:
return
@@ -0,0 +1,840 @@
package commanderailab.ai;
import commanderailab.ml.MacroActionExecutor;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import forge.LobbyPlayer;
import forge.ai.AiController;
import forge.ai.ComputerUtilAbility;
import forge.ai.ComputerUtilMana;
import forge.ai.PlayerControllerAi;
import forge.game.Game;
import forge.game.card.Card;
import forge.game.card.CardCollection;
import forge.game.card.CardCollectionView;
import forge.game.phase.PhaseHandler;
import forge.game.phase.PhaseType;
import forge.game.player.Player;
import forge.game.spellability.SpellAbility;
import forge.game.spellability.SpellAbilityStackInstance;
import forge.game.zone.ZoneType;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Logger;
/**
* PolicyControllerAi — Phase 2 live-game controller that routes AI decisions
* through the Python policy server instead of Forge's built-in heuristics.
*
* <h2>Architecture</h2>
* <pre>
* Forge game loop
* └─ player.getController() → PolicyControllerAi
* └─ brains (PolicyAiController extends AiController)
* └─ chooseSpellAbilityToPlay()
* ├─ buildGameStateJson()
* ├─ policyClient.decide(stateJson) → macro-action
* ├─ mapMacroActionToSpellAbility() → SpellAbility
* └─ super.chooseSpellAbilityToPlay() (fallback)
* </pre>
*
* <h2>Two-class design</h2>
* <ul>
* <li>{@link PolicyAiController} — inner subclass of {@link AiController}
* that overrides {@code chooseSpellAbilityToPlay()}. This is the
* intercept point closest to the game loop.</li>
* <li>{@link PolicyControllerAi} — subclass of {@link PlayerControllerAi}
* that swaps Forge's default {@code AiController} for a
* {@code PolicyAiController} and exposes lifecycle helpers
* (game-end reward submission, statistics).</li>
* </ul>
*
* <h2>Decision flow</h2>
* <ol>
* <li>Build a rich {@link JsonObject} snapshot of the current game state
* (per-player life/mana/zones, stack, phase, legal actions).</li>
* <li>POST the snapshot to {@code /api/policy/decide} via
* {@link PolicyClient#decide(JsonObject)}.</li>
* <li>Translate the returned macro-action name (e.g. {@code "cast_creature"})
* to a concrete {@link SpellAbility} from the legal action list using
* {@link MacroActionExecutor}'s card-role databases.</li>
* <li>If the server is unreachable or no mapping succeeds, fall back to
* {@link AiController#chooseSpellAbilityToPlay()} transparently.</li>
* <li>Log every decision (policy or fallback) for training data collection.</li>
* </ol>
*
* <h2>Fallback guarantee</h2>
* Every code path in {@link PolicyAiController#chooseSpellAbilityToPlay()}
* is wrapped in a broad {@code try/catch}. Any unexpected exception causes
* an immediate delegate to the parent Forge AI so the game continues safely.
*
* <h2>Usage</h2>
* <pre>{@code
* PolicyClient client = new PolicyClient("http://localhost:8080");
* client.connect();
*
* // When Forge constructs the AI player's controller:
* PolicyControllerAi controller = new PolicyControllerAi(game, player, lobbyPlayer, client);
*
* // After the game ends:
* controller.onGameEnd(won ? 1.0 : -1.0);
* }</pre>
*
* @see PolicyClient
* @see PolicyAiController
* @see MacroActionExecutor
* @since Phase 2.1 — Issue #83
*/
public class PolicyControllerAi extends PlayerControllerAi {
// ─────────────────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────────────────
private static final Logger LOG = Logger.getLogger(PolicyControllerAi.class.getName());
/**
* Directory for training-data decision logs.
* One JSON-lines file per game session is written here.
*/
private static final String LOG_DIR = "data/decisions";
// ─────────────────────────────────────────────────────────────────────────
// Instance state
// ─────────────────────────────────────────────────────────────────────────
/** Unique identifier for this game session (used in reward submission). */
private final String gameId = UUID.randomUUID().toString();
/** The policy client used by the embedded {@link PolicyAiController}. */
private final PolicyClient policyClient;
/**
* Log writer for training-data collection.
* Lazily opened on the first decision; {@code null} if log dir is unavailable.
*/
private PrintWriter decisionLog;
/** Running count of decisions in this game (policy + fallback). */
private final AtomicInteger decisionCount = new AtomicInteger(0);
// ─────────────────────────────────────────────────────────────────────────
// Construction
// ─────────────────────────────────────────────────────────────────────────
/**
* Construct a policy-driven AI controller.
*
* <p>The standard Forge {@link AiController} created by the parent class is
* immediately replaced by a {@link PolicyAiController} that holds a
* reference to {@code client}. All other Forge AI infrastructure
* (combat, simulation, sideboard, etc.) is inherited unchanged.</p>
*
* @param game the active Forge {@link Game}
* @param player the {@link Player} this controller drives
* @param lobbyPlayer the underlying lobby seat
* @param client live {@link PolicyClient}; must not be {@code null}
*/
public PolicyControllerAi(Game game, Player player, LobbyPlayer lobbyPlayer,
PolicyClient client) {
// Parent creates its own AiController; we replace it below via reflection.
super(game, player, lobbyPlayer);
this.policyClient = client;
// Swap the brains field to our policy-aware subclass.
injectPolicyAiController(game, player);
}
/**
* Convenience constructor that builds a {@link PolicyClient} from a URL.
*
* <p>The client's {@link PolicyClient#connect()} is called automatically;
* if the server is unavailable the controller will silently fall back to
* Forge's built-in AI for all decisions.</p>
*
* @param game the active Forge {@link Game}
* @param player the {@link Player} this controller drives
* @param lobbyPlayer the underlying lobby seat
* @param policyServerUrl base URL of the policy server, e.g.
* {@code "http://localhost:8080"}
*/
public PolicyControllerAi(Game game, Player player, LobbyPlayer lobbyPlayer,
String policyServerUrl) {
this(game, player, lobbyPlayer, buildAndConnect(policyServerUrl));
}
// ─────────────────────────────────────────────────────────────────────────
// Lifecycle helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Signal the end of a game so the policy client can submit the terminal
* reward and flush any buffered training tuples.
*
* <p>Call this from your game-runner once Forge reports a winner.</p>
*
* @param reward terminal reward signal: {@code +1.0} for a win,
* {@code -1.0} for a loss, or an intermediate value
*/
public void onGameEnd(double reward) {
try {
policyClient.submitReward(gameId, reward);
policyClient.flushCollectedTuples();
} catch (Exception e) {
LOG.warning("[PolicyControllerAi] onGameEnd error: " + e.getMessage());
} finally {
closeDecisionLog();
}
LOG.info(String.format(
"[PolicyControllerAi] game=%s reward=%.2f decisions=%d stats=%s",
gameId, reward, decisionCount.get(), policyClient.getStatsSummary()));
}
/**
* Returns the unique game identifier used in reward submissions.
*
* @return UUID string
*/
public String getGameId() {
return gameId;
}
/**
* Returns a snapshot of policy statistics for this game session.
*
* @return human-readable stats string from the underlying {@link PolicyClient}
*/
public String getStatsSummary() {
return policyClient.getStatsSummary();
}
// ─────────────────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Uses reflection to replace the {@code brains} field (an {@link AiController})
* that the parent constructor already created with a new
* {@link PolicyAiController}.
*
* <p>This is the only use of reflection in this class. It is necessary
* because {@code PlayerControllerAi} declares {@code brains} as
* {@code private final} and constructs it in its own constructor body,
* before we get a chance to supply a subclass.</p>
*
* <p>If reflection fails for any reason (e.g. a future Forge version adds
* a security manager), the controller falls back to the original
* {@code AiController} that the parent already created. A warning is
* logged so the issue is easy to diagnose.</p>
*
* @param game the active game
* @param player the AI player
*/
private void injectPolicyAiController(Game game, Player player) {
try {
java.lang.reflect.Field brains =
PlayerControllerAi.class.getDeclaredField("brains");
brains.setAccessible(true);
PolicyAiController policyBrains =
new PolicyAiController(player, game, policyClient, this);
brains.set(this, policyBrains);
LOG.info("[PolicyControllerAi] PolicyAiController injected for player="
+ player.getName() + " game=" + gameId);
} catch (Exception e) {
LOG.warning("[PolicyControllerAi] Could not inject PolicyAiController; "
+ "falling back to vanilla Forge AI. Cause: " + e.getMessage());
}
}
/** Build a {@link PolicyClient} and attempt to connect. */
private static PolicyClient buildAndConnect(String serverUrl) {
PolicyClient client = new PolicyClient(serverUrl);
client.connect();
return client;
}
// ─────────────────────────────────────────────────────────────────────────
// Decision logging
// ─────────────────────────────────────────────────────────────────────────
/**
* Write a single JSON-lines decision record to the per-game log file.
* Creates the log directory and file on first call.
*
* @param record the JSON object to append
*/
void logDecision(JsonObject record) {
decisionCount.incrementAndGet();
try {
if (decisionLog == null) {
File dir = new File(LOG_DIR);
//noinspection ResultOfMethodCallIgnored
dir.mkdirs();
File logFile = new File(dir, "decisions-" + gameId + ".jsonl");
decisionLog = new PrintWriter(new FileWriter(logFile, true));
}
decisionLog.println(record.toString());
decisionLog.flush();
} catch (IOException e) {
// Non-fatal: the game continues even if we can't log.
LOG.warning("[PolicyControllerAi] Could not write decision log: "
+ e.getMessage());
}
}
/** Close the decision log file handle. */
private void closeDecisionLog() {
if (decisionLog != null) {
decisionLog.close();
decisionLog = null;
}
}
// ═════════════════════════════════════════════════════════════════════════
// Inner class: PolicyAiController
// ═════════════════════════════════════════════════════════════════════════
/**
* PolicyAiController — intercepts {@link AiController#chooseSpellAbilityToPlay()}
* and routes the decision through the Python policy server.
*
* <h2>Decision flow</h2>
* <ol>
* <li>Enumerate all legal actions (lands + spells/abilities) using
* {@link ComputerUtilAbility} — the same utilities Forge uses
* internally — so the legal-action list is always authoritative.</li>
* <li>Build a {@link JsonObject} describing the full game state,
* including per-player life/mana/zones/stack/phase and a
* {@code legal_actions} array.</li>
* <li>POST to {@code /api/policy/decide} via {@link PolicyClient#decide(JsonObject)}.
* The server returns a macro-action name such as {@code "cast_creature"}.</li>
* <li>Translate the macro-action to a concrete {@link SpellAbility} by
* scanning the legal-action list with
* {@link #mapMacroActionToSpellAbility}.</li>
* <li>On any failure, delegate to the parent
* {@link AiController#chooseSpellAbilityToPlay()} so the game never stalls.</li>
* </ol>
*
* <h2>Thread safety</h2>
* All mutable state is confined to the single game thread that Forge uses
* for AI decisions. The {@link PolicyClient} methods called here are
* themselves thread-safe.
*/
static final class PolicyAiController extends AiController {
/** Maximum age (ms) of a decision before it is considered stale. */
private static final long DECISION_TIMEOUT_MS = 5_000;
private final PolicyClient policyClient;
private final PolicyControllerAi outer;
PolicyAiController(Player player, Game game,
PolicyClient policyClient,
PolicyControllerAi outer) {
super(player, game);
this.policyClient = policyClient;
this.outer = outer;
}
// ─── Main intercept ───────────────────────────────────────────────
/**
* Override of the core AI decision method.
*
* <p>This is called by the Forge game loop whenever the AI player has
* priority and needs to decide whether/what to play. We intercept
* here to route the decision to the policy server.</p>
*
* @return list of {@link SpellAbility} objects for Forge to execute,
* or {@code null} / empty list to pass priority
*/
@Override
public List<SpellAbility> chooseSpellAbilityToPlay() {
long t0 = System.currentTimeMillis();
try {
Player player = getPlayer();
Game game = getGame();
// 1. Enumerate legal actions
List<SpellAbility> legalActions = gatherLegalActions(game, player);
// If there are no legal actions, pass immediately — no server
// round-trip required.
if (legalActions.isEmpty()) {
logAndReturn(null, "pass", "no_legal_actions", t0, legalActions);
return super.chooseSpellAbilityToPlay();
}
// 2. Build game-state JSON with legal actions embedded
JsonObject stateJson = buildGameStateJson(game, player, legalActions);
// 3. Ask the policy server for a macro-action
String macroAction = policyClient.decide(stateJson);
// 4. Map macro-action → concrete SpellAbility
SpellAbility chosen = mapMacroActionToSpellAbility(
macroAction, legalActions, player);
if (chosen != null) {
// Policy succeeded
List<SpellAbility> result = Collections.singletonList(chosen);
logAndReturn(stateJson, macroAction, "policy", t0, result);
return result;
}
// macro-action mapped to "pass" or was unresolvable but not an error
if (MacroActionExecutor.PASS.equals(macroAction)
|| MacroActionExecutor.HOLD_MANA.equals(macroAction)) {
logAndReturn(stateJson, macroAction, "policy_pass", t0, null);
// Return super so Forge can decide whether to pass or play a land
return super.chooseSpellAbilityToPlay();
}
// Macro-action was not mappable to anything concrete; fall back.
logAndReturn(stateJson, macroAction, "fallback_no_map", t0, null);
return super.chooseSpellAbilityToPlay();
} catch (Exception e) {
// Broad catch to guarantee the game never stalls
LOG.warning("[PolicyAiController] Unexpected error: " + e.getMessage());
return super.chooseSpellAbilityToPlay();
}
}
// ─── Legal action enumeration ─────────────────────────────────────
/**
* Gather all legal actions available to {@code player} in the current
* game state, using Forge's own utility methods.
*
* <p>The result combines:</p>
* <ul>
* <li>Land-play abilities (from hand or other sources Forge allows)</li>
* <li>All playable spell/activated abilities from hand and battlefield</li>
* </ul>
*
* @param game the active game
* @param player the acting player
* @return mutable list of legal {@link SpellAbility} objects; never {@code null}
*/
private List<SpellAbility> gatherLegalActions(Game game, Player player) {
List<SpellAbility> actions = new ArrayList<>();
// Land plays — only available during main phases with empty stack
CardCollection lands = ComputerUtilAbility.getAvailableLandsToPlay(game, player);
if (lands != null) {
for (Card land : lands) {
List<SpellAbility> landAbilities =
land.getAllPossibleAbilities(player, true);
landAbilities.removeIf(sa -> !sa.isLandAbility());
actions.addAll(landAbilities);
}
}
// Spells and activated abilities from all available card sources
CardCollection available = ComputerUtilAbility.getAvailableCards(game, player);
if (available != null && !available.isEmpty()) {
List<SpellAbility> spells =
ComputerUtilAbility.getSpellAbilities(available, player);
actions.addAll(spells);
}
return actions;
}
// ─── Snapshot construction ────────────────────────────────────────
/**
* Build a comprehensive game-state {@link JsonObject} suitable for
* POST to {@code /api/policy/decide}.
*
* <p>The JSON schema mirrors the Python-side state encoder:</p>
* <pre>
* {
* "game_id": string,
* "turn": int,
* "phase": string, // e.g. "MAIN1"
* "phase_is_main": bool,
* "stack_size": int,
* "stack": [ { "card": string, "controller": string }, … ],
* "active_seat": int,
* "priority_seat": int,
* "players": [
* {
* "seat": int,
* "name": string,
* "life": int,
* "mana_available": int,
* "commander_damage": int,
* "commander_casts": int,
* "cards_in_hand": int,
* "hand": [ string, … ],
* "battlefield": [ string, … ],
* "graveyard": [ string, … ],
* "command_zone": [ string, … ],
* "creatures_on_field": int,
* "land_count": int,
* "total_power": int,
* "total_toughness": int
* }, …
* ],
* "legal_actions": [
* {
* "index": int,
* "macro": string, // inferred macro-action
* "card_name": string,
* "is_land": bool,
* "mana_cost": string
* }, …
* ]
* }
* </pre>
*
* @param game the active game
* @param player the acting player (seat 0 in the players array)
* @param legalActions the legal actions list produced by
* {@link #gatherLegalActions}
* @return the populated JSON object
*/
private JsonObject buildGameStateJson(Game game, Player player,
List<SpellAbility> legalActions) {
JsonObject root = new JsonObject();
root.addProperty("game_id", outer.gameId);
// ── Phase / turn info ─────────────────────────────────────────
PhaseHandler ph = game.getPhaseHandler();
PhaseType phase = ph.getPhase();
root.addProperty("turn", ph.getTurn());
root.addProperty("phase", phase == null ? "UNKNOWN" : phase.toString());
root.addProperty("phase_is_main",
phase != null && phase.isMain());
// ── Stack info ────────────────────────────────────────────────
int stackSize = game.getStack().size();
root.addProperty("stack_size", stackSize);
JsonArray stackArr = new JsonArray();
for (SpellAbilityStackInstance si : game.getStack()) {
JsonObject se = new JsonObject();
se.addProperty("card", si.getSourceCard() != null
? si.getSourceCard().getName() : "unknown");
se.addProperty("controller", si.getActivatingPlayer() != null
? si.getActivatingPlayer().getName() : "unknown");
stackArr.add(se);
}
root.add("stack", stackArr);
// Seat of the current active (turn) player
Player activePlayer = ph.getPlayerTurn();
int activeSeat = seatIndex(game, activePlayer);
int prioritySeat = seatIndex(game, player);
root.addProperty("active_seat", activeSeat);
root.addProperty("priority_seat", prioritySeat);
// ── Per-player state ──────────────────────────────────────────
JsonArray playersArr = new JsonArray();
List<Player> allPlayers = new ArrayList<>(game.getPlayers());
for (int seat = 0; seat < allPlayers.size(); seat++) {
Player p = allPlayers.get(seat);
playersArr.add(buildPlayerJson(seat, p, game));
}
root.add("players", playersArr);
// ── Legal actions ─────────────────────────────────────────────
JsonArray actionsArr = new JsonArray();
for (int i = 0; i < legalActions.size(); i++) {
SpellAbility sa = legalActions.get(i);
JsonObject ae = new JsonObject();
ae.addProperty("index", i);
ae.addProperty("card_name", sa.getHostCard() != null
? sa.getHostCard().getName() : "unknown");
ae.addProperty("is_land", sa.isLandAbility());
ae.addProperty("mana_cost", sa.getPayCosts() != null
? sa.getPayCosts().getTotalMana().toString() : "");
ae.addProperty("macro", inferMacro(sa));
actionsArr.add(ae);
}
root.add("legal_actions", actionsArr);
return root;
}
/**
* Serialize a single player's observable state into a {@link JsonObject}.
*
* @param seat 0-based seat index in {@code game.getPlayers()}
* @param p the player to serialize
* @param game the active game (for stack/zone queries)
* @return populated player JSON
*/
private JsonObject buildPlayerJson(int seat, Player p, Game game) {
JsonObject pj = new JsonObject();
pj.addProperty("seat", seat);
pj.addProperty("name", p.getName());
pj.addProperty("life", p.getLife());
pj.addProperty("mana_available",
ComputerUtilMana.getAvailableManaEstimate(p));
// Commander damage (from any commander source)
int cdrDamage = 0;
for (Map.Entry<Card, Integer> entry : p.getCommanderDamage()) {
cdrDamage += entry.getValue();
}
pj.addProperty("commander_damage", cdrDamage);
// Commander casts (aggregate across all commanders)
int cdrCasts = 0;
for (Card cdr : p.getCardsIn(ZoneType.Command)) {
cdrCasts += p.getCommanderCast(cdr);
}
pj.addProperty("commander_casts", cdrCasts);
// Zone lists
CardCollectionView hand = p.getCardsIn(ZoneType.Hand);
pj.addProperty("cards_in_hand", hand.size());
pj.add("hand", cardNamesArray(hand));
pj.add("battlefield", cardNamesArray(p.getCardsIn(ZoneType.Battlefield)));
pj.add("graveyard", cardNamesArray(p.getCardsIn(ZoneType.Graveyard)));
pj.add("command_zone", cardNamesArray(p.getCardsIn(ZoneType.Command)));
// Board summary stats
CardCollectionView bf = p.getCardsIn(ZoneType.Battlefield);
int creatures = 0, lands = 0, power = 0, toughness = 0;
for (Card c : bf) {
if (c.isCreature()) {
creatures++;
power += c.getNetPower();
toughness += c.getNetToughness();
}
if (c.isLand()) lands++;
}
pj.addProperty("creatures_on_field", creatures);
pj.addProperty("land_count", lands);
pj.addProperty("total_power", power);
pj.addProperty("total_toughness", toughness);
return pj;
}
// ─── Macro-action mapping ─────────────────────────────────────────
/**
* Map a policy macro-action string back to a concrete {@link SpellAbility}
* from the legal-action list.
*
* <p>The algorithm scans the legal actions list and picks the first
* {@link SpellAbility} whose source card belongs to the category implied
* by the macro-action, using the same card-role databases that
* {@link MacroActionExecutor} uses. This keeps Java and Python in sync.</p>
*
* <p>For {@code CAST_COMMANDER}, the command zone is checked first.</p>
*
* @param macroAction the macro-action returned by the policy server
* @param legalActions all legal actions in the current state
* @param player the acting player (for commander zone check)
* @return the best matching {@link SpellAbility}, or {@code null} if
* no match is found or the macro-action is pass/hold
*/
private SpellAbility mapMacroActionToSpellAbility(
String macroAction,
List<SpellAbility> legalActions,
Player player) {
switch (macroAction) {
// ── Lands ────────────────────────────────────────────────
case MacroActionExecutor.CAST_RAMP: {
// Prefer ramp spells; if none, fall through to a land play
SpellAbility ramp = findFirstMatching(legalActions, sa ->
!sa.isLandAbility()
&& sa.getHostCard() != null
&& MacroActionExecutor.isRampCard(sa.getHostCard().getName()));
if (ramp != null) return ramp;
// Land drop counts as ramp when no ramp spell is available
return findFirstMatching(legalActions, SpellAbility::isLandAbility);
}
// ── Creatures ────────────────────────────────────────────
case MacroActionExecutor.CAST_CREATURE: {
return findFirstMatching(legalActions, sa ->
!sa.isLandAbility()
&& sa.getHostCard() != null
&& sa.getHostCard().isCreature()
&& !MacroActionExecutor.isRemovalCard(sa.getHostCard().getName())
&& !MacroActionExecutor.isDrawCard(sa.getHostCard().getName())
&& !MacroActionExecutor.isRampCard(sa.getHostCard().getName()));
}
// ── Removal / interaction ─────────────────────────────────
case MacroActionExecutor.CAST_REMOVAL: {
return findFirstMatching(legalActions, sa ->
!sa.isLandAbility()
&& sa.getHostCard() != null
&& MacroActionExecutor.isRemovalCard(sa.getHostCard().getName()));
}
// ── Card draw ────────────────────────────────────────────
case MacroActionExecutor.CAST_DRAW: {
return findFirstMatching(legalActions, sa ->
!sa.isLandAbility()
&& sa.getHostCard() != null
&& MacroActionExecutor.isDrawCard(sa.getHostCard().getName()));
}
// ── Commander ────────────────────────────────────────────
case MacroActionExecutor.CAST_COMMANDER: {
// Commander spells appear as regular spells in legalActions
// but their source card is in the command zone.
return findFirstMatching(legalActions, sa ->
!sa.isLandAbility()
&& sa.getHostCard() != null
&& sa.getHostCard().isCommander()
&& player.getCardsIn(ZoneType.Command)
.contains(sa.getHostCard()));
}
// ── Pass / hold ──────────────────────────────────────────
case MacroActionExecutor.PASS:
case MacroActionExecutor.HOLD_MANA:
case MacroActionExecutor.ATTACK_OPPONENT:
// Attacks are declared in a different decision point;
// returning null here passes priority gracefully.
return null;
default:
LOG.warning("[PolicyAiController] Unknown macro-action: " + macroAction);
return null;
}
}
// ─── Infer macro from SA (for the legal_actions array) ───────────
/**
* Infer which macro-action category a {@link SpellAbility} belongs to.
* Used when populating the {@code legal_actions[].macro} field in the
* state JSON so the policy server can cross-check.
*
* @param sa the spell ability to classify
* @return macro-action name constant from {@link MacroActionExecutor}
*/
private String inferMacro(SpellAbility sa) {
if (sa.isLandAbility()) return MacroActionExecutor.CAST_RAMP;
Card card = sa.getHostCard();
if (card == null) return MacroActionExecutor.PASS;
String name = card.getName();
if (MacroActionExecutor.isRemovalCard(name)) return MacroActionExecutor.CAST_REMOVAL;
if (MacroActionExecutor.isDrawCard(name)) return MacroActionExecutor.CAST_DRAW;
if (MacroActionExecutor.isRampCard(name)) return MacroActionExecutor.CAST_RAMP;
if (card.isCommander()) return MacroActionExecutor.CAST_COMMANDER;
if (card.isCreature()) return MacroActionExecutor.CAST_CREATURE;
return MacroActionExecutor.PASS;
}
// ─── Utility helpers ──────────────────────────────────────────────
/**
* Return the first element of {@code actions} that matches
* {@code predicate}, or {@code null} if none match.
*
* @param actions the list to scan
* @param predicate the filter predicate
* @return first match or {@code null}
*/
private SpellAbility findFirstMatching(
List<SpellAbility> actions,
java.util.function.Predicate<SpellAbility> predicate) {
for (SpellAbility sa : actions) {
if (predicate.test(sa)) return sa;
}
return null;
}
/**
* Return the 0-based seat index of {@code target} in
* {@code game.getPlayers()}, or {@code -1} if not found.
*
* @param game the active game
* @param target the player to look up
* @return seat index
*/
private int seatIndex(Game game, Player target) {
if (target == null) return -1;
List<Player> players = new ArrayList<>(game.getPlayers());
for (int i = 0; i < players.size(); i++) {
if (players.get(i).equals(target)) return i;
}
return -1;
}
/**
* Build a {@link JsonArray} of card names from a zone view,
* suitable for embedding in the state JSON.
*
* @param cards the zone contents
* @return JSON array of name strings
*/
private JsonArray cardNamesArray(CardCollectionView cards) {
JsonArray arr = new JsonArray();
if (cards != null) {
for (Card c : cards) {
arr.add(c.getName());
}
}
return arr;
}
/**
* Write a training-data record and emit a log line for every decision,
* whether it was routed through the policy server or fell back to Forge.
*
* @param stateJson full game-state JSON, or {@code null} for early exits
* @param macro the macro-action chosen
* @param source decision source tag: {@code "policy"}, {@code "fallback_*"}, etc.
* @param t0 timestamp (ms) when this decision cycle started
* @param result the final list of abilities returned (may be {@code null})
*/
private void logAndReturn(JsonObject stateJson, String macro,
String source, long t0,
List<SpellAbility> result) {
long elapsed = System.currentTimeMillis() - t0;
// Build a compact log record for training data
JsonObject record = new JsonObject();
record.addProperty("game_id", outer.gameId);
record.addProperty("ts", Instant.now().toString());
record.addProperty("macro", macro);
record.addProperty("source", source);
record.addProperty("latency_ms", elapsed);
if (result != null && !result.isEmpty()) {
SpellAbility sa = result.get(0);
record.addProperty("card",
sa.getHostCard() != null ? sa.getHostCard().getName() : "land");
}
if (stateJson != null && stateJson.has("turn")) {
record.addProperty("turn", stateJson.get("turn").getAsInt());
record.addProperty("phase", stateJson.get("phase").getAsString());
}
outer.logDecision(record);
LOG.fine(String.format(
"[PolicyAiController] %s → %s (source=%s, %dms)",
macro,
result != null && !result.isEmpty() && result.get(0).getHostCard() != null
? result.get(0).getHostCard().getName() : "pass",
source, elapsed));
}
}
// ── end PolicyAiController ─────────────────────────────────────────────
}
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff. Show more